fix(insights): correct medication compliance and per-user insight bucketing

A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:

- Paused medications. A doctor-ordered pause flipped `active` off and stamped
  `pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
  and every paused day became a missed dose after resume — collapsing
  adherence across the Health Score, the BP gate, the Coach snapshot and the
  report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
  migration 0211), close the open era on resume, thread the eras through the
  compliance context and drop slots inside a paused interval from the
  denominator. Every compliance call site now fetches and passes the eras.

- Health Score eras. The Health-Score fast path was the only compliance
  caller that skipped the shared schedule select and schedule revisions, so
  after a schedule edit it scored past days against the current schedule
  while every other surface used the historical era. Switch it to the shared
  select and thread revisions (and pause eras) like the canonical path.

- Insight bucketing timezone. The per-metric status series bucketed every
  user in Europe/Berlin, so a near-midnight reading landed in the wrong day
  and the relative-day phrasing was off for non-Berlin self-hosters. Thread
  the resolved user timezone and key off `userDayKey`.

- Correlation significance. User-facing correlations surfaced from five
  paired points with strength read straight off the coefficient. Route them
  through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
  so small-N coincidences no longer render as confident findings.

- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
  raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
  label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
  daily low-percentile proxy) so the label matches the input.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 03:28:37 +02:00
1 parent 9e6a58b186
commit a827fe2ca7
33 files changed
+1007 -182

No files matched your search

@@ -128,6 +128,11 @@ beforeEach(() => {
dateOfBirth: new Date("1985-01-01"),
});
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
// The resting-pulse pairing (M-CS2) reads RESTING_HEART_RATE rows (and the
// raw-PULSE proxy only when none exist) straight from the measurement table.
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
+52 -2
View File
@@ -21,6 +21,7 @@ import {
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { getMedicationCategories } from "@/lib/medication-category";
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -263,8 +264,55 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
weight: p.b,
}));
const pulseDaily = aggregate.dailyByType.PULSE ?? [];
const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, pulseDaily);
// v1.2.5 (M-CS2) — this correlation is labelled "Resting pulse (bpm)";
// score it against the RESTING series, not the raw PULSE day-mean. The
// aggregator's `dailyByType.PULSE` is a per-day AVG that folds in workout
// HR for Apple-Health users and inflates the "resting" figure. Prefer the
// clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the
// low-percentile daily proxy) when the user has no resting rows — the
// proxy needs the per-day distribution, which the day-mean discards.
// Day keys follow this route's UTC-bucket convention (the per-user-tz
// bucketing is the documented v1.5 follow-up) so the resting series pairs
// against the same UTC day keys as the mood series.
const restingPulseRows = await prisma.measurement.findMany({
where: {
userId,
type: "RESTING_HEART_RATE",
deletedAt: null,
measuredAt: { gte: ninetyDaysAgo },
},
select: { measuredAt: true, value: true },
});
const rawPulseRowsForResting =
restingPulseRows.length === 0
? await prisma.measurement.findMany({
where: {
userId,
type: "PULSE",
deletedAt: null,
measuredAt: { gte: ninetyDaysAgo },
},
select: { measuredAt: true, value: true },
})
: [];
const utcDayKey = (d: Date) => d.toISOString().slice(0, 10);
const { series: restingPulseSeries } = resolveRestingPulseSeries({
restingSamples: restingPulseRows,
pulseSamples: rawPulseRowsForResting,
dayKeyOf: utcDayKey,
});
const restingByDay = new Map<string, { sum: number; count: number }>();
for (const s of restingPulseSeries) {
const key = utcDayKey(s.measuredAt);
const cur = restingByDay.get(key) ?? { sum: 0, count: 0 };
cur.sum += s.value;
cur.count += 1;
restingByDay.set(key, cur);
}
const restingPulseDaily = Array.from(restingByDay.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, stats]) => ({ day, value: stats.sum / stats.count }));
const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily);
const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs);
const moodPulseScatterData = moodPulsePairs.map((p) => ({
mood: p.a,
@@ -283,6 +331,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
});
const categoryMap = await getMedicationCategories(
@@ -71,6 +71,8 @@ export const GET = apiHandler(
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
});
// Privacy gate — same sealed 404 for "absent" and "not yours".
+23
View File
@@ -679,6 +679,29 @@ export const PUT = apiHandler(
}
if (!medication) throw lastUpdateErr;
// v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a
// single live marker that resume clears, so the paused window is
// irrecoverable once resumed and every paused day's slot collapses to
// "missed" in the compliance denominator. Mirror the live transition
// onto an additive era record the rate paths read: a pause opens an era
// (resumedAt null), a resume closes the latest open era. `userId` comes
// from the authenticated session; the data object is built field-by-field.
if (active === false && existing.active) {
await prisma.medicationPauseEra.create({
data: {
medicationId: id,
userId: user.id,
pausedAt: new Date(),
resumedAt: null,
},
});
} else if (active === true && !existing.active) {
await prisma.medicationPauseEra.updateMany({
where: { medicationId: id, userId: user.id, resumedAt: null },
data: { resumedAt: new Date() },
});
}
// Apply the v1.5.5 primary-schedule grace bridge after the
// medication update so a failure here doesn't roll the medication
// edit back. Re-read the row so the response shape carries the
@@ -50,6 +50,8 @@ export const GET = apiHandler(async () => {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
orderBy: { createdAt: "desc" },
});
+2
View File
@@ -256,6 +256,8 @@ async function buildScheduleAnchoredComplianceBuckets(
schedules: true,
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
});