1115 Commits
Author SHA1 Message Date
Marc-André Bombeck cc927f7921 Merge pull request #241 from MBombeck/release/v1.9.1
Release v1.9.1 — no assessment warm on a page visit
v1.9.1
2026-06-02 18:47:11 +02:00
Marc-André Bombeck ad471a5110 docs(planning): refresh roadmap and state to the current release line 2026-06-02 18:39:53 +02:00
Marc-André Bombeck fc7386a364 chore(release): v1.9.1 — no assessment warm on a page visit 2026-06-02 18:39:53 +02:00
Marc-André Bombeck 879833b372 fix(insights): stop warming every assessment on an overview visit
Opening the Insights overview fired a once-per-session background warm that
forced the provider to regenerate the comprehensive briefing and every category
and metric assessment at once — a multi-minute burst that contended with
foreground requests and made the app feel unresponsive until it finished. The
nightly pass keeps the caches warm and the per-metric status GETs revalidate
gently on their own, so the warm-on-mount is redundant. Remove it; the manual
'prepare assessments' button remains the explicit on-demand re-warm.
2026-06-02 18:39:53 +02:00
Marc-André Bombeck ed5d453431 Merge pull request #240 from MBombeck/release/v1.9.0
Release v1.9.0 — Insights time-ranges, deeper mood, medication coding, stability
v1.9.0
2026-06-02 18:16:10 +02:00
Marc-André Bombeck eb63cd3df3 chore(release): v1.9.0 — Insights time-ranges, deeper mood, medication coding, stability
Insights gain selectable time ranges with period-over-period deltas, and the
mood page adds time-of-day, stability and weight/BP correlation reads. Medications
can carry ATC + RxNorm codes that flow into the health-record export alongside a
MedicationAdministration per acted dose. The connection pool is sized for real
concurrency to remove the under-load stalls, and the medication advanced-settings
page is rebuilt. The standalone /targets page is retired in favour of inline editing.
2026-06-02 18:08:52 +02:00
Marc-André Bombeck a87d6b94e4 fix(insights): keep the server-only rollup readers out of the insights client bundle
The v1.9.0 time-range work imported ANALYTICS_RANGES as a value into the
time-range pills and the insights layout-prefs hook from range-delta.ts.
That module also pulls the rollup readers and the Postgres client, so the
value import dragged pg (dns/fs/net/tls) into the browser bundle and broke
the production build with eight "Module not found" errors.

Move the client-safe pieces — the range list, its type, the per-range
window length, and the period-over-period result shapes — into a new
range-shared module that imports nothing server-side. The insights client
components import from there; range-delta.ts re-exports the same names so
the server route and the OpenAPI registry keep their import paths. The
window-composition logic and the rollup/db imports stay server-side.

No runtime behaviour change — a pure module-boundary split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75124e76c1e987d8a022fb39d891049340199b38)
2026-06-02 17:41:33 +02:00
Marc-André Bombeck 1c91abcf2a docs(medications): correct the GLP-1 card equal-height comment
The reserved-slot comment claimed the compliance bars and action row
align "row-for-row across the grid" with a generic sibling. That
overstates it: the GLP-1-only rotation-hint block sits between the
reserved slot and the compliance bars, so the bars are pushed down by
its height and do not share a Y with a generic card's bars. What the
layout actually guarantees is equal card height and a shared bottom
action baseline (`h-full` + `mt-auto`); only the header, the reserved
slot, and the pinned action row align across a mixed-variant row.
Reword the comment to claim exactly that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7320c8b1b9f704d22eaba10be42a560594a5ca11)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck ada9006d9c fix(export): cap the FHIR MedicationAdministration count per export
The administration set was windowed only to the report period (up to 730
days). A chronic medication dosed several times a day across that window,
multiplied across several medications, can still materialise thousands of
MedicationAdministration resources into a single Bundle — bounded by the
window but unbounded by dose frequency.

