Compare commits
3 commits
e7fbd6ff86
...
2755d29f2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2755d29f2d | ||
|
|
713238fd0e | ||
|
|
d7ca6a1924 |
25 changed files with 447 additions and 63 deletions
|
|
@ -10,6 +10,15 @@ BODY ZONES silhouette, and a compact Program header. A new Equipment workspace
|
||||||
lists factual references and transactionally absorbs a custom duplicate into a
|
lists factual references and transactionally absorbs a custom duplicate into a
|
||||||
canonical identity while retaining history/MAX context and recording a causal
|
canonical identity while retaining history/MAX context and recording a causal
|
||||||
deletion marker; immutable embedded drafts cause an explicit conflict.
|
deletion marker; immutable embedded drafts cause an explicit conflict.
|
||||||
|
The post-review correction keeps acknowledged-generation capacity recoverable
|
||||||
|
for legacy Android rows without ACK-ledger evidence, proves two successive
|
||||||
|
Sleep-only generation/ACK exchanges, and never evicts an unacknowledged
|
||||||
|
generation. Web can now causally remove only an orphaned zero-occurrence active
|
||||||
|
draft while retaining protection for real active drafts. Mutation UUIDs use
|
||||||
|
`crypto.getRandomValues()` on loopback HTTP origins. Sleep timeline geometry,
|
||||||
|
Dashboard sizing, Program progress contrast, chart proportions, duration
|
||||||
|
coverage messaging, and the right-aligned Settings/Sync action cluster are
|
||||||
|
also refined.
|
||||||
|
|
||||||
## 0.1.5 — 2026-09-23
|
## 0.1.5 — 2026-09-23
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,20 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
repository.inSyncGenerationTransaction { db ->
|
repository.inSyncGenerationTransaction { db ->
|
||||||
val retained =
|
val retained =
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged') AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
|
/* WHY: early deployed generation builds could advance a
|
||||||
|
* producer row to acknowledged without retaining the ACK
|
||||||
|
* ledger row. Such terminal rows cannot be archived
|
||||||
|
* safely because Trainlog must not fabricate evidence,
|
||||||
|
* but they must not exhaust the active admission window
|
||||||
|
* forever. Current acknowledgements always have durable
|
||||||
|
* ledger evidence and remain counted until archived.
|
||||||
|
* INVARIANT: captured, published and unacknowledged rows
|
||||||
|
* are always retained and never pruned for capacity. */
|
||||||
|
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " +
|
||||||
|
"(status IN('captured','published','waiting_acknowledgement') OR " +
|
||||||
|
"(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " +
|
||||||
|
"WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" +
|
||||||
|
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
|
||||||
arrayOf(consumerPeerId),
|
arrayOf(consumerPeerId),
|
||||||
)
|
)
|
||||||
.use {
|
.use {
|
||||||
|
|
|
||||||
|
|
@ -872,6 +872,13 @@ class SyncGenerationServiceTest {
|
||||||
events = emptyList(),
|
events = emptyList(),
|
||||||
),
|
),
|
||||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
assertTrue(
|
||||||
|
source.validateSleepDiary(
|
||||||
|
sleepEntry.entryId,
|
||||||
|
sleepEntry.revisionId,
|
||||||
|
"2026-09-23T07:00:00+02:00",
|
||||||
|
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
|
||||||
|
)
|
||||||
val heartCapture =
|
val heartCapture =
|
||||||
source.startHeartRateCapture(
|
source.startHeartRateCapture(
|
||||||
HeartRateContextKind.SLEEP,
|
HeartRateContextKind.SLEEP,
|
||||||
|
|
@ -932,6 +939,10 @@ class SyncGenerationServiceTest {
|
||||||
"acknowledged",
|
"acknowledged",
|
||||||
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
||||||
)
|
)
|
||||||
|
assertEquals(
|
||||||
|
"unchanged",
|
||||||
|
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
||||||
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
0,
|
0,
|
||||||
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
||||||
|
|
@ -958,6 +969,69 @@ class SyncGenerationServiceTest {
|
||||||
.trim(),
|
.trim(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/* A second Sleep-only mutation must advance the same diary entry
|
||||||
|
* after the first correlated ACK. No new workout is created, and
|
||||||
|
* replaying either generation remains strictly idempotent. */
|
||||||
|
val revisedSleep =
|
||||||
|
source.saveSleepDiary(
|
||||||
|
SleepDiaryDraft(
|
||||||
|
entryId = sleepEntry.entryId,
|
||||||
|
expectedRevision = sleepEntry.revisionId,
|
||||||
|
nightStartDate = "2026-09-22",
|
||||||
|
nightEndDate = "2026-09-23",
|
||||||
|
createdAt = "2026-09-22T22:00:00+02:00",
|
||||||
|
updatedAt = "2026-09-23T08:00:00+02:00",
|
||||||
|
sleepQuality = null,
|
||||||
|
wakeQuality = null,
|
||||||
|
dayForm = null,
|
||||||
|
treatmentAndNotes = "second sleep-only revision",
|
||||||
|
events = emptyList(),
|
||||||
|
),
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
assertTrue(
|
||||||
|
source.validateSleepDiary(
|
||||||
|
revisedSleep.entryId,
|
||||||
|
revisedSleep.revisionId,
|
||||||
|
"2026-09-23T08:01:00+02:00",
|
||||||
|
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
|
||||||
|
)
|
||||||
|
val secondSleepGeneration = sourceService.capture(root, desktopPeer)
|
||||||
|
val secondSleepPublished =
|
||||||
|
sourceService.publish(
|
||||||
|
secondSleepGeneration,
|
||||||
|
java.io.File(root, "android-desktop-objects"),
|
||||||
|
)
|
||||||
|
val secondAckFile = java.io.File(root, "android-desktop-second-ack.json")
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
||||||
|
"consume-desktop",
|
||||||
|
secondSleepPublished.absolutePath,
|
||||||
|
"--database",
|
||||||
|
desktopConsumerDb.absolutePath,
|
||||||
|
"--ack-output",
|
||||||
|
secondAckFile.absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"acknowledged",
|
||||||
|
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"unchanged",
|
||||||
|
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"1|${revisedSleep.revisionId}|1",
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopConsumerDb.absolutePath,
|
||||||
|
"SELECT count(*) || '|' || current_revision_id || '|' || " +
|
||||||
|
"(SELECT count(*) FROM sessions WHERE session_id='$androidSessionId') " +
|
||||||
|
"FROM sleep_diary_entries WHERE entry_id='${sleepEntry.entryId}';",
|
||||||
|
)
|
||||||
|
.trim(),
|
||||||
|
)
|
||||||
|
|
||||||
run(
|
run(
|
||||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||||
desktopDb.absolutePath,
|
desktopDb.absolutePath,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
"columns": 12,
|
"columns": 12,
|
||||||
"max_y": 200,
|
"max_y": 200,
|
||||||
"tiles": [
|
"tiles": [
|
||||||
{ "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
|
{ "id": "next-session", "min_width": 4, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
|
||||||
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
|
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 12, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
|
||||||
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
|
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
|
||||||
{ "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
|
{ "id": "last-session", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
|
||||||
{ "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
|
{ "id": "max-records", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
|
||||||
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
|
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
|
||||||
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
|
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ parallel implementation of its rules.
|
||||||
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
|
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
|
||||||
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
||||||
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
||||||
| Current operational cursor | 0.1.6 Web UI refinement implemented; validation and private deployment review are in progress after immutable stable v0.1.5. |
|
| Current operational cursor | 0.1.6 post-review Web corrections implemented, fully validated, and served by the private `trainlog.perf` service from development commit `713238f`; immutable stable v0.1.5 remains unchanged. |
|
||||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||||
|
|
@ -315,12 +315,40 @@ exercise to individual detail. Exercise work time is derived exclusively from
|
||||||
Measurements first expose unit-safe relative change cards before real-unit
|
Measurements first expose unit-safe relative change cards before real-unit
|
||||||
detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG.
|
detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG.
|
||||||
|
|
||||||
|
The Dashboard layout keeps a 12-column canonical grid but permits every card
|
||||||
|
to reach full width and several useful heights; per-card minima, overlap
|
||||||
|
validation, exact save/cancel/reset semantics, and keyboard resizing remain
|
||||||
|
authoritative. Settings and synchronization form the rightmost Header action
|
||||||
|
cluster. Program progress uses the shared success color while retaining its
|
||||||
|
factual completed/total text. Responsive charts preserve their viewBox aspect
|
||||||
|
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
|
||||||
|
partial measured coverage instead of deriving time from sets or repetitions.
|
||||||
|
The shared Sleep agenda owns one label/track/facts grid. Its five clock labels
|
||||||
|
and every event, interval and medication marker use the same coordinates:
|
||||||
|
18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00 at 100%.
|
||||||
|
|
||||||
The Equipment route projects supplied and custom definitions with exercise,
|
The Equipment route projects supplied and custom definitions with exercise,
|
||||||
history, preparation and Program reference counts. Its guarded merge accepts a
|
history, preparation and Program reference counts. Its guarded merge accepts a
|
||||||
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
|
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
|
||||||
transaction, preserves occurrence/MAX facts indirectly through occurrence
|
transaction, preserves occurrence/MAX facts indirectly through occurrence
|
||||||
identity, and writes the existing causal deletion boundary. An immutable
|
identity, and writes the existing causal deletion boundary. An immutable
|
||||||
execution-draft JSON reference rejects the operation rather than being edited.
|
execution-draft JSON reference rejects the operation rather than being edited.
|
||||||
|
Browser mutation identities use the shared UUIDv4 helper backed by
|
||||||
|
`crypto.getRandomValues()`; no Web mutation depends on `crypto.randomUUID()`,
|
||||||
|
so `http://trainlog.perf` remains supported.
|
||||||
|
|
||||||
|
An `execution_drafts` row marked active but containing no `exercises` or
|
||||||
|
legacy `entries` is an orphaned projection, not a resumable workout. Web may
|
||||||
|
delete that exact row through the existing revision-guarded causal transaction;
|
||||||
|
the durable tombstone prevents synchronization replay from resurrecting it.
|
||||||
|
An active draft with any occurrence remains protected from Web deletion.
|
||||||
|
|
||||||
|
Generation admission archives only rows backed by an exact correlated consumed
|
||||||
|
ACK and retains the newest two acknowledged lineage members. Legacy terminal
|
||||||
|
Android rows that predate the durable ACK ledger are neither fabricated nor
|
||||||
|
deleted, but no longer consume the active admission window. Captured,
|
||||||
|
published, waiting-ACK, and current acknowledged rows continue to count until
|
||||||
|
safe archival, so capacity recovery never prunes unacknowledged evidence.
|
||||||
|
|
||||||
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
|
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
|
||||||
browser shell, Dashboard, layout ownership, build/runtime separation, and
|
browser shell, Dashboard, layout ownership, build/runtime separation, and
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ The heart-rate path remains independent from the frozen Bluetooth Trainlog
|
||||||
synchronization transport.
|
synchronization transport.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CURRENT_OPERATIONAL_CURSOR=0.1.6 Web UI refinement implemented; validation and private deployment review pending.
|
CURRENT_OPERATIONAL_CURSOR=0.1.6 post-review Web corrections validated and privately deployed; stable release work remains separate.
|
||||||
```
|
```
|
||||||
|
|
||||||
Sleep Diary V1 stays frozen/readable while active capable peers require
|
Sleep Diary V1 stays frozen/readable while active capable peers require
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,28 @@ touching real user equipment. Strict C compilation, the full Meson inventory,
|
||||||
Web typecheck/build/Vitest, JSON/import validation, changed-C formatting and
|
Web typecheck/build/Vitest, JSON/import validation, changed-C formatting and
|
||||||
diff checks remain the closeout gates.
|
diff checks remain the closeout gates.
|
||||||
|
|
||||||
|
Post-review regressions cover a validated Sleep Diary revision plus nocturnal
|
||||||
|
BPM/RR capture through the production full-generation transport, desktop
|
||||||
|
import, correlated ACK, exact ACK replay, a second Sleep-only revision and a
|
||||||
|
second generation. The 24-conversation deployed-capacity regression removes
|
||||||
|
legacy ACK-ledger rows deliberately, recovers capacity without deleting
|
||||||
|
generation evidence, and continues through archive/replay. Native Web tests
|
||||||
|
prove that an active zero-occurrence execution draft is deleted causally and
|
||||||
|
that repeating its request is byte-identical, while a populated active draft
|
||||||
|
still conflicts. The equipment client test removes `crypto.randomUUID`, uses
|
||||||
|
deterministic `getRandomValues` bytes, and completes the protected merge HTTP
|
||||||
|
request; native merge tests retain their transaction/rollback coverage.
|
||||||
|
|
||||||
|
UI regressions assert the Sleep labels at exactly 0/25/50/75/100%, persisted
|
||||||
|
Dashboard sizing and reset behavior, English preference persistence across a
|
||||||
|
remount, centralized Dashboard English labels, non-stretched chart aspect
|
||||||
|
ratios, and distinct missing/partial exercise-timing messages.
|
||||||
|
The closeout run passes Web **31 files / 196 tests**, TypeScript typecheck and
|
||||||
|
the production build; native Meson **111/111**; Android `testDebugUnitTest` and
|
||||||
|
`assembleDebug`; JSON/import validators; changed-C formatting; and diff checks.
|
||||||
|
The restarted private service serves the rebuilt executable byte-for-byte from
|
||||||
|
the development checkout and reports healthy Trainlog `0.1.6`.
|
||||||
|
|
||||||
Sleep Diary coverage includes schema creation/migration, midnight and DST
|
Sleep Diary coverage includes schema creation/migration, midnight and DST
|
||||||
offsets, revision conflict, logical deletion/reopen, companion replay and
|
offsets, revision conflict, logical deletion/reopen, companion replay and
|
||||||
rollback, Web periods/deep links, Android persistence, and vector PDF
|
rollback, Web periods/deep links, Android persistence, and vector PDF
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,16 @@ typedef struct TileContract {
|
||||||
} TileContract;
|
} TileContract;
|
||||||
|
|
||||||
static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
|
static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
|
||||||
{"next-session", 4U, 8U, 3U, 7U},
|
/* CONTRACT: every tile can reach full width and multiple useful vertical
|
||||||
{"activity", 5U, 12U, 3U, 7U},
|
* sizes. Per-tile minima preserve legibility; the shared 12-column grid
|
||||||
{"progression", 6U, 12U, 4U, 9U},
|
* and overlap validation remain the bounded layout authority. */
|
||||||
{"last-session", 3U, 7U, 3U, 6U},
|
{"next-session", 4U, 12U, 3U, 10U},
|
||||||
{"max-records", 3U, 8U, 3U, 7U},
|
{"activity", 5U, 12U, 3U, 12U},
|
||||||
{"muscle-distribution", 5U, 12U, 4U, 9U},
|
{"progression", 6U, 12U, 4U, 12U},
|
||||||
{"cardio-recovery", 5U, 12U, 4U, 8U}};
|
{"last-session", 3U, 12U, 3U, 10U},
|
||||||
|
{"max-records", 3U, 12U, 3U, 10U},
|
||||||
|
{"muscle-distribution", 5U, 12U, 4U, 12U},
|
||||||
|
{"cardio-recovery", 5U, 12U, 4U, 12U}};
|
||||||
|
|
||||||
static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
|
static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
|
||||||
{"next-session", 0U, 0U, 5U, 4U},
|
{"next-session", 0U, 0U, 5U, 4U},
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,10 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database,
|
||||||
char revision[TRAINLOG_ID_MAX + 1U],
|
char revision[TRAINLOG_ID_MAX + 1U],
|
||||||
bool *active_draft) {
|
bool *active_draft) {
|
||||||
static const char DRAFT_SQL[] =
|
static const char DRAFT_SQL[] =
|
||||||
"SELECT revision_id,state FROM execution_drafts WHERE session_id=?1 AND NOT EXISTS("
|
"SELECT revision_id,CASE WHEN state='active' AND "
|
||||||
|
"COALESCE(json_array_length(payload_json,'$.exercises'),"
|
||||||
|
"json_array_length(payload_json,'$.entries'),0)>0 THEN 'active' ELSE '' END "
|
||||||
|
"FROM execution_drafts WHERE session_id=?1 AND NOT EXISTS("
|
||||||
"SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=?1 "
|
"SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=?1 "
|
||||||
"AND deleted=1)";
|
"AND deleted=1)";
|
||||||
static const char HISTORY_SQL[] =
|
static const char HISTORY_SQL[] =
|
||||||
|
|
@ -156,6 +159,11 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database,
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
(void)snprintf(revision, TRAINLOG_ID_MAX + 1U, "%s", current);
|
(void)snprintf(revision, TRAINLOG_ID_MAX + 1U, "%s", current);
|
||||||
|
/* WHY: an imported zero-occurrence active row is an orphaned projection,
|
||||||
|
* not a resumable Android workout. CONTRACT: Web may causally delete that
|
||||||
|
* exact canonical row, while a genuinely active draft with occurrences
|
||||||
|
* remains protected from concurrent deletion. INVARIANT: the tombstone is
|
||||||
|
* stored by the existing transaction and prevents replay resurrection. */
|
||||||
*active_draft = strcmp((const char *)sqlite3_column_text(statement, 1), "active") == 0;
|
*active_draft = strcmp((const char *)sqlite3_column_text(statement, 1), "active") == 0;
|
||||||
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
||||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ static const ListDefinition LISTS[] = {
|
||||||
{"draft",
|
{"draft",
|
||||||
"SELECT "
|
"SELECT "
|
||||||
"d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d."
|
"d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d."
|
||||||
"payload_json,'$.exercises'),0),COALESCE(d.started_at,''),NULL,NULL "
|
"payload_json,'$.exercises'),json_array_length(d.payload_json,'$.entries'),0),"
|
||||||
|
"COALESCE(d.started_at,''),NULL,NULL "
|
||||||
"FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
|
"FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
|
||||||
"f.session_id=d.session_id) "
|
"f.session_id=d.session_id) "
|
||||||
"AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND "
|
"AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND "
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,8 @@ static bool active_draft_conflicts_without_mutation(void) {
|
||||||
CHECK(sqlite3_exec(database->connection,
|
CHECK(sqlite3_exec(database->connection,
|
||||||
"INSERT INTO execution_drafts VALUES("
|
"INSERT INTO execution_drafts VALUES("
|
||||||
"'se_55555555-5555-4555-8555-555555555555','training',NULL,"
|
"'se_55555555-5555-4555-8555-555555555555','training',NULL,"
|
||||||
"'2026-09-18T10:00:00Z','mu_active',NULL,'active','{}');",
|
"'2026-09-18T10:00:00Z','mu_active',NULL,'active',"
|
||||||
|
"'{\"exercises\":[{\"entry_id\":\"entry_live\"}]}');",
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
NULL) == SQLITE_OK);
|
NULL) == SQLITE_OK);
|
||||||
|
|
@ -177,6 +178,53 @@ static bool active_draft_conflicts_without_mutation(void) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool empty_active_draft_is_causally_deleted_and_replay_safe(void) {
|
||||||
|
char path[] = "/tmp/trainlog-web-delete-empty-active-XXXXXX";
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
char *response = NULL;
|
||||||
|
char *replayed = NULL;
|
||||||
|
size_t response_size = 0U;
|
||||||
|
size_t replayed_size = 0U;
|
||||||
|
int descriptor = mkstemp(path);
|
||||||
|
|
||||||
|
CHECK(descriptor >= 0);
|
||||||
|
CHECK(close(descriptor) == 0);
|
||||||
|
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(sqlite3_exec(database->connection,
|
||||||
|
"INSERT INTO execution_drafts VALUES("
|
||||||
|
"'se_56565656-5656-4565-8565-565656565656','training',NULL,"
|
||||||
|
"'2026-09-20T10:00:00Z','mu_empty',NULL,'active',"
|
||||||
|
"'{\"exercises\":[]}');",
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL) == SQLITE_OK);
|
||||||
|
CHECK(trainlog_web_session_delete_json(database,
|
||||||
|
"draft",
|
||||||
|
"se_56565656-5656-4565-8565-565656565656",
|
||||||
|
"mu_empty",
|
||||||
|
"request-delete-empty",
|
||||||
|
&response,
|
||||||
|
&response_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_web_session_delete_json(database,
|
||||||
|
"draft",
|
||||||
|
"se_56565656-5656-4565-8565-565656565656",
|
||||||
|
"mu_empty",
|
||||||
|
"request-delete-empty",
|
||||||
|
&replayed,
|
||||||
|
&replayed_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(response_size == replayed_size && memcmp(response, replayed, response_size) == 0);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM execution_drafts") == 0);
|
||||||
|
CHECK(scalar(database,
|
||||||
|
"SELECT COUNT(*) FROM sync_causal_state WHERE target_kind='execution_draft' "
|
||||||
|
"AND target_id='se_56565656-5656-4565-8565-565656565656' AND deleted=1") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 1);
|
||||||
|
free(replayed);
|
||||||
|
free(response);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
CHECK(unlink(path) == 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool completed_program_execution_deletion_is_atomic_and_idempotent(void) {
|
static bool completed_program_execution_deletion_is_atomic_and_idempotent(void) {
|
||||||
char path[] = "/tmp/trainlog-web-delete-program-execution-XXXXXX";
|
char path[] = "/tmp/trainlog-web-delete-program-execution-XXXXXX";
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
|
|
@ -276,6 +324,7 @@ int main(void) {
|
||||||
return proposal_deletion_is_durable_and_preserves_derivatives() &&
|
return proposal_deletion_is_durable_and_preserves_derivatives() &&
|
||||||
draft_and_history_use_causal_deletion() &&
|
draft_and_history_use_causal_deletion() &&
|
||||||
active_draft_conflicts_without_mutation() &&
|
active_draft_conflicts_without_mutation() &&
|
||||||
|
empty_active_draft_is_causally_deleted_and_replay_safe() &&
|
||||||
completed_program_execution_deletion_is_atomic_and_idempotent()
|
completed_program_execution_deletion_is_atomic_and_idempotent()
|
||||||
? EXIT_SUCCESS
|
? EXIT_SUCCESS
|
||||||
: EXIT_FAILURE;
|
: EXIT_FAILURE;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { parseEquipment } from './equipment'
|
import { mergeEquipment, parseEquipment } from './equipment'
|
||||||
|
|
||||||
describe('equipment API', () => {
|
describe('equipment API', () => {
|
||||||
it('accepts bounded factual merge consequences', () => {
|
it('accepts bounded factual merge consequences', () => {
|
||||||
|
|
@ -19,4 +19,33 @@ describe('equipment API', () => {
|
||||||
historical_occurrences: 0, preparation_references: 0, program_references: 0,
|
historical_occurrences: 0, preparation_references: 0, program_references: 0,
|
||||||
}] })).toThrow('equipment_invalid')
|
}] })).toThrow('equipment_invalid')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('merges on an insecure origin without crypto.randomUUID', async () => {
|
||||||
|
vi.stubGlobal('crypto', {
|
||||||
|
getRandomValues: (bytes: Uint8Array) => {
|
||||||
|
bytes.fill(0x11)
|
||||||
|
return bytes
|
||||||
|
},
|
||||||
|
randomUUID: undefined,
|
||||||
|
})
|
||||||
|
const token = 'a'.repeat(64)
|
||||||
|
const response = (body: unknown, headers: Record<string, string> = {}) => ({
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers(headers),
|
||||||
|
json: async () => body,
|
||||||
|
})
|
||||||
|
const fetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(response({ phase: 'idle', result: 'ready' }, {
|
||||||
|
'X-Trainlog-CSRF-Token': token,
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(response({ api_version: 1, items: [] }))
|
||||||
|
vi.stubGlobal('fetch', fetch)
|
||||||
|
|
||||||
|
await expect(mergeEquipment('canonical', 'duplicate')).resolves.toEqual({
|
||||||
|
api_version: 1,
|
||||||
|
items: [],
|
||||||
|
})
|
||||||
|
expect(fetch.mock.calls[1][1].headers['X-Trainlog-Request-ID'])
|
||||||
|
.toBe('11111111-1111-4111-9111-111111111111')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { mutationCsrfToken } from './sync'
|
import { mutationCsrfToken, newUuidV4 } from './sync'
|
||||||
|
|
||||||
export interface EquipmentItem {
|
export interface EquipmentItem {
|
||||||
equipment_id: string
|
equipment_id: string
|
||||||
|
|
@ -41,7 +41,7 @@ export async function mergeEquipment(canonicalId: string, duplicateId: string):
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||||
'X-Trainlog-CSRF-Token': await mutationCsrfToken(),
|
'X-Trainlog-CSRF-Token': await mutationCsrfToken(),
|
||||||
'X-Trainlog-Request-ID': crypto.randomUUID(),
|
'X-Trainlog-Request-ID': newUuidV4(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ canonical_id: canonicalId, duplicate_id: duplicateId }),
|
body: JSON.stringify({ canonical_id: canonicalId, duplicate_id: duplicateId }),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { mutationCsrfToken } from './sync'
|
import { mutationCsrfToken, newUuidV4 } from './sync'
|
||||||
|
|
||||||
export type RecordingMode = 'sets' | 'continuous'
|
export type RecordingMode = 'sets' | 'continuous'
|
||||||
export type TrackingMode = 'reps' | 'duration'
|
export type TrackingMode = 'reps' | 'duration'
|
||||||
|
|
@ -113,7 +113,7 @@ export async function fetchBodyZones(signal?: AbortSignal): Promise<BodyZoneChoi
|
||||||
async function mutation(path: string, method: 'POST' | 'PUT' | 'DELETE',
|
async function mutation(path: string, method: 'POST' | 'PUT' | 'DELETE',
|
||||||
input?: ExerciseInput, revision?: string): Promise<ExerciseDetail | { state: 'retired' }> {
|
input?: ExerciseInput, revision?: string): Promise<ExerciseDetail | { state: 'retired' }> {
|
||||||
const csrf = await mutationCsrfToken()
|
const csrf = await mutationCsrfToken()
|
||||||
const requestId = crypto.randomUUID()
|
const requestId = newUuidV4()
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||||
'X-Trainlog-CSRF-Token': csrf,
|
'X-Trainlog-CSRF-Token': csrf,
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export async function startSync(requestId: string): Promise<SyncStatus> {
|
||||||
if (/^[0-9a-f]{64}$/.test(token)) csrfToken = token
|
if (/^[0-9a-f]{64}$/.test(token)) csrfToken = token
|
||||||
return parse(value)
|
return parse(value)
|
||||||
}
|
}
|
||||||
export function newRequestId(): string {
|
export function newUuidV4(): string {
|
||||||
/*
|
/*
|
||||||
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
|
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
|
||||||
* context, so randomUUID() is legitimately unavailable there.
|
* context, so randomUUID() is legitimately unavailable there.
|
||||||
|
|
@ -176,7 +176,10 @@ export function newRequestId(): string {
|
||||||
hexadecimal.slice(8, 10).join(''),
|
hexadecimal.slice(8, 10).join(''),
|
||||||
hexadecimal.slice(10, 16).join(''),
|
hexadecimal.slice(10, 16).join(''),
|
||||||
].join('-')
|
].join('-')
|
||||||
return `sy_${uuid}`
|
return uuid
|
||||||
|
}
|
||||||
|
export function newRequestId(): string {
|
||||||
|
return `sy_${newUuidV4()}`
|
||||||
}
|
}
|
||||||
export function syncIsActive(phase: SyncPhase): boolean {
|
export function syncIsActive(phase: SyncPhase): boolean {
|
||||||
return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)
|
return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)
|
||||||
|
|
|
||||||
|
|
@ -166,4 +166,21 @@ describe('shell Trainlog', () => {
|
||||||
expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(6)
|
expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(6)
|
||||||
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page')
|
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('persists English and applies centralized Dashboard navigation labels after reload', async () => {
|
||||||
|
mockHealth()
|
||||||
|
window.history.replaceState(null, '', '/parametres')
|
||||||
|
const first = render(<App />)
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'English' }))
|
||||||
|
fireEvent.click(screen.getByRole('link', { name: 'Dashboard' }))
|
||||||
|
expect(await screen.findByRole('button', { name: 'Edit layout' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Next session')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Prochaine séance')).not.toBeInTheDocument()
|
||||||
|
first.unmount()
|
||||||
|
window.history.replaceState(null, '', '/')
|
||||||
|
render(<App />)
|
||||||
|
expect(await screen.findByRole('button', { name: 'Edit layout' })).toBeInTheDocument()
|
||||||
|
expect(document.documentElement.lang).toBe('en')
|
||||||
|
window.localStorage.removeItem('trainlog.web.language.v1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,11 @@ export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<a href="/parametres" className={activeRoute.id === 'settings' ? 'nav-link settings-link is-active' : 'nav-link settings-link'}
|
<div className="header-actions">
|
||||||
onClick={(event) => follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'}</a>
|
<a href="/parametres" className={activeRoute.id === 'settings' ? 'nav-link settings-link is-active' : 'nav-link settings-link'}
|
||||||
<SyncControl onCommitted={onSyncCommitted} />
|
onClick={(event) => follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'}</a>
|
||||||
|
<SyncControl onCommitted={onSyncCommitted} />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,17 @@ import {
|
||||||
import { ActivityTile, LastSessionTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles'
|
import { ActivityTile, LastSessionTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles'
|
||||||
import { MeasurementsTile, ProgramTile } from './DashboardTiles'
|
import { MeasurementsTile, ProgramTile } from './DashboardTiles'
|
||||||
import type { AnalysisSnapshot } from '../api/analysis'
|
import type { AnalysisSnapshot } from '../api/analysis'
|
||||||
|
import { useLanguagePreferences } from '../presentation/LanguagePreferences'
|
||||||
|
import { dashboardMessages } from './dashboardMessages'
|
||||||
|
|
||||||
const dashboardTiles: Readonly<Record<TileId, { title: string, eyebrow: string, component: DashboardTileComponent }>> = {
|
const dashboardComponents: Readonly<Record<TileId, DashboardTileComponent>> = {
|
||||||
'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', component: NextSessionTile },
|
'next-session': NextSessionTile,
|
||||||
activity: { title: 'Activité', eyebrow: 'Régularité', component: ActivityTile },
|
activity: ActivityTile,
|
||||||
progression: { title: 'Progression', eyebrow: 'Évolution', component: ProgressionTile },
|
progression: ProgressionTile,
|
||||||
'last-session': { title: 'Dernière séance', eyebrow: 'Historique', component: LastSessionTile },
|
'last-session': LastSessionTile,
|
||||||
'max-records': { title: 'Mensurations', eyebrow: 'Évolution', component: MeasurementsTile },
|
'max-records': MeasurementsTile,
|
||||||
'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', component: MuscleDistributionTile },
|
'muscle-distribution': MuscleDistributionTile,
|
||||||
'cardio-recovery': { title: 'Programme actif', eyebrow: 'Planification', component: ProgramTile },
|
'cardio-recovery': ProgramTile,
|
||||||
}
|
}
|
||||||
|
|
||||||
function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
|
function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
|
||||||
|
|
@ -57,6 +59,13 @@ export function DashboardGrid({
|
||||||
preparedItemsFailed = false,
|
preparedItemsFailed = false,
|
||||||
analysis = null,
|
analysis = null,
|
||||||
}: DashboardGridProps) {
|
}: DashboardGridProps) {
|
||||||
|
const { language } = useLanguagePreferences()
|
||||||
|
const messages = dashboardMessages[language]
|
||||||
|
const dashboardTiles = Object.fromEntries(TILE_IDS.map((id) => [id, {
|
||||||
|
title: messages.tiles[id][0],
|
||||||
|
eyebrow: messages.tiles[id][1],
|
||||||
|
component: dashboardComponents[id],
|
||||||
|
}])) as Readonly<Record<TileId, { title: string, eyebrow: string, component: DashboardTileComponent }>>
|
||||||
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [announcement, setAnnouncement] = useState('')
|
const [announcement, setAnnouncement] = useState('')
|
||||||
|
|
@ -151,27 +160,27 @@ export function DashboardGrid({
|
||||||
|
|
||||||
const libraryLayout = toGridLayout(visibleLayout, editing && desktop, view.columns)
|
const libraryLayout = toGridLayout(visibleLayout, editing && desktop, view.columns)
|
||||||
|
|
||||||
if (loading) return <p className="layout-loading" role="status">Chargement de l’agencement…</p>
|
if (loading) return <p className="layout-loading" role="status">{messages.loading}</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`dashboard-layout${editing ? ' is-editing' : ''}`}>
|
<div className={`dashboard-layout${editing ? ' is-editing' : ''}`}>
|
||||||
<div className="layout-toolbar" aria-label="Agencement du Dashboard">
|
<div className="layout-toolbar" aria-label={messages.layout}>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
<button className="layout-action layout-action-primary" type="button" onClick={enterEditMode}>Modifier l’agencement</button>
|
<button className="layout-action layout-action-primary" type="button" onClick={enterEditMode}>{messages.edit}</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="layout-help">Flèches : déplacer · Maj + flèches : redimensionner</p>
|
<p className="layout-help">{messages.help}</p>
|
||||||
{!desktop && <p className="layout-breakpoint-note">Revenez sur un écran large pour modifier la grille canonique.</p>}
|
{!desktop && <p className="layout-breakpoint-note">Revenez sur un écran large pour modifier la grille canonique.</p>}
|
||||||
<button className="layout-action" type="button" onClick={cancelEditing}>Annuler</button>
|
<button className="layout-action" type="button" onClick={cancelEditing}>{messages.cancel}</button>
|
||||||
<button className="layout-action" type="button" onClick={resetEditing}>Réinitialiser</button>
|
<button className="layout-action" type="button" onClick={resetEditing}>{messages.reset}</button>
|
||||||
<button className="layout-action layout-action-primary" type="button" disabled={saving || snapshot === null || !desktop} onClick={() => void saveEditing()}>{saving ? 'Enregistrement…' : 'Enregistrer'}</button>
|
<button className="layout-action layout-action-primary" type="button" disabled={saving || snapshot === null || !desktop} onClick={() => void saveEditing()}>{saving ? messages.saving : messages.save}</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{preferenceError && <div className="layout-message is-error" role="alert">{preferenceError}{preferenceError.includes('autre onglet') && <button className="layout-action" type="button" onClick={() => void reloadLayout()}>Recharger l’agencement</button>}</div>}
|
{preferenceError && <div className="layout-message is-error" role="alert">{preferenceError}{preferenceError.includes('autre onglet') && <button className="layout-action" type="button" onClick={() => void reloadLayout()}>Recharger l’agencement</button>}</div>}
|
||||||
{pending && <div className="dashboard-data-message" role="status">Chargement des données du Dashboard…</div>}
|
{pending && <div className="dashboard-data-message" role="status">{messages.dashboardLoading}</div>}
|
||||||
{failed && <div className="dashboard-data-message is-error" role="alert">Les données du Dashboard sont momentanément indisponibles.</div>}
|
{failed && <div className="dashboard-data-message is-error" role="alert">{messages.dashboardFailed}</div>}
|
||||||
{dashboard?.meta.invalid_data && <div className="dashboard-data-message is-warning" role="status">Certaines données invalides ont été écartées.</div>}
|
{dashboard?.meta.invalid_data && <div className="dashboard-data-message is-warning" role="status">{messages.invalidData}</div>}
|
||||||
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
|
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
|
||||||
<div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}>
|
<div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}>
|
||||||
<GridLayout
|
<GridLayout
|
||||||
|
|
@ -204,7 +213,7 @@ export function DashboardGrid({
|
||||||
size={size}
|
size={size}
|
||||||
editable={editing && desktop}
|
editable={editing && desktop}
|
||||||
onKeyDown={(event) => onTileKeyDown(event, id)}
|
onKeyDown={(event) => onTileKeyDown(event, id)}
|
||||||
stateLabel={tilePending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
|
stateLabel={tilePending ? (language === 'fr' ? 'CHARGEMENT' : 'LOADING') : available ? (partial ? messages.recent : messages.available) : messages.unavailable}
|
||||||
stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
|
stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
|
||||||
>
|
>
|
||||||
{id === 'next-session' ? (
|
{id === 'next-session' ? (
|
||||||
|
|
|
||||||
56
web/src/dashboard/dashboardMessages.ts
Normal file
56
web/src/dashboard/dashboardMessages.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { WebLanguage } from '../presentation/LanguagePreferences'
|
||||||
|
|
||||||
|
export const dashboardMessages = {
|
||||||
|
fr: {
|
||||||
|
tiles: {
|
||||||
|
'next-session': ['Prochaine séance', 'Planification'],
|
||||||
|
activity: ['Activité', 'Régularité'],
|
||||||
|
progression: ['Progression', 'Évolution'],
|
||||||
|
'last-session': ['Dernière séance', 'Historique'],
|
||||||
|
'max-records': ['Mensurations', 'Évolution'],
|
||||||
|
'muscle-distribution': ['Répartition musculaire', 'Zones'],
|
||||||
|
'cardio-recovery': ['Programme actif', 'Planification'],
|
||||||
|
},
|
||||||
|
layout: 'Agencement du Dashboard',
|
||||||
|
edit: 'Modifier l’agencement',
|
||||||
|
cancel: 'Annuler',
|
||||||
|
reset: 'Réinitialiser',
|
||||||
|
save: 'Enregistrer',
|
||||||
|
saving: 'Enregistrement…',
|
||||||
|
help: 'Flèches : déplacer · Maj + flèches : redimensionner',
|
||||||
|
loading: 'Chargement de l’agencement…',
|
||||||
|
dashboardLoading: 'Chargement des données du Dashboard…',
|
||||||
|
dashboardFailed: 'Les données du Dashboard sont momentanément indisponibles.',
|
||||||
|
invalidData: 'Certaines données invalides ont été écartées.',
|
||||||
|
unavailable: 'INDISPONIBLE',
|
||||||
|
available: 'DISPONIBLE',
|
||||||
|
recent: 'RÉCENT',
|
||||||
|
tileEditable: 'tuile modifiable',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
tiles: {
|
||||||
|
'next-session': ['Next session', 'Planning'],
|
||||||
|
activity: ['Activity', 'Consistency'],
|
||||||
|
progression: ['Progress', 'Evolution'],
|
||||||
|
'last-session': ['Last session', 'History'],
|
||||||
|
'max-records': ['Measurements', 'Evolution'],
|
||||||
|
'muscle-distribution': ['Muscle distribution', 'Zones'],
|
||||||
|
'cardio-recovery': ['Active program', 'Planning'],
|
||||||
|
},
|
||||||
|
layout: 'Dashboard layout',
|
||||||
|
edit: 'Edit layout',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
reset: 'Reset',
|
||||||
|
save: 'Save',
|
||||||
|
saving: 'Saving…',
|
||||||
|
help: 'Arrow keys: move · Shift + arrow keys: resize',
|
||||||
|
loading: 'Loading layout…',
|
||||||
|
dashboardLoading: 'Loading Dashboard data…',
|
||||||
|
dashboardFailed: 'Dashboard data is temporarily unavailable.',
|
||||||
|
invalidData: 'Some invalid data was excluded.',
|
||||||
|
unavailable: 'UNAVAILABLE',
|
||||||
|
available: 'AVAILABLE',
|
||||||
|
recent: 'RECENT',
|
||||||
|
tileEditable: 'editable tile',
|
||||||
|
},
|
||||||
|
} as const satisfies Record<WebLanguage, object>
|
||||||
|
|
@ -9,7 +9,7 @@ interface LanguageContextValue {
|
||||||
setLanguage: (language: WebLanguage) => void
|
setLanguage: (language: WebLanguage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const LanguageContext = createContext<LanguageContextValue | null>(null)
|
const LanguageContext = createContext<LanguageContextValue>({ language: 'fr', setLanguage: () => undefined })
|
||||||
|
|
||||||
export function LanguagePreferencesProvider({ children }: { children: ReactNode }) {
|
export function LanguagePreferencesProvider({ children }: { children: ReactNode }) {
|
||||||
const [language, updateLanguage] = useState<WebLanguage>(() =>
|
const [language, updateLanguage] = useState<WebLanguage>(() =>
|
||||||
|
|
@ -29,7 +29,5 @@ export function LanguagePreferencesProvider({ children }: { children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLanguagePreferences(): LanguageContextValue {
|
export function useLanguagePreferences(): LanguageContextValue {
|
||||||
const value = useContext(LanguageContext)
|
return useContext(LanguageContext)
|
||||||
if (value === null) throw new Error('LanguagePreferencesProvider is missing')
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,8 @@ describe("AnalysisPage", () => {
|
||||||
expect(delta.parentElement).toHaveClass("analysis-delta");
|
expect(delta.parentElement).toHaveClass("analysis-delta");
|
||||||
expect(screen.getByText(/Poids · kg · 2 points réels/)).toBeInTheDocument();
|
expect(screen.getByText(/Poids · kg · 2 points réels/)).toBeInTheDocument();
|
||||||
expect(document.querySelectorAll(".chart-x-label")).toHaveLength(2);
|
expect(document.querySelectorAll(".chart-x-label")).toHaveLength(2);
|
||||||
|
expect(document.querySelector(".analysis-chart svg"))
|
||||||
|
.toHaveAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a factual exercises-by-date histogram in the overview", async () => {
|
it("uses a factual exercises-by-date histogram in the overview", async () => {
|
||||||
|
|
@ -201,4 +203,36 @@ describe("AnalysisPage", () => {
|
||||||
expect.stringContaining("1 exercice(s)"),
|
expect.stringContaining("1 exercice(s)"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports missing and partial exercise timing without inventing duration", async () => {
|
||||||
|
const missing: AnalysisSnapshot = {
|
||||||
|
...analysisFixture,
|
||||||
|
exercise_groups: [{
|
||||||
|
zone_id: "legs", label: "Jambes", exercises: [{
|
||||||
|
exercise_id: "ex_timing", name: "Presse", occurrences: 2, sessions: 2,
|
||||||
|
sets: 5, measured_seconds: 0, measured_occurrences: 0,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(missing), { status: 200 }),
|
||||||
|
);
|
||||||
|
const { rerender } = render(<AnalysisPage />);
|
||||||
|
expect(await screen.findByTestId("duration-coverage")).toHaveTextContent(
|
||||||
|
"Temps non mesuré · durée indisponible",
|
||||||
|
);
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({
|
||||||
|
...missing,
|
||||||
|
exercise_groups: [{
|
||||||
|
...missing.exercise_groups[0],
|
||||||
|
exercises: [{ ...missing.exercise_groups[0].exercises[0], measured_seconds: 600, measured_occurrences: 1 }],
|
||||||
|
}],
|
||||||
|
}), { status: 200 }),
|
||||||
|
);
|
||||||
|
rerender(<AnalysisPage key="partial" />);
|
||||||
|
expect(await screen.findByTestId("duration-coverage")).toHaveTextContent(
|
||||||
|
"Couverture temporelle partielle",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,7 @@ export function AnalysisLineChart({
|
||||||
viewBox="0 0 112 78"
|
viewBox="0 0 112 78"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${label}: ${points.length} points`}
|
aria-label={`${label}: ${points.length} points`}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
>
|
>
|
||||||
<line className="chart-axis" x1="10" y1="6" x2="10" y2="62" />
|
<line className="chart-axis" x1="10" y1="6" x2="10" y2="62" />
|
||||||
<line className="chart-axis" x1="10" y1="62" x2="108" y2="62" />
|
<line className="chart-axis" x1="10" y1="62" x2="108" y2="62" />
|
||||||
|
|
@ -680,6 +680,23 @@ export function AnalysisPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
{(() => {
|
||||||
|
const occurrences = snapshot.exercise_groups.flatMap((group) => group.exercises)
|
||||||
|
.reduce((sum, exercise) => sum + exercise.occurrences, 0);
|
||||||
|
const measured = snapshot.exercise_groups.flatMap((group) => group.exercises)
|
||||||
|
.reduce((sum, exercise) => sum + exercise.measured_occurrences, 0);
|
||||||
|
if (occurrences === 0 || measured === occurrences) return null;
|
||||||
|
const coverage = measured === 0
|
||||||
|
? (language === "fr"
|
||||||
|
? "Temps non mesuré · durée indisponible"
|
||||||
|
: "Time not measured · duration unavailable")
|
||||||
|
: (language === "fr"
|
||||||
|
? "Couverture temporelle partielle"
|
||||||
|
: "Partial time coverage");
|
||||||
|
return <p className="analysis-note" data-testid="duration-coverage">
|
||||||
|
{coverage}
|
||||||
|
</p>;
|
||||||
|
})()}
|
||||||
{snapshot.overview.sessions === 0 && (
|
{snapshot.overview.sessions === 0 && (
|
||||||
<p className="analysis-empty">{t.noData}</p>
|
<p className="analysis-empty">{t.noData}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -639,9 +639,11 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
9,
|
9,
|
||||||
);
|
);
|
||||||
expect(intake).toHaveStyle({ left: "18.75%" });
|
expect(intake).toHaveStyle({ left: "18.75%" });
|
||||||
expect(document.querySelectorAll(".sleep-hour-axis > span")).toHaveLength(
|
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
|
||||||
25,
|
expect(ticks.map((tick) => [tick.textContent, tick.style.left])).toEqual([
|
||||||
);
|
["18:00", "0%"], ["00:00", "25%"], ["06:00", "50%"],
|
||||||
|
["12:00", "75%"], ["18:00", "100%"],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isolates an empty editor night while retaining the period agenda", async () => {
|
it("isolates an empty editor night while retaining the period agenda", async () => {
|
||||||
|
|
|
||||||
|
|
@ -717,12 +717,12 @@ export function SleepDiaryWorkspace({
|
||||||
<div className="sleep-axis-row" aria-hidden="true">
|
<div className="sleep-axis-row" aria-hidden="true">
|
||||||
<span />
|
<span />
|
||||||
<span className="sleep-hour-axis">
|
<span className="sleep-hour-axis">
|
||||||
{Array.from({ length: 25 }, (_, index) => (
|
{[0, 6, 12, 18, 24].map((index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
style={{ left: `${(index / 24) * 100}%` }}
|
style={{ left: `${(index / 24) * 100}%` }}
|
||||||
>
|
>
|
||||||
{(18 + index) % 24}
|
{String((18 + index) % 24).padStart(2, "0")}:00
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -114,12 +114,17 @@
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
.sync-control {
|
.sync-control {
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.header-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
.sync-control button {
|
.sync-control button {
|
||||||
border: 1px solid var(--lavender);
|
border: 1px solid var(--lavender);
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
|
|
@ -514,7 +519,7 @@ h2 {
|
||||||
.analysis-program progress {
|
.analysis-program progress {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0.7rem;
|
height: 0.7rem;
|
||||||
accent-color: var(--lavender);
|
accent-color: var(--green);
|
||||||
}
|
}
|
||||||
.analysis-next-session {
|
.analysis-next-session {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
|
@ -1558,7 +1563,7 @@ h2 {
|
||||||
.program-progress progress {
|
.program-progress progress {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0.65rem;
|
height: 0.65rem;
|
||||||
accent-color: var(--lavender);
|
accent-color: var(--green);
|
||||||
}
|
}
|
||||||
.program-progress .program-next {
|
.program-progress .program-next {
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
|
|
@ -2941,6 +2946,11 @@ h2 {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
.sync-details[open] {
|
.sync-details[open] {
|
||||||
position: static;
|
position: static;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue