diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index 1134cdf0d..8367e5363 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -106,7 +106,7 @@ const ALLOWED: Record = { "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.", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 2cdc57ea3..41dedd7a6 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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> - >, + caches.analytics as ServerCache, `${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>["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; - }, ->( - body: T, -): Omit & { - 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> = {}; - 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 ``. 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 { - 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. diff --git a/src/lib/analytics/__tests__/glucose-panel-window.test.ts b/src/lib/analytics/__tests__/glucose-panel-window.test.ts index 56bfa8e60..dd4071fce 100644 --- a/src/lib/analytics/__tests__/glucose-panel-window.test.ts +++ b/src/lib/analytics/__tests__/glucose-panel-window.test.ts @@ -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. diff --git a/src/lib/analytics/response/bp-in-target.ts b/src/lib/analytics/response/bp-in-target.ts new file mode 100644 index 000000000..5ebf062c4 --- /dev/null +++ b/src/lib/analytics/response/bp-in-target.ts @@ -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>; + +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, + }; +} diff --git a/src/lib/analytics/response/correlations.ts b/src/lib/analytics/response/correlations.ts new file mode 100644 index 000000000..61a7b71e9 --- /dev/null +++ b/src/lib/analytics/response/correlations.ts @@ -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>; + +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; +} diff --git a/src/lib/analytics/response/default-body.ts b/src/lib/analytics/response/default-body.ts new file mode 100644 index 000000000..4ba36afe1 --- /dev/null +++ b/src/lib/analytics/response/default-body.ts @@ -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>; + +/** + * 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 ``. Additive: clients that + // don't read the field stay unchanged. + lastSeenByType, + }; +} diff --git a/src/lib/analytics/response/glucose.ts b/src/lib/analytics/response/glucose.ts new file mode 100644 index 000000000..10a812eff --- /dev/null +++ b/src/lib/analytics/response/glucose.ts @@ -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> = {}; + 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 }; +} diff --git a/src/lib/analytics/response/health-score.ts b/src/lib/analytics/response/health-score.ts new file mode 100644 index 000000000..e3c9818a2 --- /dev/null +++ b/src/lib/analytics/response/health-score.ts @@ -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; + 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 }; +} diff --git a/src/lib/analytics/response/last-seen.ts b/src/lib/analytics/response/last-seen.ts new file mode 100644 index 000000000..547cebdc0 --- /dev/null +++ b/src/lib/analytics/response/last-seen.ts @@ -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; + }, +>( + body: T, +): Omit & { + 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 }; +} diff --git a/src/lib/analytics/response/sleep-stages.ts b/src/lib/analytics/response/sleep-stages.ts new file mode 100644 index 000000000..60a72beca --- /dev/null +++ b/src/lib/analytics/response/sleep-stages.ts @@ -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 { + 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); +}