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 ( +
+ ); +} 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 ( - - - - - - - - - - - - {rows.map(({ lane, item }) => ( - - - - - + // 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")}
{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) - : ""} -
+ + + + + + + - ))} - -
{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 ( - - - - - - {series.map((s) => ( - - ))} - - - - {rows.reverse().map((start) => ( - - - {series.map((s) => { - const point = byKey.get(s.key)?.get(start); - return ( - - ); - })} + // 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")} - {seriesLabel(s.key)} -
{bucketText(start, bucket, intl, t)} - {point - ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}` - : t("timeline.values.noValue")} -
+ + + + + {series.map((s) => ( + + ))} - ))} - -
{t("timeline.table.seriesCaption")}
{t("timeline.table.period")} + {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;