diff --git a/CHANGELOG.md b/CHANGELOG.md index e17b9d3..2507f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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 - Added USB-priority full-generation synchronization with a separately diff --git a/README.md b/README.md index c518540..08eb37a 100644 --- a/README.md +++ b/README.md @@ -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 field companion, its C17/Notcurses desktop TUI is the administration and technical surface, and its local browser Web sibling provides the implemented -Dashboard, Sessions, and Programmes surfaces while reserving Analyse and -Exercises for separately contracted work. +Dashboard, Sessions, Programmes, and Exercises surfaces while reserving Analyse +for separately contracted work. Android and desktop each own a local SQLite database. The desktop database is 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. | | 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 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 Programs. Sessions → Programmes remains the technical administration/import, list, detail, archive, and delete surface. -The ordered 0.1.3 development focus is an Android UX redesign followed by Web -Exercises administration; both remain planned, separately contracted work. +Web Exercises administration is implemented for 0.1.3 development. The next +separately contracted priority is the Android UX redesign. ## Releases @@ -349,8 +349,8 @@ As of 2026-09-18: - `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and `WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`; `/programmes` is an operational active-Program calendar, while - Sessions → Programmes remains the administration surface; Analyse and - Exercises remain placeholders; + Sessions → Programmes remains the administration surface; + `TRAINLOG_WEB_EXERCISES_V1=PASS`; Analyse remains a placeholder; - `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated private desktop/Android deployment, real Firefox presentation, correlated `programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass; diff --git a/docs/README.md b/docs/README.md index 734595f..50d368b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,9 @@ evidence and chronology. ## 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, history and Android-delivery workflow. - [Program format V1](program_format_v1.md) owns the strict desktop planning diff --git a/docs/current_state.md b/docs/current_state.md index e0da537..43f0062 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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) | | 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` | +| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` | | 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 | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_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 top-level `/programmes` route is an operational daily active-Program calendar; Sessions → Programmes remains the technical administration/import, list, -detail, archive, and delete surface. Analyse and Exercises remain explicit -placeholders. +detail, archive, and delete surface. `/exercices` is the operational desktop +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)`, `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 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 - implemented. `TRAINLOG_WEB_EXERCISES_V1` follows it and will replace the - current `/exercices` placeholder only after a separate Core/read/command - contract. Analyse remains a placeholder and is not a v0.1.3 priority. +- `TRAINLOG_WEB_EXERCISES_V1=PASS`. The next planned work is + `TRAINLOG_ANDROID_UI_REDESIGN_V1`; it remains unimplemented. Analyse remains + a placeholder and is not a v0.1.3 priority. - `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. - Session Generator V1 is hidden while V2 planning semantics are developed. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7b16b36..b261de6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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` route is an operational daily active-Program calendar, while Sessions → Programmes remains the technical administration/import, list, -detail, archive, and delete surface. Analyse and Exercises remain placeholders. -Exercises is now the second ordered v0.1.3 priority; Analyse remains outside -this cycle unless a later explicit decision changes that order. +detail, archive, and delete surface. Web Exercises V1 is implemented; Analyse +remains a placeholder outside this cycle unless a later explicit decision +changes that order. The corrective prepared-item projection, bounded phase-owned MTP outbox, Sessions presentation correction, local date preference and durable preparation withdrawal are implemented. The withdrawal uses the separate versioned @@ -60,7 +60,6 @@ causal-deletion contracts without reopening those completed gates. ```text 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 @@ -166,20 +165,21 @@ not authorize implementation and it did not select Analyse. ## 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; -2. the complete Web Exercices surface. +1. the complete Web Exercices surface; +2. the global Android experience redesign. ```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 -is documentary ordering, not authorization to implement both lots in one -ticket. Analyse and every other new Web module remain after these priorities -unless a later explicit decision changes the order. +The Web lot is implemented and validated. The Android lot still requires its +own reviewed contract before implementation. Analyse and every other new Web +module remain after this priority unless a later explicit decision changes the +order. ### TRAINLOG_ANDROID_UI_REDESIGN_V1 @@ -222,12 +222,12 @@ will be made screen by screen. ### TRAINLOG_WEB_EXERCISES_V1 -The top-level `/exercices` placeholder will become the desktop catalogue -administration surface. The first step is an audit of existing Core catalogue -services and persisted capabilities; implementation is not authorized until a -separate read/command contract has been reviewed. +`TRAINLOG_WEB_EXERCISES_V1=PASS`. The top-level `/exercices` route is the +desktop catalogue administration surface. It uses bounded Core read/command +services and a deterministic optimistic token; it does not put business rules +in React. -The future lot includes: +The delivered lot includes: - **Catalogue:** a complete or paged list according to the Core contract, search, relevant filters, available/retired state when the model supports it, @@ -239,7 +239,8 @@ The future lot includes: business truth. - **Creation:** create through a real transactional Core command service, with 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 distinguish mutable and immutable fields, compatible changes, changes that 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 catalogue, profile, BODY ZONES, and equipment artifacts to determine whether the planned changes are representable. Frozen formats remain frozen; if a new -datum cannot be transported, the contract must state that explicitly before -any protocol change. +datum cannot be transported, a future contract must state that explicitly +before any protocol change. No automatic synchronization is initiated by this +surface. ### Bounded v0.1.3 order ```text 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 +| | +| +-- Core/catalogue characterization +| +-- read-model contract +| +-- command-service contract +| +-- Web catalogue +| +-- exercise detail +| +-- creation +| +-- modification +| +-- causal retirement +| +-- Android synchronization +| +-- real validation +| ++-- ANDROID_UI_REDESIGN_V1 | - +-- Core/catalogue characterization - +-- read-model contract - +-- command-service contract - +-- Web catalogue - +-- exercise detail - +-- creation - +-- modification - +-- causal retirement - +-- Android synchronization - +-- real validation + +-- current-state audit + +-- UX/navigation contract + +-- shell / buttons / components + +-- active-session workflow + +-- exercise-entry workflow + +-- real visual review + +-- accessibility validation ``` ## Next @@ -352,8 +354,8 @@ normal capture. ## Canonical order ```text -TRAINLOG_ANDROID_UI_REDESIGN_V1 - -> TRAINLOG_WEB_EXERCISES_V1 +TRAINLOG_WEB_EXERCISES_V1 + -> TRAINLOG_ANDROID_UI_REDESIGN_V1 -> GYM_CATALOG_V1 -> EXERCISE_METADATA_V1 -> SESSION_GENERATOR_V2 diff --git a/docs/web_exercises.md b/docs/web_exercises.md new file mode 100644 index 0000000..d34cfa9 --- /dev/null +++ b/docs/web_exercises.md @@ -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_` 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. diff --git a/tests/test_causal_delete_exchange.py b/tests/test_causal_delete_exchange.py index 5fbe27a..557534e 100644 --- a/tests/test_causal_delete_exchange.py +++ b/tests/test_causal_delete_exchange.py @@ -4,6 +4,7 @@ import importlib.util import json import sqlite3 +import subprocess import sys import tempfile import unittest @@ -106,6 +107,22 @@ class CausalDeleteTest(unittest.TestCase): with self.assertRaisesRegex(causal.CausalError, "built-in"): 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): operation = {"operation_id": "del_" + "a" * 36, "target_kind": kind, "target_id": target, "creator_id": "hostile_fixture", diff --git a/tests/test_web_exercises_browser.py b/tests/test_web_exercises_browser.py new file mode 100644 index 0000000..29278db --- /dev/null +++ b/tests/test_web_exercises_browser.py @@ -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() diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 4ccb3fc..2f31a5e 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -77,6 +77,15 @@ def main(): 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( ''' SELECT @@ -86,6 +95,7 @@ def main(): tracking_mode, data_fields FROM exercises + ''' + active_filter + ''' ORDER BY name COLLATE NOCASE, exercise_id; diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 4d97241..074883c 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -250,11 +250,13 @@ TrainlogStatus trainlog_database_list_exercises_filtered(TrainlogDatabase *datab size_t *output_count); /* 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 - * rejected once completed history references the exercise; a same-profile - * rename or zone replacement preserves exercise_id and history. Zone rules - * match replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND; - * invalid metadata and name collisions remain explicit errors. */ + * inputs are borrowed only for the duration of the call. Profile changes affect + * future occurrences only because schema v16 snapshots the complete profile on + * every occurrence. Existing sessions and drafts are never rewritten. The + * function owns a transaction when called in autocommit mode and participates + * 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, const char *exercise_id, const char *name, diff --git a/tui/include/trainlog/web_exercises.h b/tui/include/trainlog/web_exercises.h new file mode 100644 index 0000000..ce184cf --- /dev/null +++ b/tui/include/trainlog/web_exercises.h @@ -0,0 +1,61 @@ +/* Trainlog Web Exercises V1 application service. */ +#ifndef TRAINLOG_WEB_EXERCISES_H +#define TRAINLOG_WEB_EXERCISES_H + +#include + +#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 diff --git a/tui/meson.build b/tui/meson.build index 6f21839..9d5e59b 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -76,9 +76,13 @@ if web_assets_enabled '../web/src/routes/PlaceholderPage.tsx', '../web/src/routes/SessionsPage.tsx', '../web/src/api/sessions.ts', + '../web/src/api/exercises.ts', + '../web/src/api/exercises.test.ts', '../web/src/api/programs.ts', '../web/src/routes/ProgramsTab.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/tokens.css', ) @@ -209,6 +213,7 @@ trainlog_core_sources = files( 'src/web_sessions.c', 'src/web_session_deletions.c', 'src/web_programs.c', + 'src/web_exercises.c', 'src/duration.c', 'src/id.c', 'src/timeutil.c', @@ -382,6 +387,15 @@ test_web_programs = executable( ) 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', 'tests/test_web_session_deletions.c', diff --git a/tui/src/database.c b/tui/src/database.c index 68fd43d..60768f9 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -2817,6 +2817,7 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data sqlite3_stmt *statement = NULL; sqlite3_int64 row_id; TrainlogStatus status; + bool owns_transaction; int rc; const char *tracking; const char *recording; @@ -2832,7 +2833,8 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data if (status != TRAINLOG_STATUS_OK) { 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; } @@ -2872,6 +2874,9 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data if (status != TRAINLOG_STATUS_OK) { goto rollback; } + if (!owns_transaction) { + return TRAINLOG_STATUS_OK; + } if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) { (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); return TRAINLOG_STATUS_DATABASE_ERROR; @@ -2882,7 +2887,9 @@ rollback: if (statement != NULL) { (void)sqlite3_finalize(statement); } - (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + if (owns_transaction) { + (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + } return status; } diff --git a/tui/src/web_exercises.c b/tui/src/web_exercises.c new file mode 100644 index 0000000..ac9c32b --- /dev/null +++ b/tui/src/web_exercises.c @@ -0,0 +1,915 @@ +#include "trainlog/web_exercises.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#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; +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 01debd5..d9218fa 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -21,6 +21,7 @@ #include #include "trainlog/web_dashboard.h" +#include "trainlog/web_exercises.h" #include "trainlog/web_prepared_items.h" #include "trainlog/web_programs.h" #include "trainlog/web_session_deletions.h" @@ -39,6 +40,7 @@ #define TRAINLOG_HTTP_BACKLOG 32U #define TRAINLOG_HTTP_POLL_MAX_MS 250ULL #define TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY 256U +#define TRAINLOG_EXERCISE_REVISION_CAPACITY 72U typedef struct TrainlogWebContext { 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); } +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, TrainlogStatus status, char *json, @@ -1132,6 +1316,9 @@ static enum MHD_Result handle_request(void *closure, } 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) { return handle_program_request(context, connection, url, method, is_get, state); } diff --git a/tui/tests/test_web_exercises.c b/tui/tests/test_web_exercises.c new file mode 100644 index 0000000..7c5a18d --- /dev/null +++ b/tui/tests/test_web_exercises.c @@ -0,0 +1,224 @@ +#include +#include +#include +#include + +#include + +#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; +} diff --git a/web/src/api/exercises.test.ts b/web/src/api/exercises.test.ts new file mode 100644 index 0000000..0844882 --- /dev/null +++ b/web/src/api/exercises.test.ts @@ -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)['If-Match']) + .toBe('"exrev_abc"') + }) +}) diff --git a/web/src/api/exercises.ts b/web/src/api/exercises.ts new file mode 100644 index 0000000..5c3a3b6 --- /dev/null +++ b/web/src/api/exercises.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const csrf = await mutationCsrfToken() + const requestId = crypto.randomUUID() + const headers: Record = { + 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 { + return await mutation('/api/v1/exercises', 'POST', input) as ExerciseDetail +} + +export async function updateExercise(exerciseId: string, revision: string, + input: ExerciseInput): Promise { + return await mutation(`/api/v1/exercise/${encodeURIComponent(exerciseId)}`, + 'PUT', input, revision) as ExerciseDetail +} + +export async function retireExercise(exerciseId: string, revision: string): Promise { + await mutation(`/api/v1/exercise/${encodeURIComponent(exerciseId)}`, 'DELETE', undefined, revision) +} diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 26927cd..071cb9e 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -8,14 +8,14 @@ import { DashboardPage } from '../routes/DashboardPage' import { PlaceholderPage } from '../routes/PlaceholderPage' import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage' import { SessionsPage } from '../routes/SessionsPage' +import { ExercisesPage } from '../routes/ExercisesPage' import type { RouteId } from './routes' import { useRoute } from './useRoute' import { DatePreferencesProvider } from '../presentation/DatePreferences' -const placeholderContent: Record, [string, string, string]> = { +const placeholderContent: Record, [string, string, string]> = { 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é.'], - exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'], } function AppContent() { @@ -76,6 +76,7 @@ function AppContent() { /> ) : route.id === 'sessions' ? : route.id === 'programs' ? : + route.id === 'exercises' ? : diff --git a/web/src/app/routes.ts b/web/src/app/routes.ts index 890b39c..31f0af8 100644 --- a/web/src/app/routes.ts +++ b/web/src/app/routes.ts @@ -15,6 +15,11 @@ export const routes: readonly 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] } diff --git a/web/src/routes/ExercisesPage.test.tsx b/web/src/routes/ExercisesPage.test.tsx new file mode 100644 index 0000000..bd3c4c0 --- /dev/null +++ b/web/src/routes/ExercisesPage.test.tsx @@ -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() + 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() + 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() + }) +}) diff --git a/web/src/routes/ExercisesPage.tsx b/web/src/routes/ExercisesPage.tsx new file mode 100644 index 0000000..63bee5c --- /dev/null +++ b/web/src/routes/ExercisesPage.tsx @@ -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): 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 = { + 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 +}) { + 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
+ +
Mode de saisie + + +
+
Suivi + + +
+
Champs supplémentaires + + +
+ +
Zones secondaires +
{zones.filter((zone) => zone.zone_id !== input.primary_zone_id).map((zone) => + )}
+
+ {invalidProfile &&

Continu + répétitions est invalide.

} + {confirmProfile &&
+

Cette modification changera la saisie des futurs entraînements. Les séances déjà enregistrées resteront inchangées.

+ +
} + {error &&

{error}

} +
+ + +
+
+} + +function DetailView({ exerciseId, zones, onNavigate }: { + exerciseId: string; zones: BodyZoneChoice[]; onNavigate: (path: string) => void +}) { + const [detail, setDetail] = useState(null) + const [editing, setEditing] = useState(false) + const [retiring, setRetiring] = useState(false) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + const retireButton = useRef(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
{error}
+ if (detail === null) return

Chargement de l’exercice…

+ if (editing) return 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
+ +

FICHE EXERCICE

{detail.name}

+
+ {detail.retireable ? + : Identité Trainlog protégée}
+
+
Identité technique
{detail.exercise_id}
+
Profil
{profileLabel(detail)}
+
Champs supplémentaires
{fieldsLabel(detail.data_fields)}
+
Zone principale
{detail.primary_zone_id ? zoneNames.get(detail.primary_zone_id) ?? detail.primary_zone_id : 'Non renseignée'}
+
Zones secondaires
{detail.secondary_zone_ids.map((id) => zoneNames.get(id) ?? id).join(', ') || 'Aucune'}
+
Équipements observés
{detail.equipment.map((item) => item.display_name).join(', ') || 'Aucun équipement associé connu'}
+
+ {message &&

{message}

} + {retiring && setRetiring(false)} onConfirm={() => { void retireExercise(detail.exercise_id, detail.revision) + .then(() => onNavigate('/exercices')).catch((reason) => { setRetiring(false); setError(errorMessage(reason)) }) }} />} + {error &&

{error}

} +
+} + +export function ExercisesPage({ path, onNavigate }: Props) { + const [zones, setZones] = useState([]) + const [items, setItems] = useState([]) + const [search, setSearch] = useState('') + const [profile, setProfile] = useState('') + 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 + if (isNew) return

NOUVEL EXERCICE

Créer un exercice

+ onNavigate('/exercices')} onSubmit={async (input) => { + const created = await createExercise(input); onNavigate(`/exercices/${created.exercise_id}`) + }} />
+ + return
+

CATALOGUE D’EXERCICES

Exercices

+
+
+ + + +
+ {error &&

{error}

} + {message &&

{message}

} +
{items.map((exercise) =>
+ +
)}
+ {items.length === 0 && !error &&

Aucun exercice ne correspond aux filtres.

} +
+
+
+} diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 93ef515..cd1c020 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -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 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); } + +.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-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); }