Commit Graph
6301 Commits
Author SHA1 Message Date
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 7f7ee2c013 feat(telegram): add prompt-context table for mood-note correlation
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.
2026-06-21 11:13:17 +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 7969b79443 fix(i18n): add missing notification event labels
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.
2026-06-21 11:05:01 +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 328e1b4544 Merge w11b/demoseed into release/v1.19.0 2026-06-21 10:59:35 +02:00
Marc-André Bombeck 9cf6921ff0 seed(demo): add glucose CGM, richer labs, illness journal, cycle data
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.
2026-06-21 10:58:24 +02:00
Marc-André Bombeck 9cc84e2209 Merge w11b/cycleui into release/v1.19.0 2026-06-21 10:57: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 b8fc648bfc Merge w11b/unraid into release/v1.19.0 2026-06-21 10:33:28 +02:00
Marc-André Bombeck 7f9bb6cd71 Merge w11b/a5editorial into release/v1.19.0 2026-06-21 10:33:28 +02:00
Marc-André Bombeck c903c7f1b1 docs(self-hosting): add Unraid CA and Portainer templates, harden compose quick-start
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.
2026-06-21 10:32:40 +02:00
Marc-André Bombeck 189065af0f docs(insights): make metric explainer copy vendor-blind and cited
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.
2026-06-21 10:32:35 +02:00
Marc-André Bombeck 3faafef0df Merge w11b/dense into release/v1.19.0 2026-06-21 10:31:45 +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
Marc-André Bombeck 3e578836cc chore(release): v1.18.11 2026-06-21 09:40:53 +02:00
Marc-André Bombeck c17f2abd7b fix(ai): keep gpt-5-codex slugs text-only in the vision gate
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.
2026-06-21 09:38:58 +02:00
Marc-André Bombeck 94ce9e5901 Merge w11/codesplits into release/v1.18.11 2026-06-21 09:32:49 +02:00
Marc-André Bombeck 639b211ca4 refactor(openapi): drop the pre-split medications and insights route files
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.
2026-06-21 01:22:46 +02:00
Marc-André Bombeck 8e34a58c0a refactor(settings): split account-section into profile, passkey, and avatar modules 2026-06-21 01:20:12 +02:00
Marc-André Bombeck fa5c6500b5 refactor(settings): split import-panel into per-source card components 2026-06-21 01:15:54 +02:00
Marc-André Bombeck e9d90a689f refactor(validations): split medication schemas into focused modules behind a barrel 2026-06-21 01:12:35 +02:00
Marc-André Bombeck d1be2a9c6b refactor(openapi): split insights route registry into schemas + paths behind a barrel 2026-06-21 01:06:59 +02:00
Marc-André Bombeck 2e2e596cca refactor(openapi): split medications route registry into schemas + paths behind a barrel 2026-06-21 01:06:53 +02:00
Marc-André Bombeck 85828371a6 test(insights): stub briefing illness/cycle context in generate route test
The v1.18.11 briefing now folds illness-episode and cycle state into the
prompt via buildBriefingIllnessCycleContext, which resolves module gates
through memoizePerRequest and queries cycleProfile. The generate route test
mocks neither getEvent nor the cycle/illness models, so every case threw once
the new call path ran. Stub the context module to no output and add the
getEvent export to the logging-context mock so these cases stay scoped to the
provider and cache behaviour they assert.
2026-06-21 01:02:44 +02:00
Marc-André Bombeck 5666e63a57 Merge w11/aipipeline2 into release/v1.18.11 2026-06-21 00:57:12 +02:00
Marc-André Bombeck 7a5f305a3a perf(insights): gate weight and BMI status on a cheap input fingerprint
Weight and BMI move on a weekly cadence, but the per-metric generators
rebuilt the full snapshot (bounded findMany + per-series rollup reads +
correlation math) every day and relied on the post-build content-hash gate
to skip only the model call. Add an input gate that runs first: a single
grouped probe over the salient inputs (per-type count + newest reading, plus
mood for weight and the profile height for BMI) fingerprints what the
snapshot would read. On a non-forced run whose fingerprint matches the cached
row, the cached assessment is re-stamped under today's key and the whole
build is skipped. A miss falls through to the normal build and the existing
post-build gate.

