mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) ?? [];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user