Commit Graph
1086 Commits
Author SHA1 Message Date
Marc-André Bombeck 141c083ff6 feat(insights): add time-of-day and stability mood analyses
Extend the mood-aggregate engine with two pure helpers over the rows it
already fetches:

- computeTimeOfDayAverages buckets every entry into morning / afternoon /
  evening / night in the entry's own timezone (legacy null-tz rows fall
  back to UTC). A spread guard keeps the once-a-day logger — whose entries
  cluster in a single bucket — from producing a misleading daypart claim:
  the pattern is only marked reliable once at least two buckets each clear
  the per-bucket sample floor.
- computeMoodStability maps the population standard deviation of the daily
  means onto a 0-100 score (higher = steadier) with a descriptive,
  non-judgemental band. Sparse loggers below the minimum-days floor get
  null rather than a meaningless number.

Both feed the MoodAggregates object that drives the visible mood page and
the LLM snapshot, so the prose and the charts agree. A new time-of-day
narrative takeaway joins the ranked feed, gated on the same reliability
flag plus the existing effect-magnitude bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 685b53c0fd994324c270df2b9a5ac964bb442c82)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck 3694efe6e8 fix(insights): keep the assessment card polling while a stale text revalidates
The stale-while-revalidate path serves the last-good assessment on a cache
miss and returns a terminal payload, so the status hook stopped polling. The
worker warmed a fresh assessment out of band seconds later, but the open card
never picked it up — the stale text stuck until the page remounted.

Add a `revalidating` flag to the read-only miss outcome, set only when
last-good text is served AND a refresh was enqueued (false on the
suppressed-stub branch and when no prior text exists). Thread it through the
generic metric-status generator and the seven specialised generators onto the
wire, type it on the client, and make `nextStatusPollInterval` keep polling on
`preparing || revalidating`. The existing 12-attempt ceiling bounds both
signals, so a persistently failing generation still cannot poll forever.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c9217534fbdb304c4df1b6eb55fb19e4360b8a10)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck 03925ddece feat(insights): time-range pills with period-over-period deltas on metric pages
Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page,
between the stat strip and the chart, with a sentiment-coloured "vs prior
period" delta caption beside it.

New route GET /api/analytics/range?type=<MeasurementType>&range=<7d|30d|90d|1y>
returns the current-window aggregate, the previous comparable window, and the
composed delta. Single metric type by construction, so the read is one
rollup-tier call covering the trailing 2N days sliced into the two halves —
no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers
(readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern
the year-ago baseline already uses; count/min/max/mean/sum compose across
buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is
untouched so the hottest dashboard cache key never fragments.

The delta caption reuses the dashboard's metric-aware up-good/up-bad colour
logic — extracted from trend-card into a shared trend-sentiment module — with
the metric's good direction sourced from the metric-status registry
(target-band metrics render neutral, matching deviation framing). When the
prior window has no data the caption shows "no prior-period data" rather than
a misleading 0%. The range choice persists across metrics via a localStorage
seam and defaults to 30d so nothing regresses. Cache keyed per (type, range)
through the query-key factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck f1145c0e91 fix(insights): debounce ingest-driven status invalidation to coalesce the regeneration storm
A constantly-syncing client drips measurement batches every few minutes.
Every batch deleted the dirtied per-metric assessment cache rows and
re-enqueued a regeneration. The per-(user,scope,locale) enqueue singleton
is only 120 s, so it could not coalesce across batches that arrive minutes
apart, and each delete dropped the row a fresh enqueue would otherwise have
found warm — so a metric card regenerated several times an hour and its
assessment felt regenerated on every visit.

Before evicting and re-enqueuing, check whether the scope's cached
assessment was already (re)generated within a 30 min debounce window. A
scope that is fresh is left intact and not re-enqueued; a genuinely stale
or missing scope still refreshes immediately, so correctness holds. A
timeout stub never counts as fresh, so a scope that recently stalled still
retries. One indexed read per user answers it for every candidate scope at
once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 024baea664490ef0494add78c87492f01e61c6b9)
2026-06-02 16:23:16 +02:00
Marc-André Bombeck 2c6a9f8165 fix(insights): decouple the forced warm sections so a slow comprehensive step cannot starve the rest
The on-demand force-warm ran the comprehensive insight first and only ran
the per-status (7) and generic-metric (~30) assessment passes when that
step returned generated or cached. A slow or stalled comprehensive
generation therefore aborted the entire warm — observed in production as a
102 s run ending with assessments_warmed: 0 — leaving every card cold and
the user back on the lazy 30-60 s on-first-click path.

