Commit Graph
2615 Commits
Author SHA1 Message Date
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 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
Marc-André Bombeck 41849a9f9a Merge w11/coachdata into release/v1.18.11 2026-06-21 00:26:58 +02:00
Marc-André Bombeck b0a8dfc29e feat(coach): pass recent lab values into the coach context
Add a labs block to the coach snapshot so the assistant can answer
questions about the user's lab results. Each entry is the most recent
reading per biomarker over the last 12 months (capped), carrying the
resolved name, value, unit, reference range, in/out-of-range verdict,
and measured date. Qualitative readings (e.g. negativ) are included
with a neutral range status. Values resolve server-side via the same
helpers the labs API uses, and the system prompt instructs the model to
quote them verbatim and never invent a lab number, so the block stays
grounded. The free-text qualitative result is sanitised before it can
reach the prompt; the encrypted note is never read.
2026-06-21 00:26:19 +02:00
Marc-André Bombeck 899e2f5fcd Merge w11/coachtile into release/v1.18.11 2026-06-21 00:26:17 +02:00
Marc-André Bombeck ead44e79d9 feat(coach): deep-link the Coach page into a conversation
The Coach page opened a blank new chat regardless of where the user came
from. Make it a deep-link target: a `?c=<id>` query param opens that
thread on mount, and absent it the page auto-opens the user's most-recent
conversation. "Most recent" is the `updatedAt desc` head of the rail
list, so the same thread opens on web and mobile.

The shared conversation surface grows two additive props
(`initialConversationId`, `autoOpenMostRecent`) that feed its existing
selection state; the history drawer's selection stays the single source of
truth. The drawer surface is unchanged — both props default off and the
rail list is only fetched when auto-open is requested.
2026-06-21 00:25:54 +02:00
Marc-André Bombeck 397dbefbd7 Merge w11/inventory into release/v1.18.11 2026-06-21 00:25:50 +02:00
Marc-André Bombeck ea314b40f9 fix(medications): clamp pooled inventory stock at zero
Add a central sanity gate in summariseSupply, the single helper every
supply readout flows through. A corrupt or legacy row with a non-finite
or negative unit count could pool straight into the headline and surface
a nonsensical negative Bestand; the helper now floors the pooled
remaining, total and expired figures at zero and emits a
medication.inventory.underflow wide-event when a raw value would have
gone negative, so the underlying data defect stays observable.

closes #31
2026-06-21 00:25:25 +02:00
Marc-André Bombeck 6975849bec Merge w11/perf into release/v1.18.11
Serve achievements via SWR and precompute Berlin day-keys to kill the per-row
Intl cost; switch dashboard/summary to cachedSwr; lazy-load three charts that
bypassed the dynamic boundary.
2026-06-20 19:05:27 +02:00
Marc-André Bombeck a0187c496a Merge w11/aipipeline into release/v1.18.11
Bound the comprehensive briefing's feature read to 400 days while keeping
all-time extremes honest via a full-history groupBy, and append module-gated
illness and cycle context to the briefing prompt.
2026-06-20 19:05:10 +02:00
Marc-André Bombeck f3af5d361a perf(gamification,dashboard,insights): cut achievements cold rebuilds and lazy-load three charts
Achievements: the bucket was 60 s hard-TTL while the app-wide unlock
notifier polls every 2 minutes, so every poll landed on an expired
entry and fired a cold ~400 ms rebuild in the foreground. Add a
10-minute stale-while-revalidate window and read the route through
cachedSwr; the route already persists pendingUnlocks outside the cached
factory, so a stale serve never skips an unlock write.

Achievements builder: the vitals array ran through several full-array
passes, each re-deriving a Berlin day-key (and hour) per row via
Intl.DateTimeFormat. Compute each row's day-key once and thread the
parallel array into the green-day, weekly-consistency, engagement, and
hidden passes; the hidden pass reuses a single precomputed hour array.
Results are byte-identical to the per-row derivation (parity test added).

dashboard/summary: read through cachedSwr so the iOS-polled endpoint
serves its existing 1 h stale window instead of re-paying the cold
rebuild every 60 s on the plain cached path.

