Commit Graph
2702 Commits
Author SHA1 Message Date
Marc-André Bombeck b2ff41070c feat(demo): showcase the seeded tiles on the demo dashboard
The demo account seeds rich sleep, steps, glucose, VO2max and workout
data but inserted no dashboard layout, so the home page rendered the
new-account default and under-sold the product on its primary surface.
Persist an explicit dashboard layout derived from the default with the
seeded-rich strip tiles forced on and the daily-verdict hero enabled, so
a fresh seed renders a full, lived-in dashboard for screenshots.
2026-06-22 07:01:03 +02:00
Marc-André Bombeck efd0470bc1 feat(dashboard): surface sleep, steps, glucose and vorsorge by default
The sleep, steps and glucose strip tiles shipped default-hidden even
though their modules are on by default and rich data flows in from the
sync integrations, so accounts had to hunt through Settings to see them.
Flip the three strip tiles to tileVisible:true and the vorsorge chart-row
card to visible:true, matching the VO2max precedent: each tile self-gates
on having data, so empty accounts keep a clean dashboard. Chart rows for
the three metrics stay off — those charts live on the /insights sub-pages.

totalBodyWater, boneMass and oxygenSaturation stay hidden: they carry as
catalogue ids for the iOS layout round-trip only and have no web render
path, so a flip would surface nothing. Note that at the splice point.

Update the silhouette skeleton and layout default tests for the new
counts.
2026-06-22 07:00:56 +02:00
Marc-André Bombeck 461190e63e 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
v1.19.2
2026-06-21 21:32:20 +02:00
Marc-André Bombeck b4bf31a8c9 chore(release): v1.19.2 2026-06-21 21:21:13 +02:00
Marc-André Bombeck c1d05c9e47 chore(openapi): regenerate for v1.19.2 schema additions
Pick up the HR bucket min/max fields, the TELEGRAM measurement source,
and the recovery-gap driver/return additions in one regeneration.
2026-06-21 21:08:28 +02:00
Marc-André Bombeck ad2634e41b Merge data-model additions into release/v1.19.2
Add per-bucket HR min/max (migration 0188) threaded through batch ingest
and the series reader; make telegram_chat_id unique (0189) with a
defensive pre-clean; make migration 0187 rerun-safe. Capture a numeric
measurement from a Telegram reminder reply (source=TELEGRAM, strict chat
binding, self-cleaning); blood pressure stays satisfy-only pending a
guided two-number prompt.
2026-06-21 21:07:46 +02:00
Marc-André Bombeck 5a2d759e50 Merge symptom-severity recovery track into release/v1.19.2
Fold the illness journal's per-day functional-impact curve (falling back
to linked symptom severity) into the recovery-gap as a return track
against the known-constant healthy baseline. It is adverse by
construction, feeds the qualifying-days floor and the gap median, and
withholds on sparse logging. The card names the driver when symptoms
lead ('…before your logged symptoms eased').
2026-06-21 21:07:02 +02:00
Marc-André Bombeck 9c8fb8c7c8 feat(telegram): unique chat binding and numeric measurement capture
Two related changes to the Telegram bot's data path.

One chat ⇄ one account. `User.telegramChatId` is now UNIQUE (migration
0189, defensive pre-clean of any pre-existing duplicate binding). The
inbound webhook resolves the user from the chat id, so a chat shared
across two accounts would route a reply or button tap ambiguously. The
index is over a nullable column, so an unlinked account (null chat id)
never collides — only two accounts binding the same non-null chat id are
rejected.

Numeric measurement capture. A numeric reply to a measurement reminder is
now captured as a Measurement (`source = TELEGRAM`, added to the enum in
0189). On "Erledigt" for a reminder that names a single-value metric, the
bot offers an optional value prompt via force_reply; a numeric reply is
written through the shared `logTelegramMeasurement` helper — same range
guard, canonical unit, cache + rollup refresh as the manual route. The
reply binds to the reminder and 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 payload. Comma
decimals are tolerated; non-numeric and out-of-range replies get a calm
hint and write nothing; the reply self-cleans on the ~30-min sweep. Blood
pressure (two values) is out of scope for the single-numeric path and
keeps its satisfy-only flow.
2026-06-21 21:06:42 +02:00
Marc-André Bombeck 5c0583133d chore(db): make the 0187 telegram-prompt-context migration rerun-safe
Guard the CREATE TABLE + both CREATE INDEX statements with IF NOT EXISTS so
a re-run on a database that already has the table is a no-op, matching the
guard convention every other recent migration ships with. Semantics are
unchanged — same table, same columns, same indexes.
2026-06-21 21:06:17 +02:00
Marc-André Bombeck 1352ba2aa6 feat(illness): name the symptom curve in the recovery-gap card
When the logged symptom curve drives the recovery gap, the card names it
('you felt better N days before your logged symptoms eased') instead of
the vital-driven phrasing. Mirror the new gapDriverType and the return
track's adverse flag into the client DTO, and add the driver-aware copy
across all six locales.
2026-06-21 21:06:14 +02:00
Marc-André Bombeck 868dfb72bd feat(measurements): add per-bucket heart-rate min/max to the hourly contract
The v1.19.0 hourly heart-rate bucket
(`stats:HKQuantityTypeIdentifierHeartRate:<UTC-hour>`) shipped avg-only:
one PULSE row carrying the hour's mean bpm. Add the hour's min/max so a
client can render the intra-hour range without re-uploading raw samples.

