mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
1 parent
1344b56faa
commit
aad72ea0e1
8 files changed
+945
-36
No files matched your search
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user