From 9ffcdea312aa737e7d18a294ef0f449e512cdfd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 04:37:08 +0200 Subject: [PATCH] fix(insights): keep chronic conditions out of the symptom series fill The symptom-severity series fills healthy days with zero across each illness episode's span. A chronic condition has no end, so its span covered the whole window and the correlation engine read a near-constant run of zeros. Only episodes that can put the account into Rest Mode (`REST_MODE_EPISODE_WHERE`) carry a span now. --- .../__tests__/symptom-series-chronic.test.ts | 78 +++++++++++++++++++ .../insights/correlation-channel-series.ts | 11 ++- 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 src/lib/insights/__tests__/symptom-series-chronic.test.ts diff --git a/src/lib/insights/__tests__/symptom-series-chronic.test.ts b/src/lib/insights/__tests__/symptom-series-chronic.test.ts new file mode 100644 index 000000000..c949b3380 --- /dev/null +++ b/src/lib/insights/__tests__/symptom-series-chronic.test.ts @@ -0,0 +1,78 @@ +/** + * A chronic condition does not zero-fill the symptom-severity series. + * + * The series fills healthy days with 0 across an episode's span. A chronic + * condition has no end, so its span covered the whole window and the engine + * correlated a near-constant run of zeros. Only episodes that can put the + * account into Rest Mode carry a span; the fake store below honours the + * lifecycle filter the way Postgres would. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Episode = { + id: string; + lifecycle: string; + onsetAt: Date; + resolvedAt: Date | null; +}; +const store: { episodes: Episode[] } = { episodes: [] }; + +vi.mock("@/lib/db", () => ({ + prisma: { + illnessEpisode: { + findMany: vi.fn( + async (args: { where: { lifecycle?: { not: string } } }) => + store.episodes.filter( + (e) => + args.where.lifecycle === undefined || + e.lifecycle !== args.where.lifecycle.not, + ), + ), + }, + illnessDayLog: { findMany: vi.fn(async () => []) }, + }, +})); + +import { fetchSymptomSeries } from "../correlation-channel-series"; + +const DAY = 24 * 60 * 60 * 1000; +const since = new Date(Date.now() - 60 * DAY); + +beforeEach(() => { + store.episodes = []; +}); + +describe("fetchSymptomSeries", () => { + it("has no series for a chronic condition alone", async () => { + store.episodes = [ + { + id: "chronic", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date(Date.now() - 900 * DAY), + resolvedAt: null, + }, + ]; + const series = await fetchSymptomSeries("user-1", "UTC", since); + expect(series.points).toEqual([]); + }); + + it("fills only the acute episode's span beside a chronic one", async () => { + store.episodes = [ + { + id: "chronic", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date(Date.now() - 900 * DAY), + resolvedAt: null, + }, + { + id: "cold", + lifecycle: "ACUTE", + onsetAt: new Date(Date.now() - 20 * DAY), + resolvedAt: new Date(Date.now() - 14 * DAY), + }, + ]; + const series = await fetchSymptomSeries("user-1", "UTC", since); + expect(series.points.length).toBeGreaterThanOrEqual(6); + expect(series.points.length).toBeLessThanOrEqual(8); + }); +}); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 57635b873..d383307fe 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -20,6 +20,7 @@ * fabricating a constant. */ import { prisma } from "@/lib/db"; +import { REST_MODE_EPISODE_WHERE } from "@/lib/illness/rest-mode"; import { buildComplianceMedicationContext, COURSES_COMPLIANCE_SELECT, @@ -152,9 +153,9 @@ export async function fetchComplianceSeries( * v1.21.0 (FDREXTEND) — build the user's SYMPTOM_SEVERITY daily series in the * `outcome` role (callers that need the behaviour role re-tag the returned * series — the points are role-invariant). Reads every in-window illness episode - * + its day-logs; the builder zero-fills healthy days ONLY across real episode - * spans, so a user with no episodes yields an empty series that degrades to - * absent. + * that is not a chronic condition + its day-logs; the builder zero-fills healthy + * days ONLY across those spans, so a user with no such episode yields an empty + * series that degrades to absent. */ export async function fetchSymptomSeries( userId: string, @@ -165,11 +166,15 @@ export async function fetchSymptomSeries( const episodes = await prisma.illnessEpisode.findMany({ // An episode overlaps the window when it onset before `now` and either is // ongoing or resolved at/after the window start. + // A chronic condition has no end, so its span would zero-fill every day + // of the window and hand the engine a near-constant series. Only the + // episodes that can put the account into Rest Mode carry a span. where: { userId, deletedAt: null, onsetAt: { lte: now }, OR: [{ resolvedAt: null }, { resolvedAt: { gte: since } }], + ...REST_MODE_EPISODE_WHERE, }, select: { id: true, onsetAt: true, resolvedAt: true }, });