/** * v1.7.0 W6 — unified dashboard first-paint snapshot. * * `buildDashboardSnapshot` assembles every above-the-fold tile field in * a single server round-trip from the existing rollup / mood / widget * helpers plus a read-only lift of the pre-generated daily briefing. * The whole strip therefore shares one completion moment instead of the * legacy four-cell waterfall (slim analytics + thick analytics + mood + * widget layout, each gated behind `/api/auth/me`). * * Two-phase shape — the deliberate trade-off documented in the * R-firstpaint spec §6 "Risks": * * - `tiles` — fast, ALWAYS present. The slim summaries slice, the * mood block, the resolved widget layout, the derived user profile * fields. These resolve sub-second on a warm rollup tenant. * - `extras` — thick, MAY be null. The BD-Zielbereich (`bpInTargetPct*`) * and per-context glucose tiles ride the slowest reads (the * `computeBpInTargetFastPath` fast-path falls back to multi-second * live SQL on a rollup-coverage miss). Collapsing them into the * fast phase would make the WHOLE strip wait for the slowest * sub-query. Instead the builder runs both phases concurrently * inside one `Promise.all`; on a coverage miss `extras` is emitted * `null` so the BD/glucose tiles fall back to a per-tile shimmer * while every other tile paints together. Warm-rollup tenants get * everything in the single payload because the thick reads resolve * inside the same fast window. * * No LLM is reachable from this builder. The briefing is lifted * read-only from `User.insightsCachedText`; a cache miss / stale row * yields `briefingState: "preparing"` (with the last good briefing * delivered under `briefingStale: true` when one exists) and the * `insight-pregenerate` cron refills it — or `"no-provider"` when no * AI provider is configured anywhere, so clients can stop waiting. * The builder NEVER POSTs `/api/insights/generate`. */ import type { PrismaClient, MeasurementType } from "@/generated/prisma/client"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night"; import { getUnitForType } from "@/lib/validations/measurement"; import { summarize, type DataPoint, type DataSummary, } from "@/lib/analytics/trends"; import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets"; import { computeGlucoseClinicalMetrics, GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose"; import { computeBpInTargetFastPath, type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; import { resolveEffectiveBpTargets, resolveWeightTargetOverride, } from "@/lib/analytics/effective-range"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { resolveWeightTrend, weightTrendReferenceKg, type WeightTrendJudgement, } from "@/lib/targets/weight-trend"; import { probeRollupCoverage, typesWithReadingsInFoldWindow, type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; import { resolveDashboardLayout, DASHBOARD_WIDGET_CATALOGUE_IDS, type DashboardLayout, type DashboardWidgetCatalogueId, } from "@/lib/dashboard-layout"; import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema"; import { hasAnyConfiguredProvider } from "@/lib/ai/provider"; import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { buildMedsTodayBlock, type MedsTodayBlock, } from "@/lib/dashboard/meds-today"; import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record"; import { buildDashboardBands as buildTargetBands, type DashboardTargetBands, } from "@/lib/dashboard/bands"; import type { RestModeAnnotation, ScoreBand, ScoreBasis, ScoreDeltaReason, ScorePillarId, } from "@/lib/analytics/score/types"; import type { DerivedConfidence } from "@/lib/insights/derived/types"; import { resolveRestMode } from "@/lib/illness/rest-mode"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { buildScoreNarrativeBlock, type ScoreTensionDto, type ScoreReturnToBandDto, } from "@/lib/dashboard/score-narrative"; import { buildCoachMemoryBlock, type CoachMemoryBlock, } from "@/lib/ai/coach/memory-snapshot"; import { buildScoreRingsBlock, type DashboardScoreRing, } from "@/lib/dashboard/score-rings"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { binaryReferenceSex, toProfileSex, type ProfileSex, } from "@/lib/profile/sex"; import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; /** * v1.18.0 — the two dashboard module maps live in a client-safe module * (`@/lib/dashboard/widget-modules`) so the settings client component can * import them without pulling the server snapshot builder (and its * `pg` / `dns` chain) into the browser bundle. Re-exported here for the * server call sites and the existing tests. */ export { WIDGET_MODULE_BY_ID, SUMMARY_TYPE_MODULE, } from "@/lib/dashboard/widget-modules"; import { gateSummariesByModules, hideModuleWidgets, unavailableWidgetIds, } from "@/lib/dashboard/widget-modules"; /** * v1.7.0 — HealthLog `MeasurementType` → iOS `MetricKind` raw value. * * The iOS client decodes `MetricKind` from these exact raw strings * (`MetricKind.swift:30-122`); the snapshot's `metricStates` block is * keyed by them so a cold-launch first-paint seeds every tile without * a per-metric round-trip. Most map to the lowercase / camel form of * the enum, but a handful are non-obvious and locked verbatim in * `.planning/ios-coord/v1.7.0-ios-convergence-locks.md` §4b: * * - SpO₂ → `oxygenSaturation` * - body water → `totalBodyWater` * - HRV → `heartRateVariability` * - BMI → `bodyMassIndex` * - walking asymmetry → `walkingAsymmetryPercentage` * - walking double support → `walkingDoubleSupportPercentage` * - environmental audio → `environmentalAudioExposure` * - headphone audio → `headphoneAudioExposure` * - active energy → `activeEnergyBurned` * * Types without an iOS `MetricKind` counterpart are intentionally * absent (no key emitted) rather than mapped to a guessed raw. The * audio-exposure EVENT flag, walking steadiness, and the Withings-only * body-composition metrics (fat-free / muscle mass, PWV, vascular age, * visceral fat, skin temperature) have no iOS tile and are omitted. */ const METRIC_KIND_RAW_BY_TYPE: Partial> = { WEIGHT: "weight", BLOOD_PRESSURE_SYS: "bloodPressureSystolic", BLOOD_PRESSURE_DIA: "bloodPressureDiastolic", PULSE: "pulse", BODY_FAT: "bodyFat", SLEEP_DURATION: "sleep", ACTIVITY_STEPS: "steps", BLOOD_GLUCOSE: "bloodGlucose", TOTAL_BODY_WATER: "totalBodyWater", BONE_MASS: "boneMass", OXYGEN_SATURATION: "oxygenSaturation", HEART_RATE_VARIABILITY: "heartRateVariability", RESTING_HEART_RATE: "restingHeartRate", ACTIVE_ENERGY_BURNED: "activeEnergyBurned", FLIGHTS_CLIMBED: "flightsClimbed", WALKING_RUNNING_DISTANCE: "walkingRunningDistance", VO2_MAX: "vo2Max", BODY_TEMPERATURE: "bodyTemperature", FAT_FREE_MASS: "fatFreeMass", FAT_MASS: "fatMass", MUSCLE_MASS: "muscleMass", RESPIRATORY_RATE: "respiratoryRate", BODY_MASS_INDEX: "bodyMassIndex", LEAN_BODY_MASS: "leanBodyMass", WALKING_HEART_RATE_AVERAGE: "walkingHeartRateAverage", WALKING_ASYMMETRY: "walkingAsymmetryPercentage", WALKING_DOUBLE_SUPPORT: "walkingDoubleSupportPercentage", WALKING_STEP_LENGTH: "walkingStepLength", WALKING_SPEED: "walkingSpeed", AUDIO_EXPOSURE_ENV: "environmentalAudioExposure", AUDIO_EXPOSURE_HEADPHONE: "headphoneAudioExposure", TIME_IN_DAYLIGHT: "timeInDaylight", }; /** * v1.15.20 — `"no-provider"` joins the tri-state: the cache is stale or * missing AND no AI provider is configured anywhere (user chain, legacy * selection, Codex link, operator key), so no warm pass will ever fill * it. Clients render a "connect a provider" hint instead of an eternal * "preparing" spinner. Existing states keep their exact semantics. */ export type BriefingState = "ready" | "preparing" | "disabled" | "no-provider"; export interface DashboardSnapshotUser { username: string; /** * The user's configured zone — the ONE server-resolved input the * greeting needs. The wall-clock hour itself is deliberately not on * this payload: the body is served stale-while-revalidate for up to an * hour, so a baked-in hour can name the wrong salutation long after * the boundary passed, and every client already reads a live clock. * Resolve the zone here, read the hour there. */ timezone: string; heightCm: number | null; dateOfBirth: string | null; /** * Sex as stored, `OTHER` included. Emitting `null` for an account that * answered `OTHER` made the value indistinguishable from no answer at * all on every client reading this payload. */ gender: ProfileSex; glucoseUnit: string | null; onboardingTourCompleted: boolean; } /** * v1.18.6 — server-computed band / target math (audit finding #3). * * The dashboard used to derive every chart band + tile range CLIENT-side * in `page.tsx` from `user.dateOfBirth` / `gender` / `heightCm`. This * block resolves the same numbers server-side from the EXISTING band * helpers (`getBpTargets`, `getPersonalizedPulseTarget`, * `getBodyFatTargetRange`, `buildWeight*FromHeight`, * `buildTrafficLightBands`, `buildTrafficRange`) so the client reads them * instead of recomputing. Only the NUMERIC structures live here — the * `bpTargetZones` array carries `t()`-localised labels, so the client * assembles THAT from `bpTargets` (numbers) at render time. Every field * is `null` when the driving profile fact is missing (no DOB → BP/pulse * personalisation, no height → weight band), matching the client's * previous null-guards exactly. */ export { buildTargetBands }; export type { DashboardTargetBands }; export interface DashboardSnapshotMoodEntry { date: string; score: number; samples: number; } /** * Thick analytics slice — `null` when the rollup tier is not warm for * this tenant (coverage miss). The dashboard renders a per-tile shimmer * for the BD-Zielbereich + glucose tiles in that case and the next * snapshot (after the boot backfill converges) carries the values. */ export interface DashboardSnapshotExtras { bpInTargetPct: number | null; bpInTargetPct7d: number | null; bpInTargetPct30d: number | null; bpInTargetPctAllTime: number | null; bpInTargetPctPriorMonth: number | null; bpInTargetPctPriorYear: number | null; /** * v1.17 W1b — number of paired BP readings inside the trailing-90-day * window, and the EFFECTIVE label span (real calendar span capped at 90 * days, or `null` when the window is empty). The BD-Zielbereich tile reads * the count against the confidence floor to decide between a percentage and * the "collecting data" placeholder, and renders the span in the label so a * thin-history user never sees a dishonest static "· 90 T". */ bpInTargetCount90: number | null; bpInTargetSpanDays90: number | null; glucoseByContext: Record; /** * v1.17.0 — server-authoritative glucose clinical panel over the trailing * 30-day window: the learning gate (+ reason), window/span/count, mean, * Battelino TIR distribution, GMI, estimated A1C, SD/CV%/instability, and the * advanced J-index + LBGI/HBGI tier. Computed ONCE by * `computeGlucoseClinicalMetrics` so the iOS client renders the same numbers * the web panel, the coach, and the doctor report do — iOS never re-derives. * Always present (even with zero readings) so the native client paints the * calm "still learning" state from a populated object. */ glucoseClinical: GlucoseClinicalMetrics; } /** * v1.7.0 — latest reading per chartable metric, keyed by the iOS * `MetricKind` raw value. Additive cold-launch seed: the iOS client * paints every tile from this block on first launch without a * per-metric round-trip. Derived in-process from the slim summaries * slice already fetched for `tiles.summaries` — NO extra DB query. */ export interface DashboardMetricState { value: number; measuredAt: string; unit: string; } /** * v1.7.0 — one widget row in the full catalogue. Mirrors the * web-layout `DashboardWidgetConfig` shape (`visible` + `order`) but * over the full 27-id catalogue (server-known + iOS-only), so the * iOS layout round-trips in one key. */ export interface DashboardLayoutCatalogueEntry { id: DashboardWidgetCatalogueId; visible: boolean; order: number; } /** * Health-score summary for the hero — score + band + week-over-week * delta ONLY. The per-pillar component breakdown is deliberately NOT * serialised here; the analytics route remains the one surface that * exposes components. */ export interface DashboardSnapshotHealthScore { score: number; band: ScoreBand; delta: number | null; confidence?: DerivedConfidence; composition?: ScorePillarId[]; /** * v1.35.0 — the resolved "this score is configured" flag: true when the * person's own recipe narrows the composition below what the account's * defaults would resolve to today. Server-resolved so the hero, the * widget and the complication acknowledge an authored recipe without * interpreting a config blob; the blob itself never rides this wire, * and neither does any per-pillar config detail. Optional on the type * (additive contract) so older cached snapshots stay valid; the live * builder always sets it. */ configured?: boolean; /** * v1.38 — what the number rests on: how many distinct areas of health * were counted, how many the method recommends, the resulting tier * (`full` / `partial` / `minimal`) and whether any counted pillar was a * physiological measurement. Since v1.38 a score exists at one or two * areas as well as three, computed exactly the same way, so this block * is the only thing that tells a narrow score from a broad one — a * surface that renders the number should render its basis too. * Counting `composition` is not a substitute: three of the seven * pillars share the cardiometabolic area. Optional on the type * (additive contract) so older cached snapshots stay valid; the live * builder always sets it when a score exists. */ scoreBasis?: ScoreBasis; deltaReason?: ScoreDeltaReason | null; scoreVersion?: number; bandSetter?: ScorePillarId | null; /** * v1.18.1 — Rest Mode annotation. When an illness/condition episode is * active the dashboard frames the score ("you were unwell during this * window") WITHOUT changing it — the same value-free context the * `/api/analytics` payload carries and iOS mirrors. Null when the account * is not in Rest Mode. Resolved server-side (fail-soft) so the surface * never recomputes it. Optional on the type (additive contract) so older * cached snapshots + test fixtures without the field stay valid; the live * builder always sets it (null when not in Rest Mode). */ restMode?: RestModeAnnotation | null; /** * v1.21.2 (A5) — Tension Verdict, locale-agnostic. Fires only when the * readiness composite's contributors DISAGREE (≥1 strongly favourable AND ≥1 * strongly unfavourable); suppressed (`null`) under a clinical red-flag so the * red-flag path dominates. `positive` / `negative` carry the readiness * contributor KEYS — the client maps each to its localised display label * before handing the card its already-localised strings. Null on a coherent * day. Optional (additive contract) so older cached snapshots stay valid. */ tension?: ScoreTensionDto | null; /** * v1.21.2 (A6) — return-to-baseline, locale-agnostic. Present only when a * salient metric has come BACK inside the user's own personal range after a * prior out-of-band run; at most one (the most salient). `metricType` is the * `MeasurementType` the client maps to its localised metric name. Null * otherwise. Optional (additive contract). */ returnToBand?: ScoreReturnToBandDto | null; } /** * v1.21.2 (A4) — the briefing recall + forward-look. Both strings are * already-localised, server-resolved prose: `recall` is the prior period's * narrative headline; `forward` points ahead to the most salient trend drift * (or a calm "holding steady" when nothing drifted). The card renders them * verbatim. Null when no prior narrative is on file. */ export interface DashboardSnapshotBriefingMemory { recall: string; forward: string; } export interface DashboardSnapshot { user: DashboardSnapshotUser; layout: DashboardLayout; /** * v1.7.0 — full 27-id widget catalogue (visibility + order) for the * iOS cold-launch seed. Additive alongside the web `layout`; the web * page keeps reading `layout` byte-identically. */ layoutCatalogue: DashboardLayoutCatalogueEntry[]; /** * v1.7.0 — per-chartable-metric latest reading, keyed by iOS * `MetricKind` raw value. Additive; the web page does not read it. */ metricStates: Record; /** * v1.18.6 — server-computed band / target math (audit finding #3). The * web dashboard reads these instead of recomputing from the profile; * additive for iOS. */ targetBands: DashboardTargetBands; /** Fast phase — always present. */ tiles: { summaries: Record; lastSeenByType: Record< string, { lastSeenAt: string; daysAgo: number } | null >; mood: { summary: DataSummary | null; entries: DashboardSnapshotMoodEntry[]; }; /** * v1.28.x — source-discrepancy annotation for the latest night behind * `summaries.SLEEP_DURATION.latest` (see `SummariesSlice`). Null when * the writers agree or the sleep module is off. Optional on the type * (additive contract) so older cached snapshots / fixtures stay valid. */ sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null; /** * v1.39 — which way a weight change counts as progress, judged against * the person's own stored target (`src/lib/targets/weight-trend.ts`). * The weight tile colours its arrow, delta and comparison caption by * `direction`; a client never re-derives it. Optional on the type * (additive contract) so older cached snapshots stay valid; the live * builder always sets it. */ weightTrend?: WeightTrendJudgement; }; /** Thick phase — null on a rollup-coverage miss. */ extras: DashboardSnapshotExtras | null; /** * Fast phase — today's medication block (projection-backed tally + * earliest next-due across active medications). * * Caching contract: the snapshot is served read-through a cache, so * `medsToday.nextDueAt` can sit in the past with * `medsToday.nextDueOverdue: false` when the slot's anchor passed * AFTER the snapshot was built. Consumers MUST render that state as * the plain day summary — never as overdue — until a fresh snapshot * carries `nextDueOverdue: true` from the band model itself. */ medsToday: MedsTodayBlock; /** * Warm-phase health score (score + band + delta only — no * components). `null` on a rollup-coverage miss (the score rides the * thick phase alongside `extras`) and when no pillar is computable. */ healthScore: DashboardSnapshotHealthScore | null; /** * v1.27.7 — the user-selected hero score rings (max 3), resolved * server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE through the * same engines the derived batch route calls, MED_COMPLIANCE as * TODAY's dose tally (taken / scheduled, from the snapshot's own * medsToday block — v1.27.8; not a trailing 7-day rate). Only * rings with data appear (selection order preserved); module-disabled * and data-less selections drop out, so the hero row self-gates. * Optional on the type (additive contract) so older cached snapshots * / fixtures stay valid; the live builder always sets it. */ scoreRings?: DashboardScoreRing[]; briefing: DailyBriefing | null; /** * v1.21.2 (A4) — server-resolved recall + forward-look for the briefing card. * Null when no prior narrative is on file. Rides the snapshot DTO so iOS reads * the same already-localised block. Optional on the type (additive contract) * so older cached snapshots / fixtures without it stay valid. */ briefingMemory?: DashboardSnapshotBriefingMemory | null; briefingState: BriefingState; briefingUpdatedAt: string | null; /** * v1.15.20 — additive honesty flag. `true` when `briefing` carries the * LAST GOOD (expired-TTL) briefing while a refresh is pending * (`briefingState: "preparing"`) or will never come * (`"no-provider"`). Clients show the stale content with its * `briefingUpdatedAt` timestamp instead of a blank tile — the same * stale-while-revalidate honesty the insights page uses. */ briefingStale: boolean; /** * Whether the daily briefing can be shown for this record, and why not. * The builder lifts whatever the cache holds and leaves this null; the * cached read applies the `briefing` capability on every request * (`applyBriefingCapability`), so a switch flip, an opt-out or a withdrawn * consent hides the text on the next read rather than when the cached body * ages out. Optional so older cached bodies and fixtures stay valid. */ briefingAi?: AiCapabilityState | null; generatedAt: string; } /** Minimal user shape the builder needs — a subset of the Prisma `User`. */ export interface SnapshotUserInput { id: string; username: string; displayName?: string | null; timezone: string | null; heightCm: number | null; dateOfBirth: Date | null; gender: string | null; glucoseUnit: string | null; onboardingTourCompleted: boolean; insightsCachedText: string | null; insightsCachedAt: Date | null; /** * Language the cached text was generated in. `null` on rows written before * the tag existed (served as-is). A tag that differs from the reader's * locale makes the cache count as absent for prose purposes. */ insightsCachedLocale: string | null; dashboardWidgetsJson: unknown; /** Raw source-priority settings already loaded with the authenticated user. */ sourcePriorityJson?: unknown; /** * Raw personal targets used by the unscored weight-goal context row and * dashboard reference bands. */ thresholdsJson: unknown; /** * Raw per-user Health Score recipe. Part of the number's identity, so * it is required rather than optional: a snapshot built without it * would show a score composed from someone else's rules. */ healthScoreConfigJson: unknown; } function enrichLastSeen( raw: Record, nowMs: number, ): Record { const out: Record = {}; for (const [type, slot] of Object.entries(raw)) { if (slot === null) { out[type] = null; continue; } const lastMs = new Date(slot.lastSeenAt).getTime(); const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000)); out[type] = { lastSeenAt: slot.lastSeenAt, daysAgo }; } return out; } /** * Read-only mood block. v1.17.1 — delegates to the single mood engine * `buildMoodDailySeries` so the dashboard tile reads the exact numbers * `/api/mood/analytics` (the insights mood sparkline source) returns. */ async function buildMoodBlock( client: PrismaClient, userId: string, ): Promise<{ summary: DataSummary | null; entries: DashboardSnapshotMoodEntry[]; }> { const series = await buildMoodDailySeries(userId, client); return { summary: series.summary, entries: series.entries }; } /** * v1.29 — fluid-intake dashboard tile block, read from `NutrientIntakeDay` * (nutrient="water", last 30 day-keys, summed ACROSS SOURCES per day — * migration 0249 can carry an APPLE_HEALTH row and a MANUAL row for the * same day). ≤ 60 rows off the existing `(userId, nutrient, day desc)` * index. Sparse by construction (only days with an actual write become a * `DataPoint`) — matching the convention every real `MeasurementType` * summary follows: an unlogged day is absent, not a zero reading. `null` * when there is no water data in the window; the caller strips the * synthetic `NUTRIENT_WATER` key entirely in that case (mirrors mood). * Always fetched (cheap, indexed) and stripped post-hoc by * `gateSummariesByModules` when the `nutrients` module is off — the same * fetch-then-gate posture the sleep/glucose/recovery types already use. */ async function buildNutrientWaterBlock( prisma: PrismaClient, userId: string, ): Promise<{ summary: DataSummary; lastSeenAt: string } | null> { // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: 30-day lower bound on stored water day keys; a day either way at the far edge does not change the tile const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); const rows = await prisma.nutrientIntakeDay.findMany({ where: { userId, nutrient: "water", day: { gte: since } }, select: { day: true, amount: true }, }); if (rows.length === 0) return null; const sumByDay = new Map(); for (const row of rows) { sumByDay.set(row.day, (sumByDay.get(row.day) ?? 0) + row.amount); } const points: DataPoint[] = [...sumByDay.entries()] .map(([day, amount]) => ({ date: dayKeyAsUtcMidnight(day), value: amount, })) .sort((a, b) => a.date.getTime() - b.date.getTime()); return { summary: summarize(points), lastSeenAt: points[points.length - 1].date.toISOString(), }; } /** * Thick slice — BD-Zielbereich + per-context glucose + health score. * Only called by the builder when the rollup tier is warm for the * types this slice actually reads (`isThickPhaseWarm`); the BP * fast-path then stays on the sub-second rollup branch and never drops * into the multi-second live fallback that would make the whole strip * wait. On a coverage miss for one of those types the builder skips * this entirely and emits `extras: null` + `healthScore: null` * (per-tile shimmer until the boot backfill converges). * * The Health Score rides this warm phase because it reuses the BP windows and * coverage map. Weight remains in the warm gate for the unscored personal-goal * row, which must not force a dense live fallback while the rollup backfill is * pending. * * v1.34 — takes the resolved module map so a disabled mental-assessment module * cannot leave WELLBEING in this composition while Insights omits it. */ async function buildExtras( prisma: PrismaClient, user: SnapshotUserInput, userTz: string, coverage: RollupCoverageMap, now: Date, time: (label: string, fn: () => Promise) => Promise, modules: Record, ): Promise<{ extras: DashboardSnapshotExtras; healthScore: DashboardSnapshotHealthScore | null; }> { let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; let bpInTargetPctAllTime: number | null = null; let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; let bpInTargetCount90: number | null = null; let bpInTargetSpanDays90: number | null = null; // v1.17 W1b — hold the current + prior-week BP envelopes so the shared // Health-Score input builder grades the pillar off the identical shape the // analytics route uses. 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) { // v1.17 W1b — two runs (current + prior-week), identical to the // analytics route, so the dashboard ring's week-over-week delta reflects // BP movement instead of zeroing it out. Both reuse the already-probed // coverage map and share the rollup/live branch decision. 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, userTz, }), computeBpInTargetFastPath({ userId: user.id, targets: bpTargets, now: sevenDaysAgo, coverage, userTz, }), computeBpInTargetFastPath({ userId: user.id, targets: bpTargets, now: fourteenDaysAgo, coverage, userTz, }), ], ); 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 W1d — the headline standardises on the trailing-90-day // window (labelled "· 90 T" in the tile), identical to the analytics // route, so the dashboard tile and the insights surface never narrate // two windows for the same metric. All-time stays carried for the // 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 W1b — count + effective span for the tile's confidence gate, // derived through the shared helper so this surface and `/api/analytics` // can never disagree on the gate or the label span. const bpWindow = deriveBpWindow90( windows.last90Days, windows.last90EarliestAt, now, ); bpInTargetCount90 = bpWindow.count; bpInTargetSpanDays90 = bpWindow.spanDays; } // Health score — reuses the BP windows captured above plus the // already-probed coverage map (no second probe). Score + band + // delta only; the component breakdown stays off this wire. // // v1.17 W1b — the BP-pillar inputs come from the ONE shared // `buildHealthScoreBpInputs` builder the analytics route also uses, so the // ring and the insights card grade the pillar off identical inputs (same // 90-day window via W1d, same all-time fallback, same graded score, same // prior-week delta values). Closes the dashboard-vs-insights divergence. const scoreSourcePriority = user.sourcePriorityJson ?? null; const scoreResult = await time("healthScore", () => computeAndRecordUserHealthScore({ prisma, userId: user.id, now, profile: { dateOfBirth: user.dateOfBirth, heightCm: user.heightCm, timezone: userTz, sourcePriorityJson: scoreSourcePriority, thresholdsJson: user.thresholdsJson, glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: modules.glucose !== false, labs: modules.labs !== false, sleep: modules.sleep !== false, mentalHealth: modules.mentalHealth !== false, }, healthScoreConfigJson: user.healthScoreConfigJson, bpTargets: clinicalBpTargets, bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks, }), ); // v1.18.1 — resolve the value-free Rest Mode annotation alongside the score // so the dashboard hero can frame (never penalise) the number, matching the // `/api/analytics` payload + iOS. Fail-soft inside `resolveRestMode`, and // only resolved when a score actually rendered. const restMode: RestModeAnnotation | null = scoreResult.composite.status === "ok" ? await (async () => { const ctx = await resolveRestMode(user.id, now); return ctx.active ? { active: true, since: ctx.since, episodeCount: ctx.episodeCount, } : null; })() : null; const healthScore: DashboardSnapshotHealthScore | null = scoreResult.composite.status === "ok" ? { score: scoreResult.composite.value.score, band: scoreResult.composite.value.band, delta: scoreResult.delta, confidence: scoreResult.composite.confidence, composition: scoreResult.composite.value.composition, configured: scoreResult.composite.value.configured, scoreBasis: scoreResult.composite.value.scoreBasis, deltaReason: scoreResult.deltaReason, scoreVersion: scoreResult.scoreVersion, bandSetter: scoreResult.composite.value.bandSetter, restMode, } : null; 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] = summarize( ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })), ); } // v1.17.0 — clinical panel from the SAME 30-day glucose rows already read // above (no extra DB hop), computed by the one literature-locked engine so // the iOS cold-launch seed matches the web panel / coach / doctor report. const glucoseClinical = computeGlucoseClinicalMetrics( glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })), { windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now }, ); return { extras: { bpInTargetPct, bpInTargetPct7d, bpInTargetPct30d, bpInTargetPctAllTime, bpInTargetPctPriorMonth, bpInTargetPctPriorYear, bpInTargetCount90, bpInTargetSpanDays90, glucoseByContext, glucoseClinical, }, healthScore, }; } /** Parse + validate the cached `dailyBriefing` block; null when unusable. */ function parseCachedBriefing(cachedText: string | null): DailyBriefing | null { if (!cachedText) return null; try { const parsed = JSON.parse(cachedText) as Record; const candidate = parsed?.dailyBriefing; if (candidate == null) return null; const validated = dailyBriefingSchema.safeParse(candidate); return validated.success ? validated.data : null; } catch { return null; } } /** * Hide the briefing (and the recall that rides it) unless the `briefing` * capability is available for this record, and publish the state beside it. * * Applied on every read, after the cache, so a body cached while the * capability was available never outlives it. `briefingState` keeps its * published values: `no-provider` when that is the reason, `disabled` for * every other one. */ export function applyBriefingCapability( body: DashboardSnapshot, ai: AiCapabilityState, ): DashboardSnapshot { if (ai.available) return { ...body, briefingAi: ai }; return { ...body, briefing: null, briefingMemory: null, briefingState: ai.reason === "no_provider" ? "no-provider" : "disabled", briefingUpdatedAt: null, briefingStale: false, briefingAi: ai, }; } /** * Read-only briefing lift. Parses `User.insightsCachedText`, validates * the `dailyBriefing` block, and reports a four-state. NEVER calls the * provider chain — `hasProvider` is a credential-PRESENCE thunk (two * narrow reads, no decrypt, no network) evaluated only on the * stale/missing path, so the common warm-cache snapshot pays nothing. * * v1.15.20 — two honesty upgrades over the old tri-state: * - a stale-but-parseable briefing is DELIVERED with * `briefingStale: true` (plus its timestamp) instead of `null`, so * the tile can show yesterday's content while the warm pass runs; * - when no provider is configured anywhere the state is * `"no-provider"` instead of an eternal `"preparing"`, so clients * can point at Settings → AI rather than spin forever. */ async function liftBriefing( user: SnapshotUserInput, hasProvider: () => Promise, readerLocale: Locale, ): Promise<{ briefing: DailyBriefing | null; briefingState: BriefingState; briefingUpdatedAt: string | null; briefingStale: boolean; }> { // No capability decision here: the body is cached and rebuilt in the // background, where there is no request to resolve one for. Whether the // lifted text may be shown is decided per read by `applyBriefingCapability`. // The cache is one slot per user with no per-language copy. Prose written // in another language is never the reader's briefing, stale or not, so a // differing tag makes the slot count as never generated: no text, no // timestamp. The advisor read (`GET /api/insights/generate`) enqueues the // re-warm in the reader's language on the same condition. An untagged row // predates the tag and is served as before. const cachedLocale = user.insightsCachedLocale; const languageMismatch = cachedLocale !== null && cachedLocale !== readerLocale; const cachedAt = languageMismatch ? null : user.insightsCachedAt; const updatedAt = cachedAt?.toISOString() ?? null; const stale = !cachedAt || Date.now() - cachedAt.getTime() >= BRIEFING_TTL_MS; const cachedBriefing = languageMismatch ? null : parseCachedBriefing(user.insightsCachedText); if (!stale && cachedBriefing) { return { briefing: cachedBriefing, briefingState: "ready", briefingUpdatedAt: updatedAt, briefingStale: false, }; } // Stale or unusable cache — distinguish "a warm pass will fill this" // from "nothing ever will" so the client can stop waiting honestly. const briefingState: BriefingState = (await hasProvider()) ? "preparing" : "no-provider"; return { // Serve the last good briefing (when one parses) instead of a blank // tile; `briefingStale: true` + the timestamp carry the honesty. briefing: cachedBriefing, briefingState, briefingUpdatedAt: updatedAt, briefingStale: cachedBriefing !== null, }; } /** * v1.7.0 — derive the per-metric latest-reading block from the slim * summaries slice already fetched for `tiles.summaries`. ZERO extra DB * round-trips: the slim slice's `DISTINCT ON (type)` read already * carries the latest `value` (`summaries[type].latest`) and the * matching timestamp (`lastSeenByType[type].lastSeenAt`); the unit is * the static canonical-unit lookup. This keeps the snapshot pool-safe * — no new query is issued against the shared Prisma pool. * * Keyed by the iOS `MetricKind` raw value so the iOS cold-launch seed * can decode each entry directly. Types with no iOS counterpart, no * latest value, or no timestamp are omitted (no key emitted). */ function buildMetricStates( summaries: Record, lastSeenByType: Record, ): Record { const out: Record = {}; for (const [type, metricKindRaw] of Object.entries(METRIC_KIND_RAW_BY_TYPE)) { if (!metricKindRaw) continue; const summary = summaries[type]; const lastSeen = lastSeenByType[type]; if (!summary || summary.latest === null || !lastSeen) continue; // v1.11.4 — `summaries.SLEEP_DURATION.latest` now carries last night's // TIME-ASLEEP total in MINUTES (the slim slice collapses the per-stage // rows). Emit the cold-launch seed in HOURS with an explicit `unit: // "h"` so it matches the `/api/dashboard/summary` sleep tile, which // already emits hours. Every other metric passes through with its // canonical stored unit. if (type === "SLEEP_DURATION") { out[metricKindRaw] = { value: Math.round((summary.latest / 60) * 100) / 100, measuredAt: lastSeen.lastSeenAt, unit: "h", }; continue; } out[metricKindRaw] = { value: summary.latest, measuredAt: lastSeen.lastSeenAt, unit: getUnitForType(type), }; } return out; } /** * v1.7.0 — full 27-id widget catalogue (visibility + order). The * server-known ids inherit the user's resolved layout (the same * `visible` / `order` the web `layout` block carries); the 11 iOS-only * ids are appended default-invisible after the highest known order so * the catalogue round-trips in one key. Pure projection over the * already-resolved layout — no DB read. */ function buildLayoutCatalogue( layout: DashboardLayout, ): DashboardLayoutCatalogueEntry[] { const knownById = new Map(); for (const w of layout.widgets) { knownById.set(w.id, { visible: w.visible, order: w.order }); } let nextOrder = layout.widgets.length > 0 ? Math.max(...layout.widgets.map((w) => w.order)) + 1 : 0; const out: DashboardLayoutCatalogueEntry[] = []; for (const id of DASHBOARD_WIDGET_CATALOGUE_IDS) { const known = knownById.get(id); if (known) { out.push({ id, visible: known.visible, order: known.order }); } else { // iOS-only id — not in the user's saved layout. Append // default-invisible after the known ids, mirroring the on-read // auto-upgrade convention for newly-introduced widgets. out.push({ id, visible: false, order: nextOrder }); nextOrder += 1; } } return out.sort((a, b) => a.order - b.order); } /** * Per-type warm gate for the thick phase (extras + healthScore), * replacing the all-types `isFullyCovered` AND. The all-types gate * nulled the whole thick phase whenever ANY unrelated type lacked a * DAY bucket — one fresh wearable reading whose async fold hadn't * landed yet zeroed the hero score, while the insights analytics * route (which calls the self-gating helpers unconditionally) kept * showing a score for the same account. Gate only the dense types the thick * phase might otherwise read live: blood-pressure pairs and activity slices. * Sparse weight rows feed the unscored personal-goal disclosure and remain * cheap; every other score input is naturally sparse. * * `!== false` rather than `=== true`: a type ABSENT from the map has * zero measurements, so the helpers' own live fallbacks are trivially * cheap (empty reads) and absence must not cold the phase. A type * present-but-`false` has live rows without buckets — for these dense types * that is exactly the multi-second live fallback the snapshot must never * wait on, so the phase stays `null` until the backfill converges — unless * none of its readings lies inside the fold window. The fold never writes a * bucket older than `ROLLUP_FOLD_WINDOW_MS`, so such a type is not waiting * for anything: counting it as a miss held the phase `null` for good on an * account whose blood pressure, say, was all imported history. Its live reads * cover only rows older than the window, and the window reads come back * empty. The second query runs on the miss path alone. * An empty map is a fresh account with no measurements at all — nothing to * compute, so the thick phase stays `null`. */ const THICK_PHASE_GATE_TYPES = [ "WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "ACTIVITY_STEPS", ] as const; async function isThickPhaseWarm( userId: string, coverage: RollupCoverageMap, ): Promise { if (coverage.size === 0) return false; const uncovered = THICK_PHASE_GATE_TYPES.filter( (type) => coverage.get(type) === false, ); if (uncovered.length === 0) return true; const foldable = await typesWithReadingsInFoldWindow(userId, uncovered); return foldable.size === 0; } /** * The layout the snapshot publishes: widgets of a switched-off module, and * widgets that cannot paint for this account, forced invisible. Same rule the * Settings screen hides their toggles with, so the dashboard and the switches * never disagree. The stored layout is untouched. */ function gateLayoutByModules( layout: DashboardLayout, modules: Record, summaries: Record, ): DashboardLayout { const unavailable = new Set( unavailableWidgetIds(modules, { hasSdnn: (summaries.HEART_RATE_VARIABILITY?.count ?? 0) > 0, }), ); return hideModuleWidgets(layout, modules, unavailable); } /** * v1.21.2 (A4) — map the machine-derived coach memory block onto the briefing * card's `{ recall, forward }` shape, both already-localised. `recall` is the * prior period's narrative headline (locale-generated prose). `forward` points * ahead from the single most salient trend drift (a metric whose current-period * center has moved OUT of its prior personal band), or a calm "holding steady" * line when nothing drifted. Returns `null` when there is no prior narrative to * recall — silence is the default, never a fabricated callback. * * Localised through the server translator + the EXISTING metric-name keys * (`measurements.type*`). Two NEW forward-look template keys * (`insights.briefing.memory.forwardWatch` / `forwardHolding`) are reported to * the orchestrator; they degrade to the raw key string until the bundle lands. */ function mapBriefingMemory( block: CoachMemoryBlock | null, locale: Locale, ): DashboardSnapshotBriefingMemory | null { if (!block?.priorNarrative) return null; const recall = block.priorNarrative.headline.trim(); if (recall.length === 0) return null; const { t } = getServerTranslator(locale); // The most salient drift: a trend metric whose current-period center sits // OUT of its prior band. Deterministic — first in canonical map order. When // none drifted, the forward-look is the calm "holding steady" line. const driftType = Object.keys(block.trendMemory).find( (type) => block.trendMemory[type]?.currentBand !== "in", ); let forward: string; if (driftType) { const metricLabel = localisedMeasurementLabel(driftType, t); forward = t("insights.briefing.memory.forwardWatch", { metric: metricLabel, }); } else { forward = t("insights.briefing.memory.forwardHolding"); } return { recall, forward }; } /** * Localised display name for a `MeasurementType`, via the existing * `measurements.type*` keys (reused — no new metric-name keys). Falls back to * the prettified raw type when no key resolves so a new type never blanks. */ function localisedMeasurementLabel( type: string, t: (key: string, vars?: Record) => string, ): string { // `WALKING_RUNNING_DISTANCE` → `measurements.typeWalkingRunningDistance`. const camel = type .toLowerCase() .split("_") .map((part, i) => i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), ) .join(""); const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; const resolved = t(key); // The server translator echoes the key on a miss; fall back to a readable form. return resolved === key ? type.replace(/_/g, " ").toLowerCase() : resolved; } /** * The narrative row is keyed by the full six-locale union — the pipeline * generates and stores a row per UI locale — so the briefing memory recall * reads the caller's locale directly. This used to narrow to de/en, which * would now read a row a fr/es/it/pl account never has. */ /** * Assemble the full snapshot in ONE `Promise.all`. Every sub-read is * timed via the optional `time` wrapper so a regression is attributable * through `meta.snapshot.sub_*_ms` without re-instrumenting the route. */ export async function buildDashboardSnapshot( prisma: PrismaClient, user: SnapshotUserInput, options: { /** Optional per-sub-query timing sink (route surfaces it under `meta`). */ time?: (label: string, fn: () => Promise) => Promise; /** * Provider credential-presence probe for the briefing lift — * injectable for tests. Defaults to the cheap presence check in * `@/lib/ai/provider` (no decrypt, no network). */ hasProvider?: () => Promise; /** * v1.18.0 — resolved per-user module map. Injectable for tests; * defaults to `resolveModuleMap(user.id)` (memoised per request). * Disabled toggleable modules have their dashboard tiles / data * stripped from the snapshot at the build layer so nothing leaks to * the client. */ modules?: () => Promise>; /** * v1.21.2 — active locale for the A4 briefing memory prose. Defaults to * English; the route resolves it from the request (cookie / header / user * preference). The recall headline is read locale-specific: the narrative * row `buildCoachMemoryBlock` reads is keyed by this exact locale. */ locale?: Locale; /** * v1.21.2 — injectable narrative-memory builder so the snapshot tests can * exercise the A4 wire without a narrative row. Defaults to the real * `buildCoachMemoryBlock`. */ coachMemory?: typeof buildCoachMemoryBlock; /** * v1.21.2 — injectable score-narrative builder (A5 tension + A6 * return-to-baseline) so the snapshot tests can drive the wire without the * derived engines. Defaults to the real `buildScoreNarrativeBlock`. */ scoreNarrative?: typeof buildScoreNarrativeBlock; /** * v1.27.7 — injectable hero score-ring resolver so the snapshot tests * can pin the wire without the derived engines / compliance reads. * Defaults to the real `buildScoreRingsBlock`. */ scoreRings?: typeof buildScoreRingsBlock; } = {}, ): Promise { const userTz = user.timezone ?? DEFAULT_TIMEZONE; const now = new Date(); const nowMs = now.getTime(); const time = options.time ?? ((_label: string, fn: () => Promise) => fn()); // Probe coverage once up front so the thick `extras` phase only runs // when the rollup tier is warm for the types it reads. A coverage // miss on one of those types returns `extras: null` immediately // rather than dropping into the live-SQL fallback that would make // the whole strip wait on the slowest read (R-firstpaint §6 — // paint-together vs slowest-wins). Unrelated uncovered types do NOT // cold the phase (see `isThickPhaseWarm`). const coverage = await time("coverage", () => probeRollupCoverage(user.id)); const warm = await isThickPhaseWarm(user.id, coverage); // Resolve the stored layout once up front: the hero score rings read // the `selectedScoreRings` preference off it, and the module-gated // `layout` block below reuses the same resolution. const storedLayout = resolveDashboardLayout(user.dashboardWidgetsJson); // v1.18.0 — resolved module map (memoised per request); gates the // toggleable tiles below at the build layer. Held as a shared promise // so the score-ring task can await it inside the same `Promise.all` // without a second resolver call. const modulesPromise = time("modules", () => (options.modules ?? (() => resolveModuleMap(user.id)))(), ); // Fast phase — projection-backed today tally + earliest next-due. Held // as a shared promise (the `modulesPromise` pattern) so the score-ring // task can read today's dose tally inside the same `Promise.all` // without a second builder call. const medsTodayPromise = time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now), ); const [ slimRaw, moodRaw, extrasResult, medsToday, modules, scoreRings, nutrientWater, ] = await Promise.all([ // A5 — reuse the coverage map already probed above so the slice // doesn't re-run the identical `probeRollupCoverage` query. time("summaries", () => computeSummariesSlice(user.id, coverage)), time("mood", () => buildMoodBlock(prisma, user.id)), // v1.34 — the thick slice awaits the shared `modulesPromise` (the same // pattern the score-ring task uses) so the hero ring's health score can // gate its mood pillar on the resolved module map without a second // resolver call or a serialised phase. warm ? time("extras", async () => buildExtras( prisma, user, userTz, coverage, now, time, await modulesPromise, ), ) : Promise.resolve(null), medsTodayPromise, modulesPromise, // v1.27.7 — the selected hero score rings, resolved through the // same engines the derived batch route calls; the dose ring reads // today's tally off the shared medsToday block. Fail-soft: a // throwing resolver yields no rings, never a sunk snapshot. time("scoreRings", async () => (options.scoreRings ?? buildScoreRingsBlock)( prisma, user.id, storedLayout.selectedScoreRings ?? [], await modulesPromise, now, await medsTodayPromise, ), ).catch(() => [] as DashboardScoreRing[]), // v1.29 — fluid-intake tile block (see `buildNutrientWaterBlock`). time("nutrientWater", () => buildNutrientWaterBlock(prisma, user.id)), ]); // v1.29 — fold the water block into the slim slice under a synthetic // `NUTRIENT_WATER` key BEFORE the module gate below strips it (like // every other summary type) when `nutrients` is off. Not a real // `MeasurementType` — see the widened `SUMMARY_TYPE_MODULE` note. if (nutrientWater) { const lastMs = new Date(nutrientWater.lastSeenAt).getTime(); const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000)); slimRaw.summaries = { ...slimRaw.summaries, NUTRIENT_WATER: nutrientWater.summary, }; slimRaw.lastSeenByType = { ...slimRaw.lastSeenByType, NUTRIENT_WATER: { lastSeenAt: nutrientWater.lastSeenAt, daysAgo }, }; } // v1.18.0 — strip disabled-module data before it leaves the server. // Mood is blanked when the mood module is off; sleep / glucose summary // types are dropped from the slim slice; the glucose clinical panel + // per-context block are cleared when glucose is off; the layout + // catalogue hide every disabled-module widget. const slim = gateSummariesByModules( slimRaw.summaries, slimRaw.lastSeenByType, modules, ); const mood = modules.mood === false ? { summary: null, entries: [] as DashboardSnapshotMoodEntry[] } : moodRaw; if (extrasResult && modules.glucose === false) { extrasResult.extras.glucoseByContext = {}; extrasResult.extras.glucoseClinical = computeGlucoseClinicalMetrics([], { windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now, }); } const layout = gateLayoutByModules(storedLayout, modules, slim.summaries); // The Daily Briefing is the dashboard's one AI surface. The builder lifts // it from the cache unconditionally; the `briefing` capability (operator // switch, the AI analysis opt-out, provider, consent) is applied per read // by `applyBriefingCapability`, after the cache. The Coach opt-out no // longer hides it: hiding the Coach hides the Coach. // The reader's locale: the route resolves it from the request; the builder // defaults to English. It decides which cached briefing counts as the // reader's and which narrative row the briefing memory reads. const locale = options.locale ?? "en"; const briefing = await liftBriefing( user, options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)), locale, ); // v1.21.2 (A4 / A5 / A6) — the score-card narrative (Tension Verdict + // return-to-baseline) and the briefing recall + forward-look. The score // narrative is computed from the derived engines, no model involved, so no // AI switch and no AI opt-out gates it. The recall reads the period // narrative, which a model may have written, so it rides the briefing and // is hidden with it per read. Both are fail-soft — a transient derived-engine read resolves to null and // never sinks the snapshot. The narrative block rides the warm phase's // healthScore; memory rides the briefing card. const narrativeSex = binaryReferenceSex(user.gender); const narrativeProfile = { ageYears: getAgeFromDateOfBirth(user.dateOfBirth), sex: narrativeSex, heightCm: user.heightCm ?? null, }; // The Tension Verdict only adds signal once a health score actually rendered // (a coherent ring with no contributors is nothing to reconcile); memory only // matters once a briefing is shown. const narrativeEnabled = extrasResult?.healthScore != null; const [scoreNarrative, coachMemoryBlock] = await Promise.all([ narrativeEnabled ? time("scoreNarrative", () => (options.scoreNarrative ?? buildScoreNarrativeBlock)( user.id, narrativeProfile, now, userTz, coverage, ), ).catch(() => null) : Promise.resolve(null), briefing.briefing !== null ? time("coachMemory", () => (options.coachMemory ?? buildCoachMemoryBlock)( user.id, narrativeProfile, now, locale, ), ).catch(() => null) : Promise.resolve(null), ]); const briefingMemory = mapBriefingMemory(coachMemoryBlock, locale); const gender = toProfileSex(user.gender); const weightTargetOverride = resolveWeightTargetOverride(user.thresholdsJson); return { user: { username: user.displayName?.trim() || user.username, timezone: userTz, heightCm: user.heightCm ?? null, dateOfBirth: user.dateOfBirth?.toISOString() ?? null, gender, glucoseUnit: user.glucoseUnit ?? null, onboardingTourCompleted: user.onboardingTourCompleted, }, layout, layoutCatalogue: buildLayoutCatalogue(layout), metricStates: buildMetricStates(slim.summaries, slim.lastSeenByType), targetBands: buildTargetBands({ dateOfBirth: user.dateOfBirth, gender, heightCm: user.heightCm ?? null, // v1.34 — the user's own weight target, when they set one, replaces the // height-derived band on the tile + the chart. Resolved off the same // `thresholdsJson` blob the health score reads. weightTargetOverride, }), tiles: { summaries: slim.summaries, lastSeenByType: enrichLastSeen(slim.lastSeenByType, nowMs), mood: { summary: mood.entries.length > 0 ? mood.summary : null, entries: mood.entries, }, // v1.28.x — gated like the SLEEP_DURATION summary itself: a // disabled sleep module carries no sleep annotation either. sleepSourceDiscrepancy: modules.sleep === false ? null : slimRaw.sleepSourceDiscrepancy, // v1.39 — the weight tile's progress direction, judged against the // stored target from the same summary the tile renders. weightTrend: resolveWeightTrend( weightTargetOverride, weightTrendReferenceKg(slim.summaries.WEIGHT), ), }, extras: extrasResult?.extras ?? null, medsToday, // v1.21.2 (A5 / A6) — fold the score-card narrative onto the health score so // the hero reads tension + return-to-baseline off the resolved score DTO. healthScore: extrasResult?.healthScore ? { ...extrasResult.healthScore, tension: scoreNarrative?.tension ?? null, returnToBand: scoreNarrative?.returnToBand ?? null, } : null, // v1.27.7 — the resolved hero score rings (selection order, data- // gated). Always set by the live builder; optional on the type so // older cached snapshots stay valid. scoreRings, briefing: briefing.briefing, // v1.21.2 (A4) — the briefing recall + forward-look. Null when there is no // prior narrative on file or no briefing is shown. briefingMemory: briefing.briefing !== null ? briefingMemory : null, briefingState: briefing.briefingState, briefingUpdatedAt: briefing.briefingUpdatedAt, briefingStale: briefing.briefingStale, // Applied per read, after the cache (`applyBriefingCapability`). briefingAi: null, generatedAt: now.toISOString(), }; }