From f7b556cc37bc63e597e5202db5a782def4749a08 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 18:06:56 +0200 Subject: [PATCH] Fix sync MTP timeout and prepared-item visibility --- CHANGELOG.md | 13 ++ docs/current_state.md | 16 ++ docs/roadmap.md | 3 + docs/sync_exchange.md | 17 +++ docs/tests.md | 12 ++ tests/test_sync_error_classification.py | 78 ++++++++++ tests/test_sync_orchestrator.py | 4 +- tools/sync_orchestrator.py | 16 ++ tools/sync_peer_worker.py | 104 +++++++++++-- tui/include/trainlog/web_prepared_items.h | 49 ++++++ tui/meson.build | 11 ++ tui/src/web_prepared_items.c | 174 ++++++++++++++++++++++ tui/src/web_prepared_items_json.c | 140 +++++++++++++++++ tui/src/web_server.c | 22 +++ tui/tests/test_web_prepared_items.c | 110 ++++++++++++++ tui/tests/test_web_server.c | 13 ++ web/src/api/preparedItems.test.ts | 21 +++ web/src/api/preparedItems.ts | 56 +++++++ web/src/api/sync.ts | 135 +++++++++++++++-- web/src/app/App.tsx | 37 ++++- web/src/components/SyncControl.test.tsx | 68 +++++++-- web/src/components/SyncControl.tsx | 161 ++++++++++++++++++-- web/src/dashboard/DashboardGrid.tsx | 41 ++++- web/src/dashboard/DashboardTiles.test.tsx | 29 +++- web/src/dashboard/DashboardTiles.tsx | 44 +++++- web/src/routes/DashboardPage.tsx | 28 +++- 26 files changed, 1345 insertions(+), 57 deletions(-) create mode 100644 tests/test_sync_error_classification.py create mode 100644 tui/include/trainlog/web_prepared_items.h create mode 100644 tui/src/web_prepared_items.c create mode 100644 tui/src/web_prepared_items_json.c create mode 100644 tui/tests/test_web_prepared_items.c create mode 100644 web/src/api/preparedItems.test.ts create mode 100644 web/src/api/preparedItems.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 17dd0a4..73aa50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased — 0.1.2 +- Fixed a production full-generation timeout caused by recursively uploading + retained staging and unrelated transport history on every MTP push. Each + phase now publishes a private bounded outbox containing only its request, + ACK, and current referenced generation, while preserving every retained + generation, tombstone, and causal proof. Transport expiry has a stable + browser-facing code and action instead of a truncated traceback. + +- Replaced the Web Next Session placeholder with a separate read-only Core + projection that distinguishes AI proposals from active/pending execution + drafts without redefining the frozen Dashboard field or counting targets as + performed work. The browser preserves the last successful durable view + across refresh failures and uses one abortable, per-run ordered sync poller. + - Completed the authorized private full-generation rollout over production direct MTP: verified Android and desktop backups, signing-preserving Android updates, schema migrations, persistent peer pairing, bidirectional durable diff --git a/docs/current_state.md b/docs/current_state.md index 530f3e8..b3d5bce 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -219,6 +219,22 @@ persistence with optimistic conflict and CSRF/Origin protection. It provides no business mutation or automatic browser launch. Analyse, Programmes, Sessions, and Exercises remain explicit placeholders. +The Next Session tile no longer treats the frozen Dashboard +`next_session.available` placeholder as a durable inventory. A separate bounded +`GET /api/v1/prepared-items` Core projection distinguishes imported AI +proposals from active or pending execution drafts and exposes stable identity, +state, title/date, occurrence count, and provenance. Reload and later sync +failure do not erase the last successfully read projection. Proposal targets +remain planning data and never alter Dashboard actual-work metrics. + +Full-generation MTP publication now uses a private phase-owned outbox. The +request, desktop consumption ACK, and current desktop generation are published +only when their phase requires them; retained staging, inbound objects, legacy +files, prior ACKs, and causal evidence are not recursively re-uploaded. Adapter +timeouts are persisted as the stable `transport_timeout` status instead of a +truncated Python traceback. The browser polls through one abortable scheduler, +orders revisions per `run_id`, and rejects stale responses from older runs. + ## Data semantics - `exercise_id` identifies a catalogue exercise; `entry_id` identifies one diff --git a/docs/roadmap.md b/docs/roadmap.md index bf34b97..4b81c7b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -34,6 +34,9 @@ CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data contract, frozen interactive grid, versioned layout persistence and factual tile rendering exist. The Dashboard's bounded progression and BODY ZONES visualizations are implemented; the other four Web routes remain placeholders. +The corrective prepared-item projection and bounded phase-owned MTP outbox are +implemented without opening the Sessions route, changing schemas, or changing +the frozen Dashboard and exchange contracts. ## Current cursor diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 33c60c7..c04551e 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -798,6 +798,23 @@ truncation, and publishes generation manifests last. Production behavior is tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem object double; the same compiled transport algorithm remains under test. +Each worker push is additionally scoped by a private disposable outbox. The +first push contains only the correlated request; the post-import push adds the +desktop consumption ACK; the publication push adds the current desktop +generation reference and exactly its referenced generation directory. The +durable transport root remains the owner of retained generations, staging, +legacy compatibility artifacts, and evidence, but those unrelated objects are +not recursively sent on every phase. Constructing an outbox does not delete or +rewrite any source object. A bounded adapter timeout is an ambiguous transport +failure with stable code `transport_timeout`; it is not evidence of rollback +and does not authorize automatic mutation replay. + +`GET /api/v1/prepared-items` is a separate read-only Web projection. It does not +change the frozen Dashboard `next_session.available` field or any exchange +format. It lists bounded durable desktop AI proposals and relevant execution +drafts as distinct kinds. Completed sessions remain outside this projection, +and finalized or causally deleted execution drafts are excluded. + ## 19. Legacy active-protocol limitations The Web end-to-end tranche adds an explicit opt-in application orchestrator. diff --git a/docs/tests.md b/docs/tests.md index 436ac90..81a50b5 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -6,6 +6,18 @@ through the C adapter, observes the desktop commit at an Android test barrier, then releases the real ACK. This proves the directory transport double, not physical MTP. Orchestrator tests cover bounded streams and descendant cleanup; deployment-tool tests cover consistent SQLite backup and candidate inventory. +The incident regressions also prove that a phase-owned MTP outbox excludes +retained staging and unrelated legacy files, adapter expiry produces the stable +`transport_timeout` code, and operational failure classes remain distinct. + +`web_prepared_items` exercises the production SQLite projection and JSON +serializer with a proposal and an execution draft, then proves finalized and +causally deleted execution drafts are absent. The HTTP test covers the +read-only route and method rejection. Vitest distinguishes proposal, active +draft, loading, empty and failed-refresh states; it retains an already rendered +durable value across a reread failure. Sync-control tests cover a single +abortable polling scheduler, per-run revisions, rejection of an older run's +late terminal response, stable error/action rendering, and cleanup on unmount. The `ai_history_export` test covers an empty database, a simple session, distinct multi-occurrences of one exercise, ordered sets, exact occurrence diff --git a/tests/test_sync_error_classification.py b/tests/test_sync_error_classification.py new file mode 100644 index 0000000..9e24100 --- /dev/null +++ b/tests/test_sync_error_classification.py @@ -0,0 +1,78 @@ +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +import sync_orchestrator # noqa: E402 +import sync_peer_worker # noqa: E402 + + +class SyncErrorClassificationTest(unittest.TestCase): + def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self): + timeout = subprocess.TimeoutExpired(["adapter", "push"], 30) + with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout): + with self.assertRaisesRegex( + RuntimeError, + r"^transport_timeout: MTP push did not finish within 30 seconds$", + ) as caught: + sync_peer_worker.run_adapter( + Path("/adapter"), + "push", + "peer_00000000-0000-4000-8000-000000000001", + Path("/transport"), + sync_peer_worker.time.monotonic() + 60, + ) + self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout") + + def test_known_operational_failures_remain_distinct(self): + self.assertEqual( + sync_orchestrator.failure_code(RuntimeError("expected Android peer not found")), + "device_unavailable", + ) + self.assertEqual( + sync_orchestrator.failure_code(RuntimeError("pending capacity exhausted")), + "capacity_exhausted", + ) + self.assertEqual( + sync_orchestrator.failure_code(RuntimeError("revision conflict")), + "data_conflict", + ) + + def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "transport" + root.mkdir() + (root / "request-v1.json").write_text("{}") + (root / "staging").mkdir() + (root / "staging" / "retained.json").write_text("retained") + (root / "legacy.json").write_text("legacy") + observed = [] + + def inspect(command, **_kwargs): + outbox = Path(command[3]) + observed.extend( + str(path.relative_to(outbox)) + for path in outbox.rglob("*") + if path.is_file() + ) + return subprocess.CompletedProcess(command, 0, b"", b"") + + with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=inspect): + sync_peer_worker.push_adapter( + Path("/adapter"), + "peer_00000000-0000-4000-8000-000000000001", + root, + ["request-v1.json"], + sync_peer_worker.time.monotonic() + 60, + ) + self.assertEqual(observed, ["request-v1.json"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_orchestrator.py b/tests/test_sync_orchestrator.py index f83ad76..533fa91 100644 --- a/tests/test_sync_orchestrator.py +++ b/tests/test_sync_orchestrator.py @@ -76,7 +76,9 @@ class OrchestratorTest(unittest.TestCase): values = self.fixture(root, **override) result = self.invoke(ORCHESTRATOR, values) self.assertEqual(result.returncode, 2) - self.assertEqual(json.loads(values[1].read_text())["phase"], "failed") + final = json.loads(values[1].read_text()) + self.assertEqual(final["phase"], "failed") + self.assertEqual(final["error_code"], "internal_error") def test_stdout_bound_is_enforced_before_complete_accumulation(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tools/sync_orchestrator.py b/tools/sync_orchestrator.py index 93232b4..41a16b0 100644 --- a/tools/sync_orchestrator.py +++ b/tools/sync_orchestrator.py @@ -118,6 +118,20 @@ def update(state_path: Path, state: dict, phase: str, **fields: object) -> None: atomic_write(state_path, state) +def failure_code(error: BaseException) -> str: + """Map bounded operational evidence to one stable browser-facing code.""" + diagnostic = str(error).lower() + if "transport_timeout:" in diagnostic or "peer worker timed out" in diagnostic: + return "transport_timeout" + if "expected android peer not found" in diagnostic: + return "device_unavailable" + if "capacity exhausted" in diagnostic or "retained generation capacity" in diagnostic: + return "capacity_exhausted" + if "conflict" in diagnostic or "wrong consumer" in diagnostic: + return "data_conflict" + return "internal_error" + + def validate_result(value: dict, run_id: str) -> dict: required = { "format", @@ -303,6 +317,7 @@ def run(args: argparse.Namespace) -> int: state, "failed", result="conflict", + error_code="sync_in_progress", diagnostic="Another synchronization owns the common lock", finished_at=datetime.now(timezone.utc).isoformat(), ) @@ -418,6 +433,7 @@ def main() -> int: state, phase, result=phase, + error_code=("interrupted" if phase == "interrupted" else failure_code(error)), diagnostic=str(error)[:MAX_DIAGNOSTIC], finished_at=datetime.now(timezone.utc).isoformat(), ) diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py index b453223..edde883 100644 --- a/tools/sync_peer_worker.py +++ b/tools/sync_peer_worker.py @@ -7,7 +7,17 @@ always produced and consumed by the existing generation services. """ from __future__ import annotations -import argparse, hashlib, json, os, sys, time, uuid + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import uuid from contextlib import closing, redirect_stdout from pathlib import Path @@ -61,13 +71,21 @@ def run_adapter( allow_missing_peer: bool = False, ) -> bool: remaining = max(1, int(deadline - time.monotonic())) - result = __import__("subprocess").run( - [str(adapter), operation, peer, str(root)], - stdin=__import__("subprocess").DEVNULL, - capture_output=True, - timeout=min(remaining, 30), - check=False, - ) + timeout = min(remaining, 30) + try: + result = subprocess.run( + [str(adapter), operation, peer, str(root)], + stdin=subprocess.DEVNULL, + capture_output=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as error: + # CONTRACT: an adapter timeout is an ambiguous transport failure. It + # never implies rollback, replay, or permission to retry a mutation. + raise RuntimeError( + f"transport_timeout: MTP {operation} did not finish within {timeout} seconds" + ) from error if result.returncode != 0: diagnostic = ( result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed" @@ -82,6 +100,47 @@ def run_adapter( return True +def push_adapter( + adapter: Path, + peer: str, + root: Path, + relative_paths: list[str], + deadline: float, +) -> None: + """Publish only the phase-owned bounded MTP objects. + + WHY: the durable transport root also retains inbound objects, legacy files, + and staging evidence; recursively uploading that root makes each exchange + grow with history and can time out before Android sees the request. + CONTRACT: callers name fixed relative files/directories already durably + published below ``root``. The adapter receives a private disposable view. + INVARIANT: no retained generation, ACK, tombstone, or source object is + deleted or modified while constructing the view. + """ + with tempfile.TemporaryDirectory(prefix="trainlog-mtp-outbox-", dir=root.parent) as directory: + outbox = Path(directory) + os.chmod(outbox, 0o700) + for relative in relative_paths: + source = root / relative + destination = outbox / relative + if ( + source.is_symlink() + or not source.exists() + or not source.resolve().is_relative_to(root.resolve()) + ): + raise RuntimeError(f"invalid MTP outbox source: {relative}") + if source.is_dir() and any(path.is_symlink() for path in source.rglob("*")): + raise RuntimeError(f"MTP outbox source contains a symbolic link: {relative}") + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if source.is_dir(): + shutil.copytree(source, destination, copy_function=shutil.copy2) + elif source.is_file(): + shutil.copy2(source, destination) + else: + raise RuntimeError(f"invalid MTP outbox object: {relative}") + run_adapter(adapter, "push", peer, outbox, deadline) + + def wait_file(path: Path, deadline: float, pump=None) -> None: while not path.is_file(): if time.monotonic() >= deadline: @@ -167,8 +226,12 @@ def main() -> int: request_path = args.transport_root / "request-v1.json" publish_json(request_path, request) if args.mode == "mtp": - run_adapter( - args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline + push_adapter( + args.mtp_adapter, + args.expected_peer, + args.transport_root, + ["request-v1.json"], + deadline, ) emit( args.run_id, @@ -185,8 +248,12 @@ def main() -> int: ack_path = args.transport_root / "desktop-consumption-ack-v1.json" publish_json(ack_path, ack) if args.mode == "mtp": - run_adapter( - args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline + push_adapter( + args.mtp_adapter, + args.expected_peer, + args.transport_root, + ["request-v1.json", "desktop-consumption-ack-v1.json"], + deadline, ) if ack["result"] != "consumed": raise RuntimeError("desktop rejected Android generation") @@ -218,8 +285,17 @@ def main() -> int: } publish_json(args.transport_root / "desktop-generation-v1.json", ref) if args.mode == "mtp": - run_adapter( - args.mtp_adapter, "push", args.expected_peer, args.transport_root, deadline + push_adapter( + args.mtp_adapter, + args.expected_peer, + args.transport_root, + [ + "request-v1.json", + "desktop-consumption-ack-v1.json", + "desktop-generation-v1.json", + ref["relative_path"], + ], + deadline, ) emit( args.run_id, diff --git a/tui/include/trainlog/web_prepared_items.h b/tui/include/trainlog/web_prepared_items.h new file mode 100644 index 0000000..e3f3f30 --- /dev/null +++ b/tui/include/trainlog/web_prepared_items.h @@ -0,0 +1,49 @@ +/* Trainlog Web prepared-item V1 read model. */ +#ifndef TRAINLOG_WEB_PREPARED_ITEMS_H +#define TRAINLOG_WEB_PREPARED_ITEMS_H + +#include + +#include "trainlog/database.h" + +#define TRAINLOG_WEB_PREPARED_ITEM_CAPACITY 32U +#define TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY (64U * 1024U) + +typedef enum TrainlogWebPreparedItemKind { + TRAINLOG_WEB_PREPARED_AI_PROPOSAL = 0, + TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT = 1 +} TrainlogWebPreparedItemKind; + +typedef struct TrainlogWebPreparedItem { + TrainlogWebPreparedItemKind kind; + char identity[TRAINLOG_ID_MAX + 1U]; + char state[32]; + char title[TRAINLOG_NAME_MAX + 1U]; + char planned_for[11]; + size_t occurrence_count; + char provenance[32]; +} TrainlogWebPreparedItem; + +typedef struct TrainlogWebPreparedItems { + char generated_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + TrainlogWebPreparedItem items[TRAINLOG_WEB_PREPARED_ITEM_CAPACITY]; + size_t item_count; + bool partial; +} TrainlogWebPreparedItems; + +/* WHY: sync status is one run's evidence, not a durable inventory. + * CONTRACT: database/output are required and borrowed for the call. The + * projection is bounded, deterministic, read-only, and keeps AI proposals + * separate from executable drafts and completed sessions. + * INVARIANT: targets never become performed occurrences through this API. */ +TrainlogStatus trainlog_web_prepared_items_load( + TrainlogDatabase *database, TrainlogWebPreparedItems *output); + +/* Caller owns output. Success guarantees valid NUL-terminated JSON. */ +TrainlogStatus trainlog_web_prepared_items_serialize( + const TrainlogWebPreparedItems *items, + char *output, + size_t capacity, + size_t *output_size); + +#endif diff --git a/tui/meson.build b/tui/meson.build index a7aa32e..2a60475 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -192,6 +192,8 @@ trainlog_core_sources = files( 'src/json_writer.c', 'src/web_dashboard.c', 'src/web_dashboard_json.c', + 'src/web_prepared_items.c', + 'src/web_prepared_items_json.c', 'src/duration.c', 'src/id.c', 'src/timeutil.c', @@ -335,6 +337,15 @@ test_web_dashboard = executable( ) test('web_dashboard', test_web_dashboard) +test_web_prepared_items = executable( + 'test_web_prepared_items', + 'tests/test_web_prepared_items.c', + dependencies: trainlog_core_dep, + include_directories: trainlog_private_include, + c_args: strict_c_args, +) +test('web_prepared_items', test_web_prepared_items) + test_dashboard_header = executable( 'test_dashboard_header', 'tests/test_dashboard_header.c', diff --git a/tui/src/web_prepared_items.c b/tui/src/web_prepared_items.c new file mode 100644 index 0000000..1bf8c73 --- /dev/null +++ b/tui/src/web_prepared_items.c @@ -0,0 +1,174 @@ +#include "trainlog/web_prepared_items.h" + +#include +#include +#include + +#include + +#include "database_internal.h" + +static bool copy_column(sqlite3_stmt *statement, int column, char *output, size_t capacity) { + const unsigned char *text; + int bytes; + + if (sqlite3_column_type(statement, column) == SQLITE_NULL) { + output[0] = '\0'; + return true; + } + if (sqlite3_column_type(statement, column) != SQLITE_TEXT) { + return false; + } + text = sqlite3_column_text(statement, column); + bytes = sqlite3_column_bytes(statement, column); + if (text == NULL || bytes < 0 || (size_t)bytes >= capacity) { + return false; + } + (void)memcpy(output, text, (size_t)bytes); + output[bytes] = '\0'; + return true; +} + +static bool read_count(sqlite3_stmt *statement, int column, size_t *output) { + sqlite3_int64 value; + + if (sqlite3_column_type(statement, column) != SQLITE_INTEGER) { + return false; + } + value = sqlite3_column_int64(statement, column); + if (value < 0 || (uint64_t)value > (uint64_t)SIZE_MAX) { + return false; + } + *output = (size_t)value; + return true; +} + +static bool format_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 TrainlogStatus load_ai_proposals( + TrainlogDatabase *database, TrainlogWebPreparedItems *output) { + static const char SQL[] = + "SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,'')," + "COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' " + "END FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON " + "e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31')," + "d.created_at,d.draft_id LIMIT ?1;"; + sqlite3_stmt *statement = NULL; + int step; + + if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_int64( + statement, 1, (sqlite3_int64)(TRAINLOG_WEB_PREPARED_ITEM_CAPACITY + 1U)) != SQLITE_OK) { + goto fail; + } + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + TrainlogWebPreparedItem *item; + + if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) { + output->partial = true; + continue; + } + item = &output->items[output->item_count]; + item->kind = TRAINLOG_WEB_PREPARED_AI_PROPOSAL; + if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) || + !copy_column(statement, 1, item->title, sizeof(item->title)) || + !copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) || + !read_count(statement, 3, &item->occurrence_count) || + !copy_column(statement, 4, item->state, sizeof(item->state))) { + goto fail; + } + (void)snprintf(item->provenance, sizeof(item->provenance), "%s", "ai_import"); + ++output->item_count; + } + if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + return TRAINLOG_STATUS_OK; + +fail: + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +static TrainlogStatus load_execution_drafts( + TrainlogDatabase *database, TrainlogWebPreparedItems *output) { + static const char SQL[] = + "SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),'')," + "COALESCE(json_array_length(d.payload_json,'$.exercises'),0) FROM execution_drafts d " + "WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE " + "f.session_id=d.session_id) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE " + "c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) " + "ORDER BY CASE d.state WHEN 'active' THEN 0 ELSE 1 END,d.session_id LIMIT ?1;"; + sqlite3_stmt *statement = NULL; + int step; + size_t remaining = TRAINLOG_WEB_PREPARED_ITEM_CAPACITY - output->item_count; + + if (remaining == 0U) { + return TRAINLOG_STATUS_OK; + } + if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_int64(statement, 1, (sqlite3_int64)(remaining + 1U)) != SQLITE_OK) { + goto fail; + } + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + TrainlogWebPreparedItem *item; + + if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) { + output->partial = true; + continue; + } + item = &output->items[output->item_count]; + item->kind = TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT; + if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) || + !copy_column(statement, 1, item->state, sizeof(item->state)) || + !copy_column(statement, 2, item->title, sizeof(item->title)) || + !copy_column(statement, 3, item->planned_for, sizeof(item->planned_for)) || + !read_count(statement, 4, &item->occurrence_count)) { + goto fail; + } + (void)snprintf(item->provenance, sizeof(item->provenance), "%s", "execution_store"); + ++output->item_count; + } + if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + return TRAINLOG_STATUS_OK; + +fail: + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_web_prepared_items_load( + TrainlogDatabase *database, TrainlogWebPreparedItems *output) { + TrainlogStatus status; + TrainlogStatus end_status; + + if (database == NULL || output == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + (void)memset(output, 0, sizeof(*output)); + if (!format_now(output->generated_at)) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + status = trainlog_database_read_snapshot_begin(database); + if (status != TRAINLOG_STATUS_OK) { + return status; + } + status = load_ai_proposals(database, output); + if (status == TRAINLOG_STATUS_OK) { + status = load_execution_drafts(database, output); + } + end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK); + return status == TRAINLOG_STATUS_OK ? end_status : status; +} diff --git a/tui/src/web_prepared_items_json.c b/tui/src/web_prepared_items_json.c new file mode 100644 index 0000000..2e58a1e --- /dev/null +++ b/tui/src/web_prepared_items_json.c @@ -0,0 +1,140 @@ +#include "trainlog/web_prepared_items.h" + +#include +#include +#include +#include + +typedef struct JsonWriter { + char *output; + size_t capacity; + size_t length; + bool failed; +} JsonWriter; + +static void append(JsonWriter *writer, const char *format, ...) + __attribute__((format(printf, 2, 3))); + +static void append(JsonWriter *writer, const char *format, ...) { + va_list arguments; + int written; + + if (writer->failed || writer->length >= writer->capacity) { + writer->failed = true; + return; + } + va_start(arguments, format); + written = vsnprintf( + writer->output + writer->length, writer->capacity - writer->length, format, arguments); + va_end(arguments); + if (written < 0 || (size_t)written >= writer->capacity - writer->length) { + writer->failed = true; + return; + } + writer->length += (size_t)written; +} + +static bool valid_utf8(const char *value) { + const utf8proc_uint8_t *cursor = (const utf8proc_uint8_t *)value; + + while (*cursor != '\0') { + utf8proc_int32_t codepoint; + utf8proc_ssize_t sequence = utf8proc_iterate(cursor, -1, &codepoint); + (void)codepoint; + if (sequence <= 0) { + return false; + } + cursor += (size_t)sequence; + } + return true; +} + +static void string(JsonWriter *writer, const char *value) { + const unsigned char *cursor = (const unsigned char *)value; + + if (!valid_utf8(value)) { + writer->failed = true; + return; + } + append(writer, "\""); + while (!writer->failed && *cursor != '\0') { + unsigned char byte = *cursor++; + + if (byte == '"' || byte == '\\') { + append(writer, "\\%c", (int)byte); + } else if (byte == '\b') { + append(writer, "\\b"); + } else if (byte == '\f') { + append(writer, "\\f"); + } else if (byte == '\n') { + append(writer, "\\n"); + } else if (byte == '\r') { + append(writer, "\\r"); + } else if (byte == '\t') { + append(writer, "\\t"); + } else if (byte < 0x20U) { + append(writer, "\\u%04x", (unsigned int)byte); + } else { + append(writer, "%c", (int)byte); + } + } + append(writer, "\""); +} + +TrainlogStatus trainlog_web_prepared_items_serialize( + const TrainlogWebPreparedItems *items, + char *output, + size_t capacity, + size_t *output_size) { + JsonWriter writer = {output, capacity, 0U, false}; + size_t index; + + if (items == NULL || output == NULL || output_size == NULL || capacity == 0U || + items->item_count > TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + *output_size = 0U; + append(&writer, "{\"api_version\":1,\"generated_at\":"); + string(&writer, items->generated_at); + append(&writer, ",\"partial\":%s,\"items\":[", items->partial ? "true" : "false"); + for (index = 0U; index < items->item_count; ++index) { + const TrainlogWebPreparedItem *item = &items->items[index]; + const char *kind; + + if (item->kind == TRAINLOG_WEB_PREPARED_AI_PROPOSAL) { + kind = "ai_proposal"; + } else if (item->kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT) { + kind = "execution_draft"; + } else { + output[0] = '\0'; + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + append(&writer, "%s{\"identity\":", index == 0U ? "" : ","); + string(&writer, item->identity); + append(&writer, ",\"kind\":"); + string(&writer, kind); + append(&writer, ",\"title\":"); + string(&writer, item->title); + append(&writer, ",\"planned_for\":"); + if (item->planned_for[0] == '\0') { + append(&writer, "null"); + } else { + string(&writer, item->planned_for); + } + append(&writer, ",\"state\":"); + string(&writer, item->state); + append(&writer, ",\"occurrence_count\":%zu,\"provenance\":", item->occurrence_count); + string(&writer, item->provenance); + append(&writer, "}"); + } + append(&writer, "]}\n"); + if (writer.failed) { + if (capacity > 0U) { + output[0] = '\0'; + } + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + *output_size = writer.length; + return TRAINLOG_STATUS_OK; +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c index b03e835..710a972 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -21,6 +21,7 @@ #include #include "trainlog/web_dashboard.h" +#include "trainlog/web_prepared_items.h" #include "trainlog/dashboard_layout.h" #include "web_assets.h" @@ -707,6 +708,27 @@ static enum MHD_Result handle_request(void *closure, } return queue_json(connection, MHD_HTTP_OK, json, NULL); } + if (strcmp(url, "/api/v1/prepared-items") == 0) { + char json[TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY]; + size_t json_size = 0U; + TrainlogWebPreparedItems items; + + if (!is_get) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET"); + } + if (trainlog_web_prepared_items_load(context->database, &items) != TRAINLOG_STATUS_OK || + trainlog_web_prepared_items_serialize(&items, json, sizeof(json), &json_size) != + TRAINLOG_STATUS_OK) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"prepared_items_unavailable\"}\n", + NULL); + } + return queue_json(connection, MHD_HTTP_OK, json, NULL); + } if (strcmp(url, "/api/v1/dashboard-layout") == 0) { TrainlogDashboardLayout layout; TrainlogDashboardLayout saved; diff --git a/tui/tests/test_web_prepared_items.c b/tui/tests/test_web_prepared_items.c new file mode 100644 index 0000000..c84eee5 --- /dev/null +++ b/tui/tests/test_web_prepared_items.c @@ -0,0 +1,110 @@ +#include +#include +#include + +#include + +#include "trainlog/database.h" +#include "trainlog/web_prepared_items.h" +#include "database_internal.h" + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "check failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +static bool execute(TrainlogDatabase *database, const char *sql) { + char *error = NULL; + int status = sqlite3_exec(database->connection, sql, NULL, NULL, &error); + if (status != SQLITE_OK) { + (void)fprintf(stderr, "SQLite fixture failed: %s\n", error == NULL ? "unknown" : error); + } + sqlite3_free(error); + return status == SQLITE_OK; +} + +static bool test_distinct_durable_items(void) { + TrainlogDatabase *database = NULL; + TrainlogWebPreparedItems items; + char json[TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY]; + size_t json_size = 0U; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(execute(database, + "INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode," + "data_fields) VALUES('ex_00000000-0000-4000-8000-000000000001','Test','test','reps'," + "'sets',0);" + "INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes," + "published_at) VALUES('aid_00000000-0000-4000-8000-000000000001'," + "'2026-09-16T10:00:00Z','2026-09-17','training','Prepared title',NULL," + "'2026-09-16T11:00:00Z');" + "INSERT INTO ai_session_draft_entries(draft_row_id,entry_id,position,exercise_row_id," + "source_exercise_id,recording_mode,tracking_mode,data_fields,equipment_id,load_mode," + "target_sets,target_reps,target_duration_seconds,target_weight_kg,rest_seconds) " + "SELECT d.id,'sxe_00000000-0000-4000-8000-000000000001',0,e.id,e.exercise_id,'sets'," + "'reps',0,NULL,'none',2,8,NULL,NULL,60 FROM ai_session_drafts d,exercises e WHERE " + "e.exercise_id='ex_00000000-0000-4000-8000-000000000001';" + "INSERT INTO execution_drafts(session_id,session_type,source_session_id,started_at," + "revision_id,parent_revision_id,state,payload_json) VALUES(" + "'se_00000000-0000-4000-8000-000000000001','training',NULL,'2026-09-17T12:00:00Z'," + "'dr_one',NULL,'active','{\"exercises\":[{},{}]}');")); + { + TrainlogStatus status = trainlog_web_prepared_items_load(database, &items); + if (status != TRAINLOG_STATUS_OK) { + (void)fprintf(stderr, "prepared load failed: %d sqlite=%s\n", (int)status, + sqlite3_errmsg(database->connection)); + } + CHECK(status == TRAINLOG_STATUS_OK); + } + CHECK(items.item_count == 2U); + CHECK(items.items[0].kind == TRAINLOG_WEB_PREPARED_AI_PROPOSAL); + CHECK(strcmp(items.items[0].state, "published") == 0); + CHECK(strcmp(items.items[0].title, "Prepared title") == 0); + CHECK(items.items[0].occurrence_count == 1U); + CHECK(items.items[1].kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT); + CHECK(strcmp(items.items[1].state, "active") == 0); + CHECK(items.items[1].occurrence_count == 2U); + CHECK(trainlog_web_prepared_items_serialize( + &items, json, sizeof(json), &json_size) == TRAINLOG_STATUS_OK); + CHECK(json_size > 0U); + CHECK(strstr(json, "\"kind\":\"ai_proposal\"") != NULL); + CHECK(strstr(json, "\"kind\":\"execution_draft\"") != NULL); + items.items[0].title[0] = (char)0xc3; + items.items[0].title[1] = '\0'; + CHECK(trainlog_web_prepared_items_serialize( + &items, json, sizeof(json), &json_size) == TRAINLOG_STATUS_SYSTEM_ERROR); + CHECK(json_size == 0U && json[0] == '\0'); + trainlog_database_close(database); + return true; +} + +static bool test_finalized_and_deleted_drafts_are_hidden(void) { + TrainlogDatabase *database = NULL; + TrainlogWebPreparedItems items; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(execute(database, + "INSERT INTO execution_drafts(session_id,session_type,revision_id,state,payload_json) " + "VALUES('se_00000000-0000-4000-8000-000000000002','training','dr_two','pending'," + "'{\"exercises\":[]}'),('se_00000000-0000-4000-8000-000000000003','training'," + "'dr_three','pending','{\"exercises\":[]}');" + "INSERT INTO execution_draft_finalizations(session_id,final_revision_id,finalized_at) " + "VALUES('se_00000000-0000-4000-8000-000000000002','dr_final'," + "'2026-09-17T12:00:00Z');" + "INSERT INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted) " + "VALUES('execution_draft','se_00000000-0000-4000-8000-000000000003','dr_delete',1);")); + CHECK(trainlog_web_prepared_items_load(database, &items) == TRAINLOG_STATUS_OK); + CHECK(items.item_count == 0U); + trainlog_database_close(database); + return true; +} + +int main(void) { + if (!test_distinct_durable_items() || !test_finalized_and_deleted_drafts_are_hidden()) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index 5ee4430..9e72e77 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -255,6 +255,19 @@ static bool test_http_contract(TrainlogDatabase *database) { CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL); CHECK(strstr(response, "\"window_days\":90") != NULL); CHECK(strstr(response, "\"partial\":false") != NULL); + CHECK(exchange(port, + "GET /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL); + CHECK(strstr(response, "\"api_version\":1") != NULL); + CHECK(strstr(response, "\"items\":[]") != NULL); + CHECK(exchange(port, + "POST /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Content-Length: 0\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 405") != NULL); { TrainlogDashboardLayout layout; char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY]; diff --git a/web/src/api/preparedItems.test.ts b/web/src/api/preparedItems.test.ts new file mode 100644 index 0000000..3c7e17c --- /dev/null +++ b/web/src/api/preparedItems.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { parsePreparedItems } from './preparedItems' + +describe('projection des préparations', () => { + it('distingue une proposition IA d’un brouillon d’exécution', () => { + const value = parsePreparedItems({ + api_version: 1, + generated_at: '2026-09-17T12:00:00Z', + partial: false, + items: [ + { identity: 'aid_one', kind: 'ai_proposal', title: 'Plan', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' }, + { identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' }, + ], + }) + expect(value.items.map((item) => item.kind)).toEqual(['ai_proposal', 'execution_draft']) + }) + + it('refuse les compteurs et types non bornés', () => { + expect(() => parsePreparedItems({ api_version: 1, generated_at: 'x', partial: false, items: [{ identity: 'x', kind: 'session', title: '', planned_for: null, state: '', occurrence_count: -1, provenance: '' }] })).toThrow() + }) +}) diff --git a/web/src/api/preparedItems.ts b/web/src/api/preparedItems.ts new file mode 100644 index 0000000..c8f2e30 --- /dev/null +++ b/web/src/api/preparedItems.ts @@ -0,0 +1,56 @@ +export type PreparedItemKind = 'ai_proposal' | 'execution_draft' + +export interface PreparedItem { + identity: string + kind: PreparedItemKind + title: string + planned_for: string | null + state: string + occurrence_count: number + provenance: string +} + +export interface PreparedItemsSnapshot { + api_version: 1 + generated_at: string + partial: boolean + items: PreparedItem[] +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function validItem(value: unknown): value is PreparedItem { + if (!record(value)) return false + const kind = value.kind + return (kind === 'ai_proposal' || kind === 'execution_draft') && + typeof value.identity === 'string' && + typeof value.title === 'string' && + (value.planned_for === null || typeof value.planned_for === 'string') && + typeof value.state === 'string' && + Number.isSafeInteger(value.occurrence_count) && + Number(value.occurrence_count) >= 0 && + typeof value.provenance === 'string' +} + +export function parsePreparedItems(value: unknown): PreparedItemsSnapshot { + if (!record(value) || value.api_version !== 1 || + typeof value.generated_at !== 'string' || + typeof value.partial !== 'boolean' || + !Array.isArray(value.items) || + value.items.length > 32 || + !value.items.every(validItem)) { + throw new TypeError('projection des préparations invalide') + } + return value as unknown as PreparedItemsSnapshot +} + +export async function fetchPreparedItems(signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/prepared-items', { + headers: { Accept: 'application/json' }, + signal, + }) + if (!response.ok) throw new Error(`prepared-items HTTP ${response.status}`) + return parsePreparedItems(await response.json()) +} diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts index 2e75d5f..0e0e7ad 100644 --- a/web/src/api/sync.ts +++ b/web/src/api/sync.ts @@ -1,11 +1,126 @@ -export type SyncPhase = 'idle' | 'requested' | 'waiting_android_publication' | 'running' | 'local_import_committed' | 'published' | 'waiting_acknowledgement' | 'peer_consumed' | 'completed' | 'failed' | 'interrupted' | 'explicitly_degraded' -export interface SyncDraftSummary { draft_id: string; state: 'active'|'pending'|'finalized'|'stale'; session_type: string; occurrence_count: number } -export interface SyncStatus { api_version?: number; enabled?: boolean; phase: SyncPhase; result: string; run_id?: string; request_id?: string; progress_revision?: number; diagnostic?: string; sessions_reconciled?: number|null; inbound_generation_id?: string|null; outbound_generation_id?: string|null; missing_capabilities?: string[]; domains?: Record; drafts?: SyncDraftSummary[]; finished_at?: string|null } -let csrfToken='' -const phases=new Set(['idle','requested','waiting_android_publication','running','local_import_committed','published','waiting_acknowledgement','peer_consumed','completed','failed','interrupted','explicitly_degraded']) -function parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus } -export async function fetchSyncStatus(signal?:AbortSignal):Promise{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())} -export async function startSync(requestId:string):Promise{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)} +export type SyncPhase = + | 'idle' + | 'requested' + | 'waiting_android_publication' + | 'running' + | 'local_import_committed' + | 'published' + | 'waiting_acknowledgement' + | 'peer_consumed' + | 'completed' + | 'failed' + | 'interrupted' + | 'explicitly_degraded' + +export interface SyncDraftSummary { + draft_id: string + state: 'active' | 'pending' | 'finalized' | 'stale' + session_type: string + occurrence_count: number +} + +export interface SyncStatus { + api_version?: number + enabled?: boolean + phase: SyncPhase + result: string + run_id?: string + request_id?: string + progress_revision?: number + diagnostic?: string + error_code?: string + sessions_reconciled?: number | null + inbound_generation_id?: string | null + outbound_generation_id?: string | null + missing_capabilities?: string[] + domains?: Record + drafts?: SyncDraftSummary[] + started_at?: string | null + finished_at?: string | null +} + +let csrfToken = '' +const phases = new Set([ + 'idle', + 'requested', + 'waiting_android_publication', + 'running', + 'local_import_committed', + 'published', + 'waiting_acknowledgement', + 'peer_consumed', + 'completed', + 'failed', + 'interrupted', + 'explicitly_degraded', +]) +function optionalString(value: unknown): boolean { + return value === undefined || value === null || typeof value === 'string' +} + +function validDraft(value: unknown): value is SyncDraftSummary { + if (typeof value !== 'object' || value === null) return false + const draft = value as Partial + return typeof draft.draft_id === 'string' && + ['active', 'pending', 'finalized', 'stale'].includes(draft.state ?? '') && + typeof draft.session_type === 'string' && + Number.isSafeInteger(draft.occurrence_count) && + Number(draft.occurrence_count) >= 0 +} + +function parse(value: unknown): SyncStatus { + if (typeof value !== 'object' || value === null) throw new Error('statut sync invalide') + const status = value as Partial + if (typeof status.phase !== 'string' || !phases.has(status.phase as SyncPhase) || + typeof status.result !== 'string' || + !optionalString(status.run_id) || + !optionalString(status.request_id) || + !optionalString(status.diagnostic) || + !optionalString(status.error_code) || + !optionalString(status.started_at) || + !optionalString(status.finished_at) || + (status.progress_revision !== undefined && + (!Number.isSafeInteger(status.progress_revision) || status.progress_revision < 0))) { + throw new Error('statut sync invalide') + } + if (status.drafts !== undefined && + (!Array.isArray(status.drafts) || status.drafts.length > 32 || !status.drafts.every(validDraft))) { + throw new Error('résumé des brouillons invalide') + } + return status as SyncStatus +} + +export async function fetchSyncStatus(signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/sync/status', { + headers: { Accept: 'application/json' }, + signal, + }) + if (!response.ok) throw new Error(`sync status HTTP ${response.status}`) + const token = response.headers.get('X-Trainlog-CSRF-Token') ?? '' + if (!/^[0-9a-f]{64}$/.test(token)) throw new Error('jeton CSRF absent') + csrfToken = token + return parse(await response.json()) +} + +export async function startSync(requestId: string): Promise { + if (!/^sy_[0-9a-f-]{36}$/.test(requestId) || csrfToken.length !== 64) { + throw new Error('requête sync invalide') + } + const response = await fetch('/api/v1/sync', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Trainlog-CSRF-Token': csrfToken, + }, + body: JSON.stringify({ request_id: requestId, trigger: 'web' }), + }) + const value = await response.json() + if (!response.ok) { + throw new Error(typeof value?.error === 'string' ? value.error : `sync HTTP ${response.status}`) + } + return parse(value) +} export function newRequestId(): string { /* * WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure @@ -27,4 +142,6 @@ export function newRequestId(): string { ].join('-') return `sy_${uuid}` } -export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)} +export function syncIsActive(phase: SyncPhase): boolean { + return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase) +} diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index bbe4267..9d14a13 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { fetchHealth, type Health } from '../api/health' import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard' +import { fetchPreparedItems, type PreparedItemsSnapshot } from '../api/preparedItems' import { Footer } from '../components/Footer' import { Header } from '../components/Header' import { DashboardPage } from '../routes/DashboardPage' @@ -23,10 +24,24 @@ export function App() { const [dashboard, setDashboard] = useState(null) const [dashboardPending, setDashboardPending] = useState(true) const [dashboardFailed, setDashboardFailed] = useState(false) + const [preparedItems, setPreparedItems] = useState(null) + const [preparedItemsPending, setPreparedItemsPending] = useState(true) + const [preparedItemsFailed, setPreparedItemsFailed] = useState(false) const reloadDashboard = useCallback(() => { fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) }) .catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false)) }, []) + const reloadPreparedItems = useCallback(() => { + setPreparedItemsPending(true) + fetchPreparedItems().then((value) => { + setPreparedItems(value) + setPreparedItemsFailed(false) + }).catch(() => { + // INVARIANT: a transient reread failure does not erase the last durable + // projection already shown to the user. + setPreparedItemsFailed(true) + }).finally(() => setPreparedItemsPending(false)) + }, []) useEffect(() => { const controller = new AbortController() @@ -39,10 +54,25 @@ export function App() { }).catch(() => { setDashboard(null); setDashboardFailed(true) }).finally(() => setDashboardPending(false)) + fetchPreparedItems(controller.signal).then((value) => { + setPreparedItems(value) + setPreparedItemsFailed(false) + }).catch(() => { + setPreparedItemsFailed(true) + }).finally(() => setPreparedItemsPending(false)) return () => controller.abort() }, []) - const content = route.id === 'dashboard' ? : + const content = route.id === 'dashboard' ? ( + + ) : @@ -50,7 +80,10 @@ export function App() { return (
Aller au contenu -
+
{ + reloadDashboard() + reloadPreparedItems() + }} />
{content}
diff --git a/web/src/components/SyncControl.test.tsx b/web/src/components/SyncControl.test.tsx index 68aef2d..4c97e28 100644 --- a/web/src/components/SyncControl.test.tsx +++ b/web/src/components/SyncControl.test.tsx @@ -1,11 +1,61 @@ -import { fireEvent,render,screen,waitFor } from '@testing-library/react' -import { describe,expect,it,vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' import { SyncControl } from './SyncControl' -const headers=new Headers({'X-Trainlog-CSRF-Token':'c'.repeat(64)}) -const reply=(value:unknown,status=200)=>({ok:status<300,status,headers,json:()=>Promise.resolve(value)}) -describe('contrôle global de synchronisation',()=>{ - it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller',async()=>{const fetch=vi.fn() - .mockResolvedValueOnce(reply({phase:'idle',result:'idle'})) - .mockResolvedValueOnce(reply({phase:'requested',result:'running'},202)) - .mockResolvedValueOnce(reply({phase:'completed',result:'completed',progress_revision:7,sessions_reconciled:1,finished_at:'2026-09-17T12:00:00Z',drafts:[{draft_id:'draft_1',state:'pending',session_type:'strength',occurrence_count:2}]}));vi.stubGlobal('fetch',fetch);const committed=vi.fn();render();await waitFor(()=>expect(screen.getByRole('button',{name:'Synchroniser'})).toBeEnabled());fireEvent.click(screen.getByRole('button',{name:'Synchroniser'}));await waitFor(()=>expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(),{timeout:2500});fireEvent.click(screen.getByText('Synchronisation confirmée'));expect(screen.getByRole('region',{name:'Brouillons synchronisés'})).toHaveTextContent('pending');expect(committed).toHaveBeenCalledTimes(1);expect(fetch.mock.calls.filter(call=>String(call[0]).endsWith('/api/v1/sync'))).toHaveLength(1)}) + +const headers = new Headers({ 'X-Trainlog-CSRF-Token': 'c'.repeat(64) }) + +function reply(value: unknown, status = 200) { + return { ok: status < 300, status, headers, json: () => Promise.resolve(value) } +} + +describe('contrôle global de synchronisation', () => { + it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller', async () => { + const runId = 'sy_00000000-0000-4000-8000-000000000001' + const fetch = vi.fn() + .mockResolvedValueOnce(reply({ phase: 'idle', result: 'idle' })) + .mockResolvedValueOnce(reply({ phase: 'requested', result: 'running', run_id: runId, progress_revision: 1 }, 202)) + .mockResolvedValueOnce(reply({ + phase: 'completed', result: 'completed', run_id: runId, progress_revision: 7, + sessions_reconciled: 1, finished_at: '2026-09-17T12:00:00Z', + drafts: [{ draft_id: 'draft_1', state: 'pending', session_type: 'strength', occurrence_count: 2 }], + })) + vi.stubGlobal('fetch', fetch) + const committed = vi.fn() + render() + await waitFor(() => expect(screen.getByRole('button', { name: 'Synchroniser' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: 'Synchroniser' })) + await waitFor(() => expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(), { timeout: 2500 }) + fireEvent.click(screen.getByText('Synchronisation confirmée')) + expect(screen.getByRole('region', { name: 'Brouillons synchronisés' })).toHaveTextContent('pending') + expect(committed).toHaveBeenCalledTimes(1) + expect(fetch.mock.calls.filter((call) => String(call[0]).endsWith('/api/v1/sync'))).toHaveLength(1) + }) + + it('refuse une réponse terminale d’un ancien run et poursuit le run admis', async () => { + const current = 'sy_00000000-0000-4000-8000-000000000002' + const stale = 'sy_00000000-0000-4000-8000-000000000001' + const fetch = vi.fn() + .mockResolvedValueOnce(reply({ phase: 'idle', result: 'idle' })) + .mockResolvedValueOnce(reply({ phase: 'running', result: 'running', run_id: current, progress_revision: 2, started_at: '2026-09-17T13:00:00Z' }, 202)) + .mockResolvedValueOnce(reply({ phase: 'failed', result: 'failed', run_id: stale, progress_revision: 9, started_at: '2026-09-17T12:00:00Z' })) + .mockResolvedValueOnce(reply({ phase: 'completed', result: 'completed', run_id: current, progress_revision: 8, started_at: '2026-09-17T13:00:00Z' })) + vi.stubGlobal('fetch', fetch) + render() + await waitFor(() => expect(screen.getByRole('button', { name: 'Synchroniser' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: 'Synchroniser' })) + await waitFor(() => expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(), { timeout: 3500 }) + expect(screen.queryByText('Échec de synchronisation')).not.toBeInTheDocument() + }) + + it('affiche le code stable et l’action pour un timeout MTP', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(reply({ + phase: 'failed', result: 'failed', error_code: 'transport_timeout', + diagnostic: 'transport_timeout: MTP push did not finish within 30 seconds', + }))) + render() + const summary = await screen.findByText('Échec de synchronisation') + fireEvent.click(summary) + expect(screen.getByText('Code : transport_timeout')).toBeInTheDocument() + expect(screen.getByText(/Vérifiez le dialogue USB\/MTP/)).toBeInTheDocument() + }) }) diff --git a/web/src/components/SyncControl.tsx b/web/src/components/SyncControl.tsx index f4b6574..f6c681e 100644 --- a/web/src/components/SyncControl.tsx +++ b/web/src/components/SyncControl.tsx @@ -1,10 +1,153 @@ -import { useEffect,useRef,useState } from 'react' -import { fetchSyncStatus,newRequestId,startSync,syncIsActive,type SyncStatus } from '../api/sync' -const labels:Record={idle:'Synchronisation inactive',requested:'Synchronisation demandée',waiting_android_publication:'Publication Android requise',running:'Synchronisation en cours',local_import_committed:'Données desktop validées',published:'Génération retour publiée',waiting_acknowledgement:'Accusé Android attendu',peer_consumed:'Pair confirmé',completed:'Synchronisation confirmée',failed:'Échec de synchronisation',interrupted:'Synchronisation interrompue',explicitly_degraded:'Pair incompatible'} -export function SyncControl({onCommitted}:{onCommitted:()=>void}){const [status,setStatus]=useState(null);const [error,setError]=useState('');const committed=useRef(-1) - const accept=(next:SyncStatus)=>{setStatus(next);setError('');if(['local_import_committed','published','waiting_acknowledgement','peer_consumed','completed'].includes(next.phase)&&(next.progress_revision??0)>committed.current){committed.current=next.progress_revision??0;onCommitted()}} - useEffect(()=>{let stopped=false;let timer:number|undefined;const poll=async()=>{try{const next=await fetchSyncStatus();if(stopped)return;accept(next);if(syncIsActive(next.phase))timer=window.setTimeout(poll,750)}catch{if(!stopped)setError('Statut de synchronisation indisponible')}};void poll();return()=>{stopped=true;if(timer!==undefined)window.clearTimeout(timer)}},[onCommitted]) - const start=async()=>{setError('');try{accept(await startSync(newRequestId()));const poll=async()=>{try{const next=await fetchSyncStatus();accept(next);if(syncIsActive(next.phase))window.setTimeout(poll,750)}catch{setError('Résultat inconnu : vérifiez le statut avant de réessayer')}};window.setTimeout(poll,250)}catch(reason){setError(reason instanceof Error?reason.message:'Démarrage impossible')}} - const active=status?syncIsActive(status.phase):false - return
{error||labels[status?.phase??'idle']}{status?.diagnostic&&

{status.diagnostic}

}{status?.sessions_reconciled!==null&&status?.sessions_reconciled!==undefined&&

Séances rapprochées : {status.sessions_reconciled}

}{status?.phase==='waiting_android_publication'&&

Ouvrez l’écran de synchronisation Android pour publier une génération fraîche.

}{(status?.drafts?.length??0)>0&&

Brouillons reçus

    {status!.drafts!.map(d=>
  • {d.draft_id} · {d.state} · {d.session_type} · {d.occurrence_count} occurrence(s)
  • )}
}{status?.finished_at&&

Dernier résultat : {status.finished_at}

}
+import { useCallback, useEffect, useRef, useState } from 'react' +import { fetchSyncStatus, newRequestId, startSync, syncIsActive, type SyncStatus } from '../api/sync' + +const labels: Record = { + idle: 'Synchronisation inactive', + requested: 'Synchronisation demandée', + waiting_android_publication: 'Publication Android requise', + running: 'Synchronisation en cours', + local_import_committed: 'Données desktop validées', + published: 'Génération retour publiée', + waiting_acknowledgement: 'Accusé Android attendu', + peer_consumed: 'Pair confirmé', + completed: 'Synchronisation confirmée', + failed: 'Échec de synchronisation', + interrupted: 'Synchronisation interrompue', + explicitly_degraded: 'Pair incompatible', +} + +const committedPhases = new Set([ + 'local_import_committed', + 'published', + 'waiting_acknowledgement', + 'peer_consumed', + 'completed', +]) + +function statusAction(status: SyncStatus | null): string { + if (status?.phase === 'waiting_android_publication') { + return 'Ouvrez l’écran de synchronisation Android et laissez le téléphone déverrouillé.' + } + if (status?.phase === 'failed' && status.error_code === 'device_unavailable') { + return 'Connectez et déverrouillez le téléphone, puis réessayez.' + } + if (status?.phase === 'failed' && status.error_code === 'transport_timeout') { + return 'Vérifiez le dialogue USB/MTP sur Android avant de relancer.' + } + return '' +} + +export function SyncControl({ onCommitted }: { onCommitted: () => void }) { + const [status, setStatus] = useState(null) + const [error, setError] = useState('') + const statusRef = useRef(null) + const committedByRun = useRef(new Map()) + const timer = useRef(undefined) + const controller = useRef(null) + const mounted = useRef(false) + + const accept = useCallback((next: SyncStatus): boolean => { + const current = statusRef.current + if (current?.run_id && next.run_id === current.run_id && + (next.progress_revision ?? 0) < (current.progress_revision ?? 0)) { + return false + } + if (current?.run_id && next.run_id && next.run_id !== current.run_id && + syncIsActive(current.phase)) { + if (!next.started_at || !current.started_at || next.started_at <= current.started_at) { + return false + } + } + statusRef.current = next + setStatus(next) + setError('') + if (next.run_id && committedPhases.has(next.phase)) { + const revision = next.progress_revision ?? 0 + const prior = committedByRun.current.get(next.run_id) ?? -1 + if (revision > prior) { + committedByRun.current.set(next.run_id, revision) + onCommitted() + } + } + return true + }, [onCommitted]) + + const schedulePoll = useCallback((delay: number) => { + if (timer.current !== undefined) window.clearTimeout(timer.current) + timer.current = window.setTimeout(async () => { + controller.current?.abort() + const nextController = new AbortController() + controller.current = nextController + try { + const next = await fetchSyncStatus(nextController.signal) + if (!mounted.current) return + const accepted = accept(next) + const current = accepted ? next : statusRef.current + if (current && syncIsActive(current.phase)) schedulePoll(750) + } catch (reason) { + if (!mounted.current || nextController.signal.aborted) return + setError('Statut indisponible : le dernier état confirmé reste affiché.') + } + }, delay) + }, [accept]) + + useEffect(() => { + mounted.current = true + schedulePoll(0) + return () => { + mounted.current = false + controller.current?.abort() + if (timer.current !== undefined) window.clearTimeout(timer.current) + } + }, [schedulePoll]) + + async function start() { + setError('') + try { + const admitted = await startSync(newRequestId()) + accept(admitted) + schedulePoll(250) + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Démarrage impossible') + } + } + + const active = status ? syncIsActive(status.phase) : false + const action = statusAction(status) + + return ( +
+ +
+ {error || labels[status?.phase ?? 'idle']} + {action &&

{action}

} + {status?.error_code &&

Code : {status.error_code}

} + {status?.diagnostic &&

{status.diagnostic}

} + {status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && ( +

Séances rapprochées : {status.sessions_reconciled}

+ )} + {(status?.drafts?.length ?? 0) > 0 && ( +
+

Brouillons reçus

+
    + {status?.drafts?.map((draft) => ( +
  • + {draft.draft_id} · {draft.state} · {draft.session_type} ·{' '} + {draft.occurrence_count} occurrence(s) +
  • + ))} +
+
+ )} + {status?.started_at &&

Démarrée : {status.started_at}

} + {status?.finished_at &&

Dernier résultat : {status.finished_at}

} +
+
+ ) } diff --git a/web/src/dashboard/DashboardGrid.tsx b/web/src/dashboard/DashboardGrid.tsx index 025c9b5..97e0d22 100644 --- a/web/src/dashboard/DashboardGrid.tsx +++ b/web/src/dashboard/DashboardGrid.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout' import { Tile } from '../components/Tile' import type { DashboardSnapshot } from '../api/dashboard' +import type { PreparedItemsSnapshot } from '../api/preparedItems' import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout' import { cloneLayout, @@ -35,9 +36,23 @@ function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } { return { columns: 12, rowHeight: 52 } } -interface DashboardGridProps { dashboard?: DashboardSnapshot | null; pending?: boolean; failed?: boolean } +interface DashboardGridProps { + dashboard?: DashboardSnapshot | null + pending?: boolean + failed?: boolean + preparedItems?: PreparedItemsSnapshot | null + preparedItemsPending?: boolean + preparedItemsFailed?: boolean +} -export function DashboardGrid({ dashboard = null, pending = false, failed = false }: DashboardGridProps) { +export function DashboardGrid({ + dashboard = null, + pending = false, + failed = false, + preparedItems = null, + preparedItemsPending = false, + preparedItemsFailed = false, +}: DashboardGridProps) { const [layout, setLayout] = useState(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) const [editing, setEditing] = useState(false) const [announcement, setAnnouncement] = useState('') @@ -169,7 +184,10 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals const content = dashboardTiles[id] const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0] const size = tileSize(dimensions) - const available = dashboard === null ? false : tileAvailable(dashboard, id) + const available = id === 'next-session' + ? (preparedItems?.items.length ?? 0) > 0 + : dashboard !== null && tileAvailable(dashboard, id) + const tilePending = id === 'next-session' ? preparedItemsPending : pending const partial = dashboard?.meta.partial === true && id === 'max-records' const TileContent = content.component return ( @@ -180,10 +198,21 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals size={size} editable={editing && desktop} onKeyDown={(event) => onTileKeyDown(event, id)} - stateLabel={pending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'} + stateLabel={tilePending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'} stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'} > - {dashboard === null ? : } + {id === 'next-session' ? ( + + ) : dashboard === null ? ( + + ) : ( + + )} ) @@ -195,7 +224,7 @@ export function DashboardGrid({ dashboard = null, pending = false, failed = fals } function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean { - if (id === 'next-session') return snapshot.data.next_session.available + if (id === 'next-session') return false if (id === 'activity') return snapshot.data.activity.available if (id === 'progression') return snapshot.data.progression.available if (id === 'last-session') return snapshot.data.last_session.available diff --git a/web/src/dashboard/DashboardTiles.test.tsx b/web/src/dashboard/DashboardTiles.test.tsx index 1780bba..aaabd26 100644 --- a/web/src/dashboard/DashboardTiles.test.tsx +++ b/web/src/dashboard/DashboardTiles.test.tsx @@ -6,8 +6,8 @@ import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistri describe('tuiles Dashboard alimentées par le contrat', () => { it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => { const snapshot = dashboardFixture() - const { rerender } = render() - expect(screen.getByText('Aucune séance préparée')).toBeInTheDocument() + const { rerender } = render() + expect(screen.getByText('Aucun élément disponible')).toBeInTheDocument() expect(document.body).not.toHaveTextContent('no_persisted_executable_plan') rerender() expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument() @@ -15,6 +15,31 @@ describe('tuiles Dashboard alimentées par le contrat', () => { expect(document.body).not.toHaveTextContent(/bpm|%/i) }) + it('sépare proposition à valider et brouillon actif sans fabriquer de réalisé', () => { + const proposal = { + api_version: 1 as const, + generated_at: '2026-09-17T12:00:00Z', + partial: false, + items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' }], + } + const { rerender } = render( + , + ) + expect(screen.getByText('Proposition préparée · à valider')).toBeInTheDocument() + expect(screen.getByText('Préparation')).toBeInTheDocument() + expect(document.body).not.toHaveTextContent(/réalisée|terminée/i) + + rerender() + expect(screen.getByText('Brouillon d’exécution · à reprendre')).toBeInTheDocument() + }) + + it('conserve une lecture durable lorsque son actualisation échoue', () => { + render() + expect(screen.getByText('Conservée')).toBeInTheDocument() + expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument() + expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument() + }) + it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => { const { container } = render() const heatmap = screen.getByRole('img') diff --git a/web/src/dashboard/DashboardTiles.tsx b/web/src/dashboard/DashboardTiles.tsx index cd060a1..fb16322 100644 --- a/web/src/dashboard/DashboardTiles.tsx +++ b/web/src/dashboard/DashboardTiles.tsx @@ -3,13 +3,53 @@ import { lazy, Suspense, type ComponentType } from 'react' import type { TileSize } from './dashboardLayout' import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat' import { BodyZoneFigure } from './BodyZoneFigure' +import type { PreparedItemsSnapshot } from '../api/preparedItems' const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart }))) interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize } -export function NextSessionTile({ size }: TileDataProps) { - return

Aucune séance préparée

{size !== 'compact' && Aucune séance préparée n’est actuellement disponible.}{size === 'large' && }
+export interface NextSessionTileProps { + size: TileSize + preparedItems?: PreparedItemsSnapshot | null + pending?: boolean + failed?: boolean +} + +export function NextSessionTile({ + size, + preparedItems = null, + pending = false, + failed = false, +}: NextSessionTileProps) { + if (preparedItems === null) { + return + } + const item = preparedItems.items[0] + if (item === undefined) { + return + } + const proposal = item.kind === 'ai_proposal' + const heading = proposal + ? 'Proposition préparée · à valider' + : item.state === 'active' + ? 'Brouillon d’exécution · à reprendre' + : 'Brouillon d’exécution · en attente' + const title = item.title || (proposal ? 'Proposition sans titre' : 'Séance préparée') + return ( +
+

{heading}

+ {title} + {size !== 'compact' && ( +
+ + +
+ )} + {failed && Dernière lecture conservée · actualisation indisponible} + {pending && !failed && Actualisation…} +
+ ) } export function ActivityTile({ snapshot, size }: TileDataProps) { diff --git a/web/src/routes/DashboardPage.tsx b/web/src/routes/DashboardPage.tsx index 3d7d4ae..6452eb1 100644 --- a/web/src/routes/DashboardPage.tsx +++ b/web/src/routes/DashboardPage.tsx @@ -1,15 +1,37 @@ import { DashboardGrid } from '../dashboard/DashboardGrid' import type { DashboardSnapshot } from '../api/dashboard' +import type { PreparedItemsSnapshot } from '../api/preparedItems' -interface DashboardPageProps { dashboard: DashboardSnapshot | null; pending: boolean; failed: boolean } +interface DashboardPageProps { + dashboard: DashboardSnapshot | null + pending: boolean + failed: boolean + preparedItems: PreparedItemsSnapshot | null + preparedItemsPending: boolean + preparedItemsFailed: boolean +} -export function DashboardPage({ dashboard, pending, failed }: DashboardPageProps) { +export function DashboardPage({ + dashboard, + pending, + failed, + preparedItems, + preparedItemsPending, + preparedItemsFailed, +}: DashboardPageProps) { return (

VUE D’ENSEMBLE

Dashboard

- +
) }