([]);
const [medName, setMedName] = useState("");
const [medDose, setMedDose] = useState("");
@@ -286,6 +293,8 @@ export function SleepDiaryWorkspace({
const mutationSequence = useRef(0);
const diaryReadSequence = useRef(0);
const medicationReadSequence = useRef(0);
+ const nightSelectionSequence = useRef(0);
+ const activeNightRef = useRef(activeNight);
const range = useMemo(() => {
if (period === "all") return {};
const days = Number(period.slice(0, -1));
@@ -312,29 +321,37 @@ export function SleepDiaryWorkspace({
return catalog;
};
const adoptEntry = (entry: SleepEntry) => {
+ activeNightRef.current = entry.night_start_date;
+ setActiveNight(entry.night_start_date);
identity.current = {
entry_id: entry.entry_id,
revision_id: entry.revision_id,
};
setPublicationStatus(entry.publication_status);
setDraft(canonicalInput(entry));
+ setSaveState("saved");
+ };
+ const resetNight = (startDate: string) => {
+ identity.current = null;
+ setDraft(blankForNight(startDate));
+ setPublicationStatus("draft");
+ setSaveState("idle");
};
const reload = async () => {
+ const requestedNight = activeNightRef.current;
+ const selection = ++nightSelectionSequence.current;
try {
const [diary] = await Promise.all([readDiary(), readMedications()]);
- const current = identity.current;
- const resume = current
- ? diary.entries.find(
- (entry) =>
- entry.entry_id === current.entry_id &&
- entry.revision_id === current.revision_id,
- )
- : (diary.entries.find(
- (entry) =>
- entry.publication_status === "draft" ||
- entry.publication_status === "modified",
- ) ?? diary.entries[0]);
- if (resume) adoptEntry(resume);
+ if (
+ selection !== nightSelectionSequence.current ||
+ requestedNight !== activeNightRef.current
+ )
+ return;
+ const exact = diary.entries.find(
+ (entry) => entry.night_start_date === requestedNight,
+ );
+ if (exact) adoptEntry(exact);
+ else resetNight(requestedNight);
} catch {
setError("sleep_diary_unavailable");
}
@@ -343,17 +360,47 @@ export function SleepDiaryWorkspace({
void reload();
}, [period]);
const edit = (entry: SleepEntry) => {
- identity.current = {
- entry_id: entry.entry_id,
- revision_id: entry.revision_id,
- };
+ ++nightSelectionSequence.current;
adoptEntry(entry);
};
+ const selectNight = (startDate: string) => {
+ const selection = ++nightSelectionSequence.current;
+ ++mutationSequence.current;
+ activeNightRef.current = startDate;
+ setActiveNight(startDate);
+ resetNight(startDate);
+ setError("");
+ const cached = snapshot?.entries.find(
+ (entry) => entry.night_start_date === startDate,
+ );
+ if (cached) adoptEntry(cached);
+ void readDiary()
+ .then((diary) => {
+ if (
+ selection !== nightSelectionSequence.current ||
+ startDate !== activeNightRef.current
+ )
+ return;
+ const exact = diary.entries.find(
+ (entry) => entry.night_start_date === startDate,
+ );
+ if (exact) adoptEntry(exact);
+ else resetNight(startDate);
+ })
+ .catch(() => {
+ if (selection === nightSelectionSequence.current)
+ setError("sleep_diary_unavailable");
+ });
+ };
const persist = (next: SleepEntryInput) => {
+ ++nightSelectionSequence.current;
const sequence = ++mutationSequence.current;
setSaveState("saving");
persistence.current = persistence.current.then(async () => {
try {
+ // CONTRACT: a queued edit belongs only to the night on which it was
+ // authored; navigating away must not apply it with another identity.
+ if (activeNightRef.current !== next.night_start_date) return;
const current = identity.current;
const result = await saveSleepEntry({
...next,
@@ -361,6 +408,7 @@ export function SleepDiaryWorkspace({
expected_revision: current?.revision_id ?? next.expected_revision,
updated_at: sleepTimestamp(),
});
+ if (activeNightRef.current !== next.night_start_date) return;
identity.current = result;
const diary = await readDiary();
const persisted = diary.entries.find(
@@ -375,7 +423,11 @@ export function SleepDiaryWorkspace({
setSaveState("saved");
}
} catch (reason: unknown) {
- if (sequence !== mutationSequence.current) return;
+ if (
+ sequence !== mutationSequence.current ||
+ activeNightRef.current !== next.night_start_date
+ )
+ return;
try {
const diary = await readDiary();
const current = identity.current;
@@ -576,7 +628,7 @@ export function SleepDiaryWorkspace({
revision_id: current.revision_id,
});
identity.current = null;
- setDraft(blank());
+ resetNight(activeNightRef.current);
await reload();
} catch (reason) {
setError(
@@ -602,6 +654,11 @@ export function SleepDiaryWorkspace({
index,
})),
].sort((a, b) => a.at.localeCompare(b.at) || a.kind.localeCompare(b.kind));
+ const activeEntry = entries.find(
+ (entry) => entry.night_start_date === activeNight,
+ );
+ const activeFacts = activeEntry ? sleepEntryFacts(activeEntry) : null;
+ const agendaEntries = activeEntry ? [activeEntry] : [];
return (
@@ -638,17 +695,26 @@ export function SleepDiaryWorkspace({
- {entries.length === 0 ? (
- {t.empty}
+ {agendaEntries.length === 0 ? (
+ {t.agendaEmpty}
) : (
-
- {Array.from({ length: 25 }, (_, index) => (
-
{(18 + index) % 24}
- ))}
+
+
+
+ {Array.from({ length: 25 }, (_, index) => (
+
+ {(18 + index) % 24}
+
+ ))}
+
+
- {entries.map((entry) => {
+ {agendaEntries.map((entry) => {
const facts = sleepEntryFacts(entry);
const groupedIntakes = Object.entries(
entry.intakes.reduce
>(
@@ -675,33 +741,46 @@ export function SleepDiaryWorkspace({
{entry.events.map((event) => {
- const left = offset(
- event.start_at,
- entry.night_start_date,
- );
- const width = event.end_at
- ? Math.max(
- 1,
- offset(event.end_at, entry.night_start_date) -
- left,
+ const geometry = event.end_at
+ ? sleepTimelineInterval(
+ event.start_at,
+ event.end_at,
+ entry.night_start_date,
+ entry.created_at,
)
- : 1;
+ : {
+ left: sleepTimelinePosition(
+ event.start_at,
+ entry.night_start_date,
+ entry.created_at,
+ ),
+ width: 0,
+ };
return (
- {event.type === "bed_time"
- ? "↓"
- : event.type === "final_get_up" ||
- event.type === "night_get_up"
- ? "↑"
- : event.type === "daytime_sleepiness"
- ? "S"
- : ""}
+ {!event.end_at && (
+
+ {event.type === "bed_time"
+ ? "↓"
+ : event.type === "final_get_up" ||
+ event.type === "night_get_up"
+ ? "↑"
+ : event.type === "daytime_sleepiness"
+ ? "S"
+ : ""}
+
+ )}
);
})}
@@ -711,12 +790,13 @@ export function SleepDiaryWorkspace({
className="sleep-event sleep-medication"
data-testid="sleep-agenda-medication"
style={{
- left: `${offset(takenAt, entry.night_start_date)}%`,
- width: "1%",
+ left: `${sleepTimelinePosition(takenAt, entry.night_start_date, entry.created_at) * 100}%`,
}}
title={`${clock(takenAt)}\n${intakes.map(intakeLabel).join("\n")}`}
>
- M{intakes.length > 1 ? ` ×${intakes.length}` : ""}
+
+ M{intakes.length > 1 ? ` ×${intakes.length}` : ""}
+
))}
@@ -818,20 +898,26 @@ export function SleepDiaryWorkspace({
- change("night_start_date", event.target.value)
- }
+ onChange={(event) => selectNight(event.target.value)}
/>
→
- change("night_end_date", event.target.value)
- }
+ onChange={(event) => {
+ if (identity.current)
+ change("night_end_date", event.target.value);
+ else
+ setDraft((current) => ({
+ ...current,
+ night_end_date: event.target.value,
+ }));
+ }}
/>
@@ -842,17 +928,19 @@ export function SleepDiaryWorkspace({
>
{t.statuses[publicationStatus]}
-
- {saveState === "saving"
- ? t.saving
- : saveState === "saved"
- ? t.saved
- : error}
-
+ {saveState !== "idle" && (
+
+ {saveState === "saving"
+ ? t.saving
+ : saveState === "saved"
+ ? t.saved
+ : error}
+
+ )}
@@ -1353,46 +1441,60 @@ export function SleepDiaryWorkspace({
{language === "fr" ? "Nuits enregistrées" : "Recorded nights"}
-
{entries.length}
+
{activeEntry ? 1 : 0}
{language === "fr" ? "Sommeil déclaré" : "Declared sleep"}
- {formatDuration(snapshot?.summary.sleep_duration_seconds ?? 0)}
+ {activeFacts && activeFacts.sleepSeconds > 0
+ ? formatDuration(activeFacts.sleepSeconds)
+ : "—"}
{language === "fr" ? "Longs réveils" : "Long awakenings"}
- {countAndDuration(
- snapshot?.summary.long_awake_count ?? 0,
- snapshot?.summary.long_awake_duration_seconds ?? 0,
- )}
+ {activeFacts
+ ? countAndDuration(
+ activeFacts.longAwakeCount,
+ activeFacts.longAwakeSeconds,
+ )
+ : "—"}
{language === "fr" ? "Siestes" : "Naps"}
- {countAndDuration(
- snapshot?.summary.nap_count ?? 0,
- snapshot?.summary.nap_duration_seconds ?? 0,
- )}
+ {activeFacts
+ ? countAndDuration(
+ activeFacts.napCount,
+ activeFacts.napSeconds,
+ )
+ : "—"}
+
+
{t.daytime_sleepiness}
+ {activeFacts ? activeFacts.sleepinessCount : "—"}
+
+
+
{language === "fr" ? "Prises" : "Intakes"}
+ {activeEntry ? activeEntry.intakes.length : "—"}
+
{t.observations}
- {entries
- .filter((entry) => entry.treatment_and_notes)
- .map((entry) => (
-
- {entry.night_start_date} —{" "}
- {entry.treatment_and_notes}
-
- ))}
+ {activeEntry?.treatment_and_notes ? (
+
+ {activeEntry.night_start_date} —{" "}
+ {activeEntry.treatment_and_notes}
+
+ ) : (
+ —
+ )}
diff --git a/web/src/routes/sleepTimelineGeometry.test.ts b/web/src/routes/sleepTimelineGeometry.test.ts
new file mode 100644
index 0000000..419d7e6
--- /dev/null
+++ b/web/src/routes/sleepTimelineGeometry.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import {
+ sleepTimelineInterval,
+ sleepTimelinePosition,
+} from "./sleepTimelineGeometry";
+
+describe("sleep timeline geometry", () => {
+ it.each([
+ ["2026-09-20T18:00:00Z", 0],
+ ["2026-09-21T00:00:00Z", 0.25],
+ ["2026-09-21T06:00:00Z", 0.5],
+ ["2026-09-21T12:00:00Z", 0.75],
+ ["2026-09-21T18:00:00Z", 1],
+ ["2026-09-20T22:30:00Z", 0.1875],
+ ["2026-09-21T04:45:00Z", 0.4479166667],
+ ["2026-09-21T15:30:00Z", 0.8958333333],
+ ])("projects %s onto the shared 18:00 axis", (timestamp, expected) => {
+ expect(sleepTimelinePosition(timestamp, "2026-09-20")).toBeCloseTo(
+ expected,
+ 9,
+ );
+ });
+
+ it("uses the same axis for interval left and width", () => {
+ const interval = sleepTimelineInterval(
+ "2026-09-20T22:30:00Z",
+ "2026-09-21T03:30:00Z",
+ "2026-09-20",
+ );
+ expect(interval.left).toBeCloseTo(0.1875, 12);
+ expect(interval.width).toBeCloseTo(5 / 24, 12);
+ });
+
+ it("keeps one absolute origin when event offsets differ", () => {
+ expect(
+ sleepTimelinePosition(
+ "2026-10-25T05:00:00+01:00",
+ "2026-10-24",
+ "2026-10-24T18:00:00+02:00",
+ ),
+ ).toBeCloseTo(0.5, 12);
+ });
+});
diff --git a/web/src/routes/sleepTimelineGeometry.ts b/web/src/routes/sleepTimelineGeometry.ts
new file mode 100644
index 0000000..03ede00
--- /dev/null
+++ b/web/src/routes/sleepTimelineGeometry.ts
@@ -0,0 +1,37 @@
+export const SLEEP_TIMELINE_DURATION_MS = 24 * 60 * 60 * 1000;
+
+export function sleepTimelinePosition(
+ timestamp: string,
+ nightStartDate: string,
+ timelineOffsetSource = timestamp,
+): number {
+ const timelineOffset =
+ timelineOffsetSource.match(/(Z|[+-]\d{2}:\d{2})$/)?.[1] ?? "";
+ // CONTRACT: the visual day is exactly 24 elapsed hours beginning at local
+ // 18:00 in the event timestamp's own offset. Ticks and every event consumer
+ // call this projection, so text columns can never affect temporal geometry.
+ const timelineStart = new Date(
+ `${nightStartDate}T18:00:00${timelineOffset}`,
+ ).getTime();
+ const eventTime = new Date(timestamp).getTime();
+ if (!Number.isFinite(timelineStart) || !Number.isFinite(eventTime)) return 0;
+ return Math.max(
+ 0,
+ Math.min(1, (eventTime - timelineStart) / SLEEP_TIMELINE_DURATION_MS),
+ );
+}
+
+export function sleepTimelineInterval(
+ startAt: string,
+ endAt: string,
+ nightStartDate: string,
+ timelineOffsetSource = startAt,
+): { left: number; width: number } {
+ const left = sleepTimelinePosition(
+ startAt,
+ nightStartDate,
+ timelineOffsetSource,
+ );
+ const end = sleepTimelinePosition(endAt, nightStartDate, timelineOffsetSource);
+ return { left, width: Math.max(0, end - left) };
+}
diff --git a/web/src/theme/app.css b/web/src/theme/app.css
index 73b930b..a01ff28 100644
--- a/web/src/theme/app.css
+++ b/web/src/theme/app.css
@@ -683,14 +683,32 @@ h2 {
display: grid;
gap: 0.35rem;
}
-.sleep-hours {
+.sleep-axis-row {
display: grid;
- grid-template-columns: 12rem repeat(25, 1fr) 25rem;
+ grid-template-columns: 12rem minmax(28rem, 1fr) 25rem;
+ align-items: end;
+ gap: 0.6rem;
+ padding: 0 0.45rem;
+ min-height: 1.2rem;
color: var(--subtext-0);
font-size: 0.72rem;
}
-.sleep-hours::before {
- content: "";
+.sleep-hour-axis {
+ position: relative;
+ width: 100%;
+ height: 1rem;
+}
+.sleep-hour-axis > span {
+ position: absolute;
+ bottom: 0;
+ transform: translateX(-50%);
+ font-variant-numeric: tabular-nums;
+}
+.sleep-hour-axis > span:first-child {
+ transform: none;
+}
+.sleep-hour-axis > span:last-child {
+ transform: translateX(-100%);
}
.sleep-row {
display: grid;
@@ -755,11 +773,12 @@ h2 {
position: relative;
height: 1.75rem;
background: var(--surface-0);
- border: 1px solid var(--surface-2);
+ box-shadow: inset 0 0 0 1px var(--surface-2);
border-radius: 0.35rem;
}
.sleep-event {
position: absolute;
+ display: block;
top: 0.15rem;
bottom: 0.15rem;
min-width: 0.3rem;
@@ -768,6 +787,20 @@ h2 {
text-align: center;
color: var(--text);
}
+.sleep-point,
+.sleep-medication {
+ width: 0;
+ min-width: 0;
+ overflow: visible;
+}
+.sleep-point > span,
+.sleep-medication > span {
+ position: absolute;
+ display: block;
+ left: 0;
+ transform: translateX(-50%);
+ white-space: nowrap;
+}
.sleep-sleep,
.sleep-nap {
background: repeating-linear-gradient(
@@ -788,14 +821,15 @@ h2 {
);
}
.sleep-medication {
+ z-index: 2;
+}
+.sleep-medication > span {
min-width: 1.35rem;
padding: 0 0.2rem;
border-radius: 0.25rem;
background: var(--green);
color: var(--crust);
font-weight: 800;
- white-space: nowrap;
- z-index: 2;
}
.sleep-editor-columns {
display: grid;