diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index be3c18f70..22419defa 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -32,6 +32,11 @@ import { 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"; @@ -43,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"; /** @@ -113,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); }; } @@ -167,12 +181,6 @@ type Translate = ( params?: Record, ) => 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 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 @@ -206,21 +214,21 @@ export function dayTitle(kind: string, title: string, t: Translate): string { return t("day.event.illnessResolved", { label: title }); case "mood": { const key = MOOD_LABEL_KEYS[title]; - return (key ? known(t, key) : null) ?? t("nav.mood"); + 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; } - 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"]); - /** * 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), @@ -234,26 +242,32 @@ export function dayMeta( 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}`); + 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 bandText = band - ? known(t, `mentalHealth.band.${title}.${band}`) + 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": - return known(t, `records.allergies.severity.${meta}`); + case "allergyOnset": { + const key = keyOf(ALLERGY_SEVERITY_KEY, meta); + return key ? t(key) : null; + } case "visit": case "procedure": return meta === "PLANNED" @@ -262,11 +276,13 @@ export function dayMeta( 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; + 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; } - case "document": - return known(t, `documents.kind.${meta}`); default: return meta; } diff --git a/src/components/day/label-keys.ts b/src/components/day/label-keys.ts new file mode 100644 index 000000000..237fc520c --- /dev/null +++ b/src/components/day/label-keys.ts @@ -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> = { + extremeHigh: "day.notable.extremeHigh", + extremeLow: "day.notable.extremeLow", + firstValue: "day.notable.firstValue", + gap: "day.notable.gap", +}; + +export const CYCLE_FLOW_KEY: Readonly> = { + 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> = { + MILD: "records.allergies.severity.MILD", + MODERATE: "records.allergies.severity.MODERATE", + SEVERE: "records.allergies.severity.SEVERE", +}; + +export const ASSESSMENT_INSTRUMENT_KEY: Readonly> = + { + 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>> +> = { + 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> = { + 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> = { + "0": "illness.impact.0", + "1": "illness.impact.1", + "2": "illness.impact.2", + "3": "illness.impact.3", +}; diff --git a/src/components/timeline/__tests__/label-keys.test.ts b/src/components/timeline/__tests__/label-keys.test.ts new file mode 100644 index 000000000..651bcad93 --- /dev/null +++ b/src/components/timeline/__tests__/label-keys.test.ts @@ -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( + (o, part) => + o && typeof o === "object" + ? (o as Record)[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(), + ); + }); +}); diff --git a/src/components/timeline/__tests__/no-template-key-guard.test.ts b/src/components/timeline/__tests__/no-template-key-guard.test.ts new file mode 100644 index 000000000..f9661ba6e --- /dev/null +++ b/src/components/timeline/__tests__/no-template-key-guard.test.ts @@ -0,0 +1,64 @@ +/** + * 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 = /(?> = { + "src/components/timeline/readiness-inventory.tsx": { + count: 1, + why: "wordKey checks the server-sent key exists before it words it", + }, +}; + +function templateCalls(): Map { + const out = new Map(); + 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", () => { + expect( + templateCalls().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([]); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-view-keys.test.tsx b/src/components/timeline/__tests__/timeline-view-keys.test.tsx new file mode 100644 index 000000000..e5e372a19 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-view-keys.test.tsx @@ -0,0 +1,122 @@ +/** + * 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 { 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(); + 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 }; + const { TimelineView } = await import("../timeline-view"); + return textOf( + renderToStaticMarkup( + + + + + , + ), + ); +} + +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([]); + }); + } + } + } +}); diff --git a/src/components/timeline/item-words.ts b/src/components/timeline/item-words.ts index 55decc74d..b526dd18c 100644 --- a/src/components/timeline/item-words.ts +++ b/src/components/timeline/item-words.ts @@ -17,6 +17,12 @@ 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, @@ -36,10 +42,14 @@ export interface ItemWords { 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; +/** A code worded through `map`, or null for none or a code it lacks. */ +function worded( + t: Translate, + map: Readonly>, + code: string | null, +): string | null { + const key = code === null ? undefined : keyOf(map, code); + return key ? t(key) : null; } const ENCOUNTER_KIND_CODES: ReadonlySet = new Set([ @@ -70,7 +80,7 @@ export function makeItemWords( case "lifeEvent": return { label: item.label, - sub: item.sub ? known(t, `lifeEvents.category.${item.sub}`) : null, + sub: worded(t, LIFE_EVENT_CATEGORY_KEY, item.sub), }; case "travel": return { label: t("day.travel"), sub: null }; @@ -102,7 +112,7 @@ export function makeItemWords( case "document": return { label: item.label, - sub: item.sub ? known(t, `documents.kind.${item.sub}`) : null, + sub: worded(t, DOCUMENT_KIND_KEY, item.sub), }; default: // A dose, a dose change, a name: the record's own text. diff --git a/src/components/timeline/label-keys.ts b/src/components/timeline/label-keys.ts new file mode 100644 index 000000000..f4de96e35 --- /dev/null +++ b/src/components/timeline/label-keys.ts @@ -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 +> = { + 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> = { + all: "timeline.zoom.all", + year: "timeline.zoom.year", + quarter: "timeline.zoom.quarter", + range: "timeline.zoom.range", +}; + +export const TIMELINE_LEGEND_MEAN_KEY: Readonly< + Record +> = { + 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 +> = { + 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 +> = { + 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 +> = { + carries: "timeline.readiness.verdict.carries", + thin: "timeline.readiness.verdict.thin", +}; + +export const TIMELINE_READINESS_STATUS_KEY: Readonly< + Record +> = { + carries: "timeline.readiness.status.carries", + thin: "timeline.readiness.status.thin", + empty: "timeline.readiness.status.empty", +}; + +export const LIFE_EVENT_CATEGORY_KEY: Readonly< + Record +> = { + 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 +> = { + DAY: "lifeEvents.precision.DAY", + MONTH: "lifeEvents.precision.MONTH", + YEAR: "lifeEvents.precision.YEAR", +}; + +export const LIFE_EVENT_ERROR_KEY: Readonly< + Record +> = { + 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 +> = { + 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( + map: Readonly>, + code: string, +): string | undefined { + return Object.hasOwn(map, code) ? map[code as K] : undefined; +} diff --git a/src/components/timeline/life-event-draft.ts b/src/components/timeline/life-event-draft.ts index d2514303e..12954fe9c 100644 --- a/src/components/timeline/life-event-draft.ts +++ b/src/components/timeline/life-event-draft.ts @@ -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. */ diff --git a/src/components/timeline/life-event-form.tsx b/src/components/timeline/life-event-form.tsx index 203ce7000..e329b5930 100644 --- a/src/components/timeline/life-event-form.tsx +++ b/src/components/timeline/life-event-form.tsx @@ -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])} ); })} @@ -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({ ({ value: p, - label: t(`lifeEvents.precision.${p}`), + label: t(LIFE_EVENT_PRECISION_KEY[p]), }))} value={draft.precision} onChange={(p) => setDraft((d) => withPrecision(d, p))} diff --git a/src/components/timeline/readiness-inventory.tsx b/src/components/timeline/readiness-inventory.tsx index b9486285f..68c27df50 100644 --- a/src/components/timeline/readiness-inventory.tsx +++ b/src/components/timeline/readiness-inventory.tsx @@ -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 = { @@ -119,7 +116,7 @@ export function VerdictMeter({

- {t(`timeline.readiness.verdict.${readiness.verdict}`)} + {t(TIMELINE_READINESS_VERDICT_KEY[readiness.verdict])} {readiness.verdict === "carries" && ( <> @@ -178,7 +175,7 @@ export function ReadinessInventory({ >

diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 5d9045f36..2004eb12d 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -52,6 +52,7 @@ 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, @@ -423,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)} {lane.spans.map((span) => ( {rows.map(({ lane, item }) => ( - {t(`timeline.lanes.${lane}`)} + {t(TIMELINE_LANE_LABEL_KEY[lane])} {[ words(item).label, diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index 6ada2fc9c..df4bcc51f 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -42,6 +42,7 @@ import { type ChronicleRails, } from "./chronicle-model"; import { useItemWords } from "./item-words"; +import { TIMELINE_CHRONICLE_NOTABLE_KEY } from "./label-keys"; import { formatAtPrecision, formatDayMonth, @@ -182,7 +183,7 @@ 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"), }; } diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index b94cdd571..bad95fb11 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -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])} ))} diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index 99962904f..a3de9ffb2 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -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> = { - 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} >