diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e85dfc98..3aea05deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.19.2] — 2026-06-21 — Recovery-gap symptom signal, resilience tile, long-range charts, richer device + Telegram data + +A follow-up that folds the illness journal's own symptom curve into the recovery-gap, surfaces Oura resilience, fixes multi-year chart ranges, and widens the heart-rate and Telegram data paths. Two additive migrations (`0188`, `0189`); no breaking changes. + +### Added + +- The recovery-gap now reads the illness journal's own daily functional-impact curve (and linked symptom severity), not just passive vitals. When symptoms lead the recovery, the card names them — "you tend to feel better before your logged symptoms ease". Sparse journals withhold rather than guess. +- An Oura resilience tile on the recovery surface, showing the latest resilience band with a quiet above/in-line/below-average cue and a calm learning state until enough days are recorded. +- Hourly heart-rate uploads can carry a per-bucket low and high alongside the average, so a high-frequency client no longer loses the within-hour spread. +- A numeric reply to a Telegram measurement reminder is captured as a reading (for example a weight or a glucose value), bound to the linked chat and self-cleaning like the mood path. Blood pressure stays a simple "done" for now, since it needs two values. + +### Changed + +- A multi-year "All" chart range now renders the whole history, stepped up to a weekly or monthly tier, instead of being silently truncated to the most recent year. + +### Fixed + +- `telegram_chat_id` is now unique, with a one-time clean-up of any stale duplicate bindings (no account is removed). +- Migration `0187` is rerun-safe. +- The sleep peer-card comment no longer overstates window parity; each card discloses its own night count. Dead streaming state left after the reasoning-disclosure removal is gone. + ## [1.19.1] — 2026-06-21 — Coach flow, sleep and mood polish, navigation, recovery-gap A follow-up to 1.19.0 that refines the Coach conversation flow and cost, adds a sleep average card, completes the mood colour pass, tidies navigation, and sharpens the illness recovery-gap. No schema changes; no breaking changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3fe9cd882..255762cab 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.19.1 + version: 1.19.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1744,6 +1744,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM - in: query name: valueMin schema: @@ -2238,6 +2239,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM externalId: type: string maxLength: 128 @@ -3355,6 +3357,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -3371,6 +3374,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -3387,6 +3391,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -3403,6 +3408,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -3419,6 +3425,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -3435,6 +3442,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -3451,6 +3459,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -3467,6 +3476,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -3483,6 +3493,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -3499,6 +3510,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -3515,6 +3527,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -3531,6 +3544,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -3547,6 +3561,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -3563,6 +3578,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -3579,6 +3595,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -3595,6 +3612,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -3611,6 +3629,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -3627,6 +3646,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM metricPriority: type: object properties: @@ -3646,6 +3666,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -3662,6 +3683,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -3678,6 +3700,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -3694,6 +3717,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -3710,6 +3734,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -3726,6 +3751,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -3742,6 +3768,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -3758,6 +3785,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -3774,6 +3802,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -3790,6 +3819,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -3806,6 +3836,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -3822,6 +3853,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -3838,6 +3870,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -3854,6 +3887,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -3870,6 +3904,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -3886,6 +3921,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -3902,6 +3938,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -3918,6 +3955,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM deviceTypePriority: type: object properties: @@ -7213,8 +7251,18 @@ components: value: type: number description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an - hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm — v1.19.0 ships avg-only; min/max - are a documented fast-follow. + hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides + `valueMin` / `valueMax`. + valueMin: + description: "v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`). + Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) + on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract." + type: number + valueMax: + description: "v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`). + Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) + on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract." + type: number unit: type: string minLength: 1 @@ -10553,6 +10601,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM notes: anyOf: - type: string @@ -11096,6 +11145,20 @@ components: additionalProperties: type: number - type: "null" + valueMin: + description: "v1.19.2 (iOS #34 extension) — per-point MINIMUM for `kind=pulse`. On an aggregated hourly heart-rate + bucket `value` is the hour's average and this is the hour's low; null on a per-sample PULSE row and + absent for every other kind." + anyOf: + - type: number + - type: "null" + valueMax: + description: "v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate + bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and + absent for every other kind." + anyOf: + - type: number + - type: "null" required: - id - at @@ -11261,6 +11324,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM notes: anyOf: - type: string @@ -11503,6 +11567,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM - type: "null" start: type: string @@ -11629,6 +11694,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM - type: "null" start: type: string @@ -12001,6 +12067,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM externalId: anyOf: - type: string @@ -12109,6 +12176,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM externalId: anyOf: - type: string @@ -14315,6 +14383,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -14331,6 +14400,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -14347,6 +14417,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -14363,6 +14434,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -14379,6 +14451,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -14395,6 +14468,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -14411,6 +14485,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -14427,6 +14502,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -14443,6 +14519,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -14459,6 +14536,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -14475,6 +14553,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -14491,6 +14570,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -14507,6 +14587,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -14523,6 +14604,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -14539,6 +14621,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -14555,6 +14638,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -14571,6 +14655,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -14587,6 +14672,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM metricPriority: type: object properties: @@ -14606,6 +14692,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -14622,6 +14709,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -14638,6 +14726,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -14654,6 +14743,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -14670,6 +14760,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -14686,6 +14777,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -14702,6 +14794,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -14718,6 +14811,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -14734,6 +14828,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -14750,6 +14845,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -14766,6 +14862,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -14782,6 +14879,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -14798,6 +14896,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -14814,6 +14913,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -14830,6 +14930,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -14846,6 +14947,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -14862,6 +14964,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -14878,6 +14981,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM additionalProperties: false deviceTypePriority: type: object @@ -14943,6 +15047,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -14959,6 +15064,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -14975,6 +15081,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -14991,6 +15098,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -15007,6 +15115,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -15023,6 +15132,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -15039,6 +15149,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -15055,6 +15166,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -15071,6 +15183,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -15087,6 +15200,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -15103,6 +15217,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -15119,6 +15234,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -15135,6 +15251,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -15151,6 +15268,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -15167,6 +15285,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -15183,6 +15302,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -15199,6 +15319,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -15215,6 +15336,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM metricPriority: type: object properties: @@ -15234,6 +15356,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -15250,6 +15373,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -15266,6 +15390,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -15282,6 +15407,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -15298,6 +15424,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -15314,6 +15441,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -15330,6 +15458,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -15346,6 +15475,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -15362,6 +15492,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -15378,6 +15509,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -15394,6 +15526,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -15410,6 +15543,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -15426,6 +15560,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -15442,6 +15577,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -15458,6 +15594,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -15474,6 +15611,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -15490,6 +15628,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -15506,6 +15645,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM additionalProperties: false deviceTypePriority: type: object @@ -20316,6 +20456,10 @@ components: anyOf: - type: string - type: "null" + gapDriverType: + anyOf: + - type: string + - type: "null" redFlags: type: array items: @@ -20328,10 +20472,12 @@ components: - recoveryGapDays - adverseCoverageDays - feltBetterDay + - gapDriverType - redFlags additionalProperties: false description: "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital - physiological returns, the headline recovery-gap (median of per-vital gaps), and any red flags." + physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline + recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags." IllnessVitalDeviation: type: object properties: @@ -20376,13 +20522,18 @@ components: anyOf: - type: number - type: "null" + adverse: + type: boolean required: - type - returnedDay - gapDays + - adverse additionalProperties: false - description: "A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days) - from the felt-better marker (positive = the body lagged the feeling)." + description: "A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap + (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was + illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for + the user-logged symptom-burden track (functionalImpact, baseline = constant 0)." IllnessRedFlag: type: object properties: diff --git a/messages/de.json b/messages/de.json index c15520593..e5629cc51 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Körpertemperatur-Abweichung", "subtitle": "Nächtliche Abweichung von deiner Basislinie" + }, + "resilience": { + "explainer": "Wie gut dein Körper die tägliche Belastung mit der Erholung ausbalanciert — aus deinen nächtlichen Signalen der letzten Tage abgelesen.", + "learning": "Deine Resilienz wird noch erlernt — ein paar Tage mehr festigen das Niveau.", + "band": { + "limited": "Eingeschränkt", + "adequate": "Ausreichend", + "solid": "Solide", + "strong": "Stark", + "exceptional": "Außergewöhnlich" + }, + "trend": { + "above": "Über deinem jüngsten Durchschnitt.", + "steady": "Im Einklang mit deinem jüngsten Durchschnitt.", + "below": "Unter deinem jüngsten Durchschnitt." + } } }, "thresholds": { @@ -5939,7 +5955,13 @@ "askMedicationName": "Bitte Medikament angeben, z.B. genommen Ramipril.", "medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.", "helpHeader": "Verfügbare Befehle:", - "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen" + "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen", + "measureValuePrompt": "Antworte mit dem Wert, um ihn zu speichern (optional).", + "buttonMeasureValue": "Wert…", + "measureValueSaved": "Messwert gespeichert.", + "measureValueInvalid": "Antworte mit einer Zahl, um einen Messwert zu speichern.", + "measureValueOutOfRange": "Dieser Wert liegt außerhalb des plausiblen Bereichs.", + "measureValueExpired": "Diese Aufforderung ist abgelaufen." }, "medicationReminders": { "phaseGreenTitle": "🟢 Erinnerung: {medName}", @@ -6866,6 +6888,8 @@ "recoveryGapTitle": "Erholungslücke", "recoveryGapLag": "Deine Werte normalisierten sich {days} Tage, nachdem es dir besser ging.", "recoveryGapLead": "Deine Werte normalisierten sich {days} Tage, bevor du Genesung vermerktest.", + "recoveryGapLagSymptom": "Dir ging es {days} Tage besser, bevor deine erfassten Symptome abklangen.", + "recoveryGapLeadSymptom": "Deine erfassten Symptome klangen {days} Tage ab, bevor du Genesung vermerktest.", "recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.", "redFlagTitle": "Genauer ansehen", "redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.", diff --git a/messages/en.json b/messages/en.json index 4ad161b92..a44932a9f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Body temperature deviation", "subtitle": "Overnight delta from your baseline" + }, + "resilience": { + "explainer": "How well your body is balancing daily strain with recovery, read from your overnight signals across recent days.", + "learning": "Still learning your resilience — a few more days will settle the band.", + "band": { + "limited": "Limited", + "adequate": "Adequate", + "solid": "Solid", + "strong": "Strong", + "exceptional": "Exceptional" + }, + "trend": { + "above": "Above your recent average.", + "steady": "In line with your recent average.", + "below": "Below your recent average." + } } }, "thresholds": { @@ -5939,7 +5955,13 @@ "askMedicationName": "Please specify a medication, e.g. taken Ramipril.", "medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.", "helpHeader": "Available commands:", - "helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\nText commands:\ntaken <name> — confirm an intake\n\nButtons in reminders:\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake" + "helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\nText commands:\ntaken <name> — confirm an intake\n\nButtons in reminders:\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake", + "measureValuePrompt": "Reply with the value to log it (optional).", + "buttonMeasureValue": "Value…", + "measureValueSaved": "Reading saved.", + "measureValueInvalid": "Reply with a number to log a reading.", + "measureValueOutOfRange": "That value is outside the plausible range.", + "measureValueExpired": "That prompt has expired." }, "medicationReminders": { "phaseGreenTitle": "🟢 Reminder: {medName}", @@ -6866,6 +6888,8 @@ "recoveryGapTitle": "Recovery gap", "recoveryGapLag": "Your numbers settled {days} days after you felt better.", "recoveryGapLead": "Your numbers settled {days} days before you marked recovery.", + "recoveryGapLagSymptom": "You felt better {days} days before your logged symptoms eased.", + "recoveryGapLeadSymptom": "Your logged symptoms eased {days} days before you marked recovery.", "recoveryGapSame": "Your numbers settled the same day you felt better.", "redFlagTitle": "Worth a closer look", "redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.", diff --git a/messages/es.json b/messages/es.json index 66d9d2421..e8f6b9456 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Desviación de la temperatura corporal", "subtitle": "Variación nocturna respecto a tu línea base" + }, + "resilience": { + "explainer": "Cómo equilibra tu cuerpo la carga diaria con la recuperación, leído a partir de tus señales nocturnas de los últimos días.", + "learning": "Aún estamos aprendiendo tu resiliencia — unos días más asentarán el nivel.", + "band": { + "limited": "Limitada", + "adequate": "Adecuada", + "solid": "Sólida", + "strong": "Fuerte", + "exceptional": "Excepcional" + }, + "trend": { + "above": "Por encima de tu media reciente.", + "steady": "En línea con tu media reciente.", + "below": "Por debajo de tu media reciente." + } } }, "thresholds": { @@ -5939,7 +5955,13 @@ "askMedicationName": "Indica el medicamento, p. ej. tomado Ramipril.", "medicationNotFoundExact": "Medicamento no encontrado. Envía el nombre exactamente como aparece en HealthLog.", "helpHeader": "Comandos disponibles:", - "helpBody": "/help — mostrar esta ayuda\n/add — registrar una toma\n/start — iniciar el bot\n\nComandos de texto:\ntaken <nombre> — confirmar una toma\n\nBotones en los recordatorios:\n• Tomado — confirmar la toma\n• 🕐 1h / 🕐 3h — posponer el recordatorio\n• ⏭ Omitir — omitir la toma\n• ✓ Confirmar — confirmar una toma omitida" + "helpBody": "/help — mostrar esta ayuda\n/add — registrar una toma\n/start — iniciar el bot\n\nComandos de texto:\ntaken <nombre> — confirmar una toma\n\nBotones en los recordatorios:\n• Tomado — confirmar la toma\n• 🕐 1h / 🕐 3h — posponer el recordatorio\n• ⏭ Omitir — omitir la toma\n• ✓ Confirmar — confirmar una toma omitida", + "measureValuePrompt": "Responde con el valor para registrarlo (opcional).", + "buttonMeasureValue": "Valor…", + "measureValueSaved": "Lectura guardada.", + "measureValueInvalid": "Responde con un número para registrar una lectura.", + "measureValueOutOfRange": "Ese valor está fuera del rango plausible.", + "measureValueExpired": "Esa solicitud ha caducado." }, "medicationReminders": { "phaseGreenTitle": "🟢 Recordatorio: {medName}", @@ -6866,6 +6888,8 @@ "recoveryGapTitle": "Brecha de recuperación", "recoveryGapLag": "Tus valores se estabilizaron {days} días después de sentirte mejor.", "recoveryGapLead": "Tus valores se estabilizaron {days} días antes de marcar la recuperación.", + "recoveryGapLagSymptom": "Te sentiste mejor {days} días antes de que cedieran tus síntomas registrados.", + "recoveryGapLeadSymptom": "Tus síntomas registrados cedieron {days} días antes de marcar la recuperación.", "recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.", "redFlagTitle": "Conviene revisarlo", "redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.", diff --git a/messages/fr.json b/messages/fr.json index 608aef1a6..1c58b4827 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Écart de température corporelle", "subtitle": "Écart nocturne par rapport à votre référence" + }, + "resilience": { + "explainer": "Comment votre corps équilibre la charge quotidienne et la récupération, d'après vos signaux nocturnes des derniers jours.", + "learning": "Votre résilience est encore en cours d'apprentissage — quelques jours de plus stabiliseront le niveau.", + "band": { + "limited": "Limitée", + "adequate": "Adéquate", + "solid": "Solide", + "strong": "Forte", + "exceptional": "Exceptionnelle" + }, + "trend": { + "above": "Au-dessus de votre moyenne récente.", + "steady": "Conforme à votre moyenne récente.", + "below": "En dessous de votre moyenne récente." + } } }, "thresholds": { @@ -5939,7 +5955,13 @@ "askMedicationName": "Indique le médicament, p. ex. pris Ramipril.", "medicationNotFoundExact": "Médicament introuvable. Envoie le nom exactement tel qu’il apparaît dans HealthLog.", "helpHeader": "Commandes disponibles :", - "helpBody": "/help — afficher cette aide\n/add — enregistrer une prise\n/start — démarrer le bot\n\nCommandes texte :\ntaken <nom> — confirmer une prise\n\nBoutons dans les rappels :\n• Pris — confirmer la prise\n• 🕐 1h / 🕐 3h — reporter le rappel\n• ⏭ Ignorer — ignorer la prise\n• ✓ Confirmer — confirmer une prise manquée" + "helpBody": "/help — afficher cette aide\n/add — enregistrer une prise\n/start — démarrer le bot\n\nCommandes texte :\ntaken <nom> — confirmer une prise\n\nBoutons dans les rappels :\n• Pris — confirmer la prise\n• 🕐 1h / 🕐 3h — reporter le rappel\n• ⏭ Ignorer — ignorer la prise\n• ✓ Confirmer — confirmer une prise manquée", + "measureValuePrompt": "Répondez avec la valeur pour l’enregistrer (facultatif).", + "buttonMeasureValue": "Valeur…", + "measureValueSaved": "Mesure enregistrée.", + "measureValueInvalid": "Répondez avec un nombre pour enregistrer une mesure.", + "measureValueOutOfRange": "Cette valeur est en dehors de la plage plausible.", + "measureValueExpired": "Cette invite a expiré." }, "medicationReminders": { "phaseGreenTitle": "🟢 Rappel : {medName}", @@ -6866,6 +6888,8 @@ "recoveryGapTitle": "Écart de récupération", "recoveryGapLag": "Tes valeurs se sont stabilisées {days} jours après que tu te sois senti mieux.", "recoveryGapLead": "Tes valeurs se sont stabilisées {days} jours avant que tu notes la guérison.", + "recoveryGapLagSymptom": "Tu t'es senti mieux {days} jours avant que tes symptômes consignés s'apaisent.", + "recoveryGapLeadSymptom": "Tes symptômes consignés se sont apaisés {days} jours avant que tu notes la guérison.", "recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.", "redFlagTitle": "À examiner de plus près", "redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.", diff --git a/messages/it.json b/messages/it.json index 226eaebb0..6778c0ec7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Deviazione della temperatura corporea", "subtitle": "Scostamento notturno dalla tua linea di base" + }, + "resilience": { + "explainer": "Quanto bene il tuo corpo bilancia il carico quotidiano con il recupero, letto dai tuoi segnali notturni degli ultimi giorni.", + "learning": "Stiamo ancora imparando la tua resilienza — qualche giorno in più stabilizzerà il livello.", + "band": { + "limited": "Limitata", + "adequate": "Adeguata", + "solid": "Solida", + "strong": "Forte", + "exceptional": "Eccezionale" + }, + "trend": { + "above": "Sopra la tua media recente.", + "steady": "In linea con la tua media recente.", + "below": "Sotto la tua media recente." + } } }, "thresholds": { @@ -5939,7 +5955,13 @@ "askMedicationName": "Indica il farmaco, es. preso Ramipril.", "medicationNotFoundExact": "Farmaco non trovato. Invia il nome esattamente come appare in HealthLog.", "helpHeader": "Comandi disponibili:", - "helpBody": "/help — mostra questa guida\n/add — registra un'assunzione\n/start — avvia il bot\n\nComandi testuali:\ntaken <nome> — conferma un'assunzione\n\nPulsanti nei promemoria:\n• Assunto — conferma l'assunzione\n• 🕐 1h / 🕐 3h — posticipa il promemoria\n• ⏭ Salta — salta l'assunzione\n• ✓ Conferma — conferma un'assunzione saltata" + "helpBody": "/help — mostra questa guida\n/add — registra un'assunzione\n/start — avvia il bot\n\nComandi testuali:\ntaken <nome> — conferma un'assunzione\n\nPulsanti nei promemoria:\n• Assunto — conferma l'assunzione\n• 🕐 1h / 🕐 3h — posticipa il promemoria\n• ⏭ Salta — salta l'assunzione\n• ✓ Conferma — conferma un'assunzione saltata", + "measureValuePrompt": "Rispondi con il valore per registrarlo (facoltativo).", + "buttonMeasureValue": "Valore…", + "measureValueSaved": "Misurazione salvata.", + "measureValueInvalid": "Rispondi con un numero per registrare una misurazione.", + "measureValueOutOfRange": "Questo valore è fuori dall’intervallo plausibile.", + "measureValueExpired": "Questo messaggio è scaduto." }, "medicationReminders": { "phaseGreenTitle": "🟢 Promemoria: {medName}", @@ -6866,6 +6888,8 @@ "recoveryGapTitle": "Divario di recupero", "recoveryGapLag": "I tuoi valori si sono stabilizzati {days} giorni dopo esserti sentito meglio.", "recoveryGapLead": "I tuoi valori si sono stabilizzati {days} giorni prima di segnare la guarigione.", + "recoveryGapLagSymptom": "Ti sei sentito meglio {days} giorni prima che i sintomi registrati si attenuassero.", + "recoveryGapLeadSymptom": "I tuoi sintomi registrati si sono attenuati {days} giorni prima di segnare la guarigione.", "recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.", "redFlagTitle": "Da guardare più da vicino", "redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.", diff --git a/messages/pl.json b/messages/pl.json index 4853b2972..638f8a520 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Odchylenie temperatury ciała", "subtitle": "Nocne odchylenie od Twojej linii bazowej" + }, + "resilience": { + "explainer": "Jak dobrze Twój organizm równoważy codzienne obciążenie z regeneracją — odczytane z nocnych sygnałów z ostatnich dni.", + "learning": "Wciąż poznajemy Twoją odporność — kilka kolejnych dni ustali poziom.", + "band": { + "limited": "Ograniczona", + "adequate": "Wystarczająca", + "solid": "Solidna", + "strong": "Silna", + "exceptional": "Wyjątkowa" + }, + "trend": { + "above": "Powyżej Twojej ostatniej średniej.", + "steady": "Zgodnie z Twoją ostatnią średnią.", + "below": "Poniżej Twojej ostatniej średniej." + } } }, "thresholds": { @@ -5939,7 +5955,13 @@ "askMedicationName": "Podaj lek, np. przyjęty Ramipril.", "medicationNotFoundExact": "Nie znaleziono leku. Wyślij nazwę dokładnie tak, jak w HealthLog.", "helpHeader": "Dostępne polecenia:", - "helpBody": "/help — pokaż tę pomoc\n/add — zapisz przyjęcie\n/start — uruchom bota\n\nPolecenia tekstowe:\ntaken <nazwa> — potwierdź przyjęcie\n\nPrzyciski w przypomnieniach:\n• Przyjęto — potwierdź przyjęcie\n• 🕐 1h / 🕐 3h — odłóż przypomnienie\n• ⏭ Pomiń — pomiń przyjęcie\n• ✓ Potwierdź — potwierdź pominięte przyjęcie" + "helpBody": "/help — pokaż tę pomoc\n/add — zapisz przyjęcie\n/start — uruchom bota\n\nPolecenia tekstowe:\ntaken <nazwa> — potwierdź przyjęcie\n\nPrzyciski w przypomnieniach:\n• Przyjęto — potwierdź przyjęcie\n• 🕐 1h / 🕐 3h — odłóż przypomnienie\n• ⏭ Pomiń — pomiń przyjęcie\n• ✓ Potwierdź — potwierdź pominięte przyjęcie", + "measureValuePrompt": "Odpowiedz wartością, aby ją zapisać (opcjonalnie).", + "buttonMeasureValue": "Wartość…", + "measureValueSaved": "Pomiar zapisany.", + "measureValueInvalid": "Odpowiedz liczbą, aby zapisać pomiar.", + "measureValueOutOfRange": "Ta wartość jest poza wiarygodnym zakresem.", + "measureValueExpired": "Ten monit wygasł." }, "medicationReminders": { "phaseGreenTitle": "🟢 Przypomnienie: {medName}", @@ -6866,6 +6888,8 @@ "recoveryGapTitle": "Luka rekonwalescencji", "recoveryGapLag": "Twoje wartości ustabilizowały się {days} dni po tym, jak poczułeś się lepiej.", "recoveryGapLead": "Twoje wartości ustabilizowały się {days} dni przed oznaczeniem wyzdrowienia.", + "recoveryGapLagSymptom": "Poczułeś się lepiej {days} dni przed ustąpieniem zapisanych objawów.", + "recoveryGapLeadSymptom": "Twoje zapisane objawy ustąpiły {days} dni przed oznaczeniem wyzdrowienia.", "recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.", "redFlagTitle": "Warto przyjrzeć się bliżej", "redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.", diff --git a/package.json b/package.json index 3c4ab7789..aea525f95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.19.1", + "version": "1.19.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql index aab9f45f5..3f8c027f2 100644 --- a/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql +++ b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql @@ -1,6 +1,10 @@ -- v1.19.0 — force-reply correlation for the interactive mood reminder. -- Maps a bot prompt message to the entry a free-text reply attaches to. -CREATE TABLE "telegram_prompt_contexts" ( +-- +-- Idempotent guards (`IF NOT EXISTS`) so a rerun is safe on prod. The +-- semantics are unchanged from the original cut (same table, same columns, +-- same indexes) — v1.19.2 added only the guards. +CREATE TABLE IF NOT EXISTS "telegram_prompt_contexts" ( "id" TEXT NOT NULL, "user_id" TEXT NOT NULL, "chat_id" TEXT NOT NULL, @@ -14,7 +18,7 @@ CREATE TABLE "telegram_prompt_contexts" ( ); -- CreateIndex -CREATE UNIQUE INDEX "telegram_prompt_contexts_chat_id_prompt_msg_id_key" ON "telegram_prompt_contexts"("chat_id", "prompt_msg_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "telegram_prompt_contexts_chat_id_prompt_msg_id_key" ON "telegram_prompt_contexts"("chat_id", "prompt_msg_id"); -- CreateIndex -CREATE INDEX "telegram_prompt_contexts_expires_at_idx" ON "telegram_prompt_contexts"("expires_at"); +CREATE INDEX IF NOT EXISTS "telegram_prompt_contexts_expires_at_idx" ON "telegram_prompt_contexts"("expires_at"); diff --git a/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql b/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql new file mode 100644 index 000000000..e143e9af6 --- /dev/null +++ b/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql @@ -0,0 +1,33 @@ +-- v1.19.2 (iOS #34 extension) — per-bucket heart-rate spread. +-- +-- v1.19.0 shipped the hourly heart-rate bucket +-- (`stats:HKQuantityTypeIdentifierHeartRate:`) carrying the +-- hour's AVERAGE bpm as one PULSE row. This adds the hour's MIN / MAX bpm +-- as two companion columns so a client can render the intra-hour range +-- (low/high band around the mean) without iOS re-uploading raw samples. +-- +-- Two additive, nullable columns on `measurements`: +-- +-- * `value_min` — the bucket's minimum bpm. +-- * `value_max` — the bucket's maximum bpm. +-- +-- Set ONLY on the hourly heart-rate bucket rows; NULL for every other row +-- (per-sample readings, per-day cumulative `stats:` totals, manual +-- entries). The series reader hands them through only for the heart-rate +-- kind, and the batch ingest persists / overwrites them alongside `value`. +-- +-- Additive; no existing row touched. The columns default to NULL so every +-- legacy row and every non-HR-bucket write stays unchanged. Idempotent +-- guards (`ADD COLUMN IF NOT EXISTS`) so a rerun is safe on prod. +-- +-- Reversibility (down): +-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_min"; +-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_max"; +-- The columns are read-additive — dropping them reverts to the avg-only +-- v1.19.0 contract. + +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "value_min" DOUBLE PRECISION; + +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "value_max" DOUBLE PRECISION; diff --git a/prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql b/prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql new file mode 100644 index 000000000..44cc79f90 --- /dev/null +++ b/prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql @@ -0,0 +1,55 @@ +-- v1.19.2 — Telegram: one chat ⇄ one account, plus a TELEGRAM measurement +-- source for numeric-reply capture. +-- +-- 1. `measurement_source` += `TELEGRAM`. A numeric reply to a measurement +-- reminder is captured as a Measurement that enters through the +-- chat-bound webhook (not the cookie/Bearer client write path), so it +-- carries its own source label. Purely-additive enum extension; no row +-- touched. `ADD VALUE IF NOT EXISTS` makes the rerun safe. The new value +-- is NOT used elsewhere in this migration, so it is safe to extend the +-- enum in the same step (Postgres only forbids USING a freshly-added +-- value in the same transaction). Mirrors the 0160 source-extension +-- precedent. +-- +-- 2. `users.telegram_chat_id` UNIQUE. The inbound webhook resolves the user +-- from `telegram_chat_id` (`findTelegramUser`); a chat id shared across +-- two accounts would route a reply / button tap / numeric capture +-- ambiguously. The index is over a nullable column, so Postgres' default +-- NULL-distinct semantics keep an unlinked account (NULL chat id) from +-- ever colliding — only two accounts both binding the SAME non-NULL chat +-- id are rejected, which is the bug we are closing. +-- +-- Defensive pre-clean: should any duplicate non-NULL chat ids already +-- exist (a chat re-pointed between accounts before this constraint +-- landed), keep the most recently updated row's binding and NULL out the +-- stale ones so the unique index can be built without error. The cleared +-- accounts simply fall back to "Telegram not linked" and re-link from +-- Settings; no data beyond the binding is touched. +-- +-- Idempotent guards (`IF NOT EXISTS`) make a rerun safe. Forward-only. +-- +-- Reversibility: Postgres cannot remove an enum value, so `TELEGRAM` stays +-- (inert with no rows). The unique index drops with +-- `DROP INDEX IF EXISTS "users_telegram_chat_id_key"`. + +-- ── 1. measurement_source — append the user-driven webhook source ────── +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'TELEGRAM'; + +-- ── 2. users.telegram_chat_id — one chat binds one account ───────────── +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY telegram_chat_id + ORDER BY updated_at DESC, id DESC + ) AS rn + FROM "users" + WHERE telegram_chat_id IS NOT NULL +) +UPDATE "users" u +SET telegram_chat_id = NULL +FROM ranked +WHERE u.id = ranked.id AND ranked.rn > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS "users_telegram_chat_id_key" + ON "users" ("telegram_chat_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1ffce14c..e6839cdce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,7 +94,12 @@ model User { // Telegram notifications (per-user) telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted - telegramChatId String? @map("telegram_chat_id") + // v1.19.2 — one Telegram chat binds to exactly one HealthLog account. + // The inbound webhook resolves the user from this chat id; a chat shared + // across two accounts would route a reply / button tap ambiguously. The + // unique is partial by Postgres NULL semantics (multiple NULLs allowed), + // so an unlinked account (NULL chat id) never collides. Migration 0189. + telegramChatId String? @unique @map("telegram_chat_id") telegramEnabled Boolean @default(false) @map("telegram_enabled") // Withings OAuth credentials (per-user, encrypted) @@ -1002,6 +1007,14 @@ enum MeasurementSource { // path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS // DTO mirrors this exact spelling so /api/sync/changes decodes the rows. OURA + // v1.19.2 — measurements captured from a numeric reply to a Telegram + // measurement reminder. Unlike the server-owned integrations above, this + // is a user-driven write, but it enters through the webhook (chat-bound) + // rather than the cookie/Bearer client write path, so it carries its own + // source label. The client batch + single-POST surfaces still reject it + // (server-resolved only); iOS DTO mirrors this spelling so the + // /api/sync/changes delta feed decodes the rows. + TELEGRAM @@map("measurement_source") } @@ -1026,6 +1039,16 @@ model Measurement { userId String @map("user_id") type MeasurementType value Float + // v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row. + // Set ONLY on the hourly heart-rate bucket + // (`stats:HKQuantityTypeIdentifierHeartRate:`), where `value` + // is the hour's AVERAGE bpm and these carry the hour's MIN / MAX bpm so a + // client can render the intra-hour range without re-uploading raw + // samples. NULL for every other row (per-sample readings, per-day + // cumulative `stats:` totals, manual entries) — the series reader hands + // them through only for the heart-rate kind. + valueMin Float? @map("value_min") + valueMax Float? @map("value_max") unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius source MeasurementSource @default(MANUAL) measuredAt DateTime @map("measured_at") diff --git a/public/sw.js b/public/sw.js index 0cb85fa5a..b8a96ce5b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.19.1"; + /* @sw-version-fallback */ "v1.19.2"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts index 09f6ceaad..839e85d2f 100644 --- a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts @@ -85,7 +85,11 @@ function makeRequest(body: unknown): NextRequest { }); } -function hrBucketEntry(externalId: string, value: number) { +function hrBucketEntry( + externalId: string, + value: number, + spread?: { valueMin: number; valueMax: number }, +) { return { hkIdentifier: "HKQuantityTypeIdentifierHeartRate", value, @@ -93,6 +97,7 @@ function hrBucketEntry(externalId: string, value: number) { startDate: "2026-06-21T14:00:00.000Z", endDate: "2026-06-21T14:59:59.000Z", externalId, + ...(spread ?? {}), }; } @@ -146,6 +151,69 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", ( expect(rows[0].value).toBe(72); }); + it("persists per-bucket min/max on a fresh hourly HR bucket (iOS #34 ext)", async () => { + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ + entries: [ + hrBucketEntry(HR_BUCKET_ID, 72, { valueMin: 58, valueMax: 96 }), + ], + }), + ); + expect(res.status).toBe(200); + const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0]; + const rows = ( + createArg as { + data: { value: number; valueMin: number; valueMax: number }[]; + } + ).data; + expect(rows[0].value).toBe(72); + expect(rows[0].valueMin).toBe(58); + expect(rows[0].valueMax).toBe(96); + }); + + it("overwrites min/max alongside the average on a re-post", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID }, + ] as never); + const res = await POST( + makeRequest({ + entries: [ + hrBucketEntry(HR_BUCKET_ID, 78, { valueMin: 55, valueMax: 110 }), + ], + }), + ); + expect(res.status).toBe(200); + const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0]; + const data = ( + updateArg as { + data: { value: number; valueMin: number; valueMax: number }; + } + ).data; + expect(data.value).toBe(78); + expect(data.valueMin).toBe(55); + expect(data.valueMax).toBe(110); + }); + + it("leaves min/max null on a per-sample PULSE row even if sent", async () => { + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 }); + const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000"; + const res = await POST( + makeRequest({ + entries: [hrBucketEntry(SAMPLE_ID, 64, { valueMin: 50, valueMax: 80 })], + }), + ); + expect(res.status).toBe(200); + const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0]; + const rows = ( + createArg as { + data: { valueMin: number | null; valueMax: number | null }[]; + } + ).data; + expect(rows[0].valueMin).toBeNull(); + expect(rows[0].valueMax).toBeNull(); + }); + it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => { // The bucket already exists from an earlier within-hour post. vi.mocked(prisma.measurement.findMany).mockResolvedValue([ diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index d56a1d236..bb9e9b1d6 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -124,6 +124,15 @@ const batchEntrySchema = z.object({ // device's own verdict / severity; resolved to a `rhythmClassification` // by `mapAppleHealthEntry`. Ignored for every non-event identifier. categoryValue: z.number().int().min(0).max(20).optional(), + // v1.19.2 (iOS #34 extension) — per-bucket spread for the hourly + // heart-rate bucket. `value` carries the hour's AVERAGE bpm; these carry + // the hour's MIN / MAX so a client renders the intra-hour band without + // re-uploading raw samples. Persisted ONLY on a well-formed + // `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored + // NULL) for every other entry. Optional + backward-compatible: a pre-W192 + // iOS build omits them and the bucket keeps the avg-only contract. + valueMin: z.number().finite().optional(), + valueMax: z.number().finite().optional(), externalId: z.string().min(1).max(120), externalSourceVersion: z.string().min(1).max(120).optional(), // v1.8.6 W6 — optional per-entry source tag. Defaults to @@ -255,6 +264,8 @@ async function postBatch(request: NextRequest): Promise { endDate: entry.endDate, sleepStage: entry.sleepStage, categoryValue: entry.categoryValue, + valueMin: entry.valueMin, + valueMax: entry.valueMax, }); if (!mapped) { @@ -301,6 +312,21 @@ async function postBatch(request: NextRequest): Promise { continue; } + // v1.19.2 (iOS #34 extension) — the per-bucket MIN / MAX are persisted + // ONLY on a well-formed hourly HR bucket row. A per-sample reading, a + // per-day cumulative `stats:` total, or a manual entry never carries a + // spread, so we pin them to null there even if a client mis-sends the + // fields — `value` stays the single source of truth for those rows. + const isHrBucket = isHourlyHeartRateStatsExternalId(entry.externalId); + const valueMin = + isHrBucket && typeof mapped.valueMin === "number" + ? mapped.valueMin + : null; + const valueMax = + isHrBucket && typeof mapped.valueMax === "number" + ? mapped.valueMax + : null; + prepared.push({ index, entry, @@ -308,6 +334,8 @@ async function postBatch(request: NextRequest): Promise { userId: user.id, type: mapped.type, value: mapped.value, + valueMin, + valueMax, unit: mapped.unit, // v1.8.6 W6 — honour the per-entry source tag, defaulting to // `APPLE_HEALTH` when absent so legacy callers are unchanged. @@ -505,6 +533,12 @@ async function postBatch(request: NextRequest): Promise { }, data: { value: p.row.value as number, + // v1.19.2 (iOS #34 extension) — overwrite the per-bucket + // spread alongside `value` so a within-hour re-post (the + // running mean + range shift as more samples land) replaces + // both. Null for every non-HR-bucket `stats:` overwrite. + valueMin: p.row.valueMin as number | null, + valueMax: p.row.valueMax as number | null, unit: p.row.unit as string, measuredAt: p.row.measuredAt as Date, externalSourceVersion: p.row.externalSourceVersion as diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 7e8c4d0d4..e4070e049 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -134,6 +134,51 @@ describe("GET /api/measurements/series", () => { expect(body.data.points[0].secondary).toBeNull(); }); + it("surfaces per-point min/max for kind=pulse (iOS #34 ext)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + // hourly HR bucket — average + spread + { + id: "p1", + value: 72, + measuredAt: new Date("2026-06-21T14:00:00.000Z"), + valueMin: 58, + valueMax: 96, + }, + // per-sample PULSE row — no spread + { + id: "p2", + value: 64, + measuredAt: new Date("2026-06-21T15:00:00.000Z"), + valueMin: null, + valueMax: null, + }, + ] as never); + const res = await GET(req("kind=pulse&days=7")); + const body = (await res.json()) as { + data: { + points: Array<{ + value: number; + valueMin: number | null; + valueMax: number | null; + }>; + }; + }; + expect(body.data.points[0].valueMin).toBe(58); + expect(body.data.points[0].valueMax).toBe(96); + expect(body.data.points[1].valueMin).toBeNull(); + expect(body.data.points[1].valueMax).toBeNull(); + + // The pulse read selects the spread columns. + const selectArg = vi.mocked(prisma.measurement.findMany).mock.calls[0][0]; + expect( + (selectArg as { select: Record }).select.valueMin, + ).toBe(true); + expect( + (selectArg as { select: Record }).select.valueMax, + ).toBe(true); + }); + it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => { // SLEEP_DURATION is stored one row per STAGE per night (minutes). The // series must return ONE point per night carrying the TIME-ASLEEP diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 505bbc6a6..23f16bc75 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -124,6 +124,15 @@ interface SeriesPoint { * the night's TIME-ASLEEP total. */ sleepStages?: Partial> | null; + /** + * v1.19.2 (iOS #34 extension) — per-point spread for `kind=pulse`. On an + * aggregated hourly heart-rate bucket `value` is the hour's AVERAGE bpm + * and these carry the hour's MIN / MAX. `null` for a per-sample PULSE row + * (no bucket spread) and absent for every non-pulse kind, so a client can + * render a low/high band only where the data supports it. + */ + valueMin?: number | null; + valueMax?: number | null; } function stdDev(values: number[]): number { @@ -317,6 +326,11 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } else { const type = KIND_TO_TYPE[kind]; + // v1.19.2 (iOS #34 extension) — pull the per-bucket spread for the + // heart-rate kind so the chart can render a low/high band around the + // hourly average. Only PULSE rows ever carry a non-null spread (the + // hourly HR bucket); every other kind selects the bare value shape. + const includeSpread = kind === "pulse"; const rows = await prisma.measurement.findMany({ where: { userId: user.id, @@ -325,7 +339,12 @@ export const GET = apiHandler(async (request: NextRequest) => { deletedAt: null, }, orderBy: { measuredAt: "asc" }, - select: { id: true, value: true, measuredAt: true }, + select: { + id: true, + value: true, + measuredAt: true, + ...(includeSpread ? { valueMin: true, valueMax: true } : {}), + }, }); if (kind === "glucose") { // v1.16.16 — glucose is stored canonical mg/dL; convert each point to @@ -379,6 +398,24 @@ export const GET = apiHandler(async (request: NextRequest) => { count: rawSummary.count, }; } + } else if (includeSpread) { + // v1.19.2 (iOS #34 extension) — pulse points carry the per-bucket + // spread. A per-sample PULSE row has null min/max; an hourly HR bucket + // carries the hour's range around the average. + points = rows.map((r) => { + const row = r as typeof r & { + valueMin: number | null; + valueMax: number | null; + }; + return { + id: row.id, + at: row.measuredAt.toISOString(), + value: row.value, + secondary: null, + valueMin: row.valueMin ?? null, + valueMax: row.valueMax ?? null, + }; + }); } else { points = rows.map((r) => ({ id: r.id, diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 5c25ed80e..7de67e696 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -33,6 +33,10 @@ import { } from "@/lib/mood/create-from-telegram"; import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +import { + isTelegramCapturableType, + logTelegramMeasurement, +} from "@/lib/measurements/create-from-telegram"; interface TelegramUpdate { update_id: number; @@ -776,13 +780,15 @@ async function handleCallback(update: TelegramUpdate) { await deleteMessage(botToken, chatId, messageId); } } else if (data.startsWith("measure_done:")) { - // v1.19.0 — mark a Vorsorge/measurement reminder done. No value - // capture (restraint) — satisfy the cadence via the shared primitive. + // v1.19.0 — mark a Vorsorge/measurement reminder done; satisfy the + // cadence via the shared primitive. const reminderId = data.slice("measure_done:".length).trim(); const reminder = await prisma.measurementReminder.findFirst({ where: { id: reminderId, userId: user.id, deletedAt: null }, select: { id: true, + // v1.19.2 — the target metric; drives the optional numeric capture. + measurementType: true, intervalDays: true, rrule: true, anchorDate: true, @@ -809,6 +815,48 @@ async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } + + // v1.19.2 — optional numeric value capture. When the reminder names a + // single-value metric (BP and free-text Vorsorge are excluded), offer a + // force-reply prompt; a numeric reply is captured as a Measurement + // (source=TELEGRAM) in `handleTextMessage`. The reply binds to THIS + // reminder + user through a TelegramPromptContext keyed on the prompt + // message id, the SAME strict chat/message binding the mood-note path + // uses — the userId is never read from the inbound payload. Optional and + // self-cleaning: ignoring the prompt leaves the cadence already + // satisfied, and the prompt is swept on the ~30-min auto-delete. + if (isTelegramCapturableType(reminder.measurementType)) { + const valuePrompt = await sendTelegramMessage( + botToken, + chatId, + t("telegram.measureValuePrompt"), + { + replyMarkup: { + force_reply: true, + input_field_placeholder: t("telegram.buttonMeasureValue"), + }, + }, + ); + if (valuePrompt.ok && valuePrompt.messageId) { + try { + await prisma.telegramPromptContext.create({ + data: { + userId: user.id, + chatId, + promptMsgId: valuePrompt.messageId, + kind: "measure_value", + refId: reminder.id, + // Same ~30-min window as the deletion sweep; a later reply is + // treated as expired. + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + }, + }); + } catch { + // Best-effort — without the context row the value simply can't bind. + } + await scheduleAutoDelete(user.id, chatId, [valuePrompt.messageId]); + } + } } else if (data.startsWith("measure_later:")) { // v1.19.0 — postpone a measurement reminder by N minutes (one-shot // nudge). Pushes `nextDueAt` forward; the cron re-fires once due AND in @@ -892,7 +940,11 @@ async function handleTextMessage(update: TelegramUpdate) { expiresAt: true, }, }); - if (context && context.userId === user.id && context.kind === "mood_note") { + if ( + context && + context.userId === user.id && + (context.kind === "mood_note" || context.kind === "measure_value") + ) { // Consume the context row regardless of outcome so a reply can't be // replayed onto the entry twice. await prisma.telegramPromptContext @@ -903,7 +955,9 @@ async function handleTextMessage(update: TelegramUpdate) { const resp = await sendTelegramMessage( botToken, chatId, - t("telegram.moodNoteExpired"), + context.kind === "measure_value" + ? t("telegram.measureValueExpired") + : t("telegram.moodNoteExpired"), ); const toDelete = [userMsgId, replyToId, resp.messageId].filter( (id): id is number => id != null, @@ -913,6 +967,49 @@ async function handleTextMessage(update: TelegramUpdate) { } return; } + + if (context.kind === "measure_value") { + // v1.19.2 — numeric value capture for a measurement reminder. The + // reminder named on the context row carries the expected metric; the + // userId is the chat-resolved one (never the payload). A non-numeric + // / out-of-range reply gets a calm hint, not a write. The reply, the + // prompt, and the confirmation all self-clean on the ~30-min sweep. + const reminder = await prisma.measurementReminder.findFirst({ + where: { id: context.refId, userId: user.id, deletedAt: null }, + select: { measurementType: true }, + }); + let confirmation: string; + if (!isTelegramCapturableType(reminder?.measurementType)) { + confirmation = t("telegram.measurementNotFound"); + } else { + const externalId = `telegram:measure:${chatId}:${replyToId}`.slice( + 0, + 120, + ); + const result = await logTelegramMeasurement({ + userId: user.id, + type: reminder.measurementType, + rawText: text, + tz: user.timezone, + externalId, + }); + confirmation = + result.status === "ok" + ? t("telegram.measureValueSaved") + : result.status === "out_of_range" + ? t("telegram.measureValueOutOfRange") + : t("telegram.measureValueInvalid"); + } + const resp = await sendTelegramMessage(botToken, chatId, confirmation); + const toDelete = [userMsgId, replyToId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + const attached = await attachTelegramMoodNote({ userId: user.id, moodEntryId: context.refId, diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 38b37e72f..dcc1c6454 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -89,9 +89,14 @@ const TIME_RANGES_KEYS = [ * A generous fixed bound (~10 years) that is clearly larger than any * other range tab, so "All" means "all of my history" rather than the * old silent 365-day truncation. Fixed (not per-account derived) so the - * fetch-window cache key stays stable across the session. The server's - * daily-aggregate reader still caps the returned bucket count, so this - * only widens the requested window, never the render cost. + * fetch-window cache key stays stable across the session. + * + * v1.19.2 — the server's daily-aggregate reader now steps UP the bucket + * tier (DAY → WEEK → MONTH) for windows wider than the DAY cap, so a + * multi-year "All" range returns whole-history coverage downsampled to + * the tier the chart's `bucketTimeSeries` would render anyway, instead of + * truncating to the most recent ~365 daily buckets. The render cost stays + * flat — the coarse tier bounds the point count. */ const ALL_RANGE_DAYS = 3650; @@ -650,10 +655,13 @@ export function HealthChart({ // (~10 years) rather than the old 365-day cap, which silently truncated // any account holding more than a year of history. The window is a // generous fixed bound (not derived per-account) so the cache key stays - // stable across the session; the daily-aggregate reader caps the - // RETURNED bucket count at `BUCKET_CAP.daily` (365), so the chart still - // paints at most ~365 daily buckets — see the report's note on raising - // that server-side ceiling for true multi-year coverage. + // stable across the session. + // + // v1.19.2 — the server reader steps the bucket tier up to WEEK / MONTH + // for windows past the DAY cap, so the "All" tab now returns coverage + // across the whole history (downsampled by tier) rather than the most + // recent ~365 daily buckets. The client folds those coarse buckets and + // `bucketTimeSeries` re-buckets to the visible range as before. const fetchWindow = useMemo(() => { const to = new Date(); const windowDays = rangePoints > 0 ? rangePoints : ALL_RANGE_DAYS; @@ -845,6 +853,13 @@ export function HealthChart({ // falls back to live SQL when the rollup is empty for the // requested window so brand-new accounts still see a chart // on their first render. + // + // v1.19.2 — the `aggregate=daily` ask still holds, but for windows + // wider than the DAY bucket cap the server steps the rollup tier + // up to WEEK / MONTH and returns whole-history coverage at that + // tier. Each returned row is one coarse bucket; the daily fold + + // `bucketTimeSeries` below downsample it to the visible range, so + // the request contract is unchanged. if (fetchWindow.windowDays > 7) { typeParams.set("aggregate", "daily"); typeParams.set("source", "rollup"); diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx index 000bac25f..57e937154 100644 --- a/src/components/illness/illness-correlation-card.tsx +++ b/src/components/illness/illness-correlation-card.tsx @@ -44,6 +44,10 @@ function DeviationRow({ d }: { d: IllnessVitalDeviation }) { function CorrelationBody({ value }: { value: IllnessCorrelationValue }) { const { t } = useTranslations(); const gap = value.recoveryGapDays; + // Driver-aware copy: when the user's logged symptom curve drove the gap, name + // the symptoms ("…before your symptoms eased"); otherwise the existing + // vital-driven phrasing. Clinically calm — observation, never prediction. + const symptomDriven = value.gapDriverType === "FUNCTIONAL_IMPACT"; return (
@@ -71,11 +75,19 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {

{gap > 0 - ? t("illness.correlation.recoveryGapLag", { days: gap }) + ? t( + symptomDriven + ? "illness.correlation.recoveryGapLagSymptom" + : "illness.correlation.recoveryGapLag", + { days: gap }, + ) : gap < 0 - ? t("illness.correlation.recoveryGapLead", { - days: Math.abs(gap), - }) + ? t( + symptomDriven + ? "illness.correlation.recoveryGapLeadSymptom" + : "illness.correlation.recoveryGapLead", + { days: Math.abs(gap) }, + ) : t("illness.correlation.recoveryGapSame")}

diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 6bda26316..581dcd3fd 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -97,9 +97,11 @@ export interface IllnessVitalDeviation { } export interface IllnessVitalReturn { + /** A `MeasurementType`, or `"FUNCTIONAL_IMPACT"` for the symptom-burden track. */ type: string; returnedDay: string | null; gapDays: number | null; + adverse: boolean; } export interface IllnessRedFlag { @@ -117,6 +119,12 @@ export interface IllnessCorrelationValue { recoveryGapDays: number | null; adverseCoverageDays: number; feltBetterDay: string | null; + /** + * The metric that drove the headline gap — a `MeasurementType`, + * `"FUNCTIONAL_IMPACT"` (the symptom-burden track), or null. Drives the + * driver-aware recovery-gap copy. + */ + gapDriverType: string | null; redFlags: IllnessRedFlag[]; } diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index 0018b2cab..4aa8994ed 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -234,3 +234,60 @@ describe(" data-gating", () => { expect(html).not.toContain('data-slot="recovery-empty"'); }); }); + +describe(" on the recovery surface", () => { + it("stays hidden when there is no resilience reading", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + DAY_STRAIN: summary({ count: 14, latest: 12, mean: 11 }), + RESILIENCE: summary({ count: 0 }), + }), + ); + const html = render(); + expect(html).not.toContain('data-slot="resilience-tile"'); + }); + + it("names the latest band and explains it once enough days exist", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + // latest ordinal 4 → "strong"; mean 3 → above the recent average. + RESILIENCE: summary({ count: 9, latest: 4, mean: 3 }), + }), + ); + const html = render(); + expect(html).toContain('data-slot="resilience-tile"'); + expect(html).toContain('data-slot="resilience-band"'); + expect(html).toContain("Strong"); + expect(html).toContain('data-slot="resilience-trend"'); + expect(html).toContain("Above your recent average."); + // Calm by construction — no learning gate once past the threshold. + expect(html).not.toContain('data-slot="resilience-learning"'); + }); + + it("shows the calm learning note while the series is still sparse", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + RESILIENCE: summary({ count: 2, latest: 3, mean: 3 }), + }), + ); + const html = render(); + expect(html).toContain('data-slot="resilience-tile"'); + expect(html).toContain('data-slot="resilience-learning"'); + // No trend cue until the band has settled. + expect(html).not.toContain('data-slot="resilience-trend"'); + }); + + it("keeps the recovery surface non-empty when resilience is the only signal", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + SLEEP_DURATION: summary({ count: 10 }), + RESILIENCE: summary({ count: 9, latest: 2, mean: 2 }), + }), + ); + const html = render(); + expect(html).not.toContain('data-slot="recovery-empty"'); + expect(html).toContain('data-slot="resilience-tile"'); + // "adequate" band label is surfaced. + expect(html).toContain("Adequate"); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index aa20016d2..dd9356ed3 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -62,10 +62,10 @@ import { import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types"; import type { CoachStreamingMessage } from "../use-coach"; -// v1.18.9 — `CoachStreamingMessage` gained `usage` / `startedAt` / -// `reasoning` (per-message token footer + thinking disclosure). The -// fixtures below predate those fields; this builder fills the defaults so -// each test only spells out the fields it cares about. +// v1.18.9 — `CoachStreamingMessage` gained a `usage` field for the +// per-message token footer. The fixtures below predate it; this builder +// fills the defaults so each test only spells out the fields it cares +// about. function streaming( partial: Partial, ): CoachStreamingMessage { @@ -77,8 +77,6 @@ function streaming( messageId: null, errorCode: null, usage: null, - startedAt: null, - reasoning: "", ...partial, }; } diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 5517d5237..db101a0d6 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -199,19 +199,6 @@ export interface CoachStreamingMessage { * bubble; persisted bubbles read `CoachMessageDTO.tokensUsed` instead. */ usage: CoachUsage | null; - /** - * v1.18.9 — epoch-ms timestamp the send fired, so the thinking - * disclosure can show a live "thinking for N s" counter that freezes - * to a past-tense summary once the first token lands. Null before a - * turn starts. - */ - startedAt: number | null; - /** - * v1.18.9 — optional reasoning-summary text from the additive - * `reasoning` frame, rendered inside the thinking disclosure when a - * reasoning-capable provider emits it. Empty string when none. - */ - reasoning: string; } /** @@ -253,8 +240,6 @@ const EMPTY_STREAMING: CoachStreamingMessage = { messageId: null, errorCode: null, usage: null, - startedAt: null, - reasoning: "", }; export interface SendCoachMessageParams { @@ -349,10 +334,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: null, usage: null, - // v1.18.9 — anchor the thinking-disclosure elapsed timer to the - // moment the send fired. - startedAt: Date.now(), - reasoning: "", }); // v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode @@ -367,8 +348,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: "coach.network", usage: null, - startedAt: null, - reasoning: "", }); return null; } @@ -403,8 +382,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: "coach.network", usage: null, - startedAt: null, - reasoning: "", }); return null; } @@ -418,8 +395,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: `coach.http.${response.status}`, usage: null, - startedAt: null, - reasoning: "", }); return null; } @@ -458,8 +433,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: structured ?? `coach.http.${response.status}`, usage: null, - startedAt: null, - reasoning: "", }); // v1.16.4 — KEEP the optimistic user bubble: a rejected turn // (budget gate, 4xx) has no persisted twin coming, and dropping @@ -479,7 +452,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { let lastError: string | null = null; let messageId: string | null = null; let collectedUsage: CoachUsage | null = null; - let collectedReasoning = ""; try { while (true) { @@ -512,16 +484,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { })); break; case "reasoning": - // v1.18.9 — additive reasoning-summary frame. Append so - // multiple chunks accumulate; the disclosure renders it - // when present, else falls back to the elapsed-time label. - // Guard the text: a `reasoning` frame with no `text` must not - // append a literal "undefined". - collectedReasoning += evt.text ?? ""; - setStreaming((prev) => ({ - ...prev, - reasoning: prev.reasoning + (evt.text ?? ""), - })); + // v1.19.1 — the thinking disclosure was removed, so the + // additive `reasoning` frame is accepted off the wire and + // dropped. Kept as an explicit no-op case so the wire frame + // stays a recognised type rather than slipping to a default. break; case "done": resolvedConversationId = evt.conversationId; @@ -544,8 +510,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { collectedUsage = evt.usage ?? null; } else if (evt.type === "error") { lastError = evt.code; - } else if (evt.type === "reasoning") { - collectedReasoning += evt.text ?? ""; } } } catch (err) { @@ -554,7 +518,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { } } - setStreaming((prev) => ({ + setStreaming({ content: collectedContent, metricSource: collectedProvenance, suggestion: collectedSuggestion, @@ -562,12 +526,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId, errorCode: lastError, usage: collectedUsage, - // v1.18.9 — keep the send-anchored timestamp so the disclosure can - // freeze the elapsed time to a past-tense "thought for N s" once - // the turn settles. - startedAt: prev.startedAt, - reasoning: collectedReasoning, - })); + }); if (resolvedConversationId) { // Invalidate the freshly-persisted conversation + the rail so diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 6fdf36cf6..908d96795 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -20,6 +20,7 @@ import type { RestModeAnnotation } from "@/lib/analytics/health-score"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-metric-block"; +import { ResilienceTile } from "@/components/insights/recovery/resilience-tile"; interface BlockSpec { type: string; @@ -168,11 +169,17 @@ export function RecoverySection() { }, ]; + // v1.19.2 — resilience is an ordinal band (limited … exceptional), surfaced + // as a calm dedicated tile rather than a chart block, so it counts toward the + // data gate independently of `groups` (a chart-block list). + const hasResilience = (summaries?.["RESILIENCE"]?.count ?? 0) > 0; + const hasAny = summaries != null && - groups.some((group) => - group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0), - ); + (hasResilience || + groups.some((group) => + group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0), + )); if (isLoading || summaries == null) { return ( @@ -208,6 +215,9 @@ export function RecoverySection() { {/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface. Self-gating: renders nothing unless an episode is active. */} + {/* v1.19.2 — Oura resilience band, self-gating (renders nothing without + a reading). A calm ordinal readout, not a chart block. */} + {presentBlocks.map((block) => ( ` rather than a one-off trend. + */ + +/** Min readings before the "vs recent average" cue is shown. */ +const MIN_TREND_READINGS = 3; + +/** + * The ordinal → static i18n key, in lock-step with `RESILIENCE_LEVELS` + * (limited=1 … exceptional=5). Index 0 is unused so the ordinal indexes + * directly. The keys are written out in full (not `band.${ordinal}`) so the + * i18n call-site coverage guard verifies every one resolves. The band + * vocabulary is Oura's own — not invented here. + */ +const BAND_KEYS = [ + null, + "insights.resilience.band.limited", + "insights.resilience.band.adequate", + "insights.resilience.band.solid", + "insights.resilience.band.strong", + "insights.resilience.band.exceptional", +] as const; + +/** Static trend keys, written out so the i18n coverage guard resolves them. */ +const TREND_KEYS = { + above: "insights.resilience.trend.above", + steady: "insights.resilience.trend.steady", + below: "insights.resilience.trend.below", +} as const; + +export function ResilienceTile() { + const { t } = useTranslations(); + const { data } = useInsightsAnalytics("SLEEP_DURATION"); + const summary = data?.summaries?.["RESILIENCE"]; + + const count = summary?.count ?? 0; + if (count === 0) return null; + + const latest = summary?.latest ?? null; + const mean = summary?.mean ?? null; + // The ordinal lands as a number; clamp into the known 1–5 band range so a + // future Oura level the ingest does not yet recognise can never index out + // of the label table (ingest already drops unknown levels, this is belt). + const ordinal = + latest != null ? Math.min(5, Math.max(1, Math.round(latest))) : null; + const bandKey = ordinal != null ? BAND_KEYS[ordinal] : null; + + // Quiet trend: today's level against the rounded trailing-window mean. Calm + // wording (above / steady / below the recent average), never a colour. Both + // operands are server-computed; nothing is recomputed from raw rows here. + let trendKey: "above" | "steady" | "below" | null = null; + if (count >= MIN_TREND_READINGS && ordinal != null && mean != null) { + const meanOrdinal = Math.round(mean); + if (ordinal > meanOrdinal) trendKey = "above"; + else if (ordinal < meanOrdinal) trendKey = "below"; + else trendKey = "steady"; + } + + return ( + + + + + {bandKey != null ? ( + + {t(bandKey)} + + ) : null} + + +

+ {t("insights.resilience.explainer")} +

+ {count < MIN_TREND_READINGS ? ( + + ) : trendKey != null ? ( +

+ {t(TREND_KEYS[trendKey])} +

+ ) : null} +
+
+ ); +} diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index 27bc955b3..fd5a855b3 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -16,10 +16,12 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { computeIllnessCorrelation, dayDiff, + FUNCTIONAL_IMPACT_RETURN_KEY, isAdverseDeviation, MIN_BASELINE_DAYS, MIN_EPISODE_COVERAGE_DAYS, type IllnessCorrelationInput, + type SymptomBurdenPoint, type VitalDayPoint, } from "../correlation"; @@ -615,3 +617,196 @@ describe("computeIllnessCorrelation — return anchor", () => { expect(out.value.recoveryGapDays).toBeGreaterThan(0); }); }); + +describe("computeIllnessCorrelation — symptom-burden return track", () => { + // A banded RHR vital present in EVERY case below clears the engine's + // episode-coverage floor (the gate counts banded-vital days, not symptom + // days). Its band stays IN throughout so it produces no adverse signal and + // never competes with the symptom track for the driver — it is scaffolding. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02"); + const rhrFlat: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 55 }, + { day: "2026-01-11", mean: 55 }, + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 55 }, + ]; + + it("folds a functional-impact return into the gap with a constant-0 baseline", () => { + // Onset 01-10, felt better 01-14. Logged impact: bedbound early, eases to 0 + // on 01-16 and HOLDS for 3 logged days → symptom return 01-16, gap +2. + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-14", impact: 1 }, // felt-better day, still symptomatic + { day: "2026-01-16", impact: 0 }, // return start + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, // 3 logged in-band days → stable + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + expect(sym).toBeDefined(); + expect(sym?.returnedDay).toBe("2026-01-16"); + expect(sym?.adverse).toBe(true); + // Gap = 01-16 − 01-14 = +2. RHR stayed flat (no gap), so the symptom track + // is the only contributor → headline gap = +2. + expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-16")); + expect(out.value.recoveryGapDays).toBe(2); + // It contributes to the adverse-coverage floor (3 logged adverse days). + expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(3); + expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY); + }); + + it("WITHHOLDS the symptom return when logging is too sparse to stabilise", () => { + // The user logs while sick, then logs impact-0 ONCE and stops — the + // 3-logged-day stability run can never fire. The symptom track must + // contribute NO gap (honest withholding), never fabricate recovery from the + // absence of further logs. + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-16", impact: 0 }, // single trailing in-band log + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + expect(sym).toBeDefined(); + expect(sym?.returnedDay).toBeNull(); + expect(sym?.gapDays).toBeNull(); + // No vital returned either → no headline gap fabricated. + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.gapDriverType).toBeNull(); + // The logged adverse days still count toward coverage (honest). + expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(2); + }); + + it("lets the symptom track win the driver over a co-returning vital", () => { + // Both the symptom curve AND an adverse RHR spike return and produce gaps; + // the functional-impact track must win `gapDriverType` on a tie (it is the + // most illness-specific signal). + const rhrSpike: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // adverse up + { day: "2026-01-11", mean: 74 }, + { day: "2026-01-16", mean: 55 }, // back in band + { day: "2026-01-17", mean: 55 }, + { day: "2026-01-18", mean: 55 }, // 3 stable + ]; + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-16", impact: 0 }, // same return day → same gap as RHR + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrSpike }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + const rhrReturn = out.value.returns.find((r) => r.type === rhr); + expect(sym?.gapDays).toBe(2); + expect(rhrReturn?.gapDays).toBe(2); + expect(out.value.recoveryGapDays).toBe(2); + // Tie on |gap − median| → functional-impact wins. + expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY); + }); + + it("ignores a symptom curve with no adverse day (nothing to return from)", () => { + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 0 }, + { day: "2026-01-12", impact: 0 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect( + out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY), + ).toBeUndefined(); + }); + + it("never produces a symptom return for CHRONIC_ONGOING", () => { + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-16", impact: 0 }, + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "CHRONIC_ONGOING", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.gapDriverType).toBeNull(); + expect( + out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/illness/correlation-read.ts b/src/lib/illness/correlation-read.ts index b9e1c0dfd..ca05de559 100644 --- a/src/lib/illness/correlation-read.ts +++ b/src/lib/illness/correlation-read.ts @@ -39,6 +39,7 @@ import { computeIllnessCorrelation, type DayLogFeverPoint, type IllnessCorrelationValue, + type SymptomBurdenPoint, type VitalSeries, } from "./correlation"; @@ -103,7 +104,7 @@ export async function computeEpisodeCorrelation( // run paired rather than serially (halving the per-type round-trips). // The day-log fever read shares the same fan-out budget. const limit = pLimit(VITAL_SCAN_CONCURRENCY); - const [perType, dayLogFever] = await Promise.all([ + const [perType, dayLogFever, symptomBurden] = await Promise.all([ Promise.all( ILLNESS_SCAN_TYPES.map((type) => limit(async () => { @@ -132,6 +133,7 @@ export async function computeEpisodeCorrelation( ), ), limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)), + limit(() => readDayLogSymptomBurden(episode.id, preOnsetStart, end, tz)), ]); const series: VitalSeries[] = []; @@ -167,6 +169,7 @@ export async function computeEpisodeCorrelation( window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle }, series, dayLogFever, + symptomBurden, source, now, }); @@ -275,3 +278,56 @@ async function readDayLogFever( .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([day, feverC]) => ({ day, feverC })); } + +/** + * Read the illness day-log symptom-burden series over [from, to) — the user's + * own logged daily-impact curve, keyed by the stored `date` (already the + * user-tz day string, so NO tz conversion). Mirrors `readDayLogFever`: one + * `findMany`, present days only, no zero-fill. + * + * Per day the burden is `functionalImpact` (0–3, the deliberate daily slider) + * when logged; else the day's MAX linked `IllnessSymptomLink.severity` (0–3) as + * a fallback corroborator. A day with neither is dropped (absent from the + * series — the engine treats a missing day as "not logged", never an + * implicit 0). + */ +async function readDayLogSymptomBurden( + episodeId: string, + from: Date, + to: Date, + tz: string, +): Promise { + const fromKey = dayKeyForUserTz(from, tz); + const toKey = dayKeyForUserTz(to, tz); + const rows = await prisma.illnessDayLog.findMany({ + where: { + episodeId, + deletedAt: null, + date: { gte: fromKey, lte: toKey }, + }, + select: { + date: true, + functionalImpact: true, + symptomLinks: { select: { severity: true } }, + }, + }); + const points: SymptomBurdenPoint[] = []; + for (const row of rows) { + if (row.functionalImpact != null) { + points.push({ day: row.date, impact: row.functionalImpact }); + continue; + } + // Fallback corroborator: the day's strongest linked symptom severity. + let maxSeverity: number | null = null; + for (const link of row.symptomLinks) { + if (link.severity == null) continue; + maxSeverity = + maxSeverity === null + ? link.severity + : Math.max(maxSeverity, link.severity); + } + if (maxSeverity != null) + points.push({ day: row.date, impact: maxSeverity }); + } + return points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); +} diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index e220b0f14..7756c7c33 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -66,6 +66,21 @@ export const NOTABLE_SD = 2; * for the recovery-gap (one lucky in-band day is not a recovery). */ export const RETURN_STABILITY_DAYS = 3; +/** + * Sentinel `type` for the functional-impact return track. The symptom curve is + * NOT a `MeasurementType` — it is the user's own logged daily-impact slider — + * so it rides `returns[]` under a stable string key the surface keys copy off + * (the typed `VitalReturnFinding.type` is widened to accept it). Tallied like + * any vital type in `gapReturnTypes` so it can win `gapDriverType`. + */ +export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const; +/** + * Functional-impact threshold (0–3 slider) at/above which a logged day counts + * as ADVERSE (symptomatic). The baseline is the known constant 0 (healthy), so + * the symptom curve needs none of the MAD-band / contamination machinery the + * passive vitals carry — "return" is simply impact back to below this floor. + */ +export const FUNCTIONAL_IMPACT_ADVERSE_FLOOR = 1; /** Minimum episode days with ANY vital coverage before the gap is asserted. */ export const MIN_EPISODE_COVERAGE_DAYS = 4; @@ -153,6 +168,22 @@ export interface DayLogFeverPoint { feverC: number; } +/** + * A user-logged symptom-burden reading from the illness day-log, keyed by the + * stored `date` (already the user-tz local day). `impact` is the day's + * `functionalImpact` slider (0–3, 0 = fully functional … 3 = bedbound); when + * NULL it is the day's max linked `IllnessSymptomLink.severity` (0–3) as a + * fallback corroborator, or null when neither was logged. Present days only — + * never zero-filled (mirrors the fever read), so sparse logging WITHHOLDS the + * symptom return rather than fabricating an all-clear from absence of rows. + */ +export interface SymptomBurdenPoint { + /** Local day key, `YYYY-MM-DD`. */ + day: string; + /** The day's symptom burden (0–3): functionalImpact, else max severity. */ + impact: number; +} + /** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */ export interface EpisodeWindow { /** Onset day. */ @@ -174,6 +205,17 @@ export interface IllnessCorrelationInput { * escalation even with no passive BODY_TEMPERATURE rows. Optional. */ dayLogFever?: DayLogFeverPoint[]; + /** + * Per-day symptom-burden readings from the illness day-log (`functionalImpact` + * primary, max linked symptom `severity` as fallback). Folded into the + * recovery-gap as ONE more return track against the known-constant baseline + * (healthy = 0): a "symptom return" is the burden back below the adverse floor + * and held for `RETURN_STABILITY_DAYS` LOGGED days. Always `adverse:true` (it + * is illness-relevant by construction), so it contributes to + * `adverseCoverageDays` and can drive the headline gap even on a + * passive-vital-thin episode. Optional. + */ + symptomBurden?: SymptomBurdenPoint[]; /** Provenance source the dominant read resolved against. */ source: "DAY" | "live" | "none"; /** Compute time (injected for deterministic tests). */ @@ -201,7 +243,11 @@ export interface VitalDeviationFinding { /** A vital's recovery timing within the episode. */ export interface VitalReturnFinding { - type: MeasurementType; + /** + * The metric the return describes — a `MeasurementType` for a passive vital, + * or `FUNCTIONAL_IMPACT_RETURN_KEY` for the user-logged symptom-burden track. + */ + type: MeasurementType | typeof FUNCTIONAL_IMPACT_RETURN_KEY; /** First day the vital re-entered its band AND stayed in for the window. */ returnedDay: string | null; /** Days from felt-better to physiological return (signed); null if N/A. */ @@ -255,6 +301,16 @@ export interface IllnessCorrelationValue { adverseCoverageDays: number; /** Felt-better marker echoed for the surface. */ feltBetterDay: string | null; + /** + * The metric whose physiological return dominates the headline gap — the + * adverse return-track whose own gap is closest to the median, with the + * functional-impact symptom track winning ties (it is the most + * illness-specific signal). A `MeasurementType`, + * `FUNCTIONAL_IMPACT_RETURN_KEY`, or null when no adverse return drove a gap. + * The card names it ("…before your symptoms eased" vs "…before your resting + * heart rate settled"). Retrospective only. + */ + gapDriverType: string | null; /** * Red-flag escalation — a sustained adverse SpO2 drop or sustained fever * during the episode. Retrospective, but the copy must escalate ("if this @@ -471,6 +527,21 @@ export function computeIllnessCorrelation( } } + // Symptom-burden return track — the user's OWN logged daily-impact curve, + // folded in as one more return against the KNOWN-CONSTANT baseline (healthy + // = 0). No MAD band, no contamination guard, no min-baseline floor: the band + // IS the constant 0, so it sidesteps every reliability caveat the passive + // vitals carry. Always adverse by construction (a logged impact is illness- + // relevant), so it contributes to `adverseCoverageDays` and the headline gap + // even when the passive vitals are thin — the genuine illness-relevant + // contributor the WEIGHT-only floor lacked. Logged-days-only stability: + // sparse logging WITHHOLDS the return rather than fabricating an all-clear. + const symptomReturn = computeSymptomReturn(input.symptomBurden ?? [], window); + if (symptomReturn) { + returns.push(symptomReturn.finding); + for (const day of symptomReturn.adverseDays) adverseDayKeys.add(day); + } + // Red-flag escalation runs DECOUPLED from the banded loop: it scans the raw // episode days for SpO2 + temperature against ABSOLUTE clinical floors, so a // rock-steady (MAD=0) vital that drops the band filter still escalates — the @@ -489,6 +560,31 @@ export function computeIllnessCorrelation( ? null : Math.round(median(gaps)); + // Name the per-episode driver: among the ADVERSE return tracks that produced + // a real gap, the one whose own gap sits closest to the headline median (it + // is the return that "explains" the headline). The functional-impact symptom + // track wins ties — it is the most illness-specific signal the engine has, so + // when it co-explains the gap it gets to name it ("…before your symptoms + // eased"). Null when no adverse return drove a gap. + const gapDriverType = + recoveryGapDays === null + ? null + : (returns + .filter( + (r): r is VitalReturnFinding & { gapDays: number } => + r.gapDays !== null && r.adverse, + ) + .sort((a, b) => { + const da = Math.abs(a.gapDays - recoveryGapDays); + const db = Math.abs(b.gapDays - recoveryGapDays); + if (da !== db) return da - db; + // Tie: prefer the functional-impact symptom track, then by name. + const fa = a.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1; + const fb = b.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1; + if (fa !== fb) return fa - fb; + return String(a.type) < String(b.type) ? -1 : 1; + })[0]?.type ?? null); + const { coverage, confidence } = deriveCoverage({ requiredInputs: 1, presentInputs: banded.length, @@ -510,6 +606,7 @@ export function computeIllnessCorrelation( recoveryGapDays, adverseCoverageDays: adverseDayKeys.size, feltBetterDay: window.feltBetterDay, + gapDriverType: gapDriverType === null ? null : String(gapDriverType), redFlags, }, coverage, @@ -544,6 +641,87 @@ function firstStableReturn( return null; } +/** + * Compute the functional-impact (symptom-burden) return track for one episode. + * + * The symptom curve's baseline is the KNOWN CONSTANT 0 (healthy), so this needs + * none of the passive vitals' MAD-band / contamination machinery — "adverse" is + * simply `impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR`, "in band" is below it. + * + * - Only LOGGED days (present in `burden`) participate — never zero-filled. + * - The anchor is the first logged adverse day (same role as the vital + * `firstDeviationIndex`): a return search starts AFTER it, so a run-up is + * never read as a return. + * - A "return" is the first logged day at/after the anchor that is in-band AND + * stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days + * stability (not calendar days) is the honest-withholding handler for sparse + * journals: a single trailing impact-0 log never satisfies the run, so the + * track WITHHOLDS rather than fabricating recovery from absence of rows. + * - The gap is `dayDiff(feltBetterDay, returnedDay)`, same signed semantic. + * - Always `adverse:true` (a logged symptom curve is illness-relevant), so it + * feeds `adverseCoverageDays` and can drive the headline gap. + * + * Returns null (contributes nothing) when no adverse day was logged or the + * episode is CHRONIC_ONGOING. `adverseDays` is the set of logged adverse days in + * the active span, for the coverage tally. + */ +function computeSymptomReturn( + burden: SymptomBurdenPoint[], + window: EpisodeWindow, +): { finding: VitalReturnFinding; adverseDays: string[] } | null { + if (window.lifecycle === "CHRONIC_ONGOING") return null; + // Active span only, chronological. (Logged days are sparse and arbitrary, so + // a stable sort by the day key is the canonical order.) + const active = burden + .filter((p) => p.day >= window.onsetDay) + .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + if (active.length === 0) return null; + + const adverseDays = active + .filter((p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) + .map((p) => p.day); + // No adverse day logged → there is nothing to "return" from. + if (adverseDays.length === 0) return null; + + // Anchor: first logged adverse day. The return search starts there so an + // early in-band log (impact 0 before symptoms appeared) is never a return. + const firstAdverseIndex = active.findIndex( + (p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR, + ); + let returnedDay: string | null = null; + for (let i = firstAdverseIndex; i < active.length; i++) { + if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue; + // Count consecutive in-band LOGGED days from here. + let run = 0; + for ( + let j = i; + j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR; + j++ + ) { + run++; + } + if (run >= RETURN_STABILITY_DAYS) { + returnedDay = active[i].day; + break; + } + } + + const gapDays = + returnedDay && window.feltBetterDay + ? dayDiff(window.feltBetterDay, returnedDay) + : null; + + return { + finding: { + type: FUNCTIONAL_IMPACT_RETURN_KEY, + returnedDay, + gapDays, + adverse: true, + }, + adverseDays, + }; +} + /** * Detect the retrospective red flags — DECOUPLED from the banded loop and the * own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts index ec3cbbaff..e9a2eade5 100644 --- a/src/lib/insights/derived/sleep-rhythm.ts +++ b/src/lib/insights/derived/sleep-rhythm.ts @@ -75,10 +75,14 @@ export interface SleepDebtDto { * Wire DTO for the average sleep per night over the rhythm window. * * v1.19.1 — a third peer card next to sleep-debt + chronotype. The mean of the - * canonical per-night asleep totals over the same scorable-night set the debt - * and chronotype read, so the three cards can never contradict one another. - * Carries the same calm `partial` state under a minimum-night threshold so it - * never asserts an average off one or two thin nights. + * canonical per-night asleep totals over the full scorable span (up to + * `DEFAULT_WINDOW_DAYS`). Each of the three cards reads its OWN sub-window — + * sleep-debt caps to 14 nights, chronotype to `CHRONOTYPE_WINDOW_NIGHTS`, this + * average over every scorable night — so the figures are each internally honest + * rather than identical; the card caption discloses its `nightsCounted` so the + * reader sees exactly which span produced the number. Carries the same calm + * `partial` state under a minimum-night threshold so it never asserts an average + * off one or two thin nights. */ export interface AverageSleepDto { state: "partial" | "ready"; @@ -261,8 +265,11 @@ export function computeSleepRhythmFromNights( })); const chronotype = computeChronotype(chronoNights); - // Average sleep per night over the same scorable set the debt + chronotype - // read, so the three peer cards share one window and can't disagree. + // Average sleep per night over the full scorable span. Each of the three peer + // cards reads its OWN window — debt slices its trailing 14 nights, chronotype + // its trailing CHRONOTYPE_WINDOW_NIGHTS, this average every scorable night — + // so the figures are each internally honest rather than identical; each card's + // caption shows its own nightsCounted so the reader sees the span behind it. const averagePerNight = computeAverageSleep(scorable); return { diff --git a/src/lib/measurements/__tests__/create-from-telegram.test.ts b/src/lib/measurements/__tests__/create-from-telegram.test.ts new file mode 100644 index 000000000..7dfdf3141 --- /dev/null +++ b/src/lib/measurements/__tests__/create-from-telegram.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { measurement } = vi.hoisted(() => ({ + measurement: { + findUnique: vi.fn(), + create: vi.fn(), + }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { measurement }, +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addMeta: vi.fn() }), +})); + +import { + logTelegramMeasurement, + isTelegramCapturableType, + parseTelegramNumber, +} from "@/lib/measurements/create-from-telegram"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +beforeEach(() => { + vi.clearAllMocks(); + measurement.findUnique.mockResolvedValue(null); + measurement.create.mockResolvedValue({ id: "m-1" }); +}); + +describe("parseTelegramNumber", () => { + it("parses an integer", () => { + expect(parseTelegramNumber("72")).toBe(72); + }); + it("parses a comma decimal (de-DE keyboard)", () => { + expect(parseTelegramNumber("82,5")).toBe(82.5); + }); + it("trims surrounding whitespace", () => { + expect(parseTelegramNumber(" 98 ")).toBe(98); + }); + it("rejects trailing unit noise", () => { + expect(parseTelegramNumber("72 bpm")).toBeNull(); + }); + it("rejects non-numeric text", () => { + expect(parseTelegramNumber("done")).toBeNull(); + }); +}); + +describe("isTelegramCapturableType", () => { + it("accepts single-value metrics", () => { + expect(isTelegramCapturableType("WEIGHT")).toBe(true); + expect(isTelegramCapturableType("BLOOD_GLUCOSE")).toBe(true); + }); + it("rejects blood pressure (needs two values)", () => { + expect(isTelegramCapturableType("BLOOD_PRESSURE_SYS")).toBe(false); + }); + it("rejects null / free-text reminders", () => { + expect(isTelegramCapturableType(null)).toBe(false); + expect(isTelegramCapturableType(undefined)).toBe(false); + }); +}); + +describe("logTelegramMeasurement", () => { + it("writes a Measurement with source=TELEGRAM and the canonical unit", async () => { + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "WEIGHT", + rawText: "82,5", + tz: "Europe/Berlin", + externalId: "telegram:measure:7777:555", + }); + + expect(result.status).toBe("ok"); + expect(measurement.create).toHaveBeenCalledTimes(1); + const data = measurement.create.mock.calls[0][0].data; + expect(data.userId).toBe("user-1"); + expect(data.type).toBe("WEIGHT"); + expect(data.value).toBe(82.5); + expect(data.source).toBe("TELEGRAM"); + expect(data.unit).toBe("kg"); + expect(data.externalId).toBe("telegram:measure:7777:555"); + expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("rejects a non-numeric reply without writing", async () => { + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "WEIGHT", + rawText: "heavy", + tz: null, + externalId: "telegram:measure:7777:556", + }); + expect(result.status).toBe("invalid_number"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("rejects an out-of-range value without writing", async () => { + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "WEIGHT", + rawText: "999", + tz: null, + externalId: "telegram:measure:7777:557", + }); + expect(result.status).toBe("out_of_range"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("refuses an unsupported (BP) type", async () => { + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "BLOOD_PRESSURE_SYS", + rawText: "120", + tz: null, + externalId: "telegram:measure:7777:558", + }); + expect(result.status).toBe("unsupported_type"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("is idempotent on a redelivered reply (externalId hit)", async () => { + measurement.findUnique.mockResolvedValue({ id: "existing" }); + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "PULSE", + rawText: "60", + tz: null, + externalId: "telegram:measure:7777:559", + }); + expect(result.status).toBe("ok"); + expect(measurement.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts new file mode 100644 index 000000000..dca7b0525 --- /dev/null +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -0,0 +1,157 @@ +/** + * v1.19.2 W-CHARTS — `readDailySeries` long-range tier step-up. + * + * Pins that a window WIDER than the DAY bucket cap routes through the + * tiered rollup reader (whole-history coverage, downsampled by tier) + * instead of the DAY path that would `LIMIT`/`slice` to the cap and + * silently drop the older history. Short / normal windows must NOT touch + * the tiered reader, so the common case stays byte-identical with the + * pre-v1.19.2 daily path (no perf regression). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + queryRaw: vi.fn(), + readTieredRollupSeries: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurementRollup: { findMany: mocks.findMany }, + $queryRaw: mocks.queryRaw, + }, +})); + +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readTieredRollupSeries: mocks.readTieredRollupSeries, +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeUserRollups: vi.fn(), +})); + +import { readDailySeries } from "../daily-series-read"; + +const DAY_MS = 86_400_000; + +beforeEach(() => { + mocks.findMany.mockReset(); + mocks.queryRaw.mockReset(); + mocks.readTieredRollupSeries.mockReset(); + // Default DAY-rollup read returns enough rows (≥ SUSPICIOUS_ROW_FLOOR) + // so the short-window path resolves without the coverage probe / live + // query firing. + mocks.findMany.mockResolvedValue([ + { + type: "WEIGHT", + source: "MANUAL", + bucketStart: new Date("2026-06-01T00:00:00.000Z"), + mean: 81, + count: 1, + sumValue: null, + minValue: 81, + maxValue: 81, + }, + { + type: "WEIGHT", + source: "MANUAL", + bucketStart: new Date("2026-06-02T00:00:00.000Z"), + mean: 82, + count: 1, + sumValue: null, + minValue: 82, + maxValue: 82, + }, + { + type: "WEIGHT", + source: "MANUAL", + bucketStart: new Date("2026-06-03T00:00:00.000Z"), + mean: 80, + count: 1, + sumValue: null, + minValue: 80, + maxValue: 80, + }, + ]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("readDailySeries — long-range tier step-up", () => { + it("routes a multi-year window through the tiered reader and returns its whole-history rows", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 3650 * DAY_MS); + const tieredRows = [ + { + type: "WEIGHT", + value: 80, + measuredAt: "2017-01-01T00:00:00.000Z", + count: 12, + }, + { + type: "WEIGHT", + value: 79, + measuredAt: "2026-01-01T00:00:00.000Z", + count: 12, + }, + ]; + mocks.readTieredRollupSeries.mockResolvedValueOnce({ + granularity: "MONTH", + rows: tieredRows, + }); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1); + expect(mocks.readTieredRollupSeries.mock.calls[0][0].windowDays).toBe(3650); + // Whole-history coverage: the earliest 2017 bucket survives. + expect(result[0].measuredAt).toBe("2017-01-01T00:00:00.000Z"); + expect(result).toHaveLength(2); + // The DAY-rollup path was NOT consulted for the long window. + expect(mocks.findMany).not.toHaveBeenCalled(); + }); + + it("falls through to the daily path on a tiered coverage miss (no silent empty)", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 3650 * DAY_MS); + mocks.readTieredRollupSeries.mockResolvedValueOnce(null); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1); + // Daily DAY-rollup read still ran as the fallback. + expect(mocks.findMany).toHaveBeenCalled(); + expect(result).toHaveLength(3); + }); + + it("does NOT touch the tiered reader for a normal 90-day window", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 90 * DAY_MS); + + await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + expect(mocks.readTieredRollupSeries).not.toHaveBeenCalled(); + expect(mocks.findMany).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts b/src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts new file mode 100644 index 000000000..67db1f4a4 --- /dev/null +++ b/src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * v1.19.2 — `User.telegramChatId` must stay UNIQUE. The inbound webhook + * resolves the user from the chat id; a chat shared across two accounts + * would route a reply / button tap / numeric capture ambiguously. The + * constraint is the structural guard, so this asserts it never silently + * drops out of the schema on a future regeneration. + */ +const SCHEMA_PATH = join(__dirname, "../../../..", "prisma", "schema.prisma"); + +describe("User.telegramChatId schema invariant", () => { + const schema = readFileSync(SCHEMA_PATH, "utf8"); + + it("declares the column @unique", () => { + const line = schema.split("\n").find((l) => l.includes("telegramChatId")); + expect(line).toBeDefined(); + expect(line).toMatch(/@unique/); + }); + + it("ships the 0189 unique-index migration", () => { + const migrationPath = join( + __dirname, + "../../../..", + "prisma", + "migrations", + "0189_v1192_telegram_chat_unique_and_source", + "migration.sql", + ); + const sql = readFileSync(migrationPath, "utf8"); + expect(sql).toMatch(/CREATE UNIQUE INDEX IF NOT EXISTS/); + expect(sql).toMatch(/users_telegram_chat_id_key/); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 866ecc5c6..d6be7265a 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -1082,6 +1082,16 @@ export interface AppleHealthEntryInput { * existing category-value slot still round-trips. */ categoryValue?: number; + /** + * v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated + * heart-rate row. The hour's MIN / MAX bpm in Apple's native unit + * (pre-conversion), companions to `value` (the hour's AVERAGE). The + * mapper applies the SAME `convertToDbUnit` it applies to `value`. + * Ignored for every non-quantity / non-HR-bucket entry; the caller + * persists them only on the hourly HR bucket row. + */ + valueMin?: number; + valueMax?: number; } /** Output of `mapAppleHealthEntry()`. */ @@ -1090,6 +1100,13 @@ export interface AppleHealthEntryOutput { value: number; unit: string; takenAt: Date; + /** + * v1.19.2 (iOS #34 extension) — converted per-bucket MIN / MAX, set + * only when the input carried them (the hourly HR bucket). NULL/absent + * for every other row. + */ + valueMin?: number; + valueMax?: number; /** Set only for sleep entries. */ sleepStage?: SleepStage; /** @@ -1132,6 +1149,17 @@ export function mapAppleHealthEntry( takenAt, }; + // v1.19.2 (iOS #34 extension) — carry the per-bucket MIN / MAX through + // the SAME unit conversion as `value` when the caller supplied them (the + // hourly HR bucket). A finite check guards a malformed payload; the + // caller only persists these on the HR-bucket row. + if (typeof input.valueMin === "number" && Number.isFinite(input.valueMin)) { + out.valueMin = mapping.convertToDbUnit(input.valueMin); + } + if (typeof input.valueMax === "number" && Number.isFinite(input.valueMax)) { + out.valueMax = mapping.convertToDbUnit(input.valueMax); + } + if (mapping.sleepStageMap) { if (input.sleepStage === undefined) return null; const stage = mapping.sleepStageMap[input.sleepStage]; diff --git a/src/lib/measurements/create-from-telegram.ts b/src/lib/measurements/create-from-telegram.ts new file mode 100644 index 000000000..8b415e253 --- /dev/null +++ b/src/lib/measurements/create-from-telegram.ts @@ -0,0 +1,164 @@ +/** + * v1.19.2 — server-authoritative measurement capture from the Telegram bot. + * + * The HTTP measurement-create path (`POST /api/measurements`) is bound to a + * cookie/Bearer session via `requireAuth`; the Telegram webhook resolves the + * user from the linked chat instead. This helper holds the shared write so + * the two entry points agree on the canonical shape: the same range guard, + * the same canonical unit, the same cache invalidation + DAY-rollup + * recompute the manual route fires. `source` is pinned to the `TELEGRAM` + * `MeasurementSource` value (added in migration 0189). + * + * The `userId` is NEVER taken from the Telegram payload — the caller passes + * the id it resolved from the stored `telegramChatId` binding. The capture + * is correlated to a specific `MeasurementReminder` (carrying the expected + * `measurementType`) through a `TelegramPromptContext` row, so a numeric + * reply can only ever land the metric that reminder asked for, for that + * user. BP (two values) is out of scope for the single-numeric-reply path. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma as defaultPrisma } from "@/lib/db"; +import { + getUnitForType, + validateMeasurementRange, +} from "@/lib/validations/measurement"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; +import { getEvent } from "@/lib/logging/context"; + +/** + * The single-value measurement types a numeric Telegram reply can capture. + * + * Excludes BLOOD_PRESSURE_SYS — a BP reading needs systolic AND diastolic, + * which a single numeric reply can't express unambiguously. The BP reminder + * keeps its existing satisfy-only "done" flow; a value capture for it is a + * documented follow-up (a guided two-number prompt). + */ +const TELEGRAM_CAPTURABLE_TYPES: ReadonlySet = + new Set([ + "WEIGHT", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "BODY_FAT", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + "BODY_MASS_INDEX", + ]); + +/** + * True when a numeric Telegram reply can capture a Measurement for this + * reminder's target type. The webhook gates the value-prompt on this so a + * BP reminder (or a free-text Vorsorge with no `measurementType`) never + * offers an ambiguous numeric capture. + */ +export function isTelegramCapturableType( + type: MeasurementType | null | undefined, +): type is MeasurementType { + return type != null && TELEGRAM_CAPTURABLE_TYPES.has(type); +} + +export interface TelegramMeasurementResult { + /** `ok` — a row was written (or already existed under the dedup key). + * `invalid_number` — the reply did not parse to a finite number. + * `out_of_range` — the value fell outside the type's plausible range. + * `unsupported_type` — the type is not single-value capturable. */ + status: "ok" | "invalid_number" | "out_of_range" | "unsupported_type"; +} + +/** + * Parse a free-text Telegram reply into a finite number. Tolerates a + * comma decimal separator (de-DE keyboards) and surrounding whitespace; + * rejects anything with trailing non-numeric noise so "72 bpm" doesn't + * silently capture 72. + */ +export function parseTelegramNumber(text: string): number | null { + const normalised = text.trim().replace(",", "."); + if (!/^-?\d+(\.\d+)?$/.test(normalised)) return null; + const n = Number(normalised); + return Number.isFinite(n) ? n : null; +} + +/** + * Capture a measurement on behalf of a Telegram-linked user from a numeric + * reply to a measurement-reminder prompt. + * + * Idempotent on `externalId` (the per-prompt key the caller derives from + * `telegram:measure::`): a redelivered reply converges + * onto the same row via the `(userId, type, source, externalId)` unique + * instead of minting a duplicate. + */ +export async function logTelegramMeasurement(input: { + userId: string; + type: MeasurementType; + rawText: string; + tz: string | null; + /** Stable per-prompt id for Telegram redelivery dedup. */ + externalId: string; + client?: PrismaClient | Prisma.TransactionClient; +}): Promise { + const prisma = (input.client ?? defaultPrisma) as PrismaClient; + + if (!isTelegramCapturableType(input.type)) { + return { status: "unsupported_type" }; + } + + const value = parseTelegramNumber(input.rawText); + if (value === null) { + return { status: "invalid_number" }; + } + + if (validateMeasurementRange(input.type, value) !== null) { + return { status: "out_of_range" }; + } + + const measuredAt = new Date(); + + const existing = await prisma.measurement.findUnique({ + where: { + userId_type_source_externalId: { + userId: input.userId, + type: input.type, + source: "TELEGRAM", + externalId: input.externalId, + }, + }, + select: { id: true }, + }); + if (existing) { + return { status: "ok" }; + } + + await prisma.measurement.create({ + data: { + userId: input.userId, + type: input.type, + value, + unit: getUnitForType(input.type), + source: "TELEGRAM", + measuredAt, + externalId: input.externalId, + }, + }); + + invalidateUserMeasurements(input.userId, { evict: true }); + + // Best-effort rollup refresh — a cache tier, never a write-path invariant. + try { + await recomputeBucketsForMeasurement(input.userId, input.type, measuredAt); + } catch (rollupErr) { + getEvent()?.addMeta( + "telegram_measurement_rollup_failed", + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + ); + } + + return { status: "ok" }; +} diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 969c8f140..8759c4e72 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -20,6 +20,7 @@ import { prisma } from "@/lib/db"; import { BUCKET_CAP } from "@/lib/measurements/range-aggregation"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { collapseRollupRowsBySource } from "@/lib/rollups/measurement-read"; +import { readTieredRollupSeries } from "@/lib/rollups/measurement-read-wmy"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; import { annotate } from "@/lib/logging/context"; @@ -59,6 +60,45 @@ export async function readDailySeries(opts: { const { userId, type, from, to, priorityJson } = opts; const cap = Math.min(opts.limit ?? BUCKET_CAP.daily, BUCKET_CAP.daily); + // v1.19.2 — whole-history step-up for very long ranges. When the + // requested window holds more days than the DAY bucket cap can carry, + // the daily path below would `LIMIT`/`slice` to `cap` (365) buckets and + // silently drop the older history — a multi-year "Alle" range collapsed + // to roughly the most recent year. Step UP the bucket tier + // (DAY → WEEK → MONTH → YEAR) so the series spans the whole window + // inside a sane point budget instead. The chart's own `bucketTimeSeries` + // downsampler already renders week / month points for long ranges, so a + // coarser server tier is the resolution it would have collapsed to + // anyway — minus the truncation. Short / normal ranges (≤ cap days) + // never enter this branch and stay byte-identical with the prior daily + // path, so there is no perf regression for the common case. + const windowDaysFull = Math.ceil( + (to.getTime() - from.getTime()) / 86_400_000, + ); + if (windowDaysFull > cap) { + const tiered = await readTieredRollupSeries({ + userId, + type, + windowDays: windowDaysFull, + priorityJson, + }); + if (tiered && tiered.rows.length > 0) { + return tiered.rows; + } + // Coverage miss at every tier — fall through to the daily path, which + // probes the live table and folds an under-converged window. The + // daily fallback still caps at `cap`; the annotate below records that + // the long-range read could not step up so a truncated daily slice is + // an explicit, logged outcome rather than a silent one. + annotate({ + meta: { + tiered_series_coverage_miss: true, + window_days: windowDaysFull, + type, + }, + }); + } + const readRollup = async () => collapseRollupRowsBySource( await prisma.measurementRollup.findMany({ diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 900f49119..4b8131e8a 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -181,11 +181,12 @@ const illnessVitalReturn = z type: z.string(), returnedDay: z.string().nullable(), gapDays: z.number().nullable(), + adverse: z.boolean(), }) .meta({ id: "IllnessVitalReturn", description: - "A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days) from the felt-better marker (positive = the body lagged the feeling).", + "A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for the user-logged symptom-burden track (functionalImpact, baseline = constant 0).", }); const illnessRedFlag = z @@ -210,12 +211,13 @@ const illnessCorrelationValue = z recoveryGapDays: z.number().nullable(), adverseCoverageDays: z.number().int(), feltBetterDay: z.string().nullable(), + gapDriverType: z.string().nullable(), redFlags: z.array(illnessRedFlag), }) .meta({ id: "IllnessCorrelationValue", description: - "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns, the headline recovery-gap (median of per-vital gaps), and any red flags.", + "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags.", }); const illnessCorrelationResponse = z diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 9af53ec26..0c2a70172 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -30,7 +30,21 @@ const batchEntrySchema = z .number() .finite() .describe( - "Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm — v1.19.0 ships avg-only; min/max are a documented fast-follow.", + "Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides `valueMin` / `valueMax`.", + ), + valueMin: z + .number() + .finite() + .optional() + .describe( + "v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.", + ), + valueMax: z + .number() + .finite() + .optional() + .describe( + "v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.", ), unit: z.string().min(1).max(60), startDate: z.iso.datetime({ offset: true }), @@ -407,6 +421,20 @@ const seriesPointSchema = z.object({ .describe( "Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.", ), + valueMin: z + .number() + .nullable() + .optional() + .describe( + "v1.19.2 (iOS #34 extension) — per-point MINIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's low; null on a per-sample PULSE row and absent for every other kind.", + ), + valueMax: z + .number() + .nullable() + .optional() + .describe( + "v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.", + ), }); const seriesResponse = z diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index f47c6dffa..733937bb6 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -44,6 +44,7 @@ vi.mock("@/lib/db", () => ({ import { aggregateWmyBuckets, readBestGranularityRollups, + readTieredRollupSeries, type RollupBucketRow, } from "../measurement-read-wmy"; @@ -286,3 +287,126 @@ describe("readBestGranularityRollups — cross-consumer routing parity", () => { expect(monthAgg.mean).toBeCloseTo(dayAgg.mean ?? Number.NaN, 5); }); }); + +/** + * v1.19.2 W-CHARTS — whole-history series reader. Pins that a very long + * "Alle" window reads the display-matching coarse tier (WEEK for 1–2 y, + * MONTH beyond) and returns coverage spanning the FULL span rather than a + * truncated recent slice; that the finer fallback rescues a coverage + * miss; and that the wire shape mirrors the daily reader. + */ +describe("readTieredRollupSeries", () => { + it("returns null on a non-positive window without touching the db", async () => { + expect( + await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 0, + }), + ).toBeNull(); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("reads the MONTH tier for a multi-year window and spans the whole history", async () => { + // 10-year window → MONTH tier. The fixture carries buckets from the + // OLDEST month through the most recent — the result must keep the + // oldest bucket, proving no recent-slice truncation. + const months: RollupBucketRow[] = []; + for (let y = 2017; y <= 2026; y++) { + months.push(bucket(`${y}-01-01T00:00:00.000Z`, { count: 12, mean: 80 })); + } + findMany.mockResolvedValueOnce(months); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 3650, + }); + + expect(result).not.toBeNull(); + expect(result?.granularity).toBe("MONTH"); + expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); + // Whole-history coverage: the earliest 2017 bucket survives. + expect(result?.rows[0].measuredAt).toBe("2017-01-01T00:00:00.000Z"); + expect(result?.rows.at(-1)?.measuredAt).toBe("2026-01-01T00:00:00.000Z"); + expect(result?.rows.length).toBe(10); + }); + + it("reads the WEEK tier for a 1–2 year window", async () => { + findMany.mockResolvedValueOnce([bucket("2025-08-04T00:00:00.000Z")]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 540, + }); + expect(result?.granularity).toBe("WEEK"); + expect(findMany.mock.calls[0][0].where.granularity).toBe("WEEK"); + }); + + it("falls FINER (MONTH → WEEK → DAY) when the target tier has no coverage", async () => { + findMany + .mockResolvedValueOnce([]) // MONTH miss + .mockResolvedValueOnce([]) // WEEK miss + .mockResolvedValueOnce([bucket("2024-03-01T00:00:00.000Z")]); // DAY hit + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 3650, + }); + expect(result?.granularity).toBe("DAY"); + expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); + expect(findMany.mock.calls[1][0].where.granularity).toBe("WEEK"); + expect(findMany.mock.calls[2][0].where.granularity).toBe("DAY"); + }); + + it("returns null when no tier at or below the target carries coverage", async () => { + findMany.mockResolvedValue([]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 3650, + }); + expect(result).toBeNull(); + // MONTH, WEEK, DAY all probed (never coarser than the target). + expect(findMany).toHaveBeenCalledTimes(3); + }); + + it("surfaces the cumulative summed total for SUM metrics and drops the spread", async () => { + findMany.mockResolvedValueOnce([ + bucket("2025-01-01T00:00:00.000Z", { + count: 30, + mean: 8000, + sumValue: 240_000, + minValue: 0, + maxValue: 20_000, + }), + ]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "ACTIVITY_STEPS", + windowDays: 3650, + }); + expect(result?.rows[0].value).toBe(240_000); + expect(result?.rows[0].minValue).toBeUndefined(); + expect(result?.rows[0].maxValue).toBeUndefined(); + }); + + it("carries the count-weighted mean + spread for spot metrics", async () => { + findMany.mockResolvedValueOnce([ + bucket("2025-01-01T00:00:00.000Z", { + count: 12, + mean: 81.5, + minValue: 78, + maxValue: 85, + }), + ]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 3650, + }); + expect(result?.rows[0].value).toBe(81.5); + expect(result?.rows[0].minValue).toBe(78); + expect(result?.rows[0].maxValue).toBe(85); + }); +}); diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 7d9222ec7..46eb1235d 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -61,6 +61,8 @@ import type { } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { annotate } from "@/lib/logging/context"; import { collapseRollupRowsBySource, loadUserSourcePriority, @@ -272,3 +274,135 @@ async function readGranularity( maxValue: r.maxValue, })); } + +/** + * Wire-row shape the chart-data client consumes — mirrors + * `DailySeriesRow` in `daily-series-read.ts` so a tier-stepped series + * interleaves with the daily path without the caller branching on the + * source granularity. `measuredAt` is the bucket-start ISO string at the + * resolved tier (one row per DAY / WEEK / MONTH / YEAR bucket). + */ +export interface TieredSeriesRow { + type: string; + value: number; + measuredAt: string; + count: number; + minValue?: number | null; + maxValue?: number | null; +} + +/** + * Pick the rollup granularity that matches the chart's own client-side + * `bucketTimeSeries` downsampler (`src/lib/charts/bucket-time-series.ts`): + * + * - > 730 days → MONTH (the chart renders month points) + * - 366–730 → WEEK (the chart renders week points) + * - ≤ 365 → DAY (daily resolution; the DAY cap covers it) + * + * Mirroring the display tier means the server returns exactly the + * resolution the chart will paint — no finer (which would truncate at the + * DAY cap) and no coarser (which would drop visible detail). The finer + * fallback in `readTieredRollupSeries` still rescues a tenant whose + * coarse buckets the worker has not minted yet. + */ +function pickRollupGranularityForWindow(windowDays: number): RollupGranularity { + if (windowDays > 730) return "MONTH"; + if (windowDays > 365) return "WEEK"; + return "DAY"; +} + +/** DAY → WEEK → MONTH → YEAR ordering for the finer-fallback walk. */ +const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"]; + +/** + * v1.19.2 — whole-history series reader for very long chart ranges. + * + * The DAY-only `readDailySeries` reader caps its result at + * `BUCKET_CAP.daily` (365) buckets. A multi-year "Alle" range therefore + * silently truncated to roughly the most recent year — the older history + * never reached the client even though the chart's own + * `bucketTimeSeries` downsampler would have rendered it as week / month + * points. This reader closes that gap by reading the bucket tier that + * matches the chart's display granularity (WEEK for 1–2 years, MONTH + * beyond), so the returned series spans the WHOLE requested window inside + * a sane point budget instead of being chopped to a recent slice. + * + * The tier is purely a downsampling choice: `count` and the + * count-weighted `mean` compose identically across any granularity (see + * the compositional contract above), so a 5-year window rendered as ~60 + * MONTH points carries the same trend as the ~1 800 DAY points would have + * — minus the truncation. `minValue` / `maxValue` ride through for the + * range band on spot metrics; cumulative metrics (steps, energy, + * distance) surface the bucket's summed total and drop the spread. + * + * Coverage handling: the target tier is read first; on a coverage miss + * (the boot backfill / worker has not minted the coarse buckets yet) the + * reader walks FINER (MONTH → WEEK → DAY) so a tenant still gets a usable + * series rather than an empty chart. Returns `null` only when no tier at + * or below the target carries any buckets for the window — the caller + * then falls through to its live-SQL path. + * + * The result is NOT capped — the tier selection bounds the row count + * (≤ ~104 weeks for the WEEK tier, ≤ ~12 months/year for MONTH). If a + * future tier ever risks an unbounded count it must step coarser rather + * than truncate; no silent cap lives on this path. + */ +export async function readTieredRollupSeries(opts: { + userId: string; + type: MeasurementType; + windowDays: number; + priorityJson?: unknown; +}): Promise<{ + granularity: RollupGranularity; + rows: TieredSeriesRow[]; +} | null> { + const { userId, type, windowDays, priorityJson } = opts; + if (!Number.isFinite(windowDays) || windowDays <= 0) return null; + + const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); + const priority = + priorityJson !== undefined + ? priorityJson + : await loadUserSourcePriority(userId); + + const target = pickRollupGranularityForWindow(windowDays); + // Walk the target tier first, then finer (never coarser — coarser would + // drop detail the chart can render). Stop at the first tier with coverage. + const targetIdx = TIER_ORDER.indexOf(target); + for (let i = targetIdx; i >= 0; i--) { + const granularity = TIER_ORDER[i]; + const rows = await readGranularity( + userId, + type, + granularity, + since, + priority, + ); + if (rows && rows.length > 0) { + const useSum = CUMULATIVE_HK_TYPES.has(type); + annotate({ + action: { name: "measurement.list" }, + meta: { + total: rows.length, + type, + aggregate: "tiered", + granularity, + target_granularity: target, + source: "rollup", + }, + }); + return { + granularity, + rows: rows.map((r) => ({ + type, + value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean, + measuredAt: r.bucketStart.toISOString(), + count: r.count, + minValue: useSum ? undefined : r.minValue, + maxValue: useSum ? undefined : r.maxValue, + })), + }; + } + } + return null; +} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 9d3197295..991094226 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -177,6 +177,13 @@ export const measurementSourceEnum = z.enum([ // path), exactly like WHOOP/FITBIT. Part of this enum so the read/response // shapes (and the iOS decoder) can decode the rows it surfaces. "OURA", + // v1.19.2 — Telegram numeric-reply capture. A reply to a measurement + // reminder is written server-side from the chat-bound webhook (not the + // cookie/Bearer client write path), so it carries its own source label. + // Part of this enum so the read/response shapes (and the iOS decoder) can + // decode the rows it surfaces; the client-facing write surfaces still + // reject it (see `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist). + "TELEGRAM", ]); /**