diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 572a7a7ca..9c44ed2fb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 45bbcd82c..8e83adbe3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index b7e6f4594..c01f68359 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 1e36a2261..18e3b80cc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 345d8841b..9f2dae44c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 91ac79e39..3f7c24a80 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 5d344b36a..83143dd75 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 7b7cbf95e..d9a137b7b 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -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", () => { diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index 6adcd7b5b..d4c5e77ef 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -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, ); diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index 4b4f21999..dceb607f3 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -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, ); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 905d6c272..c442523cf 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -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( diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index 7d0a34249..414be182d 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -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(" — 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"); diff --git a/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx new file mode 100644 index 000000000..476fbbbfa --- /dev/null +++ b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx @@ -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( + "@/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 & { 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( + + + + + , + ); +} + +describe("", () => { + 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(); + }); +}); diff --git a/src/components/settings/report-selection/report-visit-offer.tsx b/src/components/settings/report-selection/report-visit-offer.tsx new file mode 100644 index 000000000..5108a1588 --- /dev/null +++ b/src/components/settings/report-selection/report-visit-offer.tsx @@ -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 ( +
+ {/* Content, not meta: it is the offer, and it names its own source. */} +

+ + + {t("settings.healthRecord.visitOffer", { + what, + when: format.date(next.occurredAt), + })} + +

+ + +
+ ); +} diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx index e661cd4bb..3f589a20f 100644 --- a/src/components/settings/report-selection/selection-panel.tsx +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -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() { + {/* Never auto-applies: pressing it is what opens the custom window and + sets its end date. */} + { + setCustomRange(true); + setEndDate(endDay); + }} + /> + {customRange ? (
diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 940dae220..df1894fcb 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -172,6 +172,7 @@ const fakeDb = vi.hoisted(() => { ecgRecording: { findFirst: async () => null }, personalRecord: { findMany: none }, arrivalReaction: { findMany: none }, + encounter: { findFirst: async () => null }, }; }); diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 464f1f85f..3461dacdd 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -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, diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 1d1de1c0d..af2b7fc85 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -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; diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 38c28e9f0..18a428688 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -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(); diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index 9553a15db..4ad0f456c 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -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 { + 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. diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 1c707d722..79c4ccc0f 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -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, diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index a71b60600..1f46d8a24 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -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. diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 0fb75962a..27d0c73cf 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -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", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index f8e1b86da..d65fa64d3 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -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 = { 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 = { PATIENT_IDENTITY: "reportSelection.leafPatientIdentity", INSURANCE: "reportSelection.leafInsurance", @@ -217,6 +219,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record = { 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> = { 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. */