mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(INTEGFIX): wire compliance/symptom FDR channels into the Coach correlations reader
This commit is contained in:
commit
1cee07b9f0
5 files changed
+363
-170
No files matched your search
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(
|
||||
prisma.illnessEpisode.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.illnessDayLog.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -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<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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<typeof real.discoverCorrelations>[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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
[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);
|
||||
|
||||
@@ -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<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 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<NamedSeries> {
|
||||
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",
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user