mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge symptom-severity recovery track into release/v1.19.2
Fold the illness journal's per-day functional-impact curve (falling back
to linked symptom severity) into the recovery-gap as a return track
against the known-constant healthy baseline. It is adverse by
construction, feeds the qualifying-days floor and the gap median, and
withholds on sparse logging. The card names the driver when symptoms
lead ('…before your logged symptoms eased').
This commit is contained in:
commit
5a2d759e50
12 files changed
+471
-8
No files matched your search
@@ -6882,6 +6882,8 @@
|
||||
"recoveryGapTitle": "Erholungslücke",
|
||||
"recoveryGapLag": "Deine Werte normalisierten sich {days} Tage, nachdem es dir besser ging.",
|
||||
"recoveryGapLead": "Deine Werte normalisierten sich {days} Tage, bevor du Genesung vermerktest.",
|
||||
"recoveryGapLagSymptom": "Dir ging es {days} Tage besser, bevor deine erfassten Symptome abklangen.",
|
||||
"recoveryGapLeadSymptom": "Deine erfassten Symptome klangen {days} Tage ab, bevor du Genesung vermerktest.",
|
||||
"recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.",
|
||||
"redFlagTitle": "Genauer ansehen",
|
||||
"redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.",
|
||||
|
||||
@@ -6882,6 +6882,8 @@
|
||||
"recoveryGapTitle": "Recovery gap",
|
||||
"recoveryGapLag": "Your numbers settled {days} days after you felt better.",
|
||||
"recoveryGapLead": "Your numbers settled {days} days before you marked recovery.",
|
||||
"recoveryGapLagSymptom": "You felt better {days} days before your logged symptoms eased.",
|
||||
"recoveryGapLeadSymptom": "Your logged symptoms eased {days} days before you marked recovery.",
|
||||
"recoveryGapSame": "Your numbers settled the same day you felt better.",
|
||||
"redFlagTitle": "Worth a closer look",
|
||||
"redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.",
|
||||
|
||||
@@ -6882,6 +6882,8 @@
|
||||
"recoveryGapTitle": "Brecha de recuperación",
|
||||
"recoveryGapLag": "Tus valores se estabilizaron {days} días después de sentirte mejor.",
|
||||
"recoveryGapLead": "Tus valores se estabilizaron {days} días antes de marcar la recuperación.",
|
||||
"recoveryGapLagSymptom": "Te sentiste mejor {days} días antes de que cedieran tus síntomas registrados.",
|
||||
"recoveryGapLeadSymptom": "Tus síntomas registrados cedieron {days} días antes de marcar la recuperación.",
|
||||
"recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.",
|
||||
"redFlagTitle": "Conviene revisarlo",
|
||||
"redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.",
|
||||
|
||||
@@ -6882,6 +6882,8 @@
|
||||
"recoveryGapTitle": "Écart de récupération",
|
||||
"recoveryGapLag": "Tes valeurs se sont stabilisées {days} jours après que tu te sois senti mieux.",
|
||||
"recoveryGapLead": "Tes valeurs se sont stabilisées {days} jours avant que tu notes la guérison.",
|
||||
"recoveryGapLagSymptom": "Tu t'es senti mieux {days} jours avant que tes symptômes consignés s'apaisent.",
|
||||
"recoveryGapLeadSymptom": "Tes symptômes consignés se sont apaisés {days} jours avant que tu notes la guérison.",
|
||||
"recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.",
|
||||
"redFlagTitle": "À examiner de plus près",
|
||||
"redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.",
|
||||
|
||||
@@ -6882,6 +6882,8 @@
|
||||
"recoveryGapTitle": "Divario di recupero",
|
||||
"recoveryGapLag": "I tuoi valori si sono stabilizzati {days} giorni dopo esserti sentito meglio.",
|
||||
"recoveryGapLead": "I tuoi valori si sono stabilizzati {days} giorni prima di segnare la guarigione.",
|
||||
"recoveryGapLagSymptom": "Ti sei sentito meglio {days} giorni prima che i sintomi registrati si attenuassero.",
|
||||
"recoveryGapLeadSymptom": "I tuoi sintomi registrati si sono attenuati {days} giorni prima di segnare la guarigione.",
|
||||
"recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.",
|
||||
"redFlagTitle": "Da guardare più da vicino",
|
||||
"redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.",
|
||||
|
||||
@@ -6882,6 +6882,8 @@
|
||||
"recoveryGapTitle": "Luka rekonwalescencji",
|
||||
"recoveryGapLag": "Twoje wartości ustabilizowały się {days} dni po tym, jak poczułeś się lepiej.",
|
||||
"recoveryGapLead": "Twoje wartości ustabilizowały się {days} dni przed oznaczeniem wyzdrowienia.",
|
||||
"recoveryGapLagSymptom": "Poczułeś się lepiej {days} dni przed ustąpieniem zapisanych objawów.",
|
||||
"recoveryGapLeadSymptom": "Twoje zapisane objawy ustąpiły {days} dni przed oznaczeniem wyzdrowienia.",
|
||||
"recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.",
|
||||
"redFlagTitle": "Warto przyjrzeć się bliżej",
|
||||
"redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.",
|
||||
|
||||
@@ -44,6 +44,10 @@ function DeviationRow({ d }: { d: IllnessVitalDeviation }) {
|
||||
function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
|
||||
const { t } = useTranslations();
|
||||
const gap = value.recoveryGapDays;
|
||||
// Driver-aware copy: when the user's logged symptom curve drove the gap, name
|
||||
// the symptoms ("…before your symptoms eased"); otherwise the existing
|
||||
// vital-driven phrasing. Clinically calm — observation, never prediction.
|
||||
const symptomDriven = value.gapDriverType === "FUNCTIONAL_IMPACT";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -71,11 +75,19 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{gap > 0
|
||||
? t("illness.correlation.recoveryGapLag", { days: gap })
|
||||
? t(
|
||||
symptomDriven
|
||||
? "illness.correlation.recoveryGapLagSymptom"
|
||||
: "illness.correlation.recoveryGapLag",
|
||||
{ days: gap },
|
||||
)
|
||||
: gap < 0
|
||||
? t("illness.correlation.recoveryGapLead", {
|
||||
days: Math.abs(gap),
|
||||
})
|
||||
? t(
|
||||
symptomDriven
|
||||
? "illness.correlation.recoveryGapLeadSymptom"
|
||||
: "illness.correlation.recoveryGapLead",
|
||||
{ days: Math.abs(gap) },
|
||||
)
|
||||
: t("illness.correlation.recoveryGapSame")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -97,9 +97,11 @@ export interface IllnessVitalDeviation {
|
||||
}
|
||||
|
||||
export interface IllnessVitalReturn {
|
||||
/** A `MeasurementType`, or `"FUNCTIONAL_IMPACT"` for the symptom-burden track. */
|
||||
type: string;
|
||||
returnedDay: string | null;
|
||||
gapDays: number | null;
|
||||
adverse: boolean;
|
||||
}
|
||||
|
||||
export interface IllnessRedFlag {
|
||||
@@ -117,6 +119,12 @@ export interface IllnessCorrelationValue {
|
||||
recoveryGapDays: number | null;
|
||||
adverseCoverageDays: number;
|
||||
feltBetterDay: string | null;
|
||||
/**
|
||||
* The metric that drove the headline gap — a `MeasurementType`,
|
||||
* `"FUNCTIONAL_IMPACT"` (the symptom-burden track), or null. Drives the
|
||||
* driver-aware recovery-gap copy.
|
||||
*/
|
||||
gapDriverType: string | null;
|
||||
redFlags: IllnessRedFlag[];
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
computeIllnessCorrelation,
|
||||
dayDiff,
|
||||
FUNCTIONAL_IMPACT_RETURN_KEY,
|
||||
isAdverseDeviation,
|
||||
MIN_BASELINE_DAYS,
|
||||
MIN_EPISODE_COVERAGE_DAYS,
|
||||
type IllnessCorrelationInput,
|
||||
type SymptomBurdenPoint,
|
||||
type VitalDayPoint,
|
||||
} from "../correlation";
|
||||
|
||||
@@ -615,3 +617,196 @@ describe("computeIllnessCorrelation — return anchor", () => {
|
||||
expect(out.value.recoveryGapDays).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeIllnessCorrelation — symptom-burden return track", () => {
|
||||
// A banded RHR vital present in EVERY case below clears the engine's
|
||||
// episode-coverage floor (the gate counts banded-vital days, not symptom
|
||||
// days). Its band stays IN throughout so it produces no adverse signal and
|
||||
// never competes with the symptom track for the driver — it is scaffolding.
|
||||
const rhr: MeasurementType = "RESTING_HEART_RATE";
|
||||
const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02");
|
||||
const rhrFlat: VitalDayPoint[] = [
|
||||
{ day: "2026-01-10", mean: 55 },
|
||||
{ day: "2026-01-11", mean: 55 },
|
||||
{ day: "2026-01-12", mean: 55 },
|
||||
{ day: "2026-01-13", mean: 55 },
|
||||
{ day: "2026-01-14", mean: 55 },
|
||||
];
|
||||
|
||||
it("folds a functional-impact return into the gap with a constant-0 baseline", () => {
|
||||
// Onset 01-10, felt better 01-14. Logged impact: bedbound early, eases to 0
|
||||
// on 01-16 and HOLDS for 3 logged days → symptom return 01-16, gap +2.
|
||||
const symptomBurden: SymptomBurdenPoint[] = [
|
||||
{ day: "2026-01-10", impact: 3 },
|
||||
{ day: "2026-01-12", impact: 2 },
|
||||
{ day: "2026-01-14", impact: 1 }, // felt-better day, still symptomatic
|
||||
{ day: "2026-01-16", impact: 0 }, // return start
|
||||
{ day: "2026-01-17", impact: 0 },
|
||||
{ day: "2026-01-18", impact: 0 }, // 3 logged in-band days → stable
|
||||
];
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
window: {
|
||||
onsetDay: "2026-01-10",
|
||||
feltBetterDay: "2026-01-14",
|
||||
lifecycle: "ACUTE",
|
||||
},
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
symptomBurden,
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
const sym = out.value.returns.find(
|
||||
(r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY,
|
||||
);
|
||||
expect(sym).toBeDefined();
|
||||
expect(sym?.returnedDay).toBe("2026-01-16");
|
||||
expect(sym?.adverse).toBe(true);
|
||||
// Gap = 01-16 − 01-14 = +2. RHR stayed flat (no gap), so the symptom track
|
||||
// is the only contributor → headline gap = +2.
|
||||
expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-16"));
|
||||
expect(out.value.recoveryGapDays).toBe(2);
|
||||
// It contributes to the adverse-coverage floor (3 logged adverse days).
|
||||
expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(3);
|
||||
expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY);
|
||||
});
|
||||
|
||||
it("WITHHOLDS the symptom return when logging is too sparse to stabilise", () => {
|
||||
// The user logs while sick, then logs impact-0 ONCE and stops — the
|
||||
// 3-logged-day stability run can never fire. The symptom track must
|
||||
// contribute NO gap (honest withholding), never fabricate recovery from the
|
||||
// absence of further logs.
|
||||
const symptomBurden: SymptomBurdenPoint[] = [
|
||||
{ day: "2026-01-10", impact: 3 },
|
||||
{ day: "2026-01-12", impact: 2 },
|
||||
{ day: "2026-01-16", impact: 0 }, // single trailing in-band log
|
||||
];
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
window: {
|
||||
onsetDay: "2026-01-10",
|
||||
feltBetterDay: "2026-01-14",
|
||||
lifecycle: "ACUTE",
|
||||
},
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
symptomBurden,
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
const sym = out.value.returns.find(
|
||||
(r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY,
|
||||
);
|
||||
expect(sym).toBeDefined();
|
||||
expect(sym?.returnedDay).toBeNull();
|
||||
expect(sym?.gapDays).toBeNull();
|
||||
// No vital returned either → no headline gap fabricated.
|
||||
expect(out.value.recoveryGapDays).toBeNull();
|
||||
expect(out.value.gapDriverType).toBeNull();
|
||||
// The logged adverse days still count toward coverage (honest).
|
||||
expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("lets the symptom track win the driver over a co-returning vital", () => {
|
||||
// Both the symptom curve AND an adverse RHR spike return and produce gaps;
|
||||
// the functional-impact track must win `gapDriverType` on a tie (it is the
|
||||
// most illness-specific signal).
|
||||
const rhrSpike: VitalDayPoint[] = [
|
||||
{ day: "2026-01-10", mean: 75 }, // adverse up
|
||||
{ day: "2026-01-11", mean: 74 },
|
||||
{ day: "2026-01-16", mean: 55 }, // back in band
|
||||
{ day: "2026-01-17", mean: 55 },
|
||||
{ day: "2026-01-18", mean: 55 }, // 3 stable
|
||||
];
|
||||
const symptomBurden: SymptomBurdenPoint[] = [
|
||||
{ day: "2026-01-10", impact: 3 },
|
||||
{ day: "2026-01-12", impact: 2 },
|
||||
{ day: "2026-01-16", impact: 0 }, // same return day → same gap as RHR
|
||||
{ day: "2026-01-17", impact: 0 },
|
||||
{ day: "2026-01-18", impact: 0 },
|
||||
];
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
window: {
|
||||
onsetDay: "2026-01-10",
|
||||
feltBetterDay: "2026-01-14",
|
||||
lifecycle: "ACUTE",
|
||||
},
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrSpike },
|
||||
],
|
||||
symptomBurden,
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
const sym = out.value.returns.find(
|
||||
(r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY,
|
||||
);
|
||||
const rhrReturn = out.value.returns.find((r) => r.type === rhr);
|
||||
expect(sym?.gapDays).toBe(2);
|
||||
expect(rhrReturn?.gapDays).toBe(2);
|
||||
expect(out.value.recoveryGapDays).toBe(2);
|
||||
// Tie on |gap − median| → functional-impact wins.
|
||||
expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY);
|
||||
});
|
||||
|
||||
it("ignores a symptom curve with no adverse day (nothing to return from)", () => {
|
||||
const symptomBurden: SymptomBurdenPoint[] = [
|
||||
{ day: "2026-01-10", impact: 0 },
|
||||
{ day: "2026-01-12", impact: 0 },
|
||||
];
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
window: {
|
||||
onsetDay: "2026-01-10",
|
||||
feltBetterDay: "2026-01-14",
|
||||
lifecycle: "ACUTE",
|
||||
},
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
symptomBurden,
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(
|
||||
out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never produces a symptom return for CHRONIC_ONGOING", () => {
|
||||
const symptomBurden: SymptomBurdenPoint[] = [
|
||||
{ day: "2026-01-10", impact: 3 },
|
||||
{ day: "2026-01-16", impact: 0 },
|
||||
{ day: "2026-01-17", impact: 0 },
|
||||
{ day: "2026-01-18", impact: 0 },
|
||||
];
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
window: {
|
||||
onsetDay: "2026-01-10",
|
||||
feltBetterDay: "2026-01-14",
|
||||
lifecycle: "CHRONIC_ONGOING",
|
||||
},
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
symptomBurden,
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.recoveryGapDays).toBeNull();
|
||||
expect(out.value.gapDriverType).toBeNull();
|
||||
expect(
|
||||
out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
computeIllnessCorrelation,
|
||||
type DayLogFeverPoint,
|
||||
type IllnessCorrelationValue,
|
||||
type SymptomBurdenPoint,
|
||||
type VitalSeries,
|
||||
} from "./correlation";
|
||||
|
||||
@@ -103,7 +104,7 @@ export async function computeEpisodeCorrelation(
|
||||
// run paired rather than serially (halving the per-type round-trips).
|
||||
// The day-log fever read shares the same fan-out budget.
|
||||
const limit = pLimit(VITAL_SCAN_CONCURRENCY);
|
||||
const [perType, dayLogFever] = await Promise.all([
|
||||
const [perType, dayLogFever, symptomBurden] = await Promise.all([
|
||||
Promise.all(
|
||||
ILLNESS_SCAN_TYPES.map((type) =>
|
||||
limit(async () => {
|
||||
@@ -132,6 +133,7 @@ export async function computeEpisodeCorrelation(
|
||||
),
|
||||
),
|
||||
limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)),
|
||||
limit(() => readDayLogSymptomBurden(episode.id, preOnsetStart, end, tz)),
|
||||
]);
|
||||
|
||||
const series: VitalSeries[] = [];
|
||||
@@ -167,6 +169,7 @@ export async function computeEpisodeCorrelation(
|
||||
window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle },
|
||||
series,
|
||||
dayLogFever,
|
||||
symptomBurden,
|
||||
source,
|
||||
now,
|
||||
});
|
||||
@@ -275,3 +278,56 @@ async function readDayLogFever(
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([day, feverC]) => ({ day, feverC }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the illness day-log symptom-burden series over [from, to) — the user's
|
||||
* own logged daily-impact curve, keyed by the stored `date` (already the
|
||||
* user-tz day string, so NO tz conversion). Mirrors `readDayLogFever`: one
|
||||
* `findMany`, present days only, no zero-fill.
|
||||
*
|
||||
* Per day the burden is `functionalImpact` (0–3, the deliberate daily slider)
|
||||
* when logged; else the day's MAX linked `IllnessSymptomLink.severity` (0–3) as
|
||||
* a fallback corroborator. A day with neither is dropped (absent from the
|
||||
* series — the engine treats a missing day as "not logged", never an
|
||||
* implicit 0).
|
||||
*/
|
||||
async function readDayLogSymptomBurden(
|
||||
episodeId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
tz: string,
|
||||
): Promise<SymptomBurdenPoint[]> {
|
||||
const fromKey = dayKeyForUserTz(from, tz);
|
||||
const toKey = dayKeyForUserTz(to, tz);
|
||||
const rows = await prisma.illnessDayLog.findMany({
|
||||
where: {
|
||||
episodeId,
|
||||
deletedAt: null,
|
||||
date: { gte: fromKey, lte: toKey },
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
functionalImpact: true,
|
||||
symptomLinks: { select: { severity: true } },
|
||||
},
|
||||
});
|
||||
const points: SymptomBurdenPoint[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.functionalImpact != null) {
|
||||
points.push({ day: row.date, impact: row.functionalImpact });
|
||||
continue;
|
||||
}
|
||||
// Fallback corroborator: the day's strongest linked symptom severity.
|
||||
let maxSeverity: number | null = null;
|
||||
for (const link of row.symptomLinks) {
|
||||
if (link.severity == null) continue;
|
||||
maxSeverity =
|
||||
maxSeverity === null
|
||||
? link.severity
|
||||
: Math.max(maxSeverity, link.severity);
|
||||
}
|
||||
if (maxSeverity != null)
|
||||
points.push({ day: row.date, impact: maxSeverity });
|
||||
}
|
||||
return points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
}
|
||||
@@ -66,6 +66,21 @@ export const NOTABLE_SD = 2;
|
||||
* for the recovery-gap (one lucky in-band day is not a recovery).
|
||||
*/
|
||||
export const RETURN_STABILITY_DAYS = 3;
|
||||
/**
|
||||
* Sentinel `type` for the functional-impact return track. The symptom curve is
|
||||
* NOT a `MeasurementType` — it is the user's own logged daily-impact slider —
|
||||
* so it rides `returns[]` under a stable string key the surface keys copy off
|
||||
* (the typed `VitalReturnFinding.type` is widened to accept it). Tallied like
|
||||
* any vital type in `gapReturnTypes` so it can win `gapDriverType`.
|
||||
*/
|
||||
export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const;
|
||||
/**
|
||||
* Functional-impact threshold (0–3 slider) at/above which a logged day counts
|
||||
* as ADVERSE (symptomatic). The baseline is the known constant 0 (healthy), so
|
||||
* the symptom curve needs none of the MAD-band / contamination machinery the
|
||||
* passive vitals carry — "return" is simply impact back to below this floor.
|
||||
*/
|
||||
export const FUNCTIONAL_IMPACT_ADVERSE_FLOOR = 1;
|
||||
/** Minimum episode days with ANY vital coverage before the gap is asserted. */
|
||||
export const MIN_EPISODE_COVERAGE_DAYS = 4;
|
||||
|
||||
@@ -153,6 +168,22 @@ export interface DayLogFeverPoint {
|
||||
feverC: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-logged symptom-burden reading from the illness day-log, keyed by the
|
||||
* stored `date` (already the user-tz local day). `impact` is the day's
|
||||
* `functionalImpact` slider (0–3, 0 = fully functional … 3 = bedbound); when
|
||||
* NULL it is the day's max linked `IllnessSymptomLink.severity` (0–3) as a
|
||||
* fallback corroborator, or null when neither was logged. Present days only —
|
||||
* never zero-filled (mirrors the fever read), so sparse logging WITHHOLDS the
|
||||
* symptom return rather than fabricating an all-clear from absence of rows.
|
||||
*/
|
||||
export interface SymptomBurdenPoint {
|
||||
/** Local day key, `YYYY-MM-DD`. */
|
||||
day: string;
|
||||
/** The day's symptom burden (0–3): functionalImpact, else max severity. */
|
||||
impact: number;
|
||||
}
|
||||
|
||||
/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */
|
||||
export interface EpisodeWindow {
|
||||
/** Onset day. */
|
||||
@@ -174,6 +205,17 @@ export interface IllnessCorrelationInput {
|
||||
* escalation even with no passive BODY_TEMPERATURE rows. Optional.
|
||||
*/
|
||||
dayLogFever?: DayLogFeverPoint[];
|
||||
/**
|
||||
* Per-day symptom-burden readings from the illness day-log (`functionalImpact`
|
||||
* primary, max linked symptom `severity` as fallback). Folded into the
|
||||
* recovery-gap as ONE more return track against the known-constant baseline
|
||||
* (healthy = 0): a "symptom return" is the burden back below the adverse floor
|
||||
* and held for `RETURN_STABILITY_DAYS` LOGGED days. Always `adverse:true` (it
|
||||
* is illness-relevant by construction), so it contributes to
|
||||
* `adverseCoverageDays` and can drive the headline gap even on a
|
||||
* passive-vital-thin episode. Optional.
|
||||
*/
|
||||
symptomBurden?: SymptomBurdenPoint[];
|
||||
/** Provenance source the dominant read resolved against. */
|
||||
source: "DAY" | "live" | "none";
|
||||
/** Compute time (injected for deterministic tests). */
|
||||
@@ -201,7 +243,11 @@ export interface VitalDeviationFinding {
|
||||
|
||||
/** A vital's recovery timing within the episode. */
|
||||
export interface VitalReturnFinding {
|
||||
type: MeasurementType;
|
||||
/**
|
||||
* The metric the return describes — a `MeasurementType` for a passive vital,
|
||||
* or `FUNCTIONAL_IMPACT_RETURN_KEY` for the user-logged symptom-burden track.
|
||||
*/
|
||||
type: MeasurementType | typeof FUNCTIONAL_IMPACT_RETURN_KEY;
|
||||
/** First day the vital re-entered its band AND stayed in for the window. */
|
||||
returnedDay: string | null;
|
||||
/** Days from felt-better to physiological return (signed); null if N/A. */
|
||||
@@ -255,6 +301,16 @@ export interface IllnessCorrelationValue {
|
||||
adverseCoverageDays: number;
|
||||
/** Felt-better marker echoed for the surface. */
|
||||
feltBetterDay: string | null;
|
||||
/**
|
||||
* The metric whose physiological return dominates the headline gap — the
|
||||
* adverse return-track whose own gap is closest to the median, with the
|
||||
* functional-impact symptom track winning ties (it is the most
|
||||
* illness-specific signal). A `MeasurementType`,
|
||||
* `FUNCTIONAL_IMPACT_RETURN_KEY`, or null when no adverse return drove a gap.
|
||||
* The card names it ("…before your symptoms eased" vs "…before your resting
|
||||
* heart rate settled"). Retrospective only.
|
||||
*/
|
||||
gapDriverType: string | null;
|
||||
/**
|
||||
* Red-flag escalation — a sustained adverse SpO2 drop or sustained fever
|
||||
* during the episode. Retrospective, but the copy must escalate ("if this
|
||||
@@ -471,6 +527,21 @@ export function computeIllnessCorrelation(
|
||||
}
|
||||
}
|
||||
|
||||
// Symptom-burden return track — the user's OWN logged daily-impact curve,
|
||||
// folded in as one more return against the KNOWN-CONSTANT baseline (healthy
|
||||
// = 0). No MAD band, no contamination guard, no min-baseline floor: the band
|
||||
// IS the constant 0, so it sidesteps every reliability caveat the passive
|
||||
// vitals carry. Always adverse by construction (a logged impact is illness-
|
||||
// relevant), so it contributes to `adverseCoverageDays` and the headline gap
|
||||
// even when the passive vitals are thin — the genuine illness-relevant
|
||||
// contributor the WEIGHT-only floor lacked. Logged-days-only stability:
|
||||
// sparse logging WITHHOLDS the return rather than fabricating an all-clear.
|
||||
const symptomReturn = computeSymptomReturn(input.symptomBurden ?? [], window);
|
||||
if (symptomReturn) {
|
||||
returns.push(symptomReturn.finding);
|
||||
for (const day of symptomReturn.adverseDays) adverseDayKeys.add(day);
|
||||
}
|
||||
|
||||
// Red-flag escalation runs DECOUPLED from the banded loop: it scans the raw
|
||||
// episode days for SpO2 + temperature against ABSOLUTE clinical floors, so a
|
||||
// rock-steady (MAD=0) vital that drops the band filter still escalates — the
|
||||
@@ -489,6 +560,31 @@ export function computeIllnessCorrelation(
|
||||
? null
|
||||
: Math.round(median(gaps));
|
||||
|
||||
// Name the per-episode driver: among the ADVERSE return tracks that produced
|
||||
// a real gap, the one whose own gap sits closest to the headline median (it
|
||||
// is the return that "explains" the headline). The functional-impact symptom
|
||||
// track wins ties — it is the most illness-specific signal the engine has, so
|
||||
// when it co-explains the gap it gets to name it ("…before your symptoms
|
||||
// eased"). Null when no adverse return drove a gap.
|
||||
const gapDriverType =
|
||||
recoveryGapDays === null
|
||||
? null
|
||||
: (returns
|
||||
.filter(
|
||||
(r): r is VitalReturnFinding & { gapDays: number } =>
|
||||
r.gapDays !== null && r.adverse,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const da = Math.abs(a.gapDays - recoveryGapDays);
|
||||
const db = Math.abs(b.gapDays - recoveryGapDays);
|
||||
if (da !== db) return da - db;
|
||||
// Tie: prefer the functional-impact symptom track, then by name.
|
||||
const fa = a.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1;
|
||||
const fb = b.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1;
|
||||
if (fa !== fb) return fa - fb;
|
||||
return String(a.type) < String(b.type) ? -1 : 1;
|
||||
})[0]?.type ?? null);
|
||||
|
||||
const { coverage, confidence } = deriveCoverage({
|
||||
requiredInputs: 1,
|
||||
presentInputs: banded.length,
|
||||
@@ -510,6 +606,7 @@ export function computeIllnessCorrelation(
|
||||
recoveryGapDays,
|
||||
adverseCoverageDays: adverseDayKeys.size,
|
||||
feltBetterDay: window.feltBetterDay,
|
||||
gapDriverType: gapDriverType === null ? null : String(gapDriverType),
|
||||
redFlags,
|
||||
},
|
||||
coverage,
|
||||
@@ -544,6 +641,87 @@ function firstStableReturn(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the functional-impact (symptom-burden) return track for one episode.
|
||||
*
|
||||
* The symptom curve's baseline is the KNOWN CONSTANT 0 (healthy), so this needs
|
||||
* none of the passive vitals' MAD-band / contamination machinery — "adverse" is
|
||||
* simply `impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR`, "in band" is below it.
|
||||
*
|
||||
* - Only LOGGED days (present in `burden`) participate — never zero-filled.
|
||||
* - The anchor is the first logged adverse day (same role as the vital
|
||||
* `firstDeviationIndex`): a return search starts AFTER it, so a run-up is
|
||||
* never read as a return.
|
||||
* - A "return" is the first logged day at/after the anchor that is in-band AND
|
||||
* stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days
|
||||
* stability (not calendar days) is the honest-withholding handler for sparse
|
||||
* journals: a single trailing impact-0 log never satisfies the run, so the
|
||||
* track WITHHOLDS rather than fabricating recovery from absence of rows.
|
||||
* - The gap is `dayDiff(feltBetterDay, returnedDay)`, same signed semantic.
|
||||
* - Always `adverse:true` (a logged symptom curve is illness-relevant), so it
|
||||
* feeds `adverseCoverageDays` and can drive the headline gap.
|
||||
*
|
||||
* Returns null (contributes nothing) when no adverse day was logged or the
|
||||
* episode is CHRONIC_ONGOING. `adverseDays` is the set of logged adverse days in
|
||||
* the active span, for the coverage tally.
|
||||
*/
|
||||
function computeSymptomReturn(
|
||||
burden: SymptomBurdenPoint[],
|
||||
window: EpisodeWindow,
|
||||
): { finding: VitalReturnFinding; adverseDays: string[] } | null {
|
||||
if (window.lifecycle === "CHRONIC_ONGOING") return null;
|
||||
// Active span only, chronological. (Logged days are sparse and arbitrary, so
|
||||
// a stable sort by the day key is the canonical order.)
|
||||
const active = burden
|
||||
.filter((p) => p.day >= window.onsetDay)
|
||||
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
if (active.length === 0) return null;
|
||||
|
||||
const adverseDays = active
|
||||
.filter((p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR)
|
||||
.map((p) => p.day);
|
||||
// No adverse day logged → there is nothing to "return" from.
|
||||
if (adverseDays.length === 0) return null;
|
||||
|
||||
// Anchor: first logged adverse day. The return search starts there so an
|
||||
// early in-band log (impact 0 before symptoms appeared) is never a return.
|
||||
const firstAdverseIndex = active.findIndex(
|
||||
(p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR,
|
||||
);
|
||||
let returnedDay: string | null = null;
|
||||
for (let i = firstAdverseIndex; i < active.length; i++) {
|
||||
if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue;
|
||||
// Count consecutive in-band LOGGED days from here.
|
||||
let run = 0;
|
||||
for (
|
||||
let j = i;
|
||||
j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR;
|
||||
j++
|
||||
) {
|
||||
run++;
|
||||
}
|
||||
if (run >= RETURN_STABILITY_DAYS) {
|
||||
returnedDay = active[i].day;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const gapDays =
|
||||
returnedDay && window.feltBetterDay
|
||||
? dayDiff(window.feltBetterDay, returnedDay)
|
||||
: null;
|
||||
|
||||
return {
|
||||
finding: {
|
||||
type: FUNCTIONAL_IMPACT_RETURN_KEY,
|
||||
returnedDay,
|
||||
gapDays,
|
||||
adverse: true,
|
||||
},
|
||||
adverseDays,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the retrospective red flags — DECOUPLED from the banded loop and the
|
||||
* own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE
|
||||
|
||||
@@ -181,11 +181,12 @@ const illnessVitalReturn = z
|
||||
type: z.string(),
|
||||
returnedDay: z.string().nullable(),
|
||||
gapDays: z.number().nullable(),
|
||||
adverse: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
id: "IllnessVitalReturn",
|
||||
description:
|
||||
"A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days) from the felt-better marker (positive = the body lagged the feeling).",
|
||||
"A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for the user-logged symptom-burden track (functionalImpact, baseline = constant 0).",
|
||||
});
|
||||
|
||||
const illnessRedFlag = z
|
||||
@@ -210,12 +211,13 @@ const illnessCorrelationValue = z
|
||||
recoveryGapDays: z.number().nullable(),
|
||||
adverseCoverageDays: z.number().int(),
|
||||
feltBetterDay: z.string().nullable(),
|
||||
gapDriverType: z.string().nullable(),
|
||||
redFlags: z.array(illnessRedFlag),
|
||||
})
|
||||
.meta({
|
||||
id: "IllnessCorrelationValue",
|
||||
description:
|
||||
"The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns, the headline recovery-gap (median of per-vital gaps), and any red flags.",
|
||||
"The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags.",
|
||||
});
|
||||
|
||||
const illnessCorrelationResponse = z
|
||||
|
||||
Reference in new issue
Block a user