mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
df67e285de
commit
b1a0598c9d
7 files changed
+564
-90
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user