- Two nullable columns on `measurements` (`value_min` / `value_max`),
  migration 0188. Set only on a well-formed hourly HR bucket row; null for
  per-sample readings, per-day cumulative totals, and manual entries.
- Batch ingest threads the spread through the same unit conversion as the
  average and persists / overwrites it alongside `value` on the bucket
  row, pinning it null everywhere else.
- The series reader returns the spread for `kind=pulse` (null on a
  per-sample PULSE row); the OpenAPI Zod contract documents both fields on
  the batch entry and the series point. The avg path is unchanged.
2026-06-21 21:06:09 +02:00
Marc-André Bombeck fc76c39aea feat(illness): fold the logged symptom curve into the recovery gap
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.
2026-06-21 21:06:06 +02:00
Marc-André Bombeck 38e10b9ca0 Merge multi-year chart range into release/v1.19.2
A multi-year All range was silently truncated to the most recent year by
the DAY-only 365-row cap. Route windows wider than the cap through a new
tiered rollup reader (WEEK/MONTH by span, finer on coverage miss,
uncapped by tier choice); narrow windows stay byte-identical. No silent
truncation — a coverage miss logs and falls through to the daily path.
2026-06-21 21:02:06 +02:00
Marc-André Bombeck 9ea258556d docs(charts): note the multi-year tier step-up in the chart data path
The chart still requests aggregate=daily for windows over 7 days, but
the server now steps the rollup tier up to WEEK / MONTH for ranges past
the DAY cap and returns whole-history coverage. Update the stale
comments that claimed the "All" range was still capped at ~365 daily
buckets; the client fold + bucketTimeSeries downsample the coarse
buckets to the visible range as before.
2026-06-21 21:01:10 +02:00
Marc-André Bombeck 14ba0b013e fix(charts): step the rollup tier up for multi-year ranges
A multi-year "All" range read its series through the DAY-only daily
reader, which caps the result at BUCKET_CAP.daily (365) buckets. The
older history was silently dropped — the range collapsed to roughly the
most recent year instead of the whole span.

Add readTieredRollupSeries, which reads the bucket tier matching the
charts own display downsampler (WEEK for 1-2 years, MONTH beyond) and
returns whole-history coverage at that tier. readDailySeries now routes
any window wider than the DAY cap through it, falling finer on a coverage
miss and logging the miss rather than truncating silently. Short and
normal ranges never enter the branch, so the common path is unchanged.
2026-06-21 21:01:04 +02:00
Marc-André Bombeck 559db472fc Merge Oura resilience tile into release/v1.19.2
Surface the captured Oura resilience band (limited…exceptional) as a calm
tile on the recovery surface, with a quiet above/in-line/below-average cue
and a learning state while the series is sparse. Server-authoritative read
of the existing summaries slice; neutral card chrome.
2026-06-21 20:58:42 +02:00
Marc-André Bombeck ede01e5700 Merge L1/L2 cleanup into release/v1.19.2
Correct the sleep-rhythm peer-card comment to state plainly that each
card reads its own window (the captions disclose the per-card night
count); no computation changed. Remove the dead streaming.startedAt and
streaming.reasoning state left after the thinking-disclosure removal.
2026-06-21 20:57:35 +02:00
Marc-André Bombeck 7f40860891 feat(insights): surface the Oura resilience band on the recovery page
v1.19.0 ingests Oura's daily resilience level end-to-end (RESILIENCE,
ordinal-encoded limited=1 … exceptional=5) but no surface rendered it.
Add a calm resilience tile to the recovery page: it names the latest
band, gives a one-line description, and a quiet "vs the recent average"
cue once a few days exist, with a learning note while the series is
still sparse. Resilience is a categorical band, not a continuous score,
so it reads as a dedicated readout rather than a misleading line chart.

Server-authoritative — the tile reads the stored summaries slice (latest
+ mean, both computed server-side) and never re-derives the value. Calm
posture: one neutral card, no green-when-good / red-when-low tint. The
band label and trend cue carry the read in muted text only.

