fix(web): anchor sleep rows to factual timezone

This commit is contained in:
fy59 2026-09-24 08:42:31 +02:00
parent 77b5bbca85
commit 8646422bef
5 changed files with 40 additions and 7 deletions

View file

@ -77,6 +77,10 @@ also refined.
The duplicate terminal `18` is removed; detail/HR night selection remains The duplicate terminal `18` is removed; detail/HR night selection remains
independent from the report range, whose date fields now use a grouped independent from the report range, whose date fields now use a grouped
Trainlog form treatment. 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 ## 0.1.5 — 2026-09-23

View file

@ -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 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 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 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 sleep intervals or, in their absence, the displayed estimate minus overlapping
structured awakenings. The overlays remain available without an HR capture and structured awakenings. The overlays remain available without an HR capture and
do not fabricate BPM. do not fabricate BPM.

View file

@ -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 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. 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 ## Automated validation
- Web targeted suites: 34/34 passed. - Web targeted suites: 34/34 passed.

View file

@ -697,11 +697,18 @@ describe("SleepDiaryWorkspace", () => {
}); });
it("aligns ticks, events, intervals, and medication markers on one 18:00 axis", async () => { 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]); api.fetchSleepMedications.mockResolvedValue([medication]);
render(<SleepDiaryWorkspace period="30d" language="fr" />); render(<SleepDiaryWorkspace period="30d" language="fr" />);
const bedtime = await screen.findByTestId("sleep-agenda-event-bed_time"); 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 sleep = screen.getByTestId("sleep-agenda-event-sleep");
const getUp = screen.getByTestId("sleep-agenda-event-final_get_up"); const getUp = screen.getByTestId("sleep-agenda-event-final_get_up");
const sleepiness = screen.getByTestId( const sleepiness = screen.getByTestId(

View file

@ -172,6 +172,13 @@ const copy = {
} as const; } as const;
const clock = (iso: string) => (iso ? iso.slice(11, 16) : ""); 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) => const projectedClock = (timestamp: number) =>
new Date(timestamp).toLocaleTimeString([], { new Date(timestamp).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
@ -805,6 +812,14 @@ export function SleepDiaryWorkspace({
{agendaEntries.map((entry) => { {agendaEntries.map((entry) => {
const facts = sleepEntryFacts(entry); const facts = sleepEntryFacts(entry);
const projection = projectSleepTimeline(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( const estimatedSleep = projection.sleep.filter(
(range) => range.estimated, (range) => range.estimated,
); );
@ -834,7 +849,7 @@ export function SleepDiaryWorkspace({
onDoubleClick={() => edit(entry)} onDoubleClick={() => edit(entry)}
> >
<strong> <strong>
{t.night} {entry.night_start_date} {t.night} {displayDate(entry.night_start_date, language)}
<small> <small>
{t.statuses[entry.publication_status]} · {t.continue} {t.statuses[entry.publication_status]} · {t.continue}
</small> </small>
@ -845,7 +860,7 @@ export function SleepDiaryWorkspace({
new Date(range.start).toISOString(), new Date(range.start).toISOString(),
new Date(range.end).toISOString(), new Date(range.end).toISOString(),
entry.night_start_date, entry.night_start_date,
entry.created_at, timelineOffsetSource,
); );
const label = `${ const label = `${
language === "fr" ? "Sommeil estimé" : "Estimated sleep" language === "fr" ? "Sommeil estimé" : "Estimated sleep"
@ -872,7 +887,7 @@ export function SleepDiaryWorkspace({
event.start_at, event.start_at,
event.end_at as string, event.end_at as string,
entry.night_start_date, entry.night_start_date,
entry.created_at, timelineOffsetSource,
); );
return ( return (
<i <i
@ -891,7 +906,7 @@ export function SleepDiaryWorkspace({
const left = sleepTimelinePosition( const left = sleepTimelinePosition(
event.start_at, event.start_at,
entry.night_start_date, entry.night_start_date,
entry.created_at, timelineOffsetSource,
); );
return ( return (
<i key={event.event_id} <i key={event.event_id}
@ -911,7 +926,7 @@ export function SleepDiaryWorkspace({
className="sleep-event sleep-medication" className="sleep-event sleep-medication"
data-testid="sleep-agenda-medication" data-testid="sleep-agenda-medication"
style={{ style={{
left: `${sleepTimelinePosition(takenAt, entry.night_start_date, entry.created_at) * 100}%`, left: `${sleepTimelinePosition(takenAt, entry.night_start_date, timelineOffsetSource) * 100}%`,
}} }}
title={`${clock(takenAt)}\n${intakes.map(intakeLabel).join("\n")}`} title={`${clock(takenAt)}\n${intakes.map(intakeLabel).join("\n")}`}
> >