mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
42 files changed
+2307
-102
No files matched your search
@@ -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
@@ -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
@@ -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 <Name> — 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 <Name> — 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
@@ -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 <name> — 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 <name> — 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
@@ -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 <nombre> — 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 <nombre> — 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
@@ -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 <nom> — 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 <nom> — 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
@@ -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 <nome> — 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 <nome> — 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
@@ -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 <nazwa> — 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 <nazwa> — 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
@@ -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
@@ -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
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user