Consent is checked before any re-date so a revoked-consent user never has
stale text presented as current. Forced runs never gate but still record a
fresh fingerprint for the next day.
2026-06-21 00:56:07 +02:00
Marc-André Bombeck e18bd3e23a perf(insights): generate the per-metric status cards in one batched call nightly
The 02:00 status cron now runs generateStatusBatchForUser for every eligible
user: it builds the seven per-metric snapshots once and issues a single
provider call for the metrics still needing the model, fanning each summary
back into the same per-metric cache rows the standalone generators write.

The six later per-metric crons (02:05-02:30) keep their drivers but now hit
the calendar-day cache the batch already filled, so they resolve as cache
reads and only cover the edges — a user discovered after 02:00, a metric the
batch omitted, or a batch-call failure (which the batch falls back per metric).
Reproducibility and the consent/grounding gates are unchanged: the batch runs
through runStatusCompletion, which still pins the reference seed.

Drops the now-unused per-metric general generator and recordRun plumbing.
2026-06-21 00:55:56 +02:00
Marc-André Bombeck 49add3f43b perf(analytics): fold the comprehensive aggregate's canonical self-join into one CTE
The warm narrow query and the cold heavy query each referenced the
canonical-source subquery twice — once in the window_stats CTE and once in
the outer body — so the DISTINCT-ON pick over the live 90-day rows ran twice
per rebuild. Extract canonicalMeasurementsCte (the SELECT body without the
alias wrapper), bind it once as WITH cm AS (...), and select FROM cm m from
both window_stats and the outer aggregate. Output is identical; the dominant
self-join runs once.

canonicalMeasurementsFrom now wraps the same body, byte-stable for the
single-reference call sites (pinned by a new parity test).
2026-06-21 00:55:47 +02:00
Marc-André Bombeck 5859aaf46f Merge w11/backlog into release/v1.18.11 2026-06-21 00:54:53 +02:00
Marc-André Bombeck 0f5d67033a feat(oura): ingest the daily cardiovascular-age estimate
The daily_cardiovascular_age collection reports a years-unit arterial-age
estimate that maps directly onto the existing VASCULAR_AGE metric, so no
migration is needed. Resilience stays out: it is a categorical level with
no numeric home and would require a new type plus a categorical column.
2026-06-21 00:54:27 +02:00
Marc-André Bombeck 621b6734d0 feat(withings): capture ECG / AFib screening results
The Heart List endpoint returns a per-recording AFib verdict from the
ScanWatch family that no sync fetched. Capture each recording as an
IRREGULAR_RHYTHM_NOTIFICATION event row carrying the device's own verdict
in rhythm_classification, mirroring the Apple Health categorical-event
treatment. Reuses the existing enum, column, and constraint, so no
migration. Fires on the measure webhook path, which shares the metrics
scope the Heart List endpoint needs.
2026-06-21 00:54:16 +02:00
Marc-André Bombeck af86a14824 feat(dashboard): surface the daily briefing on the hero
The briefing only reached the dashboard as the hero's lowest-priority
verdict, which the severity ladder almost always pre-empted, so a fresh
briefing was invisible above the fold. Lift its own signals (falling back
to key findings) onto the hero as a self-gating strip beneath the verdict
row, reusing the insights briefing's tone-bar row language. Placement
only: the verdict ladder and the briefing content are unchanged.
2026-06-21 00:54:09 +02:00
Marc-André Bombeck 118415da12 Merge w11/hragg into release/v1.18.11 2026-06-21 00:30:03 +02:00
Marc-André Bombeck 8f1e3c5d44 feat(measurements): preserve resting + daily min/max when folding dense heart rate
The dense intra-day retention drain folds out-of-window per-sample PULSE rows
to one daily-mean row to bound the high-frequency ingest. The mean-only fold
erased the two clinically-meaningful daily figures derived from the raw
stream: the resting estimate (20th-percentile of each day's PULSE, the
fallback for users without a native resting series) and the daily min/max held
in the persistent rollup DAY bucket.

Preserve both in the PULSE facet of the fold:

- Recompute the PULSE DAY rollup from the raw rows before the fold, while they
  are still live, so the bucket keeps the true intraday min/max/mean; skip the
  post-fold recompute that would collapse them.
- For users with no native resting series, mint one derived RESTING_HEART_RATE
  row per folded day from the day's 20th percentile, sourced COMPUTED so it
  never collides with measured resting rows and reads back as the clean
  resting series. Users with a native series are skipped to avoid
  double-counting; the probe is memoised per user.

Add a per-day failure boundary so one poisoned day can no longer abort the
whole walk. Surface the derived-resting count through the handler return,
the wide-event annotation, and the worker log.
2026-06-21 00:29:35 +02:00