Cap the set at the most-recent 1000 acted intakes overall. The omitted
rows are the oldest in the window, so recent adherence is preserved
intact. When the cap trims rows the aggregator records the original and
included counts and the FHIR document narrative discloses the
truncation, so the export is honest about the omitted history rather
than silently dropping it. Cover the cap in the report aggregator and
the narrative note in the bundle builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c6ce7df2bf6efb5e2c9d948d8f189d95d1289758)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck f0f2caab79 fix(medications): bound the RxNorm code length
The RxCUI field validates against `^\d+$`, which is unbounded — a caller
could submit an arbitrarily long digit string. Add a `.max(20)` ceiling
as defence-in-depth so the column write stays tight; real RxCUIs are at
most a handful of digits. `atcCode` needs no max — its regex already
fixes the length. Regenerate the OpenAPI contract for the new
`maxLength` on both the create and update bodies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8b97d35bc375ef3b2e70f938c0d6729a5fa6bccb)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck bfdf0a12a0 fix(medications): route injection-site taps to the nearest quadrant centre
The two stacked abdomen quadrants sit 14 viewBox units apart, yet each
per-dot hit-target was an r=14 transparent circle (Ø=28u), so the
targets overlapped by ~14 units. SVG hit-testing resolves to the
topmost-painted element rather than the nearest centre, so a tap in the
overlap band logged the wrong quadrant — defeating the rotation feature.

Replace the overlapping per-dot hit-circles with a single transparent
overlay that projects the pointer into viewBox space and resolves it to
the nearest site centre (new `nearestSiteAt` helper, constrained to the
effective allowed set). Every pixel now maps to its closest site, so
full-area routing holds without sub-target dead zones, and the abdomen
quadrants can no longer mis-route. Keyboard and assistive-tech users
keep a per-site focusable r=6 circle (non-overlapping at this spacing)
with the Space/Enter handler and aria-label. Add tests asserting a tap
in the former overlap band maps to the nearer quadrant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a07988f139b0067e82ccc5ad7e5890e0517fe869)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck 107a111545 fix(insights): polish range pills, zero-delta caption, scrollbar gutter, mood tiles
- Range pills: make the radiogroup a single tab stop with roving tabindex and
  Arrow / Home / End key handling, matching the WAI-ARIA radiogroup pattern.
  Previously every pill was its own tab stop and the arrow keys were dead.
- Range delta caption: render a neutral dash + "no change" for a perfectly
  stable metric instead of a down-arrow with "−0%". Adds the `noChange`
  string across all six locales.
- Scrollbar gutter: scope the document-level `scrollbar-gutter: stable` to the
  body-scrolled shells (login / register / onboarding / legal) via a
  `data-scroll` attribute the shell sets, so the height-locked authenticated
  branch no longer reserves a second, never-painted gutter on classic
  scrollbar platforms.
- Mood tiles: a lone in-target / stability tile now spans full width instead
  of leaving a half-width orphan beside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12b434a0c262e29ced1ac0aaf631db6678c3ad05)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck b13721cc97 fix(insights): stop a timed-out warm from evicting its own freshly-warmed caches
The on-demand force-warm bounds the comprehensive briefing with a 45 s
timeout and then warms the per-status + generic-metric caches itself. The
timeout could not cancel the underlying generation, so a comprehensive that
resolved after the timeout reached its own per-status eviction and deleted
the rows the warm passes had just written — leaving the user back on a cold
cache.

Thread an AbortSignal into the comprehensive generation and abort it when the
timeout fires; the generation now bails out before its write + evict once the
signal is aborted, so a late resolve can never undo the warm.

Also align the force-warm gating with the route: the route admits the job on
the per-user assessment surface, so warm the per-status + generic-metric
cards whenever that surface is enabled, even when the global briefing switch
is off. Only the comprehensive briefing stays gated on the briefing switch.

Factor the per-status cache-action shape into a single helper so the key is
built in one place rather than hand-assembled across the generators, the
eviction sweep, and the ingest debounce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75e0b5a9129893915f7a213ec96253df19341d15)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck 94415e468e fix(layout): reserve the scrollbar gutter so the nav no longer shifts
The authenticated shell scrolls inside its own `<main>` (the body is height-
locked with `flex h-dvh`), and that `<main>` centres its content with
`mx-auto`. A tall section paints the vertical scrollbar and narrows the
content box; a short one hides it and widens the box, so the whole column —
including the admin/settings sidebar — recentres a few px sideways on every
view switch. The admin "Insights Quality" ↔ "Integrations" toggle was the
visible case.

