feat(vaccinations): seed the antigen-level vaccine catalogue

- 35 entries across the childhood, standard, later-decade, indication
  and travel sets, so a Pass covering a whole life has somewhere to land
- ATC-coded from the WHO J07 subtree, component-aware so one combined
  dose counts into every antigen series it contains, and each entry
  cites the document its numbers come from
- no manufacturer column and no trade name in any shipped string; the
  one place a trade name belongs is the person's own transcription
- the resolver is the only lookup path and answers null rather than
  throwing, so a slug a later release drops still renders as what the
  person wrote
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 13:58:17 +02:00
1 parent 6926caa11d
commit e190c32c77
2 files changed
+768

No files matched your search

@@ -0,0 +1,209 @@
/**
* What the catalogue promises about itself, and the one promise it makes to
* every row that outlives it.
*
* The structural checks below are cheap and mostly hold shapes the type system
* already holds — they exist because the file is edited by hand, entry by
* entry, and a paste that duplicates a slug or points a combination at an
* antigen that no longer exists compiles fine right up until two doses share
* an identity.
*
* The one that matters is the last block: a slug this release does not know
* must degrade to the record's own free text. A record keeps `antigenSlug`
* verbatim, backups restore it without validation, and slugs are append-only
* by convention rather than by constraint — so the day one is dropped or
* renamed, every dose that carried it is still a real dose belonging to a real
* person. The resolver returning `null` is what makes that survivable, and a
* resolver that threw or a renderer that produced an empty label would turn a
* catalogue edit into data loss on screen.
*/
import { describe, expect, it } from "vitest";
import {
ANTIGEN_SLUGS,
VACCINE_CATALOG,
VACCINE_CATALOG_SLUGS,
componentsForSlug,
resolveCatalogEntry,
type AntigenSlug,
} from "@/lib/vaccinations/vaccine-catalog";
const MONOVALENT = new Set<string>(ANTIGEN_SLUGS);
describe("the vaccine catalogue holds its own shape", () => {
it("carries a whole life's worth of entries, not a token list", () => {
// The floor is not decoration: an adult-only catalogue was the rejected
// alternative, and it would ship a module that cannot hold a child's
// Pass. A count that collapses is the symptom.
expect(VACCINE_CATALOG.length).toBeGreaterThanOrEqual(30);
expect(MONOVALENT.size).toBeGreaterThanOrEqual(20);
});
it("gives every entry a unique slug", () => {
const seen = new Map<string, number>();
for (const slug of VACCINE_CATALOG_SLUGS) {
seen.set(slug, (seen.get(slug) ?? 0) + 1);
}
const duplicated = [...seen.entries()]
.filter(([, count]) => count > 1)
.map(([slug]) => slug);
expect(
duplicated,
"two entries under one slug means two doses share an identity",
).toEqual([]);
});
it("offers every monovalent antigen as an entry of its own", () => {
// An older Pass line records a component on its own, so every antigen a
// combination can contain must also be loggable by itself.
const missing = ANTIGEN_SLUGS.filter(
(slug) => !VACCINE_CATALOG_SLUGS.includes(slug),
);
expect(missing).toEqual([]);
});
it("resolves every combination's components to real monovalent antigens", () => {
const foreign: string[] = [];
for (const entry of VACCINE_CATALOG) {
for (const component of entry.components) {
if (!MONOVALENT.has(component))
foreign.push(`${entry.slug}/${component}`);
}
}
expect(
foreign,
"a component that is not an antigen breaks the series axis silently",
).toEqual([]);
});
it("makes every monovalent entry its own single component", () => {
const wrong = VACCINE_CATALOG.filter(
(entry) =>
MONOVALENT.has(entry.slug) &&
(entry.components.length !== 1 || entry.components[0] !== entry.slug),
).map((entry) => entry.slug);
expect(
wrong,
"a monovalent that does not count into its own series counts into none",
).toEqual([]);
});
it("gives every entry an ATC code in the J07 subtree", () => {
const wrong = VACCINE_CATALOG.filter(
(entry) => !/^J07[A-Z]{2}\d{2}$/.test(entry.atc),
).map((entry) => `${entry.slug}: ${entry.atc}`);
expect(wrong).toEqual([]);
});
it("cites a source for every entry's numbers", () => {
// Rendered numbers need a rendered citation. A blank or one-word source
// is how a footnote stops being checkable.
const thin = VACCINE_CATALOG.filter(
(entry) => entry.source.trim().length < 8,
).map((entry) => entry.slug);
expect(thin).toEqual([]);
});
it("keeps the numbers inside the range a schedule can mean", () => {
const wrong: string[] = [];
for (const entry of VACCINE_CATALOG) {
const { typicalSeriesDoses: doses, boosterIntervalMonths: months } =
entry;
if (doses !== null && (doses < 1 || doses > 10)) {
wrong.push(`${entry.slug}: ${doses} doses`);
}
if (months !== null && (months < 1 || months > 240)) {
wrong.push(`${entry.slug}: every ${months} months`);
}
}
expect(wrong).toEqual([]);
});
it("keeps every synonym generic, lowercase and distinct", () => {
// The vendor-name rule cannot be fully held by a matcher — a trade name
// is a proper noun and nothing here knows the register. What a test CAN
// hold is the shape a trade name would have to break: synonyms are
// lowercase generic wording, never blank, never repeated.
const wrong: string[] = [];
const seen = new Set<string>();
for (const entry of VACCINE_CATALOG) {
for (const synonym of entry.synonyms ?? []) {
if (synonym !== synonym.toLowerCase() || synonym.trim().length === 0) {
wrong.push(`${entry.slug}: ${synonym}`);
}
if (seen.has(synonym)) wrong.push(`${entry.slug}: repeats ${synonym}`);
seen.add(synonym);
}
}
expect(wrong).toEqual([]);
});
});
describe("a slug the catalogue no longer knows degrades to the free text", () => {
/**
* A record as the app would hold it after the catalogue moved on: a slug
* written by an older release, and the person's own transcription beside it.
*/
const ORPHANED_RECORD = {
antigenSlug: "an-antigen-this-release-does-not-list",
vaccineName: "Tetanus, as the 1987 Pass wrote it",
};
/**
* What every consumer of the catalogue does with a record: ask the resolver,
* take the catalogue's name when it answers, fall back to the person's own
* wording when it does not. Written here rather than imported so the
* guarantee is checked as a contract on the resolver, which is what every
* caller depends on.
*/
function displayIdentity(record: {
antigenSlug: string | null;
vaccineName: string | null;
}): string {
const entry = resolveCatalogEntry(record.antigenSlug);
return entry ? entry.slug : (record.vaccineName ?? "");
}
it("returns null instead of throwing for a slug it does not have", () => {
expect(() =>
resolveCatalogEntry(ORPHANED_RECORD.antigenSlug),
).not.toThrow();
expect(resolveCatalogEntry(ORPHANED_RECORD.antigenSlug)).toBeNull();
});
it("renders the person's own wording rather than an empty label", () => {
const label = displayIdentity(ORPHANED_RECORD);
expect(
label,
"an unresolvable slug must degrade to the free-text arm; an empty " +
"label turns a catalogue edit into data loss on screen",
).toBe("Tetanus, as the 1987 Pass wrote it");
expect(label.length).toBeGreaterThan(0);
});
it("degrades the same way for a null slug and an empty one", () => {
expect(resolveCatalogEntry(null)).toBeNull();
expect(resolveCatalogEntry(undefined)).toBeNull();
expect(resolveCatalogEntry("")).toBeNull();
expect(
displayIdentity({ antigenSlug: null, vaccineName: "Free text only" }),
).toBe("Free text only");
});
it("matches no antigen at all rather than guessing from the name", () => {
// The consequence that matters: an unresolvable dose must not clear a
// booster reminder. No components means no match.
expect(componentsForSlug(ORPHANED_RECORD.antigenSlug)).toEqual([]);
expect(componentsForSlug(null)).toEqual([]);
});
it("still resolves a slug it does have, so the check above is not vacuous", () => {
const tdap = resolveCatalogEntry("tdap");
expect(tdap).not.toBeNull();
expect(tdap?.components).toEqual<AntigenSlug[]>([
"tetanus",
"diphtheria",
"pertussis",
]);
});
});
+559
View File
@@ -0,0 +1,559 @@
/**
* The antigen-level vaccine catalogue: what a dose can be, in one static list.
*
* This is NOT a DB table, for the same reason `BIOMARKER_CATALOG` is not one
* (`src/lib/labs/biomarker-catalog.ts`): the slug is the stable identity, the
* display name resolves through i18n, the numbers are correctable in a patch
* release rather than a migration, and an account that has never opened the
* picker carries no rows for it.
*
* ── What it deliberately is not ────────────────────────────────────────────
*
* There is no manufacturer column and there is no trade name anywhere in it.
* Entries are named for the antigen or the disease, synonyms are generic or
* colloquial, and the one place a trade name may legitimately appear in this
* feature is the user's own `vaccineName` transcription of their Pass — their
* data, not our content.
*
* The numbers are TYPICAL values for orientation, and `source` names where
* each one comes from so the sentence rendered beside a dose carries its
* citation. They are not an individual schedule: what a particular person
* needs and when depends on their history, their age and their reasons, and
* that adjudication belongs to their doctor. Nothing in this file or in
* anything reading it computes what is due — the module renders what a person
* logged and, at most, offers to set a reminder they confirm themselves.
*
* ── Slugs are append-only ──────────────────────────────────────────────────
*
* A record stores `antigenSlug` verbatim, backups restore it without
* validation, and a slug this file has stopped listing must still describe
* what someone was given. {@link resolveCatalogEntry} is the ONLY lookup path
* and it returns `null` rather than throwing; every consumer answers `null` by
* falling back to the record's own `vaccineName`. That degrade is a tested
* guarantee, not an accident — see
* `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`.
*
* ── Combinations ───────────────────────────────────────────────────────────
*
* `components` is the axis everything downstream turns on. A combination lists
* the monovalent antigens it contains, so one Tdap dose counts into the
* tetanus, the diphtheria AND the pertussis series, and clears a booster
* reminder for any of them. A monovalent lists itself.
*
* ATC codes are the WHO J07 subtree at the most specific level that stays
* product-blind. Two entries may legitimately share a code: ATC classifies by
* antigen combination and does not distinguish a full-strength childhood
* preparation from the reduced-antigen adult one.
*/
/** The monovalent antigens a dose can contain. Combinations reference these. */
export const ANTIGEN_SLUGS = [
"tetanus",
"diphtheria",
"pertussis",
"polio",
"measles",
"mumps",
"rubella",
"varicella",
"hib",
"hepatitis-a",
"hepatitis-b",
"influenza",
"pneumococcal",
"herpes-zoster",
"rsv",
"covid-19",
"fsme",
"hpv",
"meningococcal-acwy",
"meningococcal-b",
"rotavirus",
"typhoid",
"rabies",
"yellow-fever",
"japanese-encephalitis",
"cholera",
] as const;
export type AntigenSlug = (typeof ANTIGEN_SLUGS)[number];
/**
* How the picker groups an entry.
*
* `standard` is the general schedule, `standard60` the additions recommended
* from the later decades, `childhood` what a child's Pass fills up with,
* `indication` what a particular exposure or condition calls for, and `travel`
* what a destination does. A person's own Pass mixes all five, which is why
* the catalogue covers a whole life rather than an adult floor.
*/
export type VaccineCategory =
"standard" | "standard60" | "childhood" | "indication" | "travel";
export interface VaccineSeed {
/** Stable EN identity; `vaccinations.catalog.<slug>` resolves the name. */
slug: string;
/** WHO ATC code (J07 subtree), most specific level that stays product-blind. */
atc: string;
/** Component antigens — self for monovalents. The series-derivation axis. */
components: readonly AntigenSlug[];
/** Typical primary-series length, or null (seasonal / single-dose). */
typicalSeriesDoses: number | null;
/** Typical booster interval in months, or null. 12 = yearly / seasonal. */
boosterIntervalMonths: number | null;
/** Picker grouping. */
category: VaccineCategory;
/**
* The document the numbers on this entry come from, rendered as the
* footnote beside them. A citation is a proper noun, like a unit: literal,
* not translated.
*/
source: string;
/** Generic search aliases only — never trade names. */
synonyms?: readonly string[];
}
/** The German standing-committee schedule, for everything on it. */
const STIKO = "STIKO Epid Bull 4/2026";
export const VACCINE_CATALOG: readonly VaccineSeed[] = [
// ── Monovalent: the general schedule ────────────────────────────────────
{
// Primary series in infancy, then a lifelong ten-year rhythm. The
// ten-year figure is the one number in this file most people already
// know, and the one a reminder is most often set from.
slug: "tetanus",
atc: "J07AM01",
components: ["tetanus"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
synonyms: ["lockjaw"],
},
{
// Travels with tetanus in practice; the schedule and the interval match,
// which is why the combined preparation is the usual one.
slug: "diphtheria",
atc: "J07AF01",
components: ["diphtheria"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
},
{
// No fixed adult rhythm: refreshed once in adulthood, and again in each
// pregnancy, neither of which is an interval. Null rather than a number
// invented to fill the field.
slug: "pertussis",
atc: "J07AJ02",
components: ["pertussis"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "standard",
source: STIKO,
synonyms: ["whooping cough"],
},
{
// Four doses in childhood, one refresher in adolescence. Adults refresh
// only for travel to a region where it still circulates, which is a
// destination question rather than an interval.
slug: "polio",
atc: "J07BF03",
components: ["polio"],
typicalSeriesDoses: 4,
boosterIntervalMonths: null,
category: "standard",
source: STIKO,
synonyms: ["poliomyelitis", "ipv"],
},
{
// Three doses in infancy; no routine adult booster.
slug: "hepatitis-b",
atc: "J07BC01",
components: ["hepatitis-b"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "standard",
source: STIKO,
synonyms: ["hep b"],
},
{
// Two doses, then variant-adapted refreshers whose cadence is a moving
// recommendation rather than a fixed schedule. Twelve months is the
// rhythm the seasonal refresher has settled into.
slug: "covid-19",
atc: "J07BN01",
components: ["covid-19"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 12,
category: "standard",
source: STIKO,
synonyms: ["coronavirus", "sars-cov-2"],
},
// ── Monovalent: added in the later decades ──────────────────────────────
{
// Seasonal: one dose per year, and the series concept does not apply.
slug: "influenza",
atc: "J07BB02",
components: ["influenza"],
typicalSeriesDoses: 1,
boosterIntervalMonths: 12,
category: "standard60",
source: STIKO,
synonyms: ["flu", "seasonal flu"],
},
{
slug: "pneumococcal",
atc: "J07AL02",
components: ["pneumococcal"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "standard60",
source: STIKO,
synonyms: ["pneumonia"],
},
{
// Two doses, two to six months apart; no booster after them.
slug: "herpes-zoster",
atc: "J07BK03",
components: ["herpes-zoster"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "standard60",
source: STIKO,
synonyms: ["shingles", "zoster"],
},
{
slug: "rsv",
atc: "J07BX05",
components: ["rsv"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "standard60",
source: STIKO,
synonyms: ["respiratory syncytial virus"],
},
// ── Monovalent: the childhood Pass ──────────────────────────────────────
{
// Given as part of a combined preparation in practice; the monovalent
// entry exists because an older Pass line may record it on its own.
slug: "measles",
atc: "J07BD01",
components: ["measles"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
},
{
slug: "mumps",
atc: "J07BE01",
components: ["mumps"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
},
{
slug: "rubella",
atc: "J07BJ01",
components: ["rubella"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["german measles"],
},
{
slug: "varicella",
atc: "J07BK01",
components: ["varicella"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["chickenpox"],
},
{
slug: "hib",
atc: "J07AG01",
components: ["hib"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["haemophilus influenzae type b"],
},
{
// Oral, and the series is finished early — the whole course sits inside
// the first months of life.
slug: "rotavirus",
atc: "J07BH01",
components: ["rotavirus"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
},
{
// Two doses when the course starts before fifteen, three when it starts
// later. Two is the typical value; the record's own `seriesDoses`
// override is there for the person whose Pass says three.
slug: "hpv",
atc: "J07BM03",
components: ["hpv"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["human papillomavirus"],
},
// ── Monovalent: by indication ───────────────────────────────────────────
{
// Three doses, then a refresher every three to five years while the
// exposure lasts. Sixty months is the longer end of that window.
slug: "fsme",
atc: "J07BA02",
components: ["fsme"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 60,
category: "indication",
source: STIKO,
synonyms: ["tick-borne encephalitis", "tbe"],
},
{
slug: "hepatitis-a",
atc: "J07BC02",
components: ["hepatitis-a"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "indication",
source: STIKO,
synonyms: ["hep a"],
},
{
slug: "meningococcal-acwy",
atc: "J07AH08",
components: ["meningococcal-acwy"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "indication",
source: STIKO,
synonyms: ["meningococcus acwy", "quadrivalent meningococcal"],
},
{
slug: "meningococcal-b",
atc: "J07AH09",
components: ["meningococcal-b"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "indication",
source: STIKO,
synonyms: ["meningococcus b"],
},
{
// The pre-exposure course. What follows an actual exposure is treatment
// and is not a schedule this catalogue has an opinion about.
slug: "rabies",
atc: "J07BG01",
components: ["rabies"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "indication",
source: STIKO,
synonyms: ["lyssa"],
},
// ── Monovalent: by destination ──────────────────────────────────────────
// Not on the standing-committee schedule, so each cites the WHO position
// paper for its own antigen rather than borrowing a citation from a
// document that does not cover it.
{
slug: "typhoid",
atc: "J07AP03",
components: ["typhoid"],
typicalSeriesDoses: 1,
boosterIntervalMonths: 36,
category: "travel",
source: "WHO position paper on typhoid vaccines",
synonyms: ["typhoid fever"],
},
{
// One dose is treated as lifelong protection; the certificate that used
// to expire after ten years no longer does.
slug: "yellow-fever",
atc: "J07BL01",
components: ["yellow-fever"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "travel",
source: "WHO position paper on yellow fever vaccines",
},
{
slug: "japanese-encephalitis",
atc: "J07BA01",
components: ["japanese-encephalitis"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 12,
category: "travel",
source: "WHO position paper on Japanese encephalitis vaccines",
},
{
// Oral, and the only entry here whose route is not an injection — which
// is why route derives from the antigen at the export boundary rather
// than being asked of the person filling in the form.
slug: "cholera",
atc: "J07AE01",
components: ["cholera"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 24,
category: "travel",
source: "WHO position paper on cholera vaccines",
},
// ── Combinations ────────────────────────────────────────────────────────
{
// The adult refresher most Passes are full of.
slug: "td",
atc: "J07AM51",
components: ["tetanus", "diphtheria"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
synonyms: ["tetanus diphtheria"],
},
{
slug: "tdap",
atc: "J07AJ52",
components: ["tetanus", "diphtheria", "pertussis"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
synonyms: ["tetanus diphtheria pertussis"],
},
{
slug: "tdap-ipv",
atc: "J07CA02",
components: ["tetanus", "diphtheria", "pertussis", "polio"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
},
{
// The childhood preparation. Same ATC code as the adult one above: ATC
// classifies the antigen combination and does not distinguish the
// full-strength form from the reduced-antigen one.
slug: "dtap-ipv",
atc: "J07CA02",
components: ["diphtheria", "tetanus", "pertussis", "polio"],
typicalSeriesDoses: 4,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
},
{
// Six antigens in one injection, which is what makes an infant Pass page
// short and this catalogue's component axis necessary: one line here
// counts into six separate series.
slug: "hexavalent",
atc: "J07CA09",
components: [
"diphtheria",
"tetanus",
"pertussis",
"polio",
"hib",
"hepatitis-b",
],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["six-in-one"],
},
{
slug: "mmr",
atc: "J07BD52",
components: ["measles", "mumps", "rubella"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["measles mumps rubella"],
},
{
slug: "mmrv",
atc: "J07BD54",
components: ["measles", "mumps", "rubella", "varicella"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["measles mumps rubella varicella"],
},
{
slug: "hepa-hepb",
atc: "J07BC20",
components: ["hepatitis-a", "hepatitis-b"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "travel",
source: STIKO,
synonyms: ["hepatitis a and b"],
},
{
slug: "hepa-typhoid",
atc: "J07CA10",
components: ["hepatitis-a", "typhoid"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "travel",
source: "WHO position paper on typhoid vaccines",
},
];
/** Every slug the catalogue currently offers, combinations included. */
export const VACCINE_CATALOG_SLUGS: readonly string[] = VACCINE_CATALOG.map(
(entry) => entry.slug,
);
const BY_SLUG = new Map<string, VaccineSeed>(
VACCINE_CATALOG.map((entry) => [entry.slug, entry]),
);
/**
* The one lookup path into the catalogue.
*
* Returns `null` for anything it does not know — an empty slug, a slug a past
* release wrote and this one no longer lists, a slug restored verbatim from a
* backup written by an older version. It never throws, because every one of
* those is a real row belonging to a real person, and the caller's answer is
* to fall back to the record's own `vaccineName` rather than to fail.
*/
export function resolveCatalogEntry(
slug: string | null | undefined,
): VaccineSeed | null {
if (!slug) return null;
return BY_SLUG.get(slug) ?? null;
}
/**
* The antigens a dose with this slug counts into.
*
* Empty for an unknown slug and for a free-text-only record: a name string is
* not evidence about which antigens were given, and guessing from one is how
* a booster reminder would be cleared by a dose that never contained it.
*/
export function componentsForSlug(
slug: string | null | undefined,
): readonly AntigenSlug[] {
return resolveCatalogEntry(slug)?.components ?? [];
}