Give the comprehensive step its own bounded budget and treat a timeout,
error, or skip as non-fatal: the per-status and generic-metric passes now
always run afterwards. Each per-card generator resolves its own provider
chain and bounds its own provider call, so running them after a failed
comprehensive costs a cheap chain-resolve when no provider is configured
and is capped per card otherwise. The result reports each section's
outcome independently, and a timed-out comprehensive is reported distinctly
from a failed one so a partial warm is observable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit f52f8e9c3b32590f3b085d082418ebdd3a923f1d)
2026-06-02 16:23:16 +02:00
Marc-André Bombeck 4a0ad1fc64 fix(medications): restore equal-height cards and aligned dose rows
The v1.8.6 compliance rework left the last/next-dose and compliance rows in
a plain vertical stack with independently conditional rows. A card missing
its last-dose line (never taken) or its streak flame (zero streak) rendered
shorter, so the dose rows and action buttons drifted out of alignment across
the two-column grid.

Pin the layout on both card variants identically: the Card fills the
stretched grid cell with h-full, the CardContent becomes a flex column, the
next/last-dose pair sits in a reserved min-height slot so a missing line
keeps its footprint, and the intake-actions block is pushed to the bottom
with mt-auto for a shared action baseline. Generic and GLP-1 cards now align
row-for-row regardless of which optional rows render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a7e541eeb5123abb63514f56d12b93b0023ce417)
2026-06-02 16:23:16 +02:00
Marc-André BombeckandClaude Opus 4.8 5f8e191e2b fix(compose): set an explicit, tunable database connection_limit
The app pins DATABASE_URL in the compose environment block, which shadows
any connection_limit an operator sets in .env, so Prisma fell back to its
default pool (cpu_count*2+1 — 9 on a 4-core host). A combined web+worker
process starves that pool under background AI and rollup load, leaving
HTTP requests waiting on the pool until they time out. Set connection_limit
explicitly (default 20, override via DB_CONNECTION_LIMIT) plus a pool_timeout
override, so the pool can be sized to the host and its database.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:06:55 +02:00
Marc-André Bombeck e9906c8111 Merge pull request #239 from MBombeck/release/v1.8.7.1
v1.8.7.1 — assessments for every HealthKit metric, one-tap pre-generation
v1.8.7.1
2026-06-02 14:40:13 +02:00
Marc-André BombeckandClaude Opus 4.8 a9ed2ed661 chore(release): v1.8.7.1 — assessments for every HealthKit metric, one-tap pre-generation
Adds a plain-language assessment to every HealthKit metric page via a
data-driven generator (archetype templates + per-metric metadata, no
per-metric prompt), a one-tap/auto background pre-generation of all
assessments, and the iOS-facing widening of the Insights layout to the
full metric set. Updates the ChatGPT Codex model-slug ladder so
assessments generate again after the backend rotated its identifiers.
Splits the Targets/Sources settings pages, renames the section to
AI Insights, and applies the metric-detail back-nav, dropdown padding,
heading, and Sources-translation polish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:32:51 +02:00
Marc-André Bombeck 2472579059 feat(insights): warm assessments from the overview page
Wire the on-demand full assessment warm into the Insights overview. A new
useInsightsWarm hook POSTs /api/insights/pregenerate; the cards then fill
through their existing stale-while-revalidate reads with nothing to
invalidate locally.

Two entry points share the one mutation. A background warm fires once per
browser session (sessionStorage-gated) as soon as the overview confirms
the user has data, so a returning user lands on warm caches without
acting; it is silent and tolerates the 429 the server returns while a
warm is already in flight. A manual button ('Prepare assessments') runs
the same warm and shows a toast.

Add the insightsPregenerate mutationKey to the factory and the label +
toast strings across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1273a6253dc0d00c7c7ce774c5fbe352664de2e9)
2026-06-02 14:29:49 +02:00
Marc-André Bombeck bf5052bb01 feat(insights): add on-demand full assessment warm
Add POST /api/insights/pregenerate so every AI assessment text — the
comprehensive insight, the seven specialised status cards, and every
data-bearing generic metric assessment — can be warmed for the calling
user on demand, in the active locale, instead of waiting for the nightly
cron or a lazy first-visit generation.

