HRV_RMSSD was read nowhere HRV is analysed: the correlation scan and the
illness engine only knew the SDNN series, so a strap or ring wearer's
HRV never took part in either. RMSSD is now its own outcome channel
(owned by the recovery module, like the summary tile) and its own
illness scan type with a drop read as adverse. It is not merged into
the SDNN channel: the estimators differ in scale, and one series of
both would correlate the change of device rather than the body.
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
A chronic ongoing condition has no end date, so counting it as rest mode
kept the account in it for good: Day N counted from an onset years back,
cadence nudges never resumed and every streak gap since the onset was
bridged. Only active episodes that are not CHRONIC_ONGOING (acute,
recurring, flare) now trigger rest mode, and Day N counts from the
earliest of those. The achievements streak freeze applies the same
filter to its own episode query.
Chronic conditions stay context: a coincident vital deviation is still
framed as explained by any active episode, and the Coach block and the
briefing still name them, with restMode false.
Refs #1216
The doctor report's pulse average and day points, the correlation channels,
the illness correlation, the personal baseline and the Coach's tiered context
each folded pulse readings into a day by plain mean. They now take the day as
the mean of its local hours' means and a window as the mean of its days, in
the zone each reader buckets by. The day aggregates carry the weighted sums for
an hourly-mean type, so every caller reads one day value. Every other type is
unchanged.
The hypoglycemia/hyperglycemia escalation push hardcoded mg/dL in all six
locale strings and interpolated the raw mg/dL reading regardless of the
user's glucose display preference. A mmol/L user hitting the confirmed-hypo
alarm read "70 mg/dL (65 mg/dL)" — the wrong unit at the single most
safety-critical moment.
Resolve User.glucoseUnit in the safety-floor write hook and convert both the
reading and the floor threshold before handing them to the notification
copy. The bundle strings now take {threshold} {unit} / {value} {unit}
instead of a baked-in "70 mg/dL" / "54 mg/dL".
A sustained fever (three calendar-consecutive days at or above the fever
red-flag floor) or sustained low SpO2 was already classified "seek care",
but the only escalation path ran inside a GET on the episode correlation
route — no push fired unless the user opened that tab, and no cron drove
it. The proactive on-write safety check covered only blood pressure and
glucose; the two signals with the strongest safety case had the weakest
delivery.
Add sustained-run temperature and SpO2 checks to the on-write
`runSafetyFloorCheck`, wired from both measurement-ingest seams. The run
length and clinical floors are shared with the retrospective detector (no
duplicated thresholds), day bucketing honours the user's timezone, and
escalation reuses the existing red-flag notification with its push-attempt
dedup so a febrile user logging repeatedly is alerted at most once per
reason per day.
The SpO2 sustained-low scan passed episodeDays straight to runFlag, relying
on the reader to deliver them in day order, while the fever path already
sorts its unioned series. The run scan is calendar-consecutive, so feed it
chronological input by construction: sort the filtered SpO2 points before
the scan, matching the fever path and removing the latent ordering coupling.
Both lastStableReturn and computeSymptomReturn counted the in-band settle
run by array-index adjacency instead of calendar days, so three sparse logs
spread over weeks could register a stable return and move the headline
recovery gap. Extend the run only while the prior point is in-band and the
calendar day immediately before, matching the runFlag rule, bounding the
run's span to its logged-day count.
Symptom and vital points are present-days-only, so a sparse febrile
series (e.g. days 10/15/20/25) was counted as one long run and falsely
fired the sustained_fever escalation. Continue a run only when the next
matching point is the calendar day immediately after the previous one,
using the existing dayDiff helper; otherwise start a fresh run of one.
The last-sustained-return walk nulled the recovery day whenever the final reading
sat out of band, even after a vital had settled for several days. A lone noisy
sample on the last logged day — common in resting-HR and HRV curves — then erased
a real recovery gap that had previously been reported.
Treat a trailing out-of-band tail shorter than the stability window as transient
noise and keep the settle day; only a sustained re-deviation (a tail at least as
long as the stability window, a genuine relapse with no re-settle) clears the
return. Apply the same noise tolerance to the symptom-burden track for parity.
Cover the trailing-outlier, genuine-relapse-then-re-settle, and
sustained-no-re-settle cases.
The fever escalation unions two individually-sorted sources — the passive
BODY_TEMPERATURE series and the day-log feverC — into a Map, then feeds the
entries to the consecutive-run scanner. Map iteration is insertion order, not
chronology, so an episode with both a thermometer feed and journaled fever read
the run out of day order: a broken fever could read as a sustained run (a false
seek-care escalation) or a genuine run could be hidden (a missed one).
Sort the unioned points by day before the scan. Cover both directions with
interleaved-source fixtures.
Adds a neutral sleep observation alongside the per-episode recovery gap: the
user's usual asleep minutes over their well baseline vs the mean over the
episode window, narrated as 'you slept more/less than usual' when the two
diverge. It is an observation only — never a recovery claim, never a return
track, and never part of the headline recovery-gap number.
Per-night asleep totals come from the canonical night reconstruction over the
baseline and episode windows, so a multi-source night (two writers for one
sleep) is counted once, matching every other sleep surface. The observation
withholds entirely on thin data — too few baseline or episode nights, or a
delta below a 30-minute floor — so day-to-day jitter is never narrated as a
finding.
Additive optional DTO field; no schema, no migration. Two locale keys across
the six bundles. Regenerate the OpenAPI contract from the Zod source.
A vital or symptom curve that settles, re-deviates mid-episode, and settles
again now reports the FINAL sustained in-band run as the recovery return rather
than the first. The earlier first-return rule locked onto an interim settle and
under-reported the gap whenever a relapse followed. The change is monotone — a
last return is always at or after the first — so no existing recovery number
shifts; it only extends the gap for episodes that genuinely relapsed.
The anchor-after-first-deviation guard is unchanged: an early in-band run-up is
still never read as a return. The symptom-burden track honours relapse
identically over logged days so the headline median never mixes first- and
last-return semantics.
Adds relapse, multiple-relapse, too-short-final-run, ends-out-of-band, and
symptom-relapse engine fixtures plus a cross-episode integration fixture.
The recovery-gap engine reasoned only over passive vitals and ignored
the journal's own per-day symptom series. Add the functional-impact
(symptom-burden) return track: read functionalImpact per episode day
(falling back to the strongest linked symptom severity) and treat it as
one more return against the known-constant baseline (healthy = 0).
The symptom track needs none of the MAD-band / contamination machinery
the passive vitals carry — the band is the constant 0 — so it is more
reliable to fold in than any passive vital. It is adverse by
construction, so it contributes to adverseCoverageDays and the headline
gap median, giving the qualifying-days floor a genuinely
illness-relevant contributor where a weight-only episode had none.
Stability is logged-days-only: a sparse journal that logs impact-0 once
and stops withholds the return rather than fabricating an all-clear from
the absence of further logs. gapDriverType names the dominant return
track, with the symptom curve winning ties as the most illness-specific
signal.
Add averagePerNight to the SleepRhythmResource OpenAPI schema so the new
average-per-night card's field is part of the iOS-consumed contract (the
endpoint already returned it; the Zod source had not been updated, so the
in-sync check could not catch the omission).
Tag each vital return with whether it deviated in the illness-adverse
direction and tally only adverse returns when naming the recovery driver,
so a neutral move (e.g. weight drift) can no longer be surfaced as the
vital someone recovered ahead of.
The cross-episode summary already computes per-vital physiological returns
but the card surfaced only one bare line. Thread the dominant returning
vital through the aggregate and the DTO so the copy reads 'you tend to feel
better before your resting heart rate settles' instead of a context-free
gap figure.
The aggregate tallies which vital produced a real return across the
episodes that qualified for the typical-gap median and reports the most
frequent one (deterministic alphabetical tiebreak); the driver is named
only when a gap actually clears the magnitude gate. Zero new compute — the
returns were already in hand. Adds the localized copy in all six locales.
The cross-episode typical-gap median counts an episode toward its
qualifying-days floor via the per-episode coverage `historyDays`, which
counts a day if ANY banded vital has a reading. A WEIGHT-only episode (no
illness-adverse direction) could therefore clear the floor and feed the
median despite carrying no illness-relevant signal, weakening the honest-
sample claim.
Compute a distinct-adverse-day count in the engine — days with at least
one notable reading in the metric's adverse direction — and feed that as
the qualifying-days floor instead. Reuses the engine's single adverse-
direction predicate (now exported as `isAdverseDeviation`); a neutral-
direction vital never counts a day. The insufficient arm contributes 0,
which is correct since its gap is null and never reaches the median.
A partial-update upsert soft-decrypted the existing encrypted column,
returned null on failure, then re-wrote it — so editing an unrelated
field permanently nulled an undecryptable note or sensitive envelope.
Preserve the existing ciphertext verbatim when the encrypted field is
absent from the update; only re-encrypt on an explicit field write.
Mirrors the v1.18.1 rotation fix.
The illness list load triggered the cross-episode correlation fan-out on
every mount, so the page waited on up to 24 per-episode reads before it
painted. Move that work behind an explicit opt-in: the insights endpoint
takes an includeRecoveryGap query param (default off), the list reads the
count breakdown on a single fast query, and the recovery gap is computed
only when the viewer opens the Analyse expansion.
Tighten the typical-gap gate against coincidental data. A gap now counts
only from an episode that cleared a per-episode measurement floor
(MIN_GAP_MEASUREMENT_DAYS), at least MIN_EPISODES_FOR_TYPICAL_GAP such
episodes must qualify, and the median magnitude must reach
MIN_TYPICAL_GAP_MAGNITUDE_DAYS — a 0/+-1-day gap is the coincidental
baseline and is withheld as noise.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
Add absolute, guideline-backed safety floors that escalate a freshly logged
reading — but only after a second, confirming reading inside a one-hour
re-test window, so a single oscillometric spike or a one-off finger-stick can
never raise a false alarm:
- blood pressure 180/120 or higher (re-check, contact your doctor; symptom-
coupled lifts to emergency copy) and a symmetric low-systolic-under-90
cautionary tier;
- glucose under 70 (low) and under 54 (very low), and a sustained very-high
250-or-higher tier. The 200 DKA criterion deliberately never shows an
all-clear and never urgently escalates on its own, since a value alone
cannot rule euglycemic DKA in or out.
The escalation reuses the urgent SYSTEM_ALERT path with a 24h per-reason
dedup, never names a condition, and gates glucose behind the glucose module.
An optional transient `symptomsPresent` flag on the measurement write lifts a
confirmed breach from the contact-your-doctor copy to the emergency copy. The
measurement POST runs the check fire-and-forget so a notification failure
never fails the user's write.
Add an urgent dimension to dispatched notifications so a self-hosted
instance reaches every configured channel at its strongest level without
assuming APNs is present:
- APNs -> interruption-level time-sensitive + priority 10 (critical
only when APNS_CRITICAL_ENTITLEMENT=true; time-sensitive needs no
Apple approval)
- ntfy -> Priority 5 (max) + warning tag (suppressed in discreet mode)
- Web Push-> Urgency: high send option + requireInteraction
- Webhook -> priority "urgent" in the JSON body
- Telegram-> normal delivery (no urgency tier)
Wire the illness red-flag escalation (sustained low SpO2 / sustained
fever) to emit an urgent SYSTEM_ALERT through the dispatcher, owner-scoped
and module-gated, de-duplicated per episode via the push-attempt ledger.
Normal events are unchanged.
Wrap episode-create in withIdempotency so an iOS retry can't mint duplicates.
Enforce resolvedAt >= onsetAt on create, edit, and the resolve verb (a partial
edit is checked against the stored onset). Run the day-log upsert, its symptom-
link replacement, and the read-back in one transaction and return the full DTO,
dropping the route's second round-trip. Restrict symptom severity to 1-3 (a
link's presence already means present, so 0 is not a distinct state). Return
the standard { deleted: true } envelope from DELETE instead of a bodyless 204.
Anchor the physiological-return search after the first notable deviation so
an early in-band run-up can no longer be reported as a return before the
vital ever deviated (the spurious-negative-gap bug). Decouple the red-flag
escalation from the baseline-band loop: it now scans the raw episode days for
SpO2 and temperature against absolute clinical floors, so a rock-steady
(MAD=0) vital still escalates a low SpO2 or sustained fever. Union the day-log
feverC series into the fever flag and prefer per-day max so an evening spike
is not masked by the daily mean. Key every per-day series in the user's
timezone (rollup buckets and the raw fallback) so the vital series lines up
with the tz-keyed onset and felt-better markers instead of an off-by-one for
non-UTC accounts.
An active illness/condition episode annotates, it never penalises: the
measured number is untouched, only the narrative changes.
- rest-mode.ts: one server-authoritative resolver (module-gated, fail-soft)
for "is the account in Rest Mode + which episodes back it". iOS mirrors the
resolved context, never recomputes the suppression.
- health-score: /api/analytics attaches a value-free Rest Mode annotation;
computeHealthScore stays pure (score never adjusted).
- recovery-score: computeRecoveryScore carries the Rest Mode context as of the
scored day; the persisted blend is unchanged.
- coach-nudge: pause cadence-nudges during an active episode (new
skippedDuringIllness counter, gate sits before the trigger search).
- achievements: bridgeFrozenStreakGaps freezes (does not break) every
day-streak across illness days; never invents a streak, a non-illness gap
still breaks.
- coincident-deviation: reframe a fired flag as illness-explained; vital
numbers untouched. Surfaced as a neutral line on the insights card.
- coach snapshot: illness history + active illness as load-bearing context so
the coach knows about Rest Mode (no decrypted note reaches the prompt).
- FHIR: illness episodes map to Condition (generic disease root, label on
code.text, patient-reported/unconfirmed) + a bounding Encounter.
- doctor-report: module-gated illness section in the aggregator + PDF table
(retrospective, neutral palette); FHIR Condition section in the bundle.
i18n x6 for the report + reframe copy. Tests: score-annotation-not-
penalisation, streak-freeze, coach-nudge pause, FHIR Condition/Encounter
mapping, doctor-report inclusion, Rest Mode resolver. typecheck/lint/test
green, openapi in sync, knip clean.
Add the retrospective correlation engine for illness episodes: a pre-onset
anomaly scan (deviation in robust SDs from the user's own median±MAD
baseline), the nadir / what-dropped across the active span, and the
recovery-gap (physiological return-to-baseline vs the felt-better marker).
CHRONIC_ONGOING is excluded from the gap. Every derived number is gated by
the shared coverage/confidence model — a thin signal asserts nothing rather
than showing a wrong figure.
The baseline window ends strictly before the pre-onset lookback, so an
episode's own days can never contaminate the baseline it is measured against.
Reads are DAY-native (rollup tier with a live-SQL fallback), reusing the
baseline engine's read path so the rollup-vs-live parity carries over.
Surfaces:
- GET /api/illness/episodes/{id}/correlation — per-episode findings (gated
Derived wire shape; red-flag escalation for sustained low SpO2 / fever).
- GET /api/illness/insights — cross-episode retrospective ("sick N times ·
typical recovery gap X days"), with the typical-gap withheld below the
min-sample floor. Retrospective only — never a predictor or diagnoser.
Reliability battery: golden-fixture recovery-gap math, rollup-vs-live parity,
baseline-contamination guard, thin-data / coverage-gate, red-flag escalation.
Born-gated + owner-scoped; server-authoritative DTO (iOS renders). i18n ×6.
Introduce a retrospective illness/condition journal: episodes with a
lifecycle (acute / chronic-ongoing / recurring / flare) and a daily
symptom timeline forked structurally from the cycle log-day surface. The
journal is a record, not a medical device — no prediction or diagnosis UI.
- Register `illness` as a born-gated (opt-in / default-off) module. The
gate resolver flips the disabled-allowlist default to off for born-gated
keys, so the surface stays dark until the account opts in from the
Modules hub. `isIllnessEnabled` + `requireIllnessEnabled` delegate to the
module foundation (one source of truth) and re-stamp an illness errorCode.
- Episode CRUD under /api/illness: list / create / get / edit / resolve /
delete, plus a per-episode-day day-log upsert on (episodeId, date). Every
route wraps apiHandler, Zod-validates with returnAllZodIssues, narrows
userId from auth, builds data field-by-field, and gates the module.
Free-text notes encrypt at rest through the shared Bytes codec.
- Log-day sheet (cycle fork): ~8 Jackson/WURSS symptom chips 0-3 + a
functional-impact group + fever + an encrypted note, one row per day.
- Episode history page + a born-gated nav entry, neutral palette, status
via a neutral Badge, retrospective-only copy. i18n across all six locales.
- Cover the new routes in the module-route-gate inventory; unit tests for
the day-log upsert, the gate enforcement, and the born-gated resolver.
The /api/auth/me module map now projects the `illness` flag (openapi.yaml
regenerated).