W-CI raised the onboarding checklist toggle and the intake-history
pager to the 44 px mobile touch floor (WCAG 2.5.5). The new
medication-intake quick-add sheet sibling shipped with an empty-state
CTA at h-8 (size="sm" default) and footer Save/Cancel buttons at h-10,
both below the project-wide floor and visibly inconsistent with the
sibling MoodForm / MeasurementForm + the rest of the v1.4.37 surfaces.
Apply the established min-h-11 sm:min-h-9 override to the empty-state
CTA, the footer Save/Cancel, and the empty-state Close button. Extend
the SSR contract test to count three min-h-11 floors instead of two so
a future regression that drops one of the buttons back below 44 px
trips before the release.
The W7c drill-down resolved the day window via
canonicalDailyTimestamp(dayKey, tz) ± 12 h. The shape worked for any
non-DST day because the noon instant carries the zone's offset, but
on the two transition days per year the true local day is 23 or 25
hours long while the window always spanned exactly 24 hours. On
Berlin spring-forward (Mar 30, 2025) the window leaked the previous
day's last hour into the drill-down; on fall-back (Oct 26, 2025) it
hid the first hour of `today`. Same shape applies to every IANA zone
that observes DST.
Add a localDayWindow(dayKey, tz) helper that anchors dayStart and
dayEnd at the user's local 00:00 of the requested day and the next
day. The next-day anchor pattern picks up the DST shift automatically
because each day's offset is read at its own UTC midnight (EU/US DST
transitions happen at local 02:00 / 03:00, so the offset at 00:00
UTC is unambiguous). Half-hour zones (Asia/Kolkata UTC+5:30,
Pacific/Chatham UTC+12:45) keep their minute component.
The dayKey validator regex matched any YYYY-MM-DD string — including
impossible dates like 2026-02-30 or 2026-13-01. `new Date()` silently
overflows those values into the following month, so the drill-down
returned rows from a different calendar day than the user asked for.
No 422 was emitted; the response was syntactically well-formed and
pointed at the wrong day. The same helper feeds the admin drain
route, so a malformed CLI invocation had the same blast radius.
Add a refine that round-trips the parsed Date back to ISO and
rejects when the slice no longer matches the input.
The groupBy=day collapsed branch runs the per-user-day bucketing AFTER
the per-sample findMany, so it can't honour a non-zero offset without
real Postgres-side date_trunc grouping plus a distinct count — both
v1.4.38 backlog items. The dayKey drill-down has the same shape: it
returns a single bounded page, not a cursor.
Both branches previously accepted offset silently and hard-coded
offset:0 in the response meta, so a consumer threading the v1
pagination contract (admin tools, any future iOS list view) would
paint "showing 1-25 of N" with the wrong N. Reject the combination
at the validator instead with a clear 422.
The analytics route carried an inline MeasurementType →
SourcePriorityMetricKey switch next to the cumulative-day-sum
bucket-and-sum. Adding a new cumulative metric required updates in
three places — the SourcePriorityMetricKey enum, the route-level
switch, and CUMULATIVE_HK_TYPES — with no shared test to pin the
drift. Same shape as the H-2 / W10-4 H3 finding the previous commit
closed for the cumulative-type set.
Move the lookup next to CUMULATIVE_DAY_SUM_TYPES in
src/lib/measurements/cumulative-day-sum.ts and add vitest assertions:
every CUMULATIVE_HK_TYPES member resolves either to a real
SourcePriorityMetricKey or to null (the picker's pass-through escape
hatch for types like TIME_IN_DAYLIGHT). The route now imports the
helper instead of duplicating the switch.
CUMULATIVE_HK_TYPES (apple-health-mapping.ts) and CUMULATIVE_DAY_SUM_TYPES
(cumulative-day-sum.ts) enumerated the same five Apple Health cumulative
types in two literal blocks. A contributor adding a sixth type (e.g.
swimming strokes) only had to update one list and the system would
silently misreport for that type — either the list-view collapse would
not fire or the analytics pickCumulativeDaySum would skip it, depending
on which file the contributor touched.
Derive CUMULATIVE_DAY_SUM_TYPES from CUMULATIVE_HK_TYPES at module load
(sorted alphabetically for stable iteration) and add a vitest parity
assertion so the membership stays pinned. Both exports remain so every
existing import keeps working.
pg-boss v12 requires explicit createQueue before scheduling. The
nightly drain schedule registered without a matching createQueue call,
which silently no-ops the cron — the per-sample Apple Health rows
never collapse into one row per day. The list view still works via
the on-demand groupBy=day branch, but the underlying table stays
bloated forever.
Add the queue to the allQueues array next to ROLLUP_FULL_BACKFILL_QUEUE
so the boot loop creates it before the schedule registration runs.
Extend the existing scheduler test to grep the allQueues block so a
future regression flips red instead of slipping through.
The two helpers carried byte-equivalent CF / XFF / x-real-ip ladders
since the F-6 audit landed `getClientIpOrTrustWarning` alongside
`getClientIp`. Project `.ip` through the tagged helper so the rotation
guard, Cloudflare opt-in and one-shot trust-violation warning live in
one place; every existing call site keeps the same return value.
The v1.4.34.5 tap-target sweep covered the dashboard tile and
per-row chevron but missed the two CTAs the
`<GettingStartedChecklist>` header renders on the onboarded-user
dashboard: the expand/collapse toggle (32 px tall) and the
dismiss-all CTA (40 px via shadcn's default Button, which has not
yet learned the `h-11 sm:h-10` mobile lift the input primitives
follow). Both flagged in the `mobile-viewport` touch-target probe.
Pin `min-h-11 sm:min-h-10` on each so the desktop rhythm is
unchanged.
While here, two e2e specs needed updates to match the same wave:
- `mobile-viewport.spec.ts` enforced `width < 44 || height < 44`,
which flagged the chart-range pill row (`7T` / `30T` / `90T`,
each 44 px tall but 30-40 px wide) as a WCAG violation even
though 2.5.5 honours adjacent-target spacing for horizontal
groups. Drop the width branch; the height floor is the hard
contract every solo CTA must clear.
- `onboarding-flicker.spec.ts` route-stubbed `**/api/analytics`,
which is exact-match against the path — the IW1 slim-slice
variant `/api/analytics?slice=summaries` (which the checklist
hook now uses) bypassed the stub and hit the real route, so the
onboarded-user fixture flipped to `measurementCount = 0` and
the card rendered. Swap both stubs to a regex that matches the
base path AND any query-string variant.
The v1.4.34.5 iOS-textarea-zoom sweep lifted the mobile input
height floor from 40 px (`h-10`) to 44 px (`h-11`) on `Input`,
`Select` trigger, and the native `<select>` primitives — the
desktop tier still resolves to 40 px via `sm:h-10`. The
settings-mobile-consistency spec still pinned every form input,
the Compare-to trigger, and every `/settings/ai` native select to
40 px, so it went red on the v1.4.36 tag while the implementation
was correct.
Bump the three height assertions to 44 px and refresh the header
docblock so a future maintainer reads the contract as "44 on
mobile, 40 on >=sm" instead of stumbling on a stale baseline.
The integration suite used to share a single module graph across
files (`pool: forks`, `fileParallelism: false`, `isolate: false`).
That kept the container boot amortised but meant a `vi.mock()`
declaration in one file lost to whichever earlier file had already
pulled the real module into the graph. `apns-dispatch.test.ts` and
`integration-status.test.ts` both fall through to
`@/lib/notifications/dispatcher` (and its `@parse/node-apn` /
telegram sender deps); when a sibling test mounted the real
dispatcher first, the mock factories registered later silently
no-op'd and the in-suite run reported the mock as called zero
times. Each file still passed in isolation, which is the
fingerprint the v1.4.35.1 closure already flagged as pre-existing.
Flip the integration config to `isolate: true`. Per-file module
isolation costs ~10 s of import rebuild across the 56-file run;
the Postgres testcontainer + migrations live in `globalSetup`, so
the slow part (container boot, ~5-15 s) is unaffected. The two
`withings-*-sync.test.ts` files were previously dependent on a
sibling setting `process.env.ENCRYPTION_KEY` before them — pin the
deterministic test key in each file so they remain self-contained
under per-file isolation. Locally `pnpm test:integration` now
shows the four target failures green; the remaining two
bp-in-target flakes are pre-existing local-only data drift that
passed on CI under the old isolation flag and continue to be
ordering-independent.
The features-payload-too-large guard constructs a ~5.6 MB rollup
payload and runs the whole serialiser pipeline to confirm the
ceiling fires. Local hardware completes the assertion well inside
the default 5 s budget, but the GitHub-Actions Ubuntu runners
crossed it and timed out on the v1.4.36 push. Pin a 30 s budget on
this single case (Vitest 4's two-arg `it(name, opts, fn)` form,
the deprecated three-arg form was removed) and leave the rest of
the suite on the default; the runtime is dominated by the giant
allocation, so the wider window only matters for the slowest
runners.
Two findings from the post-implementation self-review:
- Empty-state CTA: swap the plain anchor for next/link so the
/medications jump stays inside the SPA router instead of triggering
a full page reload.
- JSDoc accuracy: the original module-level comment claimed a
non-default dose 'surfaces as a sonner toast hint' — neither the
toast nor a notes-style append actually exists. Rewrite the dose
paragraph to honestly describe the contract: the dose input is
pre-filled for visual confirmation, editable, but the POST body
never carries the dose override. The intake event always
references the medication's catalogue dose; a half/double dose
still routes through the medication edit on /medications.
Add the SSR-level suite for the new quick-add: empty-state markup, the
populated form's field testids plus 44 px touch floors, the pure
default-selection heuristic across the four ranked branches
(empty / single-active / due-now / alphabetical fallback), and the
no-raw-key locale check for English and German.
Extend the existing quick-add-labels guard so it covers the third
DropdownMenu row alongside the original two — the icon is decorative
and the trigger label sits above the menu, so the three visible
labels must stay distinct from each other and from the trigger.
Add the menu-item label (dashboard.quickAddMedicationIntake) and the
dashboard.medicationIntakeQuickAdd block (sheet title, sheet
description, field labels, hint, error message, and empty-state copy)
to all six locales. The German bundle ships Marc-Voice copy with full
UTF-8 umlauts; the other locales mirror the English fallback already
in use for the other two quick-add rows until the localisation pass.
Add the third row to the dashboard quick-add DropdownMenu so a
medication-intake event can be logged without leaving the page. The new
ResponsiveSheet shares the same footer-portal contract as the existing
Measurement and Mood sheets so the Save and Cancel buttons sticky-pin
above the mobile soft keyboard, and the menu label is a self-contained
verb-phrase ('Medikamenteneinnahme erfassen' / 'Log medication intake')
that doesn't collide with the trigger or the sibling rows.
Mirror the ResponsiveSheet primitive used by the Measurement and Mood
quick-adds for medication intake. The sheet auto-selects the active
medication whose schedule window is currently open (or the only active
medication when the user has just one), pre-fills the dose from the
catalogue entry, and defaults the time taken to now. On submit the
form invalidates medicationDependentKeys plus the inline per-medication
compliance chart cache so the detail tile, the dashboard compliance
chart, the today list, and the achievements card all refresh in lockstep
with the API. An empty-state branch suppresses the form and surfaces a
CTA to /medications when the user hasn't yet created a medication.
The `runGeoBackfill` helper has shipped since v1.4.27 but was never
wired into the worker boot — it only ever fired through a test path,
so the admin sign-in overview's "Standort" column kept rendering
"—" on every historical row where the fire-and-forget IP resolver
missed at write time. Marc's report against v1.4.36 made the
problem visible: even after the v1.4.36 fallback fix, the backfill
was missing entirely.
Wire the helper into the existing pg-boss schedule list at :40
every hour. The queue name + cron expression live in the helper
module so the worker boot pulls them by import; a co-located unit
test pins both shapes (queue name + cron slot) plus a source-text
probe that confirms the worker actually imports and registers the
runner. The helper itself is unchanged — it stays idempotent, capped
at 5 000 rows per pass, and scoped to the trailing 30-day window
that the operational triage cares about.
The :40 slot was chosen to dodge the existing :00 (Withings activity
+ measure crons), :15 (Withings sleep), :30 (moodlog sync), and
:25 (medication compliance status) crowd at the top of the hour;
the helper's per-call cap keeps the audit-log write budget under
control even if a future ingest path enqueues a hot run.
Per-commit summary, file ownership, tests delta, self-review findings
and applied resolutions, visual diff between Ramipril and Mounjaro
post-fix, and the GLP-1-specific rotation-hint prominence judgement.
Self-review polish on top of the v1.4.37 W7c step-consolidation
landing:
- The collapsed daily view returns up to 5000 synthesised rows in
one shot, so the legacy 25-row pagination would have re-fired the
same scan with offset=0 returning the same slice. Disable
pagination on the day-grouped path; per-sample lists keep their
PAGE_SIZE=25 behaviour.
- Bump the per-page row scan from 1250 to the server max of 5000 so
even chatty pre-drain Apple-Watch accounts (~200 chunks/day on
busy days) get a useful number of daily rows on the first paint.
Post-drain the scan is trivially short (each day reduces to one
stats: row).
- Reset offset=0 explicitly on the grouped path so a sort-direction
flip never paginates into an empty server slice.
- Clarify the day-boundary comment in the dayKey drill-down branch
— the ±12h around canonical noon shift works for every IANA
offset, including sub-half-hour zones (Asia/Kathmandu +5:45,
Pacific/Chatham +12:45).
The Coolify-fronted HealthLog stack sits behind Cloudflare, which
strips and re-sets `cf-connecting-ip` on every request that lands
on its edge. Without consulting this header, `getClientIp` reads
the Caddy loopback IP off the `X-Forwarded-For` chain and the
admin sign-in geo lookup ends up with a null IP for every visitor
— Marc's "Standort" column rendered a literal "—" on every row of
the sign-in overview even after v1.4.36 closed the missing-ASN
fallback.
The new branch prefers `cf-connecting-ip` ahead of the XFF / x-real-ip
chain, but only when the operator opts in via `TRUST_CF_CONNECTING_IP=1`.
A self-hosted deployment without Cloudflare in front MUST leave the
flag off — an attacker can set the header directly on the public
internet, and the downstream geo resolver would happily report a
forged location.
The trust-violation accounting in `getClientIpOrTrustWarning` skips
when the CF header satisfies the lookup because Cloudflare re-sets
the value on every edge request — there is no chain to violate. The
existing XFF + x-real-ip semantics are unchanged for every
deployment that leaves the flag unset.
`.env.example` documents the flag in a dedicated "Reverse-proxy
trust" block right above the process-split section so the operator
finds it next to the existing infra-trust controls.
Side-by-side contract suite for the v1.4.37 W4b symmetry fixes from
Marc's UX audit (item 11). Asserts the load-bearing structural seams
both medication-card variants must share:
* History + Pencil header buttons render on both cards.
* No kebab on either card when no overflow prop is wired (Mounjaro
matches Ramipril in the common case).
* Kebab only mounts on Mounjaro when `onLogSideEffect` is supplied;
no equivalent surface on Ramipril.
* Category-label badge uses the shared category-map lookup on both
cards (Mounjaro no longer hard-codes the treatment-class label).
* Take-now coloured pill renders on both cards when inside the
current schedule window.
* Primary-actions row carries the canonical two-button shape — the
GLP-1-specific Stethoscope no longer leaks into this row.
* Purple `text-purple-400` accent on the upcoming schedule dose
surfaces uniformly across both card kinds.
Four new test files cover the v1.4.37 W7c surface:
* group-by-day.test.ts (route) — collapses per-sample step rows into
one row per user-TZ day with sampleCount; gates on
CUMULATIVE_HK_TYPES so non-cumulative types keep the legacy
findMany behaviour; resolves the dayKey drill-down window via
measuredAt: { gte, lt }; rejects malformed dayKey at the
validation layer.
* measurement-list-step-grouping.test.tsx (UI) — renders the
collapsed daily total with the localised sample-count caption;
exposes a chevron-expand button with aria-expanded; suppresses
the edit/delete actions on the synthesised daily row.
* drain-per-sample-cumulative.test.ts (helper) — passes a
measuredAt cutoff into the per-sample scan when cutoffHours is
set; falls back to the all-rows behaviour for CLI/admin callers
that omit the option.
* drain-cumulative-queue.test.ts (scheduler) — pins the queue name,
cron cadence (03:45 Europe/Berlin), 36 h cutoff constant, and
pg-boss handler wiring.
Self-review polish on top of the W2 fast-path landings:
- Rename the misleading `ninetyFiveDaysAgo` local in
bp-in-target-fast-path to `readSince` so the 396-day BP rollup
read window does not read like a 95-day shortcut.
- Document the rollup-bucket-vs-display-tz day-key shift in
correlations-fast-path. The DAY bucket anchors at UTC midnight; for
the Berlin-centric production tenant that lands on the same local
calendar day, so the rollup-fast-path's per-day pair shape matches
the live path within the n >= 20 surface gate. A v1.5 follow-up can
mint per-user-tz buckets if a non-Berlin account needs strict
parity.
The GLP-1 card carried a third Stethoscope button in the primary
actions row (Eingenommen / Übersprungen / Nebenwirkung), which broke
the two-button symmetry the generic medication card establishes.
Marc reported the asymmetry during the v1.4.37 UX audit (audit
item 11): "ich möchte natürlich, dass das genau gleich ist".
Move the side-effect quick-log into a kebab-style overflow in the
header `actions` slot. The kebab only mounts when the parent wires
`onLogSideEffect`, so Mounjaro and Ramipril cards stay byte-equivalent
in the common case (history + edit, no kebab). When the side-effect
hand-off is wired, Mounjaro gains the kebab while the primary actions
row stays the canonical two-button shape.
The localised label re-uses the existing `common.moreOptions` key for
the trigger aria-label and the existing `medications.glp1LogSideEffect`
key for the menu item, so no new i18n strings are required.
Marc directive: when the operator turns the global Coach flag off,
every Coach affordance on every surface must vanish — no grey-out,
no error, no inert button, no DOM trace. The recent v1.4.37 W5
hotfixes closed three holes the UX audit caught (HeroStrip action
button, SuggestedPrompts chip strip, /targets CoachDrawer mount); a
follow-up audit item asked for an automated invariant so the gate
contract can't regress.
Walk every Coach-bearing web surface (HeroStrip action row,
SuggestedPrompts chips, CoachLaunchButton inline pill, LayoutCoachFab
mobile sticky, LayoutCoachMount drawer subtree) under a QueryClient
pre-seeded with `coach: false` and assert each one paints nothing
measurable in the SSR output. The fixture maintains a known surface
list; a trailing count check forces contributors to revisit the
fixture when a new Coach-bearing component lands.
The generic medication card surfaces the upcoming schedule dose with
a `font-medium text-purple-400` accent so titration overrides on the
schedule visually pop out of the muted next-intake line. The GLP-1
card omitted the accent entirely.
Mirror the same accent on the GLP-1 card so the only difference
between Mounjaro and Ramipril detail surfaces is the data being
shown, not the styling treatment. The schedule.dose still rides
inside the existing next-injection paragraph (hidden below sm: like
the generic card so the narrow-viewport row stays one line).
Registers a new drain-per-sample-cumulative pg-boss queue + cron
(03:45 Europe/Berlin, slotting between the audit-log cleanup at
03:15 and the feedback aggregator at 04:00). The handler invokes
drainPerSampleCumulative across every user with cutoffHours=36 so
today + the trailing watch-sync window stay per-sample while
completed-and-stable days collapse into one stats: row per
calendar day per cumulative HK type.
Replaces the manual CLI + admin-endpoint pattern from v1.4.30 for
the recurring case (the operator-driven paths remain for one-shot
backfills) and avoids the prod-image tsx + Prisma symlink hack
documented in feedback_prod_image_tsx_prisma_gap.md.
The drain previously collapsed every per-sample APPLE_HEALTH cumulative
row it could see, including today's still-in-flight watch syncs. When
called from the scheduled nightly job we want to leave a grace window
around now so the list view keeps surfacing late watch reconciliations
in real time before the day stabilises.
cutoffHours adds an optional window: rows newer than now() -
cutoffHours are excluded from the scan. CLI + admin-endpoint callers
keep the previous all-rows behaviour by omitting the option.
When the type filter is a cumulative HK type (steps, active energy,
distance, flights, daylight) the measurements list now opts into the
route's groupBy=day mode and renders one row per user-TZ day with
the day's total + a sampleCount caption. A chevron in the row
action column lazy-fetches the day's per-sample chunks via a second
query against ?dayKey=YYYY-MM-DD and inlines them beneath the row
so power users can still answer wann wurden Schritte gemeldet
without scrolling past 200 step rows on the default view.
Mirrors the universal Apple-Health / Garmin / Withings pattern
captured in the v1.4.37 research brief: one number per day as the
primary surface, drill-down to the chunks behind it.
GET /api/measurements gains two new branches:
* groupBy=day + cumulative type → returns one synthesised row per
user-TZ day with value=SUM, sampleCount, and a dayKey anchor so
the list view paints one row per day instead of hundreds of
per-sample chunks from Apple Health.
* dayKey=YYYY-MM-DD + cumulative type → returns the per-sample rows
for that single calendar day in the user's IANA timezone, so the
expand chevron in the row can lazy-fetch the chunks that summed
into the daily total.
Both branches gate on CUMULATIVE_HK_TYPES so the iOS contract for
spot metrics (BP, weight, pulse, BG) stays untouched. The collapsed
row's measuredAt anchors to local noon to match the
canonicalDailyTimestamp convention used by the drain helper.
The generic medication card painted a coloured take-now / overdue /
very-overdue pill at the top of the detail surface; the GLP-1 card
silently omitted it. Marc reported the asymmetry during the v1.4.37
UX audit (audit item 11): Mounjaro and Ramipril cards felt "different"
even though both should read as the same row shape.
Wire the shared `reduceCurrentWindowStatus` reducer into the GLP-1
card, fetch the same reminder thresholds as the generic card, and
re-render once a minute so the pill tracks wall-clock progress without
a route reload. The pill renders only when a schedule is currently
in/past its window and the user has not yet covered every overdue
schedule with today's intake events — same suppression rules as the
generic card.
Add a render assertion that the inner column ships with grid +
flex-1 + grid-rows-[auto_auto_auto_auto_auto_1fr_auto] so a future
refactor can't silently revert to a flex stack and re-introduce
the v1.4.36 "Karte hört bei der Trennlinie auf" report. The
mt-auto + h-full + flex-col + flex assertions stay in place
alongside the new grid contract — they describe complementary
load-bearing classes.
Mock useFeatureFlags so the suite can drive both the flag-on (CTA
mounts) and flag-off (CTA hides) branches alongside the existing
aiEnabled gate test. Both branches share the all-on default unless
the case overrides it with mockReturnValueOnce, so the rest of the
suite continues to render with the default-mock flag set.
Every chip in <SuggestedPrompts> is a Coach affordance — clicking one
seeds the Coach drawer's composer with the localised prompt and opens
the drawer. The strip rendered unconditionally until now, so a
deployment with the operator's global Coach flag off still painted
five inert chips below the hero band.
Read flags.coach inside the component and short-circuit when the flag
is off. The HeroStrip caller already wraps the mount in the same
guard (W5 commit prior); this is defence-in-depth so a future caller
that mounts <SuggestedPrompts> outside the hero band can never leak a
Coach surface.
Read useFeatureFlags() inside TargetCard and pair the existing
aiEnabled guard with flags.coach so the per-card Coach CTA hides
entirely when the operator disables the Coach feature globally.
Pre-fix the CTA mounted whenever the user-level provider chain was
configured, even when the operator-level kill-switch was off; the
disable cascade is supposed to make every Coach surface disappear,
not just the hero-strip button. Sister sites (hero-strip,
suggested-prompts, /targets CoachDrawer) belong to W5.
Adds an SSR-only contract suite for `<ArztberichtHeroCard>` covering
the hero slot, value statement, CTA touch-target floor, the
`aria-describedby` link from the CTA to the value statement, the i18n
key resolution path (DE + EN), the hero-gradient + glow-purple visual
treatment, and the closed-state pass that keeps the dialog markup out
of the SSR output until the CTA opens it.
Updates the existing `<ExportSection>` smoke suite for the v1.4.37 W7a
shape: four secondary cards in the grid (not five), the hero mounted
above the grid, and the "Other export options" sub-heading.
The doctor-report PDF is HealthLog's flagship "data out" path. The
v1.4.16 phase B7 layout treated it as one of five equal cards in the
grid; this change lifts it into a hero card at the top of
`/settings/export`. The remaining four export cards (Measurements CSV,
Medications CSV, Mood CSV, Full JSON backup) move under a dedicated
"Weitere Export-Optionen" / "Other export options" sub-heading.
The hero reuses the `hero-gradient + glow-purple` visual treatment from
the Insights hero strip, so the surface feels consistent across the
app. The CTA routes through the existing `<DoctorReportDialog>` +
`/api/doctor-report` flow — only the framing changed. Mobile-first: at
< sm the CTA falls onto its own row and clears the 44 px WCAG touch
floor (`h-11 sm:h-10`); the button carries an `aria-describedby` link
to the value statement so screen readers announce the framing
alongside the action label.
/targets carries two Coach affordances — the per-card 'Ask Coach' CTA
on every TargetCard and the page-level CoachDrawer mount. The CTAs
were gated through `aiEnabled` (provider chain status), but the
drawer mount always rendered regardless of the operator's global
Coach flag. When the operator turned Coach off in Settings the drawer
still loaded its SSE machinery + Sheet portal subtree even though no
on-page CTA could open it.
Read flags.coach via useFeatureFlags and fold it into the existing
`aiEnabled` gate so the per-card CTAs vanish when either layer is
off. Wrap the page-level <CoachDrawer> mount in the same gate as
defence-in-depth, since the CTAs already hide when the flag is off.
Tests: add two new cases to targets-coach-mount.test.tsx covering the
Coach-flag-off branch (CTAs hide + drawer mount disappears). The
sibling targets-* test files mock @tanstack/react-query without
exposing QueryClientContext; add a use-feature-flags mock that
short-circuits to the all-on default so the existing assertions keep
running against the visible page shape.
The generic medication card mapped `medication.category` to one of the
localised category labels via an inline lookup table, while the GLP-1
card hard-coded `medications.treatmentClassGlp1` into the same slot.
The asymmetry made Mounjaro and Ramipril cards visually diverge on the
medications list even though both should read the same row shape.
Lift the lookup into `src/lib/medications/category-label.ts` and use it
from both card variants. The GLP-1 nature of a medication is already
implied by the rotation hint + injection-metadata rows below the
header, so the category badge now carries the real classification
(BLOOD_PRESSURE, HORMONE, OTHER, ...) on both cards.
Tests pinned the old contract; they now assert the new shared label
shape for both English and German locales.
Switch the title-row flex from items-start to items-center
sm:items-start. The dashboard.title plus welcomeText sub-line
wraps to two lines on < 380 px viewports and the button used to
hug the top of that 2-line block without a baseline anchor.
items-center on mobile centres the button against the title; sm+
returns to the original top-aligned posture where the title is
one line.
Collapse the Card's inter-row gap to 0 and drop the CardHeader
inner gap to 0 so the only space between the title and the chart
strip is the CardHeader's own pb-1 (4 px). HealthChart mini paints
a plain div with mb-1 under its title; Mood's pre-fix gap-1
(4 px) on top of the header pb-1 stacked an extra 4 px below the
title, leaving Mood ~12-16 px taller than the BP/weight siblings
inside the trends row.
The doctor-report PDF is HealthLog's flagship "data out" path — the
artefact a user actually carries into a doctor's appointment. The
v1.4.16 phase B7 layout treated it as one of five equal cards in the
grid, which undersold the feature.
This change extracts the doctor-report flow into a new
`<ArztberichtHeroCard>` component and renders it at the top of
`/settings/export` with the same `hero-gradient + glow-purple` visual
treatment the Insights hero strip uses, so the surface feels
consistent with the rest of the app. The remaining four export cards
(Measurements CSV, Medications CSV, Mood CSV, Full JSON backup) move
under a dedicated "Weitere Export-Optionen" / "Other export options"
sub-heading.
The CTA routes through the existing `<DoctorReportDialog>` +
`/api/doctor-report` flow — only the framing changed. Mobile-first:
the hero stacks at < sm with the CTA falling onto its own row; the
button clears the 44 px WCAG touch floor (`h-11 sm:h-10`) and carries
an `aria-describedby` link to the value statement so screen readers
announce both alongside the action.
Adds a new `settings.sections.export.hero.{eyebrow,valueStatement,cta,
formatHint}` bag plus a `settings.sections.export.otherOptionsHeading`
key across DE, EN, FR, ES, IT, PL so the doctor-report hero card and
the "Other export options" sub-heading resolve everywhere. Retires the
now-unreferenced `settings.sections.export.cards.doctorReport.action`
key — the hero owns the CTA copy from this point on.
DE + EN ship fully translated; the four AI-initial locales (FR, ES, IT,
PL) receive native translations for the hero copy in line with the rest
of their export bag.
Both the generic medication card and the GLP-1 variant need to paint
the same coloured take-now / overdue / very-overdue pill, but the
schedule-window math lived inlined inside medication-card.tsx only.
Move parseTimeToMinutes, toBerlinDate, getWindowStatus,
isLastIntakeInCurrentWindow, and the reducer that picks the
most-actionable schedule into src/lib/medications/window-status.ts so
both cards can consume the same code path.
No behaviour change for the generic card. The extracted reducer
mirrors the previous inline implementation, including the
overnight-window adjustment, the late / very-late thresholds, and the
"don't show in-window if the last intake already covered the window
today" guard. The helper accepts any schedule shape that exposes
windowStart, windowEnd, and daysOfWeek, which both card variants
already satisfy.