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