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] 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"],