Merge pull request #239 from MBombeck/release/v1.8.7.1

v1.8.7.1 — assessments for every HealthKit metric, one-tap pre-generation
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-02 14:40:13 +02:00
commit e9906c8111
83 files changed
+3466 -427

No files matched your search

+21
View File
@@ -1,5 +1,26 @@
# Changelog
## [1.8.7.1] — 2026-06-02 — Assessments for every HealthKit metric, one-tap pre-generation
### Added
- **A plain-language assessment on every HealthKit metric page.** The HealthKit metric pages (resting heart rate, heart-rate variability, blood oxygen, glucose, body composition, gait, audio exposure, sleep, and the rest) showed charts but no written assessment, because the core metrics' bespoke per-metric prompts don't scale to thirty more. Each page now carries an assessment, generated from the metric's own normal range and the direction a healthy value moves, anchored on your individual baseline — the same treatment the core metrics already had. A metric with no data keeps its existing insufficient-data note.
- **One-tap pre-generation of every assessment.** A button on the Insights overview generates all assessments in the background so they are ready immediately the next time you open them. The overview also warms a cold cache on its own, and the nightly pass keeps everything fresh — so a page opens to its assessment instead of a wait.
### Changed
- **"Auswertungen" is now "KI-Auswertungen" / "AI Insights"** in the settings heading and the menu, so it's clear the section is AI-generated.
- **Targets and Sources are two separate settings pages** instead of one combined page, each with its own heading.
- **The back link sits above the heading** on a metric's detail page.
- **Dropdown fields have symmetric padding**, so the chevron no longer crowds the right edge.
- **The Insights layout accepts the full metric set**, so a saved layout (order and visibility) can include every metric page, not just the core ones.
### Fixed
- **Assessments generate again.** The assessment provider's accepted model identifiers had rotated, so generation failed and an assessment could sit on "preparing" indefinitely; the model ladder is updated and falls through to the current identifiers.
- **The Active Energy assessment loads.** Its page requested an assessment under a mismatched identifier and silently fell back to "no analysis yet"; the identifier is corrected and now type-checked, so the same class of mismatch is caught at build time.
- **The source-priority editor is fully translated** for Spanish, French, Italian and Polish — it was English-only after the settings split.
## [1.8.7] — 2026-06-02 — Instant insight assessments, GLP-1 blood-level chart, tile + caption polish
### Fixed
+244 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.8.7
version: 1.8.7.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -1927,6 +1927,105 @@ paths:
"401": *a1
"422": *a2
"429": *a3
/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:
$ref: "#/components/schemas/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:
$ref: "#/components/schemas/InsightsPregenerateResponseEnvelope"
"401": *a1
"422": *a2
"429": *a3
/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."
parameters:
- in: query
name: metric
schema:
type: string
enum:
- RESTING_HEART_RATE
- HEART_RATE_VARIABILITY
- OXYGEN_SATURATION
- RESPIRATORY_RATE
- BODY_TEMPERATURE
- SKIN_TEMPERATURE
- BLOOD_GLUCOSE
- WALKING_HEART_RATE_AVERAGE
- PULSE_WAVE_VELOCITY
- VASCULAR_AGE
- STEPS
- ACTIVE_ENERGY
- FLIGHTS_CLIMBED
- WALKING_RUNNING_DISTANCE
- TIME_IN_DAYLIGHT
- VO2_MAX
- TOTAL_BODY_WATER
- BONE_MASS
- FAT_FREE_MASS
- FAT_MASS
- MUSCLE_MASS
- LEAN_BODY_MASS
- VISCERAL_FAT
- WALKING_STEADINESS
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
- WALKING_STEP_LENGTH
- WALKING_SPEED
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- AUDIO_EXPOSURE_EVENT
- SLEEP_DURATION
description: "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."
required: true
description: "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."
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
content:
application/json:
schema:
$ref: "#/components/schemas/MetricStatusResponseEnvelope"
"401": *a1
"422": *a2
"429": *a3
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -2618,13 +2717,37 @@ components:
- pulse
- oxygen
- body-temperature
- respiratory-rate
- weight
- bmi
- body-water
- bone-mass
- fat-free-mass
- fat-mass
- muscle-mass
- visceral-fat
- lean-body-mass
- active-energy
- workouts
- flights-climbed
- walking-distance
- walking-steadiness
- walking-heart-rate
- walking-asymmetry
- double-support-time
- step-length
- walking-speed
- sleep
- resting-pulse
- hrv
- pulse-wave-velocity
- vascular-age
- environmental-audio
- headphone-audio
- audio-events
- daylight
- blood-glucose
- skin-temperature
- mood
- medications
- blutdruck
@@ -2671,6 +2794,11 @@ components:
required:
- rating
description: Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.
InsightsPregenerateRequest:
type: object
properties: {}
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.
MedicationTreatmentClass:
type: string
enum:
@@ -6171,6 +6299,97 @@ components:
additionalProperties: false
description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface
needs day-level grounding.
InsightsPregenerateResponseEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/InsightsPregenerateResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
InsightsPregenerateResponse:
type: object
properties:
queued:
type: boolean
description: True when the full warm was accepted and enqueued.
locale:
type: string
enum:
- de
- en
description: The locale the assessments are being warmed in.
required:
- queued
- locale
additionalProperties: false
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.
MetricStatusResponseEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MetricStatusResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MetricStatusResponse:
type: object
properties:
hasProvider:
type: boolean
description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance.
text:
anyOf:
- type: string
- type: "null"
description: 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:
type: boolean
description: True when `text` is served from cache (incl. last-good).
updatedAt:
anyOf:
- 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)))$
- type: "null"
description: When the served assessment was generated; null when none.
preparing:
description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls
until it lands.
type: boolean
insufficient:
description: True when the metric has no readings; no assessment is generated (no LLM call). The card shows its
insufficient-data state.
type: boolean
required:
- hasProvider
- text
- cached
- updatedAt
additionalProperties: false
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."
MedicationScheduleOutput:
type: object
properties:
@@ -6303,13 +6522,37 @@ components:
- pulse
- oxygen
- body-temperature
- respiratory-rate
- weight
- bmi
- body-water
- bone-mass
- fat-free-mass
- fat-mass
- muscle-mass
- visceral-fat
- lean-body-mass
- active-energy
- workouts
- flights-climbed
- walking-distance
- walking-steadiness
- walking-heart-rate
- walking-asymmetry
- double-support-time
- step-length
- walking-speed
- sleep
- resting-pulse
- hrv
- pulse-wave-velocity
- vascular-age
- environmental-audio
- headphone-audio
- audio-events
- daylight
- blood-glucose
- skin-temperature
- mood
- medications
- blutdruck
+5 -4
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Wird generiert",
"regenerateAnalysis": "Analyse neu starten",
"regenerateSuccess": "Analyse wurde neu erstellt",
"warmAssessments": "Auswertungen vorbereiten",
"warmStarted": "Auswertungen werden im Hintergrund erstellt",
"heroFallbackSubtitle": "Täglicher Blick auf deine Trends — direkt aus den Zahlen, die du protokollierst.",
"heroGreetingMorning": "Guten Morgen",
"heroGreetingAfternoon": "Guten Tag",
@@ -2632,13 +2634,12 @@
"description": "Kachel-Layout und Reihenfolge."
},
"thresholds": {
"title": "Zielwerte & Quellen",
"description": "Pro Metrik: eigener Zielbereich und welche Quelle führt, wenn mehrere am selben Tag denselben Wert liefern."
"title": "Zielwerte",
"description": "Pro Metrik dein eigener Zielbereich für gesunde Werte."
},
"sources": {
"title": "Quellen",
"description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.",
"redirectNotice": "Quellen sind jetzt Teil von „Zielwerte & Quellen“.",
"cardTitle": "Quellen-Priorität pro Metrik",
"help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.",
"resetDefaults": "Auf Standard zurücksetzen",
@@ -2688,7 +2689,7 @@
}
},
"ai": {
"title": "Auswertungen",
"title": "KI-Auswertungen",
"description": "Provider, Modell, API-Key."
},
"api": {
+5 -4
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Regenerating",
"regenerateAnalysis": "Re-run analysis",
"regenerateSuccess": "Analysis refreshed",
"warmAssessments": "Prepare assessments",
"warmStarted": "Assessments are being prepared in the background",
"heroFallbackSubtitle": "A daily read of your trends, drawn straight from the numbers you've logged.",
"heroGreetingMorning": "Good morning",
"heroGreetingAfternoon": "Good afternoon",
@@ -2632,13 +2634,12 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Targets & Sources",
"description": "Per metric: your own target range and which source wins when more than one records the same value on the same day."
"title": "Targets",
"description": "Per metric: your own target range for healthy values."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Sources are now part of “Targets & Sources”.",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
@@ -2688,7 +2689,7 @@
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+36 -35
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Generando",
"regenerateAnalysis": "Volver a iniciar el análisis",
"regenerateSuccess": "Análisis regenerado",
"warmAssessments": "Preparar evaluaciones",
"warmStarted": "Las evaluaciones se están preparando en segundo plano",
"heroFallbackSubtitle": "Una mirada diaria a tus tendencias — directamente de los datos que registras.",
"heroGreetingMorning": "Buenos días",
"heroGreetingAfternoon": "Buenas tardes",
@@ -2632,63 +2634,62 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Objetivos y fuentes",
"description": "Por métrica: tu propio rango objetivo y qué fuente prevalece cuando varias registran el mismo valor el mismo día."
"title": "Objetivos",
"description": "Por métrica: tu propio rango objetivo de valores saludables."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Las fuentes ahora forman parte de «Objetivos y fuentes».",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
"saveError": "Saving the source priority failed.",
"resetSuccess": "Source priority reset to defaults.",
"perMetricToggle": "Per-metric overrides ({count})",
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
"title": "Fuentes",
"description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.",
"cardTitle": "Prioridad de fuentes por métrica",
"help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.",
"resetDefaults": "Restablecer valores predeterminados",
"moveUp": "Subir",
"moveDown": "Bajar",
"saveSuccess": "Prioridad de fuentes guardada.",
"saveError": "No se pudo guardar la prioridad de fuentes.",
"resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados.",
"perMetricToggle": "Ajustes por métrica ({count})",
"perMetricHelp": "Métricas que has personalizado respecto al orden predeterminado. Usa esta lista para devolver cualquier métrica concreta a su valor predeterminado sin tocar las demás.",
"perMetricEmpty": "No hay ajustes por métrica — todas las métricas usan el orden global de arriba.",
"resetMetric": "Restablecer",
"deviceTypeToggle": "Device-type priority ({count})",
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
"deviceTypeDefault": "Default device-type ladder",
"resetDeviceTypes": "Reset device-types",
"deviceTypeToggle": "Prioridad por tipo de dispositivo ({count})",
"deviceTypeHelp": "Dentro de una fuente, qué dispositivo prevalece. Cuando un Apple Watch y un iPhone registran ambos los pasos, el tipo de dispositivo mejor clasificado cuenta como la lectura canónica.",
"deviceTypeDefault": "Orden predeterminado por tipo de dispositivo",
"resetDeviceTypes": "Restablecer tipos de dispositivo",
"metrics": {
"steps": "Pasos",
"activeEnergy": "Active energy",
"walkingRunningDistance": "Walking + running distance",
"flightsClimbed": "Flights climbed",
"activeEnergy": "Energía activa",
"walkingRunningDistance": "Distancia a pie + corriendo",
"flightsClimbed": "Pisos subidos",
"sleep": "Sueño",
"weight": "Peso",
"bloodPressure": "Presión arterial",
"pulse": "Pulso",
"bodyFat": "Grasa corporal",
"bodyTemperature": "Body temperature",
"spo2": "Oxygen saturation",
"hrv": "Heart-rate variability",
"bodyTemperature": "Temperatura corporal",
"spo2": "Saturación de oxígeno",
"hrv": "Variabilidad de la frecuencia cardíaca",
"restingHeartRate": "Frecuencia cardíaca en reposo",
"vo2Max": "VO2 max"
"vo2Max": "VO2 máx."
},
"sourceLabels": {
"WITHINGS": "Withings",
"APPLE_HEALTH": "Apple Health",
"MANUAL": "Manual entry",
"MANUAL": "Entrada manual",
"IMPORT": "Importar"
},
"deviceLabels": {
"watch": "Watch",
"band": "Band",
"ring": "Ring",
"phone": "Phone",
"scale": "Scale",
"other": "Other",
"watch": "Reloj",
"band": "Pulsera",
"ring": "Anillo",
"phone": "Teléfono",
"scale": "Báscula",
"other": "Otro",
"unknown": "Desconocido"
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+34 -33
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Génération en cours",
"regenerateAnalysis": "Relancer l’analyse",
"regenerateSuccess": "Analyse regénérée",
"warmAssessments": "Préparer les évaluations",
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
"heroFallbackSubtitle": "Un coup d’œil quotidien sur tes tendances — directement issu des chiffres que tu saisis.",
"heroGreetingMorning": "Bonjour",
"heroGreetingAfternoon": "Bonjour",
@@ -2632,63 +2634,62 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Objectifs et sources",
"description": "Par métrique : votre propre plage cible et quelle source prévaut quand plusieurs enregistrent la même valeur le même jour."
"title": "Objectifs",
"description": "Par métrique : votre propre plage cible pour des valeurs saines."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Les sources font désormais partie de « Objectifs et sources ».",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
"saveError": "Saving the source priority failed.",
"resetSuccess": "Source priority reset to defaults.",
"perMetricToggle": "Per-metric overrides ({count})",
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
"description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.",
"cardTitle": "Priorité des sources par métrique",
"help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.",
"resetDefaults": "Rétablir les valeurs par défaut",
"moveUp": "Monter",
"moveDown": "Descendre",
"saveSuccess": "Priorité des sources enregistrée.",
"saveError": "Échec de l’enregistrement de la priorité des sources.",
"resetSuccess": "Priorité des sources rétablie aux valeurs par défaut.",
"perMetricToggle": "Réglages par métrique ({count})",
"perMetricHelp": "Métriques que vous avez personnalisées par rapport à l’ordre par défaut. Utilisez cette liste pour rétablir une métrique donnée à sa valeur par défaut sans toucher aux autres.",
"perMetricEmpty": "Aucun réglage par métrique — chaque métrique utilise l’ordre global ci-dessus.",
"resetMetric": "Réinitialiser",
"deviceTypeToggle": "Device-type priority ({count})",
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
"deviceTypeDefault": "Default device-type ladder",
"resetDeviceTypes": "Reset device-types",
"deviceTypeToggle": "Priorité par type d’appareil ({count})",
"deviceTypeHelp": "Au sein d’une source, quel appareil l’emporte. Lorsqu’une Apple Watch et un iPhone enregistrent tous deux les pas, le type d’appareil le mieux classé compte comme la lecture canonique.",
"deviceTypeDefault": "Ordre par défaut des types d’appareil",
"resetDeviceTypes": "Réinitialiser les types d’appareil",
"metrics": {
"steps": "Pas",
"activeEnergy": "Active energy",
"walkingRunningDistance": "Walking + running distance",
"flightsClimbed": "Flights climbed",
"activeEnergy": "Énergie active",
"walkingRunningDistance": "Distance marche + course",
"flightsClimbed": "Étages gravis",
"sleep": "Sommeil",
"weight": "Poids",
"bloodPressure": "Tension artérielle",
"pulse": "Pouls",
"bodyFat": "Masse grasse",
"bodyTemperature": "Body temperature",
"spo2": "Oxygen saturation",
"hrv": "Heart-rate variability",
"bodyTemperature": "Température corporelle",
"spo2": "Saturation en oxygène",
"hrv": "Variabilité de la fréquence cardiaque",
"restingHeartRate": "Fréquence cardiaque au repos",
"vo2Max": "VO2 max"
},
"sourceLabels": {
"WITHINGS": "Withings",
"APPLE_HEALTH": "Apple Health",
"MANUAL": "Manual entry",
"MANUAL": "Saisie manuelle",
"IMPORT": "Importer"
},
"deviceLabels": {
"watch": "Watch",
"band": "Band",
"ring": "Ring",
"phone": "Phone",
"scale": "Scale",
"other": "Other",
"watch": "Montre",
"band": "Bracelet",
"ring": "Bague",
"phone": "Téléphone",
"scale": "Balance",
"other": "Autre",
"unknown": "Inconnu"
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+35 -34
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Generazione in corso",
"regenerateAnalysis": "Riavvia l’analisi",
"regenerateSuccess": "Analisi rigenerata",
"warmAssessments": "Prepara le valutazioni",
"warmStarted": "Le valutazioni vengono preparate in background",
"heroFallbackSubtitle": "Uno sguardo quotidiano ai tuoi trend — direttamente dai numeri che registri.",
"heroGreetingMorning": "Buongiorno",
"heroGreetingAfternoon": "Buon pomeriggio",
@@ -2632,63 +2634,62 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Obiettivi e fonti",
"description": "Per metrica: il tuo intervallo target e quale fonte prevale quando più di una registra lo stesso valore lo stesso giorno."
"title": "Obiettivi",
"description": "Per metrica: il tuo intervallo target per valori sani."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Le fonti ora fanno parte di «Obiettivi e fonti».",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
"saveError": "Saving the source priority failed.",
"resetSuccess": "Source priority reset to defaults.",
"perMetricToggle": "Per-metric overrides ({count})",
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
"title": "Fonti",
"description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.",
"cardTitle": "Priorità delle fonti per metrica",
"help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.",
"resetDefaults": "Ripristina i valori predefiniti",
"moveUp": "Sposta su",
"moveDown": "Sposta giù",
"saveSuccess": "Priorità delle fonti salvata.",
"saveError": "Salvataggio della priorità delle fonti non riuscito.",
"resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti.",
"perMetricToggle": "Personalizzazioni per metrica ({count})",
"perMetricHelp": "Metriche che hai personalizzato rispetto all’ordine predefinito. Usa questo elenco per riportare una singola metrica al suo valore predefinito senza toccare le altre.",
"perMetricEmpty": "Nessuna personalizzazione per metrica — ogni metrica usa l’ordine globale qui sopra.",
"resetMetric": "Reimposta",
"deviceTypeToggle": "Device-type priority ({count})",
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
"deviceTypeDefault": "Default device-type ladder",
"resetDeviceTypes": "Reset device-types",
"deviceTypeToggle": "Priorità per tipo di dispositivo ({count})",
"deviceTypeHelp": "All’interno di una fonte, quale dispositivo prevale. Quando un Apple Watch e un iPhone registrano entrambi i passi, il tipo di dispositivo più in alto conta come lettura canonica.",
"deviceTypeDefault": "Ordine predefinito per tipo di dispositivo",
"resetDeviceTypes": "Reimposta i tipi di dispositivo",
"metrics": {
"steps": "Passi",
"activeEnergy": "Active energy",
"walkingRunningDistance": "Walking + running distance",
"flightsClimbed": "Flights climbed",
"activeEnergy": "Energia attiva",
"walkingRunningDistance": "Distanza a piedi + corsa",
"flightsClimbed": "Piani saliti",
"sleep": "Sonno",
"weight": "Peso",
"bloodPressure": "Pressione arteriosa",
"pulse": "Polso",
"bodyFat": "Massa grassa",
"bodyTemperature": "Body temperature",
"spo2": "Oxygen saturation",
"hrv": "Heart-rate variability",
"bodyTemperature": "Temperatura corporea",
"spo2": "Saturazione di ossigeno",
"hrv": "Variabilità della frequenza cardiaca",
"restingHeartRate": "Frequenza cardiaca a riposo",
"vo2Max": "VO2 max"
},
"sourceLabels": {
"WITHINGS": "Withings",
"APPLE_HEALTH": "Apple Health",
"MANUAL": "Manual entry",
"MANUAL": "Inserimento manuale",
"IMPORT": "Importa"
},
"deviceLabels": {
"watch": "Watch",
"band": "Band",
"ring": "Ring",
"phone": "Phone",
"scale": "Scale",
"other": "Other",
"watch": "Orologio",
"band": "Bracciale",
"ring": "Anello",
"phone": "Telefono",
"scale": "Bilancia",
"other": "Altro",
"unknown": "Sconosciuto"
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+35 -34
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Generowanie",
"regenerateAnalysis": "Uruchom analizę ponownie",
"regenerateSuccess": "Analiza wygenerowana ponownie",
"warmAssessments": "Przygotuj analizy",
"warmStarted": "Analizy są przygotowywane w tle",
"heroFallbackSubtitle": "Codzienne spojrzenie na twoje trendy — prosto z liczb, które rejestrujesz.",
"heroGreetingMorning": "Dzień dobry",
"heroGreetingAfternoon": "Dzień dobry",
@@ -2632,63 +2634,62 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Cele i źródła",
"description": "Dla każdej metryki: własny zakres docelowy i które źródło wygrywa, gdy kilka rejestruje tę samą wartość tego samego dnia."
"title": "Cele",
"description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Źródła są teraz częścią „Cele i źródła”.",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
"saveError": "Saving the source priority failed.",
"resetSuccess": "Source priority reset to defaults.",
"perMetricToggle": "Per-metric overrides ({count})",
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
"title": "Źródła",
"description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.",
"cardTitle": "Priorytet źródeł według metryki",
"help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.",
"resetDefaults": "Przywróć wartości domyślne",
"moveUp": "W górę",
"moveDown": "W dół",
"saveSuccess": "Zapisano priorytet źródeł.",
"saveError": "Zapisanie priorytetu źródeł nie powiodło się.",
"resetSuccess": "Przywrócono domyślny priorytet źródeł.",
"perMetricToggle": "Ustawienia dla poszczególnych metryk ({count})",
"perMetricHelp": "Metryki dostosowane względem domyślnej kolejności. Użyj tej listy, aby przywrócić pojedynczą metrykę do wartości domyślnej, nie zmieniając pozostałych.",
"perMetricEmpty": "Brak indywidualnych ustawień — każda metryka korzysta z globalnej kolejności powyżej.",
"resetMetric": "Resetuj",
"deviceTypeToggle": "Device-type priority ({count})",
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
"deviceTypeDefault": "Default device-type ladder",
"resetDeviceTypes": "Reset device-types",
"deviceTypeToggle": "Priorytet według typu urządzenia ({count})",
"deviceTypeHelp": "W obrębie źródła decyduje, które urządzenie wygrywa. Gdy Apple Watch i iPhone rejestrują kroki, wyżej sklasyfikowany typ urządzenia liczy się jako odczyt kanoniczny.",
"deviceTypeDefault": "Domyślna kolejność typów urządzeń",
"resetDeviceTypes": "Resetuj typy urządzeń",
"metrics": {
"steps": "Kroki",
"activeEnergy": "Active energy",
"walkingRunningDistance": "Walking + running distance",
"flightsClimbed": "Flights climbed",
"activeEnergy": "Aktywna energia",
"walkingRunningDistance": "Dystans marszu + biegu",
"flightsClimbed": "Pokonane piętra",
"sleep": "Sen",
"weight": "Waga",
"bloodPressure": "Ciśnienie krwi",
"pulse": "Tętno",
"bodyFat": "Tkanka tłuszczowa",
"bodyTemperature": "Body temperature",
"spo2": "Oxygen saturation",
"hrv": "Heart-rate variability",
"bodyTemperature": "Temperatura ciała",
"spo2": "Saturacja tlenem",
"hrv": "Zmienność rytmu serca",
"restingHeartRate": "Tętno spoczynkowe",
"vo2Max": "VO2 max"
},
"sourceLabels": {
"WITHINGS": "Withings",
"APPLE_HEALTH": "Apple Health",
"MANUAL": "Manual entry",
"MANUAL": "Wpis ręczny",
"IMPORT": "Importuj"
},
"deviceLabels": {
"watch": "Watch",
"band": "Band",
"ring": "Ring",
"phone": "Phone",
"scale": "Scale",
"other": "Other",
"watch": "Zegarek",
"band": "Opaska",
"ring": "Pierścień",
"phone": "Telefon",
"scale": "Waga",
"other": "Inne",
"unknown": "Nieznane"
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.8.7",
"version": "1.8.7.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -40,6 +40,9 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
"src/app/api/insights/cards/route.ts",
"src/app/api/insights/correlations/route.ts",
"src/app/api/insights/medication-compliance-status/route.ts",
// v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the
// same `insightStatus` sub-flag as the seven specialised status routes.
"src/app/api/insights/metric-status/route.ts",
"src/app/api/insights/mood-status/route.ts",
"src/app/api/insights/pulse-status/route.ts",
"src/app/api/insights/weight-status/route.ts",
@@ -60,6 +60,7 @@ import { annotate } from "@/lib/logging/context";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout";
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -252,6 +253,111 @@ describe("PUT /api/insights/layout — happy path", () => {
});
});
describe("PUT /api/insights/layout — full metric-slug universe", () => {
// v1.8.7.1 — the layout tile-id enum derives from `SUB_PAGE_SLUGS`, so
// the long-tail HealthKit + body-composition + mobility + audio slugs
// the routed tab strip already exposes are now valid layout ids. iOS
// can persist a layout covering the full metric set rather than 422ing
// on the ~25 slugs the fixed allow-list did not know about.
const NEWLY_ACCEPTED_SLUGS = [
"blood-glucose",
"skin-temperature",
"respiratory-rate",
"flights-climbed",
"walking-speed",
"vascular-age",
"headphone-audio",
"muscle-mass",
"daylight",
] as const;
it("accepts a layout that includes every previously-rejected sub-page slug", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const tiles = NEWLY_ACCEPTED_SLUGS.map((id, order) => ({
id,
visible: order % 2 === 0,
order,
}));
const res = await callPut(makeReq({ version: 1, tiles }));
// No 422 — these are all canonical sub-page slugs now.
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string; visible: boolean }> };
};
const returnedIds = body.data.tiles.map((t) => t.id);
for (const slug of NEWLY_ACCEPTED_SLUGS) {
expect(returnedIds).toContain(slug);
}
});
it("accepts a layout covering the ENTIRE slug universe in one PUT", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const tiles = ["overview", ...SUB_PAGE_SLUGS].map((id, order) => ({
id,
visible: true,
order,
}));
const res = await callPut(makeReq({ version: 1, tiles }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string }> };
};
const returnedIds = new Set(body.data.tiles.map((t) => t.id));
// Every slug the web tab strip enumerates round-trips through the PUT.
for (const slug of SUB_PAGE_SLUGS) {
expect(returnedIds.has(slug)).toBe(true);
}
expect(returnedIds.has("overview")).toBe(true);
});
it("round-trips the new slugs back through GET after a save", async () => {
const savedTiles = NEWLY_ACCEPTED_SLUGS.map((id, order) => ({
id,
visible: true,
order: order + 1,
}));
vi.mocked(prisma.user.findUnique).mockResolvedValue({
insightsLayoutJson: {
version: 1,
tiles: [{ id: "overview", visible: true, order: 0 }, ...savedTiles],
},
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string }> };
};
const ids = body.data.tiles.map((t) => t.id);
for (const slug of NEWLY_ACCEPTED_SLUGS) {
expect(ids).toContain(slug);
}
});
it("still rejects a genuinely-unknown id even with the wider set", async () => {
const res = await callPut(
makeReq({
version: 1,
tiles: [
{ id: "blood-glucose", visible: true, order: 0 },
{ id: "not-a-metric-at-all", visible: true, order: 1 },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(
body.details.issues.some((i) => i.path.startsWith("tiles")),
).toBe(true);
});
});
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
const res = await callPut(makeReq({ version: 2, tiles: [] }));
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the heavy generator so the route test stays isolated from the
// real status pipeline.
vi.mock("@/lib/insights/metric-status", () => ({
generateMetricStatus: vi.fn(async () => ({
hasProvider: true,
text: "ok",
cached: true,
updatedAt: new Date().toISOString(),
})),
resolveMetricStatusLocale: () => "en",
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { generateMetricStatus } from "@/lib/insights/metric-status";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(metric?: string): NextRequest {
const url = new URL("http://localhost/api/insights/metric-status");
if (metric !== undefined) url.searchParams.set("metric", metric);
return new NextRequest(url);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
});
describe("GET /api/insights/metric-status", () => {
it("returns 200 + envelope for a valid registered metric", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("RESTING_HEART_RATE"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { text: string; hasProvider: boolean } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.text).toBe("ok");
expect(generateMetricStatus).toHaveBeenCalledWith(
expect.objectContaining({
metric: "RESTING_HEART_RATE",
userId: "user-1",
readOnly: true,
}),
);
});
it("422s on an unknown metric without calling the generator", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("NOT_A_METRIC"));
expect(res.status).toBe(422);
expect(generateMetricStatus).not.toHaveBeenCalled();
});
it("422s on a missing metric param", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(422);
expect(generateMetricStatus).not.toHaveBeenCalled();
});
it("422s when a specialised metric is requested through the generic route", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("WEIGHT"));
expect(res.status).toBe(422);
expect(generateMetricStatus).not.toHaveBeenCalled();
});
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq("SLEEP_DURATION"));
expect(res.status).toBe(401);
});
it("403s + errorCode when insightStatus is disabled", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
} as never);
const res = await callGet(makeReq("SLEEP_DURATION"));
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
});
});
@@ -0,0 +1,86 @@
/**
* v1.8.7.1 — generic per-HealthKit-metric assessment route.
*
* `GET /api/insights/metric-status?metric=<METRIC_ID>` serves the
* data-driven assessment for any metric registered in
* `metric-status-registry.ts` (RESTING_HEART_RATE, SLEEP_DURATION, …).
* The seven specialised metrics keep their own `*-status` routes; this
* single generic route covers the ~30 HealthKit pages without one route
* per metric.
*
* Read-only by construction (v1.8.3 freeze fix): a cache miss enqueues an
* out-of-band generation and serves the last-good (stale-while-revalidate)
* text, never blocking on the provider. An unknown `metric` 422s via the
* closed registry enum.
*
* Unlike the seven legacy `*-status` routes, this one carries OpenAPI
* coverage (the route schema lives in `src/lib/openapi/routes.ts`),
* establishing the pattern the legacy routes can be backfilled onto later.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
apiSuccess,
returnAllZodIssues,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import {
generateMetricStatus,
resolveMetricStatusLocale,
} from "@/lib/insights/metric-status";
import {
METRIC_STATUS_IDS,
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
export const dynamic = "force-dynamic";
// Closed enum derived from the registry so the route + registry cannot
// drift — an id added to the registry is accepted automatically, and an
// unknown id 422s rather than silently resolving to no card.
const metricQuerySchema = z.object({
metric: z.enum(METRIC_STATUS_IDS as [string, ...string[]]),
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const parsed = metricQuerySchema.safeParse({
metric: request.nextUrl.searchParams.get("metric"),
});
if (!parsed.success) {
annotate({
action: { name: "insights.metric-status.invalid-metric" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const metric = parsed.data.metric as MetricStatusMetricId;
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveMetricStatusLocale(resolved);
const result = await generateMetricStatus({
metric,
userId: user.id,
locale,
force: false,
readOnly: true,
});
annotate({
action: { name: "insights.metric-status" },
meta: { metric },
});
return apiSuccess(result);
});
+70
View File
@@ -0,0 +1,70 @@
/**
* v1.8.7.1 — on-demand full assessment warm.
*
* `POST /api/insights/pregenerate` enqueues a forced full warm of every
* AI assessment for the calling user — the comprehensive insight (daily
* briefing), the seven specialised `*-status` cards, and every
* data-bearing generic `metric:<ID>` assessment — for the user's active
* locale. The heavy generation runs on the worker (the
* `insight-pregenerate` queue) so this route returns immediately; the
* cards then fill via the existing read-only stale-while-revalidate GETs
* without the user waiting on a provider round-trip.
*
* The forced path bypasses the nightly cron's per-user 20 h budget (so a
* user can warm on demand the moment they land on the page), so this
* route carries its own short anti-spam bucket — one warm per
* `WARM_WINDOW_MS` per user. Empty metrics never reach the provider (the
* worker filters to data-bearing types) and the comprehensive generator
* no-ops without a configured provider, so a spam-free call on a
* provider-less account costs one cheap chain-resolve and no LLM call.
*
* `userId` is always narrowed from the session / Bearer — never a body
* field. Both web and the iOS client can call it; they read the same
* cached routes afterward.
*/
import { NextRequest } from "next/server";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
export const dynamic = "force-dynamic";
/** Anti-spam window — one forced warm per user per 3 minutes. */
const WARM_WINDOW_MS = 3 * 60 * 1000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Same surface gate as the read-only status routes — when the operator
// disables the assistant surface there is nothing to warm.
await requireAssistantSurface("coach");
const userId = user.id;
// Short per-user bucket so the bypassed nightly budget can't be abused
// into a provider-cost amplifier by a tight POST loop. A blocked call
// is harmless — the caches are already being warmed by the prior call.
const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS);
if (!rl.allowed) {
return apiError("A warm is already in progress. Try again shortly.", 429);
}
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
});
const locale = resolved === "en" ? "en" : "de";
await enqueueForceWarm({ userId, locale });
annotate({
action: { name: "insights.pregenerate.requested" },
meta: { locale },
});
// The work runs on the worker; report that it was accepted, not that it
// is done. The client polls the read-only status GETs for the text.
return apiSuccess({ queued: true, locale });
});
+1
View File
@@ -17,6 +17,7 @@ export default function InsightsActiveEnergyPage() {
return (
<HealthKitMetricPage
measurementType="ACTIVE_ENERGY_BURNED"
statusMetric="ACTIVE_ENERGY"
insightMetric="ACTIVE_ENERGY_BURNED"
chartKey="activeEnergy"
i18nPrefix="insights.activeEnergy"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsLaermereignissePage() {
return (
<HealthKitMetricPage
measurementType="AUDIO_EXPOSURE_EVENT"
statusMetric="AUDIO_EXPOSURE_EVENT"
insightMetric="AUDIO_EXPOSURE_EVENT"
chartKey="audioExposureEvent"
i18nPrefix="insights.audioExposureEvent"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsBlutzuckerPage() {
return (
<HealthKitMetricPage
measurementType="BLOOD_GLUCOSE"
statusMetric="BLOOD_GLUCOSE"
insightMetric="BLOOD_GLUCOSE"
chartKey="bloodGlucose"
i18nPrefix="insights.bloodGlucose"
@@ -16,6 +16,7 @@ export default function InsightsBodyTemperaturePage() {
return (
<HealthKitMetricPage
measurementType="BODY_TEMPERATURE"
statusMetric="BODY_TEMPERATURE"
insightMetric="BODY_TEMPERATURE"
chartKey="bodyTemperature"
i18nPrefix="insights.bodyTemperature"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsKoerperwasserPage() {
return (
<HealthKitMetricPage
measurementType="TOTAL_BODY_WATER"
statusMetric="TOTAL_BODY_WATER"
insightMetric="TOTAL_BODY_WATER"
chartKey="totalBodyWater"
i18nPrefix="insights.totalBodyWater"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsKnochenmassePage() {
return (
<HealthKitMetricPage
measurementType="BONE_MASS"
statusMetric="BONE_MASS"
insightMetric="BONE_MASS"
chartKey="boneMass"
i18nPrefix="insights.boneMass"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsTageslichtPage() {
return (
<HealthKitMetricPage
measurementType="TIME_IN_DAYLIGHT"
statusMetric="TIME_IN_DAYLIGHT"
insightMetric="TIME_IN_DAYLIGHT"
chartKey="timeInDaylight"
i18nPrefix="insights.timeInDaylight"
@@ -16,6 +16,7 @@ export default function InsightsDoppelstandphasePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_DOUBLE_SUPPORT"
statusMetric="WALKING_DOUBLE_SUPPORT"
insightMetric="WALKING_DOUBLE_SUPPORT"
chartKey="walkingDoubleSupport"
i18nPrefix="insights.walkingDoubleSupport"
@@ -16,6 +16,7 @@ export default function InsightsLaermbelastungPage() {
return (
<HealthKitMetricPage
measurementType="AUDIO_EXPOSURE_ENV"
statusMetric="AUDIO_EXPOSURE_ENV"
insightMetric="AUDIO_EXPOSURE_ENV"
chartKey="audioExposureEnv"
i18nPrefix="insights.audioExposureEnv"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsFettfreieMassePage() {
return (
<HealthKitMetricPage
measurementType="FAT_FREE_MASS"
statusMetric="FAT_FREE_MASS"
insightMetric="FAT_FREE_MASS"
chartKey="fatFreeMass"
i18nPrefix="insights.fatFreeMass"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsFettmassePage() {
return (
<HealthKitMetricPage
measurementType="FAT_MASS"
statusMetric="FAT_MASS"
insightMetric="FAT_MASS"
chartKey="fatMass"
i18nPrefix="insights.fatMass"
@@ -16,6 +16,7 @@ export default function InsightsStockwerkePage() {
return (
<HealthKitMetricPage
measurementType="FLIGHTS_CLIMBED"
statusMetric="FLIGHTS_CLIMBED"
insightMetric="FLIGHTS_CLIMBED"
chartKey="flightsClimbed"
i18nPrefix="insights.flightsClimbed"
@@ -16,6 +16,7 @@ export default function InsightsKopfhoererpegelPage() {
return (
<HealthKitMetricPage
measurementType="AUDIO_EXPOSURE_HEADPHONE"
statusMetric="AUDIO_EXPOSURE_HEADPHONE"
insightMetric="AUDIO_EXPOSURE_HEADPHONE"
chartKey="audioExposureHeadphone"
i18nPrefix="insights.audioExposureHeadphone"
+1
View File
@@ -17,6 +17,7 @@ export default function InsightsHrvPage() {
return (
<HealthKitMetricPage
measurementType="HEART_RATE_VARIABILITY"
statusMetric="HEART_RATE_VARIABILITY"
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
i18nPrefix="insights.hrv"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsMagermassePage() {
return (
<HealthKitMetricPage
measurementType="LEAN_BODY_MASS"
statusMetric="LEAN_BODY_MASS"
insightMetric="LEAN_BODY_MASS"
chartKey="leanBodyMass"
i18nPrefix="insights.leanBodyMass"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsMuskelmassePage() {
return (
<HealthKitMetricPage
measurementType="MUSCLE_MASS"
statusMetric="MUSCLE_MASS"
insightMetric="MUSCLE_MASS"
chartKey="muscleMass"
i18nPrefix="insights.muscleMass"
+1
View File
@@ -18,6 +18,7 @@ export default function InsightsOxygenSaturationPage() {
return (
<HealthKitMetricPage
measurementType="OXYGEN_SATURATION"
statusMetric="OXYGEN_SATURATION"
insightMetric="OXYGEN_SATURATION"
chartKey="oxygenSaturation"
i18nPrefix="insights.oxygenSaturation"
+26 -1
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { TrendingUp } from "lucide-react";
import { TrendingUp, Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -14,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { HeroStrip } from "@/components/insights/hero-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useInsightsWarm } from "@/components/insights/use-insights-warm";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
@@ -150,6 +152,17 @@ export default function InsightsPage() {
const analyticsQuery = useAnalyticsQuery();
const analytics = analyticsQuery.data as AnalyticsData | undefined;
// v1.8.7.1 — on-demand full assessment warm. The auto-trigger fires once
// per session as soon as the overview confirms the user has data, so the
// status cards (and the iOS client, which reads the same routes) land on
// warm caches without the user waiting. The manual button below re-warms
// everything on demand.
const { warm, isWarming, autoWarmOnce } = useInsightsWarm();
const hasData = !!data && data.totalMeasurements > 0;
useEffect(() => {
autoWarmOnce(isAuthenticated && hasData);
}, [autoWarmOnce, isAuthenticated, hasData]);
// Empty-state shortcut — only paint once the comprehensive query has
// resolved AND reported zero measurements. While it's in-flight we
// fall through to the streamed shell so the user gets the hero +
@@ -210,6 +223,18 @@ export default function InsightsPage() {
healthScore={analytics?.healthScore ?? undefined}
/>
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
onClick={warm}
disabled={isWarming}
>
<Sparkles className="size-4" />
{t("insights.warmAssessments")}
</Button>
</div>
{flags.briefing && (
<DailyBriefing
briefing={briefingPayload}
@@ -16,6 +16,7 @@ export default function InsightsPulswellengeschwindigkeitPage() {
return (
<HealthKitMetricPage
measurementType="PULSE_WAVE_VELOCITY"
statusMetric="PULSE_WAVE_VELOCITY"
insightMetric="PULSE_WAVE_VELOCITY"
chartKey="pulseWaveVelocity"
i18nPrefix="insights.pulseWaveVelocity"
@@ -16,6 +16,7 @@ export default function InsightsAtemfrequenzPage() {
return (
<HealthKitMetricPage
measurementType="RESPIRATORY_RATE"
statusMetric="RESPIRATORY_RATE"
insightMetric="RESPIRATORY_RATE"
chartKey="respiratoryRate"
i18nPrefix="insights.respiratoryRate"
+1
View File
@@ -17,6 +17,7 @@ export default function InsightsRestingHrPage() {
return (
<HealthKitMetricPage
measurementType="RESTING_HEART_RATE"
statusMetric="RESTING_HEART_RATE"
insightMetric="RESTING_HEART_RATE"
chartKey="restingHr"
i18nPrefix="insights.restingHr"
@@ -16,6 +16,7 @@ export default function InsightsHauttemperaturPage() {
return (
<HealthKitMetricPage
measurementType="SKIN_TEMPERATURE"
statusMetric="SKIN_TEMPERATURE"
insightMetric="SKIN_TEMPERATURE"
chartKey="skinTemperature"
i18nPrefix="insights.skinTemperature"
+12 -9
View File
@@ -6,6 +6,7 @@ import { Moon } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
@@ -68,16 +69,18 @@ export default function InsightsSchlafPage() {
<MetricTargetSummary slug="sleep" />
{/*
v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all
mount `<InsightStatusCard>` underneath the chart so the user
sees a written per-section assessment. Sleep has no
`/api/insights/sleep-status` route yet (the v1.4.23 schema
landed the data but the assessment-generation pass was deferred
to the v1.5 iOS sprint where the Apple-Health sleep snapshot
will inform the prompt). No per-section assessment yet; the
structural slot waits for v1.5 where the route + hook key will
wire in.
v1.8.7.1 — the per-section sleep assessment lands via the generic
metric-status route (`?metric=SLEEP_DURATION`), closing the slot
the v1.4.28 deferral left open. The shared `<MetricStatusCard>`
owns the hook + card; only the icon differs from the HealthKit
scaffold (Moon vs Sparkles). The card mounts only on this
data-bearing branch, so a source-less account never fetches.
*/}
<MetricStatusCard
metric="SLEEP_DURATION"
icon={<Moon className="h-5 w-5" />}
enabled={!isEmpty}
/>
</SubPageShell>
);
}
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsSchrittlaengePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_STEP_LENGTH"
statusMetric="WALKING_STEP_LENGTH"
insightMetric="WALKING_STEP_LENGTH"
chartKey="walkingStepLength"
i18nPrefix="insights.walkingStepLength"
+14 -13
View File
@@ -47,20 +47,21 @@ export default function InsightsMetricValuesPage({
<SubPageShell
title={t("insights.subPage.valuesPageTitle", { metric: metricLabel })}
description={t("insights.subPage.valuesPageDescription")}
backLink={
<Button
asChild
variant="ghost"
size="sm"
data-slot="metric-values-back"
className="-ml-2 w-fit"
>
<Link href="/insights">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
{t("insights.subPage.valuesBack")}
</Link>
</Button>
}
>
<Button
asChild
variant="ghost"
size="sm"
data-slot="metric-values-back"
className="-ml-2 w-fit"
>
<Link href="/insights">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
{t("insights.subPage.valuesBack")}
</Link>
</Button>
<MeasurementList lockedType={type} />
</SubPageShell>
);
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsGefaessalterPage() {
return (
<HealthKitMetricPage
measurementType="VASCULAR_AGE"
statusMetric="VASCULAR_AGE"
insightMetric="VASCULAR_AGE"
chartKey="vascularAge"
i18nPrefix="insights.vascularAge"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsViszeralfettPage() {
return (
<HealthKitMetricPage
measurementType="VISCERAL_FAT"
statusMetric="VISCERAL_FAT"
insightMetric="VISCERAL_FAT"
chartKey="visceralFat"
i18nPrefix="insights.visceralFat"
@@ -16,6 +16,7 @@ export default function InsightsGangasymmetriePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_ASYMMETRY"
statusMetric="WALKING_ASYMMETRY"
insightMetric="WALKING_ASYMMETRY"
chartKey="walkingAsymmetry"
i18nPrefix="insights.walkingAsymmetry"
@@ -16,6 +16,7 @@ export default function InsightsGehstreckePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_RUNNING_DISTANCE"
statusMetric="WALKING_RUNNING_DISTANCE"
insightMetric="WALKING_RUNNING_DISTANCE"
chartKey="walkingRunningDistance"
i18nPrefix="insights.walkingRunningDistance"
@@ -16,6 +16,7 @@ export default function InsightsGehpulsPage() {
return (
<HealthKitMetricPage
measurementType="WALKING_HEART_RATE_AVERAGE"
statusMetric="WALKING_HEART_RATE_AVERAGE"
insightMetric="WALKING_HEART_RATE_AVERAGE"
chartKey="walkingHeartRateAverage"
i18nPrefix="insights.walkingHeartRateAverage"
+1
View File
@@ -16,6 +16,7 @@ export default function InsightsGehgeschwindigkeitPage() {
return (
<HealthKitMetricPage
measurementType="WALKING_SPEED"
statusMetric="WALKING_SPEED"
insightMetric="WALKING_SPEED"
chartKey="walkingSpeed"
i18nPrefix="insights.walkingSpeed"
@@ -16,6 +16,7 @@ export default function InsightsGangstabilitaetPage() {
return (
<HealthKitMetricPage
measurementType="WALKING_STEADINESS"
statusMetric="WALKING_STEADINESS"
insightMetric="WALKING_STEADINESS"
chartKey="walkingSteadiness"
i18nPrefix="insights.walkingSteadiness"
+14 -12
View File
@@ -40,19 +40,21 @@ export default function InsightsWorkoutDetailPage({
<SubPageShell
title={t("insights.workouts.title")}
description={t("insights.workouts.description")}
backLink={
<Button
asChild
variant="ghost"
size="sm"
data-slot="workout-detail-back"
className="-ml-2 w-fit"
>
<Link href="/insights/workouts">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
{t("insights.workouts.detail.backToList")}
</Link>
</Button>
}
>
<Button
asChild
variant="ghost"
size="sm"
data-slot="workout-detail-back"
>
<Link href="/insights/workouts">
<ArrowLeft className="mr-1 size-4" />
{t("insights.workouts.detail.backToList")}
</Link>
</Button>
{isLoading ? (
<div data-slot="workout-detail-loading" className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
+6 -4
View File
@@ -11,11 +11,12 @@ import { ExportSection } from "@/components/settings/export-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
// v1.4.34 IW-D — `sources` slug was retired (merged into `thresholds`).
// The combined "Targets & Sources" component lives in
// `thresholds-section.tsx`; `/settings/sources` is now a
// `permanentRedirect` handled by the dedicated route file.
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
// separate sections again. `ThresholdsSection` renders the target-range
// editor; `SourcesSection` (standalone mode) renders the source-priority
// ladders. Both are served by this dynamic route.
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import { SourcesSection } from "@/components/settings/sources-section";
import {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
@@ -49,6 +50,7 @@ const SECTION_COMPONENTS: Record<
notifications: NotificationsSection,
dashboard: DashboardSection,
thresholds: ThresholdsSection,
sources: () => <SourcesSection />,
api: ApiSection,
export: ExportSection,
advanced: AdvancedSection,
-23
View File
@@ -1,23 +0,0 @@
import { permanentRedirect } from "next/navigation";
/**
* v1.4.34 IW-D — `/settings/sources` permanent redirect.
*
* The Sources settings section was merged into `/settings/thresholds`
* (now titled "Targets & Sources" / "Zielwerte & Quellen"). The two
* editors share the same per-metric configuration shelf and reading
* them side-by-side on one page matches how a user actually thinks
* about metric configuration ("for this metric, which sources do I
* trust AND what range counts as healthy?"). Per
* `.planning/research/v1434-r-2-carryover-scope.md` §7 and
* `.planning/round-v1433-audit-menu.md` §7.1 (item 3).
*
* 308 (`permanentRedirect`) keeps the request method intact so any
* external system that POSTs (none today, but cheap insurance) follows
* through with its original method. Existing bookmarks, iOS Settings
* deep-links, and any docs that referenced `/settings/sources`
* continue to resolve.
*/
export default function SettingsSourcesRedirect(): never {
permanentRedirect("/settings/thresholds");
}
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { InsightStatusData } from "@/hooks/use-insight-status";
/**
* v1.8.7.1 — `<HealthKitMetricPage statusMetric=…>` mounts the generic
* per-metric assessment card beneath the chart when the metric has data,
* and falls back to the existing insufficient-data empty state otherwise.
*
* The page leans on several hooks (`useAuth`, `useInsightsAnalytics`,
* `useInsightMetricStatus`, …); the test mocks them so the data /
* empty branch is deterministic. The chart is `next/dynamic`-imported,
* so SSR renders its stub — enough to verify the assessment slot.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="healthkit-chart-stub" />;
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC", dateOfBirth: null, gender: null },
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
const metricStatusMock = vi.fn();
vi.mock("@/hooks/use-insight-status", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/hooks/use-insight-status")>();
return {
...actual,
useInsightMetricStatus: (...args: unknown[]) => metricStatusMock(...args),
};
});
import { HealthKitMetricPage } from "../healthkit-metric-page";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
import InsightsActiveEnergyPage from "@/app/insights/active-energy/page";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
const baseProps = {
measurementType: "HEART_RATE_VARIABILITY",
insightMetric: "HEART_RATE_VARIABILITY" as const,
chartKey: "hrv" as const,
i18nPrefix: "insights.hrv",
color: "#bd93f9",
unit: "ms",
emptyStateIcon: null,
};
const settledStatus: InsightStatusData = {
hasProvider: true,
text: "Your HRV has trended upward over the last month.",
cached: false,
updatedAt: null,
};
beforeEach(() => {
analyticsMock.mockReset();
metricStatusMock.mockReset();
});
describe("<HealthKitMetricPage> status mount", () => {
it("mounts the assessment card when statusMetric is set and the metric has data", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 12 } } },
isEmpty: false,
});
metricStatusMock.mockReturnValue({
data: settledStatus,
isLoading: false,
});
const html = render(
<HealthKitMetricPage
{...baseProps}
statusMetric="HEART_RATE_VARIABILITY"
/>,
);
expect(html).toContain("Your HRV has trended upward");
// The card fetch is enabled (statusMetric present + data present).
expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", true);
});
it("does not mount the assessment card when statusMetric is omitted", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 12 } } },
isEmpty: false,
});
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(<HealthKitMetricPage {...baseProps} />);
// No assessment heading, and the card (which owns the hook) never
// mounts, so the generic fetch is never wired.
expect(html).not.toContain("Assessment");
expect(metricStatusMock).not.toHaveBeenCalled();
});
it("renders the insufficient-data empty state (no card) when the metric has no data", () => {
analyticsMock.mockReturnValue({ data: null, isEmpty: true });
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage
{...baseProps}
statusMetric="HEART_RATE_VARIABILITY"
/>,
);
// Empty branch: the page short-circuits to the empty state before the
// card JSX, so no settled assessment text and the card (and its fetch)
// never mounts — a source-less account never fires the round-trip.
expect(html).not.toContain("Your HRV has trended upward");
expect(metricStatusMock).not.toHaveBeenCalled();
});
it("wires the active-energy page to an accepted registry id (not the MeasurementType remap)", () => {
// v1.8.7.1 (Design H1) — the active-energy page formerly passed
// `ACTIVE_ENERGY_BURNED` (the DB MeasurementType), which the route's
// closed enum rejects with 422; the registry id is `ACTIVE_ENERGY`.
// Render the real page module and assert the hook receives a metric id
// the route accepts.
analyticsMock.mockReturnValue({
data: { summaries: { ACTIVE_ENERGY_BURNED: { count: 30 } } },
isEmpty: false,
});
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
render(<InsightsActiveEnergyPage />);
expect(metricStatusMock).toHaveBeenCalled();
const passedMetric = metricStatusMock.mock.calls[0][0] as string;
expect(passedMetric).toBe("ACTIVE_ENERGY");
expect(METRIC_STATUS_IDS).toContain(passedMetric);
});
});
@@ -92,6 +92,23 @@ describe("<SubPageShell>", () => {
expect(probeIdx).toBeLessThan(headerEnd);
});
it("renders the back link above the heading when supplied", () => {
const html = render(
<SubPageShell
title="Weight"
backLink={<a data-slot="back-probe">back</a>}
>
<span />
</SubPageShell>,
);
// v1.8.7.1 — the back-nav leads the page, above the heading.
const backIdx = html.indexOf('data-slot="back-probe"');
const headerIdx = html.indexOf("<header");
expect(backIdx).toBeGreaterThan(-1);
expect(headerIdx).toBeGreaterThan(-1);
expect(backIdx).toBeLessThan(headerIdx);
});
it("mounts no coach launch surface when coachLaunch is omitted", () => {
const html = render(
<SubPageShell title="Pulse">
@@ -3,14 +3,18 @@
import Link from "next/link";
import type { ComponentProps, ReactNode } from "react";
import { Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useTranslations } from "@/lib/i18n/context";
import type { InsightMetric } from "@/lib/insights/metric-availability";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
@@ -96,6 +100,23 @@ export interface HealthKitMetricPageProps {
* rather than a bespoke module. Omit it for metrics without a target.
*/
targetSummarySlug?: string;
/**
* v1.8.7.1 — when set, mounts `<InsightStatusCard>` beneath the chart,
* pointed at the generic per-metric assessment route
* (`/api/insights/metric-status?metric=<statusMetric>`). The value is
* the HealthKit metric identifier the route keys on — almost always the
* same string as `measurementType`. The card is only rendered on the
* data-bearing branch; the empty (insufficient-data) branch keeps the
* existing `<MetricEmptyState>` note and never fires an assessment
* fetch. Omit it for metrics that should not carry an assessment.
*
* Typed to the closed `MetricStatusMetricId` union (the registry-id
* vocabulary the route's Zod enum accepts) rather than a bare string,
* so a page that passes a MeasurementType remap (e.g.
* `ACTIVE_ENERGY_BURNED` instead of the `ACTIVE_ENERGY` registry id)
* is a compile error, not a silent 422.
*/
statusMetric?: MetricStatusMetricId;
}
export function HealthKitMetricPage({
@@ -113,12 +134,14 @@ export function HealthKitMetricPage({
valueScale,
explainerMetric,
targetSummarySlug,
statusMetric,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric);
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
// The stat strip renders display-unit values. The summary holds stored
// values, so when the page renders a scaled unit (e.g. WALKING_SPEED
@@ -199,6 +222,13 @@ export function HealthKitMetricPage({
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
{statusMetric ? (
<MetricStatusCard
metric={statusMetric}
icon={<Sparkles className="h-5 w-5" />}
enabled={!isEmpty}
/>
) : null}
</SubPageShell>
);
}
@@ -0,0 +1,52 @@
"use client";
import type { ReactNode } from "react";
import { useInsightMetricStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
interface MetricStatusCardProps {
/** The registry metric id the generic assessment route keys on. */
metric: MetricStatusMetricId;
/** The leading glyph (Sparkles for the generic scaffold, Moon for sleep). */
icon: ReactNode;
/**
* Gate the fetch on the page having data, so an insufficient-data page
* never fires an assessment round-trip. The hook still runs on every
* render (rules of hooks); `enabled` decides whether it talks to the
* network.
*/
enabled: boolean;
}
/**
* v1.8.7.1 — shared mount for the generic per-metric assessment card.
*
* Owns the `useInsightMetricStatus` hook + the eight-prop `<InsightStatusCard>`
* wiring (`status?.x ?? default`) so the HealthKit metric scaffold and the
* bespoke sleep page consume one seam instead of repeating the block. Only
* the icon differs between the two sites; everything else is identical.
*/
export function MetricStatusCard({
metric,
icon,
enabled,
}: MetricStatusCardProps) {
const { t } = useTranslations();
const { data: status, isLoading } = useInsightMetricStatus(metric, enabled);
return (
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={icon}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isLoading}
preparing={status?.preparing ?? false}
/>
);
}
@@ -31,6 +31,15 @@ import { cn } from "@/lib/utils";
export interface SubPageShellProps {
title: string;
/**
* v1.8.7.1 — optional back-navigation control rendered ABOVE the page
* heading, the standard back-nav placement. Detail/values sub-pages
* (`/insights/values/[type]`, `/insights/workouts/[id]`) pass their
* "back to …" link here so it leads the page rather than trailing the
* content. Omitted on the mother page and category pages reached via the
* tab strip.
*/
backLink?: ReactNode;
/** Optional accent badge (status, dataset coverage, …). */
badge?: ReactNode;
/**
@@ -108,6 +117,7 @@ export interface SubPageShellProps {
export function SubPageShell({
title,
backLink,
badge,
description,
explainerMetric,
@@ -142,6 +152,8 @@ export function SubPageShell({
return (
<div data-slot="insights-subpage" className="space-y-4 md:space-y-5">
{/* v1.8.7.1 — back-nav leads the page, above the heading. */}
{backLink}
<header className="space-y-1.5">
{/* v1.8.6 — the heading row pins the Coach icon top-right while the
title / nudge / badge wrap together on the left.
@@ -0,0 +1,101 @@
"use client";
import { useCallback, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.8.7.1 — on-demand full assessment warm.
*
* POSTs `/api/insights/pregenerate`, which enqueues a forced warm of every
* AI assessment (comprehensive insight + the seven specialised status cards
* + every data-bearing generic metric assessment) for the calling user on
* the worker and returns immediately. The cards then fill via their existing
* stale-while-revalidate GETs — there is nothing to invalidate here.
*
* Two entry points share the one mutation:
* - `warm()` — the manual "prepare assessments" button. Shows a toast.
* - `autoWarmOnce(enabled)` — a fire-and-forget background warm fired ONCE
* per browser session (sessionStorage-gated) when the Insights overview
* mounts with data, so a returning user lands on warm caches without
* tapping anything. The auto path is silent (no toast) and tolerates a
* blocked/failed request — the server-side anti-spam bucket and the
* nightly cron are the catch-net.
*/
const AUTO_WARM_SESSION_KEY = "healthlog:insights:auto-warm";
async function postWarm(): Promise<void> {
// Same-origin relative-path fetch — exempt from the safe-fetch rule by
// construction. A 429 (warm already in progress) is an expected,
// harmless outcome, not an error.
const res = await fetch("/api/insights/pregenerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok && res.status !== 429) {
throw new Error(`HTTP ${res.status}`);
}
}
export interface UseInsightsWarmResult {
/** Manual trigger — POSTs the warm and shows a toast. */
warm: () => void;
/** True while the warm POST is in flight. */
isWarming: boolean;
/**
* Fire the background warm once per session. Pass `enabled` (e.g.
* authenticated AND the user has data) — the warm only fires on the
* first call where `enabled` is true, and never again this session.
*/
autoWarmOnce: (enabled: boolean) => void;
}
export function useInsightsWarm(): UseInsightsWarmResult {
const { t } = useTranslations();
const mutation = useMutation({
mutationKey: queryKeys.insightsPregenerate(),
mutationFn: postWarm,
});
const { mutate } = mutation;
const warm = useCallback(() => {
mutate(undefined, {
onSuccess: () => toast.success(t("insights.warmStarted")),
});
}, [mutate, t]);
// The auto-trigger must fire at most once per session even across the
// remounts a route navigation causes. A ref guards within the mount;
// sessionStorage guards across mounts / navigations.
const firedThisMount = useRef(false);
const autoWarmOnce = useCallback(
(enabled: boolean) => {
if (!enabled || firedThisMount.current) return;
firedThisMount.current = true;
try {
if (window.sessionStorage.getItem(AUTO_WARM_SESSION_KEY)) return;
window.sessionStorage.setItem(AUTO_WARM_SESSION_KEY, String(Date.now()));
} catch {
// sessionStorage can throw under strict-privacy modes — fall
// through and fire the warm anyway; the server bucket de-dupes.
}
// Silent background warm — no toast, errors swallowed by the
// mutation's default (no onError handler surfaces it).
mutate();
},
[mutate],
);
return {
warm,
isWarming: mutation.isPending,
autoWarmOnce,
};
}
@@ -79,15 +79,6 @@ vi.mock("@/components/settings/dashboard-layout-section", () => ({
vi.mock("@/components/settings/thresholds-editor-section", () => ({
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
}));
// v1.4.34 IW-D — `<ThresholdsSection>` now also embeds
// `<SourcesSection mode="embedded">` so the combined "Targets &
// Sources" page reads as one shelf. The SourcesSection itself owns
// React Query and TanStack mutation wiring; stub it at the import
// boundary to keep the SSR smoke test focused on the wrapper.
vi.mock("@/components/settings/sources-section", () => ({
SourcesSection: () => <div data-testid="sources-section" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AboutSection } from "../about-section";
import { AccountSection } from "../account-section";
@@ -169,12 +160,12 @@ describe("settings sections — SSR smoke", () => {
expect(html).toContain("Tour ansehen");
});
it("<AiSection> renders the Insights card", () => {
it("<AiSection> renders the AI Insights heading", () => {
const html = render(<AiSection />);
// v1.4.33 IW7 — section renamed from "AI Insights" to "Insights"
// per the Marc-Voice rule. The card title is replaced with a sparkles
// icon + status badges, and the section H1 carries the only heading.
expect(html).toContain("Insights");
// v1.8.7.1 — section renamed to "AI Insights" so the AI nature of the
// surface is explicit (was the bare "Insights"). The section H1 carries
// the only heading.
expect(html).toContain("AI Insights");
expect(html).toContain("settings-section-ai-title");
});
@@ -230,26 +221,26 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain(">Jetzt prüfen<");
});
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
it("<ThresholdsSection> renders the Targets heading and the threshold editor only", () => {
// v1.4.16 phase B6 contract: the route wrapper is named
// `<ThresholdsSection>` (was `<ThresholdsSettingsSection>`), lives
// at `thresholds-section.tsx`, and embeds the inner editor under
// `<ThresholdsEditorSection>` (was `<ThresholdsSection>`).
// v1.4.34 IW-D — the wrapper now also embeds
// `<SourcesSection mode="embedded">` so the page is the merged
// "Targets & Sources" surface. Both editor mounts must be present.
// v1.8.7.1 — Sources split back onto its own page, so this wrapper
// renders the target-range editor only.
const html = render(<ThresholdsSection />);
// v1.4.34 IW-D: section renamed to "Targets & Sources".
expect(html).toContain("Targets &amp; Sources");
// v1.8.7.1: section renamed to "Targets".
expect(html).toContain("Targets");
expect(html).toContain('data-testid="thresholds-editor"');
expect(html).toContain('data-testid="sources-section"');
expect(html).not.toContain('data-testid="sources-section"');
expect(html).not.toContain("settings.sections.thresholds.");
});
it("<ThresholdsSection> resolves the German title (B6 rename + IW-D merge)", () => {
it("<ThresholdsSection> resolves the German title", () => {
const html = render(<ThresholdsSection />, "de");
expect(html).toContain("Zielwerte &amp; Quellen");
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
expect(html).toContain("Zielwerte");
expect(html).toContain('data-testid="thresholds-editor"');
expect(html).toContain('data-testid="sources-section"');
expect(html).not.toContain('data-testid="sources-section"');
});
});
@@ -35,7 +35,7 @@ function renderShell(props: {
}
describe("SETTINGS_SECTION_SLUGS", () => {
it("declares the ten sections agreed for v1.4.34", () => {
it("declares the eleven sections", () => {
// Order matters — `generateStaticParams()` and the sidebar derive their
// ordering from this constant, so a reorder is a behaviour change.
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
@@ -44,19 +44,17 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.4.16 phase B7 added the consolidated `export` section between
// `api` and `advanced` so every "give me my data out" path lives in
// one place.
// v1.4.25 W5e added `sources` between `thresholds` and `ai` so the
// per-metric-class source-priority surface lives next to the other
// analytics-shaping controls (thresholds → ranges; sources →
// canonical source per metric).
// v1.4.34 IW-D — `sources` slug was retired (merged into
// `thresholds`, now "Targets & Sources"). `/settings/sources` stays
// alive as a `permanentRedirect`. Section count: 10 (was 11).
// v1.4.25 W5e added `sources` between `thresholds` and `ai`; v1.4.34
// IW-D merged it into `thresholds`.
// v1.8.7.1 — `sources` (Sources) is its own slug again, sitting
// between `thresholds` (Targets) and `ai`. Section count: 11.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
@@ -164,11 +162,11 @@ describe("<SettingsShell>", () => {
// in the sidebar user-card dropdown. Route `/settings/about` is
// still alive for direct links.
expect(html).not.toContain('href="/settings/about"');
// v1.4.34 IW-D — Sources slug retired; merged into Targets & Sources
// under `/settings/thresholds`. The Sources nav entry must not
// appear; the route stays alive via permanentRedirect.
expect(html).not.toContain('href="/settings/sources"');
expect(html).toContain("Targets &amp; Sources");
// v1.8.7.1 — Targets and Sources are two separate nav entries again.
expect(html).toContain('href="/settings/thresholds"');
expect(html).toContain('href="/settings/sources"');
expect(html).toContain("Targets");
expect(html).toContain("Sources");
});
it("resolves every section title via the i18n provider — German", () => {
@@ -184,21 +182,21 @@ describe("<SettingsShell>", () => {
// per-metric overrides moved out into their own "Persönliche Zielwerte"
// section, which is the new entry below the Dashboard one.
expect(html).toContain("Dashboard");
// v1.4.34 IW-D — section renamed from "Persönliche Zielwerte" to
// "Zielwerte & Quellen" (combined with the former Sources section).
expect(html).toContain("Zielwerte &amp; Quellen");
// v1.4.33 IW7 — "KI-Auswertungen" renamed to "Auswertungen" per
// the Marc-Voice rule (no "KI"/"AI" prefix in user-facing copy).
expect(html).toContain("Auswertungen");
// v1.8.7.1 — Targets and Sources are two separate German nav
// entries again: "Zielwerte" and "Quellen".
expect(html).toContain("Zielwerte");
expect(html).toContain("Quellen");
// v1.8.7.1 — the AI Insights section is named "KI-Auswertungen" in
// German (the "KI" prefix makes the AI nature explicit).
expect(html).toContain("KI-Auswertungen");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Erweitert");
// v1.4.33 IW7 — "Über" (About) section removed from the in-shell
// nav, folded into the sidebar user-card dropdown ("Über HealthLog").
expect(html).not.toContain('href="/settings/about"');
// v1.4.34 IW-D — Sources nav entry retired (merged into Targets &
// Sources).
expect(html).not.toContain('href="/settings/sources"');
// v1.8.7.1 — both Targets and Sources nav entries are present.
expect(html).toContain('href="/settings/sources"');
});
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
@@ -16,6 +16,7 @@ import {
Download,
Info,
KeyRound,
Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -35,6 +36,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
notifications: Bell,
dashboard: LayoutDashboard,
thresholds: SlidersHorizontal,
sources: Layers,
ai: Sparkles,
api: KeyRound,
export: Download,
+8 -8
View File
@@ -6,20 +6,20 @@
* graph.
*/
// v1.4.34 IW-D — `sources` slug was retired. The two former sections
// (`/settings/thresholds` and `/settings/sources`) merged into a single
// "Targets & Sources" page under `/settings/thresholds`. The historic
// `/settings/sources` URL stays alive as a `permanentRedirect` (see
// `src/app/settings/sources/page.tsx`) so external bookmarks (iOS,
// docs) follow through unchanged. Per
// `.planning/research/v1434-r-2-carryover-scope.md` §7 and
// `.planning/round-v1433-audit-menu.md` §7.1 (item 3).
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are again two
// separate settings pages, each its own left-nav entry and route. They were
// merged into one "Targets & Sources" page in v1.4.34 IW-D, but the two
// editors drive distinct mutation flows (`/api/user/thresholds` vs
// `/api/auth/me/source-priority`) and reading them as one page made the
// surface long; splitting them back keeps each concern self-contained.
// Both slugs are served by the dynamic `[section]` route below.
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
+10 -8
View File
@@ -21,6 +21,7 @@ import {
Bell,
Download,
KeyRound,
Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -74,14 +75,10 @@ interface SettingsSection {
* navigation. Direct links (the user-card dropdown, the public
* `/about` permalink) continue to resolve to the same component.
*
* v1.4.34 IW-D — `sources` was merged into `thresholds` (now titled
* "Targets & Sources" / "Zielwerte & Quellen"). The combined section
* surfaces per-metric source priority alongside the threshold inputs
* on a single page. `/settings/sources` stays alive as a
* `permanentRedirect` so external bookmarks keep working; the slug
* was dropped from `SETTINGS_SECTION_SLUGS` because the dynamic
* `[section]` route no longer serves it. Per
* `.planning/research/v1434-r-2-carryover-scope.md` §7.
* v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
* separate sidebar entries again (merged into a single "Targets &
* Sources" page in v1.4.34 IW-D, split back here). Both are served by
* the dynamic `[section]` route.
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
@@ -105,6 +102,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.thresholds.title",
icon: SlidersHorizontal,
},
{
slug: "sources",
titleKey: "settings.sections.sources.title",
icon: Layers,
},
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
{
+23 -43
View File
@@ -99,25 +99,15 @@ const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
};
/**
* v1.4.34 IW-D — section presentation mode.
* - `"standalone"` (default) renders the page-level header (h1 +
* subtitle) and keeps the section's own aria-labelledby wiring.
* - `"embedded"` drops the header because a parent surface
* (`<ThresholdsSection>` on `/settings/thresholds`) already names
* the page. The section becomes a `<div>` instead of a
* `<section>` so the assistive-tech reading order stays one
* combined surface.
* `<SourcesSection>` — route-level wrapper for `/settings/sources`
* (the "Sources" / "Quellen" page).
*
* `/settings/sources` no longer renders this component directly — it
* permanently redirects to `/settings/thresholds`, which uses the
* `embedded` mode. The default keeps backward compatibility for any
* test/storybook that still mounts `<SourcesSection />` standalone.
* v1.8.7.1 — Sources is its own settings page again (it was merged into
* the combined "Targets & Sources" page under `/settings/thresholds` in
* v1.4.34 IW-D). The section renders its own page header (h1 + subtitle)
* and owns the per-metric source-priority + device-type ladders.
*/
export interface SourcesSectionProps {
mode?: "standalone" | "embedded";
}
export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}) {
export function SourcesSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -292,32 +282,22 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
return Object.keys(priority.deviceTypePriority).length;
}, [priority]);
// v1.4.34 IW-D — `embedded` mode skips the section header because the
// parent surface (`<ThresholdsSection>`) already names the page.
const isStandalone = mode === "standalone";
const Wrapper = isStandalone ? "section" : "div";
const wrapperProps = isStandalone
? {
"aria-labelledby": "settings-section-sources-title",
className: "space-y-6",
}
: { className: "space-y-6" };
return (
<Wrapper {...wrapperProps}>
{isStandalone && (
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sources.description")}
</p>
</header>
)}
<section
aria-labelledby="settings-section-sources-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sources.description")}
</p>
</header>
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
@@ -599,7 +579,7 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
</div>
)}
</div>
</Wrapper>
</section>
);
}
+13 -33
View File
@@ -1,37 +1,24 @@
"use client";
import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
import { useTranslations } from "@/lib/i18n/context";
/**
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`.
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`
* (the "Targets" / "Zielwerte" page).
*
* v1.4.34 IW-D — section was renamed "Zielwerte & Quellen" / "Targets &
* Sources" and absorbs the former `/settings/sources` content. The two
* editors stay distinct mutation flows (`/api/user/thresholds` for
* ranges + `/api/auth/me/source-priority` for source ladders) so a save
* on one half never disturbs the other; presenting them on a single
* page collapses two adjacent sidebar slots into one and matches the
* Settings nav audit (§7.1 item 3 in
* `.planning/round-v1433-audit-menu.md` — both screens were per-metric
* configuration shelves and reading them together matches how a user
* actually thinks about a metric: "for this metric, which sources do
* I trust AND what range counts as healthy?").
*
* `/settings/sources` stays alive as a `permanentRedirect` (see
* `src/app/settings/sources/page.tsx`) so external bookmarks (iOS
* Settings deep-links, docs) keep resolving.
* v1.8.7.1 — Targets and Sources are two separate settings pages again.
* This page owns only the per-metric target-range editor
* (`/api/user/thresholds`); the source-priority ladders moved back onto
* their own `/settings/sources` page (`<SourcesSection>`). The two were
* merged into one "Targets & Sources" page in v1.4.34 IW-D, but the
* combined surface ran long and the two editors drive distinct mutation
* flows, so each concern is self-contained on its own page.
*
* Visual structure on this page:
* 1. Section header (combined title + description).
* 2. `<ThresholdsEditorSection>` — per-metric range inputs (top).
* The editor card kept its own affordance row (icon, reset-all
* button) so the action surface stays compact.
* 3. `<SourcesSection>` body — per-metric source priority + the
* two-axis device-type expander (bottom). The inner section's
* header was suppressed in `mode="embedded"` because the page
* header above already names the surface.
* 1. Section header (title + description).
* 2. `<ThresholdsEditorSection>` — per-metric range inputs. The editor
* card keeps its own affordance row (icon, reset-all button).
*
* v1.4.16 phase B6: file renamed from the historic
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`)
@@ -63,15 +50,8 @@ export function ThresholdsSection() {
</p>
</header>
{/* Per-metric threshold ranges — top card. */}
{/* Per-metric threshold ranges. */}
<ThresholdsEditorSection id="thresholds" />
{/* Per-metric source priority + two-axis device-type ladder —
bottom card. `mode="embedded"` suppresses the inner h1 +
subtitle (the page header above owns the surface name) and
drops the inner `<section>` aria-label so the assistive-tech
reading order stays one combined surface, not two. */}
<SourcesSection mode="embedded" />
</section>
);
}
+9 -13
View File
@@ -41,19 +41,15 @@ function SelectTrigger({
// clear WCAG 2.5.5 and shrinks to 40 px on sm+ to match the
// desktop input rhythm. `size=sm` (filter chips) intentionally
// keeps the 32 px footprint.
// v1.4.37 W4a item 4 — split the trigger's horizontal padding
// (`pl-3 pr-2`) so the trailing chevron parks at the same
// visual gutter as the browser-native date-input calendar
// icon. The old uniform `px-3` parked the chevron 12 px from
// the edge, which read visibly tighter than the date-picker
// glyph when both controls shared a form row.
// v1.4.38 W-D P1-6 — drop the `[&_svg:last-child]:mr-1`
// double-shift and tighten `pr-2.5` to `pr-2`. The previous
// 14 px combined gutter (10 + 4) hit Chromium-Material native
// date-inputs cleanly but lagged ~4 px against Safari and
// Chromium-legacy themes; `pr-2` (8 px) lands the chevron
// inside the gutter the non-Material engines actually paint.
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-2 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// v1.8.7.1 — symmetric horizontal padding. The earlier asymmetric
// `pl-3 pr-2` (12 px left / 8 px right) parked the trailing chevron
// visibly closer to the right edge than the value text sits from the
// left, so the trigger read lopsided. Match the right gutter to the
// left text padding (`px-3` → both 12 px) so the chevron and the
// value are inset equally; `gap-2` between them keeps the chevron
// from crowding a long value, and `shrink-0` guarantees it never
// clips.
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
+65
View File
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { queryKeys } from "@/lib/query-keys";
/**
@@ -163,3 +164,67 @@ export function useInsightStatus(metric: InsightStatusMetric) {
),
});
}
/**
* v1.8.7.1 — generic per-metric assessment loader for the HealthKit
* metric sub-pages. The seven bespoke metrics above (`weight`, `pulse`,
* …) each own a hand-written `/api/insights/<metric>-status` route; the
* ~29 HealthKit pages instead share one generic route keyed by the
* metric id:
*
* GET /api/insights/metric-status?metric=<METRIC_ID>&locale=<locale>
*
* where METRIC_ID is the existing HealthKit measurement identifier
* (`HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`, `SLEEP_DURATION`, …).
* The response envelope, the 8 s client ceiling, the no-retry policy and
* the bounded `preparing` poll are identical to `useInsightStatus` — the
* card consumes the same `InsightStatusData` shape for every scope.
*
* `enabled` lets the page suppress the fetch entirely when the metric has
* no data (the page renders the insufficient-data empty state instead of
* the card), so a brand-new account never fires an assessment round-trip.
*/
export function useInsightMetricStatus(
// The closed registry-id union (the route's Zod enum vocabulary). `""`
// is the disabled-fetch placeholder the `HealthKitMetricPage` passes when
// no `statusMetric` is wired (the hook must still run per the rules of
// hooks); `enabled` is false on that branch so the empty id never reaches
// a round-trip. A typo'd metric id is therefore a compile error.
metric: MetricStatusMetricId | "",
enabled = true,
) {
const { isAuthenticated } = useAuth();
const { locale } = useTranslations();
return useQuery({
queryKey: queryKeys.insightsMetricStatus(metric, locale),
queryFn: async (): Promise<InsightStatusData> => {
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
STATUS_TIMEOUT_MS,
);
try {
const res = await fetch(
`/api/insights/metric-status?metric=${encodeURIComponent(
metric,
)}&locale=${locale}`,
{ signal: controller.signal },
);
if (!res.ok) throw new Error("Failed");
const json = (await res.json()) as { data: InsightStatusData };
return json.data;
} finally {
clearTimeout(timeoutHandle);
}
},
enabled: isAuthenticated && enabled,
staleTime: 60 * 1000,
retry: 0,
refetchInterval: (query) =>
nextStatusPollInterval(
query.state.data?.preparing,
query.state.dataUpdateCount,
),
});
}
@@ -337,6 +337,39 @@ describe("Codex slug fallback chain", () => {
expect(helper(400, "unrelated 400")).toBe(false);
});
it("DEFAULT_SLUG_FALLBACK_CHAIN tracks the current ChatGPT-auth ladder", () => {
// The backend rotated its ChatGPT-auth slugs onto the gpt-5.x line on
// 2026-06-02; the in-code default must match the accepted set, in order.
expect([...__test.DEFAULT_SLUG_FALLBACK_CHAIN]).toEqual([
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5.2",
]);
});
it("loadFallbackChain falls back to the default ladder with no env override", () => {
const origModel = process.env.CODEX_MODEL;
const origChain = process.env.CODEX_MODEL_FALLBACK_CHAIN;
delete process.env.CODEX_MODEL;
delete process.env.CODEX_MODEL_FALLBACK_CHAIN;
try {
expect(__test.loadFallbackChain()).toEqual([
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5.2",
]);
} finally {
if (origModel === undefined) delete process.env.CODEX_MODEL;
else process.env.CODEX_MODEL = origModel;
if (origChain === undefined) delete process.env.CODEX_MODEL_FALLBACK_CHAIN;
else process.env.CODEX_MODEL_FALLBACK_CHAIN = origChain;
}
});
it("loadFallbackChain folds CODEX_MODEL into position 0", () => {
const orig = process.env.CODEX_MODEL;
process.env.CODEX_MODEL = "custom-pinned-slug";
@@ -344,7 +377,7 @@ describe("Codex slug fallback chain", () => {
const chain = __test.loadFallbackChain();
expect(chain[0]).toBe("custom-pinned-slug");
// Defaults still present after position 0.
expect(chain).toContain("gpt-5.3-codex");
expect(chain).toContain("gpt-5.5");
// No duplicates.
const seen = new Set(chain);
expect(seen.size).toBe(chain.length);
+12 -5
View File
@@ -33,16 +33,23 @@ import {
const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
/**
* Default fallback chain — most-current-first as of 2026-05-09. Override
* Default fallback chain — most-current-first as of 2026-06-02. Override
* via `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated) on apps01 if a
* specific plan ladder needs different ordering. `CODEX_MODEL` is
* folded into position 0 of the chain when set; duplicates are dropped.
*
* The ChatGPT Codex backend rotated its ChatGPT-auth slugs off the
* `-codex` specialist line onto the plain `gpt-5.x` line on 2026-06-02;
* the former `-codex` defaults are now rejected with a 503 "all
* configured Codex slugs were rejected". The ladder below tracks the
* current accepted set.
*/
const DEFAULT_SLUG_FALLBACK_CHAIN = [
"gpt-5.3-codex", // verified accepted on Plus/Pro 2026-05-09
"gpt-5-codex", // historical default — kept as second-chance retry; backend may flip back
"gpt-5", // bare slug — rejected on ChatGPT-auth as of 2026-05; safe to keep as ladder-rung
"gpt-4o", // last-ditch capability fallback
"gpt-5.5", // current default across paid tiers as of 2026-06-02
"gpt-5.4", // documented fallback
"gpt-5.4-mini", // documented lighter-weight fallback
"gpt-5.3-codex", // late rung — still accepted intermittently
"gpt-5.2", // legacy floor
] as const;
function loadFallbackChain(): string[] {
+145
View File
@@ -0,0 +1,145 @@
/**
* v1.8.7.1 — archetype prompt templates for the generic per-metric
* assessment generator.
*
* The seven specialised status cards each ship a bespoke
* `src/lib/ai/prompts/<metric>.ts`. Hand-writing ~30 more for the
* HealthKit metric pages is not viable, so this module parameterises one
* template per archetype (five) plus a dedicated `sleep` template. Each
* template is driven entirely by the metric metadata (display name,
* unit, direction, normal range) + the same graded snapshot shape the
* specialised cards send, so a new metric in the registry produces a
* grounded assessment without a new prompt.
*
* Every template extends `getBaseSystemPrompt` (the shared "one short,
* grounded, warm assessment paragraph → { summary }" contract) and adds
* a per-archetype section describing how to read THAT family of metrics
* (a vital is a point-in-time reading placed against a normal range; an
* activity metric is a cumulative daily count, mostly higher-better; a
* mobility metric is a stability signal; …). The user prompt then ships
* the snapshot JSON + the metric metadata + the optional previous-context
* block, exactly like the specialised generators.
*/
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
import type {
MetricArchetype,
MetricStatusMeta,
} from "@/lib/insights/metric-status-registry";
function directionPhrase(
meta: MetricStatusMeta,
locale: Locale,
): string {
if (locale === "en") {
switch (meta.direction) {
case "higher-better":
return "Higher values are generally more favourable for this metric.";
case "lower-better":
return "Lower values are generally more favourable for this metric.";
case "target-band":
return "Neither extreme is good — the value should sit inside its normal band; deviations in either direction are worth noting.";
}
}
switch (meta.direction) {
case "higher-better":
return "Höhere Werte sind bei dieser Metrik tendenziell günstiger.";
case "lower-better":
return "Niedrigere Werte sind bei dieser Metrik tendenziell günstiger.";
case "target-band":
return "Kein Extrem ist gut — der Wert sollte im Normalband liegen; Abweichungen in beide Richtungen sind erwähnenswert.";
}
}
/**
* Render the metric-metadata block both the system section and the user
* prompt reference. Kept terse — the snapshot carries the numbers, this
* just tells the model what it is looking at.
*/
function metaBlock(meta: MetricStatusMeta, locale: Locale): string {
const range = meta.normalRange
? locale === "en"
? `Coarse population reference band: ${meta.normalRange.low}–${meta.normalRange.high} ${meta.unit} (a placement aid only — the user's OWN baseline leads).`
: `Grobes Referenzband (Population): ${meta.normalRange.low}–${meta.normalRange.high} ${meta.unit} (nur Orientierung — die EIGENE Baseline führt).`
: locale === "en"
? "No fixed population band applies — judge purely against the user's own baseline."
: "Kein festes Referenzband — beurteile rein gegen die eigene Baseline der Person.";
if (locale === "en") {
return [
`METRIC: ${meta.displayName} (unit: ${meta.unit}).`,
directionPhrase(meta, locale),
range,
].join("\n");
}
return [
`METRIK: ${meta.displayName} (Einheit: ${meta.unit}).`,
directionPhrase(meta, locale),
range,
].join("\n");
}
const ARCHETYPE_SECTION: Record<
MetricArchetype,
{ de: string; en: string }
> = {
"physiological-vital": {
en: "ARCHETYPE — PHYSIOLOGICAL VITAL:\n- This is a point-in-time physiological reading. Name the recent level, place it against both the user's own baseline (recent vs monthly/yearly mean) and, when a reference band is given, that band.\n- A single out-of-band reading is rarely a finding; a sustained shift across the recent window against the user's baseline is. Note an out-of-band trend without alarm.\n- Frame any direction (rising/falling) as a possible explanation (fitness, stress, sleep, illness, medication), never a diagnosis.",
de: "ARCHETYP — PHYSIOLOGISCHER VITALWERT:\n- Das ist ein Momentanwert. Benenne das aktuelle Niveau und ordne es sowohl gegen die eigene Baseline (recent vs. Monats-/Jahresmittel) als auch — falls ein Referenzband vorliegt — gegen dieses Band ein.\n- Ein einzelner Wert außerhalb des Bands ist selten ein Befund; eine anhaltende Verschiebung über das recent-Fenster gegen die Baseline schon. Erwähne einen Trend außerhalb des Bands sachlich, ohne zu alarmieren.\n- Formuliere jede Richtung (steigend/fallend) als mögliche Erklärung (Fitness, Stress, Schlaf, Krankheit, Medikament), nie als Diagnose.",
},
"activity-fitness": {
en: "ARCHETYPE — ACTIVITY / FITNESS:\n- This is a daily activity or fitness measure, mostly higher-better. Read the recent daily mean and compare it to the user's own weekly/monthly baseline — is their activity up, down, or steady?\n- Day-to-day swings are expected; report the multi-day trend, not a single low or high day. When a reference band is given, use it as a coarse target, not a verdict.\n- Close with one doable, specific step toward more (or sustained) movement — never the generic 'exercise more' platitude.",
de: "ARCHETYP — AKTIVITÄT / FITNESS:\n- Das ist ein tägliches Aktivitäts- oder Fitnessmaß, meist höher = besser. Lies das recent-Tagesmittel und vergleiche es mit der eigenen Wochen-/Monats-Baseline — ist die Aktivität gestiegen, gesunken oder stabil?\n- Tagesschwankungen sind normal; melde den Mehrtagestrend, nicht einen einzelnen niedrigen oder hohen Tag. Ein Referenzband ist ein grobes Ziel, kein Urteil.\n- Schließe mit einem machbaren, konkreten Schritt für mehr (oder gleichbleibende) Bewegung — nie die generische Floskel 'mehr Bewegung'.",
},
"body-composition": {
en: "ARCHETYPE — BODY COMPOSITION:\n- This is a body-composition mass (or rating). A healthy absolute value depends entirely on body size, so judge the TREND toward or away from the user's own baseline, not an absolute number.\n- Report a sustained directional change across weeks/months; single readings carry scale + hydration noise. Tie it to weight context only if the snapshot supports it.\n- Close with one step that supports the favourable direction for THIS metric.",
de: "ARCHETYP — KÖRPERZUSAMMENSETZUNG:\n- Das ist eine Körperzusammensetzungs-Masse (oder Bewertung). Ein gesunder Absolutwert hängt stark von der Körpergröße ab — beurteile den TREND zur bzw. weg von der eigenen Baseline, nicht eine Absolutzahl.\n- Melde eine anhaltende Richtungsänderung über Wochen/Monate; Einzelwerte tragen Waagen- und Hydrationsrauschen. Stelle einen Gewichtsbezug nur her, wenn der Snapshot ihn stützt.\n- Schließe mit einem Schritt, der die für DIESE Metrik günstige Richtung unterstützt.",
},
"mobility-gait": {
en: "ARCHETYPE — MOBILITY / GAIT:\n- This is a gait/mobility-stability signal Apple surfaces in its Mobility section. Its value is as a stability + anomaly flag: a steady reading near the user's baseline is reassuring; a sustained drift in the unfavourable direction is the signal to surface.\n- These metrics move slowly; one off day is noise. Only flag a multi-week drift, and frame it as a mobility observation, never a fall-risk diagnosis.\n- If steady, say so plainly. If drifting unfavourably, give one gentle, doable step.",
de: "ARCHETYP — MOBILITÄT / GANG:\n- Das ist ein Gang-/Mobilitäts-Stabilitätssignal aus Apples Mobilitätsbereich. Sein Wert liegt als Stabilitäts- und Anomalie-Flag: ein stabiler Wert nahe der Baseline beruhigt; eine anhaltende Drift in die ungünstige Richtung ist das Signal.\n- Diese Werte ändern sich langsam; ein Ausreißertag ist Rauschen. Melde nur eine mehrwöchige Drift und formuliere sie als Mobilitätsbeobachtung, nie als Sturzrisiko-Diagnose.\n- Ist es stabil, sage das klar. Driftet es ungünstig, gib einen sanften, machbaren Schritt.",
},
"environmental-exposure": {
en: "ARCHETYPE — ENVIRONMENTAL EXPOSURE:\n- This is an environmental exposure (sound level / loud-event count), lower-better. The reference band is a threshold (e.g. WHO 80 dBA) — recent values above it sustained across days are the finding worth surfacing.\n- A single loud concert or flight is not a pattern. Report repeated above-threshold exposure, not an isolated spike.\n- Close with one practical step to reduce exposure (e.g. lower the listening volume, take a quiet break) — framed as protective, never alarming.",
de: "ARCHETYP — UMWELT-EXPOSITION:\n- Das ist eine Umwelt-Exposition (Schallpegel / Anzahl lauter Ereignisse), niedriger = besser. Das Referenzband ist ein Schwellenwert (z.B. WHO 80 dBA) — recent-Werte, die ihn über mehrere Tage überschreiten, sind der erwähnenswerte Befund.\n- Ein einzelnes lautes Konzert oder ein Flug ist kein Muster. Melde wiederholte Überschreitungen, nicht einen Einzelausschlag.\n- Schließe mit einem praktischen Schritt zur Reduktion (z.B. Hörlautstärke senken, eine ruhige Pause) — schützend formuliert, nie alarmierend.",
},
sleep: {
en: "ARCHETYPE — SLEEP:\n- This is nightly sleep duration. The snapshot's values are in minutes; convert to hours for your prose (e.g. 450 min = 7.5 h). Assess both the typical duration and its CONSISTENCY: a stable ~7–9 h is the favourable pattern; high night-to-night variability is itself worth naming even when the mean looks fine.\n- Compare the recent nights to the user's own weekly/monthly baseline. A reference band of 7–9 h is a coarse adult guide, not a verdict — short or long sleepers vary.\n- Close with one doable step toward more consistent or sufficient sleep — never the forbidden 'get enough sleep' platitude; be specific (e.g. a steadier wind-down time).",
de: "ARCHETYP — SCHLAF:\n- Das ist die nächtliche Schlafdauer. Die Snapshot-Werte sind in Minuten; rechne für deinen Text in Stunden um (z.B. 450 min = 7,5 h). Bewerte sowohl die typische Dauer als auch ihre KONSTANZ: stabile ~7–9 h sind das günstige Muster; hohe Schwankung von Nacht zu Nacht ist selbst dann erwähnenswert, wenn der Mittelwert passt.\n- Vergleiche die jüngsten Nächte mit der eigenen Wochen-/Monats-Baseline. Ein Referenzband von 7–9 h ist eine grobe Erwachsenen-Orientierung, kein Urteil — Kurz- und Langschläfer variieren.\n- Schließe mit einem machbaren Schritt zu konstanterem oder ausreichenderem Schlaf — nie die verbotene Floskel 'achte auf ausreichend Schlaf'; sei konkret (z.B. eine festere Einschlafzeit).",
},
};
export function getMetricArchetypeSystemPrompt(
meta: MetricStatusMeta,
locale: Locale,
): string {
const section = ARCHETYPE_SECTION[meta.archetype];
const archetypeText = locale === "en" ? section.en : section.de;
return `${getBaseSystemPrompt(locale)}
${archetypeText}
${metaBlock(meta, locale)}`;
}
export function getMetricArchetypeUserPrompt(
meta: MetricStatusMeta,
snapshotJson: string,
todayKey: string,
locale: Locale,
previousContextBlock?: string,
): string {
const ctxBlock =
previousContextBlock && previousContextBlock.trim().length > 0
? `\n\n${previousContextBlock}\n`
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short assessment of this person's ${meta.displayName.toLowerCase()}: name the current level with a concrete number from the snapshot, place the recent window against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person: benenne das aktuelle Niveau mit einer konkreten Zahl aus dem Snapshot, ordne das recent-Fenster gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+42 -42
View File
@@ -14,14 +14,26 @@
* code paths against either surface.
*/
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
/**
* Every insights tile the layout knows about. Each entry maps to a
* routed sub-page under `/insights/<slug>` (the mother-page overview
* is keyed `"overview"`). The order of entries here is irrelevant —
* Every insights tile the layout knows about. The mother-page overview
* is keyed `"overview"`; every other id is a routed sub-page slug under
* `/insights/<slug>`. The order of entries here is irrelevant —
* `DEFAULT_INSIGHTS_LAYOUT.tiles[].order` drives the visual order.
* Exported so the API Zod `tileIdEnum` can be built from a single
* source of truth.
*
* v1.8.7.1 — the tile-id universe is derived FROM `SUB_PAGE_SLUGS`
* (`src/lib/insights/sub-page-metric.ts`), the same record the routed
* tab strip and the per-metric sub-pages enumerate. The two surfaces
* therefore stay in lockstep by construction: adding a sub-page slug
* automatically widens the layout enum, and the iOS client can persist
* a layout covering the full metric set (blood-pressure … skin-
* temperature) rather than tripping a 422 on the ~25 slugs the layout
* previously did not know about. `"overview"` is layout-only — it is
* the mother page, not a sub-page slug — so it is prepended explicitly.
*
* v1.8.0 — the canonical ids are English, matching the routed slugs
* (`/insights/<slug>`) and the naming-convention ADR
* (`docs/adr/0001-insights-naming-convention.md`). The ids were German
@@ -32,29 +44,7 @@
* `LEGACY_INSIGHTS_TILE_ID_ALIASES` + `normalizeInsightsTileId` below.
* No client breaks; iOS migrates to the English ids at its own pace.
*/
export const INSIGHTS_TILE_IDS = [
"overview",
// ── vitals ──
"blood-pressure",
"pulse",
"oxygen",
"body-temperature",
// ── body composition ──
"weight",
"bmi",
// ── activity ──
"active-energy",
"workouts",
// ── sleep ──
"sleep",
// ── cardiovascular ──
"resting-pulse",
"hrv",
// ── mood ──
"mood",
// ── events ──
"medications",
] as const;
export const INSIGHTS_TILE_IDS = ["overview", ...SUB_PAGE_SLUGS] as const;
export type InsightsTileId = (typeof INSIGHTS_TILE_IDS)[number];
@@ -123,24 +113,34 @@ const INSIGHTS_LAYOUT_VERSION = 1;
* additional `{ id, visible: false, order: N }` row here and the
* resolver auto-merges it onto existing users' saved layouts.
*/
// Core surfaces that ship default-visible for a fresh account. Every
// other tile (the long-tail HealthKit + body-composition + mobility +
// audio pills) stays default-invisible and the user opts in once the
// matching data starts to flow. Keeping this as an explicit set means
// the default-visibility decision is auditable in one place while the
// tile-id universe itself derives from `SUB_PAGE_SLUGS`.
const DEFAULT_VISIBLE_TILE_IDS = new Set<InsightsTileId>([
"overview",
"blood-pressure",
"pulse",
"weight",
"bmi",
"workouts",
"mood",
"medications",
]);
export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = {
version: INSIGHTS_LAYOUT_VERSION,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blood-pressure", visible: true, order: 1 },
{ id: "pulse", visible: true, order: 2 },
{ id: "oxygen", visible: false, order: 3 },
{ id: "body-temperature", visible: false, order: 4 },
{ id: "weight", visible: true, order: 5 },
{ id: "bmi", visible: true, order: 6 },
{ id: "active-energy", visible: false, order: 7 },
{ id: "workouts", visible: true, order: 8 },
{ id: "sleep", visible: false, order: 9 },
{ id: "resting-pulse", visible: false, order: 10 },
{ id: "hrv", visible: false, order: 11 },
{ id: "mood", visible: true, order: 12 },
{ id: "medications", visible: true, order: 13 },
],
// Order follows `INSIGHTS_TILE_IDS` (overview first, then the
// `SUB_PAGE_SLUGS` order, which itself tracks the MeasurementCategory
// overlay: vitals → body → activity → sleep → cardiovascular → mood →
// events). The dense 0-based `order` mirrors the routed tab strip.
tiles: INSIGHTS_TILE_IDS.map((id, order) => ({
id,
visible: DEFAULT_VISIBLE_TILE_IDS.has(id),
order,
})),
};
export function resolveInsightsLayout(raw: unknown): InsightsLayout {
@@ -69,17 +69,27 @@ describe("invalidateStatusInsightsForTypes", () => {
expect(deletedScopes()).toEqual(["blood-pressure", "general"]);
});
it("maps PULSE and RESTING_HEART_RATE to pulse + general", async () => {
it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => {
await invalidateStatusInsightsForTypes("u1", [
"PULSE",
"RESTING_HEART_RATE",
]);
expect(deletedScopes()).toEqual(["general", "pulse"]);
// PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE
// feeds those too AND carries its own generic assessment card
// (`metric:RESTING_HEART_RATE`), so all three are dirtied.
expect(deletedScopes()).toEqual([
"general",
"metric:RESTING_HEART_RATE",
"pulse",
]);
});
it("maps an unmapped metric to general only", async () => {
it("maps a metric with no specialised scope to general + its generic scope", async () => {
// BLOOD_GLUCOSE has no specialised assessment, but v1.8.7.1 gives it a
// generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the
// general overview AND that generic scope.
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
expect(deletedScopes()).toEqual(["general"]);
expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
});
it("dedupes scopes across a mixed batch", async () => {
@@ -88,10 +98,12 @@ describe("invalidateStatusInsightsForTypes", () => {
"PULSE",
"BLOOD_GLUCOSE",
]);
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ general
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪
// general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE)
expect(deletedScopes()).toEqual([
"bmi",
"general",
"metric:BLOOD_GLUCOSE",
"pulse",
"weight",
]);
@@ -133,7 +145,35 @@ describe("invalidateStatusInsightsForTypes", () => {
// pulse / bp scopes — the constant Apple-Health sync must not thrash
// every card's cache.
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]);
expect(deletedScopes()).toEqual(["general"]);
expect(enqueuedScopes()).toEqual(["general"]);
expect(deletedScopes()).toEqual(["general", "metric:STEPS"]);
expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]);
});
it("re-warms the matching generic metric scope for a registered type", async () => {
// v1.8.7.1 — a blood-glucose sample dirties the general overview AND
// its own generic assessment card (`metric:BLOOD_GLUCOSE`), so the
// freshly-synced metric's card refreshes in the background instead of
// lagging until the nightly warm pass.
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
});
it("maps a remapped MeasurementType to its registry metric id", async () => {
// ACTIVE_ENERGY_BURNED is stored under that DB type but the generic
// metric id (route param + cache scope) is ACTIVE_ENERGY.
await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"]);
expect(enqueuedScopes()).toEqual(["general", "metric:ACTIVE_ENERGY"]);
});
it("does not emit a generic scope for an unregistered type", async () => {
// A weight sample feeds weight + bmi + general; WEIGHT is one of the
// seven specialised metrics and has no generic registry entry, so no
// `metric:` scope is enqueued.
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
expect(
enqueuedScopes().some((s) => s.startsWith("metric:")),
).toBe(false);
});
});
@@ -0,0 +1,218 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn(), count: vi.fn() },
measurementRollup: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
getPreviousInsightContext: vi.fn().mockResolvedValue(null),
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
}));
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateMetricStatus } from "../metric-status";
import {
getMetricArchetypeSystemPrompt,
getMetricArchetypeUserPrompt,
} from "@/lib/ai/prompts/metric-archetypes";
import {
getMetricStatusMeta,
metricStatusScope,
METRIC_STATUS_IDS,
} from "../metric-status-registry";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { systemPrompt: string | null; userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { systemPrompt: string; userPrompt: string }) => {
if (capture) {
capture.systemPrompt = args.systemPrompt;
capture.userPrompt = args.userPrompt;
}
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("metric-status registry", () => {
it("excludes the seven specialised metrics from the generic set", () => {
for (const excluded of [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BMI",
"MOOD",
"MEDICATION",
]) {
expect(METRIC_STATUS_IDS).not.toContain(excluded);
}
});
it("scope id carries the metric: prefix and (with -status suffix) keeps the eviction substring", () => {
expect(metricStatusScope("RESTING_HEART_RATE")).toBe(
"metric:RESTING_HEART_RATE",
);
// The cache action appends `-status.<locale>`; the eviction sweep
// matches on the `-status.` substring, so a generic scope is swept too.
expect(
`insights.${metricStatusScope("SLEEP_DURATION")}-status.de`,
).toContain("-status.");
});
it("maps STEPS / ACTIVE_ENERGY ids onto their divergent MeasurementType", () => {
expect(getMetricStatusMeta("STEPS")?.measurementType).toBe(
"ACTIVITY_STEPS",
);
expect(getMetricStatusMeta("ACTIVE_ENERGY")?.measurementType).toBe(
"ACTIVE_ENERGY_BURNED",
);
});
});
describe("archetype prompt templates", () => {
it("injects the metric metadata + normal range into the system prompt", () => {
const meta = getMetricStatusMeta("OXYGEN_SATURATION")!;
const sys = getMetricArchetypeSystemPrompt(meta, "en");
expect(sys).toContain("Blood oxygen");
expect(sys).toContain("95");
expect(sys).toContain("PHYSIOLOGICAL VITAL");
});
it("uses the dedicated sleep archetype for SLEEP_DURATION", () => {
const meta = getMetricStatusMeta("SLEEP_DURATION")!;
const sys = getMetricArchetypeSystemPrompt(meta, "en");
expect(sys).toContain("SLEEP");
expect(getMetricArchetypeUserPrompt(meta, "{}", "2026-06-02", "en")).toContain(
"sleep duration",
);
});
});
describe("generateMetricStatus — empty-data guard", () => {
it("returns insufficient WITHOUT calling the provider when the metric has no data", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never);
const result = await generateMetricStatus({
metric: "RESTING_HEART_RATE",
userId: "user-1",
locale: "en",
readOnly: true,
});
expect(result.insufficient).toBe(true);
expect(result.text).toBeNull();
expect(runStatusCompletion).not.toHaveBeenCalled();
// No raw read either — the guard short-circuits before the gather.
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
});
describe("generateMetricStatus — generation path", () => {
it("builds a graded snapshot, runs the archetype completion, and persists", async () => {
const now = new Date();
const records: Array<{ value: number; measuredAt: Date }> = [];
for (let day = 0; day < 400; day++) {
records.push({
value: 55 + (day % 6),
measuredAt: new Date(now.getTime() - day * dayMs),
});
}
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.count).mockResolvedValue(400 as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
const captured = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion('{"summary":"Your resting heart rate is steady."}', captured);
const result = await generateMetricStatus({
metric: "RESTING_HEART_RATE",
userId: "user-1",
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Your resting heart rate is steady.");
expect(result.cached).toBe(false);
expect(result.hasProvider).toBe(true);
// The snapshot embeds the graded series, not a raw daily array.
const match = captured.userPrompt!.match(/\{[\s\S]*\}/);
const snapshot = JSON.parse(match![0]);
expect(snapshot.RESTING_HEART_RATE.series).toHaveProperty("recent");
expect(snapshot.metric.unit).toBe("bpm");
// Persisted under the generic scope cache action.
const createCall = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { action: string; details: string };
};
expect(createCall.data.action).toBe(
"insights.metric:RESTING_HEART_RATE-status.en",
);
});
it("strips chart tokens from the persisted text", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.count).mockResolvedValue(3 as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 58, measuredAt: new Date() },
] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
stubCompletion(
'{"summary":"Steady. metric:RESTING_HEART_RATE Good baseline."}',
);
const result = await generateMetricStatus({
metric: "RESTING_HEART_RATE",
userId: "user-1",
locale: "en",
});
expect(result.text).not.toContain("metric:");
expect(result.text).toContain("Steady.");
});
});
+33 -4
View File
@@ -49,6 +49,10 @@ import {
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared";
import { normalizeLocale } from "@/lib/insights/status-shared";
import {
metricIdForMeasurementType,
metricStatusScope,
} from "@/lib/insights/metric-status-registry";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -218,19 +222,37 @@ export async function invalidateStatusInsightsForTypes(
types: Iterable<MeasurementType>,
): Promise<void> {
const scopes = new Set<PerStatusScope>();
// v1.8.7.1 — a fresh measurement of a type that backs a generic
// HealthKit assessment card (the ~30 registry metrics) also dirties that
// card's `metric:<ID>` scope. Collect those alongside the seven
// specialised scopes so the generic tier is invalidated + re-warmed on
// ingest with the same debounced enqueue, rather than lagging until the
// nightly cron. Only registered, data-bearing types map here; the seven
// specialised metrics and any unregistered type resolve to null and are
// skipped, so the constant sync cannot fan out to unwanted scopes.
const metricScopes = new Set<`metric:${string}`>();
for (const type of types) {
for (const scope of statusScopesForMeasurementType(type)) {
scopes.add(scope);
}
const metricId = metricIdForMeasurementType(type);
if (metricId) {
metricScopes.add(metricStatusScope(metricId));
}
}
if (scopes.size === 0) return;
if (scopes.size === 0 && metricScopes.size === 0) return;
await prisma.auditLog.deleteMany({
where: {
userId,
OR: Array.from(scopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
OR: [
...Array.from(scopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
...Array.from(metricScopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
],
},
});
@@ -258,6 +280,13 @@ export async function invalidateStatusInsightsForTypes(
for (const scope of scopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
// v1.8.7.1 — re-warm the matching generic metric scopes the same way, so
// a freshly-synced HealthKit metric's assessment card refreshes in the
// background instead of lagging until the nightly warm pass. Same
// single-locale, singleton-debounced enqueue as the seven scopes above.
for (const scope of metricScopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
}
interface GenerateOptions {
+11 -1
View File
@@ -33,6 +33,16 @@ export type InsightScope =
| "medication-compliance-status"
| "mood-status";
/**
* v1.8.7.1 — the generic per-HealthKit-metric assessments key their
* previous-context rows under `insights.metric:<ID>-status.<locale>`, the
* same `-status` action shape the seven specialised scopes use. Accept
* that scope form alongside the seven so the generic generator can read
* its comparison row without an `as never` cast that would silently
* disable type checking on the argument.
*/
export type PreviousContextScope = InsightScope | `metric:${string}-status`;
export interface PreviousInsightContext {
/** ISO timestamp the previous analysis was rendered. */
generatedAt: string;
@@ -59,7 +69,7 @@ export interface PreviousInsightContext {
*/
export async function getPreviousInsightContext(
userId: string,
scope: InsightScope,
scope: PreviousContextScope,
locale: "de" | "en",
minAgeHours: number = 12,
): Promise<PreviousInsightContext | null> {
+466
View File
@@ -0,0 +1,466 @@
/**
* v1.8.7.1 — generic per-metric assessment registry.
*
* The seven specialised assessment scopes (blood-pressure / pulse /
* weight / bmi / mood / medication-compliance / general) each ship a
* bespoke generator + prompt. The ~30 HealthKit metric pages
* (`HealthKitMetricPage`) carried charts but no plain-language
* assessment, because hand-writing 30 more prompts is not viable.
*
* This registry is the data-driven alternative: each HealthKit metric is
* described by the small amount of metadata a generic generator needs to
* assess it — a display name, a unit, the direction a "good" value moves,
* an optional clinical/consumer normal range, and an archetype that
* selects one of six shared prompt templates. A metric with no entry here
* simply gets no assessment card (the safe default the spec calls for).
*
* Metric ids are the existing `InsightMetric` HealthKit identifiers
* (WEIGHT, RESTING_HEART_RATE, SLEEP_DURATION, …) so the UI contract,
* the route query param, and the cache scope all speak one vocabulary.
* Internally each id maps to the `MeasurementType` the rollup tier and
* the raw `Measurement` rows are keyed by (a handful differ — `STEPS`
* is stored as `ACTIVITY_STEPS`, `ACTIVE_ENERGY` as `ACTIVE_ENERGY_BURNED`).
*
* The seven specialised metrics (WEIGHT, BLOOD_PRESSURE_*, PULSE, BMI,
* MOOD, MEDICATION) are intentionally NOT registered here — they keep
* their existing dedicated scopes and are never routed through the
* generic path.
*
* Medical note (design §"Medical note"): normal ranges are population
* anchors a generic consumer/clinical guideline supports, used only as a
* coarse placement aid. The generator's primary signal is the user's OWN
* baseline (the graded series), and where the profile stores age + sex
* (`dateOfBirth` + `gender`) the generator threads them so the model can
* sanity-check the placement. NO ancestry/region-of-birth ranges — that
* is medically contentious and a data category HealthLog does not
* collect.
*/
import type { MeasurementType } from "@/generated/prisma/client";
/** The five shared archetypes plus the dedicated sleep template. */
export type MetricArchetype =
| "physiological-vital"
| "activity-fitness"
| "body-composition"
| "mobility-gait"
| "environmental-exposure"
| "sleep";
/**
* Direction a favourable value moves. `target-band` means neither
* extreme is good — the value should sit inside `normalRange`.
*/
export type MetricDirection = "higher-better" | "lower-better" | "target-band";
export interface MetricNormalRange {
low: number;
high: number;
}
export interface MetricStatusMeta {
/** The HealthKit metric id — the public route param + cache scope. */
id: MetricStatusMetricId;
/** The DB `MeasurementType` the rollup tier + raw reads are keyed by. */
measurementType: MeasurementType;
/** Stable English display name (the model localises its prose itself). */
displayName: string;
/** Canonical storage unit (matches the DB column semantics). */
unit: string;
direction: MetricDirection;
/** Coarse population placement anchor; the user's own baseline leads. */
normalRange?: MetricNormalRange;
archetype: MetricArchetype;
}
/**
* The metric ids the generic assessment path covers. A strict subset of
* `InsightMetric` — the seven specialised metrics and the event-driven
* MOOD / MEDICATION / WORKOUTS keys are excluded by construction.
*/
export type MetricStatusMetricId =
| "RESTING_HEART_RATE"
| "HEART_RATE_VARIABILITY"
| "OXYGEN_SATURATION"
| "RESPIRATORY_RATE"
| "BODY_TEMPERATURE"
| "SKIN_TEMPERATURE"
| "BLOOD_GLUCOSE"
| "WALKING_HEART_RATE_AVERAGE"
| "PULSE_WAVE_VELOCITY"
| "VASCULAR_AGE"
| "STEPS"
| "ACTIVE_ENERGY"
| "FLIGHTS_CLIMBED"
| "WALKING_RUNNING_DISTANCE"
| "TIME_IN_DAYLIGHT"
| "VO2_MAX"
| "TOTAL_BODY_WATER"
| "BONE_MASS"
| "FAT_FREE_MASS"
| "FAT_MASS"
| "MUSCLE_MASS"
| "LEAN_BODY_MASS"
| "VISCERAL_FAT"
| "WALKING_STEADINESS"
| "WALKING_ASYMMETRY"
| "WALKING_DOUBLE_SUPPORT"
| "WALKING_STEP_LENGTH"
| "WALKING_SPEED"
| "AUDIO_EXPOSURE_ENV"
| "AUDIO_EXPOSURE_HEADPHONE"
| "AUDIO_EXPOSURE_EVENT"
| "SLEEP_DURATION";
/**
* The registry. Keyed by the HealthKit metric id. Each entry's
* `measurementType` is the DB enum the rollup tier reads against.
*
* Normal ranges are coarse consumer/clinical anchors (resting-pulse
* 60–100 bpm, SpO2 95–100 %, …) — present only where a broadly-accepted
* placement exists. Body-composition mass metrics carry no fixed band:
* a healthy fat-free mass depends entirely on body size, so the
* `target-band` direction defers wholly to the user's own baseline.
*/
const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
// ── physiological-vital ──
RESTING_HEART_RATE: {
id: "RESTING_HEART_RATE",
measurementType: "RESTING_HEART_RATE",
displayName: "Resting heart rate",
unit: "bpm",
direction: "lower-better",
normalRange: { low: 50, high: 100 },
archetype: "physiological-vital",
},
HEART_RATE_VARIABILITY: {
id: "HEART_RATE_VARIABILITY",
measurementType: "HEART_RATE_VARIABILITY",
displayName: "Heart-rate variability (SDNN)",
unit: "ms",
direction: "higher-better",
archetype: "physiological-vital",
},
OXYGEN_SATURATION: {
id: "OXYGEN_SATURATION",
measurementType: "OXYGEN_SATURATION",
displayName: "Blood oxygen (SpO₂)",
unit: "%",
direction: "higher-better",
normalRange: { low: 95, high: 100 },
archetype: "physiological-vital",
},
RESPIRATORY_RATE: {
id: "RESPIRATORY_RATE",
measurementType: "RESPIRATORY_RATE",
displayName: "Respiratory rate",
unit: "breaths/min",
direction: "target-band",
normalRange: { low: 12, high: 20 },
archetype: "physiological-vital",
},
BODY_TEMPERATURE: {
id: "BODY_TEMPERATURE",
measurementType: "BODY_TEMPERATURE",
displayName: "Body temperature",
unit: "°C",
direction: "target-band",
normalRange: { low: 36.1, high: 37.5 },
archetype: "physiological-vital",
},
SKIN_TEMPERATURE: {
id: "SKIN_TEMPERATURE",
measurementType: "SKIN_TEMPERATURE",
displayName: "Wrist skin temperature",
unit: "°C",
direction: "target-band",
archetype: "physiological-vital",
},
BLOOD_GLUCOSE: {
id: "BLOOD_GLUCOSE",
measurementType: "BLOOD_GLUCOSE",
displayName: "Blood glucose",
unit: "mg/dL",
direction: "target-band",
normalRange: { low: 70, high: 140 },
archetype: "physiological-vital",
},
WALKING_HEART_RATE_AVERAGE: {
id: "WALKING_HEART_RATE_AVERAGE",
measurementType: "WALKING_HEART_RATE_AVERAGE",
displayName: "Walking heart rate",
unit: "bpm",
direction: "lower-better",
archetype: "physiological-vital",
},
PULSE_WAVE_VELOCITY: {
id: "PULSE_WAVE_VELOCITY",
measurementType: "PULSE_WAVE_VELOCITY",
displayName: "Pulse-wave velocity",
unit: "m/s",
direction: "lower-better",
archetype: "physiological-vital",
},
VASCULAR_AGE: {
id: "VASCULAR_AGE",
measurementType: "VASCULAR_AGE",
displayName: "Vascular age",
unit: "years",
direction: "lower-better",
archetype: "physiological-vital",
},
// ── activity-fitness ──
STEPS: {
id: "STEPS",
measurementType: "ACTIVITY_STEPS",
displayName: "Steps",
unit: "steps/day",
direction: "higher-better",
normalRange: { low: 7000, high: 10000 },
archetype: "activity-fitness",
},
ACTIVE_ENERGY: {
id: "ACTIVE_ENERGY",
measurementType: "ACTIVE_ENERGY_BURNED",
displayName: "Active energy",
unit: "kcal/day",
direction: "higher-better",
archetype: "activity-fitness",
},
FLIGHTS_CLIMBED: {
id: "FLIGHTS_CLIMBED",
measurementType: "FLIGHTS_CLIMBED",
displayName: "Flights climbed",
unit: "flights/day",
direction: "higher-better",
archetype: "activity-fitness",
},
WALKING_RUNNING_DISTANCE: {
id: "WALKING_RUNNING_DISTANCE",
measurementType: "WALKING_RUNNING_DISTANCE",
displayName: "Walking + running distance",
unit: "m/day",
direction: "higher-better",
archetype: "activity-fitness",
},
TIME_IN_DAYLIGHT: {
id: "TIME_IN_DAYLIGHT",
measurementType: "TIME_IN_DAYLIGHT",
displayName: "Time in daylight",
unit: "min/day",
direction: "higher-better",
normalRange: { low: 30, high: 120 },
archetype: "activity-fitness",
},
VO2_MAX: {
id: "VO2_MAX",
measurementType: "VO2_MAX",
displayName: "VO₂ max (cardio fitness)",
unit: "mL/(kg·min)",
direction: "higher-better",
archetype: "activity-fitness",
},
// ── body-composition ──
TOTAL_BODY_WATER: {
id: "TOTAL_BODY_WATER",
measurementType: "TOTAL_BODY_WATER",
displayName: "Total body water",
unit: "kg",
direction: "target-band",
archetype: "body-composition",
},
BONE_MASS: {
id: "BONE_MASS",
measurementType: "BONE_MASS",
displayName: "Bone mass",
unit: "kg",
direction: "target-band",
archetype: "body-composition",
},
FAT_FREE_MASS: {
id: "FAT_FREE_MASS",
measurementType: "FAT_FREE_MASS",
displayName: "Fat-free mass",
unit: "kg",
direction: "target-band",
archetype: "body-composition",
},
FAT_MASS: {
id: "FAT_MASS",
measurementType: "FAT_MASS",
displayName: "Fat mass",
unit: "kg",
direction: "lower-better",
archetype: "body-composition",
},
MUSCLE_MASS: {
id: "MUSCLE_MASS",
measurementType: "MUSCLE_MASS",
displayName: "Muscle mass",
unit: "kg",
direction: "higher-better",
archetype: "body-composition",
},
LEAN_BODY_MASS: {
id: "LEAN_BODY_MASS",
measurementType: "LEAN_BODY_MASS",
displayName: "Lean body mass",
unit: "kg",
direction: "target-band",
archetype: "body-composition",
},
VISCERAL_FAT: {
id: "VISCERAL_FAT",
measurementType: "VISCERAL_FAT",
displayName: "Visceral fat rating",
unit: "rating",
direction: "lower-better",
normalRange: { low: 1, high: 12 },
archetype: "body-composition",
},
// ── mobility-gait ──
WALKING_STEADINESS: {
id: "WALKING_STEADINESS",
measurementType: "WALKING_STEADINESS",
displayName: "Walking steadiness",
unit: "%",
direction: "higher-better",
normalRange: { low: 50, high: 100 },
archetype: "mobility-gait",
},
WALKING_ASYMMETRY: {
id: "WALKING_ASYMMETRY",
measurementType: "WALKING_ASYMMETRY",
displayName: "Walking asymmetry",
unit: "%",
direction: "lower-better",
archetype: "mobility-gait",
},
WALKING_DOUBLE_SUPPORT: {
id: "WALKING_DOUBLE_SUPPORT",
measurementType: "WALKING_DOUBLE_SUPPORT",
displayName: "Double-support time",
unit: "%",
direction: "lower-better",
normalRange: { low: 20, high: 40 },
archetype: "mobility-gait",
},
WALKING_STEP_LENGTH: {
id: "WALKING_STEP_LENGTH",
measurementType: "WALKING_STEP_LENGTH",
displayName: "Step length",
unit: "m",
direction: "higher-better",
archetype: "mobility-gait",
},
WALKING_SPEED: {
id: "WALKING_SPEED",
measurementType: "WALKING_SPEED",
displayName: "Walking speed",
unit: "m/s",
direction: "higher-better",
normalRange: { low: 1.2, high: 1.4 },
archetype: "mobility-gait",
},
// ── environmental-exposure ──
AUDIO_EXPOSURE_ENV: {
id: "AUDIO_EXPOSURE_ENV",
measurementType: "AUDIO_EXPOSURE_ENV",
displayName: "Environmental sound exposure",
unit: "dBA",
direction: "lower-better",
normalRange: { low: 0, high: 80 },
archetype: "environmental-exposure",
},
AUDIO_EXPOSURE_HEADPHONE: {
id: "AUDIO_EXPOSURE_HEADPHONE",
measurementType: "AUDIO_EXPOSURE_HEADPHONE",
displayName: "Headphone audio exposure",
unit: "dBA",
direction: "lower-better",
normalRange: { low: 0, high: 80 },
archetype: "environmental-exposure",
},
AUDIO_EXPOSURE_EVENT: {
id: "AUDIO_EXPOSURE_EVENT",
measurementType: "AUDIO_EXPOSURE_EVENT",
displayName: "Loud-exposure events",
unit: "events",
direction: "lower-better",
archetype: "environmental-exposure",
},
// ── sleep (dedicated template) ──
SLEEP_DURATION: {
id: "SLEEP_DURATION",
measurementType: "SLEEP_DURATION",
// Stored in minutes (see schema MeasurementType.SLEEP_DURATION); the
// template surfaces hours for the model's prose.
displayName: "Sleep duration",
unit: "min",
direction: "target-band",
normalRange: { low: 420, high: 540 },
archetype: "sleep",
},
};
/** Closed set of ids the generic route accepts (Zod enum source). */
export const METRIC_STATUS_IDS = Object.keys(
REGISTRY,
) as MetricStatusMetricId[];
/** Type guard narrowing an arbitrary string to a registered metric id. */
export function isMetricStatusId(
value: string,
): value is MetricStatusMetricId {
return Object.prototype.hasOwnProperty.call(REGISTRY, value);
}
/** Resolve the metadata for a metric id, or null when unregistered. */
export function getMetricStatusMeta(
metric: string,
): MetricStatusMeta | null {
return isMetricStatusId(metric) ? REGISTRY[metric] : null;
}
/**
* Reverse index: the DB `MeasurementType` → the generic metric id it backs
* (built once at module load). A handful of types map under a different id
* than their literal name (`ACTIVITY_STEPS` → `STEPS`,
* `ACTIVE_ENERGY_BURNED` → `ACTIVE_ENERGY`); every other registered type
* is its own id. Types with no registry entry (the seven specialised
* metrics, WORKOUTS, …) are simply absent.
*/
const MEASUREMENT_TYPE_TO_METRIC_ID = new Map<
MeasurementType,
MetricStatusMetricId
>(
(Object.values(REGISTRY) as MetricStatusMeta[]).map((meta) => [
meta.measurementType,
meta.id,
]),
);
/**
* Resolve the generic metric id a `MeasurementType` feeds, or null when
* the type carries no generic assessment card. Used by the ingest
* invalidation path to re-warm the matching `metric:<ID>` scope when a
* fresh measurement of a data-bearing type lands.
*/
export function metricIdForMeasurementType(
type: MeasurementType,
): MetricStatusMetricId | null {
return MEASUREMENT_TYPE_TO_METRIC_ID.get(type) ?? null;
}
/**
* The cache/scope id for a metric assessment. The `metric:` prefix keeps
* the generic scopes disjoint from the seven bare specialised scope
* slugs while the trailing `-status.` substring (appended at cache-key
* time, like every other scope) keeps the eviction + invalidation
* sweeps matching. Example cache action:
* `insights.metric:RESTING_HEART_RATE-status.de`.
*/
export function metricStatusScope(
metric: MetricStatusMetricId,
): `metric:${MetricStatusMetricId}` {
return `metric:${metric}`;
}
+351
View File
@@ -0,0 +1,351 @@
/**
* v1.8.7.1 — generic per-HealthKit-metric assessment generator.
*
* The seven specialised generators (`pulse-status.ts`, …) each hand-roll
* a metric-specific snapshot + prompt. This one generator covers every
* registered HealthKit metric (`metric-status-registry.ts`) through the
* archetype prompt templates (`prompts/metric-archetypes.ts`), reusing
* the exact same machinery the specialised cards use:
* - the graded-series snapshot builder (`buildGradedSeriesWithRollups`),
* - the shared provider runner (`runStatusCompletion`),
* - the stale-while-revalidate cache (`readFreshStatusText` /
* `resolveReadOnlyStatusMiss`),
* - the standard cache-row persist (`persistStatusInsight`),
* - the previous-context comparison block (`getPreviousInsightContext`).
*
* The return shape is byte-identical to the specialised generators so
* `InsightStatusCard` consumes it unchanged.
*
* Empty-data guard (design §3): when the metric has no readings the
* generator returns an `insufficient` marker WITHOUT touching the
* provider chain — no LLM call, no cache write. The route maps that onto
* the card's insufficient-data state.
*/
import { prisma } from "@/lib/db";
import {
getMetricArchetypeSystemPrompt,
getMetricArchetypeUserPrompt,
} from "@/lib/ai/prompts/metric-archetypes";
import {
getMetricStatusMeta,
metricStatusScope,
type MetricStatusMeta,
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
export interface MetricStatusResult {
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/**
* v1.8.7.1 — true when the metric has no data at all. The route maps
* this onto the card's insufficient-data state; no LLM was called.
*/
insufficient?: boolean;
}
/** Count this metric's live (non-deleted) readings — the empty-data gate. */
async function countMetricReadings(
userId: string,
meta: MetricStatusMeta,
): Promise<number> {
return prisma.measurement.count({
where: { userId, type: meta.measurementType, deletedAt: null },
});
}
/**
* Generate (or read from cache) the generic assessment for one metric.
* Mirrors the specialised generators' contract:
* - cache hit (today, non-stub) → serve it,
* - `readOnly` miss → enqueue out-of-band + serve last-good
* (stale-while-revalidate),
* - empty data → `insufficient`, no LLM call,
* - otherwise → build snapshot, run completion, persist.
*/
export async function generateMetricStatus(args: {
metric: MetricStatusMetricId;
userId: string;
locale?: string | null;
force?: boolean;
/** Read-only navigation path — never blocks on the provider. */
readOnly?: boolean;
}): Promise<MetricStatusResult> {
const meta = getMetricStatusMeta(args.metric);
if (!meta) {
// Defensive — the route Zod-validates against the registry, so an
// unknown id never reaches here. Treat as insufficient rather than
// throwing so a stale caller degrades gracefully.
return {
hasProvider: false,
text: null,
cached: false,
updatedAt: null,
insufficient: true,
};
}
const locale = normalizeLocale(args.locale);
const force = args.force === true;
const readOnly = args.readOnly === true;
const scope = metricStatusScope(meta.id);
const cacheAction = `insights.${scope}-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
userId: args.userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
// Empty-data guard — no readings means no assessment to generate. Done
// BEFORE the read-only enqueue so an empty metric never queues an LLM
// job nor blocks on the provider. The nightly warm pass applies the same
// gate so the cron never burns a call on an empty metric either.
const readingCount = await countMetricReadings(args.userId, meta);
if (readingCount === 0) {
annotate({
action: { name: "insights.metric-status.insufficient" },
meta: { metric: meta.id },
});
return {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
insufficient: true,
};
}
if (readOnly) {
const outcome = await resolveReadOnlyStatusMiss({
userId: args.userId,
metric: scope,
locale,
});
if (outcome.kind === "no-provider") {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
};
}
// Stale-while-revalidate: serve the last good assessment (if any)
// instantly while the worker re-warms the cache; only fall to the
// empty preparing skeleton when none was ever produced.
return {
hasProvider: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
};
}
const user = await prisma.user.findUnique({
where: { id: args.userId },
select: { dateOfBirth: true, gender: true },
});
const now = new Date();
// Bounded raw read for the derived recent stats; the monthly/yearly
// tail comes from the rollup tier (full-history fallback on a miss),
// exactly as the specialised cards do.
const measurements = await prisma.measurement
.findMany({
where: { userId: args.userId, type: meta.measurementType, deletedAt: null },
orderBy: { measuredAt: "desc" },
take: 365,
select: { value: true, measuredAt: true },
})
.then((rows) => rows.reverse());
const points = measurements.map((m) => ({
measuredAt: m.measuredAt,
value: m.value,
}));
const series = applyPayloadBudget(points, { now });
const graded = await buildGradedSeriesWithRollups(
args.userId,
meta.measurementType,
now,
);
const summary = summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
);
const latest = series.daily[0] ?? null;
const oldest = measurements.length > 0 ? measurements[0].measuredAt : null;
const newest =
measurements.length > 0
? measurements[measurements.length - 1].measuredAt
: null;
const totalSpanDays =
oldest && newest
? Math.round(
(newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000),
)
: 0;
const newestDaysAgo = newest
? Math.round((Date.now() - newest.getTime()) / (24 * 60 * 60 * 1000))
: null;
const snapshot = {
locale,
generatedForDay: todayKey,
focus: meta.id,
metric: {
id: meta.id,
displayName: meta.displayName,
unit: meta.unit,
direction: meta.direction,
...(meta.normalRange ? { normalRange: meta.normalRange } : {}),
},
// Profile context for placement only when stored — the medical note
// limits population ranges to age + sex (NO ancestry/region). Omitted
// when the profile leaves them blank, so the model leans on baseline.
profile: {
ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null),
sex: user?.gender ?? null,
},
dataCoverage: {
totalMeasurements: measurements.length,
totalSpanDays,
newestMeasurementDaysAgo: newestDaysAgo,
},
[meta.id]: {
summary,
series: graded,
latestDayFocus: latest
? { dayOffset: latest.dayOffset, value: latest.value }
: null,
},
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
args.userId,
// memory.ts keys previous context by the `insights.<scope>.<locale>`
// action; the generic scope (`metric:<ID>-status`) carries the same
// `-status` suffix so the comparison row reads back correctly. The
// `getPreviousInsightContext` param is typed to accept this scope form
// (`PreviousContextScope`), so the argument type-checks honestly.
`${scope}-status`,
locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
locale,
);
const outcome = await runStatusCompletion({
userId: args.userId,
cacheAction,
systemPrompt: getMetricArchetypeSystemPrompt(meta, locale),
userPrompt: getMetricArchetypeUserPrompt(
meta,
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
userId: args.userId,
todayKey,
stubText: getNoKeyGeneralStatusText(locale),
});
}
const text = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!text) {
throw new Error(`Metric-status summary was empty for ${meta.id}`);
}
const updatedAt = await persistStatusInsight({
userId: args.userId,
cacheAction,
todayKey,
locale,
text,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return { hasProvider: true, text, cached: false, updatedAt };
}
export function resolveMetricStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { prisma } from "@/lib/db";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
import {
enqueueStatusGeneration,
type InsightStatusMetric,
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { annotate } from "@/lib/logging/context";
@@ -176,7 +176,7 @@ export type ReadOnlyMissOutcome =
*/
export async function resolveReadOnlyStatusMiss(args: {
userId: string;
metric: InsightStatusMetric;
metric: InsightStatusScope;
locale: "de" | "en";
}): Promise<ReadOnlyMissOutcome> {
const hasProvider = await hasUsableStatusProvider(args.userId);
@@ -51,9 +51,21 @@ vi.mock("@/lib/insights/medication-compliance-status", () => ({
vi.mock("@/lib/insights/general-status", () => ({
generateGeneralStatusForUser: vi.fn(),
}));
// v1.8.7.1 — the generic metric generator also imports the provider
// chain transitively; stub it so the default generic warm pass never
// touches a live provider when the test does not inject its own.
vi.mock("@/lib/insights/metric-status", () => ({
generateMetricStatus: vi.fn(async () => ({
hasProvider: true,
text: "ok",
cached: false,
updatedAt: new Date().toISOString(),
})),
}));
import {
runInsightPregenerate,
forceWarmUser,
findPregenerateCandidates,
PREGENERATE_STALE_MS,
INSIGHT_PREGENERATE_QUEUE,
@@ -321,6 +333,129 @@ describe("runInsightPregenerate — per-metric warm pass", () => {
});
});
describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => {
it("warms the generic metric caches after a successful comprehensive generation", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "generated", providerType: "x" });
// Stub the seven specialised generators so only the generic count
// is under test.
const statusGenerators = Array.from({ length: 7 }, () =>
vi.fn().mockResolvedValue({ hasProvider: true, cached: true }),
);
const warmGenericMetrics = vi.fn().mockResolvedValue(9);
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
warmGenericMetrics,
});
expect(warmGenericMetrics).toHaveBeenCalledTimes(1);
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"]);
expect(result.metricAssessmentsWarmed).toBe(9);
});
it("does NOT warm the generic metric caches when the comprehensive pass skipped (no provider)", async () => {
const { prisma } = makePrisma([{ id: "u1", locale: "en" }]);
const generate = vi
.fn()
.mockResolvedValue({ status: "skipped", reason: "no-provider" });
const statusGenerators = Array.from({ length: 7 }, () =>
vi.fn().mockResolvedValue({ hasProvider: false, cached: true }),
);
const warmGenericMetrics = vi.fn().mockResolvedValue(9);
const result = await runInsightPregenerate(prisma as never, {
generate,
statusGenerators,
warmGenericMetrics,
});
expect(warmGenericMetrics).not.toHaveBeenCalled();
expect(result.metricAssessmentsWarmed).toBe(0);
});
});
describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
it("warms ONLY the active locale, bypasses the budget gate, and tallies counts", async () => {
const { prisma } = makePrisma([]);
const generate = vi
.fn()
.mockResolvedValue({ status: "generated", providerType: "x" });
const statusGenerators = Array.from({ length: 7 }, () =>
vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
);
const warmGenericMetrics = vi.fn().mockResolvedValue(5);
const result = await forceWarmUser(prisma as never, "u1", "en", {
generate,
statusGenerators,
warmGenericMetrics,
});
// Comprehensive forced once, in the active locale only.
expect(generate).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledWith("u1", { locale: "en", force: true });
// Each specialised generator forced exactly once — the active locale,
// NOT both de+en like the nightly cron.
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true });
}
// Generic warm pass runs for the single active locale.
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["en"]);
// No 20 h budget bucket consulted on the forced path.
expect(checkRateLimit).not.toHaveBeenCalled();
expect(result).toMatchObject({
comprehensive: "generated",
assessmentsWarmed: 7,
metricAssessmentsWarmed: 5,
});
});
it("does NOT warm the per-status or generic caches when the comprehensive pass skipped (no provider)", async () => {
const { prisma } = makePrisma([]);
const generate = vi
.fn()
.mockResolvedValue({ status: "skipped", reason: "no-provider" });
const statusGenerators = Array.from({ length: 7 }, () =>
vi.fn().mockResolvedValue({ hasProvider: false, cached: true }),
);
const warmGenericMetrics = vi.fn().mockResolvedValue(5);
const result = await forceWarmUser(prisma as never, "u1", "de", {
generate,
statusGenerators,
warmGenericMetrics,
});
for (const g of statusGenerators) {
expect(g).not.toHaveBeenCalled();
}
expect(warmGenericMetrics).not.toHaveBeenCalled();
expect(result).toMatchObject({
comprehensive: "skipped",
assessmentsWarmed: 0,
metricAssessmentsWarmed: 0,
});
});
it("short-circuits to a no-op when the briefing surface is disabled globally", async () => {
getAssistantFlags.mockResolvedValue({ enabled: false, briefing: false });
const { prisma } = makePrisma([]);
const generate = vi.fn();
const result = await forceWarmUser(prisma as never, "u1", "de", {
generate,
});
expect(generate).not.toHaveBeenCalled();
expect(result.comprehensive).toBe("skipped");
});
});
describe("queue registration", () => {
const workerSrc = fs.readFileSync(
path.resolve(__dirname, "../reminder-worker.ts"),
@@ -0,0 +1,80 @@
/**
* v1.8.7.1 — generator-free contract for the insight-pregenerate queue.
*
* The on-demand warm route (`POST /api/insights/pregenerate`) enqueues a
* forced full-warm job here without importing the concrete generators
* (which would pull the whole `comprehensive-generate` + seven `*-status`
* tree into the route bundle and risk an import cycle). The worker-only
* pipeline (`runInsightPregenerate`, `forceWarmUser`) lives in
* `insight-pregenerate.ts`, which re-exports the queue name from here so
* there is a single source of truth.
*
* Mirrors the `insight-status-generate-shared.ts` split exactly: queue
* name + payload type + enqueue helper in the generator-free module, the
* concrete dispatch in the worker module.
*/
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate";
export interface InsightPregeneratePayload {
/** Set on the nightly scheduled tick (informational; the handler keys
* its batch behaviour off the ABSENCE of `userId`/`force`). */
triggeredAt?: string;
/**
* Forced single-user warm. When `force` is set the worker runs the
* full per-user warm for `userId` directly, bypassing the candidate
* scan AND the per-user 20 h budget bucket.
*/
userId?: string;
force?: boolean;
/** Locale to warm on the forced path; defaults to "de" when absent. */
locale?: "de" | "en";
}
/**
* Fire-and-forget enqueue used by the on-demand warm route. A
* `singletonKey` per `(user, locale)` collapses repeated requests within
* a short window into one queued job, so a client that taps the button
* twice (or the auto-trigger races a manual tap) can't double-warm. No-ops
* cleanly when the global boss instance is not available (e.g. a web
* process without an embedded worker) — the nightly cron remains the
* catch-net.
*/
export async function enqueueForceWarm(payload: {
userId: string;
locale: "de" | "en";
}): Promise<void> {
const boss = getGlobalBoss();
if (!boss) {
annotate({
action: { name: "insights.pregenerate.force.no_boss" },
meta: { locale: payload.locale },
});
return;
}
try {
await boss.send(
INSIGHT_PREGENERATE_QUEUE,
{
userId: payload.userId,
force: true,
locale: payload.locale,
} satisfies InsightPregeneratePayload,
{
singletonKey: `force:${payload.userId}:${payload.locale}`,
// De-dupe within a 2-minute window so a double tap or an
// auto-trigger racing a manual tap enqueues one warm, not two.
singletonSeconds: 120,
},
);
annotate({
action: { name: "insights.pregenerate.force.enqueued" },
meta: { locale: payload.locale },
});
} catch {
// Enqueue is best-effort; a failure here just means the caches stay
// cold until the next poll / nightly cron warms them.
}
}
+214 -3
View File
@@ -62,8 +62,23 @@ import { generateBmiStatusForUser } from "@/lib/insights/bmi-status";
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
import { generateGeneralStatusForUser } from "@/lib/insights/general-status";
import { generateMetricStatus } from "@/lib/insights/metric-status";
import {
METRIC_STATUS_IDS,
getMetricStatusMeta,
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
import {
INSIGHT_PREGENERATE_QUEUE,
enqueueForceWarm,
type InsightPregeneratePayload,
} from "@/lib/jobs/insight-pregenerate-shared";
export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate";
export {
INSIGHT_PREGENERATE_QUEUE,
enqueueForceWarm,
};
export type { InsightPregeneratePayload };
/**
* 04:30 Europe/Berlin. Slots between the cumulative drain (03:30) and
@@ -94,8 +109,14 @@ const PREGENERATE_BUDGET_WINDOW_MS = 20 * 60 * 60 * 1000;
*/
const WARM_PASS_CONCURRENCY = 3;
export interface InsightPregeneratePayload {
triggeredAt: string;
/** Per-user result of one forced full warm. */
export interface ForceWarmResult {
/** Outcome of the comprehensive-insight generation. */
comprehensive: GenerateOutcome["status"];
/** Count of specialised per-status assessments written warm. */
assessmentsWarmed: number;
/** Count of generic per-HealthKit-metric assessments written warm. */
metricAssessmentsWarmed: number;
}
export interface PregenerateRunResult {
@@ -114,6 +135,14 @@ export interface PregenerateRunResult {
* one live status generation per card. This counts the warm writes.
*/
assessmentsWarmed: number;
/**
* v1.8.7.1 — count of generic per-HealthKit-metric assessment caches
* written warm across the run (the ~30 metric pages, data-bearing
* only). Tracked separately from `assessmentsWarmed` so the nightly
* dashboard can see the generic-tier coverage independently of the
* seven specialised cards.
*/
metricAssessmentsWarmed: number;
}
/**
@@ -186,6 +215,76 @@ async function warmPerStatusCaches(
return results.reduce((sum: number, n) => sum + n, 0);
}
/**
* v1.8.7.1 — warm the generic per-HealthKit-metric assessment caches for
* one user, but ONLY for metrics that have data. A single grouped count
* answers "which metric types does this user have readings for?" in one
* query, so the cron never burns a (cheap) per-metric count nor (costly)
* an LLM call on a metric the user has never logged. The generic
* generator's own empty-data guard is the second line of defence, but the
* up-front filter keeps the cron from forcing ~30 metric generators per
* user when most have no data.
*
* Returns the count of fresh provider-backed assessments written.
*/
async function warmGenericMetricCaches(
prisma: PrismaClient,
userId: string,
locales: ReadonlyArray<"de" | "en">,
): Promise<number> {
// One distinct read: the set of MeasurementTypes the user has live
// rows for. Best-effort — a read failure (or a worker prisma surface
// without `measurement`) yields zero metrics rather than aborting the
// user's whole pre-generation loop.
let rows: Array<{ type: string }>;
try {
rows = await prisma.measurement.findMany({
where: { userId, deletedAt: null },
distinct: ["type"],
select: { type: true },
});
} catch {
return 0;
}
const typesWithData = new Set(rows.map((r) => r.type));
const metricsWithData = METRIC_STATUS_IDS.filter(
(id: MetricStatusMetricId) => {
const meta = getMetricStatusMeta(id);
return meta !== null && typesWithData.has(meta.measurementType);
},
);
if (metricsWithData.length === 0) return 0;
const limit = pLimit(WARM_PASS_CONCURRENCY);
const tasks = metricsWithData.flatMap((metric) =>
locales.map((locale) =>
limit(async () => {
try {
const outcome = await generateMetricStatus({
metric,
userId,
locale,
force: true,
});
// `insufficient` never reaches here (we pre-filtered to
// metrics with data), but guard anyway: count only a fresh
// provider-backed assessment.
return outcome.hasProvider &&
!outcome.cached &&
outcome.insufficient !== true
? 1
: 0;
} catch {
return 0;
}
}),
),
);
const results = await Promise.all(tasks);
return results.reduce((sum: number, n) => sum + n, 0);
}
interface PregenerateCandidate {
id: string;
locale: string | null;
@@ -242,6 +341,15 @@ export async function runInsightPregenerate(
) => Promise<GenerateOutcome>;
/** Injected for the test — defaults to the seven real status generators. */
statusGenerators?: ReadonlyArray<StatusGenerator>;
/**
* v1.8.7.1 — injected for the test. Defaults to the real generic
* metric warm pass; the test stubs it to assert it runs only for
* data-bearing metrics without a live LLM.
*/
warmGenericMetrics?: (
userId: string,
locales: ReadonlyArray<"de" | "en">,
) => Promise<number>;
} = {},
): Promise<PregenerateRunResult> {
const now = options.now ?? new Date();
@@ -249,6 +357,10 @@ export async function runInsightPregenerate(
const generate = options.generate ?? generateComprehensiveInsight;
const statusGenerators =
options.statusGenerators ?? DEFAULT_STATUS_GENERATORS;
const warmGenericMetrics =
options.warmGenericMetrics ??
((userId: string, locales: ReadonlyArray<"de" | "en">) =>
warmGenericMetricCaches(prisma, userId, locales));
const result: PregenerateRunResult = {
total: 0,
@@ -258,6 +370,7 @@ export async function runInsightPregenerate(
failed: 0,
budgetBlocked: 0,
assessmentsWarmed: 0,
metricAssessmentsWarmed: 0,
};
// Master kill-switch — when the operator disabled the briefing
@@ -329,6 +442,13 @@ export async function runInsightPregenerate(
["de", "en"],
statusGenerators,
);
// v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for
// the user's data-bearing metrics only (the helper filters via one
// grouped count, so an empty metric never reaches the provider).
result.metricAssessmentsWarmed += await warmGenericMetrics(
candidate.id,
["de", "en"],
);
}
}
@@ -344,6 +464,97 @@ export async function runInsightPregenerate(
failed: result.failed,
budget_blocked: result.budgetBlocked,
assessments_warmed: result.assessmentsWarmed,
metric_assessments_warmed: result.metricAssessmentsWarmed,
},
});
return result;
}
/**
* v1.8.7.1 — forced full warm for ONE user, on demand.
*
* Runs the same pipeline the nightly tick runs per candidate — the
* comprehensive insight (which carries the daily briefing), the seven
* specialised `*-status` assessments, and every data-bearing generic
* `metric:<ID>` assessment — but for a single explicit `userId` and
* WITHOUT the per-user 20 h budget bucket. The endpoint that enqueues this
* job carries its own short anti-spam rate-limit (one warm per few
* minutes), so the forced path can skip the nightly budget while staying
* bounded against abuse.
*
* Safety is preserved by the helpers it reuses, not by a budget gate:
* - The comprehensive generator no-ops to `{ status: "skipped" }` when
* no provider is configured (no LLM call).
* - `warmGenericMetricCaches` filters to metrics the user has rows for
* via one grouped read, so an empty metric never reaches the provider.
* - Each generator forces a fresh write but is idempotent — re-running
* it just rewrites the same cache row; concurrent enqueues collapse via
* the queue `singletonKey`.
*
* Unlike the nightly cron (de+en), the forced path warms only the caller's
* active `locale` so a single-locale user pays half the provider cost. A
* `failed`/`skipped` comprehensive outcome short-circuits the per-status
* warm exactly as the cron does — re-running seven (then ~30) generators
* against an unhealthy or provider-less chain would only multiply the
* failure.
*/
export async function forceWarmUser(
prisma: PrismaClient,
userId: string,
locale: "de" | "en",
options: {
generate?: (
userId: string,
opts: { locale: "de" | "en"; force?: boolean },
) => Promise<GenerateOutcome>;
statusGenerators?: ReadonlyArray<StatusGenerator>;
warmGenericMetrics?: (
userId: string,
locales: ReadonlyArray<"de" | "en">,
) => Promise<number>;
} = {},
): Promise<ForceWarmResult> {
const generate = options.generate ?? generateComprehensiveInsight;
const statusGenerators =
options.statusGenerators ?? DEFAULT_STATUS_GENERATORS;
const warmGenericMetrics =
options.warmGenericMetrics ??
((id: string, locales: ReadonlyArray<"de" | "en">) =>
warmGenericMetricCaches(prisma, id, locales));
const result: ForceWarmResult = {
comprehensive: "skipped",
assessmentsWarmed: 0,
metricAssessmentsWarmed: 0,
};
// Master kill-switch — when the operator disabled the briefing surface
// globally there is nothing to warm, on demand or otherwise.
const flags = await getAssistantFlags();
if (!flags.briefing) return result;
const locales: ReadonlyArray<"de" | "en"> = [locale];
const outcome = await generate(userId, { locale, force: true });
result.comprehensive = outcome.status;
if (outcome.status === "generated" || outcome.status === "cached") {
result.assessmentsWarmed = await warmPerStatusCaches(
userId,
locales,
statusGenerators,
);
result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales);
}
annotate({
action: { name: "insights.pregenerate.force" },
meta: {
locale,
comprehensive: result.comprehensive,
assessments_warmed: result.assessmentsWarmed,
metric_assessments_warmed: result.metricAssessmentsWarmed,
},
});
+18 -1
View File
@@ -46,9 +46,26 @@ export const INSIGHT_STATUS_METRICS: ReadonlyArray<InsightStatusMetric> = [
"medication-compliance",
];
/**
* v1.8.7.1 — the generic per-HealthKit-metric assessment scopes ride the
* same on-demand queue. Their scope id is `metric:<METRIC_ID>` (see
* `metricStatusScope` in the registry), which the worker dispatch routes
* to the generic generator rather than one of the seven specialised
* ones. The queue payload, the read-only resolver, and the enqueue helper
* all accept this broader scope so a generic card warms exactly like a
* specialised one. A specialised scope is one of `InsightStatusMetric`;
* a generic scope is any `metric:`-prefixed string.
*/
export type InsightStatusScope = InsightStatusMetric | `metric:${string}`;
export interface InsightStatusGeneratePayload {
userId: string;
metric: InsightStatusMetric;
/**
* The scope to generate. A specialised scope (one of the seven) or a
* generic `metric:<METRIC_ID>` scope (v1.8.7.1). The worker dispatch
* routes the prefix to the right generator.
*/
metric: InsightStatusScope;
/** Resolved client locale — the cache key the route reads against. */
locale: "de" | "en";
}
+26 -1
View File
@@ -34,6 +34,8 @@ import { generatePulseStatusForUser } from "@/lib/insights/pulse-status";
import { generateBmiStatusForUser } from "@/lib/insights/bmi-status";
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
import { generateMetricStatus } from "@/lib/insights/metric-status";
import { isMetricStatusId } from "@/lib/insights/metric-status-registry";
import {
INSIGHT_STATUS_GENERATE_QUEUE,
INSIGHT_STATUS_GENERATE_CONCURRENCY,
@@ -80,7 +82,30 @@ export async function runInsightStatusGenerate(
payload: InsightStatusGeneratePayload,
generators: Record<InsightStatusMetric, StatusGenerator> = GENERATORS,
): Promise<void> {
const generate = generators[payload.metric];
// v1.8.7.1 — a `metric:<METRIC_ID>` scope routes to the generic
// HealthKit-metric generator rather than one of the seven specialised
// ones. The generic generator applies its own empty-data guard, so a
// job enqueued for a metric that has since lost its data costs only a
// cheap count, not an LLM call.
if (payload.metric.startsWith("metric:")) {
const metricId = payload.metric.slice("metric:".length);
if (!isMetricStatusId(metricId)) {
annotate({
action: { name: "insights.status.generate.unknown_metric" },
meta: { metric: payload.metric },
});
return;
}
await generateMetricStatus({
metric: metricId,
userId: payload.userId,
locale: payload.locale,
force: true,
});
return;
}
const generate = generators[payload.metric as InsightStatusMetric];
if (!generate) {
annotate({
action: { name: "insights.status.generate.unknown_metric" },
+27 -1
View File
@@ -65,6 +65,7 @@ import {
INSIGHT_PREGENERATE_QUEUE,
INSIGHT_PREGENERATE_CRON,
runInsightPregenerate,
forceWarmUser,
type InsightPregeneratePayload,
} from "@/lib/jobs/insight-pregenerate";
import {
@@ -1692,8 +1693,33 @@ async function handleMedicationInventoryExpire(
async function handleInsightPregenerateJob(
jobs: Job<InsightPregeneratePayload>[],
) {
void jobs;
await withBackgroundEvent("job.insight_pregenerate", async (evt) => {
// v1.8.7.1 — a forced single-user warm carries `{ userId, force }`;
// the scheduled tick carries neither. Route each job individually so a
// batch that mixes a cron tick with on-demand warms (it never does in
// practice, but the contract is per-job) stays correct.
const forced = jobs.filter((j) => j.data?.force && j.data?.userId);
const scheduled = jobs.filter((j) => !(j.data?.force && j.data?.userId));
for (const job of forced) {
const userId = job.data.userId as string;
const locale = job.data.locale === "en" ? "en" : "de";
try {
const summary = await forceWarmUser(getWorkerPrisma(), userId, locale);
evt.addMeta(
"force_warm",
`${summary.comprehensive}:${summary.assessmentsWarmed}+${summary.metricAssessmentsWarmed}`,
);
} catch (err) {
evt.addWarning(
`insight-pregenerate force-warm failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
if (scheduled.length === 0) return;
try {
const summary = await runInsightPregenerate(getWorkerPrisma());
evt.setBackground({
+136
View File
@@ -46,6 +46,7 @@ import {
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
/**
* Common envelopes — every HealthLog API response wraps payload in
@@ -1097,6 +1098,87 @@ const insightsComprehensiveResponse = z
"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.
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" });
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.",
),
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.",
});
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.",
});
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,
@@ -2350,6 +2432,60 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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/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,
},
},
},
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+19
View File
@@ -103,6 +103,25 @@ export const queryKeys = {
["insights", "mood-status", locale] as const,
insightsMedicationComplianceStatus: (locale: string) =>
["insights", "medication-compliance-status", locale] as const,
/**
* v1.8.7.1 — per-metric insight assessment for the HealthKit metric
* sub-pages. One generic route (`/api/insights/metric-status?metric=…`)
* backs every HealthKit metric, so the key is parameterised by the
* metric id alongside the locale rather than minting a bespoke factory
* entry per metric. The seven bespoke `*-status` routes above keep
* their own keys; this one covers the ~29 generic HealthKit pages.
*/
insightsMetricStatus: (metric: string, locale: string) =>
["insights", "metric-status", metric, locale] as const,
/**
* v1.8.7.1 — mutationKey for the on-demand full-warm POST
* (`/api/insights/pregenerate`). Lives in the factory so the bare-array
* ESLint rule stays satisfied; the warm enqueues every assessment
* generation on the worker, so there is no read cache to invalidate
* here — the existing status queries pick up the warmed text via their
* own stale-while-revalidate.
*/
insightsPregenerate: () => ["insights", "pregenerate"] as const,
medications: () => ["medications"] as const,
medicationDetail: (id: string) => ["medications", id] as const,