fix(web): anchor sleep rows to factual timezone
This commit is contained in:
parent
77b5bbca85
commit
8646422bef
5 changed files with 40 additions and 7 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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")}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue