Files
HealthLog/src/lib/analytics/window-confidence.ts
T
Marc-André Bombeck 3bb130d4a1 fix(validation): stop a future timestamp emitting a spurious past-bound error
Review follow-up to the shared entry-instant guard. The past-floor refine
re-ran the full plausibility predicate, so a future-dated instant tripped
both refines and the 422 carried a nonsensical "too far in the past" message
alongside the correct future one. The floor refine now checks the past bound
alone; a future value emits only the future message, matching the pre-refactor
single-message contract.

Also harden the Health-Score BP inputs and de-duplicate the tile-window
derivation surfaced in the same review:

- the prior-week graded score now keys off the prior-week window's own
  sufficiency rather than the current window's, so the week-over-week graded
  delta never pairs a confident current value against a thin-sample prior one;
- the BD-Zielbereich 90-day count + effective span derive through one shared
  `deriveBpWindow90` helper, so the dashboard snapshot and the analytics thick
  slice can never compute the gate or the label span differently.
2026-06-14 13:59:56 +02:00

114 lines
4.5 KiB
TypeScript

/**
* Shared window-confidence helper (v1.17 W1b).
*
* Several insight tiles narrate a metric over a fixed trailing window —
* the BD-Zielbereich tile reports the BP in-target share over the last
* 90 days, and future tiles (glucose TIR / GMI, sleep efficiency, …)
* will do the same over their own windows. A static "· 90 T" label is
* dishonest for a user who has only a few weeks of history or only a
* handful of readings: the headline reads like a stable quarter-long
* average when it is really a thin sample.
*
* This helper turns a window length + the readings observed inside it
* into two truthful signals every windowed tile can reuse:
*
* 1. `effectiveSpanDays` — the real calendar span of the data, capped
* at the window. A user with 23 days of history sees "· 23 T"; once
* ~90 days of history exist the span saturates at the window and the
* label reads "· 90 T". `null` when the window holds no readings.
*
* 2. `sufficient` — `false` when fewer than `MIN_READINGS_FOR_CONFIDENCE`
* readings fall in the window. The caller renders a "collecting data"
* placeholder instead of a confident percentage, and the Health-Score
* pillar treats the metric as absent rather than grading a thin sample.
*
* Pure & deterministic so the unit suite can pin both outputs.
*/
/**
* Minimum readings inside the window before a percentage is trustworthy.
* Below this the tile narrates "collecting data" and the score pillar is
* suppressed. Five is the same floor the trend cards use before they draw
* a first slope — a sample small enough to be noise.
*/
export const MIN_READINGS_FOR_CONFIDENCE = 5;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* `true` once a window holds enough readings to narrate a confident
* percentage. Shared by the server (Health-Score pillar gate) and the
* client (tile placeholder) so both apply the identical floor.
*/
export function isWindowSufficient(readingCount: number): boolean {
return readingCount >= MIN_READINGS_FOR_CONFIDENCE;
}
export interface WindowConfidence {
/**
* Real calendar span of the in-window data, in days, capped at
* `windowDays`. `null` when the window holds no readings (caller renders
* the "—" / placeholder rather than "· 0 T").
*/
effectiveSpanDays: number | null;
/** `true` once at least `MIN_READINGS_FOR_CONFIDENCE` readings exist. */
sufficient: boolean;
}
/**
* v1.17 W1b — derive the BD-Zielbereich tile's confidence inputs (90-day
* pair count + effective label span) from a BP envelope's 90-day window.
* Shared by the dashboard snapshot and the `/api/analytics` thick slice so
* the two surfaces can never compute the tile's gate or span differently.
*/
export function deriveBpWindow90(
last90: { pairs: number } | null,
last90EarliestAt: Date | null,
now: Date,
): { count: number; spanDays: number | null } {
const count = last90?.pairs ?? 0;
const spanDays = computeWindowConfidence({
windowDays: 90,
readingCount: count,
earliestReadingAt: last90EarliestAt,
now,
}).effectiveSpanDays;
return { count, spanDays };
}
/**
* Compute the effective label span + sufficiency flag for a windowed
* metric.
*
* @param windowDays the configured trailing window (e.g. 90).
* @param readingCount number of readings counted inside the window.
* @param earliestReadingAt timestamp of the oldest in-window reading, or
* `null` when the window is empty. The span is
* `now - earliest`, ceil-rounded to whole days,
* and never exceeds `windowDays`. At least 1 day
* so a same-day-only sample still reads "· 1 T".
* @param now clock anchor (injectable for deterministic tests).
*/
export function computeWindowConfidence(input: {
windowDays: number;
readingCount: number;
earliestReadingAt: Date | null;
now?: Date;
}): WindowConfidence {
const { windowDays, readingCount, earliestReadingAt } = input;
const now = input.now ?? new Date();
const sufficient = isWindowSufficient(readingCount);
if (readingCount <= 0 || earliestReadingAt === null) {
return { effectiveSpanDays: null, sufficient };
}
const rawSpanMs = now.getTime() - earliestReadingAt.getTime();
// A reading logged moments ago still spans one calendar day of history.
const rawSpanDays = Math.max(1, Math.ceil(rawSpanMs / DAY_MS));
const effectiveSpanDays = Math.min(rawSpanDays, windowDays);
return { effectiveSpanDays, sufficient };
}