feat: add the timeline page, its readiness inventory and life-event entry

The opt-in timeline module gets its page (#613). At 768 px and up the
record's lanes (life, conditions, allergies, medications, vaccinations,
doctor and procedures, labs, documents) are drawn in one SVG on a shared
time axis, with a year, month or week grid, open periods ending in an
arrow, a dashed lead-in where a start is unknown, dose marks and pauses on
the medication they belong to, a Today line and a selection line. Up to
three neutral value lines run below as monthly means, chosen in a menu.
Recharts stays for every value chart; this surface is plain SVG like the
heatmaps.

All geometry is pure (timeline-geometry.ts): window, scale, row packing
and label placement through one occupancy map, so no label overlaps
another label or a bar, none runs past the card edge, and labels that do
not fit are left out rather than squeezed. The width comes from a
ResizeObserver, so a docked day panel re-runs the layout.

The bar under the chart names the selected month's entries as chips, the
month's means and "Open 3 Jan.", which puts ?day= in the URL for the day
layer. The chart is one tab stop: arrows move the selection by a day, a
month or a year with the zoom, Home and End jump to the ends, Enter opens
the day. A visually hidden table lists every entry for screen readers.
The legend says once that stacked means at the same time, not cause and
effect.

Below 768 px the timeline is a chronicle, newest first by month or year,
with an Ongoing block for what has no meaningful start, rails beside the
rows an episode or course spans, and empty stretches named as such.

Switching the module on in Settings opens the readiness inventory: each
lane carries, is thin or is empty, with one link per gap and the verdict
as a count. The same inventory sits as a dismissible card on the timeline
while a gap is open, and under Layers as "Data coverage".

Life events are entered in a sheet (title, kind, a date to the day, month
or year, an optional end, an optional note) from the timeline, the
capture picker and the dashboard's add menu; one surface line maps the
capture kind to the timeline module, and it is offered in one's own
record only. Deleting asks first.

Server routes are built in parallel; the e2e journey answers them with the
contract's shapes. Copy in all seven locales; the timeline and lifeEvents
release-pending prefixes are gone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 14:27:02 +02:00
1 parent b8f6372963
commit 32258bc5ab
50 files changed
+8277 -54

No files matched your search

