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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 09:16:43 +02:00
1 parent 67e2563c63
commit 3004f55875
3 files changed
+72 -31

No files matched your search

@@ -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 },
]);
});
});
+12 -15
View File
@@ -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,
+12 -10
View File
@@ -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));