Fix sync MTP timeout and prepared-item visibility
This commit is contained in:
parent
56ac1cdfd2
commit
f7b556cc37
26 changed files with 1345 additions and 57 deletions
13
CHANGELOG.md
13
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
tests/test_sync_error_classification.py
Normal file
78
tests/test_sync_error_classification.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
49
tui/include/trainlog/web_prepared_items.h
Normal file
49
tui/include/trainlog/web_prepared_items.h
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* Trainlog Web prepared-item V1 read model. */
|
||||
#ifndef TRAINLOG_WEB_PREPARED_ITEMS_H
|
||||
#define TRAINLOG_WEB_PREPARED_ITEMS_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
174
tui/src/web_prepared_items.c
Normal file
174
tui/src/web_prepared_items.c
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
#include "trainlog/web_prepared_items.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
140
tui/src/web_prepared_items_json.c
Normal file
140
tui/src/web_prepared_items_json.c
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#include "trainlog/web_prepared_items.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <utf8proc.h>
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
#include <yyjson.h>
|
||||
|
||||
#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;
|
||||
|
|
|
|||
110
tui/tests/test_web_prepared_items.c
Normal file
110
tui/tests/test_web_prepared_items.c
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
21
web/src/api/preparedItems.test.ts
Normal file
21
web/src/api/preparedItems.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
56
web/src/api/preparedItems.ts
Normal file
56
web/src/api/preparedItems.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
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<PreparedItemsSnapshot> {
|
||||
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())
|
||||
}
|
||||
|
|
@ -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<string,boolean>; drafts?: SyncDraftSummary[]; finished_at?: string|null }
|
||||
let csrfToken=''
|
||||
const phases=new Set<SyncPhase>(['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<SyncStatus>;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<SyncStatus>{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<SyncStatus>{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<string, boolean>
|
||||
drafts?: SyncDraftSummary[]
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
}
|
||||
|
||||
let csrfToken = ''
|
||||
const phases = new Set<SyncPhase>([
|
||||
'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<SyncDraftSummary>
|
||||
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<SyncStatus>
|
||||
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<SyncStatus> {
|
||||
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<SyncStatus> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DashboardSnapshot | null>(null)
|
||||
const [dashboardPending, setDashboardPending] = useState(true)
|
||||
const [dashboardFailed, setDashboardFailed] = useState(false)
|
||||
const [preparedItems, setPreparedItems] = useState<PreparedItemsSnapshot | null>(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' ? <DashboardPage dashboard={dashboard} pending={dashboardPending} failed={dashboardFailed} /> :
|
||||
const content = route.id === 'dashboard' ? (
|
||||
<DashboardPage
|
||||
dashboard={dashboard}
|
||||
pending={dashboardPending}
|
||||
failed={dashboardFailed}
|
||||
preparedItems={preparedItems}
|
||||
preparedItemsPending={preparedItemsPending}
|
||||
preparedItemsFailed={preparedItemsFailed}
|
||||
/>
|
||||
) :
|
||||
<PlaceholderPage title={placeholderContent[route.id][0]}
|
||||
eyebrow={placeholderContent[route.id][1]}
|
||||
description={placeholderContent[route.id][2]} />
|
||||
|
|
@ -50,7 +80,10 @@ export function App() {
|
|||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Aller au contenu</a>
|
||||
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={reloadDashboard} />
|
||||
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={() => {
|
||||
reloadDashboard()
|
||||
reloadPreparedItems()
|
||||
}} />
|
||||
<main className="app-main" id="main-content">{content}</main>
|
||||
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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(<SyncControl onCommitted={committed}/>);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(<SyncControl onCommitted={committed} />)
|
||||
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(<SyncControl onCommitted={vi.fn()} />)
|
||||
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(<SyncControl onCommitted={vi.fn()} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,153 @@
|
|||
import { useEffect,useRef,useState } from 'react'
|
||||
import { fetchSyncStatus,newRequestId,startSync,syncIsActive,type SyncStatus } from '../api/sync'
|
||||
const labels:Record<string,string>={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<SyncStatus|null>(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 <div className="sync-control"><button type="button" onClick={start} disabled={active||status?.enabled===false}>Synchroniser</button><details className="sync-details"><summary aria-live="polite">{error||labels[status?.phase??'idle']}</summary>{status?.diagnostic&&<p>{status.diagnostic}</p>}{status?.sessions_reconciled!==null&&status?.sessions_reconciled!==undefined&&<p>Séances rapprochées : {status.sessions_reconciled}</p>}{status?.phase==='waiting_android_publication'&&<p>Ouvrez l’écran de synchronisation Android pour publier une génération fraîche.</p>}{(status?.drafts?.length??0)>0&&<section aria-label="Brouillons synchronisés"><h2>Brouillons reçus</h2><ul>{status!.drafts!.map(d=><li key={d.draft_id}><code>{d.draft_id}</code> · {d.state} · {d.session_type} · {d.occurrence_count} occurrence(s)</li>)}</ul></section>}{status?.finished_at&&<p>Dernier résultat : {status.finished_at}</p>}</details></div>
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { fetchSyncStatus, newRequestId, startSync, syncIsActive, type SyncStatus } from '../api/sync'
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
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<SyncStatus | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const statusRef = useRef<SyncStatus | null>(null)
|
||||
const committedByRun = useRef(new Map<string, number>())
|
||||
const timer = useRef<number | undefined>(undefined)
|
||||
const controller = useRef<AbortController | null>(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 (
|
||||
<div className="sync-control">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={active || status?.enabled === false}
|
||||
>
|
||||
Synchroniser
|
||||
</button>
|
||||
<details className="sync-details">
|
||||
<summary aria-live="polite">{error || labels[status?.phase ?? 'idle']}</summary>
|
||||
{action && <p>{action}</p>}
|
||||
{status?.error_code && <p>Code : {status.error_code}</p>}
|
||||
{status?.diagnostic && <p>{status.diagnostic}</p>}
|
||||
{status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && (
|
||||
<p>Séances rapprochées : {status.sessions_reconciled}</p>
|
||||
)}
|
||||
{(status?.drafts?.length ?? 0) > 0 && (
|
||||
<section aria-label="Brouillons synchronisés">
|
||||
<h2>Brouillons reçus</h2>
|
||||
<ul>
|
||||
{status?.drafts?.map((draft) => (
|
||||
<li key={draft.draft_id}>
|
||||
<code>{draft.draft_id}</code> · {draft.state} · {draft.session_type} ·{' '}
|
||||
{draft.occurrence_count} occurrence(s)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{status?.started_at && <p>Démarrée : {status.started_at}</p>}
|
||||
{status?.finished_at && <p>Dernier résultat : {status.finished_at}</p>}
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TileLayout[]>(() => 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 ? <div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div> : <TileContent snapshot={dashboard} size={size} />}
|
||||
{id === 'next-session' ? (
|
||||
<NextSessionTile
|
||||
size={size}
|
||||
preparedItems={preparedItems}
|
||||
pending={preparedItemsPending}
|
||||
failed={preparedItemsFailed}
|
||||
/>
|
||||
) : dashboard === null ? (
|
||||
<div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div>
|
||||
) : (
|
||||
<TileContent snapshot={dashboard} size={size} />
|
||||
)}
|
||||
</Tile>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(<NextSessionTile snapshot={snapshot} size="large" />)
|
||||
expect(screen.getByText('Aucune séance préparée')).toBeInTheDocument()
|
||||
const { rerender } = render(<NextSessionTile preparedItems={{api_version:1,generated_at:'2026-09-17T12:00:00Z',partial:false,items:[]}} pending={false} failed={false} size="large" />)
|
||||
expect(screen.getByText('Aucun élément disponible')).toBeInTheDocument()
|
||||
expect(document.body).not.toHaveTextContent('no_persisted_executable_plan')
|
||||
rerender(<CardioTile snapshot={snapshot} size="large" />)
|
||||
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(
|
||||
<NextSessionTile size="large" preparedItems={proposal} pending={false} failed={false} />,
|
||||
)
|
||||
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(<NextSessionTile size="large" preparedItems={{ ...proposal, items: [{ ...proposal.items[0], identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' }] }} pending={false} failed={false} />)
|
||||
expect(screen.getByText('Brouillon d’exécution · à reprendre')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('conserve une lecture durable lorsque son actualisation échoue', () => {
|
||||
render(<NextSessionTile size="medium" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [{ identity: 'aid_one', kind: 'ai_proposal', title: 'Conservée', planned_for: null, state: 'published', occurrence_count: 1, provenance: 'ai_import' }] }} pending={false} failed />)
|
||||
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(<ActivityTile snapshot={dashboardFixture()} size={size} />)
|
||||
const heatmap = screen.getByRole('img')
|
||||
|
|
|
|||
|
|
@ -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 <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>Aucune séance préparée</p>{size !== 'compact' && <small>Aucune séance préparée n’est actuellement disponible.</small>}{size === 'large' && <span className="future-action" aria-hidden="true">Préparation à venir</span>}</div>
|
||||
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 <Unavailable text={failed ? 'Données indisponibles' : 'Chargement des préparations…'} />
|
||||
}
|
||||
const item = preparedItems.items[0]
|
||||
if (item === undefined) {
|
||||
return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} />
|
||||
}
|
||||
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 (
|
||||
<div className="session-content">
|
||||
<p className="primary-label">{heading}</p>
|
||||
<strong>{title}</strong>
|
||||
{size !== 'compact' && (
|
||||
<dl className="fact-list horizontal">
|
||||
<Fact label="Éléments" value={String(item.occurrence_count)} />
|
||||
<Fact label="Prévue" value={item.planned_for ? formatDate(item.planned_for) : 'Non renseignée'} />
|
||||
</dl>
|
||||
)}
|
||||
{failed && <small>Dernière lecture conservée · actualisation indisponible</small>}
|
||||
{pending && !failed && <small>Actualisation…</small>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActivityTile({ snapshot, size }: TileDataProps) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section className="page" aria-labelledby="page-title">
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
|
||||
</div>
|
||||
<DashboardGrid dashboard={dashboard} pending={pending} failed={failed} />
|
||||
<DashboardGrid
|
||||
dashboard={dashboard}
|
||||
pending={pending}
|
||||
failed={failed}
|
||||
preparedItems={preparedItems}
|
||||
preparedItemsPending={preparedItemsPending}
|
||||
preparedItemsFailed={preparedItemsFailed}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue