refactor(timeline): word codes through typed key maps, never template keys

A key built from a template, t(`timeline.zoom.${zoom}`), is invisible to
the call-site coverage guard, so a code without its message would ship as
the raw key and the gate stays green. The timeline and the day view now
word every code family (lanes, zooms, legends, readiness rows, life event
categories, precisions and errors, document kinds, notables, cycle flow,
allergy severity, screener instruments and bands, sports, functional
impact) through maps typed over the code's own union, so a new code does
not compile without its key.

Three checks hold it: one resolves every map value in all seven bundles
and pins the band and sport maps to their sources, one forbids template
keys in both trees (the one existence-checked readiness lookup is named),
and one renders the whole timeline page at every zoom and every bucket in
two languages and fails on any message key in the visible text.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 13:13:33 +02:00
1 parent 3f47224825
commit 78aa80b2ea
14 files changed
+627 -65

No files matched your search

+46 -30
View File
@@ -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, 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 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;
}
+99
View File
@@ -0,0 +1,99 @@
/**
* The message key of every code the day view words (v1.42, #613).
*
* Maps rather than template keys, for the reason in
* `components/timeline/label-keys.ts`: each is typed over the code's own
* union, and `label-keys.test.ts` resolves every value against the bundle.
*/
import type { AllergySeverity, FlowLevel } from "@/generated/prisma/client";
import type { DayNotableKind } from "@/lib/day/contract";
import type { InstrumentId } from "@/lib/mental-health/instruments";
import type { WorkoutSportType } from "@/lib/validations/workout";
export const DAY_NOTABLE_KEY: Readonly<Record<DayNotableKind, string>> = {
extremeHigh: "day.notable.extremeHigh",
extremeLow: "day.notable.extremeLow",
firstValue: "day.notable.firstValue",
gap: "day.notable.gap",
};
export const CYCLE_FLOW_KEY: Readonly<Record<FlowLevel, string>> = {
NONE: "cycle.flow.NONE",
SPOTTING: "cycle.flow.SPOTTING",
LIGHT: "cycle.flow.LIGHT",
MEDIUM: "cycle.flow.MEDIUM",
HEAVY: "cycle.flow.HEAVY",
};
export const ALLERGY_SEVERITY_KEY: Readonly<Record<AllergySeverity, string>> = {
MILD: "records.allergies.severity.MILD",
MODERATE: "records.allergies.severity.MODERATE",
SEVERE: "records.allergies.severity.SEVERE",
};
export const ASSESSMENT_INSTRUMENT_KEY: Readonly<Record<InstrumentId, string>> =
{
PHQ9: "mentalHealth.instrument.phq9",
GAD7: "mentalHealth.instrument.gad7",
WHO5: "mentalHealth.instrument.who5",
SCI: "mentalHealth.instrument.sci",
};
/** Each screener's bands (`instruments.ts`), by the band key it stores. */
export const ASSESSMENT_BAND_KEY: Readonly<
Record<InstrumentId, Readonly<Record<string, string>>>
> = {
PHQ9: {
minimal: "mentalHealth.band.PHQ9.minimal",
mild: "mentalHealth.band.PHQ9.mild",
moderate: "mentalHealth.band.PHQ9.moderate",
modSevere: "mentalHealth.band.PHQ9.modSevere",
severe: "mentalHealth.band.PHQ9.severe",
},
GAD7: {
minimal: "mentalHealth.band.GAD7.minimal",
mild: "mentalHealth.band.GAD7.mild",
moderate: "mentalHealth.band.GAD7.moderate",
severe: "mentalHealth.band.GAD7.severe",
},
WHO5: {
low: "mentalHealth.band.WHO5.low",
good: "mentalHealth.band.WHO5.good",
},
SCI: {
belowThreshold: "mentalHealth.band.SCI.belowThreshold",
aboveThreshold: "mentalHealth.band.SCI.aboveThreshold",
},
};
export const WORKOUT_SPORT_KEY: Readonly<Record<WorkoutSportType, string>> = {
walking: "insights.workouts.sport.walking",
running: "insights.workouts.sport.running",
cycling: "insights.workouts.sport.cycling",
hiking: "insights.workouts.sport.hiking",
swimming: "insights.workouts.sport.swimming",
rowing: "insights.workouts.sport.rowing",
elliptical: "insights.workouts.sport.elliptical",
stairClimber: "insights.workouts.sport.stairClimber",
yoga: "insights.workouts.sport.yoga",
mindAndBody: "insights.workouts.sport.mindAndBody",
strength: "insights.workouts.sport.strength",
hiit: "insights.workouts.sport.hiit",
dance: "insights.workouts.sport.dance",
golf: "insights.workouts.sport.golf",
badminton: "insights.workouts.sport.badminton",
tennis: "insights.workouts.sport.tennis",
basketball: "insights.workouts.sport.basketball",
soccer: "insights.workouts.sport.soccer",
crossTraining: "insights.workouts.sport.crossTraining",
mixedCardio: "insights.workouts.sport.mixedCardio",
other: "insights.workouts.sport.other",
};
/** An illness day's functional impact, 0 (fully functional) to 3. */
export const ILLNESS_IMPACT_KEY: Readonly<Record<string, string>> = {
"0": "illness.impact.0",
"1": "illness.impact.1",
"2": "illness.impact.2",
"3": "illness.impact.3",
};
@@ -0,0 +1,78 @@
/**
* v1.42 (#613) — every key the timeline and the day view word a code with
* exists. The maps in `label-keys.ts` replaced template keys the call-site
* coverage guard could not read; this walks their values instead, so a map
* entry without its message fails here rather than on a screen.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import * as dayKeys from "@/components/day/label-keys";
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
import * as timelineKeys from "../label-keys";
const LOCALES = ["de", "en", "es", "fr", "it", "ko", "pl"] as const;
const bundles = Object.fromEntries(
LOCALES.map((l) => [
l,
JSON.parse(
readFileSync(join(process.cwd(), "messages", `${l}.json`), "utf8"),
),
]),
);
function leaf(bundle: unknown, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(o, part) =>
o && typeof o === "object"
? (o as Record<string, unknown>)[part]
: undefined,
bundle,
);
}
/** Every string value in a map, maps of maps included, with its path. */
function values(map: unknown, path: string): Array<[string, string]> {
if (typeof map === "string") return [[path, map]];
if (!map || typeof map !== "object") return [];
return Object.entries(map).flatMap(([k, v]) => values(v, `${path}.${k}`));
}
const MAPS = [
...Object.entries(timelineKeys),
...Object.entries(dayKeys),
].filter(([, v]) => typeof v === "object");
describe("label key maps", () => {
it("finds the maps it is meant to check", () => {
expect(MAPS.length).toBeGreaterThanOrEqual(18);
});
it.each(LOCALES)("resolves every key in %s", (locale) => {
const missing = MAPS.flatMap(([name, map]) => values(map, name)).filter(
([, key]) => typeof leaf(bundles[locale], key) !== "string",
);
expect(missing).toEqual([]);
});
it("names every band each screener stores", () => {
for (const id of INSTRUMENT_ORDER) {
expect(Object.keys(dayKeys.ASSESSMENT_BAND_KEY[id]).sort()).toEqual(
INSTRUMENTS[id].bands.map((b) => b.key).sort(),
);
}
});
it("names every sport a workout can carry", () => {
expect(Object.keys(dayKeys.WORKOUT_SPORT_KEY).sort()).toEqual(
[...workoutSportTypeEnum.options].sort(),
);
});
});
@@ -0,0 +1,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 = /(?<![\w.])(?:t|tCount)\(\s*`/g;
const ALLOWED: Readonly<Record<string, { count: number; why: string }>> = {
"src/components/timeline/readiness-inventory.tsx": {
count: 1,
why: "wordKey checks the server-sent key exists before it words it",
},
};
function templateCalls(): Map<string, number> {
const out = new Map<string, number>();
for (const root of ROOTS) {
const dir = join(process.cwd(), root);
for (const rel of walkSourceFiles(dir, { floor: 10 })) {
if (rel.includes("__tests__")) continue;
const src = stripComments(readFileSync(join(dir, rel), "utf8"));
const count = [...src.matchAll(TEMPLATE_KEY)].length;
if (count > 0) out.set(`${root}/${rel}`, count);
}
}
return out;
}
describe("template message keys", () => {
it("finds the one sanctioned call, so the matcher is live", () => {
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([]);
});
});
@@ -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<typeof import("../timeline-dates")>();
return { ...real, todayKeyIn: () => TODAY };
});
/** The text a reader sees: every character outside a tag, runs kept apart. */
function textOf(html: string): string {
let out = "";
let inTag = false;
for (const ch of html) {
if (ch === "<") inTag = true;
else if (ch === ">") {
inTag = false;
out += " ";
} else if (!inTag) out += ch;
}
return out;
}
/** A message key as it would leak: a namespace, a dot, a name. */
const RAW_KEY =
/\b(?:timeline|day|lifeEvents|documents|encounters|mentalHealth|insights|records|cycle|illness|charts|measurements)\.[a-zA-Z][\w.]*/g;
async function renderView(
zoom: TimelineZoom,
bucket: TimelineBucket,
locale: "de" | "en",
) {
search = new URLSearchParams({ zoom });
if (zoom === "range") {
search.set("from", "2025-10-01");
search.set("to", "2026-01-31");
}
timeline = { ...fullTimeline(), zoom, bucket };
const { TimelineView } = await import("../timeline-view");
return textOf(
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>
<TimelineView />
</I18nProvider>
</QueryClientProvider>,
),
);
}
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([]);
});
}
}
}
});
+16 -6
View File
@@ -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<string, string | number>,
@@ -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<K extends string>(
t: Translate,
map: Readonly<Record<K, string>>,
code: string | null,
): string | null {
const key = code === null ? undefined : keyOf(map, code);
return key ? t(key) : null;
}
const ENCOUNTER_KIND_CODES: ReadonlySet<string> = new Set([
@@ -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.
+160
View File
@@ -0,0 +1,160 @@
/**
* The message key of every code the timeline words (v1.42, #613).
*
* A key built from a template (`t(\`timeline.zoom.${zoom}\`)`) is invisible to
* `i18n-call-site-coverage.test.ts`, so a value without its key shipped as
* the raw key and nothing in the gate noticed. Each code family is a map
* here instead: typed over the code's own union, so a new code does not
* compile until it has a key, and walked by `label-keys.test.ts`, which
* resolves every value against the bundle. `no-template-key-guard.test.ts`
* keeps the template form out of the timeline and the day view.
*/
import type {
DayNotableKind,
LifeEventCategory,
LifeEventPrecision,
TimelineBucket,
TimelineLaneKey,
TimelineReadinessKey,
TimelineReadinessStatus,
TimelineReadinessVerdict,
TimelineZoom,
} from "@/lib/day/contract";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import type { LifeEventDraftError } from "./life-event-draft";
export const TIMELINE_LANE_LABEL_KEY: Readonly<
Record<TimelineLaneKey, string>
> = {
life: "timeline.lanes.life",
illness: "timeline.lanes.illness",
allergies: "timeline.lanes.allergies",
medications: "timeline.lanes.medications",
vaccinations: "timeline.lanes.vaccinations",
visits: "timeline.lanes.visits",
labs: "timeline.lanes.labs",
documents: "timeline.lanes.documents",
cycle: "timeline.lanes.cycle",
};
export const TIMELINE_ZOOM_LABEL_KEY: Readonly<Record<TimelineZoom, string>> = {
all: "timeline.zoom.all",
year: "timeline.zoom.year",
quarter: "timeline.zoom.quarter",
range: "timeline.zoom.range",
};
export const TIMELINE_LEGEND_MEAN_KEY: Readonly<
Record<TimelineBucket, string>
> = {
quarter: "timeline.legendMeanQuarter",
month: "timeline.legendMeanMonth",
week: "timeline.legendMeanWeek",
day: "timeline.legendMeanDay",
};
/** The two blood pressure lines read as their own names on the chart. */
export const TIMELINE_BLOOD_PRESSURE_SERIES_KEY: Readonly<
Record<"BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA", string>
> = {
BLOOD_PRESSURE_SYS: "timeline.values.series.BLOOD_PRESSURE_SYS",
BLOOD_PRESSURE_DIA: "timeline.values.series.BLOOD_PRESSURE_DIA",
};
export const TIMELINE_CHRONICLE_NOTABLE_KEY: Readonly<
Record<DayNotableKind, string>
> = {
extremeHigh: "timeline.chronicle.notable.extremeHigh",
extremeLow: "timeline.chronicle.notable.extremeLow",
firstValue: "timeline.chronicle.notable.firstValue",
gap: "timeline.chronicle.notable.gap",
};
/** A readiness row's name: the lane's own, or the inventory's for the rest. */
export const TIMELINE_READINESS_LANE_KEY: Readonly<
Record<TimelineReadinessKey, string>
> = {
values: "timeline.readiness.lanes.values",
mood: "timeline.readiness.lanes.mood",
environment: "timeline.readiness.lanes.environment",
life: "timeline.readiness.lanes.life",
illness: "timeline.lanes.illness",
allergies: "timeline.lanes.allergies",
medications: "timeline.lanes.medications",
vaccinations: "timeline.lanes.vaccinations",
visits: "timeline.lanes.visits",
labs: "timeline.lanes.labs",
documents: "timeline.lanes.documents",
cycle: "timeline.lanes.cycle",
};
export const TIMELINE_READINESS_VERDICT_KEY: Readonly<
Record<TimelineReadinessVerdict, string>
> = {
carries: "timeline.readiness.verdict.carries",
thin: "timeline.readiness.verdict.thin",
};
export const TIMELINE_READINESS_STATUS_KEY: Readonly<
Record<TimelineReadinessStatus, string>
> = {
carries: "timeline.readiness.status.carries",
thin: "timeline.readiness.status.thin",
empty: "timeline.readiness.status.empty",
};
export const LIFE_EVENT_CATEGORY_KEY: Readonly<
Record<LifeEventCategory, string>
> = {
FAMILY: "lifeEvents.category.FAMILY",
HOME: "lifeEvents.category.HOME",
WORK: "lifeEvents.category.WORK",
LOSS: "lifeEvents.category.LOSS",
OTHER: "lifeEvents.category.OTHER",
};
export const LIFE_EVENT_PRECISION_KEY: Readonly<
Record<LifeEventPrecision, string>
> = {
DAY: "lifeEvents.precision.DAY",
MONTH: "lifeEvents.precision.MONTH",
YEAR: "lifeEvents.precision.YEAR",
};
export const LIFE_EVENT_ERROR_KEY: Readonly<
Record<LifeEventDraftError, string>
> = {
titleRequired: "lifeEvents.errors.titleRequired",
titleTooLong: "lifeEvents.errors.titleTooLong",
categoryRequired: "lifeEvents.errors.categoryRequired",
dateRequired: "lifeEvents.errors.dateRequired",
endBeforeStart: "lifeEvents.errors.endBeforeStart",
noteTooLong: "lifeEvents.errors.noteTooLong",
};
export const DOCUMENT_KIND_KEY: Readonly<
Record<InboundDocumentKindValue, string>
> = {
DOCTOR_REPORT: "documents.kind.DOCTOR_REPORT",
DISCHARGE_LETTER: "documents.kind.DISCHARGE_LETTER",
LAB_RESULT: "documents.kind.LAB_RESULT",
IMAGING: "documents.kind.IMAGING",
PRESCRIPTION: "documents.kind.PRESCRIPTION",
REFERRAL: "documents.kind.REFERRAL",
INSURANCE: "documents.kind.INSURANCE",
VACCINATION: "documents.kind.VACCINATION",
SICK_NOTE: "documents.kind.SICK_NOTE",
OTHER: "documents.kind.OTHER",
};
/**
* `map[code]` for a code that arrived as a plain string: the key, or
* undefined for a code the map does not hold.
*/
export function keyOf<K extends string>(
map: Readonly<Record<K, string>>,
code: string,
): string | undefined {
return Object.hasOwn(map, code) ? map[code as K] : undefined;
}
+10 -1
View File
@@ -32,8 +32,17 @@ export interface LifeEventDraft {
note: string;
}
/** What a draft can be refused for, each worded under `lifeEvents.errors`. */
export type LifeEventDraftError =
| "titleRequired"
| "titleTooLong"
| "categoryRequired"
| "dateRequired"
| "endBeforeStart"
| "noteTooLong";
export type LifeEventDraftErrors = Partial<
Record<"title" | "category" | "start" | "end" | "note", string>
Record<"title" | "category" | "start" | "end" | "note", LifeEventDraftError>
>;
/** Snap a date to the first day of its month or year. */
+8 -3
View File
@@ -47,6 +47,11 @@ import {
type LifeEventDraft,
type LifeEventDraftErrors,
} from "./life-event-draft";
import {
LIFE_EVENT_CATEGORY_KEY,
LIFE_EVENT_ERROR_KEY,
LIFE_EVENT_PRECISION_KEY,
} from "./label-keys";
import { Segmented } from "./segmented";
import { formatMonthLong, todayKeyIn } from "./timeline-dates";
import { useLifeEventMutations } from "./use-timeline";
@@ -105,7 +110,7 @@ function CategoryChips({
: "border-border text-foreground hover:bg-accent",
)}
>
{t(`lifeEvents.category.${category}`)}
{t(LIFE_EVENT_CATEGORY_KEY[category])}
</button>
);
})}
@@ -227,7 +232,7 @@ export function LifeEventForm({
value: LifeEventDraft[K],
) => setDraft((d) => ({ ...d, [key]: value }));
const fieldError = (field: keyof LifeEventDraftErrors) =>
errors[field] ? t(`lifeEvents.errors.${errors[field]}`) : undefined;
errors[field] ? t(LIFE_EVENT_ERROR_KEY[errors[field]]) : undefined;
async function submit(e: React.FormEvent) {
e.preventDefault();
@@ -381,7 +386,7 @@ export function LifeEventForm({
<Segmented
options={LIFE_EVENT_PRECISIONS.map((p) => ({
value: p,
label: t(`lifeEvents.precision.${p}`),
label: t(LIFE_EVENT_PRECISION_KEY[p]),
}))}
value={draft.precision}
onChange={(p) => setDraft((d) => withPrecision(d, p))}
@@ -27,6 +27,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { isCalendarDateKey } from "@/lib/tz/date-only";
import { cn } from "@/lib/utils";
import {
TIMELINE_READINESS_LANE_KEY,
TIMELINE_READINESS_STATUS_KEY,
TIMELINE_READINESS_VERDICT_KEY,
} from "./label-keys";
import { formatMonthYear } from "./timeline-dates";
import { orderedReadinessLanes, readinessTally } from "./readiness-model";
@@ -68,15 +73,7 @@ export function wordKey(
}
export function laneLabel(t: Translate, key: TimelineReadinessLane["key"]) {
if (
key === "values" ||
key === "mood" ||
key === "environment" ||
key === "life"
) {
return t(`timeline.readiness.lanes.${key}`);
}
return t(`timeline.lanes.${key}`);
return t(TIMELINE_READINESS_LANE_KEY[key]);
}
const STATUS_ICON: Record<TimelineReadinessStatus, typeof CircleCheck> = {
@@ -119,7 +116,7 @@ export function VerdictMeter({
</div>
<p className="text-sm">
<span className="font-semibold">
{t(`timeline.readiness.verdict.${readiness.verdict}`)}
{t(TIMELINE_READINESS_VERDICT_KEY[readiness.verdict])}
</span>
{readiness.verdict === "carries" && (
<>
@@ -178,7 +175,7 @@ export function ReadinessInventory({
>
<Icon
className={cn("mt-0.5 size-4", STATUS_CLASS[lane.status])}
aria-label={t(`timeline.readiness.status.${lane.status}`)}
aria-label={t(TIMELINE_READINESS_STATUS_KEY[lane.status])}
role="img"
/>
<div className="min-w-0">
+3 -2
View File
@@ -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)}
</text>
{lane.spans.map((span) => (
<SpanMark
@@ -899,7 +900,7 @@ function TimelineTable({
<tbody>
{rows.map(({ lane, item }) => (
<tr key={`${lane}-${item.id}`}>
<td>{t(`timeline.lanes.${lane}`)}</td>
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
<td>
{[
words(item).label,
@@ -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"),
};
}
+3 -1
View File
@@ -20,6 +20,8 @@ import {
import { TIMELINE_MAX_SERIES, type TimelineLaneKey } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
/** The most value lines the chart runs under its lanes. */
export const MAX_VALUE_SERIES = TIMELINE_MAX_SERIES;
@@ -131,7 +133,7 @@ export function LayersMenu({
onSelect={(event) => event.preventDefault()}
onCheckedChange={(next) => onToggle(lane, next === true)}
>
{t(`timeline.lanes.${lane}`)}
{t(TIMELINE_LANE_LABEL_KEY[lane])}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
+8 -10
View File
@@ -45,6 +45,11 @@ import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"
import type { ModuleKey } from "@/lib/modules/registry";
import type { ChronicleGrouping } from "./chronicle-model";
import {
TIMELINE_BLOOD_PRESSURE_SERIES_KEY,
TIMELINE_LEGEND_MEAN_KEY,
TIMELINE_ZOOM_LABEL_KEY,
} from "./label-keys";
import { LifeEventSheet } from "./life-event-sheet";
import { ReadinessCard } from "./readiness-card";
import {
@@ -284,7 +289,7 @@ export function TimelineView() {
// "Dia") lean on a column header the chart does not have.
const seriesLabel = (key: string) =>
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
? t(`timeline.values.series.${key}`)
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
: key === "MOOD"
? t("timeline.readiness.lanes.mood")
: t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key);
@@ -309,7 +314,7 @@ export function TimelineView() {
const zoomOptions = TIMELINE_ZOOMS.map((z) => ({
value: z,
label: t(`timeline.zoom.${z}`),
label: t(TIMELINE_ZOOM_LABEL_KEY[z]),
}));
const groupingOptions: Array<{ value: ChronicleGrouping; label: string }> = [
{ value: "year", label: t("timeline.chronicle.years") },
@@ -544,13 +549,6 @@ export function TimelineView() {
);
}
const LEGEND_MEAN_KEY: Readonly<Record<TimelineBucket, string>> = {
quarter: "timeline.legendMeanQuarter",
month: "timeline.legendMeanMonth",
week: "timeline.legendMeanWeek",
day: "timeline.legendMeanDay",
};
function Legend({
bucket,
hasSeries,
@@ -586,7 +584,7 @@ function Legend({
data-bucket={bucket}
>
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
{t(LEGEND_MEAN_KEY[bucket])}
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
</span>
<span className="inline-flex items-center gap-1.5">
<span