feat(web): add exercise catalog administration

This commit is contained in:
fy59 2026-09-20 12:11:45 +02:00
parent 0a9639bb94
commit 88e0eb0fb7
23 changed files with 2204 additions and 67 deletions

View file

@ -4,6 +4,16 @@
Development opened after the stable v0.1.2 release. Development opened after the stable v0.1.2 release.
- Added the loopback-only `/exercices` desktop catalogue administration
surface with bounded search/profile/BODY ZONES filtering, detail and
read-only equipment presentation, Core-owned creation and editing, complete
optimistic concurrency, and protected causal retirement. Profile changes
preserve stable exercise IDs and all historical occurrence snapshots.
Mutations remain desktop-local until the existing USB-priority, Drive
mirror/fallback synchronization publishes the existing catalogue companions;
no Android code, schema, exchange format, or automatic synchronization was
added.
## 0.1.2 — 2026-09-20 ## 0.1.2 — 2026-09-20
- Added USB-priority full-generation synchronization with a separately - Added USB-priority full-generation synchronization with a separately

View file

@ -11,8 +11,8 @@ Trainlog is a local-first workout and body-data system. Trainlog Core owns
business truth and canonical desktop persistence. Its native Android app is the business truth and canonical desktop persistence. Its native Android app is the
field companion, its C17/Notcurses desktop TUI is the administration and field companion, its C17/Notcurses desktop TUI is the administration and
technical surface, and its local browser Web sibling provides the implemented technical surface, and its local browser Web sibling provides the implemented
Dashboard, Sessions, and Programmes surfaces while reserving Analyse and Dashboard, Sessions, Programmes, and Exercises surfaces while reserving Analyse
Exercises for separately contracted work. for separately contracted work.
Android and desktop each own a local SQLite database. The desktop database is Android and desktop each own a local SQLite database. The desktop database is
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
@ -24,7 +24,7 @@ over direct MTP; it never copies SQLite database files between devices.
|---|---| |---|---|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. | | Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. | | Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (0.1.3 development) | Display the Dashboard, the operational top-level Programmes daily active-program calendar, and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and Programs administration workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse and Exercises remain placeholders. | | Local Web (0.1.3 development) | Display the Dashboard, the operational Programmes calendar, Sessions administration, and the desktop exercise catalogue through typed Trainlog Core/API boundaries. Exercises supports search/filter, detail, creation, editing and causal retirement; equipment is read-only. Analyse remains a placeholder. |
No interface reconstructs business truth from SQLite tables. The local Web is No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
@ -34,8 +34,8 @@ make the remaining placeholder routes functional.
The top-level `/programmes` route is an operational daily calendar for active The top-level `/programmes` route is an operational daily calendar for active
Programs. Sessions → Programmes remains the technical administration/import, Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface. list, detail, archive, and delete surface.
The ordered 0.1.3 development focus is an Android UX redesign followed by Web Web Exercises administration is implemented for 0.1.3 development. The next
Exercises administration; both remain planned, separately contracted work. separately contracted priority is the Android UX redesign.
## Releases ## Releases
@ -349,8 +349,8 @@ As of 2026-09-18:
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and - `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`; `WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
`/programmes` is an operational active-Program calendar, while `/programmes` is an operational active-Program calendar, while
Sessions → Programmes remains the administration surface; Analyse and Sessions → Programmes remains the administration surface;
Exercises remain placeholders; `TRAINLOG_WEB_EXERCISES_V1=PASS`; Analyse remains a placeholder;
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated - `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
private desktop/Android deployment, real Firefox presentation, correlated private desktop/Android deployment, real Firefox presentation, correlated
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass; `programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;

View file

@ -14,6 +14,9 @@ evidence and chronology.
## Interfaces ## Interfaces
- [Web Exercises V1](web_exercises.md) owns desktop catalogue administration,
optimistic editing, historical preservation, causal retirement, and its
existing-artifact synchronization boundary.
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume, - [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
history and Android-delivery workflow. history and Android-delivery workflow.
- [Program format V1](program_format_v1.md) owns the strict desktop planning - [Program format V1](program_format_v1.md) owns the strict desktop planning

View file

@ -105,8 +105,8 @@ parallel implementation of its rules.
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) | | Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) | | Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` | | Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` |
| Next after Android redesign | `TRAINLOG_WEB_EXERCISES_V1` |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
@ -250,8 +250,18 @@ resume and history views, stable details, optimistic manual-preparation writes,
explicit proposal derivation and generation-backed Android delivery. The explicit proposal derivation and generation-backed Android delivery. The
top-level `/programmes` route is an operational daily active-Program calendar; top-level `/programmes` route is an operational daily active-Program calendar;
Sessions → Programmes remains the technical administration/import, list, Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. Analyse and Exercises remain explicit detail, archive, and delete surface. `/exercices` is the operational desktop
placeholders. catalogue administration surface; Analyse remains an explicit placeholder.
Exercises uses bounded Core-owned list/detail reads, canonical name
normalization, BODY ZONES taxonomy, profiled creation/update commands and
causal retirement. Its deterministic optimistic token covers all editable
catalogue state. Built-in identities cannot be retired, equipment associations
are presented read-only, and profile edits affect future use only: occurrence
snapshots, performed sets, feedback and MAX history are never rewritten. A
successful mutation changes only the canonical desktop database; the existing
USB-priority, Drive mirror/fallback synchronization later carries the existing
catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync.
The calendar uses the existing typed Core services `fetchAllPrograms(active)`, The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
`fetchProgram`, and `createPreparationFromProgram`; it derives no local program `fetchProgram`, and `createPreparationFromProgram`; it derives no local program
@ -518,10 +528,9 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green. pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
- The next planned work is `TRAINLOG_ANDROID_UI_REDESIGN_V1`; it is not yet - `TRAINLOG_WEB_EXERCISES_V1=PASS`. The next planned work is
implemented. `TRAINLOG_WEB_EXERCISES_V1` follows it and will replace the `TRAINLOG_ANDROID_UI_REDESIGN_V1`; it remains unimplemented. Analyse remains
current `/exercices` placeholder only after a separate Core/read/command a placeholder and is not a v0.1.3 priority.
contract. Analyse remains a placeholder and is not a v0.1.3 priority.
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its - `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
observations are inputs to, not decisions for, the new 0.1.3 Android contract. observations are inputs to, not decisions for, the new 0.1.3 Android contract.
- Session Generator V1 is hidden while V2 planning semantics are developed. - Session Generator V1 is hidden while V2 planning semantics are developed.

View file

@ -43,9 +43,9 @@ tile rendering exist. The Dashboard's bounded progression and BODY ZONES
visualizations and Sessions V1 are implemented. The top-level `/programmes` visualizations and Sessions V1 are implemented. The top-level `/programmes`
route is an operational daily active-Program calendar, while route is an operational daily active-Program calendar, while
Sessions → Programmes remains the technical administration/import, list, Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. Analyse and Exercises remain placeholders. detail, archive, and delete surface. Web Exercises V1 is implemented; Analyse
Exercises is now the second ordered v0.1.3 priority; Analyse remains outside remains a placeholder outside this cycle unless a later explicit decision
this cycle unless a later explicit decision changes that order. changes that order.
The corrective prepared-item projection, bounded phase-owned MTP outbox, The corrective prepared-item projection, bounded phase-owned MTP outbox,
Sessions presentation correction, local date preference and durable preparation Sessions presentation correction, local date preference and durable preparation
withdrawal are implemented. The withdrawal uses the separate versioned withdrawal are implemented. The withdrawal uses the separate versioned
@ -60,7 +60,6 @@ causal-deletion contracts without reopening those completed gates.
```text ```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT CURRENT_OPERATIONAL_CURSOR=TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT
NEXT_AFTER_ANDROID_UI_REDESIGN=TRAINLOG_WEB_EXERCISES_V1
``` ```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
@ -166,20 +165,21 @@ not authorize implementation and it did not select Analyse.
## Trainlog v0.1.3 ## Trainlog v0.1.3
Trainlog v0.1.3 has two ordered priorities: Trainlog v0.1.3 retains two priorities. Web Exercises was completed first; the
cursor now returns to the Android redesign:
1. the global Android experience redesign; 1. the complete Web Exercices surface;
2. the complete Web Exercices surface. 2. the global Android experience redesign.
```text ```text
TRAINLOG_ANDROID_UI_REDESIGN_V1 TRAINLOG_WEB_EXERCISES_V1
-> TRAINLOG_WEB_EXERCISES_V1 -> TRAINLOG_ANDROID_UI_REDESIGN_V1
``` ```
Each lot requires its own reviewed contract before implementation. This roadmap The Web lot is implemented and validated. The Android lot still requires its
is documentary ordering, not authorization to implement both lots in one own reviewed contract before implementation. Analyse and every other new Web
ticket. Analyse and every other new Web module remain after these priorities module remain after this priority unless a later explicit decision changes the
unless a later explicit decision changes the order. order.
### TRAINLOG_ANDROID_UI_REDESIGN_V1 ### TRAINLOG_ANDROID_UI_REDESIGN_V1
@ -222,12 +222,12 @@ will be made screen by screen.
### TRAINLOG_WEB_EXERCISES_V1 ### TRAINLOG_WEB_EXERCISES_V1
The top-level `/exercices` placeholder will become the desktop catalogue `TRAINLOG_WEB_EXERCISES_V1=PASS`. The top-level `/exercices` route is the
administration surface. The first step is an audit of existing Core catalogue desktop catalogue administration surface. It uses bounded Core read/command
services and persisted capabilities; implementation is not authorized until a services and a deterministic optimistic token; it does not put business rules
separate read/command contract has been reviewed. in React.
The future lot includes: The delivered lot includes:
- **Catalogue:** a complete or paged list according to the Core contract, - **Catalogue:** a complete or paged list according to the Core contract,
search, relevant filters, available/retired state when the model supports it, search, relevant filters, available/retired state when the model supports it,
@ -239,7 +239,8 @@ The future lot includes:
business truth. business truth.
- **Creation:** create through a real transactional Core command service, with - **Creation:** create through a real transactional Core command service, with
name, profile, tracking, `data_fields`, primary and secondary zones, and name, profile, tracking, `data_fields`, primary and secondary zones, and
equipment/associations only where the Core contract permits them. equipment associations remain read-only because no bounded association
command is part of this contract.
- **Modification:** expose only Core-authorized changes. The contract must - **Modification:** expose only Core-authorized changes. The contract must
distinguish mutable and immutable fields, compatible changes, changes that distinguish mutable and immutable fields, compatible changes, changes that
require a new identity, and exercises already referenced by history. require a new identity, and exercises already referenced by history.
@ -260,36 +261,37 @@ with Drive as mirror/fallback. React must not introduce an Exercises-specific
synchronization path. The future contract must first audit the existing synchronization path. The future contract must first audit the existing
catalogue, profile, BODY ZONES, and equipment artifacts to determine whether catalogue, profile, BODY ZONES, and equipment artifacts to determine whether
the planned changes are representable. Frozen formats remain frozen; if a new the planned changes are representable. Frozen formats remain frozen; if a new
datum cannot be transported, the contract must state that explicitly before datum cannot be transported, a future contract must state that explicitly
any protocol change. before any protocol change. No automatic synchronization is initiated by this
surface.
### Bounded v0.1.3 order ### Bounded v0.1.3 order
```text ```text
0.1.3 0.1.3
| |
+-- ANDROID_UI_REDESIGN_V1
| |
| +-- current-state audit
| +-- UX/navigation contract
| +-- shell / buttons / components
| +-- active-session workflow
| +-- exercise-entry workflow
| +-- real visual review
| +-- accessibility validation
|
+-- WEB_EXERCISES_V1 +-- WEB_EXERCISES_V1
| |
| +-- Core/catalogue characterization
| +-- read-model contract
| +-- command-service contract
| +-- Web catalogue
| +-- exercise detail
| +-- creation
| +-- modification
| +-- causal retirement
| +-- Android synchronization
| +-- real validation
| |
+-- Core/catalogue characterization +-- ANDROID_UI_REDESIGN_V1
+-- read-model contract |
+-- command-service contract +-- current-state audit
+-- Web catalogue +-- UX/navigation contract
+-- exercise detail +-- shell / buttons / components
+-- creation +-- active-session workflow
+-- modification +-- exercise-entry workflow
+-- causal retirement +-- real visual review
+-- Android synchronization +-- accessibility validation
+-- real validation
``` ```
## Next ## Next
@ -352,8 +354,8 @@ normal capture.
## Canonical order ## Canonical order
```text ```text
TRAINLOG_ANDROID_UI_REDESIGN_V1 TRAINLOG_WEB_EXERCISES_V1
-> TRAINLOG_WEB_EXERCISES_V1 -> TRAINLOG_ANDROID_UI_REDESIGN_V1
-> GYM_CATALOG_V1 -> GYM_CATALOG_V1
-> EXERCISE_METADATA_V1 -> EXERCISE_METADATA_V1
-> SESSION_GENERATOR_V2 -> SESSION_GENERATOR_V2

