mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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
@@ -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