Commit Graph
3886 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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
Marc-André Bombeck 0045188899 Merge w11b/telegram into release/v1.19.0 2026-06-21 11:14:24 +02:00
Marc-André Bombeck b24a893b66 feat(telegram): log mood and act on measurement reminders in chat
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.
2026-06-21 11:13:33 +02:00
Marc-André Bombeck 6206f24774 Merge w11b/notifpolish into release/v1.19.0 2026-06-21 11:05:40 +02:00
Marc-André Bombeck 359bb4ffce fix(notifications): drop redundant breadcrumb above the page heading
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.
2026-06-21 11:05:10 +02:00
Marc-André Bombeck 9680f318a3 Merge w11b/chartrange into release/v1.19.0 2026-06-21 11:00:24 +02:00
Marc-André Bombeck 55b4374b26 fix(charts): honour batch coverage on range-tab change
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.
2026-06-21 10:59:39 +02:00
Marc-André Bombeck 75129c798d refine(cycle): consolidate disclaimer, align columns, unify tiles
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.
2026-06-21 10:57:16 +02:00
Marc-André Bombeck aff4e9abeb feat(oura): capture daily resilience as an ordinal RESILIENCE metric
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.
2026-06-21 10:44:15 +02:00
Marc-André Bombeck 3fe728c20b fix(tz): key the tiered raw daily band on the user's local day
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.
2026-06-21 10:39:11 +02:00
Marc-André Bombeck 60c8a76b2c feat(measurements): fold out-of-window SpO2 to the dense intra-day tier
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.
2026-06-21 10:31:27 +02:00
Marc-André Bombeck 3712b80d0f Merge w11b/a1pipeline into release/v1.19.0 2026-06-21 10:24:20 +02:00
Marc-André Bombeck d7c71f3b32 perf(insights): compute-once feature snapshot per warm scope
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.
2026-06-21 10:23:14 +02:00
Marc-André Bombeck d62a33a5e7 Merge w11b/a3ecg into release/v1.19.0 2026-06-21 10:23:09 +02:00
Marc-André Bombeck 7f9aeba918 perf(insights): fold correlation-discovery channels into the status input gate
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.
2026-06-21 10:23:04 +02:00
Marc-André Bombeck 837f215f66 Merge w11b/a4hrwire into release/v1.19.0 2026-06-21 10:22:36 +02:00
Marc-André Bombeck 462948b33b feat(withings): capture the full ECG waveform, not just the AFib verdict
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.
2026-06-21 10:22:29 +02:00
Marc-André Bombeck f38c4bed05 feat(measurements): hourly heart-rate batch upload contract
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.
2026-06-21 10:22:11 +02:00
Marc-André Bombeck b8e691f6c5 Merge w11b/a2inv into release/v1.19.0 2026-06-21 10:20:26 +02:00
Marc-André Bombeck 2386d1fe78 fix(medications): make inventory Bestand summary server-authoritative
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.
2026-06-21 10:19:41 +02:00
Marc-André Bombeck c9b1631da7 fix(insights): paint mood bars at full saturation
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.
2026-06-21 10:16:06 +02:00
Marc-André Bombeck 7540d0b508 fix(medications): keep the inventory supply helper free of server-only logging
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.
2026-06-21 09:53:27 +02:00