55
docs/web_exercises.md Normal file
View file

@ -0,0 +1,55 @@
# Web Exercises V1
`TRAINLOG_WEB_EXERCISES_V1=PASS` for Trainlog 0.1.3 development. The
loopback-only `/exercices` route is the desktop exercise-catalogue
administration surface. Android remains the field capture client and receives
catalogue changes only through a later normal synchronization.
## Ownership and history
Trainlog Core and the canonical desktop SQLite database own catalogue truth.
React never generates an `exercise_id`, normalizes a name as business truth, or
writes SQL. Creation generates the existing `ex_<uuid-v4>` identity in Core;
updates preserve that identity.
Changing name, profile, fields, or BODY ZONES affects future uses only.
Occurrence-owned recording/tracking/data-field snapshots, actual sets or
continuous activity, targets, equipment, feedback and MAX results in past
sessions are immutable under catalogue editing. No compatibility operation
silently rewrites history or existing drafts.
## Read and command surface
The versioned local API provides bounded catalogue pages, detail, assignable
BODY ZONES, creation, update, and retirement. Search uses Core canonical name
normalization. Profile and BODY ZONES validation remains authoritative in
Core: `CONTINUOUS + REPS`, unknown data-field bits, group zones, duplicate
roles, secondary-only assignment, and interactive SETS creation without a
primary zone are rejected.
Detail exposes the persisted profile, stable ID, zones, a deterministic
revision token, whether causal retirement is allowed, and resolved equipment
associations. Equipment is read-only in V1.
All mutations retain the Web loopback, trusted-Origin, CSRF, exact JSON content
type, bounded body, request-ID and strict-JSON protections. PUT and DELETE
require the current quoted revision. The token covers name, profile,
`data_fields`, zones and causal state under one immediate transaction; stale
writes fail rather than overwrite concurrent changes.
"Delete" is causal retirement, never physical exercise deletion. Built-in
Trainlog identities are protected. A retired custom exercise is excluded from
new-work catalogue reads and the next PC catalogue export, while its source row
and all historical references remain. Existing Programs, preparations,
proposals and drafts are not silently changed; their occurrence/planning
snapshots remain readable, and new-work selectors apply the established active
catalogue rule.
## Synchronization boundary
Create, update and retirement first commit only to the desktop database. They
do not call synchronization and do not require a phone. The next user-triggered
normal synchronization reuses the existing PC catalogue, exercise profile
state, BODY ZONES, equipment companions and causal deletion artifacts through
the existing USB-priority engine with Drive as mirror/fallback. No Web
Exercises artifact or protocol version was introduced.

View file