The route narrows userId from the session/Bearer, runs the assistant
surface gate, and enqueues a forced full-warm job onto the existing
insight-pregenerate queue, returning immediately. A short per-user bucket
(insights-warm:<userId>, one per three minutes) returns 429 on a tight
loop so the bypassed nightly 20h budget cannot be turned into a provider
cost amplifier.

The worker branches on the payload: a job carrying { userId, force }
runs forceWarmUser for that user, skipping the candidate scan and the
20h budget; a scheduled tick with no payload keeps its existing batch
behaviour. forceWarmUser reuses the existing comprehensive generator and
warm helpers — no new generation logic — and stays safe by construction:
the comprehensive generator no-ops without a provider, the generic warm
pass filters to metrics the user has rows for so an empty metric never
reaches the provider, and every write is idempotent.

The queue name and a generator-free enqueue helper move to
insight-pregenerate-shared.ts, mirroring the insight-status-generate
split, so the route enqueues without pulling the generator tree.

Regenerate the OpenAPI contract for the new route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit e10ae9bfeca544c7e5aecd9102459d97ddfad6db)
2026-06-02 14:29:49 +02:00
Marc-André Bombeck d81ab69dc8 feat(insights): widen the layout tile-id set to the full metric universe
Derive `INSIGHTS_TILE_IDS` from `SUB_PAGE_SLUGS` rather than maintaining a
separate 14-id list. The layout tile-id universe and the routed tab strip
now share one source of truth, so a client can persist a layout covering
every metric page the web exposes (37 slugs + the overview mother page)
instead of tripping a 422 on the ~25 slugs the fixed allow-list did not
know about.

Rebuild `DEFAULT_INSIGHTS_LAYOUT` from the derived id list with an explicit
default-visible set; the long-tail HealthKit, body-composition, mobility,
and audio tiles stay default-invisible. The accepted set stays closed — an
unknown id still 422s — and the legacy German aliases keep round-tripping,
so no persisted layout breaks.

Regenerate the OpenAPI contract and extend the route deck: a PUT carrying
the previously-rejected slugs now succeeds, a full-universe PUT round-trips
through GET, and a genuinely-unknown id still 422s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0a161d430533845b0c4c322047701b93aea49a06)
2026-06-02 14:15:31 +02:00
Marc-André Bombeck 2123c63af4 a11y(insights): hide the workouts back-link arrow from assistive tech
The workouts detail back-link's `ArrowLeft` glyph carried no
`aria-hidden`, so a screen reader announced it alongside the link text. Add
`aria-hidden="true"`, matching the sibling values detail page's back-link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 108c3ff583a707b100570e2999b129055f90c76f)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck 4364f3b509 i18n(settings): translate the source-priority editor body for es/fr/it/pl
Splitting Sources back out into a standalone settings page re-exposed the
source-priority editor body — card title, help text, reorder buttons, the
move-up / move-down ARIA labels, per-metric and device-type controls — which
were still English placeholders for es/fr/it/pl. Provide natural
translations so a non-de/en screen-reader user no longer hears English.

Drop the orphaned `settings.sections.sources.redirectNotice` key from all six
bundles; the redirect it captioned was deleted when Sources became a real
route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 945d3ed8a6981c23dc51f45c22a1cd3f2625c859)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck 746e002104 fix(insights): re-warm generic metric assessments on measurement ingest
The ingest invalidation path mapped a fresh measurement only to the seven
specialised assessment scopes, so a freshly-synced HealthKit metric's generic
assessment card lagged its real-time data until the nightly warm pass —
an asymmetry against the near-real-time specialised cards.

Add a reverse `MeasurementType → metric id` index to the registry and extend
`invalidateStatusInsightsForTypes` so a reading of a type that backs a
registered generic metric also evicts and re-enqueues the matching
`metric:<ID>` scope, using the same single-locale, singleton-debounced
enqueue as the specialised scopes. Unregistered types and the seven
specialised metrics resolve to no generic scope, so the constant sync cannot
fan out to unwanted cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9bc0f116b46950c8ffa79d127d5dcce32ea644a4)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck bd8a4e7990 refactor(insights): type-check the generic metric previous-context scope
The generic metric-status generator read its previous-context row through an
`as never` cast, because `getPreviousInsightContext` was typed to the seven
specialised scopes only. Widen the helper's `scope` param to a
`PreviousContextScope` union that also accepts the `metric:<ID>-status` scope
form, so the generic call type-checks honestly instead of silently disabling
type checking on the argument. No behaviour change — the helper only
interpolates the scope into the audit action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 899d59b6fa219a14ba39eacded2e042f0f95c2c5)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck 58b793ee4a fix(insights): correct the active-energy assessment id and share the status card
The active-energy page passed `ACTIVE_ENERGY_BURNED` as `statusMetric`, but
that is the DB MeasurementType, not the registry id the metric-status route
accepts. The route's closed enum only knows `ACTIVE_ENERGY`, so every fetch
422'd and the assessment card silently fell back to its empty body.

Point the page at the `ACTIVE_ENERGY` registry id and type `statusMetric`
(plus the `useInsightMetricStatus` metric param) to the closed
`MetricStatusMetricId` union instead of a bare string, so a future
MeasurementType-vs-registry-id mismatch is a compile error rather than a
silent 422. A typecheck pass over all 29 pages confirms active-energy was the
only diverging one.

Extract a shared `MetricStatusCard` that owns the `useInsightMetricStatus`
hook and the assessment-card wiring, collapsing the duplicated mount block in
the HealthKit metric scaffold and the bespoke sleep page to a single line
each (only the icon differs). Behaviour is unchanged: the card mounts only on
the data-bearing branch, so a source-less account never fires an assessment
round-trip. Add a test asserting the active-energy page wires an accepted
registry id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6e371aaeb9698f7b4251c22e1d8f381ddfa70a00)
2026-06-02 14:03:00 +02:00
Marc-André BombeckandClaude Opus 4.8 5827ef58cb refactor(insights): drop the unused metric-to-insight key helper
The identity-cast helper had no caller and tripped the dead-code gate.
Remove it and its now-orphaned type import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:32:30 +02:00
Marc-André Bombeck 78b76d2ac5 feat(settings): split targets and sources, rename AI Insights section
Three related settings changes:

- Split the combined Targets & Sources page back into two pages: Targets
  (/settings/thresholds) owns the per-metric target-range editor and
  Sources (/settings/sources) owns the source-priority and device-type
  ladders. Each is its own left-nav entry and route, served by the
  dynamic [section] route. The former /settings/sources permanent
  redirect is removed since the route now renders the page directly.
- Rename the AI Insights settings section and nav label so the AI nature
  is explicit: 'AI Insights' (EN) / 'KI-Auswertungen' (DE), replacing the
  bare 'Insights' / 'Auswertungen'.
- Each page renders its own h1 heading consistent with sibling settings
  pages.

Drop the now-unused embedded mode from SourcesSection. Update the section
titles and descriptions across all six locale bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5d8842934b985eb8ce037993a63bb0736ac0c7bd)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck 708d012d28 fix(ui): make select trigger horizontal padding symmetric
The select trigger used pl-3 pr-2 (12 px left, 8 px right), parking the
chevron visibly closer to the right edge than the value text sits from
the left. Use px-3 so both gutters are 12 px; the gap-2 between value and
chevron and the chevron's shrink-0 keep it from crowding or clipping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit aaea4ea8102d8f74ea039fc6764ee2e3e5474afc)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck ee845946fa fix(insights): place metric detail back-nav above the heading
The metric values subpage (/insights/values/[type]) and the workout
detail page rendered the back link as the first child of SubPageShell,
which paints it below the page heading. Add an optional backLink slot to
SubPageShell that renders above the header — the standard back-nav
placement — and move both pages' back links into it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 72a73a52eb8295c9e70a3456972476b8c4469f07)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck a9aa0b8531 fix(ai): track the ChatGPT Codex backend's current model slugs
The ChatGPT Codex backend rotated its ChatGPT-auth model slugs off the
`-codex` specialist line onto the plain `gpt-5.x` line, so the in-code
default ladder (`gpt-5.3-codex`, `gpt-5-codex`, `gpt-5`, `gpt-4o`) is
now rejected and insight generation fails with a 503 on a fresh deploy
that has no `CODEX_MODEL_FALLBACK_CHAIN` override.