Reserve the gutter with `scrollbar-gutter: stable` on the `<main>` scroll
viewport, and on `html` for the body-scrolled login / onboarding / standalone
pages. The content box width is now constant whether or not the bar is
painted, so the left nav no longer shifts when a scrollbar appears. The fix is
global: every signed-in route renders inside the same `<main>`, so the same
shift on settings, insights, and dashboard navigation is fixed at once. The
height-locked authenticated body never scrolls the document, so no double
gutter results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c1459e5ee42b7f7acbfa933a9eb7d1f5a1c6890a)
2026-06-02 17:10:36 +02:00
Marc-André BombeckandClaude Opus 4.8 6d1587531f test(settings): expect the shortened notifications section label
The settings section reverts to the single-line "Notifications" /
"Benachrichtigungen"; update the shell title assertions to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:04:40 +02:00
Marc-André Bombeck 170862fdf8 fix(targets): convert glucose targets back to mg/dL on save
The per-metric target editor receives a glucose range already projected
into the user's display unit (mmol/L) along with `unit="mmol/L"`, but it
seeded, validated and saved that value verbatim. HealthLog stores glucose
canonically in mg/dL, so a user on the mmol/L preference either had a
valid target (e.g. 3.9–5.5 mmol/L) rejected by the 40–400 mg/dL bounds,
or — when an override already existed — saw a mg/dL number rendered as if
it were mmol/L and wrote it straight back.

Add `mmolToMgdl` / `toCanonicalMgdl` (the inverse of `convertGlucose`) and
teach the edit sheet to treat the display unit at its boundary: seed the
persisted mg/dL override into the display unit, validate against bounds
expressed in the display unit, and convert the typed value back to
canonical mg/dL before the PUT. A no-op for every non-glucose metric and
for users on the mg/dL preference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0cc0361058a85f32d7135e5f2fd425c98f62d885)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 09af60303d feat(medications): redraw the injection-site picker as an anatomical silhouette
Replace the stick-figure outline with a traced front-of-body silhouette,
still an inline SVG so it inherits the theme tokens (currentColor + a
soft currentColor wash) and tints correctly across light/dark and the
zinc/Dracula palettes with no raster asset.

Recalibrate SITE_COORDS onto the new 100x200 viewBox (aspect ≈0.5) using
the iOS client's hands-on fractional coordinates, so both apps land their
dots on the same anatomy. The coordinates double as the Euclidean basis
for the rotation recommender, so picker and recommendation stay in sync.

Dot states: selected (filled), recommended-next (dashed primary ring),
and a new last-used solid ring; hit targets are r=14 on the viewBox
(≈45 CSS px at the rendered scale), clearing the 44px floor. Props,
callbacks, the screen-left=="links" convention and the v1.8.5 site→enum
bridge are unchanged — graphic and coordinates only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46125c3bbec5573910f41a319ae717d37f39c228)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 7857aa3dd2 fix(settings): shorten the notifications section label to one line
The notification-channels section heading and the left-nav label both
render `settings.sections.notifications.title`, whose German value
("Benachrichtigungs-Kanäle") is a long compound that wraps to two lines
in the nav rail. Shorten the displayed value to the plain plural across
all six locales (de "Benachrichtigungen"; en "Notifications"; es
"Notificaciones"; fr "Notifications"; it "Notifiche"; pl
"Powiadomienia"). Key id is unchanged; the breadcrumb on the
notification-center page keeps its own wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit af437f48b09b9f99b6a80709ddacd565c10132ab)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck d880385635 refactor(targets): retire the standalone Targets page
The standalone /targets (Zielwerte) page is retired. Target editing
moved inline onto the Insights metric pages in v1.8.6 — each metric's
reference panel mounts the editor and writes the same
PUT /api/user/thresholds, reading the same /api/insights/targets
payload. The page carried a deprecation notice and had already dropped
out of the navigation, so removing it is a UI-only change: no data
model, API route, or inline editor moves.