Charts: route the three recharts consumers that bypassed the shared
next/dynamic boundary (lab biomarker chart, sleep stage stacked bar,
recommendation rationale charts) through dynamic imports with matching
loading skeletons, taking recharts off the first-load JS for
/labs/[id], /insights/sleep, and the insights recommendations surface.
2026-06-20 19:04:31 +02:00
Marc-André Bombeck 8479ea001b perf(insights): bound briefing feature window and add illness/cycle context
Bound the comprehensive briefing's feature read to a 400-day window instead of
walking the full measurement history on every generation. All-time min/max/avg
are sourced from one grouped aggregation over the whole history, so the
allTime* fields stay honest while the unbounded row materialisation is gone;
the oversize downgrade ladder remains as the safety net.

Fold illness-episode and cycle state into the briefing prompt, reusing the
server-authoritative builders the Coach already assembles. Both are
module-gated and cost nothing for users without them, so a mid-illness or
mid-cycle user finally gets context-aware prose.
2026-06-20 19:04:08 +02:00
Marc-André Bombeck c100b67eda Merge w11/geo into release/v1.18.11
Surface non-ok geo-lookup responses as a warning instead of swallowing them,
make the lookup provider/URL override explicit, and whitelist TRUST_CF_CONNECTING_IP
+ the geo env in compose so the real visitor IP is stored behind Cloudflare.
2026-06-20 19:01:37 +02:00
Marc-André Bombeck 72e944aabb Merge w11/codexvision into release/v1.18.11
Fold image input into the Codex client (input_image over the ChatGPT-OAuth
responses endpoint, no API key); make the codex vision gate model-aware; resolve
labs OCR to vision mode for multimodal codex with safe text/local-OCR fallback.
2026-06-20 19:00:40 +02:00
Marc-André Bombeck 8d73d9721f fix(geo): default to ip-api.com and surface non-ok lookup failures
The ipwho.is default rejected server-side egress with a 403 on the free
plan, which the resolver swallowed as a clean miss, so the admin
login/audit overview rendered "—" for every session. Switch the default
online provider to ip-api.com (the parser already accepts its
status/countryCode shape) and surface a non-ok HTTP status (403/429/5xx)
as a wide-event warning instead of returning null silently, so a future
provider rejection is visible.

Whitelist TRUST_CF_CONNECTING_IP, IP_GEO_LOOKUP_URL and
IP_GEO_LOOKUP_DISABLED in the compose environment block and document them
in .env.production.example so a Cloudflare-fronted deployment can store
the real visitor IP rather than the edge address.
2026-06-20 19:00:27 +02:00
Marc-André Bombeck 178d4d7610 feat(labs): unlock native Codex vision for lab OCR
Image input rides the ChatGPT-OAuth codex/responses endpoint via the
input_image content block (protocol spec 2b) — no vision API key. The
codex client folds params.images into input_image blocks alongside the
input_text; text-only calls keep the prior byte-identical wire. The
vision gate now resolves codex capability against the working slug
(GPT-5.x / gpt-4o-class multimodal) instead of a blanket false, with the
OCR capability resolver feeding the codex slug rather than the user's
aiModel. A non-multimodal slug or an image-block rejection degrades to a
clean extract failure / local-OCR text mode.
2026-06-20 18:59:37 +02:00
Marc-André Bombeck d91cb35569 Merge w11/coachux into release/v1.18.11
Rework the Coach page to a ChatGPT-style IA: drop the top header, make the
composer the control hub (+ actions, settings, mic), open history as a left
slide-in drawer, and keep the composer one constant width across new-chat and
active conversation. The FAB drawer surface is untouched.
2026-06-20 18:58:03 +02:00
Marc-André Bombeck 1469336cfe feat(coach): rebuild the Coach page around a ChatGPT-style composer hub
Drop the dedicated page's top header bar and the conversation-history
strip. The composer now carries the controls: a leading actions menu
(new chat + open conversations) and a settings deep-link beside the mic
and send. Conversation history opens as a left slide-in drawer that works
on phone and desktop.

