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.
|
||||
|
||||
- Added the loopback-only `/exercices` desktop catalogue administration
|
||||
surface with bounded search/profile/BODY ZONES filtering, detail and
|
||||
read-only equipment presentation, Core-owned creation and editing, complete
|
||||
optimistic concurrency, and protected causal retirement. Profile changes
|
||||
preserve stable exercise IDs and all historical occurrence snapshots.
|
||||
Mutations remain desktop-local until the existing USB-priority, Drive
|
||||
mirror/fallback synchronization publishes the existing catalogue companions;
|
||||
no Android code, schema, exchange format, or automatic synchronization was
|
||||
added.
|
||||
|
||||
## 0.1.2 — 2026-09-20
|
||||
|
||||
- Added USB-priority full-generation synchronization with a separately
|
||||
|
|
|
|||
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
|
||||
field companion, its C17/Notcurses desktop TUI is the administration and
|
||||
technical surface, and its local browser Web sibling provides the implemented
|
||||
Dashboard, Sessions, and Programmes surfaces while reserving Analyse and
|
||||
Exercises for separately contracted work.
|
||||
Dashboard, Sessions, Programmes, and Exercises surfaces while reserving Analyse
|
||||
for separately contracted work.
|
||||
|
||||
Android and desktop each own a local SQLite database. The desktop database is
|
||||
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
|
||||
|
|
@ -24,7 +24,7 @@ over direct MTP; it never copies SQLite database files between devices.
|
|||
|---|---|
|
||||
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
|
||||
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
||||
| Local Web (0.1.3 development) | Display the Dashboard, the operational top-level Programmes daily active-program calendar, and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and Programs administration workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse and Exercises remain placeholders. |
|
||||
| Local Web (0.1.3 development) | Display the Dashboard, the operational Programmes calendar, Sessions administration, and the desktop exercise catalogue through typed Trainlog Core/API boundaries. Exercises supports search/filter, detail, creation, editing and causal retirement; equipment is read-only. Analyse remains a placeholder. |
|
||||
|
||||
No interface reconstructs business truth from SQLite tables. The local Web is
|
||||
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
||||
|
|
@ -34,8 +34,8 @@ make the remaining placeholder routes functional.
|
|||
The top-level `/programmes` route is an operational daily calendar for active
|
||||
Programs. Sessions → Programmes remains the technical administration/import,
|
||||
list, detail, archive, and delete surface.
|
||||
The ordered 0.1.3 development focus is an Android UX redesign followed by Web
|
||||
Exercises administration; both remain planned, separately contracted work.
|
||||
Web Exercises administration is implemented for 0.1.3 development. The next
|
||||
separately contracted priority is the Android UX redesign.
|
||||
|
||||
## Releases
|
||||
|
||||
|
|
@ -349,8 +349,8 @@ As of 2026-09-18:
|
|||
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
|
||||
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
||||
`/programmes` is an operational active-Program calendar, while
|
||||
Sessions → Programmes remains the administration surface; Analyse and
|
||||
Exercises remain placeholders;
|
||||
Sessions → Programmes remains the administration surface;
|
||||
`TRAINLOG_WEB_EXERCISES_V1=PASS`; Analyse remains a placeholder;
|
||||
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
|
||||
private desktop/Android deployment, real Firefox presentation, correlated
|
||||
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ evidence and chronology.
|
|||
|
||||
## Interfaces
|
||||
|
||||
- [Web Exercises V1](web_exercises.md) owns desktop catalogue administration,
|
||||
optimistic editing, historical preservation, causal retirement, and its
|
||||
existing-artifact synchronization boundary.
|
||||
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
|
||||
history and Android-delivery workflow.
|
||||
- [Program format V1](program_format_v1.md) owns the strict desktop planning
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ parallel implementation of its rules.
|
|||
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
|
||||
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
|
||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
||||
| Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` |
|
||||
| Next after Android redesign | `TRAINLOG_WEB_EXERCISES_V1` |
|
||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
|
|
@ -250,8 +250,18 @@ resume and history views, stable details, optimistic manual-preparation writes,
|
|||
explicit proposal derivation and generation-backed Android delivery. The
|
||||
top-level `/programmes` route is an operational daily active-Program calendar;
|
||||
Sessions → Programmes remains the technical administration/import, list,
|
||||
detail, archive, and delete surface. Analyse and Exercises remain explicit
|
||||
placeholders.
|
||||
detail, archive, and delete surface. `/exercices` is the operational desktop
|
||||
catalogue administration surface; Analyse remains an explicit placeholder.
|
||||
|
||||
Exercises uses bounded Core-owned list/detail reads, canonical name
|
||||
normalization, BODY ZONES taxonomy, profiled creation/update commands and
|
||||
causal retirement. Its deterministic optimistic token covers all editable
|
||||
catalogue state. Built-in identities cannot be retired, equipment associations
|
||||
are presented read-only, and profile edits affect future use only: occurrence
|
||||
snapshots, performed sets, feedback and MAX history are never rewritten. A
|
||||
successful mutation changes only the canonical desktop database; the existing
|
||||
USB-priority, Drive mirror/fallback synchronization later carries the existing
|
||||
catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync.
|
||||
|
||||
The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
||||
`fetchProgram`, and `createPreparationFromProgram`; it derives no local program
|
||||
|
|
@ -518,10 +528,9 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
|
|||
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
|
||||
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
||||
|
||||
- The next planned work is `TRAINLOG_ANDROID_UI_REDESIGN_V1`; it is not yet
|
||||
implemented. `TRAINLOG_WEB_EXERCISES_V1` follows it and will replace the
|
||||
current `/exercices` placeholder only after a separate Core/read/command
|
||||
contract. Analyse remains a placeholder and is not a v0.1.3 priority.
|
||||
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. The next planned work is
|
||||
`TRAINLOG_ANDROID_UI_REDESIGN_V1`; it remains unimplemented. Analyse remains
|
||||
a placeholder and is not a v0.1.3 priority.
|
||||
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
|
||||
observations are inputs to, not decisions for, the new 0.1.3 Android contract.
|
||||
- Session Generator V1 is hidden while V2 planning semantics are developed.
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ tile rendering exist. The Dashboard's bounded progression and BODY ZONES
|
|||
visualizations and Sessions V1 are implemented. The top-level `/programmes`
|
||||
route is an operational daily active-Program calendar, while
|
||||
Sessions → Programmes remains the technical administration/import, list,
|
||||
detail, archive, and delete surface. Analyse and Exercises remain placeholders.
|
||||
Exercises is now the second ordered v0.1.3 priority; Analyse remains outside
|
||||
this cycle unless a later explicit decision changes that order.
|
||||
detail, archive, and delete surface. Web Exercises V1 is implemented; Analyse
|
||||
remains a placeholder outside this cycle unless a later explicit decision
|
||||
changes that order.
|
||||
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
||||
Sessions presentation correction, local date preference and durable preparation
|
||||
withdrawal are implemented. The withdrawal uses the separate versioned
|
||||
|
|
@ -60,7 +60,6 @@ causal-deletion contracts without reopening those completed gates.
|
|||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT
|
||||
NEXT_AFTER_ANDROID_UI_REDESIGN=TRAINLOG_WEB_EXERCISES_V1
|
||||
```
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
|
|
@ -166,20 +165,21 @@ not authorize implementation and it did not select Analyse.
|
|||
|
||||
## Trainlog v0.1.3
|
||||
|
||||
Trainlog v0.1.3 has two ordered priorities:
|
||||
Trainlog v0.1.3 retains two priorities. Web Exercises was completed first; the
|
||||
cursor now returns to the Android redesign:
|
||||
|
||||
1. the global Android experience redesign;
|
||||
2. the complete Web Exercices surface.
|
||||
1. the complete Web Exercices surface;
|
||||
2. the global Android experience redesign.
|
||||
|
||||
```text
|
||||
TRAINLOG_ANDROID_UI_REDESIGN_V1
|
||||
-> TRAINLOG_WEB_EXERCISES_V1
|
||||
TRAINLOG_WEB_EXERCISES_V1
|
||||
-> TRAINLOG_ANDROID_UI_REDESIGN_V1
|
||||
```
|
||||
|
||||
Each lot requires its own reviewed contract before implementation. This roadmap
|
||||
is documentary ordering, not authorization to implement both lots in one
|
||||
ticket. Analyse and every other new Web module remain after these priorities
|
||||
unless a later explicit decision changes the order.
|
||||
The Web lot is implemented and validated. The Android lot still requires its
|
||||
own reviewed contract before implementation. Analyse and every other new Web
|
||||
module remain after this priority unless a later explicit decision changes the
|
||||
order.
|
||||
|
||||
### TRAINLOG_ANDROID_UI_REDESIGN_V1
|
||||
|
||||
|
|
@ -222,12 +222,12 @@ will be made screen by screen.
|
|||
|
||||
### TRAINLOG_WEB_EXERCISES_V1
|
||||
|
||||
The top-level `/exercices` placeholder will become the desktop catalogue
|
||||
administration surface. The first step is an audit of existing Core catalogue
|
||||
services and persisted capabilities; implementation is not authorized until a
|
||||
separate read/command contract has been reviewed.
|
||||
`TRAINLOG_WEB_EXERCISES_V1=PASS`. The top-level `/exercices` route is the
|
||||
desktop catalogue administration surface. It uses bounded Core read/command
|
||||
services and a deterministic optimistic token; it does not put business rules
|
||||
in React.
|
||||
|
||||
The future lot includes:
|
||||
The delivered lot includes:
|
||||
|
||||
- **Catalogue:** a complete or paged list according to the Core contract,
|
||||
search, relevant filters, available/retired state when the model supports it,
|
||||
|
|
@ -239,7 +239,8 @@ The future lot includes:
|
|||
business truth.
|
||||
- **Creation:** create through a real transactional Core command service, with
|
||||
name, profile, tracking, `data_fields`, primary and secondary zones, and
|
||||
equipment/associations only where the Core contract permits them.
|
||||
equipment associations remain read-only because no bounded association
|
||||
command is part of this contract.
|
||||
- **Modification:** expose only Core-authorized changes. The contract must
|
||||
distinguish mutable and immutable fields, compatible changes, changes that
|
||||
require a new identity, and exercises already referenced by history.
|
||||
|
|
@ -260,36 +261,37 @@ with Drive as mirror/fallback. React must not introduce an Exercises-specific
|
|||
synchronization path. The future contract must first audit the existing
|
||||
catalogue, profile, BODY ZONES, and equipment artifacts to determine whether
|
||||
the planned changes are representable. Frozen formats remain frozen; if a new
|
||||
datum cannot be transported, the contract must state that explicitly before
|
||||
any protocol change.
|
||||
datum cannot be transported, a future contract must state that explicitly
|
||||
before any protocol change. No automatic synchronization is initiated by this
|
||||
surface.
|
||||
|
||||
### Bounded v0.1.3 order
|
||||
|
||||
```text
|
||||
0.1.3
|
||||
|
|
||||
+-- ANDROID_UI_REDESIGN_V1
|
||||
| |
|
||||
| +-- current-state audit
|
||||
| +-- UX/navigation contract
|
||||
| +-- shell / buttons / components
|
||||
| +-- active-session workflow
|
||||
| +-- exercise-entry workflow
|
||||
| +-- real visual review
|
||||
| +-- accessibility validation
|
||||
|
|
||||
+-- WEB_EXERCISES_V1
|
||||
| |
|
||||
| +-- Core/catalogue characterization
|
||||
| +-- read-model contract
|
||||
| +-- command-service contract
|
||||
| +-- Web catalogue
|
||||
| +-- exercise detail
|
||||
| +-- creation
|
||||
| +-- modification
|
||||
| +-- causal retirement
|
||||
| +-- Android synchronization
|
||||
| +-- real validation
|
||||
|
|
||||
+-- ANDROID_UI_REDESIGN_V1
|
||||
|
|
||||
+-- Core/catalogue characterization
|
||||
+-- read-model contract
|
||||
+-- command-service contract
|
||||
+-- Web catalogue
|
||||
+-- exercise detail
|
||||
+-- creation
|
||||
+-- modification
|
||||
+-- causal retirement
|
||||
+-- Android synchronization
|
||||
+-- real validation
|
||||
+-- current-state audit
|
||||
+-- UX/navigation contract
|
||||
+-- shell / buttons / components
|
||||
+-- active-session workflow
|
||||
+-- exercise-entry workflow
|
||||
+-- real visual review
|
||||
+-- accessibility validation
|
||||
```
|
||||
|
||||
## Next
|
||||
|
|
@ -352,8 +354,8 @@ normal capture.
|
|||
## Canonical order
|
||||
|
||||
```text
|
||||
TRAINLOG_ANDROID_UI_REDESIGN_V1
|
||||
-> TRAINLOG_WEB_EXERCISES_V1
|
||||
TRAINLOG_WEB_EXERCISES_V1
|
||||
-> TRAINLOG_ANDROID_UI_REDESIGN_V1
|
||||
-> GYM_CATALOG_V1
|
||||
-> EXERCISE_METADATA_V1
|
||||
-> SESSION_GENERATOR_V2
|
||||
|
|
|
|||
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 json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
|
@ -106,6 +107,22 @@ class CausalDeleteTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(causal.CausalError, "built-in"):
|
||||
causal.local_delete(self.db, "exercise", built_in, "peer")
|
||||
|
||||
def test_retired_exercise_is_omitted_from_next_pc_catalog(self):
|
||||
self.delete("exercise", "ex_user")
|
||||
output = Path(self.tmp.name) / "pc-catalog.json"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "tools/export_pc_catalog.py"),
|
||||
"--database", str(self.path), str(output)],
|
||||
check=False, capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
catalog = json.loads(output.read_text(encoding="utf-8"))
|
||||
self.assertEqual([], catalog["exercises"])
|
||||
self.assertEqual(1, self.db.execute(
|
||||
"SELECT count(*) FROM exercises WHERE exercise_id='ex_user'").fetchone()[0])
|
||||
self.assertEqual(1, self.db.execute(
|
||||
"SELECT count(*) FROM session_exercises WHERE exercise_row_id=1").fetchone()[0])
|
||||
|
||||
def hostile_operation(self, kind, target):
|
||||
operation = {"operation_id": "del_" + "a" * 36, "target_kind": kind,
|
||||
"target_id": target, "creator_id": "hostile_fixture",
|
||||
|
|
|
|||
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}"
|
||||
)
|
||||
|
||||
active_filter = "" if version < 20 else '''
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_causal_state AS causal
|
||||
WHERE causal.target_kind = 'exercise'
|
||||
AND causal.target_id = exercises.exercise_id
|
||||
AND causal.deleted = 1
|
||||
)
|
||||
'''
|
||||
rows = connection.execute(
|
||||
'''
|
||||
SELECT
|
||||
|
|
@ -86,6 +95,7 @@ def main():
|
|||
tracking_mode,
|
||||
data_fields
|
||||
FROM exercises
|
||||
''' + active_filter + '''
|
||||
ORDER BY
|
||||
name COLLATE NOCASE,
|
||||
exercise_id;
|
||||
|
|
|
|||
|
|
@ -250,11 +250,13 @@ TrainlogStatus trainlog_database_list_exercises_filtered(TrainlogDatabase *datab
|
|||
size_t *output_count);
|
||||
|
||||
/* CONTRACT: name/profile/direct zones are one transaction and all string/list
|
||||
* inputs are borrowed only for the duration of the call. A profile change is
|
||||
* rejected once completed history references the exercise; a same-profile
|
||||
* rename or zone replacement preserves exercise_id and history. Zone rules
|
||||
* match replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND;
|
||||
* invalid metadata and name collisions remain explicit errors. */
|
||||
* inputs are borrowed only for the duration of the call. Profile changes affect
|
||||
* future occurrences only because schema v16 snapshots the complete profile on
|
||||
* every occurrence. Existing sessions and drafts are never rewritten. The
|
||||
* function owns a transaction when called in autocommit mode and participates
|
||||
* in an already-open caller transaction otherwise. Zone rules match
|
||||
* replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND; invalid
|
||||
* metadata and name collisions remain explicit errors. */
|
||||
TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *database,
|
||||
const char *exercise_id,
|
||||
const char *name,
|
||||
|
|
|
|||
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/SessionsPage.tsx',
|
||||
'../web/src/api/sessions.ts',
|
||||
'../web/src/api/exercises.ts',
|
||||
'../web/src/api/exercises.test.ts',
|
||||
'../web/src/api/programs.ts',
|
||||
'../web/src/routes/ProgramsTab.tsx',
|
||||
'../web/src/routes/ProgramsCalendarPage.tsx',
|
||||
'../web/src/routes/ExercisesPage.tsx',
|
||||
'../web/src/routes/ExercisesPage.test.tsx',
|
||||
'../web/src/theme/app.css',
|
||||
'../web/src/theme/tokens.css',
|
||||
)
|
||||
|
|
@ -209,6 +213,7 @@ trainlog_core_sources = files(
|
|||
'src/web_sessions.c',
|
||||
'src/web_session_deletions.c',
|
||||
'src/web_programs.c',
|
||||
'src/web_exercises.c',
|
||||
'src/duration.c',
|
||||
'src/id.c',
|
||||
'src/timeutil.c',
|
||||
|
|
@ -382,6 +387,15 @@ test_web_programs = executable(
|
|||
)
|
||||
test('web_programs', test_web_programs)
|
||||
|
||||
test_web_exercises = executable(
|
||||
'test_web_exercises',
|
||||
'tests/test_web_exercises.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
include_directories: trainlog_private_include,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('web_exercises', test_web_exercises)
|
||||
|
||||
test_web_session_deletions = executable(
|
||||
'test_web_session_deletions',
|
||||
'tests/test_web_session_deletions.c',
|
||||
|
|
|
|||
|
|
@ -2817,6 +2817,7 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data
|
|||
sqlite3_stmt *statement = NULL;
|
||||
sqlite3_int64 row_id;
|
||||
TrainlogStatus status;
|
||||
bool owns_transaction;
|
||||
int rc;
|
||||
const char *tracking;
|
||||
const char *recording;
|
||||
|
|
@ -2832,7 +2833,8 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data
|
|||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) {
|
||||
owns_transaction = sqlite3_get_autocommit(database->connection) != 0;
|
||||
if (owns_transaction && execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
|
|
@ -2872,6 +2874,9 @@ TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *data
|
|||
if (status != TRAINLOG_STATUS_OK) {
|
||||
goto rollback;
|
||||
}
|
||||
if (!owns_transaction) {
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) {
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
|
|
@ -2882,7 +2887,9 @@ rollback:
|
|||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
if (owns_transaction) {
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
|
|
|
|||
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 "trainlog/web_dashboard.h"
|
||||
#include "trainlog/web_exercises.h"
|
||||
#include "trainlog/web_prepared_items.h"
|
||||
#include "trainlog/web_programs.h"
|
||||
#include "trainlog/web_session_deletions.h"
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
#define TRAINLOG_HTTP_BACKLOG 32U
|
||||
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
|
||||
#define TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY 256U
|
||||
#define TRAINLOG_EXERCISE_REVISION_CAPACITY 72U
|
||||
|
||||
typedef struct TrainlogWebContext {
|
||||
uint16_t port;
|
||||
|
|
@ -843,6 +845,188 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
|||
return queue_owned_programs_json(connection, status, json, json_size);
|
||||
}
|
||||
|
||||
static enum MHD_Result queue_owned_exercises_json(struct MHD_Connection *connection,
|
||||
TrainlogStatus status,
|
||||
char *json,
|
||||
size_t json_size,
|
||||
const char *conflict_error) {
|
||||
if (status == TRAINLOG_STATUS_NOT_FOUND) {
|
||||
free(json);
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"exercise_not_found\"}\n", NULL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||
free(json);
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_CONFLICT,
|
||||
conflict_error == NULL ? "{\"error\":\"exercise_conflict\"}\n"
|
||||
: conflict_error,
|
||||
NULL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||
free(json);
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_exercise\"}\n", NULL);
|
||||
}
|
||||
if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) {
|
||||
free(json);
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"exercises_unavailable\"}\n",
|
||||
NULL);
|
||||
}
|
||||
(void)json_size;
|
||||
{
|
||||
enum MHD_Result result = queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||
free(json);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
static enum MHD_Result handle_exercises_request(TrainlogWebContext *context,
|
||||
struct MHD_Connection *connection,
|
||||
const char *url,
|
||||
const char *method,
|
||||
bool is_get,
|
||||
TrainlogHttpRequestState *request) {
|
||||
static const char COLLECTION[] = "/api/v1/exercises";
|
||||
static const char DETAIL_PREFIX[] = "/api/v1/exercise/";
|
||||
char *json = NULL;
|
||||
size_t json_size = 0U;
|
||||
const char *request_id;
|
||||
TrainlogStatus status;
|
||||
|
||||
if (strcmp(url, "/api/v1/exercise-zones") == 0) {
|
||||
if (!is_get) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n",
|
||||
"GET");
|
||||
}
|
||||
status = trainlog_web_exercises_zones_json(&json, &json_size);
|
||||
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
|
||||
}
|
||||
if (strcmp(url, COLLECTION) == 0) {
|
||||
if (is_get) {
|
||||
TrainlogWebExercisesQuery query;
|
||||
const char *unclassified;
|
||||
if (!parse_page_number(
|
||||
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "offset"),
|
||||
0U,
|
||||
1000000U,
|
||||
&query.offset) ||
|
||||
!parse_page_number(
|
||||
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit"),
|
||||
24U,
|
||||
TRAINLOG_WEB_EXERCISES_PAGE_MAX,
|
||||
&query.limit) ||
|
||||
query.limit == 0U) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL);
|
||||
}
|
||||
query.search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search");
|
||||
query.profile =
|
||||
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "profile");
|
||||
query.zone_id =
|
||||
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "zone_id");
|
||||
unclassified =
|
||||
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "unclassified");
|
||||
query.unclassified = unclassified != NULL && strcmp(unclassified, "true") == 0;
|
||||
status = trainlog_web_exercises_list_json(context->database, &query, &json, &json_size);
|
||||
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
|
||||
}
|
||||
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n",
|
||||
"GET, POST");
|
||||
}
|
||||
if (!program_mutation_allowed(context, connection)) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||
}
|
||||
request_id =
|
||||
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
||||
if (request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_request_id\"}\n", NULL);
|
||||
}
|
||||
status = trainlog_web_exercises_create_json(
|
||||
context->database, request->body, request->body_size, &json, &json_size);
|
||||
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
|
||||
}
|
||||
if (strncmp(url, DETAIL_PREFIX, strlen(DETAIL_PREFIX)) == 0) {
|
||||
const char *exercise_id = url + strlen(DETAIL_PREFIX);
|
||||
const char *if_match;
|
||||
char revision[TRAINLOG_EXERCISE_REVISION_CAPACITY];
|
||||
if (exercise_id[0] == '\0' || strlen(exercise_id) > TRAINLOG_ID_MAX ||
|
||||
strchr(exercise_id, '/') != NULL) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_identity\"}\n", NULL);
|
||||
}
|
||||
if (is_get) {
|
||||
status = trainlog_web_exercises_detail_json(
|
||||
context->database, exercise_id, &json, &json_size);
|
||||
return queue_owned_exercises_json(connection, status, json, json_size, NULL);
|
||||
}
|
||||
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
|
||||
strcmp(method, MHD_HTTP_METHOD_DELETE) != 0) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n",
|
||||
"GET, PUT, DELETE");
|
||||
}
|
||||
if (!program_mutation_allowed(context, connection)) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||
}
|
||||
request_id =
|
||||
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
||||
if (request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_request_id\"}\n", NULL);
|
||||
}
|
||||
if_match =
|
||||
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
|
||||
if (!parse_quoted_revision(if_match, revision, sizeof(revision))) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_PRECONDITION_REQUIRED,
|
||||
"{\"error\":\"precondition_required\"}\n",
|
||||
NULL);
|
||||
}
|
||||
if (strcmp(method, MHD_HTTP_METHOD_PUT) == 0) {
|
||||
status = trainlog_web_exercises_update_json(context->database,
|
||||
exercise_id,
|
||||
revision,
|
||||
request->body,
|
||||
request->body_size,
|
||||
&json,
|
||||
&json_size);
|
||||
return queue_owned_exercises_json(connection,
|
||||
status,
|
||||
json,
|
||||
json_size,
|
||||
"{\"error\":\"exercise_revision_conflict\"}\n");
|
||||
}
|
||||
if (request->body_size != 0U) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"unexpected_body\"}\n", NULL);
|
||||
}
|
||||
status = trainlog_web_exercises_retire_json(
|
||||
context->database, exercise_id, revision, &json, &json_size);
|
||||
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||
free(json);
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_CONFLICT,
|
||||
"{\"error\":\"exercise_retirement_forbidden\"}\n",
|
||||
NULL);
|
||||
}
|
||||
return queue_owned_exercises_json(
|
||||
connection, status, json, json_size, "{\"error\":\"exercise_revision_conflict\"}\n");
|
||||
}
|
||||
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||
}
|
||||
|
||||
static enum MHD_Result queue_session_deletion_result(struct MHD_Connection *connection,
|
||||
TrainlogStatus status,
|
||||
char *json,
|
||||
|
|
@ -1132,6 +1316,9 @@ static enum MHD_Result handle_request(void *closure,
|
|||
}
|
||||
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||
}
|
||||
if (strncmp(url, "/api/v1/exercise", strlen("/api/v1/exercise")) == 0) {
|
||||
return handle_exercises_request(context, connection, url, method, is_get, state);
|
||||
}
|
||||
if (strncmp(url, "/api/v1/sessions/program", strlen("/api/v1/sessions/program")) == 0) {
|
||||
return handle_program_request(context, connection, url, method, is_get, state);
|
||||
}
|
||||
|
|
|
|||
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 { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage'
|
||||
import { SessionsPage } from '../routes/SessionsPage'
|
||||
import { ExercisesPage } from '../routes/ExercisesPage'
|
||||
import type { RouteId } from './routes'
|
||||
import { useRoute } from './useRoute'
|
||||
import { DatePreferencesProvider } from '../presentation/DatePreferences'
|
||||
|
||||
const placeholderContent: Record<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.'],
|
||||
sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'],
|
||||
exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'],
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
|
|
@ -76,6 +76,7 @@ function AppContent() {
|
|||
/>
|
||||
) : route.id === 'sessions' ? <SessionsPage /> :
|
||||
route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> :
|
||||
route.id === 'exercises' ? <ExercisesPage path={route.path} onNavigate={navigate} /> :
|
||||
<PlaceholderPage title={placeholderContent[route.id][0]}
|
||||
eyebrow={placeholderContent[route.id][1]}
|
||||
description={placeholderContent[route.id][2]} />
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ export const routes: readonly AppRoute[] = [
|
|||
]
|
||||
|
||||
export function routeFromPath(pathname: string): AppRoute {
|
||||
if (pathname === '/seances' || pathname.startsWith('/seances/')) return routes[3]
|
||||
if (pathname === '/seances' || pathname.startsWith('/seances/')) {
|
||||
return { ...routes[3], path: pathname }
|
||||
}
|
||||
if (pathname === '/exercices' || pathname.startsWith('/exercices/')) {
|
||||
return { ...routes[4], path: pathname }
|
||||
}
|
||||
return routes.find((route) => route.path === pathname) ?? routes[0]
|
||||
}
|
||||
|
|
|
|||
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 li, .program-calendar-state-label { padding: .3rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: var(--font-size-xs); font-weight: 700; }
|
||||
.program-calendar-state-todo { color: var(--lavender); background: color-mix(in srgb, var(--lavender) 7%, transparent); }
|
||||
|
||||
.exercise-toolbar { display: grid; grid-template-columns: minmax(14rem, 2fr) repeat(2, minmax(11rem, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.exercise-toolbar label, .exercise-form > label { display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||
.exercise-toolbar input, .exercise-toolbar select, .exercise-form input[type="text"], .exercise-form > label > input, .exercise-form select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; color: var(--text); background: var(--base); border: 1px solid var(--surface-2); border-radius: .5rem; }
|
||||
.exercise-list { display: grid; gap: .65rem; }
|
||||
.exercise-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: stretch; overflow: hidden; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
|
||||
.exercise-card:hover { border-color: var(--lavender); }
|
||||
.exercise-card-main { display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(8rem, 1fr)); gap: 1rem; align-items: center; padding: 1rem 1.1rem; color: var(--text); background: transparent; border: 0; text-align: left; cursor: pointer; }
|
||||
.exercise-card-main span { color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||
.exercise-card > .quiet-action { margin: .65rem; }
|
||||
.exercise-pagination { display: flex; justify-content: flex-end; gap: .65rem; margin-top: 1rem; }
|
||||
.exercise-detail { width: min(100%, 70rem); margin: 0 auto; }
|
||||
.exercise-detail-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin: 2rem 0; }
|
||||
.exercise-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
|
||||
.exercise-facts div { padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--mantle); }
|
||||
.exercise-facts dt { margin-bottom: .4rem; color: var(--subtext-0); font-size: var(--font-size-xs); text-transform: uppercase; }
|
||||
.exercise-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.protected-identity { align-self: center; color: var(--yellow); font-size: var(--font-size-sm); }
|
||||
.exercise-form { width: min(100%, 54rem); display: grid; gap: 1rem; margin-top: 2rem; padding: clamp(1rem, 3vw, 2rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
|
||||
.exercise-form fieldset { display: flex; flex-wrap: wrap; gap: .75rem 1.25rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .65rem; }
|
||||
.exercise-form legend { padding: 0 .35rem; color: var(--subtext-0); }
|
||||
.exercise-form fieldset label { display: flex; align-items: center; gap: .45rem; }
|
||||
.exercise-zone-options { display: grid; grid-template-columns: repeat(2, minmax(12rem, 1fr)); gap: .65rem; width: 100%; }
|
||||
.profile-confirmation { padding: 1rem; border: 1px solid var(--yellow); border-radius: .65rem; color: var(--yellow); background: rgb(249 226 175 / 7%); }
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.exercise-toolbar, .exercise-facts { grid-template-columns: 1fr; }
|
||||
.exercise-card { grid-template-columns: 1fr; }
|
||||
.exercise-card-main { grid-template-columns: 1fr; gap: .35rem; }
|
||||
.exercise-card > .quiet-action { justify-self: start; }
|
||||
.exercise-detail-heading { align-items: start; flex-direction: column; }
|
||||
.exercise-zone-options { grid-template-columns: 1fr; }
|
||||
}
|
||||
.program-calendar-state-prepared { color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, transparent); }
|
||||
.program-calendar-state-in_progress { color: var(--sapphire); background: color-mix(in srgb, var(--sapphire) 9%, transparent); }
|
||||
.program-calendar-state-completed { color: var(--green); background: color-mix(in srgb, var(--green) 8%, transparent); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue