feat(web): add exercise catalog administration
This commit is contained in:
parent
0a9639bb94
commit
88e0eb0fb7
23 changed files with 2204 additions and 67 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -4,6 +4,16 @@
|
||||||
|
|
||||||
Development opened after the stable v0.1.2 release.
|
Development opened after the stable v0.1.2 release.
|
||||||
|
|
||||||
|
- Added the loopback-only `/exercices` desktop catalogue administration
|
||||||
|
surface with bounded search/profile/BODY ZONES filtering, detail and
|
||||||
|
read-only equipment presentation, Core-owned creation and editing, complete
|
||||||
|
optimistic concurrency, and protected causal retirement. Profile changes
|
||||||
|
preserve stable exercise IDs and all historical occurrence snapshots.
|
||||||
|
Mutations remain desktop-local until the existing USB-priority, Drive
|
||||||
|
mirror/fallback synchronization publishes the existing catalogue companions;
|
||||||
|
no Android code, schema, exchange format, or automatic synchronization was
|
||||||
|
added.
|
||||||
|
|
||||||
## 0.1.2 — 2026-09-20
|
## 0.1.2 — 2026-09-20
|
||||||
|
|
||||||
- Added USB-priority full-generation synchronization with a separately
|
- Added USB-priority full-generation synchronization with a separately
|
||||||
|
|
|
||||||
14
README.md
14
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
|
business truth and canonical desktop persistence. Its native Android app is the
|
||||||
field companion, its C17/Notcurses desktop TUI is the administration and
|
field companion, its C17/Notcurses desktop TUI is the administration and
|
||||||
technical surface, and its local browser Web sibling provides the implemented
|
technical surface, and its local browser Web sibling provides the implemented
|
||||||
Dashboard, Sessions, and Programmes surfaces while reserving Analyse and
|
Dashboard, Sessions, Programmes, and Exercises surfaces while reserving Analyse
|
||||||
Exercises for separately contracted work.
|
for separately contracted work.
|
||||||
|
|
||||||
Android and desktop each own a local SQLite database. The desktop database is
|
Android and desktop each own a local SQLite database. The desktop database is
|
||||||
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
|
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
|
||||||
|
|
@ -24,7 +24,7 @@ over direct MTP; it never copies SQLite database files between devices.
|
||||||
|---|---|
|
|---|---|
|
||||||
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
|
||||||
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
||||||
| Local Web (0.1.3 development) | Display the Dashboard, the operational top-level Programmes daily active-program calendar, and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and Programs administration workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse and Exercises remain placeholders. |
|
| Local Web (0.1.3 development) | Display the Dashboard, the operational Programmes calendar, Sessions administration, and the desktop exercise catalogue through typed Trainlog Core/API boundaries. Exercises supports search/filter, detail, creation, editing and causal retirement; equipment is read-only. Analyse remains a placeholder. |
|
||||||
|
|
||||||
No interface reconstructs business truth from SQLite tables. The local Web is
|
No interface reconstructs business truth from SQLite tables. The local Web is
|
||||||
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
||||||
|
|
@ -34,8 +34,8 @@ make the remaining placeholder routes functional.
|
||||||
The top-level `/programmes` route is an operational daily calendar for active
|
The top-level `/programmes` route is an operational daily calendar for active
|
||||||
Programs. Sessions → Programmes remains the technical administration/import,
|
Programs. Sessions → Programmes remains the technical administration/import,
|
||||||
list, detail, archive, and delete surface.
|
list, detail, archive, and delete surface.
|
||||||
The ordered 0.1.3 development focus is an Android UX redesign followed by Web
|
Web Exercises administration is implemented for 0.1.3 development. The next
|
||||||
Exercises administration; both remain planned, separately contracted work.
|
separately contracted priority is the Android UX redesign.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
|
@ -349,8 +349,8 @@ As of 2026-09-18:
|
||||||
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
|
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
|
||||||
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
||||||
`/programmes` is an operational active-Program calendar, while
|
`/programmes` is an operational active-Program calendar, while
|
||||||
Sessions → Programmes remains the administration surface; Analyse and
|
Sessions → Programmes remains the administration surface;
|
||||||
Exercises remain placeholders;
|
`TRAINLOG_WEB_EXERCISES_V1=PASS`; Analyse remains a placeholder;
|
||||||
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
|
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
|
||||||
private desktop/Android deployment, real Firefox presentation, correlated
|
private desktop/Android deployment, real Firefox presentation, correlated
|
||||||
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
|
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ evidence and chronology.
|
||||||
|
|
||||||
## Interfaces
|
## Interfaces
|
||||||
|
|
||||||
|
- [Web Exercises V1](web_exercises.md) owns desktop catalogue administration,
|
||||||
|
optimistic editing, historical preservation, causal retirement, and its
|
||||||
|
existing-artifact synchronization boundary.
|
||||||
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
|
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
|
||||||
history and Android-delivery workflow.
|
history and Android-delivery workflow.
|
||||||
- [Program format V1](program_format_v1.md) owns the strict desktop planning
|
- [Program format V1](program_format_v1.md) owns the strict desktop planning
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,8 @@ parallel implementation of its rules.
|
||||||
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
|
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
|
||||||
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
|
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
|
||||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||||
|
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
||||||
| Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` |
|
| Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` |
|
||||||
| Next after Android redesign | `TRAINLOG_WEB_EXERCISES_V1` |
|
|
||||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||||
|
|
@ -250,8 +250,18 @@ resume and history views, stable details, optimistic manual-preparation writes,
|
||||||
explicit proposal derivation and generation-backed Android delivery. The
|
explicit proposal derivation and generation-backed Android delivery. The
|
||||||
top-level `/programmes` route is an operational daily active-Program calendar;
|
top-level `/programmes` route is an operational daily active-Program calendar;
|
||||||
Sessions → Programmes remains the technical administration/import, list,
|
Sessions → Programmes remains the technical administration/import, list,
|
||||||
detail, archive, and delete surface. Analyse and Exercises remain explicit
|
detail, archive, and delete surface. `/exercices` is the operational desktop
|
||||||
placeholders.
|
catalogue administration surface; Analyse remains an explicit placeholder.
|
||||||
|
|
||||||
|
Exercises uses bounded Core-owned list/detail reads, canonical name
|
||||||
|
normalization, BODY ZONES taxonomy, profiled creation/update commands and
|
||||||
|
causal retirement. Its deterministic optimistic token covers all editable
|
||||||
|
catalogue state. Built-in identities cannot be retired, equipment associations
|
||||||
|
are presented read-only, and profile edits affect future use only: occurrence
|
||||||
|
snapshots, performed sets, feedback and MAX history are never rewritten. A
|
||||||
|
successful mutation changes only the canonical desktop database; the existing
|
||||||
|
USB-priority, Drive mirror/fallback synchronization later carries the existing
|
||||||
|
catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync.
|
||||||
|
|
||||||
The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
||||||
`fetchProgram`, and `createPreparationFromProgram`; it derives no local program
|
`fetchProgram`, and `createPreparationFromProgram`; it derives no local program
|
||||||
|
|
@ -518,10 +528,9 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
|
||||||
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
|
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
|
||||||
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
||||||
|
|
||||||
- The next planned work is `TRAINLOG_ANDROID_UI_REDESIGN_V1`; it is not yet
|
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. The next planned work is
|
||||||
implemented. `TRAINLOG_WEB_EXERCISES_V1` follows it and will replace the
|
`TRAINLOG_ANDROID_UI_REDESIGN_V1`; it remains unimplemented. Analyse remains
|
||||||
current `/exercices` placeholder only after a separate Core/read/command
|
a placeholder and is not a v0.1.3 priority.
|
||||||
contract. Analyse remains a placeholder and is not a v0.1.3 priority.
|
|
||||||
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
|
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
|
||||||
observations are inputs to, not decisions for, the new 0.1.3 Android contract.
|
observations are inputs to, not decisions for, the new 0.1.3 Android contract.
|
||||||
- Session Generator V1 is hidden while V2 planning semantics are developed.
|
- Session Generator V1 is hidden while V2 planning semantics are developed.
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ tile rendering exist. The Dashboard's bounded progression and BODY ZONES
|
||||||
visualizations and Sessions V1 are implemented. The top-level `/programmes`
|
visualizations and Sessions V1 are implemented. The top-level `/programmes`
|
||||||
route is an operational daily active-Program calendar, while
|
route is an operational daily active-Program calendar, while
|
||||||
Sessions → Programmes remains the technical administration/import, list,
|
Sessions → Programmes remains the technical administration/import, list,
|
||||||
detail, archive, and delete surface. Analyse and Exercises remain placeholders.
|
detail, archive, and delete surface. Web Exercises V1 is implemented; Analyse
|
||||||
Exercises is now the second ordered v0.1.3 priority; Analyse remains outside
|
remains a placeholder outside this cycle unless a later explicit decision
|
||||||
this cycle unless a later explicit decision changes that order.
|
changes that order.
|
||||||
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
||||||
Sessions presentation correction, local date preference and durable preparation
|
Sessions presentation correction, local date preference and durable preparation
|
||||||
withdrawal are implemented. The withdrawal uses the separate versioned
|
withdrawal are implemented. The withdrawal uses the separate versioned
|
||||||
|
|
@ -60,7 +60,6 @@ causal-deletion contracts without reopening those completed gates.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT
|
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT
|
||||||
NEXT_AFTER_ANDROID_UI_REDESIGN=TRAINLOG_WEB_EXERCISES_V1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||||
|
|
@ -166,20 +165,21 @@ not authorize implementation and it did not select Analyse.
|
||||||
|
|
||||||
## Trainlog v0.1.3
|
## Trainlog v0.1.3
|
||||||
|
|
||||||
Trainlog v0.1.3 has two ordered priorities:
|
Trainlog v0.1.3 retains two priorities. Web Exercises was completed first; the
|
||||||
|
cursor now returns to the Android redesign:
|
||||||
|
|
||||||
1. the global Android experience redesign;
|
1. the complete Web Exercices surface;
|
||||||
2. the complete Web Exercices surface.
|
2. the global Android experience redesign.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_ANDROID_UI_REDESIGN_V1
|
TRAINLOG_WEB_EXERCISES_V1
|
||||||
-> TRAINLOG_WEB_EXERCISES_V1
|
-> TRAINLOG_ANDROID_UI_REDESIGN_V1
|
||||||
```
|
```
|
||||||
|
|
||||||
Each lot requires its own reviewed contract before implementation. This roadmap
|
The Web lot is implemented and validated. The Android lot still requires its
|
||||||
is documentary ordering, not authorization to implement both lots in one
|
own reviewed contract before implementation. Analyse and every other new Web
|
||||||
ticket. Analyse and every other new Web module remain after these priorities
|
module remain after this priority unless a later explicit decision changes the
|
||||||
unless a later explicit decision changes the order.
|
order.
|
||||||
|
|
||||||
### TRAINLOG_ANDROID_UI_REDESIGN_V1
|
### TRAINLOG_ANDROID_UI_REDESIGN_V1
|
||||||
|
|
||||||
|
|
@ -222,12 +222,12 @@ will be made screen by screen.
|
||||||
|
|
||||||
### TRAINLOG_WEB_EXERCISES_V1
|
### TRAINLOG_WEB_EXERCISES_V1
|
||||||
|
|
||||||
The top-level `/exercices` placeholder will become the desktop catalogue
|
`TRAINLOG_WEB_EXERCISES_V1=PASS`. The top-level `/exercices` route is the
|
||||||
administration surface. The first step is an audit of existing Core catalogue
|
desktop catalogue administration surface. It uses bounded Core read/command
|
||||||
services and persisted capabilities; implementation is not authorized until a
|
services and a deterministic optimistic token; it does not put business rules
|
||||||
separate read/command contract has been reviewed.
|
in React.
|
||||||
|
|
||||||
The future lot includes:
|
The delivered lot includes:
|
||||||
|
|
||||||
- **Catalogue:** a complete or paged list according to the Core contract,
|
- **Catalogue:** a complete or paged list according to the Core contract,
|
||||||
search, relevant filters, available/retired state when the model supports it,
|
search, relevant filters, available/retired state when the model supports it,
|
||||||
|
|
@ -239,7 +239,8 @@ The future lot includes:
|
||||||
business truth.
|
business truth.
|
||||||
- **Creation:** create through a real transactional Core command service, with
|
- **Creation:** create through a real transactional Core command service, with
|
||||||
name, profile, tracking, `data_fields`, primary and secondary zones, and
|
name, profile, tracking, `data_fields`, primary and secondary zones, and
|
||||||
equipment/associations only where the Core contract permits them.
|
equipment associations remain read-only because no bounded association
|
||||||
|
command is part of this contract.
|
||||||
- **Modification:** expose only Core-authorized changes. The contract must
|
- **Modification:** expose only Core-authorized changes. The contract must
|
||||||
distinguish mutable and immutable fields, compatible changes, changes that
|
distinguish mutable and immutable fields, compatible changes, changes that
|
||||||
require a new identity, and exercises already referenced by history.
|
require a new identity, and exercises already referenced by history.
|
||||||
|
|
@ -260,36 +261,37 @@ with Drive as mirror/fallback. React must not introduce an Exercises-specific
|
||||||
synchronization path. The future contract must first audit the existing
|
synchronization path. The future contract must first audit the existing
|
||||||
catalogue, profile, BODY ZONES, and equipment artifacts to determine whether
|
catalogue, profile, BODY ZONES, and equipment artifacts to determine whether
|
||||||
the planned changes are representable. Frozen formats remain frozen; if a new
|
the planned changes are representable. Frozen formats remain frozen; if a new
|
||||||
datum cannot be transported, the contract must state that explicitly before
|
datum cannot be transported, a future contract must state that explicitly
|
||||||
any protocol change.
|
before any protocol change. No automatic synchronization is initiated by this
|
||||||
|
surface.
|
||||||
|
|
||||||
### Bounded v0.1.3 order
|
### Bounded v0.1.3 order
|
||||||
|
|
||||||
```text
|
```text
|
||||||
0.1.3
|
0.1.3
|
||||||
|
|
|
|
||||||
+-- ANDROID_UI_REDESIGN_V1
|
|
||||||
| |
|
|
||||||
| +-- current-state audit
|
|
||||||
| +-- UX/navigation contract
|
|
||||||
| +-- shell / buttons / components
|
|
||||||
| +-- active-session workflow
|
|
||||||
| +-- exercise-entry workflow
|
|
||||||
| +-- real visual review
|
|
||||||
| +-- accessibility validation
|
|
||||||
|
|
|
||||||
+-- WEB_EXERCISES_V1
|
+-- WEB_EXERCISES_V1
|
||||||
|
| |
|
||||||
|
| +-- Core/catalogue characterization
|
||||||
|
| +-- read-model contract
|
||||||
|
| +-- command-service contract
|
||||||
|
| +-- Web catalogue
|
||||||
|
| +-- exercise detail
|
||||||
|
| +-- creation
|
||||||
|
| +-- modification
|
||||||
|
| +-- causal retirement
|
||||||
|
| +-- Android synchronization
|
||||||
|
| +-- real validation
|
||||||
|
|
|
||||||
|
+-- ANDROID_UI_REDESIGN_V1
|
||||||
|
|
|
|
||||||
+-- Core/catalogue characterization
|
+-- current-state audit
|
||||||
+-- read-model contract
|
+-- UX/navigation contract
|
||||||
+-- command-service contract
|
+-- shell / buttons / components
|
||||||
+-- Web catalogue
|
+-- active-session workflow
|
||||||
+-- exercise detail
|
+-- exercise-entry workflow
|
||||||
+-- creation
|
+-- real visual review
|
||||||
+-- modification
|
+-- accessibility validation
|
||||||
+-- causal retirement
|
|
||||||
+-- Android synchronization
|
|
||||||
+-- real validation
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
@ -352,8 +354,8 @@ normal capture.
|
||||||
## Canonical order
|
## Canonical order
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_ANDROID_UI_REDESIGN_V1
|
TRAINLOG_WEB_EXERCISES_V1
|
||||||
-> TRAINLOG_WEB_EXERCISES_V1
|
-> TRAINLOG_ANDROID_UI_REDESIGN_V1
|
||||||
-> GYM_CATALOG_V1
|
-> GYM_CATALOG_V1
|
||||||
-> EXERCISE_METADATA_V1
|
-> EXERCISE_METADATA_V1
|
||||||
-> SESSION_GENERATOR_V2
|
-> SESSION_GENERATOR_V2
|
||||||
|
|
|
||||||
55
docs/web_exercises.md
Normal file
55
docs/web_exercises.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Web Exercises V1
|
||||||
|
|
||||||
|
`TRAINLOG_WEB_EXERCISES_V1=PASS` for Trainlog 0.1.3 development. The
|
||||||
|
loopback-only `/exercices` route is the desktop exercise-catalogue
|
||||||
|
administration surface. Android remains the field capture client and receives
|
||||||
|
catalogue changes only through a later normal synchronization.
|
||||||
|
|
||||||
|
## Ownership and history
|
||||||
|
|
||||||
|
Trainlog Core and the canonical desktop SQLite database own catalogue truth.
|
||||||
|
React never generates an `exercise_id`, normalizes a name as business truth, or
|
||||||
|
writes SQL. Creation generates the existing `ex_<uuid-v4>` identity in Core;
|
||||||
|
updates preserve that identity.
|
||||||
|
|
||||||
|
Changing name, profile, fields, or BODY ZONES affects future uses only.
|
||||||
|
Occurrence-owned recording/tracking/data-field snapshots, actual sets or
|
||||||
|
continuous activity, targets, equipment, feedback and MAX results in past
|
||||||
|
sessions are immutable under catalogue editing. No compatibility operation
|
||||||
|
silently rewrites history or existing drafts.
|
||||||
|
|
||||||
|
## Read and command surface
|
||||||
|
|
||||||
|
The versioned local API provides bounded catalogue pages, detail, assignable
|
||||||
|
BODY ZONES, creation, update, and retirement. Search uses Core canonical name
|
||||||
|
normalization. Profile and BODY ZONES validation remains authoritative in
|
||||||
|
Core: `CONTINUOUS + REPS`, unknown data-field bits, group zones, duplicate
|
||||||
|
roles, secondary-only assignment, and interactive SETS creation without a
|
||||||
|
primary zone are rejected.
|
||||||
|
|
||||||
|
Detail exposes the persisted profile, stable ID, zones, a deterministic
|
||||||
|
revision token, whether causal retirement is allowed, and resolved equipment
|
||||||
|
associations. Equipment is read-only in V1.
|
||||||
|
|
||||||
|
All mutations retain the Web loopback, trusted-Origin, CSRF, exact JSON content
|
||||||
|
type, bounded body, request-ID and strict-JSON protections. PUT and DELETE
|
||||||
|
require the current quoted revision. The token covers name, profile,
|
||||||
|
`data_fields`, zones and causal state under one immediate transaction; stale
|
||||||
|
writes fail rather than overwrite concurrent changes.
|
||||||
|
|
||||||
|
"Delete" is causal retirement, never physical exercise deletion. Built-in
|
||||||
|
Trainlog identities are protected. A retired custom exercise is excluded from
|
||||||
|
new-work catalogue reads and the next PC catalogue export, while its source row
|
||||||
|
and all historical references remain. Existing Programs, preparations,
|
||||||
|
proposals and drafts are not silently changed; their occurrence/planning
|
||||||
|
snapshots remain readable, and new-work selectors apply the established active
|
||||||
|
catalogue rule.
|
||||||
|
|
||||||
|
## Synchronization boundary
|
||||||
|
|
||||||
|
Create, update and retirement first commit only to the desktop database. They
|
||||||
|
do not call synchronization and do not require a phone. The next user-triggered
|
||||||
|
normal synchronization reuses the existing PC catalogue, exercise profile
|
||||||
|
state, BODY ZONES, equipment companions and causal deletion artifacts through
|
||||||
|
the existing USB-priority engine with Drive as mirror/fallback. No Web
|
||||||
|
Exercises artifact or protocol version was introduced.
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
@ -106,6 +107,22 @@ class CausalDeleteTest(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(causal.CausalError, "built-in"):
|
with self.assertRaisesRegex(causal.CausalError, "built-in"):
|
||||||
causal.local_delete(self.db, "exercise", built_in, "peer")
|
causal.local_delete(self.db, "exercise", built_in, "peer")
|
||||||
|
|
||||||
|
def test_retired_exercise_is_omitted_from_next_pc_catalog(self):
|
||||||
|
self.delete("exercise", "ex_user")
|
||||||
|
output = Path(self.tmp.name) / "pc-catalog.json"
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(ROOT / "tools/export_pc_catalog.py"),
|
||||||
|
"--database", str(self.path), str(output)],
|
||||||
|
check=False, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
catalog = json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual([], catalog["exercises"])
|
||||||
|
self.assertEqual(1, self.db.execute(
|
||||||
|
"SELECT count(*) FROM exercises WHERE exercise_id='ex_user'").fetchone()[0])
|
||||||
|
self.assertEqual(1, self.db.execute(
|
||||||
|
"SELECT count(*) FROM session_exercises WHERE exercise_row_id=1").fetchone()[0])
|
||||||
|
|
||||||
def hostile_operation(self, kind, target):
|
def hostile_operation(self, kind, target):
|
||||||
operation = {"operation_id": "del_" + "a" * 36, "target_kind": kind,
|
operation = {"operation_id": "del_" + "a" * 36, "target_kind": kind,
|
||||||
"target_id": target, "creator_id": "hostile_fixture",
|
"target_id": target, "creator_id": "hostile_fixture",
|
||||||
|
|
|
||||||
96
tests/test_web_exercises_browser.py
Normal file
96
tests/test_web_exercises_browser.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Disposable real-browser smoke test for Web Exercises V1."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BUILD = ROOT / "build" / "tui"
|
||||||
|
GECKO = Path(os.environ.get(
|
||||||
|
"TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"))
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserExercisesTest(unittest.TestCase):
|
||||||
|
def test_create_edit_retire_at_desktop_and_mobile_widths(self):
|
||||||
|
if not GECKO.is_file():
|
||||||
|
self.skipTest("pinned geckodriver unavailable")
|
||||||
|
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
from selenium.webdriver.firefox.service import Service
|
||||||
|
from selenium.common.exceptions import StaleElementReferenceException
|
||||||
|
from selenium.webdriver.support.ui import Select, WebDriverWait
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="trainlog-web-exercises-") as directory:
|
||||||
|
temporary = Path(directory)
|
||||||
|
data = temporary / "data"
|
||||||
|
config = temporary / "config"
|
||||||
|
data.mkdir()
|
||||||
|
config.mkdir()
|
||||||
|
with socket.socket() as reserved:
|
||||||
|
reserved.bind(("127.0.0.1", 0))
|
||||||
|
port = reserved.getsockname()[1]
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment.update({"XDG_DATA_HOME": str(data), "XDG_CONFIG_HOME": str(config),
|
||||||
|
"XDG_CACHE_HOME": str(temporary / "cache")})
|
||||||
|
server = subprocess.Popen(
|
||||||
|
[str(BUILD / "trainlog"), "--web", "--port", str(port)],
|
||||||
|
env=environment, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
driver = None
|
||||||
|
try:
|
||||||
|
for _ in range(200):
|
||||||
|
with socket.socket() as probe:
|
||||||
|
if probe.connect_ex(("127.0.0.1", port)) == 0:
|
||||||
|
break
|
||||||
|
time.sleep(0.025)
|
||||||
|
options = webdriver.FirefoxOptions()
|
||||||
|
options.add_argument("-headless")
|
||||||
|
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
|
||||||
|
wait = WebDriverWait(driver, 15, ignored_exceptions=(StaleElementReferenceException,))
|
||||||
|
driver.set_window_size(1440, 1000)
|
||||||
|
driver.get(f"http://127.0.0.1:{port}/exercices")
|
||||||
|
wait.until(lambda page: page.find_element(By.TAG_NAME, "h1").text == "Exercices")
|
||||||
|
self.assertEqual(0, driver.execute_script(
|
||||||
|
"return document.documentElement.scrollWidth-document.documentElement.clientWidth"))
|
||||||
|
|
||||||
|
driver.find_element(By.XPATH, "//button[contains(., 'Nouvel exercice')]").click()
|
||||||
|
wait.until(lambda page: page.current_url.endswith("/exercices/nouveau"))
|
||||||
|
name = wait.until(lambda page: page.find_element(By.CSS_SELECTOR, ".exercise-form input"))
|
||||||
|
name.send_keys("Exercice visuel jetable")
|
||||||
|
Select(driver.find_element(By.CSS_SELECTOR, ".exercise-form select")).select_by_index(1)
|
||||||
|
name.send_keys(Keys.ENTER)
|
||||||
|
wait.until(lambda page: page.find_element(By.TAG_NAME, "h1").text == "Exercice visuel jetable")
|
||||||
|
|
||||||
|
driver.find_element(By.XPATH, "//button[normalize-space(.)='Modifier']").click()
|
||||||
|
name = wait.until(lambda page: page.find_element(By.CSS_SELECTOR, ".exercise-form input"))
|
||||||
|
name.clear()
|
||||||
|
name.send_keys("Exercice visuel renommé")
|
||||||
|
name.send_keys(Keys.ENTER)
|
||||||
|
wait.until(lambda page: page.find_element(By.TAG_NAME, "h1").text == "Exercice visuel renommé")
|
||||||
|
|
||||||
|
driver.set_window_size(390, 844)
|
||||||
|
self.assertEqual(0, driver.execute_script(
|
||||||
|
"return document.documentElement.scrollWidth-document.documentElement.clientWidth"))
|
||||||
|
driver.find_element(By.XPATH, "//button[contains(., 'Retirer du catalogue')]").click()
|
||||||
|
wait.until(lambda page: page.find_element(By.CSS_SELECTOR, "[role='alertdialog']"))
|
||||||
|
driver.find_element(By.XPATH, "//*[@role='alertdialog']//button[normalize-space(.)='Retirer']").click()
|
||||||
|
wait.until(lambda page: page.find_element(By.TAG_NAME, "h1").text == "Exercices")
|
||||||
|
self.assertNotIn("Exercice visuel renommé", driver.page_source)
|
||||||
|
finally:
|
||||||
|
if driver is not None:
|
||||||
|
driver.quit()
|
||||||
|
os.killpg(server.pid, signal.SIGTERM)
|
||||||
|
server.wait(timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -77,6 +77,15 @@ def main():
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
active_filter = "" if version < 20 else '''
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sync_causal_state AS causal
|
||||||
|
WHERE causal.target_kind = 'exercise'
|
||||||
|
AND causal.target_id = exercises.exercise_id
|
||||||
|
AND causal.deleted = 1
|
||||||
|
)
|
||||||
|
'''
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
'''
|
'''
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -86,6 +95,7 @@ def main():
|
||||||
tracking_mode,
|
tracking_mode,
|
||||||
data_fields
|
data_fields
|
||||||
FROM exercises
|
FROM exercises
|
||||||
|
''' + active_filter + '''
|
||||||
ORDER BY
|
ORDER BY
|
||||||
name COLLATE NOCASE,
|
name COLLATE NOCASE,
|
||||||
exercise_id;
|
exercise_id;
|
||||||
|
|
|
||||||
|
|
@ -250,11 +250,13 @@ TrainlogStatus trainlog_database_list_exercises_filtered(TrainlogDatabase *datab
|
||||||
size_t *output_count);
|
size_t *output_count);
|
||||||
|
|
||||||
/* CONTRACT: name/profile/direct zones are one transaction and all string/list
|
/* CONTRACT: name/profile/direct zones are one transaction and all string/list
|
||||||
* inputs are borrowed only for the duration of the call. A profile change is
|
* inputs are borrowed only for the duration of the call. Profile changes affect
|
||||||
* rejected once completed history references the exercise; a same-profile
|
* future occurrences only because schema v16 snapshots the complete profile on
|
||||||
* rename or zone replacement preserves exercise_id and history. Zone rules
|
* every occurrence. Existing sessions and drafts are never rewritten. The
|
||||||
* match replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND;
|
* function owns a transaction when called in autocommit mode and participates
|
||||||
* invalid metadata and name collisions remain explicit errors. */
|
* in an already-open caller transaction otherwise. Zone rules match
|
||||||
|
* replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND; invalid
|
||||||
|
* metadata and name collisions remain explicit errors. */
|
||||||
TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *database,
|
TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *database,
|
||||||
const char *exercise_id,
|
const char *exercise_id,
|
||||||
const char *name,
|
const char *name,
|
||||||
|
|
|
||||||
61
tui/include/trainlog/web_exercises.h
Normal file
61
tui/include/trainlog/web_exercises.h
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/* Trainlog Web Exercises V1 application service. */
|
||||||
|
#ifndef TRAINLOG_WEB_EXERCISES_H
|
||||||
|
#define TRAINLOG_WEB_EXERCISES_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
|
#define TRAINLOG_WEB_EXERCISES_PAGE_MAX 64U
|
||||||
|
#define TRAINLOG_WEB_EXERCISES_BODY_MAX (64U * 1024U)
|
||||||
|
|
||||||
|
typedef struct TrainlogWebExercisesQuery {
|
||||||
|
size_t offset;
|
||||||
|
size_t limit;
|
||||||
|
const char *search;
|
||||||
|
const char *profile;
|
||||||
|
const char *zone_id;
|
||||||
|
bool unclassified;
|
||||||
|
} TrainlogWebExercisesQuery;
|
||||||
|
|
||||||
|
/* CONTRACT: successful JSON functions transfer one heap allocation to the
|
||||||
|
* caller, which releases it with free(). List and detail expose only current
|
||||||
|
* Core state; no SQLite handle, row ID, or borrowed database text escapes. */
|
||||||
|
TrainlogStatus trainlog_web_exercises_list_json(TrainlogDatabase *database,
|
||||||
|
const TrainlogWebExercisesQuery *query,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size);
|
||||||
|
TrainlogStatus trainlog_web_exercises_detail_json(TrainlogDatabase *database,
|
||||||
|
const char *exercise_id,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size);
|
||||||
|
TrainlogStatus trainlog_web_exercises_zones_json(char **output_json, size_t *output_size);
|
||||||
|
|
||||||
|
/* CONTRACT: body is strict JSON containing the complete editable definition.
|
||||||
|
* Creation delegates UUID/name/profile/zone authority to catalog/database Core.
|
||||||
|
* Update compares expected_revision under BEGIN IMMEDIATE and preserves the
|
||||||
|
* stable exercise_id. Neither operation starts synchronization. */
|
||||||
|
TrainlogStatus trainlog_web_exercises_create_json(TrainlogDatabase *database,
|
||||||
|
const char *body,
|
||||||
|
size_t body_size,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size);
|
||||||
|
TrainlogStatus trainlog_web_exercises_update_json(TrainlogDatabase *database,
|
||||||
|
const char *exercise_id,
|
||||||
|
const char *expected_revision,
|
||||||
|
const char *body,
|
||||||
|
size_t body_size,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size);
|
||||||
|
|
||||||
|
/* CONTRACT: retirement is one durable causal operation. Built-in identities
|
||||||
|
* are forbidden, history remains resolvable, and exact repeat requests are
|
||||||
|
* idempotent through existing causal state. */
|
||||||
|
TrainlogStatus trainlog_web_exercises_retire_json(TrainlogDatabase *database,
|
||||||
|
const char *exercise_id,
|
||||||
|
const char *expected_revision,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -76,9 +76,13 @@ if web_assets_enabled
|
||||||
'../web/src/routes/PlaceholderPage.tsx',
|
'../web/src/routes/PlaceholderPage.tsx',
|
||||||
'../web/src/routes/SessionsPage.tsx',
|
'../web/src/routes/SessionsPage.tsx',
|
||||||
'../web/src/api/sessions.ts',
|
'../web/src/api/sessions.ts',
|
||||||
|
'../web/src/api/exercises.ts',
|
||||||
|
'../web/src/api/exercises.test.ts',
|
||||||
'../web/src/api/programs.ts',
|
'../web/src/api/programs.ts',
|
||||||
'../web/src/routes/ProgramsTab.tsx',
|
'../web/src/routes/ProgramsTab.tsx',
|
||||||
'../web/src/routes/ProgramsCalendarPage.tsx',
|
'../web/src/routes/ProgramsCalendarPage.tsx',
|
||||||
|
'../web/src/routes/ExercisesPage.tsx',
|
||||||
|
'../web/src/routes/ExercisesPage.test.tsx',
|
||||||
'../web/src/theme/app.css',
|
'../web/src/theme/app.css',
|
||||||
'../web/src/theme/tokens.css',
|
'../web/src/theme/tokens.css',
|
||||||
)
|
)
|
||||||
|
|
@ -209,6 +213,7 @@ trainlog_core_sources = files(
|
||||||
'src/web_sessions.c',
|
'src/web_sessions.c',
|
||||||
'src/web_session_deletions.c',
|
'src/web_session_deletions.c',
|
||||||
'src/web_programs.c',
|
'src/web_programs.c',
|
||||||
|
'src/web_exercises.c',
|
||||||
'src/duration.c',
|
'src/duration.c',
|
||||||
'src/id.c',
|
'src/id.c',
|
||||||
'src/timeutil.c',
|
'src/timeutil.c',
|
||||||
|
|
@ -382,6 +387,15 @@ test_web_programs = executable(
|
||||||
)
|
)
|
||||||
test('web_programs', test_web_programs)
|
test('web_programs', test_web_programs)
|
||||||
|
|
||||||
|
test_web_exercises = executable(
|
||||||
|
'test_web_exercises',
|
||||||
|
'tests/test_web_exercises.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
include_directories: trainlog_private_include,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
test('web_exercises', test_web_exercises)
|
||||||
|
|
||||||
test_web_session_deletions = executable(
|
test_web_session_deletions = executable(
|
||||||
'test_web_session_deletions',
|
'test_web_session_deletions',
|
||||||
'tests/test_web_session_deletions.c',
|
'tests/test_web_session_deletions.c',
|
||||||
|
|
|
||||||
|
|
@ -2817,6 +2817,7 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
sqlite3_int64 row_id;
|
sqlite3_int64 row_id;
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
|
bool owns_transaction;
|
||||||
int rc;
|
int rc;
|
||||||
const char *tracking;
|
const char *tracking;
|
||||||
const char *recording;
|
const char *recording;
|
||||||
|
|
@ -2832,7 +2833,8 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) {
|
owns_transaction = sqlite3_get_autocommit(database->connection) != 0;
|
||||||
|
if (owns_transaction && execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) {
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2872,6 +2874,9 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
goto rollback;
|
goto rollback;
|
||||||
}
|
}
|
||||||
|
if (!owns_transaction) {
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) {
|
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) {
|
||||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
|
@ -2882,7 +2887,9 @@ rollback:
|
||||||
if (statement != NULL) {
|
if (statement != NULL) {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
}
|
}
|
||||||
|
if (owns_transaction) {
|
||||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||||
|
}
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
915
tui/src/web_exercises.c
Normal file
915
tui/src/web_exercises.c
Normal file
|
|
@ -0,0 +1,915 @@
|
||||||
|
#include "trainlog/web_exercises.h"
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
#include <sqlite3.h>
|
||||||
|
#include <yyjson.h>
|
||||||
|
|
||||||
|
#include "database_internal.h"
|
||||||
|
#include "trainlog/body_zone_catalog.h"
|
||||||
|
#include "trainlog/catalog.h"
|
||||||
|
#include "trainlog/exercise_name_catalog.h"
|
||||||
|
#include "trainlog/id.h"
|
||||||
|
|
||||||
|
#define EXERCISE_REVISION_CAPACITY 72U
|
||||||
|
#define CAUSAL_OPERATION_MAX 4096
|
||||||
|
#define CAUSAL_TEXT_BUDGET (3 * 1024 * 1024)
|
||||||
|
|
||||||
|
typedef struct ExerciseInput {
|
||||||
|
char name[TRAINLOG_NAME_MAX + 1U];
|
||||||
|
TrainlogRecordingMode recording_mode;
|
||||||
|
TrainlogTrackingMode tracking_mode;
|
||||||
|
TrainlogExerciseDataFields data_fields;
|
||||||
|
char primary_zone_id[TRAINLOG_ZONE_ID_MAX + 1U];
|
||||||
|
char secondary_zone_ids[16][TRAINLOG_ZONE_ID_MAX + 1U];
|
||||||
|
const char *secondary_zone_pointers[16];
|
||||||
|
size_t secondary_count;
|
||||||
|
} ExerciseInput;
|
||||||
|
|
||||||
|
static const char *recording_text(TrainlogRecordingMode mode) {
|
||||||
|
return mode == TRAINLOG_RECORDING_CONTINUOUS ? "continuous" : "sets";
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *tracking_text(TrainlogTrackingMode mode) {
|
||||||
|
return mode == TRAINLOG_TRACKING_DURATION ? "duration" : "reps";
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool sha256_hex(const char *input, size_t input_size, char output[65]) {
|
||||||
|
unsigned char digest[32];
|
||||||
|
unsigned int digest_size = 0U;
|
||||||
|
EVP_MD_CTX *context = EVP_MD_CTX_new();
|
||||||
|
size_t index;
|
||||||
|
bool success;
|
||||||
|
|
||||||
|
if (context == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
success = EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
|
||||||
|
EVP_DigestUpdate(context, input, input_size) == 1 &&
|
||||||
|
EVP_DigestFinal_ex(context, digest, &digest_size) == 1 && digest_size == 32U;
|
||||||
|
EVP_MD_CTX_free(context);
|
||||||
|
if (!success) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < 32U; ++index) {
|
||||||
|
(void)snprintf(output + (index * 2U), 3U, "%02x", digest[index]);
|
||||||
|
}
|
||||||
|
output[64] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
|
||||||
|
time_t now = time(NULL);
|
||||||
|
struct tm utc;
|
||||||
|
|
||||||
|
return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
|
||||||
|
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool json_finish(yyjson_mut_doc **document, char **output_json, size_t *output_size) {
|
||||||
|
*output_json = yyjson_mut_write(*document, 0U, output_size);
|
||||||
|
yyjson_mut_doc_free(*document);
|
||||||
|
*document = NULL;
|
||||||
|
return *output_json != NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool exercise_is_builtin(const char *exercise_id) {
|
||||||
|
return trainlog_exercise_name_catalog_lookup(exercise_id) != NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WHY: profile_state alone omits mutable name and BODY ZONES. This digest is
|
||||||
|
* the optimistic token for exactly the complete Web-editable state. CONTRACT:
|
||||||
|
* byte ordering is fixed and zones are queried in role/ID order. INVARIANT:
|
||||||
|
* no timestamp or SQLite row ID participates. */
|
||||||
|
static TrainlogStatus exercise_revision(TrainlogDatabase *database,
|
||||||
|
const char *exercise_id,
|
||||||
|
char output[EXERCISE_REVISION_CAPACITY]) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
EVP_MD_CTX *digest = NULL;
|
||||||
|
unsigned char raw[32];
|
||||||
|
unsigned int raw_size = 0U;
|
||||||
|
int result;
|
||||||
|
int column;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
result = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
"SELECT e.exercise_id,e.name,e.recording_mode,e.tracking_mode,e.data_fields,"
|
||||||
|
"COALESCE((SELECT group_concat(zone_id||':'||role,'|') FROM (SELECT zone_id,role "
|
||||||
|
"FROM exercise_body_zones WHERE exercise_row_id=e.id ORDER BY role,zone_id)),''),"
|
||||||
|
"COALESCE((SELECT deleted FROM sync_causal_state WHERE target_kind='exercise' AND "
|
||||||
|
"target_id=e.exercise_id),0) FROM exercises e WHERE e.exercise_id=?1",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (result != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
digest = EVP_MD_CTX_new();
|
||||||
|
if (digest == NULL || EVP_DigestInit_ex(digest, EVP_sha256(), NULL) != 1) {
|
||||||
|
EVP_MD_CTX_free(digest);
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
for (column = 0; column < 7; ++column) {
|
||||||
|
const void *value = sqlite3_column_text(statement, column);
|
||||||
|
int bytes = sqlite3_column_bytes(statement, column);
|
||||||
|
static const char separator = '\0';
|
||||||
|
if (value == NULL || bytes < 0 || EVP_DigestUpdate(digest, value, (size_t)bytes) != 1 ||
|
||||||
|
EVP_DigestUpdate(digest, &separator, 1U) != 1) {
|
||||||
|
EVP_MD_CTX_free(digest);
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK ||
|
||||||
|
EVP_DigestFinal_ex(digest, raw, &raw_size) != 1 || raw_size != 32U) {
|
||||||
|
EVP_MD_CTX_free(digest);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
EVP_MD_CTX_free(digest);
|
||||||
|
(void)memcpy(output, "exrev_", 6U);
|
||||||
|
for (index = 0U; index < 32U; ++index) {
|
||||||
|
(void)snprintf(output + 6U + (index * 2U), 3U, "%02x", raw[index]);
|
||||||
|
}
|
||||||
|
output[70] = '\0';
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus append_zones(TrainlogDatabase *database,
|
||||||
|
const char *exercise_id,
|
||||||
|
yyjson_mut_doc *document,
|
||||||
|
yyjson_mut_val *object) {
|
||||||
|
TrainlogExerciseBodyZone relations[16];
|
||||||
|
size_t count = 0U;
|
||||||
|
size_t index;
|
||||||
|
yyjson_mut_val *secondary = yyjson_mut_arr(document);
|
||||||
|
const char *primary = NULL;
|
||||||
|
TrainlogStatus status =
|
||||||
|
trainlog_database_list_exercise_body_zones(database, exercise_id, relations, 16U, &count);
|
||||||
|
|
||||||
|
if (status != TRAINLOG_STATUS_OK || count > 16U || secondary == NULL) {
|
||||||
|
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < count; ++index) {
|
||||||
|
if (relations[index].role == TRAINLOG_BODY_ZONE_PRIMARY) {
|
||||||
|
primary = relations[index].zone_id;
|
||||||
|
} else if (!yyjson_mut_arr_add_strcpy(document, secondary, relations[index].zone_id)) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (primary == NULL) {
|
||||||
|
if (!yyjson_mut_obj_add_null(document, object, "primary_zone_id")) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
} else if (!yyjson_mut_obj_add_strcpy(document, object, "primary_zone_id", primary)) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
return yyjson_mut_obj_add_val(document, object, "secondary_zone_ids", secondary)
|
||||||
|
? TRAINLOG_STATUS_OK
|
||||||
|
: TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus append_equipment(TrainlogDatabase *database,
|
||||||
|
const char *exercise_id,
|
||||||
|
yyjson_mut_doc *document,
|
||||||
|
yyjson_mut_val *object) {
|
||||||
|
TrainlogResolvedEquipment equipment[64];
|
||||||
|
size_t count = 0U;
|
||||||
|
size_t index;
|
||||||
|
yyjson_mut_val *items = yyjson_mut_arr(document);
|
||||||
|
TrainlogStatus status =
|
||||||
|
trainlog_database_list_exercise_equipment(database, exercise_id, equipment, 64U, &count);
|
||||||
|
|
||||||
|
if (status != TRAINLOG_STATUS_OK || count > 64U || items == NULL) {
|
||||||
|
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < count; ++index) {
|
||||||
|
yyjson_mut_val *item = yyjson_mut_obj(document);
|
||||||
|
if (item == NULL ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(
|
||||||
|
document, item, "equipment_id", equipment[index].equipment_id) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(
|
||||||
|
document, item, "display_name", equipment[index].display_name) ||
|
||||||
|
!yyjson_mut_arr_add_val(items, item)) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return yyjson_mut_obj_add_val(document, object, "equipment", items)
|
||||||
|
? TRAINLOG_STATUS_OK
|
||||||
|
: TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus append_exercise(TrainlogDatabase *database,
|
||||||
|
const TrainlogExercise *exercise,
|
||||||
|
bool detail,
|
||||||
|
yyjson_mut_doc *document,
|
||||||
|
yyjson_mut_val *object) {
|
||||||
|
char revision[EXERCISE_REVISION_CAPACITY];
|
||||||
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
if (!yyjson_mut_obj_add_strcpy(document, object, "exercise_id", exercise->exercise_id) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(document, object, "name", exercise->name) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(
|
||||||
|
document, object, "recording_mode", recording_text(exercise->recording_mode)) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(
|
||||||
|
document, object, "tracking_mode", tracking_text(exercise->tracking_mode)) ||
|
||||||
|
!yyjson_mut_obj_add_uint(document, object, "data_fields", exercise->data_fields) ||
|
||||||
|
!yyjson_mut_obj_add_bool(
|
||||||
|
document, object, "retireable", !exercise_is_builtin(exercise->exercise_id))) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
status = append_zones(database, exercise->exercise_id, document, object);
|
||||||
|
if (status != TRAINLOG_STATUS_OK || !detail) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
status = exercise_revision(database, exercise->exercise_id, revision);
|
||||||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(document, object, "revision", revision)) {
|
||||||
|
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_SYSTEM_ERROR : status;
|
||||||
|
}
|
||||||
|
return append_equipment(database, exercise->exercise_id, document, object);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_exercises_list_json(TrainlogDatabase *database,
|
||||||
|
const TrainlogWebExercisesQuery *query,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size) {
|
||||||
|
static const char SQL[] =
|
||||||
|
"SELECT e.exercise_id,e.name,e.tracking_mode,e.recording_mode,e.data_fields FROM "
|
||||||
|
"exercises e WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
|
||||||
|
"c.target_kind='exercise' AND c.target_id=e.exercise_id AND c.deleted=1) AND "
|
||||||
|
"(?1='' OR e.normalized_name LIKE ?1||'%') AND (?2='' OR "
|
||||||
|
"(e.recording_mode||'_'||e.tracking_mode)=?2) AND "
|
||||||
|
"(?3='' OR EXISTS(SELECT 1 FROM exercise_body_zones z WHERE z.exercise_row_id=e.id AND "
|
||||||
|
"z.zone_id=?3)) AND (?4=0 OR NOT EXISTS(SELECT 1 FROM exercise_body_zones z WHERE "
|
||||||
|
"z.exercise_row_id=e.id)) ORDER BY e.name COLLATE NOCASE,e.exercise_id LIMIT ?5 OFFSET ?6";
|
||||||
|
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U] = "";
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
yyjson_mut_doc *document = NULL;
|
||||||
|
yyjson_mut_val *root;
|
||||||
|
yyjson_mut_val *items;
|
||||||
|
size_t count = 0U;
|
||||||
|
int result;
|
||||||
|
TrainlogStatus status = TRAINLOG_STATUS_OK;
|
||||||
|
|
||||||
|
if (database == NULL || query == NULL || output_json == NULL || output_size == NULL ||
|
||||||
|
query->limit == 0U || query->limit > TRAINLOG_WEB_EXERCISES_PAGE_MAX ||
|
||||||
|
query->offset > 1000000U || (query->zone_id != NULL && query->unclassified) ||
|
||||||
|
(query->profile != NULL && query->profile[0] != '\0' &&
|
||||||
|
strcmp(query->profile, "sets_reps") != 0 && strcmp(query->profile, "sets_duration") != 0 &&
|
||||||
|
strcmp(query->profile, "continuous_duration") != 0)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
*output_json = NULL;
|
||||||
|
*output_size = 0U;
|
||||||
|
if (query->search != NULL && query->search[0] != '\0' &&
|
||||||
|
trainlog_catalog_normalize_name(query->search, normalized, sizeof(normalized)) !=
|
||||||
|
TRAINLOG_STATUS_OK) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
document = yyjson_mut_doc_new(NULL);
|
||||||
|
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||||
|
items = document == NULL ? NULL : yyjson_mut_arr(document);
|
||||||
|
if (document == NULL || root == NULL || items == NULL) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_set_root(document, root);
|
||||||
|
result = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, normalized, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(
|
||||||
|
statement, 2, query->profile == NULL ? "" : query->profile, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(
|
||||||
|
statement, 3, query->zone_id == NULL ? "" : query->zone_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_int(statement, 4, query->unclassified ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_int64(statement, 5, (sqlite3_int64)query->limit + 1);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_int64(statement, 6, (sqlite3_int64)query->offset);
|
||||||
|
}
|
||||||
|
if (result != SQLITE_OK) {
|
||||||
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
while (status == TRAINLOG_STATUS_OK && (result = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||||
|
if (count < query->limit) {
|
||||||
|
TrainlogExercise exercise;
|
||||||
|
yyjson_mut_val *item = yyjson_mut_obj(document);
|
||||||
|
const char *tracking = (const char *)sqlite3_column_text(statement, 2);
|
||||||
|
const char *recording = (const char *)sqlite3_column_text(statement, 3);
|
||||||
|
(void)memset(&exercise, 0, sizeof(exercise));
|
||||||
|
if (item == NULL || sqlite3_column_text(statement, 0) == NULL ||
|
||||||
|
sqlite3_column_text(statement, 1) == NULL || tracking == NULL ||
|
||||||
|
recording == NULL) {
|
||||||
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
(void)snprintf(exercise.exercise_id,
|
||||||
|
sizeof(exercise.exercise_id),
|
||||||
|
"%s",
|
||||||
|
sqlite3_column_text(statement, 0));
|
||||||
|
(void)snprintf(
|
||||||
|
exercise.name, sizeof(exercise.name), "%s", sqlite3_column_text(statement, 1));
|
||||||
|
exercise.tracking_mode = strcmp(tracking, "duration") == 0 ? TRAINLOG_TRACKING_DURATION
|
||||||
|
: TRAINLOG_TRACKING_REPS;
|
||||||
|
exercise.recording_mode = strcmp(recording, "continuous") == 0
|
||||||
|
? TRAINLOG_RECORDING_CONTINUOUS
|
||||||
|
: TRAINLOG_RECORDING_SETS;
|
||||||
|
exercise.data_fields = (TrainlogExerciseDataFields)sqlite3_column_int64(statement, 4);
|
||||||
|
status = append_exercise(database, &exercise, false, document, item);
|
||||||
|
if (status != TRAINLOG_STATUS_OK || !yyjson_mut_arr_add_val(items, item)) {
|
||||||
|
status = status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_SYSTEM_ERROR : status;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
if (result != SQLITE_DONE && status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||||
|
status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK &&
|
||||||
|
(!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||||
|
!yyjson_mut_obj_add_uint(document, root, "offset", query->offset) ||
|
||||||
|
!yyjson_mut_obj_add_bool(document, root, "more", count > query->limit) ||
|
||||||
|
!yyjson_mut_obj_add_uint(document,
|
||||||
|
root,
|
||||||
|
"next_offset",
|
||||||
|
query->offset + (count > query->limit ? query->limit : count)) ||
|
||||||
|
!yyjson_mut_obj_add_val(document, root, "items", items) ||
|
||||||
|
!json_finish(&document, output_json, output_size))) {
|
||||||
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
document = NULL;
|
||||||
|
}
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_exercises_detail_json(TrainlogDatabase *database,
|
||||||
|
const char *exercise_id,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size) {
|
||||||
|
TrainlogExercise exercise;
|
||||||
|
yyjson_mut_doc *document;
|
||||||
|
yyjson_mut_val *root;
|
||||||
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
if (database == NULL || exercise_id == NULL || exercise_id[0] == '\0' || output_json == NULL ||
|
||||||
|
output_size == NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
*output_json = NULL;
|
||||||
|
*output_size = 0U;
|
||||||
|
status = trainlog_database_get_exercise_profile(database, exercise_id, &exercise);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
document = yyjson_mut_doc_new(NULL);
|
||||||
|
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||||
|
if (document == NULL || root == NULL) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_set_root(document, root);
|
||||||
|
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||||
|
(status = append_exercise(database, &exercise, true, document, root)) !=
|
||||||
|
TRAINLOG_STATUS_OK ||
|
||||||
|
!json_finish(&document, output_json, output_size)) {
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_exercises_zones_json(char **output_json, size_t *output_size) {
|
||||||
|
yyjson_mut_doc *document;
|
||||||
|
yyjson_mut_val *root;
|
||||||
|
yyjson_mut_val *zones;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
if (output_json == NULL || output_size == NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
*output_json = NULL;
|
||||||
|
*output_size = 0U;
|
||||||
|
document = yyjson_mut_doc_new(NULL);
|
||||||
|
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||||
|
zones = document == NULL ? NULL : yyjson_mut_arr(document);
|
||||||
|
if (document == NULL || root == NULL || zones == NULL) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_set_root(document, root);
|
||||||
|
for (index = 0U; index < trainlog_body_zone_catalog_count(); ++index) {
|
||||||
|
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index);
|
||||||
|
yyjson_mut_val *item;
|
||||||
|
if (zone == NULL || zone->is_group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
item = yyjson_mut_obj(document);
|
||||||
|
if (item == NULL || !yyjson_mut_obj_add_strcpy(document, item, "zone_id", zone->zone_id) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(document, item, "name", zone->display_name) ||
|
||||||
|
!yyjson_mut_arr_add_val(zones, item)) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||||
|
!yyjson_mut_obj_add_val(document, root, "zones", zones) ||
|
||||||
|
!json_finish(&document, output_json, output_size)) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool input_has_exact_keys(yyjson_val *root) {
|
||||||
|
static const char *const EXPECTED[] = {"name",
|
||||||
|
"recording_mode",
|
||||||
|
"tracking_mode",
|
||||||
|
"data_fields",
|
||||||
|
"primary_zone_id",
|
||||||
|
"secondary_zone_ids"};
|
||||||
|
bool seen[sizeof(EXPECTED) / sizeof(EXPECTED[0])] = {false};
|
||||||
|
yyjson_obj_iter iterator;
|
||||||
|
yyjson_val *key;
|
||||||
|
|
||||||
|
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != sizeof(EXPECTED) / sizeof(EXPECTED[0])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
yyjson_obj_iter_init(root, &iterator);
|
||||||
|
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
|
||||||
|
const char *name = yyjson_get_str(key);
|
||||||
|
size_t index;
|
||||||
|
bool matched = false;
|
||||||
|
|
||||||
|
for (index = 0U; index < sizeof(EXPECTED) / sizeof(EXPECTED[0]); ++index) {
|
||||||
|
if (strcmp(name, EXPECTED[index]) == 0 && !seen[index]) {
|
||||||
|
seen[index] = true;
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus
|
||||||
|
parse_input(const char *body, size_t body_size, bool require_sets_primary, ExerciseInput *output) {
|
||||||
|
yyjson_doc *document;
|
||||||
|
yyjson_val *root;
|
||||||
|
yyjson_val *name;
|
||||||
|
yyjson_val *recording;
|
||||||
|
yyjson_val *tracking;
|
||||||
|
yyjson_val *fields;
|
||||||
|
yyjson_val *primary;
|
||||||
|
yyjson_val *secondary;
|
||||||
|
size_t index;
|
||||||
|
size_t maximum = trainlog_body_zone_catalog_count();
|
||||||
|
|
||||||
|
if (body == NULL || body_size == 0U || body_size > TRAINLOG_WEB_EXERCISES_BODY_MAX ||
|
||||||
|
output == NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
document = yyjson_read_opts((char *)body, body_size, YYJSON_READ_NOFLAG, NULL, NULL);
|
||||||
|
if (document == NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
root = yyjson_doc_get_root(document);
|
||||||
|
name = yyjson_obj_get(root, "name");
|
||||||
|
recording = yyjson_obj_get(root, "recording_mode");
|
||||||
|
tracking = yyjson_obj_get(root, "tracking_mode");
|
||||||
|
fields = yyjson_obj_get(root, "data_fields");
|
||||||
|
primary = yyjson_obj_get(root, "primary_zone_id");
|
||||||
|
secondary = yyjson_obj_get(root, "secondary_zone_ids");
|
||||||
|
if (!input_has_exact_keys(root) || !yyjson_is_str(name) || yyjson_get_len(name) == 0U ||
|
||||||
|
yyjson_get_len(name) > TRAINLOG_NAME_MAX || !yyjson_is_str(recording) ||
|
||||||
|
!yyjson_is_str(tracking) || !yyjson_is_uint(fields) ||
|
||||||
|
yyjson_get_uint(fields) > TRAINLOG_EXERCISE_DATA_KNOWN_MASK ||
|
||||||
|
(!yyjson_is_null(primary) && !yyjson_is_str(primary)) || !yyjson_is_arr(secondary) ||
|
||||||
|
yyjson_arr_size(secondary) > maximum || yyjson_arr_size(secondary) > 16U) {
|
||||||
|
yyjson_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
(void)memset(output, 0, sizeof(*output));
|
||||||
|
(void)snprintf(output->name, sizeof(output->name), "%s", yyjson_get_str(name));
|
||||||
|
if (strcmp(yyjson_get_str(recording), "sets") == 0) {
|
||||||
|
output->recording_mode = TRAINLOG_RECORDING_SETS;
|
||||||
|
} else if (strcmp(yyjson_get_str(recording), "continuous") == 0) {
|
||||||
|
output->recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
|
||||||
|
} else {
|
||||||
|
yyjson_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
if (strcmp(yyjson_get_str(tracking), "reps") == 0) {
|
||||||
|
output->tracking_mode = TRAINLOG_TRACKING_REPS;
|
||||||
|
} else if (strcmp(yyjson_get_str(tracking), "duration") == 0) {
|
||||||
|
output->tracking_mode = TRAINLOG_TRACKING_DURATION;
|
||||||
|
} else {
|
||||||
|
yyjson_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
output->data_fields = (TrainlogExerciseDataFields)yyjson_get_uint(fields);
|
||||||
|
if (yyjson_is_str(primary)) {
|
||||||
|
if (yyjson_get_len(primary) > TRAINLOG_ZONE_ID_MAX) {
|
||||||
|
yyjson_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
(void)snprintf(output->primary_zone_id,
|
||||||
|
sizeof(output->primary_zone_id),
|
||||||
|
"%s",
|
||||||
|
yyjson_get_str(primary));
|
||||||
|
}
|
||||||
|
if ((output->recording_mode == TRAINLOG_RECORDING_SETS && output->data_fields != 0U) ||
|
||||||
|
(require_sets_primary && output->recording_mode == TRAINLOG_RECORDING_SETS &&
|
||||||
|
output->primary_zone_id[0] == '\0')) {
|
||||||
|
yyjson_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
output->secondary_count = yyjson_arr_size(secondary);
|
||||||
|
for (index = 0U; index < output->secondary_count; ++index) {
|
||||||
|
yyjson_val *value = yyjson_arr_get(secondary, index);
|
||||||
|
if (!yyjson_is_str(value) || yyjson_get_len(value) == 0U ||
|
||||||
|
yyjson_get_len(value) > TRAINLOG_ZONE_ID_MAX) {
|
||||||
|
yyjson_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
(void)snprintf(output->secondary_zone_ids[index],
|
||||||
|
sizeof(output->secondary_zone_ids[index]),
|
||||||
|
"%s",
|
||||||
|
yyjson_get_str(value));
|
||||||
|
output->secondary_zone_pointers[index] = output->secondary_zone_ids[index];
|
||||||
|
}
|
||||||
|
yyjson_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_exercises_create_json(TrainlogDatabase *database,
|
||||||
|
const char *body,
|
||||||
|
size_t body_size,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size) {
|
||||||
|
ExerciseInput input;
|
||||||
|
TrainlogExercise exercise;
|
||||||
|
TrainlogStatus status = parse_input(body, body_size, true, &input);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
status = trainlog_catalog_create_exercise_profiled_with_zones(
|
||||||
|
database,
|
||||||
|
input.name,
|
||||||
|
input.tracking_mode,
|
||||||
|
input.recording_mode,
|
||||||
|
input.data_fields,
|
||||||
|
input.primary_zone_id[0] == '\0' ? NULL : input.primary_zone_id,
|
||||||
|
input.secondary_zone_pointers,
|
||||||
|
input.secondary_count,
|
||||||
|
&exercise);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
return trainlog_web_exercises_detail_json(
|
||||||
|
database, exercise.exercise_id, output_json, output_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_exercises_update_json(TrainlogDatabase *database,
|
||||||
|
const char *exercise_id,
|
||||||
|
const char *expected_revision,
|
||||||
|
const char *body,
|
||||||
|
size_t body_size,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size) {
|
||||||
|
ExerciseInput input;
|
||||||
|
char current[EXERCISE_REVISION_CAPACITY];
|
||||||
|
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
|
||||||
|
TrainlogStatus status = parse_input(body, body_size, false, &input);
|
||||||
|
if (status != TRAINLOG_STATUS_OK || expected_revision == NULL || expected_revision[0] == '\0') {
|
||||||
|
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
|
||||||
|
}
|
||||||
|
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
status = exercise_revision(database, exercise_id, current);
|
||||||
|
if (status == TRAINLOG_STATUS_OK && strcmp(current, expected_revision) != 0) {
|
||||||
|
status = TRAINLOG_STATUS_CONFLICT;
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = trainlog_catalog_normalize_name(input.name, normalized, sizeof(normalized));
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = trainlog_database_update_exercise_profiled(
|
||||||
|
database,
|
||||||
|
exercise_id,
|
||||||
|
input.name,
|
||||||
|
normalized,
|
||||||
|
input.tracking_mode,
|
||||||
|
input.recording_mode,
|
||||||
|
input.data_fields,
|
||||||
|
input.primary_zone_id[0] == '\0' ? NULL : input.primary_zone_id,
|
||||||
|
input.secondary_zone_pointers,
|
||||||
|
input.secondary_count);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK &&
|
||||||
|
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
|
||||||
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
return trainlog_web_exercises_detail_json(database, exercise_id, output_json, output_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus causal_capacity_available(TrainlogDatabase *database) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int result =
|
||||||
|
sqlite3_prepare_v2(database->connection,
|
||||||
|
"SELECT "
|
||||||
|
"COUNT(*),COALESCE(SUM(length(operation_id)+length(target_kind)+length("
|
||||||
|
"target_id)+length(creator_id)+length(predecessor_revision_id)+length("
|
||||||
|
"created_at)+length(payload_sha256)+128),0) FROM sync_causal_operations",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (result != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
result = sqlite3_column_int64(statement, 0) < CAUSAL_OPERATION_MAX &&
|
||||||
|
sqlite3_column_int64(statement, 1) < CAUSAL_TEXT_BUDGET;
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return result ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus
|
||||||
|
exercise_live_revision(TrainlogDatabase *database, const char *exercise_id, char output[68]) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
yyjson_mut_doc *document = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val *array = document == NULL ? NULL : yyjson_mut_arr(document);
|
||||||
|
char *json = NULL;
|
||||||
|
size_t json_size = 0U;
|
||||||
|
char digest[65];
|
||||||
|
int result;
|
||||||
|
if (document == NULL || array == NULL) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_set_root(document, array);
|
||||||
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
|
"SELECT exercise_id,name,recording_mode,tracking_mode,data_fields "
|
||||||
|
"FROM exercises WHERE exercise_id=?1",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (result != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (!yyjson_mut_arr_add_strcpy(
|
||||||
|
document, array, (const char *)sqlite3_column_text(statement, 0)) ||
|
||||||
|
!yyjson_mut_arr_add_strcpy(
|
||||||
|
document, array, (const char *)sqlite3_column_text(statement, 1)) ||
|
||||||
|
!yyjson_mut_arr_add_strcpy(
|
||||||
|
document, array, (const char *)sqlite3_column_text(statement, 2)) ||
|
||||||
|
!yyjson_mut_arr_add_strcpy(
|
||||||
|
document, array, (const char *)sqlite3_column_text(statement, 3)) ||
|
||||||
|
!yyjson_mut_arr_add_int(document, array, sqlite3_column_int64(statement, 4))) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
json = yyjson_mut_write(document, 0U, &json_size);
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
if (json == NULL || !sha256_hex(json, json_size, digest)) {
|
||||||
|
free(json);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
free(json);
|
||||||
|
(void)snprintf(output, 68U, "lv_%s", digest);
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_exercises_retire_json(TrainlogDatabase *database,
|
||||||
|
const char *exercise_id,
|
||||||
|
const char *expected_revision,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size) {
|
||||||
|
static const char CREATOR[] = "peer_desktop_web";
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
char current[EXERCISE_REVISION_CAPACITY];
|
||||||
|
char predecessor[68];
|
||||||
|
char operation_id[TRAINLOG_ID_MAX + 1U];
|
||||||
|
char created_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
|
char payload[1024];
|
||||||
|
char digest[65];
|
||||||
|
int written;
|
||||||
|
int result;
|
||||||
|
TrainlogStatus status;
|
||||||
|
yyjson_mut_doc *document;
|
||||||
|
yyjson_mut_val *root;
|
||||||
|
if (database == NULL || exercise_id == NULL || expected_revision == NULL ||
|
||||||
|
output_json == NULL || output_size == NULL || exercise_is_builtin(exercise_id)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
*output_json = NULL;
|
||||||
|
*output_size = 0U;
|
||||||
|
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
|
"SELECT operation_id FROM sync_causal_state WHERE "
|
||||||
|
"target_kind='exercise' AND target_id=?1 AND deleted=1",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
(void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL);
|
||||||
|
document = yyjson_mut_doc_new(NULL);
|
||||||
|
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||||
|
if (document == NULL || root == NULL) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_set_root(document, root);
|
||||||
|
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
|
||||||
|
(void)yyjson_mut_obj_add_strcpy(document, root, "exercise_id", exercise_id);
|
||||||
|
(void)yyjson_mut_obj_add_strcpy(document, root, "state", "retired");
|
||||||
|
return json_finish(&document, output_json, output_size) ? TRAINLOG_STATUS_OK
|
||||||
|
: TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
if (result != SQLITE_DONE) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
status = exercise_revision(database, exercise_id, current);
|
||||||
|
if (status == TRAINLOG_STATUS_OK && strcmp(current, expected_revision) != 0) {
|
||||||
|
status = TRAINLOG_STATUS_CONFLICT;
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = causal_capacity_available(database);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = exercise_live_revision(database, exercise_id, predecessor);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK &&
|
||||||
|
(trainlog_id_generate("del", operation_id, sizeof(operation_id)) != TRAINLOG_STATUS_OK ||
|
||||||
|
!timestamp_now(created_at))) {
|
||||||
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
written = status == TRAINLOG_STATUS_OK
|
||||||
|
? snprintf(payload,
|
||||||
|
sizeof(payload),
|
||||||
|
"{\"created_at\":\"%s\",\"creator_id\":\"%s\",\"operation_id\":\"%s\","
|
||||||
|
"\"predecessor_revision_id\":\"%s\",\"publication_context\":null,"
|
||||||
|
"\"target_id\":\"%s\",\"target_kind\":\"exercise\"}",
|
||||||
|
created_at,
|
||||||
|
CREATOR,
|
||||||
|
operation_id,
|
||||||
|
predecessor,
|
||||||
|
exercise_id)
|
||||||
|
: -1;
|
||||||
|
if (status == TRAINLOG_STATUS_OK && (written < 0 || (size_t)written >= sizeof(payload) ||
|
||||||
|
!sha256_hex(payload, (size_t)written, digest))) {
|
||||||
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
result = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
"INSERT INTO sync_causal_operations VALUES(?1,'exercise',?2,?3,?4,?5,?6,NULL)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 2, exercise_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 3, CREATOR, -1, SQLITE_STATIC);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 4, predecessor, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 5, created_at, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 6, digest, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (statement != NULL) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
statement = NULL;
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result != SQLITE_DONE) {
|
||||||
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
result = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
"INSERT OR REPLACE INTO "
|
||||||
|
"sync_causal_state(target_kind,target_id,current_revision_id,deleted,"
|
||||||
|
"operation_id) VALUES('exercise',?1,?2,1,?2)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (statement != NULL) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
statement = NULL;
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && result != SQLITE_DONE) {
|
||||||
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK &&
|
||||||
|
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
|
||||||
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
document = yyjson_mut_doc_new(NULL);
|
||||||
|
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||||
|
if (document == NULL || root == NULL) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_set_root(document, root);
|
||||||
|
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(document, root, "exercise_id", exercise_id) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(document, root, "state", "retired") ||
|
||||||
|
!json_finish(&document, output_json, output_size)) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
#include <yyjson.h>
|
#include <yyjson.h>
|
||||||
|
|
||||||
#include "trainlog/web_dashboard.h"
|
#include "trainlog/web_dashboard.h"
|
||||||
|
#include "trainlog/web_exercises.h"
|
||||||
#include "trainlog/web_prepared_items.h"
|
#include "trainlog/web_prepared_items.h"
|
||||||
#include "trainlog/web_programs.h"
|
#include "trainlog/web_programs.h"
|
||||||
#include "trainlog/web_session_deletions.h"
|
#include "trainlog/web_session_deletions.h"
|
||||||
|
|
@ -39,6 +40,7 @@
|
||||||
#define TRAINLOG_HTTP_BACKLOG 32U
|
#define TRAINLOG_HTTP_BACKLOG 32U
|
||||||
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
|
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
|
||||||
#define TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY 256U
|
#define TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY 256U
|
||||||
|
#define TRAINLOG_EXERCISE_REVISION_CAPACITY 72U
|
||||||
|
|
||||||
typedef struct TrainlogWebContext {
|
typedef struct TrainlogWebContext {
|
||||||
uint16_t port;
|
uint16_t port;
|
||||||
|
|
@ -843,6 +845,188 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
||||||
return queue_owned_programs_json(connection, status, json, json_size);
|
return queue_owned_programs_json(connection, status, json, json_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static enum MHD_Result queue_owned_exercises_json(struct MHD_Connection *connection,
|
||||||
|
TrainlogStatus status,
|
||||||
|
char *json,
|
||||||
|
size_t json_size,
|
||||||
|
const char *conflict_error) {
|
||||||
|
if (status == TRAINLOG_STATUS_NOT_FOUND) {
|
||||||
|
free(json);
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"exercise_not_found\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||||
|
free(json);
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_CONFLICT,
|
||||||
|
conflict_error == NULL ? "{\"error\":\"exercise_conflict\"}\n"
|
||||||
|
: conflict_error,
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||||
|
free(json);
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_exercise\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) {
|
||||||
|
free(json);
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||||
|
"{\"error\":\"exercises_unavailable\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
(void)json_size;
|
||||||
|
{
|
||||||
|
enum MHD_Result result = queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||||
|
free(json);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static enum MHD_Result handle_exercises_request(TrainlogWebContext *context,
|
||||||
|
struct MHD_Connection *connection,
|
||||||
|
const char *url,
|
||||||
|
const char *method,
|
||||||
|
bool is_get,
|
||||||
|
TrainlogHttpRequestState *request) {
|
||||||
|
static const char COLLECTION[] = "/api/v1/exercises";
|
||||||
|
static const char DETAIL_PREFIX[] = "/api/v1/exercise/";
|
||||||
|
char *json = NULL;
|
||||||
|
size_t json_size = 0U;
|
||||||
|
const char *request_id;
|
||||||
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
if (strcmp(url, "/api/v1/exercise-zones") == 0) {
|
||||||
|
if (!is_get) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||||
|
"{\"error\":\"method_not_allowed\"}\n",
|
||||||
|
"GET");
|
||||||
|
}
|
||||||
|
status = trainlog_web_exercises_zones_json(&json, &json_size);
|
||||||
|
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
|
||||||
|
}
|
||||||
|
if (strcmp(url, COLLECTION) == 0) {
|
||||||
|
if (is_get) {
|
||||||
|
TrainlogWebExercisesQuery query;
|
||||||
|
const char *unclassified;
|
||||||
|
if (!parse_page_number(
|
||||||
|
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "offset"),
|
||||||
|
0U,
|
||||||
|
1000000U,
|
||||||
|
&query.offset) ||
|
||||||
|
!parse_page_number(
|
||||||
|
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit"),
|
||||||
|
24U,
|
||||||
|
TRAINLOG_WEB_EXERCISES_PAGE_MAX,
|
||||||
|
&query.limit) ||
|
||||||
|
query.limit == 0U) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL);
|
||||||
|
}
|
||||||
|
query.search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search");
|
||||||
|
query.profile =
|
||||||
|
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "profile");
|
||||||
|
query.zone_id =
|
||||||
|
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "zone_id");
|
||||||
|
unclassified =
|
||||||
|
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "unclassified");
|
||||||
|
query.unclassified = unclassified != NULL && strcmp(unclassified, "true") == 0;
|
||||||
|
status = trainlog_web_exercises_list_json(context->database, &query, &json, &json_size);
|
||||||
|
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
|
||||||
|
}
|
||||||
|
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||||
|
"{\"error\":\"method_not_allowed\"}\n",
|
||||||
|
"GET, POST");
|
||||||
|
}
|
||||||
|
if (!program_mutation_allowed(context, connection)) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||||
|
}
|
||||||
|
request_id =
|
||||||
|
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
||||||
|
if (request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_request_id\"}\n", NULL);
|
||||||
|
}
|
||||||
|
status = trainlog_web_exercises_create_json(
|
||||||
|
context->database, request->body, request->body_size, &json, &json_size);
|
||||||
|
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
|
||||||
|
}
|
||||||
|
if (strncmp(url, DETAIL_PREFIX, strlen(DETAIL_PREFIX)) == 0) {
|
||||||
|
const char *exercise_id = url + strlen(DETAIL_PREFIX);
|
||||||
|
const char *if_match;
|
||||||
|
char revision[TRAINLOG_EXERCISE_REVISION_CAPACITY];
|
||||||
|
if (exercise_id[0] == '\0' || strlen(exercise_id) > TRAINLOG_ID_MAX ||
|
||||||
|
strchr(exercise_id, '/') != NULL) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_identity\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if (is_get) {
|
||||||
|
status = trainlog_web_exercises_detail_json(
|
||||||
|
context->database, exercise_id, &json, &json_size);
|
||||||
|
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
|
||||||
|
}
|
||||||
|
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
|
||||||
|
strcmp(method, MHD_HTTP_METHOD_DELETE) != 0) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||||
|
"{\"error\":\"method_not_allowed\"}\n",
|
||||||
|
"GET, PUT, DELETE");
|
||||||
|
}
|
||||||
|
if (!program_mutation_allowed(context, connection)) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||||
|
}
|
||||||
|
request_id =
|
||||||
|
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
||||||
|
if (request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_request_id\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if_match =
|
||||||
|
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
|
||||||
|
if (!parse_quoted_revision(if_match, revision, sizeof(revision))) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_PRECONDITION_REQUIRED,
|
||||||
|
"{\"error\":\"precondition_required\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
if (strcmp(method, MHD_HTTP_METHOD_PUT) == 0) {
|
||||||
|
status = trainlog_web_exercises_update_json(context->database,
|
||||||
|
exercise_id,
|
||||||
|
revision,
|
||||||
|
request->body,
|
||||||
|
request->body_size,
|
||||||
|
&json,
|
||||||
|
&json_size);
|
||||||
|
return queue_owned_exercises_json(connection,
|
||||||
|
status,
|
||||||
|
json,
|
||||||
|
json_size,
|
||||||
|
"{\"error\":\"exercise_revision_conflict\"}\n");
|
||||||
|
}
|
||||||
|
if (request->body_size != 0U) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"unexpected_body\"}\n", NULL);
|
||||||
|
}
|
||||||
|
status = trainlog_web_exercises_retire_json(
|
||||||
|
context->database, exercise_id, revision, &json, &json_size);
|
||||||
|
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||||
|
free(json);
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_CONFLICT,
|
||||||
|
"{\"error\":\"exercise_retirement_forbidden\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
return queue_owned_exercises_json(
|
||||||
|
connection, status, json, json_size, "{\"error\":\"exercise_revision_conflict\"}\n");
|
||||||
|
}
|
||||||
|
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||||
|
}
|
||||||
|
|
||||||
static enum MHD_Result queue_session_deletion_result(struct MHD_Connection *connection,
|
static enum MHD_Result queue_session_deletion_result(struct MHD_Connection *connection,
|
||||||
TrainlogStatus status,
|
TrainlogStatus status,
|
||||||
char *json,
|
char *json,
|
||||||
|
|
@ -1132,6 +1316,9 @@ static enum MHD_Result handle_request(void *closure,
|
||||||
}
|
}
|
||||||
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||||
}
|
}
|
||||||
|
if (strncmp(url, "/api/v1/exercise", strlen("/api/v1/exercise")) == 0) {
|
||||||
|
return handle_exercises_request(context, connection, url, method, is_get, state);
|
||||||
|
}
|
||||||
if (strncmp(url, "/api/v1/sessions/program", strlen("/api/v1/sessions/program")) == 0) {
|
if (strncmp(url, "/api/v1/sessions/program", strlen("/api/v1/sessions/program")) == 0) {
|
||||||
return handle_program_request(context, connection, url, method, is_get, state);
|
return handle_program_request(context, connection, url, method, is_get, state);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
224
tui/tests/test_web_exercises.c
Normal file
224
tui/tests/test_web_exercises.c
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include "database_internal.h"
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
#include "trainlog/web_exercises.h"
|
||||||
|
|
||||||
|
#define CHECK(expression) \
|
||||||
|
do { \
|
||||||
|
if (!(expression)) { \
|
||||||
|
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
|
||||||
|
return false; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static int scalar(TrainlogDatabase *database, const char *sql) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int value = -1;
|
||||||
|
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||||
|
sqlite3_step(statement) == SQLITE_ROW) {
|
||||||
|
value = sqlite3_column_int(statement, 0);
|
||||||
|
}
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool json_string(const char *json, const char *key, char *output, size_t capacity) {
|
||||||
|
char marker[96];
|
||||||
|
const char *start;
|
||||||
|
const char *end;
|
||||||
|
size_t length;
|
||||||
|
CHECK(snprintf(marker, sizeof(marker), "\"%s\":\"", key) > 0);
|
||||||
|
start = strstr(json, marker);
|
||||||
|
CHECK(start != NULL);
|
||||||
|
start += strlen(marker);
|
||||||
|
end = strchr(start, '"');
|
||||||
|
CHECK(end != NULL);
|
||||||
|
length = (size_t)(end - start);
|
||||||
|
CHECK(length > 0U && length < capacity);
|
||||||
|
(void)memcpy(output, start, length);
|
||||||
|
output[length] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool lifecycle_preserves_history_and_is_causal(void) {
|
||||||
|
static const char CREATE[] =
|
||||||
|
"{\"name\":\" Test Web \u00c9paules \" ,\"recording_mode\":\"sets\","
|
||||||
|
"\"tracking_mode\":\"reps\",\"data_fields\":0,"
|
||||||
|
"\"primary_zone_id\":\"shoulders\",\"secondary_zone_ids\":[\"arms\"]}";
|
||||||
|
static const char UPDATE[] = "{\"name\":\"Test Web Renomm\u00e9\",\"recording_mode\":\"sets\","
|
||||||
|
"\"tracking_mode\":\"duration\",\"data_fields\":0,"
|
||||||
|
"\"primary_zone_id\":\"core\",\"secondary_zone_ids\":[]}";
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogWebExercisesQuery query = {0U, 24U, "test web", "sets_reps", "shoulders", false};
|
||||||
|
char *created = NULL;
|
||||||
|
char *page = NULL;
|
||||||
|
char *updated = NULL;
|
||||||
|
char *retired = NULL;
|
||||||
|
size_t size = 0U;
|
||||||
|
char exercise_id[TRAINLOG_ID_MAX + 1U];
|
||||||
|
char revision[80];
|
||||||
|
char updated_revision[80];
|
||||||
|
char sql[2048];
|
||||||
|
int initial_exercise_count;
|
||||||
|
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
initial_exercise_count = scalar(database, "SELECT COUNT(*) FROM exercises");
|
||||||
|
CHECK(trainlog_web_exercises_create_json(database, CREATE, strlen(CREATE), &created, &size) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(json_string(created, "exercise_id", exercise_id, sizeof(exercise_id)));
|
||||||
|
CHECK(json_string(created, "revision", revision, sizeof(revision)));
|
||||||
|
CHECK(strstr(created, "\"primary_zone_id\":\"shoulders\"") != NULL);
|
||||||
|
CHECK(strstr(created, "\"secondary_zone_ids\":[\"arms\"]") != NULL);
|
||||||
|
CHECK(strstr(created, "\"retireable\":true") != NULL);
|
||||||
|
CHECK(snprintf(sql,
|
||||||
|
sizeof(sql),
|
||||||
|
"SELECT COUNT(*) FROM exercise_profile_state s JOIN exercises e ON e.id=s."
|
||||||
|
"exercise_row_id WHERE e.exercise_id='%s'",
|
||||||
|
exercise_id) > 0);
|
||||||
|
CHECK(scalar(database, sql) == 1);
|
||||||
|
CHECK(snprintf(sql,
|
||||||
|
sizeof(sql),
|
||||||
|
"SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e ON e.id=z."
|
||||||
|
"exercise_row_id WHERE e.exercise_id='%s'",
|
||||||
|
exercise_id) > 0);
|
||||||
|
CHECK(scalar(database, sql) == 2);
|
||||||
|
CHECK(trainlog_web_exercises_list_json(database, &query, &page, &size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(strstr(page, exercise_id) != NULL);
|
||||||
|
free(page);
|
||||||
|
page = NULL;
|
||||||
|
|
||||||
|
CHECK(snprintf(sql,
|
||||||
|
sizeof(sql),
|
||||||
|
"INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES("
|
||||||
|
"'se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','2026-09-20T10:00:00Z',"
|
||||||
|
"'2026-09-20T11:00:00Z','training');"
|
||||||
|
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,"
|
||||||
|
"recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) "
|
||||||
|
"SELECT 'sxe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',s.id,e.id,'sets','reps',0,"
|
||||||
|
"0,'external',0 FROM sessions s,exercises e WHERE e.exercise_id='%s';"
|
||||||
|
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) "
|
||||||
|
"SELECT id,0,8,42.5 FROM session_exercises;",
|
||||||
|
exercise_id) > 0);
|
||||||
|
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
|
||||||
|
CHECK(trainlog_web_exercises_update_json(
|
||||||
|
database, exercise_id, revision, UPDATE, strlen(UPDATE), &updated, &size) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(json_string(updated, "revision", updated_revision, sizeof(updated_revision)));
|
||||||
|
CHECK(strcmp(revision, updated_revision) != 0);
|
||||||
|
CHECK(strstr(updated, "\"tracking_mode\":\"duration\"") != NULL);
|
||||||
|
CHECK(snprintf(sql,
|
||||||
|
sizeof(sql),
|
||||||
|
"SELECT COUNT(*) FROM exercise_profile_revisions r JOIN exercises e ON e.id=r."
|
||||||
|
"exercise_row_id WHERE e.exercise_id='%s'",
|
||||||
|
exercise_id) > 0);
|
||||||
|
CHECK(scalar(database, sql) >= 2);
|
||||||
|
CHECK(snprintf(sql,
|
||||||
|
sizeof(sql),
|
||||||
|
"SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e ON e.id=z."
|
||||||
|
"exercise_row_id WHERE e.exercise_id='%s'",
|
||||||
|
exercise_id) > 0);
|
||||||
|
CHECK(scalar(database, sql) == 1);
|
||||||
|
CHECK(scalar(database,
|
||||||
|
"SELECT COUNT(*) FROM session_exercises WHERE recording_mode='sets' AND "
|
||||||
|
"tracking_mode='reps' AND data_fields=0") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM performed_sets WHERE reps=8 AND weight_kg=42.5") ==
|
||||||
|
1);
|
||||||
|
CHECK(trainlog_web_exercises_update_json(
|
||||||
|
database, exercise_id, revision, UPDATE, strlen(UPDATE), &page, &size) ==
|
||||||
|
TRAINLOG_STATUS_CONFLICT);
|
||||||
|
CHECK(page == NULL);
|
||||||
|
CHECK(trainlog_web_exercises_retire_json(
|
||||||
|
database, exercise_id, updated_revision, &retired, &size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(strstr(retired, "\"state\":\"retired\"") != NULL);
|
||||||
|
CHECK(scalar(database,
|
||||||
|
"SELECT COUNT(*) FROM sync_causal_state WHERE target_kind='exercise' AND "
|
||||||
|
"deleted=1") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM exercises") == initial_exercise_count + 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM sessions") == 1);
|
||||||
|
query.search = "";
|
||||||
|
query.profile = "";
|
||||||
|
query.zone_id = NULL;
|
||||||
|
CHECK(trainlog_web_exercises_list_json(database, &query, &page, &size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(strstr(page, exercise_id) == NULL);
|
||||||
|
free(page);
|
||||||
|
page = NULL;
|
||||||
|
free(retired);
|
||||||
|
retired = NULL;
|
||||||
|
CHECK(trainlog_web_exercises_retire_json(
|
||||||
|
database, exercise_id, updated_revision, &retired, &size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(scalar(database,
|
||||||
|
"SELECT COUNT(*) FROM sync_causal_operations WHERE target_kind='exercise'") == 1);
|
||||||
|
free(created);
|
||||||
|
free(updated);
|
||||||
|
free(retired);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool validation_collision_and_builtin_protection(void) {
|
||||||
|
static const char INVALID[] = "{\"name\":\"Invalide\",\"recording_mode\":\"continuous\","
|
||||||
|
"\"tracking_mode\":\"reps\",\"data_fields\":0,"
|
||||||
|
"\"primary_zone_id\":null,\"secondary_zone_ids\":[]}";
|
||||||
|
static const char DUPLICATE[] =
|
||||||
|
"{\"name\":\" LEG EXTENSION \" ,\"recording_mode\":\"sets\","
|
||||||
|
"\"tracking_mode\":\"reps\",\"data_fields\":0,"
|
||||||
|
"\"primary_zone_id\":\"chest\",\"secondary_zone_ids\":[]}";
|
||||||
|
static const char DUPLICATE_KEY[] =
|
||||||
|
"{\"name\":\"A\",\"name\":\"B\",\"recording_mode\":\"sets\","
|
||||||
|
"\"tracking_mode\":\"reps\",\"data_fields\":0,"
|
||||||
|
"\"primary_zone_id\":\"chest\",\"secondary_zone_ids\":[]}";
|
||||||
|
static const char SETS_FIELDS[] = "{\"name\":\"Fields\",\"recording_mode\":\"sets\","
|
||||||
|
"\"tracking_mode\":\"reps\",\"data_fields\":1,"
|
||||||
|
"\"primary_zone_id\":\"chest\",\"secondary_zone_ids\":[]}";
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
char *json = NULL;
|
||||||
|
size_t size = 0U;
|
||||||
|
char revision[80];
|
||||||
|
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||||
|
"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab",
|
||||||
|
"Leg Extension",
|
||||||
|
"leg extension",
|
||||||
|
TRAINLOG_TRACKING_REPS,
|
||||||
|
TRAINLOG_RECORDING_SETS,
|
||||||
|
0U) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_web_exercises_create_json(database, INVALID, strlen(INVALID), &json, &size) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
CHECK(trainlog_web_exercises_create_json(
|
||||||
|
database, DUPLICATE_KEY, strlen(DUPLICATE_KEY), &json, &size) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
CHECK(trainlog_web_exercises_create_json(
|
||||||
|
database, SETS_FIELDS, strlen(SETS_FIELDS), &json, &size) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
CHECK(trainlog_web_exercises_create_json(
|
||||||
|
database, DUPLICATE, strlen(DUPLICATE), &json, &size) == TRAINLOG_STATUS_CONFLICT);
|
||||||
|
CHECK(trainlog_web_exercises_detail_json(
|
||||||
|
database, "ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", &json, &size) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(json_string(json, "revision", revision, sizeof(revision)));
|
||||||
|
CHECK(strstr(json, "\"retireable\":false") != NULL);
|
||||||
|
free(json);
|
||||||
|
json = NULL;
|
||||||
|
CHECK(trainlog_web_exercises_retire_json(
|
||||||
|
database, "ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", revision, &json, &size) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 0);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
if (!lifecycle_preserves_history_and_is_causal() ||
|
||||||
|
!validation_collision_and_builtin_protection()) {
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
(void)puts("web exercises tests passed");
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
56
web/src/api/exercises.test.ts
Normal file
56
web/src/api/exercises.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
createExercise, fetchBodyZones, fetchExercise, fetchExercises, retireExercise, updateExercise,
|
||||||
|
type ExerciseDetail, type ExerciseInput,
|
||||||
|
} from './exercises'
|
||||||
|
|
||||||
|
const detail: ExerciseDetail = {
|
||||||
|
api_version: 1, exercise_id: 'ex_11111111-1111-4111-8111-111111111111', name: 'Test',
|
||||||
|
recording_mode: 'sets', tracking_mode: 'reps', data_fields: 0, retireable: true,
|
||||||
|
primary_zone_id: 'chest', secondary_zone_ids: ['arms'], revision: 'exrev_abc', equipment: [],
|
||||||
|
} as ExerciseDetail & { api_version: 1 }
|
||||||
|
const input: ExerciseInput = {
|
||||||
|
name: 'Test', recording_mode: 'sets', tracking_mode: 'reps', data_fields: 0,
|
||||||
|
primary_zone_id: 'chest', secondary_zone_ids: ['arms'],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exercises API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.stubGlobal('fetch', vi.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates list, detail, and BODY ZONES read models', async () => {
|
||||||
|
const fetchMock = vi.mocked(fetch)
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ api_version: 1, offset: 0,
|
||||||
|
more: false, next_offset: 1, items: [detail] }), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ api_version: 1,
|
||||||
|
zones: [{ zone_id: 'chest', name: 'Pectoraux' }] }), { status: 200 }))
|
||||||
|
expect((await fetchExercises({ search: 'test', profile: 'sets_reps' })).items).toHaveLength(1)
|
||||||
|
expect((await fetchExercise(detail.exercise_id)).revision).toBe('exrev_abc')
|
||||||
|
expect(await fetchBodyZones()).toEqual([{ zone_id: 'chest', name: 'Pectoraux' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses guarded Core mutations and never calls sync or Android endpoints', async () => {
|
||||||
|
const fetchMock = vi.mocked(fetch)
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ phase: 'idle', result: 'idle' }), {
|
||||||
|
status: 200, headers: { 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ ...detail, name: 'Nouveau' }), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ api_version: 1,
|
||||||
|
exercise_id: detail.exercise_id, state: 'retired' }), { status: 200 }))
|
||||||
|
await createExercise(input)
|
||||||
|
await updateExercise(detail.exercise_id, detail.revision, { ...input, name: 'Nouveau' })
|
||||||
|
await retireExercise(detail.exercise_id, detail.revision)
|
||||||
|
const calls = fetchMock.mock.calls.map(([url]) => String(url))
|
||||||
|
expect(calls).toEqual(['/api/v1/sync/status', '/api/v1/exercises',
|
||||||
|
`/api/v1/exercise/${detail.exercise_id}`, `/api/v1/exercise/${detail.exercise_id}`])
|
||||||
|
expect(calls.every((url) => !url.includes('android') && url !== '/api/v1/sync')).toBe(true)
|
||||||
|
expect((fetchMock.mock.calls[2][1]?.headers as Record<string, string>)['If-Match'])
|
||||||
|
.toBe('"exrev_abc"')
|
||||||
|
})
|
||||||
|
})
|
||||||
141
web/src/api/exercises.ts
Normal file
141
web/src/api/exercises.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { mutationCsrfToken } from './sync'
|
||||||
|
|
||||||
|
export type RecordingMode = 'sets' | 'continuous'
|
||||||
|
export type TrackingMode = 'reps' | 'duration'
|
||||||
|
|
||||||
|
export interface ExerciseSummary {
|
||||||
|
exercise_id: string
|
||||||
|
name: string
|
||||||
|
recording_mode: RecordingMode
|
||||||
|
tracking_mode: TrackingMode
|
||||||
|
data_fields: number
|
||||||
|
retireable: boolean
|
||||||
|
primary_zone_id: string | null
|
||||||
|
secondary_zone_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExerciseDetail extends ExerciseSummary {
|
||||||
|
revision: string
|
||||||
|
equipment: { equipment_id: string; display_name: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExercisePage {
|
||||||
|
api_version: 1
|
||||||
|
offset: number
|
||||||
|
more: boolean
|
||||||
|
next_offset: number
|
||||||
|
items: ExerciseSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BodyZoneChoice { zone_id: string; name: string }
|
||||||
|
|
||||||
|
export interface ExerciseInput {
|
||||||
|
name: string
|
||||||
|
recording_mode: RecordingMode
|
||||||
|
tracking_mode: TrackingMode
|
||||||
|
data_fields: number
|
||||||
|
primary_zone_id: string | null
|
||||||
|
secondary_zone_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExerciseApiError extends Error {
|
||||||
|
constructor(public readonly code: string, public readonly status: number) {
|
||||||
|
super(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSummary(value: unknown): value is ExerciseSummary {
|
||||||
|
if (!object(value)) return false
|
||||||
|
return typeof value.exercise_id === 'string' && typeof value.name === 'string' &&
|
||||||
|
['sets', 'continuous'].includes(String(value.recording_mode)) &&
|
||||||
|
['reps', 'duration'].includes(String(value.tracking_mode)) &&
|
||||||
|
Number.isSafeInteger(value.data_fields) && Number(value.data_fields) >= 0 &&
|
||||||
|
typeof value.retireable === 'boolean' &&
|
||||||
|
(value.primary_zone_id === null || typeof value.primary_zone_id === 'string') &&
|
||||||
|
Array.isArray(value.secondary_zone_ids) &&
|
||||||
|
value.secondary_zone_ids.every((zone) => typeof zone === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checked(response: Response): Promise<unknown> {
|
||||||
|
const value: unknown = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
const code = object(value) && typeof value.error === 'string' ? value.error : 'exercise_error'
|
||||||
|
throw new ExerciseApiError(code, response.status)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExercises(parameters: {
|
||||||
|
offset?: number; search?: string; profile?: string; zone?: string; unclassified?: boolean
|
||||||
|
}, signal?: AbortSignal): Promise<ExercisePage> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
offset: String(parameters.offset ?? 0), limit: '24',
|
||||||
|
})
|
||||||
|
if (parameters.search?.trim()) query.set('search', parameters.search.trim())
|
||||||
|
if (parameters.profile) query.set('profile', parameters.profile)
|
||||||
|
if (parameters.zone) query.set('zone_id', parameters.zone)
|
||||||
|
if (parameters.unclassified) query.set('unclassified', 'true')
|
||||||
|
const value = await checked(await fetch(`/api/v1/exercises?${query}`, {
|
||||||
|
headers: { Accept: 'application/json' }, signal,
|
||||||
|
}))
|
||||||
|
if (!object(value) || value.api_version !== 1 || !Array.isArray(value.items) ||
|
||||||
|
!value.items.every(validSummary) || typeof value.more !== 'boolean' ||
|
||||||
|
!Number.isSafeInteger(value.offset) || !Number.isSafeInteger(value.next_offset)) {
|
||||||
|
throw new TypeError('catalogue exercices invalide')
|
||||||
|
}
|
||||||
|
return value as unknown as ExercisePage
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExercise(exerciseId: string, signal?: AbortSignal): Promise<ExerciseDetail> {
|
||||||
|
const value = await checked(await fetch(`/api/v1/exercise/${encodeURIComponent(exerciseId)}`, {
|
||||||
|
headers: { Accept: 'application/json' }, signal,
|
||||||
|
}))
|
||||||
|
if (!validSummary(value) || !object(value) || typeof value.revision !== 'string' ||
|
||||||
|
!Array.isArray(value.equipment)) throw new TypeError('fiche exercice invalide')
|
||||||
|
return value as unknown as ExerciseDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBodyZones(signal?: AbortSignal): Promise<BodyZoneChoice[]> {
|
||||||
|
const value = await checked(await fetch('/api/v1/exercise-zones', {
|
||||||
|
headers: { Accept: 'application/json' }, signal,
|
||||||
|
}))
|
||||||
|
if (!object(value) || !Array.isArray(value.zones) || !value.zones.every((zone) =>
|
||||||
|
object(zone) && typeof zone.zone_id === 'string' && typeof zone.name === 'string')) {
|
||||||
|
throw new TypeError('zones corporelles invalides')
|
||||||
|
}
|
||||||
|
return value.zones as BodyZoneChoice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutation(path: string, method: 'POST' | 'PUT' | 'DELETE',
|
||||||
|
input?: ExerciseInput, revision?: string): Promise<ExerciseDetail | { state: 'retired' }> {
|
||||||
|
const csrf = await mutationCsrfToken()
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||||
|
'X-Trainlog-CSRF-Token': csrf,
|
||||||
|
'X-Trainlog-Request-ID': requestId,
|
||||||
|
}
|
||||||
|
if (revision) headers['If-Match'] = `"${revision}"`
|
||||||
|
const value = await checked(await fetch(path, {
|
||||||
|
method, headers, body: input === undefined ? undefined : JSON.stringify(input),
|
||||||
|
}))
|
||||||
|
return value as ExerciseDetail | { state: 'retired' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExercise(input: ExerciseInput): Promise<ExerciseDetail> {
|
||||||
|
return await mutation('/api/v1/exercises', 'POST', input) as ExerciseDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateExercise(exerciseId: string, revision: string,
|
||||||
|
input: ExerciseInput): Promise<ExerciseDetail> {
|
||||||
|
return await mutation(`/api/v1/exercise/${encodeURIComponent(exerciseId)}`,
|
||||||
|
'PUT', input, revision) as ExerciseDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retireExercise(exerciseId: string, revision: string): Promise<void> {
|
||||||
|
await mutation(`/api/v1/exercise/${encodeURIComponent(exerciseId)}`, 'DELETE', undefined, revision)
|
||||||
|
}
|
||||||
|
|
@ -8,14 +8,14 @@ import { DashboardPage } from '../routes/DashboardPage'
|
||||||
import { PlaceholderPage } from '../routes/PlaceholderPage'
|
import { PlaceholderPage } from '../routes/PlaceholderPage'
|
||||||
import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage'
|
import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage'
|
||||||
import { SessionsPage } from '../routes/SessionsPage'
|
import { SessionsPage } from '../routes/SessionsPage'
|
||||||
|
import { ExercisesPage } from '../routes/ExercisesPage'
|
||||||
import type { RouteId } from './routes'
|
import type { RouteId } from './routes'
|
||||||
import { useRoute } from './useRoute'
|
import { useRoute } from './useRoute'
|
||||||
import { DatePreferencesProvider } from '../presentation/DatePreferences'
|
import { DatePreferencesProvider } from '../presentation/DatePreferences'
|
||||||
|
|
||||||
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'programs'>, [string, string, string]> = {
|
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'programs' | 'exercises'>, [string, string, string]> = {
|
||||||
analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'],
|
analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'],
|
||||||
sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'],
|
sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'],
|
||||||
exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
|
@ -76,6 +76,7 @@ function AppContent() {
|
||||||
/>
|
/>
|
||||||
) : route.id === 'sessions' ? <SessionsPage /> :
|
) : route.id === 'sessions' ? <SessionsPage /> :
|
||||||
route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> :
|
route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> :
|
||||||
|
route.id === 'exercises' ? <ExercisesPage path={route.path} onNavigate={navigate} /> :
|
||||||
<PlaceholderPage title={placeholderContent[route.id][0]}
|
<PlaceholderPage title={placeholderContent[route.id][0]}
|
||||||
eyebrow={placeholderContent[route.id][1]}
|
eyebrow={placeholderContent[route.id][1]}
|
||||||
description={placeholderContent[route.id][2]} />
|
description={placeholderContent[route.id][2]} />
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ export const routes: readonly AppRoute[] = [
|
||||||
]
|
]
|
||||||
|
|
||||||
export function routeFromPath(pathname: string): AppRoute {
|
export function routeFromPath(pathname: string): AppRoute {
|
||||||
if (pathname === '/seances' || pathname.startsWith('/seances/')) return routes[3]
|
if (pathname === '/seances' || pathname.startsWith('/seances/')) {
|
||||||
|
return { ...routes[3], path: pathname }
|
||||||
|
}
|
||||||
|
if (pathname === '/exercices' || pathname.startsWith('/exercices/')) {
|
||||||
|
return { ...routes[4], path: pathname }
|
||||||
|
}
|
||||||
return routes.find((route) => route.path === pathname) ?? routes[0]
|
return routes.find((route) => route.path === pathname) ?? routes[0]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
web/src/routes/ExercisesPage.test.tsx
Normal file
48
web/src/routes/ExercisesPage.test.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ExercisesPage } from './ExercisesPage'
|
||||||
|
|
||||||
|
const exercise = {
|
||||||
|
exercise_id: 'ex_11111111-1111-4111-8111-111111111111', name: 'Développé couché',
|
||||||
|
recording_mode: 'sets' as const, tracking_mode: 'reps' as const, data_fields: 0,
|
||||||
|
retireable: true, primary_zone_id: 'chest', secondary_zone_ids: ['arms'],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ExercisesPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url: string) => {
|
||||||
|
if (String(url).startsWith('/api/v1/exercise-zones')) return Promise.resolve(new Response(
|
||||||
|
JSON.stringify({ api_version: 1, zones: [{ zone_id: 'chest', name: 'Pectoraux' },
|
||||||
|
{ zone_id: 'arms', name: 'Bras' }] }), { status: 200 }))
|
||||||
|
if (String(url).startsWith('/api/v1/exercises')) return Promise.resolve(new Response(
|
||||||
|
JSON.stringify({ api_version: 1, offset: 0, more: false, next_offset: 1,
|
||||||
|
items: [exercise] }), { status: 200 }))
|
||||||
|
return Promise.reject(new Error(`unexpected ${url}`))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the real responsive catalogue, search, filters and navigation', async () => {
|
||||||
|
const navigate = vi.fn()
|
||||||
|
render(<ExercisesPage path="/exercices" onNavigate={navigate} />)
|
||||||
|
expect(await screen.findByText('Développé couché')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { name: 'Exercices' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('option', { name: 'Séries + répétitions' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('option', { name: 'Non renseignés' })).toBeInTheDocument()
|
||||||
|
fireEvent.change(screen.getByLabelText('Rechercher'), { target: { value: 'développé' } })
|
||||||
|
await waitFor(() => expect(vi.mocked(fetch).mock.calls.some(([url]) =>
|
||||||
|
String(url).includes('search=d%C3%A9velopp%C3%A9'))).toBe(true))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Développé couché/ }))
|
||||||
|
expect(navigate).toHaveBeenCalledWith(`/exercices/${exercise.exercise_id}`)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '+ Nouvel exercice' }))
|
||||||
|
expect(navigate).toHaveBeenCalledWith('/exercices/nouveau')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prevents continuous repetitions in the creation form', async () => {
|
||||||
|
render(<ExercisesPage path="/exercices/nouveau" onNavigate={vi.fn()} />)
|
||||||
|
await screen.findByRole('option', { name: 'Pectoraux' })
|
||||||
|
fireEvent.click(screen.getByLabelText('Continu'))
|
||||||
|
expect(screen.getByLabelText('Répétitions')).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', { name: 'Créer l’exercice' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
241
web/src/routes/ExercisesPage.tsx
Normal file
241
web/src/routes/ExercisesPage.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
createExercise,
|
||||||
|
ExerciseApiError,
|
||||||
|
fetchBodyZones,
|
||||||
|
fetchExercise,
|
||||||
|
fetchExercises,
|
||||||
|
retireExercise,
|
||||||
|
updateExercise,
|
||||||
|
type BodyZoneChoice,
|
||||||
|
type ExerciseDetail,
|
||||||
|
type ExerciseInput,
|
||||||
|
type ExerciseSummary,
|
||||||
|
} from '../api/exercises'
|
||||||
|
import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog'
|
||||||
|
|
||||||
|
interface Props { path: string; onNavigate: (path: string) => void }
|
||||||
|
type ProfileFilter = '' | 'sets_reps' | 'sets_duration' | 'continuous_duration'
|
||||||
|
|
||||||
|
function profileLabel(exercise: Pick<ExerciseSummary, 'recording_mode' | 'tracking_mode'>): string {
|
||||||
|
if (exercise.recording_mode === 'continuous') return 'Continu · durée'
|
||||||
|
return exercise.tracking_mode === 'duration' ? 'Séries · durée' : 'Séries · répétitions'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldsLabel(fields: number): string {
|
||||||
|
const labels: string[] = []
|
||||||
|
if ((fields & 1) !== 0) labels.push('Vitesse')
|
||||||
|
if ((fields & 2) !== 0) labels.push('Distance')
|
||||||
|
return labels.length === 0 ? 'Aucun champ supplémentaire' : labels.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (!(error instanceof ExerciseApiError)) return 'Une erreur inattendue est survenue.'
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
invalid_exercise: 'Vérifiez le nom, le profil et les zones sélectionnées.',
|
||||||
|
exercise_conflict: 'Un exercice porte déjà ce nom normalisé.',
|
||||||
|
exercise_revision_conflict: 'Cet exercice a été modifié. Rechargez sa fiche.',
|
||||||
|
exercise_retirement_forbidden: 'Cette identité fournie par Trainlog est protégée.',
|
||||||
|
exercise_not_found: 'Cet exercice est introuvable.',
|
||||||
|
}
|
||||||
|
return messages[error.code] ?? 'L’opération n’a pas pu être effectuée.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyInput(): ExerciseInput {
|
||||||
|
return { name: '', recording_mode: 'sets', tracking_mode: 'reps', data_fields: 0,
|
||||||
|
primary_zone_id: null, secondary_zone_ids: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputFromDetail(detail: ExerciseDetail): ExerciseInput {
|
||||||
|
return { name: detail.name, recording_mode: detail.recording_mode,
|
||||||
|
tracking_mode: detail.tracking_mode, data_fields: detail.data_fields,
|
||||||
|
primary_zone_id: detail.primary_zone_id,
|
||||||
|
secondary_zone_ids: [...detail.secondary_zone_ids] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExerciseForm({ initial, zones, submitLabel, onCancel, onSubmit }: {
|
||||||
|
initial: ExerciseInput; zones: BodyZoneChoice[]; submitLabel: string
|
||||||
|
onCancel: () => void; onSubmit: (input: ExerciseInput) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [input, setInput] = useState(initial)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [confirmProfile, setConfirmProfile] = useState(false)
|
||||||
|
const profileChanged = input.recording_mode !== initial.recording_mode ||
|
||||||
|
input.tracking_mode !== initial.tracking_mode || input.data_fields !== initial.data_fields
|
||||||
|
const invalidProfile = input.recording_mode === 'continuous' && input.tracking_mode === 'reps'
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try { await onSubmit(input) } catch (reason) { setError(errorMessage(reason)); setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSave = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (profileChanged && !confirmProfile) { setConfirmProfile(true); return }
|
||||||
|
void save()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <form className="exercise-form" onSubmit={requestSave}>
|
||||||
|
<label>Nom
|
||||||
|
<input required maxLength={200} value={input.name}
|
||||||
|
onChange={(event) => setInput({ ...input, name: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<fieldset><legend>Mode de saisie</legend>
|
||||||
|
<label><input type="radio" name="recording" checked={input.recording_mode === 'sets'}
|
||||||
|
onChange={() => setInput({ ...input, recording_mode: 'sets', data_fields: 0 })} /> Séries</label>
|
||||||
|
<label><input type="radio" name="recording" checked={input.recording_mode === 'continuous'}
|
||||||
|
onChange={() => setInput({ ...input, recording_mode: 'continuous', tracking_mode: 'duration' })} /> Continu</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset><legend>Suivi</legend>
|
||||||
|
<label><input type="radio" name="tracking" checked={input.tracking_mode === 'reps'}
|
||||||
|
disabled={input.recording_mode === 'continuous'}
|
||||||
|
onChange={() => setInput({ ...input, tracking_mode: 'reps' })} /> Répétitions</label>
|
||||||
|
<label><input type="radio" name="tracking" checked={input.tracking_mode === 'duration'}
|
||||||
|
onChange={() => setInput({ ...input, tracking_mode: 'duration' })} /> Durée</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset disabled={input.recording_mode === 'sets'}><legend>Champs supplémentaires</legend>
|
||||||
|
<label><input type="checkbox" checked={(input.data_fields & 1) !== 0}
|
||||||
|
onChange={(event) => setInput({ ...input,
|
||||||
|
data_fields: event.target.checked ? input.data_fields | 1 : input.data_fields & ~1 })} /> Vitesse</label>
|
||||||
|
<label><input type="checkbox" checked={(input.data_fields & 2) !== 0}
|
||||||
|
onChange={(event) => setInput({ ...input,
|
||||||
|
data_fields: event.target.checked ? input.data_fields | 2 : input.data_fields & ~2 })} /> Distance</label>
|
||||||
|
</fieldset>
|
||||||
|
<label>Zone principale
|
||||||
|
<select required={input.recording_mode === 'sets'} value={input.primary_zone_id ?? ''}
|
||||||
|
onChange={(event) => setInput({ ...input, primary_zone_id: event.target.value || null,
|
||||||
|
secondary_zone_ids: input.secondary_zone_ids.filter((id) => id !== event.target.value) })}>
|
||||||
|
<option value="">Non renseignée</option>
|
||||||
|
{zones.map((zone) => <option key={zone.zone_id} value={zone.zone_id}>{zone.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset disabled={!input.primary_zone_id}><legend>Zones secondaires</legend>
|
||||||
|
<div className="exercise-zone-options">{zones.filter((zone) => zone.zone_id !== input.primary_zone_id).map((zone) =>
|
||||||
|
<label key={zone.zone_id}><input type="checkbox"
|
||||||
|
checked={input.secondary_zone_ids.includes(zone.zone_id)}
|
||||||
|
onChange={(event) => setInput({ ...input, secondary_zone_ids: event.target.checked
|
||||||
|
? [...input.secondary_zone_ids, zone.zone_id]
|
||||||
|
: input.secondary_zone_ids.filter((id) => id !== zone.zone_id) })} /> {zone.name}</label>)}</div>
|
||||||
|
</fieldset>
|
||||||
|
{invalidProfile && <p className="form-error" role="alert">Continu + répétitions est invalide.</p>}
|
||||||
|
{confirmProfile && <div className="profile-confirmation" role="alert">
|
||||||
|
<p>Cette modification changera la saisie des futurs entraînements. Les séances déjà enregistrées resteront inchangées.</p>
|
||||||
|
<button type="button" className="quiet-action" onClick={() => setConfirmProfile(false)}>Revenir au formulaire</button>
|
||||||
|
</div>}
|
||||||
|
{error && <p className="form-error" role="alert">{error}</p>}
|
||||||
|
<div className="editor-actions">
|
||||||
|
<button type="button" className="quiet-action" disabled={busy} onClick={onCancel}>Annuler</button>
|
||||||
|
<button type="submit" className="primary-action" disabled={busy || invalidProfile}>
|
||||||
|
{busy ? 'Enregistrement…' : confirmProfile ? 'Confirmer la modification' : submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailView({ exerciseId, zones, onNavigate }: {
|
||||||
|
exerciseId: string; zones: BodyZoneChoice[]; onNavigate: (path: string) => void
|
||||||
|
}) {
|
||||||
|
const [detail, setDetail] = useState<ExerciseDetail | null>(null)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [retiring, setRetiring] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const retireButton = useRef<HTMLButtonElement>(null)
|
||||||
|
const zoneNames = useMemo(() => new Map(zones.map((zone) => [zone.zone_id, zone.name])), [zones])
|
||||||
|
const load = () => fetchExercise(exerciseId).then(setDetail).catch((reason) => setError(errorMessage(reason)))
|
||||||
|
useEffect(() => { void load() }, [exerciseId])
|
||||||
|
|
||||||
|
if (error && detail === null) return <div className="error-panel" role="alert">{error}</div>
|
||||||
|
if (detail === null) return <p>Chargement de l’exercice…</p>
|
||||||
|
if (editing) return <ExerciseForm initial={inputFromDetail(detail)} zones={zones}
|
||||||
|
submitLabel="Enregistrer" onCancel={() => setEditing(false)} onSubmit={async (input) => {
|
||||||
|
const updated = await updateExercise(detail.exercise_id, detail.revision, input)
|
||||||
|
setDetail(updated); setEditing(false)
|
||||||
|
setMessage('Modification enregistrée sur le PC. Elle sera synchronisée vers Android lors de la prochaine synchronisation.')
|
||||||
|
}} />
|
||||||
|
|
||||||
|
return <section className="exercise-detail">
|
||||||
|
<button className="quiet-action" type="button" onClick={() => onNavigate('/exercices')}>← Catalogue</button>
|
||||||
|
<div className="exercise-detail-heading"><div><p className="eyebrow">FICHE EXERCICE</p><h1>{detail.name}</h1></div>
|
||||||
|
<div className="detail-actions"><button className="primary-action" type="button" onClick={() => setEditing(true)}>Modifier</button>
|
||||||
|
{detail.retireable ? <button ref={retireButton} className="danger-action" type="button" onClick={() => setRetiring(true)}>Retirer du catalogue</button>
|
||||||
|
: <span className="protected-identity">Identité Trainlog protégée</span>}</div></div>
|
||||||
|
<dl className="exercise-facts">
|
||||||
|
<div><dt>Identité technique</dt><dd><code>{detail.exercise_id}</code></dd></div>
|
||||||
|
<div><dt>Profil</dt><dd>{profileLabel(detail)}</dd></div>
|
||||||
|
<div><dt>Champs supplémentaires</dt><dd>{fieldsLabel(detail.data_fields)}</dd></div>
|
||||||
|
<div><dt>Zone principale</dt><dd>{detail.primary_zone_id ? zoneNames.get(detail.primary_zone_id) ?? detail.primary_zone_id : 'Non renseignée'}</dd></div>
|
||||||
|
<div><dt>Zones secondaires</dt><dd>{detail.secondary_zone_ids.map((id) => zoneNames.get(id) ?? id).join(', ') || 'Aucune'}</dd></div>
|
||||||
|
<div><dt>Équipements observés</dt><dd>{detail.equipment.map((item) => item.display_name).join(', ') || 'Aucun équipement associé connu'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{message && <p className="success-panel" aria-live="polite">{message}</p>}
|
||||||
|
{retiring && <DeleteConfirmationDialog title={detail.name} itemType="EXERCICE"
|
||||||
|
consequence="Retirer cet exercice l’empêche d’être utilisé pour de nouvelles saisies. Les séances déjà réalisées restent conservées."
|
||||||
|
cancelLabel="Annuler" confirmLabel="Retirer" busy={false} error="" returnFocus={retireButton}
|
||||||
|
onCancel={() => setRetiring(false)} onConfirm={() => { void retireExercise(detail.exercise_id, detail.revision)
|
||||||
|
.then(() => onNavigate('/exercices')).catch((reason) => { setRetiring(false); setError(errorMessage(reason)) }) }} />}
|
||||||
|
{error && <p className="form-error" role="alert">{error}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExercisesPage({ path, onNavigate }: Props) {
|
||||||
|
const [zones, setZones] = useState<BodyZoneChoice[]>([])
|
||||||
|
const [items, setItems] = useState<ExerciseSummary[]>([])
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [profile, setProfile] = useState<ProfileFilter>('')
|
||||||
|
const [zone, setZone] = useState('')
|
||||||
|
const [unclassified, setUnclassified] = useState(false)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [more, setMore] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const isNew = path === '/exercices/nouveau'
|
||||||
|
const detailId = path.startsWith('/exercices/') && !isNew ? decodeURIComponent(path.slice('/exercices/'.length)) : ''
|
||||||
|
const zoneNames = useMemo(() => new Map(zones.map((item) => [item.zone_id, item.name])), [zones])
|
||||||
|
|
||||||
|
useEffect(() => { const controller = new AbortController(); fetchBodyZones(controller.signal)
|
||||||
|
.then(setZones).catch((reason) => setError(errorMessage(reason))); return () => controller.abort() }, [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew || detailId) return
|
||||||
|
const controller = new AbortController()
|
||||||
|
fetchExercises({ offset, search, profile, zone: unclassified ? '' : zone, unclassified }, controller.signal)
|
||||||
|
.then((page) => { setItems(page.items); setMore(page.more); setError('') })
|
||||||
|
.catch((reason) => setError(errorMessage(reason)))
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [detailId, isNew, offset, profile, search, unclassified, zone])
|
||||||
|
|
||||||
|
if (detailId) return <DetailView exerciseId={detailId} zones={zones} onNavigate={onNavigate} />
|
||||||
|
if (isNew) return <section className="page"><p className="eyebrow">NOUVEL EXERCICE</p><h1>Créer un exercice</h1>
|
||||||
|
<ExerciseForm initial={emptyInput()} zones={zones} submitLabel="Créer l’exercice"
|
||||||
|
onCancel={() => onNavigate('/exercices')} onSubmit={async (input) => {
|
||||||
|
const created = await createExercise(input); onNavigate(`/exercices/${created.exercise_id}`)
|
||||||
|
}} /></section>
|
||||||
|
|
||||||
|
return <section className="page exercises-page">
|
||||||
|
<div className="page-heading"><div><p className="eyebrow">CATALOGUE D’EXERCICES</p><h1>Exercices</h1></div>
|
||||||
|
<button className="primary-action" type="button" onClick={() => onNavigate('/exercices/nouveau')}>+ Nouvel exercice</button></div>
|
||||||
|
<div className="exercise-toolbar">
|
||||||
|
<label>Rechercher<input value={search} onChange={(event) => { setSearch(event.target.value); setOffset(0) }} /></label>
|
||||||
|
<label>Profil<select value={profile} onChange={(event) => { setProfile(event.target.value as ProfileFilter); setOffset(0) }}>
|
||||||
|
<option value="">Tous profils</option><option value="sets_reps">Séries + répétitions</option>
|
||||||
|
<option value="sets_duration">Séries + durée</option><option value="continuous_duration">Continu + durée</option></select></label>
|
||||||
|
<label>Body zone<select value={unclassified ? '__none__' : zone} onChange={(event) => {
|
||||||
|
setUnclassified(event.target.value === '__none__'); setZone(event.target.value === '__none__' ? '' : event.target.value); setOffset(0)
|
||||||
|
}}><option value="">Toutes</option><option value="__none__">Non renseignés</option>
|
||||||
|
{zones.map((item) => <option key={item.zone_id} value={item.zone_id}>{item.name}</option>)}</select></label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-panel" role="alert">{error}</p>}
|
||||||
|
{message && <p className="success-panel" aria-live="polite">{message}</p>}
|
||||||
|
<div className="exercise-list">{items.map((exercise) => <article className="exercise-card" key={exercise.exercise_id}>
|
||||||
|
<button className="exercise-card-main" type="button" onClick={() => onNavigate(`/exercices/${exercise.exercise_id}`)}>
|
||||||
|
<strong>{exercise.name}</strong><span>{profileLabel(exercise)}</span><span>{fieldsLabel(exercise.data_fields)}</span>
|
||||||
|
<span>{exercise.primary_zone_id ? zoneNames.get(exercise.primary_zone_id) ?? exercise.primary_zone_id : 'Non renseignée'}</span>
|
||||||
|
</button><button type="button" className="quiet-action" onClick={() => onNavigate(`/exercices/${exercise.exercise_id}`)}>Modifier</button>
|
||||||
|
</article>)}</div>
|
||||||
|
{items.length === 0 && !error && <p className="empty-inline">Aucun exercice ne correspond aux filtres.</p>}
|
||||||
|
<div className="exercise-pagination"><button className="quiet-action" type="button" disabled={offset === 0}
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - 24))}>Page précédente</button>
|
||||||
|
<button className="quiet-action" type="button" disabled={!more} onClick={() => setOffset(offset + 24)}>Page suivante</button></div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
@ -218,6 +218,39 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
||||||
.program-calendar-legend { margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .45rem; list-style: none; }
|
.program-calendar-legend { margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .45rem; list-style: none; }
|
||||||
.program-calendar-legend li, .program-calendar-state-label { padding: .3rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: var(--font-size-xs); font-weight: 700; }
|
.program-calendar-legend li, .program-calendar-state-label { padding: .3rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: var(--font-size-xs); font-weight: 700; }
|
||||||
.program-calendar-state-todo { color: var(--lavender); background: color-mix(in srgb, var(--lavender) 7%, transparent); }
|
.program-calendar-state-todo { color: var(--lavender); background: color-mix(in srgb, var(--lavender) 7%, transparent); }
|
||||||
|
|
||||||
|
.exercise-toolbar { display: grid; grid-template-columns: minmax(14rem, 2fr) repeat(2, minmax(11rem, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
.exercise-toolbar label, .exercise-form > label { display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||||
|
.exercise-toolbar input, .exercise-toolbar select, .exercise-form input[type="text"], .exercise-form > label > input, .exercise-form select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; color: var(--text); background: var(--base); border: 1px solid var(--surface-2); border-radius: .5rem; }
|
||||||
|
.exercise-list { display: grid; gap: .65rem; }
|
||||||
|
.exercise-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: stretch; overflow: hidden; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
|
||||||
|
.exercise-card:hover { border-color: var(--lavender); }
|
||||||
|
.exercise-card-main { display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(8rem, 1fr)); gap: 1rem; align-items: center; padding: 1rem 1.1rem; color: var(--text); background: transparent; border: 0; text-align: left; cursor: pointer; }
|
||||||
|
.exercise-card-main span { color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||||
|
.exercise-card > .quiet-action { margin: .65rem; }
|
||||||
|
.exercise-pagination { display: flex; justify-content: flex-end; gap: .65rem; margin-top: 1rem; }
|
||||||
|
.exercise-detail { width: min(100%, 70rem); margin: 0 auto; }
|
||||||
|
.exercise-detail-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin: 2rem 0; }
|
||||||
|
.exercise-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
|
||||||
|
.exercise-facts div { padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--mantle); }
|
||||||
|
.exercise-facts dt { margin-bottom: .4rem; color: var(--subtext-0); font-size: var(--font-size-xs); text-transform: uppercase; }
|
||||||
|
.exercise-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.protected-identity { align-self: center; color: var(--yellow); font-size: var(--font-size-sm); }
|
||||||
|
.exercise-form { width: min(100%, 54rem); display: grid; gap: 1rem; margin-top: 2rem; padding: clamp(1rem, 3vw, 2rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
|
||||||
|
.exercise-form fieldset { display: flex; flex-wrap: wrap; gap: .75rem 1.25rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .65rem; }
|
||||||
|
.exercise-form legend { padding: 0 .35rem; color: var(--subtext-0); }
|
||||||
|
.exercise-form fieldset label { display: flex; align-items: center; gap: .45rem; }
|
||||||
|
.exercise-zone-options { display: grid; grid-template-columns: repeat(2, minmax(12rem, 1fr)); gap: .65rem; width: 100%; }
|
||||||
|
.profile-confirmation { padding: 1rem; border: 1px solid var(--yellow); border-radius: .65rem; color: var(--yellow); background: rgb(249 226 175 / 7%); }
|
||||||
|
|
||||||
|
@media (max-width: 50rem) {
|
||||||
|
.exercise-toolbar, .exercise-facts { grid-template-columns: 1fr; }
|
||||||
|
.exercise-card { grid-template-columns: 1fr; }
|
||||||
|
.exercise-card-main { grid-template-columns: 1fr; gap: .35rem; }
|
||||||
|
.exercise-card > .quiet-action { justify-self: start; }
|
||||||
|
.exercise-detail-heading { align-items: start; flex-direction: column; }
|
||||||
|
.exercise-zone-options { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
.program-calendar-state-prepared { color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, transparent); }
|
.program-calendar-state-prepared { color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, transparent); }
|
||||||
.program-calendar-state-in_progress { color: var(--sapphire); background: color-mix(in srgb, var(--sapphire) 9%, transparent); }
|
.program-calendar-state-in_progress { color: var(--sapphire); background: color-mix(in srgb, var(--sapphire) 9%, transparent); }
|
||||||
.program-calendar-state-completed { color: var(--green); background: color-mix(in srgb, var(--green) 8%, transparent); }
|
.program-calendar-state-completed { color: var(--green); background: color-mix(in srgb, var(--green) 8%, transparent); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue