mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user