mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
Reference in new issue
Block a user