Pin the composer to one centred, max-width-capped column shared with the
thread so its width stays constant from the new-chat hero into an active
conversation. The drawer surface keeps its own header and body chrome
unchanged.
2026-06-20 18:57:18 +02:00
Marc-André Bombeck 4aebaaf399 Merge release/v1.18.10 into main (v1.18.10)
Data-loss fix, measurement consolidation, AI grounding, full-page Coach, local OCR, Withings sleep vitals, and broad polish. Six upfront audits + diff review; CI green.
v1.18.10
2026-06-20 17:38:07 +02:00
Marc-André Bombeck 32296c901c fix(db): raise the default statement timeout to 60s
The 15s default cancelled the live-aggregate analytics fallback on a coverage
miss over a large account (Postgres 57014), surfacing as a 500. Sixty seconds
still bounds a runaway query well before it can starve the pool while leaving
headroom for the heaviest legitimate read.
2026-06-20 17:30:59 +02:00
Marc-André Bombeck 16759b4df8 chore(release): v1.18.10
Fix encrypted day-log data loss and the stalled cumulative-measurement drain;
capture Withings sleep vitals and Oura VO2max; harden AI grounding across the
briefing, Coach, narrative, and status cards; full-page Coach with history and a
working mic; on-device lab OCR; preventive-care, labs, and settings polish; BP
warm/cold parity, a statement timeout, and a covering measurements index.
2026-06-20 17:19:14 +02:00
Marc-André Bombeck 5c75fb6194 Merge w10/reconmobile into release/v1.18.10
Keep the full-page Coach composer clear of the mobile bottom nav and safe-area;
wrap long More-hub labels instead of ellipsising; raise lab-history row controls
to 44px on touch; make the biomarker-detail Add button icon-only on small phones.
2026-06-20 17:16:02 +02:00
Marc-André Bombeck 8ef61b02fe fix(mobile): keep Coach composer above the bottom nav, restore full hub labels
Size the full-page Coach surface to clear the mobile BottomNav band plus the
iOS safe area so the docked composer and Stop control never sit under the nav;
desktop full-bleed height is unchanged (the nav is mobile-only).

Stop truncating the More-hub labels: shrink the label type and allow a
two-line clamp so long words (German Benachrichtigungen, and the long es/fr/it
labels) read in full instead of ellipsising.

Raise the lab reading-history edit/delete controls to 44px on touch
(compact on desktop), and drop the lab-detail Add button to icon-only on the
narrowest phones.
2026-06-20 17:15:35 +02:00
Marc-André Bombeck 1706b67dcf Merge w10/codehygiene into release/v1.18.10
Enforce apiFetch in src/lib/queries and migrate two hand-rolled hooks; thread a
DST-safe day-key into readiness and mood-narratives; lazy-load qrcode; add a
shared day-millisecond constant.
2026-06-20 17:11:51 +02:00
Marc-André Bombeck 643c33250b perf(settings): code-split qrcode out of the sharing chunk
The QR is only rendered after a share link is created, so import qrcode
lazily inside the effect instead of statically at module top. The library
no longer ships in the initial settings bundle.
2026-06-20 17:10:48 +02:00
Marc-André Bombeck a9c327401a fix(insights): key readiness + mood-streak day buckets in user tz
The readiness same-day grouping and the mood-streak anchor/walk both
sliced the UTC instant for their day key, mis-bucketing a late-evening
reading for a logger east or west of UTC. Thread an optional tz (default
Europe/Berlin) through both paths and key the buckets via userDayKey so
the day boundary follows the user's wall clock. Centralise the inline
day-in-ms literal in a shared time-constants module and use it here.
2026-06-20 17:10:36 +02:00
Marc-André Bombeck 320c33b918 lint: enforce api-fetch wrapper across src/lib/queries
Add src/lib/queries/ to the api-fetch-required ENFORCED_ROOTS so the
rule matches its own header comment, then route the two flagged query
hooks through apiGet instead of hand-rolling the envelope unwrap.
2026-06-20 17:10:24 +02:00
Marc-André Bombeck 234c43e4da Merge w10/aigrounding into release/v1.18.10
Ground daily-briefing numbers against the signal features, guard Coach replies
against fabricated doses and risk scores, gate the period narrative, feed the six
status cards a precomputed signal block, add SSE cancellation, and cap the Coach
snapshot read.
2026-06-20 16:59:58 +02:00
Marc-André Bombeck 883383c6a6 test(coach): assert grounding + GLP-1 contract in every locale's prompt
The safety-contract matrix parity test checks rule bodies are non-empty;
this asserts the resolved getCoachSystemPrompt(locale) string carries the
grounding marker and the GLP-1 dose-safety contract for all six UI
locales, closing the gap where a non-EN/DE locale could silently fall
back to a weaker EN-plus-footer body.
2026-06-20 16:58:45 +02:00
Marc-André Bombeck db68f994c2 fix(insights): feed the six specialised cards a precomputed SIGNAL block
BP, pulse, weight, BMI, mood and medication-compliance still asked the
model to derive the recent-vs-baseline comparison from raw buckets — the
exact arithmetic small models do unreliably, and the pattern the
base-system prompt already advertises as precomputed.

