mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
merge(FDREXTEND): compliance + symptom as FDR correlation channels
This commit is contained in:
commit
1fa02ce4cf
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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -366,3 +366,166 @@ describe("D2-2 — effect-size floor on discovered drivers", () => {
|
||||
expect(pair!.interpretation).toMatch(/never a cause/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── FDREXTEND — medication compliance + symptom severity channels ─────
|
||||
|
||||
import {
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
} from "../correlation-discovery";
|
||||
|
||||
describe("compliance + symptom channels (FDREXTEND)", () => {
|
||||
it("registers compliance as a behaviour and symptom in both roles", () => {
|
||||
expect(DISCOVERY_BEHAVIOURS).toContain(MEDICATION_COMPLIANCE_CHANNEL_KEY);
|
||||
expect(DISCOVERY_BEHAVIOURS).toContain(SYMPTOM_SEVERITY_CHANNEL_KEY);
|
||||
expect(DISCOVERY_OUTCOMES).toContain(SYMPTOM_SEVERITY_CHANNEL_KEY);
|
||||
});
|
||||
|
||||
it("each new channel is its own metricFamily (no shared collapse)", () => {
|
||||
expect(metricFamily(MEDICATION_COMPLIANCE_CHANNEL_KEY)).toBe(
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
);
|
||||
expect(metricFamily(SYMPTOM_SEVERITY_CHANNEL_KEY)).toBe(
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
);
|
||||
// Neither shares a family with a vital / sleep / mood channel, so they can
|
||||
// pair cross-domain.
|
||||
expect(metricFamily(MEDICATION_COMPLIANCE_CHANNEL_KEY)).not.toBe(
|
||||
metricFamily(SYMPTOM_SEVERITY_CHANNEL_KEY),
|
||||
);
|
||||
expect(metricFamily(MEDICATION_COMPLIANCE_CHANNEL_KEY)).not.toBe(
|
||||
metricFamily("RESTING_HEART_RATE"),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces an adherence-dip → next-day symptom-flare link at a confident tier", () => {
|
||||
// 70 contiguous days. Lower adherence on day D drives a higher symptom
|
||||
// burden on day D+1 (the flagship cross-metric link). Adherence oscillates
|
||||
// 100/70/40; symptom[d+1] = (100 - adherence[d]) scaled into 0..3.
|
||||
const n = 70;
|
||||
const adherence = Array.from({ length: n }, (_, i) =>
|
||||
i % 3 === 0 ? 100 : i % 3 === 1 ? 70 : 40,
|
||||
);
|
||||
const symptom = [
|
||||
0,
|
||||
...adherence
|
||||
.slice(0, n - 1)
|
||||
// (100 - a) maps 0..60 → ~0..3, plus a tiny deterministic jitter so the
|
||||
// pair is not a perfect line (a real-world-shaped strong link).
|
||||
.map((a, i) => ((100 - a) / 20) * (i % 2 === 0 ? 1.0 : 0.98)),
|
||||
];
|
||||
const result = discoverCorrelations([
|
||||
{
|
||||
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
role: "behaviour",
|
||||
points: longSeries(adherence),
|
||||
},
|
||||
{
|
||||
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
role: "outcome",
|
||||
points: longSeries(symptom),
|
||||
},
|
||||
]);
|
||||
const pair = result.discovered.find(
|
||||
(p) =>
|
||||
p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY &&
|
||||
p.outcome === SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
);
|
||||
expect(pair).toBeDefined();
|
||||
expect(pair!.n).toBeGreaterThanOrEqual(20);
|
||||
// Negative: higher adherence → lower next-day symptom burden.
|
||||
expect(pair!.r).toBeLessThan(0);
|
||||
expect(pair!.tier).toBe("high");
|
||||
expect(pair!.interpretation).toMatch(/medication compliance/);
|
||||
expect(pair!.interpretation).toMatch(/symptom severity/);
|
||||
expect(pair!.interpretation).toMatch(/not a cause/);
|
||||
});
|
||||
|
||||
it("does NOT surface a link when the overlap is below the n ≥ 20 floor", () => {
|
||||
// A real adherence→symptom relationship but only ~10 paired days: sparse
|
||||
// logging must withhold, never fabricate a confident driver.
|
||||
const n = 12;
|
||||
const adherence = Array.from({ length: n }, (_, i) =>
|
||||
i % 2 === 0 ? 100 : 40,
|
||||
);
|
||||
const symptom = [
|
||||
0,
|
||||
...adherence.slice(0, n - 1).map((a) => (100 - a) / 30),
|
||||
];
|
||||
const result = discoverCorrelations([
|
||||
{
|
||||
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
role: "behaviour",
|
||||
points: longSeries(adherence),
|
||||
},
|
||||
{
|
||||
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
role: "outcome",
|
||||
points: longSeries(symptom),
|
||||
},
|
||||
]);
|
||||
// Below the floor the pair is never even tested.
|
||||
expect(result.pairsTested).toBe(0);
|
||||
expect(result.discovered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never tests the symptom → symptom self-lag", () => {
|
||||
const n = 40;
|
||||
const symptom = Array.from({ length: n }, (_, i) => (i % 4) / 2);
|
||||
const result = discoverCorrelations([
|
||||
{
|
||||
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
role: "behaviour",
|
||||
points: longSeries(symptom),
|
||||
},
|
||||
{
|
||||
key: SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
role: "outcome",
|
||||
points: longSeries(symptom),
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
result.discovered.find(
|
||||
(p) =>
|
||||
p.behaviour === SYMPTOM_SEVERITY_CHANNEL_KEY &&
|
||||
p.outcome === SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(result.pairsTested).toBe(0);
|
||||
});
|
||||
|
||||
it("compliance can pair with a vital outcome (cross-domain)", () => {
|
||||
// Adherence dip today → resting HR drift up tomorrow. Confirms the channel
|
||||
// is free to pair with vitals, not just symptom.
|
||||
const n = 70;
|
||||
const adherence = Array.from({ length: n }, (_, i) =>
|
||||
i % 3 === 0 ? 100 : i % 3 === 1 ? 65 : 35,
|
||||
);
|
||||
const rhr = [
|
||||
55,
|
||||
...adherence
|
||||
.slice(0, n - 1)
|
||||
.map((a, i) => 55 + (100 - a) / 6 + (i % 2 === 0 ? 0.1 : -0.1)),
|
||||
];
|
||||
const result = discoverCorrelations([
|
||||
{
|
||||
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
role: "behaviour",
|
||||
points: longSeries(adherence),
|
||||
},
|
||||
{
|
||||
key: "RESTING_HEART_RATE",
|
||||
role: "outcome",
|
||||
points: longSeries(rhr),
|
||||
},
|
||||
]);
|
||||
const pair = result.discovered.find(
|
||||
(p) =>
|
||||
p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY &&
|
||||
p.outcome === "RESTING_HEART_RATE",
|
||||
);
|
||||
expect(pair).toBeDefined();
|
||||
expect(pair!.r).toBeLessThan(0); // higher adherence → lower next-day RHR
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
buildComplianceDailySeries,
|
||||
buildSymptomSeverityDailySeries,
|
||||
type SymptomDayLogRow,
|
||||
type SymptomEpisodeSpan,
|
||||
} from "../correlation-series-builders";
|
||||
import {
|
||||
discoverCorrelations,
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
type NamedSeries,
|
||||
} from "../correlation-discovery";
|
||||
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
|
||||
|
||||
/** Minimal slot ledger row at a UTC instant with a given status. */
|
||||
function slot(at: string, status: DoseHistoryRow["status"]): DoseHistoryRow {
|
||||
return { kind: "slot", at: new Date(at), status } as DoseHistoryRow;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe("buildComplianceDailySeries", () => {
|
||||
it("computes a per-day rate pooled across slots, keyed in the user tz", () => {
|
||||
const rows: DoseHistoryRow[] = [
|
||||
// 2026-03-02 (Berlin): 1 taken, 1 missed → 50%.
|
||||
slot("2026-03-02T07:00:00Z", "taken_on_time"),
|
||||
slot("2026-03-02T19:00:00Z", "missed"),
|
||||
// 2026-03-03 (Berlin): both taken → 100%.
|
||||
slot("2026-03-03T07:00:00Z", "taken_on_time"),
|
||||
slot("2026-03-03T19:00:00Z", "taken_late"),
|
||||
];
|
||||
const series = buildComplianceDailySeries(rows, "Europe/Berlin");
|
||||
expect(series.key).toBe(MEDICATION_COMPLIANCE_CHANNEL_KEY);
|
||||
expect(series.role).toBe("behaviour");
|
||||
expect(series.points).toEqual([
|
||||
{ day: "2026-03-02", value: 50 },
|
||||
{ day: "2026-03-03", value: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-keys late-night instants into the correct user-tz day (not UTC)", () => {
|
||||
// 2026-03-02T23:30Z is 2026-03-03 00:30 in Berlin (UTC+1) — must bucket on
|
||||
// the Berlin day, proving the day-key is tz-aware, not a UTC slice.
|
||||
const rows: DoseHistoryRow[] = [
|
||||
slot("2026-03-02T23:30:00Z", "taken_on_time"),
|
||||
];
|
||||
const series = buildComplianceDailySeries(rows, "Europe/Berlin");
|
||||
expect(series.points).toEqual([{ day: "2026-03-03", value: 100 }]);
|
||||
});
|
||||
|
||||
it("excludes skipped / upcoming / ad-hoc rows from the denominator", () => {
|
||||
const rows: DoseHistoryRow[] = [
|
||||
slot("2026-03-02T07:00:00Z", "taken_on_time"),
|
||||
slot("2026-03-02T12:00:00Z", "skipped"),
|
||||
slot("2026-03-02T19:00:00Z", "upcoming"),
|
||||
{
|
||||
kind: "ad_hoc",
|
||||
at: new Date("2026-03-02T15:00:00Z"),
|
||||
status: "ad_hoc",
|
||||
} as DoseHistoryRow,
|
||||
];
|
||||
const series = buildComplianceDailySeries(rows, "Europe/Berlin");
|
||||
// Only the one taken slot counts → 100%, denominator 1.
|
||||
expect(series.points).toEqual([{ day: "2026-03-02", value: 100 }]);
|
||||
});
|
||||
|
||||
it("degrades to an empty series when no resolved slots exist", () => {
|
||||
const rows: DoseHistoryRow[] = [
|
||||
slot("2026-03-02T07:00:00Z", "skipped"),
|
||||
slot("2026-03-02T19:00:00Z", "upcoming"),
|
||||
];
|
||||
const series = buildComplianceDailySeries(rows, "Europe/Berlin");
|
||||
expect(series.points).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSymptomSeverityDailySeries", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
|
||||
it("zero-fills healthy days only across a real episode span", () => {
|
||||
// Episode runs 2026-03-01 → 2026-03-05. Logs: impact 2 on the 2nd, 3 on the
|
||||
// 3rd. The other in-span days fill to 0 (healthy).
|
||||
const windowStart = new Date("2026-02-01T00:00:00Z");
|
||||
const windowEnd = new Date("2026-03-31T00:00:00Z");
|
||||
const episodes: SymptomEpisodeSpan[] = [
|
||||
{
|
||||
onsetAt: new Date("2026-03-01T08:00:00Z"),
|
||||
resolvedAt: new Date("2026-03-05T08:00:00Z"),
|
||||
},
|
||||
];
|
||||
const dayLogs: SymptomDayLogRow[] = [
|
||||
{ day: "2026-03-02", impact: 2 },
|
||||
{ day: "2026-03-03", impact: 3 },
|
||||
];
|
||||
const series = buildSymptomSeverityDailySeries({
|
||||
dayLogs,
|
||||
episodes,
|
||||
tz,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
role: "outcome",
|
||||
});
|
||||
expect(series.key).toBe(SYMPTOM_SEVERITY_CHANNEL_KEY);
|
||||
expect(series.role).toBe("outcome");
|
||||
const byDay = new Map(series.points.map((p) => [p.day, p.value]));
|
||||
// Span days 01..05 present; logged days carry their impact, others 0.
|
||||
expect(byDay.get("2026-03-01")).toBe(0);
|
||||
expect(byDay.get("2026-03-02")).toBe(2);
|
||||
expect(byDay.get("2026-03-03")).toBe(3);
|
||||
expect(byDay.get("2026-03-04")).toBe(0);
|
||||
expect(byDay.get("2026-03-05")).toBe(0);
|
||||
// A day far outside the span is NOT fabricated.
|
||||
expect(byDay.has("2026-02-15")).toBe(false);
|
||||
// Genuine variance exists (0 ↔ >0), so the series is usable by Pearson.
|
||||
const values = series.points.map((p) => p.value);
|
||||
expect(Math.max(...values)).toBeGreaterThan(Math.min(...values));
|
||||
});
|
||||
|
||||
it("degrades to an empty series when the user has no episodes", () => {
|
||||
const series = buildSymptomSeverityDailySeries({
|
||||
dayLogs: [],
|
||||
episodes: [],
|
||||
tz,
|
||||
windowStart: new Date("2026-02-01T00:00:00Z"),
|
||||
windowEnd: new Date("2026-03-31T00:00:00Z"),
|
||||
role: "outcome",
|
||||
});
|
||||
expect(series.points).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clamps an ongoing episode (null resolvedAt) to the window end", () => {
|
||||
const windowStart = new Date("2026-03-01T00:00:00Z");
|
||||
const windowEnd = new Date("2026-03-04T00:00:00Z");
|
||||
const episodes: SymptomEpisodeSpan[] = [
|
||||
{ onsetAt: new Date("2026-03-02T08:00:00Z"), resolvedAt: null },
|
||||
];
|
||||
const series = buildSymptomSeverityDailySeries({
|
||||
dayLogs: [{ day: "2026-03-02", impact: 1 }],
|
||||
episodes,
|
||||
tz,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
role: "outcome",
|
||||
});
|
||||
const days = series.points.map((p) => p.day);
|
||||
// Span clamps to windowEnd (03-04); no day past it is fabricated.
|
||||
expect(days).toContain("2026-03-02");
|
||||
expect(days).toContain("2026-03-04");
|
||||
expect(days.every((d) => d <= "2026-03-04")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration — built series flow through the discovery gates", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
|
||||
it("a sparse, thin-overlap compliance series produces NO confident driver", () => {
|
||||
// Only ~8 days of compliance data: below the n ≥ 20 floor → withheld.
|
||||
const rows: DoseHistoryRow[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const day = new Date(Date.UTC(2026, 2, 1 + i, 7, 0, 0));
|
||||
rows.push(
|
||||
slot(day.toISOString(), i % 2 === 0 ? "taken_on_time" : "missed"),
|
||||
);
|
||||
}
|
||||
const compliance = buildComplianceDailySeries(rows, tz);
|
||||
|
||||
// A symptom outcome over the same thin span.
|
||||
const episodes: SymptomEpisodeSpan[] = [
|
||||
{
|
||||
onsetAt: new Date("2026-03-01T08:00:00Z"),
|
||||
resolvedAt: new Date("2026-03-09T08:00:00Z"),
|
||||
},
|
||||
];
|
||||
const symptom = buildSymptomSeverityDailySeries({
|
||||
dayLogs: Array.from({ length: 8 }, (_, i) => ({
|
||||
day: `2026-03-0${i + 1}`,
|
||||
impact: i % 2 === 0 ? 0 : 2,
|
||||
})),
|
||||
episodes,
|
||||
tz,
|
||||
windowStart: new Date("2026-02-01T00:00:00Z"),
|
||||
windowEnd: new Date("2026-03-31T00:00:00Z"),
|
||||
role: "outcome",
|
||||
});
|
||||
|
||||
const result = discoverCorrelations([compliance, symptom]);
|
||||
expect(result.pairsTested).toBe(0);
|
||||
expect(result.discovered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a sufficient adherence-dip → symptom-flare built series surfaces the link", () => {
|
||||
// 50 days of daily compliance + a 50-day episode span with logged flares on
|
||||
// low-adherence days → a discoverable next-day link.
|
||||
const start = Date.UTC(2026, 0, 1, 7, 0, 0);
|
||||
const rows: DoseHistoryRow[] = [];
|
||||
const dayLogs: SymptomDayLogRow[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const at = new Date(start + i * MS_PER_DAY);
|
||||
const low = i % 3 === 2; // every third day is a big adherence dip
|
||||
rows.push(slot(at.toISOString(), low ? "missed" : "taken_on_time"));
|
||||
// Symptom on day D+1 flares after a dip on day D.
|
||||
const prevLow = i > 0 && (i - 1) % 3 === 2;
|
||||
const dayKey = new Date(start + i * MS_PER_DAY)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
dayLogs.push({ day: dayKey, impact: prevLow ? 3 : 0 });
|
||||
}
|
||||
const compliance = buildComplianceDailySeries(rows, tz);
|
||||
const symptom = buildSymptomSeverityDailySeries({
|
||||
dayLogs,
|
||||
episodes: [
|
||||
{
|
||||
onsetAt: new Date(start),
|
||||
resolvedAt: new Date(start + 50 * MS_PER_DAY),
|
||||
},
|
||||
],
|
||||
tz,
|
||||
windowStart: new Date(start - 5 * MS_PER_DAY),
|
||||
windowEnd: new Date(start + 55 * MS_PER_DAY),
|
||||
role: "outcome",
|
||||
});
|
||||
|
||||
const result = discoverCorrelations([compliance, symptom]);
|
||||
const pair = result.discovered.find(
|
||||
(p) =>
|
||||
p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY &&
|
||||
p.outcome === SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
);
|
||||
expect(pair).toBeDefined();
|
||||
expect(pair!.n).toBeGreaterThanOrEqual(20);
|
||||
expect(pair!.r).toBeLessThan(0); // higher adherence → lower next-day symptom
|
||||
expect(pair!.interpretation).toMatch(/not a cause/);
|
||||
});
|
||||
|
||||
it("the two built channels keep the never-causal framing", () => {
|
||||
const series: NamedSeries[] = [
|
||||
buildComplianceDailySeries(
|
||||
Array.from({ length: 40 }, (_, i) =>
|
||||
slot(
|
||||
new Date(Date.UTC(2026, 0, 1 + i, 7)).toISOString(),
|
||||
i % 4 === 0 ? "missed" : "taken_on_time",
|
||||
),
|
||||
),
|
||||
tz,
|
||||
),
|
||||
];
|
||||
// Pair against a vital with a clean linear lag so a driver surfaces.
|
||||
const start = Date.UTC(2026, 0, 1);
|
||||
series.push({
|
||||
key: "RESTING_HEART_RATE",
|
||||
role: "outcome",
|
||||
points: Array.from({ length: 40 }, (_, i) => ({
|
||||
day: new Date(start + i * MS_PER_DAY).toISOString().slice(0, 10),
|
||||
value: 55 + (i > 0 && (i - 1) % 4 === 0 ? 8 : 0) + (i % 2) * 0.1,
|
||||
})),
|
||||
});
|
||||
const result = discoverCorrelations(series);
|
||||
for (const p of result.discovered) {
|
||||
expect(p.interpretation).toMatch(/not a cause|never a cause/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,9 +26,17 @@
|
||||
* already-fetched daily series — the DB read lives in the route.
|
||||
*
|
||||
* Framing discipline: descriptive, never causal; every pair carries n, r,
|
||||
* p, and the BH-adjusted q; medication compliance is NOT yet a behaviour
|
||||
* channel (the cadence-aware per-day rate needs the compliance engine — a
|
||||
* later wave), documented here so the omission is intentional, not a gap.
|
||||
* p, and the BH-adjusted q.
|
||||
*
|
||||
* v1.21.0 (FDREXTEND) — medication compliance (a daily adherence rate from the
|
||||
* compliance engine's dose-history ledger) and symptom severity (the illness
|
||||
* day-log functional-impact / symptom-burden track) are now first-class daily
|
||||
* channels in the matrix, so the high-value "adherence dip → symptom flare" and
|
||||
* "compliance↓ → a vital drifting" links can finally be discovered. They are
|
||||
* the sparsest, noisiest inputs in the system — they flow through the SAME
|
||||
* n ≥ 20 / p < 0.05 / BH-FDR / effect-size-floor / shrinkage gates as every
|
||||
* other channel, so a thin series degrades to absent rather than to a spurious
|
||||
* link. The series builders live in `correlation-series-builders.ts`.
|
||||
*/
|
||||
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||||
|
||||
@@ -94,6 +102,14 @@ export function metricFamily(key: string): string {
|
||||
// tautology as MOOD→MOOD and is excluded too.
|
||||
if (key.startsWith(FACTOR_CHANNEL_PREFIX) || key === "MOOD") return "MOOD";
|
||||
if (key.startsWith("BLOOD_PRESSURE")) return "BLOOD_PRESSURE";
|
||||
// v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form
|
||||
// their OWN single-channel family, so the loop's same-family guard collapses
|
||||
// only the self-lag (compliance→compliance, symptom→symptom). The returned
|
||||
// key equals the channel key for both, so neither shares a family with any
|
||||
// vital / sleep / mood channel — they remain free to pair cross-domain
|
||||
// (the whole point: adherence↓ → next-day symptom↑, or compliance↓ → a vital
|
||||
// drifting). No special-case branch is needed; the `return key` fall-through
|
||||
// already isolates them. This comment documents the deliberate decision.
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -286,6 +302,62 @@ function humanise(key: string): string {
|
||||
/** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */
|
||||
export const FACTOR_CHANNEL_PREFIX = "FACTOR:";
|
||||
|
||||
/**
|
||||
* v1.21.0 (FDREXTEND) — stable channel key for the daily medication-compliance
|
||||
* adherence rate (per-day taken/scheduled, 0–100). Sourced from the compliance
|
||||
* engine's unified dose-history ledger, NOT a `MeasurementType`, so the caller
|
||||
* builds its series separately (the way MOOD is read from MoodEntry) and folds
|
||||
* it in. A BEHAVIOUR channel: the actionable, high-value direction is
|
||||
* "adherence dip today → a worse outcome tomorrow" (next-day symptom flare or a
|
||||
* vital drifting), so compliance lags the outcome by a day like every other
|
||||
* behaviour. It forms its own `metricFamily`, so the only pair the loop skips is
|
||||
* the compliance→compliance self-lag.
|
||||
*/
|
||||
export const MEDICATION_COMPLIANCE_CHANNEL_KEY = "MEDICATION_COMPLIANCE";
|
||||
|
||||
/**
|
||||
* v1.21.0 (FDREXTEND) — stable channel key for the daily symptom-severity /
|
||||
* functional-impact burden (0–3; 0 = healthy, 3 = bedbound). Sourced from the
|
||||
* illness day-log (`functionalImpact`, else max linked symptom severity), NOT a
|
||||
* `MeasurementType`. It rides BOTH roles like MOOD: as an OUTCOME it surfaces
|
||||
* "adherence dip today → symptom flare tomorrow"; as a BEHAVIOUR it surfaces
|
||||
* "symptom burden today → a vital drifting tomorrow". The same-family guard
|
||||
* skips only the symptom→symptom self-lag. The series is built only across the
|
||||
* span the user actually logs illness (healthy days inside that span = 0); a
|
||||
* user who never logs illness yields an EMPTY series that degrades to absent —
|
||||
* never a spurious all-zero constant that could fabricate a link.
|
||||
*/
|
||||
export const SYMPTOM_SEVERITY_CHANNEL_KEY = "SYMPTOM_SEVERITY";
|
||||
|
||||
/**
|
||||
* v1.21.0 (FDREXTEND) — discovery channels that are NOT `MeasurementType` enum
|
||||
* values: each is backed by a different model (MOOD → MoodEntry,
|
||||
* MEDICATION_COMPLIANCE → the dose-history ledger, SYMPTOM_SEVERITY → the
|
||||
* illness day-log). EVERY caller that derives a `MeasurementType[]` list from
|
||||
* `DISCOVERY_BEHAVIOURS` / `DISCOVERY_OUTCOMES` to feed a Prisma
|
||||
* `measurement.findMany({ where: { type: { in } } })` MUST exclude these — a
|
||||
* non-enum string in the `IN (...)` list errors the Postgres enum cast. Use
|
||||
* {@link discoveryMeasurementTypes} (or this set) rather than re-spelling the
|
||||
* `k !== "MOOD"` filter, so a future non-measurement channel cannot be missed at
|
||||
* one call site and crash its query.
|
||||
*/
|
||||
export const NON_MEASUREMENT_DISCOVERY_CHANNELS: ReadonlySet<string> = new Set([
|
||||
"MOOD",
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.21.0 (FDREXTEND) — the subset of `keys` that ARE real `MeasurementType`
|
||||
* enum values (i.e. excluding every {@link NON_MEASUREMENT_DISCOVERY_CHANNELS}
|
||||
* channel), safe to splice into a Prisma `type IN (...)` filter. The cast is the
|
||||
* caller's existing `as MeasurementType[]` assertion — this only drops the
|
||||
* non-enum keys first.
|
||||
*/
|
||||
export function discoveryMeasurementTypes(keys: readonly string[]): string[] {
|
||||
return keys.filter((k) => !NON_MEASUREMENT_DISCOVERY_CHANNELS.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the FDR-controlled discovery over the behaviour × outcome matrix.
|
||||
*
|
||||
@@ -382,9 +454,15 @@ export function discoverCorrelations(
|
||||
|
||||
/**
|
||||
* The curated discovery matrix — the channels the engine pairs. Behaviours
|
||||
* (lag sources) on the left, outcomes (lag targets) on the right. Medication
|
||||
* compliance is deliberately NOT yet a behaviour channel — its cadence-aware
|
||||
* per-day rate needs the compliance engine; folding it in is a later wave.
|
||||
* (lag sources) on the left, outcomes (lag targets) on the right.
|
||||
*
|
||||
* v1.21.0 (FDREXTEND) — medication compliance is NOW a behaviour channel (its
|
||||
* cadence-aware per-day rate comes from the compliance engine's dose-history
|
||||
* ledger; the caller builds the series and folds it in, the way MOOD is read
|
||||
* from MoodEntry). Symptom severity rides both roles (see the OUTCOMES note).
|
||||
* Both are sparse, noisy inputs — the engine's existing n ≥ 20 / p < 0.05 /
|
||||
* BH-FDR / effect-size-floor / shrinkage gates apply UNCHANGED, so a thin
|
||||
* compliance or symptom series cannot surface a confident driver.
|
||||
*/
|
||||
export const DISCOVERY_BEHAVIOURS = [
|
||||
"TIME_IN_DAYLIGHT",
|
||||
@@ -392,6 +470,12 @@ export const DISCOVERY_BEHAVIOURS = [
|
||||
"BLOOD_GLUCOSE",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"ACTIVITY_STEPS",
|
||||
// v1.21.0 (FDREXTEND) — daily adherence rate as a lag source: the high-value
|
||||
// "adherence dip today → a worse outcome tomorrow" direction.
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
// v1.21.0 (FDREXTEND) — symptom burden as a lag source too: "more symptomatic
|
||||
// today → a vital drifting tomorrow". Mirrored as an outcome below.
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
] as const;
|
||||
|
||||
export const DISCOVERY_OUTCOMES = [
|
||||
@@ -406,6 +490,11 @@ export const DISCOVERY_OUTCOMES = [
|
||||
// to the existing "mood today → next-day outcome" direction. BH-FDR
|
||||
// already controls the larger pair family this opens up.
|
||||
"MOOD",
|
||||
// v1.21.0 (FDREXTEND) — symptom severity as an OUTCOME: the flagship
|
||||
// "adherence dip today → symptom flare tomorrow" link (compliance behaviour →
|
||||
// symptom outcome). The same-family guard skips the symptom→symptom self-lag;
|
||||
// BH-FDR controls the wider family the dual-role channel opens up.
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
// Wrist temperature is a credible future OUTCOME channel (near-daily, so
|
||||
// n ≥ 20 is reachable; "did a hard workout / late alcohol raise next-night
|
||||
// temperature?"), but it is deliberately NOT a channel yet: it is
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* v1.21.0 (FDREXTEND) — daily-series builders for the two cross-metric channels
|
||||
* the FDR correlation matrix could not previously see: medication compliance
|
||||
* and symptom severity.
|
||||
*
|
||||
* These are the SPARSEST, noisiest inputs in the system, so the builders are
|
||||
* deliberately conservative:
|
||||
*
|
||||
* - **Compliance** is the per-day adherence rate (0–100) from the compliance
|
||||
* engine's unified dose-history ledger. Only days the cadence ACTUALLY
|
||||
* expected a dose produce a point (off-cadence / off-week days emit nothing),
|
||||
* so a sparse schedule never fabricates a 0%-or-100% point on a day it did
|
||||
* not dose. The ledger's `at` instant is re-keyed to the user's display tz
|
||||
* here (the engine's `dailyComplianceRatesFromLedger` keys in fixed Berlin),
|
||||
* so the day-key basis aligns with every other discovery series.
|
||||
*
|
||||
* - **Symptom severity** is the illness day-log functional-impact / symptom
|
||||
* burden (0–3). The logged days are SPARSE and, on their own, often
|
||||
* constant (a short flare logged at impact 2 for three days is a flat line
|
||||
* with no variance — Pearson cannot use it). To make the actionable
|
||||
* "adherence dip → symptom flare" link discoverable we zero-fill HEALTHY
|
||||
* days, but ONLY across the span of the user's real episodes (onset →
|
||||
* resolved/window-end) intersected with the window. A user who never logs an
|
||||
* illness episode yields an EMPTY series, so the channel DEGRADES TO ABSENT
|
||||
* rather than to a spurious all-zero constant. Within an episode span, days
|
||||
* the user didn't log are treated as 0 (healthy) — the same constant-0
|
||||
* healthy baseline the recovery-gap symptom track already uses.
|
||||
*
|
||||
* Both series then flow UNCHANGED through `discoverCorrelations` — the n ≥ 20
|
||||
* paired-day floor, the BH-FDR control, the effect-size floor, and the
|
||||
* James-Stein shrinkage all apply exactly as they do to every vital channel.
|
||||
* Nothing here computes a statistic; these are pure series shapers.
|
||||
*
|
||||
* Pure over already-fetched rows — the DB reads live in the caller (the route),
|
||||
* mirroring how the engine itself is pure over `NamedSeries[]`.
|
||||
*/
|
||||
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
|
||||
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
|
||||
import {
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
type DailySeriesPoint,
|
||||
type NamedSeries,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Build the daily MEDICATION_COMPLIANCE behaviour series from the user's pooled
|
||||
* dose-history ledger rows (every medication's `slot` rows over the window).
|
||||
*
|
||||
* One point per calendar day (user tz) that had at least one scheduled,
|
||||
* resolved slot (`taken_on_time` / `taken_late` / `missed`). The rate is the
|
||||
* day's pooled `taken / (taken + missed)`, 0–100 — the SAME numerator /
|
||||
* denominator the window tally uses. `skipped` (deliberate), `upcoming` (window
|
||||
* not open), and `ad_hoc` (off-schedule top-up) rows never enter the day's
|
||||
* counts, exactly as the engine excludes them, so a day whose only rows are
|
||||
* skips emits no point (no fabricated 0%).
|
||||
*
|
||||
* Returns a behaviour `NamedSeries`. When no day clears the slot test the
|
||||
* `points` array is empty — the discovery loop then drops the channel (it
|
||||
* cannot clear the n ≥ 20 floor), so the channel degrades to absent.
|
||||
*/
|
||||
export function buildComplianceDailySeries(
|
||||
ledgerRows: DoseHistoryRow[],
|
||||
tz: string,
|
||||
): NamedSeries {
|
||||
const byDay = new Map<string, { taken: number; missed: number }>();
|
||||
for (const row of ledgerRows) {
|
||||
// Only scheduled slots have a defensible denominator (ad-hoc top-ups are
|
||||
// excluded from the rate exactly as the ledger tally excludes them).
|
||||
if (row.kind !== "slot") continue;
|
||||
const isTaken =
|
||||
row.status === "taken_on_time" || row.status === "taken_late";
|
||||
const isMissed = row.status === "missed";
|
||||
if (!isTaken && !isMissed) continue;
|
||||
|
||||
const day = dayKeyForUserTz(row.at, tz);
|
||||
const bucket = byDay.get(day) ?? { taken: 0, missed: 0 };
|
||||
if (isTaken) bucket.taken += 1;
|
||||
else bucket.missed += 1;
|
||||
byDay.set(day, bucket);
|
||||
}
|
||||
|
||||
const points: DailySeriesPoint[] = [];
|
||||
for (const [day, { taken, missed }] of byDay) {
|
||||
const denom = taken + missed;
|
||||
if (denom === 0) continue;
|
||||
points.push({ day, value: Math.min(100, (taken / denom) * 100) });
|
||||
}
|
||||
points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
return {
|
||||
key: MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
role: "behaviour",
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
/** A logged symptom-burden day (already keyed in the user's local tz). */
|
||||
export interface SymptomDayLogRow {
|
||||
/** Stored `date` string — already a user-tz `YYYY-MM-DD` day key. */
|
||||
day: string;
|
||||
/** The day's burden (0–3): functionalImpact, else max linked severity. */
|
||||
impact: number;
|
||||
}
|
||||
|
||||
/** An episode's active span (instants), used to bound the healthy-day fill. */
|
||||
export interface SymptomEpisodeSpan {
|
||||
onsetAt: Date;
|
||||
/** NULL = ongoing → bounded by the window end at build time. */
|
||||
resolvedAt: Date | null;
|
||||
}
|
||||
|
||||
/** Inclusive `YYYY-MM-DD` day key for an instant in the user's tz. */
|
||||
function dayKey(at: Date, tz: string): string {
|
||||
return dayKeyForUserTz(at, tz);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Compute the set of in-window calendar days covered by ANY episode span
|
||||
* (onset → resolved, an ongoing episode clamped to `windowEnd`). This is
|
||||
* the only span where a "healthy = 0" claim is defensible: the user was in
|
||||
* a logging-relevant period.
|
||||
* 2. Initialise every such day to 0 (healthy baseline).
|
||||
* 3. Overlay the logged impacts (a day logged at impact k → k; multiple logs
|
||||
* 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.
|
||||
*/
|
||||
export function buildSymptomSeverityDailySeries(args: {
|
||||
dayLogs: SymptomDayLogRow[];
|
||||
episodes: SymptomEpisodeSpan[];
|
||||
tz: string;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
role: "behaviour" | "outcome";
|
||||
}): NamedSeries {
|
||||
const { dayLogs, episodes, tz, windowStart, windowEnd, role } = args;
|
||||
|
||||
const startKey = dayKey(windowStart, tz);
|
||||
const endKey = dayKey(windowEnd, tz);
|
||||
|
||||
// Step 1+2 — zero-fill the healthy baseline across episode spans (clamped to
|
||||
// the window). An episode with no in-window days contributes nothing.
|
||||
const byDay = new Map<string, number>();
|
||||
for (const ep of episodes) {
|
||||
const epStart = ep.onsetAt.getTime();
|
||||
const epEnd = (ep.resolvedAt ?? windowEnd).getTime();
|
||||
// Clamp the iteration to the window so we never fabricate days outside it.
|
||||
const from = Math.max(epStart, windowStart.getTime());
|
||||
const to = Math.min(epEnd, windowEnd.getTime());
|
||||
if (from > to) continue;
|
||||
// Walk calendar days from `from` to `to`, keying each in the user's tz.
|
||||
// Step by a day in ms; the tz-key collapses any DST hour drift to the
|
||||
// right local day (we never index by the raw ms, only by the day key).
|
||||
for (let t = from; t <= to + MS_PER_DAY; t += MS_PER_DAY) {
|
||||
const key = dayKey(new Date(t), tz);
|
||||
if (key < startKey || key > endKey) continue;
|
||||
if (!byDay.has(key)) byDay.set(key, 0);
|
||||
if (key === endKey) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
for (const log of dayLogs) {
|
||||
if (log.day < startKey || log.day > endKey) continue;
|
||||
if (!Number.isFinite(log.impact)) continue;
|
||||
const prev = byDay.get(log.day);
|
||||
byDay.set(log.day, prev == null ? log.impact : Math.max(prev, log.impact));
|
||||
}
|
||||
|
||||
const points: DailySeriesPoint[] = [...byDay.entries()]
|
||||
.map(([day, value]) => ({ day, value }))
|
||||
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
|
||||
return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import {
|
||||
discoverCorrelations,
|
||||
discoveryMeasurementTypes,
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
type DailySeriesPoint,
|
||||
@@ -103,13 +104,17 @@ export async function getRelevantCorrelationsForMetric(
|
||||
const tz = profile?.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",
|
||||
// Non-measurement channels (MOOD, and v1.21.0 MEDICATION_COMPLIANCE /
|
||||
// SYMPTOM_SEVERITY) are backed by other models — `discoveryMeasurementTypes`
|
||||
// strips them from the measurement `type IN (...)` query (a non-enum string
|
||||
// would error the cast). This per-metric-card surface does not populate the
|
||||
// compliance / symptom channels (they degrade to absent here); the
|
||||
// canonical `/api/insights/correlations` route builds them.
|
||||
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([
|
||||
|
||||
@@ -34,6 +34,7 @@ import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import {
|
||||
discoverCorrelations,
|
||||
discoveryMeasurementTypes,
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
FACTOR_CHANNEL_PREFIX,
|
||||
@@ -532,13 +533,19 @@ export async function buildPeriodNarrativeContext(
|
||||
);
|
||||
|
||||
// The full set of types any beat needs: delta metrics ∪ banded vitals ∪
|
||||
// discovery channels (minus MOOD, which is mood-entry backed).
|
||||
// discovery channels (minus the NON-measurement channels: MOOD is mood-entry
|
||||
// backed; v1.21.0 MEDICATION_COMPLIANCE / SYMPTOM_SEVERITY are
|
||||
// ledger / illness-day-log backed — none are MeasurementType enum values, so
|
||||
// `discoveryMeasurementTypes` drops them from the `type IN (...)` query).
|
||||
// Those two channels are not populated on the period-narrative surface (they
|
||||
// degrade to absent here); the canonical `/api/insights/correlations` route
|
||||
// builds them.
|
||||
const measurementTypes = Array.from(
|
||||
new Set<string>([
|
||||
...DELTA_METRICS.map((m) => m.type),
|
||||
...VITALS_BASELINE_TYPES,
|
||||
...DISCOVERY_BEHAVIOURS.filter((k) => k !== "MOOD"),
|
||||
...DISCOVERY_OUTCOMES.filter((k) => k !== "MOOD"),
|
||||
...discoveryMeasurementTypes(DISCOVERY_BEHAVIOURS),
|
||||
...discoveryMeasurementTypes(DISCOVERY_OUTCOMES),
|
||||
]),
|
||||
) as MeasurementType[];
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/lib/jobs/insight-status-generate-shared";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import {
|
||||
discoveryMeasurementTypes,
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
@@ -25,10 +26,19 @@ import { annotate } from "@/lib/logging/context";
|
||||
* relation would be silently skipped for the day. `MOOD` is mood-entry backed
|
||||
* (folded separately via `includeMood`), so it is excluded from the
|
||||
* measurement-type set here.
|
||||
*
|
||||
* v1.21.0 (FDREXTEND) — `MEDICATION_COMPLIANCE` (dose-history ledger) and
|
||||
* `SYMPTOM_SEVERITY` (illness day-log) are likewise NON-measurement channels;
|
||||
* `discoveryMeasurementTypes` excludes them too, or the `type IN (...)` groupBy
|
||||
* below would try to cast a non-enum string to `MeasurementType` and error.
|
||||
* Their own data changes still flip a card's gate through their source models —
|
||||
* they simply do not belong in the measurement fingerprint set.
|
||||
*/
|
||||
const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] = Array.from(
|
||||
new Set<string>([...DISCOVERY_BEHAVIOURS, ...DISCOVERY_OUTCOMES]),
|
||||
).filter((key) => key !== "MOOD") as MeasurementType[];
|
||||
const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] =
|
||||
discoveryMeasurementTypes([
|
||||
...DISCOVERY_BEHAVIOURS,
|
||||
...DISCOVERY_OUTCOMES,
|
||||
]) as MeasurementType[];
|
||||
|
||||
/**
|
||||
* Shared cache-read for the seven `*-status.ts` insight generators.
|
||||
|
||||
Reference in new issue
Block a user