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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 20:45:46 +02:00
1 parent ff72917833
commit f540298521
15 files changed
+384 -90

No files matched your search

+15
View File
@@ -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."
+15
View File
@@ -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."
+15
View File
@@ -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."
+15
View File
@@ -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."
+15
View File
@@ -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."
+15
View File
@@ -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."
@@ -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(
<I18nProvider initialLocale="en">
<CatalogInfo slug={slug} />
</I18nProvider>,
);
}
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");
});
});
@@ -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"',
);
});
});
@@ -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<typeof useTranslations>["t"];
type TranslateCount = ReturnType<typeof useTranslations>["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 (
<div className="space-y-1" data-slot="vaccination-catalog-info">
<ul className="text-foreground space-y-0.5 text-sm">
{info.lines.map((line, index) => (
<li key={index}>{line}</li>
))}
</ul>
<p className="text-muted-foreground text-xs">
{t("vaccinations.info.sourceLabel", { source: info.source })}
</p>
</div>
);
}
+91 -74
View File
@@ -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 (
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
data-slot="vaccination-catalog-trigger"
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
>
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
)}
>
{selected
? name(selected.slug)
: t("vaccinations.form.catalogNone")}
</span>
<ChevronsUpDown
className="size-4 shrink-0 opacity-50"
aria-hidden
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
<Input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={t("vaccinations.form.catalogSearch")}
aria-label={t("vaccinations.form.catalogSearch")}
data-slot="vaccination-catalog-search"
/>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
{matches.length === 0 ? (
<p className="text-muted-foreground px-2 py-3 text-sm">
{t("vaccinations.form.catalogNoMatch")}
</p>
) : (
matches.map((entry) => (
<button
key={entry.slug}
type="button"
data-slot="vaccination-catalog-option"
onClick={() => {
onChange(entry.slug);
setOpen(false);
}}
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
>
<Check
className={cn(
"size-4 shrink-0",
entry.slug === value ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="text-foreground min-w-0 flex-1 truncate">
{name(entry.slug)}
</span>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
{selected ? (
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
data-slot="vaccination-catalog-trigger"
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
variant="ghost"
size="icon"
className="size-11 shrink-0"
aria-label={t("vaccinations.form.catalogClear")}
onClick={() => onChange(null)}
>
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
)}
>
{selected ? name(selected.slug) : t("vaccinations.form.catalogNone")}
</span>
<ChevronsUpDown className="size-4 shrink-0 opacity-50" aria-hidden />
<X className="size-4" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
<Input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={t("vaccinations.form.catalogSearch")}
aria-label={t("vaccinations.form.catalogSearch")}
data-slot="vaccination-catalog-search"
/>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
{matches.length === 0 ? (
<p className="text-muted-foreground px-2 py-3 text-sm">
{t("vaccinations.form.catalogNoMatch")}
</p>
) : (
matches.map((entry) => (
<button
key={entry.slug}
type="button"
data-slot="vaccination-catalog-option"
onClick={() => {
onChange(entry.slug);
setOpen(false);
}}
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
>
<Check
className={cn(
"size-4 shrink-0",
entry.slug === value ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="text-foreground min-w-0 flex-1 truncate">
{name(entry.slug)}
</span>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
) : null}
</div>
{selected ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0"
aria-label={t("vaccinations.form.catalogClear")}
onClick={() => onChange(null)}
>
<X className="size-4" aria-hidden />
</Button>
) : null}
{/* Rung 1: the catalogue's sourced sentences for the chosen entry. */}
{selected ? <CatalogInfo slug={selected.slug} /> : null}
</div>
);
}
@@ -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,
@@ -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({
/>
</FieldGroup>
<FieldGroup htmlFor="vaccination-site" label={t("vaccinations.form.site")}>
<FieldGroup
htmlFor="vaccination-site"
label={t("vaccinations.form.site")}
>
<NativeSelect
id="vaccination-site"
value={draft.site}
@@ -283,7 +288,10 @@ export function VaccinationForm({
slot="vaccination-encounter-suggestion"
/>
<FieldGroup htmlFor="vaccination-note" label={t("vaccinations.form.note")}>
<FieldGroup
htmlFor="vaccination-note"
label={t("vaccinations.form.note")}
>
<Textarea
id="vaccination-note"
rows={2}
@@ -28,9 +28,11 @@
import { Syringe } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { InfoPopover } from "@/components/ui/info-popover";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { SeriesPosition } from "@/lib/vaccinations/series";
import type { Vaccination } from "./use-vaccinations";
import { CatalogInfo, catalogInfoAvailable } from "./catalog-info";
type Translate = ReturnType<typeof useTranslations>["t"];
@@ -57,12 +59,16 @@ interface AntigenGroup {
* 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}`);
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 {
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) {
@@ -170,7 +176,10 @@ function DoseRow({
<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">
<span
className="text-foreground font-medium"
data-slot="vaccination-series"
>
{series}
</span>
) : null}
@@ -220,6 +229,14 @@ export function VaccinationList({
? t(`vaccinations.catalog.${group.antigen}`)
: group.freeName}
<span className="normal-case">({group.rows.length})</span>
{group.antigen && catalogInfoAvailable(group.antigen) ? (
<InfoPopover
label={t("vaccinations.info.affordanceLabel")}
content={<CatalogInfo slug={group.antigen} />}
iconClassName="h-3 w-3"
triggerDataSlot="vaccination-info-trigger"
/>
) : null}
</h2>
<div className="space-y-2">
{group.rows.map((row, index) => (
@@ -125,7 +125,10 @@ export function VaccinationSheet({
) : null}
{isEdit ? (
<AlertDialog open={confirmingDelete} onOpenChange={setConfirmingDelete}>
<AlertDialog
open={confirmingDelete}
onOpenChange={setConfirmingDelete}
>
<AlertDialogTrigger asChild>
<Button
type="button"
@@ -50,7 +50,9 @@ export function VaccinationsView() {
<div className="space-y-6">
<PageHeader
title={
<span data-tour-id="vaccinations-hero">{t("vaccinations.title")}</span>
<span data-tour-id="vaccinations-hero">
{t("vaccinations.title")}
</span>
}
description={t("vaccinations.subtitle")}
actions={