mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(doctor-report): a report can mention a visit, and know why it exists
The VISITS leaf ships in the release that made a visit possible, because a selectable checkbox that produces nothing is worse than no checkbox. Leaf, collector and PDF renderer land together, and the leaf carries no LEAF_MODULE entry — like ALLERGIES beside it — because a visit is core and the whole report is already gated by doctorReport. The absence is commented at the map so a later reader does not read it as an oversight. The rows print the date, the practice with its specialty, the kind, the reason, the outcome and the conditions the visit was filed against. The two encrypted columns decrypt at render, fail-soft, and never enter the stored selection. A condition the person has since deleted is not named: the link row survives a soft delete, and printing a label they withdrew would put it in front of a doctor. No FHIR resource type is added. That deferral is already written at the Encounter model, and the bundle builder now says so at the point a reader would look for the missing section: FHIR splits the future from the past across Appointment and Encounter, which is a serialisation question this release does not answer. Two properties are free and worth stating: a stored selection never grows (unknown ids drop) and a clinician share link's sections are frozen write-once, so no existing report and no already-shared link gains a section. The panel also offers a booked visit as the report's reason — one query, one prefilled window end, one dismissible offer that names its own source and never applies itself. A window control that moves on its own is what makes an export panel untrustworthy.
This commit is contained in:
1 parent
c47122ccfe
commit
4d2f706dad
24 files changed
+550
-27
No files matched your search
@@ -932,7 +932,7 @@ paths:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 91
|
||||
maxItems: 92
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -1037,7 +1037,7 @@ paths:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 91
|
||||
maxItems: 92
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -15716,7 +15716,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 91
|
||||
maxItems: 92
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
+13
-3
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Erkrankung",
|
||||
"familyHistoryColAgeAtOnset": "Alter bei Beginn",
|
||||
"labsSourceRangeNote": "* Der Referenzbereich stammt aus dem Befund und weicht vom gespeicherten Bereich ab:",
|
||||
"labsCatalogRangeLabel": "gespeicherter Bereich"
|
||||
"labsCatalogRangeLabel": "gespeicherter Bereich",
|
||||
"visitsTitle": "Arztbesuche",
|
||||
"visitsColDate": "Datum",
|
||||
"visitsColPractice": "Praxis",
|
||||
"visitsColKind": "Art",
|
||||
"visitsColReason": "Anlass",
|
||||
"visitsColOutcome": "Ergebnis",
|
||||
"visitsColConditions": "Erkrankungen"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
@@ -5978,7 +5985,9 @@
|
||||
"rangeCustomEnd": "Bis",
|
||||
"errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||
"errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.",
|
||||
"errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet."
|
||||
"errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.",
|
||||
"visitOffer": "Am {when} steht ein Termin an: {what}.",
|
||||
"visitOfferApply": "Bericht bis dahin"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
|
||||
"globalExcludedInjectionSitesHint": "Nie angeboten und beim Erfassen abgelehnt, auch wenn eine Medikation sie bevorzugt.",
|
||||
@@ -9719,7 +9728,8 @@
|
||||
"sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.",
|
||||
"scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.",
|
||||
"scopeCount": "{count} Einträge aus {groups} Bereichen",
|
||||
"scopeSensitive": "inkl. {names}"
|
||||
"scopeSensitive": "inkl. {names}",
|
||||
"leafVisits": "Arztbesuche"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+13
-3
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Condition",
|
||||
"familyHistoryColAgeAtOnset": "Age at onset",
|
||||
"labsSourceRangeNote": "* The reference range comes from the report itself and differs from the saved range:",
|
||||
"labsCatalogRangeLabel": "saved range"
|
||||
"labsCatalogRangeLabel": "saved range",
|
||||
"visitsTitle": "Visits",
|
||||
"visitsColDate": "Date",
|
||||
"visitsColPractice": "Practice",
|
||||
"visitsColKind": "Kind",
|
||||
"visitsColReason": "Reason",
|
||||
"visitsColOutcome": "Outcome",
|
||||
"visitsColConditions": "Conditions"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -5978,7 +5985,9 @@
|
||||
"rangeCustomEnd": "To",
|
||||
"errorGeneric": "The export could not be generated. Please try again.",
|
||||
"errorRateLimit": "You have reached the export limit for this hour.",
|
||||
"errorModuleDisabled": "The health record export is switched off for this account."
|
||||
"errorModuleDisabled": "The health record export is switched off for this account.",
|
||||
"visitOffer": "An appointment is coming up on {when}: {what}.",
|
||||
"visitOfferApply": "Report up to then"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Never offered and rejected when logging a dose, even if a medication prefers them.",
|
||||
@@ -9719,7 +9728,8 @@
|
||||
"sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.",
|
||||
"scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.",
|
||||
"scopeCount": "{count} entries from {groups} areas",
|
||||
"scopeSensitive": "incl. {names}"
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visits"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+13
-3
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Afección",
|
||||
"familyHistoryColAgeAtOnset": "Edad de inicio",
|
||||
"labsSourceRangeNote": "* El rango de referencia procede del propio informe y difiere del rango guardado:",
|
||||
"labsCatalogRangeLabel": "rango guardado"
|
||||
"labsCatalogRangeLabel": "rango guardado",
|
||||
"visitsTitle": "Visitas médicas",
|
||||
"visitsColDate": "Fecha",
|
||||
"visitsColPractice": "Consulta",
|
||||
"visitsColKind": "Tipo",
|
||||
"visitsColReason": "Motivo",
|
||||
"visitsColOutcome": "Resultado",
|
||||
"visitsColConditions": "Enfermedades"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo ha salido mal",
|
||||
@@ -5978,7 +5985,9 @@
|
||||
"rangeCustomEnd": "Hasta",
|
||||
"errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.",
|
||||
"errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.",
|
||||
"errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta."
|
||||
"errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.",
|
||||
"visitOffer": "El {when} hay una cita: {what}.",
|
||||
"visitOfferApply": "Informe hasta esa fecha"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
|
||||
"globalExcludedInjectionSitesHint": "Nunca se ofrecen y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
|
||||
@@ -9719,7 +9728,8 @@
|
||||
"sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.",
|
||||
"scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.",
|
||||
"scopeCount": "{count} entradas de {groups} áreas",
|
||||
"scopeSensitive": "incl. {names}"
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visitas médicas"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+13
-3
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Affection",
|
||||
"familyHistoryColAgeAtOnset": "Âge au début",
|
||||
"labsSourceRangeNote": "* La plage de référence provient du compte rendu lui-même et diffère de la plage enregistrée :",
|
||||
"labsCatalogRangeLabel": "plage enregistrée"
|
||||
"labsCatalogRangeLabel": "plage enregistrée",
|
||||
"visitsTitle": "Consultations",
|
||||
"visitsColDate": "Date",
|
||||
"visitsColPractice": "Cabinet",
|
||||
"visitsColKind": "Type",
|
||||
"visitsColReason": "Motif",
|
||||
"visitsColOutcome": "Résultat",
|
||||
"visitsColConditions": "Maladies"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur est survenue",
|
||||
@@ -5978,7 +5985,9 @@
|
||||
"rangeCustomEnd": "Au",
|
||||
"errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.",
|
||||
"errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.",
|
||||
"errorModuleDisabled": "L'export du dossier est désactivé pour ce compte."
|
||||
"errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.",
|
||||
"visitOffer": "Un rendez-vous est prévu le {when} : {what}.",
|
||||
"visitOfferApply": "Rapport jusque-là"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
|
||||
"globalExcludedInjectionSitesHint": "Jamais proposés et refusés à l'enregistrement, même si un médicament les privilégie.",
|
||||
@@ -9719,7 +9728,8 @@
|
||||
"sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.",
|
||||
"scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.",
|
||||
"scopeCount": "{count} éléments issus de {groups} domaines",
|
||||
"scopeSensitive": "dont {names}"
|
||||
"scopeSensitive": "dont {names}",
|
||||
"leafVisits": "Consultations"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+13
-3
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Condizione",
|
||||
"familyHistoryColAgeAtOnset": "Età di esordio",
|
||||
"labsSourceRangeNote": "* L'intervallo di riferimento proviene dal referto stesso e differisce da quello salvato:",
|
||||
"labsCatalogRangeLabel": "intervallo salvato"
|
||||
"labsCatalogRangeLabel": "intervallo salvato",
|
||||
"visitsTitle": "Visite mediche",
|
||||
"visitsColDate": "Data",
|
||||
"visitsColPractice": "Studio",
|
||||
"visitsColKind": "Tipo",
|
||||
"visitsColReason": "Motivo",
|
||||
"visitsColOutcome": "Esito",
|
||||
"visitsColConditions": "Malattie"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
@@ -5978,7 +5985,9 @@
|
||||
"rangeCustomEnd": "Al",
|
||||
"errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.",
|
||||
"errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.",
|
||||
"errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account."
|
||||
"errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.",
|
||||
"visitOffer": "Il {when} c'è un appuntamento: {what}.",
|
||||
"visitOfferApply": "Referto fino a quella data"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
|
||||
"globalExcludedInjectionSitesHint": "Mai proposti e rifiutati alla registrazione, anche se un farmaco li preferisce.",
|
||||
@@ -9719,7 +9728,8 @@
|
||||
"sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.",
|
||||
"scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.",
|
||||
"scopeCount": "{count} voci da {groups} aree",
|
||||
"scopeSensitive": "incl. {names}"
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visite mediche"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
+13
-3
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Schorzenie",
|
||||
"familyHistoryColAgeAtOnset": "Wiek zachorowania",
|
||||
"labsSourceRangeNote": "* Zakres referencyjny pochodzi z samego wyniku i różni się od zapisanego zakresu:",
|
||||
"labsCatalogRangeLabel": "zapisany zakres"
|
||||
"labsCatalogRangeLabel": "zapisany zakres",
|
||||
"visitsTitle": "Wizyty lekarskie",
|
||||
"visitsColDate": "Data",
|
||||
"visitsColPractice": "Gabinet",
|
||||
"visitsColKind": "Rodzaj",
|
||||
"visitsColReason": "Powód",
|
||||
"visitsColOutcome": "Wynik",
|
||||
"visitsColConditions": "Choroby"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
@@ -5978,7 +5985,9 @@
|
||||
"rangeCustomEnd": "Do",
|
||||
"errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.",
|
||||
"errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.",
|
||||
"errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta."
|
||||
"errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.",
|
||||
"visitOffer": "{when} zaplanowano wizytę: {what}.",
|
||||
"visitOfferApply": "Raport do tego dnia"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
|
||||
"globalExcludedInjectionSitesHint": "Nigdy nieproponowane i odrzucane przy rejestracji, nawet jeśli lek je preferuje.",
|
||||
@@ -9719,7 +9728,8 @@
|
||||
"sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.",
|
||||
"scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.",
|
||||
"scopeCount": "{count} pozycji z {groups} obszarów",
|
||||
"scopeSensitive": "w tym {names}"
|
||||
"scopeSensitive": "w tym {names}",
|
||||
"leafVisits": "Wizyty lekarskie"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
|
||||
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(91);
|
||||
expect(ALL_LEAF_IDS.length).toBe(92);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("places every leaf in exactly one group", () => {
|
||||
|
||||
@@ -23,6 +23,11 @@ vi.mock("@/lib/db", () => ({
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
encounterDocumentLink: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
documentContentIndex: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -32,6 +37,9 @@ vi.mock("@/lib/db", () => ({
|
||||
illnessEpisode: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
encounter: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -129,6 +137,10 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,8 @@ const {
|
||||
txCreateMany,
|
||||
txDocumentFindMany,
|
||||
txEpisodeFindMany,
|
||||
txEncounterFindMany,
|
||||
txEncounterLinkCreateMany,
|
||||
} = vi.hoisted(() => ({
|
||||
txQueryRaw: vi.fn(),
|
||||
txCreate: vi.fn(),
|
||||
@@ -28,6 +30,8 @@ const {
|
||||
// writes, so the transaction double has to answer for them.
|
||||
txDocumentFindMany: vi.fn(),
|
||||
txEpisodeFindMany: vi.fn(),
|
||||
txEncounterFindMany: vi.fn(),
|
||||
txEncounterLinkCreateMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
@@ -35,7 +39,9 @@ vi.mock("@/lib/db", () => {
|
||||
$queryRaw: txQueryRaw,
|
||||
inboundDocument: { create: txCreate, findMany: txDocumentFindMany },
|
||||
illnessEpisode: { findMany: txEpisodeFindMany },
|
||||
encounter: { findMany: txEncounterFindMany },
|
||||
documentConditionLink: { createMany: txCreateMany },
|
||||
encounterDocumentLink: { createMany: txEncounterLinkCreateMany },
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
@@ -50,6 +56,9 @@ vi.mock("@/lib/db", () => {
|
||||
documentConditionLink: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
encounterDocumentLink: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
documentContentIndex: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
@@ -59,6 +68,9 @@ vi.mock("@/lib/db", () => {
|
||||
illnessEpisode: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
encounter: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -183,6 +195,12 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
txEncounterFindMany.mockResolvedValue([]);
|
||||
txEncounterLinkCreateMany.mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
encounter: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
@@ -165,6 +166,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
|
||||
|
||||
@@ -44,6 +44,10 @@ vi.mock("@/hooks/use-auth", async () => {
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
// The panel's visit offer reads the encounter list. Pending forever here:
|
||||
// this file is about the panel's own state, and a resolved read would put
|
||||
// an offer in the markup every assertion below would have to know about.
|
||||
useQuery: () => ({ data: undefined, isPending: true, isError: false }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
@@ -188,7 +192,8 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
expect(groupChip(html, "body")).toBe("0/12");
|
||||
expect(groupChip(html, "labs")).toBe("0/1");
|
||||
expect(groupChip(html, "medications")).toBe("0/4");
|
||||
expect(groupChip(html, "history")).toBe("0/2");
|
||||
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
|
||||
expect(groupChip(html, "history")).toBe("0/3");
|
||||
expect(groupChip(html, "cardio")).toBe("0/14");
|
||||
expect(groupChip(html, "activity")).toBe("0/8");
|
||||
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The report's reason, pinned on the two properties that make it trustworthy:
|
||||
*
|
||||
* - it appears for an appointment inside the horizon and for nothing else;
|
||||
* - it NEVER auto-applies. Applying is a press, and the component calls
|
||||
* nothing on render — a window control that moves on its own is what makes
|
||||
* an export panel untrustworthy.
|
||||
*
|
||||
* The fixtures are relative to the moment the test runs, so the horizon cannot
|
||||
* rot out from under them as the calendar moves.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: {} },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return { ...actual, apiGet: () => new Promise(() => {}) };
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { ReportVisitOffer } from "../report-visit-offer";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
return {
|
||||
occurredAt: new Date(Date.now() + 5 * DAY_MS).toISOString(),
|
||||
status: "PLANNED",
|
||||
kind: "SPECIALIST",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(upcoming: EncounterDTO[], onUse = () => {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
queryKeys.encounterList(null, null, undefined, undefined),
|
||||
{
|
||||
upcoming,
|
||||
past: [],
|
||||
},
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ReportVisitOffer onUseVisitDate={onUse} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ReportVisitOffer>", () => {
|
||||
it("offers an appointment five days out, naming its own source", () => {
|
||||
const html = render([visit({ id: "soon" })]);
|
||||
expect(html).toContain('data-slot="report-visit-offer"');
|
||||
expect(html).toContain("An appointment is coming up");
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
});
|
||||
|
||||
it("stays silent for an appointment thirty days out", () => {
|
||||
const html = render([
|
||||
visit({
|
||||
id: "far",
|
||||
occurredAt: new Date(Date.now() + 30 * DAY_MS).toISOString(),
|
||||
}),
|
||||
]);
|
||||
expect(html).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("stays silent when there is no appointment at all", () => {
|
||||
expect(render([])).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("ignores a cancelled appointment inside the horizon", () => {
|
||||
const html = render([visit({ id: "off", status: "CANCELLED" })]);
|
||||
expect(html).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("changes the window only when pressed", () => {
|
||||
const onUse = vi.fn();
|
||||
const html = render([visit({ id: "soon" })], onUse);
|
||||
// The offer rendered — so the assertion below is about restraint, not
|
||||
// about a component that produced nothing.
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
expect(onUse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Bericht für den Termin am … erstellen" — the report knows why it is being
|
||||
* generated.
|
||||
*
|
||||
* The cheapest integration in this milestone and the one that changes the most:
|
||||
* a person opening the export panel a week before an appointment is almost
|
||||
* always opening it BECAUSE of the appointment, and the window they want ends
|
||||
* on that day. One query, one prefilled field, one dismissible offer.
|
||||
*
|
||||
* **It never auto-applies.** The window is not silently changed and then left
|
||||
* for the person to discover; the offer names its own source ("weil am … ein
|
||||
* Termin ansteht") and does nothing until pressed. A control that moves on its
|
||||
* own is the thing that makes an export panel untrustworthy.
|
||||
*
|
||||
* Nothing about the stored selection changes: this sets the custom-range end
|
||||
* date and nothing else.
|
||||
*/
|
||||
import { CalendarClock, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* How far ahead a booked visit is treated as the reason for this report.
|
||||
*
|
||||
* Two weeks: far enough that somebody preparing in advance is offered it, near
|
||||
* enough that a routine check-up six months out does not colonise the panel.
|
||||
*/
|
||||
export const REPORT_VISIT_HORIZON_DAYS = 14;
|
||||
|
||||
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
|
||||
function localDay(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
const offset = at.getTimezoneOffset();
|
||||
return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function ReportVisitOffer({
|
||||
onUseVisitDate,
|
||||
}: {
|
||||
/** Sets the custom window's END date. Called only on an explicit press. */
|
||||
onUseVisitDate: (endDay: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const visits = useEncounters();
|
||||
|
||||
// Read once, at mount. A clock read during render is impure — and here it
|
||||
// would also mean the offer could vanish mid-interaction as a visit crossed
|
||||
// the horizon between two renders.
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const horizon = now + REPORT_VISIT_HORIZON_DAYS * 24 * 60 * 60 * 1000;
|
||||
const next = (visits.data?.upcoming ?? []).find((visit) => {
|
||||
if (visit.status !== "PLANNED") return false;
|
||||
const at = new Date(visit.occurredAt).getTime();
|
||||
return at > now && at <= horizon;
|
||||
});
|
||||
if (!next) return null;
|
||||
|
||||
const what =
|
||||
next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="report-visit-offer"
|
||||
className="border-border flex flex-wrap items-center gap-2 rounded-lg border p-3"
|
||||
>
|
||||
{/* Content, not meta: it is the offer, and it names its own source. */}
|
||||
<p className="text-foreground flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<CalendarClock className="size-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{t("settings.healthRecord.visitOffer", {
|
||||
what,
|
||||
when: format.date(next.occurredAt),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="report-visit-offer-apply"
|
||||
onClick={() => onUseVisitDate(localDay(next.occurredAt))}
|
||||
>
|
||||
{t("settings.healthRecord.visitOfferApply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 sm:size-9"
|
||||
aria-label={t("common.dismiss")}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { ReportVisitOffer } from "./report-visit-offer";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
@@ -253,6 +254,15 @@ export function HealthRecordExportPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Never auto-applies: pressing it is what opens the custom window and
|
||||
sets its end date. */}
|
||||
<ReportVisitOffer
|
||||
onUseVisitDate={(endDay) => {
|
||||
setCustomRange(true);
|
||||
setEndDate(endDay);
|
||||
}}
|
||||
/>
|
||||
|
||||
{customRange ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -172,6 +172,7 @@ const fakeDb = vi.hoisted(() => {
|
||||
ecgRecording: { findFirst: async () => null },
|
||||
personalRecord: { findMany: none },
|
||||
arrivalReaction: { findMany: none },
|
||||
encounter: { findFirst: async () => null },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -217,6 +217,73 @@ export function buildClinicalRecordsNotesSection(
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// Visits inside the window — what happened at a practice, and what came out
|
||||
// of it. Present only when the VISITS leaf was selected AND the window held a
|
||||
// visit (the aggregator gates `data.visits`); the leaf carries no module of
|
||||
// its own, because a visit is core and the whole report is already gated by
|
||||
// `doctorReport`. Same calm factual register as the illness table above: no
|
||||
// colour, no status tint. The encrypted reason and outcome decrypt at render
|
||||
// and never enter the stored selection.
|
||||
if (data.visits && data.visits.length > 0) {
|
||||
y = ensureSpace(y, 6 + 18);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.visitsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const visitRows = data.visits.map((visit) => [
|
||||
fmtDate(visit.occurredAt),
|
||||
// The practice, with its specialty when there is one; an em dash when
|
||||
// the visit named neither, which is honest rather than invented.
|
||||
[visit.practitionerName, visit.practitionerSpecialty]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "—",
|
||||
t(`encounters.kind.${visit.kind}`),
|
||||
visit.reason ?? "—",
|
||||
visit.outcome ?? "—",
|
||||
visit.conditionLabels.length > 0 ? visit.conditionLabels.join(", ") : "—",
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.visitsColDate"),
|
||||
t("doctorReport.visitsColPractice"),
|
||||
t("doctorReport.visitsColKind"),
|
||||
t("doctorReport.visitsColReason"),
|
||||
t("doctorReport.visitsColOutcome"),
|
||||
t("doctorReport.visitsColConditions"),
|
||||
],
|
||||
],
|
||||
body: visitRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// v1.27.x — structured allergy / intolerance records. Reference data
|
||||
// (not time-windowed) the aggregator populates when the `allergies`
|
||||
// toggle is ON (default) and rows exist. Stored fields only — substance,
|
||||
|
||||
@@ -274,6 +274,28 @@ export interface DoctorReportData {
|
||||
* module is off or no episode overlaps the window; both builders then skip
|
||||
* the section. Optional so pre-v1.18.1 fixtures still typecheck.
|
||||
*/
|
||||
/**
|
||||
* Visits inside the window — the contacts with the healthcare system this
|
||||
* period held. Null when the leaf was not selected or none fell inside the
|
||||
* window; both builders then skip the section. Optional so pre-v1.38
|
||||
* fixtures still typecheck.
|
||||
*
|
||||
* `kind` rides as the enum constant: the renderer names it in the REPORT's
|
||||
* language, which is the reading clinician's, not whoever generated it.
|
||||
* `reason` and `outcome` are the decrypted free text and are null on a
|
||||
* key-rotation gap — an absence rather than a fabricated line.
|
||||
*/
|
||||
visits?: Array<{
|
||||
occurredAt: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
practitionerName: string | null;
|
||||
practitionerSpecialty: string | null;
|
||||
reason: string | null;
|
||||
outcome: string | null;
|
||||
/** Labels of the conditions this visit was filed against. */
|
||||
conditionLabels: string[];
|
||||
}> | null;
|
||||
illnessEpisodes?: Array<{
|
||||
/** User-facing condition label (e.g. "Erkältung"). */
|
||||
label: string;
|
||||
|
||||
@@ -216,6 +216,17 @@ function seed() {
|
||||
resolvedAt: new Date("2026-01-20T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
rows.encounter = [
|
||||
{
|
||||
occurredAt: new Date("2026-02-05T09:00:00.000Z"),
|
||||
kind: "SPECIALIST",
|
||||
status: "DONE",
|
||||
reasonEncrypted: null,
|
||||
outcomeEncrypted: null,
|
||||
practitioner: { name: "Sample practice", specialty: "Cardiology" },
|
||||
conditionLinks: [],
|
||||
},
|
||||
];
|
||||
rows.allergy = [
|
||||
{
|
||||
substance: "Penicillin",
|
||||
@@ -262,6 +273,7 @@ describe("per-leaf gating sweep", () => {
|
||||
expect(allPayload.allergies).not.toBeNull();
|
||||
expect(allPayload.familyHistory).not.toBeNull();
|
||||
expect(allPayload.illnessEpisodes).not.toBeNull();
|
||||
expect(allPayload.visits).not.toBeNull();
|
||||
expect(allPayload.mood).not.toBeNull();
|
||||
expect(allPayload.cycle).not.toBeNull();
|
||||
expect(allPayload.anamnesis).not.toBeNull();
|
||||
@@ -415,6 +427,7 @@ describe("zero read for unchosen leaves", () => {
|
||||
["ALLERGIES", "allergy"],
|
||||
["LAB_RESULTS", "labResult"],
|
||||
["ILLNESS_EPISODES", "illnessEpisode"],
|
||||
["VISITS", "encounter"],
|
||||
["ANAMNESIS", "userHealthProfile"],
|
||||
["ANAMNESIS", "healthProfileFactRevision"],
|
||||
];
|
||||
@@ -474,6 +487,7 @@ describe("the empty selection serves nothing", () => {
|
||||
expect(payload.allergies).toBeNull();
|
||||
expect(payload.familyHistory).toBeNull();
|
||||
expect(payload.illnessEpisodes).toBeNull();
|
||||
expect(payload.visits).toBeNull();
|
||||
expect(payload.anamnesis).toBeNull();
|
||||
expect(payload.cycle).toBeNull();
|
||||
expect(payload.mood).toBeNull();
|
||||
|
||||
@@ -122,6 +122,78 @@ export async function loadIllnessEpisodes(
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits inside the window, with the conditions each was filed against.
|
||||
*
|
||||
* The two free-text columns are `Bytes` ciphertext and decrypt fail-soft, the
|
||||
* same stance the allergy reaction takes: a key-rotation gap on one row reads
|
||||
* as a visit with no reason recorded rather than as a failed report. `kind` and
|
||||
* `status` cross as enum constants — the renderer names them in the REPORT's
|
||||
* language, which is the reading clinician's.
|
||||
*
|
||||
* The condition labels come off the link rows directly rather than through the
|
||||
* link service: this is a read of the OWNER's own record for the owner's own
|
||||
* report, with no grant to narrow against and no redaction to apply, and the
|
||||
* service's per-family visibility predicate has no meaning here.
|
||||
*/
|
||||
export async function loadVisits(
|
||||
userId: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<DoctorReportData["visits"]> {
|
||||
const rows = await prisma.encounter.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
occurredAt: { gte: start, lte: end },
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
select: {
|
||||
occurredAt: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
reasonEncrypted: true,
|
||||
outcomeEncrypted: true,
|
||||
practitioner: { select: { name: true, specialty: true } },
|
||||
conditionLinks: {
|
||||
select: { episode: { select: { label: true, deletedAt: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const decrypt = (
|
||||
value: Uint8Array | null,
|
||||
field: "reason" | "outcome",
|
||||
): string | null => {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(value);
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
`doctor-report: visit ${field} decrypt failed for ${userId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const mapped = rows.map((row) => ({
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
practitionerName: row.practitioner?.name ?? null,
|
||||
practitionerSpecialty: row.practitioner?.specialty ?? null,
|
||||
reason: decrypt(row.reasonEncrypted, "reason"),
|
||||
outcome: decrypt(row.outcomeEncrypted, "outcome"),
|
||||
// A condition the person has since deleted is not named in a clinical
|
||||
// document: the link row survives the soft delete, and printing a label
|
||||
// they removed would put a withdrawn diagnosis in front of a doctor.
|
||||
conditionLabels: row.conditionLinks
|
||||
.filter((link) => link.episode?.deletedAt === null)
|
||||
.map((link) => link.episode.label),
|
||||
}));
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured allergy records. Reference data, not time-windowed — a
|
||||
* penicillin allergy does not expire with the report window.
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
loadAnamnesis,
|
||||
loadFamilyHistory,
|
||||
loadIllnessEpisodes,
|
||||
loadVisits,
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
|
||||
@@ -498,6 +499,7 @@ export async function collectDoctorReportData(
|
||||
cycle,
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
visits,
|
||||
allergies,
|
||||
familyHistory,
|
||||
anamnesis,
|
||||
@@ -511,6 +513,9 @@ export async function collectDoctorReportData(
|
||||
gate.admits("ILLNESS_EPISODES")
|
||||
? loadIllnessEpisodes(userId, start, end)
|
||||
: Promise.resolve(null),
|
||||
gate.admits("VISITS")
|
||||
? loadVisits(userId, start, end)
|
||||
: Promise.resolve(null),
|
||||
gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null),
|
||||
gate.admits("FAMILY_HISTORY")
|
||||
? loadFamilyHistory(userId)
|
||||
@@ -579,6 +584,7 @@ export async function collectDoctorReportData(
|
||||
cycle,
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
visits,
|
||||
allergies,
|
||||
familyHistory,
|
||||
anamnesis,
|
||||
|
||||
@@ -261,6 +261,14 @@ export function buildFhirDocumentBundle(
|
||||
|
||||
// --- Condition + Encounter per illness/condition episode (opt-in) ------
|
||||
// v1.18.1 — present only when the illness module is enabled AND the window
|
||||
// No visits section, and the absence is a decision written at the
|
||||
// `Encounter` model in `prisma/schema.prisma`: FHIR splits the future from
|
||||
// the past across `Appointment` and `Encounter`, which is a serialisation
|
||||
// question this release does not answer. `FHIR_REST_RESOURCE_TYPES` stays
|
||||
// untouched for the same reason — a capability statement naming a type with
|
||||
// no route module lies. The PDF carries the visits; the Bundle does not, and
|
||||
// a reader who notices the gap should find this comment rather than a bug.
|
||||
//
|
||||
// held an episode (the aggregator gates `data.illnessEpisodes`). Each
|
||||
// episode emits a patient-reported Condition (generic SNOMED root, label on
|
||||
// `code.text`) plus a bounding Encounter that references it.
|
||||
|
||||
@@ -52,8 +52,8 @@ function resolves(key: string): boolean {
|
||||
describe("report selection catalogue", () => {
|
||||
it("carries every measurement type and every structured leaf", () => {
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(14);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(91);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(15);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(92);
|
||||
});
|
||||
|
||||
it("resolves a label for every leaf", () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ export type StructuredLeafId =
|
||||
| "GLP1_THERAPY"
|
||||
| "ALLERGIES"
|
||||
| "ILLNESS_EPISODES"
|
||||
| "VISITS"
|
||||
| "FAMILY_HISTORY"
|
||||
| "MOOD"
|
||||
| "CYCLE"
|
||||
@@ -199,13 +200,14 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
|
||||
GLP1_THERAPY: "medications",
|
||||
ALLERGIES: "history",
|
||||
ILLNESS_EPISODES: "history",
|
||||
VISITS: "history",
|
||||
FAMILY_HISTORY: "sensitive",
|
||||
MOOD: "sensitive",
|
||||
CYCLE: "sensitive",
|
||||
ANAMNESIS: "sensitive",
|
||||
};
|
||||
|
||||
/** i18n label keys for the 14 structured leaves. */
|
||||
/** i18n label keys for the 15 structured leaves. */
|
||||
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
|
||||
INSURANCE: "reportSelection.leafInsurance",
|
||||
@@ -217,6 +219,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
|
||||
ALLERGIES: "reportSelection.leafAllergies",
|
||||
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
|
||||
VISITS: "reportSelection.leafVisits",
|
||||
FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
|
||||
MOOD: "reportSelection.leafMood",
|
||||
CYCLE: "reportSelection.leafCycle",
|
||||
@@ -306,6 +309,10 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
||||
MEDICATION_ADMINISTRATIONS: "medications",
|
||||
MEDICATION_COMPLIANCE: "medications",
|
||||
GLP1_THERAPY: "medications",
|
||||
// VISITS is deliberately absent, like ALLERGIES beside it. A visit carries
|
||||
// no module of its own — it is core, like the checkups page it lives on —
|
||||
// and the whole report is already gated by `doctorReport`. Reading the
|
||||
// absence as an oversight is the mistake this comment exists to prevent.
|
||||
};
|
||||
|
||||
/** Every measurement leaf id, in enum order. */
|
||||
|
||||
Reference in new issue
Block a user