mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(timeline): word every code an entry carries, and read a pause as one
The timeline and the day view showed what the server sends as it was sent: a life event's category as FAMILY, a visit's kind as PREVENTIVE, a document's kind as DISCHARGE_LETTER, a trip and a cycle under their sentinel labels, a lab day as a bare "5", a mood as SUPER_GUT, a screener as PHQ9 with its band code. A pause read as "Ramipril" at its start and "Ramipril ended" at its end. Every surface that shows a timeline item (lane labels, hover titles, the screen-reader table, the selection bar, the phone chronicle) now reads it through one wording, item-words.ts: a code goes through the bundle's existing labels, a lab day reads "5 lab values", and a code the bundle does not know is left out rather than shown. The day view's title and second line do the same per event kind (mood, screener and band, sport, severity, visit kind or planned, dose number, intensity, functional impact, document kind), and a medication that starts, ends, pauses or resumes says so, in the day view and in the visit preparation alike. A pause reads "Ramipril paused" at its start and "Ramipril resumed · after 11 days" at its end, on the lanes, in the selection bar and in the chronicle. A guard renders a record with every item kind, every category and every kind code on each surface, in two languages, and fails on any upper-case code or sentinel in the visible text. The wire schema now says which fields carry codes.
This commit is contained in:
1 parent
d8a3614120
commit
2638174b66
24 files changed
+1055
-95
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
|
||||
|
||||
+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": {
|
||||
|
||||
@@ -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,9 @@ import {
|
||||
type DayRunningKind,
|
||||
type DayValue,
|
||||
} from "@/lib/day/contract";
|
||||
import { visitKindWords } from "@/components/timeline/item-words";
|
||||
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";
|
||||
@@ -161,43 +162,114 @@ const RUNNING_COLOR: Record<DayRunningKind, string> = {
|
||||
lifeEvent: "var(--chart-2)",
|
||||
};
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/** The bundle's wording of `key`, or null when the bundle has none. */
|
||||
function known(t: Translate, key: string): string | null {
|
||||
const value = t(key);
|
||||
return value === key ? null : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? known(t, key) : null) ?? t("nav.mood");
|
||||
}
|
||||
case "assessment":
|
||||
return (
|
||||
known(t, `mentalHealth.instrument.${title.toLowerCase()}`) ?? title
|
||||
);
|
||||
case "workout":
|
||||
return known(t, `insights.workouts.sport.${title}`) ?? 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":
|
||||
return FLOW_LEVELS.has(meta)
|
||||
? `${t("cycle.flow.label")}: ${t(`cycle.flow.${meta}`)}`
|
||||
: null;
|
||||
case "lifeEvent":
|
||||
return known(t, `lifeEvents.category.${meta}`);
|
||||
// The mood is the title; its score says the same thing again.
|
||||
case "mood":
|
||||
return null;
|
||||
case "assessment": {
|
||||
const [score, band] = meta.split(" ");
|
||||
const bandText = band
|
||||
? known(t, `mentalHealth.band.${title}.${band}`)
|
||||
: null;
|
||||
return [score, bandText].filter(Boolean).join(" · ") || null;
|
||||
}
|
||||
case "symptom":
|
||||
return t("symptoms.intensityPill", { value: meta });
|
||||
case "allergyOnset":
|
||||
return known(t, `records.allergies.severity.${meta}`);
|
||||
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 impact = known(t, `illness.impact.${meta}`);
|
||||
return impact ? t("illness.timeline.impact", { impact }) : null;
|
||||
}
|
||||
case "document":
|
||||
return known(t, `documents.kind.${meta}`);
|
||||
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 +524,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 +560,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.
|
||||
|
||||
@@ -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,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,135 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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;
|
||||
|
||||
/** The bundle's wording of `key`, or null when the bundle has none. */
|
||||
function known(t: Translate, key: string): string | null {
|
||||
const value = t(key);
|
||||
return value === key ? null : value;
|
||||
}
|
||||
|
||||
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: item.sub ? known(t, `lifeEvents.category.${item.sub}`) : null,
|
||||
};
|
||||
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: item.sub ? known(t, `documents.kind.${item.sub}`) : null,
|
||||
};
|
||||
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]);
|
||||
}
|
||||
@@ -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,7 @@ import type {
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { useItemWords, type ItemWordsFn } from "./item-words";
|
||||
import {
|
||||
bucketAfter,
|
||||
bucketStart,
|
||||
@@ -147,6 +148,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 +166,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;
|
||||
@@ -676,17 +679,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 +705,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 +769,7 @@ function SpanMark({
|
||||
);
|
||||
}
|
||||
|
||||
function PointMark({
|
||||
export function PointMark({
|
||||
point,
|
||||
color,
|
||||
intl,
|
||||
@@ -773,7 +779,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 +880,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 })))
|
||||
@@ -893,9 +901,13 @@ function TimelineTable({
|
||||
<tr key={`${lane}-${item.id}`}>
|
||||
<td>{t(`timeline.lanes.${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,7 @@ import {
|
||||
type ChronicleGrouping,
|
||||
type ChronicleRails,
|
||||
} from "./chronicle-model";
|
||||
import { useItemWords } from "./item-words";
|
||||
import {
|
||||
formatAtPrecision,
|
||||
formatDayMonth,
|
||||
@@ -146,9 +147,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.
|
||||
@@ -185,18 +187,23 @@ export function TimelineChronicle({
|
||||
};
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in new issue
Block a user