mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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
This commit is contained in:
1 parent
eefc151617
commit
1d07204293
11 files changed
+495
-12
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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<VaccinationDTO> & { 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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<VaccinationList records={records} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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\"");
|
||||
});
|
||||
});
|
||||
@@ -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<VaccinationListDTO>(
|
||||
antigenSlug
|
||||
? `/api/vaccinations?antigenSlug=${encodeURIComponent(antigenSlug)}`
|
||||
: "/api/vaccinations",
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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<typeof useTranslations>["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<string, AntigenGroup>();
|
||||
|
||||
const ensure = (
|
||||
key: string,
|
||||
seed: Omit<AntigenGroup, "rows">,
|
||||
): 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 (
|
||||
<Card
|
||||
className="gap-0"
|
||||
data-slot="vaccination-row"
|
||||
data-vaccination-id={record.id}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<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">
|
||||
{series}
|
||||
</span>
|
||||
) : null}
|
||||
{showIdentity && identity ? (
|
||||
<span className="text-muted-foreground">· {identity}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
|
||||
<span>{format.date(record.occurredAt)}</span>
|
||||
{record.lotNumber ? (
|
||||
<span data-slot="vaccination-lot">
|
||||
{t("vaccinations.row.lot", { lot: record.lotNumber })}
|
||||
</span>
|
||||
) : null}
|
||||
{record.practitioner?.name ? (
|
||||
<span>{record.practitioner.name}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function VaccinationList({ records }: { records: readonly Vaccination[] }) {
|
||||
const { t } = useTranslations();
|
||||
const groups = groupByAntigen(records);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => (
|
||||
<section
|
||||
key={group.key}
|
||||
className="space-y-2"
|
||||
data-slot="vaccination-group"
|
||||
data-antigen={group.antigen ?? "free"}
|
||||
>
|
||||
<h2 className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
|
||||
<Syringe className="size-3.5" aria-hidden />
|
||||
{group.antigen
|
||||
? t(`vaccinations.catalog.${group.antigen}`)
|
||||
: group.freeName}
|
||||
<span className="normal-case">({group.rows.length})</span>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{group.rows.map((row, index) => (
|
||||
<DoseRow key={`${row.record.id}:${index}`} row={row} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={<span data-tour-id="vaccinations-hero">{t("vaccinations.title")}</span>}
|
||||
title={
|
||||
<span data-tour-id="vaccinations-hero">{t("vaccinations.title")}</span>
|
||||
}
|
||||
description={t("vaccinations.subtitle")}
|
||||
/>
|
||||
|
||||
<EmptyState
|
||||
icon={<Syringe className="size-6" />}
|
||||
title={t("vaccinations.empty.title")}
|
||||
description={t("vaccinations.empty.description")}
|
||||
action={
|
||||
actions={
|
||||
<Button className="min-h-11 sm:min-h-9" disabled>
|
||||
<Plus className="size-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
// A read failure is not an empty Impfpass — surface the error + Retry so
|
||||
// an outage never reads as "nothing logged yet".
|
||||
<QueryErrorCard
|
||||
title={t("vaccinations.listLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : records.length > 0 ? (
|
||||
<VaccinationList records={records} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Syringe className="size-6" />}
|
||||
title={t("vaccinations.empty.title")}
|
||||
description={t("vaccinations.empty.description")}
|
||||
action={
|
||||
<Button className="min-h-11 sm:min-h-9" disabled>
|
||||
<Plus className="size-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user