@ -4,6 +4,7 @@
import importlib.util import importlib.util
import json import json
import sqlite3 import sqlite3
import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
@ -106,6 +107,22 @@ class CausalDeleteTest(unittest.TestCase):
with self.assertRaisesRegex(causal.CausalError, "built-in"): with self.assertRaisesRegex(causal.CausalError, "built-in"):
causal.local_delete(self.db, "exercise", built_in, "peer") causal.local_delete(self.db, "exercise", built_in, "peer")
def test_retired_exercise_is_omitted_from_next_pc_catalog(self):
self.delete("exercise", "ex_user")
output = Path(self.tmp.name) / "pc-catalog.json"
result = subprocess.run(
[sys.executable, str(ROOT / "tools/export_pc_catalog.py"),
"--database", str(self.path), str(output)],
check=False, capture_output=True, text=True,
)
self.assertEqual(0, result.returncode, result.stderr)
catalog = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual([], catalog["exercises"])
self.assertEqual(1, self.db.execute(
"SELECT count(*) FROM exercises WHERE exercise_id='ex_user'").fetchone()[0])
self.assertEqual(1, self.db.execute(
"SELECT count(*) FROM session_exercises WHERE exercise_row_id=1").fetchone()[0])
def hostile_operation(self, kind, target): def hostile_operation(self, kind, target):
operation = {"operation_id": "del_" + "a" * 36, "target_kind": kind, operation = {"operation_id": "del_" + "a" * 36, "target_kind": kind,
"target_id": target, "creator_id": "hostile_fixture", "target_id": target, "creator_id": "hostile_fixture",

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Disposable real-browser smoke test for Web Exercises V1."""
import os
import signal
import socket
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILD = ROOT / "build" / "tui"
GECKO = Path(os.environ.get(
"TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"))
class BrowserExercisesTest(unittest.TestCase):
def test_create_edit_retire_at_desktop_and_mobile_widths(self):
if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.firefox.service import Service
from selenium.common.exceptions import StaleElementReferenceException
from selenium.webdriver.support.ui import Select, WebDriverWait
with tempfile.TemporaryDirectory(prefix="trainlog-web-exercises-") as directory:
temporary = Path(directory)
data = temporary / "data"
config = temporary / "config"
data.mkdir()
config.mkdir()
with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1]
environment = os.environ.copy()
environment.update({"XDG_DATA_HOME": str(data), "XDG_CONFIG_HOME": str(config),
"XDG_CACHE_HOME": str(temporary / "cache")})
server = subprocess.Popen(
[str(BUILD / "trainlog"), "--web", "--port", str(port)],
env=environment, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
driver = None
try:
for _ in range(200):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
break
time.sleep(0.025)
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
wait = WebDriverWait(driver, 15, ignored_exceptions=(StaleElementReferenceException,))
driver.set_window_size(1440, 1000)
driver.get(f"http://127.0.0.1:{port}/exercices")
wait.until(lambda page: page.find_element(By.TAG_NAME, "h1").text == "Exercices")
self.assertEqual(0, driver.execute_script(
"return document.documentElement.scrollWidth-document.documentElement.clientWidth"))
driver.find_element(By.XPATH, "//button[contains(., 'Nouvel exercice')]").click()
wait.until(lambda page: page.current_url.endswith("/exercices/nouveau"))
name = wait.until(lambda page: page.find_element(By.CSS_SELECTOR, ".exercise-form input"))
name.send_keys("Exercice visuel jetable")
Select(driver.find_element(By.CSS_SELECTOR, ".exercise-form select")).select_by_index(1)
name.send_keys(Keys.ENTER)
wait.until(lambda page: page.find_element(By.TAG_NAME, "h1").text == "Exercice visuel jetable")
driver.find_element(By.XPATH, "//button[normalize-space(.)='Modifier']").click()
name = wait.until(lambda page: page.find_element(By.CSS_SELECTOR, ".exercise-form input"))
name.clear()
name.send_keys("Exercice visuel renommé")
name.send_keys(Keys.ENTER)
wait.until(lambda page: page.find_element(By.TAG_NAME, "h1").text == "Exercice visuel renommé")
driver.set_window_size(390, 844)
self.assertEqual(0, driver.execute_script(
"return document.documentElement.scrollWidth-document.documentElement.clientWidth"))
driver.find_element(By.XPATH, "//button[contains(., 'Retirer du catalogue')]").click()
wait.until(lambda page: page.find_element(By.CSS_SELECTOR, "[role='alertdialog']"))
driver.find_element(By.XPATH, "//*[@role='alertdialog']//button[normalize-space(.)='Retirer']").click()
wait.until(lambda page: page.find_element(By.TAG_NAME, "h1").text == "Exercices")
self.assertNotIn("Exercice visuel renommé", driver.page_source)
finally:
if driver is not None:
driver.quit()
os.killpg(server.pid, signal.SIGTERM)
server.wait(timeout=10)
if __name__ == "__main__":
unittest.main()

View file

@ -77,6 +77,15 @@ def main():
f"schema={version}" f"schema={version}"
) )
active_filter = "" if version < 20 else '''
WHERE NOT EXISTS (
SELECT 1
FROM sync_causal_state AS causal
WHERE causal.target_kind = 'exercise'
AND causal.target_id = exercises.exercise_id
AND causal.deleted = 1
)
'''
rows = connection.execute( rows = connection.execute(
''' '''
SELECT SELECT
@ -86,6 +95,7 @@ def main():
tracking_mode, tracking_mode,
data_fields data_fields
FROM exercises FROM exercises
''' + active_filter + '''
ORDER BY ORDER BY
name COLLATE NOCASE, name COLLATE NOCASE,
exercise_id; exercise_id;

View file

@ -250,11 +250,13 @@ TrainlogStatus trainlog_database_list_exercises_filtered(TrainlogDatabase *datab
size_t *output_count); size_t *output_count);
/* CONTRACT: name/profile/direct zones are one transaction and all string/list /* CONTRACT: name/profile/direct zones are one transaction and all string/list
* inputs are borrowed only for the duration of the call. A profile change is * inputs are borrowed only for the duration of the call. Profile changes affect
* rejected once completed history references the exercise; a same-profile * future occurrences only because schema v16 snapshots the complete profile on
* rename or zone replacement preserves exercise_id and history. Zone rules * every occurrence. Existing sessions and drafts are never rewritten. The
* match replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND; * function owns a transaction when called in autocommit mode and participates
* invalid metadata and name collisions remain explicit errors. */ * in an already-open caller transaction otherwise. Zone rules match
* replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND; invalid
* metadata and name collisions remain explicit errors. */
TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *database, TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *database,
const char *exercise_id, const char *exercise_id,
const char *name, const char *name,

View file

@ -0,0 +1,61 @@
/* Trainlog Web Exercises V1 application service. */
#ifndef TRAINLOG_WEB_EXERCISES_H
#define TRAINLOG_WEB_EXERCISES_H
#include <stddef.h>
#include "trainlog/database.h"
#include "trainlog/status.h"
#define TRAINLOG_WEB_EXERCISES_PAGE_MAX 64U
#define TRAINLOG_WEB_EXERCISES_BODY_MAX (64U * 1024U)
typedef struct TrainlogWebExercisesQuery {
size_t offset;
size_t limit;
const char *search;
const char *profile;
const char *zone_id;
bool unclassified;
} TrainlogWebExercisesQuery;
/* CONTRACT: successful JSON functions transfer one heap allocation to the
* caller, which releases it with free(). List and detail expose only current
* Core state; no SQLite handle, row ID, or borrowed database text escapes. */
TrainlogStatus trainlog_web_exercises_list_json(TrainlogDatabase *database,
const TrainlogWebExercisesQuery *query,
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_exercises_detail_json(TrainlogDatabase *database,
const char *exercise_id,
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_exercises_zones_json(char **output_json, size_t *output_size);
/* CONTRACT: body is strict JSON containing the complete editable definition.
* Creation delegates UUID/name/profile/zone authority to catalog/database Core.
* Update compares expected_revision under BEGIN IMMEDIATE and preserves the
* stable exercise_id. Neither operation starts synchronization. */
TrainlogStatus trainlog_web_exercises_create_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_exercises_update_json(TrainlogDatabase *database,
const char *exercise_id,
const char *expected_revision,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size);
/* CONTRACT: retirement is one durable causal operation. Built-in identities
* are forbidden, history remains resolvable, and exact repeat requests are
* idempotent through existing causal state. */
TrainlogStatus trainlog_web_exercises_retire_json(TrainlogDatabase *database,
const char *exercise_id,
const char *expected_revision,
char **output_json,
size_t *output_size);
#endif

View file

@ -76,9 +76,13 @@ if web_assets_enabled
'../web/src/routes/PlaceholderPage.tsx', '../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx', '../web/src/routes/SessionsPage.tsx',
'../web/src/api/sessions.ts', '../web/src/api/sessions.ts',
'../web/src/api/exercises.ts',
'../web/src/api/exercises.test.ts',
'../web/src/api/programs.ts', '../web/src/api/programs.ts',
'../web/src/routes/ProgramsTab.tsx', '../web/src/routes/ProgramsTab.tsx',
'../web/src/routes/ProgramsCalendarPage.tsx', '../web/src/routes/ProgramsCalendarPage.tsx',
'../web/src/routes/ExercisesPage.tsx',
'../web/src/routes/ExercisesPage.test.tsx',
'../web/src/theme/app.css', '../web/src/theme/app.css',
'../web/src/theme/tokens.css', '../web/src/theme/tokens.css',
) )
@ -209,6 +213,7 @@ trainlog_core_sources = files(
'src/web_sessions.c', 'src/web_sessions.c',
'src/web_session_deletions.c', 'src/web_session_deletions.c',
'src/web_programs.c', 'src/web_programs.c',
'src/web_exercises.c',
'src/duration.c', 'src/duration.c',
'src/id.c', 'src/id.c',
'src/timeutil.c', 'src/timeutil.c',
@ -382,6 +387,15 @@ test_web_programs = executable(
) )
test('web_programs', test_web_programs) test('web_programs', test_web_programs)
test_web_exercises = executable(
'test_web_exercises',
'tests/test_web_exercises.c',
dependencies: trainlog_core_dep,
include_directories: trainlog_private_include,
c_args: strict_c_args,
)
test('web_exercises', test_web_exercises)
test_web_session_deletions = executable( test_web_session_deletions = executable(
'test_web_session_deletions', 'test_web_session_deletions',
'tests/test_web_session_deletions.c', 'tests/test_web_session_deletions.c',

View file

@ -2817,6 +2817,7 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
sqlite3_int64 row_id; sqlite3_int64 row_id;
TrainlogStatus status; TrainlogStatus status;
bool owns_transaction;
int rc; int rc;
const char *tracking; const char *tracking;
const char *recording; const char *recording;
@ -2832,7 +2833,8 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
return status; return status;
} }
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) { owns_transaction = sqlite3_get_autocommit(database->connection) != 0;
if (owns_transaction && execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -2872,6 +2874,9 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
goto rollback; goto rollback;
} }
if (!owns_transaction) {
return TRAINLOG_STATUS_OK;
}
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) { if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) {
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
@ -2882,7 +2887,9 @@ rollback:
if (statement != NULL) { if (statement != NULL) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
} }
if (owns_transaction) {
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
}
return status; return status;
} }

915
tui/src/web_exercises.c Normal file
View file

@ -0,0 +1,915 @@
#include "trainlog/web_exercises.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <openssl/evp.h>
#include <sqlite3.h>
#include <yyjson.h>
#include "database_internal.h"
#include "trainlog/body_zone_catalog.h"
#include "trainlog/catalog.h"
#include "trainlog/exercise_name_catalog.h"
#include "trainlog/id.h"
#define EXERCISE_REVISION_CAPACITY 72U
#define CAUSAL_OPERATION_MAX 4096
#define CAUSAL_TEXT_BUDGET (3 * 1024 * 1024)
typedef struct ExerciseInput {
char name[TRAINLOG_NAME_MAX + 1U];
TrainlogRecordingMode recording_mode;
TrainlogTrackingMode tracking_mode;
TrainlogExerciseDataFields data_fields;
char primary_zone_id[TRAINLOG_ZONE_ID_MAX + 1U];
char secondary_zone_ids[16][TRAINLOG_ZONE_ID_MAX + 1U];
const char *secondary_zone_pointers[16];
size_t secondary_count;
} ExerciseInput;
static const char *recording_text(TrainlogRecordingMode mode) {
return mode == TRAINLOG_RECORDING_CONTINUOUS ? "continuous" : "sets";
}
static const char *tracking_text(TrainlogTrackingMode mode) {
return mode == TRAINLOG_TRACKING_DURATION ? "duration" : "reps";
}
static bool sha256_hex(const char *input, size_t input_size, char output[65]) {
unsigned char digest[32];
unsigned int digest_size = 0U;
EVP_MD_CTX *context = EVP_MD_CTX_new();
size_t index;
bool success;
if (context == NULL) {
return false;
}
success = EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
EVP_DigestUpdate(context, input, input_size) == 1 &&
EVP_DigestFinal_ex(context, digest, &digest_size) == 1 && digest_size == 32U;
EVP_MD_CTX_free(context);
if (!success) {
return false;
}
for (index = 0U; index < 32U; ++index) {
(void)snprintf(output + (index * 2U), 3U, "%02x", digest[index]);
}
output[64] = '\0';
return true;
}
static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
time_t now = time(NULL);
struct tm utc;
return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
}
static bool json_finish(yyjson_mut_doc **document, char **output_json, size_t *output_size) {
*output_json = yyjson_mut_write(*document, 0U, output_size);
yyjson_mut_doc_free(*document);
*document = NULL;
return *output_json != NULL;
}
static bool exercise_is_builtin(const char *exercise_id) {
return trainlog_exercise_name_catalog_lookup(exercise_id) != NULL;
}
/* WHY: profile_state alone omits mutable name and BODY ZONES. This digest is
* the optimistic token for exactly the complete Web-editable state. CONTRACT:
* byte ordering is fixed and zones are queried in role/ID order. INVARIANT:
* no timestamp or SQLite row ID participates. */
static TrainlogStatus exercise_revision(TrainlogDatabase *database,
const char *exercise_id,
char output[EXERCISE_REVISION_CAPACITY]) {
sqlite3_stmt *statement = NULL;
EVP_MD_CTX *digest = NULL;
unsigned char raw[32];
unsigned int raw_size = 0U;
int result;
int column;
size_t index;
result = sqlite3_prepare_v2(
database->connection,
"SELECT e.exercise_id,e.name,e.recording_mode,e.tracking_mode,e.data_fields,"
"COALESCE((SELECT group_concat(zone_id||':'||role,'|') FROM (SELECT zone_id,role "
"FROM exercise_body_zones WHERE exercise_row_id=e.id ORDER BY role,zone_id)),''),"
"COALESCE((SELECT deleted FROM sync_causal_state WHERE target_kind='exercise' AND "
"target_id=e.exercise_id),0) FROM exercises e WHERE e.exercise_id=?1",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (result != SQLITE_ROW) {
(void)sqlite3_finalize(statement);
return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
}
digest = EVP_MD_CTX_new();
if (digest == NULL || EVP_DigestInit_ex(digest, EVP_sha256(), NULL) != 1) {
EVP_MD_CTX_free(digest);
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
for (column = 0; column < 7; ++column) {
const void *value = sqlite3_column_text(statement, column);
int bytes = sqlite3_column_bytes(statement, column);
static const char separator = '\0';
if (value == NULL || bytes < 0 || EVP_DigestUpdate(digest, value, (size_t)bytes) != 1 ||
EVP_DigestUpdate(digest, &separator, 1U) != 1) {
EVP_MD_CTX_free(digest);
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
}
if (sqlite3_finalize(statement) != SQLITE_OK ||
EVP_DigestFinal_ex(digest, raw, &raw_size) != 1 || raw_size != 32U) {
EVP_MD_CTX_free(digest);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
EVP_MD_CTX_free(digest);
(void)memcpy(output, "exrev_", 6U);
for (index = 0U; index < 32U; ++index) {
(void)snprintf(output + 6U + (index * 2U), 3U, "%02x", raw[index]);
}
output[70] = '\0';
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus append_zones(TrainlogDatabase *database,
const char *exercise_id,
yyjson_mut_doc *document,
yyjson_mut_val *object) {
TrainlogExerciseBodyZone relations[16];
size_t count = 0U;
size_t index;
yyjson_mut_val *secondary = yyjson_mut_arr(document);
const char *primary = NULL;
TrainlogStatus status =
trainlog_database_list_exercise_body_zones(database, exercise_id, relations, 16U, &count);
if (status != TRAINLOG_STATUS_OK || count > 16U || secondary == NULL) {
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
for (index = 0U; index < count; ++index) {
if (relations[index].role == TRAINLOG_BODY_ZONE_PRIMARY) {
primary = relations[index].zone_id;
} else if (!yyjson_mut_arr_add_strcpy(document, secondary, relations[index].zone_id)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
}
if (primary == NULL) {
if (!yyjson_mut_obj_add_null(document, object, "primary_zone_id")) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
} else if (!yyjson_mut_obj_add_strcpy(document, object, "primary_zone_id", primary)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return yyjson_mut_obj_add_val(document, object, "secondary_zone_ids", secondary)
? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_SYSTEM_ERROR;
}
static TrainlogStatus append_equipment(TrainlogDatabase *database,
const char *exercise_id,
yyjson_mut_doc *document,
yyjson_mut_val *object) {
TrainlogResolvedEquipment equipment[64];
size_t count = 0U;
size_t index;
yyjson_mut_val *items = yyjson_mut_arr(document);
TrainlogStatus status =
trainlog_database_list_exercise_equipment(database, exercise_id, equipment, 64U, &count);
if (status != TRAINLOG_STATUS_OK || count > 64U || items == NULL) {
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
for (index = 0U; index < count; ++index) {
yyjson_mut_val *item = yyjson_mut_obj(document);
if (item == NULL ||
!yyjson_mut_obj_add_strcpy(
document, item, "equipment_id", equipment[index].equipment_id) ||
!yyjson_mut_obj_add_strcpy(
document, item, "display_name", equipment[index].display_name) ||
!yyjson_mut_arr_add_val(items, item)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
}
return yyjson_mut_obj_add_val(document, object, "equipment", items)
? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_SYSTEM_ERROR;
}
static TrainlogStatus append_exercise(TrainlogDatabase *database,
const TrainlogExercise *exercise,
bool detail,
yyjson_mut_doc *document,
yyjson_mut_val *object) {
char revision[EXERCISE_REVISION_CAPACITY];
TrainlogStatus status;
if (!yyjson_mut_obj_add_strcpy(document, object, "exercise_id", exercise->exercise_id) ||
!yyjson_mut_obj_add_strcpy(document, object, "name", exercise->name) ||
!yyjson_mut_obj_add_strcpy(
document, object, "recording_mode", recording_text(exercise->recording_mode)) ||
!yyjson_mut_obj_add_strcpy(
document, object, "tracking_mode", tracking_text(exercise->tracking_mode)) ||
!yyjson_mut_obj_add_uint(document, object, "data_fields", exercise->data_fields) ||
!yyjson_mut_obj_add_bool(
document, object, "retireable", !exercise_is_builtin(exercise->exercise_id))) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
status = append_zones(database, exercise->exercise_id, document, object);
if (status != TRAINLOG_STATUS_OK || !detail) {
return status;
}
status = exercise_revision(database, exercise->exercise_id, revision);
if (status != TRAINLOG_STATUS_OK ||
!yyjson_mut_obj_add_strcpy(document, object, "revision", revision)) {
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_SYSTEM_ERROR : status;
}
return append_equipment(database, exercise->exercise_id, document, object);
}
TrainlogStatus trainlog_web_exercises_list_json(TrainlogDatabase *database,
const TrainlogWebExercisesQuery *query,
char **output_json,
size_t *output_size) {
static const char SQL[] =
"SELECT e.exercise_id,e.name,e.tracking_mode,e.recording_mode,e.data_fields FROM "
"exercises e WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
"c.target_kind='exercise' AND c.target_id=e.exercise_id AND c.deleted=1) AND "
"(?1='' OR e.normalized_name LIKE ?1||'%') AND (?2='' OR "
"(e.recording_mode||'_'||e.tracking_mode)=?2) AND "
"(?3='' OR EXISTS(SELECT 1 FROM exercise_body_zones z WHERE z.exercise_row_id=e.id AND "
"z.zone_id=?3)) AND (?4=0 OR NOT EXISTS(SELECT 1 FROM exercise_body_zones z WHERE "
"z.exercise_row_id=e.id)) ORDER BY e.name COLLATE NOCASE,e.exercise_id LIMIT ?5 OFFSET ?6";
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U] = "";
sqlite3_stmt *statement = NULL;
yyjson_mut_doc *document = NULL;
yyjson_mut_val *root;
yyjson_mut_val *items;
size_t count = 0U;
int result;
TrainlogStatus status = TRAINLOG_STATUS_OK;
if (database == NULL || query == NULL || output_json == NULL || output_size == NULL ||
query->limit == 0U || query->limit > TRAINLOG_WEB_EXERCISES_PAGE_MAX ||
query->offset > 1000000U || (query->zone_id != NULL && query->unclassified) ||
(query->profile != NULL && query->profile[0] != '\0' &&
strcmp(query->profile, "sets_reps") != 0 && strcmp(query->profile, "sets_duration") != 0 &&
strcmp(query->profile, "continuous_duration") != 0)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
if (query->search != NULL && query->search[0] != '\0' &&
trainlog_catalog_normalize_name(query->search, normalized, sizeof(normalized)) !=
TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
items = document == NULL ? NULL : yyjson_mut_arr(document);
if (document == NULL || root == NULL || items == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, root);
result = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, normalized, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(
statement, 2, query->profile == NULL ? "" : query->profile, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(
statement, 3, query->zone_id == NULL ? "" : query->zone_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_int(statement, 4, query->unclassified ? 1 : 0);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_int64(statement, 5, (sqlite3_int64)query->limit + 1);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_int64(statement, 6, (sqlite3_int64)query->offset);
}
if (result != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
while (status == TRAINLOG_STATUS_OK && (result = sqlite3_step(statement)) == SQLITE_ROW) {
if (count < query->limit) {
TrainlogExercise exercise;
yyjson_mut_val *item = yyjson_mut_obj(document);
const char *tracking = (const char *)sqlite3_column_text(statement, 2);
const char *recording = (const char *)sqlite3_column_text(statement, 3);
(void)memset(&exercise, 0, sizeof(exercise));
if (item == NULL || sqlite3_column_text(statement, 0) == NULL ||
sqlite3_column_text(statement, 1) == NULL || tracking == NULL ||
recording == NULL) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
break;
}
(void)snprintf(exercise.exercise_id,
sizeof(exercise.exercise_id),
"%s",
sqlite3_column_text(statement, 0));
(void)snprintf(
exercise.name, sizeof(exercise.name), "%s", sqlite3_column_text(statement, 1));
exercise.tracking_mode = strcmp(tracking, "duration") == 0 ? TRAINLOG_TRACKING_DURATION
: TRAINLOG_TRACKING_REPS;
exercise.recording_mode = strcmp(recording, "continuous") == 0
? TRAINLOG_RECORDING_CONTINUOUS
: TRAINLOG_RECORDING_SETS;
exercise.data_fields = (TrainlogExerciseDataFields)sqlite3_column_int64(statement, 4);
status = append_exercise(database, &exercise, false, document, item);
if (status != TRAINLOG_STATUS_OK || !yyjson_mut_arr_add_val(items, item)) {
status = status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_SYSTEM_ERROR : status;
break;
}
}
++count;
}
if (result != SQLITE_DONE && status == TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
status == TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
if (status == TRAINLOG_STATUS_OK &&
(!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
!yyjson_mut_obj_add_uint(document, root, "offset", query->offset) ||
!yyjson_mut_obj_add_bool(document, root, "more", count > query->limit) ||
!yyjson_mut_obj_add_uint(document,
root,
"next_offset",
query->offset + (count > query->limit ? query->limit : count)) ||
!yyjson_mut_obj_add_val(document, root, "items", items) ||
!json_finish(&document, output_json, output_size))) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
document = NULL;
}
if (status != TRAINLOG_STATUS_OK) {
yyjson_mut_doc_free(document);
}
return status;
}
TrainlogStatus trainlog_web_exercises_detail_json(TrainlogDatabase *database,
const char *exercise_id,
char **output_json,
size_t *output_size) {
TrainlogExercise exercise;
yyjson_mut_doc *document;
yyjson_mut_val *root;
TrainlogStatus status;
if (database == NULL || exercise_id == NULL || exercise_id[0] == '\0' || output_json == NULL ||
output_size == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
status = trainlog_database_get_exercise_profile(database, exercise_id, &exercise);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
if (document == NULL || root == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, root);
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
(status = append_exercise(database, &exercise, true, document, root)) !=
TRAINLOG_STATUS_OK ||
!json_finish(&document, output_json, output_size)) {
if (status == TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_free(document);
return status;
}
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_web_exercises_zones_json(char **output_json, size_t *output_size) {
yyjson_mut_doc *document;
yyjson_mut_val *root;
yyjson_mut_val *zones;
size_t index;
if (output_json == NULL || output_size == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
zones = document == NULL ? NULL : yyjson_mut_arr(document);
if (document == NULL || root == NULL || zones == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, root);
for (index = 0U; index < trainlog_body_zone_catalog_count(); ++index) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index);
yyjson_mut_val *item;
if (zone == NULL || zone->is_group) {
continue;
}
item = yyjson_mut_obj(document);
if (item == NULL || !yyjson_mut_obj_add_strcpy(document, item, "zone_id", zone->zone_id) ||
!yyjson_mut_obj_add_strcpy(document, item, "name", zone->display_name) ||
!yyjson_mut_arr_add_val(zones, item)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
}
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
!yyjson_mut_obj_add_val(document, root, "zones", zones) ||
!json_finish(&document, output_json, output_size)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return TRAINLOG_STATUS_OK;
}
static bool input_has_exact_keys(yyjson_val *root) {
static const char *const EXPECTED[] = {"name",
"recording_mode",
"tracking_mode",
"data_fields",
"primary_zone_id",
"secondary_zone_ids"};
bool seen[sizeof(EXPECTED) / sizeof(EXPECTED[0])] = {false};
yyjson_obj_iter iterator;
yyjson_val *key;
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != sizeof(EXPECTED) / sizeof(EXPECTED[0])) {
return false;
}
yyjson_obj_iter_init(root, &iterator);
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
const char *name = yyjson_get_str(key);
size_t index;
bool matched = false;
for (index = 0U; index < sizeof(EXPECTED) / sizeof(EXPECTED[0]); ++index) {
if (strcmp(name, EXPECTED[index]) == 0 && !seen[index]) {
seen[index] = true;
matched = true;
break;
}
}
if (!matched) {
return false;
}
}
return true;
}
static TrainlogStatus
parse_input(const char *body, size_t body_size, bool require_sets_primary, ExerciseInput *output) {
yyjson_doc *document;
yyjson_val *root;
yyjson_val *name;
yyjson_val *recording;
yyjson_val *tracking;
yyjson_val *fields;
yyjson_val *primary;
yyjson_val *secondary;
size_t index;
size_t maximum = trainlog_body_zone_catalog_count();
if (body == NULL || body_size == 0U || body_size > TRAINLOG_WEB_EXERCISES_BODY_MAX ||
output == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
document = yyjson_read_opts((char *)body, body_size, YYJSON_READ_NOFLAG, NULL, NULL);
if (document == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
root = yyjson_doc_get_root(document);
name = yyjson_obj_get(root, "name");
recording = yyjson_obj_get(root, "recording_mode");
tracking = yyjson_obj_get(root, "tracking_mode");
fields = yyjson_obj_get(root, "data_fields");
primary = yyjson_obj_get(root, "primary_zone_id");
secondary = yyjson_obj_get(root, "secondary_zone_ids");
if (!input_has_exact_keys(root) || !yyjson_is_str(name) || yyjson_get_len(name) == 0U ||
yyjson_get_len(name) > TRAINLOG_NAME_MAX || !yyjson_is_str(recording) ||
!yyjson_is_str(tracking) || !yyjson_is_uint(fields) ||
yyjson_get_uint(fields) > TRAINLOG_EXERCISE_DATA_KNOWN_MASK ||
(!yyjson_is_null(primary) && !yyjson_is_str(primary)) || !yyjson_is_arr(secondary) ||
yyjson_arr_size(secondary) > maximum || yyjson_arr_size(secondary) > 16U) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(output, 0, sizeof(*output));
(void)snprintf(output->name, sizeof(output->name), "%s", yyjson_get_str(name));
if (strcmp(yyjson_get_str(recording), "sets") == 0) {
output->recording_mode = TRAINLOG_RECORDING_SETS;
} else if (strcmp(yyjson_get_str(recording), "continuous") == 0) {
output->recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
} else {
yyjson_doc_free(document);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (strcmp(yyjson_get_str(tracking), "reps") == 0) {
output->tracking_mode = TRAINLOG_TRACKING_REPS;
} else if (strcmp(yyjson_get_str(tracking), "duration") == 0) {
output->tracking_mode = TRAINLOG_TRACKING_DURATION;
} else {
yyjson_doc_free(document);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
output->data_fields = (TrainlogExerciseDataFields)yyjson_get_uint(fields);
if (yyjson_is_str(primary)) {
if (yyjson_get_len(primary) > TRAINLOG_ZONE_ID_MAX) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)snprintf(output->primary_zone_id,
sizeof(output->primary_zone_id),
"%s",
yyjson_get_str(primary));
}
if ((output->recording_mode == TRAINLOG_RECORDING_SETS && output->data_fields != 0U) ||
(require_sets_primary && output->recording_mode == TRAINLOG_RECORDING_SETS &&
output->primary_zone_id[0] == '\0')) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
output->secondary_count = yyjson_arr_size(secondary);
for (index = 0U; index < output->secondary_count; ++index) {
yyjson_val *value = yyjson_arr_get(secondary, index);
if (!yyjson_is_str(value) || yyjson_get_len(value) == 0U ||
yyjson_get_len(value) > TRAINLOG_ZONE_ID_MAX) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)snprintf(output->secondary_zone_ids[index],
sizeof(output->secondary_zone_ids[index]),
"%s",
yyjson_get_str(value));
output->secondary_zone_pointers[index] = output->secondary_zone_ids[index];
}
yyjson_doc_free(document);
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_web_exercises_create_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size) {
ExerciseInput input;
TrainlogExercise exercise;
TrainlogStatus status = parse_input(body, body_size, true, &input);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = trainlog_catalog_create_exercise_profiled_with_zones(
database,
input.name,
input.tracking_mode,
input.recording_mode,
input.data_fields,
input.primary_zone_id[0] == '\0' ? NULL : input.primary_zone_id,
input.secondary_zone_pointers,
input.secondary_count,
&exercise);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
return trainlog_web_exercises_detail_json(
database, exercise.exercise_id, output_json, output_size);
}
TrainlogStatus trainlog_web_exercises_update_json(TrainlogDatabase *database,
const char *exercise_id,
const char *expected_revision,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size) {
ExerciseInput input;
char current[EXERCISE_REVISION_CAPACITY];
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
TrainlogStatus status = parse_input(body, body_size, false, &input);
if (status != TRAINLOG_STATUS_OK || expected_revision == NULL || expected_revision[0] == '\0') {
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
}
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
status = exercise_revision(database, exercise_id, current);
if (status == TRAINLOG_STATUS_OK && strcmp(current, expected_revision) != 0) {
status = TRAINLOG_STATUS_CONFLICT;
}
if (status == TRAINLOG_STATUS_OK) {
status = trainlog_catalog_normalize_name(input.name, normalized, sizeof(normalized));
}
if (status == TRAINLOG_STATUS_OK) {
status = trainlog_database_update_exercise_profiled(
database,
exercise_id,
input.name,
normalized,
input.tracking_mode,
input.recording_mode,
input.data_fields,
input.primary_zone_id[0] == '\0' ? NULL : input.primary_zone_id,
input.secondary_zone_pointers,
input.secondary_count);
}
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
if (status != TRAINLOG_STATUS_OK) {
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
return status;
}
return trainlog_web_exercises_detail_json(database, exercise_id, output_json, output_size);
}
static TrainlogStatus causal_capacity_available(TrainlogDatabase *database) {
sqlite3_stmt *statement = NULL;
int result =
sqlite3_prepare_v2(database->connection,
"SELECT "
"COUNT(*),COALESCE(SUM(length(operation_id)+length(target_kind)+length("
"target_id)+length(creator_id)+length(predecessor_revision_id)+length("
"created_at)+length(payload_sha256)+128),0) FROM sync_causal_operations",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (result != SQLITE_ROW) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
result = sqlite3_column_int64(statement, 0) < CAUSAL_OPERATION_MAX &&
sqlite3_column_int64(statement, 1) < CAUSAL_TEXT_BUDGET;
(void)sqlite3_finalize(statement);
return result ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
}
static TrainlogStatus
exercise_live_revision(TrainlogDatabase *database, const char *exercise_id, char output[68]) {
sqlite3_stmt *statement = NULL;
yyjson_mut_doc *document = yyjson_mut_doc_new(NULL);
yyjson_mut_val *array = document == NULL ? NULL : yyjson_mut_arr(document);
char *json = NULL;
size_t json_size = 0U;
char digest[65];
int result;
if (document == NULL || array == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, array);
result = sqlite3_prepare_v2(database->connection,
"SELECT exercise_id,name,recording_mode,tracking_mode,data_fields "
"FROM exercises WHERE exercise_id=?1",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (result != SQLITE_ROW) {
(void)sqlite3_finalize(statement);
yyjson_mut_doc_free(document);
return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
}
if (!yyjson_mut_arr_add_strcpy(
document, array, (const char *)sqlite3_column_text(statement, 0)) ||
!yyjson_mut_arr_add_strcpy(
document, array, (const char *)sqlite3_column_text(statement, 1)) ||
!yyjson_mut_arr_add_strcpy(
document, array, (const char *)sqlite3_column_text(statement, 2)) ||
!yyjson_mut_arr_add_strcpy(
document, array, (const char *)sqlite3_column_text(statement, 3)) ||
!yyjson_mut_arr_add_int(document, array, sqlite3_column_int64(statement, 4))) {
(void)sqlite3_finalize(statement);
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (sqlite3_finalize(statement) != SQLITE_OK) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
json = yyjson_mut_write(document, 0U, &json_size);
yyjson_mut_doc_free(document);
if (json == NULL || !sha256_hex(json, json_size, digest)) {
free(json);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
free(json);
(void)snprintf(output, 68U, "lv_%s", digest);
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_web_exercises_retire_json(TrainlogDatabase *database,
const char *exercise_id,
const char *expected_revision,
char **output_json,
size_t *output_size) {
static const char CREATOR[] = "peer_desktop_web";
sqlite3_stmt *statement = NULL;
char current[EXERCISE_REVISION_CAPACITY];
char predecessor[68];
char operation_id[TRAINLOG_ID_MAX + 1U];
char created_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char payload[1024];
char digest[65];
int written;
int result;
TrainlogStatus status;
yyjson_mut_doc *document;
yyjson_mut_val *root;
if (database == NULL || exercise_id == NULL || expected_revision == NULL ||
output_json == NULL || output_size == NULL || exercise_is_builtin(exercise_id)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
result = sqlite3_prepare_v2(database->connection,
"SELECT operation_id FROM sync_causal_state WHERE "
"target_kind='exercise' AND target_id=?1 AND deleted=1",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (result == SQLITE_ROW) {
(void)sqlite3_finalize(statement);
(void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL);
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
if (document == NULL || root == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, root);
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
(void)yyjson_mut_obj_add_strcpy(document, root, "exercise_id", exercise_id);
(void)yyjson_mut_obj_add_strcpy(document, root, "state", "retired");
return json_finish(&document, output_json, output_size) ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (result != SQLITE_DONE) {
(void)sqlite3_finalize(statement);
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)sqlite3_finalize(statement);
status = exercise_revision(database, exercise_id, current);
if (status == TRAINLOG_STATUS_OK && strcmp(current, expected_revision) != 0) {
status = TRAINLOG_STATUS_CONFLICT;
}
if (status == TRAINLOG_STATUS_OK) {
status = causal_capacity_available(database);
}
if (status == TRAINLOG_STATUS_OK) {
status = exercise_live_revision(database, exercise_id, predecessor);
}
if (status == TRAINLOG_STATUS_OK &&
(trainlog_id_generate("del", operation_id, sizeof(operation_id)) != TRAINLOG_STATUS_OK ||
!timestamp_now(created_at))) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
}
written = status == TRAINLOG_STATUS_OK
? snprintf(payload,
sizeof(payload),
"{\"created_at\":\"%s\",\"creator_id\":\"%s\",\"operation_id\":\"%s\","
"\"predecessor_revision_id\":\"%s\",\"publication_context\":null,"
"\"target_id\":\"%s\",\"target_kind\":\"exercise\"}",
created_at,
CREATOR,
operation_id,
predecessor,
exercise_id)
: -1;
if (status == TRAINLOG_STATUS_OK && (written < 0 || (size_t)written >= sizeof(payload) ||
!sha256_hex(payload, (size_t)written, digest))) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (status == TRAINLOG_STATUS_OK) {
result = sqlite3_prepare_v2(
database->connection,
"INSERT INTO sync_causal_operations VALUES(?1,'exercise',?2,?3,?4,?5,?6,NULL)",
-1,
&statement,
NULL);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, exercise_id, -1, SQLITE_TRANSIENT);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, CREATOR, -1, SQLITE_STATIC);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, predecessor, -1, SQLITE_TRANSIENT);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 5, created_at, -1, SQLITE_TRANSIENT);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 6, digest, -1, SQLITE_TRANSIENT);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (statement != NULL) {
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (status == TRAINLOG_STATUS_OK && result != SQLITE_DONE) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
if (status == TRAINLOG_STATUS_OK) {
result = sqlite3_prepare_v2(
database->connection,
"INSERT OR REPLACE INTO "
"sync_causal_state(target_kind,target_id,current_revision_id,deleted,"
"operation_id) VALUES('exercise',?1,?2,1,?2)",
-1,
&statement,
NULL);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT);
}
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (statement != NULL) {
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (status == TRAINLOG_STATUS_OK && result != SQLITE_DONE) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
if (status != TRAINLOG_STATUS_OK) {
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
return status;
}
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
if (document == NULL || root == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, root);
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
!yyjson_mut_obj_add_strcpy(document, root, "exercise_id", exercise_id) ||
!yyjson_mut_obj_add_strcpy(document, root, "state", "retired") ||
!json_finish(&document, output_json, output_size)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return TRAINLOG_STATUS_OK;
}

View file

@ -21,6 +21,7 @@
#include <yyjson.h> #include <yyjson.h>
#include "trainlog/web_dashboard.h" #include "trainlog/web_dashboard.h"
#include "trainlog/web_exercises.h"
#include "trainlog/web_prepared_items.h" #include "trainlog/web_prepared_items.h"
#include "trainlog/web_programs.h" #include "trainlog/web_programs.h"
#include "trainlog/web_session_deletions.h" #include "trainlog/web_session_deletions.h"
@ -39,6 +40,7 @@
#define TRAINLOG_HTTP_BACKLOG 32U #define TRAINLOG_HTTP_BACKLOG 32U
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL #define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
#define TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY 256U #define TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY 256U
#define TRAINLOG_EXERCISE_REVISION_CAPACITY 72U
typedef struct TrainlogWebContext { typedef struct TrainlogWebContext {
uint16_t port; uint16_t port;
@ -843,6 +845,188 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
return queue_owned_programs_json(connection, status, json, json_size); return queue_owned_programs_json(connection, status, json, json_size);
} }
static enum MHD_Result queue_owned_exercises_json(struct MHD_Connection *connection,
TrainlogStatus status,
char *json,
size_t json_size,
const char *conflict_error) {
if (status == TRAINLOG_STATUS_NOT_FOUND) {
free(json);
return queue_json(
connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"exercise_not_found\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_CONFLICT) {
free(json);
return queue_json(connection,
MHD_HTTP_CONFLICT,
conflict_error == NULL ? "{\"error\":\"exercise_conflict\"}\n"
: conflict_error,
NULL);
}
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
free(json);
return queue_json(
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_exercise\"}\n", NULL);
}
if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) {
free(json);
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"exercises_unavailable\"}\n",
NULL);
}
(void)json_size;
{
enum MHD_Result result = queue_json(connection, MHD_HTTP_OK, json, NULL);
free(json);
return result;
}
}
static enum MHD_Result handle_exercises_request(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *url,
const char *method,
bool is_get,
TrainlogHttpRequestState *request) {
static const char COLLECTION[] = "/api/v1/exercises";
static const char DETAIL_PREFIX[] = "/api/v1/exercise/";
char *json = NULL;
size_t json_size = 0U;
const char *request_id;
TrainlogStatus status;
if (strcmp(url, "/api/v1/exercise-zones") == 0) {
if (!is_get) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET");
}
status = trainlog_web_exercises_zones_json(&json, &json_size);
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
}
if (strcmp(url, COLLECTION) == 0) {
if (is_get) {
TrainlogWebExercisesQuery query;
const char *unclassified;
if (!parse_page_number(
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "offset"),
0U,
1000000U,
&query.offset) ||
!parse_page_number(
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit"),
24U,
TRAINLOG_WEB_EXERCISES_PAGE_MAX,
&query.limit) ||
query.limit == 0U) {
return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL);
}
query.search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search");
query.profile =
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "profile");
query.zone_id =
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "zone_id");
unclassified =
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "unclassified");
query.unclassified = unclassified != NULL && strcmp(unclassified, "true") == 0;
status = trainlog_web_exercises_list_json(context->database, &query, &json, &json_size);
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
}
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET, POST");
}
if (!program_mutation_allowed(context, connection)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
request_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
if (request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_request_id\"}\n", NULL);
}
status = trainlog_web_exercises_create_json(
context->database, request->body, request->body_size, &json, &json_size);
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
}
if (strncmp(url, DETAIL_PREFIX, strlen(DETAIL_PREFIX)) == 0) {
const char *exercise_id = url + strlen(DETAIL_PREFIX);
const char *if_match;
char revision[TRAINLOG_EXERCISE_REVISION_CAPACITY];
if (exercise_id[0] == '\0' || strlen(exercise_id) > TRAINLOG_ID_MAX ||
strchr(exercise_id, '/') != NULL) {
return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_identity\"}\n", NULL);
}
if (is_get) {
status = trainlog_web_exercises_detail_json(
context->database, exercise_id, &json, &json_size);
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
}
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
strcmp(method, MHD_HTTP_METHOD_DELETE) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET, PUT, DELETE");
}
if (!program_mutation_allowed(context, connection)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
request_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
if (request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_request_id\"}\n", NULL);
}
if_match =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
if (!parse_quoted_revision(if_match, revision, sizeof(revision))) {
return queue_json(connection,
MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n",
NULL);
}
if (strcmp(method, MHD_HTTP_METHOD_PUT) == 0) {
status = trainlog_web_exercises_update_json(context->database,
exercise_id,
revision,
request->body,
request->body_size,
&json,
&json_size);
return queue_owned_exercises_json(connection,
status,
json,
json_size,
"{\"error\":\"exercise_revision_conflict\"}\n");
}
if (request->body_size != 0U) {
return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"unexpected_body\"}\n", NULL);
}
status = trainlog_web_exercises_retire_json(
context->database, exercise_id, revision, &json, &json_size);
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
free(json);
return queue_json(connection,
MHD_HTTP_CONFLICT,
"{\"error\":\"exercise_retirement_forbidden\"}\n",
NULL);
}
return queue_owned_exercises_json(
connection, status, json, json_size, "{\"error\":\"exercise_revision_conflict\"}\n");
}
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
static enum MHD_Result queue_session_deletion_result(struct MHD_Connection *connection, static enum MHD_Result queue_session_deletion_result(struct MHD_Connection *connection,
TrainlogStatus status, TrainlogStatus status,
char *json, char *json,
@ -1132,6 +1316,9 @@ static enum MHD_Result handle_request(void *closure,
} }
return queue_json(connection, MHD_HTTP_OK, json, NULL); return queue_json(connection, MHD_HTTP_OK, json, NULL);
} }
if (strncmp(url, "/api/v1/exercise", strlen("/api/v1/exercise")) == 0) {
return handle_exercises_request(context, connection, url, method, is_get, state);
}
if (strncmp(url, "/api/v1/sessions/program", strlen("/api/v1/sessions/program")) == 0) { if (strncmp(url, "/api/v1/sessions/program", strlen("/api/v1/sessions/program")) == 0) {
return handle_program_request(context, connection, url, method, is_get, state); return handle_program_request(context, connection, url, method, is_get, state);
} }

View file

@ -0,0 +1,224 @@
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>
#include "database_internal.h"
#include "trainlog/database.h"
#include "trainlog/web_exercises.h"
#define CHECK(expression) \
do { \
if (!(expression)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
return false; \
} \
} while (0)
static int scalar(TrainlogDatabase *database, const char *sql) {
sqlite3_stmt *statement = NULL;
int value = -1;
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
value = sqlite3_column_int(statement, 0);
}
(void)sqlite3_finalize(statement);
return value;
}
static bool json_string(const char *json, const char *key, char *output, size_t capacity) {
char marker[96];
const char *start;
const char *end;
size_t length;
CHECK(snprintf(marker, sizeof(marker), "\"%s\":\"", key) > 0);
start = strstr(json, marker);
CHECK(start != NULL);
start += strlen(marker);
end = strchr(start, '"');
CHECK(end != NULL);
length = (size_t)(end - start);
CHECK(length > 0U && length < capacity);
(void)memcpy(output, start, length);
output[length] = '\0';
return true;
}
static bool lifecycle_preserves_history_and_is_causal(void) {
static const char CREATE[] =
"{\"name\":\" Test Web \u00c9paules \" ,\"recording_mode\":\"sets\","
"\"tracking_mode\":\"reps\",\"data_fields\":0,"
"\"primary_zone_id\":\"shoulders\",\"secondary_zone_ids\":[\"arms\"]}";
static const char UPDATE[] = "{\"name\":\"Test Web Renomm\u00e9\",\"recording_mode\":\"sets\","
"\"tracking_mode\":\"duration\",\"data_fields\":0,"
"\"primary_zone_id\":\"core\",\"secondary_zone_ids\":[]}";
TrainlogDatabase *database = NULL;
TrainlogWebExercisesQuery query = {0U, 24U, "test web", "sets_reps", "shoulders", false};
char *created = NULL;
char *page = NULL;
char *updated = NULL;
char *retired = NULL;
size_t size = 0U;
char exercise_id[TRAINLOG_ID_MAX + 1U];
char revision[80];
char updated_revision[80];
char sql[2048];
int initial_exercise_count;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
initial_exercise_count = scalar(database, "SELECT COUNT(*) FROM exercises");
CHECK(trainlog_web_exercises_create_json(database, CREATE, strlen(CREATE), &created, &size) ==
TRAINLOG_STATUS_OK);
CHECK(json_string(created, "exercise_id", exercise_id, sizeof(exercise_id)));
CHECK(json_string(created, "revision", revision, sizeof(revision)));
CHECK(strstr(created, "\"primary_zone_id\":\"shoulders\"") != NULL);
CHECK(strstr(created, "\"secondary_zone_ids\":[\"arms\"]") != NULL);
CHECK(strstr(created, "\"retireable\":true") != NULL);
CHECK(snprintf(sql,
sizeof(sql),
"SELECT COUNT(*) FROM exercise_profile_state s JOIN exercises e ON e.id=s."
"exercise_row_id WHERE e.exercise_id='%s'",
exercise_id) > 0);
CHECK(scalar(database, sql) == 1);
CHECK(snprintf(sql,
sizeof(sql),
"SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e ON e.id=z."
"exercise_row_id WHERE e.exercise_id='%s'",
exercise_id) > 0);
CHECK(scalar(database, sql) == 2);
CHECK(trainlog_web_exercises_list_json(database, &query, &page, &size) == TRAINLOG_STATUS_OK);
CHECK(strstr(page, exercise_id) != NULL);
free(page);
page = NULL;
CHECK(snprintf(sql,
sizeof(sql),
"INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES("
"'se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','2026-09-20T10:00:00Z',"
"'2026-09-20T11:00:00Z','training');"
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,"
"recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) "
"SELECT 'sxe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',s.id,e.id,'sets','reps',0,"
"0,'external',0 FROM sessions s,exercises e WHERE e.exercise_id='%s';"
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) "
"SELECT id,0,8,42.5 FROM session_exercises;",
exercise_id) > 0);
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
CHECK(trainlog_web_exercises_update_json(
database, exercise_id, revision, UPDATE, strlen(UPDATE), &updated, &size) ==
TRAINLOG_STATUS_OK);
CHECK(json_string(updated, "revision", updated_revision, sizeof(updated_revision)));
CHECK(strcmp(revision, updated_revision) != 0);
CHECK(strstr(updated, "\"tracking_mode\":\"duration\"") != NULL);
CHECK(snprintf(sql,
sizeof(sql),
"SELECT COUNT(*) FROM exercise_profile_revisions r JOIN exercises e ON e.id=r."
"exercise_row_id WHERE e.exercise_id='%s'",
exercise_id) > 0);
CHECK(scalar(database, sql) >= 2);
CHECK(snprintf(sql,
sizeof(sql),
"SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e ON e.id=z."
"exercise_row_id WHERE e.exercise_id='%s'",
exercise_id) > 0);
CHECK(scalar(database, sql) == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM session_exercises WHERE recording_mode='sets' AND "
"tracking_mode='reps' AND data_fields=0") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM performed_sets WHERE reps=8 AND weight_kg=42.5") ==
1);
CHECK(trainlog_web_exercises_update_json(
database, exercise_id, revision, UPDATE, strlen(UPDATE), &page, &size) ==
TRAINLOG_STATUS_CONFLICT);
CHECK(page == NULL);
CHECK(trainlog_web_exercises_retire_json(
database, exercise_id, updated_revision, &retired, &size) == TRAINLOG_STATUS_OK);
CHECK(strstr(retired, "\"state\":\"retired\"") != NULL);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sync_causal_state WHERE target_kind='exercise' AND "
"deleted=1") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM exercises") == initial_exercise_count + 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM sessions") == 1);
query.search = "";
query.profile = "";
query.zone_id = NULL;
CHECK(trainlog_web_exercises_list_json(database, &query, &page, &size) == TRAINLOG_STATUS_OK);
CHECK(strstr(page, exercise_id) == NULL);
free(page);
page = NULL;
free(retired);
retired = NULL;
CHECK(trainlog_web_exercises_retire_json(
database, exercise_id, updated_revision, &retired, &size) == TRAINLOG_STATUS_OK);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sync_causal_operations WHERE target_kind='exercise'") == 1);
free(created);
free(updated);
free(retired);
trainlog_database_close(database);
return true;
}
static bool validation_collision_and_builtin_protection(void) {
static const char INVALID[] = "{\"name\":\"Invalide\",\"recording_mode\":\"continuous\","
"\"tracking_mode\":\"reps\",\"data_fields\":0,"
"\"primary_zone_id\":null,\"secondary_zone_ids\":[]}";
static const char DUPLICATE[] =
"{\"name\":\" LEG EXTENSION \" ,\"recording_mode\":\"sets\","
"\"tracking_mode\":\"reps\",\"data_fields\":0,"
"\"primary_zone_id\":\"chest\",\"secondary_zone_ids\":[]}";
static const char DUPLICATE_KEY[] =
"{\"name\":\"A\",\"name\":\"B\",\"recording_mode\":\"sets\","
"\"tracking_mode\":\"reps\",\"data_fields\":0,"
"\"primary_zone_id\":\"chest\",\"secondary_zone_ids\":[]}";
static const char SETS_FIELDS[] = "{\"name\":\"Fields\",\"recording_mode\":\"sets\","
"\"tracking_mode\":\"reps\",\"data_fields\":1,"
"\"primary_zone_id\":\"chest\",\"secondary_zone_ids\":[]}";
TrainlogDatabase *database = NULL;
char *json = NULL;
size_t size = 0U;
char revision[80];
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab",
"Leg Extension",
"leg extension",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
0U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_exercises_create_json(database, INVALID, strlen(INVALID), &json, &size) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_web_exercises_create_json(
database, DUPLICATE_KEY, strlen(DUPLICATE_KEY), &json, &size) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_web_exercises_create_json(
database, SETS_FIELDS, strlen(SETS_FIELDS), &json, &size) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_web_exercises_create_json(
database, DUPLICATE, strlen(DUPLICATE), &json, &size) == TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_web_exercises_detail_json(
database, "ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", &json, &size) ==
TRAINLOG_STATUS_OK);
CHECK(json_string(json, "revision", revision, sizeof(revision)));
CHECK(strstr(json, "\"retireable\":false") != NULL);
free(json);
json = NULL;
CHECK(trainlog_web_exercises_retire_json(
database, "ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", revision, &json, &size) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 0);
trainlog_database_close(database);
return true;
}
int main(void) {
if (!lifecycle_preserves_history_and_is_causal() ||
!validation_collision_and_builtin_protection()) {
return EXIT_FAILURE;
}
(void)puts("web exercises tests passed");
return EXIT_SUCCESS;
}

View file

@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createExercise, fetchBodyZones, fetchExercise, fetchExercises, retireExercise, updateExercise,
type ExerciseDetail, type ExerciseInput,
} from './exercises'
const detail: ExerciseDetail = {
api_version: 1, exercise_id: 'ex_11111111-1111-4111-8111-111111111111', name: 'Test',
recording_mode: 'sets', tracking_mode: 'reps', data_fields: 0, retireable: true,
primary_zone_id: 'chest', secondary_zone_ids: ['arms'], revision: 'exrev_abc', equipment: [],
} as ExerciseDetail & { api_version: 1 }
const input: ExerciseInput = {
name: 'Test', recording_mode: 'sets', tracking_mode: 'reps', data_fields: 0,
primary_zone_id: 'chest', secondary_zone_ids: ['arms'],
}
describe('Exercises API', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.stubGlobal('fetch', vi.fn())
})
it('validates list, detail, and BODY ZONES read models', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({ api_version: 1, offset: 0,
more: false, next_offset: 1, items: [detail] }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ api_version: 1,
zones: [{ zone_id: 'chest', name: 'Pectoraux' }] }), { status: 200 }))
expect((await fetchExercises({ search: 'test', profile: 'sets_reps' })).items).toHaveLength(1)
expect((await fetchExercise(detail.exercise_id)).revision).toBe('exrev_abc')
expect(await fetchBodyZones()).toEqual([{ zone_id: 'chest', name: 'Pectoraux' }])
})
it('uses guarded Core mutations and never calls sync or Android endpoints', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({ phase: 'idle', result: 'idle' }), {
status: 200, headers: { 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
}))
.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...detail, name: 'Nouveau' }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ api_version: 1,
exercise_id: detail.exercise_id, state: 'retired' }), { status: 200 }))
await createExercise(input)
await updateExercise(detail.exercise_id, detail.revision, { ...input, name: 'Nouveau' })
await retireExercise(detail.exercise_id, detail.revision)
const calls = fetchMock.mock.calls.map(([url]) => String(url))
expect(calls).toEqual(['/api/v1/sync/status', '/api/v1/exercises',
`/api/v1/exercise/${detail.exercise_id}`, `/api/v1/exercise/${detail.exercise_id}`])
expect(calls.every((url) => !url.includes('android') && url !== '/api/v1/sync')).toBe(true)
expect((fetchMock.mock.calls[2][1]?.headers as Record<string, string>)['If-Match'])
.toBe('"exrev_abc"')
})
})

141
web/src/api/exercises.ts Normal file
View file

@ -0,0 +1,141 @@
import { mutationCsrfToken } from './sync'
export type RecordingMode = 'sets' | 'continuous'
export type TrackingMode = 'reps' | 'duration'
export interface ExerciseSummary {
exercise_id: string
name: string
recording_mode: RecordingMode
tracking_mode: TrackingMode
data_fields: number
retireable: boolean
primary_zone_id: string | null
secondary_zone_ids: string[]
}
export interface ExerciseDetail extends ExerciseSummary {
revision: string
equipment: { equipment_id: string; display_name: string }[]
}
export interface ExercisePage {
api_version: 1
offset: number
more: boolean
next_offset: number
items: ExerciseSummary[]
}
export interface BodyZoneChoice { zone_id: string; name: string }
export interface ExerciseInput {
name: string
recording_mode: RecordingMode
tracking_mode: TrackingMode
data_fields: number
primary_zone_id: string | null
secondary_zone_ids: string[]
}
export class ExerciseApiError extends Error {
constructor(public readonly code: string, public readonly status: number) {
super(code)
}
}
function object(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function validSummary(value: unknown): value is ExerciseSummary {
if (!object(value)) return false
return typeof value.exercise_id === 'string' && typeof value.name === 'string' &&
['sets', 'continuous'].includes(String(value.recording_mode)) &&
['reps', 'duration'].includes(String(value.tracking_mode)) &&
Number.isSafeInteger(value.data_fields) && Number(value.data_fields) >= 0 &&
typeof value.retireable === 'boolean' &&
(value.primary_zone_id === null || typeof value.primary_zone_id === 'string') &&
Array.isArray(value.secondary_zone_ids) &&
value.secondary_zone_ids.every((zone) => typeof zone === 'string')
}
async function checked(response: Response): Promise<unknown> {
const value: unknown = await response.json()
if (!response.ok) {
const code = object(value) && typeof value.error === 'string' ? value.error : 'exercise_error'
throw new ExerciseApiError(code, response.status)
}
return value
}
export async function fetchExercises(parameters: {
offset?: number; search?: string; profile?: string; zone?: string; unclassified?: boolean
}, signal?: AbortSignal): Promise<ExercisePage> {
const query = new URLSearchParams({
offset: String(parameters.offset ?? 0), limit: '24',
})
if (parameters.search?.trim()) query.set('search', parameters.search.trim())
if (parameters.profile) query.set('profile', parameters.profile)
if (parameters.zone) query.set('zone_id', parameters.zone)
if (parameters.unclassified) query.set('unclassified', 'true')
const value = await checked(await fetch(`/api/v1/exercises?${query}`, {
headers: { Accept: 'application/json' }, signal,
}))
if (!object(value) || value.api_version !== 1 || !Array.isArray(value.items) ||
!value.items.every(validSummary) || typeof value.more !== 'boolean' ||
!Number.isSafeInteger(value.offset) || !Number.isSafeInteger(value.next_offset)) {
throw new TypeError('catalogue exercices invalide')
}
return value as unknown as ExercisePage
}
export async function fetchExercise(exerciseId: string, signal?: AbortSignal): Promise<ExerciseDetail> {
const value = await checked(await fetch(`/api/v1/exercise/${encodeURIComponent(exerciseId)}`, {
headers: { Accept: 'application/json' }, signal,
}))
if (!validSummary(value) || !object(value) || typeof value.revision !== 'string' ||
!Array.isArray(value.equipment)) throw new TypeError('fiche exercice invalide')
return value as unknown as ExerciseDetail
}
export async function fetchBodyZones(signal?: AbortSignal): Promise<BodyZoneChoice[]> {
const value = await checked(await fetch('/api/v1/exercise-zones', {
headers: { Accept: 'application/json' }, signal,
}))
if (!object(value) || !Array.isArray(value.zones) || !value.zones.every((zone) =>
object(zone) && typeof zone.zone_id === 'string' && typeof zone.name === 'string')) {
throw new TypeError('zones corporelles invalides')
}
return value.zones as BodyZoneChoice[]
}
async function mutation(path: string, method: 'POST' | 'PUT' | 'DELETE',
input?: ExerciseInput, revision?: string): Promise<ExerciseDetail | { state: 'retired' }> {
const csrf = await mutationCsrfToken()
const requestId = crypto.randomUUID()
const headers: Record<string, string> = {
Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf,
'X-Trainlog-Request-ID': requestId,
}
if (revision) headers['If-Match'] = `"${revision}"`
const value = await checked(await fetch(path, {
method, headers, body: input === undefined ? undefined : JSON.stringify(input),
}))
return value as ExerciseDetail | { state: 'retired' }
}
export async function createExercise(input: ExerciseInput): Promise<ExerciseDetail> {
return await mutation('/api/v1/exercises', 'POST', input) as ExerciseDetail
}
export async function updateExercise(exerciseId: string, revision: string,
input: ExerciseInput): Promise<ExerciseDetail> {
return await mutation(`/api/v1/exercise/${encodeURIComponent(exerciseId)}`,
'PUT', input, revision) as ExerciseDetail
}
export async function retireExercise(exerciseId: string, revision: string): Promise<void> {
await mutation(`/api/v1/exercise/${encodeURIComponent(exerciseId)}`, 'DELETE', undefined, revision)
}

View file

@ -8,14 +8,14 @@ import { DashboardPage } from '../routes/DashboardPage'
import { PlaceholderPage } from '../routes/PlaceholderPage' import { PlaceholderPage } from '../routes/PlaceholderPage'
import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage' import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage'
import { SessionsPage } from '../routes/SessionsPage' import { SessionsPage } from '../routes/SessionsPage'
import { ExercisesPage } from '../routes/ExercisesPage'
import type { RouteId } from './routes' import type { RouteId } from './routes'
import { useRoute } from './useRoute' import { useRoute } from './useRoute'
import { DatePreferencesProvider } from '../presentation/DatePreferences' import { DatePreferencesProvider } from '../presentation/DatePreferences'
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'programs'>, [string, string, string]> = { const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'programs' | 'exercises'>, [string, string, string]> = {
analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'], analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'],
sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'], sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'],
exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'],
} }
function AppContent() { function AppContent() {
@ -76,6 +76,7 @@ function AppContent() {
/> />
) : route.id === 'sessions' ? <SessionsPage /> : ) : route.id === 'sessions' ? <SessionsPage /> :
route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> : route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> :
route.id === 'exercises' ? <ExercisesPage path={route.path} onNavigate={navigate} /> :
<PlaceholderPage title={placeholderContent[route.id][0]} <PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]} eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} /> description={placeholderContent[route.id][2]} />

View file

@ -15,6 +15,11 @@ export const routes: readonly AppRoute[] = [
] ]
export function routeFromPath(pathname: string): AppRoute { export function routeFromPath(pathname: string): AppRoute {
if (pathname === '/seances' || pathname.startsWith('/seances/')) return routes[3] if (pathname === '/seances' || pathname.startsWith('/seances/')) {
return { ...routes[3], path: pathname }
}
if (pathname === '/exercices' || pathname.startsWith('/exercices/')) {
return { ...routes[4], path: pathname }
}
return routes.find((route) => route.path === pathname) ?? routes[0] return routes.find((route) => route.path === pathname) ?? routes[0]
} }

View file

@ -0,0 +1,48 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ExercisesPage } from './ExercisesPage'
const exercise = {
exercise_id: 'ex_11111111-1111-4111-8111-111111111111', name: 'Développé couché',
recording_mode: 'sets' as const, tracking_mode: 'reps' as const, data_fields: 0,
retireable: true, primary_zone_id: 'chest', secondary_zone_ids: ['arms'],
}
describe('ExercisesPage', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.stubGlobal('fetch', vi.fn((url: string) => {
if (String(url).startsWith('/api/v1/exercise-zones')) return Promise.resolve(new Response(
JSON.stringify({ api_version: 1, zones: [{ zone_id: 'chest', name: 'Pectoraux' },
{ zone_id: 'arms', name: 'Bras' }] }), { status: 200 }))
if (String(url).startsWith('/api/v1/exercises')) return Promise.resolve(new Response(
JSON.stringify({ api_version: 1, offset: 0, more: false, next_offset: 1,
items: [exercise] }), { status: 200 }))
return Promise.reject(new Error(`unexpected ${url}`))
}))
})
it('renders the real responsive catalogue, search, filters and navigation', async () => {
const navigate = vi.fn()
render(<ExercisesPage path="/exercices" onNavigate={navigate} />)
expect(await screen.findByText('Développé couché')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Exercices' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Séries + répétitions' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Non renseignés' })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Rechercher'), { target: { value: 'développé' } })
await waitFor(() => expect(vi.mocked(fetch).mock.calls.some(([url]) =>
String(url).includes('search=d%C3%A9velopp%C3%A9'))).toBe(true))
fireEvent.click(screen.getByRole('button', { name: /Développé couché/ }))
expect(navigate).toHaveBeenCalledWith(`/exercices/${exercise.exercise_id}`)
fireEvent.click(screen.getByRole('button', { name: '+ Nouvel exercice' }))
expect(navigate).toHaveBeenCalledWith('/exercices/nouveau')
})
it('prevents continuous repetitions in the creation form', async () => {
render(<ExercisesPage path="/exercices/nouveau" onNavigate={vi.fn()} />)
await screen.findByRole('option', { name: 'Pectoraux' })
fireEvent.click(screen.getByLabelText('Continu'))
expect(screen.getByLabelText('Répétitions')).toBeDisabled()
expect(screen.getByRole('button', { name: 'Créer l’exercice' })).toBeEnabled()
})
})

View file

@ -0,0 +1,241 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
createExercise,
ExerciseApiError,
fetchBodyZones,
fetchExercise,
fetchExercises,
retireExercise,
updateExercise,
type BodyZoneChoice,
type ExerciseDetail,
type ExerciseInput,
type ExerciseSummary,
} from '../api/exercises'
import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog'
interface Props { path: string; onNavigate: (path: string) => void }
type ProfileFilter = '' | 'sets_reps' | 'sets_duration' | 'continuous_duration'
function profileLabel(exercise: Pick<ExerciseSummary, 'recording_mode' | 'tracking_mode'>): string {
if (exercise.recording_mode === 'continuous') return 'Continu · durée'
return exercise.tracking_mode === 'duration' ? 'Séries · durée' : 'Séries · répétitions'
}
function fieldsLabel(fields: number): string {
const labels: string[] = []
if ((fields & 1) !== 0) labels.push('Vitesse')
if ((fields & 2) !== 0) labels.push('Distance')
return labels.length === 0 ? 'Aucun champ supplémentaire' : labels.join(' · ')
}
function errorMessage(error: unknown): string {
if (!(error instanceof ExerciseApiError)) return 'Une erreur inattendue est survenue.'
const messages: Record<string, string> = {
invalid_exercise: 'Vérifiez le nom, le profil et les zones sélectionnées.',
exercise_conflict: 'Un exercice porte déjà ce nom normalisé.',
exercise_revision_conflict: 'Cet exercice a été modifié. Rechargez sa fiche.',
exercise_retirement_forbidden: 'Cette identité fournie par Trainlog est protégée.',
exercise_not_found: 'Cet exercice est introuvable.',
}
return messages[error.code] ?? 'L’opération n’a pas pu être effectuée.'
}
function emptyInput(): ExerciseInput {
return { name: '', recording_mode: 'sets', tracking_mode: 'reps', data_fields: 0,
primary_zone_id: null, secondary_zone_ids: [] }
}
function inputFromDetail(detail: ExerciseDetail): ExerciseInput {
return { name: detail.name, recording_mode: detail.recording_mode,
tracking_mode: detail.tracking_mode, data_fields: detail.data_fields,
primary_zone_id: detail.primary_zone_id,
secondary_zone_ids: [...detail.secondary_zone_ids] }
}
function ExerciseForm({ initial, zones, submitLabel, onCancel, onSubmit }: {
initial: ExerciseInput; zones: BodyZoneChoice[]; submitLabel: string
onCancel: () => void; onSubmit: (input: ExerciseInput) => Promise<void>
}) {
const [input, setInput] = useState(initial)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [confirmProfile, setConfirmProfile] = useState(false)
const profileChanged = input.recording_mode !== initial.recording_mode ||
input.tracking_mode !== initial.tracking_mode || input.data_fields !== initial.data_fields
const invalidProfile = input.recording_mode === 'continuous' && input.tracking_mode === 'reps'
const save = async () => {
setBusy(true); setError('')
try { await onSubmit(input) } catch (reason) { setError(errorMessage(reason)); setBusy(false) }
}
const requestSave = (event: React.FormEvent) => {
event.preventDefault()
if (profileChanged && !confirmProfile) { setConfirmProfile(true); return }
void save()
}
return <form className="exercise-form" onSubmit={requestSave}>
<label>Nom
<input required maxLength={200} value={input.name}
onChange={(event) => setInput({ ...input, name: event.target.value })} />
</label>
<fieldset><legend>Mode de saisie</legend>
<label><input type="radio" name="recording" checked={input.recording_mode === 'sets'}
onChange={() => setInput({ ...input, recording_mode: 'sets', data_fields: 0 })} /> Séries</label>
<label><input type="radio" name="recording" checked={input.recording_mode === 'continuous'}
onChange={() => setInput({ ...input, recording_mode: 'continuous', tracking_mode: 'duration' })} /> Continu</label>
</fieldset>
<fieldset><legend>Suivi</legend>
<label><input type="radio" name="tracking" checked={input.tracking_mode === 'reps'}
disabled={input.recording_mode === 'continuous'}
onChange={() => setInput({ ...input, tracking_mode: 'reps' })} /> Répétitions</label>
<label><input type="radio" name="tracking" checked={input.tracking_mode === 'duration'}
onChange={() => setInput({ ...input, tracking_mode: 'duration' })} /> Durée</label>
</fieldset>
<fieldset disabled={input.recording_mode === 'sets'}><legend>Champs supplémentaires</legend>
<label><input type="checkbox" checked={(input.data_fields & 1) !== 0}
onChange={(event) => setInput({ ...input,
data_fields: event.target.checked ? input.data_fields | 1 : input.data_fields & ~1 })} /> Vitesse</label>
<label><input type="checkbox" checked={(input.data_fields & 2) !== 0}
onChange={(event) => setInput({ ...input,
data_fields: event.target.checked ? input.data_fields | 2 : input.data_fields & ~2 })} /> Distance</label>
</fieldset>
<label>Zone principale
<select required={input.recording_mode === 'sets'} value={input.primary_zone_id ?? ''}
onChange={(event) => setInput({ ...input, primary_zone_id: event.target.value || null,
secondary_zone_ids: input.secondary_zone_ids.filter((id) => id !== event.target.value) })}>
<option value="">Non renseignée</option>
{zones.map((zone) => <option key={zone.zone_id} value={zone.zone_id}>{zone.name}</option>)}
</select>
</label>
<fieldset disabled={!input.primary_zone_id}><legend>Zones secondaires</legend>
<div className="exercise-zone-options">{zones.filter((zone) => zone.zone_id !== input.primary_zone_id).map((zone) =>
<label key={zone.zone_id}><input type="checkbox"
checked={input.secondary_zone_ids.includes(zone.zone_id)}
onChange={(event) => setInput({ ...input, secondary_zone_ids: event.target.checked
? [...input.secondary_zone_ids, zone.zone_id]
: input.secondary_zone_ids.filter((id) => id !== zone.zone_id) })} /> {zone.name}</label>)}</div>
</fieldset>
{invalidProfile && <p className="form-error" role="alert">Continu + répétitions est invalide.</p>}
{confirmProfile && <div className="profile-confirmation" role="alert">
<p>Cette modification changera la saisie des futurs entraînements. Les séances déjà enregistrées resteront inchangées.</p>
<button type="button" className="quiet-action" onClick={() => setConfirmProfile(false)}>Revenir au formulaire</button>
</div>}
{error && <p className="form-error" role="alert">{error}</p>}
<div className="editor-actions">
<button type="button" className="quiet-action" disabled={busy} onClick={onCancel}>Annuler</button>
<button type="submit" className="primary-action" disabled={busy || invalidProfile}>
{busy ? 'Enregistrement…' : confirmProfile ? 'Confirmer la modification' : submitLabel}
</button>
</div>
</form>
}
function DetailView({ exerciseId, zones, onNavigate }: {
exerciseId: string; zones: BodyZoneChoice[]; onNavigate: (path: string) => void
}) {
const [detail, setDetail] = useState<ExerciseDetail | null>(null)
const [editing, setEditing] = useState(false)
const [retiring, setRetiring] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const retireButton = useRef<HTMLButtonElement>(null)
const zoneNames = useMemo(() => new Map(zones.map((zone) => [zone.zone_id, zone.name])), [zones])
const load = () => fetchExercise(exerciseId).then(setDetail).catch((reason) => setError(errorMessage(reason)))
useEffect(() => { void load() }, [exerciseId])
if (error && detail === null) return <div className="error-panel" role="alert">{error}</div>
if (detail === null) return <p>Chargement de l’exercice…</p>
if (editing) return <ExerciseForm initial={inputFromDetail(detail)} zones={zones}
submitLabel="Enregistrer" onCancel={() => setEditing(false)} onSubmit={async (input) => {
const updated = await updateExercise(detail.exercise_id, detail.revision, input)
setDetail(updated); setEditing(false)
setMessage('Modification enregistrée sur le PC. Elle sera synchronisée vers Android lors de la prochaine synchronisation.')
}} />
return <section className="exercise-detail">
<button className="quiet-action" type="button" onClick={() => onNavigate('/exercices')}>← Catalogue</button>
<div className="exercise-detail-heading"><div><p className="eyebrow">FICHE EXERCICE</p><h1>{detail.name}</h1></div>
<div className="detail-actions"><button className="primary-action" type="button" onClick={() => setEditing(true)}>Modifier</button>
{detail.retireable ? <button ref={retireButton} className="danger-action" type="button" onClick={() => setRetiring(true)}>Retirer du catalogue</button>
: <span className="protected-identity">Identité Trainlog protégée</span>}</div></div>
<dl className="exercise-facts">
<div><dt>Identité technique</dt><dd><code>{detail.exercise_id}</code></dd></div>
<div><dt>Profil</dt><dd>{profileLabel(detail)}</dd></div>
<div><dt>Champs supplémentaires</dt><dd>{fieldsLabel(detail.data_fields)}</dd></div>
<div><dt>Zone principale</dt><dd>{detail.primary_zone_id ? zoneNames.get(detail.primary_zone_id) ?? detail.primary_zone_id : 'Non renseignée'}</dd></div>
<div><dt>Zones secondaires</dt><dd>{detail.secondary_zone_ids.map((id) => zoneNames.get(id) ?? id).join(', ') || 'Aucune'}</dd></div>
<div><dt>Équipements observés</dt><dd>{detail.equipment.map((item) => item.display_name).join(', ') || 'Aucun équipement associé connu'}</dd></div>
</dl>
{message && <p className="success-panel" aria-live="polite">{message}</p>}
{retiring && <DeleteConfirmationDialog title={detail.name} itemType="EXERCICE"
consequence="Retirer cet exercice l’empêche d’être utilisé pour de nouvelles saisies. Les séances déjà réalisées restent conservées."
cancelLabel="Annuler" confirmLabel="Retirer" busy={false} error="" returnFocus={retireButton}
onCancel={() => setRetiring(false)} onConfirm={() => { void retireExercise(detail.exercise_id, detail.revision)
.then(() => onNavigate('/exercices')).catch((reason) => { setRetiring(false); setError(errorMessage(reason)) }) }} />}
{error && <p className="form-error" role="alert">{error}</p>}
</section>
}
export function ExercisesPage({ path, onNavigate }: Props) {
const [zones, setZones] = useState<BodyZoneChoice[]>([])
const [items, setItems] = useState<ExerciseSummary[]>([])
const [search, setSearch] = useState('')
const [profile, setProfile] = useState<ProfileFilter>('')
const [zone, setZone] = useState('')
const [unclassified, setUnclassified] = useState(false)
const [offset, setOffset] = useState(0)
const [more, setMore] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const isNew = path === '/exercices/nouveau'
const detailId = path.startsWith('/exercices/') && !isNew ? decodeURIComponent(path.slice('/exercices/'.length)) : ''
const zoneNames = useMemo(() => new Map(zones.map((item) => [item.zone_id, item.name])), [zones])
useEffect(() => { const controller = new AbortController(); fetchBodyZones(controller.signal)
.then(setZones).catch((reason) => setError(errorMessage(reason))); return () => controller.abort() }, [])
useEffect(() => {
if (isNew || detailId) return
const controller = new AbortController()
fetchExercises({ offset, search, profile, zone: unclassified ? '' : zone, unclassified }, controller.signal)
.then((page) => { setItems(page.items); setMore(page.more); setError('') })
.catch((reason) => setError(errorMessage(reason)))
return () => controller.abort()
}, [detailId, isNew, offset, profile, search, unclassified, zone])
if (detailId) return <DetailView exerciseId={detailId} zones={zones} onNavigate={onNavigate} />
if (isNew) return <section className="page"><p className="eyebrow">NOUVEL EXERCICE</p><h1>Créer un exercice</h1>
<ExerciseForm initial={emptyInput()} zones={zones} submitLabel="Créer l’exercice"
onCancel={() => onNavigate('/exercices')} onSubmit={async (input) => {
const created = await createExercise(input); onNavigate(`/exercices/${created.exercise_id}`)
}} /></section>
return <section className="page exercises-page">
<div className="page-heading"><div><p className="eyebrow">CATALOGUE D’EXERCICES</p><h1>Exercices</h1></div>
<button className="primary-action" type="button" onClick={() => onNavigate('/exercices/nouveau')}>+ Nouvel exercice</button></div>
<div className="exercise-toolbar">
<label>Rechercher<input value={search} onChange={(event) => { setSearch(event.target.value); setOffset(0) }} /></label>
<label>Profil<select value={profile} onChange={(event) => { setProfile(event.target.value as ProfileFilter); setOffset(0) }}>
<option value="">Tous profils</option><option value="sets_reps">Séries + répétitions</option>
<option value="sets_duration">Séries + durée</option><option value="continuous_duration">Continu + durée</option></select></label>
<label>Body zone<select value={unclassified ? '__none__' : zone} onChange={(event) => {
setUnclassified(event.target.value === '__none__'); setZone(event.target.value === '__none__' ? '' : event.target.value); setOffset(0)
}}><option value="">Toutes</option><option value="__none__">Non renseignés</option>
{zones.map((item) => <option key={item.zone_id} value={item.zone_id}>{item.name}</option>)}</select></label>
</div>
{error && <p className="error-panel" role="alert">{error}</p>}
{message && <p className="success-panel" aria-live="polite">{message}</p>}
<div className="exercise-list">{items.map((exercise) => <article className="exercise-card" key={exercise.exercise_id}>
<button className="exercise-card-main" type="button" onClick={() => onNavigate(`/exercices/${exercise.exercise_id}`)}>
<strong>{exercise.name}</strong><span>{profileLabel(exercise)}</span><span>{fieldsLabel(exercise.data_fields)}</span>
<span>{exercise.primary_zone_id ? zoneNames.get(exercise.primary_zone_id) ?? exercise.primary_zone_id : 'Non renseignée'}</span>
</button><button type="button" className="quiet-action" onClick={() => onNavigate(`/exercices/${exercise.exercise_id}`)}>Modifier</button>
</article>)}</div>
{items.length === 0 && !error && <p className="empty-inline">Aucun exercice ne correspond aux filtres.</p>}
<div className="exercise-pagination"><button className="quiet-action" type="button" disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - 24))}>Page précédente</button>
<button className="quiet-action" type="button" disabled={!more} onClick={() => setOffset(offset + 24)}>Page suivante</button></div>
</section>
}

View file

@ -218,6 +218,39 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.program-calendar-legend { margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .45rem; list-style: none; } .program-calendar-legend { margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .45rem; list-style: none; }
.program-calendar-legend li, .program-calendar-state-label { padding: .3rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: var(--font-size-xs); font-weight: 700; } .program-calendar-legend li, .program-calendar-state-label { padding: .3rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: var(--font-size-xs); font-weight: 700; }
.program-calendar-state-todo { color: var(--lavender); background: color-mix(in srgb, var(--lavender) 7%, transparent); } .program-calendar-state-todo { color: var(--lavender); background: color-mix(in srgb, var(--lavender) 7%, transparent); }
.exercise-toolbar { display: grid; grid-template-columns: minmax(14rem, 2fr) repeat(2, minmax(11rem, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.exercise-toolbar label, .exercise-form > label { display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
.exercise-toolbar input, .exercise-toolbar select, .exercise-form input[type="text"], .exercise-form > label > input, .exercise-form select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; color: var(--text); background: var(--base); border: 1px solid var(--surface-2); border-radius: .5rem; }
.exercise-list { display: grid; gap: .65rem; }
.exercise-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: stretch; overflow: hidden; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
.exercise-card:hover { border-color: var(--lavender); }
.exercise-card-main { display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(8rem, 1fr)); gap: 1rem; align-items: center; padding: 1rem 1.1rem; color: var(--text); background: transparent; border: 0; text-align: left; cursor: pointer; }
.exercise-card-main span { color: var(--subtext-0); font-size: var(--font-size-sm); }
.exercise-card > .quiet-action { margin: .65rem; }
.exercise-pagination { display: flex; justify-content: flex-end; gap: .65rem; margin-top: 1rem; }
.exercise-detail { width: min(100%, 70rem); margin: 0 auto; }
.exercise-detail-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin: 2rem 0; }
.exercise-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
.exercise-facts div { padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--mantle); }
.exercise-facts dt { margin-bottom: .4rem; color: var(--subtext-0); font-size: var(--font-size-xs); text-transform: uppercase; }
.exercise-facts dd { margin: 0; overflow-wrap: anywhere; }
.protected-identity { align-self: center; color: var(--yellow); font-size: var(--font-size-sm); }
.exercise-form { width: min(100%, 54rem); display: grid; gap: 1rem; margin-top: 2rem; padding: clamp(1rem, 3vw, 2rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
.exercise-form fieldset { display: flex; flex-wrap: wrap; gap: .75rem 1.25rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .65rem; }
.exercise-form legend { padding: 0 .35rem; color: var(--subtext-0); }
.exercise-form fieldset label { display: flex; align-items: center; gap: .45rem; }
.exercise-zone-options { display: grid; grid-template-columns: repeat(2, minmax(12rem, 1fr)); gap: .65rem; width: 100%; }
.profile-confirmation { padding: 1rem; border: 1px solid var(--yellow); border-radius: .65rem; color: var(--yellow); background: rgb(249 226 175 / 7%); }
@media (max-width: 50rem) {
.exercise-toolbar, .exercise-facts { grid-template-columns: 1fr; }
.exercise-card { grid-template-columns: 1fr; }
.exercise-card-main { grid-template-columns: 1fr; gap: .35rem; }
.exercise-card > .quiet-action { justify-self: start; }
.exercise-detail-heading { align-items: start; flex-direction: column; }
.exercise-zone-options { grid-template-columns: 1fr; }
}
.program-calendar-state-prepared { color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, transparent); } .program-calendar-state-prepared { color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, transparent); }
.program-calendar-state-in_progress { color: var(--sapphire); background: color-mix(in srgb, var(--sapphire) 9%, transparent); } .program-calendar-state-in_progress { color: var(--sapphire); background: color-mix(in srgb, var(--sapphire) 9%, transparent); }
.program-calendar-state-completed { color: var(--green); background: color-mix(in srgb, var(--green) 8%, transparent); } .program-calendar-state-completed { color: var(--green); background: color-mix(in srgb, var(--green) 8%, transparent); }