Merge pull request #357 from MBombeck/release/v1.19.2

v1.19.2 — recovery-gap symptom signal, resilience tile, long-range charts, richer device + Telegram data
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-21 21:32:20 +02:00
commit 84c143aec5
42 files changed
+2307 -102

No files matched your search

+21
View File
@@ -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.
+157 -6
View File
@@ -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:<hour>` 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:<hour>` 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:
+25 -1
View File
@@ -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. <b>genommen Ramipril</b>.",
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
"helpHeader": "<b>Verfügbare Befehle:</b>",
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen &lt;Name&gt; — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\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\n<b>Textbefehle:</b>\ngenommen &lt;Name&gt; — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\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.",
+25 -1
View File
@@ -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. <b>taken Ramipril</b>.",
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
"helpHeader": "<b>Available commands:</b>",
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken &lt;name&gt; — confirm an intake\n\n<b>Buttons in reminders:</b>\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\n<b>Text commands:</b>\ntaken &lt;name&gt; — confirm an intake\n\n<b>Buttons in reminders:</b>\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.",
+25 -1
View File
@@ -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. <b>tomado Ramipril</b>.",
"medicationNotFoundExact": "Medicamento no encontrado. Envía el nombre exactamente como aparece en HealthLog.",
"helpHeader": "<b>Comandos disponibles:</b>",
"helpBody": "/help — mostrar esta ayuda\n/add — registrar una toma\n/start — iniciar el bot\n\n<b>Comandos de texto:</b>\ntaken &lt;nombre&gt; — confirmar una toma\n\n<b>Botones en los recordatorios:</b>\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\n<b>Comandos de texto:</b>\ntaken &lt;nombre&gt; — confirmar una toma\n\n<b>Botones en los recordatorios:</b>\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.",
+25 -1
View File
@@ -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. <b>pris Ramipril</b>.",
"medicationNotFoundExact": "Médicament introuvable. Envoie le nom exactement tel qu’il apparaît dans HealthLog.",
"helpHeader": "<b>Commandes disponibles :</b>",
"helpBody": "/help — afficher cette aide\n/add — enregistrer une prise\n/start — démarrer le bot\n\n<b>Commandes texte :</b>\ntaken &lt;nom&gt; — confirmer une prise\n\n<b>Boutons dans les rappels :</b>\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\n<b>Commandes texte :</b>\ntaken &lt;nom&gt; — confirmer une prise\n\n<b>Boutons dans les rappels :</b>\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.",
+25 -1
View File
@@ -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. <b>preso Ramipril</b>.",
"medicationNotFoundExact": "Farmaco non trovato. Invia il nome esattamente come appare in HealthLog.",
"helpHeader": "<b>Comandi disponibili:</b>",
"helpBody": "/help — mostra questa guida\n/add — registra un'assunzione\n/start — avvia il bot\n\n<b>Comandi testuali:</b>\ntaken &lt;nome&gt; — conferma un'assunzione\n\n<b>Pulsanti nei promemoria:</b>\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\n<b>Comandi testuali:</b>\ntaken &lt;nome&gt; — conferma un'assunzione\n\n<b>Pulsanti nei promemoria:</b>\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.",
+25 -1
View File
@@ -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. <b>przyjęty Ramipril</b>.",
"medicationNotFoundExact": "Nie znaleziono leku. Wyślij nazwę dokładnie tak, jak w HealthLog.",
"helpHeader": "<b>Dostępne polecenia:</b>",
"helpBody": "/help — pokaż tę pomoc\n/add — zapisz przyjęcie\n/start — uruchom bota\n\n<b>Polecenia tekstowe:</b>\ntaken &lt;nazwa&gt; — potwierdź przyjęcie\n\n<b>Przyciski w przypomnieniach:</b>\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\n<b>Polecenia tekstowe:</b>\ntaken &lt;nazwa&gt; — potwierdź przyjęcie\n\n<b>Przyciski w przypomnieniach:</b>\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.",
+1 -1
View File
@@ -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",
@@ -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");
@@ -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:<UTC-hour>`) 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;
@@ -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");
+24 -1
View File
@@ -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:<UTC-hour>`), 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")
+1 -1
View File
@@ -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/*`
@@ -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([
+34
View File
@@ -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:<hour>` 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<Response> {
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<Response> {
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<Response> {
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<Response> {
},
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
@@ -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<string, boolean> }).select.valueMin,
).toBe(true);
expect(
(selectArg as { select: Record<string, boolean> }).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
+38 -1
View File
@@ -124,6 +124,15 @@ interface SeriesPoint {
* the night's TIME-ASLEEP total.
*/
sleepStages?: Partial<Record<SleepStage, number>> | 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,
+101 -4
View File
@@ -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,
+22 -7
View File
@@ -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");
@@ -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 (
<div className="space-y-5">
@@ -71,11 +75,19 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
</p>
<p className="text-muted-foreground mt-1 text-sm">
{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")}
</p>
</div>
+8
View File
@@ -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[];
}
@@ -234,3 +234,60 @@ describe("<RecoverySection> data-gating", () => {
expect(html).not.toContain('data-slot="recovery-empty"');
});
});
describe("<ResilienceTile> 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(<RecoverySection />);
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(<RecoverySection />);
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(<RecoverySection />);
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(<RecoverySection />);
expect(html).not.toContain('data-slot="recovery-empty"');
expect(html).toContain('data-slot="resilience-tile"');
// "adequate" band label is surfaced.
expect(html).toContain("Adequate");
});
});
@@ -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>,
): CoachStreamingMessage {
@@ -77,8 +77,6 @@ function streaming(
messageId: null,
errorCode: null,
usage: null,
startedAt: null,
reasoning: "",
...partial,
};
}
@@ -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
@@ -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. */}
<RestModeBanner annotation={restMode} />
{/* v1.19.2 — Oura resilience band, self-gating (renders nothing without
a reading). A calm ordinal readout, not a chart block. */}
<ResilienceTile />
{presentBlocks.map((block) => (
<RecoveryMetricBlock
key={block.type}
@@ -0,0 +1,135 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
/**
* v1.19.2 — calm readout for Oura's daily resilience level.
*
* v1.19.0 ingests `daily_resilience.level` end-to-end as the `RESILIENCE`
* MeasurementType, ordinal-encoded into the numeric value (limited=1 …
* exceptional=5; see `RESILIENCE_LEVELS` in `src/lib/oura/client`). The data
* landed but no surface rendered it; this tile is that surface.
*
* Resilience is a categorical band, not a continuous score, so the generic
* chart block would paint a misleading line over five discrete steps. Instead
* this tile reads the stored latest ordinal, names its band, and gives a calm
* one-line description plus a quiet "vs the recent average" cue. It is
* server-authoritative throughout — it reads the shared
* `["analytics", "summaries"]` slice (`latest` + `mean`, both computed
* server-side) and never re-derives the value.
*
* Calm posture (the maintainer's standing card rule): one neutral card, no
* green-when-good / red-when-low tint, no alarming chrome. The band label and
* the trend cue carry the read in muted text only.
*
* Self-gating: renders nothing when the metric has no readings, so a
* non-Oura account stays byte-identical. When the series is present but still
* sparse it shows the shared `<LearningGate>` 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 (
<Card data-slot="resilience-tile" data-metric="RESILIENCE">
<CardHeader>
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
<ShieldCheck
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="truncate">{t("measurements.typeResilience")}</span>
</CardTitle>
{bandKey != null ? (
<CardAction
data-slot="resilience-band"
className="text-foreground self-baseline text-lg font-semibold"
>
{t(bandKey)}
</CardAction>
) : null}
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm leading-relaxed">
{t("insights.resilience.explainer")}
</p>
{count < MIN_TREND_READINGS ? (
<LearningGate
compact
bodySlot="resilience-learning"
message={t("insights.resilience.learning")}
/>
) : trendKey != null ? (
<p
data-slot="resilience-trend"
className="text-muted-foreground text-xs"
>
{t(TREND_KEYS[trendKey])}
</p>
) : null}
</CardContent>
</Card>
);
}
@@ -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();
});
});
+57 -1
View File
@@ -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<SymptomBurdenPoint[]> {
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));
}
+179 -1
View File
@@ -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
+13 -6
View File
@@ -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 {
@@ -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();
});
});
@@ -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();
});
});
@@ -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/);
});
});
@@ -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];
@@ -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<MeasurementType> =
new Set<MeasurementType>([
"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:<chatId>:<promptMsgId>`): 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<TelegramMeasurementResult> {
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" };
}
+40
View File
@@ -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({
+4 -2
View File
@@ -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
+29 -1
View File
@@ -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:<hour>` 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:<hour>` 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
@@ -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);
});
});
+134
View File
@@ -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;
}
+7
View File
@@ -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",
]);
/**