fix(i18n): name a visit kind instead of printing its enum

Three surfaces built the label key by interpolation — the doctor-report
PDF's visit table, the daily digest's upcoming-visit line and the
clinician share view. `EncounterKind` is `ROUTINE` and the bundle leaf is
`routine`, so all three rendered `encounters.kind.ROUTINE` verbatim: into
a document a practice files, onto a lock screen, and onto the page.

Route them through `encounterKindLabelKey`, a literal switch beside the
existing `encounterKindLabel`, so the mapping is written down once.

The enum-derived i18n guard could not have caught this: it only knows
key spaces of the form `prefix + member`, and this one is not. It gains a
second arm for spaces whose mapping is a function, with `encounters.kind`
as its first entry. Break-proof: point one arm of the resolver back at
the uppercase key and six locale cases go red.
This commit is contained in:
Marc-André Bombeck committed 2026-08-24 01:02:20 +02:00
1 parent d290422923
commit 29ab274775
4 files changed
+101 -2

No files matched your search

@@ -18,6 +18,8 @@ import {
allergyStatusEnum,
} from "@/lib/validations/allergy";
import { familyRelationshipEnum } from "@/lib/validations/family-history";
import { encounterKindEnum } from "@/lib/validations/encounters";
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
/**
@@ -131,6 +133,32 @@ const REGISTRY: readonly KeySpace[] = [
{ prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS },
];
/**
* The second arm: key spaces whose member → key mapping is NOT `prefix +
* member`, so the registry above cannot express them. The mapping function
* itself is the source, and the guard runs the enum through it.
*
* `encounters.kind` is why this arm exists. The enum member is `ROUTINE`, the
* bundle leaf is `routine`, and three surfaces — the doctor-report PDF's visit
* table, the daily digest's upcoming-visit line and the clinician share view —
* built the key by interpolation. All three printed `encounters.kind.ROUTINE`
* verbatim: into a clinical document, onto a lock screen, and onto a page a
* doctor opens. The prefix-plus-member form of this guard would have caught it
* on the day the interpolation was written, but only if the mapping had been
* expressible; it is not, so it was never registered and nothing else looked.
*/
const RESOLVED_REGISTRY: readonly {
/** What the space is, for the failure message. */
name: string;
/** Every key the resolver can return, over the whole source enum. */
keys: readonly string[];
}[] = [
{
name: "encounters.kind via encounterKindLabelKey",
keys: encounterKindEnum.options.map(encounterKindLabelKey),
},
];
describe("dynamic-key exhaustiveness (enum-derived)", () => {
// Six shipped locales. An empty read of `messages/` would register zero
// per-locale cases and the suite would report green over nothing.
@@ -158,6 +186,22 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
}
});
it("every resolver-mapped key space returns a distinct key per member", () => {
for (const space of RESOLVED_REGISTRY) {
expect(
space.keys.length,
`${space.name} resolved to an empty key set — source import broke`,
).toBeGreaterThan(0);
// A resolver whose default arm swallowed a member would collapse two
// enum values onto one key and the per-locale checks below would still
// pass, because the surviving key resolves fine.
expect(
new Set(space.keys).size,
`${space.name} maps two enum members onto one key`,
).toBe(space.keys.length);
}
});
for (const { locale, messages } of LOCALES) {
for (const space of REGISTRY) {
for (const member of space.members) {
@@ -171,5 +215,16 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
});
}
}
for (const space of RESOLVED_REGISTRY) {
for (const key of space.keys) {
it(`resolves ${key} in ${locale} (${space.name})`, () => {
const value = resolveKey(messages, key);
expect(value, `${key} missing in ${locale}.json`).toBeTypeOf(
"string",
);
expect((value ?? "").trim().length).toBeGreaterThan(0);
});
}
}
}
});
+8 -1
View File
@@ -19,8 +19,10 @@
* provisional→final refresh (sleep-arrival debounce) is S4's work; it will
* populate the same two fields the DTO already carries, so no consumer changes.
*/
import type { EncounterKind } from "@/generated/prisma/client";
import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema";
import type { ArrivalKind } from "@/lib/arrivals/types";
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
@@ -560,7 +562,12 @@ function buildUpcomingVisitItem(
// Today vs tomorrow is decided on the SAME instants the read window used, so
// a visit admitted by the query can never be described as neither.
const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000);
const what = visit.practitionerName ?? t(`encounters.kind.${visit.kind}`);
// Through the key resolver, never `encounters.kind.${kind}`: the enum is
// `ROUTINE` and the bundle leaf is `routine`, so the interpolated form put
// raw dot notation on a lock screen.
const what =
visit.practitionerName ??
t(encounterKindLabelKey(visit.kind as EncounterKind));
return {
kind: "upcoming_visit",
title: t("daily.item.upcomingVisit.title"),
@@ -1,5 +1,7 @@
import type { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import type { EncounterKind } from "@/generated/prisma/client";
import { encounterKindLabelKey } from "../encounters/kind-label";
import {
classifyReferenceRange,
formatReferenceRange,
@@ -239,7 +241,10 @@ export function buildClinicalRecordsNotesSection(
[visit.practitionerName, visit.practitionerSpecialty]
.filter(Boolean)
.join(" · ") || "—",
t(`encounters.kind.${visit.kind}`),
// Through the key resolver: the enum is `ROUTINE` and the bundle leaf is
// `routine`, so the interpolated key printed raw dot notation into the
// clinical document.
t(encounterKindLabelKey(visit.kind as EncounterKind)),
visit.reason ?? "—",
visit.outcome ?? "—",
visit.conditionLabels.length > 0 ? visit.conditionLabels.join(", ") : "—",
+32
View File
@@ -45,3 +45,35 @@ export function encounterKindLabel(
return t("encounters.kind.other");
}
}
/**
* The same mapping as a KEY, for callers that already hold a translator and
* cannot reach for a second one — the doctor-report PDF, the daily digest and
* the clinician share view.
*
* Those three built the key by interpolation (`encounters.kind.${kind}`), and
* the enum is `ROUTINE` while the bundle leaf is `routine`, so all three
* printed raw dot notation into a clinical document. The interpolated form was
* invisible to `i18n-call-site-coverage`, which is the reason it survived: the
* key it asked for never existed and nothing said so.
*/
export function encounterKindLabelKey(kind: EncounterKind): string {
switch (kind) {
case "ROUTINE":
return "encounters.kind.routine";
case "ACUTE":
return "encounters.kind.acute";
case "SPECIALIST":
return "encounters.kind.specialist";
case "PREVENTIVE":
return "encounters.kind.preventive";
case "EMERGENCY":
return "encounters.kind.emergency";
case "HOSPITAL":
return "encounters.kind.hospital";
case "THERAPY":
return "encounters.kind.therapy";
default:
return "encounters.kind.other";
}
}