The v1.19.0 saturation fix missed two surfaces: the mood-by-time-of-day
bars and the mood calendar still rendered populated values at 0.55
opacity, reading as a dimmed glitch. Drop the dimming on populated bars
and cells (and the legend swatches); keep the quiet tint only on the
no-data empty state.
Compute the mean asleep minutes per night from the same canonical
per-night data the sleep-debt and chronotype cards read, exposed as a
new averagePerNight field on the sleep-rhythm DTO. Surface it as a third
peer card with identical chrome, so the three tiles sit side by side.
Calm partial state under a four-night floor; localised in six locales.
The canonical rotation registry (src/lib/crypto/encrypted-columns.ts) is the
source of truth its guard test cross-checks; add EcgRecording.waveformEncrypted
there (the script already references it) and drop the redundant parallel guard.
Update the sleep/rhythm route test to the v1.19.0 rolling 5-night balance
(nightsCounted 5, debt 300) instead of the old 14-night cumulative sum.
The insights provider-chain integration test mocks @/lib/insights/features but
omitted BRIEFING_FEATURE_WINDOW_DAYS, which the generate route now reads, so the
route threw on access. Add it to the mock.
The v1.19.0 EcgRecording.waveformEncrypted Bytes column was not added to the
key-rotation script, so an operator rotation would strand ECG waveforms on the
legacy key and dropping it would make them permanently undecryptable — the same
class as the v1.18.1 clinical-spine gap. Wire the column into the rotation and
add a schema-driven coverage test that fails CI if any encrypted Bytes column
is ever left out again.
The Telegram reminder flow confirms a dismissed measurement with
telegram.measurementDone; the parallel measurementDismissed string was added
but never wired, tripping the orphan-key i18n guard. Remove it from all
locales.
The chronotype card carried its own section heading on top of the title it
already renders, so 'Chronotype' appeared twice and the tile did not match
its sibling. Drop the standalone heading and bring the card to the same
shape as the sleep-debt tile: identical chrome and a bold primary value with
one secondary line. The chronotype band is the primary value at the same
scale the debt tile uses for its hours figure, with the natural sleep-
midpoint clock as the companion line.
The cumulative model summed every nightly deficit over a 14-night window
with no credit for catch-up sleep, so a sleeper a modest amount short on
most nights carried hours of standing debt that never recovered while the
pattern held. Replace it with a running balance over a 5-night window:
short nights add their capped shortfall, nights above need pay the balance
down at a sub-linear recovery rate, floored at zero. The headline now
reflects what is still owed tonight and shrinks as the sleeper catches up.
Extend the self-cleaning Telegram bot beyond medication. A mood reminder
now carries a 1-5 inline keyboard plus a "Note..." force-reply and a
"Later" action; a tap logs a MoodEntry (source TELEGRAM, server-side,
user resolved from the linked chat, idempotent on a per-tap externalId)
and confirms with a calm callback toast, no chat echo. An optional
free-text note attaches to that entry via the prompt-context correlation.
A measurement reminder carries "Done" (satisfies the cadence) and
"Later" (pushes nextDueAt forward) — completion and snooze only, no value
capture.
Prompts and replies self-clean in ~30 minutes: the window drops from 1h
and moves to a shared helper, the sender schedules an unanswered prompt's
deletion at send time, and the existing 15-min sweep also prunes expired
prompt-context rows. The webhook keeps the shared-secret + timingSafeEqual
+ rate-limit-before-secret posture; every write callback authenticates via
the stored chat binding. The Telegram chat id is added to the egress
redaction denylist.
Add TelegramPromptContext (migration 0187) mapping a bot force-reply
prompt to the entry a free-text reply attaches to, keyed on
(chatId, promptMsgId) and scoped to the owning user. MoodEntry.source
already accepts the free-text TELEGRAM value, so no enum change is
needed.
The notifications page stacked a breadcrumb strip directly above its h1,
so the page read as two headings (the title followed by a channels line).
Remove the breadcrumb so the page starts with a single heading, matching
every other page. Drop the now-unused breadcrumb i18n keys.
The notification preferences build event labels dynamically as
notifications.event<PascalCase>, so the static call-site coverage test
never enumerated them and three event types rendered raw keys:
MEASUREMENT_REMINDER, MEDICATION_INTAKE_SYNC and COACH_NUDGE. Add label
and description for each across all six locale bundles.
A range tab wider than the dashboard's batched series slice (90 / All on a
~30-day batch) re-read the narrow slice instead of fetching the requested
window, so the chart kept showing ~30 days. Thread the batch's coverage
day-span to the chart and read the preloaded slice only when the requested
window fits within it; a wider tab falls through to a real fetch. Also raise
the All-range request window past the old 365-day cap so longer histories
are no longer silently truncated client-side.
Round out the public demo so every feature is explorable:
- Glucose: a 14-day Nightscout-style CGM stream (a reading every 15 min)
alongside the existing spot panel, so the glucose surfaces render fully.
Tagged RANDOM to satisfy the glucose-context CHECK.
- Labs: a second older panel plus qualitative rows (negativ / nicht
nachweisbar) so the list, detail, sort and delete all have content and a
per-analyte trend.
- Illness journal: a resolved cold with a day-by-day symptom curve and an
active chronic condition carrying a recent flare.
- Cycle: opt the demo account in and seed five observed cycles with biphasic
basal body temperature, period flow, fertile-window mucus and OPK, per-day
symptoms, BODY_TEMPERATURE dual-writes, and a cached forecast.
Cleanup extended to the new tables in FK-safe order; the seed stays
idempotent across re-runs.
Move the non-medical disclaimer to the cycle-tracking on-ramp and drop it
from the repeating in-feature surfaces (calendar tab, predictions panel);
delete the now-unused disclaimer component. Align the left ring column with
the tab content rather than the tab strip. Raise the settings subheading
sizes to the standard card-description scale. Reflow the reminder rows so a
wrapping label no longer collides with its button at narrow widths. Unify
the wheel and phase-education tile padding. Cite the symptothermal
double-check primary source under the second fertility sign.
Oura's daily_resilience collection reports a categorical level — limited,
adequate, solid, strong, exceptional. Encode it ordinally (limited=1 …
exceptional=5) into the numeric measurement value so it fits the existing
model with no new categorical column: add the RESILIENCE measurement type
(migration 0186), fetch and map daily_resilience in the Oura sync, and skip
an unknown or missing level rather than coercing it to zero.
RESILIENCE_LEVELS in the Oura client is the single source of truth for the
encoding. The day-keyed externalId keeps a re-sync idempotent so a re-scored
day overwrites in place. Extend the measurement-type label, unit, range,
category, chart-token, and PR-direction maps; add the metric label across all
six locales.
The 0–14d raw band folded same-day readings on a UTC calendar key, so a
late-evening reading slipped onto the wrong day for non-UTC users and
across DST transitions. Key it on userDayKey(tz) instead and make the
timezone a required option so a caller cannot silently fall back to UTC.
The coarser DAY/WEEK/MONTH/YEAR bands stay UTC-anchored on purpose: the
rollup writer and the live aggregator both bucket on the UTC calendar
day and are parity-locked for the read-swap fallback. Document the split
and thread the resolved user timezone from the coach snapshot caller.
Surface the load-bearing first-run vars in docker-compose.yml and the README
quick-start: secret generation, the SESSION_COOKIE_SECURE=false LAN note, the
single persistent volume, and a /api/version verify step. Add an Unraid
Community Applications template plus a NAS walkthrough covering the external
Postgres setup, and a Portainer app template referencing the compose stack.
Drop vendor names from four metric definition bodies (wrist temperature,
falls, six-minute walk, sleep breathing disturbances) and frame the
six-minute-walk and breathing-index readings as screening proxies rather
than the supervised clinical test. Add a primary citation to the cardio
fitness body: each one-MET higher fitness associates with roughly
10-25% lower mortality (AHA, Circulation 2016). Propagated across all
six locales.
Apple Watch Blood-Oxygen samples OXYGEN_SATURATION through the day and
overnight. The readings map to latest aggregation and sit in neither the
cumulative SUM drain nor the daily-mean drain, so every sample piled up raw
forever. Fold out-of-window per-sample SpO2 rows to one daily-mean stats:
row, soft-delete the raw rows, and preserve the daily min/max on the rollup
DAY bucket via a pre-fold recompute — the lowest sample is the overnight
desaturation nadir, the clinically meaningful figure the mean would hide.
Generalise the PULSE min/max preservation to a min/max-preserving set so
the pre-fold recompute and the skipped post-fold recompute cover both PULSE
and SpO2; resting-HR derivation stays PULSE-only. RESPIRATORY_RATE is left
to the existing daily-mean drain it already belongs to.
The comprehensive briefing's bounded feature read (extractFeatures) was
paid more than once where it should not be: the on-demand route and the
shared generator each run a downgrade ladder that re-reads with identical
arguments, and a nightly warm has no shared object across consumers in a
single tick.
Add a request/run-scoped feature cache: withFeatureCacheScope opens an
AsyncLocalStorage store, getCachedFeatures memoises the first compute per
(userId, day, includeRaw, sinceDays), gated on a cheap salient-input
fingerprint so a mid-scope ingest recomputes and a probe failure fails
safe. Outside a scope it is a pure passthrough. Wired into the
comprehensive generator, the on-demand POST handler, and the nightly
per-candidate loop + forceWarmUser. Coach keeps its own snapshot.
The slow-moving status input fingerprint covered the card's own metric
rows plus mood and height, but not the FDR discovery channels that also
feed the prompt via getRelevantCorrelationsForMetric. A correlation
newly surfacing off a behaviour channel (e.g. daylight -> next-day
weight) left the card's own rows untouched, so the input gate re-stamped
the stale assessment and the discovery never reached the prose.
computeStatusInputFingerprint gains includeCorrelationChannels: it widens
the same grouped probe by the discovery behaviour + outcome channels
(deduped, mood excluded as it is mood-entry backed), so a channel change
flips the hash. Wired into weight-status, the one caller that folds a
relations block.
The Heart sync recorded only the device's atrial-fibrillation verdict per
on-device ECG. Fetch each recording's full waveform via the Heart get
endpoint and store the raw micro-volt samples so a real trace can later be
rendered.
The sample array is sensitive health data: it is held AES-256-GCM encrypted
at rest in the new EcgRecording.waveformEncrypted Bytes column (fail-closed
crypto), with the sampling frequency, sample count, duration, lead, average
heart rate, and AFib verdict kept as plaintext descriptors. A re-sync upserts
on (userId, source, externalRecordingId) so a recording is never duplicated;
a waveform fetch failure never fails the verdict ingest. Rendering UI is a
follow-up.
Define the go-forward aggregated heart-rate upload shape so clients can
stop posting one Measurement per raw HealthKit sample. A heart-rate row
keyed `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>`
carries the hour's average bpm as a single PULSE row and rides the
existing `stats:` overwrite path: a re-post within the hour overwrites
the value instead of duplicating, identical to the per-day cumulative
rows. The hour boundary is UTC so the key is timezone-stable.
The batch route rejects a row that targets the heart-rate bucket prefix
with a malformed hour suffix as a per-entry skip
(`malformed_hr_bucket_id`) rather than persisting an un-overwriteable
row; well-formed buckets, per-sample uuid ingest, and the per-day
cumulative overwrite path are all unchanged.
Average-only for now — the measurement row carries a single value
column, so hourly min/max would need a schema change and is left as a
follow-up. Documents all three externalId shapes plus the `updated`
status in the OpenAPI contract.
The medication detail clients computed the supply headline in the browser
via summariseSupply, risking web/iOS drift and pulling the shared math
into the client bundle. The GET /api/medications/[id]/inventory route now
computes the canonical SupplySummary server-side through summariseSupply
(the single source of truth) and ships it in the DTO; the Bestand and
Übersicht surfaces render the ready figures. Adds the MedicationSupplySummary
schema to the inventory list response and regenerates the OpenAPI contract.
The muted mood treatment (opacity-55 on the tag bars, fillOpacity 0.55 on the
distribution and weekday charts) read as a rendering glitch rather than a
deliberate calm tint. Drop the dimming so every mood bar paints at full
saturation in its level hue; the guard test now asserts the dimming stays gone.
A separate mood colour concept can be revisited later.
summariseSupply is rendered by the medication detail client surfaces, so the
v1.18.11 underflow wide-event it called pulled node:async_hooks into the client
chunk and failed the production build. Split the observability out: the helper
stays a pure clamp, a new pure detectSupplyUnderflow reports a would-be negative
pool, and the low-stock job emits the medication.inventory.underflow event at
its request-scoped seam where annotate can actually fire.
The codex vision gate accepted any gpt-5 prefix, which included the gpt-5-codex
specialist slugs that the surrounding contract documents as text-only. Each lab
OCR upload on such a slug burned a doomed image attempt before falling back.
Exclude the -codex slugs from the gate so multimodal gpt-5.x slugs still report
vision while the codex specialists stay on the text path.
The split commits added the directory barrels but left the original
single-file modules in the tree; Node resolves the .ts file ahead of the
directory index, so the barrels were shadowed. Remove the originals so
the split modules are the live source.