mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge v142/shots-fixes into release/v1.42.0
This commit is contained in:
commit
cda6c58d12
45 files changed
+2050
-138
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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`}>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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 }]]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user