mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
It could never score. The schema records VO2max but cannot tell a measured test apart from a device estimate, so every fitness row reached the scorer unproven and was refused - the pillar has never produced a value on any account. Listing it on the score settings page as a thing you can choose, with a line admitting it does nothing, was chrome over a switch that changed no number. So it leaves the catalogue: no pillar id, no domain, no scorer, no row, no wire field. A stored selection that still names it reads back fine - the resolver has always filtered stored ids against the catalogue and drops what it no longer knows, so nobody's saved recipe throws or is discarded over it. That also fixes the page claiming unsaved changes forever. The save stripped the fitness pillar out of the selection on its way to the server, so the answer that came back could never match the form that sent it, and 'All pillars' left the save button lit no matter how often you pressed it.
1473 lines
58 KiB
TypeScript
1473 lines
58 KiB
TypeScript
/**
|
|
* 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 {
|
|
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 {
|
|
probeRollupCoverage,
|
|
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 { getAssistantFlags } from "@/lib/feature-flags";
|
|
import { hasAnyConfiguredProvider } from "@/lib/ai/provider";
|
|
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,
|
|
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";
|
|
|
|
/** 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 {
|
|
WIDGET_MODULE_BY_ID,
|
|
disabledSummaryTypes,
|
|
} from "@/lib/dashboard/widget-modules";
|
|
|
|
const GLUCOSE_CONTEXTS = [
|
|
"FASTING",
|
|
"POSTPRANDIAL",
|
|
"RANDOM",
|
|
"BEDTIME",
|
|
] as const;
|
|
|
|
/**
|
|
* 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<Record<MeasurementType, string>> = {
|
|
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;
|
|
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;
|
|
/**
|
|
* Server-computed wall-clock hour in the user's timezone so the
|
|
* client greeting never has to run its own `Intl.DateTimeFormat`
|
|
* before first paint.
|
|
*/
|
|
greetingHour: number;
|
|
}
|
|
|
|
/**
|
|
* 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<string, DataSummary>;
|
|
/**
|
|
* 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;
|
|
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<string, DashboardMetricState>;
|
|
/**
|
|
* 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<string, DataSummary>;
|
|
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;
|
|
};
|
|
/** 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;
|
|
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;
|
|
disableCoach: boolean;
|
|
insightsCachedText: string | null;
|
|
insightsCachedAt: Date | 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;
|
|
}
|
|
|
|
/** Wall-clock hour in `tz`, used for the server-side greeting. */
|
|
function hourInTimezone(now: Date, tz: string): number {
|
|
try {
|
|
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
hour: "numeric",
|
|
hour12: false,
|
|
timeZone: tz,
|
|
});
|
|
const parsed = parseInt(fmt.format(now), 10);
|
|
// Intl emits "24" for midnight under hour12:false on some ICU builds.
|
|
return Number.isFinite(parsed) ? parsed % 24 : now.getUTCHours();
|
|
} catch {
|
|
return now.getUTCHours();
|
|
}
|
|
}
|
|
|
|
function enrichLastSeen(
|
|
raw: Record<string, { lastSeenAt: string } | null>,
|
|
nowMs: number,
|
|
): Record<string, { lastSeenAt: string; daysAgo: number } | null> {
|
|
const out: Record<string, { lastSeenAt: string; daysAgo: number } | null> =
|
|
{};
|
|
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> {
|
|
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<string, number>();
|
|
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: new Date(`${day}T00:00:00.000Z`),
|
|
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: <T>(label: string, fn: () => Promise<T>) => Promise<T>,
|
|
modules: Record<ModuleKey, boolean>,
|
|
): 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,
|
|
},
|
|
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,
|
|
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 },
|
|
});
|
|
const glucoseByContext: Record<string, DataSummary> = {};
|
|
if (glucoseRows.length > 0) {
|
|
for (const ctx of GLUCOSE_CONTEXTS) {
|
|
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
|
|
if (ctxRows.length === 0) continue;
|
|
glucoseByContext[ctx] = 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<string, unknown>;
|
|
const candidate = parsed?.dailyBriefing;
|
|
if (candidate == null) return null;
|
|
const validated = dailyBriefingSchema.safeParse(candidate);
|
|
return validated.success ? validated.data : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
coachEnabled: boolean,
|
|
hasProvider: () => Promise<boolean>,
|
|
): Promise<{
|
|
briefing: DailyBriefing | null;
|
|
briefingState: BriefingState;
|
|
briefingUpdatedAt: string | null;
|
|
briefingStale: boolean;
|
|
}> {
|
|
if (!coachEnabled || user.disableCoach) {
|
|
return {
|
|
briefing: null,
|
|
briefingState: "disabled",
|
|
briefingUpdatedAt: null,
|
|
briefingStale: false,
|
|
};
|
|
}
|
|
|
|
const cachedAt = user.insightsCachedAt;
|
|
const updatedAt = cachedAt?.toISOString() ?? null;
|
|
const stale = !cachedAt || Date.now() - cachedAt.getTime() >= BRIEFING_TTL_MS;
|
|
const cachedBriefing = 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<string, DataSummary>,
|
|
lastSeenByType: Record<string, { lastSeenAt: string } | null>,
|
|
): Record<string, DashboardMetricState> {
|
|
const out: Record<string, DashboardMetricState> = {};
|
|
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<string, { visible: boolean; order: number }>();
|
|
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.
|
|
* An empty map is a fresh account with no measurements at all — nothing to
|
|
* compute, so the thick phase stays `null`.
|
|
*/
|
|
function isThickPhaseWarm(coverage: RollupCoverageMap): boolean {
|
|
if (coverage.size === 0) return false;
|
|
return (
|
|
coverage.get("WEIGHT") !== false &&
|
|
coverage.get("BLOOD_PRESSURE_SYS") !== false &&
|
|
coverage.get("BLOOD_PRESSURE_DIA") !== false &&
|
|
coverage.get("ACTIVITY_STEPS") !== false
|
|
);
|
|
}
|
|
|
|
/**
|
|
* v1.18.0 — force every widget whose module is disabled to invisible on
|
|
* the resolved layout (both `visible` and `tileVisible`). Order is
|
|
* preserved so a re-enable restores the user's saved position. Pure
|
|
* projection — the persisted `dashboardWidgetsJson` is untouched; only
|
|
* what the snapshot publishes is gated.
|
|
*/
|
|
function gateLayoutByModules(
|
|
layout: DashboardLayout,
|
|
modules: Record<ModuleKey, boolean>,
|
|
): DashboardLayout {
|
|
return {
|
|
...layout,
|
|
widgets: layout.widgets.map((w) => {
|
|
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
|
|
if (moduleKey && modules[moduleKey] === false) {
|
|
return { ...w, visible: false, tileVisible: false };
|
|
}
|
|
return w;
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* v1.18.0 — strip disabled-module measurement types from the slim slice
|
|
* so neither `tiles.summaries` / `tiles.lastSeenByType` nor the derived
|
|
* `metricStates` carry data for a module the user turned off. Returns a
|
|
* shallow copy; the inputs are not mutated.
|
|
*/
|
|
function gateSummariesByModules(
|
|
summaries: Record<string, DataSummary>,
|
|
lastSeenByType: Record<string, { lastSeenAt: string } | null>,
|
|
modules: Record<ModuleKey, boolean>,
|
|
): {
|
|
summaries: Record<string, DataSummary>;
|
|
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
|
} {
|
|
// The drop decision is shared with `GET /api/dashboard/summary` — see
|
|
// `disabledSummaryTypes`. Only the filtering below is snapshot-shaped.
|
|
const dropped = disabledSummaryTypes(modules);
|
|
if (dropped.size === 0) return { summaries, lastSeenByType };
|
|
const outSummaries: Record<string, DataSummary> = {};
|
|
for (const [type, summary] of Object.entries(summaries)) {
|
|
if (!dropped.has(type)) outSummaries[type] = summary;
|
|
}
|
|
const outLastSeen: Record<string, { lastSeenAt: string } | null> = {};
|
|
for (const [type, slot] of Object.entries(lastSeenByType)) {
|
|
if (!dropped.has(type)) outLastSeen[type] = slot;
|
|
}
|
|
return { summaries: outSummaries, lastSeenByType: outLastSeen };
|
|
}
|
|
|
|
/**
|
|
* 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 | number>) => 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?: <T>(label: string, fn: () => Promise<T>) => Promise<T>;
|
|
/**
|
|
* 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<boolean>;
|
|
/**
|
|
* 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<Record<ModuleKey, boolean>>;
|
|
/**
|
|
* 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<DashboardSnapshot> {
|
|
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
|
const now = new Date();
|
|
const nowMs = now.getTime();
|
|
const time =
|
|
options.time ?? (<T>(_label: string, fn: () => Promise<T>) => 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 = isThickPhaseWarm(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,
|
|
flags,
|
|
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),
|
|
time("flags", () => getAssistantFlags()),
|
|
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);
|
|
// v1.18.0 — the Daily Briefing is the dashboard's AI-narrative surface,
|
|
// so the `insights` module gates it alongside the operator briefing
|
|
// flag + per-user coach opt-out. Disabling `insights` yields the same
|
|
// `briefingState: "disabled"` empty surface the existing flags produce
|
|
// (the raw weight/BP/pulse data stays untouched — `insights` is the
|
|
// narrative layer, not the data layer).
|
|
const briefing = await liftBriefing(
|
|
user,
|
|
flags.briefing && modules.insights !== false,
|
|
options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)),
|
|
);
|
|
|
|
// v1.21.2 (A4 / A5 / A6) — the score-card narrative (Tension Verdict +
|
|
// return-to-baseline) and the briefing recall + forward-look. Gated like the
|
|
// briefing on the `insights` module so a narrative-off account carries none.
|
|
// 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. The locale defaults to English.
|
|
const locale = options.locale ?? "en";
|
|
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 =
|
|
modules.insights !== false && 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 && modules.insights !== false
|
|
? 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);
|
|
|
|
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,
|
|
greetingHour: hourInTimezone(now, userTz),
|
|
},
|
|
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: resolveWeightTargetOverride(user.thresholdsJson),
|
|
}),
|
|
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,
|
|
},
|
|
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,
|
|
generatedAt: now.toISOString(),
|
|
};
|
|
}
|