Replace the default ladder with the current accepted set, in order:
`gpt-5.5` (current default across paid tiers), `gpt-5.4` and
`gpt-5.4-mini` (documented fallbacks), `gpt-5.3-codex` (late rung, still
accepted intermittently), `gpt-5.2` (legacy floor). The
fall-through-on-rejection walk, the working-slug cache, and the env
override path are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit bced19de2d4f0788bfd6fa56a78e719347776a1f)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck b1b2e22eeb feat(insights): mount per-metric assessment on HealthKit sub-pages
Add an optional statusMetric prop to HealthKitMetricPage that mounts
InsightStatusCard beneath the chart, pointed at the generic
metric-status route. The card renders only on the data-bearing branch;
the insufficient-data branch keeps the existing empty-state note and
never fires an assessment fetch. Wire statusMetric on the 29
HealthKit metric sub-pages and on the sleep page, which closes the
assessment slot its deferral comment had left open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 083044201af7715740ec0ee2f14c7240c4999fbf)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck 16b0c349f2 feat(insights): add generic per-metric assessment loader
Add a parameterised query key (insightsMetricStatus) and a
useInsightMetricStatus hook that reads the generic per-metric
assessment route GET /api/insights/metric-status?metric=<id>. The
seven bespoke status routes keep their own keys and hook union; this
one covers the HealthKit metric sub-pages that share a single route
keyed by the metric id. The hook mirrors the existing status loader:
8 s client ceiling, no inline retry, and the bounded preparing poll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a3407a2f6c1e9f54c92a6f182db894ae0593e69f)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck e33911e2d4 feat(insights): generic per-metric assessment coverage for HealthKit metrics
Extend the assessment-card system beyond the seven specialised scopes to
the ~30 HealthKit metric pages, without hand-writing a prompt per metric.

- Add a metric-metadata registry keyed by HealthKit id (display name,
  unit, direction, optional normal range, archetype, scope), covering the
  data-bearing HealthKit metrics. The seven specialised metrics keep
  their own scopes and are excluded from the generic set; an unregistered
  id gets no card.
- Add a generic data-driven generator that reuses the existing graded
  snapshot, provider runner, stale-while-revalidate cache, and persist
  path. One prompt template per archetype (physiological-vital,
  activity-fitness, body-composition, mobility-gait,
  environmental-exposure) plus a dedicated sleep template, parameterised
  by metric metadata. Empty-data metrics return an insufficient marker
  with no provider call. Normal ranges use age + sex where the profile
  stores them; no ancestry/region ranges.
- Add GET /api/insights/metric-status?metric=<id>: apiHandler-wrapped,
  Zod-validated against the closed registry id set (422 on unknown),
  read-only with stale-while-revalidate. First insights route with
  OpenAPI coverage; regenerate docs/api/openapi.yaml.
- Route the generic metric:<id> scope through the on-demand status queue
  worker dispatch.
- Extend the nightly warm pass to the generic metric caches for a user's
  data-bearing metrics only (one distinct read gates the set), keeping
  de+en warm and never calling a provider for an empty metric.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 41453fbdfee60793c16269b9a9e5f04b6c3ea450)
