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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 20:32:05 +02:00
1 parent eefc151617
commit 1d07204293
11 files changed
+495 -12

No files matched your search

+9
View File
@@ -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."
+9
View File
@@ -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."
+9
View File
@@ -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."
+9
View File
@@ -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."
+9
View File
@@ -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."
+9
View File
@@ -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."
@@ -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<VaccinationDTO> & { 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(
<I18nProvider initialLocale="en">
<VaccinationList records={records} />
</I18nProvider>,
);
}
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\"");
});
});
@@ -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<VaccinationListDTO>(
antigenSlug
? `/api/vaccinations?antigenSlug=${encodeURIComponent(antigenSlug)}`
: "/api/vaccinations",
),
});
}
@@ -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<typeof useTranslations>["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<string, AntigenGroup>();
const ensure = (
key: string,
seed: Omit<AntigenGroup, "rows">,
): 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 (
<Card
className="gap-0"
data-slot="vaccination-row"
data-vaccination-id={record.id}
>
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-center gap-x-2 text-sm">
{series ? (
<span className="text-foreground font-medium" data-slot="vaccination-series">
{series}
</span>
) : null}
{showIdentity && identity ? (
<span className="text-muted-foreground">· {identity}</span>
) : null}
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
<span>{format.date(record.occurredAt)}</span>
{record.lotNumber ? (
<span data-slot="vaccination-lot">
{t("vaccinations.row.lot", { lot: record.lotNumber })}
</span>
) : null}
{record.practitioner?.name ? (
<span>{record.practitioner.name}</span>
) : null}
</div>
</div>
</CardContent>
</Card>
);
}
export function VaccinationList({ records }: { records: readonly Vaccination[] }) {
const { t } = useTranslations();
const groups = groupByAntigen(records);
return (
<div className="space-y-6">
{groups.map((group) => (
<section
key={group.key}
className="space-y-2"
data-slot="vaccination-group"
data-antigen={group.antigen ?? "free"}
>
<h2 className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
<Syringe className="size-3.5" aria-hidden />
{group.antigen
? t(`vaccinations.catalog.${group.antigen}`)
: group.freeName}
<span className="normal-case">({group.rows.length})</span>
</h2>
<div className="space-y-2">
{group.rows.map((row, index) => (
<DoseRow key={`${row.record.id}:${index}`} row={row} />
))}
</div>
</section>
))}
</div>
);
}
@@ -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 (
<div className="space-y-6">
<PageHeader
title={<span data-tour-id="vaccinations-hero">{t("vaccinations.title")}</span>}
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={
actions={
<Button className="min-h-11 sm:min-h-9" disabled>
<Plus className="size-4" />
{t("common.add")}
</Button>
}
/>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : isError ? (
// A read failure is not an empty Impfpass — surface the error + Retry so
// an outage never reads as "nothing logged yet".
<QueryErrorCard
title={t("vaccinations.listLoadError")}
onRetry={() => void refetch()}
/>
) : records.length > 0 ? (
<VaccinationList records={records} />
) : (
<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>
);
}
+19
View File
@@ -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