Compare commits

..

3 commits

25 changed files with 447 additions and 63 deletions

View file

@ -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
canonical identity while retaining history/MAX context and recording a causal
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

View file

@ -294,7 +294,20 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
repository.inSyncGenerationTransaction { db ->
val retained =
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),
)
.use {

View file

@ -872,6 +872,13 @@ class SyncGenerationServiceTest {
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
sleepEntry.entryId,
sleepEntry.revisionId,
"2026-09-23T07:00:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val heartCapture =
source.startHeartRateCapture(
HeartRateContextKind.SLEEP,
@ -932,6 +939,10 @@ class SyncGenerationServiceTest {
"acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
"unchanged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
0,
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
@ -958,6 +969,69 @@ class SyncGenerationServiceTest {
.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(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,

View file

@ -4,12 +4,12 @@
"columns": 12,
"max_y": 200,
"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": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "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": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "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": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "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": "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": 12, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "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": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 4, "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": 12, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
]
}

View file

@ -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. |
| 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 |
| 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 |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_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
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,
history, preparation and Program reference counts. Its guarded merge accepts a
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
transaction, preserves occurrence/MAX facts indirectly through occurrence
identity, and writes the existing causal deletion boundary. An immutable
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,
browser shell, Dashboard, layout ownership, build/runtime separation, and

View file

@ -64,7 +64,7 @@ The heart-rate path remains independent from the frozen Bluetooth Trainlog
synchronization transport.
```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

View file

@ -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
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
offsets, revision conflict, logical deletion/reopen, companion replay and
rollback, Web periods/deep links, Android persistence, and vector PDF

View file

@ -21,13 +21,16 @@ typedef struct TileContract {
} TileContract;
static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"next-session", 4U, 8U, 3U, 7U},
{"activity", 5U, 12U, 3U, 7U},
{"progression", 6U, 12U, 4U, 9U},
{"last-session", 3U, 7U, 3U, 6U},
{"max-records", 3U, 8U, 3U, 7U},
{"muscle-distribution", 5U, 12U, 4U, 9U},
{"cardio-recovery", 5U, 12U, 4U, 8U}};
/* CONTRACT: every tile can reach full width and multiple useful vertical
* sizes. Per-tile minima preserve legibility; the shared 12-column grid
* and overlap validation remain the bounded layout authority. */
{"next-session", 4U, 12U, 3U, 10U},
{"activity", 5U, 12U, 3U, 12U},
{"progression", 6U, 12U, 4U, 12U},
{"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] = {
{"next-session", 0U, 0U, 5U, 4U},

View file

@ -126,7 +126,10 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database,
char revision[TRAINLOG_ID_MAX + 1U],
bool *active_draft) {
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 "
"AND deleted=1)";
static const char HISTORY_SQL[] =
@ -156,6 +159,11 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database,
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(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;
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;

View file

@ -46,7 +46,8 @@ static const ListDefinition LISTS[] = {
{"draft",
"SELECT "
"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 "
"f.session_id=d.session_id) "
"AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND "

View file

@ -158,7 +158,8 @@ static bool active_draft_conflicts_without_mutation(void) {
CHECK(sqlite3_exec(database->connection,
"INSERT INTO execution_drafts VALUES("
"'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) == SQLITE_OK);
@ -177,6 +178,53 @@ static bool active_draft_conflicts_without_mutation(void) {
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) {
char path[] = "/tmp/trainlog-web-delete-program-execution-XXXXXX";
TrainlogDatabase *database = NULL;
@ -276,6 +324,7 @@ int main(void) {
return proposal_deletion_is_durable_and_preserves_derivatives() &&
draft_and_history_use_causal_deletion() &&
active_draft_conflicts_without_mutation() &&
empty_active_draft_is_causally_deleted_and_replay_safe() &&
completed_program_execution_deletion_is_atomic_and_idempotent()
? EXIT_SUCCESS
: EXIT_FAILURE;

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { parseEquipment } from './equipment'
import { describe, expect, it, vi } from 'vitest'
import { mergeEquipment, parseEquipment } from './equipment'
describe('equipment API', () => {
it('accepts bounded factual merge consequences', () => {
@ -19,4 +19,33 @@ describe('equipment API', () => {
historical_occurrences: 0, preparation_references: 0, program_references: 0,
}] })).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')
})
})

View file

@ -1,4 +1,4 @@
import { mutationCsrfToken } from './sync'
import { mutationCsrfToken, newUuidV4 } from './sync'
export interface EquipmentItem {
equipment_id: string
@ -41,7 +41,7 @@ export async function mergeEquipment(canonicalId: string, duplicateId: string):
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'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 }),
})

View file

@ -1,4 +1,4 @@
import { mutationCsrfToken } from './sync'
import { mutationCsrfToken, newUuidV4 } from './sync'
export type RecordingMode = 'sets' | 'continuous'
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',
input?: ExerciseInput, revision?: string): Promise<ExerciseDetail | { state: 'retired' }> {
const csrf = await mutationCsrfToken()
const requestId = crypto.randomUUID()
const requestId = newUuidV4()
const headers: Record<string, string> = {
Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf,

View file

@ -157,7 +157,7 @@ export async function startSync(requestId: string): Promise<SyncStatus> {
if (/^[0-9a-f]{64}$/.test(token)) csrfToken = token
return parse(value)
}
export function newRequestId(): string {
export function newUuidV4(): string {
/*
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
* context, so randomUUID() is legitimately unavailable there.
@ -176,7 +176,10 @@ export function newRequestId(): string {
hexadecimal.slice(8, 10).join(''),
hexadecimal.slice(10, 16).join(''),
].join('-')
return `sy_${uuid}`
return uuid
}
export function newRequestId(): string {
return `sy_${newUuidV4()}`
}
export function syncIsActive(phase: SyncPhase): boolean {
return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)

View file

@ -166,4 +166,21 @@ describe('shell Trainlog', () => {
expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(6)
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')
})
})

View file

@ -38,9 +38,11 @@ export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps
</a>
))}
</nav>
<a href="/parametres" className={activeRoute.id === 'settings' ? 'nav-link settings-link is-active' : 'nav-link settings-link'}
onClick={(event) => follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'}</a>
<SyncControl onCommitted={onSyncCommitted} />
<div className="header-actions">
<a href="/parametres" className={activeRoute.id === 'settings' ? 'nav-link settings-link is-active' : 'nav-link settings-link'}
onClick={(event) => follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'}</a>
<SyncControl onCommitted={onSyncCommitted} />
</div>
</header>
)
}

View file

@ -21,15 +21,17 @@ import {
import { ActivityTile, LastSessionTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles'
import { MeasurementsTile, ProgramTile } from './DashboardTiles'
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 }>> = {
'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', component: NextSessionTile },
activity: { title: 'Activité', eyebrow: 'Régularité', component: ActivityTile },
progression: { title: 'Progression', eyebrow: 'Évolution', component: ProgressionTile },
'last-session': { title: 'Dernière séance', eyebrow: 'Historique', component: LastSessionTile },
'max-records': { title: 'Mensurations', eyebrow: 'Évolution', component: MeasurementsTile },
'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', component: MuscleDistributionTile },
'cardio-recovery': { title: 'Programme actif', eyebrow: 'Planification', component: ProgramTile },
const dashboardComponents: Readonly<Record<TileId, DashboardTileComponent>> = {
'next-session': NextSessionTile,
activity: ActivityTile,
progression: ProgressionTile,
'last-session': LastSessionTile,
'max-records': MeasurementsTile,
'muscle-distribution': MuscleDistributionTile,
'cardio-recovery': ProgramTile,
}
function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
@ -57,6 +59,13 @@ export function DashboardGrid({
preparedItemsFailed = false,
analysis = null,
}: 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 [editing, setEditing] = useState(false)
const [announcement, setAnnouncement] = useState('')
@ -151,27 +160,27 @@ export function DashboardGrid({
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 (
<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 ? (
<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>}
<button className="layout-action" type="button" onClick={cancelEditing}>Annuler</button>
<button className="layout-action" type="button" onClick={resetEditing}>Réinitialiser</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" type="button" onClick={cancelEditing}>{messages.cancel}</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 ? messages.saving : messages.save}</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>}
{failed && <div className="dashboard-data-message is-error" role="alert">Les données du Dashboard sont momentanément indisponibles.</div>}
{dashboard?.meta.invalid_data && <div className="dashboard-data-message is-warning" role="status">Certaines données invalides ont été écartées.</div>}
{pending && <div className="dashboard-data-message" role="status">{messages.dashboardLoading}</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">{messages.invalidData}</div>}
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
<div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}>
<GridLayout
@ -204,7 +213,7 @@ export function DashboardGrid({
size={size}
editable={editing && desktop}
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'}
>
{id === 'next-session' ? (

View 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>

View file

@ -9,7 +9,7 @@ interface LanguageContextValue {
setLanguage: (language: WebLanguage) => void
}
const LanguageContext = createContext<LanguageContextValue | null>(null)
const LanguageContext = createContext<LanguageContextValue>({ language: 'fr', setLanguage: () => undefined })
export function LanguagePreferencesProvider({ children }: { children: ReactNode }) {
const [language, updateLanguage] = useState<WebLanguage>(() =>
@ -29,7 +29,5 @@ export function LanguagePreferencesProvider({ children }: { children: ReactNode
}
export function useLanguagePreferences(): LanguageContextValue {
const value = useContext(LanguageContext)
if (value === null) throw new Error('LanguagePreferencesProvider is missing')
return value
return useContext(LanguageContext)
}

View file

@ -188,6 +188,8 @@ describe("AnalysisPage", () => {
expect(delta.parentElement).toHaveClass("analysis-delta");
expect(screen.getByText(/Poids · kg · 2 points réels/)).toBeInTheDocument();
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 () => {
@ -201,4 +203,36 @@ describe("AnalysisPage", () => {
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",
);
});
});

View file

@ -283,7 +283,7 @@ export function AnalysisLineChart({
viewBox="0 0 112 78"
role="img"
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="62" x2="108" y2="62" />
@ -680,6 +680,23 @@ export function AnalysisPage() {
/>
)}
</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 && (
<p className="analysis-empty">{t.noData}</p>
)}

View file

@ -639,9 +639,11 @@ describe("SleepDiaryWorkspace", () => {
9,
);
expect(intake).toHaveStyle({ left: "18.75%" });
expect(document.querySelectorAll(".sleep-hour-axis > span")).toHaveLength(
25,
);
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
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 () => {

View file

@ -717,12 +717,12 @@ export function SleepDiaryWorkspace({
<div className="sleep-axis-row" aria-hidden="true">
<span />
<span className="sleep-hour-axis">
{Array.from({ length: 25 }, (_, index) => (
{[0, 6, 12, 18, 24].map((index) => (
<span
key={index}
style={{ left: `${(index / 24) * 100}%` }}
>
{(18 + index) % 24}
{String((18 + index) % 24).padStart(2, "0")}:00
</span>
))}
</span>

View file

@ -114,12 +114,17 @@
border-radius: 2px;
}
.sync-control {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.6rem;
position: relative;
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.45rem;
}
.sync-control button {
border: 1px solid var(--lavender);
border-radius: 0.55rem;
@ -514,7 +519,7 @@ h2 {
.analysis-program progress {
width: 100%;
height: 0.7rem;
accent-color: var(--lavender);
accent-color: var(--green);
}
.analysis-next-session {
padding: 0.75rem;
@ -1558,7 +1563,7 @@ h2 {
.program-progress progress {
width: 100%;
height: 0.65rem;
accent-color: var(--lavender);
accent-color: var(--green);
}
.program-progress .program-next {
margin-top: 0.35rem;
@ -2941,6 +2946,11 @@ h2 {
margin-left: 0;
justify-content: space-between;
}
.header-actions {
width: 100%;
margin-left: 0;
justify-content: flex-end;
}
.sync-details[open] {
position: static;
min-width: 0;