Files
HealthLog/src/lib/vaccinations/series.ts
T
Marc-André Bombeck e44b1615e1 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
2026-08-08 14:00:39 +02:00

146 lines
6.1 KiB
TypeScript

/**
* "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) ?? [];
}