diff --git a/CHANGELOG.md b/CHANGELOG.md index f23a782..d8e900c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,10 @@ also refined. The duplicate terminal `18` is removed; detail/HR night selection remains independent from the report range, whose date fields now use a grouped Trainlog form treatment. +- Fixed a real Sleep agenda offset bug when revision metadata used UTC `Z` but + factual events used the user's local offset: the row now anchors its complete + 18:00 axis to one factual night offset. Agenda night headings use French + `DD/MM/YYYY` rather than persistence-oriented ISO dates in the French UI. ## 0.1.5 — 2026-09-23 diff --git a/docs/current_state.md b/docs/current_state.md index 314657f..42b07f3 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -206,7 +206,8 @@ intakes, and bounds. The agenda renders an estimated interval with distinct hatching, never substitutes it for a factual interval, and retains the exact shared 18:00-to-18:00 geometry, reinforced by 24 equal hour columns shared by every row, with each hour label centered inside the cell it names. Displayed -sleep duration is the sum of factual +events use one factual night offset even when revision metadata is UTC, and +French agenda headings use `DD/MM/YYYY`. Displayed sleep duration is the sum of factual sleep intervals or, in their absence, the displayed estimate minus overlapping structured awakenings. The overlays remain available without an HR capture and do not fabricate BPM. diff --git a/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md b/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md index 3aaff02..de25071 100644 --- a/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md +++ b/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md @@ -19,6 +19,12 @@ not alter it. Each of the 24 hour labels is centered in the equal visual cell it names, using the same grid beneath every 18:00-to-18:00 agenda row; there is no ambiguous duplicate `18` at the right edge. +A real-data follow-up found that using UTC revision `created_at` as the visual +offset source moved `+02:00` events two hours left. The agenda now derives one +row-wide offset from factual bedtime/event data. Regression coverage mixes UTC +metadata with `+02:00` events and keeps the 04:45 final get-up at its local +04:45 position. French night headings render `DD/MM/YYYY`. + ## Automated validation - Web targeted suites: 34/34 passed. diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index b50d871..04e0136 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -697,11 +697,18 @@ describe("SleepDiaryWorkspace", () => { }); it("aligns ticks, events, intervals, and medication markers on one 18:00 axis", async () => { - api.fetchSleepDiary.mockResolvedValue(snapshotWith([dayAEntry()])); + const mixedOffsetEntry = { + ...dayAEntry(), + // Regression fixture: metadata is UTC while factual night events carry + // the user's +02:00 local offset. + created_at: "2026-09-20T16:00:00Z", + }; + api.fetchSleepDiary.mockResolvedValue(snapshotWith([mixedOffsetEntry])); api.fetchSleepMedications.mockResolvedValue([medication]); render(); const bedtime = await screen.findByTestId("sleep-agenda-event-bed_time"); + expect(screen.getByText("Nuit du 20/09/2026")).toBeInTheDocument(); const sleep = screen.getByTestId("sleep-agenda-event-sleep"); const getUp = screen.getByTestId("sleep-agenda-event-final_get_up"); const sleepiness = screen.getByTestId( diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 22c6713..0d802ad 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -172,6 +172,13 @@ const copy = { } as const; const clock = (iso: string) => (iso ? iso.slice(11, 16) : ""); +const displayDate = (isoDate: string, language: Language) => { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate); + if (!match) return isoDate; + return language === "fr" + ? `${match[3]}/${match[2]}/${match[1]}` + : `${match[1]}-${match[2]}-${match[3]}`; +}; const projectedClock = (timestamp: number) => new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", @@ -805,6 +812,14 @@ export function SleepDiaryWorkspace({ {agendaEntries.map((entry) => { const facts = sleepEntryFacts(entry); const projection = projectSleepTimeline(entry); + // WHY: persistence metadata may be UTC while factual night + // events carry the user's local offset. The 18:00 axis must + // use one factual local offset for the whole row or every + // marker is shifted by the UTC/local difference. + const timelineOffsetSource = entry.events.find( + (event) => event.type === "bed_time", + )?.start_at ?? entry.events[0]?.start_at ?? + entry.intakes[0]?.taken_at ?? entry.created_at; const estimatedSleep = projection.sleep.filter( (range) => range.estimated, ); @@ -834,7 +849,7 @@ export function SleepDiaryWorkspace({ onDoubleClick={() => edit(entry)} > - {t.night} {entry.night_start_date} + {t.night} {displayDate(entry.night_start_date, language)} {t.statuses[entry.publication_status]} · {t.continue} @@ -845,7 +860,7 @@ export function SleepDiaryWorkspace({ new Date(range.start).toISOString(), new Date(range.end).toISOString(), entry.night_start_date, - entry.created_at, + timelineOffsetSource, ); const label = `${ language === "fr" ? "Sommeil estimé" : "Estimated sleep" @@ -872,7 +887,7 @@ export function SleepDiaryWorkspace({ event.start_at, event.end_at as string, entry.night_start_date, - entry.created_at, + timelineOffsetSource, ); return (