Self-gating: renders nothing without a reading, and counts toward the
recovery-page data gate so an Oura-only account no longer sees the empty
note. Copy lands in all six locales.
2026-06-21 20:57:30 +02:00
Marc-André Bombeck 1bf8f3d661 refactor(coach): drop the unread streaming startedAt and reasoning state
The thinking disclosure was removed, so the coach streaming bubble no
longer reads startedAt or reasoning. Both fields stayed populated by the
send hook but nothing consumed them. Remove them from
CoachStreamingMessage and every writer, accept the additive reasoning
SSE frame as an explicit no-op so the wire type stays recognised, and
update the thread-test fixture. Streaming is otherwise unchanged.
2026-06-21 20:54:39 +02:00
Marc-André Bombeck aa3d7fcdf7 docs(sleep): correct the sleep-rhythm peer-card window comment
The comment claimed the average-per-night, sleep-debt, and chronotype
cards share one window and cannot disagree. They each read their own
sub-window — the average over the full scorable span, sleep-debt over
its trailing 14 nights, chronotype over CHRONOTYPE_WINDOW_NIGHTS — so
the figures are each internally honest rather than identical, and every
card's caption discloses its own nightsCounted. State that plainly; no
computation changes.
2026-06-21 20:54:33 +02:00
Marc-André Bombeck 02ce177ac4 Merge pull request #349 from MBombeck/release/v1.19.1
v1.19.1 — Coach flow, sleep & mood polish, navigation, recovery-gap
v1.19.1
2026-06-21 14:46:12 +02:00
Marc-André Bombeck 67ead71e24 test(e2e): match the coach conversations handoff query string
The drawer's Conversations button now hands off to
/coach?view=conversations so the full page opens the conversation list on
arrival; widen the navigation assertion to accept the query string.
2026-06-21 14:35:59 +02:00
Marc-André Bombeck 41dab24985 chore(release): v1.19.1 2026-06-21 14:24:39 +02:00
Marc-André Bombeck e2ce064254 fix(illness,sleep): document the sleep average DTO and gate the recovery driver
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.
2026-06-21 14:23:24 +02:00
Marc-André Bombeck b9f396c72a Merge recovery-gap refinements into release/v1.19.1
Tighten the cross-episode typical-gap floor to count only active days
carrying an adverse-direction reading (a neutral weight-only episode can
no longer clear it), via a single shared adverse-direction predicate.
Surface the most-frequent returning vital so the card names the driver
('…before your resting heart rate settles') instead of a bare line.
2026-06-21 14:12:02 +02:00
Marc-André Bombeck 638bc6c835 feat(illness): name the driving vital in the recovery-gap summary
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.
2026-06-21 14:11:20 +02:00
Marc-André Bombeck 36affd5daf fix(illness): gate the recovery-gap floor on adverse-direction days
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.
2026-06-21 14:07:44 +02:00
Marc-André Bombeck 62a4dfdf7e Merge S1/S2 sleep average + mood saturation into release/v1.19.1
S1: add an average-per-night sleep card as a third peer beside debt and
chronotype, computed by a shared helper over the same scorable-night set
so the three cannot disagree; grid widens to three columns. S2: restore
full saturation on the two missed mood surfaces (calendar heatmap and the
time-of-day chart), keeping the quiet tint only for no-data cells.
2026-06-21 14:02:09 +02:00
Marc-André Bombeck bbff6ab143 fix(mood): full-saturate the time-of-day chart and mood calendar
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.
2026-06-21 14:01:37 +02:00
Marc-André Bombeck c67bd942a2 feat(sleep): add average-per-night card beside sleep debt and chronotype
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.
2026-06-21 14:01:30 +02:00
Marc-André Bombeck 430f7e81a1 Merge C1/C2/C3/C5 Coach UI rework into release/v1.19.1
C1: /coach defaults to the new-chat hero, auto-resuming only on an unread
coach-initiated message. C2: always-visible Conversations + New chat
toolbar. C3: replace the thinking disclosure with the typing-dots into
word-fade streaming animation. C5: the drawer Conversations handoff opens
the history list on arrival so the full view is never a blank pane.
2026-06-21 13:59:48 +02:00
Marc-André Bombeck 336c9224f4 Merge S3/S4 settings + sidebar nav into release/v1.19.1
S3: rename the settings nav entry label Layout to Dashboard across all
six locales (route unchanged). S4: fix the main sidebar spine order to
Medications, Vorsorge, Laborwerte, Krankheit, Insights, Coach,
Achievements, with module gating preserved.
2026-06-21 13:59:24 +02:00
Marc-André Bombeck c6f4772606 fix(coach): default to new chat and never dead-end on conversations
Reverse the resume-most-recent entry default: opening the Coach now lands
on the new-chat hero. The only exception is an unread coach-initiated
message (nudge-status `unread`), which auto-opens the most-recent thread so
the user sees what the Coach wrote. The `?c=<id>` deep-link is unchanged.

