mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
1527 lines
61 KiB
TypeScript
1527 lines
61 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 { 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<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;
|
|
/**
|
|
* 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<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;
|
|
/**
|
|
* 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<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;
|
|
/**
|
|
* 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<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> {
|
|
// 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<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: 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: <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,
|
|
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<string, DataSummary> = {};
|
|
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<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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<boolean>,
|
|
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<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 — 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<boolean> {
|
|
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<ModuleKey, boolean>,
|
|
summaries: Record<string, DataSummary>,
|
|
): 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 | 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 = 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(),
|
|
};
|
|
}
|