Remove the page and its tests, the components reachable only from it
(target card, per-card coach button, summary header, target prompt /
scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets
proxy redirect, and the orphaned targets.* i18n keys across all six
locales. Repoint the handful of code comments that pointed readers at
the page to the inline Insights panel, and prune the two coach
gate-inventory allowlists plus the queryKey factory guard root that
named the removed files.

The reference panel keeps the range bar, consistency strip, status
pill, edit sheet, and source-link helpers — those are the live editing
surface now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 8c316ee3b0 ops: add a post-deploy version assertion script
scripts/assert-deploy.ts polls /api/version on prod + demo and asserts the
served data.version matches an expected value, printing each target's
buildSha and exiting non-zero on a mismatch or after a bounded retry loop.
This catches the BuildKit layer-cache pitfall where the deploy reports
clean but /api/version still answers the prior release. Run via
`pnpm dlx tsx scripts/assert-deploy.ts <version>` — not bundled into the
standalone image. Document it in the deploy runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a86a9151ef670846c4b33a1c068da40324393308)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 4feac6b7e4 fix(insights): gate the assessment warm on insightStatus, not coach
POST /api/insights/pregenerate warms the assessment cards — the comprehensive
insight plus the specialised status cards — all of which the read-only status
GETs gate on the insightStatus surface. It was gating on coach, so a user with
assessments enabled but the Coach disabled got a 403 and could not warm. Move
the gate to insightStatus to match the surface it actually serves, add it to
the route-gate inventory allowlist, and cover the coach-disabled-still-warms
path with a route test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ec041db6e2c81b95a429625bb765f1f41ebce40b)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 18cde65af7 docs(openapi): register the six specialised insight status routes
The generic metric-status route set the OpenAPI pattern for the assessment
cards; retrofit the six specialised routes onto it. Add registry entries
for /api/insights/{blood-pressure,pulse,weight,bmi,mood,medication-compliance}-status
mirroring the metric-status envelope (hasProvider/text/cached/updatedAt plus
the preparing + revalidating flags). Medication-compliance carries its richer
summary + per-medication note array as its own schema. Regenerate the YAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 91887a13b30e8978e91f615604163331bdc54950)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 7fa2866154 feat(fhir): emit Coverage on a bare KVNR + add Composition.text
Align the server's `Coverage` emission with the iOS exporter: a
Coverage is now emitted whenever ANY payor signal is present, including
a bare KVNR with no insurer name or IKNR. The KVNR-only case carries the
`subscriberId` with no contained payor Organization; the insurer-known
case is unchanged. A bundle with no payor signal at all still omits
Coverage entirely.

Also add a top-level `Composition.text` narrative (reusing the same
escaped summary the section narrative carries) so a strict
US-Core-style validator sees the document-level narrative it expects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 22c5ed7fbe02993307c3f04cd0498d199c79cf43)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck ca8781867b feat(fhir): add a MedicationAdministration per acted intake
Emit one `MedicationAdministration` per intake the user actually
actioned — `completed` for a taken dose (`effectiveDateTime = takenAt`),
`not-done` for an explicit skip (`effectiveDateTime = scheduledFor`).
Pending / missed slots and soft-deleted tombstones are never exported,
so the record only asserts administrations that happened.

Each administration reuses the statement's ATC/RxNorm + text concept,
so it is self-describing without a cross-resource reference. A `dosage`
is emitted only when a structured dose Quantity is available (resolved
from the dose-change history in effect at the administration instant) —
a dosage carrying only `.text` would violate the R4 dose-or-rate
invariant. Route + injection site ride along as text-only concepts. The
administrations are referenced from the existing Medications section, so
the document's two-section graph is preserved.

