From e44b1615e1845ba10d05a782781dec2f9c8adba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:00:39 +0200 Subject: [PATCH] feat(vaccinations): derive the series position per component antigen - one pure function resolves "dose N of M" over a person's whole history, so the number rides the DTO already computed and no client re-derives it - a combined dose counts into every antigen it contains, at whatever position each of those series is at: one Tdap line is a tetanus booster and a first pertussis dose in the same act - the Pass's own dose number wins over the count, and the record's own series length wins over the catalogue's - a seasonal dose has no series to be part of, so nothing reads as a booster on a length it never had - interval adjudication is deliberately absent; this is arithmetic on what a person wrote down --- src/lib/vaccinations/__tests__/series.test.ts | 263 ++++++++++++++++++ src/lib/vaccinations/series.ts | 145 ++++++++++ src/lib/vaccinations/vaccine-catalog.ts | 6 +- 3 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 src/lib/vaccinations/__tests__/series.test.ts create mode 100644 src/lib/vaccinations/series.ts diff --git a/src/lib/vaccinations/__tests__/series.test.ts b/src/lib/vaccinations/__tests__/series.test.ts new file mode 100644 index 000000000..fc5d0938a --- /dev/null +++ b/src/lib/vaccinations/__tests__/series.test.ts @@ -0,0 +1,263 @@ +/** + * The series derivation, case by case. + * + * Every fixture date is relative to now. A fixed calendar date in a test about + * ordering rots the moment the window it sat inside slides past it, and this + * repository has shipped that defect before: the dates here are "eleven years + * ago" and "last month", never "2024-03-01". + */ +import { describe, expect, it } from "vitest"; + +import { + deriveSeries, + seriesForRecord, + type SeriesInputRecord, +} from "@/lib/vaccinations/series"; + +const MONTH_MS = 30 * 24 * 60 * 60 * 1000; + +/** `monthsAgo(132)` — eleven years back from whenever the suite runs. */ +function monthsAgo(months: number): Date { + return new Date(Date.now() - months * MONTH_MS); +} + +function record( + id: string, + monthsBack: number, + antigenSlug: string | null, + extra: Partial = {}, +): SeriesInputRecord { + return { + id, + occurredAt: monthsAgo(monthsBack), + antigenSlug, + doseNumber: null, + seriesDoses: null, + ...extra, + }; +} + +describe("a monovalent dose counts into its own series", () => { + it("places three tetanus doses at positions one, two and three", () => { + const history = [ + record("t1", 300, "tetanus"), + record("t2", 296, "tetanus"), + record("t3", 288, "tetanus"), + ]; + const series = deriveSeries(history); + expect(series.get("t1")).toEqual([ + { antigen: "tetanus", position: 1, total: 3, booster: false }, + ]); + expect(series.get("t2")).toEqual([ + { antigen: "tetanus", position: 2, total: 3, booster: false }, + ]); + expect(series.get("t3")).toEqual([ + { antigen: "tetanus", position: 3, total: 3, booster: false }, + ]); + }); + + it("orders by date rather than by the order the query returned them", () => { + // Deliberately handed to the function newest-first. + const series = deriveSeries([ + record("newest", 12, "tetanus"), + record("oldest", 240, "tetanus"), + ]); + expect(series.get("oldest")?.[0].position).toBe(1); + expect(series.get("newest")?.[0].position).toBe(2); + }); +}); + +describe("a combination counts into every component series", () => { + it("puts one Tdap dose at three different positions at once", () => { + // The case the whole component axis exists for: two plain Td doses first, + // so tetanus and diphtheria are already at two while pertussis is at none. + const history = [ + record("td-1", 240, "td"), + record("td-2", 120, "td"), + record("tdap", 6, "tdap"), + ]; + const series = deriveSeries(history); + expect(series.get("tdap")).toEqual([ + { antigen: "tetanus", position: 3, total: 3, booster: false }, + { antigen: "diphtheria", position: 3, total: 3, booster: false }, + { antigen: "pertussis", position: 1, total: 3, booster: false }, + ]); + }); + + it("counts a six-antigen infant dose into all six", () => { + const series = deriveSeries([record("hex", 400, "hexavalent")]); + expect(series.get("hex")?.map((entry) => entry.antigen)).toEqual([ + "diphtheria", + "tetanus", + "pertussis", + "polio", + "hib", + "hepatitis-b", + ]); + expect( + series.get("hex")?.every((entry) => entry.position === 1), + "the first dose of a combination is the first of each of its series", + ).toBe(true); + }); + + it("lets a monovalent dose continue a series a combination started", () => { + const series = deriveSeries([ + record("mmr", 400, "mmr"), + record("measles-only", 396, "measles"), + ]); + expect(series.get("measles-only")).toEqual([ + { antigen: "measles", position: 2, total: 2, booster: false }, + ]); + }); +}); + +describe("position and total prefer what the record itself says", () => { + it("takes the Pass's own dose number over the chronological count", () => { + // Someone transcribing only the last line of a page they never entered + // the rest of: they are right about their history, the count is not. + const series = deriveSeries([ + record("only-entry", 6, "hepatitis-b", { doseNumber: 3 }), + ]); + expect(series.get("only-entry")).toEqual([ + { antigen: "hepatitis-b", position: 3, total: 3, booster: false }, + ]); + }); + + it("takes the record's own series length over the catalogue's", () => { + // HPV is two doses when the course starts early and three when it starts + // later; the override is for the person whose Pass says three. + const series = deriveSeries([ + record("hpv-1", 24, "hpv", { seriesDoses: 3 }), + record("hpv-2", 22, "hpv", { seriesDoses: 3 }), + record("hpv-3", 18, "hpv", { seriesDoses: 3 }), + ]); + expect(series.get("hpv-3")).toEqual([ + { antigen: "hpv", position: 3, total: 3, booster: false }, + ]); + }); + + it("reports a null total for a seasonal dose that is not part of a series", () => { + // Influenza has no series length, so there is no "of M" to report and + // nothing to be past the end of. Five annual doses are five first doses + // of five different years, never four boosters. + const series = deriveSeries([ + record("flu-1", 39, "influenza"), + record("flu-2", 27, "influenza"), + record("flu-3", 15, "influenza"), + record("flu-4", 3, "influenza"), + ]); + expect(series.get("flu-4")).toEqual([ + { antigen: "influenza", position: 4, total: null, booster: false }, + ]); + expect( + [1, 2, 3, 4].every((n) => series.get(`flu-${n}`)?.[0].booster === false), + "a null total means nothing can be past the end of it", + ).toBe(true); + }); +}); + +describe("a dose past the end of its series reads as a booster", () => { + it("marks the fourth hepatitis B dose after a completed three-dose primary", () => { + const series = deriveSeries([ + record("hb-1", 300, "hepatitis-b"), + record("hb-2", 299, "hepatitis-b"), + record("hb-3", 294, "hepatitis-b"), + record("hb-later", 60, "hepatitis-b"), + ]); + expect(series.get("hb-3")?.[0].booster).toBe(false); + expect(series.get("hb-later")).toEqual([ + { antigen: "hepatitis-b", position: 4, total: 3, booster: true }, + ]); + }); + + it("marks every tetanus dose after the primary series, not just the first", () => { + const series = deriveSeries([ + record("t1", 480, "tetanus"), + record("t2", 476, "tetanus"), + record("t3", 468, "tetanus"), + record("t4", 348, "tetanus"), + record("t5", 228, "tetanus"), + record("t6", 108, "tetanus"), + ]); + expect(["t4", "t5", "t6"].map((id) => series.get(id)?.[0].booster)).toEqual( + [true, true, true], + ); + }); + + it("marks a booster per component, so a combination can be both at once", () => { + // Tetanus is long finished; pertussis has never been given. One Tdap dose + // is a tetanus booster and a first pertussis dose in the same act. + const history = [ + record("t1", 480, "tetanus"), + record("t2", 476, "tetanus"), + record("t3", 468, "tetanus"), + record("tdap", 2, "tdap"), + ]; + const series = deriveSeries(history); + const tdap = series.get("tdap") ?? []; + expect(tdap.find((e) => e.antigen === "tetanus")).toEqual({ + antigen: "tetanus", + position: 4, + total: 3, + booster: true, + }); + expect(tdap.find((e) => e.antigen === "pertussis")).toEqual({ + antigen: "pertussis", + position: 1, + total: 3, + booster: false, + }); + }); +}); + +describe("a record with no resolvable antigen yields nothing", () => { + it("returns an empty array for a free-text-only row", () => { + const series = deriveSeries([record("free-text", 12, null)]); + expect( + series.get("free-text"), + "a name string is not evidence about which antigens were given", + ).toEqual([]); + }); + + it("returns an empty array for a slug the catalogue no longer lists", () => { + const series = deriveSeries([record("orphan", 400, "retired-slug")]); + expect(series.get("orphan")).toEqual([]); + }); + + it("keeps a key for every input, resolvable or not", () => { + // Total in the mathematical sense: a caller reading a missing key as "no + // series" and one reading it as "not computed" would disagree about the + // same dose. + const history = [ + record("a", 12, "tetanus"), + record("b", 6, null), + record("c", 3, "retired-slug"), + ]; + const series = deriveSeries(history); + expect([...series.keys()].sort()).toEqual(["a", "b", "c"]); + }); + + it("does not let an unresolvable row shift the positions around it", () => { + const series = deriveSeries([ + record("t1", 300, "tetanus"), + record("free", 200, null), + record("t2", 100, "tetanus"), + ]); + expect(series.get("t2")?.[0].position).toBe(2); + }); +}); + +describe("the single-record helper agrees with the whole-set derivation", () => { + it("returns the same entry the full derivation would", () => { + const history = [record("td-1", 240, "td"), record("tdap", 6, "tdap")]; + expect(seriesForRecord("tdap", history)).toEqual( + deriveSeries(history).get("tdap"), + ); + }); + + it("returns an empty array for a record the history does not contain", () => { + expect( + seriesForRecord("not-in-history", [record("t1", 12, "tetanus")]), + ).toEqual([]); + }); +}); diff --git a/src/lib/vaccinations/series.ts b/src/lib/vaccinations/series.ts new file mode 100644 index 000000000..d1749030b --- /dev/null +++ b/src/lib/vaccinations/series.ts @@ -0,0 +1,145 @@ +/** + * "Dose 3 of 3", derived once, on the server, per component antigen. + * + * The number beside a dose is not a property of that dose. It is a property of + * the person's whole history for the antigen in question, which means it can + * only be computed by something that can see all of it — and it must be + * computed in exactly one place, or the web client, the native client and the + * doctor report each answer the question slightly differently. So this + * function resolves it once and the result rides the DTO as numbers. No client + * re-derives, and no localised string crosses the wire: six locales must not + * ride the API. + * + * ── The component axis ───────────────────────────────────────────────────── + * + * A combined dose counts into EVERY antigen series it contains, at whatever + * position each of those series happens to be at. A history of Td, Td, Tdap + * puts that last dose at tetanus position 3 and pertussis position 1, and both + * are true at once. Grouping by the record's own slug instead of by its + * components is the failure mode this whole design exists to avoid: it would + * report an adult with three tetanus doses as having had one. + * + * ── What it deliberately does not do ─────────────────────────────────────── + * + * It does not adjudicate a schedule. Whether dose 2 came at the right interval + * after dose 1, whether a gap invalidates a series, whether a person is due + * anything at all — none of that is here, and none of it is anywhere in this + * module. Unsolicited due-computation from age, sex and history is where the + * medical-device boundary starts, and it is a separate venture's territory. + * What this function reports is arithmetic on what a person wrote down. + */ +import { + componentsForSlug, + resolveCatalogEntry, + type AntigenSlug, +} from "@/lib/vaccinations/vaccine-catalog"; + +/** One antigen's view of one dose. */ +export interface SeriesPosition { + /** The component antigen this entry speaks for. */ + antigen: AntigenSlug; + /** + * Where this dose sits in that antigen's series. Prefers the number the + * person transcribed from their Pass; falls back to the chronological index + * among their own doses for the antigen. + */ + position: number; + /** + * How long the series is: the record's own override first, then the + * catalogue's typical value, then null when neither knows. Null is honest — + * a seasonal vaccine has no "of M". + */ + total: number | null; + /** + * True once the series is complete and this dose comes after it. The client + * renders "booster" rather than a position out of a total it has passed. + */ + booster: boolean; +} + +/** The minimum a record has to carry to be placed in a series. */ +export interface SeriesInputRecord { + id: string; + occurredAt: Date; + antigenSlug: string | null; + doseNumber: number | null; + seriesDoses: number | null; +} + +/** + * Resolve every record's series positions, per component antigen. + * + * Takes the whole live set rather than one record because the answer for any + * single dose depends on the ones around it. Returns a map keyed by record id + * so a caller can attach the result to a DTO without re-scanning. + * + * Total, in the mathematical sense: every input id is a key in the output, and + * a record with no resolvable antigen maps to an empty array rather than being + * absent. A caller that reads a missing key as "no series" and a caller that + * reads it as "not computed yet" would disagree about the same dose. + */ +export function deriveSeries( + records: readonly SeriesInputRecord[], +): Map { + const result = new Map(); + for (const record of records) result.set(record.id, []); + + // Oldest first: a position is a count of what came before it, and ties break + // on id so two doses recorded for the same day are ordered deterministically + // rather than by whatever the query happened to return. + const chronological = [...records].sort((a, b) => { + const byDate = a.occurredAt.getTime() - b.occurredAt.getTime(); + return byDate !== 0 ? byDate : a.id.localeCompare(b.id); + }); + + /** How many doses of each antigen have been seen so far in the walk. */ + const seen = new Map(); + + for (const record of chronological) { + // A free-text-only record — and a record whose slug the catalogue no + // longer resolves — yields nothing. Guessing an antigen from a name string + // is how a booster gets cleared by a dose that never contained it. + const components = componentsForSlug(record.antigenSlug); + if (components.length === 0) continue; + + const catalogTotal = + resolveCatalogEntry(record.antigenSlug)?.typicalSeriesDoses ?? null; + // The record's own override wins: it is what the person's actual schedule + // was, and the catalogue only knows the typical one. + const total = record.seriesDoses ?? catalogTotal; + + const positions: SeriesPosition[] = []; + for (const antigen of components) { + const index = (seen.get(antigen) ?? 0) + 1; + seen.set(antigen, index); + // The Pass's own claim wins over the count. Someone transcribing + // "3. Impfung" from a page whose earlier lines they never entered is + // right about their own history and this arithmetic is not. + const position = record.doseNumber ?? index; + positions.push({ + antigen, + position, + total, + booster: total !== null && position > total, + }); + } + result.set(record.id, positions); + } + + return result; +} + +/** + * The same answer for one record, against a history. + * + * A convenience over {@link deriveSeries} for the detail route, which holds + * one record and needs the set it belongs to anyway. Returns an empty array + * for a record the history does not contain, rather than deriving a lonely + * position that would contradict the list. + */ +export function seriesForRecord( + recordId: string, + history: readonly SeriesInputRecord[], +): SeriesPosition[] { + return deriveSeries(history).get(recordId) ?? []; +} diff --git a/src/lib/vaccinations/vaccine-catalog.ts b/src/lib/vaccinations/vaccine-catalog.ts index d10022d53..a97e98d84 100644 --- a/src/lib/vaccinations/vaccine-catalog.ts +++ b/src/lib/vaccinations/vaccine-catalog.ts @@ -195,11 +195,13 @@ export const VACCINE_CATALOG: readonly VaccineSeed[] = [ // ── Monovalent: added in the later decades ────────────────────────────── { - // Seasonal: one dose per year, and the series concept does not apply. + // Seasonal: one dose per year against that year's strains, so there is no + // series to be part of. Null rather than 1 — every autumn's dose would + // otherwise read as a booster on a one-dose series it had already passed. slug: "influenza", atc: "J07BB02", components: ["influenza"], - typicalSeriesDoses: 1, + typicalSeriesDoses: null, boosterIntervalMonths: 12, category: "standard60", source: STIKO,