mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(analytics): move the default body builder out of its route
The route keeps authentication, the slice refusal, the summaries slice, the cache read and the headers. buildAnalyticsResponse (446 lines) moves to lib/analytics/response/default-body.ts and calls one module per block, in its old order: the blood-pressure in-target windows, the glucose summaries and clinical panel, the correlation hypotheses with their stored patterns, the Health Score with its Rest Mode framing, and the sleep stages. The route falls from 761 to 155 lines. The parity walk recorded before the move is unchanged; the guards on the glucose window and the bounded reads follow the code.
This commit is contained in:
1 parent
d24c544e98
commit
51835fe1d0
10 files changed
+716
-614
No files matched your search
@@ -106,7 +106,7 @@ const ALLOWED: Record<string, string> = {
|
||||
"Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.",
|
||||
"lib/analytics/score/reader.ts::{ in: types":
|
||||
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
|
||||
'app/api/analytics/route.ts::"BLOOD_GLUCOSE"':
|
||||
'lib/analytics/response/glucose.ts::"BLOOD_GLUCOSE"':
|
||||
"The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.",
|
||||
"lib/measurements/batch-ingest/write-measurements.ts::p.row.type as MeasurementType":
|
||||
"Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.",
|
||||
|
||||
@@ -1,46 +1,19 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { analyticsQuerySchema } from "@/lib/validations/analytics";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read";
|
||||
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
|
||||
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import {
|
||||
buildSleepStageComposition,
|
||||
type SleepStageComposition,
|
||||
} from "@/lib/analytics/sleep-stage-composition";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import {
|
||||
computeBpInTargetFastPath,
|
||||
type BpInTargetEnvelope,
|
||||
} from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record";
|
||||
import { isModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateSummariesByModules } from "@/lib/dashboard/widget-modules";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
type GlucoseClinicalMetrics,
|
||||
} from "@/lib/analytics/glucose-metrics";
|
||||
import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
decisionForEvidence,
|
||||
PATTERN_FAMILIES,
|
||||
syncAcceptedPatterns,
|
||||
} from "@/lib/insights/correlation-patterns";
|
||||
buildAnalyticsResponse,
|
||||
type AnalyticsBody,
|
||||
} from "@/lib/analytics/response/default-body";
|
||||
import { enrichLastSeenDaysAgo } from "@/lib/analytics/response/last-seen";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -154,9 +127,7 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// cost a little duplication (most users have one locale).
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
const cachedBody = await cachedSwr(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildAnalyticsResponse>>
|
||||
>,
|
||||
caches.analytics as ServerCache<AnalyticsBody>,
|
||||
`${user.id}|default|${locale}`,
|
||||
() => buildAnalyticsResponse(user, locale),
|
||||
annotate,
|
||||
@@ -182,580 +153,3 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
|
||||
return response;
|
||||
});
|
||||
|
||||
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
/**
|
||||
* v1.4.38 — derive `daysAgo` from the cached `lastSeenAt` ISO string
|
||||
* per request. The cached envelope deliberately omits `daysAgo` so
|
||||
* the dashboard tile staleness caption stays correct across day
|
||||
* boundaries even when the body has been sitting in the 60s LRU.
|
||||
*
|
||||
* Returns a shallow-copied envelope with the enriched
|
||||
* `lastSeenByType` map; everything else is passed through unchanged.
|
||||
*/
|
||||
function enrichLastSeenDaysAgo<
|
||||
T extends {
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
},
|
||||
>(
|
||||
body: T,
|
||||
): Omit<T, "lastSeenByType"> & {
|
||||
lastSeenByType: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
>;
|
||||
} {
|
||||
const nowMs = Date.now();
|
||||
const enriched: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
> = {};
|
||||
for (const [type, slot] of Object.entries(body.lastSeenByType)) {
|
||||
if (slot === null) {
|
||||
enriched[type] = null;
|
||||
continue;
|
||||
}
|
||||
const lastMs = new Date(slot.lastSeenAt).getTime();
|
||||
const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000));
|
||||
enriched[type] = { lastSeenAt: slot.lastSeenAt, daysAgo };
|
||||
}
|
||||
return { ...body, lastSeenByType: enriched };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.34 — the heavy default-slice body, lifted out of the route
|
||||
* handler so `cached()` can wrap it. Returns the raw JSON payload; the
|
||||
* route handler attaches `Cache-Control` headers afterward. The split
|
||||
* matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` →
|
||||
* `buildCoachSnapshotImpl` shape.
|
||||
*/
|
||||
async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
// v1.4.37.1 — fire-and-forget the rollup-fresh check.
|
||||
// Awaiting it on the read path can stall the Node event loop for
|
||||
// 30–60 s on a power-user account whose iOS step samples keep the
|
||||
// 90-day DAY window slightly stale, which starves /api/health,
|
||||
// /api/version, and concurrent iOS calls. The downstream coverage
|
||||
// probe falls back to live SQL when a type is uncovered, so
|
||||
// correctness is preserved; the worst the user sees is data from
|
||||
// up to ~60 s ago on the very first request after a fresh
|
||||
// measurement lands, and the next request returns the up-to-date
|
||||
// value once the background refresh completes. `ensureUserRollupsFresh`
|
||||
// wraps its own try/catch, so the void call cannot reject.
|
||||
void ensureUserRollupsFresh(user.id);
|
||||
|
||||
// v1.4.37 — single per-type coverage probe shared by the
|
||||
// bp_in_target / healthScore / correlations branches below. The
|
||||
// probe is one indexed query against `measurement_rollups`; the
|
||||
// three downstream branches each reuse the resulting `coverage`
|
||||
// map to decide between the rollup-fast-path and the live
|
||||
// fallback. Probing once instead of per-branch keeps the fan-out
|
||||
// cost flat across the three branches.
|
||||
const coverage = await probeRollupCoverage(user.id);
|
||||
|
||||
// v1.4.25 — every day-bucket call inside this route honours the
|
||||
// user's display timezone via `userDayKey(date, userTz)` (the legacy
|
||||
// `berlinDayKey()` helper was retired in v1.4.40).
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
// v1.4.49.1 — replace the 15-way per-type `fetchMeasurementSeriesChunked`
|
||||
// fan-out with the slim rollup-tier reader. The previous live walk
|
||||
// against `measurements` (425-day window, ≥ 50 Prisma round-trips on a
|
||||
// 15-type tenant) saturated the `p-limit(4)` slot pool for ~8 s on the
|
||||
// production 467 k-row account — and because Prisma's 20-slot pool was
|
||||
// simultaneously claimed by those slots, the concurrent
|
||||
// `?slice=summaries` request (only ~9 small queries) queued behind it
|
||||
// and observed the same ~8 s wall-clock.
|
||||
//
|
||||
// `computeSummariesSlice` reads `measurement_rollups` DAY buckets
|
||||
// (sub-second) plus one 90-day narrow `$queryRaw` for the windowed
|
||||
// avg / slope / r² columns, and falls back to a split-aggregate live
|
||||
// path for users with incomplete rollup coverage. Response shape is
|
||||
// identical to the previous fan-out output for every field the
|
||||
// dashboard actually consumes:
|
||||
// - `count / latest / min / max / mean` from DAY buckets
|
||||
// - `avg7 / avg30 / slope7 / slope30` from the narrow query
|
||||
// - `avg30LastMonth` from the same narrow query (added in this
|
||||
// release; previously only the live walk produced it)
|
||||
// - `avg30LastYear` populated for any type whose WMY tier carries
|
||||
// the year-ago window
|
||||
// - `lastSeenByType` from the per-type latest read
|
||||
//
|
||||
// `anomalyCount` and `slope90` left this wire in v1.37.19: the slim
|
||||
// path could never compute the former (it always reported 0) and no
|
||||
// client component read the latter — the insights pipeline computes
|
||||
// both from its own `summarize()` / comprehensive-aggregator reads.
|
||||
// The one field the slim path leaves at a default value is
|
||||
// `avg30LastYear` on types with no WMY coverage, which matches the
|
||||
// pre-fix behaviour for tenants whose year-ago window happened to
|
||||
// fall outside the 425-day floor.
|
||||
const slim = await readSummariesSlice(user.id);
|
||||
// Same module gate as the slim slice and the snapshot builder — the
|
||||
// default slice feeds the dashboard too (it is the thick half of the
|
||||
// legacy two-cell path), so it cannot be the one feed that leaks a
|
||||
// disabled module's types.
|
||||
const thickModules = await resolveModuleMap(user.id);
|
||||
const thickGated = gateSummariesByModules(
|
||||
slim.summaries,
|
||||
slim.lastSeenByType,
|
||||
thickModules,
|
||||
);
|
||||
const results = thickGated.summaries;
|
||||
const lastSeenByType = thickGated.lastSeenByType;
|
||||
|
||||
// `computeSummariesSlice` annotates `action: "analytics.get.slim"`
|
||||
// for telemetry on the slim route. Restore the default-slice action
|
||||
// name so the wide-event reflects the route the client actually
|
||||
// called (otherwise every default-slice request would appear in the
|
||||
// logs under the slim action).
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// v1.4.23 — sleep-stage breakdown for the trailing 30 days. Only
|
||||
// included when the user has stage-tagged rows in window; null
|
||||
// otherwise so the UI can render a plain total without the
|
||||
// breakdown card painting empty.
|
||||
const sleepStages = await computeSleepStageBreakdown(user.id, userTz);
|
||||
|
||||
// BMI calculation
|
||||
let bmi: number | null = null;
|
||||
if (user.heightCm && results.WEIGHT?.latest) {
|
||||
const heightM = user.heightCm / 100;
|
||||
bmi = Math.round((results.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
}
|
||||
|
||||
// BP in-target percentage (auto-calculated from date of birth)
|
||||
let bpInTargetPct: number | null = null;
|
||||
let bpInTargetPct7d: number | null = null;
|
||||
let bpInTargetPct30d: number | null = null;
|
||||
let bpInTargetPctAllTime: number | null = null;
|
||||
/**
|
||||
* v1.4.22 — period-aligned prior-window
|
||||
* pcts so the BD-Zielbereich tile's comparison-overlay caption
|
||||
* stops mismatching its math with its label. The tile's
|
||||
* `compareDelta` is `last30Days - priorMonth` (or `… - priorYear`)
|
||||
* matching the user's `comparisonBaseline` selection, never
|
||||
* `last30Days - allTime` (the v1.4.22 A2 shortcut).
|
||||
*/
|
||||
let bpInTargetPctPriorMonth: number | null = null;
|
||||
let bpInTargetPctPriorYear: number | null = null;
|
||||
// v1.17 — 90-day pair count + effective label span for the tile's
|
||||
// thin-data gate. Surfaced so the BD-Zielbereich tile renders "collecting
|
||||
// data" until the window holds enough readings, with a dynamic span label.
|
||||
let bpInTargetCount90: number | null = null;
|
||||
let bpInTargetSpanDays90: number | null = null;
|
||||
// v1.17 — hold the current + prior-week BP envelopes so the shared
|
||||
// Health-Score input builder grades the pillar off the identical shape the
|
||||
// dashboard snapshot uses (one builder, no per-surface assembly drift).
|
||||
// The graded score + prior-week BP figures the score consumes are read off
|
||||
// these envelopes inside `buildHealthScoreBpInputs`.
|
||||
let bpEnvelope: BpInTargetEnvelope | null = null;
|
||||
let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null;
|
||||
let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null;
|
||||
const bpTargets = resolveEffectiveBpTargets(
|
||||
{
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
},
|
||||
user.thresholdsJson,
|
||||
);
|
||||
const clinicalBpTargets = getBpTargets(user.dateOfBirth);
|
||||
if (bpTargets) {
|
||||
const now = new Date();
|
||||
// v1.4.37 — probe-gated dispatcher replaces the inline chunked
|
||||
// read. When BOTH BP types have DAY-bucket coverage the helper
|
||||
// pairs the per-day MEAN SYS + MEAN DIA from `measurement_rollups`
|
||||
// and counts in-target days against the five reporting windows,
|
||||
// skipping the heavy 365-day chunked walk against `measurements`.
|
||||
// Falls back to the legacy `computeBpInTargetWindows` over a
|
||||
// chunked read when coverage is partial so a brand-new account
|
||||
// (no buckets yet) still sees per-event numbers. The helper emits
|
||||
// a `path: "rollup" | "live"` annotate so prod logs prove which
|
||||
// branch fired. See `bp-in-target-fast-path.ts` for the
|
||||
// documented per-day-mean approximation.
|
||||
//
|
||||
// v1.4.38 — two parallel runs: one anchored at `now` for the
|
||||
// standard windows, one anchored at `now - 7d` so the Health-Score
|
||||
// helper has a real prior-week BP pct to feed into its previous-
|
||||
// window snapshot. The two probes share the coverage map and the
|
||||
// same rollup / live branch decision; the extra call is one extra
|
||||
// pair of `readRollupBuckets` reads on the rollup path and is a
|
||||
// no-op when the user has no BP rows at all.
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all(
|
||||
[
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
// v1.4.38 — cross-tz runtime guard. The helper falls back
|
||||
// to the live SQL path when the user is more than 3 hours
|
||||
// from UTC, where the rollup table's UTC-midnight day-key
|
||||
// would slip a calendar day relative to the live aggregator's
|
||||
// window cuts.
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
],
|
||||
);
|
||||
// The pillar is graded against the CLINICAL band even when the user
|
||||
// keeps a personal target (that band is the number's identity; the
|
||||
// personal one is displayed beside it and scores nothing). So when the
|
||||
// two differ we re-run the whole helper against the clinical targets
|
||||
// and the score comes from that second run.
|
||||
//
|
||||
// v1.34.5 — the "why this number" basis rides the same envelope, so it
|
||||
// is the same run, the same representative pair and the same ceilings
|
||||
// by construction. Assign envelopes WHOLE here; picking `graded.score`
|
||||
// off one run and a basis off the other would let the popover explain
|
||||
// a number nobody is looking at, and it would look correct in review.
|
||||
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
|
||||
bpEnvelope = windows;
|
||||
bpEnvelopePriorWeek = windowsPriorWeek;
|
||||
bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks;
|
||||
} else if (clinicalBpTargets) {
|
||||
[bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] =
|
||||
await Promise.all([
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// v1.17 — the BD-Zielbereich headline standardises on the
|
||||
// trailing-90-day window (labelled "· 90 T" in the tile). All-time
|
||||
// remains carried below for the BP detail page's long view only.
|
||||
bpInTargetPct = windows.last90Days?.pct ?? null;
|
||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||
// v1.17 — count + effective span for the BD-Zielbereich tile's
|
||||
// confidence gate (thin-data → "collecting data"; dynamic span label).
|
||||
// Shared helper keeps this identical to the dashboard snapshot.
|
||||
const bpWindow = deriveBpWindow90(
|
||||
windows.last90Days,
|
||||
windows.last90EarliestAt,
|
||||
now,
|
||||
);
|
||||
bpInTargetCount90 = bpWindow.count;
|
||||
bpInTargetSpanDays90 = bpWindow.spanDays;
|
||||
}
|
||||
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
//
|
||||
// v1.4.29 — bound to the trailing 30 days. The dashboard tile
|
||||
// path only reads the trailing window; pre-bound this query
|
||||
// walked every persisted BG row a multi-year user has written.
|
||||
const glucoseSince = new Date(
|
||||
Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: glucoseSince },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
// #943 — grouped through the shared bucket resolver, so readings with no
|
||||
// meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor.
|
||||
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
|
||||
for (const [bucket, ctxRows] of groupByGlucoseContext(
|
||||
glucoseRows,
|
||||
(r) => r.glucoseContext,
|
||||
)) {
|
||||
glucoseByContext[bucket] = wireSummary(
|
||||
summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// v1.17.0 — server-authoritative glucose clinical panel. The TIR / GMI /
|
||||
// eA1C / CV% headline and the advanced J-index + LBGI/HBGI tier are computed
|
||||
// ONCE here over the declared 30-day window (matching `glucoseSince`) so the
|
||||
// insights panel, the coach snapshot, and the doctor report all render the
|
||||
// same numbers instead of each re-deriving. The learning gate keeps a thin
|
||||
// spot-data window from being asserted as a clinical AGP. Always present in
|
||||
// the payload (even with zero readings) so the client renders the calm
|
||||
// "still learning" state from a populated object rather than a missing key.
|
||||
const glucoseClinical: GlucoseClinicalMetrics = computeGlucoseClinicalMetrics(
|
||||
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
|
||||
{ windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now: new Date() },
|
||||
);
|
||||
|
||||
// v1.4.20 — three pre-defined correlation hypotheses.
|
||||
// v1.4.37 — probe-gated helper. The 28-day scan window (down
|
||||
// from 30 to keep the cold critical path tight while still
|
||||
// satisfying the n >= 20 surface gate) reads SYS / PULSE / WEIGHT
|
||||
// per-day means from `measurement_rollups` when the user has full
|
||||
// coverage; mood and medication-intake reads always stay live (no
|
||||
// rollup equivalent). The helper emits `meta.correlations.path` +
|
||||
// `meta.correlations.window_days` so prod logs prove the branch
|
||||
// selection and the truthful window.
|
||||
const correlations = await computeCorrelationHypothesesFastPath({
|
||||
userId: user.id,
|
||||
userTz,
|
||||
now: new Date(),
|
||||
coverage,
|
||||
locale,
|
||||
});
|
||||
const fixedDefinitions = [
|
||||
{
|
||||
key: "bpCompliance" as const,
|
||||
factorKey: "HYPOTHESIS:MEDICATION_COMPLIANCE",
|
||||
outcomeKey: "BLOOD_PRESSURE_SYS",
|
||||
},
|
||||
{
|
||||
key: "moodPulse" as const,
|
||||
factorKey: "HYPOTHESIS:MOOD",
|
||||
outcomeKey: "RESTING_PULSE",
|
||||
},
|
||||
{
|
||||
key: "weightWeekday" as const,
|
||||
factorKey: "HYPOTHESIS:WEEKDAY",
|
||||
outcomeKey: "WEIGHT",
|
||||
},
|
||||
];
|
||||
const acceptedFixed = fixedDefinitions.flatMap((definition) => {
|
||||
const result = correlations[definition.key];
|
||||
return result.status === "ok"
|
||||
? [
|
||||
{
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
sampleSize: result.n,
|
||||
effectSize: result.statistic,
|
||||
pValue: result.pValue,
|
||||
qValue: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const fixedDecisions = await syncAcceptedPatterns({
|
||||
userId: user.id,
|
||||
family: PATTERN_FAMILIES.fixed,
|
||||
accepted: acceptedFixed,
|
||||
});
|
||||
const correlationsWithPatterns = { ...correlations };
|
||||
for (const definition of fixedDefinitions) {
|
||||
const result = correlations[definition.key];
|
||||
if (result.status !== "ok") continue;
|
||||
const decision = decisionForEvidence(fixedDecisions, {
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
});
|
||||
correlationsWithPatterns[definition.key] = decision
|
||||
? { ...result, ...decision }
|
||||
: result;
|
||||
}
|
||||
|
||||
// v1.34 — the versioned cardiometabolic reference report. The shared reader
|
||||
// admits only evidence-backed pillars with their own windows and coverage
|
||||
// floors, then applies the three-domain breadth rule. Its BP envelopes come
|
||||
// from the same rollup/live fast path the dashboard uses; every other domain
|
||||
// resolves through the canonical reader and fails independently.
|
||||
const [
|
||||
glucoseEnabled,
|
||||
labsEnabled,
|
||||
sleepEnabled,
|
||||
mentalHealthEnabled,
|
||||
scoreSourcePriority,
|
||||
] = await Promise.all([
|
||||
isModuleEnabled(user.id, "glucose"),
|
||||
isModuleEnabled(user.id, "labs"),
|
||||
isModuleEnabled(user.id, "sleep"),
|
||||
isModuleEnabled(user.id, "mentalHealth"),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
|
||||
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
|
||||
const scoredAt = new Date();
|
||||
const healthScore = await computeAndRecordUserHealthScore({
|
||||
userId: user.id,
|
||||
now: scoredAt,
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
heightCm: user.heightCm ?? null,
|
||||
timezone: userTz,
|
||||
sourcePriorityJson: scoreSourcePriority,
|
||||
thresholdsJson: user.thresholdsJson ?? null,
|
||||
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
|
||||
},
|
||||
modules: {
|
||||
glucose: glucoseEnabled,
|
||||
labs: labsEnabled,
|
||||
sleep: sleepEnabled,
|
||||
mentalHealth: mentalHealthEnabled,
|
||||
},
|
||||
healthScoreConfigJson: user.healthScoreConfigJson,
|
||||
bpTargets: clinicalBpTargets,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks,
|
||||
});
|
||||
|
||||
// v1.18.1 — Rest Mode annotation. When an illness/condition episode is
|
||||
// active we frame the score (not penalise it): the number above is left
|
||||
// exactly as computed and we attach a value-free "you were unwell" context.
|
||||
// Resolved server-side so iOS mirrors the same fact, never recomputing it.
|
||||
// Resolved as-of the scored window (`scoredAt`), not a fresh now.
|
||||
if (healthScore) {
|
||||
const restMode = await resolveRestMode(user.id, scoredAt);
|
||||
healthScore.restMode = restMode.active
|
||||
? {
|
||||
active: true,
|
||||
since: restMode.since,
|
||||
episodeCount: restMode.episodeCount,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return {
|
||||
summaries: results,
|
||||
bmi,
|
||||
bpInTargetPct,
|
||||
bpInTargetPct7d,
|
||||
bpInTargetPct30d,
|
||||
bpInTargetPctAllTime,
|
||||
bpInTargetPctPriorMonth,
|
||||
bpInTargetPctPriorYear,
|
||||
bpInTargetCount90,
|
||||
bpInTargetSpanDays90,
|
||||
glucoseByContext: glucoseByContextOut,
|
||||
glucoseClinical: glucoseClinicalOut,
|
||||
correlations: correlationsWithPatterns,
|
||||
healthScore,
|
||||
sleepStages,
|
||||
// v1.28.x — the latest night's source-discrepancy annotation, computed
|
||||
// inside the slim slice's per-night post-pass. Additive: the dashboard
|
||||
// sleep tile marks the headline with a discreet "sources disagree"
|
||||
// hint; clients that don't read the field stay unchanged.
|
||||
sleepSourceDiscrepancy: slim.sleepSourceDiscrepancy,
|
||||
// v1.4.34 — per-type freshness map drives the dashboard's
|
||||
// staleness caption on each `<TrendCard>`. Additive: clients that
|
||||
// don't read the field stay unchanged.
|
||||
lastSeenByType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-stage sleep-minutes breakdown over the trailing 30 days.
|
||||
*
|
||||
* Returns `null` when the user has no stage-tagged sleep rows in
|
||||
* window — the analytics consumer renders the existing
|
||||
* `summaries.SLEEP_DURATION` totals without a stage card in that
|
||||
* case. Returns the sum-per-stage AND the day count covered so the
|
||||
* UI can render an "averaged across N nights" caption truthfully.
|
||||
*
|
||||
* v1.4.25 — also returns a `perNight` array: one entry per
|
||||
* Berlin-tz day inside the window, with minutes-per-stage for the
|
||||
* stacked-bar chart. Days with zero stage-tagged rows are omitted
|
||||
* from `perNight` (the chart treats them as gaps).
|
||||
*
|
||||
* The per-night stages are the MAIN NIGHT's stages. A nap ends on the
|
||||
* same wake day as that morning's night, and the per-wake-day totals
|
||||
* folded it into the night's buckets — an afternoon nap added CORE to
|
||||
* the night and the column read longer and shallower than the night
|
||||
* was. The nap's minutes now ride alongside on `napMinutes` /
|
||||
* `napCount`, both absent on a day without one.
|
||||
*/
|
||||
async function computeSleepStageBreakdown(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<SleepStageComposition | null> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const WINDOW_DAYS = 30;
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * DAY_MS);
|
||||
// v1.11.5 — read the FULL per-stage row set (not `sleepStage: { not: null }`)
|
||||
// and route it through the canonical session reconstruction so the
|
||||
// stacked-bar agrees with the dashboard tile + chart series. The old path
|
||||
// keyed each stage row by its OWN calendar day (splitting a midnight-
|
||||
// spanning night across two buckets) and never collapsed multi-source
|
||||
// (double-counting a WHOOP + Apple Health night). Routing through the
|
||||
// helper fixes both: it session-clusters, keys by the wake day, and picks
|
||||
// one canonical source per night. The bare-vs-granular de-dup is applied
|
||||
// per-session to the per-stage breakdown too. The `source` is selected so
|
||||
// the helper's source-collapse can run.
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
return buildSleepStageComposition(rows, userTz, priorityJson, WINDOW_DAYS);
|
||||
}
|
||||
|
||||
// v1.4.37 — `computeCorrelationHypotheses` body relocated to
|
||||
// `src/lib/analytics/correlations-fast-path.ts` so the probe-gated
|
||||
// rollup / live dispatcher can be unit-tested independently of the
|
||||
// route. The new helper also tightens the scan window to 28 days and
|
||||
// emits a sentinel on `meta.correlations.window_days` / `degraded`.
|
||||
|
||||
// v1.4.49.1 — the chunked `fetchMeasurementSeriesChunked` helper and
|
||||
// its supporting types / constants relocated entirely. The default
|
||||
// slice now delegates the per-type summaries work to
|
||||
// `computeSummariesSlice` (rollup-tier, sub-second on covered tenants)
|
||||
// so the legacy 15-way live walk against `measurements` no longer has
|
||||
// a caller. The slim slice was already independent of it. Deleting the
|
||||
// dead code here removes the pool-starvation surface that gated the
|
||||
// concurrent slim slice and prevents future regressions from
|
||||
// accidentally reintroducing the fan-out.
|
||||
|
||||
// v1.4.37 — `WEEKDAY_FORMATTER_CACHE`, `getWeekdayFormatter`,
|
||||
// `dateFromDayKey`, `ISO_WEEKDAY`, and `isoWeekdayInTz` relocated
|
||||
// alongside the correlation runner in
|
||||
// `src/lib/analytics/correlations-fast-path.ts`. They were only ever
|
||||
// used by the weight-weekday + day-key helpers inside the old inline
|
||||
// correlation builder.
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
const ROOT = join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
const CONSUMERS = [
|
||||
"src/app/api/analytics/route.ts",
|
||||
"src/lib/analytics/response/glucose.ts",
|
||||
"src/lib/dashboard/snapshot.ts",
|
||||
// v1.28.26 — the Coach glucose section moved into its own block module;
|
||||
// the alias derivation lives there and snapshot.ts imports the alias.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* The blood-pressure in-target block of the default analytics body: the
|
||||
* reporting-window percentages against the user's effective targets, and
|
||||
* the envelopes the Health Score grades against the clinical band.
|
||||
*/
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
|
||||
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
computeBpInTargetFastPath,
|
||||
type BpInTargetEnvelope,
|
||||
} from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import type { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
|
||||
type Coverage = Awaited<ReturnType<typeof probeRollupCoverage>>;
|
||||
|
||||
export async function readBpInTargetBlock(
|
||||
user: SessionUser,
|
||||
coverage: Coverage,
|
||||
userTz: string,
|
||||
) {
|
||||
// BP in-target percentage (auto-calculated from date of birth)
|
||||
let bpInTargetPct: number | null = null;
|
||||
let bpInTargetPct7d: number | null = null;
|
||||
let bpInTargetPct30d: number | null = null;
|
||||
let bpInTargetPctAllTime: number | null = null;
|
||||
/**
|
||||
* Period-aligned prior-window
|
||||
* pcts so the BD-Zielbereich tile's comparison-overlay caption
|
||||
* stops mismatching its math with its label. The tile's
|
||||
* `compareDelta` is `last30Days - priorMonth` (or `… - priorYear`)
|
||||
* matching the user's `comparisonBaseline` selection, never
|
||||
* `last30Days - allTime` (the v1.4.22 A2 shortcut).
|
||||
*/
|
||||
let bpInTargetPctPriorMonth: number | null = null;
|
||||
let bpInTargetPctPriorYear: number | null = null;
|
||||
// 90-day pair count + effective label span for the tile's
|
||||
// thin-data gate. Surfaced so the BD-Zielbereich tile renders "collecting
|
||||
// data" until the window holds enough readings, with a dynamic span label.
|
||||
let bpInTargetCount90: number | null = null;
|
||||
let bpInTargetSpanDays90: number | null = null;
|
||||
// Hold the current + prior-week BP envelopes so the shared
|
||||
// Health-Score input builder grades the pillar off the identical shape the
|
||||
// dashboard snapshot uses (one builder, no per-surface assembly drift).
|
||||
// The graded score + prior-week BP figures the score consumes are read off
|
||||
// these envelopes inside `buildHealthScoreBpInputs`.
|
||||
let bpEnvelope: BpInTargetEnvelope | null = null;
|
||||
let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null;
|
||||
let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null;
|
||||
const bpTargets = resolveEffectiveBpTargets(
|
||||
{
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
},
|
||||
user.thresholdsJson,
|
||||
);
|
||||
const clinicalBpTargets = getBpTargets(user.dateOfBirth);
|
||||
if (bpTargets) {
|
||||
const now = new Date();
|
||||
// Probe-gated dispatcher replaces the inline chunked
|
||||
// read. When BOTH BP types have DAY-bucket coverage the helper
|
||||
// pairs the per-day MEAN SYS + MEAN DIA from `measurement_rollups`
|
||||
// and counts in-target days against the five reporting windows,
|
||||
// skipping the heavy 365-day chunked walk against `measurements`.
|
||||
// Falls back to the legacy `computeBpInTargetWindows` over a
|
||||
// chunked read when coverage is partial so a brand-new account
|
||||
// (no buckets yet) still sees per-event numbers. The helper emits
|
||||
// a `path: "rollup" | "live"` annotate so prod logs prove which
|
||||
// branch fired. See `bp-in-target-fast-path.ts` for the
|
||||
// documented per-day-mean approximation.
|
||||
//
|
||||
// Two parallel runs: one anchored at `now` for the
|
||||
// standard windows, one anchored at `now - 7d` so the Health-Score
|
||||
// helper has a real prior-week BP pct to feed into its previous-
|
||||
// window snapshot. The two probes share the coverage map and the
|
||||
// same rollup / live branch decision; the extra call is one extra
|
||||
// pair of `readRollupBuckets` reads on the rollup path and is a
|
||||
// no-op when the user has no BP rows at all.
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all(
|
||||
[
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
// Cross-tz runtime guard. The helper falls back
|
||||
// to the live SQL path when the user is more than 3 hours
|
||||
// from UTC, where the rollup table's UTC-midnight day-key
|
||||
// would slip a calendar day relative to the live aggregator's
|
||||
// window cuts.
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
],
|
||||
);
|
||||
// The pillar is graded against the CLINICAL band even when the user
|
||||
// keeps a personal target (that band is the number's identity; the
|
||||
// personal one is displayed beside it and scores nothing). So when the
|
||||
// two differ we re-run the whole helper against the clinical targets
|
||||
// and the score comes from that second run.
|
||||
//
|
||||
// The "why this number" basis rides the same envelope, so it
|
||||
// is the same run, the same representative pair and the same ceilings
|
||||
// by construction. Assign envelopes WHOLE here; picking `graded.score`
|
||||
// off one run and a basis off the other would let the popover explain
|
||||
// a number nobody is looking at, and it would look correct in review.
|
||||
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
|
||||
bpEnvelope = windows;
|
||||
bpEnvelopePriorWeek = windowsPriorWeek;
|
||||
bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks;
|
||||
} else if (clinicalBpTargets) {
|
||||
[bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] =
|
||||
await Promise.all([
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// The BD-Zielbereich headline standardises on the
|
||||
// trailing-90-day window (labelled "· 90 T" in the tile). All-time
|
||||
// remains carried below for the BP detail page's long view only.
|
||||
bpInTargetPct = windows.last90Days?.pct ?? null;
|
||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||
// Count + effective span for the BD-Zielbereich tile's
|
||||
// confidence gate (thin-data → "collecting data"; dynamic span label).
|
||||
// Shared helper keeps this identical to the dashboard snapshot.
|
||||
const bpWindow = deriveBpWindow90(
|
||||
windows.last90Days,
|
||||
windows.last90EarliestAt,
|
||||
now,
|
||||
);
|
||||
bpInTargetCount90 = bpWindow.count;
|
||||
bpInTargetSpanDays90 = bpWindow.spanDays;
|
||||
}
|
||||
return {
|
||||
bpInTargetPct,
|
||||
bpInTargetPct7d,
|
||||
bpInTargetPct30d,
|
||||
bpInTargetPctAllTime,
|
||||
bpInTargetPctPriorMonth,
|
||||
bpInTargetPctPriorYear,
|
||||
bpInTargetCount90,
|
||||
bpInTargetSpanDays90,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks,
|
||||
clinicalBpTargets,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The correlation block of the default analytics body: the three fixed
|
||||
* hypotheses, with the accepted ones synced to the stored patterns and
|
||||
* their decision attached.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
import {
|
||||
decisionForEvidence,
|
||||
PATTERN_FAMILIES,
|
||||
syncAcceptedPatterns,
|
||||
} from "@/lib/insights/correlation-patterns";
|
||||
import type { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
|
||||
type Coverage = Awaited<ReturnType<typeof probeRollupCoverage>>;
|
||||
|
||||
export async function readCorrelationBlock(args: {
|
||||
userId: string;
|
||||
userTz: string;
|
||||
coverage: Coverage;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const { userId, userTz, coverage, locale } = args;
|
||||
// Three pre-defined correlation hypotheses.
|
||||
// Probe-gated helper. The 28-day scan window (down
|
||||
// from 30 to keep the cold critical path tight while still
|
||||
// satisfying the n >= 20 surface gate) reads SYS / PULSE / WEIGHT
|
||||
// per-day means from `measurement_rollups` when the user has full
|
||||
// coverage; mood and medication-intake reads always stay live (no
|
||||
// rollup equivalent). The helper emits `meta.correlations.path` +
|
||||
// `meta.correlations.window_days` so prod logs prove the branch
|
||||
// selection and the truthful window.
|
||||
const correlations = await computeCorrelationHypothesesFastPath({
|
||||
userId,
|
||||
userTz,
|
||||
now: new Date(),
|
||||
coverage,
|
||||
locale,
|
||||
});
|
||||
const fixedDefinitions = [
|
||||
{
|
||||
key: "bpCompliance" as const,
|
||||
factorKey: "HYPOTHESIS:MEDICATION_COMPLIANCE",
|
||||
outcomeKey: "BLOOD_PRESSURE_SYS",
|
||||
},
|
||||
{
|
||||
key: "moodPulse" as const,
|
||||
factorKey: "HYPOTHESIS:MOOD",
|
||||
outcomeKey: "RESTING_PULSE",
|
||||
},
|
||||
{
|
||||
key: "weightWeekday" as const,
|
||||
factorKey: "HYPOTHESIS:WEEKDAY",
|
||||
outcomeKey: "WEIGHT",
|
||||
},
|
||||
];
|
||||
const acceptedFixed = fixedDefinitions.flatMap((definition) => {
|
||||
const result = correlations[definition.key];
|
||||
return result.status === "ok"
|
||||
? [
|
||||
{
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
sampleSize: result.n,
|
||||
effectSize: result.statistic,
|
||||
pValue: result.pValue,
|
||||
qValue: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const fixedDecisions = await syncAcceptedPatterns({
|
||||
userId,
|
||||
family: PATTERN_FAMILIES.fixed,
|
||||
accepted: acceptedFixed,
|
||||
});
|
||||
const correlationsWithPatterns = { ...correlations };
|
||||
for (const definition of fixedDefinitions) {
|
||||
const result = correlations[definition.key];
|
||||
if (result.status !== "ok") continue;
|
||||
const decision = decisionForEvidence(fixedDecisions, {
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
});
|
||||
correlationsWithPatterns[definition.key] = decision
|
||||
? { ...result, ...decision }
|
||||
: result;
|
||||
}
|
||||
return correlationsWithPatterns;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* The default `GET /api/analytics` body, built once per (user, locale) and
|
||||
* cached by the route: the module-gated summaries, BMI, the blood-pressure
|
||||
* in-target block, the glucose blocks, the correlations, the Health Score and
|
||||
* the sleep stages. The stages run in the order the route always ran them.
|
||||
*/
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateSummariesByModules } from "@/lib/dashboard/widget-modules";
|
||||
|
||||
import { readBpInTargetBlock } from "./bp-in-target";
|
||||
import { readCorrelationBlock } from "./correlations";
|
||||
import { readGlucoseBlocks } from "./glucose";
|
||||
import { readHealthScoreBlock } from "./health-score";
|
||||
import { computeSleepStageBreakdown } from "./sleep-stages";
|
||||
|
||||
export type AnalyticsBody = Awaited<ReturnType<typeof buildAnalyticsResponse>>;
|
||||
|
||||
/**
|
||||
* The heavy default-slice body, outside the route handler so `cachedSwr()`
|
||||
* can wrap it. Returns the raw JSON payload; the route attaches the
|
||||
* `Cache-Control` header afterward.
|
||||
*/
|
||||
export async function buildAnalyticsResponse(
|
||||
user: SessionUser,
|
||||
locale: Locale,
|
||||
) {
|
||||
// Fire-and-forget the rollup-fresh check.
|
||||
// Awaiting it on the read path can stall the Node event loop for
|
||||
// 30–60 s on a power-user account whose iOS step samples keep the
|
||||
// 90-day DAY window slightly stale, which starves /api/health,
|
||||
// /api/version, and concurrent iOS calls. The downstream coverage
|
||||
// probe falls back to live SQL when a type is uncovered, so
|
||||
// correctness is preserved; the worst the user sees is data from
|
||||
// up to ~60 s ago on the very first request after a fresh
|
||||
// measurement lands, and the next request returns the up-to-date
|
||||
// value once the background refresh completes. `ensureUserRollupsFresh`
|
||||
// wraps its own try/catch, so the void call cannot reject.
|
||||
void ensureUserRollupsFresh(user.id);
|
||||
|
||||
// Single per-type coverage probe shared by the
|
||||
// bp_in_target / healthScore / correlations branches below. The
|
||||
// probe is one indexed query against `measurement_rollups`; the
|
||||
// three downstream branches each reuse the resulting `coverage`
|
||||
// map to decide between the rollup-fast-path and the live
|
||||
// fallback. Probing once instead of per-branch keeps the fan-out
|
||||
// cost flat across the three branches.
|
||||
const coverage = await probeRollupCoverage(user.id);
|
||||
|
||||
// Every day-bucket call inside this route honours the
|
||||
// user's display timezone via `userDayKey(date, userTz)` (the legacy
|
||||
// `berlinDayKey()` helper was retired in v1.4.40).
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
// Replace the 15-way per-type `fetchMeasurementSeriesChunked`
|
||||
// fan-out with the slim rollup-tier reader. The previous live walk
|
||||
// against `measurements` (425-day window, ≥ 50 Prisma round-trips on a
|
||||
// 15-type tenant) saturated the `p-limit(4)` slot pool for ~8 s on the
|
||||
// production 467 k-row account — and because Prisma's 20-slot pool was
|
||||
// simultaneously claimed by those slots, the concurrent
|
||||
// `?slice=summaries` request (only ~9 small queries) queued behind it
|
||||
// and observed the same ~8 s wall-clock.
|
||||
//
|
||||
// `computeSummariesSlice` reads `measurement_rollups` DAY buckets
|
||||
// (sub-second) plus one 90-day narrow `$queryRaw` for the windowed
|
||||
// avg / slope / r² columns, and falls back to a split-aggregate live
|
||||
// path for users with incomplete rollup coverage. Response shape is
|
||||
// identical to the previous fan-out output for every field the
|
||||
// dashboard actually consumes:
|
||||
// - `count / latest / min / max / mean` from DAY buckets
|
||||
// - `avg7 / avg30 / slope7 / slope30` from the narrow query
|
||||
// - `avg30LastMonth` from the same narrow query (added in this
|
||||
// release; previously only the live walk produced it)
|
||||
// - `avg30LastYear` populated for any type whose WMY tier carries
|
||||
// the year-ago window
|
||||
// - `lastSeenByType` from the per-type latest read
|
||||
//
|
||||
// `anomalyCount` and `slope90` left this wire in v1.37.19: the slim
|
||||
// path could never compute the former (it always reported 0) and no
|
||||
// client component read the latter — the insights pipeline computes
|
||||
// both from its own `summarize()` / comprehensive-aggregator reads.
|
||||
// The one field the slim path leaves at a default value is
|
||||
// `avg30LastYear` on types with no WMY coverage, which matches the
|
||||
// pre-fix behaviour for tenants whose year-ago window happened to
|
||||
// fall outside the 425-day floor.
|
||||
const slim = await readSummariesSlice(user.id);
|
||||
// Same module gate as the slim slice and the snapshot builder — the
|
||||
// default slice feeds the dashboard too (it is the thick half of the
|
||||
// legacy two-cell path), so it cannot be the one feed that leaks a
|
||||
// disabled module's types.
|
||||
const thickModules = await resolveModuleMap(user.id);
|
||||
const thickGated = gateSummariesByModules(
|
||||
slim.summaries,
|
||||
slim.lastSeenByType,
|
||||
thickModules,
|
||||
);
|
||||
const results = thickGated.summaries;
|
||||
const lastSeenByType = thickGated.lastSeenByType;
|
||||
|
||||
// `computeSummariesSlice` annotates `action: "analytics.get.slim"`
|
||||
// for telemetry on the slim route. Restore the default-slice action
|
||||
// name so the wide-event reflects the route the client actually
|
||||
// called (otherwise every default-slice request would appear in the
|
||||
// logs under the slim action).
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// Sleep-stage breakdown for the trailing 30 days. Only
|
||||
// included when the user has stage-tagged rows in window; null
|
||||
// otherwise so the UI can render a plain total without the
|
||||
// breakdown card painting empty.
|
||||
const sleepStages = await computeSleepStageBreakdown(user.id, userTz);
|
||||
|
||||
// BMI calculation
|
||||
let bmi: number | null = null;
|
||||
if (user.heightCm && results.WEIGHT?.latest) {
|
||||
const heightM = user.heightCm / 100;
|
||||
bmi = Math.round((results.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
}
|
||||
|
||||
const bp = await readBpInTargetBlock(user, coverage, userTz);
|
||||
const { glucoseByContext, glucoseClinical } = await readGlucoseBlocks(
|
||||
user.id,
|
||||
);
|
||||
const correlationsWithPatterns = await readCorrelationBlock({
|
||||
userId: user.id,
|
||||
userTz,
|
||||
coverage,
|
||||
locale,
|
||||
});
|
||||
const { glucoseEnabled, healthScore } = await readHealthScoreBlock({
|
||||
user,
|
||||
userTz,
|
||||
clinicalBpTargets: bp.clinicalBpTargets,
|
||||
bpEnvelope: bp.bpEnvelope,
|
||||
bpEnvelopePriorWeek: bp.bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks: bp.bpEnvelopePriorTwoWeeks,
|
||||
});
|
||||
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
|
||||
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
|
||||
|
||||
return {
|
||||
summaries: results,
|
||||
bmi,
|
||||
bpInTargetPct: bp.bpInTargetPct,
|
||||
bpInTargetPct7d: bp.bpInTargetPct7d,
|
||||
bpInTargetPct30d: bp.bpInTargetPct30d,
|
||||
bpInTargetPctAllTime: bp.bpInTargetPctAllTime,
|
||||
bpInTargetPctPriorMonth: bp.bpInTargetPctPriorMonth,
|
||||
bpInTargetPctPriorYear: bp.bpInTargetPctPriorYear,
|
||||
bpInTargetCount90: bp.bpInTargetCount90,
|
||||
bpInTargetSpanDays90: bp.bpInTargetSpanDays90,
|
||||
glucoseByContext: glucoseByContextOut,
|
||||
glucoseClinical: glucoseClinicalOut,
|
||||
correlations: correlationsWithPatterns,
|
||||
healthScore,
|
||||
sleepStages,
|
||||
// The latest night's source-discrepancy annotation, computed
|
||||
// inside the slim slice's per-night post-pass. Additive: the dashboard
|
||||
// sleep tile marks the headline with a discreet "sources disagree"
|
||||
// hint; clients that don't read the field stay unchanged.
|
||||
sleepSourceDiscrepancy: slim.sleepSourceDiscrepancy,
|
||||
// Per-type freshness map drives the dashboard's
|
||||
// staleness caption on each `<TrendCard>`. Additive: clients that
|
||||
// don't read the field stay unchanged.
|
||||
lastSeenByType,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The glucose blocks of the default analytics body: the per-context
|
||||
* summaries and the clinical panel over the same trailing window.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
type GlucoseClinicalMetrics,
|
||||
} from "@/lib/analytics/glucose-metrics";
|
||||
import { groupByGlucoseContext } from "@/lib/glucose";
|
||||
|
||||
export async function readGlucoseBlocks(userId: string) {
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
//
|
||||
// Bound to the trailing 30 days. The dashboard tile
|
||||
// path only reads the trailing window; pre-bound this query
|
||||
// walked every persisted BG row a multi-year user has written.
|
||||
const glucoseSince = new Date(
|
||||
Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: glucoseSince },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
// #943 — grouped through the shared bucket resolver, so readings with no
|
||||
// meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor.
|
||||
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
|
||||
for (const [bucket, ctxRows] of groupByGlucoseContext(
|
||||
glucoseRows,
|
||||
(r) => r.glucoseContext,
|
||||
)) {
|
||||
glucoseByContext[bucket] = wireSummary(
|
||||
summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Server-authoritative glucose clinical panel. The TIR / GMI /
|
||||
// eA1C / CV% headline and the advanced J-index + LBGI/HBGI tier are computed
|
||||
// ONCE here over the declared 30-day window (matching `glucoseSince`) so the
|
||||
// insights panel, the coach snapshot, and the doctor report all render the
|
||||
// same numbers instead of each re-deriving. The learning gate keeps a thin
|
||||
// spot-data window from being asserted as a clinical AGP. Always present in
|
||||
// the payload (even with zero readings) so the client renders the calm
|
||||
// "still learning" state from a populated object rather than a missing key.
|
||||
const glucoseClinical: GlucoseClinicalMetrics = computeGlucoseClinicalMetrics(
|
||||
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
|
||||
{ windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now: new Date() },
|
||||
);
|
||||
return { glucoseByContext, glucoseClinical };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* The Health Score block of the default analytics body: the versioned
|
||||
* cardiometabolic reference report, recorded, with the Rest Mode framing.
|
||||
*/
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import type { BpInTargetEnvelope } from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import type { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
export async function readHealthScoreBlock(args: {
|
||||
user: SessionUser;
|
||||
userTz: string;
|
||||
clinicalBpTargets: ReturnType<typeof getBpTargets>;
|
||||
bpEnvelope: BpInTargetEnvelope | null;
|
||||
bpEnvelopePriorWeek: BpInTargetEnvelope | null;
|
||||
bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null;
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
userTz,
|
||||
clinicalBpTargets,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks,
|
||||
} = args;
|
||||
// The versioned cardiometabolic reference report. The shared reader
|
||||
// admits only evidence-backed pillars with their own windows and coverage
|
||||
// floors, then applies the three-domain breadth rule. Its BP envelopes come
|
||||
// from the same rollup/live fast path the dashboard uses; every other domain
|
||||
// resolves through the canonical reader and fails independently.
|
||||
const [
|
||||
glucoseEnabled,
|
||||
labsEnabled,
|
||||
sleepEnabled,
|
||||
mentalHealthEnabled,
|
||||
scoreSourcePriority,
|
||||
] = await Promise.all([
|
||||
isModuleEnabled(user.id, "glucose"),
|
||||
isModuleEnabled(user.id, "labs"),
|
||||
isModuleEnabled(user.id, "sleep"),
|
||||
isModuleEnabled(user.id, "mentalHealth"),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const scoredAt = new Date();
|
||||
const healthScore = await computeAndRecordUserHealthScore({
|
||||
userId: user.id,
|
||||
now: scoredAt,
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
heightCm: user.heightCm ?? null,
|
||||
timezone: userTz,
|
||||
sourcePriorityJson: scoreSourcePriority,
|
||||
thresholdsJson: user.thresholdsJson ?? null,
|
||||
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
|
||||
},
|
||||
modules: {
|
||||
glucose: glucoseEnabled,
|
||||
labs: labsEnabled,
|
||||
sleep: sleepEnabled,
|
||||
mentalHealth: mentalHealthEnabled,
|
||||
},
|
||||
healthScoreConfigJson: user.healthScoreConfigJson,
|
||||
bpTargets: clinicalBpTargets,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks,
|
||||
});
|
||||
|
||||
// Rest Mode annotation. When an illness/condition episode is
|
||||
// active we frame the score (not penalise it): the number above is left
|
||||
// exactly as computed and we attach a value-free "you were unwell" context.
|
||||
// Resolved server-side so iOS mirrors the same fact, never recomputing it.
|
||||
// Resolved as-of the scored window (`scoredAt`), not a fresh now.
|
||||
if (healthScore) {
|
||||
const restMode = await resolveRestMode(user.id, scoredAt);
|
||||
healthScore.restMode = restMode.active
|
||||
? {
|
||||
active: true,
|
||||
since: restMode.since,
|
||||
episodeCount: restMode.episodeCount,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return { glucoseEnabled, healthScore };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Derive `daysAgo` from the cached `lastSeenAt` ISO string per request. The
|
||||
* cached envelope deliberately omits `daysAgo` so the dashboard tile
|
||||
* staleness caption stays correct across day boundaries even when the body
|
||||
* has been sitting in the 60s LRU.
|
||||
*
|
||||
* Returns a shallow-copied envelope with the enriched `lastSeenByType` map;
|
||||
* everything else is passed through unchanged.
|
||||
*/
|
||||
export function enrichLastSeenDaysAgo<
|
||||
T extends {
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
},
|
||||
>(
|
||||
body: T,
|
||||
): Omit<T, "lastSeenByType"> & {
|
||||
lastSeenByType: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
>;
|
||||
} {
|
||||
const nowMs = Date.now();
|
||||
const enriched: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
> = {};
|
||||
for (const [type, slot] of Object.entries(body.lastSeenByType)) {
|
||||
if (slot === null) {
|
||||
enriched[type] = null;
|
||||
continue;
|
||||
}
|
||||
const lastMs = new Date(slot.lastSeenAt).getTime();
|
||||
const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000));
|
||||
enriched[type] = { lastSeenAt: slot.lastSeenAt, daysAgo };
|
||||
}
|
||||
return { ...body, lastSeenByType: enriched };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* The sleep-stage block of the default analytics body.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildSleepStageComposition,
|
||||
type SleepStageComposition,
|
||||
} from "@/lib/analytics/sleep-stage-composition";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
|
||||
/**
|
||||
* Per-stage sleep-minutes breakdown over the trailing 30 days.
|
||||
*
|
||||
* Returns `null` when the user has no stage-tagged sleep rows in
|
||||
* window — the analytics consumer renders the existing
|
||||
* `summaries.SLEEP_DURATION` totals without a stage card in that
|
||||
* case. Returns the sum-per-stage AND the day count covered so the
|
||||
* UI can render an "averaged across N nights" caption truthfully.
|
||||
*
|
||||
* Also returns a `perNight` array: one entry per
|
||||
* Berlin-tz day inside the window, with minutes-per-stage for the
|
||||
* stacked-bar chart. Days with zero stage-tagged rows are omitted
|
||||
* from `perNight` (the chart treats them as gaps).
|
||||
*
|
||||
* The per-night stages are the MAIN NIGHT's stages. A nap ends on the
|
||||
* same wake day as that morning's night, and the per-wake-day totals
|
||||
* folded it into the night's buckets — an afternoon nap added CORE to
|
||||
* the night and the column read longer and shallower than the night
|
||||
* was. The nap's minutes now ride alongside on `napMinutes` /
|
||||
* `napCount`, both absent on a day without one.
|
||||
*/
|
||||
export async function computeSleepStageBreakdown(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<SleepStageComposition | null> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const WINDOW_DAYS = 30;
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * DAY_MS);
|
||||
// Read the FULL per-stage row set (not `sleepStage: { not: null }`)
|
||||
// and route it through the canonical session reconstruction so the
|
||||
// stacked-bar agrees with the dashboard tile + chart series. The old path
|
||||
// keyed each stage row by its OWN calendar day (splitting a midnight-
|
||||
// spanning night across two buckets) and never collapsed multi-source
|
||||
// (double-counting a WHOOP + Apple Health night). Routing through the
|
||||
// helper fixes both: it session-clusters, keys by the wake day, and picks
|
||||
// one canonical source per night. The bare-vs-granular de-dup is applied
|
||||
// per-session to the per-stage breakdown too. The `source` is selected so
|
||||
// the helper's source-collapse can run.
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
return buildSleepStageComposition(rows, userTz, priorityJson, WINDOW_DAYS);
|
||||
}
|
||||
Reference in new issue
Block a user