mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge the final display polish into release/v1.42.0
This commit is contained in:
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 () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
|
||||
Reference in new issue
Block a user