From f54029852144fb454d02433625ab13c0bbce14b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 20:45:46 +0200 Subject: [PATCH] feat(vaccinations): render the catalogue's sourced schedule text - compose rung-1 sentences from the seed's series length, booster interval and category over a handful of i18n templates, with the per-entry source as a verbatim footnote; nothing personal, no age or history conditioning - surface it in the picker's chosen-entry detail and behind the antigen group header's info affordance - schedule templates in six locales, plural-aware --- messages/de.json | 15 ++ messages/en.json | 15 ++ messages/es.json | 15 ++ messages/fr.json | 15 ++ messages/it.json | 15 ++ messages/pl.json | 15 ++ .../__tests__/catalog-info.test.tsx | 50 ++++++ .../__tests__/vaccination-list.test.tsx | 8 +- src/components/vaccinations/catalog-info.tsx | 99 +++++++++++ .../vaccinations/catalog-picker.tsx | 165 ++++++++++-------- .../vaccinations/use-vaccinations.ts | 16 +- .../vaccinations/vaccination-form.tsx | 14 +- .../vaccinations/vaccination-list.tsx | 23 ++- .../vaccinations/vaccination-sheet.tsx | 5 +- .../vaccinations/vaccinations-view.tsx | 4 +- 15 files changed, 384 insertions(+), 90 deletions(-) create mode 100644 src/components/vaccinations/__tests__/catalog-info.test.tsx create mode 100644 src/components/vaccinations/catalog-info.tsx diff --git a/messages/de.json b/messages/de.json index de0906509..a99a83959 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8564,6 +8564,21 @@ "deleteTitle": "Impfung löschen?", "deleteDescription": "Der Eintrag wird aus deinem Impfpass entfernt. Ein geplanter Auffrischungs-Reminder bleibt bestehen.", "deleteFailed": "Impfung konnte nicht gelöscht werden.", + "info": { + "primarySeriesOne": "Grundimmunisierung: {count} Dosis", + "primarySeriesFew": "Grundimmunisierung: {count} Dosen", + "primarySeriesOther": "Grundimmunisierung: {count} Dosen", + "yearly": "Jährliche Impfung", + "boosterYearsOne": "Auffrischung üblicherweise alle {count} Jahr", + "boosterYearsFew": "Auffrischung üblicherweise alle {count} Jahre", + "boosterYearsOther": "Auffrischung üblicherweise alle {count} Jahre", + "boosterMonthsOne": "Auffrischung üblicherweise alle {count} Monat", + "boosterMonthsFew": "Auffrischung üblicherweise alle {count} Monate", + "boosterMonthsOther": "Auffrischung üblicherweise alle {count} Monate", + "standard60": "Standardimpfung ab 60", + "sourceLabel": "Quelle: {source}", + "affordanceLabel": "Impfschema-Info" + }, "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 53b75d239..d672d79b8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8564,6 +8564,21 @@ "deleteTitle": "Delete this vaccination?", "deleteDescription": "The entry is removed from your record. A planned booster reminder stays.", "deleteFailed": "The vaccination could not be deleted.", + "info": { + "primarySeriesOne": "Primary series: {count} dose", + "primarySeriesFew": "Primary series: {count} doses", + "primarySeriesOther": "Primary series: {count} doses", + "yearly": "Yearly vaccination", + "boosterYearsOne": "Booster typically every {count} year", + "boosterYearsFew": "Booster typically every {count} years", + "boosterYearsOther": "Booster typically every {count} years", + "boosterMonthsOne": "Booster typically every {count} month", + "boosterMonthsFew": "Booster typically every {count} months", + "boosterMonthsOther": "Booster typically every {count} months", + "standard60": "Standard vaccination from age 60", + "sourceLabel": "Source: {source}", + "affordanceLabel": "Schedule info" + }, "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 fc5c67fcf..a716ed2db 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8564,6 +8564,21 @@ "deleteTitle": "¿Eliminar esta vacuna?", "deleteDescription": "La entrada se elimina de tu registro. Un recordatorio de refuerzo programado se mantiene.", "deleteFailed": "No se pudo eliminar la vacuna.", + "info": { + "primarySeriesOne": "Serie primaria: {count} dosis", + "primarySeriesFew": "Serie primaria: {count} dosis", + "primarySeriesOther": "Serie primaria: {count} dosis", + "yearly": "Vacunación anual", + "boosterYearsOne": "Refuerzo normalmente cada {count} año", + "boosterYearsFew": "Refuerzo normalmente cada {count} años", + "boosterYearsOther": "Refuerzo normalmente cada {count} años", + "boosterMonthsOne": "Refuerzo normalmente cada {count} mes", + "boosterMonthsFew": "Refuerzo normalmente cada {count} meses", + "boosterMonthsOther": "Refuerzo normalmente cada {count} meses", + "standard60": "Vacunación estándar a partir de los 60", + "sourceLabel": "Fuente: {source}", + "affordanceLabel": "Información del esquema" + }, "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 30d42f649..dc0ef08a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8564,6 +8564,21 @@ "deleteTitle": "Supprimer cette vaccination ?", "deleteDescription": "L'entrée est retirée de votre carnet. Un rappel programmé est conservé.", "deleteFailed": "La vaccination n'a pas pu être supprimée.", + "info": { + "primarySeriesOne": "Primovaccination : {count} dose", + "primarySeriesFew": "Primovaccination : {count} doses", + "primarySeriesOther": "Primovaccination : {count} doses", + "yearly": "Vaccination annuelle", + "boosterYearsOne": "Rappel généralement tous les {count} an", + "boosterYearsFew": "Rappel généralement tous les {count} ans", + "boosterYearsOther": "Rappel généralement tous les {count} ans", + "boosterMonthsOne": "Rappel généralement tous les {count} mois", + "boosterMonthsFew": "Rappel généralement tous les {count} mois", + "boosterMonthsOther": "Rappel généralement tous les {count} mois", + "standard60": "Vaccination standard à partir de 60 ans", + "sourceLabel": "Source : {source}", + "affordanceLabel": "Infos sur le schéma" + }, "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 27048479d..5f0a8f0d8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8564,6 +8564,21 @@ "deleteTitle": "Eliminare questa vaccinazione?", "deleteDescription": "La voce viene rimossa dal tuo libretto. Un promemoria di richiamo programmato rimane.", "deleteFailed": "Impossibile eliminare la vaccinazione.", + "info": { + "primarySeriesOne": "Ciclo primario: {count} dose", + "primarySeriesFew": "Ciclo primario: {count} dosi", + "primarySeriesOther": "Ciclo primario: {count} dosi", + "yearly": "Vaccinazione annuale", + "boosterYearsOne": "Richiamo di norma ogni {count} anno", + "boosterYearsFew": "Richiamo di norma ogni {count} anni", + "boosterYearsOther": "Richiamo di norma ogni {count} anni", + "boosterMonthsOne": "Richiamo di norma ogni {count} mese", + "boosterMonthsFew": "Richiamo di norma ogni {count} mesi", + "boosterMonthsOther": "Richiamo di norma ogni {count} mesi", + "standard60": "Vaccinazione standard dai 60 anni", + "sourceLabel": "Fonte: {source}", + "affordanceLabel": "Informazioni sullo schema" + }, "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 7dfae551f..ff8c0304d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8564,6 +8564,21 @@ "deleteTitle": "Usunąć to szczepienie?", "deleteDescription": "Wpis zostanie usunięty z Twojej karty. Zaplanowane przypomnienie o dawce przypominającej pozostaje.", "deleteFailed": "Nie udało się usunąć szczepienia.", + "info": { + "primarySeriesOne": "Seria podstawowa: {count} dawka", + "primarySeriesFew": "Seria podstawowa: {count} dawki", + "primarySeriesOther": "Seria podstawowa: {count} dawek", + "yearly": "Szczepienie coroczne", + "boosterYearsOne": "Dawka przypominająca zwykle co {count} rok", + "boosterYearsFew": "Dawka przypominająca zwykle co {count} lata", + "boosterYearsOther": "Dawka przypominająca zwykle co {count} lat", + "boosterMonthsOne": "Dawka przypominająca zwykle co {count} miesiąc", + "boosterMonthsFew": "Dawka przypominająca zwykle co {count} miesiące", + "boosterMonthsOther": "Dawka przypominająca zwykle co {count} miesięcy", + "standard60": "Szczepienie standardowe od 60. roku życia", + "sourceLabel": "Źródło: {source}", + "affordanceLabel": "Informacje o schemacie" + }, "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__/catalog-info.test.tsx b/src/components/vaccinations/__tests__/catalog-info.test.tsx new file mode 100644 index 000000000..0296dfbf8 --- /dev/null +++ b/src/components/vaccinations/__tests__/catalog-info.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +import { CatalogInfo, catalogInfoAvailable } from "../catalog-info"; + +/** + * Rung 1 renders the catalogue's own numbers, cited, and nothing personal. + * The templates compose from `typicalSeriesDoses` / `boosterIntervalMonths` / + * `category`, so each null combination simply drops its line. + */ +function render(slug: string | null): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("CatalogInfo rung-1 text", () => { + it("renders the primary series and a year-interval booster with the source", () => { + // tetanus: 3 doses, 120-month (10-year) booster, STIKO source. + const html = render("tetanus"); + expect(html).toContain("Primary series: 3 doses"); + expect(html).toContain("Booster typically every 10 years"); + expect(html).toContain("Source: STIKO Epid Bull 4/2026"); + }); + + it("renders a yearly vaccine as yearly, not as an interval", () => { + // influenza: no primary series, 12-month interval → yearly. + const html = render("influenza"); + expect(html).toContain("Yearly vaccination"); + expect(html).not.toContain("every 1 year"); + // No primary-series line when the count is null. + expect(html).not.toContain("Primary series"); + }); + + it("renders nothing for a dead slug", () => { + expect(render("no-such-antigen")).toBe(""); + expect(catalogInfoAvailable("no-such-antigen")).toBe(false); + }); + + it("marks a standard60 entry available", () => { + // pneumococcal: 1 dose, no interval, standard60. + expect(catalogInfoAvailable("pneumococcal")).toBe(true); + const html = render("pneumococcal"); + expect(html).toContain("Standard vaccination from age 60"); + }); +}); diff --git a/src/components/vaccinations/__tests__/vaccination-list.test.tsx b/src/components/vaccinations/__tests__/vaccination-list.test.tsx index 1c466c84e..031b499b7 100644 --- a/src/components/vaccinations/__tests__/vaccination-list.test.tsx +++ b/src/components/vaccinations/__tests__/vaccination-list.test.tsx @@ -125,7 +125,11 @@ describe("VaccinationList grouping", () => { 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\""); + 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/catalog-info.tsx b/src/components/vaccinations/catalog-info.tsx new file mode 100644 index 000000000..49a434a63 --- /dev/null +++ b/src/components/vaccinations/catalog-info.tsx @@ -0,0 +1,99 @@ +"use client"; + +/** + * Rung 1: the catalogue's own knowledge, rendered with its citation. + * + * This is information reproduction, never a personal recommendation. The + * sentences are composed from the seed's `typicalSeriesDoses`, + * `boosterIntervalMonths` and `category` over a handful of i18n templates — + * not hand-written per entry — so six locales carry ~6 templates rather than + * ~35 prose blocks. Every sentence is population-level and impersonal by + * construction: it states what the cited schedule says, with no "you should" + * and no conditioning on age, sex or history. That conditioning would be rung + * 3, and rung 3 is a different venture. + * + * The per-entry `source` renders verbatim as the footnote — a citation is a + * proper noun, like a unit, and is not translated. + */ +import { useTranslations } from "@/lib/i18n/context"; +import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; + +type Translate = ReturnType["t"]; +type TranslateCount = ReturnType["tCount"]; + +/** The impersonal sentences that apply to this entry, in reading order. */ +function infoSentences( + slug: string | null, + t: Translate, + tCount: TranslateCount, +): { lines: string[]; source: string } | null { + const entry = resolveCatalogEntry(slug); + if (!entry) return null; + + const lines: string[] = []; + + if (entry.typicalSeriesDoses !== null) { + lines.push( + tCount("vaccinations.info.primarySeries", entry.typicalSeriesDoses, { + count: entry.typicalSeriesDoses, + }), + ); + } + + if (entry.boosterIntervalMonths !== null) { + const months = entry.boosterIntervalMonths; + if (months === 12) { + lines.push(t("vaccinations.info.yearly")); + } else if (months % 12 === 0) { + const years = months / 12; + lines.push( + tCount("vaccinations.info.boosterYears", years, { count: years }), + ); + } else { + lines.push( + tCount("vaccinations.info.boosterMonths", months, { count: months }), + ); + } + } + + if (entry.category === "standard60") { + lines.push(t("vaccinations.info.standard60")); + } + + if (lines.length === 0) return null; + return { lines, source: entry.source }; +} + +/** + * Whether {@link CatalogInfo} would render anything for this slug — so a + * caller can decide to mount an info affordance at all, rather than opening an + * empty popover for an entry the templates say nothing about. + */ +export function catalogInfoAvailable(slug: string | null): boolean { + const entry = resolveCatalogEntry(slug); + if (!entry) return false; + return ( + entry.typicalSeriesDoses !== null || + entry.boosterIntervalMonths !== null || + entry.category === "standard60" + ); +} + +export function CatalogInfo({ slug }: { slug: string | null }) { + const { t, tCount } = useTranslations(); + const info = infoSentences(slug, t, tCount); + if (!info) return null; + + return ( +
+
    + {info.lines.map((line, index) => ( +
  • {line}
  • + ))} +
+

+ {t("vaccinations.info.sourceLabel", { source: info.source })} +

+
+ ); +} diff --git a/src/components/vaccinations/catalog-picker.tsx b/src/components/vaccinations/catalog-picker.tsx index d330fa889..c7725b30c 100644 --- a/src/components/vaccinations/catalog-picker.tsx +++ b/src/components/vaccinations/catalog-picker.tsx @@ -19,13 +19,18 @@ import { Check, ChevronsUpDown, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { VACCINE_CATALOG, type VaccineSeed, } from "@/lib/vaccinations/vaccine-catalog"; +import { CatalogInfo } from "./catalog-info"; export function CatalogPicker({ value, @@ -49,7 +54,9 @@ export function CatalogPicker({ if (!needle) return true; if (name(entry.slug).toLowerCase().includes(needle)) return true; if (entry.slug.includes(needle)) return true; - return (entry.synonyms ?? []).some((s) => s.toLowerCase().includes(needle)); + return (entry.synonyms ?? []).some((s) => + s.toLowerCase().includes(needle), + ); }); return scored; // `name` closes over `t`, which is stable per locale; term drives the recompute. @@ -61,83 +68,93 @@ export function CatalogPicker({ : undefined; return ( -
- - +
+
+ + + + + + setTerm(event.target.value)} + placeholder={t("vaccinations.form.catalogSearch")} + aria-label={t("vaccinations.form.catalogSearch")} + data-slot="vaccination-catalog-search" + /> +
+ {matches.length === 0 ? ( +

+ {t("vaccinations.form.catalogNoMatch")} +

+ ) : ( + matches.map((entry) => ( + + )) + )} +
+
+
+ + {selected ? ( - - - setTerm(event.target.value)} - placeholder={t("vaccinations.form.catalogSearch")} - aria-label={t("vaccinations.form.catalogSearch")} - data-slot="vaccination-catalog-search" - /> -
- {matches.length === 0 ? ( -

- {t("vaccinations.form.catalogNoMatch")} -

- ) : ( - matches.map((entry) => ( - - )) - )} -
-
- + ) : null} +
- {selected ? ( - - ) : null} + {/* Rung 1: the catalogue's sourced sentences for the chosen entry. */} + {selected ? : null}
); } diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 111f238b5..d83422355 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -16,8 +16,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; -import { invalidateKeys, queryKeys, vaccinationDependentKeys } from "@/lib/query-keys"; -import type { VaccinationDTO, VaccinationListDTO } from "@/lib/vaccinations/dto"; +import { + invalidateKeys, + queryKeys, + vaccinationDependentKeys, +} from "@/lib/query-keys"; +import type { + VaccinationDTO, + VaccinationListDTO, +} from "@/lib/vaccinations/dto"; export type Vaccination = VaccinationDTO; @@ -48,10 +55,7 @@ const BASE = "/api/vaccinations"; * 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, -) { +export function useVaccinations(antigenSlug?: string | null, enabled = true) { return useQuery({ queryKey: queryKeys.vaccinationList(antigenSlug ?? null), enabled, diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index e2853811a..df1880a4e 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -124,7 +124,9 @@ function toInt(value: string): number | null { } /** The body a draft becomes, empties turned into absences. Null when unsavable. */ -export function draftToBody(draft: VaccinationDraft): VaccinationWriteBody | null { +export function draftToBody( + draft: VaccinationDraft, +): VaccinationWriteBody | null { const occurredAt = draftInstant(draft.occurredAt); if (!occurredAt) return null; if (!draftHasIdentity(draft)) return null; @@ -250,7 +252,10 @@ export function VaccinationForm({ /> - + - +