`FhirMedicationAdministration` is new; `FhirDosage` gains `dose`,
`route`, and `site`. The source rows come from the same aggregator the
PDF consumes, keeping FHIR and PDF aligned by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit dd22512f56b7b6dfa42386bbc88d1ced1d71255d)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck 2fcd2e75f5 feat(fhir): emit ATC + RxNorm codings on the medication concept
The health-record export's `MedicationStatement.medicationCodeableConcept`
gains additive `coding[]` entries when the medication carries a stored
code: ATC primary (`http://www.whocc.no/atc`), RxNorm secondary
(`http://www.nlm.nih.gov/research/umls/rxnorm`). The free-text name
stays the `.text` anchor and the concept collapses to exactly the
prior text-only shape when no code is present — the export never
machine-guesses a code, only emits what the user stored.

The aggregator now carries `atcCode` / `rxNormCode` on `medications[]`
and assembles the acted-intake source rows
(`medicationAdministrations`) the administration builder consumes, so
the FHIR export keeps describing the same numbers as the PDF by
construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 740c86b3a830efa7e76d69b29ae376addeee6811)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck 0a8c6e307c feat(medications): add optional ATC + RxNorm codes to the medication row
Add two nullable, additive columns to `Medication` (`atc_code`,
`rxnorm_code`) so a user or clinician can assert a drug-classification
code that the health-record FHIR export emits on the medication concept.
Codes are taken verbatim, never machine-guessed: a wrong code is a
clinical-safety problem, an absent one is merely a (conformant)
text-only concept.

- Migration 0103 — additive, nullable, no backfill. Pre-existing rows
  read NULL, which the exporter already treats as "no code".
- Validate on write — ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`;
  422 on a malformed value. Wired field-by-field through the create +
  update routes (no mass assignment); `null` clears a code on update.
- Surface both codes in the medication advanced-settings sheet's Data
  group with a self-saving input, and echo them on the GET/POST/PUT
  response shape. OpenAPI regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2e0051d42826fa3f043c32e78a34dab7c8050165)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck eff7b42348 feat(medications): collapse the API request examples and quiet the URL
The external-API row was the tallest thing in the advanced-settings sheet:
a full-width endpoint URL block plus an always-expanded 4-9 line request
snippet, on screen for every user whether or not they touch the API.

Collapse the multi-language request examples behind a chevron disclosure,
closed by default — the language picker and the live-interpolated bearer
token now only render once the user opts in, keeping the token-shaped
payload out of the default-visible DOM. De-emphasise the raw endpoint URL
to a muted, single-line truncated `<code>`: the copy button already carries
the full value, so the long absolute URL was redundant noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6310ed49f4523e3210b26c05f2c934748967af88)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck 555138110c feat(medications): give the external API its own settings group
The advanced-settings sheet crammed the external ingest endpoint into the
Data group next to CSV import/export — two unrelated audiences sharing one
frame. Pull `ApiTokensRow` into a dedicated "Externe API" group so the
developer-facing surface reads as a distinct, lowest-frequency block. New
group order: Reminders -> Lifecycle -> Data -> Externe API -> Danger.

The new group reuses the existing `SettingsGroup` bordered-card chrome, so
it stays visually consistent with its siblings. Adds the `externalApi`
group label across all six locales and updates the composition test to pin
the five-block order plus the API row's new home.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit f9f3a1277b5aad1710815511ddf7f6b210a652b2)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck f65181bbf2 feat(insights): surface mood time-of-day, stability, and weight/BP correlations
Render the new mood analyses on the insights surface:

- a four-bar time-of-day chart (morning / afternoon / evening / night),
  shown only when the server marks the pattern reliable
- a stability tile beside the in-target tile, with the descriptive band
- the time-of-day takeaway in the narrative feed (new Clock icon, daypart
  label resolved from the bundle)

Also wire the mood x weight and mood x systolic-blood-pressure scatter
cards. Their Pearson coefficients and points were already computed and
returned by the endpoint but never rendered; they now sit alongside the
sleep / activity / pulse cards, each gated on the same minimum-pairs
threshold so a thin pairing renders an empty state rather than a noisy r.

New i18n keys land EN-first and propagate across de / es / fr / it / pl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12902b0cdbc6aa272b6da3c35ba3781f9216cf9f)
2026-06-02 16:45:14 +02:00
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