mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(insights): move the comprehensive overview builder out of its route
This commit is contained in:
1 parent
2e13b7754e
commit
98875debf4
11 files changed
+863
-710
No files matched your search
@@ -177,7 +177,7 @@ const TS_READERS: Record<string, Entry> = {
|
||||
"app/api/insights/cards/route.ts": {
|
||||
exempt: "pulse is summarised from day means (readDayMeanSeries)",
|
||||
},
|
||||
"app/api/insights/comprehensive/route.ts": {
|
||||
"lib/insights/comprehensive-overview/build.ts": {
|
||||
exempt: "summarises the mood series only",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ import type { ShareScope } from "@/lib/sharing/scope";
|
||||
|
||||
import { ADMITTED_MUTATING_HANDLERS } from "../../tests/fixtures/v137/sharing-matrix";
|
||||
import { API_HANDLER, guardModuleOf } from "./helpers/auth-guards";
|
||||
import { readSourceText } from "./helpers/source-files";
|
||||
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
@@ -3316,15 +3316,30 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
// and refreshes no token. Credential policy belongs inside every resolver
|
||||
// and presence helper so a delegate or managed system job cannot select a
|
||||
// personal BYOK key or custom base URL first.
|
||||
// The route hands its body to the overview modules, so the leg reads the
|
||||
// route and every one of them: the probe is called among them, and no
|
||||
// resolver is called by any.
|
||||
const comprehensiveModules = [
|
||||
"app/api/insights/comprehensive/route.ts",
|
||||
...walkSourceFiles(join(SRC, "lib/insights/comprehensive-overview"), {
|
||||
floor: 5,
|
||||
}).map((f) => `lib/insights/comprehensive-overview/${f}`),
|
||||
];
|
||||
expect(read(comprehensiveModules[0])).toContain(
|
||||
'"@/lib/insights/comprehensive-overview/overview"',
|
||||
);
|
||||
expect(
|
||||
callsSymbol(
|
||||
"app/api/insights/comprehensive/route.ts",
|
||||
"probeProviderPresence",
|
||||
),
|
||||
comprehensiveModules.some((m) => callsSymbol(m, "probeProviderPresence")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
callsSymbol("app/api/insights/comprehensive/route.ts", "resolveProvider"),
|
||||
).toBe(false);
|
||||
for (const m of comprehensiveModules) {
|
||||
for (const name of [
|
||||
"resolveProvider",
|
||||
"resolveProviderChain",
|
||||
"probeProviderChain",
|
||||
]) {
|
||||
expect(callsSymbol(m, name), `${m} calls ${name}`).toBe(false);
|
||||
}
|
||||
}
|
||||
for (const name of [
|
||||
"resolveProvider",
|
||||
"resolveProviderChain",
|
||||
|
||||
@@ -1,59 +1,20 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { probeProviderPresence } from "@/lib/ai/provider";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
classifyBMI,
|
||||
classifyBP,
|
||||
generateAlerts,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import {
|
||||
significantPearsonCorrelation,
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { resolveMedicationCategories } from "@/lib/medication-category";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
import {
|
||||
apiHandler,
|
||||
requireRecordAuth,
|
||||
type AuthContext,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
cachedSwrWithMeta,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { readComprehensiveOverview } from "@/lib/insights/comprehensive-overview/overview";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/insights/comprehensive — the Insights overview's computed read.
|
||||
* The body is built in `src/lib/insights/comprehensive-overview/`.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
// MANAGE-level read: computed over the whole record, with no provider
|
||||
// anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
// Shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
@@ -66,37 +27,7 @@ export const GET = apiHandler(async () => {
|
||||
// provider up, still gets the overview. The `ai` block beside the body says
|
||||
// whether the briefing and status notes that decorate the overview can be
|
||||
// shown, resolved per request and never cached with the body.
|
||||
|
||||
// v1.4.35 — read-through the analytics cache keyed on
|
||||
// (userId, "comprehensive"). The /insights page mount routinely
|
||||
// fans out to this endpoint alongside the Coach drawer + the
|
||||
// recommendations grid; the 60s TTL converts every duplicate
|
||||
// mount within the window to a Map lookup. Invalidation is handled
|
||||
// by `invalidateUserMeasurements`, which marks every key under the
|
||||
// `${userId}|` prefix stale (see `lib/cache/invalidate.ts`).
|
||||
//
|
||||
// v1.16.7 — stale-while-revalidate. This is the single heaviest
|
||||
// SQL-side aggregation on the /insights mount; serving the prior
|
||||
// body instantly while ONE background recompute refreshes it keeps
|
||||
// the page interactive across the bucket's 10-minute stale window
|
||||
// (a measurement sync used to bust the entry into a blocking cold
|
||||
// rebuild on the very next mount, all day). The truly-cold first
|
||||
// read of the day still computes inline — there is no prior body to
|
||||
// serve — but it no longer recurs per visit.
|
||||
//
|
||||
// The `revalidating` flag marks a stale-served body honestly so the
|
||||
// client can poll (bounded) until the background rebuild lands —
|
||||
// mirroring the `/api/insights/generate` GET contract. It rides on
|
||||
// the response, never inside the cached body, so a warmed entry
|
||||
// doesn't freeze the flag.
|
||||
const { value: body, outcome } = await cachedSwrWithMeta(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildComprehensiveResponse>>
|
||||
>,
|
||||
`${user.id}|comprehensive`,
|
||||
() => buildComprehensiveResponse(user),
|
||||
annotate,
|
||||
);
|
||||
const { body, revalidating } = await readComprehensiveOverview(user);
|
||||
|
||||
const [briefing, statusText] = await Promise.all([
|
||||
aiCapabilityToServe(user.id, "briefing"),
|
||||
@@ -105,627 +36,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
return apiSuccess({
|
||||
...body,
|
||||
revalidating: outcome === "stale",
|
||||
revalidating,
|
||||
ai: { briefing, statusText },
|
||||
});
|
||||
});
|
||||
|
||||
type AuthedUser = AuthContext["user"];
|
||||
|
||||
/**
|
||||
* v1.4.35 — comprehensive insights response body, lifted out of the
|
||||
* route handler so `cached()` can wrap it. Replaces the previous
|
||||
* 100k-row-in-JS aggregation with SQL-side aggregates served by
|
||||
* `buildComprehensiveAggregate`. The medication-compliance block keeps
|
||||
* its bounded Prisma reads (medications + intake events) — the directive
|
||||
* called those out as already on the safe path. The byte-shape of the
|
||||
* returned envelope is unchanged from the legacy route.
|
||||
*/
|
||||
export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
const userId = user.id;
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
// v1.30.3 — the one shared per-user tz for every day-bucketing
|
||||
// pass in this route (resting-pulse proxy, medication-continuity, BP
|
||||
// pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each
|
||||
// call site.
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
// Fetch user profile (height + DOB drive BMI + BP targets).
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
},
|
||||
});
|
||||
|
||||
// ── Aggregate-first reads ─────────────────────────────────
|
||||
// SQL-side rollup replaces the 100k-row findMany on the legacy
|
||||
// route. Returns per-type DataSummary identical to the JS
|
||||
// `summarize()` semantics over the 90-day window, plus the bounded
|
||||
// raw rows the BP target adherence pairing needs, plus daily-mean
|
||||
// buckets for the correlation pairings.
|
||||
const aggregate = await buildComprehensiveAggregate(userId);
|
||||
|
||||
// v1.4.40 — swap the 90-day unbounded `prisma.moodEntry.findMany`
|
||||
// for the persistent mood-rollup DAY tier (audit Critical Finding
|
||||
// #2). The two consumers downstream — `dailyMoodEntries` for the
|
||||
// mood × metric correlations and `moodSummary` for the dashboard
|
||||
// mood block — read at a per-day resolution, which is precisely the
|
||||
// shape the rollup writer emits.
|
||||
//
|
||||
// Coverage-fallback: when the user has mood entries but zero rollup
|
||||
// rows yet (legacy account before the boot-time backfill has caught
|
||||
// up), fall back to a bounded 90-day raw walk once and fire the
|
||||
// warm-up so the next request lands on the rollup tier. Same posture
|
||||
// as `/api/mood/analytics`.
|
||||
//
|
||||
// Since v1.32.12 the mood rollup writer mints `bucketStart` from the
|
||||
// canonical local `MoodEntry.date` label at UTC midnight, so
|
||||
// `.toISOString().slice(0, 10)` recovers the user's LOCAL calendar day —
|
||||
// byte-identical to the live fallback's `entry.date`. Both mood paths
|
||||
// therefore emit local-day keys. The mood × metric pairing partners
|
||||
// below (sys BP, weight, resting pulse) are consequently derived in the
|
||||
// user's tz (v1.32.17) so both join sides share ONE local-day key space.
|
||||
// The measurement-rollup tier itself still buckets UTC (`dailyByType`)
|
||||
// and migrates to local in a documented follow-up release; the two
|
||||
// internally-consistent UTC joins (weight × BP, BP-med continuity) are
|
||||
// left on it deliberately (see their call sites below).
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo);
|
||||
let dailyMoodEntries: Array<{ day: string; value: number }>;
|
||||
let moodDataPoints: DataPoint[];
|
||||
let moodEntryCount: number;
|
||||
if (moodRollupDayRows.length > 0) {
|
||||
dailyMoodEntries = moodRollupDayRows.map((r) => ({
|
||||
day: dateOnlyKey(r.bucketStart),
|
||||
value: Math.round(r.mean * 100) / 100,
|
||||
}));
|
||||
moodDataPoints = moodRollupDayRows.map((r) => ({
|
||||
date: r.bucketStart,
|
||||
value: r.mean,
|
||||
}));
|
||||
moodEntryCount = moodRollupDayRows.reduce((s, r) => s + r.count, 0);
|
||||
} else {
|
||||
// Coverage-fallback: legacy account with mood entries but no
|
||||
// rollup coverage yet. Bounded by the 90-day window so the walk is
|
||||
// capped even when the rollup miss happens.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
const moodByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const entry of moodEntries) {
|
||||
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
current.sum += entry.score;
|
||||
current.count += 1;
|
||||
moodByDay.set(entry.date, current);
|
||||
}
|
||||
dailyMoodEntries = Array.from(moodByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
// Feed per-day means into `summarize()` so the live-fallback path
|
||||
// shares the rollup-tier semantic (one DataPoint per day, the
|
||||
// daily mean) — mirrors the v1.4.39 mood/analytics QA UX-H1 fix.
|
||||
moodDataPoints = dailyMoodEntries.map((e) => ({
|
||||
date: new Date(`${e.day}T12:00:00.000Z`),
|
||||
value: e.value,
|
||||
}));
|
||||
moodEntryCount = moodEntries.length;
|
||||
}
|
||||
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
const summaries: Record<string, DataSummary> = aggregate.summaries;
|
||||
|
||||
// ── BMI ──────────────────────────────────────────────────
|
||||
let bmi: number | null = null;
|
||||
let bmiClassification = null;
|
||||
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
|
||||
const heightM = dbUser.heightCm / 100;
|
||||
bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
bmiClassification = classifyBMI(bmi);
|
||||
}
|
||||
|
||||
// ── BP classification (30-day average) ────────────────────
|
||||
let bpClassification = null;
|
||||
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
|
||||
if (
|
||||
summaries.BLOOD_PRESSURE_SYS?.avg30 &&
|
||||
summaries.BLOOD_PRESSURE_DIA?.avg30
|
||||
) {
|
||||
bpClassification = classifyBP(
|
||||
summaries.BLOOD_PRESSURE_SYS.avg30,
|
||||
summaries.BLOOD_PRESSURE_DIA.avg30,
|
||||
);
|
||||
}
|
||||
|
||||
// ── BP target adherence (trailing 90 days) ───────────────
|
||||
// Route through the canonical `computeBpInTargetPct` so this figure
|
||||
// shares ONE definition with the dashboard tile, the insight cards, the
|
||||
// AI snapshot and the targets endpoint (per the bp-in-target.ts
|
||||
// contract — the inline copy that used to live here drifted: it paired
|
||||
// sys+dia on the 5-minute window ONLY, dropping readings whose sys/dia
|
||||
// timestamps drift past 5 min on Withings / Apple-Health imports, which
|
||||
// skewed the share iOS renders on the Home Health Score). The helper
|
||||
// adds the same-local-day pairing fallback (v1.30.3 QA F7 — keyed on
|
||||
// the user's OWN tz, not a hardcoded Berlin day) so those readings
|
||||
// pair, and keeps the ESH-2023 band + 90/50 hypotension floor (both sys
|
||||
// AND dia at or below the age-band ceiling). Window is the aggregator's
|
||||
// 90-day raw pull — a "recent control" figure, not the all-time headline.
|
||||
let bpPctInTarget: number | null = null;
|
||||
if (bpTargets) {
|
||||
bpPctInTarget =
|
||||
computeBpInTargetPct(
|
||||
aggregate.bpRawRows.sys,
|
||||
aggregate.bpRawRows.dia,
|
||||
bpTargets,
|
||||
userTz,
|
||||
)?.pct ?? null;
|
||||
}
|
||||
|
||||
// ── Correlations: weight × Sys BP ─────────────────────────
|
||||
// Daily-key pairing (the legacy `pairByTimestamp` ran with a
|
||||
// 24-hour default tolerance — daily-mean buckets are the
|
||||
// SQL-side equivalent and the directive explicitly accepts the
|
||||
// small semantic shift to bound the raw-row footprint).
|
||||
//
|
||||
// v1.32.17 — this pair stays on the UTC `dailyByType` series: BOTH
|
||||
// sides come from `dailyByType` (weight × sys), so they share one UTC
|
||||
// day-key space and are internally consistent. Re-keying it to local
|
||||
// is R12's read-swap (it migrates with the rollup tier), NOT this
|
||||
// hotfix — doing it here would mean per-request raw re-aggregation
|
||||
// without the local-bucketed rollup behind it.
|
||||
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
|
||||
const weightBpCorrelation = significantPearsonCorrelation(weightBpPairs);
|
||||
const scatterData = weightBpPairs.map((p) => ({
|
||||
weight: p.a,
|
||||
sysBP: p.b,
|
||||
}));
|
||||
|
||||
// ── Correlations: mood × {sys BP, weight, pulse} ─────────
|
||||
// v1.32.17 — the mood series is LOCAL-day-keyed (since v1.32.12, see the
|
||||
// rollup note above). `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD
|
||||
// key, so the metric partners MUST also be local-day-keyed or every
|
||||
// evening logger west of UTC gets a lag-1 pairing sold as same-day.
|
||||
// Derive the sys / weight partners as exact local-day means from the raw
|
||||
// rows already on the request path (canonical-source deduped in the
|
||||
// aggregator), NOT from the UTC `dailyByType` — a UTC-day MEAN cannot be
|
||||
// relabeled into a local-day mean. `round2` mirrors the aggregator's
|
||||
// `ROUND(AVG, 2)` so correlation thresholds don't drift.
|
||||
const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz);
|
||||
const weightDailyLocal = deriveDailyLocalMeans(
|
||||
aggregate.weightRawRows,
|
||||
userTz,
|
||||
);
|
||||
|
||||
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal);
|
||||
const moodBpCorrelation = significantPearsonCorrelation(moodBpPairs);
|
||||
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
|
||||
|
||||
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal);
|
||||
const moodWeightCorrelation = significantPearsonCorrelation(moodWeightPairs);
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({
|
||||
mood: p.a,
|
||||
weight: p.b,
|
||||
}));
|
||||
|
||||
// v1.2.5 — 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.
|
||||
//
|
||||
// v1.32.17 — key this resting series on `userDayKey(d, userTz)`. Its ONLY
|
||||
// pairing partner is `dailyMoodEntries`, which is LOCAL-day-keyed since
|
||||
// v1.32.12 (the mood rollup writer now mints `bucketStart` from the
|
||||
// canonical local `MoodEntry.date` label, not a UTC truncation). Keeping
|
||||
// UTC here would offset every evening logger west of UTC by one day and
|
||||
// sell a lag-1 correlation as same-day. This is exactly the flip the
|
||||
// v1.30.3 F8 note said becomes correct once the mood side goes local —
|
||||
// it now has. (Unlike the mood side, this series builds from raw rows in
|
||||
// this route, so it re-keys directly with no rollup-tier change.)
|
||||
const restingPulseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "RESTING_HEART_RATE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
//
|
||||
// #1023 — the proxy folds in Postgres. It needs one percentile per day, and
|
||||
// reading the raw samples to get it walked 130 000 rows per page view on a
|
||||
// watch that records once a minute, blocking the event loop for over a
|
||||
// second. `readRestingPulseProxy` returns the same series one row per day.
|
||||
const localDayKey = (d: Date) => userDayKey(d, userTz);
|
||||
const restingPulseSeries =
|
||||
restingPulseRows.length > 0
|
||||
? resolveRestingPulseSeries({
|
||||
restingSamples: restingPulseRows,
|
||||
pulseSamples: [],
|
||||
dayKeyOf: localDayKey,
|
||||
}).series
|
||||
: await readRestingPulseProxy({
|
||||
userId,
|
||||
since: ninetyDaysAgo,
|
||||
timeZone: userTz,
|
||||
});
|
||||
const restingByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const s of restingPulseSeries) {
|
||||
const key = localDayKey(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 = significantPearsonCorrelation(moodPulsePairs);
|
||||
const moodPulseScatterData = moodPulsePairs.map((p) => ({
|
||||
mood: p.a,
|
||||
pulse: p.b,
|
||||
}));
|
||||
|
||||
// ── Medication compliance ────────────────────────────────
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// v1.25 — pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
},
|
||||
});
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
const resolvedCategories = await resolveMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
const categoryMap = Object.fromEntries(
|
||||
Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]),
|
||||
);
|
||||
|
||||
const medCompliance = [];
|
||||
const bpMedicationEvents: Array<{
|
||||
scheduledFor: Date;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
}> = [];
|
||||
const bpMedications = medications.filter(
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: { in: medications.map((m) => m.id) },
|
||||
userId,
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const ev of allEvents) {
|
||||
const list = eventsByMed.get(ev.medicationId);
|
||||
if (list) list.push(ev);
|
||||
else eventsByMed.set(ev.medicationId, [ev]);
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
}));
|
||||
// v1.7.0 — engine-routed denominator.
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
med,
|
||||
lastNonSkippedTakenAt(mapped),
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
);
|
||||
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
|
||||
medCompliance.push({
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
category: categoryMap[med.id] ?? "OTHER",
|
||||
// v1.40 (#1041) — the label of a custom category; null for a built-in.
|
||||
categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null,
|
||||
compliance7: c7.rate,
|
||||
compliance30: c30.rate,
|
||||
streak: c7.streak,
|
||||
taken7: c7.taken,
|
||||
skipped7: c7.skipped,
|
||||
missed7: c7.missed,
|
||||
});
|
||||
|
||||
if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") {
|
||||
bpMedicationEvents.push(
|
||||
...events.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Correlation: BP medication continuity × systolic BP ──
|
||||
// Uses the same daily BP_SYS series the aggregator pre-computed —
|
||||
// no second round-trip. The intake-side aggregation matches the
|
||||
// legacy code: continuity = min(1, taken / expectedPerDay), pairing
|
||||
// every day that had a sys reading.
|
||||
let bpMedicationCorrelation: {
|
||||
r: number;
|
||||
strength: string;
|
||||
n: number;
|
||||
medicationCount: number;
|
||||
} | null = null;
|
||||
const bpMedicationScatterData: Array<{
|
||||
continuityPct: number;
|
||||
sysBP: number;
|
||||
}> = [];
|
||||
|
||||
const expectedBpIntakesPerDay = bpMedications.reduce(
|
||||
(sum, med) => sum + med.schedules.length,
|
||||
0,
|
||||
);
|
||||
|
||||
if (expectedBpIntakesPerDay > 0) {
|
||||
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
|
||||
// v1.32.17 — `sysDaily` (UTC `date_trunc` from the aggregator's
|
||||
// `dailyByType`) and `takenByDay` below (intake `scheduledFor`
|
||||
// truncated to UTC) both live in the SAME UTC day-key space and are
|
||||
// internally consistent with each other — this join is NOT broken and
|
||||
// stays as is. Only the mood pairings above moved to a separately
|
||||
// derived local-day series (their partner, the mood series, is
|
||||
// local-day-keyed). This correlation migrates to local when the
|
||||
// measurement-rollup tier buckets per-user tz (R12), not in this hotfix
|
||||
// — re-keying it here without the local-bucketed rollup would mean
|
||||
// per-request raw re-aggregation.
|
||||
const sysByDay = new Map<string, number>();
|
||||
for (const row of sysDaily) {
|
||||
sysByDay.set(row.day, row.value);
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
// Days that carry an intake record of ANY kind. A logged skip is a real
|
||||
// zero and belongs in the series; a day the person never touched is not a
|
||||
// zero, it is nothing, and pairing it against that day's systolic reading
|
||||
// invented a data point they never produced. Absence is not zero, here as
|
||||
// everywhere else in this project, so the unlogged day drops out of both
|
||||
// the scatter and the coefficient rather than dragging continuity down.
|
||||
const loggedDays = new Set<string>();
|
||||
for (const event of bpMedicationEvents) {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days
|
||||
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
|
||||
loggedDays.add(dayKey);
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, avgSys] of sysByDay.entries()) {
|
||||
if (!loggedDays.has(dayKey)) continue;
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
pairs.push({
|
||||
a: continuity,
|
||||
b: avgSys,
|
||||
date: dayKeyAsUtcMidnight(dayKey),
|
||||
});
|
||||
bpMedicationScatterData.push({
|
||||
continuityPct: Math.round(continuity * 100),
|
||||
sysBP: Math.round(avgSys * 10) / 10,
|
||||
});
|
||||
}
|
||||
|
||||
const corr = significantPearsonCorrelation(pairs);
|
||||
if (corr) {
|
||||
bpMedicationCorrelation = {
|
||||
...corr,
|
||||
medicationCount: bpMedications.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Generate alerts
|
||||
const alerts = generateAlerts({
|
||||
bmi,
|
||||
bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null,
|
||||
bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null,
|
||||
bpPctInTarget,
|
||||
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
|
||||
pulseAvg30: summaries.PULSE?.avg30 ?? null,
|
||||
pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0,
|
||||
medications: medCompliance.map((m) => ({
|
||||
name: m.name,
|
||||
compliance7: m.compliance7,
|
||||
compliance30: m.compliance30,
|
||||
})),
|
||||
});
|
||||
|
||||
// Data span — derived from the earliest 90-day measurement.
|
||||
const dataSpanDays = aggregate.firstMeasurementAt
|
||||
? Math.ceil(
|
||||
(Date.now() - aggregate.firstMeasurementAt.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
moodEntries: moodEntryCount,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bmi,
|
||||
bmiClassification,
|
||||
bpClassification,
|
||||
bpPctInTarget,
|
||||
bpTargets,
|
||||
weightBpCorrelation,
|
||||
scatterData,
|
||||
bpMedicationCorrelation,
|
||||
bpMedicationScatterData,
|
||||
moodSummary,
|
||||
moodBpCorrelation,
|
||||
moodBpScatterData,
|
||||
moodWeightCorrelation,
|
||||
moodWeightScatterData,
|
||||
moodPulseCorrelation,
|
||||
moodPulseScatterData,
|
||||
medications: medCompliance,
|
||||
alerts,
|
||||
// Presence only: no client is built and no token is refreshed on a read.
|
||||
hasProvider: await probeProviderPresence(userId),
|
||||
dataSpanDays,
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* v1.32.17 — derive exact local-day mean series from raw measurement rows,
|
||||
* keyed by the user's timezone via `userDayKey`. The mood series is
|
||||
* local-day-keyed (since v1.32.12), and `pairMoodWithDaily` joins on an
|
||||
* exact YYYY-MM-DD key, so its metric partners must be local-day-keyed too.
|
||||
* A UTC-day mean (from `dailyByType`) cannot be relabeled into a local-day
|
||||
* mean, so the mean is recomputed here from raw rows already on the request
|
||||
* path. `userDayKey` formats via a memoised `Intl.DateTimeFormat` — no
|
||||
* offset arithmetic — so DST-transition days (23 h / 25 h) bucket correctly
|
||||
* and the result is host-TZ-independent (CI runs UTC). `round2` mirrors the
|
||||
* aggregator's `ROUND(AVG, 2)` so correlation thresholds don't drift.
|
||||
* Returns `{ day, value }[]` sorted ASC by day, the shape
|
||||
* `pairMoodWithDaily` expects.
|
||||
*/
|
||||
function deriveDailyLocalMeans(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
): Array<{ day: string; value: number }> {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
const key = userDayKey(r.measuredAt, tz);
|
||||
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += r.value;
|
||||
cur.count += 1;
|
||||
byDay.set(key, cur);
|
||||
}
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily-key inner join between two daily-aggregated series. Returns
|
||||
* `PairedPoint` shapes compatible with `significantPearsonCorrelation`.
|
||||
*
|
||||
* Inputs are sorted ASC by `day` (the aggregator's ORDER BY
|
||||
* guarantees that). The walk is O(n + m) merge-join style instead of
|
||||
* Map.lookup-per-element so it scales with the worst-case 90 buckets
|
||||
* x 90 buckets case without surprises.
|
||||
*/
|
||||
function joinDailyByDay(
|
||||
a: Array<{ day: string; value: number }>,
|
||||
b: Array<{ day: string; value: number }>,
|
||||
): PairedPoint[] {
|
||||
const pairs: PairedPoint[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
const ai = a[i];
|
||||
const bj = b[j];
|
||||
if (ai.day === bj.day) {
|
||||
pairs.push({
|
||||
a: ai.value,
|
||||
b: bj.value,
|
||||
date: new Date(`${ai.day}T12:00:00.000Z`),
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (ai.day < bj.day) {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair the daily mood series (left side) with a daily-aggregated
|
||||
* metric (right side). Matches the legacy `buildMoodMetricPairs`
|
||||
* helper byte-for-byte: same daily YYYY-MM-DD key, same
|
||||
* `T12:00:00.000Z` anchor for the `date` field.
|
||||
*/
|
||||
function pairMoodWithDaily(
|
||||
dailyMood: Array<{ day: string; value: number }>,
|
||||
dailyMetric: Array<{ day: string; value: number }>,
|
||||
): PairedPoint[] {
|
||||
if (dailyMood.length === 0 || dailyMetric.length === 0) return [];
|
||||
const metricByDay = new Map<string, number>();
|
||||
for (const m of dailyMetric) {
|
||||
metricByDay.set(m.day, m.value);
|
||||
}
|
||||
const pairs: PairedPoint[] = [];
|
||||
for (const mood of dailyMood) {
|
||||
const metric = metricByDay.get(mood.day);
|
||||
if (metric !== undefined) {
|
||||
pairs.push({
|
||||
a: mood.value,
|
||||
b: metric,
|
||||
date: new Date(`${mood.day}T12:00:00.000Z`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ const CALL_SITES: Array<{ file: string; surface: string }> = [
|
||||
surface: "insight targets",
|
||||
},
|
||||
{
|
||||
file: "src/app/api/insights/comprehensive/route.ts",
|
||||
file: "src/lib/insights/comprehensive-overview/medication-compliance.ts",
|
||||
surface: "comprehensive insight",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The Insights overview's response body, computed from the record.
|
||||
*
|
||||
* SQL-side aggregates served by `buildComprehensiveAggregate` replace the
|
||||
* 100k-row-in-JS aggregation the route once ran; the medication-compliance
|
||||
* block keeps its bounded Prisma reads (medications + intake events). No
|
||||
* provider is called anywhere on the path: `hasProvider` is the presence
|
||||
* probe only.
|
||||
*
|
||||
* Every day-bucketing pass (resting-pulse proxy, medication continuity, BP
|
||||
* pairing) shares the one per-user tz resolved here.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { probeProviderPresence } from "@/lib/ai/provider";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
classifyBMI,
|
||||
classifyBP,
|
||||
generateAlerts,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
import { overviewCorrelations } from "./correlations";
|
||||
import {
|
||||
bpMedicationContinuity,
|
||||
readMedicationCompliance,
|
||||
} from "./medication-compliance";
|
||||
import { readOverviewMoodSeries } from "./mood-series";
|
||||
import { readRestingPulseDaily } from "./resting-pulse-daily";
|
||||
|
||||
/** Who the overview is computed for: the record, with its zone. */
|
||||
export interface OverviewRecord {
|
||||
id: string;
|
||||
timezone?: string | null;
|
||||
}
|
||||
|
||||
export type ComprehensiveOverviewBody = Awaited<
|
||||
ReturnType<typeof buildComprehensiveResponse>
|
||||
>;
|
||||
|
||||
/** BMI and the 30-day BP classification against the record's profile. */
|
||||
function classifyProfile(
|
||||
summaries: Record<string, DataSummary>,
|
||||
dbUser: { heightCm: number | null; dateOfBirth: Date | null } | null,
|
||||
) {
|
||||
let bmi: number | null = null;
|
||||
let bmiClassification = null;
|
||||
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
|
||||
const heightM = dbUser.heightCm / 100;
|
||||
bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
bmiClassification = classifyBMI(bmi);
|
||||
}
|
||||
|
||||
let bpClassification = null;
|
||||
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
|
||||
if (
|
||||
summaries.BLOOD_PRESSURE_SYS?.avg30 &&
|
||||
summaries.BLOOD_PRESSURE_DIA?.avg30
|
||||
) {
|
||||
bpClassification = classifyBP(
|
||||
summaries.BLOOD_PRESSURE_SYS.avg30,
|
||||
summaries.BLOOD_PRESSURE_DIA.avg30,
|
||||
);
|
||||
}
|
||||
return { bmi, bmiClassification, bpClassification, bpTargets };
|
||||
}
|
||||
|
||||
export async function buildComprehensiveResponse(user: OverviewRecord) {
|
||||
const userId = user.id;
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
// Height + DOB drive BMI + BP targets.
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
},
|
||||
});
|
||||
|
||||
// SQL-side rollup: per-type DataSummary identical to the JS `summarize()`
|
||||
// semantics over the 90-day window, plus the bounded raw rows the BP target
|
||||
// adherence pairing needs, plus daily-mean buckets for the correlations.
|
||||
const aggregate = await buildComprehensiveAggregate(userId);
|
||||
|
||||
const { dailyMoodEntries, moodDataPoints, moodEntryCount } =
|
||||
await readOverviewMoodSeries(userId, ninetyDaysAgo);
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
const summaries: Record<string, DataSummary> = aggregate.summaries;
|
||||
const { bmi, bmiClassification, bpClassification, bpTargets } =
|
||||
classifyProfile(summaries, dbUser);
|
||||
|
||||
// BP target adherence over the trailing 90 days, through the canonical
|
||||
// `computeBpInTargetPct` so this figure shares ONE definition with the
|
||||
// dashboard tile, the insight cards, the AI snapshot and the targets
|
||||
// endpoint. The helper pairs sys+dia on the 5-minute window with the
|
||||
// same-local-day fallback (keyed on the user's OWN tz) and keeps the
|
||||
// ESH-2023 band + 90/50 hypotension floor. Window is the aggregator's
|
||||
// 90-day raw pull — a "recent control" figure, not the all-time headline.
|
||||
let bpPctInTarget: number | null = null;
|
||||
if (bpTargets) {
|
||||
bpPctInTarget =
|
||||
computeBpInTargetPct(
|
||||
aggregate.bpRawRows.sys,
|
||||
aggregate.bpRawRows.dia,
|
||||
bpTargets,
|
||||
userTz,
|
||||
)?.pct ?? null;
|
||||
}
|
||||
|
||||
const restingPulseDaily = await readRestingPulseDaily(
|
||||
userId,
|
||||
ninetyDaysAgo,
|
||||
userTz,
|
||||
);
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const correlations = overviewCorrelations({
|
||||
aggregate,
|
||||
dailyMoodEntries,
|
||||
restingPulseDaily,
|
||||
userTz,
|
||||
});
|
||||
|
||||
const { medications, medCompliance, bpMedications, bpMedicationEvents } =
|
||||
await readMedicationCompliance({
|
||||
userId,
|
||||
since: ninetyDaysAgo,
|
||||
timezone: user.timezone || DEFAULT_TIMEZONE,
|
||||
});
|
||||
const { bpMedicationCorrelation, bpMedicationScatterData } =
|
||||
bpMedicationContinuity({ bpMedications, bpMedicationEvents, sysDaily });
|
||||
|
||||
const alerts = generateAlerts({
|
||||
bmi,
|
||||
bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null,
|
||||
bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null,
|
||||
bpPctInTarget,
|
||||
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
|
||||
pulseAvg30: summaries.PULSE?.avg30 ?? null,
|
||||
pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0,
|
||||
medications: medCompliance.map((m) => ({
|
||||
name: m.name,
|
||||
compliance7: m.compliance7,
|
||||
compliance30: m.compliance30,
|
||||
})),
|
||||
});
|
||||
|
||||
// Data span — derived from the earliest 90-day measurement.
|
||||
const dataSpanDays = aggregate.firstMeasurementAt
|
||||
? Math.ceil(
|
||||
(Date.now() - aggregate.firstMeasurementAt.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
moodEntries: moodEntryCount,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bmi,
|
||||
bmiClassification,
|
||||
bpClassification,
|
||||
bpPctInTarget,
|
||||
bpTargets,
|
||||
weightBpCorrelation: correlations.weightBpCorrelation,
|
||||
scatterData: correlations.scatterData,
|
||||
bpMedicationCorrelation,
|
||||
bpMedicationScatterData,
|
||||
moodSummary,
|
||||
moodBpCorrelation: correlations.moodBpCorrelation,
|
||||
moodBpScatterData: correlations.moodBpScatterData,
|
||||
moodWeightCorrelation: correlations.moodWeightCorrelation,
|
||||
moodWeightScatterData: correlations.moodWeightScatterData,
|
||||
moodPulseCorrelation: correlations.moodPulseCorrelation,
|
||||
moodPulseScatterData: correlations.moodPulseScatterData,
|
||||
medications: medCompliance,
|
||||
alerts,
|
||||
// Presence only: no client is built and no token is refreshed on a read.
|
||||
hasProvider: await probeProviderPresence(userId),
|
||||
dataSpanDays,
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The overview's correlations: weight × systolic, and mood against
|
||||
* systolic, weight and resting pulse. Pure: every series is already read.
|
||||
*/
|
||||
import {
|
||||
significantPearsonCorrelation,
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import type { ComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
|
||||
import {
|
||||
deriveDailyLocalMeans,
|
||||
joinDailyByDay,
|
||||
pairMoodWithDaily,
|
||||
type DailyValue,
|
||||
} from "./daily-series";
|
||||
|
||||
export function overviewCorrelations(args: {
|
||||
aggregate: ComprehensiveAggregate;
|
||||
dailyMoodEntries: DailyValue[];
|
||||
restingPulseDaily: DailyValue[];
|
||||
userTz: string;
|
||||
}) {
|
||||
const { aggregate, dailyMoodEntries, restingPulseDaily, userTz } = args;
|
||||
|
||||
// Weight × Sys BP, daily-key pairing. Both sides come from the UTC
|
||||
// `dailyByType` series, so they share one UTC day-key space and are
|
||||
// internally consistent; re-keying it to local migrates with the rollup
|
||||
// tier.
|
||||
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
|
||||
const weightBpCorrelation = significantPearsonCorrelation(weightBpPairs);
|
||||
const scatterData = weightBpPairs.map((p) => ({
|
||||
weight: p.a,
|
||||
sysBP: p.b,
|
||||
}));
|
||||
|
||||
// Mood × {sys BP, weight, pulse}. The mood series is LOCAL-day-keyed and
|
||||
// `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD key, so the partners
|
||||
// are exact local-day means from the raw rows already on the request path
|
||||
// (canonical-source deduped in the aggregator), NOT the UTC `dailyByType` —
|
||||
// a UTC-day MEAN cannot be relabeled into a local-day mean.
|
||||
const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz);
|
||||
const weightDailyLocal = deriveDailyLocalMeans(
|
||||
aggregate.weightRawRows,
|
||||
userTz,
|
||||
);
|
||||
|
||||
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal);
|
||||
const moodBpCorrelation = significantPearsonCorrelation(moodBpPairs);
|
||||
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
|
||||
|
||||
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal);
|
||||
const moodWeightCorrelation = significantPearsonCorrelation(moodWeightPairs);
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({
|
||||
mood: p.a,
|
||||
weight: p.b,
|
||||
}));
|
||||
|
||||
const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily);
|
||||
const moodPulseCorrelation = significantPearsonCorrelation(moodPulsePairs);
|
||||
const moodPulseScatterData = moodPulsePairs.map((p) => ({
|
||||
mood: p.a,
|
||||
pulse: p.b,
|
||||
}));
|
||||
|
||||
return {
|
||||
weightBpCorrelation,
|
||||
scatterData,
|
||||
moodBpCorrelation,
|
||||
moodBpScatterData,
|
||||
moodWeightCorrelation,
|
||||
moodWeightScatterData,
|
||||
moodPulseCorrelation,
|
||||
moodPulseScatterData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Day-keyed series helpers for the Insights overview's correlations: local
|
||||
* day means from raw rows, and the two joins that pair one daily series with
|
||||
* another.
|
||||
*/
|
||||
import type { PairedPoint } from "@/lib/analytics/correlations";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export interface DailyValue {
|
||||
day: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive exact local-day mean series from raw measurement rows, keyed by the
|
||||
* user's timezone via `userDayKey`. The mood series is local-day-keyed, and
|
||||
* `pairMoodWithDaily` joins on an exact YYYY-MM-DD key, so its metric
|
||||
* partners must be local-day-keyed too. A UTC-day mean (from `dailyByType`)
|
||||
* cannot be relabeled into a local-day mean, so the mean is recomputed here
|
||||
* from raw rows already on the request path. `userDayKey` formats via a
|
||||
* memoised `Intl.DateTimeFormat` — no offset arithmetic — so DST-transition
|
||||
* days (23 h / 25 h) bucket correctly and the result is host-TZ-independent
|
||||
* (CI runs UTC). `round2` mirrors the aggregator's `ROUND(AVG, 2)` so
|
||||
* correlation thresholds don't drift. Returns `{ day, value }[]` sorted ASC
|
||||
* by day, the shape `pairMoodWithDaily` expects.
|
||||
*/
|
||||
export function deriveDailyLocalMeans(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
): DailyValue[] {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
const key = userDayKey(r.measuredAt, tz);
|
||||
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += r.value;
|
||||
cur.count += 1;
|
||||
byDay.set(key, cur);
|
||||
}
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily-key inner join between two daily-aggregated series. Returns
|
||||
* `PairedPoint` shapes compatible with `significantPearsonCorrelation`.
|
||||
*
|
||||
* Inputs are sorted ASC by `day` (the aggregator's ORDER BY
|
||||
* guarantees that). The walk is O(n + m) merge-join style instead of
|
||||
* Map.lookup-per-element so it scales with the worst-case 90 buckets
|
||||
* x 90 buckets case without surprises.
|
||||
*/
|
||||
export function joinDailyByDay(
|
||||
a: DailyValue[],
|
||||
b: DailyValue[],
|
||||
): PairedPoint[] {
|
||||
const pairs: PairedPoint[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
const ai = a[i];
|
||||
const bj = b[j];
|
||||
if (ai.day === bj.day) {
|
||||
pairs.push({
|
||||
a: ai.value,
|
||||
b: bj.value,
|
||||
date: new Date(`${ai.day}T12:00:00.000Z`),
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (ai.day < bj.day) {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair the daily mood series (left side) with a daily-aggregated
|
||||
* metric (right side). Matches the legacy `buildMoodMetricPairs`
|
||||
* helper byte-for-byte: same daily YYYY-MM-DD key, same
|
||||
* `T12:00:00.000Z` anchor for the `date` field.
|
||||
*/
|
||||
export function pairMoodWithDaily(
|
||||
dailyMood: DailyValue[],
|
||||
dailyMetric: DailyValue[],
|
||||
): PairedPoint[] {
|
||||
if (dailyMood.length === 0 || dailyMetric.length === 0) return [];
|
||||
const metricByDay = new Map<string, number>();
|
||||
for (const m of dailyMetric) {
|
||||
metricByDay.set(m.day, m.value);
|
||||
}
|
||||
const pairs: PairedPoint[] = [];
|
||||
for (const mood of dailyMood) {
|
||||
const metric = metricByDay.get(mood.day);
|
||||
if (metric !== undefined) {
|
||||
pairs.push({
|
||||
a: mood.value,
|
||||
b: metric,
|
||||
date: new Date(`${mood.day}T12:00:00.000Z`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* The overview's medication block: per-medication compliance over 7 and 30
|
||||
* days, and the blood-pressure medication continuity × systolic correlation
|
||||
* that pairs the BP medications' intake days with the daily systolic means.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { significantPearsonCorrelation } from "@/lib/analytics/correlations";
|
||||
import { resolveMedicationCategories } from "@/lib/medication-category";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
|
||||
import type { DailyValue } from "./daily-series";
|
||||
|
||||
interface BpIntakeEvent {
|
||||
scheduledFor: Date;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
/** Read the tracked scheduled medications with what compliance needs. */
|
||||
async function readScheduledMedications(userId: string) {
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// As-needed (PRN) medications never surface a compliance rate (no
|
||||
// expected doses).
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
include: {
|
||||
// Schedules through the shared compliance select so the configured
|
||||
// per-dose windows reach this surface like every other.
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// Archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// Pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
// The courses, so a gap between two expects nothing (#1024).
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
},
|
||||
});
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
return medicationRows.filter(expectsDoses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-medication compliance, plus the intake events of the blood-pressure
|
||||
* medications for the continuity correlation.
|
||||
*/
|
||||
export async function readMedicationCompliance(args: {
|
||||
userId: string;
|
||||
since: Date;
|
||||
timezone: string;
|
||||
}) {
|
||||
const { userId, since, timezone } = args;
|
||||
const medications = await readScheduledMedications(userId);
|
||||
|
||||
const resolvedCategories = await resolveMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
const categoryMap = Object.fromEntries(
|
||||
Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]),
|
||||
);
|
||||
|
||||
const medCompliance = [];
|
||||
const bpMedicationEvents: BpIntakeEvent[] = [];
|
||||
const bpMedications = medications.filter(
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: { in: medications.map((m) => m.id) },
|
||||
userId,
|
||||
// Tombstoned rows are excluded.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: since },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const ev of allEvents) {
|
||||
const list = eventsByMed.get(ev.medicationId);
|
||||
if (list) list.push(ev);
|
||||
else eventsByMed.set(ev.medicationId, [ev]);
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
}));
|
||||
// Engine-routed denominator.
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
med,
|
||||
lastNonSkippedTakenAt(mapped),
|
||||
timezone,
|
||||
);
|
||||
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
|
||||
medCompliance.push({
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
category: categoryMap[med.id] ?? "OTHER",
|
||||
// The label of a custom category; null for a built-in (#1041).
|
||||
categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null,
|
||||
compliance7: c7.rate,
|
||||
compliance30: c30.rate,
|
||||
streak: c7.streak,
|
||||
taken7: c7.taken,
|
||||
skipped7: c7.skipped,
|
||||
missed7: c7.missed,
|
||||
});
|
||||
|
||||
if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") {
|
||||
bpMedicationEvents.push(
|
||||
...events.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { medications, medCompliance, bpMedications, bpMedicationEvents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlation: BP medication continuity × systolic BP.
|
||||
*
|
||||
* Uses the same daily BP_SYS series the aggregator pre-computed — no second
|
||||
* round-trip. The intake-side aggregation matches the legacy code:
|
||||
* continuity = min(1, taken / expectedPerDay), pairing every day that had a
|
||||
* sys reading.
|
||||
*/
|
||||
export function bpMedicationContinuity(args: {
|
||||
bpMedications: Array<{ schedules: unknown[] }>;
|
||||
bpMedicationEvents: BpIntakeEvent[];
|
||||
sysDaily: DailyValue[];
|
||||
}) {
|
||||
const { bpMedications, bpMedicationEvents, sysDaily } = args;
|
||||
let bpMedicationCorrelation: {
|
||||
r: number;
|
||||
strength: string;
|
||||
n: number;
|
||||
medicationCount: number;
|
||||
} | null = null;
|
||||
const bpMedicationScatterData: Array<{
|
||||
continuityPct: number;
|
||||
sysBP: number;
|
||||
}> = [];
|
||||
|
||||
const expectedBpIntakesPerDay = bpMedications.reduce(
|
||||
(sum, med) => sum + med.schedules.length,
|
||||
0,
|
||||
);
|
||||
|
||||
if (expectedBpIntakesPerDay > 0) {
|
||||
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
|
||||
// `sysDaily` (UTC `date_trunc` from the aggregator's `dailyByType`) and
|
||||
// `takenByDay` below (intake `scheduledFor` truncated to UTC) both live
|
||||
// in the SAME UTC day-key space and are internally consistent with each
|
||||
// other, so this join stays UTC. Only the mood pairings moved to a
|
||||
// separately derived local-day series (their partner, the mood series,
|
||||
// is local-day-keyed). This correlation migrates to local when the
|
||||
// measurement-rollup tier buckets per-user tz; re-keying it here without
|
||||
// the local-bucketed rollup would mean per-request raw re-aggregation.
|
||||
const sysByDay = new Map<string, number>();
|
||||
for (const row of sysDaily) {
|
||||
sysByDay.set(row.day, row.value);
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
// Days that carry an intake record of ANY kind. A logged skip is a real
|
||||
// zero and belongs in the series; a day the person never touched is not a
|
||||
// zero, it is nothing, and pairing it against that day's systolic reading
|
||||
// invented a data point they never produced. Absence is not zero, here as
|
||||
// everywhere else in this project, so the unlogged day drops out of both
|
||||
// the scatter and the coefficient rather than dragging continuity down.
|
||||
const loggedDays = new Set<string>();
|
||||
for (const event of bpMedicationEvents) {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days
|
||||
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
|
||||
loggedDays.add(dayKey);
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, avgSys] of sysByDay.entries()) {
|
||||
if (!loggedDays.has(dayKey)) continue;
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
pairs.push({
|
||||
a: continuity,
|
||||
b: avgSys,
|
||||
date: dayKeyAsUtcMidnight(dayKey),
|
||||
});
|
||||
bpMedicationScatterData.push({
|
||||
continuityPct: Math.round(continuity * 100),
|
||||
sysBP: Math.round(avgSys * 10) / 10,
|
||||
});
|
||||
}
|
||||
|
||||
const corr = significantPearsonCorrelation(pairs);
|
||||
if (corr) {
|
||||
bpMedicationCorrelation = {
|
||||
...corr,
|
||||
medicationCount: bpMedications.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { bpMedicationCorrelation, bpMedicationScatterData };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The overview's 90-day mood series, read from the mood-rollup DAY tier.
|
||||
*
|
||||
* The two consumers downstream — `dailyMoodEntries` for the mood × metric
|
||||
* correlations and the mood summary for the dashboard mood block — read at a
|
||||
* per-day resolution, which is precisely the shape the rollup writer emits,
|
||||
* so the tier replaces an unbounded `prisma.moodEntry.findMany`.
|
||||
*
|
||||
* Coverage-fallback: when the user has mood entries but zero rollup rows yet
|
||||
* (legacy account before the boot-time backfill has caught up), fall back to
|
||||
* a bounded 90-day raw walk once and fire the warm-up so the next request
|
||||
* lands on the rollup tier. Same posture as `/api/mood/analytics`.
|
||||
*
|
||||
* The mood rollup writer mints `bucketStart` from the canonical local
|
||||
* `MoodEntry.date` label at UTC midnight, so `.toISOString().slice(0, 10)`
|
||||
* recovers the user's LOCAL calendar day — byte-identical to the live
|
||||
* fallback's `entry.date`. Both mood paths therefore emit local-day keys, and
|
||||
* the mood × metric pairing partners (sys BP, weight, resting pulse) are
|
||||
* derived in the user's tz so both join sides share ONE local-day key space.
|
||||
* The measurement-rollup tier itself still buckets UTC (`dailyByType`); the
|
||||
* two internally-consistent UTC joins (weight × BP, BP-med continuity) are
|
||||
* left on it deliberately (see their call sites in `build.ts`).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { DataPoint } from "@/lib/analytics/trends";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
import type { DailyValue } from "./daily-series";
|
||||
|
||||
export interface OverviewMoodSeries {
|
||||
dailyMoodEntries: DailyValue[];
|
||||
moodDataPoints: DataPoint[];
|
||||
moodEntryCount: number;
|
||||
}
|
||||
|
||||
export async function readOverviewMoodSeries(
|
||||
userId: string,
|
||||
since: Date,
|
||||
): Promise<OverviewMoodSeries> {
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
const moodRollupDayRows = await readMoodDayRollups(userId, since);
|
||||
if (moodRollupDayRows.length > 0) {
|
||||
return {
|
||||
dailyMoodEntries: moodRollupDayRows.map((r) => ({
|
||||
day: dateOnlyKey(r.bucketStart),
|
||||
value: Math.round(r.mean * 100) / 100,
|
||||
})),
|
||||
moodDataPoints: moodRollupDayRows.map((r) => ({
|
||||
date: r.bucketStart,
|
||||
value: r.mean,
|
||||
})),
|
||||
moodEntryCount: moodRollupDayRows.reduce((s, r) => s + r.count, 0),
|
||||
};
|
||||
}
|
||||
// Coverage-fallback: legacy account with mood entries but no
|
||||
// rollup coverage yet. Bounded by the 90-day window so the walk is
|
||||
// capped even when the rollup miss happens.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
// Tombstoned rows are excluded.
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
const moodByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const entry of moodEntries) {
|
||||
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
current.sum += entry.score;
|
||||
current.count += 1;
|
||||
moodByDay.set(entry.date, current);
|
||||
}
|
||||
const dailyMoodEntries = Array.from(moodByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
return {
|
||||
dailyMoodEntries,
|
||||
// Per-day means feed `summarize()` so the live-fallback path shares the
|
||||
// rollup-tier semantic (one DataPoint per day, the daily mean), the same
|
||||
// reading mood/analytics uses.
|
||||
moodDataPoints: dailyMoodEntries.map((e) => ({
|
||||
date: new Date(`${e.day}T12:00:00.000Z`),
|
||||
value: e.value,
|
||||
})),
|
||||
moodEntryCount: moodEntries.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The Insights overview read: the computed body through the analytics
|
||||
* cache. The route resolves the per-request AI state beside it, which is
|
||||
* never cached with the body.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
cachedSwrWithMeta,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
|
||||
import {
|
||||
buildComprehensiveResponse,
|
||||
type ComprehensiveOverviewBody,
|
||||
type OverviewRecord,
|
||||
} from "./build";
|
||||
|
||||
export async function readComprehensiveOverview(
|
||||
user: OverviewRecord,
|
||||
): Promise<{ body: ComprehensiveOverviewBody; revalidating: boolean }> {
|
||||
// Read-through the analytics cache keyed on (userId, "comprehensive"). The
|
||||
// /insights page mount routinely fans out to this endpoint alongside the
|
||||
// Coach drawer + the recommendations grid; the 60s TTL converts every
|
||||
// duplicate mount within the window to a Map lookup. Invalidation is
|
||||
// handled by `invalidateUserMeasurements`, which marks every key under the
|
||||
// `${userId}|` prefix stale (see `lib/cache/invalidate.ts`).
|
||||
//
|
||||
// Stale-while-revalidate: this is the single heaviest SQL-side aggregation
|
||||
// on the /insights mount; serving the prior body instantly while ONE
|
||||
// background recompute refreshes it keeps the page interactive across the
|
||||
// bucket's 10-minute stale window (a measurement sync used to bust the
|
||||
// entry into a blocking cold rebuild on the very next mount, all day). The
|
||||
// truly-cold first read of the day still computes inline — there is no
|
||||
// prior body to serve — but it no longer recurs per visit.
|
||||
//
|
||||
// The `revalidating` flag marks a stale-served body honestly so the client
|
||||
// can poll (bounded) until the background rebuild lands — mirroring the
|
||||
// `/api/insights/generate` GET contract. It rides on the response, never
|
||||
// inside the cached body, so a warmed entry doesn't freeze the flag.
|
||||
const { value: body, outcome } = await cachedSwrWithMeta(
|
||||
caches.analytics as ServerCache<ComprehensiveOverviewBody>,
|
||||
`${user.id}|comprehensive`,
|
||||
() => buildComprehensiveResponse(user),
|
||||
annotate,
|
||||
);
|
||||
|
||||
return { body, revalidating: outcome === "stale" };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The overview's daily resting-pulse series, the mood × pulse correlation's
|
||||
* partner.
|
||||
*
|
||||
* The correlation is labelled "Resting pulse (bpm)", so it is scored 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. The clean
|
||||
* RESTING_HEART_RATE rows win; only without them does the low-percentile
|
||||
* daily proxy over raw pulse stand in. The proxy folds in Postgres (#1023):
|
||||
* it needs one percentile per day, and reading the raw samples to get it
|
||||
* walked 130 000 rows per page view on a watch that records once a minute.
|
||||
*
|
||||
* Keyed on `userDayKey(d, userTz)`: its only pairing partner is the mood
|
||||
* series, which is local-day-keyed, and a UTC key here would offset every
|
||||
* evening logger west of UTC by one day and sell a lag-1 correlation as
|
||||
* same-day. Unlike the mood side, this series builds from raw rows, so it
|
||||
* re-keys directly with no rollup-tier change.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import type { DailyValue } from "./daily-series";
|
||||
|
||||
export async function readRestingPulseDaily(
|
||||
userId: string,
|
||||
since: Date,
|
||||
userTz: string,
|
||||
): Promise<DailyValue[]> {
|
||||
const restingPulseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "RESTING_HEART_RATE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
const localDayKey = (d: Date) => userDayKey(d, userTz);
|
||||
const restingPulseSeries =
|
||||
restingPulseRows.length > 0
|
||||
? resolveRestingPulseSeries({
|
||||
restingSamples: restingPulseRows,
|
||||
pulseSamples: [],
|
||||
dayKeyOf: localDayKey,
|
||||
}).series
|
||||
: await readRestingPulseProxy({
|
||||
userId,
|
||||
since,
|
||||
timeZone: userTz,
|
||||
});
|
||||
const restingByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const s of restingPulseSeries) {
|
||||
const key = localDayKey(s.measuredAt);
|
||||
const cur = restingByDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += s.value;
|
||||
cur.count += 1;
|
||||
restingByDay.set(key, cur);
|
||||
}
|
||||
return Array.from(restingByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({ day, value: stats.sum / stats.count }));
|
||||
}
|
||||
Reference in new issue
Block a user