From 3004f55875b799ce124f6b26fdf9c08f159c5333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 09:16:43 +0200 Subject: [PATCH] fix(correlations): keep a chronic condition's logged days in the symptom channel Excluding chronic episodes from the zero-fill also dropped them from the read, so their day-logs never reached the series and an account with only a chronic condition had no symptom channel at all. The read now takes every in-window episode; only the ones that can trigger Rest Mode carry a zero-filled span, and every logged day counts. --- .../__tests__/symptom-series-chronic.test.ts | 54 ++++++++++++++++--- .../insights/correlation-channel-series.ts | 27 +++++----- .../insights/correlation-series-builders.ts | 22 ++++---- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/src/lib/insights/__tests__/symptom-series-chronic.test.ts b/src/lib/insights/__tests__/symptom-series-chronic.test.ts index c949b3380..a6a31e0d1 100644 --- a/src/lib/insights/__tests__/symptom-series-chronic.test.ts +++ b/src/lib/insights/__tests__/symptom-series-chronic.test.ts @@ -1,11 +1,14 @@ /** - * A chronic condition does not zero-fill the symptom-severity series. + * A chronic condition does not zero-fill the symptom-severity series, but its + * logged days stay in it. * * 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. + * account into Rest Mode carry a span. The first cut of that fix dropped the + * chronic episode from the read altogether, which also dropped every day the + * person had logged for it: a chronic-only account had no channel at all. + * The fake store honours the episode filter the way Postgres would. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -15,7 +18,11 @@ type Episode = { onsetAt: Date; resolvedAt: Date | null; }; -const store: { episodes: Episode[] } = { episodes: [] }; +type DayLog = { episodeId: string; date: string; functionalImpact: number }; +const store: { episodes: Episode[]; dayLogs: DayLog[] } = { + episodes: [], + dayLogs: [], +}; vi.mock("@/lib/db", () => ({ prisma: { @@ -29,7 +36,18 @@ vi.mock("@/lib/db", () => ({ ), ), }, - illnessDayLog: { findMany: vi.fn(async () => []) }, + illnessDayLog: { + findMany: vi.fn( + async (args: { where: { episodeId: { in: string[] } } }) => + store.dayLogs + .filter((d) => args.where.episodeId.in.includes(d.episodeId)) + .map((d) => ({ + date: d.date, + functionalImpact: d.functionalImpact, + symptomLinks: [], + })), + ), + }, }, })); @@ -38,12 +56,16 @@ import { fetchSymptomSeries } from "../correlation-channel-series"; const DAY = 24 * 60 * 60 * 1000; const since = new Date(Date.now() - 60 * DAY); +const dayKeyAgo = (days: number) => + new Date(Date.now() - days * DAY).toISOString().slice(0, 10); + beforeEach(() => { store.episodes = []; + store.dayLogs = []; }); describe("fetchSymptomSeries", () => { - it("has no series for a chronic condition alone", async () => { + it("has no zero-filled series for a chronic condition without logs", async () => { store.episodes = [ { id: "chronic", @@ -75,4 +97,24 @@ describe("fetchSymptomSeries", () => { expect(series.points.length).toBeGreaterThanOrEqual(6); expect(series.points.length).toBeLessThanOrEqual(8); }); + + it("keeps the logged days of a chronic condition, without zero-fill", async () => { + store.episodes = [ + { + id: "chronic", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date(Date.now() - 900 * DAY), + resolvedAt: null, + }, + ]; + store.dayLogs = [ + { episodeId: "chronic", date: dayKeyAgo(10), functionalImpact: 3 }, + { episodeId: "chronic", date: dayKeyAgo(5), functionalImpact: 1 }, + ]; + const series = await fetchSymptomSeries("user-1", "UTC", since); + expect(series.points).toEqual([ + { day: dayKeyAgo(10), value: 3 }, + { day: dayKeyAgo(5), value: 1 }, + ]); + }); }); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 3e038c63c..7c8382edf 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -20,7 +20,7 @@ * fabricating a constant. */ import { prisma } from "@/lib/db"; -import { REST_MODE_EPISODE_WHERE } from "@/lib/illness/rest-mode"; +import { triggersRestMode } from "@/lib/illness/rest-mode"; import { buildComplianceMedicationContext, COURSES_COMPLIANCE_SELECT, @@ -150,11 +150,13 @@ 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 - * 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 + * 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 and its + * day-logs. Only episodes that can put the account into Rest Mode zero-fill + * their span: a chronic condition has no end, so its span would fill every + * day of the window and hand the engine a near-constant series. Its logged + * days still count, as real readings. No episode and no log yields an empty * series that degrades to absent. */ export async function fetchSymptomSeries( @@ -166,17 +168,13 @@ 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 }, + select: { id: true, onsetAt: true, resolvedAt: true, lifecycle: true }, }); if (episodes.length === 0) { return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role: "outcome", points: [] }; @@ -215,10 +213,9 @@ export async function fetchSymptomSeries( dayLogs.push({ day: row.date, impact: maxSeverity }); } - const spans: SymptomEpisodeSpan[] = episodes.map((e) => ({ - onsetAt: e.onsetAt, - resolvedAt: e.resolvedAt, - })); + const spans: SymptomEpisodeSpan[] = episodes + .filter(triggersRestMode) + .map((e) => ({ onsetAt: e.onsetAt, resolvedAt: e.resolvedAt })); return buildSymptomSeverityDailySeries({ dayLogs, diff --git a/src/lib/insights/correlation-series-builders.ts b/src/lib/insights/correlation-series-builders.ts index 7da33628e..a34095560 100644 --- a/src/lib/insights/correlation-series-builders.ts +++ b/src/lib/insights/correlation-series-builders.ts @@ -120,9 +120,11 @@ function dayKey(at: Date, tz: string): string { /** * Build the daily SYMPTOM_SEVERITY series (rides both roles; this returns it in * the requested `role`). Healthy days are zero-filled — but ONLY across the - * span of the user's real episodes intersected with `[windowStart, windowEnd]`, - * so a user with no episodes yields an EMPTY series (the channel degrades to - * absent, never a spurious all-zero constant). + * span of the episodes passed in, intersected with `[windowStart, windowEnd]`. + * Logged days always count, inside a span or not, so an episode that carries + * no span (a chronic condition) still contributes its real readings. No span + * and no log yields an EMPTY series (the channel degrades to absent, never a + * spurious all-zero constant). * * Algorithm: * 1. Compute the set of in-window calendar days covered by ANY episode span @@ -134,8 +136,8 @@ function dayKey(at: Date, tz: string): string { * on a day → the MAX, the most-symptomatic reading). * * The result is a dense-within-episode-spans series with genuine 0↔>0 variance - * when the user had symptomatic days, and NOTHING at all when they had no - * episodes. Pure. + * when the user had symptomatic days, and NOTHING at all when there is neither + * a span nor a logged day. Pure. */ export function buildSymptomSeverityDailySeries(args: { dayLogs: SymptomDayLogRow[]; @@ -171,11 +173,6 @@ export function buildSymptomSeverityDailySeries(args: { } } - // If no episode put a single day on the board, the channel is absent. - if (byDay.size === 0) { - return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points: [] }; - } - // Step 3 — overlay logged impacts (MAX per day). A logged day outside every // episode span is still honoured (it is, by definition, a symptomatic day the // user recorded), so it joins the series even if the span walk missed it. @@ -186,6 +183,11 @@ export function buildSymptomSeverityDailySeries(args: { byDay.set(log.day, prev == null ? log.impact : Math.max(prev, log.impact)); } + // No span and no logged day: the channel is absent. + if (byDay.size === 0) { + return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points: [] }; + } + const points: DailySeriesPoint[] = [...byDay.entries()] .map(([day, value]) => ({ day, value })) .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));