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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 20:24:42 +02:00
1 parent 11a6bc40c0
commit eefc151617
11 files changed
+176 -2

No files matched your search

@@ -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);
});
+47
View File
@@ -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 <PageAuthGate />;
}
return <VaccinationsView />;
}
+15
View File
@@ -12,6 +12,7 @@ import {
Pill,
Settings,
Stethoscope,
Syringe,
Thermometer,
Trophy,
Waves,
@@ -193,6 +194,20 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
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
@@ -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 (
<div className="space-y-6">
<PageHeader
title={<span data-tour-id="vaccinations-hero">{t("vaccinations.title")}</span>}
description={t("vaccinations.subtitle")}
/>
<EmptyState
icon={<Syringe className="size-6" />}
title={t("vaccinations.empty.title")}
description={t("vaccinations.empty.description")}
action={
<Button className="min-h-11 sm:min-h-9" disabled>
<Plus className="size-4" />
{t("common.add")}
</Button>
}
/>
</div>
);
}
+6 -1
View File
@@ -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"],