Add a dedicated, always-visible Conversations button (plus a New chat
button) to the page surface so reaching past conversations no longer hides
in the composer menu. The drawer's Conversations handoff now routes to
`/coach?view=conversations`, which opens the history drawer on arrival, so
the side-view to full-view path always shows the list instead of a blank
pane.
2026-06-21 13:59:05 +02:00
Marc-André Bombeck a86abd3662 fix(coach): restore the typing animation for the live turn
Replace the elapsed-time thinking disclosure with the classic typing
indicator: while a turn is still thinking with no prose, the assistant
bubble shows the pulsing typing dots, then swaps to the streamed
word-by-word prose once tokens land. Drop the now-unused reasoning/
startedAt bubble props and the orphaned disclosure component.
2026-06-21 13:58:57 +02:00
Marc-André Bombeck de14238535 feat(nav): fix the sidebar order of the clinical and insight spine
Below the dashboard/measurements/mood/cycle head, the entries now follow one
fixed sequence: Medications, Vorsorge, Labs, Illness, Insights, Coach,
Achievements. A disabled module drops out; the rest keep this order.
2026-06-21 13:58:43 +02:00
Marc-André Bombeck 5bbb46ef72 Merge C4 Coach token efficiency into release/v1.19.1
Send the full health snapshot only on the first conversation turn (or
when the verbatim history window starts eliding turns); cheap follow-ups
ride the transcript with a one-line grounding pointer. Drops follow-up
snapshot prefix from ~6,150 to ~50 tokens while preserving grounding and
cross-metric correlation.
2026-06-21 13:57:46 +02:00
Marc-André Bombeck bc1f30fec4 perf(coach): send the health snapshot once per conversation
The full SNAPSHOT block (every cluster plus labs, illness, cycle and the
reference-grounding lines) was interpolated into the prompt on every turn,
so a one-word follow-up re-paid the entire grounding prefix. Ship it only on
the first turn, or once the history window starts eliding turns; on a cheap
follow-up send a short pointer back to the snapshot the model already received
earlier in the same conversation. The prior turns ride the transcript, so
grounding and cross-metric correlation are preserved while a follow-up turn
sheds about 6,000 tokens. Record snapshotSent and promptChars on the reply
annotation so the cut is observable.
2026-06-21 13:56:49 +02:00
Marc-André Bombeck 8d1c90b8b6 fix(settings): label the layout nav entry Dashboard to match where it leads
The left-nav entry read "Layout" but the editor it opens is the dashboard
layout home. Rename the label across all six locales; the route stays
/settings/layout.
2026-06-21 13:56:38 +02:00
Marc-André Bombeck bbbafca7f4 Merge release/v1.19.0 into main (v1.19.0)
Release v1.19.0
v1.19.0
2026-06-21 13:13:54 +02:00
Marc-André Bombeck a6d9a39c73 fix(crypto,sleep): register the ECG column for rotation and update the sleep-debt route test
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.
2026-06-21 13:06:20 +02:00
Marc-André Bombeck c8673786eb test(integration): export the briefing window constant in the features mock
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.
2026-06-21 12:55:13 +02:00
Marc-André Bombeck 31efb3e884 chore(release): v1.19.0 2026-06-21 12:46:32 +02:00
Marc-André Bombeck c1a2a2610a fix(crypto): rotate the ECG waveform column + guard rotation coverage
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.
2026-06-21 12:43:54 +02:00
Marc-André Bombeck 54fa76de6f style: format feature-cache test 2026-06-21 12:36:48 +02:00
Marc-André Bombeck 324f569f28 fix(i18n): drop the unused telegram.measurementDismissed key
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.
2026-06-21 12:33:20 +02:00
Marc-André Bombeck 72809280cb Merge w11b/sleepcard into release/v1.19.0 2026-06-21 12:31:58 +02:00
Marc-André Bombeck 26230542ce fix(sleep): make the chronotype tile uniform with the sleep-debt tile
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.
2026-06-21 12:31:26 +02:00
Marc-André Bombeck 6d9002bb71 fix(sleep): compute sleep debt as a recovering rolling balance
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.
2026-06-21 12:31:18 +02:00
Marc-André Bombeck 7c693e94b5 Merge w11b/tz into release/v1.19.0 2026-06-21 12:22:23 +02:00
Marc-André Bombeck 2760f6b327 Merge w11b/ouraresil into release/v1.19.0 2026-06-21 12:22:21 +02:00