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>
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)
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)
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)
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)
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)
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)
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)
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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
`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.
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.
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.
`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.
The structured taxonomy tags carry a separate, richer signal than the
free-text tag list: a curated catalog pick with a label key and category,
attached to a mood entry through the tag-link join. The tag-mood takeaway
builder only ever read the flat tags, so the structured tags never reached
the ranked lift/drop pool despite both call sites already passing them.
Merge both axes into one ranked pool. A structured candidate emits its
catalog label key as tagKey, which the feed resolves to the active locale
the same way the weekday key already resolves; flat tags keep their
verbatim label. The lean status snapshot now loads the structured-tag
links too, so the takeaways the model sees match the ones on screen.
The mood page stacked the in-target tile directly above the narrative
feed, and the feed carried a same-number in-target takeaway built from
the identical share — so the percentage showed twice, one row apart.
Suppress the in-target narrative in the feed whenever the tile renders
(the tile is the canonical surface). The takeaway still rides the API
and LLM snapshot payload unchanged.
The health-record FHIR bundle now carries a Coverage resource right
after the Patient. Coverage.status is active, beneficiary references
the Patient, and subscriberId carries the KVNR when present. The payor
is a contained Organization referenced by a local #-ref: its name is
the insurer name and its identifier carries the IKNR under the
http://fhir.de/sid/arge-ik/iknr system.
When the IKNR is absent the Organization keeps its name only; when
neither insurer name nor IKNR is known the Coverage is omitted rather
than emitting an empty payor. The KVNR continues to ride on
Patient.identifier unchanged — Coverage is purely additive.
Add an optional German insurer institution number (IKNR, 9 digits) to
the user profile, alongside the existing insurer name and KVNR. The
IKNR is a public registry identifier for the institution, not a
per-person quasi-identifier, so it is stored plaintext, matching
insurer_name rather than the encrypted KVNR column.
The profile GET echoes the field; PATCH/PUT accept it via Zod —
optional, empty string maps to null, and a non-empty value must be
exactly 9 digits (422 otherwise, no checksum). The write is built
field-by-field. /api/user/profile gains an OpenAPI registration with
the profile read/write schemas.
Migration 0102 adds the nullable column, NULL-defaulted, forward-only.
Target editing now lives inline on the insights pages, so the standalone
targets page is no longer a navigation destination. Remove its sidebar
and bottom-nav (overflow) entries and add a top deprecation banner that
points the user at the insights pages and notes the page will be removed
in a future release.
The page stays routable: the /zielwerte to /targets redirect remains and
the route still renders. Removal later is UI-only because the insights
panel consumes the same targets read and threshold-override write. New
deprecation strings land across all six locales.
The per-metric reference panel on each insights category page now opens
the self-contained target editor in place rather than linking out to the
targets page. The panel mounts the same <TargetEditSheet> the targets
card uses, seeded with the metric's type, unit, and range (plus the
diastolic band for blood pressure); the sheet writes the threshold
override and invalidates the shared targets cache on save, so the panel
repaints without a navigation.
Switch the sheet's targets-cache invalidation to the query-key factory
call so it stays aligned with the centralised key.
The Trends row charts the metrics the daily briefing flags, but only
the legacy BP / weight / mood triple carried an advisor annotation.
Every additive metric (pulse, sleep, steps, HRV, resting heart rate,
active energy, flights, distance, VO2 max, body temperature) rendered
the chart with empty space underneath because the caption only painted
when a slot carried an annotation key.
Give every chart config a standard one-line description (captionKey)
and render it for slots that carry no advisor annotation, so no card
is ever caption-less. The legacy triple keeps its annotation slot. Add
the caption strings across all six locales and regression tests that
pin a caption on every tile.
The GLP-1 detail disclosure carried a titration block that only ever
rendered the standard EMA *reference* ladder — a static schedule with no
values from the user's own therapy, blank whenever the medication's brand
fell outside the catalog. Replace it with a dose-strength curve over the
user's recorded dose-change history.
The MedicationDoseChange stream is the history of record for titration
(2.5 -> 5 -> 7.5 mg escalation steps). The new curve plots that series as
a step line, carrying the latest dose forward to now so the current
strength anchors the right edge. With a single dose change there is no
escalation to draw, so the surface states the current dose plainly with a
"no titration history yet" caption rather than a flat single-point chart;
with none it shows the empty state. The curve plots only logged facts, so
unlike the estimated-PK drug-level chart it needs no Research-Mode gate.
Drop the now-unused reference-ladder section, its read route, the ladder
helpers, the titration query key, and the orphaned i18n block.
Add an optional per-entry `source` to POST /api/measurements/batch,
restricted to the {APPLE_HEALTH, MANUAL} subset of MeasurementSource and
defaulting to APPLE_HEALTH when absent so existing web and iOS callers
are unchanged. The standalone iOS client can now tag adopt-on-pair
backfill rows that the user entered by hand as MANUAL instead of having
them mis-attributed to HealthKit.
source is threaded into the (userId, type, source, externalId) dedup
key, the existence probe, and the stats:* overwrite scope, so a MANUAL
row and an APPLE_HEALTH row sharing an externalId are distinct rows and
the stats:* re-post overwrite only touches the matching source.
WITHINGS and IMPORT stay server-owned and are rejected here with 422.
Regenerate the OpenAPI spec and add integration coverage for the
absent-source default, explicit MANUAL, cross-source distinctness, and
the rejected-source 422 path.
Replace the space-y-4 + interleaved-separator danger card, whose two
rows the outer group double-framed into unequal spacing, with one
tinted card: divide-destructive/20 dividers, equal py-3 per row, and
two identical destructive buttons for a true matched pair.
Drop the lg:grid-cols-2 data|reminders grid that read as overloaded.
The sheet now narrows from 4xl to 2xl and stacks four bordered section
cards in most-used-first order: Reminders, Lifecycle, Data, Danger. One
spacing rule throughout — space-y-4 between cards, divide-y plus py-3
rows inside each card, with the SettingsGroup card owning the dividers
so no row interleaves its own separator.
The notes timeline display comes off the mood insights surface; note
capture on the entry form stays. Add a structural guard that the
timeline component is gone, the insights sections no longer render it,
and the narrative feed and in-target tile take its place.
Surface plain-language takeaways above the mood charts: the weekday dip
and peak, the overall trend direction, the weekend-vs-weekday effect,
the strongest tag-to-mood lift and drop, the in-target share, and the
current logging streak. Each takeaway is threshold-gated — every minimum
sample count and minimum effect size is a named constant, and a takeaway
only renders once its bar is cleared, so the feed never falls back to
filler. The list is ranked by effect size and capped.
The takeaways are pure helpers computed from the existing mood
aggregates, so the same feed reaches both the insights endpoint and the
mood snapshot the model reasons over — what the page shows matches what
the model is sent. Also promote the previously snapshot-only in-target
percentage to a visible headline tile.