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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 04:37:08 +02:00
1 parent ac86199808
commit 9ffcdea312
2 files changed
+86 -3

No files matched your search

@@ -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);
});
});
@@ -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 },
});