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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 14:00:39 +02:00
1 parent e190c32c77
commit e44b1615e1
3 files changed
+412 -2

No files matched your search

@@ -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> = {},
): 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([]);
});
});
+145
View File
@@ -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<string, SeriesPosition[]> {
const result = new Map<string, SeriesPosition[]>();
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<AntigenSlug, number>();
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) ?? [];
}
+4 -2
View File
@@ -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,