Commit Graph
1887 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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
Marc-André Bombeck 17a7d1c747 fix(admin): neutralise the danger-zone card surface
Render the danger-zone section on the shared SettingsCard surface
(bg-card + border-border) instead of the destructive-tinted card edge.
A red card surface reads as alarming rather than premium; the danger
signal now lives only in the destructive delete button and its typed
confirmation dialog, with the AlertTriangle header icon still flagging
intent.
2026-06-20 16:56:49 +02:00