Merge the final display polish into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 17:25:35 +02:00
commit dcfec1f35d
144 files changed
+4866 -2817

No files matched your search

@@ -72,7 +72,7 @@ describe("chart isError rendering — error state, not empty state", () => {
expect(html).toContain("Pulse");
// The retry button carries the chart title in its accessible name
// so several failed charts on one page stay distinguishable.
expect(html).toContain('aria-label="Erneut versuchen – Pulse"');
expect(html).toContain('aria-label="Erneut versuchen: Pulse"');
// The empty-state copy must NOT paint — an outage is not "no data".
expect(html).not.toContain("Keine Daten in diesem Zeitraum");
@@ -169,7 +169,7 @@ describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
it("joins the action context into the retry button's accessible name", async () => {
const html = await renderState({ actionContext: "Pulse" });
expect(html).toContain('aria-label="Retry – Pulse"');
expect(html).toContain('aria-label="Retry: Pulse"');
});
it("omits the aria-label without a context so the label is the name", async () => {
+1 -1
View File
@@ -82,7 +82,7 @@ export function ChartErrorState({
size="sm"
onClick={onAction}
aria-label={
actionContext ? `${actionLabel} – ${actionContext}` : undefined
actionContext ? `${actionLabel}: ${actionContext}` : undefined
}
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
@@ -152,7 +152,7 @@ export function IllnessSection({
: null,
t(`illness.type.${episode.type}`),
t(`illness.lifecycle.${episode.lifecycle}`),
`${fmtDate(episode.onsetAt)} – ${
`${fmtDate(episode.onsetAt)}–${
episode.resolvedAt
? fmtDate(episode.resolvedAt)
: t("doctorReport.illnessOngoing")
+1 -1
View File
@@ -357,7 +357,7 @@ export function MedicationsSection({
return (
<Fragment key={med.id}>
<StatRow
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
label={med.dose ? `${med.name} ${med.dose}` : med.name}
value={
rate
? t("clinicianView.adherence", { rate })
@@ -206,7 +206,7 @@ export function DoseLogSection({
{shown.map((dose, index) => (
<StatRow
key={`${dose.medicationName}-${dose.effectiveAt}-${index}`}
label={`${fmtDateTime(dose.effectiveAt)} — ${dose.medicationName}`}
label={`${fmtDateTime(dose.effectiveAt)}: ${dose.medicationName}`}
value={
(dose.status === "completed"
? t("doctorReport.colTaken")
+2 -2
View File
@@ -99,7 +99,7 @@ export function PredictionsPanel({
<Row
hue={FERTILE_HUE}
label={t("cycle.predictions.fertileWindow")}
value={`${formatDate(prediction.fertileWindowStart, fmt)} – ${formatDate(
value={`${formatDate(prediction.fertileWindowStart, fmt)}–${formatDate(
prediction.fertileWindowEnd,
fmt,
)}`}
@@ -264,7 +264,7 @@ function HistoryCard({
>
<span className="text-foreground tabular-nums">
{formatDate(c.startDate, fmt)}
{" – "}
{"–"}
{c.endDate
? formatDate(c.endDate, fmt)
: t("cycle.history.ongoing")}
+27 -4
View File
@@ -179,10 +179,12 @@ beforeEach(() => {
});
describe("<DayView>", () => {
it("names the day and counts what it holds", async () => {
it("names the day and carries no count line", async () => {
const html = await render();
expect(html).toContain("Saturday, January 3, 2026");
expect(html).toContain("3 values, 2 entries");
// The sections say what the day holds; a count above them repeated it.
expect(html).not.toContain("3 values, 2 entries");
expect(html).not.toContain('data-slot="day-meta"');
});
it("lists what ran through the day with its running count", async () => {
@@ -264,6 +266,26 @@ describe("<DayView>", () => {
expect(weightTile.split("</li>")[0]).not.toContain("day-number-line");
});
it("leads each value tile with a page of its own to that page", async () => {
const html = await render();
expect(html).toMatch(
/data-type="WEIGHT"[^>]*>\s*<a[^>]*href="\/insights\/weight"[^>]*data-slot="day-value-link"|data-type="WEIGHT"[^>]*><a[^>]*href="\/insights\/weight"/,
);
expect(html).toMatch(
/data-type="BLOOD_PRESSURE"[^>]*><a[^>]*href="\/insights\/blood-pressure"/,
);
// A 44 px target and a visible focus ring, like every other link here.
expect(html).toMatch(/data-slot="day-value-link"[^>]*min-h-11/);
expect(html).toMatch(/data-slot="day-value-link"[^>]*focus-visible:ring/);
});
it("keeps an event's time on one line", async () => {
const html = await render();
const times = html.match(/data-slot="day-event-time"[^>]*>/g) ?? [];
expect(times.length).toBeGreaterThan(0);
for (const tag of times) expect(tag).toContain("whitespace-nowrap");
});
it("lists what happened, each entry linking to its source", async () => {
const html = await render();
expect(html).toContain("What happened");
@@ -284,8 +306,9 @@ describe("<DayView>", () => {
},
});
expect(html).toContain('data-slot="day-focus"');
expect(html).toContain("Your 30 days before");
expect(html).toContain("121–134/78–86");
// The usual range is the tiles' to draw, not repeated beside the value.
expect(html).not.toContain("Your 30 days before");
expect(html).not.toContain('data-slot="day-focus-compare"');
expect(html).toContain('data-selected="true"');
expect(html).toContain("Highest daily value since March 2025");
});
+74 -12
View File
@@ -1,6 +1,6 @@
"use client";
import { useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import {
Suspense,
useCallback,
@@ -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,34 @@ 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]);
// `?day=` gone while the page stayed the same is the person leaving the
// day (Back past the step that opened it); gone with a page change is
// only the URL of another page, and the day comes with it. Declared
// before the effects that read the flag, so they see the close.
const pathname = usePathname();
const openOn = useRef<string | null>(null);
useEffect(() => {
if (date !== null) {
openOn.current = pathname;
return;
}
if (openOn.current !== null && openOn.current === pathname) {
if (owner !== null) writeDayClosed(owner);
}
openOn.current = null;
}, [date, pathname, owner]);
const wide = useWideViewport();
const phone = useIsMobile();
@@ -191,8 +221,26 @@ 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;
}
// Read again, not from the render: a Back on this page has just closed it.
if (!readDayOpen(owner)) return;
restoring.current = true;
window.history.replaceState(
null,
"",
withDayHref(window.location.pathname, window.location.search, railDay),
);
}, [shell, raw, leftOpen, railDay, owner]);
// What the polite region says: the day that just opened or was stepped to.
const announcement = date !== null ? longLabel(date) : "";
@@ -202,15 +250,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 (readDayOpen(owner)) {
collapsing.current = false;
return;
}
@@ -223,7 +281,7 @@ function DayLayer() {
}
}
collapsing.current = false;
}, [date, shell]);
}, [date, shell, owner]);
const onStep = useCallback(
(delta: number) => {
@@ -260,7 +318,7 @@ function DayLayer() {
if (event.key === "Escape" && shell === "docked") {
if (modalSurfaceOpen() && !inside) return;
event.preventDefault();
closeDay();
closeByPerson();
return;
}
if (
@@ -276,7 +334,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 +415,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 +458,7 @@ function DayLayer() {
shell={shell}
panelRef={panelRef}
titleRef={titleRef}
onDismiss={closeByPerson}
render={(extra) => view(SheetTitle, extra)}
/>
</>
@@ -494,11 +553,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 +592,7 @@ function DaySheet({
if (dy < -40) setFull(true);
else if (dy > 60) {
if (full) setFull(false);
else closeDay();
else onDismiss();
}
}}
onClick={() => {
@@ -551,7 +613,7 @@ function DaySheet({
<Sheet
open
onOpenChange={(open) => {
if (!open) closeDay();
if (!open) onDismiss();
}}
>
<SheetContent
+48 -25
View File
@@ -41,6 +41,7 @@ import {
} from "@/components/timeline/label-keys";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { metricPageHref } from "@/lib/insights/metric-page";
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
import { cn } from "@/lib/utils";
@@ -78,18 +79,13 @@ export const DAY_SECTION_LABEL =
/* ─── Focus ───────────────────────────────────────────────────────────── */
export function DayFocusCard({
focus,
usual,
}: {
focus: DayFocus;
/** The usual range of the focus type that day, when the server has one. */
usual: string | null;
}) {
const { t } = useTranslations();
const compare =
focus.compare ??
(usual ? { label: t("day.usualLabel"), value: usual } : null);
/**
* The value the person came from. A comparison shows only when the opening
* surface brings one (an earlier lab result); the usual range of the 30 days
* before is not repeated here, because the value tiles below draw it.
*/
export function DayFocusCard({ focus }: { focus: DayFocus }) {
const compare = focus.compare ?? null;
return (
<div
data-slot="day-focus"
@@ -264,7 +260,7 @@ export function dayMeta(
: null;
const bandKey = bands && band ? keyOf(bands, band) : undefined;
const bandText = bandKey ? t(bandKey) : null;
return [score, bandText].filter(Boolean).join(" · ") || null;
return [score, bandText].filter(Boolean).join(", ") || null;
}
case "symptom":
return t("symptoms.intensityPill", { value: meta });
@@ -444,16 +440,11 @@ function ValueTile({
const shown = formatTile(tile);
// The line plots the first row: systolic for a blood pressure.
const lead = tile.values[0];
return (
<li
data-slot="day-value"
data-type={tile.key}
data-selected={selected ? "true" : undefined}
className={cn(
"bg-muted/60 flex min-w-0 flex-col rounded-lg px-3 py-2.5",
selected && "ring-foreground ring-[1.5px] ring-inset",
)}
>
// Like a score tile, a value tile leads to its metric's page; a kind
// without a page of its own stays a plain tile.
const href = metricPageHref(tile.key);
const body = (
<>
<span className="text-muted-foreground truncate text-xs">
{shown.label}
</span>
@@ -485,6 +476,33 @@ function ValueTile({
) : null}
</>
) : null}
</>
);
const frame = cn(
"bg-muted/60 flex min-h-11 min-w-0 flex-col rounded-lg px-3 py-2.5",
selected && "ring-foreground ring-[1.5px] ring-inset",
);
return (
<li
data-slot="day-value"
data-type={tile.key}
data-selected={selected ? "true" : undefined}
className="flex min-w-0"
>
{href ? (
<Link
href={href}
data-slot="day-value-link"
className={cn(
frame,
"hover:bg-muted focus-visible:ring-ring/50 w-full transition-colors focus-visible:ring-[3px] focus-visible:outline-none",
)}
>
{body}
</Link>
) : (
<div className={cn(frame, "w-full")}>{body}</div>
)}
</li>
);
}
@@ -590,11 +608,16 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) {
data-slot="day-event"
data-kind={event.kind}
className={cn(
"relative grid grid-cols-[2.75rem_1.25rem_minmax(0,1fr)_1rem] items-start gap-x-2.5 py-2.5",
// The time column fits "08:10 AM" and "오전 08:10" on one
// line; the time itself never wraps.
"relative grid grid-cols-[3.75rem_1.25rem_minmax(0,1fr)_1rem] items-start gap-x-2.5 py-2.5",
event.href && "hover:bg-muted/40 rounded-md",
)}
>
<span className="text-muted-foreground text-xs leading-5 tabular-nums">
<span
data-slot="day-event-time"
className="text-muted-foreground text-xs leading-5 whitespace-nowrap tabular-nums"
>
{event.at ? fmt.time(event.at) : ""}
</span>
<Icon
+4 -27
View File
@@ -40,7 +40,7 @@ import {
DayValues,
} from "./day-sections";
import { DayScores } from "./day-scores";
import { curateDayValues, tileKeyOf } from "./day-values-model";
import { tileKeyOf } from "./day-values-model";
import { shiftDateKey } from "./day-url";
import { useDay, usePrefetchDay } from "./use-day";
import { useDayValueFormat } from "./use-day-value-format";
@@ -124,7 +124,7 @@ export function DayView({
above,
headerClassName,
}: DayViewProps) {
const { t, tCount } = useTranslations();
const { t } = useTranslations();
const longLabel = useLongDayLabel();
// "Mo., 5. Okt. 2026" in the phone sheet's header: a September Thursday
// spelled out in full does not fit beside the arrows at 360 px, and a
@@ -132,7 +132,7 @@ export function DayView({
const shortLabel = useLongDayLabel("short");
const pathname = usePathname();
const day = useDay(date);
const { labelFor, formatTile } = useDayValueFormat();
const { labelFor } = useDayValueFormat();
const timelineOn = useModuleEnabled("timeline");
const coach = useAiCapability("coach");
const capabilities = useRecordCapabilities();
@@ -163,12 +163,6 @@ export function DayView({
data?.notable.filter(
(n) => !(n.type !== null && focusKeys.has(tileKeyOf(n.type))),
) ?? [];
const focusTile =
focus && data
? curateDayValues(data.values, focusTypes).curated.find((tile) =>
focusKeys.has(tile.key),
)
: undefined;
const isEmpty =
data !== undefined &&
data.values.length === 0 &&
@@ -178,12 +172,6 @@ export function DayView({
const notShared = data
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
: false;
const meta = data
? [
tCount("day.countValues", data.counts.values),
tCount("day.countEntries", data.counts.entries),
].join(", ")
: null;
const compact = shell === "bottom";
const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close");
@@ -299,20 +287,9 @@ export function DayView({
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-4 pb-6",
)}
>
{/* What the day holds, as meta under the header. The line keeps its
height while the day loads, so nothing below it moves. */}
<p
className="text-muted-foreground -mb-3 text-xs tabular-nums"
data-slot="day-meta"
>
{meta ?? <span className="invisible">0</span>}
</p>
{focus ? (
<div className="space-y-2.5">
<DayFocusCard
focus={focus}
usual={focusTile ? formatTile(focusTile).usual : null}
/>
<DayFocusCard focus={focus} />
<DayNotableLines
notables={focusNotables}
labelFor={labelFor}
@@ -125,7 +125,7 @@ export function AchievementUnlockNotifier({
// card) so the surprise lands.
if (achievement.isHidden) {
toast(t("achievements.hiddenUnlockToast.title"), {
description: `${t(achievement.titleKey)} — ${t(achievement.descriptionKey)}`,
description: `${t(achievement.titleKey)}: ${t(achievement.descriptionKey)}`,
icon: <Sparkles className="size-4" />,
duration: 8000,
});
@@ -194,7 +194,7 @@ describe("<CoincidentDeviationCard>", () => {
expect(html).toContain('data-state="fired"');
expect(html).toContain('data-slot="coincident-factors"');
// The load-bearing framing line.
expect(html).toContain("Possible factors — never a cause");
expect(html).toContain("Possible factors (never a cause)");
expect(html).toContain("not a diagnosis");
// At most amber — never destructive/red.
expect(html).toContain("border-warning");
@@ -226,7 +226,7 @@ describe("<CoincidentDeviationCard>", () => {
expect(html).toContain('data-state="fired"');
// The reframe line replaces the open-ended "possible factors" copy.
expect(html).toContain("the condition you logged");
expect(html).not.toContain("Possible factors — never a cause");
expect(html).not.toContain("Possible factors (never a cause)");
// Annotate, don't escalate: no new destructive colour beyond the amber the
// fired state already carries.
expect(html).not.toContain("text-destructive");
@@ -282,8 +282,8 @@ describe("<MetricTargetSummary>", () => {
expect(html).toContain("Source: ADA 2024 / DDG");
// The i18n label keys resolve to EN context headings (rendered
// upper-cased by the heading style but kept as text content).
expect(html).toContain("Glucose — fasting");
expect(html).toContain("Glucose — post-meal");
expect(html).toContain("Glucose (fasting)");
expect(html).toContain("Glucose (post-meal)");
});
it("converts the glucose bands to mmol/L when the profile prefers it", () => {
@@ -476,7 +476,7 @@ function SortableSectionRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${title}`}
aria-label={`${labels.dragHandle}: ${title}`}
title={labels.dragHandle}
disabled={disabled}
data-slot="insights-edit-section-handle"
@@ -507,7 +507,7 @@ function SortableSectionRow({
<EyeToggle
visible={section.visible}
disabled={disabled || gatedOff}
label={`${section.visible ? labels.hide : labels.show} — ${title}`}
label={`${section.visible ? labels.hide : labels.show}: ${title}`}
onClick={() => onToggle(section.id, !section.visible)}
slot="insights-edit-section-eye"
/>
@@ -316,7 +316,7 @@ export function MetricStatStrip({
role="group"
aria-label={
seriesLabel
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
? `${t("insights.subPage.stats.label")}: ${seriesLabel}`
: t("insights.subPage.stats.label")
}
className="gap-2 py-3 md:py-4"
@@ -140,5 +140,5 @@ export function formatDateRange(
minute: "2-digit",
...hourCycleOptions(timeFormat),
});
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
return `${dateFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`;
}
@@ -54,14 +54,14 @@ describe("<OfflineBanner>", () => {
// the honest thing.
const en = loadMessages("en");
expect(en.offlineBanner.message).toBe(
"No connection — changes can't be saved until you're back online.",
"No connection: changes can't be saved until you're back online.",
);
});
it("ships the DE copy for `offlineBanner.message`", () => {
const de = loadMessages("de");
expect(de.offlineBanner.message).toBe(
"Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.",
"Keine Verbindung: Änderungen können erst gespeichert werden, wenn du wieder online bist.",
);
});
@@ -105,7 +105,7 @@ describe("<InjectionSitePicker>", () => {
// Exactly one site is recommended → exactly one composed label.
const composed =
html.match(/aria-label="[^"]*— recommended next site"/g) ?? [];
html.match(/aria-label="[^"]*: recommended next site"/g) ?? [];
expect(composed.length).toBe(1);
});
@@ -153,7 +153,7 @@ describe("<InjectionSitePicker>", () => {
// empty history the recommender lands on ABDOMEN_LEFT, so its label
// folds in the "recommended next site" cue.
expect(html).toContain(
'aria-label="Abdomen, lower left — recommended next site"',
'aria-label="Abdomen, lower left: recommended next site"',
);
expect(html).toContain('aria-label="Abdomen, lower right"');
expect(html).toContain('aria-label="Abdomen, upper left"');
@@ -173,7 +173,7 @@ describe("<InjectionSitePicker>", () => {
);
expect(html).toContain(
'aria-label="Bauch, unten links — empfohlene nächste Stelle"',
'aria-label="Bauch, unten links: empfohlene nächste Stelle"',
);
expect(html).toContain('aria-label="Bauch, unten rechts"');
expect(html).toContain('aria-label="Bauch, oben links"');
@@ -7,7 +7,7 @@
* - every column renders from the SAME payloads the cards consume
* (the list row's `nextDueAt` / `stockDosesRemaining`, the batched
* compliance-summary cache entry the cards share);
* - the action buttons are per-row labelled ("Take – {name}") and the
* - the action buttons are per-row labelled ("Take {name}") and the
* row rides the cards' shared intake hook (source-level guard);
* - tri-state sorting semantics incl. `aria-sort` and null-last
* ordering, with `null` sort = the manual order the page passed in;
@@ -254,8 +254,8 @@ describe("<MedicationTable> — structure + shared payloads", () => {
client,
);
expect(html).toContain('aria-label="Take – Ramipril"');
expect(html).toContain('aria-label="Skip – Ramipril"');
expect(html).toContain('aria-label="Take Ramipril"');
expect(html).toContain('aria-label="Skip Ramipril"');
// 44-px mobile floor on the action buttons (shrinks at sm).
expect(html).toContain("size-11 sm:size-9");
});
@@ -345,7 +345,7 @@ describe("<MedicationTable> — structure + shared payloads", () => {
expect(html).toContain("opacity-60");
expect(html).toContain("Inactive");
// No take/skip affordance on the inactive row.
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
expect(html).not.toContain('aria-label="Take Amoxicillin"');
});
// v1.29.x — the Today digest's "Log dose" deep-link
@@ -407,7 +407,7 @@ describe("<MedicationTable> — structure + shared payloads", () => {
);
expect(html).toContain("Mounjaro");
expect(html).toContain('aria-label="Take – Mounjaro"');
expect(html).toContain('aria-label="Take Mounjaro"');
expect(html).toContain("4 doses");
});
@@ -436,12 +436,12 @@ describe("<MedicationTable> — structure + shared payloads", () => {
client,
);
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
expect(html).not.toContain('aria-label="Skip – Amoxicillin"');
expect(html).not.toContain('aria-label="Take Amoxicillin"');
expect(html).not.toContain('aria-label="Skip Amoxicillin"');
expect(html).toContain('data-slot="medication-table-course-ended-marker"');
expect(html).toContain(">Ended<");
expect(html).toContain('aria-label="Take – Prednisolone"');
expect(html).toContain('aria-label="Skip – Prednisolone"');
expect(html).toContain('aria-label="Take Prednisolone"');
expect(html).toContain('aria-label="Skip Prednisolone"');
});
it("renders an as-needed row: calm marker, no compliance bars (v1.16.11 #316)", () => {
@@ -476,7 +476,7 @@ describe("<MedicationTable> — structure + shared payloads", () => {
// Compliance column shows the en-dash, not bars or a skeleton.
expect(html).not.toContain("Adherence (");
// The quick actions stay — an as-needed dose is loggable inline.
expect(html).toContain('aria-label="Take – Ibuprofen"');
expect(html).toContain('aria-label="Take Ibuprofen"');
// Stock column still renders (inventory tracking works for PRN).
expect(html).toContain("10 doses");
});
@@ -292,7 +292,7 @@ export function LogIntakeDialog({
>
{medications.map((m) => (
<option key={m.id} value={m.id}>
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
{m.dose ? `${m.name} ${formatDose(m.dose, t)}` : m.name}
</option>
))}
</NativeSelect>
@@ -355,7 +355,7 @@ function SortableMedicationRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${medication.name}`}
aria-label={`${labels.dragHandle}: ${medication.name}`}
aria-describedby={dragHintId}
title={labels.dragHandle}
disabled={disabled}
@@ -380,7 +380,7 @@ function SortableMedicationRow({
className="size-11 sm:size-9"
onClick={() => onMove(medication.id, -1)}
disabled={index === 0 || disabled}
aria-label={`${labels.moveUp} — ${medication.name}`}
aria-label={`${labels.moveUp}: ${medication.name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -391,7 +391,7 @@ function SortableMedicationRow({
className="size-11 sm:size-9"
onClick={() => onMove(medication.id, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${labels.moveDown} — ${medication.name}`}
aria-label={`${labels.moveDown}: ${medication.name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
@@ -216,7 +216,7 @@ export function ScheduleHistoryTimeline({
local day (issue #490 audit). */}
<p className="text-muted-foreground text-xs tabular-nums">
{fmt.date(revision.validFrom)}
{" – "}
{"–"}
{fmt.date(revision.validUntil)}
</p>
</div>
@@ -132,7 +132,7 @@ export function InstrumentCard({
type="button"
onClick={onOpenDetail}
data-slot="instrument-card-open"
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
aria-label={`${title}: ${t("mentalHealth.openDetail")}`}
className="focus-visible:ring-ring block w-full rounded-t-xl text-left focus-visible:ring-2 focus-visible:outline-none"
>
<MedicationCardHeader
@@ -180,7 +180,7 @@ function SortableRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${item.name}`}
aria-label={`${labels.dragHandle}: ${item.name}`}
aria-describedby={dragHintId}
title={labels.dragHandle}
data-slot="module-drag-handle"
@@ -205,7 +205,7 @@ function SortableRow({
className="size-11 sm:size-9"
onClick={() => onMove(item.id, -1)}
disabled={index === 0}
aria-label={`${labels.moveUp} — ${item.name}`}
aria-label={`${labels.moveUp}: ${item.name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -216,7 +216,7 @@ function SortableRow({
className="size-11 sm:size-9"
onClick={() => onMove(item.id, 1)}
disabled={index === total - 1}
aria-label={`${labels.moveDown} — ${item.name}`}
aria-label={`${labels.moveDown}: ${item.name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
@@ -153,7 +153,7 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
size="icon"
className="text-muted-foreground hover:text-foreground size-11 sm:size-9"
onClick={() => setPurgeTag(tag)}
aria-label={`${t("mood.manage.purgeAction")} — ${name}`}
aria-label={`${t("mood.manage.purgeAction")}: ${name}`}
title={t("mood.manage.purgeAction")}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
@@ -432,7 +432,7 @@ function SortableGroupRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${t("dashboard.dragHandle")} — ${name}`}
aria-label={`${t("dashboard.dragHandle")}: ${name}`}
aria-describedby={dragHintId}
title={t("dashboard.dragHandle")}
disabled={disabled}
@@ -461,7 +461,7 @@ function SortableGroupRow({
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
aria-label={`${t("mood.manage.moveUp")}: ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -472,7 +472,7 @@ function SortableGroupRow({
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
aria-label={`${t("mood.manage.moveDown")}: ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
@@ -484,7 +484,7 @@ function SortableGroupRow({
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
aria-label={`${t("common.moreOptions")}: ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -546,7 +546,7 @@ function SortableTagRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${t("dashboard.dragHandle")} — ${name}`}
aria-label={`${t("dashboard.dragHandle")}: ${name}`}
aria-describedby={dragHintId}
title={t("dashboard.dragHandle")}
disabled={disabled}
@@ -589,7 +589,7 @@ function SortableTagRow({
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
} — ${name}`}
}: ${name}`}
title={
tag.custom
? t("mood.manage.archive")
@@ -613,7 +613,7 @@ function SortableTagRow({
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
aria-label={`${t("mood.manage.moveUp")}: ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -624,7 +624,7 @@ function SortableTagRow({
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
aria-label={`${t("mood.manage.moveDown")}: ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
@@ -635,7 +635,7 @@ function SortableTagRow({
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
aria-label={`${t("common.moreOptions")}: ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -336,8 +336,8 @@ describe("<DashboardLayoutSection> — native-only widget group (issue #581)", (
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-widget-id="bmi"');
expect(html).toContain('aria-label="BMI — Tile"');
expect(html).toContain('aria-label="BMI — Chart"');
expect(html).toContain('aria-label="BMI: Tile"');
expect(html).toContain('aria-label="BMI: Chart"');
// Reflects the stored flags rather than inventing a default.
const row = html.match(
/data-widget-id="bmi"[\s\S]*?(?=data-slot="native-widget-row"|$)/,
@@ -448,7 +448,7 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
html.indexOf('data-slot="native-only-widgets"'),
);
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
expect(sortableList).not.toContain(`${pinOnlyLabels[id]} — `);
expect(sortableList).not.toContain(`${pinOnlyLabels[id]}: `);
// ...and each is reachable exactly once, in the native-only group.
expect(html).toContain(`data-widget-id="${id}"`);
}
@@ -465,11 +465,11 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
// The achievements widget label drives its switch aria-label; it is the
// canonical disabled-module probe (label resolves to "Achievements").
const ACHIEVEMENTS_ARIA = "Achievements — ";
const ACHIEVEMENTS_ARIA = "Achievements: ";
// Mood is a module-owned widget too; weight is a core widget with NO module
// entry, so it must survive any module-off state.
const MOOD_ARIA = "Mood — ";
const WEIGHT_ARIA = "Weight — ";
const MOOD_ARIA = "Mood: ";
const WEIGHT_ARIA = "Weight: ";
it("hides a widget toggle whose owning module is disabled", () => {
authState.modules = { achievements: false };
@@ -105,7 +105,7 @@ describe("IntegrationStatusPill", () => {
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
);
expect(html).toContain("Paused");
expect(html).toContain("Paused — reconnect");
expect(html).toContain("Paused: reconnect");
expect(html).toContain('data-state="parked"');
});
@@ -115,7 +115,7 @@ describe("IntegrationStatusPill", () => {
"de",
);
expect(html).toContain("Pausiert");
expect(html).toContain("Pausiert — neu verbinden");
expect(html).toContain("Pausiert: neu verbinden");
});
// The three states the server verdict added. `stale` and `stalled` both
@@ -233,7 +233,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
// The pill flips to `data-state="parked"` and the resume banner
// renders with the reconnect CTA. The legacy `error` reconnect text
// (red) must NOT appear because the user-facing copy is the lighter
// "Paused — reconnect" string, not "Error — reconnect".
// "Paused: reconnect" string, not "Error — reconnect".
it("Withings parked state surfaces the parked pill + resume CTA", () => {
setIntegrationStatus({
threshold: 3,
@@ -256,7 +256,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
const html = render();
expect(html).toContain('data-state="parked"');
expect(html).toContain("Paused");
expect(html).toContain("Paused — reconnect");
expect(html).toContain("Paused: reconnect");
expect(html).toContain('data-testid="withings-parked-banner"');
expect(html).toContain('data-testid="withings-resume-button"');
// The lastError is surfaced under the pill so the operator sees
@@ -190,9 +190,9 @@ describe("<MoodSection> — SSR smoke", () => {
const groupRows = html.match(/data-slot="mood-group-row"/g);
expect(groupRows?.length ?? 0).toBe(2);
// One kebab per CUSTOM group row; the seeded row carries none.
const kebabs = html.match(/More options — Garten/g);
const kebabs = html.match(/More options: Garten/g);
expect(kebabs?.length ?? 0).toBeGreaterThanOrEqual(1);
expect(html).not.toContain("More options — Feelings");
expect(html).not.toContain("More options: Feelings");
});
it("renders custom tags by decrypted label, never the raw key", () => {
@@ -206,7 +206,7 @@ describe("<MoodSection> — SSR smoke", () => {
expect(html).toMatch(
/data-slot="mood-tag-manage-row"[^>]*data-hidden="true"/,
);
expect(html).toContain("Show tag — stressed");
expect(html).toContain("Show tag: stressed");
});
it("surfaces the usage count badge", () => {
@@ -219,7 +219,7 @@ describe("<MoodSection> — SSR smoke", () => {
expect(html).toContain('data-slot="mood-archived-row"');
expect(html).toContain("Altes Tag");
expect(html).toContain("Restore");
expect(html).toContain("Delete permanently — Altes Tag");
expect(html).toContain("Delete permanently: Altes Tag");
// The active custom tag must NOT appear in the archived card.
const archivedCard = html.slice(
html.indexOf('data-slot="mood-archived-tags-card"'),
@@ -900,7 +900,7 @@ function SortableWidgetRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${labels.widgetLabel}`}
aria-label={`${labels.dragHandle}: ${labels.widgetLabel}`}
// v1.4.47 W4 — `aria-describedby` is set after `{...attributes}`
// so our shared hint paragraph wins over dnd-kit's own announcer
// hookup. The announcer still fires on drag-start / drag-over /
@@ -930,7 +930,7 @@ function SortableWidgetRow({
<Switch
checked={tileChecked}
onCheckedChange={(v) => onToggleTile(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.tileColumn}`}
aria-label={`${labels.widgetLabel}: ${labels.tileColumn}`}
disabled={disabled}
data-slot="widget-tile-switch"
/>
@@ -939,7 +939,7 @@ function SortableWidgetRow({
<Switch
checked={widget.visible}
onCheckedChange={(v) => onToggleChart(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.chartColumn}`}
aria-label={`${labels.widgetLabel}: ${labels.chartColumn}`}
disabled={disabled}
data-slot="widget-chart-switch"
/>
@@ -1012,7 +1012,7 @@ function StaticWidgetRow({
<Switch
checked={tileChecked}
onCheckedChange={(v) => onToggleTile(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.tileColumn}`}
aria-label={`${labels.widgetLabel}: ${labels.tileColumn}`}
disabled={disabled}
data-slot="widget-tile-switch"
/>
@@ -1021,7 +1021,7 @@ function StaticWidgetRow({
<Switch
checked={widget.visible}
onCheckedChange={(v) => onToggleChart(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.chartColumn}`}
aria-label={`${labels.widgetLabel}: ${labels.chartColumn}`}
disabled={disabled}
data-slot="widget-chart-switch"
/>
@@ -402,7 +402,7 @@ function EcgManagerRow({
onClick={onToggle}
disabled={disabled}
aria-pressed={visible}
aria-label={`${visible ? hideLabel : showLabel} — ${title}`}
aria-label={`${visible ? hideLabel : showLabel}: ${title}`}
title={visible ? hideLabel : showLabel}
data-slot="insights-pill-order-eye"
data-visible={visible ? "true" : "false"}
@@ -463,7 +463,7 @@ function SortablePillRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${dragHandleLabel} — ${title}`}
aria-label={`${dragHandleLabel}: ${title}`}
title={dragHandleLabel}
disabled={disabled}
data-slot="insights-pill-order-handle"
@@ -481,7 +481,7 @@ function SortablePillRow({
onClick={() => onToggle(id, !visible)}
disabled={disabled}
aria-pressed={visible}
aria-label={`${visible ? hideLabel : showLabel} — ${title}`}
aria-label={`${visible ? hideLabel : showLabel}: ${title}`}
title={visible ? hideLabel : showLabel}
data-slot="insights-pill-order-eye"
data-visible={visible ? "true" : "false"}
@@ -63,7 +63,7 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
expect(html).toContain('data-state="parked"');
expect(html).toContain('data-testid="polar-parked-banner"');
expect(html).toContain('data-testid="polar-resume-button"');
expect(html).toContain("Paused — reconnect");
expect(html).toContain("Paused: reconnect");
// The parked banner uses the same warning treatment as the WHOOP card.
expect(html).toContain("border-warning/30 bg-warning/10");
});
@@ -157,7 +157,7 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () =>
syncHealth: { verdict: "pending_first_sync", since: null },
},
});
expect(html).toContain("Saved — enter new to replace");
expect(html).toContain("Saved. Enter new to replace");
});
});
@@ -11,7 +11,25 @@ import {
standingChips,
type ChronicleRow,
} from "../chronicle-model";
import { TODAY, fullTimeline } from "./timeline-fixture";
import { TODAY, fullTimeline, item } from "./timeline-fixture";
/**
* The fixture without what fills the months between its events: the
* standing medication (taken every month) and the value lines. What is left
* shows the gap rows on their own.
*/
function eventsOnly() {
const tl = fullTimeline();
return {
...tl,
series: [] as typeof tl.series,
lanes: tl.lanes.map((lane) =>
lane.key === "medications"
? { ...lane, items: lane.items.filter((i) => i.group !== "med-1") }
: lane,
),
};
}
function headers(rows: ChronicleRow[]) {
return rows
@@ -64,7 +82,7 @@ describe("chronicleEntries", () => {
describe("buildChronicle", () => {
it("folds a run of empty months into one honest gap row", () => {
const rows = buildChronicle(fullTimeline(), TODAY, "month");
const rows = buildChronicle(eventsOnly(), TODAY, "month");
// The last entry is the course ending on 31 March 2026: April to
// October 2026 hold nothing.
const gap = rows.find((r) => r.type === "gap" && r.to === "2026-10-01");
@@ -77,12 +95,16 @@ describe("buildChronicle", () => {
expect(headers(rows)[0]).toBe("2026-03-01");
});
it("keeps a course's rail through an empty stretch it spans", () => {
const rows = buildChronicle(fullTimeline(), TODAY, "month");
it("passes over the months a course was taken in, without a gap line", () => {
const rows = buildChronicle(eventsOnly(), TODAY, "month");
// The 2024/25 winter course runs from November to March; nothing else
// is dated in December to February.
const gap = rows.find((r) => r.type === "gap" && r.from === "2024-12-01");
expect(gap?.type === "gap" && gap.rails.medications).toBe("full");
// is dated in December to February, and the course was taken then.
expect(
rows.some(
(r) =>
r.type === "gap" && r.from <= "2025-02-01" && r.to >= "2024-12-01",
),
).toBe(false);
});
it("never shows two gap rows next to each other", () => {
@@ -132,12 +154,78 @@ describe("buildChronicle", () => {
expect(visit?.type === "entry" && visit.rails.illness).toBe("full");
});
it("gives a month with values but no event its header, never a gap line", () => {
const tl = eventsOnly();
tl.series = fullTimeline().series;
const rows = buildChronicle(tl, TODAY, "month");
// July 2026 holds a blood-pressure mean and no event.
expect(headers(rows)).toContain("2026-07-01");
const gaps = rows.flatMap((r) =>
r.type === "gap" ? [[r.from, r.to]] : [],
);
expect(gaps).toContainEqual(["2026-04-01", "2026-06-01"]);
expect(gaps).toContainEqual(["2026-08-01", "2026-10-01"]);
});
it("says nothing about a month in which a medication was taken", () => {
const rows = buildChronicle(
{ ...fullTimeline(), series: [] },
TODAY,
"month",
);
// Ramipril has been taken since 2019: no month after it is empty.
expect(rows.some((r) => r.type === "gap" && r.to >= "2019-05-01")).toBe(
false,
);
expect(headers(rows)).not.toContain("2026-07-01");
});
it("still says a truly empty month out loud", () => {
const rows = buildChronicle(eventsOnly(), TODAY, "month");
expect(rows.some((r) => r.type === "gap")).toBe(true);
});
it("marks a medication entry with its own colour inside an illness rail", () => {
const tl = eventsOnly();
tl.lanes = tl.lanes.map((lane) =>
lane.key === "medications"
? {
...lane,
items: [
// Without the winter course, whose rail would pass here too.
...lane.items.filter((i) => i.group !== "vit-d"),
item({
id: "vd3",
group: "vd3",
kind: "medication",
// Inside the cold of 1 to 9 January 2026.
start: "2026-01-05",
open: true,
label: "Vitamin D3",
}),
],
}
: lane,
);
const rows = buildChronicle(tl, TODAY, "month");
const row = rows.find(
(r) =>
r.type === "entry" &&
r.entry.kind === "item" &&
r.entry.item.id === "vd3",
);
expect(row?.type === "entry" && row.rails.illness).toBe("full");
expect(row?.type === "entry" && row.rails.medications).toBe("point");
});
it("is empty for a record without anything dated", () => {
expect(
buildChronicle(
{
lanes: [],
notable: [],
series: [],
bucket: "month",
range: { from: "2026-01-01", to: "2026-12-31", dataFrom: null },
},
TODAY,
@@ -168,3 +256,18 @@ describe("standingChips", () => {
);
});
});
describe("notable lines", () => {
it("lists one line per kind on a day, however many metrics share it", () => {
const tl = fullTimeline();
tl.notable = [
{ date: "2026-08-30", kind: "firstValue" },
{ date: "2026-08-30", kind: "firstValue" },
{ date: "2026-08-30", kind: "extremeHigh" },
] as typeof tl.notable;
const lines = chronicleEntries(tl, TODAY).filter(
(e) => e.kind === "notable" && e.date === "2026-08-30",
);
expect(lines).toHaveLength(2);
});
});
@@ -17,7 +17,8 @@ import {
medicationPeriodItem,
strayDoses,
} from "../medication-rows";
import { item } from "./timeline-fixture";
import { layoutTimeline } from "../timeline-geometry";
import { item, wordsIn } from "./timeline-fixture";
const TODAY = "2025-12-31";
@@ -231,3 +232,125 @@ describe("medicationPeriodItem", () => {
).toMatchObject({ sub: "2.5 mg", start: "2025-01-06" });
});
});
/**
* Every medication still taken is drawn, on exactly one row, whatever its
* items look like: no course, an open course, dose changes, a pause, or
* courses that all ended while the medication stayed active. A medication
* that is over is drawn only where its stretch lies. This is the guard for
* an active medication silently missing from the chart while the day view
* lists it as running.
*/
describe("every medication still taken has one row in the current window", () => {
const today = "2026-10-09";
const med = (id: string, over: Partial<TimelineItem> = {}): TimelineItem =>
item({
id,
group: id,
kind: "medication",
start: "2026-01-15",
open: true,
label: id,
sub: "5 mg",
...over,
});
const of = (
group: string,
kind: TimelineItem["kind"],
over: Partial<TimelineItem>,
) =>
item({
id: `${group}-${kind}-${over.start}`,
group,
kind,
label: group,
...over,
} as never);
const cases: Array<[string, TimelineItem[], boolean]> = [
["no course, open", [med("plain")], true],
["no course, start unknown", [med("unknown", { startKnown: false })], true],
[
"an open course",
[
med("course"),
of("course", "course", { start: "2026-01-15", open: true }),
],
true,
],
[
"an open course and three dose changes",
[
med("doses"),
of("doses", "course", { start: "2026-01-15", open: true }),
of("doses", "doseChange", { start: "2026-01-15", sub: "2.5 mg" }),
of("doses", "doseChange", { start: "2026-02-12", sub: "5 mg" }),
of("doses", "doseChange", { start: "2026-04-09", sub: "7.5 mg" }),
],
true,
],
[
"a running pause",
[
med("paused"),
of("paused", "pause", { start: "2026-09-01", open: true }),
],
true,
],
[
"courses that all ended, medication still active",
[
med("stale"),
of("stale", "course", { start: "2026-01-15", end: "2026-03-01" }),
],
true,
],
[
"ended before the window",
[med("over", { open: false, end: "2026-02-01" })],
false,
],
];
for (const [name, items, drawn] of cases) {
it(`${name}: ${drawn ? "one row" : "no row"} in the last three months`, () => {
const layout = layoutTimeline({
width: 1200,
window: { from: "2026-07-11", to: today },
lanes: [{ key: "medications", items }],
series: [],
bucket: "week",
words: wordsIn("en"),
startMissing: "?",
today,
});
const lane = layout.lanes.find((l) => l.key === "medications");
if (!drawn) {
expect(lane).toBeUndefined();
return;
}
expect(lane?.rows).toBe(1);
expect(new Set(lane?.spans.map((s) => s.row))).toEqual(new Set([0]));
// The bar reaches today.
const right = Math.max(...(lane?.spans ?? []).map((s) => s.xEnd));
expect(right).toBeGreaterThan(layout.scale.x(today));
});
}
it("draws all of them together, one row each", () => {
const items = cases.filter(([, , drawn]) => drawn).flatMap(([, i]) => i);
const layout = layoutTimeline({
width: 1200,
window: { from: "2026-07-11", to: today },
lanes: [{ key: "medications", items }],
series: [],
bucket: "week",
words: wordsIn("en"),
startMissing: "?",
today,
});
const lane = layout.lanes.find((l) => l.key === "medications")!;
expect(lane.rows).toBe(6);
expect(new Set(lane.spans.map((s) => s.row)).size).toBe(6);
});
});
@@ -19,7 +19,13 @@ import { ReadinessInventory, VerdictMeter } from "../readiness-inventory";
import { SelectionBar, bucketTitle } from "../selection-bar";
import { assignSeriesColors } from "../series-colors";
import type { SeriesValueFormat } from "../series-format";
import { MeanPartsLine, SeriesLines, TimelineChart } from "../timeline-chart";
import {
MeanPartsLine,
SeriesLines,
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";
@@ -140,11 +146,23 @@ describe("readiness inventory", () => {
describe("chronicle", () => {
it("names an empty stretch instead of skipping it, and folds the ongoing items", () => {
// Without the standing medication and the value lines, April to October
// 2026 hold nothing at all.
const full = fullTimeline();
const eventsOnly = {
...full,
series: [],
lanes: full.lanes.map((lane) =>
lane.key === "medications"
? { ...lane, items: lane.items.filter((i) => i.group !== "med-1") }
: lane,
),
};
const html = render(
<WithFormat
build={(format) => (
<TimelineChronicle
timeline={fullTimeline()}
timeline={eventsOnly}
today={TODAY}
grouping="month"
selected="2026-01-03"
@@ -157,7 +175,6 @@ describe("chronicle", () => {
/>,
);
expect(html).toContain("Keine Einträge von April bis Oktober 2026");
expect(html).toContain("Dauerhaft");
expect(html).toContain("2 Allergien");
expect(html).toContain("Erkältung vorbei");
expect(html).toContain("nach 9 Tagen");
@@ -166,6 +183,34 @@ describe("chronicle", () => {
expect(html).toMatch(/data-date="2026-01-03"><button[^>]*bg-muted/);
});
it("never calls a month empty while a medication was taken in it", () => {
const html = render(
<WithFormat
build={(format) => (
<TimelineChronicle
timeline={fullTimeline()}
today={TODAY}
grouping="month"
selected={null}
seriesColor={seriesColor}
seriesFormat={format}
onOpenDay={() => undefined}
onEditLifeEvent={null}
/>
)}
/>,
);
// April to October 2026 hold no event, but Ramipril was taken.
expect(html).not.toContain("Keine Einträge von April bis Oktober 2026");
expect(html).toContain("Dauerhaft");
// Every medication entry wears the medication colour.
for (const icon of html.match(/<svg[^>]*data-lane="medications"[^>]*>/g) ??
[]) {
expect(icon).toContain("color:var(--chart-1)");
}
expect(html).toMatch(/data-lane="medications"/);
});
it("reads a pause as paused and resumed, never as an end", () => {
const html = text(
render(
@@ -272,7 +317,7 @@ const bar = (selected: string, timeline = fullTimeline(), showHint = false) =>
);
describe("selection bar", () => {
it("lists the month's entries and offers the selected day", () => {
it("lists the month's entries, and no separate link to open the day", () => {
const html = bar("2026-01-03");
expect(html).toContain("Januar 2026");
expect(html).toContain("Erkältung, 31. Dez. bis 8. Jan.");
@@ -281,8 +326,10 @@ describe("selection bar", () => {
expect(html).toContain("Vitamin D (Winter), 1. Nov. bis 31. März");
expect(html).not.toContain("laufend");
expect(html).not.toContain("Bluthochdruck");
expect(html).toContain("3. Jan. öffnen");
expect(html).toContain('data-date="2026-01-03"');
// The day opens from its chips and the chart; the lone "Open 3 Jan."
// link at the end of the bar is gone.
expect(html).not.toContain("3. Jan. öffnen");
expect(html).not.toContain('data-slot="timeline-open-day"');
});
it("names a pause as a pause", () => {
@@ -290,12 +337,25 @@ describe("selection bar", () => {
expect(html).toContain("Ramipril pausiert, 1. Juni bis 11. Juni");
});
it("names the bucket's means with the readings behind each", () => {
expect(text(bar("2026-01-03"))).toContain(
"Ø Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen), Gewicht 82,6 kg (Schnitt aus 3 Messungen)",
it("names the bucket's means, the readings behind each as a hover and for a screen reader", () => {
const html = bar("2026-01-03");
// What the line shows: the name and the value, nothing more.
const visible = text(
html.replace(
/<span[^>]*data-slot="timeline-mean-detail"[^>]*>[^<]*<\/span>/g,
"",
),
);
expect(visible).toContain("Ø Blutdruck 129/82 mmHg, Gewicht 82,6 kg");
expect(visible).not.toContain("Schnitt aus");
// The readings: a title on the value, and the same words read aloud.
expect(html).toContain('title="Schnitt aus 4 Messungen"');
expect(html).toContain('title="Schnitt aus 3 Messungen"');
expect(text(html)).toContain(
"Ø Blutdruck 129/82 mmHg, Schnitt aus 4 Messungen, Gewicht 82,6 kg, Schnitt aus 3 Messungen",
);
expect(text(bar("2026-07-15"))).toContain(
"Ø Blutdruck sys. 131 mmHg (1 Messung), Blutdruck dia. kein Wert, Gewicht kein Wert",
"Ø Blutdruck sys. 131 mmHg, 1 Messung, Blutdruck dia. kein Wert, Gewicht kein Wert",
);
});
@@ -398,7 +458,7 @@ describe("selection bar head and hint", () => {
],
};
const html = text(bar("2026-01-03", timeline));
expect(html).toContain("9 Std. 16 Min. (Schnitt aus 31 Messungen)");
expect(html).toContain("9 Std. 16 Min., Schnitt aus 31 Messungen");
expect(html).not.toContain("556");
});
});
@@ -425,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}
/>
@@ -513,3 +571,91 @@ describe("life events in the capture picker", () => {
).not.toContain("lifeEvent");
});
});
describe("value-line names", () => {
it("lead to their metric's page, with a 44 px target, and none for a kind without one", () => {
const tl = fullTimeline();
const layout = layoutTimeline({
width: 1200,
window: { from: "2025-10-01", to: "2026-10-31" },
lanes: tl.lanes,
series: [...tl.series, { key: "NOT_A_TYPE", unit: null, points: [] }],
bucket: tl.bucket,
words: wordsIn("de"),
startMissing: "?",
today: TODAY,
});
const html = render(
<SeriesNameLinks layout={layout} seriesLabel={(k) => `name ${k}`} />,
);
expect(html).toMatch(
/data-slot="timeline-series-link"[^>]*data-series="WEIGHT"|href="\/insights\/weight"[^>]*data-slot="timeline-series-link"/,
);
expect(html).toContain('href="/insights/weight"');
expect(html).toContain('href="/insights/blood-pressure"');
expect(html).toContain('aria-label="name WEIGHT"');
expect(html).not.toContain('data-series="NOT_A_TYPE"');
for (const height of html.match(/height:(\d+)px/g) ?? []) {
expect(Number(height.replace(/\D/g, ""))).toBeGreaterThanOrEqual(44);
}
});
});
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);
});
});
@@ -7,6 +7,10 @@
import { describe, expect, it } from "vitest";
import {
LineOccupancy,
NEIGHBOUR_GAP,
placeText,
seriesNameLines,
createScale,
dateAtPointer,
estimateTextWidth,
@@ -408,7 +412,10 @@ describe("layoutLane", () => {
]);
for (const label of layout.labels) {
const point = layout.points.find((p) => p.item.id === label.itemId)!;
expect(label.x).toBeGreaterThan(point.x + 7);
// Right of the glyph, or left of it when the next mark is too near.
const right = label.x > point.x + 7;
const left = label.x + estimateTextWidth(label.text) < point.x - 7;
expect(right || left).toBe(true);
expect(label.strong).toBe(true);
}
});
@@ -736,3 +743,114 @@ describe("interaction", () => {
).toBeGreaterThan(0);
});
});
describe("label room (v1.42 final)", () => {
it("puts a long value-line name on two lines instead of cutting it", () => {
const width = 164 - 12 - 16;
expect(seriesNameLines("Gewicht", width, 13)).toEqual(["Gewicht"]);
const lines = seriesNameLines("Blood pressure sys.", width, 13);
expect(lines).toEqual(["Blood pressure", "sys."]);
for (const line of lines) {
expect(estimateTextWidth(line, 13)).toBeLessThanOrEqual(width);
}
// Two lines are the most; the second is cut when even two do not hold it.
const long = seriesNameLines(
"Walking heart rate average over the whole day",
width,
13,
);
expect(long).toHaveLength(2);
expect(long[1].endsWith("…")).toBe(true);
});
it("names a closed period right of its own bar when nothing follows it", () => {
const occ = new LineOccupancy();
occ.add("1:on", 100, 200);
expect(placeText(occ, "1:on", 206, "Away", 1000, 4)).toBe("Away");
});
it("keeps a gap before the next mark, cuts a name short, or leaves it out", () => {
const occ = new LineOccupancy();
occ.add("0:on", 200, 210);
// Whole: ends a full gap before the mark at 200.
expect(placeText(occ, "0:on", 100, "abcd", 1000)).toBe("abcd");
const occ2 = new LineOccupancy();
occ2.add("0:on", 200, 210);
const cut = placeText(occ2, "0:on", 100, "a long label that runs on", 1000);
expect(cut?.endsWith("…")).toBe(true);
expect(100 + estimateTextWidth(cut!)).toBeLessThanOrEqual(
200 - NEIGHBOUR_GAP,
);
// No room for even a few characters: no label at all.
const occ3 = new LineOccupancy();
occ3.add("0:on", 120, 130);
expect(placeText(occ3, "0:on", 100, "a long label", 1000)).toBeNull();
});
it("keeps a medication's name above its bar clear of the bar's arrow", () => {
const window = { from: "2026-07-11", to: "2026-10-09" };
const scale = createScale(window, 164, 700);
const lane = layoutLane(
{
lane: {
key: "medications",
items: [
item({
id: "mg",
group: "mg",
kind: "medication",
// Started late in the window: a short bar near today.
start: "2026-09-20",
open: true,
startKnown: false,
label: "Magnesium",
sub: "400mg",
}),
],
},
top: 0,
},
window,
scale,
{ words: WORDS, startMissing: "Startdatum fehlt", today: "2026-10-09" },
);
const bar = lane.spans[0];
expect(lane.labels.length).toBeGreaterThan(0);
for (const label of lane.labels) {
const right = label.x + estimateTextWidth(label.text);
// Above the bar it ends before the arrow head; beside it, it starts
// after it.
if (label.y < bar.y) expect(right).toBeLessThanOrEqual(bar.xEnd - 2);
else expect(label.x >= bar.xEnd + 7 || right <= bar.xStart).toBe(true);
}
});
it("never lets two names of one band overlap, on a row or across two", () => {
for (const width of [390, 700, 1100, 1600]) {
const layout = layoutAt(width);
for (const lane of layout.lanes) {
// Two names closer than a text line apart vertically share a band
// (a row's names and the names above the next row's bars): they
// must not overlap sideways either.
const all = boxes(lane);
for (let i = 0; i < all.length; i++) {
for (let j = i + 1; j < all.length; j++) {
const p = all[i];
const q = all[j];
if (Math.abs(p.y - q.y) >= 12) continue;
expect(p.b <= q.a || q.b <= p.a).toBe(true);
}
}
}
}
});
it("names a life event without its category glued on", () => {
const life = fullTimeline().lanes.find((l) => l.key === "life")!;
for (const entry of life.items) {
const words = WORDS(entry);
expect(words.sub).toBeNull();
expect(itemLine(entry, WORDS, "")).toBe(entry.label);
}
});
});
@@ -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));
}
});
});
+87 -4
View File
@@ -11,7 +11,12 @@
*
* Empty stretches are said out loud: "No entries from April to August
* 2025" rather than months silently skipped, so a gap reads as a gap and not
* as a list that forgot something.
* as a list that forgot something. The chronicle lists events, not
* readings or intakes, so "empty" means empty: a month without an event
* but with values keeps its header (the month's means ride on it), and a
* month in which a medication was taken is passed over without a line,
* because "no entries" would read as nothing having happened while the
* intakes say otherwise. Only a month with none of the three is a gap.
*
* A medication is listed once per thing that happened to it
* (`medicationListItems`): its start with the dose taken then, each later
@@ -27,12 +32,18 @@ import type {
} from "@/lib/day/contract";
import {
addMonths,
bucketAfter,
dayNumber,
endOfMonth,
monthsBetween,
startOfMonth,
} from "./timeline-dates";
import { groupKey, medicationListItems } from "./medication-rows";
import {
groupKey,
medicationGroups,
medicationListItems,
} from "./medication-rows";
import { LANE_ORDER, isSpan } from "./timeline-geometry";
export type ChronicleGrouping = "month" | "year";
@@ -200,7 +211,13 @@ export function chronicleEntries(
}
}
}
// One line per kind of notable on a day: two metrics seen for the first
// time on one day are one "First value" line, not two identical ones.
const notables = new Set<string>();
for (const n of timeline.notable) {
const key = `${n.date}:${n.kind}`;
if (notables.has(key)) continue;
notables.add(key);
out.push({ kind: "notable", date: n.date, notable: n.kind });
}
const laneRank = (e: ChronicleEntry) =>
@@ -259,17 +276,63 @@ export function railsAt(
return rails;
}
/**
* An entry of a rail lane always shows its own lane's colour beside it: a
* dot on its lane's rail when no period of that lane passes the row. Without
* it a medication listed inside an illness carried only the illness's rail,
* and read as the illness's colour.
*/
function ownRail(rails: ChronicleRails, entry: ChronicleEntry): ChronicleRails {
if (entry.kind !== "item") return rails;
const lane = entry.lane as RailLane;
if (!(RAIL_LANES as readonly string[]).includes(lane) || rails[lane]) {
return rails;
}
return { ...rails, [lane]: "point" };
}
/**
* The rows: a header per group, its entries, and one gap row for every run
* of groups without an entry. Groups run from `today`'s back to the oldest
* entry, or to `dataFrom` when the record reaches further.
*/
export function buildChronicle(
timeline: Pick<TimelineResponse, "lanes" | "notable" | "range">,
timeline: Pick<
TimelineResponse,
"lanes" | "notable" | "range" | "series" | "bucket"
>,
today: string,
grouping: ChronicleGrouping,
): ChronicleRow[] {
const entries = chronicleEntries(timeline, today);
const groupAfter = (group: string) =>
grouping === "month" ? addMonths(group, 1) : addMonths(group, 12);
/** A value line has a reading in the group (its bucket overlaps it). */
const valued = (group: string) =>
timeline.series.some((s) =>
s.points.some(
(p) =>
p.count > 0 &&
p.t < groupAfter(group) &&
bucketAfter(p.t, timeline.bucket) > group,
),
);
/**
* A medication was taken through the group (its intakes are not on the
* timeline, its stretch is). A paused stretch does not count.
*/
const medications = timeline.lanes.find((l) => l.key === "medications");
const taken = medications
? medicationGroups(medications.items).flatMap((g) => g.periods)
: [];
const running = (group: string) => {
const end = groupEnd(group, grouping);
return taken.some(
(item) =>
item.start <= end &&
(item.open ? today : (item.end ?? item.start)) >= group,
);
};
const spans = railSpans(timeline.lanes);
const oldest = [
entries.at(-1)?.date,
@@ -309,6 +372,22 @@ export function buildChronicle(
for (const group of groupsBetween(first, today, grouping)) {
const list = byGroup.get(group);
if (!list) {
if (valued(group)) {
// No event, but readings: the header carries the month's means.
flushGap();
const end = groupEnd(group, grouping);
rows.push({
type: "header",
group,
rails: railsAt(spans, end < today ? end : today),
});
continue;
}
if (running(group)) {
// Something was under way: not empty, and nothing to list either.
flushGap();
continue;
}
// Groups run newest first: the first empty one is the gap's newest end.
gapTo = gapTo ?? group;
gapFrom = group;
@@ -322,7 +401,11 @@ export function buildChronicle(
rails: railsAt(spans, end < today ? end : today),
});
for (const entry of list) {
rows.push({ type: "entry", entry, rails: railsAt(spans, entry.date) });
rows.push({
type: "entry",
entry,
rails: ownRail(railsAt(spans, entry.date), entry),
});
}
}
flushGap();
+9 -2
View File
@@ -36,8 +36,14 @@ type TranslateCount = (
export interface ItemWords {
/** What the item is called. */
label: string;
/** Its second line (a category, a dose, a count), or null when none. */
/** Its second line (a dose, a count), or null when none. */
sub: string | null;
/**
* A category set apart from the name (a life event's "Home"): shown as a
* chip or on its own muted line, never run on after the name, where
* "Moved into a bigger flat Home" read as one title.
*/
tag?: string | null;
}
export type ItemWordsFn = (item: TimelineItem) => ItemWords;
@@ -80,7 +86,8 @@ export function makeItemWords(
case "lifeEvent":
return {
label: item.label,
sub: worded(t, LIFE_EVENT_CATEGORY_KEY, item.sub),
sub: null,
tag: worded(t, LIFE_EVENT_CATEGORY_KEY, item.sub),
};
case "travel":
return { label: t("day.travel"), sub: null };
+25 -2
View File
@@ -52,6 +52,30 @@ function spans(item: TimelineItem): boolean {
return item.open || (item.end !== null && item.end !== item.start);
}
/**
* The medication's own span as a stretch, where its courses do not say when
* it was taken: all of it when it has no course, and the time since its last
* course ended when it is still taken but no course is open. Without that
* second arm an active medication whose courses all lay in the past had no
* stretch in the current window and dropped out of the chart, while the day
* view, which reads the medication itself, listed it as running.
*/
function ownStretch(
medication: TimelineItem | null,
courses: readonly TimelineItem[],
): TimelineItem[] {
if (!medication) return [];
if (courses.length === 0) return [medication];
if (!medication.open || courses.some((c) => c.open)) return [];
const lastEnd = courses
.map((c) => c.end ?? c.start)
.sort()
.at(-1)!;
const start = dayKey(dayNumber(lastEnd) + 1);
if (start <= medication.start) return [medication];
return [{ ...medication, id: `${medication.id}:since`, start }];
}
/** The medications lane's items, one group per medication, oldest first. */
export function medicationGroups(
items: readonly TimelineItem[],
@@ -67,8 +91,7 @@ export function medicationGroups(
for (const [key, list] of byKey) {
const medication = list.find((i) => i.kind === "medication") ?? null;
const courses = list.filter((i) => i.kind === "course").sort(byStart);
const own = medication && courses.length === 0 ? [medication] : [];
const stretches = [...courses, ...own];
const stretches = [...courses, ...ownStretch(medication, courses)];
groups.push({
key,
label: medication?.label ?? list[0].label,
+18 -15
View File
@@ -7,8 +7,11 @@
* value that changes with the zoom says why: it is the mean of a different
* stretch. Then every entry that touches the bucket as a chip (each one a
* way into its day), the means of the chosen values with the readings behind
* each ("no value" where there is none), and "Open 3 Jan." for the selected
* day. Until someone picks a day, a short line says how to.
* each ("no value" where there is none; the readings behind a mean are a
* hover and a screen-reader detail, not a second line). The day itself opens
* from a chip, from Enter on the chart or a double click; a separate "Open
* 3 Jan." link sat apart from everything else and is gone. Until someone
* picks a day, a short line says how to.
*
* It is also where a label the chart had to leave out is read in full, and
* where each keyboard step on the chart is announced (`aria-live`).
@@ -19,8 +22,6 @@
* medication is one entry however many of its items touch the bucket
* (`medicationPeriodItem`).
*/
import { ArrowRight } from "lucide-react";
import type { TimelineResponse } from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
@@ -147,9 +148,19 @@ export function SelectionBar({
to: at(item.end),
})
: at(item.start);
const { label, sub } = words(item);
const { label, sub, tag } = words(item);
const name = [label, sub].filter(Boolean).join(" ");
const text = `${name}, ${when}`;
// A life event's category is a chip of its own inside the entry,
// never run on after its title.
const category = tag ? (
<span
data-slot="timeline-selection-tag"
className="border-border text-muted-foreground rounded-full border px-1.5 leading-4"
>
{tag}
</span>
) : null;
const editable = item.kind === "lifeEvent" && onEditLifeEvent;
if (!canOpenDay && !editable) {
return (
@@ -163,6 +174,7 @@ export function SelectionBar({
aria-hidden="true"
/>
{text}
{category}
</span>
);
}
@@ -183,6 +195,7 @@ export function SelectionBar({
aria-hidden="true"
/>
{text}
{category}
</button>
);
})}
@@ -205,16 +218,6 @@ export function SelectionBar({
{meanAfter}
</span>
)}
<button
type="button"
data-slot="timeline-open-day"
data-date={selected}
onClick={() => onOpenDay(selected)}
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 ml-auto inline-flex min-h-11 items-center gap-1 rounded-md px-2 text-sm font-medium outline-none focus-visible:ring-2 sm:min-h-8"
>
{t("timeline.openDay", { date: formatDayMonth(selected, intl) })}
<ArrowRight className="size-4" aria-hidden="true" />
</button>
</div>
);
}
+10 -2
View File
@@ -126,7 +126,13 @@ function pressure(
/** One value of a means line, with the series whose colour marks it. */
export interface MeanPart {
key: string;
/** What is read at a glance: the name and the value. */
text: string;
/**
* What stands behind it ("mean of 19 readings"): a hover, a screen reader
* and a long press reach it, the line does not show it.
*/
detail?: string;
}
/**
@@ -189,7 +195,8 @@ export function labelledMeanParts(
if (fold && m.key === "BLOOD_PRESSURE_SYS") {
parts.push({
key: m.key,
text: `${words.bloodPressure} ${pressure(sys, dia!, format)} (${words.readings(sys.count ?? 0)})`,
text: `${words.bloodPressure} ${pressure(sys, dia!, format)}`,
detail: words.readings(sys.count ?? 0),
});
continue;
}
@@ -198,7 +205,8 @@ export function labelledMeanParts(
text:
m.mean === null
? `${words.label(m.key)} ${words.noValue}`
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, format)} (${words.readings(m.count ?? 0)})`,
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, format)}`,
...(m.mean === null ? {} : { detail: words.readings(m.count ?? 0) }),
});
}
return parts;
@@ -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>
);
}
+180 -87
View File
@@ -22,6 +22,9 @@
* the card re-runs the layout: labels that no longer fit are left out, never
* overlapped.
*/
import { metricPageHref } from "@/lib/insights/metric-page";
import Link from "next/link";
import { SeriesPointTips } from "./series-point-tips";
import {
useEffect,
useMemo,
@@ -68,7 +71,9 @@ import {
LANE_COLOR,
dateAtPointer,
estimateTextWidth,
SERIES_HEIGHT,
fitText,
seriesNameLines,
gridTicks,
isSpan,
todayLabelFits,
@@ -225,7 +230,7 @@ export function TimelineChart({
onKeyDown={handleKey}
data-slot="timeline-chart"
data-width={width}
className="focus-visible:ring-ring/50 rounded-lg outline-none focus-visible:ring-2"
className="focus-visible:ring-ring/50 relative rounded-lg outline-none focus-visible:ring-2"
>
<p id="timeline-chart-help" className="sr-only">
{t("timeline.keyboardHint")}
@@ -264,6 +269,23 @@ export function TimelineChart({
) : (
<div className="h-64" />
)}
{layout ? (
<>
<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
timeline={timeline}
@@ -281,6 +303,48 @@ export function TimelineChart({
);
}
/**
* A value line's name leads to its metric's page, as a value tile in the day
* view does (`metricPageHref`, one map for both). The names are drawn in the
* SVG; the links lie over them as plain HTML, so they take focus, a hover
* and a 44 px target over the line's height. A kind without a page keeps a
* plain name. Enter on a link follows it and never reaches the chart's own
* Enter, which opens the day.
*/
export function SeriesNameLinks({
layout,
seriesLabel,
}: {
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
seriesLabel: (key: string) => string;
}) {
return (
<>
{layout.series.map((series) => {
const href = metricPageHref(series.key);
if (!href) return null;
return (
<Link
key={series.key}
href={href}
data-slot="timeline-series-link"
data-series={series.key}
aria-label={seriesLabel(series.key)}
onKeyDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
className="hover:bg-foreground/5 focus-visible:ring-ring/50 absolute left-0 rounded-md outline-none focus-visible:ring-2"
style={{
top: series.top - 4,
width: layout.scale.x0 - 8,
height: Math.max(44, SERIES_HEIGHT - 12),
}}
/>
);
})}
</>
);
}
interface ChartBodyProps {
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
window: TimeWindow;
@@ -447,19 +511,6 @@ function ChartBody({
intl={intl}
/>
))}
{lane.labels.map((label, i) => (
<text
key={`label-${label.itemId}-${i}`}
x={label.x}
y={label.y}
fontSize={11}
fill={
label.strong ? "var(--foreground)" : "var(--muted-foreground)"
}
>
{label.text}
</text>
))}
</g>
);
})}
@@ -471,10 +522,8 @@ function ChartBody({
layout={layout}
width={width}
selectedBucket={selectedBucket}
bucket={bucket}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
intl={intl}
t={t}
fmt={fmt}
/>
@@ -524,6 +573,33 @@ function ChartBody({
/>
</g>
)}
{/* The names last, over the today and selection lines, each on a
halo of the card's colour: a line passing a name runs behind it
and never through its letters. */}
<g data-slot="timeline-labels">
{layout.lanes.flatMap((lane) =>
lane.labels.map((label, i) => (
<text
key={`label-${lane.key}-${label.itemId}-${i}`}
data-label-lane={lane.key}
data-label-item={label.itemId}
x={label.x}
y={label.y}
fontSize={11}
fill={
label.strong ? "var(--foreground)" : "var(--muted-foreground)"
}
stroke="var(--card)"
strokeWidth={3}
strokeLinejoin="round"
paintOrder="stroke"
>
{label.text}
</text>
)),
)}
</g>
</g>
);
}
@@ -539,25 +615,22 @@ 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);
return (
<>
{layout.series.length > 0 && (
@@ -589,21 +662,22 @@ export function SeriesLines({
strokeLinecap="round"
/>
<text
data-slot="timeline-series-name"
x={SERIES_TEXT_X}
y={series.top + 12}
fontSize={13}
fontWeight={500}
fill="var(--foreground)"
>
{fitText(
seriesLabel(series.key),
scale.x0 - 12 - SERIES_TEXT_X,
13,
)}
{nameLines(series.key).map((line, i) => (
<tspan key={i} x={SERIES_TEXT_X} dy={i === 0 ? 0 : 15}>
{line}
</tspan>
))}
</text>
<text
x={SERIES_TEXT_X}
y={series.top + 30}
y={series.top + 30 + (nameLines(series.key).length - 1) * 15}
fontSize={12}
fill="var(--muted-foreground)"
>
@@ -652,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)
@@ -691,8 +761,10 @@ function spanTitle(
words: ItemWordsFn,
): string {
const { item, segment } = span;
const { label, sub } = words(item);
const name = [label, segment ? segment.dose : sub].filter(Boolean).join(" ");
const { label, sub, tag } = words(item);
const name = [label, segment ? segment.dose : sub, tag ? `(${tag})` : null]
.filter(Boolean)
.join(" ");
const start = segment?.start ?? item.start;
const end = segment ? segment.end : item.open ? null : item.end;
const from = formatAtPrecision(start, item.precision, intl);
@@ -915,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>
);
}
@@ -995,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>
);
}
@@ -1045,6 +1129,10 @@ export function MeanPartsLine({
<span
key={part.key}
data-series={part.key}
data-slot="timeline-mean-part"
// The readings behind a mean: a hover title, and the same words for a
// screen reader, but not on the line itself.
title={part.detail}
className={i > 0 ? "ml-3" : undefined}
>
{i > 0 ? <span className="sr-only">, </span> : null}
@@ -1055,6 +1143,11 @@ export function MeanPartsLine({
aria-hidden="true"
/>
{part.text}
{part.detail ? (
<span className="sr-only" data-slot="timeline-mean-detail">
{`, ${part.detail}`}
</span>
) : null}
</span>
));
}
+15 -2
View File
@@ -191,7 +191,7 @@ export function TimelineChronicle({
};
}
const { item, role } = entry;
const { label, sub } = words(item);
const { label, sub, tag } = words(item);
if (role === "end") {
// A pause that ends is the medication taken up again, not ended.
return {
@@ -204,7 +204,11 @@ export function TimelineChronicle({
: null,
};
}
const meta = [sub, item.startKnown ? null : t("timeline.startUnknown")]
// A life event's category sits on the muted line under its title.
const meta = [
sub ?? tag ?? null,
item.startKnown ? null : t("timeline.startUnknown"),
]
.filter(Boolean)
.join(", ");
return { title: label, meta: meta || null };
@@ -331,8 +335,17 @@ export function TimelineChronicle({
<span className="text-muted-foreground text-xs leading-5 whitespace-nowrap tabular-nums">
{dateText}
</span>
{/* Every entry of a kind carries its lane's colour, as on the
chart: a medication is the medication colour wherever it
is listed, never the colour of a rail passing beside it. */}
<Icon
className="text-muted-foreground mt-0.5 size-4"
style={
entry.kind === "item"
? { color: LANE_COLOR[entry.lane] }
: undefined
}
data-lane={entry.kind === "item" ? entry.lane : undefined}
aria-hidden="true"
/>
<span className="min-w-0">
+138 -17
View File
@@ -239,6 +239,75 @@ export class LineOccupancy {
this.add(line, a, b);
return true;
}
/**
* How far right a text starting at `a` may run on `line`: up to `margin`
* before the next thing that starts to its right. `a` itself when `a` lies
* inside something, Infinity when nothing lies to its right. What ends
* before `a` (the mark the text names) is the caller's `fits` to judge.
*/
room(line: string, a: number, margin = LABEL_MARGIN): number {
let limit = Infinity;
for (const [c, d] of this.lines.get(line) ?? []) {
if (c <= a && a < d) return a;
if (c > a) limit = Math.min(limit, c - margin);
}
return limit;
}
}
/** Air between a label and the next mark or label to its right. */
export const NEIGHBOUR_GAP = 12;
/** A label cut shorter than this many characters is left out instead. */
export const MIN_LABEL_CHARS = 6;
/**
* A name on one row's line and a name above the next row's bar sit a few
* pixels apart vertically: the two lines share a band. Whatever is placed on
* one is written into the other too, so the two never stack two names over
* each other ("Moved into a bigger flat" over "Away").
*/
export function shareBand(
occ: LineOccupancy,
row: number,
where: "on" | "above",
a: number,
b: number,
): void {
if (where === "on") occ.add(`${row + 1}:above`, a, b);
else if (row > 0) occ.add(`${row - 1}:on`, a, b);
}
/**
* Put `text` on `line` from `a`: whole when it keeps {@link NEIGHBOUR_GAP}
* from the next thing to its right and `margin` from everything else, else
* cut with an ellipsis to the room before that next thing, else not at all
* (when even {@link MIN_LABEL_CHARS} characters would not fit). Returns the
* text placed, or null.
*/
export function placeText(
occ: LineOccupancy,
line: string,
a: number,
text: string,
right: number,
margin = LABEL_MARGIN,
): string | null {
if (!occ.fits(line, a, a, margin)) return null;
const room = Math.min(right, occ.room(line, a, NEIGHBOUR_GAP));
const width = estimateTextWidth(text);
if (a + width <= room && occ.fits(line, a, a + width, margin)) {
occ.add(line, a, a + width);
return text;
}
const available = room - a;
if (available < estimateTextWidth("x".repeat(MIN_LABEL_CHARS))) return null;
const cut = fitText(text, available);
const cutWidth = estimateTextWidth(cut);
if (!occ.fits(line, a, a + cutWidth, margin)) return null;
occ.add(line, a, a + cutWidth);
return cut;
}
/**
@@ -283,6 +352,32 @@ export function wrapToWidth(
return lines;
}
/**
* A value line's name in the label column: one line when it fits, else two,
* the second cut with an ellipsis if even two do not hold it. "Blood
* pressure sys." read "Blood pressure sy…" on one line; on two it reads whole
* in every bundle, and the value under it moves down one line.
*/
export function seriesNameLines(
text: string,
maxWidth: number,
fontPx = LABEL_FONT_PX,
): string[] {
if (estimateTextWidth(text, fontPx) <= maxWidth) return [text];
const words = text.split(/\s+/);
let first = "";
let i = 0;
for (; i < words.length; i++) {
const next = first ? `${first} ${words[i]}` : words[i];
if (estimateTextWidth(next, fontPx) > maxWidth) break;
first = next;
}
// A first word longer than the column: one cut line, as before.
if (first === "") return [fitText(text, maxWidth, fontPx)];
const rest = words.slice(i).join(" ");
return rest ? [first, fitText(rest, maxWidth, fontPx)] : [first];
}
/* ─── Grid ────────────────────────────────────────────────────────────────── */
export interface GridTick {
@@ -712,13 +807,14 @@ function placeMedicationLabels(
s.segment?.first && !s.item.startKnown ? UNKNOWN_START_LEAD : 0;
const arrow = s.item.open && (s.segment?.last ?? true) ? 7 : 0;
occ.add(line(s.row, "on"), s.xStart - lead, s.xEnd + arrow);
// A name above the bar ends before the bar's own arrow head.
if (arrow) occ.add(line(s.row, "above"), s.xEnd - 2, s.xEnd + 9);
}
for (const p of points) {
const half = GLYPH_HALF_WIDTH[p.shape];
occ.add(line(p.row, "on"), p.x - half, p.x + half);
}
const labels: PlacedLabel[] = [];
const fits = (a: number, b: number) => a >= scale.x0 && b <= scale.x1 - 2;
const place = (
id: string,
row: number,
@@ -728,13 +824,20 @@ function placeMedicationLabels(
text: string,
margin = LABEL_MARGIN,
) => {
const b = a + estimateTextWidth(text);
if (!fits(a, b) || !occ.tryAdd(line(row, where), a, b, margin)) {
return false;
}
if (a < scale.x0) return false;
const placed = placeText(
occ,
line(row, where),
a,
text,
scale.x1 - 2,
margin,
);
if (placed === null) return false;
shareBand(occ, row, where, a, a + estimateTextWidth(placed));
labels.push({
itemId: id,
text,
text: placed,
x: a,
y: where === "above" ? y - 7 : y + 4,
strong: false,
@@ -835,8 +938,10 @@ function placeLabels(
const line = (row: number, where: "on" | "above") => `${row}:${where}`;
for (const s of spans) {
const lead = s.item.startKnown ? 0 : UNKNOWN_START_LEAD;
// An open period ends in a small arrow head past its last day.
// An open period ends in a small arrow head past its last day; a name
// above the bar keeps clear of it too.
occ.add(line(s.row, "on"), s.xStart - lead, s.xEnd + (s.item.open ? 7 : 0));
if (s.item.open) occ.add(line(s.row, "above"), s.xEnd - 2, s.xEnd + 9);
}
for (const p of points) {
const half = GLYPH_HALF_WIDTH[p.shape];
@@ -853,10 +958,17 @@ function placeLabels(
const aboveLabel = (s: PlacedSpan, text: string) => {
const a = s.xStart + (s.clippedLeft ? 26 : 2);
const b = a + estimateTextWidth(text);
if (!fitsCard(a, b)) return false;
if (!occ.tryAdd(line(s.row, "above"), a, b)) return false;
labels.push({ itemId: s.item.id, text, x: a, y: s.y - 7, strong: false });
if (a < scale.x0) return false;
const placed = placeText(occ, line(s.row, "above"), a, text, scale.x1 - 2);
if (placed === null) return false;
shareBand(occ, s.row, "above", a, a + estimateTextWidth(placed));
labels.push({
itemId: s.item.id,
text: placed,
x: a,
y: s.y - 7,
strong: false,
});
return true;
};
const rightLabel = (
@@ -868,13 +980,21 @@ function placeLabels(
strong: boolean,
) => {
const a = x;
const b = a + estimateTextWidth(text);
if (!fitsCard(a, b)) return false;
if (a < scale.x0) return false;
// The label already keeps its own distance from the mark it names, so a
// smaller margin here; the full margin still separates it from the next
// mark because that mark's own box is wider than this gap.
if (!occ.tryAdd(line(row, "on"), a, b, BESIDE_MARGIN)) return false;
labels.push({ itemId: id, text, x: a, y: y + 4, strong });
// smaller margin behind it; ahead of it, it keeps a full gap from the
// next mark, or is cut short, or left out.
const placed = placeText(
occ,
line(row, "on"),
a,
text,
scale.x1 - 2,
BESIDE_MARGIN,
);
if (placed === null) return false;
shareBand(occ, row, "on", a, a + estimateTextWidth(placed));
labels.push({ itemId: id, text: placed, x: a, y: y + 4, strong });
return true;
};
@@ -890,6 +1010,7 @@ function placeLabels(
const a = b - estimateTextWidth(text);
if (!fitsCard(a, b)) return false;
if (!occ.tryAdd(line(row, "on"), a, b, BESIDE_MARGIN)) return false;
shareBand(occ, row, "on", a, b);
labels.push({ itemId: id, text, x: a, y: y + 4, strong });
return true;
};
+2 -2
View File
@@ -229,7 +229,7 @@ export function FilterBarDateRange({
const fmt = useFormatters();
const active = from !== "" || to !== "";
const display = active
? `${from ? fmt.dateShortSmart(from) : "…"} – ${to ? fmt.dateShortSmart(to) : "…"}`
? `${from ? fmt.dateShortSmart(from) : "…"}–${to ? fmt.dateShortSmart(to) : "…"}`
: null;
const clear = () => {
onFromChange("");
@@ -326,7 +326,7 @@ export function FilterBarNumberRange({
toLabel: string;
}) {
const active = min !== "" || max !== "";
const display = active ? `${min || "…"} – ${max || "…"}` : null;
const display = active ? `${min || "…"}–${max || "…"}` : null;
const clear = () => {
onMinChange("");
onMaxChange("");