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.
A long free-text note forced the mood entries table into horizontal
scroll. Cap the note column width, clamp the note to two lines, and
surface the full text on hover through a tooltip. The complete note
stays editable in the entry sheet.
The Coach launch sat at the foot of every category page; move it into the
sub-page header as an icon-only button at heading height, top-right, so
"ask the coach" reads as a header action and the page no longer ends on a
stray button. The shared shell mounts it (gated on the Coach flag plus the
per-user opt-out), so every category page picks it up and the foot
instance is dropped. Also drop the purple left accent on the assessment
("Einschätzung") card — the coloured rule made it read as restless against
the calmer surrounding cards.
The measurement-diversity nudge used to render as an inline note block on
each category page; read in context it looked like a data finding rather
than a tip and confused. Move it onto the heading as a small Lightbulb
glyph that only mounts when the spread is genuinely lopsided; the hint
text it used to show inline now lives in a hover / focus Tooltip with an
accessible name. The sub-page shell carries the glyph in the heading row,
drops the removed explainer trigger, and normalises the foot-of-page
"show all readings" control to the standard secondary-button height.
The round `?` popover next to each category heading read as restless
beside the title and duplicated copy already shown inline. Remove the
`MetricExplainer` trigger entirely; the static one- to two-sentence
definition stays as the muted caption directly beneath the heading, so
the definition is still always visible without a tap. The bundle keeps
the per-metric Title leaf for stable key pairs even though only the Body
caption now renders.
v1.8.5 swapped the long-standing 7-/30-day compliance rows for a per-dose
adherence timeline on sparse-cadence medications. The timeline breaks the
card layout and reads as restless, so every medication is back to two
percentage rows.
The two windows are no longer hardcoded — they scale with the dosing
cadence. selectComplianceWindows walks a 7/30 -> 30/90 -> 90/365 ladder and
returns the densest rung whose both windows clear MIN_STABLE_DOSES realised
expected doses, so a daily med stays on 7/30, a weekly med steps up to
30/90, and a rolling 35-day injection lands on the widest 90/365 rung. When
even the top rung can't clear the floor (a brand-new or very rare med) the
widest rung is returned so the card still shows two honest rows. The row
labels follow the chosen windows via a parametrised complianceWindow string.
The compliance percentage math is unchanged: each row is the existing
cadence-aware calculateCompliance over the selected window. compliance7 /
compliance30 stay on the wire verbatim for iOS and the Health Score.
- compliance.ts: replace the timeline display with selectComplianceWindows +
the window ladder; buildComplianceDisplay now returns the two scaled rows
- remove the dose-adherence-timeline component and its now-dead strings
- medication-compliance-bars: parametrise the row labels on the day-counts
- switch the standard and GLP-1 cards back to the always-two-row bars
The status pill painted full-saturation Dracula green/orange/red text on
a 12-14% same-hue tint. That fails WCAG AA (4.5:1) for the out tone on
the muted insights reference panel in dark mode (3.87:1) and for every
tone on a white card in light mode (1.2-2.7:1), since the pill used the
raw Dracula palette rather than the theme-aware feedback tokens.
Switch the pill to dedicated theme-aware tokens (status-pill-in/near/out)
and a 10% tint: dark mode keeps Dracula green/orange and lifts the out
tone off #ff5555 to a lighter red; light mode adopts the darker
AA-corrected family already used for the chart and destructive colours.
All three tones now clear 4.5:1 on both bg-card and bg-muted/30 in both
themes (worst case 4.62:1).