mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
refactor(openapi): split insights route registry into schemas + paths behind a barrel
This commit is contained in:
1 parent
2e2e596cca
commit
d1be2a9c6b
3 files changed
+1088
No files matched your search
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* OpenAPI route table — insights. Barrel preserving the original module path.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `../index.ts`.
|
||||
*/
|
||||
export { insightsPaths } from "./paths";
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* OpenAPI path table — dashboard snapshot, comprehensive insights, analytics range, metric status, derived metrics, correlations.
|
||||
*
|
||||
* Schema declarations live in `./schemas`; this module is the path orchestrator.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { consentRequiredResponse, dataEnvelope, stdResponses } from "../shared";
|
||||
import {
|
||||
insightsComprehensiveResponse,
|
||||
metricStatusQuery,
|
||||
metricStatusResponse,
|
||||
derivedMetricQuery,
|
||||
derivedMetricResponse,
|
||||
derivedBatchQuery,
|
||||
derivedBatchResponse,
|
||||
correlationDiscoveryResponse,
|
||||
insightStatusQuery,
|
||||
insightStatusResponse,
|
||||
medicationComplianceStatusResponse,
|
||||
analyticsRangeQuery,
|
||||
analyticsRangeResponse,
|
||||
insightsPregenerateRequest,
|
||||
insightsPregenerateResponse,
|
||||
dashboardSnapshotResponse,
|
||||
} from "./schemas";
|
||||
|
||||
export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/dashboard/snapshot": {
|
||||
get: {
|
||||
tags: ["Dashboard"],
|
||||
summary: "Unified dashboard first-paint snapshot",
|
||||
description:
|
||||
"Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Dashboard snapshot.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
dashboardSnapshotResponse,
|
||||
"DashboardSnapshotResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/comprehensive": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Comprehensive AI insights bundle",
|
||||
description:
|
||||
"Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider chain egresses via the operator's server-managed key (see POST /api/consent/ai).",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Insights bundle.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsComprehensiveResponse,
|
||||
"InsightsComprehensiveResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...consentRequiredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/pregenerate": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
summary: "Warm all AI assessments for the calling user",
|
||||
description:
|
||||
"v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:<userId>`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": { schema: insightsPregenerateRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsPregenerateResponse,
|
||||
"InsightsPregenerateResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/analytics/range": {
|
||||
get: {
|
||||
tags: ["Analytics"],
|
||||
summary: "Single-metric period-over-period range delta",
|
||||
description:
|
||||
"v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: analyticsRangeQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Current + previous window aggregates and the delta.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
analyticsRangeResponse,
|
||||
"AnalyticsRangeResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/blood-pressure-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Blood-pressure assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"BloodPressureStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/pulse-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Pulse assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"PulseStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/weight-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Weight assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"WeightStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/bmi-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "BMI assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"BmiStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/mood-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Mood assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"MoodStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/medication-compliance-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Medication-compliance assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Compliance assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationComplianceStatusResponse,
|
||||
"MedicationComplianceStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/metric-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Generic per-HealthKit-metric assessment",
|
||||
description:
|
||||
"v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: metricStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
metricStatusResponse,
|
||||
"MetricStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/derived": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Derived wellness metric (compute-once)",
|
||||
description:
|
||||
"v1.10.0 — the compute-once `Derived<T>` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived<T>` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: derivedMetricQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The flat derived-metric value (ok or insufficient).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
derivedMetricResponse,
|
||||
"DerivedMetricResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/derived/batch": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Derived wellness metrics (batched compute-once)",
|
||||
description:
|
||||
"v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: derivedBatchQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The map of derived-metric values, keyed by token.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
derivedBatchResponse,
|
||||
"DerivedBatchResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/correlations": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Correlation discovery (FDR-controlled)",
|
||||
description:
|
||||
"v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The discovered correlations + the tested-pair count.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
correlationDiscoveryResponse,
|
||||
"CorrelationDiscoveryResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,707 @@
|
||||
/**
|
||||
* OpenAPI route table — dashboard snapshot, comprehensive insights, analytics range, metric status, derived metrics, correlations.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
* Schemas come from `src/lib/validations/*` where shared with the
|
||||
* runtime request parsing, so the wire contract stays single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
import {
|
||||
DERIVED_METRIC_IDS,
|
||||
VITALS_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
|
||||
export const insightsComprehensiveResponse = z
|
||||
.object({
|
||||
summary: z.string(),
|
||||
recommendations: z.array(z.record(z.string(), z.unknown())),
|
||||
citations: z.array(z.record(z.string(), z.unknown())),
|
||||
warnings: z.array(z.record(z.string(), z.unknown())),
|
||||
dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
storyboardAnnotations: z
|
||||
.array(z.record(z.string(), z.unknown()))
|
||||
.optional(),
|
||||
metricSource: z.record(z.string(), z.unknown()).optional(),
|
||||
revalidating: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsComprehensiveResponse",
|
||||
description:
|
||||
"AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.",
|
||||
});
|
||||
|
||||
// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is
|
||||
// derived from the same registry the route validates against, so the
|
||||
// spec, the route, and the cache scope cannot drift. The seven
|
||||
// specialised metrics (weight / blood-pressure / pulse / bmi / mood /
|
||||
// medication-compliance) keep their own routes and are NOT accepted here.
|
||||
export const metricStatusQuery = z
|
||||
.object({
|
||||
metric: z
|
||||
.enum(METRIC_STATUS_IDS as [string, ...string[]])
|
||||
.describe(
|
||||
"HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.",
|
||||
),
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
.optional()
|
||||
.describe("Optional UI-locale override; defaults to the session locale."),
|
||||
})
|
||||
.meta({ id: "MetricStatusQuery" });
|
||||
|
||||
export const metricStatusResponse = z
|
||||
.object({
|
||||
hasProvider: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False when the user has no usable AI provider — `text` then carries the generic no-key guidance.",
|
||||
),
|
||||
text: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the metric has insufficient data.",
|
||||
),
|
||||
cached: z
|
||||
.boolean()
|
||||
.describe("True when `text` is served from cache (incl. last-good)."),
|
||||
updatedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("When the served assessment was generated; null when none."),
|
||||
preparing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.",
|
||||
),
|
||||
revalidating: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.",
|
||||
),
|
||||
insufficient: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MetricStatusResponse",
|
||||
description:
|
||||
"Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.",
|
||||
});
|
||||
|
||||
// v1.10.0 — generic derived-wellness-metric route. The query enum is
|
||||
// derived from the same registry the route validates against, so spec +
|
||||
// route + cache scope cannot drift. `type` sub-targets the single vital
|
||||
// a baseline metric (VITALS_BASELINE) bands over.
|
||||
export const derivedMetricQuery = z
|
||||
.object({
|
||||
metric: z
|
||||
.enum(DERIVED_METRIC_IDS as [string, ...string[]])
|
||||
.describe(
|
||||
"Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.",
|
||||
),
|
||||
type: z
|
||||
.enum(VITALS_BASELINE_TYPES as [string, ...string[]])
|
||||
.optional()
|
||||
.describe(
|
||||
"For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DerivedMetricQuery" });
|
||||
|
||||
export const derivedCoverage = z
|
||||
.object({
|
||||
requiredInputs: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Inputs the metric wants (its full input set)."),
|
||||
presentInputs: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Inputs actually present in the user's data."),
|
||||
historyDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Distinct days of history backing the value (the gating floor).",
|
||||
),
|
||||
missing: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"Named inputs still missing — drives the 'track N more' nudge.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DerivedCoverage" });
|
||||
|
||||
export const derivedConfidence = z
|
||||
.object({
|
||||
score: z
|
||||
.number()
|
||||
.describe(
|
||||
"0..100 confidence; feeds the shared coverage meter unchanged.",
|
||||
),
|
||||
band: z
|
||||
.enum(["high", "medium", "low", "draft"])
|
||||
.describe("Confidence band the meter renders."),
|
||||
})
|
||||
.meta({ id: "DerivedConfidence" });
|
||||
|
||||
export const derivedProvenance = z
|
||||
.object({
|
||||
inputs: z
|
||||
.array(z.string())
|
||||
.describe("Named inputs that actually backed the value."),
|
||||
source: z
|
||||
.enum(["DAY", "WEEK", "MONTH", "YEAR", "live", "none"])
|
||||
.describe(
|
||||
"Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no data backed the value.",
|
||||
),
|
||||
windowDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Trailing window the value summarises, in days."),
|
||||
computedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe("Compute time (for cache-staleness + the 'as of' chip)."),
|
||||
})
|
||||
.meta({ id: "DerivedProvenance" });
|
||||
|
||||
// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field
|
||||
// on the derived response; the iOS field-name contract is LOCKED.
|
||||
export const derivedAssessment = z
|
||||
.object({
|
||||
text: z
|
||||
.string()
|
||||
.describe(
|
||||
"Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.",
|
||||
),
|
||||
source: z
|
||||
.string()
|
||||
.describe(
|
||||
"'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.",
|
||||
),
|
||||
updatedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe("When the text was produced / last warmed."),
|
||||
})
|
||||
.meta({ id: "DerivedAssessment" });
|
||||
|
||||
export const derivedMetricResponse = z
|
||||
.object({
|
||||
metric: z
|
||||
.enum(DERIVED_METRIC_IDS as [string, ...string[]])
|
||||
.describe("Echoes the requested derived-metric id (tags the union)."),
|
||||
status: z
|
||||
.enum(["ok", "insufficient"])
|
||||
.describe(
|
||||
"'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries `coverage` + `provenance` so the surface renders the same gating UI.",
|
||||
),
|
||||
value: z
|
||||
.record(z.string(), z.unknown())
|
||||
.nullable()
|
||||
.describe(
|
||||
"Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.",
|
||||
),
|
||||
coverage: derivedCoverage,
|
||||
confidence: derivedConfidence
|
||||
.nullable()
|
||||
.describe("Present when status is 'ok'; null when 'insufficient'."),
|
||||
provenance: derivedProvenance,
|
||||
reason: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Why the value could not be produced; null when status is 'ok'.",
|
||||
),
|
||||
assessment: derivedAssessment
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "DerivedMetricResponse",
|
||||
description:
|
||||
"Flat `Derived<T>` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.",
|
||||
});
|
||||
|
||||
// v1.10.0 — batched derived-metric query. The `metrics` CSV carries one
|
||||
// or more `metric` / `metric:type` tokens; the route fans out server-side
|
||||
// under a bounded limiter with the profile loaded once, collapsing the
|
||||
// dashboard's cold-mount fan-out of N single-metric requests into one.
|
||||
export const derivedBatchQuery = z
|
||||
.object({
|
||||
metrics: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.describe(
|
||||
"Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DerivedBatchQuery" });
|
||||
|
||||
export const derivedBatchResponse = z
|
||||
.object({
|
||||
metrics: z
|
||||
.record(z.string(), derivedMetricResponse)
|
||||
.describe(
|
||||
"Map keyed by the per-request token (`<metric>` or `<metric>:<type>`). Each value is the same flat `Derived<T>` envelope the single-metric route returns, so a client decodes one shape and reads back exactly the tokens it asked for.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "DerivedBatchResponse",
|
||||
description:
|
||||
"Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table.",
|
||||
});
|
||||
|
||||
// v1.10.0 — FDR-controlled correlation discovery result. One discovered,
|
||||
// statistically-defensible behaviour → next-day-outcome pair.
|
||||
export const discoveredCorrelation = z
|
||||
.object({
|
||||
behaviour: z
|
||||
.string()
|
||||
.describe("Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD."),
|
||||
outcome: z
|
||||
.string()
|
||||
.describe(
|
||||
"Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY.",
|
||||
),
|
||||
n: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Paired-day count after the day+1 lag join (≥ 20)."),
|
||||
r: z.number().describe("Pearson r over the lag-joined daily series."),
|
||||
pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."),
|
||||
qValue: z
|
||||
.number()
|
||||
.describe(
|
||||
"Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).",
|
||||
),
|
||||
interpretation: z
|
||||
.string()
|
||||
.describe("Conservative, descriptive interpretation — never causal."),
|
||||
lagDays: z.number().int().describe("Lag in days applied (1)."),
|
||||
})
|
||||
.meta({ id: "DiscoveredCorrelation" });
|
||||
|
||||
export const correlationDiscoveryResponse = z
|
||||
.object({
|
||||
discovered: z
|
||||
.array(discoveredCorrelation)
|
||||
.describe("Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control."),
|
||||
pairsTested: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Behaviour × outcome pairs assessed (for the honest footer)."),
|
||||
fdrQ: z.number().describe("The FDR target the surface used."),
|
||||
minPairs: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Minimum paired-day count enforced per pair."),
|
||||
})
|
||||
.meta({
|
||||
id: "CorrelationDiscoveryResponse",
|
||||
description:
|
||||
"v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.",
|
||||
});
|
||||
|
||||
// The seven specialised `*-status` routes accept an optional locale
|
||||
// override (the metric is fixed by the route path, unlike the generic
|
||||
// metric-status route which carries it as a query field).
|
||||
export const insightStatusQuery = z
|
||||
.object({
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
.optional()
|
||||
.describe("Optional UI-locale override; defaults to the session locale."),
|
||||
})
|
||||
.meta({ id: "InsightStatusQuery" });
|
||||
|
||||
// Shared response shape for the five text-bearing specialised status
|
||||
// routes (blood-pressure, pulse, weight, bmi, mood). Same envelope as
|
||||
// the generic metric-status card minus the `insufficient` flag, which is
|
||||
// metric-status-only. Read-only + stale-while-revalidate.
|
||||
export const insightStatusResponse = z
|
||||
.object({
|
||||
hasProvider: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False when the user has no usable AI provider — `text` then carries the generic no-key guidance.",
|
||||
),
|
||||
text: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing.",
|
||||
),
|
||||
cached: z
|
||||
.boolean()
|
||||
.describe("True when `text` is served from cache (incl. last-good)."),
|
||||
updatedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("When the served assessment was generated; null when none."),
|
||||
preparing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.",
|
||||
),
|
||||
revalidating: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightStatusResponse",
|
||||
description:
|
||||
"Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.",
|
||||
});
|
||||
|
||||
// The medication-compliance route carries a richer envelope than the
|
||||
// other six: a `summary` narrative plus a per-medication `text` array,
|
||||
// instead of a single `text` field.
|
||||
export const medicationComplianceStatusResponse = z
|
||||
.object({
|
||||
hasProvider: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False when the user has no usable AI provider — `summary` then carries the generic no-key guidance.",
|
||||
),
|
||||
summary: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The overall compliance narrative (plain text). Null while a first generation is preparing.",
|
||||
),
|
||||
medications: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
medicationId: z
|
||||
.string()
|
||||
.describe("The medication this note belongs to."),
|
||||
text: z
|
||||
.string()
|
||||
.describe("Per-medication compliance note (plain text)."),
|
||||
})
|
||||
.meta({ id: "MedicationComplianceStatusItem" }),
|
||||
)
|
||||
.describe(
|
||||
"Per-medication compliance notes. Empty while preparing or when no medication qualifies.",
|
||||
),
|
||||
cached: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the envelope is served from cache (incl. last-good).",
|
||||
),
|
||||
updatedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("When the served assessment was generated; null when none."),
|
||||
preparing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when a first assessment is being generated out of band and no prior summary exists yet — the client polls until it lands.",
|
||||
),
|
||||
revalidating: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded).",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationComplianceStatusResponse",
|
||||
description:
|
||||
"Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate.",
|
||||
});
|
||||
|
||||
export const analyticsRangeQuery = z
|
||||
.object({
|
||||
type: measurementTypeEnum.describe(
|
||||
"The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s.",
|
||||
),
|
||||
range: z
|
||||
.enum(ANALYTICS_RANGES)
|
||||
.describe(
|
||||
"Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span immediately before it.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "AnalyticsRangeQuery" });
|
||||
|
||||
export const analyticsWindowAggregate = z
|
||||
.object({
|
||||
count: z.number().int().describe("Reading count composed across buckets."),
|
||||
min: z.number().nullable().describe("Window minimum; null when empty."),
|
||||
max: z.number().nullable().describe("Window maximum; null when empty."),
|
||||
mean: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("Count-weighted mean across buckets; null when empty."),
|
||||
sum: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "AnalyticsWindowAggregate" });
|
||||
|
||||
export const analyticsRangeResponse = z
|
||||
.object({
|
||||
range: z
|
||||
.enum(ANALYTICS_RANGES)
|
||||
.describe("The range that was read (echoes the request)."),
|
||||
windowDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Trailing-window length in days for the chosen range."),
|
||||
granularity: z
|
||||
.string()
|
||||
.describe(
|
||||
"Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage miss).",
|
||||
),
|
||||
current: analyticsWindowAggregate.describe(
|
||||
"Aggregate over the current window `[now-N, now)`.",
|
||||
),
|
||||
previous: analyticsWindowAggregate.describe(
|
||||
"Aggregate over the previous comparable window `[now-2N, now-N)`.",
|
||||
),
|
||||
delta: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"`current.mean - previous.mean`; null when either window has no data (never a misleading 0).",
|
||||
),
|
||||
deltaPct: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"`delta / previous.mean` as a fraction (0.03 = +3 %); null when the prior window has no / zero mean (no divide-by-zero). The client shows 'no prior-period data' in that case.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AnalyticsRangeResponse",
|
||||
description:
|
||||
"Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable).",
|
||||
});
|
||||
|
||||
export const insightsPregenerateRequest = z.object({}).meta({
|
||||
id: "InsightsPregenerateRequest",
|
||||
description:
|
||||
"No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user.",
|
||||
});
|
||||
|
||||
export const insightsPregenerateResponse = z
|
||||
.object({
|
||||
queued: z
|
||||
.boolean()
|
||||
.describe("True when the full warm was accepted and enqueued."),
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
.describe("The locale the assessments are being warmed in."),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsPregenerateResponse",
|
||||
description:
|
||||
"Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes.",
|
||||
});
|
||||
|
||||
// v1.7.0 — unified dashboard first-paint snapshot. One GET that
|
||||
// assembles every above-the-fold tile field in a single round-trip.
|
||||
// Two-phase shape: `tiles` (fast, always present) + `extras` (thick,
|
||||
// nullable on a rollup-coverage miss). The nested AI / DataSummary
|
||||
// blocks are typed loosely (`z.record`) to match the comprehensive
|
||||
// response style above — the strict shapes live in their own Zod
|
||||
// modules and the iOS client does not consume this web-only route.
|
||||
export const dataSummaryRecord = z.record(z.string(), z.unknown());
|
||||
|
||||
// v1.17.0 — server-authoritative glucose clinical panel. Mirrors
|
||||
// `GlucoseClinicalMetrics` from `@/lib/analytics/glucose-metrics`: the
|
||||
// trailing-30-day TIR / GMI / eA1C / CV% headline plus the advanced
|
||||
// J-index + LBGI/HBGI tier, gated by a `stillLearning` flag so a thin
|
||||
// spot-data window is never asserted as a clinical AGP. The iOS client
|
||||
// renders these numbers verbatim and never re-derives them.
|
||||
export const glucoseClinicalSchema = z
|
||||
.object({
|
||||
stillLearning: z.boolean(),
|
||||
stillLearningReason: z.string().nullable(),
|
||||
windowDays: z.number().int(),
|
||||
actualSpanDays: z.number(),
|
||||
readingCount: z.number().int(),
|
||||
meanMgdl: z.number().nullable(),
|
||||
distribution: z
|
||||
.object({
|
||||
tir: z.number(),
|
||||
tbrLevel1: z.number(),
|
||||
tbrLevel2: z.number(),
|
||||
tarLevel1: z.number(),
|
||||
tarLevel2: z.number(),
|
||||
minutesEquivalent: z.object({
|
||||
tir: z.number(),
|
||||
tbrLevel1: z.number(),
|
||||
tbrLevel2: z.number(),
|
||||
tarLevel1: z.number(),
|
||||
tarLevel2: z.number(),
|
||||
}),
|
||||
})
|
||||
.nullable(),
|
||||
gmi: z.number().nullable(),
|
||||
estimatedA1c: z.number().nullable(),
|
||||
variability: z
|
||||
.object({
|
||||
sd: z.number(),
|
||||
cv: z.number(),
|
||||
unstable: z.boolean(),
|
||||
})
|
||||
.nullable(),
|
||||
advanced: z
|
||||
.object({
|
||||
jIndex: z.number().nullable(),
|
||||
lbgi: z.number(),
|
||||
hbgi: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
isSpotEstimate: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
id: "GlucoseClinicalMetrics",
|
||||
description:
|
||||
"Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.",
|
||||
});
|
||||
|
||||
export const dashboardSnapshotResponse = z
|
||||
.object({
|
||||
user: z.object({
|
||||
username: z.string(),
|
||||
timezone: z.string(),
|
||||
heightCm: z.number().nullable(),
|
||||
dateOfBirth: z.string().nullable(),
|
||||
gender: z.enum(["MALE", "FEMALE"]).nullable(),
|
||||
glucoseUnit: z.string().nullable(),
|
||||
onboardingTourCompleted: z.boolean(),
|
||||
greetingHour: z.number().int(),
|
||||
}),
|
||||
layout: z.record(z.string(), z.unknown()),
|
||||
// v1.7.0 — full 27-id widget catalogue (16 server-known + 11
|
||||
// iOS-only) so a cold-launch first-paint seeds every tile and the
|
||||
// layout round-trips in one key. Additive alongside the web
|
||||
// `layout` block, which stays byte-identical.
|
||||
layoutCatalogue: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
visible: z.boolean(),
|
||||
order: z.number().int(),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Full 27-id widget catalogue (server-known + iOS-only) with per-widget visibility + order. iOS-only ids are appended default-invisible. The web dashboard reads `layout`; this block is the cold-launch seed for the native client.",
|
||||
),
|
||||
// v1.7.0 — per-chartable-metric latest reading keyed by iOS
|
||||
// `MetricKind` raw value (e.g. `oxygenSaturation`,
|
||||
// `heartRateVariability`, `bodyMassIndex`). Derived in-process from
|
||||
// the slim summaries slice — no extra DB read.
|
||||
metricStates: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
value: z.number(),
|
||||
measuredAt: z.string(),
|
||||
unit: z.string(),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.",
|
||||
),
|
||||
tiles: z.object({
|
||||
summaries: dataSummaryRecord,
|
||||
lastSeenByType: z.record(z.string(), z.unknown()),
|
||||
mood: z.object({
|
||||
summary: dataSummaryRecord.nullable(),
|
||||
entries: z.array(
|
||||
z.object({
|
||||
date: z.string(),
|
||||
score: z.number(),
|
||||
samples: z.number().int(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
extras: z
|
||||
.object({
|
||||
bpInTargetPct: z.number().nullable(),
|
||||
bpInTargetPct7d: z.number().nullable(),
|
||||
bpInTargetPct30d: z.number().nullable(),
|
||||
bpInTargetPctAllTime: z.number().nullable(),
|
||||
bpInTargetPctPriorMonth: z.number().nullable(),
|
||||
bpInTargetPctPriorYear: z.number().nullable(),
|
||||
bpInTargetCount90: z.number().int().nullable(),
|
||||
bpInTargetSpanDays90: z.number().int().nullable(),
|
||||
glucoseByContext: dataSummaryRecord,
|
||||
glucoseClinical: glucoseClinicalSchema,
|
||||
})
|
||||
.nullable(),
|
||||
// Dashboard hero — today's medication block (fast phase, always
|
||||
// present). Projection-backed tally + earliest next-due across
|
||||
// active medications.
|
||||
medsToday: z
|
||||
.object({
|
||||
activeCount: z.number().int(),
|
||||
scheduledToday: z.number().int(),
|
||||
takenToday: z.number().int(),
|
||||
skippedToday: z.number().int(),
|
||||
nextDueAt: z.string().nullable(),
|
||||
nextDueOverdue: z.boolean(),
|
||||
nextDueMedicationName: z.string().nullable(),
|
||||
})
|
||||
.describe(
|
||||
"Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day summary, never an overdue state.",
|
||||
),
|
||||
// Dashboard hero — health score (warm phase, nullable on a
|
||||
// rollup-coverage miss). Score + band + delta only; the per-pillar
|
||||
// component breakdown stays on the analytics route.
|
||||
healthScore: z
|
||||
.object({
|
||||
score: z.number().int(),
|
||||
band: z.enum(["green", "yellow", "red"]),
|
||||
delta: z.number().nullable(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.",
|
||||
),
|
||||
briefing: z.record(z.string(), z.unknown()).nullable(),
|
||||
briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]),
|
||||
briefingUpdatedAt: z.string().nullable(),
|
||||
briefingStale: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile.",
|
||||
),
|
||||
generatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "DashboardSnapshotResponse",
|
||||
description:
|
||||
"Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.",
|
||||
});
|
||||
Reference in new issue
Block a user