mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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([]);
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user