mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
ff72917833
commit
f540298521
15 files changed
+384
-90
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
Reference in new issue
Block a user