@@ -235,6 +235,10 @@ function scan(): Scan {
* would make the key move when the preference did.
*/
const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
{
file: "src/components/timeline/timeline-dates.ts",
why: "`todayKeyIn` asks `en-CA` for today's `YYYY-MM-DD` key in the record's zone, to compare with the timeline's calendar keys. Never rendered; every label there is a month name.",
},
{
file: "src/app/api/measurement-reminders/[id]/snooze/route.ts",
why: "An ISO day probe split back into numbers to do day arithmetic on the postpone target. Never rendered.",
@@ -439,6 +439,15 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["AlertDialog"],
},
{
file: "components/timeline/use-timeline.ts",
destroys: "one life event with its encrypted title and note",
triggers: ["components/timeline/life-event-form.tsx"],
// The route soft-deletes; there is no restore route or surface yet.
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
},
// ── Cycle ───────────────────────────────────────────────────────────────
{
file: "components/cycle/use-cycle.ts",
@@ -80,8 +80,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
day: "v1.42 day view and its entry points (#613)",
timeline: "v1.42 timeline and its readiness inventory (#613)",
lifeEvents: "v1.42 life-event form and list (#613)",
};
/**
@@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record<
// the two status grids used to spell the tint at every call site and now
// name a tone instead, so the marker lives here once.
"src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 },
// v1.42: the readiness inventory's "carries" status icon, one arm of the
// carries / thin / empty tone table, beside its spoken status label.
"src/components/timeline/readiness-inventory.tsx": { "text-success": 1 },
"src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 },
"src/components/admin/api-token-overview-section.tsx": { "text-success": 1 },
"src/components/admin/app-log-preview-section.tsx": {
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { useAuth } from "@/hooks/use-auth";
import { TimelineView } from "@/components/timeline/timeline-view";
/**
* v1.42 (#613): the life timeline.
*
* The `timeline` module is opt-in, and its switch lives in Settings. With
* the module off the navigation entry is gone, and a deep link lands on the
* shell's module notice (`ModulePageGate`, through `nav:/timeline` in the
* surface map) instead of this page, which never mounts and so never asks
* the server, whose routes refuse on their own. An unauthenticated visitor
* is sent to login.
*
* The view reads `?day=` and `?add=` through `useSearchParams`, which needs
* a boundary on a statically rendered page.
*/
export default function TimelinePage() {
const { isLoading, isAuthenticated } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) router.push("/auth/login");
}, [isLoading, isAuthenticated, router]);
if (isLoading || !isAuthenticated) return <PageAuthGate />;
return (
<Suspense fallback={<PageAuthGate />}>
<TimelineView />
</Suspense>
);
}
@@ -155,6 +155,8 @@ describe("<DashboardHeader> — every entry follows its module, as the capture p
{ workouts: false },
{ illness: false },
{ mood: false, medications: false, workouts: false, illness: false },
{ timeline: false },
{ timeline: true },
];
for (const modules of cases) {
modulesRef.value = modules;
@@ -173,6 +175,7 @@ describe("<DashboardHeader> — every entry follows its module, as the capture p
mood: "Log mood",
symptom: "Log symptom",
workout: "Log workout",
lifeEvent: "Log life event",
} as const;
for (const kind of CAPTURE_KIND_ORDER) {
expect(
@@ -14,6 +14,7 @@ import { useMemo } from "react";
import Link from "next/link";
import {
Activity,
Flag,
Footprints,
Pill,
Plus,
@@ -72,6 +73,7 @@ export function DashboardHeader({
const canAddMood = offered.includes("mood");
const canAddSymptom = offered.includes("symptom");
const canAddWorkout = offered.includes("workout");
const canAddLifeEvent = offered.includes("lifeEvent");
const canAddAnything = offered.length > 0;
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
@@ -219,6 +221,12 @@ export function DashboardHeader({
{t("dashboard.quickAddWorkout")}
</DropdownMenuItem>
)}
{canAddLifeEvent && (
<DropdownMenuItem onClick={() => onQuickEntry("lifeEvent")}>
<Flag className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddLifeEvent")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -18,6 +18,7 @@ import {
MoodForm,
SymptomEntryForm,
} from "@/components/dashboard/quick-entry-forms.lazy";
import { LifeEventForm } from "@/components/timeline/life-event-form.lazy";
import {
AlertDialog,
AlertDialogAction,
@@ -36,7 +37,13 @@ import {
import type { ShareDomain } from "@/lib/sharing/scope";
export type QuickEntryDialog =
"measurement" | "mood" | "medicationIntake" | "symptom" | "workout" | null;
| "measurement"
| "mood"
| "medicationIntake"
| "symptom"
| "workout"
| "lifeEvent"
| null;
/**
* The section each sheet writes to; see the capture picker. `null` marks a
@@ -51,6 +58,8 @@ const QUICK_ENTRY_DOMAIN: Readonly<
mood: "mind",
symptom: "illness",
workout: null,
// v1.42 — life events are written in one's own record only.
lifeEvent: null,
};
/**
@@ -170,6 +179,8 @@ export function QuickEntrySheets({
const [symptomFooterEl, setSymptomFooterEl] = useState<HTMLDivElement | null>(
null,
);
const [lifeEventFooterEl, setLifeEventFooterEl] =
useState<HTMLDivElement | null>(null);
// v1.11.3 F3 — when an open quick-entry sheet is dismissed with
// unsaved input, hold the close in this flag and surface a confirm
// instead of nulling the dialog outright. Cleared once the user
@@ -269,6 +280,24 @@ export function QuickEntrySheets({
)}
</ResponsiveSheet>
{/* v1.42 — a life event (the opt-in timeline module). Mounted only
while open, so every opening starts from an empty draft. */}
<ResponsiveSheet
open={openSheet === "lifeEvent"}
onOpenChange={handleQuickEntryOpenChange}
title={t("lifeEvents.title")}
description={t("lifeEvents.subtitle")}
footer={<div ref={setLifeEventFooterEl} className="flex w-full" />}
>
{openSheet === "lifeEvent" && (
<LifeEventForm
onSuccess={onClose}
onCancel={onClose}
footerSlot={lifeEventFooterEl}
/>
)}
</ResponsiveSheet>
{/* v1.11.3 F3 — confirm before discarding a partly-filled
quick-entry form when the sheet is dismissed by an overlay
tap, Escape, or a mobile swipe-down. Keeps the sheet open
+30 -2
View File
@@ -1,7 +1,14 @@
"use client";
import { useState } from "react";
import { Activity, Footprints, Pill, Stethoscope, Waves } from "lucide-react";
import {
Activity,
Flag,
Footprints,
Pill,
Stethoscope,
Waves,
} from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
@@ -11,6 +18,7 @@ import {
MoodForm,
SymptomEntryForm,
} from "@/components/dashboard/quick-entry-forms.lazy";
import { LifeEventForm } from "@/components/timeline/life-event-form.lazy";
import {
AlertDialog,
AlertDialogAction,
@@ -42,6 +50,8 @@ import {
* - Workout → `<ManualWorkoutForm>` (the workouts page's "Log workout")
* - Symptom → `<SymptomEntryForm>` (one of the person's own symptoms,
* v1.40; rides the illness module)
* - Life event → `<LifeEventForm>` (v1.42; rides the opt-in timeline
* module, own record only)
*
* The picker reuses each existing capture component rather than
* rebuilding parallel forms.
@@ -58,7 +68,7 @@ import {
*/
export type CaptureKind =
"measurement" | "medication" | "mood" | "symptom" | "workout";
"measurement" | "medication" | "mood" | "symptom" | "workout" | "lifeEvent";
/**
* The section each capture surface writes to.
@@ -83,6 +93,9 @@ const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain | null>> = {
// A workout goes through the batch ingest, which resolves the caller:
// no grant reaches it, so it is offered in one's own record only.
workout: null,
// v1.42 — life events are written by the record's owner only until their
// writes join the audited delegable list; a delegate is never offered one.
lifeEvent: null,
};
/** The order the chooser lists them in. */
@@ -92,6 +105,7 @@ export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
"mood",
"symptom",
"workout",
"lifeEvent",
];
/**
@@ -231,6 +245,12 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
description: t("nav.capture.workoutDescription"),
icon: Footprints,
},
{
kind: "lifeEvent",
label: t("nav.capture.lifeEvent"),
description: t("nav.capture.lifeEventDescription"),
icon: Flag,
},
];
const options = allOptions.filter((opt) => offered.includes(opt.kind));
@@ -240,6 +260,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
mood: t("mood.addEntry"),
symptom: t("symptoms.entry.sheetTitle"),
workout: t("insights.workouts.manual.sheetTitle"),
lifeEvent: t("lifeEvents.title"),
};
// `openKind === null` keeps the form sheet closed (the title is unread
// then); the mood label is the harmless default, matching the prior
@@ -316,6 +337,13 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
footerSlot={footerEl}
/>
)}
{openKind === "lifeEvent" && (
<LifeEventForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
/>
)}
{openKind === "mood" && (
<MoodForm
onSuccess={closeForm}
@@ -33,6 +33,7 @@
* A module is enabled unless its key is explicitly `false` — default-on.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -63,6 +64,7 @@ import {
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
import { TimelineReadinessSheet } from "@/components/timeline/readiness-sheet.lazy";
import { useAuth } from "@/hooks/use-auth";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useTranslations } from "@/lib/i18n/context";
@@ -127,6 +129,10 @@ export function ModulesSection() {
const { user } = useAuth();
const coach = useAiCapability("coach");
const queryClient = useQueryClient();
// v1.42 (#613) — switching the timeline on opens its readiness inventory:
// an honest look at what it can already show, with one link per gap,
// before the person goes there. It blocks nothing.
const [readinessOpen, setReadinessOpen] = useState(false);
const modules = user?.modules ?? {};
const moduleAvailability = user?.moduleAvailability ?? {};
@@ -186,6 +192,7 @@ export function ModulesSection() {
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
}
toast.success(t("settings.sections.modules.saved"));
if (vars.key === "timeline" && vars.enabled) setReadinessOpen(true);
},
onError: (err) => {
// v1.32.22 (R5b) — instant-write disposition: a 409 means the module map
@@ -283,6 +290,10 @@ export function ModulesSection() {
);
})}
</div>
<TimelineReadinessSheet
open={readinessOpen}
onOpenChange={setReadinessOpen}
/>
</SettingsCard>
);
}
@@ -0,0 +1,167 @@
/**
* v1.42 (#613) — the phone chronicle: newest first, grouped, empty stretches
* said out loud, open things once in the Ongoing block, closed periods as
* rails.
*/
import { describe, expect, it } from "vitest";
import {
buildChronicle,
chronicleEntries,
standingChips,
type ChronicleRow,
} from "../chronicle-model";
import { TODAY, fullTimeline } from "./timeline-fixture";
function headers(rows: ChronicleRow[]) {
return rows
.filter((r) => r.type === "header")
.map((r) => (r.type === "header" ? r.group : ""));
}
describe("chronicleEntries", () => {
it("lists points, period starts and period ends, newest first", () => {
const entries = chronicleEntries(fullTimeline(), TODAY);
const dates = entries.map((e) => e.date);
expect([...dates].sort().reverse()).toEqual(dates);
const cold = entries.filter(
(e) => e.kind === "item" && e.item.id === "ill-5",
);
expect(cold.map((e) => (e.kind === "item" ? e.role : null))).toEqual([
"end",
"start",
]);
const end = cold[0];
expect(end.kind === "item" && end.days).toBe(9);
});
it("never lists anything after today", () => {
const tl = fullTimeline();
tl.lanes[0].items.push({
id: "future",
kind: "lifeEvent",
start: "2027-01-01",
end: null,
open: false,
precision: "DAY",
startKnown: true,
label: "x",
sub: null,
href: null,
});
expect(chronicleEntries(tl, TODAY).some((e) => e.date > TODAY)).toBe(false);
});
it("carries the quiet notable line", () => {
const entries = chronicleEntries(fullTimeline(), TODAY);
expect(entries.find((e) => e.kind === "notable")).toMatchObject({
date: "2026-01-04",
notable: "extremeHigh",
});
});
});
describe("buildChronicle", () => {
it("folds a run of empty months into one honest gap row", () => {
const rows = buildChronicle(fullTimeline(), 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");
expect(gap).toEqual({
type: "gap",
from: "2026-04-01",
to: "2026-10-01",
rails: {},
});
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");
// 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");
});
it("never shows two gap rows next to each other", () => {
const rows = buildChronicle(fullTimeline(), TODAY, "month");
for (let i = 1; i < rows.length; i++) {
expect(rows[i].type === "gap" && rows[i - 1].type === "gap").toBe(false);
}
});
it("groups by year when asked", () => {
const rows = buildChronicle(fullTimeline(), TODAY, "year");
expect(headers(rows)).toEqual([
"2026-01-01",
"2025-01-01",
"2024-01-01",
"2023-01-01",
"2022-01-01",
"2021-01-01",
"2020-01-01",
"2019-01-01",
// The grass-pollen allergy began in 2018.
"2018-01-01",
]);
});
it("draws a closed episode as a rail from its end row down to its start row", () => {
const rows = buildChronicle(fullTimeline(), TODAY, "month");
const railOf = (id: string, role: string) => {
const row = rows.find(
(r) =>
r.type === "entry" &&
r.entry.kind === "item" &&
r.entry.item.id === id &&
r.entry.role === role,
);
return row && row.type === "entry" ? row.rails.illness : undefined;
};
expect(railOf("ill-5", "end")).toBe("end");
expect(railOf("ill-5", "start")).toBe("start");
// The visit on 3 January sits inside the cold.
const visit = rows.find(
(r) =>
r.type === "entry" &&
r.entry.kind === "item" &&
r.entry.item.id === "v-4",
);
expect(visit?.type === "entry" && visit.rails.illness).toBe("full");
});
it("is empty for a record without anything dated", () => {
expect(
buildChronicle(
{
lanes: [],
notable: [],
range: { from: "2026-01-01", to: "2026-12-31", dataFrom: null },
},
TODAY,
"month",
),
).toEqual([]);
});
});
describe("standingChips", () => {
it("collects open things once and folds several allergies into one chip", () => {
const chips = standingChips(fullTimeline());
expect(chips.map((c) => c.id)).toEqual([
"ill-chronic",
"med-1",
"allergies",
]);
expect(chips.at(-1)).toMatchObject({ lane: "allergies", count: 2 });
});
it("lists a standing item without a start", () => {
const tl = fullTimeline();
tl.standing = [
{ lane: "illness", id: "pre", label: "Asthma", since: null, href: null },
];
expect(standingChips(tl).map((c) => c.label)).toContain("Asthma");
});
});
@@ -0,0 +1,154 @@
/**
* v1.42 (#613) — the readiness inventory's reading order and card rule, and
* the life-event form's checks and request bodies.
*/
import { describe, expect, it } from "vitest";
import type { LifeEventDTO } from "@/lib/day/contract";
import {
alignToPrecision,
createBody,
draftFromEvent,
emptyDraft,
updateBody,
validateDraft,
withPrecision,
} from "../life-event-draft";
import {
orderedReadinessLanes,
readinessGaps,
readinessTally,
showReadinessCard,
} from "../readiness-model";
import { readiness } from "./timeline-fixture";
describe("readiness", () => {
it("reads carrying lanes first, then thin, then empty", () => {
expect(orderedReadinessLanes(readiness()).map((l) => l.status)).toEqual([
"carries",
"carries",
"carries",
"thin",
"empty",
"empty",
]);
});
it("offers one link per gap, in reading order", () => {
expect(readinessGaps(readiness()).map((g) => g.key)).toEqual([
"medicationsWithoutStart",
"lifeEventsEmpty",
"vaccinationsEmpty",
]);
});
it("counts the carrying lanes, never a score", () => {
expect(readinessTally(readiness())).toEqual({ carrying: 3, total: 6 });
});
it("shows the card while a thin or empty lane has a link, until closed", () => {
expect(showReadinessCard(readiness(), false)).toBe(true);
expect(showReadinessCard(readiness(), true)).toBe(false);
expect(showReadinessCard(undefined, false)).toBe(false);
const allCarry = readiness({
lanes: readiness().lanes.map((l) => ({
...l,
status: "carries" as const,
gaps: [],
})),
});
expect(showReadinessCard(allCarry, false)).toBe(false);
});
});
const STORED: LifeEventDTO = {
id: "le-1",
category: "HOME",
startDate: "2023-09-01",
endDate: null,
precision: "MONTH",
title: "Umzug",
note: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
describe("life-event draft", () => {
it("asks for a title and a kind before anything is sent", () => {
expect(validateDraft(emptyDraft("2026-10-07"))).toEqual({
title: "titleRequired",
category: "categoryRequired",
});
});
it("refuses an end before the start and an over-long title", () => {
const draft = {
...emptyDraft("2026-10-07"),
title: "x".repeat(121),
category: "WORK" as const,
end: "2026-01-01",
};
expect(validateDraft(draft)).toEqual({
title: "titleTooLong",
end: "endBeforeStart",
});
});
it("refuses a date that is not a calendar date", () => {
const draft = {
...emptyDraft("2026-02-30"),
title: "a",
category: "OTHER" as const,
};
expect(validateDraft(draft).start).toBe("dateRequired");
});
it("aligns both ends when the precision changes", () => {
expect(alignToPrecision("2023-09-17", "MONTH")).toBe("2023-09-01");
expect(alignToPrecision("2023-09-17", "YEAR")).toBe("2023-01-01");
const draft = withPrecision(
{ ...emptyDraft("2023-09-17"), end: "2024-02-11" },
"MONTH",
);
expect([draft.start, draft.end]).toEqual(["2023-09-01", "2024-02-01"]);
});
it("sends a trimmed body, a blank note as null", () => {
const body = createBody({
...emptyDraft("2023-09-17"),
title: " Umzug nach Bochum ",
category: "HOME",
precision: "MONTH",
note: " ",
});
expect(body).toEqual({
category: "HOME",
precision: "MONTH",
startDate: "2023-09-01",
endDate: null,
title: "Umzug nach Bochum",
note: null,
});
});
it("patches only what changed, dates and precision together", () => {
const draft = draftFromEvent(STORED);
expect(updateBody(STORED, draft)).toEqual({});
expect(
updateBody(STORED, { ...draft, title: "Umzug nach Bochum" }),
).toEqual({
title: "Umzug nach Bochum",
});
expect(updateBody(STORED, withPrecision(draft, "YEAR"))).toEqual({
precision: "YEAR",
startDate: "2023-01-01",
endDate: null,
});
expect(
updateBody({ ...STORED, note: "alt" }, { ...draft, note: "" }),
).toEqual({
note: null,
});
});
});
@@ -0,0 +1,163 @@
/**
* v1.42 (#613) — the timeline's surfaces as rendered: the readiness
* inventory in its three states, the card, the phone chronicle's honest
* gaps and the selection bar's hand-off to the day, and the capture-picker
* gate for life events.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
CAPTURE_KIND_ORDER,
visibleCaptureKinds,
} from "@/components/layout/capture-picker";
import { I18nProvider } from "@/lib/i18n/context";
import { ReadinessCard } from "../readiness-card";
import { ReadinessInventory, VerdictMeter } from "../readiness-inventory";
import { SelectionBar } from "../selection-bar";
import { TimelineChronicle } from "../timeline-chronicle";
import { TODAY, fullTimeline, readiness } from "./timeline-fixture";
function render(node: React.ReactElement, locale: "de" | "en" = "de") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("readiness inventory", () => {
it("names every lane with its state and one link per gap", () => {
const html = render(<ReadinessInventory readiness={readiness()} />);
expect(html.match(/data-slot="timeline-readiness-lane"/g)).toHaveLength(6);
expect(html).toContain('data-status="carries"');
expect(html).toContain('data-status="thin"');
expect(html).toContain('data-status="empty"');
expect(html).toContain("6 Episoden, davon 1 chronisch");
expect(html).toContain("5 Medikamente, 3 ohne Startdatum");
expect(html).toContain("3 Messgrößen seit März 2019");
expect(html.match(/data-slot="timeline-readiness-gap"/g)).toHaveLength(3);
expect(html).toContain('href="/medications/med-1?edit=1"');
expect(html).toContain("Impfpass erfassen");
});
it("leaves out a detail the bundle cannot word instead of showing a raw key", () => {
const html = render(
<ReadinessInventory
readiness={readiness({
lanes: [
{
key: "labs",
status: "carries",
count: 2,
detail: { key: "somethingNew", params: { count: 2 } },
gaps: [{ key: "unknownGap", count: 0, href: "/labs" }],
},
],
})}
/>,
);
expect(html).not.toContain("somethingNew");
expect(html).not.toContain("unknownGap");
expect(html).not.toContain('data-slot="timeline-readiness-gap"');
});
it("states the verdict as a count, never a score", () => {
const carries = render(<VerdictMeter readiness={readiness()} />);
expect(carries).toContain("Trägt schon.");
expect(carries).toContain(
"3 von 6 Spuren reichen für einen Überblick ab März 2019.",
);
expect(carries).toContain('aria-label="3 von 6 Spuren tragen"');
const thin = render(
<VerdictMeter readiness={readiness({ verdict: "thin", since: null })} />,
);
expect(thin).toContain("Noch dünn.");
expect(thin).not.toContain("Überblick ab");
});
it("puts the gaps on the timeline card with their lines", () => {
const html = render(
<ReadinessCard readiness={readiness()} onDismiss={() => undefined} />,
);
expect(html).toContain("3 Medikamente ohne Startdatum");
expect(html).toContain("Noch keine Lebensereignisse");
expect(html).toContain('href="/timeline?add=lifeEvent"');
expect(html).toContain('data-slot="timeline-readiness-card-dismiss"');
});
});
describe("chronicle", () => {
it("names an empty stretch instead of skipping it, and folds the ongoing items", () => {
const html = render(
<TimelineChronicle
timeline={fullTimeline()}
today={TODAY}
grouping="month"
selected="2026-01-03"
onOpenDay={() => undefined}
onEditLifeEvent={null}
/>,
);
expect(html).toContain("April bis Oktober 2026 · keine Einträge");
expect(html).toContain("Dauerhaft");
expect(html).toContain("2 Allergien");
expect(html).toContain("Erkältung vorbei");
expect(html).toContain("nach 9 Tagen");
expect(html).toContain("Höchster Wert seit Langem");
// The selected day is the highlighted row.
expect(html).toMatch(/data-date="2026-01-03"><button[^>]*bg-muted/);
});
it("shows the month's means beside the month", () => {
const html = render(
<TimelineChronicle
timeline={fullTimeline()}
today={TODAY}
grouping="month"
selected={null}
onOpenDay={() => undefined}
onEditLifeEvent={null}
/>,
);
expect(html).toContain("129/82 mmHg · 82,6 kg");
});
});
describe("selection bar", () => {
it("lists the month's entries and offers the selected day", () => {
const html = render(
<SelectionBar
timeline={fullTimeline()}
selected="2026-01-03"
today={TODAY}
onOpenDay={() => undefined}
/>,
);
expect(html).toContain("Januar 2026");
expect(html).toContain("Erkältung · 31. Dez. bis 8. Jan.");
expect(html).toContain("Vitamin D (Winter) · laufend");
expect(html).not.toContain("Bluthochdruck");
expect(html).toContain("3. Jan. öffnen");
expect(html).toContain('data-date="2026-01-03"');
expect(html).toContain("Ø 129/82 mmHg · 82,6 kg");
});
});
describe("life events in the capture picker", () => {
const own = { inSharedRecord: false, canWriteDomain: () => true };
it("is offered only with the timeline module on", () => {
expect(
visibleCaptureKinds(own, CAPTURE_KIND_ORDER, { timeline: false }),
).not.toContain("lifeEvent");
expect(
visibleCaptureKinds(own, CAPTURE_KIND_ORDER, { timeline: true }),
).toContain("lifeEvent");
});
it("is never offered inside somebody else's record", () => {
const delegate = { inSharedRecord: true, canWriteDomain: () => true };
expect(
visibleCaptureKinds(delegate, CAPTURE_KIND_ORDER, { timeline: true }),
).not.toContain("lifeEvent");
});
});
@@ -0,0 +1,329 @@
/**
* A made-up record for the timeline tests: a chronic condition and several
* episodes, two allergies, a standing medication with a dose change, a winter
* course three years running, a medication whose start is unknown, visits
* with two procedures, documents, vaccinations and three life events. All
* data invented.
*/
import type {
TimelineItem,
TimelineReadinessResponse,
TimelineResponse,
} from "@/lib/day/contract";
export function item(
over: Partial<TimelineItem> & Pick<TimelineItem, "id" | "kind" | "start">,
): TimelineItem {
return {
end: null,
open: false,
precision: "DAY",
startKnown: true,
label: over.id,
sub: null,
href: null,
...over,
};
}
export const TODAY = "2026-10-07";
export function fullTimeline(): TimelineResponse {
return {
zoom: "all",
range: { from: "2019-01-01", to: "2026-12-31", dataFrom: "2019-01-01" },
lanes: [
{
key: "life",
items: [
item({
id: "le-1",
kind: "lifeEvent",
start: "2021-05-14",
label: "Geburt Tochter",
}),
item({
id: "le-2",
kind: "lifeEvent",
start: "2023-09-01",
precision: "MONTH",
label: "Umzug",
}),
item({
id: "le-3",
kind: "lifeEvent",
start: "2024-10-01",
label: "Neue Stelle",
}),
],
},
{
key: "illness",
items: [
item({
id: "ill-chronic",
kind: "chronic",
start: "2019-03-10",
open: true,
label: "Bluthochdruck",
sub: "chronisch",
}),
item({
id: "ill-1",
kind: "episode",
start: "2020-09-01",
end: "2020-10-20",
label: "Schulter",
}),
item({
id: "ill-2",
kind: "episode",
start: "2022-03-08",
end: "2022-03-24",
label: "COVID-19",
}),
item({
id: "ill-3",
kind: "episode",
start: "2022-11-02",
end: "2023-02-15",
label: "Bandscheibe L4/L5",
}),
item({
id: "ill-4",
kind: "episode",
start: "2024-08-10",
end: "2024-08-16",
label: "Magen-Darm",
}),
item({
id: "ill-5",
kind: "episode",
start: "2025-12-31",
end: "2026-01-08",
label: "Erkältung",
}),
],
},
{
key: "allergies",
items: [
item({
id: "al-1",
kind: "allergy",
start: "2018-01-01",
open: true,
label: "Gräserpollen",
}),
item({
id: "al-2",
kind: "allergy",
start: "2020-07-15",
open: true,
label: "Penicillin",
}),
],
},
{
key: "medications",
items: [
item({
id: "med-1",
kind: "medication",
start: "2019-04-02",
open: true,
label: "Ramipril",
sub: "2,5 mg",
}),
item({
id: "dose-1",
kind: "doseChange",
start: "2020-08-12",
label: "Ramipril",
sub: "5 mg",
}),
item({
id: "med-2",
kind: "medication",
start: "2022-11-03",
end: "2022-12-10",
label: "Ibuprofen",
}),
item({
id: "course-1",
kind: "course",
start: "2023-11-01",
end: "2024-03-31",
label: "Vitamin D (Winter)",
}),
item({
id: "course-2",
kind: "course",
start: "2024-11-01",
end: "2025-03-31",
label: "Vitamin D (Winter)",
}),
item({
id: "course-3",
kind: "course",
start: "2025-11-01",
end: "2026-03-31",
label: "Vitamin D (Winter)",
}),
item({
id: "pause-1",
kind: "pause",
start: "2021-06-01",
end: "2021-06-20",
label: "Pause",
}),
],
},
{
key: "vaccinations",
items: [
"2020-06-10",
"2021-05-20",
"2021-07-01",
"2022-10-15",
"2024-10-18",
"2025-10-10",
].map((d, i) =>
item({
id: `vac-${i}`,
kind: "vaccination",
start: d,
label: "Impfung",
}),
),
},
{
key: "visits",
items: [
item({
id: "v-1",
kind: "visit",
start: "2019-03-10",
label: "Hausarztpraxis",
}),
item({
id: "v-2",
kind: "procedure",
start: "2022-11-08",
label: "MRT Lendenwirbelsäule",
}),
item({
id: "v-3",
kind: "procedure",
start: "2025-03-12",
label: "Darmspiegelung",
}),
item({
id: "v-4",
kind: "visit",
start: "2026-01-03",
label: "Hausarztpraxis",
}),
],
},
{ key: "labs", items: [] },
{
key: "documents",
items: ["2019-03-12", "2022-11-09", "2026-01-03"].map((d, i) =>
item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }),
),
},
],
standing: [],
series: [
{
key: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
granularity: "month",
points: [
{ t: "2025-10-01", mean: 127 },
{ t: "2025-11-01", mean: 128 },
{ t: "2025-12-01", mean: 130 },
{ t: "2026-01-01", mean: 129 },
// Nothing for February to June.
{ t: "2026-07-01", mean: 131 },
],
},
{
key: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
granularity: "month",
points: [{ t: "2026-01-01", mean: 82 }],
},
{
key: "WEIGHT",
unit: "kg",
granularity: "month",
points: [{ t: "2026-01-01", mean: 82.6 }],
},
],
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
export function readiness(
over: Partial<TimelineReadinessResponse> = {},
): TimelineReadinessResponse {
return {
verdict: "carries",
since: "2019-03-01",
lanes: [
{
key: "values",
status: "carries",
count: 3,
detail: { key: "values", params: { count: 3, since: "2019-03-01" } },
gaps: [],
},
{
key: "life",
status: "empty",
count: 0,
detail: { key: "emptyLife", params: {} },
gaps: [
{ key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" },
],
},
{
key: "illness",
status: "carries",
count: 6,
detail: { key: "illness", params: { count: 6, chronic: 1 } },
gaps: [],
},
{
key: "medications",
status: "thin",
count: 5,
detail: { key: "medications", params: { count: 5, missingStart: 3 } },
gaps: [
{
key: "medicationsWithoutStart",
count: 3,
href: "/medications/med-1?edit=1",
},
],
},
{
key: "vaccinations",
status: "empty",
count: 0,
detail: { key: "empty", params: {} },
gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }],
},
{
key: "visits",
status: "carries",
count: 11,
detail: { key: "visits", params: { count: 11, procedures: 2 } },
gaps: [],
},
],
...over,
};
}
@@ -0,0 +1,421 @@
/**
* v1.42 (#613) — the timeline's geometry: window, scale, grid, rows, labels
* and value lines. The renderer paints exactly this, so the promises the
* chart makes (no label on top of another, nothing past the card edge, an
* unknown start says so) are pinned here.
*/
import { describe, expect, it } from "vitest";
import {
createScale,
dateAtPointer,
estimateTextWidth,
fitText,
gridTicks,
itemLabel,
itemsInMonth,
layoutLane,
layoutSeries,
layoutTimeline,
stepSelection,
todayLabelFits,
windowFor,
wrapToWidth,
type LaneLayout,
type TimelineLayout,
} from "../timeline-geometry";
import { dayNumber } from "../timeline-dates";
import { TODAY, fullTimeline, item } from "./timeline-fixture";
const FMT = {
year: (k: string) => k.slice(0, 4),
monthShort: (k: string) => k.slice(5, 7),
monthYear: (k: string) => `Monat ${k.slice(0, 7)}`,
};
function layoutAt(width: number): TimelineLayout {
const tl = fullTimeline();
return layoutTimeline({
width,
window: windowFor("all", TODAY, null, tl.range),
lanes: tl.lanes,
series: tl.series,
startMissing: "Startdatum fehlt",
today: TODAY,
});
}
/** Every label's box, by the same estimate the layout uses. */
function boxes(lane: LaneLayout) {
return lane.labels.map((l) => ({
...l,
a: l.x,
b: l.x + estimateTextWidth(l.text),
}));
}
describe("text fitting", () => {
it("cuts a name to its column with an ellipsis", () => {
expect(fitText("Arzt und Eingriffe", 200, 13)).toBe("Arzt und Eingriffe");
const cut = fitText("Arzt und Eingriffe", 60, 13);
expect(cut.endsWith("…")).toBe(true);
expect(estimateTextWidth(cut, 13)).toBeLessThanOrEqual(60);
});
it("wraps a note onto lines that fit, or drops it when a word cannot", () => {
expect(wrapToWidth("Noch keine Daten", 200)).toEqual(["Noch keine Daten"]);
expect(wrapToWidth("Noch keine Daten", 70)).toEqual([
"Noch keine",
"Daten",
]);
expect(wrapToWidth("Noch keine Daten", 20)).toEqual([]);
});
});
describe("windowFor", () => {
it("follows the server range at `all`", () => {
expect(
windowFor("all", TODAY, null, { from: "2019-01-01", to: "2026-12-31" }),
).toEqual({ from: "2019-01-01", to: "2026-12-31" });
});
it("shows twelve whole months that never run past this month", () => {
expect(windowFor("year", TODAY, null)).toEqual({
from: "2025-11-01",
to: "2026-10-31",
});
// An older selection stays in view, with months on both sides.
expect(windowFor("year", TODAY, "2026-01-03")).toEqual({
from: "2025-07-01",
to: "2026-06-30",
});
});
it("shows three months around the selection at `quarter`", () => {
expect(windowFor("quarter", TODAY, "2026-01-03")).toEqual({
from: "2025-12-01",
to: "2026-02-28",
});
expect(windowFor("quarter", TODAY, null)).toEqual({
from: "2026-08-01",
to: "2026-10-31",
});
});
it("ignores a selection in the future", () => {
expect(windowFor("quarter", TODAY, "2027-05-01")).toEqual(
windowFor("quarter", TODAY, null),
);
});
});
describe("createScale", () => {
const scale = createScale({ from: "2026-01-01", to: "2026-12-31" }, 100, 465);
it("maps the window onto the drawing width, one day per step", () => {
expect(scale.x("2026-01-01")).toBe(100);
expect(scale.x(dayNumber("2026-12-31") + 1)).toBe(465);
expect(scale.x("2026-01-02") - scale.x("2026-01-01")).toBeCloseTo(1, 6);
});
it("reads a day back from x, clamped into the window", () => {
expect(scale.dayAt(scale.xMid("2026-03-15"))).toBe("2026-03-15");
expect(scale.dayAt(0)).toBe("2026-01-01");
expect(scale.dayAt(10_000)).toBe("2026-12-31");
});
});
describe("gridTicks", () => {
it("labels years at `all` and drops labels that would touch", () => {
const window = { from: "2019-01-01", to: "2026-12-31" };
const wide = gridTicks(window, "all", createScale(window, 164, 1100), FMT);
expect(wide.filter((t) => t.label).map((t) => t.label)).toEqual([
"2019",
"2020",
"2021",
"2022",
"2023",
"2024",
"2025",
"2026",
]);
const narrow = gridTicks(window, "all", createScale(window, 140, 330), FMT);
const labelled = narrow.filter((t) => t.label);
expect(labelled.length).toBeLessThan(8);
for (let i = 1; i < labelled.length; i++) {
const prevEnd =
labelled[i - 1].x + 6 + estimateTextWidth(labelled[i - 1].label!, 12);
expect(labelled[i].x + 6).toBeGreaterThanOrEqual(prevEnd);
}
});
it("lets the axis labels win over the Today label", () => {
const window = { from: "2019-01-01", to: "2026-12-31" };
const scale = createScale(window, 164, 1100);
const ticks = gridTicks(window, "all", scale, FMT);
// Early October: clear of the 2026 label, room for "Heute".
expect(todayLabelFits(ticks, scale.xMid("2026-10-07"), 30, 164)).toBe(true);
// Mid January: right on top of the 2026 label.
expect(todayLabelFits(ticks, scale.xMid("2026-01-20"), 30, 164)).toBe(
false,
);
});
it("draws months at `year` and weekly lines at `quarter`", () => {
const year = { from: "2025-11-01", to: "2026-10-31" };
expect(
gridTicks(year, "year", createScale(year, 164, 1200), FMT),
).toHaveLength(12);
const quarter = { from: "2025-12-01", to: "2026-02-28" };
const ticks = gridTicks(
quarter,
"quarter",
createScale(quarter, 164, 1200),
FMT,
);
expect(ticks.filter((t) => t.major)).toHaveLength(3);
expect(ticks.filter((t) => !t.major).length).toBeGreaterThanOrEqual(11);
});
});
describe("layoutLane", () => {
const window = { from: "2019-01-01", to: "2026-12-31" };
const scale = createScale(window, 164, 1200);
const opts = { startMissing: "Startdatum fehlt", today: TODAY };
it("puts overlapping periods on separate rows and lets later ones reuse a free row", () => {
const lane = fullTimeline().lanes.find((l) => l.key === "illness")!;
const layout = layoutLane({ lane, top: 0 }, window, scale, opts);
const chronic = layout.spans.find((s) => s.item.id === "ill-chronic")!;
const episodes = layout.spans.filter((s) => s.item.kind === "episode");
expect(chronic.row).toBe(0);
expect(new Set(episodes.map((s) => s.row))).toEqual(new Set([1]));
expect(layout.rows).toBe(2);
});
it("lays a dose change and a pause over the medication they belong to", () => {
const lane = fullTimeline().lanes.find((l) => l.key === "medications")!;
const layout = layoutLane({ lane, top: 0 }, window, scale, opts);
const host = layout.spans.find((s) => s.item.id === "med-1")!;
expect(layout.points.find((p) => p.item.id === "dose-1")?.row).toBe(
host.row,
);
const pause = layout.spans.find((s) => s.item.id === "pause-1")!;
expect(pause.pause).toBe(true);
expect(pause.row).toBe(host.row);
});
it("starts an open period at its start and runs it to today", () => {
const lane = fullTimeline().lanes.find((l) => l.key === "allergies")!;
const layout = layoutLane({ lane, top: 0 }, window, scale, opts);
const grass = layout.spans.find((s) => s.item.id === "al-1")!;
expect(grass.clippedLeft).toBe(true);
expect(grass.xStart).toBe(164);
expect(grass.xEnd).toBeCloseTo(scale.x(dayNumber(TODAY) + 1), 6);
});
it("names an unknown start in the label", () => {
const unknown = item({
id: "m",
kind: "medication",
start: "2024-05-01",
open: true,
startKnown: false,
label: "Ramipril",
sub: "5 mg",
});
expect(itemLabel(unknown, "Startdatum fehlt")).toBe(
"Ramipril · 5 mg · Startdatum fehlt",
);
const layout = layoutLane(
{ lane: { key: "medications", items: [unknown] }, top: 0 },
window,
scale,
opts,
);
expect(layout.labels[0].text).toContain("Startdatum fehlt");
});
it("names life events beside their diamonds, clear of the glyph", () => {
const lane = fullTimeline().lanes.find((l) => l.key === "life")!;
const layout = layoutLane({ lane, top: 0 }, window, scale, opts);
expect(layout.labels.map((l) => l.text)).toEqual([
"Geburt Tochter",
"Umzug",
"Neue Stelle",
]);
for (const label of layout.labels) {
const point = layout.points.find((p) => p.item.id === label.itemId)!;
expect(label.x).toBeGreaterThan(point.x + 7);
expect(label.strong).toBe(true);
}
});
it("puts procedures on their own row below the plain visits", () => {
const lane = fullTimeline().lanes.find((l) => l.key === "visits")!;
const layout = layoutLane({ lane, top: 0 }, window, scale, opts);
expect(
layout.points
.filter((p) => p.item.kind === "visit")
.every((p) => p.row === 0),
).toBe(true);
expect(
layout.points
.filter((p) => p.item.kind === "procedure")
.every((p) => p.row === 1),
).toBe(true);
expect(layout.labels.map((l) => l.text)).toContain("Darmspiegelung");
});
});
describe("label collision avoidance", () => {
// 1440 full, 1440 with the docked day panel (~700), a laptop and a
// narrow tablet column.
for (const width of [1180, 940, 700, 520]) {
it(`never overlaps two labels or runs past the edge at ${width} px`, () => {
const layout = layoutAt(width);
for (const lane of layout.lanes) {
const list = boxes(lane);
for (const box of list) {
expect(box.a).toBeGreaterThanOrEqual(layout.scale.x0);
expect(box.b).toBeLessThanOrEqual(layout.scale.x1);
}
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
const a = list[i];
const b = list[j];
if (a.y !== b.y) continue;
const overlap = a.a < b.b && b.a < a.b;
expect(overlap, `${a.text} / ${b.text}`).toBe(false);
}
}
}
});
}
it("never puts a label over a bar of its own row", () => {
const layout = layoutAt(940);
for (const lane of layout.lanes) {
for (const label of lane.labels) {
// Labels on a bar's line sit at y + 4; above-labels at y - 7.
const onLine = lane.spans.filter((s) => s.y + 4 === label.y);
for (const s of onLine) {
const b = label.x + estimateTextWidth(label.text);
const overlap = label.x < s.xEnd && s.xStart < b;
expect(overlap, `${label.text} over ${s.item.label}`).toBe(false);
}
}
}
});
it("drops labels rather than squeezing them when the panel narrows the card", () => {
const wide = layoutAt(1180).lanes.reduce((n, l) => n + l.labels.length, 0);
const narrow = layoutAt(520).lanes.reduce((n, l) => n + l.labels.length, 0);
expect(narrow).toBeLessThan(wide);
expect(narrow).toBeGreaterThan(0);
});
});
describe("layoutTimeline", () => {
it("leaves an empty lane out entirely", () => {
const layout = layoutAt(1180);
expect(layout.lanes.map((l) => l.key)).not.toContain("labs");
expect(layout.lanes.map((l) => l.key)).toEqual([
"life",
"illness",
"allergies",
"medications",
"vaccinations",
"visits",
"documents",
]);
});
it("stacks lanes without gaps and puts the value lines below", () => {
const layout = layoutAt(1180);
for (let i = 1; i < layout.lanes.length; i++) {
expect(layout.lanes[i].top).toBe(
layout.lanes[i - 1].top + layout.lanes[i - 1].height,
);
}
const last = layout.lanes.at(-1)!;
expect(layout.seriesTop).toBeGreaterThan(last.top + last.height);
expect(layout.series).toHaveLength(3);
});
});
describe("layoutSeries", () => {
it("breaks the line across missing months instead of bridging them", () => {
const window = { from: "2025-10-01", to: "2026-10-31" };
const scale = createScale(window, 164, 1200);
const [bp] = layoutSeries(
fullTimeline().series.slice(0, 1),
0,
window,
scale,
);
expect(bp.path.match(/M/g)).toHaveLength(2);
expect(bp.latest).toBe(131);
});
it("keeps an empty series as a row without a line", () => {
const window = { from: "2019-01-01", to: "2019-12-31" };
const scale = createScale(window, 164, 1200);
const [bp] = layoutSeries(
fullTimeline().series.slice(0, 1),
0,
window,
scale,
);
expect(bp.path).toBe("");
expect(bp.latest).toBeNull();
});
});
describe("interaction", () => {
it("snaps a click near a mark to the mark's date", () => {
const layout = layoutAt(1180);
const visits = layout.lanes.find((l) => l.key === "visits")!;
const visit = visits.points.find((p) => p.item.id === "v-4")!;
expect(dateAtPointer(layout, visit.x + 3, visit.y + 2)).toBe("2026-01-03");
});
it("falls back to the day under the pointer", () => {
const layout = layoutAt(1180);
const x = layout.scale.xMid("2023-06-15");
expect(dateAtPointer(layout, x, layout.height - 2)).toBe("2023-06-15");
});
it("steps a day, a month or a year with the zoom, never into the future", () => {
expect(stepSelection("2026-01-03", 1, "quarter", TODAY, "2019-01-01")).toBe(
"2026-01-04",
);
expect(stepSelection("2026-01-03", -1, "year", TODAY, "2019-01-01")).toBe(
"2025-12-03",
);
expect(stepSelection("2026-01-03", -1, "all", TODAY, "2019-01-01")).toBe(
"2025-01-03",
);
expect(stepSelection("2026-10-01", 1, "year", TODAY, "2019-01-01")).toBe(
TODAY,
);
expect(stepSelection("2019-02-01", -1, "all", TODAY, "2019-01-01")).toBe(
"2019-01-01",
);
});
it("names what happens, starts, ends or runs through the month, not the backdrop", () => {
const entries = itemsInMonth(fullTimeline().lanes, "2026-01-03", TODAY);
const ids = entries.map((e) => e.item.id);
expect(ids).toEqual(["ill-5", "course-3", "v-4"]);
// The winter course neither starts nor ends in January: it runs through.
expect(entries.find((e) => e.item.id === "course-3")?.through).toBe(true);
expect(entries.find((e) => e.item.id === "ill-5")?.through).toBe(false);
// Open since 2019 and 2018: the backdrop of every month, left out.
expect(ids).not.toContain("ill-chronic");
expect(ids).not.toContain("al-1");
// An open period that starts in the month is named.
const march2019 = itemsInMonth(fullTimeline().lanes, "2019-03-15", TODAY);
expect(march2019.map((e) => e.item.id)).toContain("ill-chronic");
});
});
@@ -0,0 +1,25 @@
/**
* v1.42 (#613) — the timeline's value selector names each option's owning
* module itself, to keep the signal registry out of its chunk. It must give
* the same answer the registry gives, or a switched-off module's values
* would still be offered.
*/
import { describe, expect, it } from "vitest";
import type { MeasurementType } from "@/generated/prisma/client";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { VALUE_OPTION_MODULE } from "../timeline-view";
describe("value options", () => {
for (const [key, owner] of Object.entries(VALUE_OPTION_MODULE)) {
if (key === "MOOD") continue;
it(`${key} is owned by ${owner ?? "no module"}`, () => {
expect(moduleForMeasurementType(key as MeasurementType)).toBe(owner);
});
}
it("ties mood to the mood module", () => {
expect(VALUE_OPTION_MODULE.MOOD).toBe("mood");
});
});
+303
View File
@@ -0,0 +1,303 @@
/**
* The phone chronicle, as data (v1.42, #613).
*
* Below 768 px the timeline is not squeezed: it becomes a list, newest first,
* grouped by month (or by year), which is also the list form of the chart for
* a screen reader. What runs for years without a start that matters (a
* chronic condition, a standing medication, an allergy) sits once in the
* "Ongoing" block above the list instead of repeating in every month. What
* runs for a while and ends (an episode, a winter course) is drawn as a thin
* rail beside the rows it spans.
*
* Empty stretches are said out loud: "April to August 2025 · no entries"
* rather than months silently skipped, so a gap reads as a gap and not as a
* list that forgot something.
*/
import type {
DayNotableKind,
TimelineItem,
TimelineLane,
TimelineLaneKey,
TimelineResponse,
} from "@/lib/day/contract";
import {
dayNumber,
endOfMonth,
monthsBetween,
startOfMonth,
} from "./timeline-dates";
import { LANE_ORDER, isSpan } from "./timeline-geometry";
export type ChronicleGrouping = "month" | "year";
/** One line in the chronicle. */
export type ChronicleEntry =
| {
kind: "item";
date: string;
lane: TimelineLaneKey;
item: TimelineItem;
/** A point, the start of a period, or its end. */
role: "point" | "start" | "end";
/** Length of a closed period in days, on its end line. */
days: number | null;
}
| { kind: "notable"; date: string; notable: DayNotableKind };
/** Lanes whose closed periods are drawn as rails. */
export const RAIL_LANES = ["illness", "medications"] as const;
export type RailLane = (typeof RAIL_LANES)[number];
/**
* How a rail passes a row. The list runs newest first, so a period's end is
* the upper of its two rows: on the end row the rail runs from the middle
* down (`end`), on the start row from the top to the middle (`start`).
*/
export type RailMode = "full" | "start" | "end" | "point";
export type ChronicleRails = Partial<Record<RailLane, RailMode>>;
export type ChronicleRow =
| { type: "header"; group: string; rails: ChronicleRails }
| { type: "entry"; entry: ChronicleEntry; rails: ChronicleRails }
| { type: "gap"; from: string; to: string; rails: ChronicleRails };
export interface StandingChip {
lane: TimelineLaneKey;
id: string;
label: string;
/** More than one of the lane, folded into one chip ("2 allergies"). */
count: number;
}
/** The group a date belongs to: `YYYY-MM-01` or `YYYY-01-01`. */
export function groupOf(date: string, grouping: ChronicleGrouping): string {
return grouping === "month"
? startOfMonth(date)
: `${date.slice(0, 4)}-01-01`;
}
function groupEnd(group: string, grouping: ChronicleGrouping): string {
return grouping === "month"
? endOfMonth(group)
: `${group.slice(0, 4)}-12-31`;
}
function groupsBetween(
from: string,
to: string,
grouping: ChronicleGrouping,
): string[] {
if (grouping === "month") return monthsBetween(from, to).reverse();
const out: string[] = [];
for (let y = Number(to.slice(0, 4)); y >= Number(from.slice(0, 4)); y--) {
out.push(`${y}-01-01`);
}
return out;
}
/**
* The Ongoing block: what has no start (`standing`) and every period still
* open. Allergies fold into one chip when there is more than one.
*/
export function standingChips(
timeline: Pick<TimelineResponse, "lanes" | "standing">,
): StandingChip[] {
const chips: StandingChip[] = [];
const allergies: StandingChip[] = [];
const push = (chip: StandingChip) =>
chip.lane === "allergies" ? allergies.push(chip) : chips.push(chip);
for (const s of timeline.standing) {
push({ lane: s.lane, id: s.id, label: s.label, count: 1 });
}
for (const key of LANE_ORDER) {
if (key === "life" || key === "cycle") continue;
const lane = timeline.lanes.find((l) => l.key === key);
for (const item of lane?.items ?? []) {
if (item.open)
push({ lane: key, id: item.id, label: item.label, count: 1 });
}
}
const ordered = chips.sort(
(a, b) => LANE_ORDER.indexOf(a.lane) - LANE_ORDER.indexOf(b.lane),
);
if (allergies.length === 1) ordered.push(allergies[0]);
if (allergies.length > 1) {
ordered.push({
lane: "allergies",
id: "allergies",
label: "",
count: allergies.length,
});
}
return ordered;
}
/** Every dated line, newest first. */
export function chronicleEntries(
timeline: Pick<TimelineResponse, "lanes" | "notable">,
today: string,
): ChronicleEntry[] {
const out: ChronicleEntry[] = [];
for (const lane of timeline.lanes) {
for (const item of lane.items) {
if (!isSpan(item)) {
out.push({
kind: "item",
date: item.start,
lane: lane.key,
item,
role: "point",
days: null,
});
continue;
}
out.push({
kind: "item",
date: item.start,
lane: lane.key,
item,
role: "start",
days: null,
});
if (!item.open && item.end && item.end <= today) {
out.push({
kind: "item",
date: item.end,
lane: lane.key,
item,
role: "end",
days: dayNumber(item.end) - dayNumber(item.start) + 1,
});
}
}
}
for (const n of timeline.notable) {
out.push({ kind: "notable", date: n.date, notable: n.kind });
}
const laneRank = (e: ChronicleEntry) =>
e.kind === "item" ? LANE_ORDER.indexOf(e.lane) : LANE_ORDER.length;
return out
.filter((e) => e.date <= today)
.sort((a, b) =>
a.date !== b.date
? a.date < b.date
? 1
: -1
: laneRank(a) - laneRank(b),
);
}
/** Closed periods of the rail lanes. */
function railSpans(lanes: readonly TimelineLane[]) {
const out: Array<{ lane: RailLane; start: string; end: string }> = [];
for (const lane of lanes) {
if (!(RAIL_LANES as readonly string[]).includes(lane.key)) continue;
for (const item of lane.items) {
if (item.open || !item.end || item.end === item.start) continue;
out.push({
lane: lane.key as RailLane,
start: item.start,
end: item.end,
});
}
}
return out;
}
/** How every rail passes a row dated `date`. */
export function railsAt(
spans: ReturnType<typeof railSpans>,
date: string,
): ChronicleRails {
const rails: ChronicleRails = {};
for (const lane of RAIL_LANES) {
let upper = false;
let lower = false;
for (const s of spans) {
if (s.lane !== lane || date < s.start || date > s.end) continue;
// The period continues to newer rows (above) unless it ends here, and
// to older rows (below) unless it starts here.
if (date < s.end) upper = true;
if (date > s.start) lower = true;
}
const touches = spans.some(
(s) => s.lane === lane && date >= s.start && date <= s.end,
);
if (!touches) continue;
rails[lane] =
upper && lower ? "full" : upper ? "start" : lower ? "end" : "point";
}
return rails;
}
/**
* 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">,
today: string,
grouping: ChronicleGrouping,
): ChronicleRow[] {
const entries = chronicleEntries(timeline, today);
const spans = railSpans(timeline.lanes);
const oldest = [
entries.at(-1)?.date,
timeline.range.dataFrom ?? undefined,
].filter((d): d is string => !!d && d <= today);
if (oldest.length === 0) return [];
const first = oldest.sort()[0];
const byGroup = new Map<string, ChronicleEntry[]>();
for (const e of entries) {
const g = groupOf(e.date, grouping);
const list = byGroup.get(g);
if (list) list.push(e);
else byGroup.set(g, [e]);
}
const rows: ChronicleRow[] = [];
let gapFrom: string | null = null;
let gapTo: string | null = null;
const flushGap = () => {
if (gapFrom && gapTo) {
// A period that spans the whole empty stretch keeps its rail through it.
const from = gapFrom;
const to = groupEnd(gapTo, grouping);
const rails: ChronicleRails = {};
for (const lane of RAIL_LANES) {
if (
spans.some((s) => s.lane === lane && s.start < from && s.end > to)
) {
rails[lane] = "full";
}
}
rows.push({ type: "gap", from: gapFrom, to: gapTo, rails });
}
gapFrom = gapTo = null;
};
for (const group of groupsBetween(first, today, grouping)) {
const list = byGroup.get(group);
if (!list) {
// Groups run newest first: the first empty one is the gap's newest end.
gapTo = gapTo ?? group;
gapFrom = group;
continue;
}
flushGap();
const end = groupEnd(group, grouping);
rows.push({
type: "header",
group,
rails: railsAt(spans, end < today ? end : today),
});
for (const entry of list) {
rows.push({ type: "entry", entry, rails: railsAt(spans, entry.date) });
}
}
flushGap();
return rows;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* The life-event form's state and its checks (v1.42, #613).
*
* Plain functions, no validator library: the timeline chunk stays light, and
* the rules are the server's own (`lifeEventCreateSchema` in
* `src/lib/day/wire-schemas.ts`), restated so the form can say what is wrong
* before anything is sent. The server checks again; this only spares a
* round trip.
*
* A date is held as a calendar key at the chosen precision: at MONTH it is
* the first of the month, at YEAR the first of January. Switching precision
* realigns both ends, so "September 2023" has exactly one spelling, the one
* the server accepts.
*/
import type {
LifeEventCategory,
LifeEventCreateInput,
LifeEventDTO,
LifeEventPrecision,
LifeEventUpdateInput,
} from "@/lib/day/contract";
import { LIFE_EVENT_NOTE_MAX, LIFE_EVENT_TITLE_MAX } from "@/lib/day/contract";
import { isCalendarDateKey } from "@/lib/tz/date-only";
export interface LifeEventDraft {
title: string;
category: LifeEventCategory | null;
precision: LifeEventPrecision;
start: string;
/** Null when the event is a moment rather than a period. */
end: string | null;
note: string;
}
export type LifeEventDraftErrors = Partial<
Record<"title" | "category" | "start" | "end" | "note", string>
>;
/** Snap a date to the first day of its month or year. */
export function alignToPrecision(
key: string,
precision: LifeEventPrecision,
): string {
if (!isCalendarDateKey(key)) return key;
if (precision === "MONTH") return `${key.slice(0, 7)}-01`;
if (precision === "YEAR") return `${key.slice(0, 4)}-01-01`;
return key;
}
export function emptyDraft(defaultDate: string): LifeEventDraft {
return {
title: "",
category: null,
precision: "DAY",
start: defaultDate,
end: null,
note: "",
};
}
export function draftFromEvent(event: LifeEventDTO): LifeEventDraft {
return {
title: event.title ?? "",
category: event.category,
precision: event.precision,
start: event.startDate,
end: event.endDate,
note: event.note ?? "",
};
}
/** The draft at a new precision, both ends realigned. */
export function withPrecision(
draft: LifeEventDraft,
precision: LifeEventPrecision,
): LifeEventDraft {
return {
...draft,
precision,
start: alignToPrecision(draft.start, precision),
end: draft.end === null ? null : alignToPrecision(draft.end, precision),
};
}
/**
* The message keys (under `lifeEvents.errors`) for what keeps the draft from
* saving. An empty object means it can be sent.
*/
export function validateDraft(draft: LifeEventDraft): LifeEventDraftErrors {
const errors: LifeEventDraftErrors = {};
const title = draft.title.trim();
if (title.length === 0) errors.title = "titleRequired";
else if (title.length > LIFE_EVENT_TITLE_MAX) errors.title = "titleTooLong";
if (draft.category === null) errors.category = "categoryRequired";
if (!isCalendarDateKey(draft.start)) errors.start = "dateRequired";
if (draft.end !== null) {
if (!isCalendarDateKey(draft.end)) errors.end = "dateRequired";
else if (isCalendarDateKey(draft.start) && draft.end < draft.start) {
errors.end = "endBeforeStart";
}
}
if (draft.note.trim().length > LIFE_EVENT_NOTE_MAX)
errors.note = "noteTooLong";
return errors;
}
/** The POST body. Call only with a draft `validateDraft` passed. */
export function createBody(draft: LifeEventDraft): LifeEventCreateInput {
const note = draft.note.trim();
return {
category: draft.category as LifeEventCategory,
precision: draft.precision,
startDate: alignToPrecision(draft.start, draft.precision),
endDate:
draft.end === null ? null : alignToPrecision(draft.end, draft.precision),
title: draft.title.trim(),
note: note.length > 0 ? note : null,
};
}
/**
* The PATCH body: only what changed. Precision and both dates travel
* together whenever one of them moved, because the server re-checks their
* alignment against each other.
*/
export function updateBody(
before: LifeEventDTO,
draft: LifeEventDraft,
): LifeEventUpdateInput {
const next = createBody(draft);
const body: LifeEventUpdateInput = {};
if (next.title !== (before.title ?? "")) body.title = next.title;
if (next.category !== before.category) body.category = next.category;
if ((next.note ?? null) !== (before.note ?? null))
body.note = next.note ?? null;
if (
next.precision !== before.precision ||
next.startDate !== before.startDate ||
(next.endDate ?? null) !== before.endDate
) {
body.precision = next.precision;
body.startDate = next.startDate;
body.endDate = next.endDate ?? null;
}
return body;
}
@@ -0,0 +1,26 @@
"use client";
/**
* The life-event form, loaded when the capture picker first opens it
* (v1.42, #613). The picker sits in the shell of every authenticated page;
* this keeps the form and the timeline's date helpers out of that bundle.
* Same arrangement as the quick-entry forms.
*/
import dynamic from "next/dynamic";
import { Skeleton } from "@/components/ui/skeleton";
function FormSkeleton() {
return (
<div className="space-y-4" data-slot="life-event-form-loading">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-2/3" />
</div>
);
}
export const LifeEventForm = dynamic(
() => import("./life-event-form").then((m) => ({ default: m.LifeEventForm })),
{ ssr: false, loading: FormSkeleton },
);
+479
View File
@@ -0,0 +1,479 @@
"use client";
/**
* The life-event form (v1.42, #613): what happened, its kind, when (to the
* day, the month or only the year), an optional end for a period, an
* optional note. Title and note are encrypted at rest and go to no model;
* the form says so in one line above the actions.
*
* Used in two places: the timeline's own sheet (add and edit) and the
* capture picker's sheet, which hands it a footer slot so the actions sit in
* the sheet's sticky footer like every other quick entry.
*/
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { Check, Loader2, Lock, Plus, Trash2, X } from "lucide-react";
import { FieldError } from "@/components/forms/field-error";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { localizedApiError } from "@/lib/api/localized-error";
import {
LIFE_EVENT_CATEGORIES,
LIFE_EVENT_PRECISIONS,
LIFE_EVENT_TITLE_MAX,
type LifeEventCategory,
type LifeEventDTO,
type LifeEventPrecision,
} from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
createBody,
draftFromEvent,
emptyDraft,
updateBody,
validateDraft,
withPrecision,
type LifeEventDraft,
type LifeEventDraftErrors,
} from "./life-event-draft";
import { Segmented } from "./segmented";
import { formatMonthLong, todayKeyIn } from "./timeline-dates";
import { useLifeEventMutations } from "./use-timeline";
export interface LifeEventFormProps {
/** The event being edited, or null to add one. */
event?: LifeEventDTO | null;
/** Prefilled start for a new event (a day's capture sheet passes its day). */
defaultDate?: string;
onSuccess?: () => void;
onCancel?: () => void;
/** Where the action row goes; inline when absent. */
footerSlot?: HTMLElement | null;
}
function CategoryChips({
value,
onChange,
labelledBy,
invalid,
}: {
value: LifeEventCategory | null;
onChange: (next: LifeEventCategory) => void;
labelledBy: string;
invalid: boolean;
}) {
const { t } = useTranslations();
const { getRadioProps } = useRovingRadioGroup({
count: LIFE_EVENT_CATEGORIES.length,
selectedIndex: value ? LIFE_EVENT_CATEGORIES.indexOf(value) : -1,
onSelect: (index) => onChange(LIFE_EVENT_CATEGORIES[index]),
});
return (
<div
role="radiogroup"
aria-labelledby={labelledBy}
aria-invalid={invalid || undefined}
className="flex flex-wrap gap-2"
data-slot="life-event-category"
>
{LIFE_EVENT_CATEGORIES.map((category, index) => {
const checked = value === category;
return (
<button
key={category}
type="button"
role="radio"
aria-checked={checked}
data-value={category}
onClick={() => onChange(category)}
{...getRadioProps(index)}
className={cn(
"focus-visible:ring-ring/50 min-h-11 rounded-full border px-3.5 text-sm font-medium outline-none focus-visible:ring-2 sm:min-h-9",
checked
? "bg-foreground text-background border-transparent"
: "border-border text-foreground hover:bg-accent",
)}
>
{t(`lifeEvents.category.${category}`)}
</button>
);
})}
</div>
);
}
/** A date at the draft's precision: day field, month and year, or year. */
function PrecisionDate({
id,
value,
precision,
onChange,
invalid,
describedBy,
today,
}: {
id: string;
value: string;
precision: LifeEventPrecision;
onChange: (next: string) => void;
invalid: boolean;
describedBy?: string;
today: string;
}) {
const { t, locale } = useTranslations();
const intl = resolveIntlLocale(locale);
const thisYear = Number(today.slice(0, 4));
const years = Array.from(
{ length: thisYear + 2 - 1900 },
(_, i) => thisYear + 1 - i,
);
const year = Number(value.slice(0, 4)) || thisYear;
const month = Number(value.slice(5, 7)) || 1;
const pad = (n: number) => String(n).padStart(2, "0");
if (precision === "DAY") {
return (
<DateField
id={id}
value={value}
onChange={onChange}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
/>
);
}
const yearSelect = (
<NativeSelect
id={precision === "YEAR" ? id : `${id}-year`}
aria-label={t("lifeEvents.precision.YEAR")}
value={String(year)}
onChange={(e) =>
onChange(
precision === "YEAR"
? `${e.target.value}-01-01`
: `${e.target.value}-${pad(month)}-01`,
)
}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</NativeSelect>
);
if (precision === "YEAR") return yearSelect;
return (
<div className="grid grid-cols-[1fr_6.5rem] gap-2">
<NativeSelect
id={id}
aria-label={t("lifeEvents.precision.MONTH")}
value={String(month)}
onChange={(e) => onChange(`${year}-${pad(Number(e.target.value))}-01`)}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>
{formatMonthLong(`2001-${pad(m)}-01`, intl)}
</option>
))}
</NativeSelect>
{yearSelect}
</div>
);
}
export function LifeEventForm({
event = null,
defaultDate,
onSuccess,
onCancel,
footerSlot,
}: LifeEventFormProps) {
const { t } = useTranslations();
const ids = {
form: useId(),
title: useId(),
category: useId(),
start: useId(),
end: useId(),
note: useId(),
};
const today = todayKeyIn(undefined);
const [draft, setDraft] = useState<LifeEventDraft>(() =>
event ? draftFromEvent(event) : emptyDraft(defaultDate ?? today),
);
const [errors, setErrors] = useState<LifeEventDraftErrors>({});
const [saveError, setSaveError] = useState<string | null>(null);
const { create, update, remove } = useLifeEventMutations();
const saving = create.isPending || update.isPending || remove.isPending;
const set = <K extends keyof LifeEventDraft>(
key: K,
value: LifeEventDraft[K],
) => setDraft((d) => ({ ...d, [key]: value }));
const fieldError = (field: keyof LifeEventDraftErrors) =>
errors[field] ? t(`lifeEvents.errors.${errors[field]}`) : undefined;
async function submit(e: React.FormEvent) {
e.preventDefault();
if (saving) return;
setSaveError(null);
const found = validateDraft(draft);
setErrors(found);
if (Object.keys(found).length > 0) return;
try {
if (event) {
const body = updateBody(event, draft);
if (Object.keys(body).length > 0) {
await update.mutateAsync({ id: event.id, body });
}
} else {
await create.mutateAsync(createBody(draft));
}
toastWrittenOutcome("success", t("lifeEvents.saved"));
onSuccess?.();
} catch (err) {
setSaveError(localizedApiError(err, t, "lifeEvents.saveFailed"));
}
}
async function destroy() {
if (!event || saving) return;
setSaveError(null);
try {
await remove.mutateAsync(event.id);
toastWrittenOutcome("success", t("lifeEvents.deleted"));
onSuccess?.();
} catch (err) {
setSaveError(localizedApiError(err, t, "lifeEvents.deleteFailed"));
}
}
const footer = (
<div className="flex w-full flex-wrap items-center justify-end gap-2">
{event && (
<ConfirmButton
label={t("lifeEvents.delete")}
title={t("lifeEvents.deleteConfirm.title")}
body={t("lifeEvents.deleteConfirm.body")}
confirmLabel={t("lifeEvents.delete")}
onConfirm={() => void destroy()}
pending={remove.isPending}
disabled={saving}
size="sm"
variant="destructive"
className="mr-auto min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden="true" />}
slot="life-event-delete"
/>
)}
{onCancel && (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onCancel}
disabled={saving}
>
{t("lifeEvents.cancel")}
</Button>
)}
<Button
type="submit"
form={ids.form}
size="sm"
className="min-h-11 sm:min-h-9"
disabled={saving}
data-slot="life-event-save"
>
{saving ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : event ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Plus className="size-4" aria-hidden="true" />
)}
{t("lifeEvents.save")}
</Button>
</div>
);
return (
<form
id={ids.form}
onSubmit={submit}
noValidate
className="space-y-4"
data-slot="life-event-form"
>
<div className="space-y-2">
<Label htmlFor={ids.title} noColon>
{t("lifeEvents.fields.title")}
</Label>
<Input
id={ids.title}
value={draft.title}
maxLength={LIFE_EVENT_TITLE_MAX}
autoComplete="off"
onChange={(e) => set("title", e.target.value)}
aria-invalid={!!errors.title || undefined}
aria-describedby={errors.title ? `${ids.title}-error` : undefined}
data-slot="life-event-title"
/>
<FieldError id={`${ids.title}-error`} message={fieldError("title")} />
</div>
<div className="space-y-2">
<Label id={ids.category} noColon asChild>
<span>{t("lifeEvents.fields.category")}</span>
</Label>
<CategoryChips
value={draft.category}
onChange={(c) => set("category", c)}
labelledBy={ids.category}
invalid={!!errors.category}
/>
<FieldError
id={`${ids.category}-error`}
message={fieldError("category")}
/>
</div>
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto]">
<div className="space-y-2">
<Label htmlFor={ids.start} noColon>
{t("lifeEvents.fields.when")}
</Label>
<PrecisionDate
id={ids.start}
value={draft.start}
precision={draft.precision}
onChange={(v) => set("start", v)}
invalid={!!errors.start}
describedBy={errors.start ? `${ids.start}-error` : undefined}
today={today}
/>
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
</div>
<div className="space-y-2">
<Label id={`${ids.start}-precision`} noColon asChild>
<span>{t("lifeEvents.fields.precision")}</span>
</Label>
<Segmented
options={LIFE_EVENT_PRECISIONS.map((p) => ({
value: p,
label: t(`lifeEvents.precision.${p}`),
}))}
value={draft.precision}
onChange={(p) => setDraft((d) => withPrecision(d, p))}
label={t("lifeEvents.fields.precision")}
slot="life-event-precision"
stretch
asToggleButtons
/>
</div>
</div>
{draft.end === null ? (
<Button
type="button"
variant="ghost"
size="sm"
className="-ml-2 min-h-11 sm:min-h-9"
onClick={() => set("end", draft.start)}
data-slot="life-event-add-end"
>
<Plus className="size-4" aria-hidden="true" />
{t("lifeEvents.fields.addEnd")}
</Button>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={ids.end} noColon>
{t("lifeEvents.fields.endDate")}
</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="-mr-2 min-h-11 sm:min-h-8"
onClick={() => set("end", null)}
data-slot="life-event-remove-end"
>
<X className="size-4" aria-hidden="true" />
{t("lifeEvents.fields.removeEnd")}
</Button>
</div>
<PrecisionDate
id={ids.end}
value={draft.end}
precision={draft.precision}
onChange={(v) => set("end", v)}
invalid={!!errors.end}
describedBy={errors.end ? `${ids.end}-error` : undefined}
today={today}
/>
<FieldError id={`${ids.end}-error`} message={fieldError("end")} />
</div>
)}
<div className="space-y-2">
<Label htmlFor={ids.note} noColon className="gap-1">
{t("lifeEvents.fields.note")}{" "}
<span className="text-muted-foreground font-normal">
{t("lifeEvents.fields.optionalTag")}
</span>
</Label>
<Textarea
id={ids.note}
rows={3}
value={draft.note}
placeholder={t("lifeEvents.fields.notePlaceholder")}
onChange={(e) => set("note", e.target.value)}
aria-invalid={!!errors.note || undefined}
aria-describedby={errors.note ? `${ids.note}-error` : undefined}
data-slot="life-event-note"
/>
<FieldError id={`${ids.note}-error`} message={fieldError("note")} />
</div>
<p
className="text-muted-foreground flex items-start gap-2 text-xs"
data-slot="life-event-privacy"
>
<Lock className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
<span>{t("lifeEvents.privacy")}</span>
</p>
{saveError && (
<div
role="alert"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{saveError}
</div>
)}
{footerSlot ? createPortal(footer, footerSlot) : footer}
</form>
);
}
@@ -0,0 +1,49 @@
"use client";
/**
* The timeline's own life-event sheet (v1.42, #613): add from the header
* button or a readiness link, edit from a life event in the chronicle or the
* selection bar. A bottom sheet on a phone, a dialog above.
*/
import { useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import type { LifeEventDTO } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
import { LifeEventForm } from "./life-event-form";
export function LifeEventSheet({
open,
onOpenChange,
event,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
event: LifeEventDTO | null;
}) {
const { t } = useTranslations();
const [footer, setFooter] = useState<HTMLDivElement | null>(null);
const close = () => onOpenChange(false);
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={event ? t("lifeEvents.edit") : t("lifeEvents.title")}
description={t("lifeEvents.subtitle")}
contentWidth="lg"
footer={<div ref={setFooter} className="flex w-full" />}
>
{open && (
<LifeEventForm
// A different event remounts the form with its own draft.
key={event?.id ?? "new"}
event={event}
onSuccess={close}
onCancel={close}
footerSlot={footer}
/>
)}
</ResponsiveSheet>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
/**
* The readiness inventory as a card on top of the timeline (v1.42, #613),
* while a lane is thin or empty and offers a way to close the gap. One
* sentence on what the timeline already carries, then each gap with its one
* link. Closing it is remembered on this device; the full inventory stays
* one tap away under "Data coverage".
*/
import Link from "next/link";
import { ChevronRight, Layers, X } from "lucide-react";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import type { TimelineReadinessResponse } from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { readinessGaps, readinessTally } from "./readiness-model";
import { wordKey } from "./readiness-inventory";
import { formatMonthYear } from "./timeline-dates";
export function ReadinessCard({
readiness,
onDismiss,
}: {
readiness: TimelineReadinessResponse;
onDismiss: () => void;
}) {
const { t, tCount, locale } = useTranslations();
const intl = resolveIntlLocale(locale);
const { carrying, total } = readinessTally(readiness);
const gaps = readinessGaps(readiness);
const summary = readiness.since
? t("timeline.readiness.card.summary", {
since: formatMonthYear(readiness.since, intl),
carrying,
total,
})
: t("timeline.readiness.meter", { carrying, total });
return (
<Card className="gap-2 py-3 md:py-4" data-slot="timeline-readiness-card">
<CardHeader>
<TileHeader
size="sm"
icon={Layers}
title={t("timeline.readiness.card.title")}
right={
<Button
variant="ghost"
size="icon-sm"
className="-my-1.5 -mr-1.5 size-11 sm:size-8"
aria-label={t("timeline.readiness.card.dismiss")}
onClick={onDismiss}
data-slot="timeline-readiness-card-dismiss"
>
<X className="size-4" aria-hidden="true" />
</Button>
}
/>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm">{summary}</p>
<ul className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{gaps.map((gap) => {
const line = wordKey(
t,
tCount,
`timeline.readiness.gapLine.${gap.key}`,
{ count: gap.count },
);
const link = wordKey(
t,
tCount,
`timeline.readiness.gap.${gap.key}`,
{
count: gap.count,
},
);
if (!line || !link) return null;
return (
<li
key={`${gap.lane}-${gap.key}`}
className="flex items-center justify-between gap-3 sm:justify-start"
>
<span className="text-sm">{line}</span>
<Link
href={gap.href}
data-slot="timeline-readiness-gap"
data-gap={gap.key}
className="hover:bg-accent focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-0.5 rounded-md px-2 text-sm font-medium whitespace-nowrap outline-none focus-visible:ring-2 sm:min-h-8"
>
{link}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
</li>
);
})}
</ul>
</CardContent>
</Card>
);
}
@@ -0,0 +1,209 @@
"use client";
/**
* The readiness inventory (v1.42, #613): one row per lane with what it holds
* ("carries", "thin", "empty"), exactly one link per gap, and the verdict as
* a plain count — never a score. Shown in the sheet that opens when the
* module is switched on, and in the timeline's "Data coverage" entry.
*
* Every word comes from the bundle, keyed by what the server sent. A key the
* bundle does not know is left out rather than shown raw.
*/
import Link from "next/link";
import {
ChevronRight,
CircleCheck,
CircleDashed,
Contrast,
} from "lucide-react";
import type {
TimelineReadinessLane,
TimelineReadinessResponse,
TimelineReadinessStatus,
} from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { isCalendarDateKey } from "@/lib/tz/date-only";
import { cn } from "@/lib/utils";
import { formatMonthYear } from "./timeline-dates";
import { orderedReadinessLanes, readinessTally } from "./readiness-model";
type Translate = ReturnType<typeof useTranslations>["t"];
type TranslateCount = ReturnType<typeof useTranslations>["tCount"];
/** Params with every calendar date worded as "March 2019". */
function wordedParams(
params: Record<string, string | number>,
intl: string,
): Record<string, string | number> {
const out: Record<string, string | number> = {};
for (const [k, v] of Object.entries(params)) {
out[k] =
typeof v === "string" && isCalendarDateKey(v)
? formatMonthYear(v, intl)
: v;
}
return out;
}
/**
* Word a key under `base`: through the plural tiers when the bundle has them
* and a `count` is given, else as one string; null when the bundle has
* neither.
*/
export function wordKey(
t: Translate,
tCount: TranslateCount,
base: string,
params: Record<string, string | number>,
): string | null {
const count = typeof params.count === "number" ? params.count : null;
if (count !== null && t(`${base}Other`) !== `${base}Other`) {
return tCount(base, count, params);
}
const single = t(base, params);
return single === base ? null : single;
}
export function laneLabel(t: Translate, key: TimelineReadinessLane["key"]) {
if (
key === "values" ||
key === "mood" ||
key === "environment" ||
key === "life"
) {
return t(`timeline.readiness.lanes.${key}`);
}
return t(`timeline.lanes.${key}`);
}
const STATUS_ICON: Record<TimelineReadinessStatus, typeof CircleCheck> = {
carries: CircleCheck,
thin: Contrast,
empty: CircleDashed,
};
const STATUS_CLASS: Record<TimelineReadinessStatus, string> = {
carries: "text-success",
thin: "text-warning",
empty: "text-muted-foreground",
};
export function VerdictMeter({
readiness,
}: {
readiness: Pick<TimelineReadinessResponse, "lanes" | "verdict" | "since">;
}) {
const { t, locale } = useTranslations();
const intl = resolveIntlLocale(locale);
const { carrying, total } = readinessTally(readiness);
const label = t("timeline.readiness.meter", { carrying, total });
return (
<div
className="bg-muted/60 flex flex-col items-start gap-2 rounded-lg px-3.5 py-3 sm:flex-row sm:items-center sm:gap-3"
data-slot="timeline-readiness-verdict"
data-verdict={readiness.verdict}
>
<div className="flex shrink-0 gap-1" role="img" aria-label={label}>
{Array.from({ length: total }, (_, i) => (
<span
key={i}
className={cn(
"h-1.5 w-4 rounded-full",
i < carrying ? "bg-foreground" : "bg-border",
)}
/>
))}
</div>
<p className="text-sm">
<span className="font-semibold">
{t(`timeline.readiness.verdict.${readiness.verdict}`)}
</span>
{readiness.verdict === "carries" && (
<>
{" "}
{readiness.since
? t("timeline.readiness.carriesDetail", {
carrying,
total,
since: formatMonthYear(readiness.since, intl),
})
: t("timeline.readiness.meter", { carrying, total })}
</>
)}
</p>
</div>
);
}
export function ReadinessInventory({
readiness,
onNavigate,
}: {
readiness: TimelineReadinessResponse;
onNavigate?: () => void;
}) {
const { t, tCount, locale } = useTranslations();
const intl = resolveIntlLocale(locale);
return (
<ul className="divide-border divide-y" data-slot="timeline-readiness-lanes">
{orderedReadinessLanes(readiness).map((lane) => {
const Icon = STATUS_ICON[lane.status];
const detail =
lane.key === "life" && lane.status === "empty"
? t("lifeEvents.emptyHint")
: lane.detail
? wordKey(
t,
tCount,
`timeline.readiness.detail.${lane.detail.key}`,
wordedParams(lane.detail.params, intl),
)
: null;
const gap = lane.gaps[0];
const gapLabel = gap
? wordKey(t, tCount, `timeline.readiness.gap.${gap.key}`, {
count: gap.count,
})
: null;
return (
<li
key={lane.key}
className="grid grid-cols-[1.25rem_1fr_auto] items-start gap-x-3 py-3"
data-slot="timeline-readiness-lane"
data-lane={lane.key}
data-status={lane.status}
>
<Icon
className={cn("mt-0.5 size-4", STATUS_CLASS[lane.status])}
aria-label={t(`timeline.readiness.status.${lane.status}`)}
role="img"
/>
<div className="min-w-0">
<p className="text-sm font-medium">{laneLabel(t, lane.key)}</p>
{detail && (
<p className="text-muted-foreground mt-0.5 text-xs">{detail}</p>
)}
</div>
{gap && gapLabel ? (
<Link
href={gap.href}
onClick={onNavigate}
data-slot="timeline-readiness-gap"
data-gap={gap.key}
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 -my-2 inline-flex min-h-11 items-center gap-0.5 rounded-md px-2 text-sm font-medium whitespace-nowrap outline-none focus-visible:ring-2 sm:min-h-9"
>
{gapLabel}
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
) : (
<span />
)}
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,76 @@
/**
* What the readiness inventory shows, decided once (v1.42, #613).
*
* The server decides each lane's status and the verdict; nothing here
* recounts. This module only orders the lanes for reading (what carries,
* then what is thin, then what is empty), flattens the gap links, and says
* when the dismissible card belongs on the timeline.
*/
import type {
TimelineReadinessLane,
TimelineReadinessResponse,
TimelineReadinessStatus,
} from "@/lib/day/contract";
const STATUS_RANK: Record<TimelineReadinessStatus, number> = {
carries: 0,
thin: 1,
empty: 2,
};
/** Lanes in reading order; the server's order is kept within one status. */
export function orderedReadinessLanes(
readiness: Pick<TimelineReadinessResponse, "lanes">,
): TimelineReadinessLane[] {
return readiness.lanes
.map((lane, index) => ({ lane, index }))
.sort(
(a, b) =>
STATUS_RANK[a.lane.status] - STATUS_RANK[b.lane.status] ||
a.index - b.index,
)
.map(({ lane }) => lane);
}
export interface ReadinessGap {
lane: TimelineReadinessLane["key"];
key: string;
count: number;
href: string;
}
/** Every gap with its one link, in the lanes' reading order. */
export function readinessGaps(
readiness: Pick<TimelineReadinessResponse, "lanes">,
): ReadinessGap[] {
return orderedReadinessLanes(readiness).flatMap((lane) =>
lane.gaps.map((gap) => ({ lane: lane.key, ...gap })),
);
}
/** How many lanes carry, out of how many are listed. */
export function readinessTally(
readiness: Pick<TimelineReadinessResponse, "lanes">,
): { carrying: number; total: number } {
return {
carrying: readiness.lanes.filter((l) => l.status === "carries").length,
total: readiness.lanes.length,
};
}
/**
* The card belongs on the timeline while a lane is thin or empty and offers
* a way to close the gap, until the person closes the card.
*/
export function showReadinessCard(
readiness: Pick<TimelineReadinessResponse, "lanes"> | undefined,
dismissed: boolean,
): boolean {
if (!readiness || dismissed) return false;
return readiness.lanes.some(
(lane) => lane.status !== "carries" && lane.gaps.length > 0,
);
}
/** Storage key for the dismissed card; a per-viewer convenience only. */
export const READINESS_CARD_DISMISSED_KEY = "healthlog.timeline.readinessCard";
@@ -0,0 +1,16 @@
"use client";
/**
* The readiness sheet, loaded when it first opens (v1.42, #613). Settings
* mounts this, so the Modules page carries none of the timeline's code until
* the switch is flipped on. Client-only: the sheet is closed on the server.
*/
import dynamic from "next/dynamic";
export const TimelineReadinessSheet = dynamic(
() =>
import("./readiness-sheet").then((m) => ({
default: m.TimelineReadinessSheet,
})),
{ ssr: false },
);
@@ -0,0 +1,94 @@
"use client";
/**
* The readiness sheet (v1.42, #613): opens right after the timeline module
* is switched on in Settings, and again from "Data coverage" on the
* timeline. It blocks nothing: "Later" closes it, "Open timeline" goes
* there, and every gap link goes straight to where that gap is closed.
*
* Loaded on demand (`readiness-sheet.lazy.tsx`), so Settings carries none of
* the timeline's code until the switch is flipped.
*/
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { ReadinessInventory, VerdictMeter } from "./readiness-inventory";
import { useTimelineReadiness } from "./use-timeline";
export function TimelineReadinessSheet({
open,
onOpenChange,
showOpenTimeline = true,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** False on the timeline itself, where "open" would go nowhere. */
showOpenTimeline?: boolean;
}) {
const { t } = useTranslations();
const readiness = useTimelineReadiness(open);
const close = () => onOpenChange(false);
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("timeline.readiness.title")}
description={t("timeline.readiness.subtitle")}
contentWidth="lg"
footer={
<div className="flex w-full flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={close}
data-slot="timeline-readiness-later"
>
{t("timeline.readiness.later")}
</Button>
{showOpenTimeline && (
<Button asChild size="sm" className="min-h-11 sm:min-h-9">
<Link
href="/timeline"
onClick={close}
data-slot="timeline-readiness-open"
>
{t("timeline.readiness.open")}
</Link>
</Button>
)}
</div>
}
>
<div className="space-y-4" data-slot="timeline-readiness-sheet">
{readiness.isPending ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-2/3" />
</div>
) : readiness.isError ? (
<QueryErrorRow
message={t("timeline.readiness.loadFailed")}
onRetry={() => void readiness.refetch()}
/>
) : (
<>
<VerdictMeter readiness={readiness.data} />
<ReadinessInventory readiness={readiness.data} onNavigate={close} />
<p className="text-muted-foreground text-xs">
{t("timeline.readiness.hint")}
</p>
</>
)}
</div>
</ResponsiveSheet>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
/**
* A small segmented choice (zoom, chronicle grouping, life-event precision):
* an ARIA radiogroup with one tab stop and arrow keys, through the shared
* roving helper. Neutral palette; the chosen segment is raised, not coloured.
*
* Inside a form sheet it renders as toggle buttons (`aria-pressed`) instead:
* the sheets' discard guard reads any checked radio as the person's input,
* and a precision that starts on "Day" is a default, not an answer.
*/
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { cn } from "@/lib/utils";
export interface SegmentedOption<T extends string> {
value: T;
label: string;
}
export function Segmented<T extends string>({
options,
value,
onChange,
label,
slot,
className,
stretch = false,
asToggleButtons = false,
}: {
options: ReadonlyArray<SegmentedOption<T>>;
value: T;
onChange: (next: T) => void;
label: string;
slot: string;
className?: string;
stretch?: boolean;
asToggleButtons?: boolean;
}) {
const selectedIndex = options.findIndex((o) => o.value === value);
const { getRadioProps } = useRovingRadioGroup({
count: options.length,
selectedIndex,
onSelect: (index) => onChange(options[index].value),
});
return (
<div
role={asToggleButtons ? "group" : "radiogroup"}
aria-label={label}
data-slot={slot}
className={cn(
"bg-muted inline-flex items-center gap-1 rounded-lg p-1",
stretch && "flex w-full",
className,
)}
>
{options.map((option, index) => {
const checked = option.value === value;
return (
<button
key={option.value}
type="button"
{...(asToggleButtons
? { "aria-pressed": checked }
: {
role: "radio",
"aria-checked": checked,
...getRadioProps(index),
})}
data-value={option.value}
onClick={() => onChange(option.value)}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center justify-center rounded-md px-3 text-sm font-medium outline-none focus-visible:ring-2",
stretch && "flex-1",
checked
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
/**
* The bar under the chart (v1.42, #613): the selected month, every entry
* that touches it as a chip (each one a way into its day), the month's
* means of the chosen values, and "Open 3 Jan." for the selected day.
*
* 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`).
*/
import { ArrowRight } from "lucide-react";
import type { TimelineResponse } from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
endOfMonth,
formatAtPrecision,
formatDayMonth,
formatMonthYear,
monthOf,
startOfMonth,
} from "./timeline-dates";
import { itemsInMonth } from "./timeline-geometry";
import { laneDotStyle } from "./timeline-chart";
import { formatMonthMeans } from "./series-format";
export interface SelectionBarProps {
timeline: TimelineResponse;
selected: string;
today: string;
onOpenDay: (date: string) => void;
onEditLifeEvent?: (id: string) => void;
}
/** The month means of each series for the month of `date`. */
export function monthMeans(
series: TimelineResponse["series"],
date: string,
): Array<{ key: string; mean: number; unit: string | null }> {
const month = monthOf(date);
const out: Array<{ key: string; mean: number; unit: string | null }> = [];
for (const s of series) {
const inMonth = s.points.filter((p) => monthOf(p.t) === month);
if (inMonth.length === 0) continue;
const mean = inMonth.reduce((sum, p) => sum + p.mean, 0) / inMonth.length;
out.push({ key: s.key, mean, unit: s.unit });
}
return out;
}
export function SelectionBar({
timeline,
selected,
today,
onOpenDay,
onEditLifeEvent,
}: SelectionBarProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const intl = resolveIntlLocale(locale);
const entries = itemsInMonth(timeline.lanes, selected, today);
const means = monthMeans(timeline.series, selected);
const monthFrom = startOfMonth(selected);
const monthTo = endOfMonth(selected);
return (
<div
className="bg-muted/60 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg px-3 py-2.5"
data-slot="timeline-selection-bar"
data-month={monthOf(selected)}
>
<span className="text-sm font-semibold" aria-live="polite">
{formatMonthYear(selected, intl)}
</span>
{entries.length === 0 && (
<span className="text-muted-foreground text-xs">
{t("timeline.selection.empty")}
</span>
)}
{entries.map(({ lane, item, through }) => {
// A chip opens the day its entry touches inside this month.
const day =
item.start >= monthFrom && item.start <= monthTo
? item.start
: monthFrom;
const canOpenDay = item.precision === "DAY" && day <= today;
const when =
item.open || through
? t("timeline.selection.ongoing")
: item.end && item.end !== item.start
? t("timeline.selection.range", {
from: formatDayMonth(item.start, intl),
to: formatDayMonth(item.end, intl),
})
: item.precision === "DAY"
? formatDayMonth(item.start, intl)
: formatAtPrecision(item.start, item.precision, intl);
const text = `${item.label}${item.sub ? ` ${item.sub}` : ""} · ${when}`;
const editable = item.kind === "lifeEvent" && onEditLifeEvent;
if (!canOpenDay && !editable) {
return (
<span
key={`${lane}-${item.id}`}
className="bg-background text-foreground text-2xs inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium"
>
<span
className="size-1.5 shrink-0 rounded-full"
style={laneDotStyle(lane)}
aria-hidden="true"
/>
{text}
</span>
);
}
return (
<button
key={`${lane}-${item.id}`}
type="button"
data-slot="timeline-selection-chip"
data-item={item.id}
onClick={() =>
editable ? onEditLifeEvent(item.id) : onOpenDay(day)
}
className="bg-background text-foreground text-2xs hover:bg-accent focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 rounded-full px-2.5 py-1 font-medium outline-none focus-visible:ring-2 sm:min-h-7"
>
<span
className="size-1.5 shrink-0 rounded-full"
style={laneDotStyle(lane)}
aria-hidden="true"
/>
{text}
</button>
);
})}
{means.length > 0 && (
<span className="text-muted-foreground text-xs tabular-nums">
{t("timeline.selection.mean", {
values: formatMonthMeans(means, fmt),
})}
</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>
);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* How a value line's number reads (v1.42, #613): whole numbers for pressure,
* pulse and counts, one decimal for the rest, sleep in hours. The unit is the
* server's, already in the person's display unit.
*/
import type { Formatters } from "@/lib/format-locale";
const WHOLE_NUMBER_KEYS: ReadonlySet<string> = new Set([
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"ACTIVITY_STEPS",
]);
export function formatSeriesNumber(
key: string,
value: number,
unit: string | null,
fmt: Pick<Formatters, "number">,
): { value: string; unit: string | null } {
if (key === "SLEEP_DURATION" && unit === "min") {
return { value: fmt.number(value / 60, 1), unit: "h" };
}
// Glucose arrives in the person's display unit: whole numbers in the
// hundreds, one decimal for single digits.
const digits =
WHOLE_NUMBER_KEYS.has(key) || (key === "BLOOD_GLUCOSE" && value >= 30)
? 0
: 1;
return { value: fmt.number(value, digits), unit };
}
/** "129 mmHg", "82,6 kg", "7,4 h". */
export function formatSeriesValue(
key: string,
value: number,
unit: string | null,
fmt: Pick<Formatters, "number">,
): string {
const out = formatSeriesNumber(key, value, unit, fmt);
return out.unit ? `${out.value} ${out.unit}` : out.value;
}
/**
* The month means in one short line: systolic and diastolic fold into
* "129/82 mmHg", the rest follow as "82,6 kg".
*/
export function formatMonthMeans(
means: ReadonlyArray<{ key: string; mean: number; unit: string | null }>,
fmt: Pick<Formatters, "number">,
): string {
const sys = means.find((m) => m.key === "BLOOD_PRESSURE_SYS");
const dia = means.find((m) => m.key === "BLOOD_PRESSURE_DIA");
const parts: string[] = [];
for (const m of means) {
if (sys && dia && m.key === "BLOOD_PRESSURE_DIA") continue;
if (sys && dia && m.key === "BLOOD_PRESSURE_SYS") {
parts.push(
`${fmt.number(sys.mean, 0)}/${fmt.number(dia.mean, 0)}${sys.unit ? ` ${sys.unit}` : ""}`,
);
continue;
}
parts.push(formatSeriesValue(m.key, m.mean, m.unit, fmt));
}
return parts.join(" · ");
}
+767
View File
@@ -0,0 +1,767 @@
"use client";
/**
* The timeline's lanes and value lines, as one hand-drawn SVG (v1.42, #613).
*
* Recharts draws every value chart in the app and stays there; it cannot lay
* periods and events out in lanes on a shared time axis, so this one surface
* is plain SVG, like the mood and intake heatmaps. Nothing is decided here:
* `timeline-geometry.ts` places every mark and label, this file paints them.
*
* Reading it without sight or without a mouse:
* - The drawing itself is `aria-hidden`. A visually hidden table beside it
* lists every entry with its lane, start and end, and a one-line summary
* names the window and the lanes.
* - The chart is one tab stop. Left and right move the selection by a day,
* a month or a year with the zoom; Home and End jump to the oldest data
* and to today; Enter opens the selected day. The selection bar under the
* chart announces each move.
* - A pointer selects with a click and opens the day with a double click.
*
* The width comes from a ResizeObserver, so a docked day panel that narrows
* the card re-runs the layout: labels that no longer fit are left out, never
* overlapped.
*/
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
import {
CalendarHeart,
FileText,
Flag,
FlaskConical,
Pill,
Shield,
Stethoscope,
Syringe,
Thermometer,
type LucideIcon,
} from "lucide-react";
import type {
TimelineLaneKey,
TimelineResponse,
TimelineZoom,
} from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
formatAtPrecision,
formatMonthShort,
formatMonthYear,
dayNumber,
} from "./timeline-dates";
import {
AXIS_HEIGHT,
LANE_COLOR,
dateAtPointer,
estimateTextWidth,
fitText,
gridTicks,
isSpan,
todayLabelFits,
wrapToWidth,
layoutTimeline,
stepSelection,
type PlacedPoint,
type PlacedSpan,
type TimeWindow,
} from "./timeline-geometry";
import { formatSeriesValue } from "./series-format";
export const LANE_ICON: Readonly<Record<TimelineLaneKey, LucideIcon>> = {
life: Flag,
illness: Thermometer,
allergies: Shield,
medications: Pill,
vaccinations: Syringe,
visits: Stethoscope,
labs: FlaskConical,
documents: FileText,
cycle: CalendarHeart,
};
export interface TimelineChartProps {
timeline: TimelineResponse;
window: TimeWindow;
zoom: TimelineZoom;
today: string;
selected: string | null;
hiddenLanes: ReadonlySet<TimelineLaneKey>;
seriesLabel: (key: string) => string;
onSelect: (date: string) => void;
onOpenDay: (date: string) => void;
}
/** Observed content width of an element, 0 until measured. */
function useElementWidth<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
setWidth(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const next = Math.floor(entries[0]?.contentRect.width ?? el.clientWidth);
setWidth((prev) => (prev === next ? prev : next));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, width };
}
export function TimelineChart({
timeline,
window,
zoom,
today,
selected,
hiddenLanes,
seriesLabel,
onSelect,
onOpenDay,
}: TimelineChartProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const intl = resolveIntlLocale(locale);
const { ref, width } = useElementWidth<HTMLDivElement>();
const lanes = useMemo(
() => timeline.lanes.filter((l) => !hiddenLanes.has(l.key)),
[timeline.lanes, hiddenLanes],
);
const layout = useMemo(
() =>
width > 0
? layoutTimeline({
width,
window,
lanes,
series: timeline.series,
startMissing: t("timeline.startMissing"),
today,
})
: null,
[width, window, lanes, timeline.series, t, today],
);
const floor = timeline.range.dataFrom ?? window.from;
function handleKey(event: KeyboardEvent<HTMLDivElement>) {
const current = selected ?? today;
let next: string | null = null;
if (event.key === "ArrowLeft")
next = stepSelection(current, -1, zoom, today, floor);
else if (event.key === "ArrowRight")
next = stepSelection(current, 1, zoom, today, floor);
else if (event.key === "Home") next = floor;
else if (event.key === "End") next = today;
else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenDay(current);
return;
}
if (next !== null) {
event.preventDefault();
onSelect(next);
}
}
function pointerDate(event: React.MouseEvent<SVGSVGElement>): string | null {
if (!layout) return null;
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (x < layout.scale.x0) return null;
return dateAtPointer(layout, x, y);
}
const summary = t("timeline.chartLabel", {
from: formatMonthYear(window.from, intl),
to: formatMonthYear(window.to, intl),
});
return (
<div className="space-y-3">
<div
ref={ref}
role="group"
aria-roledescription={t("timeline.title")}
aria-label={summary}
aria-describedby="timeline-chart-help"
tabIndex={0}
onKeyDown={handleKey}
data-slot="timeline-chart"
data-width={width}
className="focus-visible:ring-ring/50 rounded-lg outline-none focus-visible:ring-2"
>
<p id="timeline-chart-help" className="sr-only">
{t("timeline.keyboardHint")}
</p>
{layout ? (
<svg
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
aria-hidden="true"
className="block select-none"
onClick={(e) => {
const date = pointerDate(e);
if (date) onSelect(date);
}}
onDoubleClick={(e) => {
const date = pointerDate(e);
if (date) onOpenDay(date);
}}
>
<ChartBody
layout={layout}
window={window}
zoom={zoom}
today={today}
selected={selected}
dataFrom={timeline.range.dataFrom}
seriesLabel={seriesLabel}
intl={intl}
t={t}
fmt={fmt}
/>
</svg>
) : (
<div className="h-64" />
)}
</div>
<TimelineTable
timeline={timeline}
hiddenLanes={hiddenLanes}
intl={intl}
/>
</div>
);
}
interface ChartBodyProps {
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
window: TimeWindow;
zoom: TimelineZoom;
today: string;
selected: string | null;
dataFrom: string | null;
seriesLabel: (key: string) => string;
intl: string;
t: ReturnType<typeof useTranslations>["t"];
fmt: ReturnType<typeof useFormatters>;
}
function ChartBody({
layout,
window,
zoom,
today,
selected,
dataFrom,
seriesLabel,
intl,
t,
fmt,
}: ChartBodyProps) {
const { scale, height, width } = layout;
const todayInWindow = today >= window.from && today <= window.to;
const todayX = scale.xMid(today);
const todayLabel = t("timeline.today");
const todayLabelWidth = estimateTextWidth(todayLabel, 11);
const ticks = gridTicks(window, zoom, scale, {
year: (k) => k.slice(0, 4),
monthShort: (k) => formatMonthShort(k, intl),
monthYear: (k) => formatMonthYear(k, intl),
});
const showTodayLabel =
todayInWindow && todayLabelFits(ticks, todayX, todayLabelWidth, scale.x0);
const noDataUntil =
dataFrom && dataFrom > window.from && dataFrom <= window.to
? scale.x(dataFrom)
: null;
const seriesTop = layout.seriesTop;
return (
<g fontFamily="inherit">
<defs>
<linearGradient id="timeline-fade">
<stop offset="0" stopColor="var(--card)" />
<stop offset="1" stopColor="var(--card)" stopOpacity="0" />
</linearGradient>
</defs>
{noDataUntil !== null && (
<g data-slot="timeline-no-data">
<rect
x={scale.x0}
y={AXIS_HEIGHT}
width={Math.max(0, noDataUntil - scale.x0)}
height={height - AXIS_HEIGHT}
fill="var(--muted)"
opacity={0.4}
/>
{wrapToWidth(
t("timeline.noDataYet"),
noDataUntil - scale.x0 - 12,
).map((line, i, lines) => (
<text
key={line}
x={scale.x0 + 8}
y={height - 8 - (lines.length - 1 - i) * 13}
fontSize={11}
fill="var(--muted-foreground)"
>
{line}
</text>
))}
</g>
)}
{/* Grid and axis labels. */}
{ticks.map((tick) => (
<g key={tick.key}>
<line
x1={tick.x}
x2={tick.x}
y1={tick.major ? AXIS_HEIGHT - 6 : AXIS_HEIGHT}
y2={height}
stroke="var(--border)"
strokeOpacity={tick.major ? 0.9 : 0.45}
/>
{tick.label && (
<text
x={tick.x + 6}
y={14}
fontSize={12}
fontWeight={500}
fill="var(--muted-foreground)"
>
{tick.label}
</text>
)}
</g>
))}
{/* Lanes. */}
{layout.lanes.map((lane, index) => {
const Icon = LANE_ICON[lane.key];
const color = LANE_COLOR[lane.key];
return (
<g key={lane.key} data-lane={lane.key}>
{index > 0 && (
<line
x1={0}
x2={width}
y1={lane.top}
y2={lane.top}
stroke="var(--border)"
strokeOpacity={0.6}
/>
)}
<Icon
x={0}
y={lane.top + 7}
width={16}
height={16}
color="var(--muted-foreground)"
strokeWidth={2}
/>
<text
x={24}
y={lane.top + 19}
fontSize={13}
fontWeight={500}
fill="var(--foreground)"
>
{fitText(t(`timeline.lanes.${lane.key}`), scale.x0 - 30, 13)}
</text>
{lane.spans.map((span) => (
<SpanMark
key={span.item.id}
span={span}
color={color}
x0={scale.x0}
intl={intl}
t={t}
/>
))}
{lane.points.map((point) => (
<PointMark
key={point.item.id}
point={point}
color={color}
intl={intl}
/>
))}
{lane.labels.map((label) => (
<text
key={`label-${label.itemId}`}
x={label.x}
y={label.y}
fontSize={11}
fill={
label.strong ? "var(--foreground)" : "var(--muted-foreground)"
}
>
{label.text}
</text>
))}
</g>
);
})}
{/* Value lines: neutral, one scale each, never coloured by meaning. */}
{layout.series.length > 0 && (
<line
x1={0}
x2={width}
y1={seriesTop - 18}
y2={seriesTop - 18}
stroke="var(--border)"
/>
)}
{layout.series.map((series) => (
<g key={series.key} data-series={series.key}>
<text
x={0}
y={series.top + 12}
fontSize={13}
fontWeight={500}
fill="var(--foreground)"
>
{fitText(seriesLabel(series.key), scale.x0 - 12, 13)}
</text>
<text
x={0}
y={series.top + 30}
fontSize={12}
fill="var(--muted-foreground)"
>
{series.latest === null
? t("timeline.values.noneInWindow")
: formatSeriesValue(series.key, series.latest, series.unit, fmt)}
</text>
{series.path && (
<path
d={series.path}
fill="none"
stroke="var(--foreground)"
strokeWidth={1.6}
strokeOpacity={0.85}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{series.points.length === 1 && (
<circle
cx={series.points[0].x}
cy={series.points[0].y}
r={2.5}
fill="var(--foreground)"
/>
)}
</g>
))}
{/* Today. */}
{todayInWindow && (
<g data-slot="timeline-today">
<line
x1={todayX}
x2={todayX}
y1={AXIS_HEIGHT - 6}
y2={height}
stroke="var(--muted-foreground)"
strokeDasharray="2 3"
/>
{showTodayLabel && (
<text
x={todayX - 4}
y={14}
textAnchor="end"
fontSize={11}
fill="var(--muted-foreground)"
>
{todayLabel}
</text>
)}
</g>
)}
{/* Selection. */}
{selected && selected >= window.from && selected <= window.to && (
<g data-slot="timeline-selection" data-date={selected}>
<line
x1={scale.xMid(selected)}
x2={scale.xMid(selected)}
y1={AXIS_HEIGHT - 6}
y2={height}
stroke="var(--foreground)"
strokeWidth={1.2}
strokeOpacity={0.8}
/>
<circle
cx={scale.xMid(selected)}
cy={AXIS_HEIGHT - 6}
r={3.5}
fill="var(--foreground)"
/>
</g>
)}
</g>
);
}
function spanTitle(
span: PlacedSpan,
intl: string,
t: ReturnType<typeof useTranslations>["t"],
): string {
const { item } = span;
const from = formatAtPrecision(item.start, item.precision, intl);
const to = item.open
? t("timeline.selection.ongoing")
: formatAtPrecision(item.end ?? item.start, item.precision, intl);
const name = item.sub ? `${item.label} · ${item.sub}` : item.label;
return `${name}: ${t("timeline.selection.range", { from, to })}`;
}
function SpanMark({
span,
color,
x0,
intl,
t,
}: {
span: PlacedSpan;
color: string;
x0: number;
intl: string;
t: ReturnType<typeof useTranslations>["t"];
}) {
const { item, xStart, xEnd, y } = span;
const title = spanTitle(span, intl, t);
if (span.pause) {
return (
<g data-kind="pause">
<title>{title}</title>
<rect
x={xStart}
y={y - 4.5}
width={xEnd - xStart}
height={9}
rx={2}
fill="var(--card)"
stroke={color}
strokeDasharray="2 2"
/>
</g>
);
}
const thick = item.open ? 3 : 7;
return (
<g data-kind={item.kind} data-item={item.id}>
<title>{title}</title>
{!item.startKnown && (
<line
x1={Math.max(x0, xStart - 28)}
x2={xStart}
y1={y}
y2={y}
stroke={color}
strokeWidth={3}
strokeDasharray="3 4"
opacity={0.6}
/>
)}
<rect
x={xStart}
y={y - thick / 2}
width={Math.max(xEnd - xStart, 4)}
height={thick}
rx={thick / 2}
fill={color}
opacity={item.open ? 0.75 : 0.9}
/>
{item.open && (
<path
d={`M${xEnd + 1} ${y - 4} l6 4 l-6 4z`}
fill={color}
opacity={0.75}
/>
)}
{span.clippedLeft && item.startKnown && (
<rect
x={x0}
y={y - 4}
width={22}
height={8}
fill="url(#timeline-fade)"
/>
)}
</g>
);
}
function PointMark({
point,
color,
intl,
}: {
point: PlacedPoint;
color: string;
intl: string;
}) {
const { x, y, item, shape } = point;
const title = `${item.label}${item.sub ? ` · ${item.sub}` : ""}: ${formatAtPrecision(item.start, item.precision, intl)}`;
let glyph: React.ReactNode;
switch (shape) {
case "diamond":
glyph = (
<rect
x={x - 5}
y={y - 5}
width={10}
height={10}
transform={`rotate(45 ${x} ${y})`}
fill="var(--card)"
stroke={color}
strokeWidth={1.6}
/>
);
break;
case "square":
glyph = (
<rect
x={x - 4.5}
y={y - 4.5}
width={9}
height={9}
rx={2}
fill={color}
/>
);
break;
case "tick":
glyph = (
<rect
x={x - 1}
y={y - 6}
width={2}
height={12}
rx={1}
fill={color}
opacity={0.7}
/>
);
break;
case "ring":
glyph = (
<circle
cx={x}
cy={y}
r={3.5}
fill="var(--card)"
stroke={color}
strokeWidth={1.6}
/>
);
break;
case "doseMark":
glyph = (
<line
x1={x}
x2={x}
y1={y - 6}
y2={y + 6}
stroke={color}
strokeWidth={2}
/>
);
break;
default:
glyph = (
<circle
cx={x}
cy={y}
r={4}
fill={color}
stroke="var(--card)"
strokeWidth={1.5}
/>
);
}
return (
<g data-kind={item.kind} data-item={item.id}>
<title>{title}</title>
{glyph}
</g>
);
}
/**
* The chart as a table, for a screen reader: every entry with its lane and
* dates. Visually hidden; the phone chronicle is the visible list form.
*/
function TimelineTable({
timeline,
hiddenLanes,
intl,
}: {
timeline: TimelineResponse;
hiddenLanes: ReadonlySet<TimelineLaneKey>;
intl: string;
}) {
const { t } = useTranslations();
const rows = timeline.lanes
.filter((lane) => !hiddenLanes.has(lane.key))
.flatMap((lane) => lane.items.map((item) => ({ lane: lane.key, item })))
.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.lanes.${lane}`)}</td>
<td>
{item.label}
{item.sub ? ` · ${item.sub}` : ""}
{!item.startKnown ? ` · ${t("timeline.startMissing")}` : ""}
</td>
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
<td>
{item.open
? t("timeline.selection.ongoing")
: isSpan(item) && item.end
? formatAtPrecision(item.end, item.precision, intl)
: ""}
</td>
</tr>
))}
</tbody>
</table>
);
}
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
return { background: LANE_COLOR[lane] };
}
@@ -0,0 +1,352 @@
"use client";
/**
* The timeline on a phone (v1.42, #613): an "Ongoing" block that folds away,
* then the chronicle, newest first, with the month's means beside each
* month, thin rails beside the rows an episode or a course spans, and empty
* stretches named rather than skipped. Built from `chronicle-model.ts`.
*
* A dated row opens its day (the same `?day=` layer the chart hands off to);
* a life event opens its edit sheet instead, because the event is the
* person's own entry and that is where it is changed.
*/
import { useState } from "react";
import {
CalendarHeart,
ChevronDown,
FileText,
Flag,
FlaskConical,
Pill,
Shield,
Sparkles,
Stethoscope,
Syringe,
Thermometer,
type LucideIcon,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import type { TimelineItemKind, TimelineResponse } from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
RAIL_LANES,
buildChronicle,
standingChips,
type ChronicleEntry,
type ChronicleGrouping,
type ChronicleRails,
} from "./chronicle-model";
import {
formatAtPrecision,
formatDayMonth,
formatMonthLong,
formatMonthYear,
} from "./timeline-dates";
import { LANE_COLOR } from "./timeline-geometry";
import { laneDotStyle } from "./timeline-chart";
import { monthMeans } from "./selection-bar";
import { formatMonthMeans } from "./series-format";
const KIND_ICON: Partial<Record<TimelineItemKind, LucideIcon>> = {
lifeEvent: Flag,
travel: Flag,
episode: Thermometer,
chronic: Thermometer,
allergy: Shield,
medication: Pill,
course: Pill,
doseChange: Pill,
pause: Pill,
vaccination: Syringe,
visit: Stethoscope,
procedure: Stethoscope,
labDay: FlaskConical,
document: FileText,
cycle: CalendarHeart,
};
const RAIL_X: Record<(typeof RAIL_LANES)[number], number> = {
illness: 6,
medications: 13,
};
function Rails({ rails }: { rails: ChronicleRails }) {
// One positioned layer over the row, so the rails never take a grid cell.
return (
<span className="pointer-events-none absolute inset-0" aria-hidden="true">
{RAIL_LANES.map((lane) => {
const mode = rails[lane];
if (!mode) return null;
const color = LANE_COLOR[lane];
const span =
mode === "full"
? { top: 0, bottom: 0 }
: mode === "start"
? { top: 0, bottom: "50%" }
: mode === "end"
? { top: "50%", bottom: 0 }
: null;
return (
<span key={lane}>
{span && (
<span
className="absolute w-[3px] rounded-sm"
style={{
left: RAIL_X[lane],
...span,
background: color,
opacity: 0.8,
}}
/>
)}
{mode !== "full" && (
<span
className="absolute size-[7px] rounded-full"
style={{
left: RAIL_X[lane] - 2,
top: "calc(50% - 3.5px)",
background: color,
}}
/>
)}
</span>
);
})}
</span>
);
}
export function TimelineChronicle({
timeline,
today,
grouping,
selected,
onOpenDay,
onEditLifeEvent,
}: {
timeline: TimelineResponse;
today: string;
grouping: ChronicleGrouping;
selected: string | null;
onOpenDay: (date: string) => void;
onEditLifeEvent: ((id: string) => void) | null;
}) {
const { t, tCount, locale } = useTranslations();
const fmt = useFormatters();
const intl = resolveIntlLocale(locale);
const [standingOpen, setStandingOpen] = useState(true);
const chips = standingChips(timeline);
const rows = buildChronicle(timeline, today, grouping);
const groupLabel = (group: string) =>
grouping === "month" ? formatMonthYear(group, intl) : group.slice(0, 4);
const gapLabel = (from: string, to: string) => {
if (from === to)
return t("timeline.chronicle.gapSingle", { period: groupLabel(from) });
const sameYear = from.slice(0, 4) === to.slice(0, 4);
const fromText =
grouping === "month" && sameYear
? formatMonthLong(from, intl)
: groupLabel(from);
return t("timeline.chronicle.gap", { from: fromText, to: groupLabel(to) });
};
function entryText(entry: ChronicleEntry): {
title: string;
meta: string | null;
} {
if (entry.kind === "notable") {
return {
title: t(`timeline.chronicle.notable.${entry.notable}`),
meta: t("timeline.chronicle.notableMeta"),
};
}
const { item, role } = entry;
if (role === "end") {
return {
title: t("timeline.chronicle.ended", { label: item.label }),
meta: entry.days
? tCount("timeline.chronicle.afterDays", entry.days)
: null,
};
}
const meta = [item.sub, item.startKnown ? null : t("timeline.startMissing")]
.filter(Boolean)
.join(" · ");
return { title: item.label, meta: meta || null };
}
return (
<div className="space-y-4" data-slot="timeline-chronicle">
{chips.length > 0 && (
<Card className="gap-2 py-3" data-slot="timeline-standing">
<button
type="button"
aria-expanded={standingOpen}
aria-controls="timeline-standing-chips"
onClick={() => setStandingOpen((v) => !v)}
className="focus-visible:ring-ring/50 flex min-h-11 items-center justify-between rounded-md px-4 text-left outline-none focus-visible:ring-2"
>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("timeline.standing")}
</span>
<ChevronDown
className={cn(
"text-muted-foreground size-4 motion-safe:transition-transform",
standingOpen && "rotate-180",
)}
aria-hidden="true"
/>
</button>
{standingOpen && (
<ul
id="timeline-standing-chips"
className="flex flex-wrap gap-1.5 px-4"
>
{chips.map((chip) => (
<li
key={`${chip.lane}-${chip.id}`}
className="bg-muted text-foreground text-2xs inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium"
>
<span
className="size-1.5 rounded-full"
style={laneDotStyle(chip.lane)}
aria-hidden="true"
/>
{chip.count > 1
? tCount("timeline.standingAllergies", chip.count)
: chip.label}
</li>
))}
</ul>
)}
</Card>
)}
<ol className="relative" aria-label={t("timeline.table.caption")}>
{rows.map((row) => {
if (row.type === "gap") {
return (
<li
key={`gap-${row.from}`}
data-slot="timeline-chronicle-gap"
className="relative pt-2 pl-6"
>
<Rails rails={row.rails} />
<p className="text-muted-foreground border-border rounded-lg border border-dashed px-3 py-2.5 text-xs">
{gapLabel(row.from, row.to)}
</p>
</li>
);
}
if (row.type === "header") {
const means =
grouping === "month"
? monthMeans(timeline.series, row.group)
: [];
return (
<li
key={`h-${row.group}`}
className="relative flex items-baseline justify-between gap-3 pt-4 pb-1.5 pl-6"
data-slot="timeline-chronicle-month"
data-group={row.group}
>
<Rails rails={row.rails} />
<h3 className="text-sm font-semibold">
{groupLabel(row.group)}
</h3>
{means.length > 0 && (
<span className="text-muted-foreground text-xs tabular-nums">
{formatMonthMeans(means, fmt)}
</span>
)}
</li>
);
}
const { entry } = row;
const { title, meta } = entryText(entry);
const Icon =
entry.kind === "notable"
? Sparkles
: (KIND_ICON[entry.item.kind] ?? Flag);
const precision =
entry.kind === "item" ? entry.item.precision : "DAY";
const dateText =
precision === "DAY"
? formatDayMonth(entry.date, intl)
: formatAtPrecision(entry.date, precision, intl);
const isLifeEvent =
entry.kind === "item" && entry.item.kind === "lifeEvent";
const action =
isLifeEvent && onEditLifeEvent && entry.kind === "item"
? () => onEditLifeEvent(entry.item.id)
: precision === "DAY"
? () => onOpenDay(entry.date)
: null;
const highlighted = selected === entry.date;
const body = (
<>
<Rails rails={row.rails} />
<span className="text-muted-foreground text-xs leading-5 whitespace-nowrap tabular-nums">
{dateText}
</span>
<Icon
className="text-muted-foreground mt-0.5 size-4"
aria-hidden="true"
/>
<span className="min-w-0">
<span
className={cn(
"block text-sm leading-5",
entry.kind === "notable" ? "font-normal" : "font-medium",
)}
>
{title}
</span>
{meta && (
<span className="text-muted-foreground mt-0.5 block text-xs">
{meta}
</span>
)}
</span>
</>
);
const rowClass = cn(
"relative grid w-full grid-cols-[3.25rem_1rem_1fr] items-start gap-x-2.5 rounded-lg py-2.5 pr-3 pl-6 text-left",
highlighted && "bg-muted",
);
const key =
entry.kind === "item"
? `${entry.item.id}-${entry.role}`
: `notable-${entry.date}-${entry.notable}`;
return (
<li
key={key}
data-slot="timeline-chronicle-row"
data-date={entry.date}
>
{action ? (
<button
type="button"
onClick={action}
className={cn(
rowClass,
"hover:bg-accent focus-visible:ring-ring/50 min-h-11 outline-none focus-visible:ring-2",
)}
>
{body}
</button>
) : (
<div className={rowClass}>{body}</div>
)}
</li>
);
})}
</ol>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Calendar arithmetic for the timeline (v1.42, #613).
*
* Every date the timeline handles is a stated calendar date (`YYYY-MM-DD`):
* the server cut it in the record's zone already, so nothing here may read it
* through the browser's zone again. Dates become whole day numbers since the
* epoch (UTC midnight), which keeps the x-scale linear across daylight-saving
* changes and makes "one day to the right" exactly `+1`.
*
* Formatting reads the same UTC instant back with `timeZone: "UTC"`, so a
* date never moves by a day for a reader east or west of Greenwich.
*/
import { isCalendarDateKey } from "@/lib/tz/date-only";
const MS_PER_DAY = 86_400_000;
/** Days since 1970-01-01 for a `YYYY-MM-DD` key. */
export function dayNumber(key: string): number {
const [y, m, d] = key.split("-").map(Number);
return Math.round(Date.UTC(y, m - 1, d) / MS_PER_DAY);
}
/** The `YYYY-MM-DD` key of a day number. */
export function dayKey(day: number): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: a calendar key held as UTC midnight, never an instant
return new Date(day * MS_PER_DAY).toISOString().slice(0, 10);
}
/** `YYYY-MM` of a date key. */
export function monthOf(key: string): string {
return key.slice(0, 7);
}
/** First day of the month a key falls in. */
export function startOfMonth(key: string): string {
return `${key.slice(0, 7)}-01`;
}
/** Last day of the month a key falls in. */
export function endOfMonth(key: string): string {
const [y, m] = key.split("-").map(Number);
return dayKey(Math.round(Date.UTC(y, m, 0) / MS_PER_DAY));
}
/**
* The key `months` calendar months away, clamped to the target month's
* length (31 January plus one month is 28 or 29 February).
*/
export function addMonths(key: string, months: number): string {
const [y, m, d] = key.split("-").map(Number);
const target = new Date(Date.UTC(y, m - 1 + months, 1));
const lastDay = new Date(
Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0),
).getUTCDate();
target.setUTCDate(Math.min(d, lastDay));
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: a calendar key held as UTC midnight, never an instant
return target.toISOString().slice(0, 10);
}
/** Every `YYYY-MM-01` from the month of `from` through the month of `to`. */
export function monthsBetween(from: string, to: string): string[] {
const out: string[] = [];
let cursor = startOfMonth(from);
const last = startOfMonth(to);
while (cursor <= last) {
out.push(cursor);
cursor = addMonths(cursor, 1);
}
return out;
}
/** Today's calendar date in an IANA zone, as `YYYY-MM-DD`. */
export function todayKeyIn(timeZone: string | undefined, now = new Date()) {
try {
// `en-CA` formats as YYYY-MM-DD.
return new Intl.DateTimeFormat("en-CA", {
timeZone: timeZone || undefined,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
} catch {
// An unknown zone name: the browser's own calendar day is the honest
// fallback, not UTC's.
const pad = (n: number) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
}
/** A `?day=` value the timeline accepts: a real date, not in the future. */
export function acceptableDayParam(
raw: string | null | undefined,
today: string,
): string | null {
if (!raw || !isCalendarDateKey(raw)) return null;
return raw <= today ? raw : null;
}
function utcDate(key: string): Date {
return new Date(dayNumber(key) * MS_PER_DAY);
}
function format(
key: string,
intlLocale: string,
options: Intl.DateTimeFormatOptions,
): string {
return new Intl.DateTimeFormat(intlLocale, {
...options,
timeZone: "UTC",
}).format(utcDate(key));
}
/** "Januar 2026". */
export function formatMonthYear(key: string, intlLocale: string): string {
return format(key, intlLocale, { month: "long", year: "numeric" });
}
/** "Jan. 2026". */
export function formatMonthYearShort(key: string, intlLocale: string): string {
return format(key, intlLocale, { month: "short", year: "numeric" });
}
/** "3. Jan.". */
export function formatDayMonth(key: string, intlLocale: string): string {
return format(key, intlLocale, { day: "numeric", month: "short" });
}
/** "3. Jan. 2026". */
export function formatDayMonthYear(key: string, intlLocale: string): string {
return format(key, intlLocale, {
day: "numeric",
month: "short",
year: "numeric",
});
}
/** "Jan". Axis labels. */
export function formatMonthShort(key: string, intlLocale: string): string {
return format(key, intlLocale, { month: "short" });
}
/** "Januar". */
export function formatMonthLong(key: string, intlLocale: string): string {
return format(key, intlLocale, { month: "long" });
}
/**
* A date at the precision it was recorded with: "3. Jan. 2026", "Sept.
* 2023" or "2019". Life events and stand-in dates use this, so a month the
* person gave is never shown as its first day.
*/
export function formatAtPrecision(
key: string,
precision: "DAY" | "MONTH" | "YEAR",
intlLocale: string,
): string {
if (precision === "YEAR") return key.slice(0, 4);
if (precision === "MONTH") return formatMonthYearShort(key, intlLocale);
return formatDayMonthYear(key, intlLocale);
}
@@ -0,0 +1,911 @@
/**
* The timeline's geometry, as pure functions (v1.42, #613).
*
* Everything the SVG draws is decided here, from the server's answer, the
* width the card has and the zoom: the time window, the x-scale, the grid,
* which row each period sits on, where each label goes and which labels are
* left out, and the value lines below. The renderer only paints the result,
* so every rule that matters (no label overlaps another label or a bar, no
* label runs past the card edge, a span with an unknown start says so) is
* provable without a browser.
*
* Text is never measured in the DOM. Label widths are estimated from the
* character count at the label font size, deliberately on the wide side: an
* estimate that runs short would let two labels touch, one that runs long
* only leaves a label out a little early. A label that is left out is never
* lost — the selection bar under the chart names every entry of the selected
* month in full.
*/
import type {
TimelineItem,
TimelineItemKind,
TimelineLane,
TimelineLaneKey,
TimelineSeries,
TimelineZoom,
} from "@/lib/day/contract";
import {
addMonths,
dayKey,
dayNumber,
endOfMonth,
monthsBetween,
startOfMonth,
} from "./timeline-dates";
/* ─── Constants ───────────────────────────────────────────────────────────── */
/** Height of the axis strip above the first lane. */
export const AXIS_HEIGHT = 26;
/** Centre of the first row below a lane's top edge. */
export const LANE_PAD_TOP = 16;
/** Distance between two row centres in one lane. */
export const ROW_HEIGHT = 22;
/** Last row centre to the lane's bottom edge. */
export const LANE_PAD_BOTTOM = 18;
/** Gap between the last lane and the first value line. */
export const SERIES_GAP = 18;
/** Height of one value line, label included. */
export const SERIES_HEIGHT = 60;
/** Label font size in the lanes, px. */
export const LABEL_FONT_PX = 11;
/** Estimated advance of one label character at 11 px, on the wide side. */
export const LABEL_CHAR_PX = 6.1;
/** Horizontal room a label keeps from anything beside it. */
export const LABEL_MARGIN = 6;
/** Two bars on one row keep at least this much air between them. */
export const BAR_GAP = 6;
/** Width of the dashed lead-in before a span whose start is unknown. */
export const UNKNOWN_START_LEAD = 28;
/** A click within this many px of a mark selects the mark's date. */
export const HIT_SLOP = 8;
/** Margin of a label set beside the mark it names. */
const BESIDE_MARGIN = 4;
/** Half the drawn width of each point glyph, for the occupancy map. */
const GLYPH_HALF_WIDTH: Record<PointShape, number> = {
dot: 4,
diamond: 7,
square: 4.5,
tick: 1,
ring: 4,
doseMark: 1,
};
/** Lane order, top to bottom: the contract's order. */
export const LANE_ORDER: readonly TimelineLaneKey[] = [
"life",
"illness",
"allergies",
"medications",
"vaccinations",
"visits",
"labs",
"documents",
"cycle",
];
/**
* One colour per lane, as tokens. Never the only signal: every lane has its
* name and icon at the left, and every kind its own shape.
*/
export const LANE_COLOR: Readonly<Record<TimelineLaneKey, string>> = {
life: "var(--foreground)",
illness: "var(--chart-5)",
allergies: "var(--chart-3)",
medications: "var(--chart-1)",
vaccinations: "var(--chart-2)",
visits: "var(--chart-4)",
labs: "var(--chart-4)",
documents: "var(--muted-foreground)",
cycle: "var(--chart-3)",
};
/* ─── Window and scale ────────────────────────────────────────────────────── */
/** An inclusive range of calendar dates. */
export interface TimeWindow {
from: string;
to: string;
}
/**
* The window a zoom shows.
*
* `all` follows the server's range (it knows where the record begins); the
* fallback before an answer is the current and the previous year. `year` and
* `quarter` are 12 and 3 whole months that keep `anchor` (the selected day)
* in view, end no later than the month of `today`, and so never open onto an
* empty future.
*/
export function windowFor(
zoom: TimelineZoom,
today: string,
anchor: string | null,
serverRange?: { from: string; to: string } | null,
): TimeWindow {
if (zoom === "all") {
if (serverRange) return { from: serverRange.from, to: serverRange.to };
const year = Number(today.slice(0, 4));
return { from: `${year - 1}-01-01`, to: `${year}-12-31` };
}
const months = zoom === "year" ? 12 : 3;
const ahead = zoom === "year" ? 5 : 1;
const base = anchor && anchor <= today ? anchor : today;
const lastMonth = startOfMonth(addMonths(startOfMonth(base), ahead));
const capped =
lastMonth > startOfMonth(today) ? startOfMonth(today) : lastMonth;
return {
from: addMonths(capped, -(months - 1)),
to: endOfMonth(capped),
};
}
/** A linear map from calendar days to x, over `[from, to + 1 day)`. */
export interface Scale {
readonly x0: number;
readonly x1: number;
readonly fromDay: number;
readonly toDay: number;
/** x of the start of a day (a key or a day number). */
x: (day: string | number) => number;
/** x of the middle of a day. */
xMid: (day: string | number) => number;
/** The day under an x position, clamped into the window. */
dayAt: (x: number) => string;
}
export function createScale(window: TimeWindow, x0: number, x1: number) {
const fromDay = dayNumber(window.from);
const toDay = dayNumber(window.to) + 1;
const span = Math.max(1, toDay - fromDay);
const width = Math.max(1, x1 - x0);
const toNumber = (d: string | number) =>
typeof d === "number" ? d : dayNumber(d);
const x = (d: string | number) =>
x0 + ((toNumber(d) - fromDay) / span) * width;
return {
x0,
x1,
fromDay,
toDay,
x,
xMid: (d: string | number) => x(toNumber(d) + 0.5),
dayAt: (px: number) => {
const raw = Math.floor(fromDay + ((px - x0) / width) * span);
return dayKey(Math.min(toDay - 1, Math.max(fromDay, raw)));
},
} satisfies Scale;
}
/* ─── Text ────────────────────────────────────────────────────────────────── */
/** Estimated width of a label at `fontPx`. */
export function estimateTextWidth(text: string, fontPx = LABEL_FONT_PX) {
return text.length * LABEL_CHAR_PX * (fontPx / LABEL_FONT_PX);
}
/** Occupied horizontal intervals on one text or bar line. */
export class LineOccupancy {
private readonly lines = new Map<string, Array<[number, number]>>();
/** True when `[a, b]` keeps `margin` from everything on `line`. */
fits(line: string, a: number, b: number, margin = LABEL_MARGIN): boolean {
for (const [c, d] of this.lines.get(line) ?? []) {
if (a < d + margin && b > c - margin) return false;
}
return true;
}
add(line: string, a: number, b: number): void {
const list = this.lines.get(line);
if (list) list.push([a, b]);
else this.lines.set(line, [[a, b]]);
}
/** Place `[a, b]` on `line` if it fits; report whether it did. */
tryAdd(line: string, a: number, b: number, margin = LABEL_MARGIN) {
if (!this.fits(line, a, b, margin)) return false;
this.add(line, a, b);
return true;
}
}
/**
* `text` cut to fit `maxWidth` at `fontPx`, with an ellipsis when it had to
* be cut. Lane and value names sit in a fixed column; a long translation
* must not run into the plot.
*/
export function fitText(
text: string,
maxWidth: number,
fontPx = LABEL_FONT_PX,
) {
if (estimateTextWidth(text, fontPx) <= maxWidth) return text;
const perChar = LABEL_CHAR_PX * (fontPx / LABEL_FONT_PX);
const keep = Math.max(1, Math.floor(maxWidth / perChar) - 1);
return `${text.slice(0, keep).trimEnd()}…`;
}
/**
* Words of `text` broken into lines that each fit `maxWidth`, or no lines
* at all when a single word does not fit: a note that cannot be read whole
* is better left out.
*/
export function wrapToWidth(
text: string,
maxWidth: number,
fontPx = LABEL_FONT_PX,
) {
const lines: string[] = [];
for (const word of text.split(/\s+/)) {
const last = lines.at(-1);
if (
last !== undefined &&
estimateTextWidth(`${last} ${word}`, fontPx) <= maxWidth
) {
lines[lines.length - 1] = `${last} ${word}`;
} else {
if (estimateTextWidth(word, fontPx) > maxWidth) return [];
lines.push(word);
}
}
return lines;
}
/* ─── Grid ────────────────────────────────────────────────────────────────── */
export interface GridTick {
key: string;
x: number;
/** Text above the grid line, or null for an unlabelled line. */
label: string | null;
/** Year lines (all), month lines (year, quarter) are major. */
major: boolean;
}
export interface GridFormat {
year: (key: string) => string;
monthShort: (key: string) => string;
monthYear: (key: string) => string;
}
/**
* Vertical grid lines and their axis labels. A label that would touch its
* neighbour (or the "today" label at the right) is dropped; the line stays.
*/
export function gridTicks(
window: TimeWindow,
zoom: TimelineZoom,
scale: Scale,
fmt: GridFormat,
): GridTick[] {
const ticks: GridTick[] = [];
const months = monthsBetween(window.from, window.to);
if (zoom === "all") {
const pxPerYear = (scale.x1 - scale.x0) / Math.max(1, months.length / 12);
for (const m of months) {
if (m < window.from) continue;
const month = Number(m.slice(5, 7));
if (month === 1) {
ticks.push({ key: m, x: scale.x(m), label: fmt.year(m), major: true });
} else if (
pxPerYear >= 240 &&
(month === 4 || month === 7 || month === 10)
) {
ticks.push({ key: m, x: scale.x(m), label: null, major: false });
}
}
} else if (zoom === "year") {
for (const m of months) {
if (m < window.from) continue;
const january = m.slice(5, 7) === "01";
ticks.push({
key: m,
x: scale.x(m),
label: january ? fmt.year(m) : fmt.monthShort(m),
major: true,
});
}
} else {
for (const m of months) {
if (m < window.from) continue;
ticks.push({
key: m,
x: scale.x(m),
label: fmt.monthYear(m),
major: true,
});
}
// Weekly minor lines, on Mondays.
const fromDay = dayNumber(window.from);
const toDay = dayNumber(window.to);
// 1970-01-01 was a Thursday: day numbers with (n + 3) % 7 === 0 are Mondays.
for (let d = fromDay; d <= toDay; d++) {
if ((d + 3) % 7 !== 0) continue;
const key = dayKey(d);
if (key.endsWith("-01")) continue;
ticks.push({ key, x: scale.x(d), label: null, major: false });
}
ticks.sort((a, b) => a.x - b.x);
}
// Labels sit right of their line; drop the ones that would collide.
const occupancy = new LineOccupancy();
return ticks.map((tick) => {
if (tick.label === null) return tick;
const a = tick.x + 6;
const b = a + estimateTextWidth(tick.label, 12);
if (b > scale.x1 || !occupancy.tryAdd("axis", a, b, 8)) {
return { ...tick, label: null };
}
return tick;
});
}
/**
* Whether the "Today" label (right-aligned to the today line) fits on the
* axis: inside the plot and clear of every year or month label. The axis
* labels win; the dashed line says "today" on its own.
*/
export function todayLabelFits(
ticks: readonly GridTick[],
todayX: number,
labelWidth: number,
x0: number,
): boolean {
const a = todayX - 4 - labelWidth;
const b = todayX - 4;
if (a < x0) return false;
return ticks.every((tick) => {
if (!tick.label) return true;
const ta = tick.x + 6;
const tb = ta + estimateTextWidth(tick.label, 12);
return b + 6 <= ta || a >= tb + 6;
});
}
/* ─── Lanes ───────────────────────────────────────────────────────────────── */
/** Point shapes, one per kind; colour is never the only difference. */
export type PointShape =
"dot" | "diamond" | "square" | "tick" | "ring" | "doseMark";
const POINT_SHAPE: Partial<Record<TimelineItemKind, PointShape>> = {
lifeEvent: "diamond",
vaccination: "dot",
visit: "dot",
procedure: "square",
labDay: "ring",
document: "tick",
doseChange: "doseMark",
};
export interface PlacedSpan {
item: TimelineItem;
row: number;
y: number;
xStart: number;
xEnd: number;
/** Started before the window: drawn with a fade at the left edge. */
clippedLeft: boolean;
/** A pause, drawn as an outlined gap over the period it interrupts. */
pause: boolean;
}
export interface PlacedPoint {
item: TimelineItem;
row: number;
y: number;
x: number;
shape: PointShape;
}
export interface PlacedLabel {
itemId: string;
text: string;
x: number;
y: number;
/** The person's own life events read as content; other labels as meta. */
strong: boolean;
}
export interface LaneLayout {
key: TimelineLaneKey;
top: number;
height: number;
rows: number;
spans: PlacedSpan[];
points: PlacedPoint[];
labels: PlacedLabel[];
}
/** A span is anything with an end or still open; the rest are points. */
export function isSpan(item: TimelineItem): boolean {
return item.open || (item.end !== null && item.end !== item.start);
}
function inWindow(item: TimelineItem, window: TimeWindow): boolean {
if (item.start > window.to) return false;
if (isSpan(item)) {
return item.open || (item.end ?? item.start) >= window.from;
}
return item.start >= window.from;
}
/**
* The visible text of an item: its label, the dose or second line when it
* has one, and the unknown-start note.
*/
export function itemLabel(item: TimelineItem, startMissing: string): string {
const parts = [item.label];
if (item.sub && item.kind !== "doseChange") parts.push(item.sub);
if (!item.startKnown) parts.push(startMissing);
return parts.filter(Boolean).join(" · ");
}
interface LaneInput {
lane: TimelineLane;
top: number;
}
/**
* Lay out one lane: rows for the periods (first row that is free), points on
* their own row above them, pauses and dose marks over the medication they
* belong to, then the labels in priority order through one occupancy map.
*/
export function layoutLane(
{ lane, top }: LaneInput,
window: TimeWindow,
scale: Scale,
options: { startMissing: string; today: string },
): LaneLayout {
const visible = lane.items.filter((item) => inWindow(item, window));
const pointItems = visible.filter(
(item) => !isSpan(item) && item.kind !== "doseChange",
);
const doseItems = visible.filter((item) => item.kind === "doseChange");
const pauseItems = visible.filter(
(item) => item.kind === "pause" && isSpan(item),
);
const spanItems = visible
.filter((item) => isSpan(item) && item.kind !== "pause")
.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
// Points take the first row(s); procedures sit on a second point row so
// their names never cover the plain visits.
const hasProcedures = pointItems.some((p) => p.kind === "procedure");
const hasPlainPoints = pointItems.some((p) => p.kind !== "procedure");
const pointRows = (hasPlainPoints ? 1 : 0) + (hasProcedures ? 1 : 0);
const procedureRow = hasPlainPoints ? 1 : 0;
const rowY = (row: number) => top + LANE_PAD_TOP + row * ROW_HEIGHT;
const todayX = scale.x(dayNumber(options.today) + 1);
// Period packing: the first row whose last bar ended before this one.
const rowEnds: number[] = [];
const spans: PlacedSpan[] = [];
for (const item of spanItems) {
const rawStart = scale.x(item.start);
const clippedLeft = rawStart < scale.x0;
const xStart = Math.max(scale.x0, rawStart);
const lead = item.startKnown ? 0 : UNKNOWN_START_LEAD;
const endX = item.end
? scale.x(dayNumber(item.end) + 1)
: Math.min(scale.x1, todayX);
const xEnd = Math.min(scale.x1, Math.max(xStart + 4, endX));
let row = rowEnds.findIndex((end) => end + BAR_GAP <= xStart - lead);
if (row === -1) {
row = rowEnds.length;
rowEnds.push(xEnd);
} else {
rowEnds[row] = xEnd;
}
spans.push({
item,
row: row + pointRows,
y: rowY(row + pointRows),
xStart,
xEnd,
clippedLeft,
pause: false,
});
}
// Pauses lie over the period they interrupt; one without a host gets a row.
for (const item of pauseItems) {
const xStart = Math.max(scale.x0, scale.x(item.start));
const xEnd = Math.min(
scale.x1,
item.end ? scale.x(dayNumber(item.end) + 1) : todayX,
);
const host = spans.find(
(s) => !s.pause && s.xStart <= xStart && s.xEnd >= xStart,
);
const row = host ? host.row : pointRows + rowEnds.length;
if (!host) rowEnds.push(xEnd);
spans.push({
item,
row,
y: rowY(row),
xStart,
xEnd: Math.max(xStart + 4, xEnd),
clippedLeft: false,
pause: true,
});
}
const points: PlacedPoint[] = [];
for (const item of pointItems) {
const row = item.kind === "procedure" ? procedureRow : 0;
points.push({
item,
row,
y: rowY(row),
x: scale.xMid(item.start),
shape: POINT_SHAPE[item.kind] ?? "dot",
});
}
// Dose marks sit on the medication line that runs through their date.
for (const item of doseItems) {
const x = scale.xMid(item.start);
const host = spans.find((s) => !s.pause && s.xStart <= x && s.xEnd >= x);
const row = host ? host.row : pointRows;
if (!host && rowEnds.length === 0) rowEnds.push(x);
points.push({ item, row, y: rowY(row), x, shape: "doseMark" });
}
const rows = Math.max(1, pointRows + rowEnds.length);
const height = LANE_PAD_TOP + (rows - 1) * ROW_HEIGHT + LANE_PAD_BOTTOM;
const labels = placeLabels(spans, points, scale, options.startMissing);
return { key: lane.key, top, height, rows, spans, points, labels };
}
/**
* Labels through one occupancy map per text line. Bars and point glyphs are
* registered first as obstacles, so a label never covers a mark either.
*
* Priority: open periods (they define the lane), life events, procedures,
* closed periods from the longest down, dose marks.
*/
function placeLabels(
spans: PlacedSpan[],
points: PlacedPoint[],
scale: Scale,
startMissing: string,
): PlacedLabel[] {
const occ = new LineOccupancy();
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.
occ.add(line(s.row, "on"), s.xStart - lead, s.xEnd + (s.item.open ? 7 : 0));
}
for (const p of points) {
if (p.shape === "doseMark") continue;
const half = GLYPH_HALF_WIDTH[p.shape];
occ.add(line(p.row, "on"), p.x - half, p.x + half);
}
const labels: PlacedLabel[] = [];
const fitsCard = (a: number, b: number) => a >= scale.x0 && b <= scale.x1 - 2;
const openSpans = spans.filter((s) => s.item.open && !s.pause);
const closedSpans = spans
.filter((s) => !s.item.open && !s.pause)
.sort((a, b) => b.xEnd - b.xStart - (a.xEnd - a.xStart));
const pausesWithLabel = spans.filter((s) => s.pause);
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 });
return true;
};
const rightLabel = (
id: string,
row: number,
x: number,
y: number,
text: string,
strong: boolean,
) => {
const a = x;
const b = a + estimateTextWidth(text);
if (!fitsCard(a, b)) 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 });
return true;
};
const leftLabel = (
id: string,
row: number,
xRight: number,
y: number,
text: string,
strong: boolean,
) => {
const b = xRight;
const a = b - estimateTextWidth(text);
if (!fitsCard(a, b)) return false;
if (!occ.tryAdd(line(row, "on"), a, b, BESIDE_MARGIN)) return false;
labels.push({ itemId: id, text, x: a, y: y + 4, strong });
return true;
};
for (const s of openSpans) aboveLabel(s, itemLabel(s.item, startMissing));
// A point's name starts just clear of its own glyph.
const besideGlyph = (p: PlacedPoint) =>
p.x + GLYPH_HALF_WIDTH[p.shape] + BESIDE_MARGIN + 1;
const leftOfGlyph = (p: PlacedPoint) =>
p.x - GLYPH_HALF_WIDTH[p.shape] - BESIDE_MARGIN - 1;
for (const kind of ["lifeEvent", "procedure"] as const) {
const strong = kind === "lifeEvent";
for (const p of points.filter((p) => p.item.kind === kind)) {
if (
rightLabel(p.item.id, p.row, besideGlyph(p), p.y, p.item.label, strong)
)
continue;
leftLabel(p.item.id, p.row, leftOfGlyph(p), p.y, p.item.label, strong);
}
}
// A period's name goes right of its bar, else above it, else left of it.
// A name already shown on the same row (a winter course every year) is
// shown once: the repeats read as the same thing again.
const shownOnRow = new Set<string>();
for (const s of closedSpans.sort((a, b) => a.xStart - b.xStart)) {
const text = itemLabel(s.item, startMissing);
const rowKey = `${s.row}:${text}`;
if (shownOnRow.has(rowKey)) continue;
const strong = s.item.kind === "lifeEvent";
const placed =
rightLabel(s.item.id, s.row, s.xEnd + 6, s.y, text, strong) ||
aboveLabel(s, text) ||
leftLabel(s.item.id, s.row, s.xStart - 6, s.y, text, strong);
if (placed) shownOnRow.add(rowKey);
}
for (const s of pausesWithLabel) aboveLabel(s, s.item.label);
for (const p of points.filter((p) => p.shape === "doseMark")) {
const text = p.item.sub ?? p.item.label;
const a = p.x + 2;
const b = a + estimateTextWidth(text);
if (fitsCard(a, b) && occ.tryAdd(line(p.row, "above"), a, b)) {
labels.push({ itemId: p.item.id, text, x: a, y: p.y - 7, strong: false });
}
}
// A labelled point with no room left is simply drawn without its name.
return labels;
}
/* ─── Value lines ─────────────────────────────────────────────────────────── */
export interface SeriesLayout {
key: string;
unit: string | null;
top: number;
height: number;
/** SVG path data; empty when the window holds no point. */
path: string;
/** The most recent mean inside the window, or null. */
latest: number | null;
points: Array<{ t: string; mean: number; x: number; y: number }>;
}
const BUCKET_DAYS: Record<TimelineSeries["granularity"], number> = {
month: 30,
week: 7,
day: 1,
};
/**
* One neutral line per series, scaled to its own range inside the window.
* A point sits in the middle of its bucket. Missing buckets break the line
* rather than bridging the gap: a straight stroke across eight empty months
* would claim values nobody measured.
*/
export function layoutSeries(
series: readonly TimelineSeries[],
top: number,
window: TimeWindow,
scale: Scale,
): SeriesLayout[] {
return series.map((s, index) => {
const rowTop = top + index * SERIES_HEIGHT + 6;
const height = SERIES_HEIGHT - 16;
const bucket = BUCKET_DAYS[s.granularity];
const inside = s.points
.filter((p) => p.t >= startOfMonthIf(window.from, s) && p.t <= window.to)
.sort((a, b) => (a.t < b.t ? -1 : 1));
if (inside.length === 0) {
return {
key: s.key,
unit: s.unit,
top: rowTop,
height,
path: "",
latest: null,
points: [],
};
}
let lo = Math.min(...inside.map((p) => p.mean));
let hi = Math.max(...inside.map((p) => p.mean));
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
lo -= pad;
hi += pad;
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
const placed = inside.map((p) => {
const mid = dayNumber(p.t) + bucket / 2;
const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
return { t: p.t, mean: p.mean, x, y: y(p.mean) };
});
let path = "";
let prevDay: number | null = null;
for (const p of placed) {
const day = dayNumber(p.t);
const broken = prevDay === null || day - prevDay > bucket * 1.6;
path += `${broken ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
prevDay = day;
}
return {
key: s.key,
unit: s.unit,
top: rowTop,
height,
path,
latest: inside[inside.length - 1].mean,
points: placed,
};
});
}
function startOfMonthIf(from: string, s: TimelineSeries): string {
// A month bucket keyed on the 1st still belongs to a window that starts
// mid-month.
return s.granularity === "month" ? startOfMonth(from) : from;
}
/* ─── Whole chart ─────────────────────────────────────────────────────────── */
export interface TimelineLayout {
width: number;
height: number;
scale: Scale;
lanes: LaneLayout[];
seriesTop: number;
series: SeriesLayout[];
}
/** Width of the lane-name column at a given chart width. */
export function labelColumnWidth(width: number): number {
return width >= 600 ? 164 : 140;
}
export function layoutTimeline(input: {
width: number;
window: TimeWindow;
lanes: readonly TimelineLane[];
series: readonly TimelineSeries[];
startMissing: string;
today: string;
}): TimelineLayout {
const x0 = labelColumnWidth(input.width);
const scale = createScale(input.window, x0, input.width - 8);
let top = AXIS_HEIGHT;
const lanes: LaneLayout[] = [];
for (const key of LANE_ORDER) {
const lane = input.lanes.find((l) => l.key === key);
if (!lane) continue;
const layout = layoutLane({ lane, top }, input.window, scale, {
startMissing: input.startMissing,
today: input.today,
});
// An empty lane is not drawn at all.
if (layout.spans.length === 0 && layout.points.length === 0) continue;
lanes.push(layout);
top += layout.height;
}
const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0);
const series = layoutSeries(input.series, seriesTop, input.window, scale);
const height = seriesTop + series.length * SERIES_HEIGHT + 4;
return { width: input.width, height, scale, lanes, seriesTop, series };
}
/* ─── Interaction ─────────────────────────────────────────────────────────── */
/**
* The date a pointer at (x, y) selects: the date of a mark within reach, the
* start of a period it lands on, otherwise the day under the pointer.
*/
export function dateAtPointer(
layout: TimelineLayout,
x: number,
y: number,
): string {
let best: { distance: number; date: string } | null = null;
for (const lane of layout.lanes) {
if (y < lane.top || y > lane.top + lane.height) continue;
for (const p of lane.points) {
const distance = Math.hypot(p.x - x, (p.y - y) / 2);
if (distance <= HIT_SLOP && (!best || distance < best.distance)) {
best = { distance, date: p.item.start };
}
}
if (best) return best.date;
for (const s of lane.spans) {
if (
Math.abs(s.y - y) <= HIT_SLOP &&
x >= s.xStart - 2 &&
x <= s.xEnd + 2
) {
const startInside = s.item.start >= layout.scale.dayAt(layout.scale.x0);
return startInside ? s.item.start : layout.scale.dayAt(x);
}
}
}
return layout.scale.dayAt(x);
}
/**
* The selection after an arrow key: a day in `quarter`, a month in `year`,
* a year in `all`, never past `today` and never before `floor`.
*/
export function stepSelection(
current: string,
direction: -1 | 1,
zoom: TimelineZoom,
today: string,
floor: string,
): string {
let next: string;
if (zoom === "quarter") next = dayKey(dayNumber(current) + direction);
else if (zoom === "year") next = addMonths(current, direction);
else next = addMonths(current, 12 * direction);
if (next > today) return today;
if (next < floor) return floor;
return next;
}
/**
* What the selection bar names for the month of `selected`: everything that
* happens, starts or ends in it, and every closed period that runs through
* it. A period that has been open since before the month (a chronic
* condition, a standing medication, an allergy) is the backdrop of every
* month and is left out, as are documents, which the day lists with their
* visit. Lane order kept.
*/
export function itemsInMonth(
lanes: readonly TimelineLane[],
selected: string,
today: string,
): Array<{ lane: TimelineLaneKey; item: TimelineItem; through: boolean }> {
const from = startOfMonth(selected);
const to = endOfMonth(selected);
const out: Array<{
lane: TimelineLaneKey;
item: TimelineItem;
through: boolean;
}> = [];
for (const key of LANE_ORDER) {
if (key === "documents") continue;
const lane = lanes.find((l) => l.key === key);
if (!lane) continue;
for (const item of lane.items) {
const end = item.open ? today : (item.end ?? item.start);
if (item.start > to || end < from) continue;
const startsInside = item.start >= from;
if (item.open && !startsInside) continue;
const endsInside = !item.open && end <= to;
out.push({ lane: key, item, through: !startsInside && !endsInside });
}
}
return out;
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
/**
* The two menus above the chart (v1.42, #613): which value lines run under
* the lanes (at most three), and which lanes show, with the way back into
* the readiness inventory ("Data coverage").
*/
import { ChartLine, ChevronDown, Layers } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { TimelineLaneKey } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
/** The most value lines the chart runs under its lanes. */
export const MAX_VALUE_SERIES = 3;
export function ValueSeriesMenu({
options,
selected,
label,
onChange,
}: {
options: readonly string[];
selected: readonly string[];
label: (key: string) => string;
onChange: (next: string[]) => void;
}) {
const { t } = useTranslations();
const summary =
selected.length > 0
? selected.map(label).join(", ")
: t("timeline.values.none");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 max-w-full sm:min-h-9"
data-slot="timeline-values-trigger"
>
<ChartLine className="size-4" aria-hidden="true" />
<span className="truncate">{summary}</span>
<ChevronDown className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>{t("timeline.values.menuLabel")}</DropdownMenuLabel>
{options.map((key) => {
const checked = selected.includes(key);
const full = !checked && selected.length >= MAX_VALUE_SERIES;
return (
<DropdownMenuCheckboxItem
key={key}
checked={checked}
disabled={full}
data-slot="timeline-values-option"
data-key={key}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(next) =>
onChange(
next ? [...selected, key] : selected.filter((k) => k !== key),
)
}
>
{label(key)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function LayersMenu({
lanes,
hidden,
onToggle,
onOpenReadiness,
iconOnly = false,
}: {
lanes: readonly TimelineLaneKey[];
hidden: ReadonlySet<TimelineLaneKey>;
onToggle: (lane: TimelineLaneKey, visible: boolean) => void;
onOpenReadiness: () => void;
iconOnly?: boolean;
}) {
const { t } = useTranslations();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size={iconOnly ? "icon" : "sm"}
className={iconOnly ? "size-11" : "min-h-11 sm:min-h-9"}
aria-label={iconOnly ? t("timeline.layers") : undefined}
data-slot="timeline-layers-trigger"
>
<Layers className="size-4" aria-hidden="true" />
{!iconOnly && t("timeline.layers")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>{t("timeline.layersLanes")}</DropdownMenuLabel>
{lanes.map((lane) => (
<DropdownMenuCheckboxItem
key={lane}
checked={!hidden.has(lane)}
data-slot="timeline-layer-option"
data-lane={lane}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(next) => onToggle(lane, next === true)}
>
{t(`timeline.lanes.${lane}`)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
data-slot="timeline-open-readiness"
onSelect={onOpenReadiness}
>
{t("timeline.readiness.menuEntry")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+480
View File
@@ -0,0 +1,480 @@
"use client";
/**
* `/timeline` (v1.42, #613): years at a glance. Conditions, allergies,
* medications, vaccinations, visits, documents and the person's own life
* events in lanes, up to three neutral value lines below them, all on one
* time axis. "Stacked means at the same time, not cause and effect" is said
* once, in the legend, and nothing on the page draws a connection.
*
* The page hands off to the day view through `?day=`: "Open 3 Jan." (and
* Enter on the chart, a double click, a chip, a chronicle row) pushes the
* parameter onto the current entry's URL, the day layer opens over the page,
* and Back closes it. An incoming `?day=` selects that day and zooms to
* three months, so a day's "In the timeline" lands on itself.
*
* At 768 px and up the chart; below it the chronicle, which is also the
* list form of the chart. The readiness card sits on top while a lane is
* thin and the person has not closed it; empty lanes are never drawn.
*/
import { useMemo, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
DAY_QUERY_PARAM,
TIMELINE_ZOOMS,
type LifeEventDTO,
type TimelineLaneKey,
type TimelineZoom,
} from "@/lib/day/contract";
import type { MeasurementType } from "@/generated/prisma/client";
import { useTranslations } from "@/lib/i18n/context";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ChronicleGrouping } from "./chronicle-model";
import { LifeEventSheet } from "./life-event-sheet";
import { ReadinessCard } from "./readiness-card";
import {
READINESS_CARD_DISMISSED_KEY,
showReadinessCard,
} from "./readiness-model";
import { TimelineReadinessSheet } from "./readiness-sheet";
import { Segmented } from "./segmented";
import { SelectionBar } from "./selection-bar";
import { TimelineChart } from "./timeline-chart";
import { TimelineChronicle } from "./timeline-chronicle";
import { acceptableDayParam, todayKeyIn } from "./timeline-dates";
import { LANE_ORDER, windowFor } from "./timeline-geometry";
import {
LayersMenu,
MAX_VALUE_SERIES,
ValueSeriesMenu,
} from "./timeline-menus";
import {
isBoolean,
isStringArray,
useDevicePreference,
} from "./use-device-preference";
import {
useLifeEvents,
useTimeline,
useTimelineReadiness,
} from "./use-timeline";
/**
* Value lines offered in the selector, in this order, with the module that
* owns each (null: core, never hidden). Spelled out rather than asked of
* `moduleForMeasurementType`, whose signal registry would ride into this
* chunk for eleven answers; `value-options.test.ts` holds the two equal.
*/
export const VALUE_OPTION_MODULE = {
BLOOD_PRESSURE_SYS: null,
BLOOD_PRESSURE_DIA: null,
WEIGHT: null,
RESTING_HEART_RATE: "recovery",
PULSE: null,
SLEEP_DURATION: "sleep",
BLOOD_GLUCOSE: "glucose",
BODY_FAT: null,
HEART_RATE_VARIABILITY: "recovery",
ACTIVITY_STEPS: null,
MOOD: "mood",
} as const satisfies Record<string, ModuleKey | null>;
export const VALUE_OPTIONS = Object.keys(VALUE_OPTION_MODULE) as Array<
keyof typeof VALUE_OPTION_MODULE
>;
export const DEFAULT_VALUES = [
"BLOOD_PRESSURE_SYS",
"WEIGHT",
"RESTING_HEART_RATE",
];
const VALUES_KEY = "healthlog.timeline.values";
const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes";
/** The `?day=` URL for the current page, other parameters kept. */
export function dayHref(
pathname: string,
search: string,
date: string,
): string {
const next = new URLSearchParams(search);
next.set(DAY_QUERY_PARAM, date);
return `${pathname}?${next.toString()}`;
}
/** The latest dated entry on or before today, to select on arrival. */
function latestEntryDate(
lanes: ReadonlyArray<{
items: ReadonlyArray<{ start: string; end: string | null }>;
}>,
today: string,
): string | null {
let best: string | null = null;
for (const lane of lanes) {
for (const item of lane.items) {
for (const d of [item.start, item.end]) {
if (d && d <= today && (!best || d > best)) best = d;
}
}
}
return best;
}
export function TimelineView() {
const { t } = useTranslations();
const { user } = useAuth();
const caps = useRecordCapabilities();
const pathname = usePathname();
const searchParams = useSearchParams();
const today = todayKeyIn(user?.timezone);
const dayParam = acceptableDayParam(searchParams.get(DAY_QUERY_PARAM), today);
// Life events are written in one's own record only (owner-only routes).
const canAddLifeEvent = !caps.inSharedRecord;
const [zoom, setZoom] = useState<TimelineZoom>(dayParam ? "quarter" : "all");
const [grouping, setGrouping] = useState<ChronicleGrouping>("month");
// The selection follows `?day=` (Back, Forward, the day panel's previous
// and next) and otherwise the last click or key. A pick remembers the
// parameter it was made under; once the parameter moves, the parameter wins.
const [picked, setPicked] = useState<{
date: string;
param: string | null;
} | null>(null);
const selected =
picked && picked.param === dayParam
? picked.date
: (dayParam ?? picked?.date ?? null);
const setSelected = (date: string) => setPicked({ date, param: dayParam });
const [readinessOpen, setReadinessOpen] = useState(false);
const [lifeEventOpen, setLifeEventOpen] = useState(false);
const [editing, setEditing] = useState<LifeEventDTO | null>(null);
const [storedValues, setStoredValues] = useDevicePreference(
VALUES_KEY,
DEFAULT_VALUES,
isStringArray,
);
const [hiddenList, setHiddenList] = useDevicePreference<string[]>(
HIDDEN_LANES_KEY,
[],
isStringArray,
);
const [cardDismissed, setCardDismissed] = useDevicePreference(
READINESS_CARD_DISMISSED_KEY,
false,
isBoolean,
);
const modules = user?.modules;
const valueOptions = useMemo(
() =>
VALUE_OPTIONS.filter((key) => {
const owner: ModuleKey | null = VALUE_OPTION_MODULE[key];
return owner === null || modules?.[owner] !== false;
}),
[modules],
);
const values = useMemo(
() =>
storedValues
.filter((v) => (valueOptions as readonly string[]).includes(v))
.slice(0, MAX_VALUE_SERIES),
[storedValues, valueOptions],
);
const hidden = useMemo(
() => new Set(hiddenList as TimelineLaneKey[]),
[hiddenList],
);
const requestWindow =
zoom === "all" ? null : windowFor(zoom, today, selected ?? dayParam);
const timeline = useTimeline(
zoom,
requestWindow?.from ?? null,
requestWindow?.to ?? null,
values,
);
const readiness = useTimelineReadiness();
const lifeEvents = useLifeEvents(canAddLifeEvent);
// A readiness link (`?add=lifeEvent`) arrives with the sheet open; closing
// it takes the parameter out of the URL, so a reload does not reopen it.
const addRequested =
searchParams.get("add") === "lifeEvent" && canAddLifeEvent;
const sheetOpen = lifeEventOpen || addRequested;
function setSheetOpen(open: boolean) {
setLifeEventOpen(open);
if (open) return;
setEditing(null);
if (searchParams.has("add")) {
const next = new URLSearchParams(searchParams.toString());
next.delete("add");
const search = next.toString();
window.history.replaceState(
null,
"",
search ? `${pathname}?${search}` : pathname,
);
}
}
const data = timeline.data;
const effectiveSelected =
selected ?? (data ? latestEntryDate(data.lanes, today) : null) ?? today;
function openDay(date: string) {
if (date > today) return;
setPicked({ date, param: date });
window.history.pushState(
{ __healthlogTimelineDay: date },
"",
dayHref(pathname, searchParams.toString(), date),
);
}
function editLifeEvent(id: string) {
const event = lifeEvents.data?.events.find((e) => e.id === id);
if (!event) return;
setEditing(event);
setLifeEventOpen(true);
}
// Blood pressure reads as its own name here; the list labels ("Sys",
// "Dia") lean on a column header the chart does not have.
const seriesLabel = (key: string) =>
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
? t(`timeline.values.series.${key}`)
: key === "MOOD"
? t("timeline.readiness.lanes.mood")
: t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key);
const window_ = data
? windowFor(
zoom,
today,
effectiveSelected,
zoom === "all" ? data.range : null,
)
: null;
const presentLanes = (data?.lanes ?? [])
.filter((l) => l.items.length > 0)
.map((l) => l.key)
.sort((a, b) => LANE_ORDER.indexOf(a) - LANE_ORDER.indexOf(b));
const isEmpty =
!!data &&
presentLanes.length === 0 &&
data.standing.length === 0 &&
data.series.every((s) => s.points.length === 0);
const zoomOptions = TIMELINE_ZOOMS.map((z) => ({
value: z,
label: t(`timeline.zoom.${z}`),
}));
const groupingOptions: Array<{ value: ChronicleGrouping; label: string }> = [
{ value: "year", label: t("timeline.chronicle.years") },
{ value: "month", label: t("timeline.chronicle.months") },
];
const addButton = canAddLifeEvent ? (
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => {
setEditing(null);
setSheetOpen(true);
}}
aria-label={t("lifeEvents.add")}
data-slot="timeline-add-life-event"
>
<Plus className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">{t("lifeEvents.add")}</span>
</Button>
) : null;
const layers = (iconOnly: boolean) => (
<LayersMenu
lanes={presentLanes}
hidden={hidden}
iconOnly={iconOnly}
onToggle={(lane, visible) =>
setHiddenList(
visible
? hiddenList.filter((l) => l !== lane)
: [...hiddenList.filter((l) => l !== lane), lane],
)
}
onOpenReadiness={() => setReadinessOpen(true)}
/>
);
return (
<div className="space-y-6" data-slot="timeline-page">
<PageHeader
title={t("timeline.title")}
description={t("timeline.subtitle")}
actions={addButton}
/>
{readiness.data && showReadinessCard(readiness.data, cardDismissed) && (
<ReadinessCard
readiness={readiness.data}
onDismiss={() => setCardDismissed(true)}
/>
)}
{timeline.isPending ? (
<Card data-slot="timeline-loading">
<CardContent className="space-y-3">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
) : timeline.isError ? (
<QueryErrorCard
description={t("timeline.loadFailed")}
onRetry={() => void timeline.refetch()}
/>
) : isEmpty || !data || !window_ ? (
<EmptyState
title={t("timeline.empty.title")}
description={t("timeline.empty.description")}
action={addButton}
/>
) : (
<>
{/* Desktop and tablet: the chart. */}
<div
className="hidden space-y-4 md:block"
data-slot="timeline-desktop"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<Segmented
options={zoomOptions}
value={zoom}
onChange={setZoom}
label={t("timeline.zoomLabel")}
slot="timeline-zoom"
/>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<ValueSeriesMenu
options={valueOptions}
selected={values}
label={seriesLabel}
onChange={setStoredValues}
/>
{layers(false)}
</div>
</div>
<Card className="md:gap-4">
<CardContent className="space-y-4">
<TimelineChart
timeline={data}
window={window_}
zoom={zoom}
today={today}
selected={effectiveSelected}
hiddenLanes={hidden}
seriesLabel={seriesLabel}
onSelect={setSelected}
onOpenDay={openDay}
/>
<SelectionBar
timeline={data}
selected={effectiveSelected}
today={today}
onOpenDay={openDay}
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined}
/>
<Legend />
</CardContent>
</Card>
</div>
{/* Phone: the chronicle. */}
<div className="space-y-4 md:hidden" data-slot="timeline-mobile">
<div className="flex items-center justify-between gap-3">
<Segmented
options={groupingOptions}
value={grouping}
onChange={setGrouping}
label={t("timeline.chronicle.groupingLabel")}
slot="timeline-grouping"
/>
{layers(true)}
</div>
<TimelineChronicle
timeline={{
...data,
lanes: data.lanes.filter((l) => !hidden.has(l.key)),
}}
today={today}
grouping={grouping}
selected={dayParam}
onOpenDay={openDay}
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : null}
/>
</div>
</>
)}
<TimelineReadinessSheet
open={readinessOpen}
onOpenChange={setReadinessOpen}
showOpenTimeline={false}
/>
{canAddLifeEvent && (
<LifeEventSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
event={editing}
/>
)}
</div>
);
}
function Legend() {
const { t } = useTranslations();
return (
<div
className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs"
data-slot="timeline-legend"
>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground h-1.5 w-4 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendSpan")}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground size-2 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendEvent")}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
{t("timeline.legendMean")}
</span>
<span className="sm:ml-auto" data-slot="timeline-legend-causality">
{t("timeline.legend")}
</span>
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
/**
* A small preference kept on this device only (v1.42, #613): which value
* lines run under the chart, which lanes are hidden, whether the readiness
* card was closed. Conveniences, never record state: storage can be empty,
* blocked or throw, and the timeline then simply starts from its defaults.
*/
import { useCallback, useState } from "react";
export function readDevicePreference<T>(
key: string,
fallback: T,
accept: (raw: unknown) => raw is T,
): T {
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return fallback;
const parsed: unknown = JSON.parse(raw);
return accept(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
export function useDevicePreference<T>(
key: string,
fallback: T,
accept: (raw: unknown) => raw is T,
): [T, (next: T) => void] {
// Read once, on the client: the timeline mounts only after the account
// resolved, so there is no server render of it to disagree with.
const [value, setValue] = useState<T>(() =>
typeof window === "undefined"
? fallback
: readDevicePreference(key, fallback, accept),
);
const update = useCallback(
(next: T) => {
setValue(next);
try {
window.localStorage.setItem(key, JSON.stringify(next));
} catch {
// Storage unavailable: the choice holds for this visit only.
}
},
[key],
);
return [value, update];
}
export const isStringArray = (raw: unknown): raw is string[] =>
Array.isArray(raw) && raw.every((v) => typeof v === "string");
export const isBoolean = (raw: unknown): raw is boolean =>
typeof raw === "boolean";
+95
View File
@@ -0,0 +1,95 @@
"use client";
/**
* Reads and writes behind the timeline (v1.42, #613).
*
* Every key comes from the factory (`queryKeys.timeline*`), every read
* unwraps the envelope through `apiGet`. A life-event write changes the
* lanes, the readiness inventory, the event list and any open day, so it
* evicts the timeline root and the day root together.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import type {
LifeEventCreateInput,
LifeEventDTO,
LifeEventListResponse,
LifeEventUpdateInput,
TimelineReadinessResponse,
TimelineResponse,
TimelineZoom,
} from "@/lib/day/contract";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
/** The query string for `GET /api/timeline`. */
export function timelinePath(
zoom: TimelineZoom,
from: string | null,
to: string | null,
values: readonly string[],
): string {
const params = new URLSearchParams({ zoom });
if (from) params.set("from", from);
if (to) params.set("to", to);
if (values.length > 0) params.set("values", values.join(","));
return `/api/timeline?${params.toString()}`;
}
export function useTimeline(
zoom: TimelineZoom,
from: string | null,
to: string | null,
values: readonly string[],
) {
return useQuery({
queryKey: queryKeys.timeline(zoom, from, to, values.join(",")),
queryFn: () =>
apiGet<TimelineResponse>(timelinePath(zoom, from, to, values)),
// Moving between zoom levels keeps the last answer on screen until the
// next one arrives, rather than blanking the chart.
placeholderData: (previous) => previous,
});
}
export function useTimelineReadiness(enabled = true) {
return useQuery({
queryKey: queryKeys.timelineReadiness(),
enabled,
queryFn: () => apiGet<TimelineReadinessResponse>("/api/timeline/readiness"),
});
}
export function useLifeEvents(enabled = true) {
return useQuery({
queryKey: queryKeys.lifeEvents(),
enabled,
queryFn: () => apiGet<LifeEventListResponse>("/api/life-events"),
});
}
export function useLifeEventMutations() {
const qc = useQueryClient();
const invalidate = () =>
invalidateKeys(qc, [queryKeys.timelineRoot(), queryKeys.dayRoot()]);
const create = useMutation({
mutationFn: (body: LifeEventCreateInput) =>
apiPost<LifeEventDTO>("/api/life-events", body),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, body }: { id: string; body: LifeEventUpdateInput }) =>
apiPatch<LifeEventDTO>(
`/api/life-events/${encodeURIComponent(id)}`,
body,
),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) =>
apiDelete<unknown>(`/api/life-events/${encodeURIComponent(id)}`),
onSuccess: invalidate,
});
return { create, update, remove };
}
@@ -279,6 +279,7 @@ describe("every surface that shows a module's data is owned by it", () => {
["capture:medication", "medications"],
["capture:workout", "workouts"],
["capture:symptom", "illness"],
["capture:lifeEvent", "timeline"],
["trend:mood", "mood"],
["widget:medications", "medications"],
["correlation:MEDICATION_COMPLIANCE", "medications"],
+1
View File
@@ -133,6 +133,7 @@ const STATIC_SURFACE_MODULE = {
"capture:workout": "workouts",
// v1.40 — an occurrence of a person-defined symptom rides the illness module.
"capture:symptom": "illness",
"capture:lifeEvent": "timeline",
// ── Dashboard widgets ──
// CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived