diff --git a/CHANGELOG.md b/CHANGELOG.md index 9592bf143..b9dd9de30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cd0f537f7..2e4cf533e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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:`, 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 diff --git a/messages/de.json b/messages/de.json index dd3303220..b8f1a6c9f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 94ec0d550..ca23e5853 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 4953bba1d..b34fa799b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index d79dcf777..cf4ceb691 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 528df33c0..175434875 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 1f43336f1..22b4b7bc5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/package.json b/package.json index a9f9048de..4d89906d8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 669e476c7..f64973294 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -40,6 +40,9 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ "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", diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts index ac014f293..42ac6ef5d 100644 --- a/src/app/api/insights/layout/__tests__/route.test.ts +++ b/src/app/api/insights/layout/__tests__/route.test.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: [] })); diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts new file mode 100644 index 000000000..45626b4a1 --- /dev/null +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -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; +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"); + }); +}); diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts new file mode 100644 index 000000000..3887c1eca --- /dev/null +++ b/src/app/api/insights/metric-status/route.ts @@ -0,0 +1,86 @@ +/** + * v1.8.7.1 — generic per-HealthKit-metric assessment route. + * + * `GET /api/insights/metric-status?metric=` 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); +}); diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts new file mode 100644 index 000000000..a18936cda --- /dev/null +++ b/src/app/api/insights/pregenerate/route.ts @@ -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:` 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 }); +}); diff --git a/src/app/insights/active-energy/page.tsx b/src/app/insights/active-energy/page.tsx index 1fcf9dc01..b5b126fe8 100644 --- a/src/app/insights/active-energy/page.tsx +++ b/src/app/insights/active-energy/page.tsx @@ -17,6 +17,7 @@ export default function InsightsActiveEnergyPage() { return ( 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} /> +
+ +
+ {flags.briefing && ( {/* - v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all - mount `` 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 `` + 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. */} + } + enabled={!isEmpty} + /> ); } diff --git a/src/app/insights/step-length/page.tsx b/src/app/insights/step-length/page.tsx index 31ac8be07..56c3a62d0 100644 --- a/src/app/insights/step-length/page.tsx +++ b/src/app/insights/step-length/page.tsx @@ -16,6 +16,7 @@ export default function InsightsSchrittlaengePage() { return ( + +