2026-06-02 13:26:10 +02:00
Marc-André BombeckandClaude Opus 4.8 2717297dae docs(contributing): make main the single trunk, retire develop
The long-lived develop branch carried no work absent from main and had
drifted 1216 ahead / 85 behind. Retire it (archived as the tag
archive/develop-pre-v1.5) and rewrite the branch model to match how
releases actually ship: main is the single trunk, releases build on
short-lived release/vX.Y.Z branches off main, merge back, and tag.
This removes the back-merge debt by construction and keeps the tagged
commit on main the single source of the OpenAPI contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:40:30 +02:00
Marc-André Bombeck 3ea6bde8a3 Merge pull request #238 from MBombeck/release/v1.8.7
v1.8.7 — Instant insight assessments, GLP-1 blood-level chart, tile + caption polish
v1.8.7
2026-06-02 10:17:32 +02:00
Marc-André Bombeck d5f75d2e03 chore(release): v1.8.7 — instant insight assessments, GLP-1 blood-level chart, tile + caption polish 2026-06-02 10:11:26 +02:00
Marc-André Bombeck 04615d1bc7 fix(medications): make the drug-level estimate disclaimer clearer
The pharmacokinetic-estimate disclaimer rendered as a low-contrast muted
italic footnote, easy to miss. The curve is a modelled value, never a
measured blood concentration, and that framing must read clearly. Lift it
to a bordered note with stronger text contrast and weight, keeping the
wording unchanged and the tone informative rather than alarming.
2026-06-02 10:10:39 +02:00
Marc-André Bombeck ecf4f06870 fix(dashboard): clip the tile card bound on a pathological value
The headline value span is shrink-0 whitespace-nowrap so it never
truncates a digit — correct. A worst-case very wide value could still
push the card's content past its grid column and break the layout. Add
overflow-hidden to the tile card wrapper so a pathological value clips at
the card bound rather than the grid, without re-introducing truncation of
the number for realistic readings.
2026-06-02 10:10:39 +02:00
Marc-André Bombeck aeb72d1a08 fix(insights): regenerate only the user's locale on a measurement invalidation
The write-path regenerate-on-invalidate enqueued a fresh assessment for
both de and en on every dirtying measurement, while the read-path only
ever serves the user's resolved locale. A frequent Apple-Health sync thus
fanned out two provider calls per scope, half for a language the user
never opens. Resolve the user's locale the way the read-path does
(normalizeLocale of user.locale) and enqueue that single locale. The
nightly warm pass still covers both locales for the rare locale switch.
2026-06-02 10:10:39 +02:00
Marc-André Bombeck ae82b3e484 fix(insights): style the fallback trend caption like the advisor caption
Extract the advisor caption's bordered card + icon + foreground prose
shell into a shared TrendCaptionCard and render the additive-metric
fallback caption through it, so the Trends-row captions read with one
typography instead of the plain muted line standing out below the
chart. The two paths now share a single presentational shell and can't
drift apart.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck be90c4b9ad fix(dashboard): never truncate the tile value, let the unit yield instead
The small KPI tiles in the dashboard strip clipped the numeric value at
narrow widths: the headline value span carried `min-w-0 truncate` while
the unit span was `shrink-0`, so on a dense strip (many tiles → narrow
grid column) the number — not the unit — shrank below its content width
and ellipsis-clipped, rendering "130 mmHg" as a truncated "13…".

Flip the priority. The value span is now `shrink-0 whitespace-nowrap` so
every digit always renders in full on one line, and the unit span takes
`min-w-0 truncate` so it yields when the column is too narrow. The
paired-value font step-down (text-3xl → text-2xl for BP systolic/
diastolic) stays as headroom.

Add guards asserting the value node never carries truncate/min-w-0 and
the full reading (single and paired) renders inside it.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck 9652047e8e fix(medications): order the drug-level chart above the dose-strength curve
Lift the GLP-1 drug-level estimate and the dose-strength (titration)
curve out of the default-closed disclosure on both the medication
detail and history pages so they are visible without a click. Render
the drug-level estimate first — it is the curve the medication concerns
— with the titration curve below it.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck 28af138691 feat(medications): show the GLP-1 drug-level estimate by default
Render the estimated drug-level-in-blood curve for any recognised GLP-1
medication without requiring the Research Mode opt-in. The chart still
self-gates on a recognised brand and on having intake data, and the
pharmacokinetic-estimate disclaimer is now always attached so the curve
reads as a modelled value, not a measured blood concentration.

