fix(openapi): declare the dashboard snapshot fields the route actually emits

GET /api/dashboard/snapshot is additionalProperties: false in the spec
but the runtime emits three blocks the schema never declared: the
band/target math (targetBands, always present), the score-card
narrative (healthScore.tension / healthScore.returnToBand, additive
optional fields), and the briefing recall/forward-look
(briefingMemory). A strictly generated client rejects the real
response as a result.

Adds all four to dashboardSnapshotResponse with the exact runtime
type, optionality, and nullability, matching DashboardSnapshot in
src/lib/dashboard/snapshot.ts and the DTOs in
src/lib/dashboard/score-narrative.ts. No route behaviour changes.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 01:26:21 +02:00
1 parent d8ddf97d9e
commit d04eab71a3
2 files changed
+519

No files matched your search

+368
View File
@@ -27668,6 +27668,199 @@ components:
`walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`,
`activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types
the user has never logged are omitted."
targetBands:
type: object
properties:
bpTargets:
anyOf:
- type: object
properties:
sysLow:
type: number
sysHigh:
type: number
diaLow:
type: number
diaHigh:
type: number
required:
- sysLow
- sysHigh
- diaLow
- diaHigh
additionalProperties: false
- type: "null"
description: Personalised blood-pressure target numbers. Null when the profile has no date of birth.
bpSysRange:
anyOf:
- type: object
properties:
greenMin:
type: number
greenMax:
type: number
orangeMin:
type: number
orangeMax:
type: number
required:
- greenMin
- greenMax
- orangeMin
- orangeMax
additionalProperties: false
- type: "null"
description: Systolic traffic-light range. Null when the profile has no date of birth.
bpDiaRange:
anyOf:
- type: object
properties:
greenMin:
type: number
greenMax:
type: number
orangeMin:
type: number
orangeMax:
type: number
required:
- greenMin
- greenMax
- orangeMin
- orangeMax
additionalProperties: false
- type: "null"
description: Diastolic traffic-light range. Null when the profile has no date of birth.
pulseDisplayRange:
type: object
properties:
greenMin:
type: number
greenMax:
type: number
orangeMin:
type: number
orangeMax:
type: number
required:
- greenMin
- greenMax
- orangeMin
- orangeMax
additionalProperties: false
description: Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.
pulseBands:
type: array
items:
type: object
properties:
min:
type: number
max:
type: number
color:
type: string
opacity:
type: number
strokeOpacity:
type: number
required:
- min
- max
- color
additionalProperties: false
description: Resting-pulse chart bands. Always present — AHA fallback with no date of birth.
bodyFatRange:
type: object
properties:
min:
type: number
max:
type: number
required:
- min
- max
additionalProperties: false
description: Gender-aware body-fat target range. Always present.
bodyFatBands:
type: array
items:
type: object
properties:
min:
type: number
max:
type: number
color:
type: string
opacity:
type: number
strokeOpacity:
type: number
required:
- min
- max
- color
additionalProperties: false
description: Body-fat chart bands. Always present.
weightRange:
anyOf:
- type: object
properties:
greenMin:
type: number
greenMax:
type: number
orangeMin:
type: number
orangeMax:
type: number
required:
- greenMin
- greenMax
- orangeMin
- orangeMax
additionalProperties: false
- type: "null"
description: "Weight traffic-light range: the user's own target band when set, else the height-derived WHO band, else
null (no height and no target)."
weightBands:
anyOf:
- type: array
items:
type: object
properties:
min:
type: number
max:
type: number
color:
type: string
opacity:
type: number
strokeOpacity:
type: number
required:
- min
- max
- color
additionalProperties: false
- type: "null"
description: Weight chart bands, from the same range. Null when `weightRange` is null.
required:
- bpTargets
- bpSysRange
- bpDiaRange
- pulseDisplayRange
- pulseBands
- bodyFatRange
- bodyFatBands
- weightRange
- weightBands
additionalProperties: false
description: Server-computed band / target math resolved from the user's profile facts, so the client reads these
numbers instead of recomputing them. Every sub-field is null only when the driving profile fact is missing
(no date of birth for blood pressure, no height and no weight target for weight).
tiles:
type: object
properties:
@@ -27967,6 +28160,144 @@ components:
- episodeCount
additionalProperties: false
- type: "null"
tension:
description: "Locale-agnostic tension verdict: present only when the readiness composite's contributors disagree.
`positive`/`negative` carry readiness contributor keys the client maps to localised labels. Null on
a coherent day or when a clinical red-flag suppresses it. Optional so older cached snapshots without
the field stay valid."
anyOf:
- type: object
properties:
band:
type: string
enum:
- green
- yellow
- red
positive:
type: array
items:
type: string
enum:
- rhr
- hrv
- sleep
- respiratory
- mood
negative:
type: array
items:
type: string
enum:
- rhr
- hrv
- sleep
- respiratory
- mood
required:
- band
- positive
- negative
additionalProperties: false
- type: "null"
returnToBand:
description: "Locale-agnostic return-to-baseline event: present only when a salient metric (resting heart rate, HRV,
respiratory rate, or weight) has come back inside the user's own personal range after a genuine
prior out-of-band run. At most one, the most salient. `metricType` is the measurement type the
client maps to a localised metric name; `daysInside` is how long it has now sat back inside range.
Null otherwise. Optional so older cached snapshots without the field stay valid."
anyOf:
- type: object
properties:
metricType:
type: string
enum:
- WEIGHT
- BLOOD_PRESSURE_SYS
- BLOOD_PRESSURE_DIA
- PULSE
- BODY_FAT
- SLEEP_DURATION
- ACTIVITY_STEPS
- BLOOD_GLUCOSE
- TOTAL_BODY_WATER
- BONE_MASS
- OXYGEN_SATURATION
- HEART_RATE_VARIABILITY
- RESTING_HEART_RATE
- ACTIVE_ENERGY_BURNED
- FLIGHTS_CLIMBED
- WALKING_RUNNING_DISTANCE
- VO2_MAX
- BODY_TEMPERATURE
- FAT_FREE_MASS
- FAT_MASS
- MUSCLE_MASS
- SKIN_TEMPERATURE
- PULSE_WAVE_VELOCITY
- VASCULAR_AGE
- VISCERAL_FAT
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
- RESPIRATORY_RATE
- BODY_MASS_INDEX
- LEAN_BODY_MASS
- WALKING_HEART_RATE_AVERAGE
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
- WALKING_STEP_LENGTH
- WALKING_SPEED
- CARDIO_RECOVERY
- WRIST_TEMPERATURE
- FALL_COUNT
- SIX_MINUTE_WALK_DISTANCE
- STAIR_ASCENT_SPEED
- STAIR_DESCENT_SPEED
- BREATHING_DISTURBANCES
- IRREGULAR_RHYTHM_NOTIFICATION
- HIGH_HEART_RATE_EVENT
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
- HRV_RMSSD
- DAY_STRAIN
- WORKOUT_STRAIN
- SLEEP_PERFORMANCE
- SLEEP_EFFICIENCY
- SLEEP_CONSISTENCY
- SLEEP_NEED
- ENERGY_EXPENDITURE_KJ
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
- RESILIENCE
- PHQ9_SCORE
- GAD7_SCORE
- GRIP_STRENGTH
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
daysInside:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- metricType
- daysInside
additionalProperties: false
- type: "null"
required:
- score
- band
@@ -28034,6 +28365,23 @@ components:
type: string
additionalProperties: {}
- type: "null"
briefingMemory:
description: "The briefing recall + forward-look: `recall` is the prior period's narrative headline, `forward` points
ahead to the most salient trend drift (or a calm 'holding steady' line). Both are already-localised prose
the client renders verbatim. Null when no prior narrative is on file or no briefing is shown. Optional so
older cached snapshots without the field stay valid."
anyOf:
- type: object
properties:
recall:
type: string
forward:
type: string
required:
- recall
- forward
additionalProperties: false
- type: "null"
briefingState:
type: string
enum:
@@ -28057,6 +28405,7 @@ components:
- layout
- layoutCatalogue
- metricStates
- targetBands
- tiles
- extras
- medsToday
@@ -34326,9 +34675,28 @@ components:
- latestAmount
- daysWithData
additionalProperties: false
lastAttempt:
anyOf:
- type: object
properties:
at:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
topReason:
type: string
required:
- at
- topReason
additionalProperties: false
- type: "null"
description: Non-null only when `nutrients` is empty AND the most recent nutrient-batch call landed nothing — the most
common skip reason from that call, so the client can say a sync arrived and why instead of a bare empty
state.
required:
- windowDays
- nutrients
- lastAttempt
additionalProperties: false
description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying
data inside the window. Nutrients without data in the window are omitted."
+151
View File
@@ -896,6 +896,112 @@ export const dashboardSnapshotResponse = z
.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.",
),
// v1.18.6 — server-computed band / target math (audit finding #3),
// resolved from the user's own profile facts (DOB / gender / height /
// weight-target override). The dashboard reads these instead of
// recomputing client-side; always present (every sub-field falls back
// to a neutral default rather than being omitted).
targetBands: z
.object({
bpTargets: z
.object({
sysLow: z.number(),
sysHigh: z.number(),
diaLow: z.number(),
diaHigh: z.number(),
})
.nullable()
.describe(
"Personalised blood-pressure target numbers. Null when the profile has no date of birth.",
),
bpSysRange: z
.object({
greenMin: z.number(),
greenMax: z.number(),
orangeMin: z.number(),
orangeMax: z.number(),
})
.nullable()
.describe(
"Systolic traffic-light range. Null when the profile has no date of birth.",
),
bpDiaRange: z
.object({
greenMin: z.number(),
greenMax: z.number(),
orangeMin: z.number(),
orangeMax: z.number(),
})
.nullable()
.describe(
"Diastolic traffic-light range. Null when the profile has no date of birth.",
),
pulseDisplayRange: z
.object({
greenMin: z.number(),
greenMax: z.number(),
orangeMin: z.number(),
orangeMax: z.number(),
})
.describe(
"Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.",
),
pulseBands: z
.array(
z.object({
min: z.number(),
max: z.number(),
color: z.string(),
opacity: z.number().optional(),
strokeOpacity: z.number().optional(),
}),
)
.describe(
"Resting-pulse chart bands. Always present — AHA fallback with no date of birth.",
),
bodyFatRange: z
.object({ min: z.number(), max: z.number() })
.describe("Gender-aware body-fat target range. Always present."),
bodyFatBands: z
.array(
z.object({
min: z.number(),
max: z.number(),
color: z.string(),
opacity: z.number().optional(),
strokeOpacity: z.number().optional(),
}),
)
.describe("Body-fat chart bands. Always present."),
weightRange: z
.object({
greenMin: z.number(),
greenMax: z.number(),
orangeMin: z.number(),
orangeMax: z.number(),
})
.nullable()
.describe(
"Weight traffic-light range: the user's own target band when set, else the height-derived WHO band, else null (no height and no target).",
),
weightBands: z
.array(
z.object({
min: z.number(),
max: z.number(),
color: z.string(),
opacity: z.number().optional(),
strokeOpacity: z.number().optional(),
}),
)
.nullable()
.describe(
"Weight chart bands, from the same range. Null when `weightRange` is null.",
),
})
.describe(
"Server-computed band / target math resolved from the user's profile facts, so the client reads these numbers instead of recomputing them. Every sub-field is null only when the driving profile fact is missing (no date of birth for blood pressure, no height and no weight target for weight).",
),
tiles: z.object({
summaries: dataSummaryRecord,
lastSeenByType: z.record(z.string(), z.unknown()),
@@ -1026,6 +1132,38 @@ export const dashboardSnapshotResponse = z
})
.nullable()
.optional(),
// v1.21.2 (A5) — the 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 that path dominates.
tension: z
.object({
band: z.enum(["green", "yellow", "red"]),
positive: z.array(
z.enum(["rhr", "hrv", "sleep", "respiratory", "mood"]),
),
negative: z.array(
z.enum(["rhr", "hrv", "sleep", "respiratory", "mood"]),
),
})
.nullable()
.optional()
.describe(
"Locale-agnostic tension verdict: present only when the readiness composite's contributors disagree. `positive`/`negative` carry readiness contributor keys the client maps to localised labels. Null on a coherent day or when a clinical red-flag suppresses it. Optional so older cached snapshots without the field stay valid.",
),
// 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.
returnToBand: z
.object({
metricType: measurementTypeEnum,
daysInside: z.number().int(),
})
.nullable()
.optional()
.describe(
"Locale-agnostic return-to-baseline event: present only when a salient metric (resting heart rate, HRV, respiratory rate, or weight) has come back inside the user's own personal range after a genuine prior out-of-band run. At most one, the most salient. `metricType` is the measurement type the client maps to a localised metric name; `daysInside` is how long it has now sat back inside range. Null otherwise. Optional so older cached snapshots without the field stay valid.",
),
})
.nullable()
.describe(
@@ -1061,6 +1199,19 @@ export const dashboardSnapshotResponse = z
"User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like `/api/insights/derived`); MED_COMPLIANCE is TODAY's dose progress off the snapshot's medsToday tally — `score` is the rounded 0..100 progress, `doses` carries the taken/scheduled pair, the band is progress semantics (green once every scheduled dose is taken, yellow while doses remain, never red), and the ring is absent when no dose is scheduled today. Only rings with data appear, in selection order — a missing entry means no data or a disabled module, never zero. Clients render what arrives and never recompute.",
),
briefing: z.record(z.string(), z.unknown()).nullable(),
// v1.21.2 (A4) — server-resolved recall + forward-look for the
// briefing card, rides the snapshot DTO so iOS reads the same
// already-localised block the web card renders verbatim.
briefingMemory: z
.object({
recall: z.string(),
forward: z.string(),
})
.nullable()
.optional()
.describe(
"The briefing recall + forward-look: `recall` is the prior period's narrative headline, `forward` points ahead to the most salient trend drift (or a calm 'holding steady' line). Both are already-localised prose the client renders verbatim. Null when no prior narrative is on file or no briefing is shown. Optional so older cached snapshots without the field stay valid.",
),
briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]),
briefingUpdatedAt: z.string().nullable(),
briefingStale: z