diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx
index 1f3fbf1b8..a1eb7e8f3 100644
--- a/src/components/day/day-layer.tsx
+++ b/src/components/day/day-layer.tsx
@@ -27,8 +27,10 @@ import { useAuth } from "@/hooks/use-auth";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
import {
+ readDayOpen,
readLastDay,
subscribeLastDay,
+ writeDayClosed,
writeLastDay,
} from "@/lib/day/last-day";
import { getRecordScope } from "@/lib/query-keys/record-scope";
@@ -155,6 +157,17 @@ function DayLayer() {
() => readLastDay(owner),
() => null,
);
+ const leftOpen = useSyncExternalStore(
+ subscribeLastDay,
+ () => readDayOpen(owner),
+ () => false,
+ );
+ // Every way the person folds or closes the day goes through here, so a page
+ // change (which drops `?day=` too) is never mistaken for one.
+ const closeByPerson = useCallback(() => {
+ if (owner !== null) writeDayClosed(owner);
+ closeDay();
+ }, [owner]);
const wide = useWideViewport();
const phone = useIsMobile();
@@ -191,8 +204,24 @@ function DayLayer() {
const collapse = useCallback(() => {
collapsing.current = true;
- closeDay();
- }, []);
+ closeByPerson();
+ }, [closeByPerson]);
+
+ // Open stays open across pages: a page reached without `?day=` while the
+ // day was left open puts the remembered day back in its URL, on every page
+ // that docks it. Restoring is not opening, so focus stays with the page.
+ const restoring = useRef(false);
+ useEffect(() => {
+ if (shell !== "docked" || raw !== null || !leftOpen || railDay === null) {
+ return;
+ }
+ restoring.current = true;
+ window.history.replaceState(
+ null,
+ "",
+ withDayHref(window.location.pathname, window.location.search, railDay),
+ );
+ }, [shell, raw, leftOpen, railDay]);
// What the polite region says: the day that just opened or was stepped to.
const announcement = date !== null ? longLabel(date) : "";
@@ -202,15 +231,25 @@ function DayLayer() {
previous.current = date;
if (date !== null) {
// Opening moves focus to the day's heading; a step keeps it where it is
- // (on the arrow the person pressed).
- if (before === null && shell === "docked") {
+ // (on the arrow the person pressed), and so does a day restored on a
+ // new page.
+ if (before === null && shell === "docked" && !restoring.current) {
titleRef.current?.focus({ preventScroll: true });
}
+ restoring.current = false;
return;
}
if (before !== null && shell === "docked") {
- // Collapsed for a neighbouring panel: focus stays where it was.
+ // Collapsed for a neighbouring panel: focus stays where it was, and the
+ // day counts as folded by the person, who opened the other panel.
if (takeDayYield()) {
+ if (owner !== null) writeDayClosed(owner);
+ collapsing.current = false;
+ return;
+ }
+ // Gone from the URL by a page change, not by the person: the day comes
+ // back on the new page, and focus stays with the page.
+ if (leftOpen) {
collapsing.current = false;
return;
}
@@ -223,7 +262,7 @@ function DayLayer() {
}
}
collapsing.current = false;
- }, [date, shell]);
+ }, [date, shell, owner, leftOpen]);
const onStep = useCallback(
(delta: number) => {
@@ -260,7 +299,7 @@ function DayLayer() {
if (event.key === "Escape" && shell === "docked") {
if (modalSurfaceOpen() && !inside) return;
event.preventDefault();
- closeDay();
+ closeByPerson();
return;
}
if (
@@ -276,7 +315,7 @@ function DayLayer() {
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
- }, [date, shell, onStep, prefetchDay, today]);
+ }, [date, shell, onStep, prefetchDay, today, closeByPerson]);
const live = (
view(SheetTitle, extra)}
/>
>
@@ -494,11 +534,14 @@ function DaySheet({
shell,
panelRef,
titleRef,
+ onDismiss,
render,
}: {
shell: "sheet" | "bottom";
panelRef: React.RefObject;
titleRef: React.RefObject;
+ /** The person closed the sheet (a swipe down, Escape, the overlay). */
+ onDismiss: () => void;
render: (extra: Partial[0]>) => React.ReactNode;
}) {
const { t } = useTranslations();
@@ -530,7 +573,7 @@ function DaySheet({
if (dy < -40) setFull(true);
else if (dy > 60) {
if (full) setFull(false);
- else closeDay();
+ else onDismiss();
}
}}
onClick={() => {
@@ -551,7 +594,7 @@ function DaySheet({
{
- if (!open) closeDay();
+ if (!open) onDismiss();
}}
>
{
layout={layout()}
width={900}
selectedBucket="2026-01-01"
- bucket="month"
seriesLabel={seriesLabel}
seriesColor={seriesColor}
- intl="de-DE"
t={(key) => key}
fmt={format}
/>
@@ -601,3 +600,62 @@ describe("value-line names", () => {
}
});
});
+
+describe("a value line's point, on its own", () => {
+ const layout = () =>
+ layoutTimeline({
+ width: 900,
+ window: { from: "2025-10-01", to: "2026-10-31" },
+ lanes: [],
+ series: fullTimeline().series,
+ bucket: "month",
+ words: wordsIn(),
+ startMissing: "",
+ today: TODAY,
+ });
+
+ it("gives every point a 24 px target named by its line alone, one tab stop per line", () => {
+ const html = render(
+ (
+ `Monat ${t}`}
+ onSelect={() => undefined}
+ onOpenDay={() => undefined}
+ />
+ )}
+ />,
+ );
+ const targets =
+ html.match(/]*data-slot="timeline-point-target"[^>]*>/g) ?? [];
+ const points = layout().series.reduce((n, s) => n + s.points.length, 0);
+ expect(targets).toHaveLength(points);
+ for (const tag of targets) {
+ expect(tag).toContain("width:24px");
+ expect(tag).toContain("height:24px");
+ }
+ // One stop per line: the newest point of each.
+ const stops = targets.filter((tag) => tag.includes('tabindex="0"'));
+ expect(stops).toHaveLength(layout().series.length);
+ // A point names its line, its stretch, its value and its readings, and
+ // nothing of the other lines.
+ const weight = targets.find((tag) => tag.includes('data-series="WEIGHT"'))!;
+ expect(weight).toMatch(
+ /aria-label="[^"]*Monat 2026-01-01[^"]*82,6[^"]*3 Messungen"/,
+ );
+ expect(weight).not.toContain("Blutdruck");
+ // Nothing shows until a point is pointed at.
+ expect(html).not.toContain('data-slot="timeline-point-tip"');
+ });
+
+ it("keeps the card inside the chart's width", () => {
+ expect(tipLeft(450, 200, 900)).toBe(350);
+ expect(tipLeft(20, 200, 900)).toBe(0);
+ expect(tipLeft(890, 200, 900)).toBe(700);
+ });
+});
diff --git a/src/components/timeline/__tests__/timeline-rework.test.tsx b/src/components/timeline/__tests__/timeline-rework.test.tsx
index 7a8f833a5..b502fc64e 100644
--- a/src/components/timeline/__tests__/timeline-rework.test.tsx
+++ b/src/components/timeline/__tests__/timeline-rework.test.tsx
@@ -307,10 +307,8 @@ describe("no cap on value lines", () => {
layout={layout}
width={1200}
selectedBucket={null}
- bucket="month"
seriesLabel={(key) => `Line ${key}`}
seriesColor={(key) => colours.get(key)!}
- intl="en-GB"
t={(key) => key}
fmt={format}
/>
@@ -326,7 +324,15 @@ describe("no cap on value lines", () => {
expect(SERIES_PALETTE).toContain(colour);
}
expect(html).not.toContain('var(--foreground)" color');
- for (const s of series) expect(text(html)).toContain(`Line ${s.key}`);
+ // A name too long for one line wraps onto two (a tspan each).
+ // A name too long for one line wraps onto two, the second cut if it
+ // must: every line is still named at its left.
+ for (const s of series) {
+ const group = html.slice(html.indexOf(`data-series="${s.key}"`));
+ const name = group.slice(0, group.indexOf(""));
+ expect(name).toContain("Line");
+ expect(name).toContain(s.key.slice(0, 12));
+ }
});
});
diff --git a/src/components/timeline/series-point-tips.tsx b/src/components/timeline/series-point-tips.tsx
new file mode 100644
index 000000000..84230b7cb
--- /dev/null
+++ b/src/components/timeline/series-point-tips.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+/**
+ * One value line's point, read on its own (v1.42).
+ *
+ * The selection bar is the cross-section of a whole bucket; a point of one
+ * line answers for that line only: its name, the value with its unit, the
+ * stretch it is the mean of ("July 2026", "Week of 22 September 2026") and
+ * the readings behind it. Nothing of the other lines, nothing of the day.
+ *
+ * The points are drawn in the SVG, which is hidden from assistive technology
+ * (the tables beside it read the chart out). Each point gets a plain HTML
+ * target over it, 24 px across whatever the dot's size:
+ *
+ * - a pointer resting on it shows the card;
+ * - the keyboard reaches each line once (one tab stop per line), Left and
+ * Right walk its points, and focus shows the same card;
+ * - a tap shows the card, a second tap on the same point (or the card's
+ * "View the whole day") opens the day, the two-step every chart in the
+ * app uses on a phone;
+ * - a click with a mouse selects the day, as a click on the chart does,
+ * and a double click opens it.
+ *
+ * The card is placed above the point and kept inside the chart's width
+ * (`tipLeft`), so it never adds a horizontal scroll of its own.
+ */
+import {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type KeyboardEvent,
+} from "react";
+
+import { RichChartTooltip } from "@/components/charts/chart-tooltip";
+import { TooltipDayAction } from "@/components/day/chart-day";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { formatSeriesValue, type SeriesValueFormat } from "./series-format";
+import type { TimelineLayout } from "./timeline-geometry";
+
+/** Hit target across, in px: bigger than the dot, a comfortable aim. */
+export const POINT_TARGET = 24;
+
+/**
+ * The card's left edge for a point at `x`: centred on the point, then pushed
+ * back inside `[0, containerWidth]`.
+ */
+export function tipLeft(
+ x: number,
+ cardWidth: number,
+ containerWidth: number,
+): number {
+ const centred = x - cardWidth / 2;
+ return Math.max(0, Math.min(centred, containerWidth - cardWidth));
+}
+
+interface Active {
+ key: string;
+ t: string;
+ pinned: boolean;
+}
+
+export function SeriesPointTips({
+ layout,
+ today,
+ seriesLabel,
+ seriesColor,
+ fmt,
+ bucketLabel,
+ onSelect,
+ onOpenDay,
+}: {
+ layout: TimelineLayout;
+ today: string;
+ seriesLabel: (key: string) => string;
+ seriesColor: (key: string) => string;
+ fmt: SeriesValueFormat;
+ /** The stretch a point is the mean of, worded ("July 2026"). */
+ bucketLabel: (t: string) => string;
+ onSelect: (date: string) => void;
+ onOpenDay: (date: string) => void;
+}) {
+ const { tCount } = useTranslations();
+ const [active, setActive] = useState(null);
+ // Per line, the point that holds its one tab stop.
+ const [stops, setStops] = useState>({});
+ const pointerType = useRef("mouse");
+ const cardRef = useRef(null);
+ const [cardWidth, setCardWidth] = useState(200);
+ const buttons = useRef(new Map());
+
+ // The card's real width, once drawn, for the clamping below.
+ useLayoutEffect(() => {
+ const width = cardRef.current?.offsetWidth;
+ if (width && width !== cardWidth) setCardWidth(width);
+ }, [active, cardWidth]);
+
+ // A card pinned by a tap goes away with a tap anywhere else.
+ const pinned = active?.pinned ?? false;
+ useEffect(() => {
+ if (!pinned) return;
+ const away = (event: PointerEvent) => {
+ const target = event.target as Element | null;
+ if (
+ target?.closest(
+ '[data-slot="timeline-point-tip"], [data-slot="timeline-point-target"]',
+ )
+ ) {
+ return;
+ }
+ setActive(null);
+ };
+ document.addEventListener("pointerdown", away);
+ return () => document.removeEventListener("pointerdown", away);
+ }, [pinned]);
+
+ // The first day of a point's stretch that has happened, the day it opens.
+ const dayOf = (t: string) => (t > today ? today : t);
+
+ const series = layout.series.find((s) => s.key === active?.key);
+ const point = series?.points.find((p) => p.t === active?.t);
+
+ const words = (key: string, t: string, mean: number, unit: string | null) =>
+ `${seriesLabel(key)}, ${bucketLabel(t)}: ${formatSeriesValue(key, mean, unit, fmt)}`;
+
+ function move(
+ event: KeyboardEvent,
+ key: string,
+ index: number,
+ ) {
+ const points = layout.series.find((s) => s.key === key)?.points ?? [];
+ let next: number | null = null;
+ if (event.key === "ArrowLeft") next = Math.max(0, index - 1);
+ else if (event.key === "ArrowRight")
+ next = Math.min(points.length - 1, index + 1);
+ else if (event.key === "Home") next = 0;
+ else if (event.key === "End") next = points.length - 1;
+ else if (event.key === "Escape") {
+ setActive(null);
+ return;
+ } else return;
+ event.preventDefault();
+ const target = points[next];
+ if (!target) return;
+ setStops((s) => ({ ...s, [key]: target.t }));
+ buttons.current.get(`${key}:${target.t}`)?.focus();
+ }
+
+ return (
+
+ {layout.series.map((s) =>
+ s.points.map((p, i) => {
+ const id = `${s.key}:${p.t}`;
+ const stop = stops[s.key] ?? s.points.at(-1)?.t;
+ return (
+
{
+ if (node) buttons.current.set(id, node);
+ else buttons.current.delete(id);
+ }}
+ type="button"
+ data-slot="timeline-point-target"
+ data-series={s.key}
+ data-t={p.t}
+ tabIndex={p.t === stop ? 0 : -1}
+ aria-label={`${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`}
+ onPointerDown={(event) => {
+ pointerType.current = event.pointerType;
+ event.stopPropagation();
+ }}
+ onPointerEnter={(event) => {
+ if (event.pointerType !== "mouse") return;
+ setActive((a) =>
+ a?.pinned ? a : { key: s.key, t: p.t, pinned: false },
+ );
+ }}
+ onPointerLeave={(event) => {
+ if (event.pointerType !== "mouse") return;
+ setActive((a) => (a?.pinned ? a : null));
+ }}
+ onFocus={() => {
+ setStops((st) => ({ ...st, [s.key]: p.t }));
+ setActive({ key: s.key, t: p.t, pinned: false });
+ }}
+ onBlur={() => setActive((a) => (a?.pinned ? a : null))}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onOpenDay(dayOf(p.t));
+ return;
+ }
+ move(event, s.key, i);
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ if (pointerType.current === "mouse") {
+ onSelect(dayOf(p.t));
+ return;
+ }
+ // A tap shows the card; a second tap on the same point opens
+ // the day.
+ if (
+ active?.pinned &&
+ active.key === s.key &&
+ active.t === p.t
+ ) {
+ onOpenDay(dayOf(p.t));
+ return;
+ }
+ setActive({ key: s.key, t: p.t, pinned: true });
+ }}
+ onDoubleClick={(event) => {
+ event.stopPropagation();
+ onOpenDay(dayOf(p.t));
+ }}
+ className="focus-visible:ring-ring/50 absolute rounded-full outline-none focus-visible:ring-2"
+ style={{
+ left: p.x - POINT_TARGET / 2,
+ top: p.y - POINT_TARGET / 2,
+ width: POINT_TARGET,
+ height: POINT_TARGET,
+ }}
+ />
+ );
+ }),
+ )}
+ {series && point ? (
+ 120
+ ? { bottom: layout.height - point.y + POINT_TARGET / 2 }
+ : { top: point.y + POINT_TARGET / 2 }),
+ }}
+ >
+ onOpenDay(dayOf(point.t))} />
+ ) : undefined
+ }
+ />
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx
index 4d6245fc4..4f821749a 100644
--- a/src/components/timeline/timeline-chart.tsx
+++ b/src/components/timeline/timeline-chart.tsx
@@ -24,6 +24,7 @@
*/
import { metricPageHref } from "@/lib/insights/metric-page";
import Link from "next/link";
+import { SeriesPointTips } from "./series-point-tips";
import {
useEffect,
useMemo,
@@ -269,7 +270,21 @@ export function TimelineChart({
)}
{layout ? (
-
+ <>
+
+
+ bucketText(start, timeline.bucket, intl, t)
+ }
+ onSelect={onSelect}
+ onOpenDay={onOpenDay}
+ />
+ >
) : null}
@@ -602,24 +615,19 @@ export function SeriesLines({
layout,
width,
selectedBucket,
- bucket,
seriesLabel,
seriesColor,
- intl,
t,
fmt,
}: {
layout: NonNullable>;
width: number;
selectedBucket: string | null;
- bucket: TimelineBucket;
seriesLabel: (key: string) => string;
seriesColor: (key: string) => string;
- intl: string;
t: ReturnType["t"];
fmt: SeriesValueFormat;
}) {
- const { tCount } = useTranslations();
const { scale, seriesTop } = layout;
const nameLines = (key: string) =>
seriesNameLines(seriesLabel(key), scale.x0 - 12 - SERIES_TEXT_X, 13);
@@ -718,11 +726,7 @@ export function SeriesLines({
fill={point.thin ? "var(--card)" : "currentColor"}
stroke="currentColor"
strokeWidth={point.thin ? 1.4 : 0}
- >
-
- {`${seriesLabel(series.key)}, ${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`}
-
-
+ />
))}
{series.points
.filter((point) => point.t === selectedBucket)
@@ -983,31 +987,37 @@ function TimelineTable({
)
.sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start));
return (
-
- {t("timeline.table.caption")}
-
-
- {t("timeline.table.lane")}
- {t("timeline.table.entry")}
- {t("timeline.table.start")}
- {t("timeline.table.end")}
-
-
-
- {rows.map(({ lane, item }) => (
-
- {t(TIMELINE_LANE_LABEL_KEY[lane])}
- {itemLine(item, words, t("timeline.startUnknown"))}
- {formatAtPrecision(item.start, item.precision, intl)}
-
- {!item.open && isSpan(item) && item.end
- ? formatAtPrecision(item.end, item.precision, intl)
- : ""}
-
+ // The table is hidden by a wrapper, never by `sr-only` on the table
+ // itself: `overflow: hidden` does not apply to a table box, so a
+ // visually hidden table still laid out its rows at full height under
+ // the page and made `` scroll past its last card into nothing.
+
+
+ {t("timeline.table.caption")}
+
+
+ {t("timeline.table.lane")}
+ {t("timeline.table.entry")}
+ {t("timeline.table.start")}
+ {t("timeline.table.end")}
- ))}
-
-
+
+
+ {rows.map(({ lane, item }) => (
+
+ {t(TIMELINE_LANE_LABEL_KEY[lane])}
+ {itemLine(item, words, t("timeline.startUnknown"))}
+ {formatAtPrecision(item.start, item.precision, intl)}
+
+ {!item.open && isSpan(item) && item.end
+ ? formatAtPrecision(item.end, item.precision, intl)
+ : ""}
+
+
+ ))}
+
+
+
);
}
@@ -1063,36 +1073,42 @@ function SeriesTable({
series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]),
);
return (
-
- {t("timeline.table.seriesCaption")}
-
-
- {t("timeline.table.period")}
- {series.map((s) => (
-
- {seriesLabel(s.key)}
-
- ))}
-
-
-
- {rows.reverse().map((start) => (
-
- {bucketText(start, bucket, intl, t)}
- {series.map((s) => {
- const point = byKey.get(s.key)?.get(start);
- return (
-
- {point
- ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
- : t("timeline.values.noValue")}
-
- );
- })}
+ // The table is hidden by a wrapper, never by `sr-only` on the table
+ // itself: `overflow: hidden` does not apply to a table box, so a
+ // visually hidden table still laid out its rows at full height under
+ // the page and made `` scroll past its last card into nothing.
+
+
+ {t("timeline.table.seriesCaption")}
+
+
+ {t("timeline.table.period")}
+ {series.map((s) => (
+
+ {seriesLabel(s.key)}
+
+ ))}
- ))}
-
-
+
+
+ {rows.reverse().map((start) => (
+
+ {bucketText(start, bucket, intl, t)}
+ {series.map((s) => {
+ const point = byKey.get(s.key)?.get(start);
+ return (
+
+ {point
+ ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
+ : t("timeline.values.noValue")}
+
+ );
+ })}
+
+ ))}
+
+
+
);
}
diff --git a/src/lib/day/__tests__/last-day.test.ts b/src/lib/day/__tests__/last-day.test.ts
index 44ac147ba..b33cb5f23 100644
--- a/src/lib/day/__tests__/last-day.test.ts
+++ b/src/lib/day/__tests__/last-day.test.ts
@@ -7,7 +7,9 @@ import { describe, expect, it } from "vitest";
import {
clearLastDay,
LAST_DAY_STORAGE_KEY,
+ readDayOpen,
readLastDay,
+ writeDayClosed,
writeLastDay,
} from "../last-day";
@@ -73,3 +75,32 @@ describe("last day", () => {
expect(() => clearLastDay(throwing)).not.toThrow();
});
});
+
+describe("a day left open", () => {
+ it("is open once opened, and stays open until the person folds it", () => {
+ const storage = memoryStorage();
+ expect(readDayOpen("u1:own", storage)).toBe(false);
+ writeLastDay("u1:own", "2026-10-05", storage);
+ expect(readDayOpen("u1:own", storage)).toBe(true);
+ writeDayClosed("u1:own", storage);
+ expect(readDayOpen("u1:own", storage)).toBe(false);
+ // The day itself is kept for the edge.
+ expect(readLastDay("u1:own", storage)).toBe("2026-10-05");
+ // Opening it again opens it again.
+ writeLastDay("u1:own", "2026-10-05", storage);
+ expect(readDayOpen("u1:own", storage)).toBe(true);
+ });
+
+ it("is never open for another account, an older value or a refusing store", () => {
+ const storage = memoryStorage();
+ writeLastDay("u1:own", "2026-10-05", storage);
+ expect(readDayOpen("u2:own", storage)).toBe(false);
+ storage.map.set(
+ LAST_DAY_STORAGE_KEY,
+ JSON.stringify({ owner: "u1:own", day: "2026-10-05" }),
+ );
+ expect(readDayOpen("u1:own", storage)).toBe(false);
+ expect(readDayOpen("u1:own", throwing)).toBe(false);
+ expect(() => writeDayClosed("u1:own", throwing)).not.toThrow();
+ });
+});
diff --git a/src/lib/day/last-day.ts b/src/lib/day/last-day.ts
index 02e4f6300..5be5af199 100644
--- a/src/lib/day/last-day.ts
+++ b/src/lib/day/last-day.ts
@@ -10,6 +10,11 @@
* never gets it back; a session end removes it outright
* (`clearCachesForSessionEnd`).
*
+ * Beside the day it keeps whether the day was left open (`open`): a page
+ * change drops `?day=` from the URL, and the docked column would fold to its
+ * edge on every navigation. Open stays open until the person folds or closes
+ * it; the layer reopens the remembered day on the next page that docks it.
+ *
* Client-only and free of Zod and server imports, like `contract.ts`.
*/
import type { DateKey } from "@/lib/day/contract";
@@ -24,6 +29,8 @@ export const LAST_DAY_CHANGE_EVENT = "healthlog:day-last";
interface Stored {
owner: string;
day: DateKey;
+ /** Left open by the person; absent in a value an older build wrote. */
+ open?: boolean;
}
function safeLocalStorage(): Storage | null {
@@ -35,11 +42,10 @@ function safeLocalStorage(): Storage | null {
}
}
-/** The remembered day for `owner`, or null (nothing, someone else's, unreadable). */
-export function readLastDay(
+function readStored(
owner: string | null,
- storage: Pick | null = safeLocalStorage(),
-): DateKey | null {
+ storage: Pick | null,
+): Stored | null {
if (owner === null || !storage) return null;
try {
const raw = storage.getItem(LAST_DAY_STORAGE_KEY);
@@ -54,19 +60,35 @@ export function readLastDay(
) {
return null;
}
- return parsed.day;
+ return { owner, day: parsed.day, open: parsed.open === true };
} catch {
return null;
}
}
+/** The remembered day for `owner`, or null (nothing, someone else's, unreadable). */
+export function readLastDay(
+ owner: string | null,
+ storage: Pick | null = safeLocalStorage(),
+): DateKey | null {
+ return readStored(owner, storage)?.day ?? null;
+}
+
+/** Whether `owner` left the remembered day open. False when unknown. */
+export function readDayOpen(
+ owner: string | null,
+ storage: Pick | null = safeLocalStorage(),
+): boolean {
+ return readStored(owner, storage)?.open === true;
+}
+
function notify() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(LAST_DAY_CHANGE_EVENT));
}
}
-/** Remember `day` as the last one `owner` opened. Best effort. */
+/** Remember `day` as the last one `owner` opened, and as open. Best effort. */
export function writeLastDay(
owner: string,
day: DateKey,
@@ -74,10 +96,33 @@ export function writeLastDay(
): void {
if (!storage) return;
try {
- if (readLastDay(owner, storage) === day) return;
+ const held = readStored(owner, storage);
+ if (held?.day === day && held.open) return;
storage.setItem(
LAST_DAY_STORAGE_KEY,
- JSON.stringify({ owner, day } satisfies Stored),
+ JSON.stringify({ owner, day, open: true } satisfies Stored),
+ );
+ } catch {
+ return;
+ }
+ notify();
+}
+
+/**
+ * The person folded or closed the day: keep the day for the edge, but do not
+ * reopen it on the next page. Best effort.
+ */
+export function writeDayClosed(
+ owner: string,
+ storage: Pick | null = safeLocalStorage(),
+): void {
+ if (!storage) return;
+ try {
+ const held = readStored(owner, storage);
+ if (!held || !held.open) return;
+ storage.setItem(
+ LAST_DAY_STORAGE_KEY,
+ JSON.stringify({ ...held, open: false } satisfies Stored),
);
} catch {
return;