From 57682f8df170f7468cad1069820508e11b2205b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:50:17 +0200 Subject: [PATCH] fix(coach): build compliance + symptom channels in get_correlations The correlation discovery matrix gained two non-MeasurementType channels (medication compliance, symptom severity), but the Coach get_correlations reader still filtered its measurement query by hand and never built the new channels. The channel keys leaked into the Postgres enum cast and threw for any user with medication or illness data. Extract the route's two channel-series fetchers into a shared module (correlation-channel-series.ts) so both consumers build the channels identically, then wire the reader to derive its type filter from discoveryMeasurementTypes and fold the fetched compliance + symptom series into the discovery input. The reader now humanises the channel keys to "medication adherence" / "symptom severity". --- .../correlations/__tests__/route.test.ts | 22 +++ src/app/api/insights/correlations/route.ts | 161 +--------------- .../coach/__tests__/correlations-read.test.ts | 112 ++++++++++- src/lib/ai/coach/tools/correlations-read.ts | 59 ++++-- .../insights/correlation-channel-series.ts | 179 ++++++++++++++++++ 5 files changed, 363 insertions(+), 170 deletions(-) create mode 100644 src/lib/insights/correlation-channel-series.ts diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 76a9c6868..ea5d940a5 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -10,6 +10,14 @@ vi.mock("@/lib/db", () => ({ }, measurement: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + // FDREXTEND — the route now folds two non-measurement channels in (built by + // the shared `correlation-channel-series` helpers). Default to empty corpora + // so neither channel produces a point and the discovery stays trivially + // pair-less for the existing assertions. + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) }, + illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -87,6 +95,20 @@ beforeEach(() => { [], ); (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); + // FDREXTEND — the two non-measurement channels read their own models; default + // them empty so neither channel produces a point. + (prisma.medication.findMany as ReturnType).mockResolvedValue( + [], + ); + ( + prisma.medicationIntakeEvent.findMany as ReturnType + ).mockResolvedValue([]); + ( + prisma.illnessEpisode.findMany as ReturnType + ).mockResolvedValue([]); + (prisma.illnessDayLog.findMany as ReturnType).mockResolvedValue( + [], + ); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 383c5df54..e9b511687 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -35,18 +35,9 @@ import { type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { - buildComplianceMedicationContext, - buildMedicationComplianceBundle, - lastNonSkippedTakenAt, - SCHEDULE_COMPLIANCE_SELECT, -} from "@/lib/analytics/compliance"; -import { - buildComplianceDailySeries, - buildSymptomSeverityDailySeries, - type SymptomDayLogRow, - type SymptomEpisodeSpan, -} from "@/lib/insights/correlation-series-builders"; -import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; + fetchComplianceSeries, + fetchSymptomSeries, +} from "@/lib/insights/correlation-channel-series"; export const dynamic = "force-dynamic"; @@ -81,148 +72,6 @@ function toDailyMeans( .sort((a, b) => (a.day < b.day ? -1 : 1)); } -/** - * v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series. - * - * Pools every active, non-PRN medication's unified dose-history ledger over the - * window, then collapses to one per-day adherence rate (user-tz day keys). A - * user with no active medications (or no resolved slots) yields an empty - * series, so the channel degrades to absent. - */ -async function buildUserComplianceSeries( - userId: string, - since: Date, - tz: string, -): Promise { - const medications = await prisma.medication.findMany({ - // PRN (as-needed) medications have no expected doses → no defensible rate. - where: { userId, active: true, asNeeded: false }, - include: { - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - }, - orderBy: { name: "asc" }, - }); - if (medications.length === 0) { - return { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: [], - }; - } - - const events = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - deletedAt: null, - medicationId: { in: medications.map((med) => med.id) }, - scheduledFor: { gte: since }, - }, - orderBy: { scheduledFor: "asc" }, - select: { - medicationId: true, - scheduledFor: true, - takenAt: true, - skipped: true, - }, - }); - - const now = new Date(); - const ledgerRows: DoseHistoryRow[] = []; - for (const medication of medications) { - const medEvents = events.filter((e) => e.medicationId === medication.id); - const ctx = buildComplianceMedicationContext( - medication, - lastNonSkippedTakenAt(medEvents), - tz, - ); - const bundle = buildMedicationComplianceBundle( - medEvents, - medication.schedules, - ctx, - now, - ); - ledgerRows.push(...bundle.ledgerRows); - } - - return buildComplianceDailySeries(ledgerRows, tz); -} - -/** - * v1.21.0 (FDREXTEND) — build the user's SYMPTOM_SEVERITY daily series (role set - * by the caller). 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. - */ -async function buildUserSymptomSeries( - userId: string, - since: Date, - now: Date, - tz: string, -): Promise { - 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. - where: { - userId, - deletedAt: null, - onsetAt: { lte: now }, - OR: [{ resolvedAt: null }, { resolvedAt: { gte: since } }], - }, - select: { id: true, onsetAt: true, resolvedAt: true }, - }); - if (episodes.length === 0) { - return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role: "outcome", points: [] }; - } - - const dayLogRows = await prisma.illnessDayLog.findMany({ - where: { - userId, - deletedAt: null, - episodeId: { in: episodes.map((e) => e.id) }, - }, - select: { - date: true, - functionalImpact: true, - symptomLinks: { select: { severity: true } }, - }, - }); - - // Collapse each day-log to one burden value (functionalImpact, else the day's - // max linked symptom severity) — the same rule the recovery-gap track uses. - const dayLogs: SymptomDayLogRow[] = []; - for (const row of dayLogRows) { - if (row.functionalImpact != null) { - dayLogs.push({ day: row.date, impact: row.functionalImpact }); - continue; - } - let maxSeverity: number | null = null; - for (const link of row.symptomLinks) { - if (link.severity == null) continue; - maxSeverity = - maxSeverity === null - ? link.severity - : Math.max(maxSeverity, link.severity); - } - if (maxSeverity != null) - dayLogs.push({ day: row.date, impact: maxSeverity }); - } - - const spans: SymptomEpisodeSpan[] = episodes.map((e) => ({ - onsetAt: e.onsetAt, - resolvedAt: e.resolvedAt, - })); - - return buildSymptomSeverityDailySeries({ - dayLogs, - episodes: spans, - tz, - windowStart: since, - windowEnd: now, - role: "outcome", - }); -} - export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -298,8 +147,8 @@ export const GET = apiHandler(async () => { // v1.21.0 (FDREXTEND) — build the two non-measurement, non-mood channels from // their own sources. Each degrades to an empty series when the user has no // data, so the discovery loop drops the channel (it cannot clear n ≥ 20). - const complianceSeries = await buildUserComplianceSeries(user.id, since, tz); - const symptomSeries = await buildUserSymptomSeries(user.id, since, now, tz); + const complianceSeries = await fetchComplianceSeries(user.id, tz, since); + const symptomSeries = await fetchSymptomSeries(user.id, tz, since); const points = (key: string): DailySeriesPoint[] => key === "MOOD" diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index b6e813f83..fcda4fbed 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -8,11 +8,19 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const measurementFindMany = vi.fn(); const moodFindMany = vi.fn(); const userFindUnique = vi.fn(); +const medicationFindMany = vi.fn(); +const intakeEventFindMany = vi.fn(); +const illnessEpisodeFindMany = vi.fn(); +const illnessDayLogFindMany = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, user: { findUnique: (a: unknown) => userFindUnique(a) }, + medication: { findMany: (a: unknown) => medicationFindMany(a) }, + medicationIntakeEvent: { findMany: (a: unknown) => intakeEventFindMany(a) }, + illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) }, + illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) }, }, })); @@ -34,7 +42,10 @@ vi.mock("@/lib/insights/correlation-discovery", async (importOriginal) => { await importOriginal< typeof import("@/lib/insights/correlation-discovery") >(); - return { ...actual, discoverCorrelations: () => discoverCorrelations() }; + return { + ...actual, + discoverCorrelations: (...args: unknown[]) => discoverCorrelations(...args), + }; }); import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; @@ -44,6 +55,10 @@ describe("readCoachCorrelations", () => { measurementFindMany.mockReset().mockResolvedValue([]); moodFindMany.mockReset().mockResolvedValue([]); userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" }); + medicationFindMany.mockReset().mockResolvedValue([]); + intakeEventFindMany.mockReset().mockResolvedValue([]); + illnessEpisodeFindMany.mockReset().mockResolvedValue([]); + illnessDayLogFindMany.mockReset().mockResolvedValue([]); loadBaselineProfile .mockReset() .mockResolvedValue({ ageYears: 40, sex: "MALE", heightCm: 180 }); @@ -143,4 +158,99 @@ describe("readCoachCorrelations", () => { expect(result.present).toBe(false); expect(result.reason).toBe("retrieval_failed"); }); + + // INTEGFIX — the measurement query must only ever carry real MeasurementType + // enum values; the three non-measurement channels (MOOD + the two new ones) + // are dropped, so the Postgres enum cast can never throw on a channel key. + it("never splices a non-measurement channel key into the type IN(...) filter", async () => { + discoverCorrelations.mockReturnValue({ + discovered: [], + pairsTested: 0, + fdrQ: 0.1, + minPairs: 20, + }); + await readCoachCorrelations("u1"); + const where = measurementFindMany.mock.calls[0]?.[0]?.where as { + type: { in: string[] }; + }; + const types = where.type.in; + expect(types).not.toContain("MOOD"); + expect(types).not.toContain("MEDICATION_COMPLIANCE"); + expect(types).not.toContain("SYMPTOM_SEVERITY"); + // The real enum channels are still present. + expect(types).toContain("SLEEP_DURATION"); + }); + + // INTEGFIX — drive the REAL discovery engine end-to-end with illness data so + // the SYMPTOM_SEVERITY channel flows through the non-enum query path and + // surfaces as a descriptive driver (proving the reader no longer throws an + // enum-cast error on a user with symptom data). + it("surfaces a symptom-severity driver through the real engine without an enum-cast error", async () => { + const real = await vi.importActual< + typeof import("@/lib/insights/correlation-discovery") + >("@/lib/insights/correlation-discovery"); + discoverCorrelations.mockImplementation((series) => + real.discoverCorrelations( + series as Parameters[0], + ), + ); + + // 45 consecutive days. Symptom burden (behaviour, day D) drives next-day + // sleep (outcome, day D+1) with a strong, deterministic linear relationship + // so the pair clears n ≥ 20 / p < 0.05 / FDR / effect-size floor. + const DAYS = 45; + const base = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → tz-stable day key + const dayKey = (offset: number): string => { + const d = new Date(base + offset * 86_400_000); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; + }; + + const dayLogs: Array<{ + date: string; + functionalImpact: number; + symptomLinks: Array<{ severity: number }>; + }> = []; + const measurements: Array<{ + type: string; + value: number; + measuredAt: Date; + }> = []; + for (let i = 0; i < DAYS; i++) { + const burden = i % 4; // 0..3, real 0↔>0 variance + dayLogs.push({ + date: dayKey(i), + functionalImpact: burden, + symptomLinks: [], + }); + // Next-day sleep falls as today's burden rises (8h baseline − burden). + measurements.push({ + type: "SLEEP_DURATION", + value: 8 - burden, + measuredAt: new Date(base + (i + 1) * 86_400_000), + }); + } + + measurementFindMany.mockResolvedValue(measurements); + // One episode spanning the whole window so the builder zero-fills/overlays + // every logged day. + illnessEpisodeFindMany.mockResolvedValue([ + { + id: "ep1", + onsetAt: new Date(base), + resolvedAt: new Date(base + DAYS * 86_400_000), + }, + ]); + illnessDayLogFindMany.mockResolvedValue(dayLogs); + + const result = await readCoachCorrelations("u1"); + + expect(result.present).toBe(true); + const symptomDriver = result.drivers?.find( + (d) => + d.behaviour === "symptom severity" || d.outcome === "symptom severity", + ); + expect(symptomDriver).toBeDefined(); + expect(symptomDriver?.outcome).toBe("sleep duration"); + expect(symptomDriver?.n).toBeGreaterThanOrEqual(20); + }); }); diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 131fb5890..997360678 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -27,11 +27,18 @@ import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, + discoveryMeasurementTypes, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; +import { + fetchComplianceSeries, + fetchSymptomSeries, +} from "@/lib/insights/correlation-channel-series"; import { computeCoincidentDeviation, loadBaselineProfile, @@ -106,9 +113,15 @@ function toDailyMeans( .sort((a, b) => (a.day < b.day ? -1 : 1)); } +/** Natural labels for the non-measurement channel keys (read cleanly in prose). */ +const CHANNEL_LABELS: Record = { + [MEDICATION_COMPLIANCE_CHANNEL_KEY]: "medication adherence", + [SYMPTOM_SEVERITY_CHANNEL_KEY]: "symptom severity", +}; + /** Lower-case, space-separated label from a discovery channel key. */ function humanise(key: string): string { - return key.replace(/_/g, " ").toLowerCase(); + return CHANNEL_LABELS[key] ?? key.replace(/_/g, " ").toLowerCase(); } /** @@ -129,13 +142,15 @@ export async function readCoachCorrelations( const tz = userRow?.timezone ?? "Europe/Berlin"; const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); - // MOOD is mood-entry backed, not a measurement type — strip it from the - // measurement query and source it separately, exactly like the route. - const behaviourTypes = DISCOVERY_BEHAVIOURS.filter( - (k) => k !== "MOOD", + // The non-MeasurementType channels (MOOD, MEDICATION_COMPLIANCE, + // SYMPTOM_SEVERITY) are backed by other models — `discoveryMeasurementTypes` + // drops them so the Postgres `type IN (...)` enum cast only ever sees real + // enum values; each is sourced separately below, exactly like the route. + const behaviourTypes = discoveryMeasurementTypes( + DISCOVERY_BEHAVIOURS, ) as MeasurementType[]; - const outcomeTypes = DISCOVERY_OUTCOMES.filter( - (k) => k !== "MOOD", + const outcomeTypes = discoveryMeasurementTypes( + DISCOVERY_OUTCOMES, ) as MeasurementType[]; const [measurements, moodEntries, coincidentDerived] = await Promise.all([ @@ -174,16 +189,34 @@ export async function readCoachCorrelations( tz, ); + // The two non-measurement, non-mood channels come from their own sources + // (the dose-history ledger + the illness day-log), folded in below exactly + // like the route. Each degrades to an empty series when the user has no + // data, so the discovery loop drops the channel (it cannot clear the n ≥ 20 + // floor) — it can never surface a fabricated driver. + const complianceSeries = await fetchComplianceSeries(userId, tz, since); + const symptomSeries = await fetchSymptomSeries(userId, tz, since); + const series: NamedSeries[] = []; for (const key of DISCOVERY_BEHAVIOURS) { - const points = - key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); - series.push({ key, role: "behaviour", points }); + if (key === MEDICATION_COMPLIANCE_CHANNEL_KEY) { + series.push(complianceSeries); + } else if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { + series.push({ ...symptomSeries, role: "behaviour" }); + } else { + const points = + key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); + series.push({ key, role: "behaviour", points }); + } } for (const key of DISCOVERY_OUTCOMES) { - const points = - key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); - series.push({ key, role: "outcome", points }); + if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { + series.push({ ...symptomSeries, role: "outcome" }); + } else { + const points = + key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); + series.push({ key, role: "outcome", points }); + } } const discovery = discoverCorrelations(series); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts new file mode 100644 index 000000000..7d2c65783 --- /dev/null +++ b/src/lib/insights/correlation-channel-series.ts @@ -0,0 +1,179 @@ +/** + * v1.21.0 (INTEGFIX) — shared data-fetching helpers for the two non-measurement + * FDR correlation channels (medication compliance + symptom severity). + * + * The pure series shapers live in `correlation-series-builders.ts`; the FDR + * engine itself is pure over `NamedSeries[]`. These two helpers own the DB reads + * that feed those shapers — the dose-history ledger (compliance) and the illness + * day-log (symptom severity) — so EVERY consumer of the discovery matrix (the + * `/api/insights/correlations` route AND the Coach `get_correlations` tool) + * builds the channels identically rather than re-implementing the queries, the + * tz keying, and the episode-span clamping per call site. + * + * Each helper degrades to an EMPTY series when the user has no data, so the + * channel drops out of discovery (it cannot clear the n ≥ 20 floor) rather than + * fabricating a constant. + */ +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + buildMedicationComplianceBundle, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; +import { + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, + type NamedSeries, +} from "@/lib/insights/correlation-discovery"; +import { + buildComplianceDailySeries, + buildSymptomSeverityDailySeries, + type SymptomDayLogRow, + type SymptomEpisodeSpan, +} from "@/lib/insights/correlation-series-builders"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; + +/** + * v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series. + * + * Pools every active, non-PRN medication's unified dose-history ledger over the + * window, then collapses to one per-day adherence rate (user-tz day keys). A + * user with no active medications (or no resolved slots) yields an empty + * series, so the channel degrades to absent. + */ +export async function fetchComplianceSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const medications = await prisma.medication.findMany({ + // PRN (as-needed) medications have no expected doses → no defensible rate. + where: { userId, active: true, asNeeded: false }, + include: { + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + }, + orderBy: { name: "asc" }, + }); + if (medications.length === 0) { + return { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: [], + }; + } + + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + medicationId: { in: medications.map((med) => med.id) }, + scheduledFor: { gte: since }, + }, + orderBy: { scheduledFor: "asc" }, + select: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + }, + }); + + const now = new Date(); + const ledgerRows: DoseHistoryRow[] = []; + for (const medication of medications) { + const medEvents = events.filter((e) => e.medicationId === medication.id); + const ctx = buildComplianceMedicationContext( + medication, + lastNonSkippedTakenAt(medEvents), + tz, + ); + const bundle = buildMedicationComplianceBundle( + medEvents, + medication.schedules, + ctx, + now, + ); + ledgerRows.push(...bundle.ledgerRows); + } + + return buildComplianceDailySeries(ledgerRows, tz); +} + +/** + * 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. + */ +export async function fetchSymptomSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const now = new Date(); + 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. + where: { + userId, + deletedAt: null, + onsetAt: { lte: now }, + OR: [{ resolvedAt: null }, { resolvedAt: { gte: since } }], + }, + select: { id: true, onsetAt: true, resolvedAt: true }, + }); + if (episodes.length === 0) { + return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role: "outcome", points: [] }; + } + + const dayLogRows = await prisma.illnessDayLog.findMany({ + where: { + userId, + deletedAt: null, + episodeId: { in: episodes.map((e) => e.id) }, + }, + select: { + date: true, + functionalImpact: true, + symptomLinks: { select: { severity: true } }, + }, + }); + + // Collapse each day-log to one burden value (functionalImpact, else the day's + // max linked symptom severity) — the same rule the recovery-gap track uses. + const dayLogs: SymptomDayLogRow[] = []; + for (const row of dayLogRows) { + if (row.functionalImpact != null) { + dayLogs.push({ day: row.date, impact: row.functionalImpact }); + continue; + } + let maxSeverity: number | null = null; + for (const link of row.symptomLinks) { + if (link.severity == null) continue; + maxSeverity = + maxSeverity === null + ? link.severity + : Math.max(maxSeverity, link.severity); + } + if (maxSeverity != null) + dayLogs.push({ day: row.date, impact: maxSeverity }); + } + + const spans: SymptomEpisodeSpan[] = episodes.map((e) => ({ + onsetAt: e.onsetAt, + resolvedAt: e.resolvedAt, + })); + + return buildSymptomSeverityDailySeries({ + dayLogs, + episodes: spans, + tz, + windowStart: since, + windowEnd: now, + role: "outcome", + }); +}