feat(insights): add compliance and symptom severity to the correlation matrix

Render daily medication-compliance rate and illness symptom-severity as
first-class series in the FDR discovery engine, so the adherence-dip →
symptom-flare link (and compliance → vital drift) can finally surface.

Compliance pools every active medication's dose-history ledger into a per-day
taken/(taken+missed) rate, re-keyed to the user's display timezone. Symptom
severity reads the illness day-log functional impact, zero-filling healthy days
only across real episode spans so a user with no episodes yields an empty
series rather than a constant. Both flow through the existing n >= 20, BH-FDR,
effect-size, and shrinkage gates unchanged; a sparse series degrades to absent.

Each channel forms its own metric family (no self-lag tautology) and keeps the
association-not-causation framing. Add discoveryMeasurementTypes() so every
caller drops the non-measurement channels before a Prisma type filter.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:42:06 +02:00
1 parent 1344b56faa
commit aad72ea0e1
8 files changed
+945 -36

No files matched your search

+196 -18
View File
@@ -26,11 +26,27 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
import type { MeasurementType } from "@/generated/prisma/client";
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 {
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";
export const dynamic = "force-dynamic";
@@ -65,6 +81,148 @@ 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<NamedSeries> {
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<NamedSeries> {
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();
@@ -85,16 +243,19 @@ export const GET = apiHandler(async () => {
select: { timezone: true },
});
const tz = profile?.timezone ?? "Europe/Berlin";
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
const now = new Date();
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
// MOOD is backed by mood entries, not measurements — it appears as both a
// behaviour and (v1.11.5 F3) an outcome channel, so filter it out of the
// measurement type list on either side.
const behaviourTypes = DISCOVERY_BEHAVIOURS.filter(
(k) => k !== "MOOD",
// Non-MeasurementType channels are backed by other models, not measurements —
// MOOD (MoodEntry), MEDICATION_COMPLIANCE (the dose-history ledger), and
// SYMPTOM_SEVERITY (the illness day-log). `discoveryMeasurementTypes` drops
// them so the `type IN (...)` query carries only real enum values; each is
// built from its own source below and folded into the series.
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] = await Promise.all([
@@ -134,20 +295,33 @@ export const GET = apiHandler(async () => {
tz,
);
// 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 points = (key: string): DailySeriesPoint[] =>
key === "MOOD"
? moodDaily
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
const series: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
const points =
key === "MOOD"
? moodDaily
: toDailyMeans(measurementsByType.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 {
series.push({ key, role: "behaviour", points: points(key) });
}
}
for (const key of DISCOVERY_OUTCOMES) {
const points =
key === "MOOD"
? moodDaily
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
series.push({ key, role: "outcome", points });
if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) {
series.push({ ...symptomSeries, role: "outcome" });
} else {
series.push({ key, role: "outcome", points: points(key) });
}
}
const result = discoverCorrelations(series);
@@ -158,6 +332,10 @@ export const GET = apiHandler(async () => {
pairs_tested: result.pairsTested,
discovered: result.discovered.length,
fdr_q: result.fdrQ,
// FDREXTEND — per-channel day-counts so a dashboard can see whether the
// two sparse new channels reached the n ≥ 20 floor or degraded to absent.
compliance_days: complianceSeries.points.length,
symptom_days: symptomSeries.points.length,
},
});