Drop the now-dead Research-Mode gated-placeholder branch and its copy.
The researchModeEnabled flag is unchanged; it still gates the advanced
settings surface and its acknowledgment dialog.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck 9e8b536ff6 fix(insights): place the mood assessment under the first chart
The mood assessment rendered last on the page, below the target summary,
the calendar, the distribution, the weekday and correlation breakdowns.
Move it directly under the mood chart so the narration sits next to the
trend it describes, matching the other category pages.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck 60f347bf8d feat(insights): serve assessments stale-while-revalidate on a cache miss
A category open whose today-cache had been evicted dropped to the
preparing skeleton even when a perfectly good assessment from an earlier
day was still on record. Read the most recent non-stub assessment for the
scope and serve it instantly while the worker re-warms the cache; only
fall back to the preparing state when no assessment was ever produced.
The user no longer waits on an open if an assessment ever existed.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck f4d5114dcd fix(insights): regenerate cached assessments when a measurement invalidates them
The measurement-sync invalidation deleted the dirtied per-scope
assessment rows and waited for the user's next category open (a cache
miss → a worker round-trip while they wait) or the nightly cron to
re-warm them. Enqueue a debounced regenerate for each dirtied scope
instead, coalesced per (user, scope, locale) through the queue's
singleton key so the constant Apple-Health sync collapses a burst of
samples into one regenerate per scope rather than a job storm.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck a8e7325a08 Merge pull request #237 from MBombeck/release/v1.8.6
v1.8.6 — Compliance two-row return, insights polish, targets deprecation
v1.8.6
2026-06-02 00:01:59 +02:00
Marc-André Bombeck cfeb98f0a7 chore(gitleaks): allowlist the trend caption i18n keys 2026-06-01 23:56:19 +02:00
Marc-André Bombeck 9b3cb5ec7e test(e2e): assert the wizard step via the dialog root data attributes 2026-06-01 23:56:19 +02:00
Marc-André Bombeck a4bb4947f9 chore(release): v1.8.6 — compliance two-row return, insights polish, targets deprecation 2026-06-01 23:46:07 +02:00
Marc-André Bombeck b868a72a91 fix(targets): merge the profile-incomplete hint into the deprecation banner
The deprecation notice and the profile-incomplete hint rendered as two
near-identical warning cards stacked on top of each other, so an
incomplete-profile user saw two warning banners. Fold the profile detail
into the single deprecation card as a secondary line beneath the headline
message, shown only when height or age is missing. The page now carries
one warning banner.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 712c5707ee fix(auth): cap insurerIkNumber length before the digit refine
The insurerIkNumber field went straight to the 9-digit regex refine with
no upper bound, unlike its siblings insurerName and fullName which cap at
120 characters. Add a defensive .max(32) before the transform — well
above the 9 digits the value ever holds — so an oversized string is
rejected at the length gate rather than reaching the regex. The refine
already rejects malformed input; this is hardening for consistency with
the surrounding fields.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 0ec4a16153 fix(insights): pin category-page heading height and coach icon placement
The relocated diversity-nudge lightbulb is a 44px touch target that only
mounts on pages where readings cluster, so the heading row sat taller on
pages with a nudge than on those without — category-page headings did not
align. Reserve a constant min-height on the heading row so the baseline
holds whether or not the glyph is present.

Split the row into a wrapping left group (title, nudge, badge) and a
shrink-0 Coach icon pinned top-right via justify-between, replacing the
ml-auto member of a single flex-wrap row. A long or wrapped title at
375px can no longer isolate or crowd the Coach icon; it stays cleanly
top-right and the title reflows in the remaining space.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 3f757f7fc2 fix(medications): name the active wizard step on small screens
On narrow viewports the dot stepper collapsed every per-dot label, so
the user faced an unlabelled row of bullets with no sense of position.
Add a compact caption below the dots that names where they stand ("Step
4 of 8 · How often") on small screens, and let the per-dot labels show
only from the small breakpoint up where there is room. The dots stay
tappable throughout.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 2c91dc7b75 refactor(medications): share the wizard cadence-line builder
`summariseCadence` and `summariseScheduleDraft` each re-spelled the
cadence phrase, its detail clause, and the interpolation count. Extract
a single `{mode, cadence, subControls}`-shaped helper both call so the
medication-wide and per-schedule summaries can't drift apart, and drop
the now-redundant `summaryKeyForCadence` wrapper. Behaviour is
identical; add coverage pinning the per-schedule summary to the
medication-wide summary's leading cadence segment.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 4038cfcffe fix(medications): drop the empty streak row from the compliance bars
The streak flame's flex wrapper rendered unconditionally, so a
zero-streak medication left a residual gap below the two compliance bars
from the wrapper's row plus the stack's vertical spacing. Mount the
wrapper only when there is a streak to show.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck a357332a32 refactor(medications): import the shared ComplianceDisplay type on the cards
The generic and GLP-1 medication cards each re-spelled the
`ComplianceDisplay` interface already exported from the compliance lib.
Drop both hand-copies and import the canonical type so a future field
change lands in one place.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 068cb168d9 fix(medications): clamp compliance expected slots to creation date
`expectedSlotsBetween` walked the full requested window even for a
medication younger than that window. The legacy weekday expander floors
on `startsOn` but not on `createdAt`, so a two-day-old daily med
reported seven expected doses over a seven-day window and thirty over a
thirty-day window — counting days before it existed. Clamp the window
lower bound to `createdAt` so the expected-dose counts, and the window
selection that reads them, reflect the medication's real age. The
displayed rates already clamp independently via `calculateCompliance`,
so only the slot counts change.
2026-06-01 23:43:39 +02:00