mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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(", ") : "—",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user