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={
+
+
+ {t("common.add")}
+
+ }
+ />
+
+ );
+}
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"],