fix(timeline): a point reads on its own, the page ends where its content does, an open day stays open

A point of a value line now shows a card for that line and that point
only: its name, the value with its unit, the stretch it is the mean of and
the readings behind it, in the tooltip the other charts use. Each point
has a 24 px HTML target over it: a pointer resting on it shows the card,
the keyboard reaches every line once and walks its points with the
arrows, a tap shows the card and a second tap (or its "View the whole
day") opens the day. The card is clamped to the chart's width. The SVG's
native titles are gone.

The two screen-reader tables beside the chart were hidden with sr-only on
the table element itself, where overflow: hidden does not apply: they laid
out at full size below the page and made <main> scroll sideways and past
its last card. They are hidden by a wrapper now, and the page ends where
its content does at every width, with and without the docked day.

The docked day stays open across pages. Leaving a page drops ?day= from
the URL, and the column folded to its edge on every navigation. The
remembered day now also remembers that it was left open (per account and
record, as before); a page reached without ?day= while it was left open
brings the day back, without taking focus from the page. Folding,
closing, Escape, a swipe down or opening the Coach's conversations
beside it is what closes it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 16:37:14 +02:00
1 parent df67e285de
commit b1a0598c9d
7 files changed
+564 -90

No files matched your search

+54 -11
View File
@@ -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 = (
<p
@@ -357,7 +396,7 @@ function DayLayer() {
today={today}
focus={focus}
shell={shell}
onClose={shell === "docked" ? collapse : closeDay}
onClose={shell === "docked" ? collapse : closeByPerson}
onStep={onStep}
onPick={onPick}
Title={Title}
@@ -400,6 +439,7 @@ function DayLayer() {
shell={shell}
panelRef={panelRef}
titleRef={titleRef}
onDismiss={closeByPerson}
render={(extra) => view(SheetTitle, extra)}
/>
</>
@@ -494,11 +534,14 @@ function DaySheet({
shell,
panelRef,
titleRef,
onDismiss,
render,
}: {
shell: "sheet" | "bottom";
panelRef: React.RefObject<HTMLElement | null>;
titleRef: React.RefObject<HTMLHeadingElement | null>;
/** The person closed the sheet (a swipe down, Escape, the overlay). */
onDismiss: () => void;
render: (extra: Partial<Parameters<typeof DayView>[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({
<Sheet
open
onOpenChange={(open) => {
if (!open) closeDay();
if (!open) onDismiss();
}}
>
<SheetContent
@@ -25,6 +25,7 @@ import {
SeriesNameLinks,
TimelineChart,
} from "../timeline-chart";
import { SeriesPointTips, tipLeft } from "../series-point-tips";
import { TimelineChronicle } from "../timeline-chronicle";
import { layoutTimeline } from "../timeline-geometry";
import { useSeriesValueFormat } from "../use-series-value-format";
@@ -484,10 +485,8 @@ describe("value line colours", () => {
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(
<WithFormat
build={(format) => (
<SeriesPointTips
layout={layout()}
today={TODAY}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
fmt={format}
bucketLabel={(t) => `Monat ${t}`}
onSelect={() => undefined}
onOpenDay={() => undefined}
/>
)}
/>,
);
const targets =
html.match(/<button[^>]*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);
});
});
@@ -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("</text>"));
expect(name).toContain("Line");
expect(name).toContain(s.key.slice(0, 12));
}
});
});
@@ -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<Active | null>(null);
// Per line, the point that holds its one tab stop.
const [stops, setStops] = useState<Record<string, string>>({});
const pointerType = useRef<string>("mouse");
const cardRef = useRef<HTMLDivElement>(null);
const [cardWidth, setCardWidth] = useState(200);
const buttons = useRef(new Map<string, HTMLButtonElement>());
// 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<HTMLButtonElement>,
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 (
<div data-slot="timeline-point-tips">
{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 (
<button
key={id}
ref={(node) => {
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 ? (
<div
ref={cardRef}
data-slot="timeline-point-tip"
data-series={series.key}
data-t={point.t}
className={
active?.pinned
? "absolute z-10 w-max max-w-60"
: "pointer-events-none absolute z-10 w-max max-w-60"
}
style={{
left: tipLeft(point.x, cardWidth, layout.width),
// Above the point, below it when the line sits at the top.
...(point.y > 120
? { bottom: layout.height - point.y + POINT_TARGET / 2 }
: { top: point.y + POINT_TARGET / 2 }),
}}
>
<RichChartTooltip
active
label={bucketLabel(point.t)}
rows={[
{
name: seriesLabel(series.key),
value: formatSeriesValue(
series.key,
point.mean,
series.unit,
fmt,
),
color: seriesColor(series.key),
delta: tCount("timeline.values.readings", point.count),
},
]}
action={
active?.pinned ? (
<TooltipDayAction onOpen={() => onOpenDay(dayOf(point.t))} />
) : undefined
}
/>
</div>
) : null}
</div>
);
}
+82 -66
View File
@@ -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({
<div className="h-64" />
)}
{layout ? (
<SeriesNameLinks layout={layout} seriesLabel={seriesLabel} />
<>
<SeriesNameLinks layout={layout} seriesLabel={seriesLabel} />
<SeriesPointTips
layout={layout}
today={today}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
fmt={fmt}
bucketLabel={(start) =>
bucketText(start, timeline.bucket, intl, t)
}
onSelect={onSelect}
onOpenDay={onOpenDay}
/>
</>
) : null}
</div>
<TimelineTable
@@ -507,10 +522,8 @@ function ChartBody({
layout={layout}
width={width}
selectedBucket={selectedBucket}
bucket={bucket}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
intl={intl}
t={t}
fmt={fmt}
/>
@@ -602,24 +615,19 @@ export function SeriesLines({
layout,
width,
selectedBucket,
bucket,
seriesLabel,
seriesColor,
intl,
t,
fmt,
}: {
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
width: number;
selectedBucket: string | null;
bucket: TimelineBucket;
seriesLabel: (key: string) => string;
seriesColor: (key: string) => string;
intl: string;
t: ReturnType<typeof useTranslations>["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}
>
<title>
{`${seriesLabel(series.key)}, ${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`}
</title>
</circle>
/>
))}
{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 (
<table className="sr-only" data-slot="timeline-table">
<caption>{t("timeline.table.caption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.lane")}</th>
<th scope="col">{t("timeline.table.entry")}</th>
<th scope="col">{t("timeline.table.start")}</th>
<th scope="col">{t("timeline.table.end")}</th>
</tr>
</thead>
<tbody>
{rows.map(({ lane, item }) => (
<tr key={`${lane}-${item.id}`}>
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
<td>{itemLine(item, words, t("timeline.startUnknown"))}</td>
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
<td>
{!item.open && isSpan(item) && item.end
? formatAtPrecision(item.end, item.precision, intl)
: ""}
</td>
// 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 `<main>` scroll past its last card into nothing.
<div className="sr-only" data-slot="timeline-table-frame">
<table data-slot="timeline-table">
<caption>{t("timeline.table.caption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.lane")}</th>
<th scope="col">{t("timeline.table.entry")}</th>
<th scope="col">{t("timeline.table.start")}</th>
<th scope="col">{t("timeline.table.end")}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{rows.map(({ lane, item }) => (
<tr key={`${lane}-${item.id}`}>
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
<td>{itemLine(item, words, t("timeline.startUnknown"))}</td>
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
<td>
{!item.open && isSpan(item) && item.end
? formatAtPrecision(item.end, item.precision, intl)
: ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -1063,36 +1073,42 @@ function SeriesTable({
series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]),
);
return (
<table className="sr-only" data-slot="timeline-series-table">
<caption>{t("timeline.table.seriesCaption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.period")}</th>
{series.map((s) => (
<th key={s.key} scope="col">
{seriesLabel(s.key)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.reverse().map((start) => (
<tr key={start} data-bucket={start}>
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
{series.map((s) => {
const point = byKey.get(s.key)?.get(start);
return (
<td key={s.key}>
{point
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
: t("timeline.values.noValue")}
</td>
);
})}
// 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 `<main>` scroll past its last card into nothing.
<div className="sr-only" data-slot="timeline-series-table-frame">
<table data-slot="timeline-series-table">
<caption>{t("timeline.table.seriesCaption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.period")}</th>
{series.map((s) => (
<th key={s.key} scope="col">
{seriesLabel(s.key)}
</th>
))}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{rows.reverse().map((start) => (
<tr key={start} data-bucket={start}>
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
{series.map((s) => {
const point = byKey.get(s.key)?.get(start);
return (
<td key={s.key}>
{point
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
: t("timeline.values.noValue")}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
+31
View File
@@ -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();
});
});
+53 -8
View File
@@ -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<Storage, "getItem"> | null = safeLocalStorage(),
): DateKey | null {
storage: Pick<Storage, "getItem"> | 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<Storage, "getItem"> | 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<Storage, "getItem"> | 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<Storage, "getItem" | "setItem"> | 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;