mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Add the retrospective correlation engine for illness episodes: a pre-onset
anomaly scan (deviation in robust SDs from the user's own median±MAD
baseline), the nadir / what-dropped across the active span, and the
recovery-gap (physiological return-to-baseline vs the felt-better marker).
CHRONIC_ONGOING is excluded from the gap. Every derived number is gated by
the shared coverage/confidence model — a thin signal asserts nothing rather
than showing a wrong figure.
The baseline window ends strictly before the pre-onset lookback, so an
episode's own days can never contaminate the baseline it is measured against.
Reads are DAY-native (rollup tier with a live-SQL fallback), reusing the
baseline engine's read path so the rollup-vs-live parity carries over.
Surfaces:
- GET /api/illness/episodes/{id}/correlation — per-episode findings (gated
Derived wire shape; red-flag escalation for sustained low SpO2 / fever).
- GET /api/illness/insights — cross-episode retrospective ("sick N times ·
typical recovery gap X days"), with the typical-gap withheld below the
min-sample floor. Retrospective only — never a predictor or diagnoser.
Reliability battery: golden-fixture recovery-gap math, rollup-vs-live parity,
baseline-contamination guard, thin-data / coverage-gate, red-flag escalation.
Born-gated + owner-scoped; server-authoritative DTO (iOS renders). i18n ×6.
99 lines
3.5 KiB
TypeScript
99 lines
3.5 KiB
TypeScript
/**
|
||
* Illness retrospective summary (v1.18.1, Workstream B / P3).
|
||
*
|
||
* The look-back surface: "sick N times this year · typical recovery gap X
|
||
* days". RETROSPECTIVE ONLY — it summarises what already happened. It is
|
||
* NEVER a predictor or diagnoser; the recurrence figure is a count of past
|
||
* episodes, not a forecast, and the copy layer must frame it as such.
|
||
*
|
||
* Pure given a list of resolved episode summaries + their computed gaps. The
|
||
* caller resolves the per-episode recovery-gap (via the correlation engine,
|
||
* which is itself coverage-gated) and passes the numbers in; this layer only
|
||
* aggregates. The aggregate is itself gated — with too few resolved episodes
|
||
* to say anything honest, it reports the count but withholds the "typical
|
||
* gap" (asserts nothing thin).
|
||
*/
|
||
import { median } from "@/lib/insights/derived/baseline";
|
||
|
||
/** Minimum resolved-with-gap episodes before a "typical gap" is asserted. */
|
||
export const MIN_EPISODES_FOR_TYPICAL_GAP = 3;
|
||
|
||
/** One episode's contribution to the summary. */
|
||
export interface RetrospectiveEpisode {
|
||
id: string;
|
||
type: string;
|
||
/** Onset local day `YYYY-MM-DD` (for the recurrence-month tally). */
|
||
onsetDay: string;
|
||
/** Whether the episode is resolved (has a felt-better marker). */
|
||
resolved: boolean;
|
||
/** The computed recovery-gap in days, or null when ungated/unavailable. */
|
||
recoveryGapDays: number | null;
|
||
/** Lifecycle — CHRONIC_ONGOING never contributes a gap. */
|
||
lifecycle: string;
|
||
}
|
||
|
||
export interface IllnessRetrospectiveSummary {
|
||
/** Episodes whose onset falls in the requested window (e.g. trailing year). */
|
||
episodeCount: number;
|
||
/** Of those, how many are resolved. */
|
||
resolvedCount: number;
|
||
/**
|
||
* Typical (median) recovery-gap in days across episodes with a computed
|
||
* gap — null when fewer than `MIN_EPISODES_FOR_TYPICAL_GAP` qualify. A
|
||
* positive gap means the body typically lagged the felt-better marker.
|
||
*/
|
||
typicalRecoveryGapDays: number | null;
|
||
/** How many episodes backed the typical-gap figure (transparency). */
|
||
gapSampleSize: number;
|
||
/**
|
||
* Recurrence tally by calendar month (1–12) → count, retrospective only.
|
||
* Drives "you tend to log illnesses in November" as an OBSERVATION, never
|
||
* a prediction. Empty when nothing falls in the window.
|
||
*/
|
||
byMonth: Record<number, number>;
|
||
/** Per-type episode counts in the window (retrospective breakdown). */
|
||
byType: Record<string, number>;
|
||
}
|
||
|
||
/**
|
||
* Aggregate the per-episode figures into the retrospective summary. Pure. The
|
||
* typical-gap is withheld (null) below the min-sample floor so a single
|
||
* episode never becomes a "typical" claim.
|
||
*/
|
||
export function summarizeIllnessRetrospective(
|
||
episodes: RetrospectiveEpisode[],
|
||
): IllnessRetrospectiveSummary {
|
||
const byMonth: Record<number, number> = {};
|
||
const byType: Record<string, number> = {};
|
||
let resolvedCount = 0;
|
||
const gaps: number[] = [];
|
||
|
||
for (const e of episodes) {
|
||
const month = Number(e.onsetDay.slice(5, 7));
|
||
if (month >= 1 && month <= 12) byMonth[month] = (byMonth[month] ?? 0) + 1;
|
||
byType[e.type] = (byType[e.type] ?? 0) + 1;
|
||
if (e.resolved) resolvedCount++;
|
||
if (
|
||
e.lifecycle !== "CHRONIC_ONGOING" &&
|
||
e.recoveryGapDays !== null &&
|
||
Number.isFinite(e.recoveryGapDays)
|
||
) {
|
||
gaps.push(e.recoveryGapDays);
|
||
}
|
||
}
|
||
|
||
const typicalRecoveryGapDays =
|
||
gaps.length >= MIN_EPISODES_FOR_TYPICAL_GAP
|
||
? Math.round(median(gaps))
|
||
: null;
|
||
|
||
return {
|
||
episodeCount: episodes.length,
|
||
resolvedCount,
|
||
typicalRecoveryGapDays,
|
||
gapSampleSize: gaps.length,
|
||
byMonth,
|
||
byType,
|
||
};
|
||
}
|