From eefc151617af2c17bf8c3ed7d2cb550a85c5148c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 20:24:42 +0200 Subject: [PATCH 01/17] feat(vaccinations): add the immunization log route, nav entry and shared-record family - register /vaccinations in the clinical nav spine, module-gated on vaccinations (default-on) and part of the shared record - join /vaccinations to the profile shared-record route family so a scoped profile grant can present the page it is allowed to read - add the page shell: authenticated, module-gated, with the standard module-off redirect the medications surface uses - add the nav label and the surface title/subtitle/empty copy in six locales --- messages/de.json | 9 ++++ messages/en.json | 9 ++++ messages/es.json | 9 ++++ messages/fr.json | 9 ++++ messages/it.json | 9 ++++ messages/pl.json | 9 ++++ .../shared-record-navigation.test.tsx | 8 +++- src/app/vaccinations/page.tsx | 47 +++++++++++++++++++ src/components/layout/nav-model.ts | 15 ++++++ .../vaccinations/vaccinations-view.tsx | 47 +++++++++++++++++++ src/lib/navigation/shared-record.ts | 7 ++- 11 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/app/vaccinations/page.tsx create mode 100644 src/components/vaccinations/vaccinations-view.tsx diff --git a/messages/de.json b/messages/de.json index 22e52f6b7..056684f07 100644 --- a/messages/de.json +++ b/messages/de.json @@ -354,6 +354,7 @@ "vorsorge": "Vorsorge", "illness": "Krankheit", "documents": "Dokumente", + "vaccinations": "Impfungen", "mentalWellbeing": "Seelisches Wohlbefinden" }, "auth": { @@ -8514,6 +8515,14 @@ "description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung." } }, + "vaccinations": { + "title": "Impfungen", + "subtitle": "Dein Impfpass, Dosis für Dosis.", + "empty": { + "title": "Noch keine Impfung erfasst", + "description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen." + } + }, "illness": { "title": "Krankheitstagebuch", "subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.", diff --git a/messages/en.json b/messages/en.json index 8998e4fad..90fbc080c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -354,6 +354,7 @@ "vorsorge": "Checkups", "illness": "Illness", "documents": "Documents", + "vaccinations": "Vaccinations", "mentalWellbeing": "Mental wellbeing" }, "auth": { @@ -8514,6 +8515,14 @@ "description": "The vaccination record, structured: dose, series, next booster." } }, + "vaccinations": { + "title": "Vaccinations", + "subtitle": "Your immunization record, dose by dose.", + "empty": { + "title": "No vaccination logged yet", + "description": "Transcribe your vaccination card line by line — a date and the vaccine are enough." + } + }, "illness": { "title": "Illness journal", "subtitle": "A retrospective log of your illness and condition episodes.", diff --git a/messages/es.json b/messages/es.json index f8b57316b..17f0d7a24 100644 --- a/messages/es.json +++ b/messages/es.json @@ -354,6 +354,7 @@ "vorsorge": "Revisiones", "illness": "Enfermedad", "documents": "Documentos", + "vaccinations": "Vacunas", "mentalWellbeing": "Bienestar mental" }, "auth": { @@ -8514,6 +8515,14 @@ "description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo." } }, + "vaccinations": { + "title": "Vacunas", + "subtitle": "Tu registro de vacunación, dosis a dosis.", + "empty": { + "title": "Aún no hay ninguna vacuna registrada", + "description": "Transcribe tu cartilla de vacunación línea por línea: bastan una fecha y la vacuna." + } + }, "illness": { "title": "Diario de enfermedad", "subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.", diff --git a/messages/fr.json b/messages/fr.json index 7daddbd8f..de85aa80c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -354,6 +354,7 @@ "vorsorge": "Examens", "illness": "Maladie", "documents": "Documents", + "vaccinations": "Vaccinations", "mentalWellbeing": "Bien-être mental" }, "auth": { @@ -8514,6 +8515,14 @@ "description": "Le carnet de vaccination structuré : dose, série, prochain rappel." } }, + "vaccinations": { + "title": "Vaccinations", + "subtitle": "Votre carnet de vaccination, dose par dose.", + "empty": { + "title": "Aucune vaccination enregistrée", + "description": "Recopiez votre carnet de vaccination ligne par ligne : une date et le vaccin suffisent." + } + }, "illness": { "title": "Journal de maladie", "subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.", diff --git a/messages/it.json b/messages/it.json index 471e33147..c1315053c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -354,6 +354,7 @@ "vorsorge": "Controlli", "illness": "Malattia", "documents": "Documenti", + "vaccinations": "Vaccinazioni", "mentalWellbeing": "Benessere mentale" }, "auth": { @@ -8514,6 +8515,14 @@ "description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo." } }, + "vaccinations": { + "title": "Vaccinazioni", + "subtitle": "Il tuo libretto vaccinale, dose per dose.", + "empty": { + "title": "Nessuna vaccinazione registrata", + "description": "Trascrivi il tuo libretto vaccinale riga per riga: bastano una data e il vaccino." + } + }, "illness": { "title": "Diario delle malattie", "subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.", diff --git a/messages/pl.json b/messages/pl.json index b4245f672..46c74cb01 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -354,6 +354,7 @@ "vorsorge": "Badania", "illness": "Choroba", "documents": "Dokumenty", + "vaccinations": "Szczepienia", "mentalWellbeing": "Dobrostan psychiczny" }, "auth": { @@ -8514,6 +8515,14 @@ "description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie." } }, + "vaccinations": { + "title": "Szczepienia", + "subtitle": "Twoja karta szczepień, dawka po dawce.", + "empty": { + "title": "Nie zarejestrowano jeszcze żadnego szczepienia", + "description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka." + } + }, "illness": { "title": "Dziennik chorób", "subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.", diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx index 65cf82ca5..8610e31de 100644 --- a/src/__tests__/shared-record-navigation.test.tsx +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -47,9 +47,15 @@ describe("shared-record navigation", () => { expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([ "/profile", "/checkups", + "/vaccinations", + ]); + expect(navigation.destinationHrefs).toEqual([ + "/profile", + "/checkups", + "/vaccinations", ]); - expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]); expect(navigation.allowsPath("/profile")).toBe(true); + expect(navigation.allowsPath("/vaccinations")).toBe(true); expect(navigation.allowsPath("/settings/anamnesis")).toBe(false); }); diff --git a/src/app/vaccinations/page.tsx b/src/app/vaccinations/page.tsx new file mode 100644 index 000000000..dd7637c37 --- /dev/null +++ b/src/app/vaccinations/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { VaccinationsView } from "@/components/vaccinations/vaccinations-view"; + +/** + * v1.38.0 — the immunization log entry. + * + * Gated on the resolved `modules.vaccinations` flag from `GET /api/auth/me`. + * The module is default-on, so `=== false` is the only state that hides the + * surface — an account that never touched the toggle keeps it. An + * unauthenticated visitor is bounced to login; an authenticated account that + * turned the module off is bounced home (the nav entry is already gone for + * them, so this only catches a direct URL hit). The `/api/vaccinations*` + * routes stay reachable regardless — the module gate governs the surface, not + * the row store — so this is a UX redirect, never the security boundary. + */ +export default function VaccinationsPage() { + const { user, isLoading, isAuthenticated } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); + const router = useRouter(); + + // Default-on: hidden only when the resolved map says `false` explicitly. A + // shared-record session always sees it — the delegate's own module map does + // not govern the owner's record. + const enabled = inSharedRecord || user?.modules?.vaccinations !== false; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (isLoading || !isAuthenticated || !enabled) { + return ; + } + + return ; +} diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 4ab0b709a..edf3d032e 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -12,6 +12,7 @@ import { Pill, Settings, Stethoscope, + Syringe, Thermometer, Trophy, Waves, @@ -193,6 +194,20 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tourId: "nav-illness", requiresModule: "illness", }, + // v1.38.0 — the immunization log sits in the clinical spine beside Illness. + // Born-gated: `requiresModule: "vaccinations"` reads the resolved module map, + // so the entry is absent until the account keeps the module on (default-on — + // it drops only when a user turns it off). SURFACE-gated: the `/api/vaccinations*` + // data routes stay reachable so a restore / import keeps working and + // re-enabling finds every dose intact. + { + href: "/vaccinations", + sharedRecord: true, + tKey: "nav.vaccinations", + icon: Syringe, + tourId: "nav-vaccinations", + requiresModule: "vaccinations", + }, // v1.18.0 — Workouts and Recovery both left the left-nav: each already // surfaces as an Insights tab-strip pill (`/insights/workouts` gated on // a workout row, `/insights/recovery` always present), so neither is a diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx new file mode 100644 index 000000000..86271dcdf --- /dev/null +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -0,0 +1,47 @@ +"use client"; + +/** + * v1.38.0 — the immunization log surface. + * + * A calm, retrospective transcription of a lifetime Impfpass. The list groups + * doses by their component antigen and shows where each series stands, with the + * numbers resolved server-side (`src/lib/vaccinations/series.ts`) so this + * client never re-derives "N von M". Neutral cards, status through a discreet + * badge only — no red card, no overdue tint. It reproduces the record; it does + * not adjudicate what is due. + * + * The list itself arrives with the capture surface; this shell owns the page + * header, the empty invitation, and the load/error states every module surface + * shares. + */ +import { Plus, Syringe } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { PageHeader } from "@/components/ui/page-header"; +import { useTranslations } from "@/lib/i18n/context"; + +export function VaccinationsView() { + const { t } = useTranslations(); + + return ( +
+ {t("vaccinations.title")}} + description={t("vaccinations.subtitle")} + /> + + } + title={t("vaccinations.empty.title")} + description={t("vaccinations.empty.description")} + action={ + + } + /> +
+ ); +} diff --git a/src/lib/navigation/shared-record.ts b/src/lib/navigation/shared-record.ts index cb4b0d55c..00733d7b7 100644 --- a/src/lib/navigation/shared-record.ts +++ b/src/lib/navigation/shared-record.ts @@ -30,7 +30,12 @@ export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = { measurements: ["/measurements", "/checkups"], medications: ["/medications"], labs: ["/labs"], - profile: ["/profile", "/checkups"], + // `/vaccinations` joins `profile`: an immunization history is health + // background in the same sense as the visits already listed here, so a + // delegate scoped to `profile` must be able to present the page the API + // already lets them read — without this line the grant reads the data and + // the chrome hides the only page that shows it. + profile: ["/profile", "/checkups", "/vaccinations"], illness: ["/illness"], mind: ["/mood", "/mental-wellbeing"], cycle: ["/cycle"], From 1d07204293831c152656b52b27cf2d9264183740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 20:32:05 +0200 Subject: [PATCH 02/17] feat(vaccinations): group the log by component antigen with resolved series - read the immunization log through the query-key factory, unwrapping the envelope; the series arrive resolved per component antigen from the DTO - render one group per antigen, a combined dose appearing in each of its component groups with that component's own position; free-text and dead-slug rows fold under their verbatim name - neutral cards, meta in muted, no due status on the page - add the dose-list dependent-keys bundle, evicting the preventive-care root alongside the dose list so a satisfied booster never lingers on checkups - series/lot/error copy in six locales --- messages/de.json | 9 + messages/en.json | 9 + messages/es.json | 9 + messages/fr.json | 9 + messages/it.json | 9 + messages/pl.json | 9 + .../__tests__/vaccination-list.test.tsx | 131 +++++++++++ .../vaccinations/use-vaccinations.ts | 44 ++++ .../vaccinations/vaccination-list.tsx | 208 ++++++++++++++++++ .../vaccinations/vaccinations-view.tsx | 51 ++++- src/lib/query-keys/index.ts | 19 ++ 11 files changed, 495 insertions(+), 12 deletions(-) create mode 100644 src/components/vaccinations/__tests__/vaccination-list.test.tsx create mode 100644 src/components/vaccinations/use-vaccinations.ts create mode 100644 src/components/vaccinations/vaccination-list.tsx diff --git a/messages/de.json b/messages/de.json index 056684f07..5a7361957 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8518,6 +8518,15 @@ "vaccinations": { "title": "Impfungen", "subtitle": "Dein Impfpass, Dosis für Dosis.", + "listLoadError": "Impfungen konnten nicht geladen werden.", + "series": { + "booster": "Auffrischung", + "ofTotal": "Dosis {position} von {total}", + "doseN": "{position}. Dosis" + }, + "row": { + "lot": "Charge {lot}" + }, "empty": { "title": "Noch keine Impfung erfasst", "description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen." diff --git a/messages/en.json b/messages/en.json index 90fbc080c..c58a68ca6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8518,6 +8518,15 @@ "vaccinations": { "title": "Vaccinations", "subtitle": "Your immunization record, dose by dose.", + "listLoadError": "Vaccinations could not be loaded.", + "series": { + "booster": "Booster", + "ofTotal": "Dose {position} of {total}", + "doseN": "Dose {position}" + }, + "row": { + "lot": "Lot {lot}" + }, "empty": { "title": "No vaccination logged yet", "description": "Transcribe your vaccination card line by line — a date and the vaccine are enough." diff --git a/messages/es.json b/messages/es.json index 17f0d7a24..50aa44062 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8518,6 +8518,15 @@ "vaccinations": { "title": "Vacunas", "subtitle": "Tu registro de vacunación, dosis a dosis.", + "listLoadError": "No se pudieron cargar las vacunas.", + "series": { + "booster": "Refuerzo", + "ofTotal": "Dosis {position} de {total}", + "doseN": "Dosis {position}" + }, + "row": { + "lot": "Lote {lot}" + }, "empty": { "title": "Aún no hay ninguna vacuna registrada", "description": "Transcribe tu cartilla de vacunación línea por línea: bastan una fecha y la vacuna." diff --git a/messages/fr.json b/messages/fr.json index de85aa80c..ab92772e3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8518,6 +8518,15 @@ "vaccinations": { "title": "Vaccinations", "subtitle": "Votre carnet de vaccination, dose par dose.", + "listLoadError": "Impossible de charger les vaccinations.", + "series": { + "booster": "Rappel", + "ofTotal": "Dose {position} sur {total}", + "doseN": "Dose {position}" + }, + "row": { + "lot": "Lot {lot}" + }, "empty": { "title": "Aucune vaccination enregistrée", "description": "Recopiez votre carnet de vaccination ligne par ligne : une date et le vaccin suffisent." diff --git a/messages/it.json b/messages/it.json index c1315053c..422154b39 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8518,6 +8518,15 @@ "vaccinations": { "title": "Vaccinazioni", "subtitle": "Il tuo libretto vaccinale, dose per dose.", + "listLoadError": "Impossibile caricare le vaccinazioni.", + "series": { + "booster": "Richiamo", + "ofTotal": "Dose {position} di {total}", + "doseN": "Dose {position}" + }, + "row": { + "lot": "Lotto {lot}" + }, "empty": { "title": "Nessuna vaccinazione registrata", "description": "Trascrivi il tuo libretto vaccinale riga per riga: bastano una data e il vaccino." diff --git a/messages/pl.json b/messages/pl.json index 46c74cb01..b8ed79922 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8518,6 +8518,15 @@ "vaccinations": { "title": "Szczepienia", "subtitle": "Twoja karta szczepień, dawka po dawce.", + "listLoadError": "Nie udało się załadować szczepień.", + "series": { + "booster": "Dawka przypominająca", + "ofTotal": "Dawka {position} z {total}", + "doseN": "Dawka {position}" + }, + "row": { + "lot": "Seria {lot}" + }, "empty": { "title": "Nie zarejestrowano jeszcze żadnego szczepienia", "description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka." diff --git a/src/components/vaccinations/__tests__/vaccination-list.test.tsx b/src/components/vaccinations/__tests__/vaccination-list.test.tsx new file mode 100644 index 000000000..1c466c84e --- /dev/null +++ b/src/components/vaccinations/__tests__/vaccination-list.test.tsx @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { VaccinationDTO } from "@/lib/vaccinations/dto"; +import type { SeriesPosition } from "@/lib/vaccinations/series"; + +import { VaccinationList } from "../vaccination-list"; + +/** + * The list answers "what have I had" and "where does each series stand": one + * group per component antigen, a combined dose rendered once in each of its + * component groups with that component's own resolved position, and a + * free-text or dead-slug record folded under its verbatim name. The numbers + * come from the DTO — this suite never asserts a re-derivation. + */ +function record( + over: Partial & { id: string; series: SeriesPosition[] }, +): VaccinationDTO { + return { + occurredAt: "2020-01-01T00:00:00.000Z", + antigenSlug: null, + vaccineName: null, + doseNumber: null, + seriesDoses: null, + lotNumber: null, + site: null, + catalogEntry: null, + practitioner: null, + encounter: null, + reminderId: null, + note: null, + createdAt: "2020-01-01T00:00:00.000Z", + updatedAt: "2020-01-01T00:00:00.000Z", + ...over, + }; +} + +function render(records: VaccinationDTO[]): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("VaccinationList grouping", () => { + it("renders a combination dose in every component group", () => { + const tdap = record({ + id: "tdap-1", + antigenSlug: "tdap", + catalogEntry: { slug: "tdap", atc: "J07AJ52", category: "standard" }, + series: [ + { antigen: "tetanus", position: 3, total: 3, booster: false }, + { antigen: "diphtheria", position: 1, total: 3, booster: false }, + { antigen: "pertussis", position: 1, total: 3, booster: false }, + ], + }); + const html = render([tdap]); + + for (const antigen of ["tetanus", "diphtheria", "pertussis"]) { + expect(html).toContain(`data-antigen="${antigen}"`); + } + // Three appearances of the one record — one per component group. + const appearances = html.match(/data-vaccination-id="tdap-1"/g) ?? []; + expect(appearances).toHaveLength(3); + }); + + it("places a monovalent and a combo in the same antigen group with per-component positions", () => { + const tetanus = record({ + id: "tet-1", + antigenSlug: "tetanus", + catalogEntry: { slug: "tetanus", atc: "J07AM01", category: "standard" }, + occurredAt: "2010-05-05T00:00:00.000Z", + series: [{ antigen: "tetanus", position: 2, total: 3, booster: false }], + }); + const tdap = record({ + id: "tdap-2", + antigenSlug: "tdap", + catalogEntry: { slug: "tdap", atc: "J07AJ52", category: "standard" }, + series: [ + { antigen: "tetanus", position: 3, total: 3, booster: false }, + { antigen: "pertussis", position: 1, total: 3, booster: false }, + ], + }); + const html = render([tetanus, tdap]); + + // The tetanus group holds both doses; the pertussis group only the Tdap. + const tetanusSection = html.slice(html.indexOf('data-antigen="tetanus"')); + expect(tetanusSection).toContain('data-vaccination-id="tet-1"'); + expect(html).toContain('data-antigen="pertussis"'); + // Dose 3 of 3 renders as resolved text, never recomputed. + expect(html).toContain("Dose 3 of 3"); + }); + + it("renders a booster past the series end as a booster, not a position", () => { + const html = render([ + record({ + id: "boost-1", + antigenSlug: "tetanus", + catalogEntry: { slug: "tetanus", atc: "J07AM01", category: "standard" }, + series: [{ antigen: "tetanus", position: 4, total: 3, booster: true }], + }), + ]); + expect(html).toContain("Booster"); + expect(html).not.toContain("Dose 4 of 3"); + }); + + it("folds a free-text and a dead-slug record under their verbatim name", () => { + const free = record({ + id: "free-1", + vaccineName: "Some old vaccine", + series: [], + }); + const deadSlug = record({ + id: "dead-1", + antigenSlug: "retired-antigen", + vaccineName: "Retired brand", + catalogEntry: null, + series: [], + }); + const html = render([free, deadSlug]); + + expect(html).toContain('data-antigen="free"'); + expect(html).toContain("Some old vaccine"); + expect(html).toContain("Retired brand"); + // A free-text row shows no series sentence — nothing is guessed. + const freeSection = html.slice(html.indexOf('data-vaccination-id="free-1"')); + expect(freeSection.slice(0, 400)).not.toContain("data-slot=\"vaccination-series\""); + }); +}); diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts new file mode 100644 index 000000000..8c4ed25bc --- /dev/null +++ b/src/components/vaccinations/use-vaccinations.ts @@ -0,0 +1,44 @@ +"use client"; + +/** + * v1.38.0 — immunization-log read hooks. + * + * Reads unwrap the envelope `data` (via `apiGet`) per the project rule, and + * every key is factory-routed through `queryKeys.vaccination*`. The list + * arrives with each dose's `series` already resolved per component antigen — + * this client renders text from those numbers and never re-derives "N von M". + * + * Writes and the booster mint land with the capture form; they invalidate + * `vaccinationDependentKeys`, which evicts the preventive-care root alongside + * the dose list because logging a dose re-anchors the booster it answers. + */ +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { VaccinationDTO, VaccinationListDTO } from "@/lib/vaccinations/dto"; + +export type Vaccination = VaccinationDTO; + +/** + * The account's immunization log, newest dose first. + * + * `antigenSlug` filters the server query to one antigen's history; the series + * numbers are still derived over the whole live set on the server, so a + * filtered view never reports the oldest visible dose as the first ever given. + */ +export function useVaccinations( + antigenSlug?: string | null, + enabled = true, +) { + return useQuery({ + queryKey: queryKeys.vaccinationList(antigenSlug ?? null), + enabled, + queryFn: () => + apiGet( + antigenSlug + ? `/api/vaccinations?antigenSlug=${encodeURIComponent(antigenSlug)}` + : "/api/vaccinations", + ), + }); +} diff --git a/src/components/vaccinations/vaccination-list.tsx b/src/components/vaccinations/vaccination-list.tsx new file mode 100644 index 000000000..5134162a3 --- /dev/null +++ b/src/components/vaccinations/vaccination-list.tsx @@ -0,0 +1,208 @@ +"use client"; + +/** + * The immunization log, grouped the way an Impfpass answers its two questions: + * "what have I had" and "where does each series stand". + * + * ── Grouping by component antigen ────────────────────────────────────────── + * + * A group is one antigen with at least one live dose. A combination record + * renders a row in EVERY component group — one Tdap shows up three times, once + * under tetanus, once under diphtheria, once under pertussis, each appearance + * carrying that component's own resolved series values. This is render-only + * duplication: one record, several appearances, and the numbers come straight + * from the DTO's `series` array, never recomputed here. + * + * A free-text-only record, and a record whose slug the catalogue no longer + * resolves, carries an empty `series` and groups under its verbatim + * `vaccineName` instead — the degrade the DTO's `catalogEntry: null` signals, + * now visible. + * + * ── Neutral, retrospective ───────────────────────────────────────────────── + * + * No due status anywhere on this page. A booster the user set shows on + * `/checkups` like every other reminder; this surface reproduces the record + * and does not adjudicate it. Cards are neutral — content in foreground, meta + * in muted, no tint. + */ +import { Syringe } from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { SeriesPosition } from "@/lib/vaccinations/series"; +import type { Vaccination } from "./use-vaccinations"; + +type Translate = ReturnType["t"]; + +/** One dose as it appears inside one antigen group. */ +interface GroupRow { + record: Vaccination; + /** This group's resolved position, or null for a free-text group. */ + position: SeriesPosition | null; +} + +interface AntigenGroup { + /** Dedup / React key. */ + key: string; + /** The catalogue antigen slug, or null for a free-text group. */ + antigen: string | null; + /** The verbatim heading for a free-text group. */ + freeName: string | null; + rows: GroupRow[]; +} + +/** + * The record's own identity, for the muted "· Tdap" marker that lets a reader + * recognise a combined dose inside a single-antigen group. A catalogue slug + * resolves through the dynamic name key; a free-text record shows its wording. + */ +function recordIdentity(t: Translate, record: Vaccination): string | null { + if (record.catalogEntry) return t(`vaccinations.catalog.${record.catalogEntry.slug}`); + return record.vaccineName; +} + +/** The series sentence for one appearance, composed from resolved numbers. */ +function seriesLabel(t: Translate, position: SeriesPosition | null): string | null { + if (!position) return null; + if (position.booster) return t("vaccinations.series.booster"); + if (position.total !== null) { + return t("vaccinations.series.ofTotal", { + position: position.position, + total: position.total, + }); + } + return t("vaccinations.series.doseN", { position: position.position }); +} + +/** + * Fold the flat, newest-first list into antigen groups. + * + * The server already sorts the list `occurredAt desc`, so pushing in order + * keeps every group newest-first without a second sort. A record with at least + * one `series` entry lands in each of its component groups; one with none folds + * into a free-text group keyed by its verbatim name. + */ +function groupByAntigen(records: readonly Vaccination[]): AntigenGroup[] { + const groups = new Map(); + + const ensure = ( + key: string, + seed: Omit, + ): AntigenGroup => { + let group = groups.get(key); + if (!group) { + group = { ...seed, rows: [] }; + groups.set(key, group); + } + return group; + }; + + for (const record of records) { + if (record.series.length > 0) { + for (const position of record.series) { + ensure(`antigen:${position.antigen}`, { + key: `antigen:${position.antigen}`, + antigen: position.antigen, + freeName: null, + }).rows.push({ record, position }); + } + continue; + } + // No resolvable antigen: a free-text row or a dead slug. Its verbatim name + // is the heading; the slug is the last-resort key so a nameless dead-slug + // row still lands somewhere rather than colliding with another. + const name = record.vaccineName ?? record.antigenSlug ?? "—"; + const key = `free:${name}`; + ensure(key, { key, antigen: null, freeName: name }).rows.push({ + record, + position: null, + }); + } + + // Antigen groups first (alphabetical by resolved-independent slug for a + // stable order), then free-text groups after, also stable. + return [...groups.values()].sort((a, b) => { + if ((a.antigen === null) !== (b.antigen === null)) { + return a.antigen === null ? 1 : -1; + } + return a.key.localeCompare(b.key); + }); +} + +function DoseRow({ row }: { row: GroupRow }) { + const { t } = useTranslations(); + const format = useFormatters(); + const { record, position } = row; + const series = seriesLabel(t, position); + const identity = recordIdentity(t, record); + // Inside an antigen group, mark a dose whose own preparation is not this + // antigen — a combined shot — so the reader sees it was a Tdap, not a + // monovalent tetanus. In a free-text group the heading already is the name. + const showIdentity = + position !== null && record.catalogEntry?.slug !== position.antigen; + + return ( + + +
+
+ {series ? ( + + {series} + + ) : null} + {showIdentity && identity ? ( + · {identity} + ) : null} +
+
+ {format.date(record.occurredAt)} + {record.lotNumber ? ( + + {t("vaccinations.row.lot", { lot: record.lotNumber })} + + ) : null} + {record.practitioner?.name ? ( + {record.practitioner.name} + ) : null} +
+
+
+
+ ); +} + +export function VaccinationList({ records }: { records: readonly Vaccination[] }) { + const { t } = useTranslations(); + const groups = groupByAntigen(records); + + return ( +
+ {groups.map((group) => ( +
+

+ + {group.antigen + ? t(`vaccinations.catalog.${group.antigen}`) + : group.freeName} + ({group.rows.length}) +

+
+ {group.rows.map((row, index) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index 86271dcdf..e7a4fa558 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -9,39 +9,66 @@ * client never re-derives "N von M". Neutral cards, status through a discreet * badge only — no red card, no overdue tint. It reproduces the record; it does * not adjudicate what is due. - * - * The list itself arrives with the capture surface; this shell owns the page - * header, the empty invitation, and the load/error states every module surface - * shares. */ import { Plus, Syringe } from "lucide-react"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; +import { VaccinationList } from "./vaccination-list"; +import { useVaccinations } from "./use-vaccinations"; + export function VaccinationsView() { const { t } = useTranslations(); + const { data, isLoading, isError, refetch } = useVaccinations(); + const records = data?.vaccinations ?? []; return (
{t("vaccinations.title")}} + title={ + {t("vaccinations.title")} + } description={t("vaccinations.subtitle")} - /> - - } - title={t("vaccinations.empty.title")} - description={t("vaccinations.empty.description")} - action={ + actions={ } /> + + {isLoading ? ( +
+ + +
+ ) : isError ? ( + // A read failure is not an empty Impfpass — surface the error + Retry so + // an outage never reads as "nothing logged yet". + void refetch()} + /> + ) : records.length > 0 ? ( + + ) : ( + } + title={t("vaccinations.empty.title")} + description={t("vaccinations.empty.description")} + action={ + + } + /> + )}
); } diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 4ce3b6979..4346959ac 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -259,6 +259,25 @@ export const encounterDependentKeys = [ queryKeys.practitioners(), ]; +/** + * Keys invalidated when a dose is logged, edited, deleted, restored, linked or + * a booster is minted. + * + * The immunization root and the preventive-care root ride together, and that + * pairing is the whole reason the bundle exists rather than a bare + * `queryKeys.vaccinations()` eviction: logging a dose runs the satisfy matcher, + * which re-anchors any booster reminder the dose answers. The checkups list + * reads that reminder from `["measurement-reminders"]`, so evicting only the + * dose list would leave a booster on screen the server had already moved a + * decade out. Both surfaces change on one write; both roots evict on one call. + * + * `useVaccinations`' mutations and the booster mint are its consumers. + */ +export const vaccinationDependentKeys = [ + queryKeys.vaccinations(), + queryKeys.measurementReminders(), +]; + /** * Invalidate every key in the bundle in parallel. Use this from mutation * `onSuccess` handlers so the call site stays a one-liner instead of repeating From ff72917833c81f45dbdd0d67231c7abfc2f06ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 20:40:40 +0200 Subject: [PATCH 03/17] feat(vaccinations): add the dose capture and edit form - one sheet for create and edit, saving with a date and one identity arm (catalogue pick or free text); nothing else can block the save - catalogue picker searches localised names and generic synonyms over the static antigen list, never overwriting the free-text arm - optional dose/series, lot, site, practice combobox, visit suggestion and note, all reusing the encounter-form primitives - write hooks fan out through the dose dependent-keys and the reminder reads - form/site/sheet copy in six locales --- messages/de.json | 37 +++ messages/en.json | 37 +++ messages/es.json | 37 +++ messages/fr.json | 37 +++ messages/it.json | 37 +++ messages/pl.json | 37 +++ .../__tests__/vaccination-form.test.ts | 74 +++++ .../vaccinations/catalog-picker.tsx | 143 +++++++++ .../vaccinations/use-vaccinations.ts | 79 ++++- .../vaccinations/vaccination-form.tsx | 296 ++++++++++++++++++ .../vaccinations/vaccination-list.tsx | 37 ++- .../vaccinations/vaccination-sheet.tsx | 171 ++++++++++ .../vaccinations/vaccinations-view.tsx | 42 ++- src/lib/query-keys/vaccinations.ts | 5 + 14 files changed, 1056 insertions(+), 13 deletions(-) create mode 100644 src/components/vaccinations/__tests__/vaccination-form.test.ts create mode 100644 src/components/vaccinations/catalog-picker.tsx create mode 100644 src/components/vaccinations/vaccination-form.tsx create mode 100644 src/components/vaccinations/vaccination-sheet.tsx diff --git a/messages/de.json b/messages/de.json index 5a7361957..de0906509 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8527,6 +8527,43 @@ "row": { "lot": "Charge {lot}" }, + "form": { + "date": "Datum", + "catalogLabel": "Impfstoff (Katalog)", + "identityHint": "Katalog wählen oder unten frei eintragen — eins genügt.", + "catalogNone": "Impfstoff wählen", + "catalogSearch": "Impfstoff suchen", + "catalogNoMatch": "Kein Katalogeintrag gefunden", + "catalogClear": "Auswahl entfernen", + "freeTextLabel": "Freitext", + "freeTextPlaceholder": "wie im Impfpass angegeben", + "seriesToggle": "Serie angeben", + "doseNumber": "Dosis-Nr.", + "seriesDoses": "Dosen in Serie", + "lot": "Chargennummer", + "site": "Impfstelle", + "siteNone": "Keine Angabe", + "practitioner": "Praxis / Arzt", + "note": "Notiz" + }, + "site": { + "LEFT_ARM": "Linker Oberarm", + "RIGHT_ARM": "Rechter Oberarm", + "LEFT_THIGH": "Linker Oberschenkel", + "RIGHT_THIGH": "Rechter Oberschenkel", + "ORAL": "Oral", + "NASAL": "Nasal", + "OTHER": "Andere" + }, + "createTitle": "Impfung erfassen", + "editTitle": "Impfung bearbeiten", + "formDescription": "Datum und Impfstoff genügen; alles andere ist optional.", + "created": "Impfung gespeichert", + "updated": "Impfung aktualisiert", + "saveFailed": "Impfung konnte nicht gespeichert werden.", + "deleteTitle": "Impfung löschen?", + "deleteDescription": "Der Eintrag wird aus deinem Impfpass entfernt. Ein geplanter Auffrischungs-Reminder bleibt bestehen.", + "deleteFailed": "Impfung konnte nicht gelöscht werden.", "empty": { "title": "Noch keine Impfung erfasst", "description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen." diff --git a/messages/en.json b/messages/en.json index c58a68ca6..53b75d239 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8527,6 +8527,43 @@ "row": { "lot": "Lot {lot}" }, + "form": { + "date": "Date", + "catalogLabel": "Vaccine (catalogue)", + "identityHint": "Pick from the catalogue or type it in below — either is enough.", + "catalogNone": "Choose a vaccine", + "catalogSearch": "Search vaccines", + "catalogNoMatch": "No catalogue entry found", + "catalogClear": "Clear selection", + "freeTextLabel": "Free text", + "freeTextPlaceholder": "as written on the record", + "seriesToggle": "Add series details", + "doseNumber": "Dose no.", + "seriesDoses": "Doses in series", + "lot": "Lot number", + "site": "Injection site", + "siteNone": "Not specified", + "practitioner": "Practice / doctor", + "note": "Note" + }, + "site": { + "LEFT_ARM": "Left upper arm", + "RIGHT_ARM": "Right upper arm", + "LEFT_THIGH": "Left thigh", + "RIGHT_THIGH": "Right thigh", + "ORAL": "Oral", + "NASAL": "Nasal", + "OTHER": "Other" + }, + "createTitle": "Log a vaccination", + "editTitle": "Edit vaccination", + "formDescription": "A date and the vaccine are enough; everything else is optional.", + "created": "Vaccination saved", + "updated": "Vaccination updated", + "saveFailed": "The vaccination could not be saved.", + "deleteTitle": "Delete this vaccination?", + "deleteDescription": "The entry is removed from your record. A planned booster reminder stays.", + "deleteFailed": "The vaccination could not be deleted.", "empty": { "title": "No vaccination logged yet", "description": "Transcribe your vaccination card line by line — a date and the vaccine are enough." diff --git a/messages/es.json b/messages/es.json index 50aa44062..fc5c67fcf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8527,6 +8527,43 @@ "row": { "lot": "Lote {lot}" }, + "form": { + "date": "Fecha", + "catalogLabel": "Vacuna (catálogo)", + "identityHint": "Elige del catálogo o escríbela abajo: basta con una.", + "catalogNone": "Elegir una vacuna", + "catalogSearch": "Buscar vacunas", + "catalogNoMatch": "No se encontró ninguna entrada del catálogo", + "catalogClear": "Quitar selección", + "freeTextLabel": "Texto libre", + "freeTextPlaceholder": "tal como figura en la cartilla", + "seriesToggle": "Añadir datos de la serie", + "doseNumber": "N.º de dosis", + "seriesDoses": "Dosis en la serie", + "lot": "Número de lote", + "site": "Lugar de aplicación", + "siteNone": "Sin especificar", + "practitioner": "Consulta / médico", + "note": "Nota" + }, + "site": { + "LEFT_ARM": "Brazo izquierdo", + "RIGHT_ARM": "Brazo derecho", + "LEFT_THIGH": "Muslo izquierdo", + "RIGHT_THIGH": "Muslo derecho", + "ORAL": "Oral", + "NASAL": "Nasal", + "OTHER": "Otro" + }, + "createTitle": "Registrar una vacuna", + "editTitle": "Editar vacuna", + "formDescription": "Bastan una fecha y la vacuna; todo lo demás es opcional.", + "created": "Vacuna guardada", + "updated": "Vacuna actualizada", + "saveFailed": "No se pudo guardar la vacuna.", + "deleteTitle": "¿Eliminar esta vacuna?", + "deleteDescription": "La entrada se elimina de tu registro. Un recordatorio de refuerzo programado se mantiene.", + "deleteFailed": "No se pudo eliminar la vacuna.", "empty": { "title": "Aún no hay ninguna vacuna registrada", "description": "Transcribe tu cartilla de vacunación línea por línea: bastan una fecha y la vacuna." diff --git a/messages/fr.json b/messages/fr.json index ab92772e3..30d42f649 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8527,6 +8527,43 @@ "row": { "lot": "Lot {lot}" }, + "form": { + "date": "Date", + "catalogLabel": "Vaccin (catalogue)", + "identityHint": "Choisissez dans le catalogue ou saisissez ci-dessous : l'un suffit.", + "catalogNone": "Choisir un vaccin", + "catalogSearch": "Rechercher des vaccins", + "catalogNoMatch": "Aucune entrée du catalogue trouvée", + "catalogClear": "Effacer la sélection", + "freeTextLabel": "Texte libre", + "freeTextPlaceholder": "comme indiqué sur le carnet", + "seriesToggle": "Ajouter les détails de la série", + "doseNumber": "N° de dose", + "seriesDoses": "Doses dans la série", + "lot": "Numéro de lot", + "site": "Site d'injection", + "siteNone": "Non précisé", + "practitioner": "Cabinet / médecin", + "note": "Note" + }, + "site": { + "LEFT_ARM": "Bras gauche", + "RIGHT_ARM": "Bras droit", + "LEFT_THIGH": "Cuisse gauche", + "RIGHT_THIGH": "Cuisse droite", + "ORAL": "Oral", + "NASAL": "Nasal", + "OTHER": "Autre" + }, + "createTitle": "Enregistrer une vaccination", + "editTitle": "Modifier la vaccination", + "formDescription": "Une date et le vaccin suffisent ; tout le reste est facultatif.", + "created": "Vaccination enregistrée", + "updated": "Vaccination mise à jour", + "saveFailed": "La vaccination n'a pas pu être enregistrée.", + "deleteTitle": "Supprimer cette vaccination ?", + "deleteDescription": "L'entrée est retirée de votre carnet. Un rappel programmé est conservé.", + "deleteFailed": "La vaccination n'a pas pu être supprimée.", "empty": { "title": "Aucune vaccination enregistrée", "description": "Recopiez votre carnet de vaccination ligne par ligne : une date et le vaccin suffisent." diff --git a/messages/it.json b/messages/it.json index 422154b39..27048479d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8527,6 +8527,43 @@ "row": { "lot": "Lotto {lot}" }, + "form": { + "date": "Data", + "catalogLabel": "Vaccino (catalogo)", + "identityHint": "Scegli dal catalogo o scrivilo sotto: ne basta uno.", + "catalogNone": "Scegli un vaccino", + "catalogSearch": "Cerca vaccini", + "catalogNoMatch": "Nessuna voce del catalogo trovata", + "catalogClear": "Rimuovi selezione", + "freeTextLabel": "Testo libero", + "freeTextPlaceholder": "come indicato sul libretto", + "seriesToggle": "Aggiungi dettagli della serie", + "doseNumber": "N. dose", + "seriesDoses": "Dosi nella serie", + "lot": "Numero di lotto", + "site": "Sede di iniezione", + "siteNone": "Non specificato", + "practitioner": "Studio / medico", + "note": "Nota" + }, + "site": { + "LEFT_ARM": "Braccio sinistro", + "RIGHT_ARM": "Braccio destro", + "LEFT_THIGH": "Coscia sinistra", + "RIGHT_THIGH": "Coscia destra", + "ORAL": "Orale", + "NASAL": "Nasale", + "OTHER": "Altro" + }, + "createTitle": "Registra una vaccinazione", + "editTitle": "Modifica vaccinazione", + "formDescription": "Bastano una data e il vaccino; tutto il resto è facoltativo.", + "created": "Vaccinazione salvata", + "updated": "Vaccinazione aggiornata", + "saveFailed": "Impossibile salvare la vaccinazione.", + "deleteTitle": "Eliminare questa vaccinazione?", + "deleteDescription": "La voce viene rimossa dal tuo libretto. Un promemoria di richiamo programmato rimane.", + "deleteFailed": "Impossibile eliminare la vaccinazione.", "empty": { "title": "Nessuna vaccinazione registrata", "description": "Trascrivi il tuo libretto vaccinale riga per riga: bastano una data e il vaccino." diff --git a/messages/pl.json b/messages/pl.json index b8ed79922..7dfae551f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8527,6 +8527,43 @@ "row": { "lot": "Seria {lot}" }, + "form": { + "date": "Data", + "catalogLabel": "Szczepionka (katalog)", + "identityHint": "Wybierz z katalogu lub wpisz poniżej — wystarczy jedno.", + "catalogNone": "Wybierz szczepionkę", + "catalogSearch": "Szukaj szczepionek", + "catalogNoMatch": "Nie znaleziono wpisu w katalogu", + "catalogClear": "Wyczyść wybór", + "freeTextLabel": "Tekst dowolny", + "freeTextPlaceholder": "jak podano w karcie szczepień", + "seriesToggle": "Dodaj szczegóły serii", + "doseNumber": "Nr dawki", + "seriesDoses": "Dawki w serii", + "lot": "Numer serii", + "site": "Miejsce podania", + "siteNone": "Nie podano", + "practitioner": "Gabinet / lekarz", + "note": "Notatka" + }, + "site": { + "LEFT_ARM": "Lewe ramię", + "RIGHT_ARM": "Prawe ramię", + "LEFT_THIGH": "Lewe udo", + "RIGHT_THIGH": "Prawe udo", + "ORAL": "Doustnie", + "NASAL": "Donosowo", + "OTHER": "Inne" + }, + "createTitle": "Zarejestruj szczepienie", + "editTitle": "Edytuj szczepienie", + "formDescription": "Wystarczą data i szczepionka; cała reszta jest opcjonalna.", + "created": "Szczepienie zapisane", + "updated": "Szczepienie zaktualizowane", + "saveFailed": "Nie udało się zapisać szczepienia.", + "deleteTitle": "Usunąć to szczepienie?", + "deleteDescription": "Wpis zostanie usunięty z Twojej karty. Zaplanowane przypomnienie o dawce przypominającej pozostaje.", + "deleteFailed": "Nie udało się usunąć szczepienia.", "empty": { "title": "Nie zarejestrowano jeszcze żadnego szczepienia", "description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka." diff --git a/src/components/vaccinations/__tests__/vaccination-form.test.ts b/src/components/vaccinations/__tests__/vaccination-form.test.ts new file mode 100644 index 000000000..0d53865b5 --- /dev/null +++ b/src/components/vaccinations/__tests__/vaccination-form.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; + +import { + draftHasIdentity, + draftInstant, + draftToBody, + emptyDraft, +} from "../vaccination-form"; + +/** + * The form's one hard rule: a dose saves with a date and ONE identity arm — a + * catalogue pick OR the person's own wording — and nothing else. These cover + * the body derivation that the sheet's Save gate reads. + */ +describe("vaccination form draft", () => { + it("saves with a date and a catalogue pick alone", () => { + const body = draftToBody( + emptyDraft({ occurredAt: "2020-03-04", antigenSlug: "tetanus" }), + ); + expect(body).not.toBeNull(); + expect(body!.antigenSlug).toBe("tetanus"); + expect(body!.occurredAt).toBe("2020-03-04T00:00:00.000Z"); + // Nothing else is required — the optional fields default to absences. + expect(body!.vaccineName).toBeNull(); + expect(body!.doseNumber).toBeNull(); + expect(body!.lotNumber).toBeNull(); + expect(body!.site).toBeNull(); + expect(body!.practitionerId).toBeNull(); + }); + + it("saves with a date and free text alone", () => { + const body = draftToBody( + emptyDraft({ occurredAt: "1987-06-01", vaccineName: " Old brand " }), + ); + expect(body).not.toBeNull(); + expect(body!.antigenSlug).toBeNull(); + expect(body!.vaccineName).toBe("Old brand"); + }); + + it("refuses a draft with neither identity arm", () => { + const draft = emptyDraft({ occurredAt: "2020-01-01" }); + expect(draftHasIdentity(draft)).toBe(false); + expect(draftToBody(draft)).toBeNull(); + }); + + it("refuses a draft with no usable date", () => { + expect(draftInstant("")).toBeNull(); + expect(draftInstant("not-a-date")).toBeNull(); + expect( + draftToBody(emptyDraft({ occurredAt: "", antigenSlug: "tetanus" })), + ).toBeNull(); + }); + + it("carries the optional fields through when present", () => { + const body = draftToBody( + emptyDraft({ + occurredAt: "2021-09-09", + antigenSlug: "tdap", + doseNumber: "3", + seriesDoses: "3", + lotNumber: "AB123", + site: "LEFT_ARM", + note: "sore arm", + }), + ); + expect(body).toMatchObject({ + doseNumber: 3, + seriesDoses: 3, + lotNumber: "AB123", + site: "LEFT_ARM", + note: "sore arm", + }); + }); +}); diff --git a/src/components/vaccinations/catalog-picker.tsx b/src/components/vaccinations/catalog-picker.tsx new file mode 100644 index 000000000..d330fa889 --- /dev/null +++ b/src/components/vaccinations/catalog-picker.tsx @@ -0,0 +1,143 @@ +"use client"; + +/** + * The catalogue arm of the identity pair. + * + * Searches the static antigen catalogue by its localised display name and by + * the generic synonyms shipped with each entry — never a trade name, because + * none exist in the catalogue; a person who types a brand matches nothing here + * and falls back to the free-text arm, which is exactly where a Pass's own + * wording belongs. Picking an entry sets `antigenSlug`; it never touches what + * the person typed in the free-text field. + * + * The list is a static import, so the search is an in-memory filter with no + * round trip and no provider dependency. Grouped by category so a lifetime Pass + * (childhood through travel) reads in the order a person recognises. + */ +import { useMemo, useState } from "react"; +import { Check, ChevronsUpDown, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { + VACCINE_CATALOG, + type VaccineSeed, +} from "@/lib/vaccinations/vaccine-catalog"; + +export function CatalogPicker({ + value, + onChange, + disabled, +}: { + /** The chosen catalogue slug, or null. */ + value: string | null; + onChange: (slug: string | null) => void; + disabled?: boolean; +}) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + const [term, setTerm] = useState(""); + + const name = (slug: string) => t(`vaccinations.catalog.${slug}`); + + const matches = useMemo(() => { + const needle = term.trim().toLowerCase(); + const scored = VACCINE_CATALOG.filter((entry) => { + if (!needle) return true; + if (name(entry.slug).toLowerCase().includes(needle)) return true; + if (entry.slug.includes(needle)) return true; + return (entry.synonyms ?? []).some((s) => s.toLowerCase().includes(needle)); + }); + return scored; + // `name` closes over `t`, which is stable per locale; term drives the recompute. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [term, t]); + + const selected: VaccineSeed | undefined = value + ? VACCINE_CATALOG.find((entry) => entry.slug === value) + : undefined; + + return ( +
+ + + + + + setTerm(event.target.value)} + placeholder={t("vaccinations.form.catalogSearch")} + aria-label={t("vaccinations.form.catalogSearch")} + data-slot="vaccination-catalog-search" + /> +
+ {matches.length === 0 ? ( +

+ {t("vaccinations.form.catalogNoMatch")} +

+ ) : ( + matches.map((entry) => ( + + )) + )} +
+
+
+ + {selected ? ( + + ) : null} +
+ ); +} diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 8c4ed25bc..111f238b5 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -12,14 +12,35 @@ * `vaccinationDependentKeys`, which evicts the preventive-care root alongside * the dose list because logging a dose re-anchors the booster it answers. */ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { apiGet } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; +import { invalidateKeys, queryKeys, vaccinationDependentKeys } from "@/lib/query-keys"; import type { VaccinationDTO, VaccinationListDTO } from "@/lib/vaccinations/dto"; export type Vaccination = VaccinationDTO; +/** + * The write body — every field optional but the identity pair and the date. + * `userId` is never here; the route narrows it from the session. + */ +export interface VaccinationWriteBody { + occurredAt?: string; + antigenSlug?: string | null; + vaccineName?: string | null; + doseNumber?: number | null; + seriesDoses?: number | null; + lotNumber?: string | null; + site?: string | null; + practitionerId?: string | null; + encounterId?: string | null; + note?: string | null; + documentIds?: string[]; +} + +const BASE = "/api/vaccinations"; + /** * The account's immunization log, newest dose first. * @@ -37,8 +58,56 @@ export function useVaccinations( queryFn: () => apiGet( antigenSlug - ? `/api/vaccinations?antigenSlug=${encodeURIComponent(antigenSlug)}` - : "/api/vaccinations", + ? `${BASE}?antigenSlug=${encodeURIComponent(antigenSlug)}` + : BASE, ), }); } + +/** + * Create / edit / soft-delete / restore a dose. + * + * Every write fans out through `vaccinationDependentKeys` AND the reminder + * reads: logging a dose runs the server's satisfy matcher, which re-anchors any + * booster the dose answers, so the preventive-care list must repaint in the + * same tick — the exact pairing the encounter mutations use, through the same + * helper rather than a second sweep that could drift from it. + */ +export function useVaccinationMutations() { + const qc = useQueryClient(); + const invalidate = () => + Promise.all([ + invalidateKeys(qc, vaccinationDependentKeys), + invalidateReminderReads(qc), + ]); + + const create = useMutation({ + mutationKey: queryKeys.vaccinationCreate(), + mutationFn: (body: VaccinationWriteBody) => + apiPost(BASE, body), + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationKey: queryKeys.vaccinationUpdate(), + mutationFn: ({ id, body }: { id: string; body: VaccinationWriteBody }) => + apiPatch(`${BASE}/${id}`, body), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationKey: queryKeys.vaccinationDelete(), + mutationFn: (id: string) => + apiDelete<{ deleted: boolean }>(`${BASE}/${id}`), + onSuccess: invalidate, + }); + + const restore = useMutation({ + mutationKey: queryKeys.vaccinationRestore(), + mutationFn: (id: string) => + apiPost(`${BASE}/${id}/restore`, {}), + onSuccess: invalidate, + }); + + return { create, update, remove, restore }; +} diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx new file mode 100644 index 000000000..e2853811a --- /dev/null +++ b/src/components/vaccinations/vaccination-form.tsx @@ -0,0 +1,296 @@ +"use client"; + +/** + * The dose capture and edit form. + * + * **A dose saves with a date and ONE identity arm — a catalogue pick OR the + * person's own wording — and nothing else.** No required dose number, no + * required batch code, no required practice. A required catalogue pick would + * make a 1987 Impfpass line unloggable, which is the whole population this + * feature exists for. The only thing that can block Save is a missing date or + * neither identity arm. + * + * The two identity arms are independent: picking a catalogue entry never + * overwrites what the person typed, and typing never clears a pick. Either is + * sufficient; both are allowed. + * + * Follows the encounter form's controlled-draft shape rather than a + * react-hook-form instance — the sibling surface this plan cites as its + * precedent uses a draft, and the server's Zod schema is the validation + * authority either way. The client guard is for the two properties a person + * feels immediately: a date is present, and the vaccine is named somehow. + */ +import { useState } from "react"; + +import { DateField } from "@/components/ui/date-field"; +import { FieldGroup } from "@/components/ui/field-group"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Textarea } from "@/components/ui/textarea"; +import { PractitionerCombobox } from "@/components/encounters/practitioner-combobox"; +import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field"; +import { useTranslations } from "@/lib/i18n/context"; +import type { Practitioner } from "@/hooks/use-practitioners"; +import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations"; +import { CatalogPicker } from "./catalog-picker"; + +/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */ +export const VACCINATION_SITES = [ + "LEFT_ARM", + "RIGHT_ARM", + "LEFT_THIGH", + "RIGHT_THIGH", + "ORAL", + "NASAL", + "OTHER", +] as const; + +export interface VaccinationDraft { + /** `YYYY-MM-DD`, the `DateField` contract. */ + occurredAt: string; + antigenSlug: string | null; + vaccineName: string; + doseNumber: string; + seriesDoses: string; + lotNumber: string; + site: string; + practitioner: Practitioner | null; + encounterId: string | null; + note: string; + documentIds: string[]; +} + +/** Today as a local `YYYY-MM-DD`, the DateField default and max. */ +export function todayLocal(): string { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +export function emptyDraft( + overrides?: Partial, +): VaccinationDraft { + return { + occurredAt: todayLocal(), + antigenSlug: null, + vaccineName: "", + doseNumber: "", + seriesDoses: "", + lotNumber: "", + site: "", + practitioner: null, + encounterId: null, + note: "", + documentIds: [], + ...overrides, + }; +} + +export function draftFromVaccination(row: Vaccination): VaccinationDraft { + return { + occurredAt: row.occurredAt.slice(0, 10), + antigenSlug: row.antigenSlug, + vaccineName: row.vaccineName ?? "", + doseNumber: row.doseNumber?.toString() ?? "", + seriesDoses: row.seriesDoses?.toString() ?? "", + lotNumber: row.lotNumber ?? "", + site: row.site ?? "", + practitioner: row.practitioner, + encounterId: row.encounter?.id ?? null, + note: row.note ?? "", + documentIds: row.documents?.map((doc) => doc.id) ?? [], + }; +} + +/** The day, at UTC midnight — an Impfpass carries dates, never times. */ +export function draftInstant(day: string): string | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null; + const parsed = new Date(`${day}T00:00:00.000Z`); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +/** Does the draft carry at least one identity arm? */ +export function draftHasIdentity(draft: VaccinationDraft): boolean { + return draft.antigenSlug !== null || draft.vaccineName.trim().length > 0; +} + +function toInt(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const n = Number.parseInt(trimmed, 10); + return Number.isNaN(n) ? null : n; +} + +/** The body a draft becomes, empties turned into absences. Null when unsavable. */ +export function draftToBody(draft: VaccinationDraft): VaccinationWriteBody | null { + const occurredAt = draftInstant(draft.occurredAt); + if (!occurredAt) return null; + if (!draftHasIdentity(draft)) return null; + return { + occurredAt, + antigenSlug: draft.antigenSlug, + vaccineName: draft.vaccineName.trim() || null, + doseNumber: toInt(draft.doseNumber), + seriesDoses: toInt(draft.seriesDoses), + lotNumber: draft.lotNumber.trim() || null, + site: draft.site || null, + practitionerId: draft.practitioner?.id ?? null, + encounterId: draft.encounterId, + note: draft.note.trim() || null, + documentIds: draft.documentIds, + }; +} + +export function VaccinationForm({ + draft, + onChange, +}: { + draft: VaccinationDraft; + onChange: (next: VaccinationDraft) => void; +}) { + const { t } = useTranslations(); + const patch = (part: Partial) => + onChange({ ...draft, ...part }); + + const [seriesOpen, setSeriesOpen] = useState( + draft.doseNumber.length > 0 || draft.seriesDoses.length > 0, + ); + + const anchor = draftInstant(draft.occurredAt); + + return ( +
+ + patch({ occurredAt: value })} + max={todayLocal()} + required + data-testid="vaccination-occurred-at" + /> + + + + patch({ antigenSlug: slug })} + /> + + + + patch({ vaccineName: event.target.value })} + placeholder={t("vaccinations.form.freeTextPlaceholder")} + data-slot="vaccination-free-text" + /> + + + {seriesOpen ? ( +
+ + patch({ doseNumber: event.target.value })} + /> + + + patch({ seriesDoses: event.target.value })} + /> + +
+ ) : ( + + )} + + + patch({ lotNumber: event.target.value })} + /> + + + + patch({ site: event.target.value })} + > + + {VACCINATION_SITES.map((site) => ( + + ))} + + + + + patch({ practitioner: next })} + /> + + + patch({ encounterId: id })} + slot="vaccination-encounter-suggestion" + /> + + +