Merge v142/shots-fixes into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 14:22:13 +02:00
commit cda6c58d12
45 files changed
+2050 -138

No files matched your search

+8
View File
@@ -81439,10 +81439,14 @@ components:
type: string
title:
type: string
description: "The record's own name, or a code the client words: a mood, a screener's instrument, a workout's sport,
`cycle` for a cycle day."
meta:
anyOf:
- type: string
- type: "null"
description: "The second line: text (a dose, a lab value, a duration) or a code or number the client words (a category,
a kind, a severity, a screener's score and band, an intensity, a dose number)."
note:
anyOf:
- type: string
@@ -81672,10 +81676,14 @@ components:
description: False when the start is a stand-in (a medication without a start date starts at its first intake).
label:
type: string
description: The item's name. A trip is labelled `travel` and a cycle `cycle`; the client words both.
sub:
anyOf:
- type: string
- type: "null"
description: "The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter
kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a
dose change. The client words every code."
href:
anyOf:
- type: string
+6 -2
View File
@@ -165,9 +165,13 @@ test.describe("document vault", () => {
const firstRow = page.locator('[data-slot="document-month-row"]').first();
await expect(firstRow).toBeVisible({ timeout: 15_000 });
// Month headings stay the grid's section labels.
// The months run side by side by default: a month is marked above
// its first document instead of taking a heading row of its own.
await expect(
page.locator('[data-slot="document-timeline"] h2').first(),
page.locator('[data-slot="document-timeline"]'),
).toHaveAttribute("data-arrangement", "flow");
await expect(
page.locator('[data-slot="document-flow-month"]').first(),
).toBeVisible();
// Tiles with the preview on top, and at most `columns` side by side:
+14 -1
View File
@@ -12284,7 +12284,15 @@
"note": "Zusammengestellt aus deinen Einträgen, ohne Bewertung. Jedes Datum öffnet den ganzen Tag.",
"report": "Arztbericht {date} bis heute"
},
"travel": "Unterwegs"
"travel": "Unterwegs",
"event": {
"started": "{label} begonnen",
"ended": "{label} beendet",
"paused": "{label} pausiert",
"resumed": "{label} wieder aufgenommen",
"illnessBegan": "{label} begann",
"illnessResolved": "{label} abgeklungen"
}
},
"timeline": {
"title": "Zeitleiste",
@@ -12487,6 +12495,11 @@
"label": "Gewählter Zeitraum",
"open": "Zeitraum wählen",
"clear": "Alles zeigen"
},
"item": {
"labValuesOne": "1 Laborwert",
"labValuesFew": "{count} Laborwerte",
"labValuesOther": "{count} Laborwerte"
}
},
"lifeEvents": {
+14 -1
View File
@@ -12284,7 +12284,15 @@
"note": "Compiled from your entries, without judgement. Every date opens the whole day.",
"report": "Doctor report from {date} to today"
},
"travel": "Away"
"travel": "Away",
"event": {
"started": "{label} started",
"ended": "{label} ended",
"paused": "{label} paused",
"resumed": "{label} resumed",
"illnessBegan": "{label} began",
"illnessResolved": "{label} resolved"
}
},
"timeline": {
"title": "Timeline",
@@ -12487,6 +12495,11 @@
"label": "Chosen range",
"open": "Choose a range",
"clear": "Show everything"
},
"item": {
"labValuesOne": "1 lab value",
"labValuesFew": "{count} lab values",
"labValuesOther": "{count} lab values"
}
},
"lifeEvents": {
+14 -1
View File
@@ -12284,7 +12284,15 @@
"note": "Reunido a partir de tus entradas, sin valoración. Cada fecha abre el día completo.",
"report": "Informe médico del {date} hasta hoy"
},
"travel": "De viaje"
"travel": "De viaje",
"event": {
"started": "Inicio: {label}",
"ended": "Fin: {label}",
"paused": "Pausa: {label}",
"resumed": "Reanudación: {label}",
"illnessBegan": "Inicio: {label}",
"illnessResolved": "Recuperación: {label}"
}
},
"timeline": {
"title": "Cronología",
@@ -12487,6 +12495,11 @@
"label": "Periodo elegido",
"open": "Elegir un periodo",
"clear": "Mostrar todo"
},
"item": {
"labValuesOne": "1 valor de laboratorio",
"labValuesFew": "{count} valores de laboratorio",
"labValuesOther": "{count} valores de laboratorio"
}
},
"lifeEvents": {
+14 -1
View File
@@ -12284,7 +12284,15 @@
"note": "Établi à partir de vos entrées, sans évaluation. Chaque date ouvre la journée entière.",
"report": "Rapport médical du {date} à aujourd’hui"
},
"travel": "En déplacement"
"travel": "En déplacement",
"event": {
"started": "Début : {label}",
"ended": "Fin : {label}",
"paused": "Pause : {label}",
"resumed": "Reprise : {label}",
"illnessBegan": "Début : {label}",
"illnessResolved": "Guérison : {label}"
}
},
"timeline": {
"title": "Chronologie",
@@ -12487,6 +12495,11 @@
"label": "Période choisie",
"open": "Choisir une période",
"clear": "Tout afficher"
},
"item": {
"labValuesOne": "1 valeur de laboratoire",
"labValuesFew": "{count} valeurs de laboratoire",
"labValuesOther": "{count} valeurs de laboratoire"
}
},
"lifeEvents": {
+14 -1
View File
@@ -12284,7 +12284,15 @@
"note": "Raccolto dalle tue voci, senza valutazioni. Ogni data apre l’intera giornata.",
"report": "Referto medico dal {date} a oggi"
},
"travel": "In viaggio"
"travel": "In viaggio",
"event": {
"started": "Inizio: {label}",
"ended": "Fine: {label}",
"paused": "Pausa: {label}",
"resumed": "Ripresa: {label}",
"illnessBegan": "Inizio: {label}",
"illnessResolved": "Guarigione: {label}"
}
},
"timeline": {
"title": "Cronologia",
@@ -12487,6 +12495,11 @@
"label": "Periodo scelto",
"open": "Scegli un periodo",
"clear": "Mostra tutto"
},
"item": {
"labValuesOne": "1 valore di laboratorio",
"labValuesFew": "{count} valori di laboratorio",
"labValuesOther": "{count} valori di laboratorio"
}
},
"lifeEvents": {
+14 -1
View File
@@ -12284,7 +12284,15 @@
"note": "평가 없이 기록에서 모았어요. 날짜를 누르면 그날 전체가 열려요.",
"report": "{date}부터 오늘까지 진료 보고서"
},
"travel": "여행 중"
"travel": "여행 중",
"event": {
"started": "{label} 시작",
"ended": "{label} 끝남",
"paused": "{label} 일시 중지",
"resumed": "{label} 다시 시작",
"illnessBegan": "{label} 시작",
"illnessResolved": "{label} 나음"
}
},
"timeline": {
"title": "타임라인",
@@ -12487,6 +12495,11 @@
"label": "선택한 기간",
"open": "기간 선택",
"clear": "전체 보기"
},
"item": {
"labValuesOne": "검사 수치 {count}개",
"labValuesFew": "검사 수치 {count}개",
"labValuesOther": "검사 수치 {count}개"
}
},
"lifeEvents": {
+14 -1
View File
@@ -12284,7 +12284,15 @@
"note": "Zestawione z Twoich wpisów, bez oceny. Każda data otwiera cały dzień.",
"report": "Raport lekarski od {date} do dziś"
},
"travel": "W podróży"
"travel": "W podróży",
"event": {
"started": "Początek: {label}",
"ended": "Koniec: {label}",
"paused": "Wstrzymano: {label}",
"resumed": "Wznowiono: {label}",
"illnessBegan": "Początek: {label}",
"illnessResolved": "Wyzdrowienie: {label}"
}
},
"timeline": {
"title": "Oś czasu",
@@ -12487,6 +12495,11 @@
"label": "Wybrany zakres",
"open": "Wybierz zakres",
"clear": "Pokaż wszystko"
},
"item": {
"labValuesOne": "1 wynik badania",
"labValuesFew": "{count} wyniki badań",
"labValuesOther": "{count} wyników badań"
}
},
"lifeEvents": {
+24
View File
@@ -1,8 +1,31 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
import { version as PKG_VERSION } from "./package.json";
/**
* The message catalogs' content hash, the cache key of the boot script
* (`/i18n/<locale>.js?v=…`, `src/app/layout.tsx`). The service worker serves
* that URL cache-first, so a key that does not move with the catalog keeps
* an old catalog alive: keyed by the package version, every build of one
* release (a beta, a redeploy) shared one URL, and a key added since the
* first of them read as its raw name.
*/
function catalogVersion(): string {
const dir = join(process.cwd(), "messages");
const hash = createHash("sha256");
for (const file of readdirSync(dir)
.filter((f) => f.endsWith(".json"))
.sort()) {
hash.update(file).update(readFileSync(join(dir, file)));
}
return hash.digest("hex").slice(0, 16);
}
const nextConfig: NextConfig = {
output: "standalone",
// `next build` type-checks the whole tsconfig program since 16.3, which
@@ -20,6 +43,7 @@ const nextConfig: NextConfig = {
// had to discover "pull-to-refresh" themselves after every release.
env: {
NEXT_PUBLIC_APP_VERSION: PKG_VERSION,
NEXT_PUBLIC_I18N_CATALOG_VERSION: catalogVersion(),
},
// v1.4.33 IW2 — strip `console.*` calls from the production bundle
// (keep `console.error` + `console.warn` so the GlitchTip reporter
+3 -2
View File
@@ -230,8 +230,9 @@ self.addEventListener("fetch", (event) => {
return;
}
// Locale-catalog boot script — cache-first. The URL is versioned
// (`/i18n/<locale>?v=<build>`) and served immutable, so cache-first is
// Locale-catalog boot script — cache-first. The URL is versioned by the
// catalogs' content hash (`/i18n/<locale>?v=<hash>`, `next.config.ts`),
// so a changed catalog is a new URL and cache-first is
// correct and keeps the catalog available on an offline relaunch (the
// cached shell HTML references the same versioned URL it was rendered
// with). Carries no user data — same public strings as the repository's
@@ -102,6 +102,8 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly<Record<string, string>> = {
// --- Baked at build time by the Dockerfile's ARG/ENV pairs. ---
NEXT_PUBLIC_APP_VERSION:
"Dockerfile build arg; a runtime value cannot change what is compiled in",
NEXT_PUBLIC_I18N_CATALOG_VERSION:
"computed by next.config.ts from the message catalogs at build time",
NEXT_PUBLIC_APP_BUILD_SHA:
"Dockerfile build arg, stamped from the release commit",
NEXT_PUBLIC_APP_BUILT_AT: "Dockerfile build arg, stamped at image build",
@@ -0,0 +1,31 @@
/**
* The locale catalog's boot script is cached by its URL: the service worker
* serves `/i18n/<locale>.js?v=…` cache-first. Keyed by the package version,
* every build of one release shared one URL, so a beta updated within a
* release kept its first catalog and every key added since read as its raw
* name ("timeline.zoom.range"). The key is the catalogs' content hash now.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8");
describe("the locale catalog's cache key", () => {
it("moves with the catalogs, not with the release", () => {
const layout = read("src/app/layout.tsx");
const src =
/src=\{`\/i18n\/\$\{initialLocale\}\.js\?v=\$\{([^}]*)\}`\}/.exec(layout);
expect(src?.[1]).toContain("NEXT_PUBLIC_I18N_CATALOG_VERSION");
expect(src?.[1]).not.toContain("NEXT_PUBLIC_APP_VERSION");
});
it("is computed from the message catalogs at build time", () => {
const config = read("next.config.ts");
expect(config).toMatch(
/NEXT_PUBLIC_I18N_CATALOG_VERSION:\s*catalogVersion\(\)/,
);
expect(config).toMatch(/join\(process\.cwd\(\), "messages"\)/);
});
});
+3 -1
View File
@@ -131,7 +131,9 @@ export default async function RootLayout({
id="healthlog-i18n-boot"
strategy="beforeInteractive"
nonce={nonce}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION || "dev"}`}
// Keyed by the catalogs' content (`next.config.ts`), not the
// release: the service worker serves this URL cache-first.
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_I18N_CATALOG_VERSION || "dev"}`}
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
-1
View File
@@ -1780,7 +1780,6 @@ export default function DashboardPageClient({
title={t("dashboard.bloodPressure")}
colors={["var(--chart-3)", "var(--chart-4)"]}
unit="mmHg"
yAxisUnit="Hg"
targetZones={bpTargetZones}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
@@ -80,6 +80,7 @@ describe("Coach day chips", () => {
describe("visit preparation", () => {
const spell = {
changeTitle: (_kind: string, title: string) => title,
notableTitle: () => "Blood pressure",
notableText: () => "Highest daily value since March",
countMeta: (count: number) => (count > 1 ? `${count} entries` : null),
+20 -1
View File
@@ -7,7 +7,8 @@ import { describe, expect, it } from "vitest";
import { dayTitle } from "../day-sections";
const t = (key: string) => `t:${key}`;
const t = (key: string, params?: Record<string, string | number>) =>
params ? `t:${key}(${Object.values(params).join(",")})` : `t:${key}`;
describe("dayTitle", () => {
it("words the cycle and travel sentinels", () => {
@@ -20,4 +21,22 @@ describe("dayTitle", () => {
expect(dayTitle("medication", "cycle", t)).toBe("cycle");
expect(dayTitle("document", "travel", t)).toBe("travel");
});
it("says what happened to a medication, not only its name", () => {
expect(dayTitle("pauseStart", "Ramipril", t)).toBe(
"t:day.event.paused(Ramipril)",
);
expect(dayTitle("pauseEnd", "Ramipril", t)).toBe(
"t:day.event.resumed(Ramipril)",
);
expect(dayTitle("medicationPause", "Ramipril", t)).toBe(
"t:day.event.paused(Ramipril)",
);
expect(dayTitle("medicationStart", "Ramipril", t)).toBe(
"t:day.event.started(Ramipril)",
);
expect(dayTitle("medicationEnd", "Ramipril", t)).toBe(
"t:day.event.ended(Ramipril)",
);
});
});
+125 -36
View File
@@ -24,7 +24,6 @@ import {
} from "lucide-react";
import {
LIFE_EVENT_CATEGORIES,
type DayEvent,
type DayEventKind,
type DayNotable,
@@ -32,7 +31,14 @@ import {
type DayRunningKind,
type DayValue,
} from "@/lib/day/contract";
import { visitKindWords } from "@/components/timeline/item-words";
import {
DOCUMENT_KIND_KEY,
LIFE_EVENT_CATEGORY_KEY,
keyOf,
} from "@/components/timeline/label-keys";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
import { cn } from "@/lib/utils";
import type { DayFocus } from "./day-layer-controller";
@@ -42,6 +48,15 @@ import {
tileKeyOf,
type DayValueTile,
} from "./day-values-model";
import {
ALLERGY_SEVERITY_KEY,
ASSESSMENT_BAND_KEY,
ASSESSMENT_INSTRUMENT_KEY,
CYCLE_FLOW_KEY,
DAY_NOTABLE_KEY,
ILLNESS_IMPACT_KEY,
WORKOUT_SPORT_KEY,
} from "./label-keys";
import { useDayValueFormat } from "./use-day-value-format";
/**
@@ -112,7 +127,7 @@ export function useNotableText() {
timeZone: "UTC",
}).format(new Date(`${since}T12:00:00.000Z`));
}
return t(`day.notable.${notable.kind}`, params);
return t(DAY_NOTABLE_KEY[notable.kind], params);
};
}
@@ -161,43 +176,116 @@ const RUNNING_COLOR: Record<DayRunningKind, string> = {
lifeEvent: "var(--chart-2)",
};
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* The server names a few records by a sentinel rather than by text it would
* have to translate: a cycle is titled "cycle", a trip "travel", a cycle
* day carries its flow level. They are worded here, in the reader's
* language; every other title is the record's own text. The record's kind
* decides, not the title: a medication or a document the person named
* "cycle" keeps its own name.
* The server sends what the record holds and leaves the words to the reader's
* language: a cycle is titled "cycle" and a trip "travel", a mood entry
* carries its mood code, a screener its instrument, a workout its sport, and
* a start, an end, a pause or a resumption the bare name of what started,
* ended, paused or resumed. They are worded here. The record's kind decides,
* not the title: a medication or a document the person named "cycle" keeps
* its own name.
*/
export function dayTitle(
kind: string,
title: string,
t: (key: string) => string,
): string {
if (kind === "cyclePhase" || kind === "cycleDayLog") return t("nav.cycle");
if (kind === "travel") return t("day.travel");
return title;
export function dayTitle(kind: string, title: string, t: Translate): string {
switch (kind) {
case "cyclePhase":
case "cycleDayLog":
return t("nav.cycle");
case "travel":
return t("day.travel");
case "medicationStart":
case "courseStart":
return t("day.event.started", { label: title });
case "medicationEnd":
case "courseEnd":
return t("day.event.ended", { label: title });
case "pauseStart":
case "medicationPause":
return t("day.event.paused", { label: title });
case "pauseEnd":
return t("day.event.resumed", { label: title });
case "illnessOnset":
return t("day.event.illnessBegan", { label: title });
case "illnessResolved":
return t("day.event.illnessResolved", { label: title });
case "mood": {
const key = MOOD_LABEL_KEYS[title];
return key ? t(key) : t("nav.mood");
}
case "assessment": {
const key = keyOf(ASSESSMENT_INSTRUMENT_KEY, title);
return key ? t(key) : title;
}
case "workout": {
const key = keyOf(WORKOUT_SPORT_KEY, title);
return key ? t(key) : title;
}
default:
return title;
}
}
const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]);
const LIFE_EVENT_CATEGORY_SET: ReadonlySet<string> = new Set(
LIFE_EVENT_CATEGORIES,
);
function dayMeta(
/**
* A row's second line. Where it is a code (a category, a kind, a severity, a
* band) or a bare number (an intensity, a dose number, a functional impact),
* it is worded; a code the bundle does not know is left out rather than
* shown raw. A dose, a duration or a lab value is the record's own text.
*/
export function dayMeta(
kind: string,
meta: string,
t: (key: string) => string,
): string {
if (kind === "cycleDayLog" && FLOW_LEVELS.has(meta)) {
return `${t("cycle.flow.label")}: ${t(`cycle.flow.${meta}`)}`;
t: Translate,
title = "",
): string | null {
switch (kind) {
case "cycleDayLog": {
const key = keyOf(CYCLE_FLOW_KEY, meta);
return key ? `${t("cycle.flow.label")}: ${t(key)}` : null;
}
case "lifeEvent": {
const key = keyOf(LIFE_EVENT_CATEGORY_KEY, meta);
return key ? t(key) : null;
}
// The mood is the title; its score says the same thing again.
case "mood":
return null;
case "assessment": {
const [score, band] = meta.split(" ");
const bands = keyOf(ASSESSMENT_INSTRUMENT_KEY, title)
? ASSESSMENT_BAND_KEY[title as keyof typeof ASSESSMENT_BAND_KEY]
: null;
const bandKey = bands && band ? keyOf(bands, band) : undefined;
const bandText = bandKey ? t(bandKey) : null;
return [score, bandText].filter(Boolean).join(" · ") || null;
}
case "symptom":
return t("symptoms.intensityPill", { value: meta });
case "allergyOnset": {
const key = keyOf(ALLERGY_SEVERITY_KEY, meta);
return key ? t(key) : null;
}
case "visit":
case "procedure":
return meta === "PLANNED"
? t("encounters.status.planned")
: visitKindWords(t, meta);
case "vaccination":
return t("vaccinations.series.doseN", { position: meta });
case "illnessDayLog": {
const key = keyOf(ILLNESS_IMPACT_KEY, meta);
return key ? t("illness.timeline.impact", { impact: t(key) }) : null;
}
case "document": {
const key = keyOf(DOCUMENT_KIND_KEY, meta);
return key ? t(key) : null;
}
default:
return meta;
}
// A life event's second line is its category, a closed code.
if (kind === "lifeEvent" && LIFE_EVENT_CATEGORY_SET.has(meta)) {
return t(`lifeEvents.category.${meta}`);
}
return meta;
}
export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
@@ -452,6 +540,9 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) {
<ul className="divide-border divide-y">
{events.map((event) => {
const Icon = EVENT_ICON[event.kind];
const meta = event.meta
? dayMeta(event.kind, event.meta, t, event.title)
: null;
return (
<li
key={`${event.kind}-${event.id}`}
@@ -485,10 +576,8 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) {
{dayTitle(event.kind, event.title, t)}
</p>
)}
{event.meta ? (
<p className="text-muted-foreground mt-0.5 text-xs">
{dayMeta(event.kind, event.meta, t)}
</p>
{meta ? (
<p className="text-muted-foreground mt-0.5 text-xs">{meta}</p>
) : null}
{event.note ? (
// The person's own words: content, never muted.
+99
View File
@@ -0,0 +1,99 @@
/**
* The message key of every code the day view words (v1.42, #613).
*
* Maps rather than template keys, for the reason in
* `components/timeline/label-keys.ts`: each is typed over the code's own
* union, and `label-keys.test.ts` resolves every value against the bundle.
*/
import type { AllergySeverity, FlowLevel } from "@/generated/prisma/client";
import type { DayNotableKind } from "@/lib/day/contract";
import type { InstrumentId } from "@/lib/mental-health/instruments";
import type { WorkoutSportType } from "@/lib/validations/workout";
export const DAY_NOTABLE_KEY: Readonly<Record<DayNotableKind, string>> = {
extremeHigh: "day.notable.extremeHigh",
extremeLow: "day.notable.extremeLow",
firstValue: "day.notable.firstValue",
gap: "day.notable.gap",
};
export const CYCLE_FLOW_KEY: Readonly<Record<FlowLevel, string>> = {
NONE: "cycle.flow.NONE",
SPOTTING: "cycle.flow.SPOTTING",
LIGHT: "cycle.flow.LIGHT",
MEDIUM: "cycle.flow.MEDIUM",
HEAVY: "cycle.flow.HEAVY",
};
export const ALLERGY_SEVERITY_KEY: Readonly<Record<AllergySeverity, string>> = {
MILD: "records.allergies.severity.MILD",
MODERATE: "records.allergies.severity.MODERATE",
SEVERE: "records.allergies.severity.SEVERE",
};
export const ASSESSMENT_INSTRUMENT_KEY: Readonly<Record<InstrumentId, string>> =
{
PHQ9: "mentalHealth.instrument.phq9",
GAD7: "mentalHealth.instrument.gad7",
WHO5: "mentalHealth.instrument.who5",
SCI: "mentalHealth.instrument.sci",
};
/** Each screener's bands (`instruments.ts`), by the band key it stores. */
export const ASSESSMENT_BAND_KEY: Readonly<
Record<InstrumentId, Readonly<Record<string, string>>>
> = {
PHQ9: {
minimal: "mentalHealth.band.PHQ9.minimal",
mild: "mentalHealth.band.PHQ9.mild",
moderate: "mentalHealth.band.PHQ9.moderate",
modSevere: "mentalHealth.band.PHQ9.modSevere",
severe: "mentalHealth.band.PHQ9.severe",
},
GAD7: {
minimal: "mentalHealth.band.GAD7.minimal",
mild: "mentalHealth.band.GAD7.mild",
moderate: "mentalHealth.band.GAD7.moderate",
severe: "mentalHealth.band.GAD7.severe",
},
WHO5: {
low: "mentalHealth.band.WHO5.low",
good: "mentalHealth.band.WHO5.good",
},
SCI: {
belowThreshold: "mentalHealth.band.SCI.belowThreshold",
aboveThreshold: "mentalHealth.band.SCI.aboveThreshold",
},
};
export const WORKOUT_SPORT_KEY: Readonly<Record<WorkoutSportType, string>> = {
walking: "insights.workouts.sport.walking",
running: "insights.workouts.sport.running",
cycling: "insights.workouts.sport.cycling",
hiking: "insights.workouts.sport.hiking",
swimming: "insights.workouts.sport.swimming",
rowing: "insights.workouts.sport.rowing",
elliptical: "insights.workouts.sport.elliptical",
stairClimber: "insights.workouts.sport.stairClimber",
yoga: "insights.workouts.sport.yoga",
mindAndBody: "insights.workouts.sport.mindAndBody",
strength: "insights.workouts.sport.strength",
hiit: "insights.workouts.sport.hiit",
dance: "insights.workouts.sport.dance",
golf: "insights.workouts.sport.golf",
badminton: "insights.workouts.sport.badminton",
tennis: "insights.workouts.sport.tennis",
basketball: "insights.workouts.sport.basketball",
soccer: "insights.workouts.sport.soccer",
crossTraining: "insights.workouts.sport.crossTraining",
mixedCardio: "insights.workouts.sport.mixedCardio",
other: "insights.workouts.sport.other",
};
/** An illness day's functional impact, 0 (fully functional) to 3. */
export const ILLNESS_IMPACT_KEY: Readonly<Record<string, string>> = {
"0": "illness.impact.0",
"1": "illness.impact.1",
"2": "illness.impact.2",
"3": "illness.impact.3",
};
+5 -2
View File
@@ -34,7 +34,7 @@ import {
import { getUnitForType } from "@/lib/measurements/unit-map";
import { queryKeys } from "@/lib/query-keys";
import { DAY_SECTION_LABEL, useNotableText } from "./day-sections";
import { DAY_SECTION_LABEL, dayTitle, useNotableText } from "./day-sections";
import { DayLink } from "./day-link";
import { dateKeyOfInstant } from "./day-url";
import { tileKeyOf } from "./day-values-model";
@@ -86,6 +86,8 @@ export interface PrepRow {
export function preparationRows(
window: Pick<DayNotableWindowResponse, "observations" | "changes">,
spell: {
/** A change's title in the reader's words (`dayTitle`). */
changeTitle: (kind: string, title: string) => string;
notableTitle: (notable: DayNotable) => string;
notableText: (notable: DayNotable) => string | null;
countMeta: (count: number) => string | null;
@@ -97,7 +99,7 @@ export function preparationRows(
key: `${change.kind}-${change.id}-${change.date}`,
date: change.date,
Icon: EVENT_ICON[change.kind] ?? FileText,
title: change.title,
title: spell.changeTitle(change.kind, change.title),
meta: spell.countMeta(change.count),
});
}
@@ -171,6 +173,7 @@ export function SinceLastVisit({
const rows = window.data
? preparationRows(window.data, {
changeTitle: (kind, title) => dayTitle(kind, title, t),
notableTitle,
notableText,
countMeta: (count) =>
@@ -148,7 +148,7 @@ describe("buildChronicle", () => {
describe("standingChips", () => {
it("collects open things once and folds several allergies into one chip", () => {
const chips = standingChips(fullTimeline());
const chips = standingChips(fullTimeline(), (i) => i.label);
expect(chips.map((c) => c.id)).toEqual([
"ill-chronic",
"med-1",
@@ -162,6 +162,8 @@ describe("standingChips", () => {
tl.standing = [
{ lane: "illness", id: "pre", label: "Asthma", since: null, href: null },
];
expect(standingChips(tl).map((c) => c.label)).toContain("Asthma");
expect(standingChips(tl, (i) => i.label).map((c) => c.label)).toContain(
"Asthma",
);
});
});
@@ -0,0 +1,78 @@
/**
* v1.42 (#613) — every key the timeline and the day view word a code with
* exists. The maps in `label-keys.ts` replaced template keys the call-site
* coverage guard could not read; this walks their values instead, so a map
* entry without its message fails here rather than on a screen.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import * as dayKeys from "@/components/day/label-keys";
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
import * as timelineKeys from "../label-keys";
const LOCALES = ["de", "en", "es", "fr", "it", "ko", "pl"] as const;
const bundles = Object.fromEntries(
LOCALES.map((l) => [
l,
JSON.parse(
readFileSync(join(process.cwd(), "messages", `${l}.json`), "utf8"),
),
]),
);
function leaf(bundle: unknown, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(o, part) =>
o && typeof o === "object"
? (o as Record<string, unknown>)[part]
: undefined,
bundle,
);
}
/** Every string value in a map, maps of maps included, with its path. */
function values(map: unknown, path: string): Array<[string, string]> {
if (typeof map === "string") return [[path, map]];
if (!map || typeof map !== "object") return [];
return Object.entries(map).flatMap(([k, v]) => values(v, `${path}.${k}`));
}
const MAPS = [
...Object.entries(timelineKeys),
...Object.entries(dayKeys),
].filter(([, v]) => typeof v === "object");
describe("label key maps", () => {
it("finds the maps it is meant to check", () => {
expect(MAPS.length).toBeGreaterThanOrEqual(18);
});
it.each(LOCALES)("resolves every key in %s", (locale) => {
const missing = MAPS.flatMap(([name, map]) => values(map, name)).filter(
([, key]) => typeof leaf(bundles[locale], key) !== "string",
);
expect(missing).toEqual([]);
});
it("names every band each screener stores", () => {
for (const id of INSTRUMENT_ORDER) {
expect(Object.keys(dayKeys.ASSESSMENT_BAND_KEY[id]).sort()).toEqual(
INSTRUMENTS[id].bands.map((b) => b.key).sort(),
);
}
});
it("names every sport a workout can carry", () => {
expect(Object.keys(dayKeys.WORKOUT_SPORT_KEY).sort()).toEqual(
[...workoutSportTypeEnum.options].sort(),
);
});
});
@@ -0,0 +1,502 @@
/**
* v1.42 (#613) — no code reaches the screen. The timeline and the day view
* receive what the record holds: a life event's category, a visit's kind, a
* document's kind, an allergy's severity, a mood, a screener's instrument and
* band as their codes, a lab day's analyte count as a bare number, and a
* sentinel for a trip or a cycle. Every one of them is worded in the reader's
* language before it is shown.
*
* This renders a record with every item kind, every category and every kind
* code on every surface that shows it (the chronicle, the selection bar, the
* screen-reader table, the lane labels and their hover titles, the day's
* running items and its events) and asserts that the visible text holds no
* upper-case code and no sentinel. A new kind or a new code that is not
* worded fails here instead of on a screenshot.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DayEvents, DayRunning } from "@/components/day/day-sections";
import { ENCOUNTER_KINDS } from "@/components/encounters/encounter-labels";
import {
DAY_EVENT_KINDS,
DAY_RUNNING_KINDS,
LIFE_EVENT_CATEGORIES,
TIMELINE_ITEM_KINDS,
type DayEvent,
type DayRunningItem,
type TimelineItem,
type TimelineResponse,
} from "@/lib/day/contract";
import { I18nProvider } from "@/lib/i18n/context";
import { MOOD_ENUM_VALUES } from "@/lib/mood/labels";
import { INBOUND_DOCUMENT_KINDS } from "@/lib/validations/inbound-documents";
import { SelectionBar } from "../selection-bar";
import type { SeriesValueFormat } from "../series-format";
import { PointMark, SpanMark, TimelineChart } from "../timeline-chart";
import { TimelineChronicle } from "../timeline-chronicle";
import { layoutTimeline } from "../timeline-geometry";
import { useSeriesValueFormat } from "../use-series-value-format";
import { TODAY, item, wordsIn } from "./timeline-fixture";
type Locale = "de" | "en";
function render(node: React.ReactElement, locale: Locale) {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
/**
* The text a reader sees: every character outside a tag, with a space where
* a tag stood, so two runs ("Ereignis 0" and "FAMILY") never fuse into one
* word the code pattern would step over.
*/
function textOf(html: string): string {
let out = "";
let inTag = false;
for (const ch of html) {
if (ch === "<") inTag = true;
else if (ch === ">") {
inTag = false;
out += " ";
} else if (!inTag) out += ch;
}
return out;
}
/**
* A code as the server sends it: four or more capitals, digits or
* underscores in one word (`FAMILY`, `SUPER_GUT`, `PHQ9`), or one of the
* lower-case sentinels.
*/
const RAW_CODE = /\b[A-Z][A-Z0-9_]{3,}\b|\btravel\b|\bcycle\b/g;
function rawCodes(text: string): string[] {
return [...new Set(text.match(RAW_CODE) ?? [])];
}
function WithFormat({
build,
}: {
build: (format: SeriesValueFormat) => React.ReactNode;
}) {
return <>{build(useSeriesValueFormat())}</>;
}
/** Every day of September 2026, one per item, so one month holds them all. */
let nextDay = 1;
const day = () => `2026-09-${String(nextDay++).padStart(2, "0")}`;
/** Every timeline item kind, with every code its second line can carry. */
function everyKind(): TimelineResponse {
nextDay = 1;
const life: TimelineItem[] = [
...LIFE_EVENT_CATEGORIES.map((category, i) =>
item({
id: `le-${i}`,
kind: "lifeEvent",
start: day(),
label: `Ereignis ${i}`,
sub: category,
}),
),
item({
id: "trip",
kind: "travel",
start: "2026-09-02",
end: "2026-09-09",
label: "travel",
}),
];
const visits: TimelineItem[] = ENCOUNTER_KINDS.map((kind, i) =>
item({
id: `visit-${i}`,
kind: kind === "PROCEDURE" ? "procedure" : "visit",
start: day(),
// Every other visit has neither a reason nor a practitioner.
label: i % 2 === 0 ? "" : `Termin ${i}`,
sub: kind,
}),
);
const documents: TimelineItem[] = INBOUND_DOCUMENT_KINDS.map((kind, i) =>
item({
id: `doc-${i}`,
kind: "document",
start: day(),
label: `Brief ${i}`,
sub: kind,
}),
);
const timeline: TimelineResponse = {
zoom: "quarter",
range: { from: "2026-08-01", to: "2026-10-31", dataFrom: "2026-08-01" },
bucket: "month",
lanes: [
{ key: "life", items: life },
{
key: "illness",
items: [
item({
id: "chronic",
kind: "chronic",
start: "2026-09-01",
open: true,
label: "Bluthochdruck",
}),
item({
id: "episode",
kind: "episode",
start: "2026-09-03",
end: "2026-09-12",
label: "Erkältung",
}),
],
},
{
key: "allergies",
items: [
item({
id: "allergy",
kind: "allergy",
start: "2026-09-04",
open: true,
label: "Penicillin",
}),
],
},
{
key: "medications",
items: [
item({
id: "med",
kind: "medication",
start: "2026-09-01",
open: true,
label: "Ramipril",
sub: "5 mg",
}),
item({
id: "course",
kind: "course",
start: "2026-09-05",
end: "2026-09-25",
label: "Vitamin D",
}),
item({
id: "dose",
kind: "doseChange",
start: "2026-09-10",
label: "Ramipril",
sub: "10 mg",
}),
item({
id: "pause",
kind: "pause",
start: "2026-09-14",
end: "2026-09-24",
label: "Ramipril",
}),
],
},
{
key: "vaccinations",
items: [
item({
id: "vacc",
kind: "vaccination",
start: day(),
label: "Tetanus",
}),
],
},
{ key: "visits", items: visits },
{
key: "labs",
items: [
item({
id: "lab-day",
kind: "labDay",
start: day(),
label: "Kreatinin, Kalium, Natrium",
sub: "5",
}),
],
},
{ key: "documents", items: documents },
{
key: "cycle",
items: [
item({
id: "cyc",
kind: "cycle",
start: "2026-09-06",
end: "2026-09-10",
label: "cycle",
}),
],
},
],
standing: [],
series: [],
notable: [],
};
// The fixture itself must reach every kind, or the guard proves less.
const kinds = new Set(
timeline.lanes.flatMap((l) => l.items.map((i) => i.kind)),
);
expect([...kinds].sort()).toEqual([...TIMELINE_ITEM_KINDS].sort());
return timeline;
}
const SELECTED = "2026-09-15";
const noop = () => undefined;
describe.each<Locale>(["de", "en"])("the timeline in %s", (locale) => {
it("words every code in the chronicle", () => {
const text = textOf(
render(
<WithFormat
build={(format) => (
<TimelineChronicle
timeline={everyKind()}
today={TODAY}
grouping="month"
selected={SELECTED}
seriesColor={() => "var(--chart-1)"}
seriesFormat={format}
onOpenDay={noop}
onEditLifeEvent={null}
/>
)}
/>,
locale,
),
);
expect(rawCodes(text)).toEqual([]);
});
it("words every code in the selection bar", () => {
const text = textOf(
render(
<WithFormat
build={(format) => (
<SelectionBar
timeline={everyKind()}
selected={SELECTED}
today={TODAY}
seriesLabel={(key) => key}
seriesColor={() => "var(--chart-1)"}
seriesFormat={format}
onOpenDay={noop}
/>
)}
/>,
locale,
),
);
expect(text).toContain(locale === "de" ? "5 Laborwerte" : "5 lab values");
expect(rawCodes(text)).toEqual([]);
});
it("words every code in the screen-reader table", () => {
const html = render(
<WithFormat
build={(format) => (
<TimelineChart
timeline={everyKind()}
window={{ from: "2026-08-01", to: "2026-10-31" }}
zoom="quarter"
today={TODAY}
selected={null}
hiddenLanes={new Set()}
seriesLabel={(key) => key}
seriesColor={() => "var(--chart-1)"}
seriesFormat={format}
onSelect={noop}
onOpenDay={noop}
/>
)}
/>,
locale,
);
const table = html.slice(html.indexOf('data-slot="timeline-table"'));
expect(rawCodes(textOf(table))).toEqual([]);
});
it("words every code in the lane labels and their hover titles", () => {
const layout = layoutTimeline({
width: 4000,
window: { from: "2026-08-01", to: "2026-10-31" },
lanes: everyKind().lanes,
series: [],
bucket: "month",
words: wordsIn(locale),
startMissing: "",
today: TODAY,
});
const labels = layout.lanes.flatMap((l) => l.labels.map((x) => x.text));
expect(labels.length).toBeGreaterThan(10);
expect(rawCodes(labels.join("\n"))).toEqual([]);
const titles = textOf(
render(
<svg>
{layout.lanes.flatMap((lane) => [
...lane.spans.map((span) => (
<SpanMark
key={span.item.id}
span={span}
color="var(--chart-1)"
x0={0}
intl={locale}
t={(key) => key}
/>
)),
...lane.points.map((point) => (
<PointMark
key={point.item.id}
point={point}
color="var(--chart-1)"
intl={locale}
/>
)),
])}
</svg>,
locale,
),
);
// The stub `t` above leaves the range words as keys; only the item
// words are under test here.
expect(rawCodes(titles.replace(/timeline\.selection\.\w+/g, ""))).toEqual(
[],
);
});
});
/* ─── The day view ──────────────────────────────────────────────────────── */
function event(
kind: DayEvent["kind"],
id: string,
title: string,
meta: string | null,
): DayEvent {
return {
at: null,
kind,
section: "medications",
id,
title,
meta,
note: null,
docs: [],
href: null,
};
}
/** Every event kind, with every code its title or second line can carry. */
function everyEvent(): DayEvent[] {
const out: DayEvent[] = [
event("intake", "i", "Ramipril", "5 mg"),
event("doseChange", "dc", "Ramipril", "10 mg"),
event("medicationStart", "ms", "Ramipril", "5 mg"),
event("medicationEnd", "me", "Ramipril", "5 mg"),
event("pauseStart", "ps", "Ramipril", null),
event("pauseEnd", "pe", "Ramipril", null),
event("courseStart", "cs", "Vitamin D", null),
event("courseEnd", "ce", "Vitamin D", null),
event("illnessOnset", "io", "Erkältung", null),
event("illnessResolved", "ir", "Erkältung", null),
event("illnessDayLog", "idl", "Erkältung", "2"),
event("symptom", "sy", "Kopfschmerz", "7"),
event("allergyOnset", "ao-1", "Penicillin", "SEVERE"),
event("allergyOnset", "ao-2", "Pollen", "MILD"),
event("labResult", "lr", "Kreatinin", "1.1 mg/dL"),
event("visit", "planned", "Kontrolle", "PLANNED"),
event("vaccination", "va", "Tetanus", "2"),
event("checkup", "ch", "Hautkrebs-Screening", null),
event("assessment", "as-1", "PHQ9", "12 moderate"),
event("assessment", "as-2", "GAD7", "3 minimal"),
event("assessment", "as-3", "WHO5", "60 good"),
event("assessment", "as-4", "SCI", "20 aboveThreshold"),
event("workout", "wo", "running", "30 min"),
event("cycleDayLog", "cdl", "cycle", "HEAVY"),
...ENCOUNTER_KINDS.map((kind, i) =>
event(
kind === "PROCEDURE" ? "procedure" : "visit",
`visit-${i}`,
"Hausärztin",
kind,
),
),
...INBOUND_DOCUMENT_KINDS.map((kind, i) =>
event("document", `doc-${i}`, `Brief ${i}`, kind),
),
...MOOD_ENUM_VALUES.map((mood, i) =>
event("mood", `mood-${i}`, mood, String(i + 1)),
),
...LIFE_EVENT_CATEGORIES.map((category, i) =>
event("lifeEvent", `le-${i}`, `Ereignis ${i}`, category),
),
];
expect([...new Set(out.map((e) => e.kind))].sort()).toEqual(
[...DAY_EVENT_KINDS].sort(),
);
return out;
}
function running(
kind: DayRunningItem["kind"],
title: string,
sub: string | null,
): DayRunningItem {
return {
kind,
section: "medications",
id: `${kind}-${title}`,
title,
sub,
since: "2026-09-01",
until: null,
dayIndex: 3,
dayCount: null,
href: null,
};
}
function everyRunning(): DayRunningItem[] {
const out: DayRunningItem[] = [
running("medication", "Ramipril", "5 mg"),
running("medicationCourse", "Vitamin D", null),
running("medicationPause", "Ramipril", null),
running("illness", "Erkältung", null),
running("restMode", "Erholung", null),
running("cyclePhase", "cycle", null),
running("travel", "travel", null),
...LIFE_EVENT_CATEGORIES.map((category, i) =>
running("lifeEvent", `Ereignis ${i}`, category),
),
];
expect([...new Set(out.map((r) => r.kind))].sort()).toEqual(
[...DAY_RUNNING_KINDS].sort(),
);
return out;
}
describe.each<Locale>(["de", "en"])("the day view in %s", (locale) => {
it("words every code under What happened", () => {
const text = textOf(render(<DayEvents events={everyEvent()} />, locale));
expect(rawCodes(text)).toEqual([]);
expect(text).toContain(locale === "de" ? "Super gut" : "Amazing");
});
it("words every code under Running that day", () => {
const text = textOf(render(<DayRunning items={everyRunning()} />, locale));
expect(rawCodes(text)).toEqual([]);
});
});
@@ -0,0 +1,66 @@
/**
* v1.42 (#613) — no template message keys in the timeline or the day view.
*
* `t(\`timeline.zoom.${zoom}\`)` reads fine and is invisible to
* `i18n-call-site-coverage.test.ts`, which only resolves literal keys. A
* value without its key then ships as the key's raw name. These two trees
* word codes through the typed maps in `label-keys.ts` instead, which
* `label-keys.test.ts` resolves value by value; this keeps the template form
* from coming back.
*
* One call stays, by name: `wordKey` words the readiness keys the server
* sends (a detail, a gap), and it asks the bundle whether the key exists
* before it uses it, so a key the bundle lacks is left out, never shown.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
stripComments,
walkSourceFiles,
} from "@/__tests__/helpers/source-files";
const ROOTS = ["src/components/timeline", "src/components/day"] as const;
/** `t(` or `tCount(` whose key is a template literal, across line breaks. */
const TEMPLATE_KEY = /(?<![\w.])(?:t|tCount)\(\s*`/g;
const ALLOWED: Readonly<Record<string, { count: number; why: string }>> = {
"src/components/timeline/readiness-inventory.tsx": {
count: 1,
why: "wordKey checks the server-sent key exists before it words it",
},
};
function templateCalls(): Map<string, number> {
const out = new Map<string, number>();
for (const root of ROOTS) {
const dir = join(process.cwd(), root);
for (const rel of walkSourceFiles(dir, { floor: 10 })) {
if (rel.includes("__tests__")) continue;
const src = stripComments(readFileSync(join(dir, rel), "utf8"));
const count = [...src.matchAll(TEMPLATE_KEY)].length;
if (count > 0) out.set(`${root}/${rel}`, count);
}
}
return out;
}
describe("template message keys", () => {
it("finds the one sanctioned call, so the matcher is live", () => {
const calls = templateCalls();
expect(calls.size).toBeGreaterThanOrEqual(1);
expect(calls.get("src/components/timeline/readiness-inventory.tsx")).toBe(
1,
);
});
it("are not used in the timeline or the day view", () => {
const offenders = [...templateCalls()]
.filter(([file, count]) => ALLOWED[file]?.count !== count)
.map(([file, count]) => `${file}: ${count}`);
expect(offenders).toEqual([]);
});
});
@@ -23,7 +23,7 @@ import { MeanPartsLine, SeriesLines, TimelineChart } from "../timeline-chart";
import { TimelineChronicle } from "../timeline-chronicle";
import { layoutTimeline } from "../timeline-geometry";
import { useSeriesValueFormat } from "../use-series-value-format";
import { TODAY, fullTimeline, readiness } from "./timeline-fixture";
import { TODAY, fullTimeline, readiness, wordsIn } from "./timeline-fixture";
function render(node: React.ReactElement, locale: "de" | "en" = "de") {
// A fresh client with no account answer: the unit preference resolves to
@@ -54,8 +54,21 @@ function I18nProbe({
return <>{run(t)}</>;
}
/** The text a reader sees, without the markup between the runs. */
const text = (html: string) => html.replace(/<[^>]+>/g, "");
/**
* The text a reader sees, without the markup between the runs. A walk over
* the characters rather than a pattern, so a tag split across another cannot
* survive it.
*/
function text(html: string): string {
let out = "";
let inTag = false;
for (const ch of html) {
if (ch === "<") inTag = true;
else if (ch === ">") inTag = false;
else if (!inTag) out += ch;
}
return out;
}
const COLORS = assignSeriesColors([
"BLOOD_PRESSURE_SYS",
@@ -153,6 +166,32 @@ describe("chronicle", () => {
expect(html).toMatch(/data-date="2026-01-03"><button[^>]*bg-muted/);
});
it("reads a pause as paused and resumed, never as an end", () => {
const html = text(
render(
<WithFormat
build={(format) => (
<TimelineChronicle
timeline={fullTimeline()}
today={TODAY}
grouping="month"
selected={null}
seriesColor={seriesColor}
seriesFormat={format}
onOpenDay={() => undefined}
onEditLifeEvent={null}
/>
)}
/>,
"en",
),
);
expect(html).toContain("Ramipril paused");
expect(html).toContain("Ramipril resumed");
expect(html).toContain("after 11 days");
expect(html).not.toContain("Ramipril ended");
});
it("shows the month's means beside the month", () => {
const html = render(
<WithFormat
@@ -243,6 +282,11 @@ describe("selection bar", () => {
expect(html).toContain('data-date="2026-01-03"');
});
it("names a pause as a pause", () => {
const html = text(bar("2021-06-05"));
expect(html).toContain("Ramipril pausiert · 1. Juni bis 11. Juni");
});
it("names the bucket's means with the readings behind each", () => {
expect(text(bar("2026-01-03"))).toContain(
"Ø Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen) · Gewicht 82,6 kg (Schnitt aus 3 Messungen)",
@@ -364,6 +408,7 @@ describe("value line colours", () => {
lanes: [],
series: fullTimeline().series,
bucket: "month",
words: wordsIn(),
startMissing: "",
today: TODAY,
});
@@ -10,6 +10,19 @@ import type {
TimelineReadinessResponse,
TimelineResponse,
} from "@/lib/day/contract";
import type { Locale } from "@/lib/i18n/config";
import { pluralKey } from "@/lib/i18n/plural";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { makeItemWords, type ItemWordsFn } from "../item-words";
/** An item's words from the real bundle, outside a React tree. */
export function wordsIn(locale: Locale = "de"): ItemWordsFn {
const { t } = getServerTranslator(locale);
return makeItemWords(t, (base, count, params) =>
t(pluralKey(base, count, locale), { count, ...params }),
);
}
export function item(
over: Partial<TimelineItem> & Pick<TimelineItem, "id" | "kind" | "start">,
@@ -174,8 +187,8 @@ export function fullTimeline(): TimelineResponse {
id: "pause-1",
kind: "pause",
start: "2021-06-01",
end: "2021-06-20",
label: "Pause",
end: "2021-06-11",
label: "Ramipril",
}),
],
},
@@ -12,7 +12,6 @@ import {
estimateTextWidth,
fitText,
gridTicks,
itemLabel,
itemsInPeriod,
latestDataDate,
layoutLane,
@@ -25,8 +24,11 @@ import {
type LaneLayout,
type TimelineLayout,
} from "../timeline-geometry";
import { itemLine } from "../item-words";
import { dayNumber } from "../timeline-dates";
import { TODAY, fullTimeline, item } from "./timeline-fixture";
import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture";
const WORDS = wordsIn("de");
const FMT = {
year: (k: string) => k.slice(0, 4),
@@ -42,6 +44,7 @@ function layoutAt(width: number): TimelineLayout {
lanes: tl.lanes,
series: tl.series,
bucket: tl.bucket,
words: WORDS,
startMissing: "Startdatum fehlt",
today: TODAY,
});
@@ -183,7 +186,7 @@ describe("gridTicks", () => {
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 };
const opts = { words: WORDS, 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")!;
@@ -207,6 +210,23 @@ describe("layoutLane", () => {
expect(pause.row).toBe(host.row);
});
it("labels a pause as a pause, above the medication it interrupts", () => {
const lane = fullTimeline().lanes.find((l) => l.key === "medications")!;
const wide = createScale(
{ from: "2021-01-01", to: "2021-12-31" },
164,
1200,
);
const layout = layoutLane(
{ lane, top: 0 },
{ from: "2021-01-01", to: "2021-12-31" },
wide,
opts,
);
const label = layout.labels.find((l) => l.itemId === "pause-1");
expect(label?.text).toBe("Ramipril pausiert");
});
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);
@@ -226,7 +246,7 @@ describe("layoutLane", () => {
label: "Ramipril",
sub: "5 mg",
});
expect(itemLabel(unknown, "Startdatum fehlt")).toBe(
expect(itemLine(unknown, WORDS, "Startdatum fehlt")).toBe(
"Ramipril · 5 mg · Startdatum fehlt",
);
const layout = layoutLane(
@@ -0,0 +1,129 @@
/**
* v1.42 (#613) — the whole timeline page, at every zoom and every bucket,
* shows no message key. Rendered through SSR with the reads stubbed, in two
* languages: the zoom control, the legend, the selection bar's head and its
* "no value" means, the lanes table and the phone chronicle all come out of
* one render, so a key that only one zoom or one bucket reaches is reached
* here too.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
TIMELINE_BUCKETS,
TIMELINE_ZOOMS,
type TimelineBucket,
type TimelineResponse,
type TimelineZoom,
} from "@/lib/day/contract";
import { I18nProvider } from "@/lib/i18n/context";
import { TODAY, fullTimeline, readiness } from "./timeline-fixture";
let search = new URLSearchParams();
let timeline: TimelineResponse = fullTimeline();
vi.mock("next/navigation", () => ({
usePathname: () => "/timeline",
useSearchParams: () => search,
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", timezone: "Europe/Berlin", modules: {} },
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: false }),
}));
vi.mock("../use-timeline", () => ({
useTimeline: () => ({
data: timeline,
isPending: false,
isError: false,
refetch: vi.fn(),
}),
useTimelineReadiness: () => ({
data: readiness(),
isPending: false,
isError: false,
}),
useLifeEvents: () => ({ data: { events: [] } }),
useLifeEventMutations: () => ({
create: { isPending: false },
update: { isPending: false },
remove: { isPending: false },
}),
}));
vi.mock("../timeline-dates", async (importOriginal) => {
const real = await importOriginal<typeof import("../timeline-dates")>();
return { ...real, todayKeyIn: () => TODAY };
});
/** The text a reader sees: every character outside a tag, runs kept apart. */
function textOf(html: string): string {
let out = "";
let inTag = false;
for (const ch of html) {
if (ch === "<") inTag = true;
else if (ch === ">") {
inTag = false;
out += " ";
} else if (!inTag) out += ch;
}
return out;
}
/** A message key as it would leak: a namespace, a dot, a name. */
const RAW_KEY =
/\b(?:timeline|day|lifeEvents|documents|encounters|mentalHealth|insights|records|cycle|illness|charts|measurements)\.[a-zA-Z][\w.]*/g;
async function renderView(
zoom: TimelineZoom,
bucket: TimelineBucket,
locale: "de" | "en",
) {
search = new URLSearchParams({ zoom });
if (zoom === "range") {
search.set("from", "2025-10-01");
search.set("to", "2026-01-31");
}
timeline = { ...fullTimeline(), zoom, bucket };
return textOf(
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>
<TimelineView />
</I18nProvider>
</QueryClientProvider>,
),
);
}
let TimelineView: typeof import("../timeline-view").TimelineView;
// The page's module graph is large; load it once, outside the per-test
// timeout, so a busy machine does not time out the first render.
beforeAll(async () => {
({ TimelineView } = await import("../timeline-view"));
}, 60_000);
beforeEach(() => {
search = new URLSearchParams();
});
describe("the timeline page shows no message key", () => {
for (const locale of ["de", "en"] as const) {
for (const zoom of TIMELINE_ZOOMS) {
for (const bucket of TIMELINE_BUCKETS) {
it(`${locale}, zoom ${zoom}, bucket ${bucket}`, async () => {
const text = await renderView(zoom, bucket, locale);
// The render reached the page, not a loading or error state.
expect(text.length).toBeGreaterThan(500);
expect([...new Set(text.match(RAW_KEY) ?? [])]).toEqual([]);
});
}
}
}
});
+4 -2
View File
@@ -99,10 +99,12 @@ function groupsBetween(
/**
* The Ongoing block: what has no start (`standing`) and every period still
* open. Allergies fold into one chip when there is more than one.
* open, each period named by `label` (`item-words.ts`, so a running pause
* reads as paused). Allergies fold into one chip when there is more than one.
*/
export function standingChips(
timeline: Pick<TimelineResponse, "lanes" | "standing">,
label: (item: TimelineItem) => string,
): StandingChip[] {
const chips: StandingChip[] = [];
const allergies: StandingChip[] = [];
@@ -116,7 +118,7 @@ export function standingChips(
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 });
push({ lane: key, id: item.id, label: label(item), count: 1 });
}
}
const ordered = chips.sort(
+145
View File
@@ -0,0 +1,145 @@
/**
* The words of one timeline item, in the reader's language (v1.42, #613).
*
* The server sends what the record holds: a life event's category, a visit's
* kind and a document's kind as their codes, a lab day's analyte count as a
* number, and a sentinel label for a trip or a cycle that has no name of its
* own. Every surface that shows an item (the lanes, their hover titles, the
* screen-reader table, the selection bar, the phone chronicle) reads it
* through here, so a code is worded once and never reaches the screen raw.
*
* A code the bundle cannot word is left out rather than shown: an unknown
* category reads as no category, not as `SOMETHING_NEW`.
*/
import { useMemo } from "react";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import type { TimelineItem } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
import {
DOCUMENT_KIND_KEY,
LIFE_EVENT_CATEGORY_KEY,
keyOf,
} from "./label-keys";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
type TranslateCount = (
baseKey: string,
count: number,
params?: Record<string, string | number>,
) => string;
export interface ItemWords {
/** What the item is called. */
label: string;
/** Its second line (a category, a dose, a count), or null when none. */
sub: string | null;
}
export type ItemWordsFn = (item: TimelineItem) => ItemWords;
/** A code worded through `map`, or null for none or a code it lacks. */
function worded<K extends string>(
t: Translate,
map: Readonly<Record<K, string>>,
code: string | null,
): string | null {
const key = code === null ? undefined : keyOf(map, code);
return key ? t(key) : null;
}
const ENCOUNTER_KIND_CODES: ReadonlySet<string> = new Set([
"ROUTINE",
"ACUTE",
"SPECIALIST",
"PREVENTIVE",
"EMERGENCY",
"HOSPITAL",
"THERAPY",
"OTHER",
"PROCEDURE",
]);
/** A visit's kind code, worded; null for a code the bundle does not know. */
export function visitKindWords(t: Translate, code: string): string | null {
return ENCOUNTER_KIND_CODES.has(code)
? encounterKindText(t, code as Parameters<typeof encounterKindText>[1])
: null;
}
export function makeItemWords(
t: Translate,
tCount: TranslateCount,
): ItemWordsFn {
return (item) => {
switch (item.kind) {
case "lifeEvent":
return {
label: item.label,
sub: worded(t, LIFE_EVENT_CATEGORY_KEY, item.sub),
};
case "travel":
return { label: t("day.travel"), sub: null };
case "cycle":
return { label: t("nav.cycle"), sub: null };
case "pause":
return {
label: t("day.event.paused", { label: item.label }),
sub: null,
};
case "visit":
case "procedure": {
const kind = item.sub ? visitKindWords(t, item.sub) : null;
// A visit without a reason or a practitioner is named by its kind.
return item.label
? { label: item.label, sub: kind }
: { label: kind ?? "", sub: null };
}
case "labDay": {
const count = Number(item.sub);
return {
label: item.label,
sub:
Number.isInteger(count) && count > 0
? tCount("timeline.item.labValues", count)
: null,
};
}
case "document":
return {
label: item.label,
sub: worded(t, DOCUMENT_KIND_KEY, item.sub),
};
default:
// A dose, a dose change, a name: the record's own text.
return { label: item.label, sub: item.sub };
}
};
}
/**
* The line an item reads as: its label, its second line (a dose change's
* dose is drawn as its own mark, so it is left out here) and the
* unknown-start note.
*/
export function itemLine(
item: TimelineItem,
words: ItemWordsFn,
startMissing: string,
): string {
const { label, sub } = words(item);
const parts = [label];
if (sub && item.kind !== "doseChange") parts.push(sub);
if (!item.startKnown) parts.push(startMissing);
return parts.filter(Boolean).join(" · ");
}
/** {@link makeItemWords} with the bundle in hand. */
export function useItemWords(): ItemWordsFn {
const { t, tCount } = useTranslations();
return useMemo(() => makeItemWords(t, tCount), [t, tCount]);
}
+160
View File
@@ -0,0 +1,160 @@
/**
* The message key of every code the timeline words (v1.42, #613).
*
* A key built from a template (`t(\`timeline.zoom.${zoom}\`)`) is invisible to
* `i18n-call-site-coverage.test.ts`, so a value without its key shipped as
* the raw key and nothing in the gate noticed. Each code family is a map
* here instead: typed over the code's own union, so a new code does not
* compile until it has a key, and walked by `label-keys.test.ts`, which
* resolves every value against the bundle. `no-template-key-guard.test.ts`
* keeps the template form out of the timeline and the day view.
*/
import type {
DayNotableKind,
LifeEventCategory,
LifeEventPrecision,
TimelineBucket,
TimelineLaneKey,
TimelineReadinessKey,
TimelineReadinessStatus,
TimelineReadinessVerdict,
TimelineZoom,
} from "@/lib/day/contract";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import type { LifeEventDraftError } from "./life-event-draft";
export const TIMELINE_LANE_LABEL_KEY: Readonly<
Record<TimelineLaneKey, string>
> = {
life: "timeline.lanes.life",
illness: "timeline.lanes.illness",
allergies: "timeline.lanes.allergies",
medications: "timeline.lanes.medications",
vaccinations: "timeline.lanes.vaccinations",
visits: "timeline.lanes.visits",
labs: "timeline.lanes.labs",
documents: "timeline.lanes.documents",
cycle: "timeline.lanes.cycle",
};
export const TIMELINE_ZOOM_LABEL_KEY: Readonly<Record<TimelineZoom, string>> = {
all: "timeline.zoom.all",
year: "timeline.zoom.year",
quarter: "timeline.zoom.quarter",
range: "timeline.zoom.range",
};
export const TIMELINE_LEGEND_MEAN_KEY: Readonly<
Record<TimelineBucket, string>
> = {
quarter: "timeline.legendMeanQuarter",
month: "timeline.legendMeanMonth",
week: "timeline.legendMeanWeek",
day: "timeline.legendMeanDay",
};
/** The two blood pressure lines read as their own names on the chart. */
export const TIMELINE_BLOOD_PRESSURE_SERIES_KEY: Readonly<
Record<"BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA", string>
> = {
BLOOD_PRESSURE_SYS: "timeline.values.series.BLOOD_PRESSURE_SYS",
BLOOD_PRESSURE_DIA: "timeline.values.series.BLOOD_PRESSURE_DIA",
};
export const TIMELINE_CHRONICLE_NOTABLE_KEY: Readonly<
Record<DayNotableKind, string>
> = {
extremeHigh: "timeline.chronicle.notable.extremeHigh",
extremeLow: "timeline.chronicle.notable.extremeLow",
firstValue: "timeline.chronicle.notable.firstValue",
gap: "timeline.chronicle.notable.gap",
};
/** A readiness row's name: the lane's own, or the inventory's for the rest. */
export const TIMELINE_READINESS_LANE_KEY: Readonly<
Record<TimelineReadinessKey, string>
> = {
values: "timeline.readiness.lanes.values",
mood: "timeline.readiness.lanes.mood",
environment: "timeline.readiness.lanes.environment",
life: "timeline.readiness.lanes.life",
illness: "timeline.lanes.illness",
allergies: "timeline.lanes.allergies",
medications: "timeline.lanes.medications",
vaccinations: "timeline.lanes.vaccinations",
visits: "timeline.lanes.visits",
labs: "timeline.lanes.labs",
documents: "timeline.lanes.documents",
cycle: "timeline.lanes.cycle",
};
export const TIMELINE_READINESS_VERDICT_KEY: Readonly<
Record<TimelineReadinessVerdict, string>
> = {
carries: "timeline.readiness.verdict.carries",
thin: "timeline.readiness.verdict.thin",
};
export const TIMELINE_READINESS_STATUS_KEY: Readonly<
Record<TimelineReadinessStatus, string>
> = {
carries: "timeline.readiness.status.carries",
thin: "timeline.readiness.status.thin",
empty: "timeline.readiness.status.empty",
};
export const LIFE_EVENT_CATEGORY_KEY: Readonly<
Record<LifeEventCategory, string>
> = {
FAMILY: "lifeEvents.category.FAMILY",
HOME: "lifeEvents.category.HOME",
WORK: "lifeEvents.category.WORK",
LOSS: "lifeEvents.category.LOSS",
OTHER: "lifeEvents.category.OTHER",
};
export const LIFE_EVENT_PRECISION_KEY: Readonly<
Record<LifeEventPrecision, string>
> = {
DAY: "lifeEvents.precision.DAY",
MONTH: "lifeEvents.precision.MONTH",
YEAR: "lifeEvents.precision.YEAR",
};
export const LIFE_EVENT_ERROR_KEY: Readonly<
Record<LifeEventDraftError, string>
> = {
titleRequired: "lifeEvents.errors.titleRequired",
titleTooLong: "lifeEvents.errors.titleTooLong",
categoryRequired: "lifeEvents.errors.categoryRequired",
dateRequired: "lifeEvents.errors.dateRequired",
endBeforeStart: "lifeEvents.errors.endBeforeStart",
noteTooLong: "lifeEvents.errors.noteTooLong",
};
export const DOCUMENT_KIND_KEY: Readonly<
Record<InboundDocumentKindValue, string>
> = {
DOCTOR_REPORT: "documents.kind.DOCTOR_REPORT",
DISCHARGE_LETTER: "documents.kind.DISCHARGE_LETTER",
LAB_RESULT: "documents.kind.LAB_RESULT",
IMAGING: "documents.kind.IMAGING",
PRESCRIPTION: "documents.kind.PRESCRIPTION",
REFERRAL: "documents.kind.REFERRAL",
INSURANCE: "documents.kind.INSURANCE",
VACCINATION: "documents.kind.VACCINATION",
SICK_NOTE: "documents.kind.SICK_NOTE",
OTHER: "documents.kind.OTHER",
};
/**
* `map[code]` for a code that arrived as a plain string: the key, or
* undefined for a code the map does not hold.
*/
export function keyOf<K extends string>(
map: Readonly<Record<K, string>>,
code: string,
): string | undefined {
return Object.hasOwn(map, code) ? map[code as K] : undefined;
}
+10 -1
View File
@@ -32,8 +32,17 @@ export interface LifeEventDraft {
note: string;
}
/** What a draft can be refused for, each worded under `lifeEvents.errors`. */
export type LifeEventDraftError =
| "titleRequired"
| "titleTooLong"
| "categoryRequired"
| "dateRequired"
| "endBeforeStart"
| "noteTooLong";
export type LifeEventDraftErrors = Partial<
Record<"title" | "category" | "start" | "end" | "note", string>
Record<"title" | "category" | "start" | "end" | "note", LifeEventDraftError>
>;
/** Snap a date to the first day of its month or year. */
+8 -3
View File
@@ -47,6 +47,11 @@ import {
type LifeEventDraft,
type LifeEventDraftErrors,
} from "./life-event-draft";
import {
LIFE_EVENT_CATEGORY_KEY,
LIFE_EVENT_ERROR_KEY,
LIFE_EVENT_PRECISION_KEY,
} from "./label-keys";
import { Segmented } from "./segmented";
import { formatMonthLong, todayKeyIn } from "./timeline-dates";
import { useLifeEventMutations } from "./use-timeline";
@@ -105,7 +110,7 @@ function CategoryChips({
: "border-border text-foreground hover:bg-accent",
)}
>
{t(`lifeEvents.category.${category}`)}
{t(LIFE_EVENT_CATEGORY_KEY[category])}
</button>
);
})}
@@ -227,7 +232,7 @@ export function LifeEventForm({
value: LifeEventDraft[K],
) => setDraft((d) => ({ ...d, [key]: value }));
const fieldError = (field: keyof LifeEventDraftErrors) =>
errors[field] ? t(`lifeEvents.errors.${errors[field]}`) : undefined;
errors[field] ? t(LIFE_EVENT_ERROR_KEY[errors[field]]) : undefined;
async function submit(e: React.FormEvent) {
e.preventDefault();
@@ -381,7 +386,7 @@ export function LifeEventForm({
<Segmented
options={LIFE_EVENT_PRECISIONS.map((p) => ({
value: p,
label: t(`lifeEvents.precision.${p}`),
label: t(LIFE_EVENT_PRECISION_KEY[p]),
}))}
value={draft.precision}
onChange={(p) => setDraft((d) => withPrecision(d, p))}
@@ -27,6 +27,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { isCalendarDateKey } from "@/lib/tz/date-only";
import { cn } from "@/lib/utils";
import {
TIMELINE_READINESS_LANE_KEY,
TIMELINE_READINESS_STATUS_KEY,
TIMELINE_READINESS_VERDICT_KEY,
} from "./label-keys";
import { formatMonthYear } from "./timeline-dates";
import { orderedReadinessLanes, readinessTally } from "./readiness-model";
@@ -68,15 +73,7 @@ export function wordKey(
}
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}`);
return t(TIMELINE_READINESS_LANE_KEY[key]);
}
const STATUS_ICON: Record<TimelineReadinessStatus, typeof CircleCheck> = {
@@ -119,7 +116,7 @@ export function VerdictMeter({
</div>
<p className="text-sm">
<span className="font-semibold">
{t(`timeline.readiness.verdict.${readiness.verdict}`)}
{t(TIMELINE_READINESS_VERDICT_KEY[readiness.verdict])}
</span>
{readiness.verdict === "carries" && (
<>
@@ -178,7 +175,7 @@ export function ReadinessInventory({
>
<Icon
className={cn("mt-0.5 size-4", STATUS_CLASS[lane.status])}
aria-label={t(`timeline.readiness.status.${lane.status}`)}
aria-label={t(TIMELINE_READINESS_STATUS_KEY[lane.status])}
role="img"
/>
<div className="min-w-0">
+4 -1
View File
@@ -30,6 +30,7 @@ import {
formatMonthLong,
formatMonthYear,
} from "./timeline-dates";
import { useItemWords } from "./item-words";
import { itemsInPeriod } from "./timeline-geometry";
import { MeanPartsLine, laneDotStyle } from "./timeline-chart";
import {
@@ -67,6 +68,7 @@ export function SelectionBar({
onEditLifeEvent,
}: SelectionBarProps) {
const { t, tCount, locale } = useTranslations();
const words = useItemWords();
const intl = resolveIntlLocale(locale);
const bucket = timeline.bucket;
const periodFrom = bucketStart(selected, bucket);
@@ -138,7 +140,8 @@ export function SelectionBar({
: item.precision === "DAY"
? formatDayMonth(item.start, intl)
: formatAtPrecision(item.start, item.precision, intl);
const text = `${item.label}${item.sub ? ` ${item.sub}` : ""} · ${when}`;
const { label, sub } = words(item);
const text = [label, sub, when].filter(Boolean).join(" · ");
const editable = item.kind === "lifeEvent" && onEditLifeEvent;
if (!canOpenDay && !editable) {
return (
+24 -11
View File
@@ -51,6 +51,8 @@ import type {
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { useItemWords, type ItemWordsFn } from "./item-words";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
import {
bucketAfter,
bucketStart,
@@ -147,6 +149,7 @@ export function TimelineChart({
onOpenDay,
}: TimelineChartProps) {
const { t, locale } = useTranslations();
const words = useItemWords();
const fmt = seriesFormat;
const intl = resolveIntlLocale(locale);
const { ref, width } = useElementWidth<HTMLDivElement>();
@@ -164,11 +167,12 @@ export function TimelineChart({
lanes,
series: timeline.series,
bucket: timeline.bucket,
words,
startMissing: t("timeline.startMissing"),
today,
})
: null,
[width, window, lanes, timeline.series, timeline.bucket, t, today],
[width, window, lanes, timeline.series, timeline.bucket, words, t, today],
);
const floor = timeline.range.dataFrom ?? window.from;
@@ -420,7 +424,7 @@ function ChartBody({
fontWeight={500}
fill="var(--foreground)"
>
{fitText(t(`timeline.lanes.${lane.key}`), scale.x0 - 30, 13)}
{fitText(t(TIMELINE_LANE_LABEL_KEY[lane.key]), scale.x0 - 30, 13)}
</text>
{lane.spans.map((span) => (
<SpanMark
@@ -676,17 +680,19 @@ function spanTitle(
span: PlacedSpan,
intl: string,
t: ReturnType<typeof useTranslations>["t"],
words: ItemWordsFn,
): string {
const { item } = span;
const { label, sub } = words(item);
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;
const name = sub ? `${label} · ${sub}` : label;
return `${name}: ${t("timeline.selection.range", { from, to })}`;
}
function SpanMark({
export function SpanMark({
span,
color,
x0,
@@ -700,7 +706,8 @@ function SpanMark({
t: ReturnType<typeof useTranslations>["t"];
}) {
const { item, xStart, xEnd, y } = span;
const title = spanTitle(span, intl, t);
const words = useItemWords();
const title = spanTitle(span, intl, t, words);
if (span.pause) {
return (
<g data-kind="pause">
@@ -763,7 +770,7 @@ function SpanMark({
);
}
function PointMark({
export function PointMark({
point,
color,
intl,
@@ -773,7 +780,8 @@ function PointMark({
intl: string;
}) {
const { x, y, item, shape } = point;
const title = `${item.label}${item.sub ? ` · ${item.sub}` : ""}: ${formatAtPrecision(item.start, item.precision, intl)}`;
const { label, sub } = useItemWords()(item);
const title = `${label}${sub ? ` · ${sub}` : ""}: ${formatAtPrecision(item.start, item.precision, intl)}`;
let glyph: React.ReactNode;
switch (shape) {
case "diamond":
@@ -873,6 +881,7 @@ function TimelineTable({
intl: string;
}) {
const { t } = useTranslations();
const words = useItemWords();
const rows = timeline.lanes
.filter((lane) => !hiddenLanes.has(lane.key))
.flatMap((lane) => lane.items.map((item) => ({ lane: lane.key, item })))
@@ -891,11 +900,15 @@ function TimelineTable({
<tbody>
{rows.map(({ lane, item }) => (
<tr key={`${lane}-${item.id}`}>
<td>{t(`timeline.lanes.${lane}`)}</td>
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
<td>
{item.label}
{item.sub ? ` · ${item.sub}` : ""}
{!item.startKnown ? ` · ${t("timeline.startMissing")}` : ""}
{[
words(item).label,
words(item).sub,
item.startKnown ? null : t("timeline.startMissing"),
]
.filter(Boolean)
.join(" · ")}
</td>
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
<td>
+13 -5
View File
@@ -41,6 +41,8 @@ import {
type ChronicleGrouping,
type ChronicleRails,
} from "./chronicle-model";
import { useItemWords } from "./item-words";
import { TIMELINE_CHRONICLE_NOTABLE_KEY } from "./label-keys";
import {
formatAtPrecision,
formatDayMonth,
@@ -146,9 +148,10 @@ export function TimelineChronicle({
onEditLifeEvent: ((id: string) => void) | null;
}) {
const { t, tCount, locale } = useTranslations();
const words = useItemWords();
const intl = resolveIntlLocale(locale);
const [standingOpen, setStandingOpen] = useState(true);
const chips = standingChips(timeline);
const chips = standingChips(timeline, (item) => words(item).label);
const rows = buildChronicle(timeline, today, grouping);
// The means ride on the month headers, each bucket on the newest month it
// overlaps; the year view lists none.
@@ -180,23 +183,28 @@ export function TimelineChronicle({
} {
if (entry.kind === "notable") {
return {
title: t(`timeline.chronicle.notable.${entry.notable}`),
title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]),
meta: t("timeline.chronicle.notableMeta"),
};
}
const { item, role } = entry;
const { label, sub } = words(item);
if (role === "end") {
// A pause that ends is the medication taken up again, not ended.
return {
title: t("timeline.chronicle.ended", { label: item.label }),
title:
item.kind === "pause"
? t("day.event.resumed", { label: item.label })
: t("timeline.chronicle.ended", { label }),
meta: entry.days
? tCount("timeline.chronicle.afterDays", entry.days)
: null,
};
}
const meta = [item.sub, item.startKnown ? null : t("timeline.startMissing")]
const meta = [sub, item.startKnown ? null : t("timeline.startMissing")]
.filter(Boolean)
.join(" · ");
return { title: item.label, meta: meta || null };
return { title: label, meta: meta || null };
}
return (
+27 -21
View File
@@ -26,6 +26,7 @@ import type {
TimelineZoom,
} from "@/lib/day/contract";
import { itemLine, type ItemWordsFn } from "./item-words";
import {
addMonths,
bucketAfter,
@@ -455,17 +456,6 @@ function inWindow(item: TimelineItem, window: TimeWindow): boolean {
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;
@@ -480,7 +470,12 @@ export function layoutLane(
{ lane, top }: LaneInput,
window: TimeWindow,
scale: Scale,
options: { startMissing: string; today: string },
options: {
/** How an item reads (`item-words.ts`): codes worded, never raw. */
words: ItemWordsFn;
startMissing: string;
today: string;
},
): LaneLayout {
const visible = lane.items.filter((item) => inWindow(item, window));
const pointItems = visible.filter(
@@ -580,7 +575,13 @@ export function layoutLane(
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);
const labels = placeLabels(
spans,
points,
scale,
options.words,
options.startMissing,
);
return { key: lane.key, top, height, rows, spans, points, labels };
}
@@ -595,6 +596,7 @@ function placeLabels(
spans: PlacedSpan[],
points: PlacedPoint[],
scale: Scale,
words: ItemWordsFn,
startMissing: string,
): PlacedLabel[] {
const occ = new LineOccupancy();
@@ -662,7 +664,9 @@ function placeLabels(
return true;
};
for (const s of openSpans) aboveLabel(s, itemLabel(s.item, startMissing));
for (const s of openSpans) {
aboveLabel(s, itemLine(s.item, words, 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;
@@ -671,11 +675,10 @@ function placeLabels(
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)
)
const { label } = words(p.item);
if (rightLabel(p.item.id, p.row, besideGlyph(p), p.y, label, strong))
continue;
leftLabel(p.item.id, p.row, leftOfGlyph(p), p.y, p.item.label, strong);
leftLabel(p.item.id, p.row, leftOfGlyph(p), p.y, label, strong);
}
}
// A period's name goes right of its bar, else above it, else left of it.
@@ -683,7 +686,7 @@ function placeLabels(
// 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 text = itemLine(s.item, words, startMissing);
const rowKey = `${s.row}:${text}`;
if (shownOnRow.has(rowKey)) continue;
const strong = s.item.kind === "lifeEvent";
@@ -693,9 +696,10 @@ function placeLabels(
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 s of pausesWithLabel) aboveLabel(s, words(s.item).label);
for (const p of points.filter((p) => p.shape === "doseMark")) {
const text = p.item.sub ?? p.item.label;
const dose = words(p.item);
const text = dose.sub ?? dose.label;
const a = p.x + 2;
const b = a + estimateTextWidth(text);
if (fitsCard(a, b) && occ.tryAdd(line(p.row, "above"), a, b)) {
@@ -864,6 +868,7 @@ export function layoutTimeline(input: {
lanes: readonly TimelineLane[];
series: readonly TimelineSeries[];
bucket: TimelineBucket;
words: ItemWordsFn;
startMissing: string;
today: string;
}): TimelineLayout {
@@ -875,6 +880,7 @@ export function layoutTimeline(input: {
const lane = input.lanes.find((l) => l.key === key);
if (!lane) continue;
const layout = layoutLane({ lane, top }, input.window, scale, {
words: input.words,
startMissing: input.startMissing,
today: input.today,
});
+3 -1
View File
@@ -20,6 +20,8 @@ import {
import { TIMELINE_MAX_SERIES, type TimelineLaneKey } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
/** The most value lines the chart runs under its lanes. */
export const MAX_VALUE_SERIES = TIMELINE_MAX_SERIES;
@@ -131,7 +133,7 @@ export function LayersMenu({
onSelect={(event) => event.preventDefault()}
onCheckedChange={(next) => onToggle(lane, next === true)}
>
{t(`timeline.lanes.${lane}`)}
{t(TIMELINE_LANE_LABEL_KEY[lane])}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
+8 -10
View File
@@ -45,6 +45,11 @@ 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 {
TIMELINE_BLOOD_PRESSURE_SERIES_KEY,
TIMELINE_LEGEND_MEAN_KEY,
TIMELINE_ZOOM_LABEL_KEY,
} from "./label-keys";
import { LifeEventSheet } from "./life-event-sheet";
import { ReadinessCard } from "./readiness-card";
import {
@@ -284,7 +289,7 @@ export function TimelineView() {
// "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}`)
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
: key === "MOOD"
? t("timeline.readiness.lanes.mood")
: t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key);
@@ -309,7 +314,7 @@ export function TimelineView() {
const zoomOptions = TIMELINE_ZOOMS.map((z) => ({
value: z,
label: t(`timeline.zoom.${z}`),
label: t(TIMELINE_ZOOM_LABEL_KEY[z]),
}));
const groupingOptions: Array<{ value: ChronicleGrouping; label: string }> = [
{ value: "year", label: t("timeline.chronicle.years") },
@@ -544,13 +549,6 @@ export function TimelineView() {
);
}
const LEGEND_MEAN_KEY: Readonly<Record<TimelineBucket, string>> = {
quarter: "timeline.legendMeanQuarter",
month: "timeline.legendMeanMonth",
week: "timeline.legendMeanWeek",
day: "timeline.legendMeanDay",
};
function Legend({
bucket,
hasSeries,
@@ -586,7 +584,7 @@ function Legend({
data-bucket={bucket}
>
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
{t(LEGEND_MEAN_KEY[bucket])}
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
</span>
<span className="inline-flex items-center gap-1.5">
<span
@@ -39,6 +39,11 @@ describe("axis unit suffix", () => {
expect(axisUnitSuffix(null)).toBeUndefined();
});
it("keeps a unit with a space of its own on one line", () => {
expect(axisUnitSuffix("/ 5")).toBe("\u00a0/\u00a05");
expect(axisUnitSuffix("mmHg")).toBe("\u00a0mmHg");
});
it("finds the axis units it is meant to police", () => {
// A matcher that finds nothing would pass the check below by default.
expect(axisUnitProps().length).toBeGreaterThanOrEqual(6);
@@ -55,6 +60,59 @@ describe("axis unit suffix", () => {
});
});
/**
* Every tag in the app that names both a `unit` and a `yAxisUnit` literal.
* The dashboard's blood pressure chart carried `yAxisUnit="Hg"` beside
* `unit="mmHg"` from the first release on, so its axis read "154 Hg".
*/
function axisUnitOverrides(): Array<{
file: string;
unit: string;
axis: string;
}> {
const out: Array<{ file: string; unit: string; axis: string }> = [];
for (const root of ["app", "components"]) {
const dir = join(process.cwd(), "src", root);
for (const rel of walkSourceFiles(dir, {
floor: 100,
extensions: [".tsx"],
})) {
if (rel.includes("__tests__")) continue;
const src = stripComments(readFileSync(join(dir, rel), "utf8"));
// The tag around each override: back to the previous element's end,
// forward to the next one's. A prop holding JSX (`icon={<X />}`) can
// sit inside, so the tag is not cut at the first `<`.
for (const m of src.matchAll(/\byAxisUnit="([^"]*)"/g)) {
const before = src.slice(0, m.index);
const from = Math.max(0, before.lastIndexOf("/>"), m.index - 1500);
const afterEnd = src.indexOf("/>", m.index);
const tag = src.slice(from, afterEnd === -1 ? undefined : afterEnd);
const unit = /\bunit="([^"]*)"/.exec(tag);
if (unit)
out.push({ file: `${root}/${rel}`, unit: unit[1], axis: m[1] });
}
}
}
return out;
}
describe("axis unit overrides", () => {
it("finds the overrides it is meant to police", () => {
expect(axisUnitOverrides().length).toBeGreaterThanOrEqual(5);
});
it("never cuts a unit short on the axis", () => {
const cut = axisUnitOverrides()
.filter(
({ unit, axis }) => axis !== "" && axis !== unit && unit.includes(axis),
)
.map(
({ file, unit, axis }) => `${file}: unit="${unit}" yAxisUnit="${axis}"`,
);
expect(cut).toEqual([]);
});
});
describe("axisTickUnitSuffix", () => {
it("keeps a short unit on the ticks", () => {
expect(axisTickUnitSuffix("bpm")).toBe(axisUnitSuffix("bpm"));
+2 -1
View File
@@ -11,7 +11,8 @@
export function axisUnitSuffix(
unit: string | null | undefined,
): string | undefined {
return unit ? ` ${unit}` : undefined;
// A space inside the unit ("/ 5") would break the label the same way.
return unit ? ` ${unit.replace(/ /g, "\u00a0")}` : undefined;
}
/**
+22 -4
View File
@@ -122,8 +122,17 @@ const dayEvent = z
kind: z.enum(DAY_EVENT_KINDS),
section: daySectionKey,
id: z.string(),
title: z.string(),
meta: z.string().nullable(),
title: z
.string()
.describe(
"The record's own name, or a code the client words: a mood, a screener's instrument, a workout's sport, `cycle` for a cycle day.",
),
meta: z
.string()
.nullable()
.describe(
"The second line: text (a dose, a lab value, a duration) or a code or number the client words (a category, a kind, a severity, a screener's score and band, an intensity, a dose number).",
),
note: z
.string()
.nullable()
@@ -318,8 +327,17 @@ const timelineItem = z
.describe(
"False when the start is a stand-in (a medication without a start date starts at its first intake).",
),
label: z.string(),
sub: z.string().nullable(),
label: z
.string()
.describe(
"The item's name. A trip is labelled `travel` and a cycle `cycle`; the client words both.",
),
sub: z
.string()
.nullable()
.describe(
"The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a dose change. The client words every code.",
),
href: z.string().nullable(),
})
.meta({ id: "TimelineItem" });
+101 -4
View File
@@ -20,7 +20,13 @@
*
* Every value is the mean of the bucket's days, each day weighing one (a
* cumulative type's days are its day totals, a pulse-like type's day is the
* mean of its hourly means, `daily-stats.ts`), matching the charts. Each
* mean of its hourly means, `daily-stats.ts`), matching the charts. Sleep is
* the exception to both sources: it is stored one row per stage, so a day
* mean or a rolled-up month would average stage fragments (and count an
* in-bed row beside the stages inside it). Its value is the night's time
* asleep as the sleep page reads it (`reconstructSleepNights`), one per
* night keyed by its wake day, and a bucket's mean is the mean of its
* nights. Each
* point carries the readings behind it, so the chart can mark a mean that
* rests on one or two. A bucket without a reading is not sent, and a series
* without a point is not sent. `MOOD` is the mood score, read from the
@@ -28,6 +34,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/enums";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { caches, cached } from "@/lib/cache/server-cache";
import { readLocalDailyCells, type DayCell } from "@/lib/day/daily-stats";
import type { TimelineBucket, TimelineSeries } from "@/lib/day/contract";
@@ -218,6 +225,93 @@ function readsRollups(from: string, to: string): boolean {
return days >= LIVE_WINDOW_MAX_DAYS;
}
/** Sleep is read a year of stage rows at a time, so no read holds more. */
const SLEEP_CHUNK_DAYS = 366;
/**
* Margin read around each chunk, so a night that crosses the chunk's edge is
* read whole in the chunk its wake day belongs to and not cut in two.
*/
const SLEEP_CHUNK_MARGIN_MS = 24 * 3_600_000;
/**
* Time asleep per night for the nights that woke in `[from, to]` (local
* day keys), each a cell of one reading. The stage rows are read a year at
* a time and folded into nights before the next year is read, so a decade
* of nights never sits in memory as rows at once.
*/
export async function readNightCells(args: {
userId: string;
from: string;
to: string;
tz: string;
priorityJson: unknown;
}): Promise<Map<string, DayCell>> {
const out = new Map<string, DayCell>();
for (
let chunkFrom = args.from;
chunkFrom <= args.to;
chunkFrom = shiftDateKey(chunkFrom, SLEEP_CHUNK_DAYS)
) {
const next = shiftDateKey(chunkFrom, SLEEP_CHUNK_DAYS);
const chunkTo = next > args.to ? args.to : shiftDateKey(next, -1);
const start = startOfLocalDayKey(chunkFrom, args.tz);
const end = startOfLocalDayKey(shiftDateKey(chunkTo, 1), args.tz);
const rows = await prisma.measurement.findMany({
where: {
userId: args.userId,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: {
gte: new Date(start.getTime() - SLEEP_CHUNK_MARGIN_MS),
lt: new Date(end.getTime() + SLEEP_CHUNK_MARGIN_MS),
},
},
orderBy: { measuredAt: "asc" },
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
});
for (const night of reconstructSleepNights(
rows,
args.tz,
args.priorityJson,
)) {
if (night.night < chunkFrom || night.night > chunkTo) continue;
if (night.asleepMinutes <= 0) continue;
out.set(night.night, { value: night.asleepMinutes, count: 1 });
}
}
return out;
}
/** The sleep series: the mean time asleep of each bucket's nights. */
async function sleepPoints(args: {
userId: string;
from: string;
to: string;
tz: string;
bucket: TimelineBucket;
priorityJson: unknown;
}): Promise<SeriesPoint[]> {
const read = async () => foldDays(await readNightCells(args), args.bucket);
if (!readsRollups(args.from, args.to)) return read();
// A long window is cached under the record's prefix: every measurement
// write marks it stale with the rest of the analytics bucket.
const key = `${args.userId}|timeline-sleep|${args.bucket}|${args.tz}|${args.from}|${args.to}`;
return cached(
caches.analytics,
key,
read,
undefined,
PRE_FOLD_TTL_MS,
) as Promise<SeriesPoint[]>;
}
async function measurementSeries(args: {
userId: string;
type: MeasurementType;
@@ -231,9 +325,12 @@ async function measurementSeries(args: {
const start = startOfLocalDayKey(args.from, args.tz);
const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz);
let points: SeriesPoint[];
// Weeks and days never read the rollups: their windows are short (a
// `day` range is under six weeks), and the rollups cut at UTC midnight.
if (
// Sleep reads its nights at every length. Weeks and days never read the
// rollups: their windows are short (a `day` range is under six weeks), and
// the rollups cut at UTC midnight.
if (args.type === "SLEEP_DURATION") {
points = await sleepPoints(args);
} else if (
args.bucket !== "week" &&
args.bucket !== "day" &&
readsRollups(args.from, args.to)
@@ -0,0 +1,162 @@
/**
* v1.42 — the timeline's sleep line is the time asleep per night, averaged
* over the bucket's nights. Against Postgres.
*
* Sleep is stored one row per stage. Read like any other type, the timeline
* averaged those rows as if each were a night (IN_BED, AWAKE and every CORE
* fragment alike), so a quarter of seven-hour nights read as a little over
* two and a half hours. A night here is what the sleep page and the day view
* call one: CORE + DEEP + REM of the night's winning writer, IN_BED and
* AWAKE left out, keyed by its wake day.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const TZ = "Europe/Berlin";
const NOW = new Date("2026-10-09T12:00:00.000Z");
let user = "";
let seq = 0;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
// A fresh record per test: the long windows are cached per record.
user = `timeline-sleep-${++seq}`;
await getPrismaClient().user.create({
data: {
id: user,
username: user,
email: `${user}@example.test`,
timezone: TZ,
},
});
});
/** A Berlin wall-clock instant (summer and winter offsets both handled). */
function berlin(day: string, hhmm: string): Date {
const guess = new Date(`${day}T${hhmm}:00.000Z`);
const wall = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(guess);
const [d, t] = wall.split(", ");
const shown = new Date(`${d}T${t}:00.000Z`);
return new Date(guess.getTime() - (shown.getTime() - guess.getTime()));
}
/**
* One night that wakes on `wake`: in bed from 22:40 the evening before to
* 06:40, asleep 50 + 150 + 90 + 100 + `lastCore` minutes around one AWAKE
* spell, the first segment ending before midnight. Each row's instant is its
* segment's end, as the importers write it.
*/
async function seedNight(wake: string, lastCore: number) {
const { shiftDateKey } = await import("@/lib/tz/format");
const eve = shiftDateKey(wake, -1);
const rows: Array<[string, number, string, string]> = [
["IN_BED", 480, wake, "06:40"],
["CORE", 50, eve, "23:50"],
["CORE", 150, wake, "02:20"],
["DEEP", 90, wake, "03:50"],
["REM", 100, wake, "05:30"],
["AWAKE", 20, wake, "05:50"],
["CORE", lastCore, wake, "06:30"],
];
await getPrismaClient().measurement.createMany({
data: rows.map(([stage, minutes, day, end], i) => ({
userId: user,
type: "SLEEP_DURATION" as const,
value: minutes,
unit: "minutes",
source: "APPLE_HEALTH" as const,
measuredAt: berlin(day, end),
externalId: `uuid-${wake}-${i}`,
sleepStage: stage as "IN_BED" | "CORE" | "DEEP" | "REM" | "AWAKE",
})),
});
}
async function sleepSeries(
from: string,
to: string,
bucket: "month" | "quarter",
) {
const { loadTimelineSeries } = await import("@/lib/timeline/series");
const [out] = await loadTimelineSeries({
userId: user,
keys: ["SLEEP_DURATION"],
from,
to,
tz: TZ,
bucket,
priorityJson: null,
now: NOW,
});
return out?.points ?? [];
}
describe("the timeline's sleep line", () => {
it("averages the nights of a quarter, each night its time asleep", async () => {
await seedNight("2026-07-03", 40); // 430 min asleep
await seedNight("2026-08-12", 50); // 440
await seedNight("2026-09-20", 60); // 450
expect(await sleepSeries("2026-07-01", "2026-09-30", "quarter")).toEqual([
{ t: "2026-07-01", mean: 440, count: 3 },
]);
expect(await sleepSeries("2026-07-01", "2026-09-30", "month")).toEqual([
{ t: "2026-07-01", mean: 430, count: 1 },
{ t: "2026-08-01", mean: 440, count: 1 },
{ t: "2026-09-01", mean: 450, count: 1 },
]);
});
it("reads the nights, not the rolled-up stage rows, over a long window", async () => {
await seedNight("2023-02-03", 40); // 430
await seedNight("2023-03-04", 60); // 450
// What the rollup tier holds for those days: the mean of the stage rows.
await getPrismaClient().measurementRollup.createMany({
data: ["2023-02-01", "2023-03-01"].map((month) => ({
userId: user,
type: "SLEEP_DURATION" as const,
granularity: "MONTH" as const,
bucketStart: new Date(`${month}T00:00:00.000Z`),
source: "APPLE_HEALTH" as const,
count: 6,
mean: 155,
minValue: 20,
maxValue: 480,
sumValue: 930,
})),
});
expect(await sleepSeries("2016-10-09", "2026-10-09", "quarter")).toEqual([
{ t: "2023-01-01", mean: 440, count: 2 },
]);
});
it("keeps a night whole where one year's read ends and the next begins", async () => {
const { readNightCells } = await import("@/lib/timeline/series");
const { shiftDateKey } = await import("@/lib/tz/format");
// The second read starts 366 days after the first.
const from = "2024-01-01";
const boundary = shiftDateKey(from, 366);
await seedNight(boundary, 40);
const nights = await readNightCells({
userId: user,
from,
to: "2025-12-31",
tz: TZ,
priorityJson: null,
});
expect([...nights]).toEqual([[boundary, { value: 430, count: 1 }]]);
});
});