Build the SIGNAL block (current / baseline / signed delta / normal-swing
verdict) with buildMetricSignal from each card's graded series and inject
it into the snapshot; BP carries two signals, compliance one per
medication. Each card's prompt section now leads from the signal block
and is told to state, not recompute. The server-computed compliance %
is unchanged.
2026-06-20 16:58:44 +02:00
Marc-André Bombeck 22fac8ae90 perf(coach): cap the snapshot measurement read
The single multi-type window read had no take bound, so a lastYear /
allTime scope over a high-frequency type (PULSE / glucose are
200k-row-class) loaded a year of rows just to fold them into the ~21
daily + ~10 weekly buckets the prompt shows. Read newest-first with a
6000-row cap and restore ascending order; the coarse MONTH/YEAR tail
still comes from the rollup tier, so deep history survives.
2026-06-20 16:58:33 +02:00
Marc-André Bombeck 080ce73475 fix(insights): add a grounding gate + retry to the period narrative
The period narrative was written straight to storage with no
post-generation check and no corrective retry, unlike Comprehensive
Insights. A populated context could still yield causal phrasing or a
number absent from the context block.

Add a validator that flags causal language (EN/DE) and any number not in
the structured context; on a trip, retry once with a corrective suffix,
then write nothing (the last good row stays) rather than persist an
ungrounded or causal story.
2026-06-20 16:58:33 +02:00
Marc-André Bombeck 869f1d9c11 fix(coach): wire the outbound screen and an SSE cancel handler
Apply the outbound safety screen to the assembled Coach reply before
persistence and streaming, with a per-turn audit row on a block.

createSseStream gains a cancel handler + a shared AbortSignal: a client
disconnect now flips the signal so the token-emit loop stops enqueuing
into a closed connection and any provider fetch wired to the signal is
torn down. The chat emit loop honours it between frames.
2026-06-20 16:58:23 +02:00
Marc-André Bombeck 64de009d66 fix(coach): screen the assistant reply before it streams
detectRefusal guards only the inbound user message; the model's reply
was streamed verbatim. A dose-prescription or a fabricated clinical risk
score that slipped past the system-prompt contracts reached the user
unfiltered.

Add a deterministic outbound screen on the assembled reply (which the
route already buffers before streaming): tight dose-change patterns plus
risk-score patterns, EN/DE. On a trip the turn is replaced with a calm,
grounded fallback and any reminder suggestion / key-value provenance from
the discarded reply is dropped.
2026-06-20 16:58:23 +02:00
Marc-André Bombeck 8b5235c25b fix(insights): ground daily-briefing numbers against precomputed signals
The daily-briefing block (paragraph / signalsOfDay / keyFindings) was
schema-shaped but never number-checked the way recommendations[] are: a
restated figure could drift from the snapshot with no server rejection.

Add a grounding cross-check that asserts every number the briefing prose
states traces to a features.signalsOfDay figure (latest / avg / delta)
within a rounding tolerance. Wire it into both generation sites with one
corrective retry, then strip the briefing rather than persist a
fabricated number. Also gate the higher-temperature paragraph reroll on
the same check so a phrasing reroll cannot drift a figure.
2026-06-20 16:58:06 +02:00
Marc-André Bombeck 28dd3b10af Merge w10/settingsmisc into release/v1.18.10
Align the Vorsorge settings card to the canonical pattern, neutralize the danger
zone card, mark the bug-report feature deprecated, make ipwho.is the default geo
resolver, and keep the mobile More-hub labels on one line.
2026-06-20 16:57:55 +02:00
Marc-André Bombeck 6ee7df3098 fix(nav): keep mobile More-menu labels on one line
Trim the longest label ('Benachrichtigungen') with truncate (nowrap +
ellipsis) instead of break-words so every More-hub tile reads on a
single line on a phone, matching its neighbours. The full label stays
discoverable via the title attribute; the desktop layout is untouched.
2026-06-20 16:57:01 +02:00
Marc-André Bombeck 5b6544ec63 feat(admin): mark the feedback feature as deprecated
Add a discreet 'Deprecated' badge and a short note ('will be removed in
a future version') to the bug-report settings entry. The functionality
stays intact; this only signals the upcoming removal across all locales.
2026-06-20 16:56:55 +02:00