Pre-v1.4.33 the shadcn Card defaults shipped `py-6` + `px-6` (uniform
24 px every breakpoint) while the dashboard tile-strip + chart cards
used `p-4 md:p-6` (16 px mobile, 24 px md+). Side-by-side on
`/insights` the correlation cards + daily-briefing read as denser
than the chart cards next to them; the visual reads as two card
languages on the same page.
This patch lifts the Card primitive (and its CardHeader / CardContent
/ CardFooter siblings) onto the same `p-4 md:p-6` token. Existing
consumers that paint their own explicit padding continue to win
through the `cn(...)` merge. Tests across `src/components/ui` /
`src/components/insights/__tests__` / `src/components/charts/__tests__`
(400 tests) still green.
Refs .planning/round-v1433-audit-polish.md §4.1 + Win 4.
Pre-v1.4.33 the insights mother page AND `<SubPageShell>` both fired
their own `requestAnimationFrame -> window.scrollTo({top:0})` on mount.
On slow hydrates the two callbacks landed ~16 ms apart and the second
one re-snapped the viewport once the chart skeleton inflated -- a
visible double-snap the maintainer flagged as "viewport jumps on click".
- src/hooks/use-scroll-reset-on-route.ts: new hook owns the RAF +
scrollTo dance, behaviour:"auto" so `prefers-reduced-motion` users
get the same instant landing everyone else does.
- src/components/insights/sub-page-shell.tsx: drop the inline effect,
call the hook. The opt-in `focusOnMount` ref-focus stays as its own
separate effect (different opt-in surface).
- src/app/insights/page.tsx: drop the duplicate effect, call the hook.
Refs .planning/round-v1433-audit-polish.md §2.1 + Win 3.
The history and sources rails mount inline next to the message thread
on lg+ / xl+ viewports, where no SheetTitle wrapper covers them.
Both rails previously rendered their section labels as <span> with
uppercase styling, so the drawer's semantic outline disappeared above
the message thread on desktop. Screen-reader users navigating by
heading skipped straight from the drawer's <h2> title to the
message-thread region with no intermediate landmark.
Promote both labels to <h3> while keeping the existing visual
treatment intact. The mobile rail-tray (Sheet-wrapped) still surfaces
its SheetTitle <h2>, so the rail's own <h3> simply nests below it on
small screens. The desktop layout now carries a coherent h2 (drawer)
> h3 (rail) hierarchy.
Regression tests assert that each rail renders its label inside a
real <h3> element so the semantic outline cannot silently drop again.
Pre-v1.4.33 the dashboard shell capped at max-w-[76.8rem] (1228 px)
while settings/admin shells used max-w-screen-xl (1280 px) and the
bugreport route capped at max-w-6xl (1152 px). Route switches between
those surfaces produced a 52-128 px lateral shift on a 1440 px laptop
which the user perceives as the page "wobbling".
- src/components/layout/auth-shell.tsx: max-w-[76.8rem] -> max-w-screen-xl
- src/app/bugreport/page.tsx: drop the inner max-w-6xl cap so the shell
alone owns the content frame.
Settings/admin shells already use max-w-screen-xl; no change needed.
Refs .planning/round-v1433-audit-polish.md §4.2 + Win 1.
Every Settings card paints the same shape — icon + title + optional
status surface + optional muted description — but the codebase
carried five different `flex-*` permutations across
`account-section.tsx`, `integrations-section.tsx`,
`notification-status-card.tsx`, the three notification channel
cards, `api-section.tsx`, and `advanced-section.tsx`. The mechanical
drift table in `.planning/round-v1433-audit-settings.md` §2.5
itemised the spread.
Consolidate the contract into `_card-header.tsx`: icon left, title
right of it, status slot lives top-right and falls below the title
on `<sm`, description sits muted below the title row. Call sites
keep ownership of their body forms and action footers.
First three call sites switched: `telegram-card.tsx`,
`ntfy-card.tsx`, `web-push-card.tsx`. The remaining cards
(`account-section.tsx`, `integrations-section.tsx`, `api-section.tsx`,
`advanced-section.tsx`, `notification-status-card.tsx`) are wired
in a follow-up — the primitive is now available without forcing
every consumer to migrate in one commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Centralise the trend-arrow + 7-day-delta + comparison-overlay colour
through a single `getTrendSentiment(change, sentiment)` helper so the
three elements never paint disagreeing signals on the same tile
(F5 audit fix). Arrow direction now follows the same signal that
drives the 7-day delta value.
- Collapse the dashboard tile strip from `flex overflow-x-auto` at <sm
+ grid above into a single responsive `repeat(auto-fit,
minmax(min(100%, 11rem), 1fr))` track. Phones wrap to two rows
instead of side-scrolling; 1440×900 picks 6 columns so the headline
digits stop truncating to `8…` / `1.`.
- Hold the tile callout slot at `min-h-[18px]` on every breakpoint so
a comparison overlay on one tile no longer pushes its callout-less
neighbours 18 px taller.
- Gate the BD-Zielbereich tile so a literal "0,0 %" placeholder no
longer renders when every window (7d / 30d / all-time) reports
zero. The tile shows once any window crosses zero; otherwise the
`/targets` Blutdruck card carries the deeper analysis.
- Restore proportional sizing on the dashboard Hinzufügen button:
`size="default"` with `min-h-11 sm:min-h-9` keeps WCAG 2.5.5 on
mobile (44 px) and tightens to a desktop-friendly 36 px above sm.
- Surface `allTimeCount` + `lastSeenAt` per metric on
`/api/dashboard/summary` so the iOS client can keep a tile visible
during a logging gap and render a `Letzter Wert vor Xd` caption.
Optional cards (glucose / sleep / steps / body fat) widen the emit
gate from "7-day window has data" to "ever logged". Added the
matching `staleDays` prop to `<TrendCard>` and `dashboard.staleHint`
i18n keys across all six locales.
Five small polish items from the v1.4.33 audit, plus the
maintainer's scroll-into-view item:
* Export-section card padding promoted from `p-5` to `p-6` so the
Export grid rhythm matches every other Settings card.
* Passkey list flips from a desktop table to a mobile card list at
`lg` instead of `md`. iPad portrait (768 px) used to sit on the
`md` inflection and flip-flop between the two layouts on
rotation; the card layout reads fine on iPad portrait, the table
needs ~620 px of column width to show the destructive delete
action without horizontal scroll.
* `<SettingsShell>` mobile chip strip now scrolls its active chip
into view on route change (`block: "nearest", inline: "center",
behavior: "smooth"`). On a 393 CSS px viewport the 11-chip strip
pushes the rightmost four chips below the fold; without the
scroll-into-view, tapping one of those chips left the strip
pinned to the leftmost chip on the new page, which read as a
broken nav. Smooth scroll honours the user's
`prefers-reduced-motion` via the browser's own auto-detection.
* F17 — `thresholds-editor-section` toggle label anchors on the
*action* it performs ("Eigene Werte" / "Custom range") instead
of mirroring the current state ("Auto" / "Überschrieben"). The
old label read as "Auto is off, why are there no inputs?" when
the toggle was off and inputs were hidden; the new label
unambiguously announces "flip me to enter a custom range". The
`thresholds.sourceOverride` badge still surfaces when the
override is the active source. Adds
`thresholds.overrideToggleLabel`.
Locale parity across de, en, es, fr, it, pl.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 10% sampling decision in WebVitalsReporter (v1.4.33 F19) was
computed inline in useMemo(), which the react-hooks/purity lint rule
rejects: Math.random() is impure and the render body must be
idempotent. The original useMemo() happened to be stable in practice
(empty dep array) but the rule fires regardless because the
expression inside the memo factory is impure.
Move the draw into useEffect(() => {}, []) and stash the result in a
useRef so every reportWebVitals callback in the same navigation reads
the same decision. SSR seeds the ref to null, so a pre-hydration
beacon (which would have no rate-limit relief value) is dropped; the
first post-hydration callback finds the ref populated and decides
whether to POST. Bundle and runtime cost unchanged — same one draw
per mount, same all-or-none per page-load. ESLint clean.
Dead-code sweep flagged by the v1.4.33 menu audit.
* AssistantDisabledNotice (src/components/insights/assistant-disabled-notice.tsx)
was introduced in v1.4.31 for the operator-disabled assistant surface
but never wired into a render site. The v1.4.28 retirement of the
weekly-report + advisor card removed the planned host components.
Delete the component, its SSR test, and the four locale strings it
consumed (insights.briefingDisabledByOperator,
insights.statusDisabledByOperator, insights.correlationsDisabledByOperator,
insights.coach.disabledByOperator) from all six locale files. The
AssistantDisabledError thrown by requireAssistantSurface() and the
assistant.disabled.<surface> errorCode envelope stay — iOS clients
read the errorCode to render an inline notice.
* settings.sections.placeholder.coming_soon was an "until extraction
lands" string for <SectionPlaceholder>. Every slug in
SETTINGS_SECTION_SLUGS has had a matching component since v1.4.22
so the placeholder body is unreachable through the type system. The
defensive component itself stays as a guard, but inherits the
section's own description for the EmptyState body, retiring the
dedicated locale key from all six locale files.
Locale-integrity contract holds (parity check green across de, en, es,
fr, it, pl). Comments in api-handler.ts and feature-flags/index.ts
that referenced <AssistantDisabledNotice> are updated to describe
the surface generically.
Refs: .planning/round-v1433-audit-menu.md §6
Telegram, ntfy, and Web Push cards used to paint their own
"Configured · Disabled / Active" badge pair in the top-right
header. The `<NotificationStatusCard>` rendered above the same
cards already shows a live channel pill ("Active · 12 min ago" /
"Auto-disabled — web_push_410_gone"), so the per-card badges were
the third (sometimes fourth) surface for the same state.
Drop the dual-Badge slot from all three cards. The section header
pill is the single source of truth, the per-card surface now
reads as identity + description + form, and the in-app vocabulary
no longer mixes "Konfiguriert", "Aktiv", and "Verbunden" for the
same fact.
Copy pass:
* Settings → Notifications description: "Live-Übersicht aller
konfigurierten Kanäle." (was a 13-word sentence repeating "Kanal"
three times).
* `notificationStatus.title` renames to "Zustellstatus" /
"Delivery status" so the page header and the first card don't
both anchor on "Kanal".
* `notificationStatus.description` compresses the "Auto-disabled"
definition.
* Telegram card title drops "-Benachrichtigungen" — the section is
Notifications, so saying it again is wasted shelf space.
* `telegramDescription` shortens to "Medikamenten-Erinnerungen
per Bot." / "Medication reminders via bot."
* `moodLogDescription` drops the second "moodLog" mention.
Notification-status SSR test updated to the new copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wave-A HealthKit pills (HRV, resting HR, SpO2, body temperature,
active energy) added in v1.4.32 stacked at the end of the strip in
insertion order so a left-to-right scan jumped across metric domains
multiple times. Reorder the pills to mirror the
MeasurementCategory overlay at src/lib/measurements/categories.ts:
vitals → body → activity → sleep → cardiovascular → mood → events.
No pill added or removed. The single source of truth is
SUB_PAGE_METRIC in src/lib/insights/sub-page-metric.ts; the strip
iterates SUB_PAGE_SLUGS (derived from the same record) and inherits
the new order automatically. Heavier regrouping (collapse five
wave-A pills under one Apple-Health pill, fold BMI into Gewicht)
is deferred to v1.4.34 — see .planning/round-v1433-audit-menu.md
§7 for the design-review rationale.
Per .planning/round-v1433-audit-runtime.md F9.
Three closely-related polish fixes from the v1.4.33 audit:
* `/settings/account` username field flips `disabled` to `readOnly`.
The disabled state inherits `opacity-50` from the `<Input>` primitive,
which on Marc's mobile pass read identically to placeholder text
and made the field look empty. `readOnly` keeps the full-contrast
value rendering while still preventing edits.
* `<SettingsShell>` main column reserves a 96 px bottom gutter on
`<md` so the last form field on `/settings/account` (Gender) and
`/settings/ai` (Active provider) is not eaten by the floating
mobile bottom-nav. Desktop keeps the parent's own padding because
the bottom-nav doesn't render on `md+`.
* Mood Log webhook-secret row stacks vertically on `<sm` with
`break-all` on the input value, killing the body-level horizontal
scroll triggered by the long hex secret on Pixel 5. The
Sync / Voll-Sync / Trennen triplet picks up the Withings
`flex-wrap` + `min-w-[10rem]` pattern, and the in-form save row
right-aligns its primary action so it matches every other
integration form.
* New locale key `settings.moodLogFullSyncConfirm`
("Vollständig synchronisieren" / "Synchronize fully") is now
wired into the Voll-Sync confirmation dialog so the trigger and
confirm actions read as distinct copy.
Locale parity across de, en, es, fr, it, pl.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every visit to /insights or any sub-route used to fire POST
/api/insights/generate from InsightsLayoutShell. The mutation
returned null on 422 (no provider configured) so the UI degraded
gracefully, but the server kept absorbing the call on every cold
mount and the dev-server logs filled with '422 Unprocessable
Entity'. Read the operator's assistant flag matrix from
/api/feature-flags (already cached server-side at 60s, client-side
under the shared ['feature-flags'] query key) and skip the mutation
entirely when 'enabled' or 'briefing' is false. The fail-open
default in useFeatureFlags means a flags-endpoint hiccup still
renders the advisor — the gate only takes effect when the operator
has explicitly turned the surface off.
Also update the per-metric InsightStatusCard's empty-state copy
from the diagnostic 'KI-Provider nicht konfiguriert' / 'AI
provider not configured' to the neutral 'Auswertung nicht
verfügbar' / 'Assessment unavailable'. The diagnostic phrasing
read as a setup failure even though the surface degrades cleanly;
the neutral phrasing matches the rest of the empty-state language.
Per .planning/round-v1433-audit-runtime.md F18.
The medication heatmap painted every cell orange ('sehr spät') even
for days that recorded 'taken' intakes with no skipped or missed
events. Tracing the upstream API (src/app/api/medications/[id]/
compliance/route.ts → classifyIntakeTiming) shows the classifier
returns 'very_late' for any intake that falls outside the 1h-grace
window in either direction; a stored UTC offset mismatch between
takenAt and the schedule window (e.g. 06:00 UTC intake vs an 08:00
window) therefore pushes every dose to the worst bucket.
Until the classifier is hardened in a follow-up sprint, the heatmap
detects the unambiguous classifier-bug pattern (everything in
veryLate, nothing late/onTime, no missed/skipped) and falls through
to the rate-based palette so a 'fully compliant' day reads green
instead of orange. Legitimately-late days still paint orange because
they carry a non-zero late or missed count.
Per .planning/round-v1433-audit-runtime.md F8.
When the visible Y-domain spans less than ~6 units the chart's
integer tick formatter rounded two adjacent gridlines to the same
label — '83 kg' appeared twice on /insights/gewicht when the
domain ran from 82.3 to 85.1. Promote the formatter to a one-
decimal projection when the domain is narrow so each tick reads
distinct without inflating the BP / glucose / steps axes where
integers are correct.
Per .planning/round-v1433-audit-runtime.md F10.
The web-vitals reporter posted every CLS/LCP/FCP/TTFB/INP measurement
on every page-load, six beacons per navigation. In production this
saturated the route-side per-IP rate-limit (60/min) inside seconds —
the runtime audit captured POST /api/internal/web-vitals 429 for
almost every metric after the first navigation, so most samples
never reached the wide-event sink. Telemetry was self-throttling.
Decide once per mount (memoised) whether the current page-load
reports its vitals, sample at 10%, and either send every metric for
that load or send none. Per-load all-or-none keeps each session's
vitals coherent (an LCP from one navigation stays comparable to the
CLS from the same navigation) while cutting traffic by an order of
magnitude. The route-side rate-limit stays in place as a backstop.
Refs: .planning/round-v1433-audit-runtime.md F19
The mobile bottom tab-bar bled into the bottom of the visible area on
/settings/account and /settings/ai per the runtime audit. The bar
previously rendered as bg-card/80 (80% opacity, translucent) and
relied on the inner flex row's h-16 alone for its height — without
a min-height floor on the outer nav, a future variant or any flex-
collapse quirk could let the surface shrink under the 64px floor the
auth-shell counts on.
Pin the bar at min-h-16 on the outer nav (defence in depth against
flex collapse) and switch the surface from bg-card/80 to a solid
bg-card. The safe-area inset and fixed bottom-anchor stay as they
were. Page shells that scroll content beneath the bar are still
responsible for reserving pb-16 + safe-area on their main column;
this change makes the bar itself robust so any future page-shell
oversight produces a hard occlusion (visibly broken on review)
rather than a translucent bleed (silently shipped). Bottom-nav SSR
test stays green (4/4) — five primary anchors, More button,
aria-haspopup, 44px tap-target floor.
Refs: .planning/round-v1433-audit-runtime.md F14
The blood-pressure chart cards on /insights/blutdruck and on the
mother-page trends row passed `yAxisUnit="Hg"` to the underlying
HealthChart, which painted gridline labels as `139 Hg`. mmHg is the
canonical clinical unit; "Hg" alone is incomplete (mercury, not
millimeters of mercury) and reads as a typo. Tile header on the same
page already says mmHg, so the axis was the only surface drifting.
Per .planning/round-v1433-audit-runtime.md F6.
The v1.4.15 spotlight tour rendered the dim backdrop as a single full-
viewport <button> with a clip-path punching a visual hole around the
target. clip-path only changes paint — the button's hit-box stayed
over the entire viewport, so every click on the underlying page
(Hinzufügen dropdown, sidebar links, header avatar) hit the dim layer
and triggered Skip without forwarding the click to the real target.
New users landing on the dashboard with onboarding_tour_completed=false
were effectively locked out of the most common action.
Replace the single backdrop with up to four rectangular dim panels
(top / bottom / left / right strips around the spotlight rect) and
set pointer-events: none on the tour root. The spotlight area is now
genuinely click-through — the spotlighted target receives clicks
normally — and the dim panels keep the "tap backdrop to dismiss"
affordance via their own onClick handlers. The center-placement
fallback (no spotlight target available) retains the legacy full-
cover dim behaviour. Tooltip opts back into click capture with
pointer-events: auto so its buttons stay reachable.
Adds an e2e regression guard
(e2e/onboarding-tour-passthrough.spec.ts) that mounts the tour by
overriding /api/auth/me with onboardingTourCompleted=false, then
asserts the header Hinzufügen dropdown opens on click while the tour
is up. Covers both branches: click on the spotlighted target
forwards to the underlying button, click on a dim strip skips. SSR
tour test stays green (8/8) — the role=dialog, aria-modal,
data-testid hooks, step counter, and i18n keys are unchanged.
Refs: .planning/round-v1433-audit-runtime.md F2
`bmi`, `pulse`, `general`, `weight`, `blood-pressure`, and `mood`
status helpers all built their `summarizeSeries` block with
`Math.min(...series.map(...))` and `Math.max(...series.map(...))`.
The same anti-pattern hit `summarize()` in `src/lib/analytics/trends.ts`
on 2026-05-16 and crashed `/api/analytics` with
`RangeError: Maximum call stack size exceeded` once an
Apple-Health-synced PULSE series outgrew V8's ~125 000-arg ceiling;
`61107e0c` folded that one. The six insight helpers are fed bounded
windows today so they never reached the ceiling, but the spread still
allocates a transient args array on every call and the fold is both
stack-safe and cheaper.
Replace each `Math.min(...) / Math.max(...) / average(...)` triplet
with a single walk that maintains running sum / min / max. The
now-unused `average()` helper is dropped from each file. Pinned by
the existing per-helper unit tests (326 passing in
`src/lib/insights` / `src/lib/analytics` / `src/app/api/analytics`).
Adds five sub-pages for the wave-A HealthKit metrics the server has
been storing since v1.4.23 / v1.4.25 but never surfaced on the web:
- /insights/hrv → HEART_RATE_VARIABILITY
- /insights/ruhepuls → RESTING_HEART_RATE
- /insights/sauerstoff → OXYGEN_SATURATION
- /insights/koerpertemperatur → BODY_TEMPERATURE
- /insights/aktive-energie → ACTIVE_ENERGY_BURNED
Each page composes the shared `<HealthKitMetricPage>` scaffold —
`SubPageShell` + `HealthChartDynamic` mounted on its own
`ChartOverlayKey` slot + `CoachLaunchButton` + an empty-state CTA
that points at the existing `/measurements?add=<TYPE>` quick-entry
where a manual entry makes sense, or an Apple-Health-onboarding cue
otherwise. The five new chart-overlay-prefs slots (`hrv`,
`restingHr`, `oxygenSaturation`, `bodyTemperature`, `activeEnergy`)
let the chart-cog popover persist per metric.
Insights tab strip picks up the five new pills via the existing
`SUB_PAGE_METRIC` + `InsightMetric` enums. The wave-A categorisation
overlay realigns HRV + resting HR to the `cardiovascular` bucket
per the iOS handoff brief (PULSE stays in `vitals`; spot-pulse is a
point-in-time signal, derived heart-rate variants are the cardiac
risk surface). Six locales updated with the new sub-page titles,
descriptions, chart titles, and empty-state copy.
Adds `<RecentWorkoutsTile>` as a default-visible dashboard widget
(`recentWorkouts`). The tile reads the top three canonical workouts
through `useWorkouts({ limit: 3 })`, renders each as a deep-link
row with the sport icon + the day + the duration, and falls back
to an Apple-Health-onboarding cue when the workout list is empty.
`DASHBOARD_WIDGET_IDS` + `DEFAULT_DASHBOARD_LAYOUT.widgets` carry
the new id; `widgetIdEnum` on `/api/dashboard/widgets` picks it up
from the same source-of-truth array. The Settings → Dashboard
layout editor surfaces it under the existing widget toggle list so
users can opt out without touching the layout JSON. Six locales
updated.
Adds `/insights/workouts/[id]` on top of the v1.4.32
`GET /api/workouts/{id}` endpoint. The page mounts a header strip
(sport icon + source + date range + duration summary), a stats card
that surfaces every aggregate field present on the row (duration,
distance, active energy, average / max / min HR, steps, elevation,
pace for foot- and ride-paced sports), an SVG-rendered route preview
when the workout carries `WorkoutRoute` GeoJSON LineString
geometry, and an HR-chart placeholder that exposes the aggregates
inline until the per-second sample series wires up in v1.5.x.
Loading state mounts skeletons; the not-found branch surfaces a
neutral notice rather than a 404 page so deep-links from the list
page degrade gracefully when the workout has been deleted upstream.
Adds the user-visible surface for the workout rows the iOS client
already syncs through `POST /api/workouts/batch`. The page lives at
`/insights/workouts`, consumes the v1.4.32 list endpoint through a
new `useWorkouts()` hook, and renders each row as a deep-link into
the detail page with the sport icon, the date, the duration, and
the optional distance + active-energy chips.
The insights tab strip gains a `workouts` pill driven by an
event-style `hasWorkouts` availability signal — the layout shell
probes the list endpoint with `limit=1` so the pill self-gates on
zero workouts. Empty state mounts the shared `MetricEmptyState`
primitive with a Coach prefill that steers the conversation toward
the Apple Health sync onboarding cue. Six locales updated.
Three client-side fixes that collapse the worst-case tap-block
window from the LLM-completion tail (~30 s on a cache-miss) down
to the bounded parallel-fetch window (~400-600 ms). The strip
itself stays interactive in the DOM throughout; the previous
freeze symptom traces to main-thread contention starving WebKit's
gesture-recognition timeout. Per
`.planning/research/v15-insights-blocking-bug.md`.
1. `fetchAdvisor` (`use-insights-advisor.ts`) gets an 8-second
`AbortController` and converts `AbortError` into the existing
graceful-null return path so the consuming UI surfaces the
regen CTA instead of pinning the query in `isFetching: true`
for the LLM's full completion latency.
2. `<InsightsTabStrip>` is wrapped in `React.memo`; the inner
`buildTabs(availability)` is `useMemo`'d on `availability`; the
layout shell `useMemo`'s the `availability` prop on its three
leaf inputs. Together they break the
"shell re-renders -> strip re-renders -> 8 pills re-render"
cascade on every cache-write of analytics or comprehensive.
3. `<CoachDrawer>` is now lazy-loaded via `next/dynamic({ ssr: false })`
inside `<LayoutCoachMount>` so the SSE machinery, the chat
reader, the suggested-prompts chip rail, and the persistent
settings sheet don't run their initialisers on every cold
/insights mount. The drawer renders nothing until the user
actually opens it.
Tests pin the memo contract on the strip and the import-cleanness
of the abort path. The wider fix-5 (analytics SQL aggregation per
R-B C1) stays scoped to v1.5.
Four user-facing keys land in all six locale files so the
`<AssistantDisabledNotice>` component reads natively localised
copy:
insights.coach.disabledByOperator
insights.briefingDisabledByOperator
insights.statusDisabledByOperator
insights.correlationsDisabledByOperator
The Coach key nests under the existing `insights.coach.*` object;
the other three sit at the top of `insights.*` to avoid colliding
with the existing string-valued `insights.correlations` key.
The Health-Score explainer has no notice — its suppression is
silent on purpose so the bare delta digit still reads as a clean
KPI.
DE carries the full native phrasing; the established convention
for the other five locales (FR/ES/IT/PL/EN) keeps the user-facing
strings localised even where admin strings still default to
English.
New admin slug `assistant` between `ai-quality` (provider config)
and `coach-feedback` (in-app feedback inbox). The panel renders six
`<SettingsToggle>` rows — master plus five sub-flags — and writes
through a dedicated `PUT /api/admin/settings/assistant-flags`
endpoint. Optimistic UI flips on click; failure surfaces a toast
and re-invalidates the read query.
The dedicated endpoint (separate from the generic `/api/admin/settings`
PUT) lets the panel scope its audit trail to a single
`admin.settings.assistant-flags.update` action and lets the
optimistic UI bind 1:1 to a focused request/response shape. The
PUT handler echoes both the raw column values and the resolved
shape so the panel can render the master vs sub-flag distinction
visually.
Sub-toggles grey out when the master is off (UX-wise) so the
operator can see which sub-flags are individually flipped before
unmuting the master. Writes invalidate the runtime
`/api/feature-flags` cache so the operator sees the toggled
surface react within the same session.
i18n: `admin.section.assistant.{title,subtitle}` and
`admin.assistant.*` keys land in all six locales (DE proper, the
five English-leaning admin locales mirror the English copy as is
the established convention for admin strings).
New client hook `useFeatureFlags()` reads the matrix from
`/api/feature-flags` once per session and feeds every gated mount
on the Insights surface. The hook fails open — any network error
or missing `<QueryClientProvider>` (the legacy presentational test
posture) returns the all-on default so the user never loses an
affordance to an instrumentation gap.
Gated surfaces:
<LayoutCoachFab>, <LayoutCoachMount>, <CoachLaunchButton>
→ coach
<DailyBriefing> on /insights/page.tsx → briefing
<CorrelationRow> on /insights/page.tsx → correlations
<InsightStatusCard> × 6 sub-pages → insightStatus
<HealthScoreDeltaExplainer> ? glyph → healthScoreExplainer
Master `assistant.enabled = false` forces every sub-flag false in
the resolver, so flipping the master collapses every surface at
once. Each component returns `null` when its flag is off; the
delta digit on the Health-Score card stays visible because the
parent reads the number directly.
New `<AssistantDisabledNotice>` component prepares the empty-state
copy for callers that detect a 403 + `errorCode:
"assistant.disabled.<surface>"` response. The localised strings
land in the dedicated i18n commit.
v1.4.30 commit 7 follow-up. The new MeasurementType values land in
every derived registry the canonical enum drives:
- measurement-list-meta.ts label keys + icons + colors so the
measurements list view renders both types (Gauge for steadiness,
Volume2 for the loud-listening event)
- 6 locale files (de/en/es/fr/it/pl) carry typeWalkingSteadiness +
typeAudioExposureEvent keys; English-fallback copy for the four
non-EN/DE locales matches the existing v1.4.25 W8d pattern
- chart-tokens.ts ALLOWED_CHART_TOKENS list so the LLM can
reference `metric:WALKING_STEADINESS` + `metric:AUDIO_EXPOSURE_EVENT`
in prose; dedicated chart components ship alongside the iOS sync
- the enum-coverage drift-guard test's EXPECTED_TYPES + PDF
exclusion set so the canonical enum-extension lint stays green
Full unit suite passes (4035 specs).
The 7-day window on the dashboard chart still pulls raw rows from
`/api/measurements` (the server-side `aggregate=daily` path kicks in
only for windows over a week). The client-side daily aggregator then
reduced every per-day bucket with `sum / count`, which averages step
counts across the per-sample rows Apple Health emits instead of
summing them. Steps on a 7-day view rendered as the per-sample
average (hundreds), not the daily total.
The reduction now branches on `CUMULATIVE_HK_TYPES` (the same set
that drives the server-side SQL aggregator in v1.4.29): cumulative
types reduce with `sum`, spot metrics keep the `sum / count`
average.
Each widget row carried a vertical pair of 44-px arrow buttons —
116-px row, repeated across 15 widgets, ~1 740 px of scroll just
for the reorder list. Three times the height of every neighbour
on the Settings page.
Swap the vertical arrow stack for a horizontal pair on the
trailing edge. `size-11 sm:size-9` preserves the 44-px mobile
tap target and shrinks to 36-px on desktop where pointer accuracy
is high. Row container drops `p-3 gap-3` for `px-3 py-2 gap-2`
with `min-h-12` as the height floor — 48 px, 2.4× tighter.
Label gains `truncate` + `title` so long widget names stay on a
single line.
Column-header spacer widens to reserve the new trailing arrow
column so the Tile / Chart switches stay column-aligned.
The existing `dashboard-layout-section.test.tsx` reads `data-slot`
attributes (unchanged), so the four-test suite stays green.
The dashboard tile strip enforces equal heights at `sm:` and above
via `grid auto-rows-fr` but lets each tile take its content height
at `<sm`. Sibling drift of 16-24 px appears whenever the comparison
callout or the sub-row pair paints on one tile and not on its
neighbour (the glucose tiles ship without `trend7Delta` /
`compareDelta`, making this the visible case).
Pin a 140-px contract at `<sm` and let it release back to `auto`
from `sm:` upwards:
- Strip wrapper carries `[--tile-h:140px] sm:[--tile-h:auto]`.
- Each tile cell consumes `h-[var(--tile-h)] sm:h-auto`.
- Inside `<TrendCard>`, the comparison-callout slot reserves
`min-h-[18px]` even when empty and clamps to `line-clamp-1`
at `<sm`.
- Sub-row pair switches to `flex-nowrap overflow-hidden` at `<sm`
(clipping over wrapping) and releases to `flex-wrap` from `sm:`.
A new snapshot test pins the contract: callout slot reserves the
minimum height, callout clamps to a single line, sub-row pair
renders with `flex-nowrap` + `overflow-hidden`.
Recharts ignores the `interval` prop on `<XAxis type="number" />`.
The pulse + mood charts switched to numeric axes in v1.4.25 to
support multi-band rendering, which silently degraded the
day-aware density policy to "every tick" on those charts. On a
365-row pulse view that meant ~365 overlapping labels.
Add `computeTickPositions(chartData, viewportWidth)` — translates
the existing `chooseTickInterval` skip count into explicit indices
the numeric axis consumes via the `ticks` prop. Tick count clamps
to a 3-12 visible-label range so dense series don't overcrowd and
sparse series don't collapse to a single label.
The category-axis charts (`compliance-line-chart`,
`medication-compliance-chart`) stay on `interval` — Recharts
honours it there.
The chart fetcher always pulled raw rows from `/api/measurements` —
up to ~5 000 pulse samples per range-tab change on a continuous-
monitoring account. The server's `aggregate=daily` SQL path lands
in v1.4.29 C2; this commit teaches `<HealthChart>` to ask for it
on every window over 7 days. Short windows (7-day overview) keep
raw fetching so hour-by-hour detail stays visible.
Effect on a 30-day pulse view: payload drops from up to ~5 000
rows to one row per day. The client-side `dailyAggregates` Map
becomes a no-op on already-bucketed rows.
Range test gains two assertions: the default 30-day window asks
for `aggregate=daily`; the `windowOverride="last7days"` mini-chart
omits the parameter.
Sweep across the v1.4.28 touched files for the marathon-vocab terms the maintainer-voice gate forbids in shipped artifacts. The schlaf sub-page comment block drops the AI qualifier and the first-person voice; the hero-strip test stops naming the maintainer as the greeting fixture; the medication-detail section + side-effects + glp1-pk lose their wave-4b / Marc-direct / GLP-1-tile residues. Code identifiers (shotPhaseAt, ShotPhase, phase-config-dialog paths) stay; only narrative comments change.
Two consumers (trends-row.tsx + vo2-max-chart-row.tsx) still hand-rolled the same dynamic() block plus a degenerate local ChartSkeleton shadow that bypassed the canonical primitive. Both files now import HealthChartDynamic; the local ChartSkeleton drops from trends-row and vo2-max-chart-row in favour of the shared chart-skeleton module.
The per-target Coach button rode the MessageCircle glyph; hero strip, inline pill, layout FAB and daily-briefing CTA all paint Sparkles. The lone outlier swaps to Sparkles so the Coach-launch glyph reads as one icon vocabulary across the app. Separately, the insights/medikamente per-medication card painted its own header recipe (text-sm title with a Pill glyph and dose on line 2); routing the same row through MedicationCardHeader matches the canonical FB-G1 two-line shape used on the medications page. The streak chip rides the stateBadges slot.
The drug-level chart shipped a hand-rolled section wrapper (rounded-xl border p-4) that diverged from the three sibling sections on /medications/[id]/history. The chart now mounts inside the shared MedicationDetailSection primitive so the page reads on one chrome recipe. The compact prop + windowHoursBefore override retired with the dashboard tile that consumed them; the chart now ships one window contract and one body height. ~95 LOC removed.
Two narrow-viewport regressions surface on 320 px phones. The medication-list row's category badge and state badges share line 2 via flex flex-wrap; any state badge pushes the row to three lines and breaks the FB-G1 two-line contract for ~20 % of configured drugs. State badges now ride their own row below the category badge so the canonical row stays two lines. The side-effects timeline pinned its date column at 88 px; the longest short-date variant fits inside 56 px with slack, so the column narrows and the category + entry + severity left slot recovers 32 px of wrap headroom.
The MoodChart mini wrapper rode the shadcn Card primitive which paints rounded-xl by default; the BP and weight tiles next to it paint rounded-md via the HealthChart mini shell. The trends row therefore showed two corner radii. Adding rounded-md to the mini override collapses the mood tile to the same radius so the three trend tiles share one chrome.
The delta-explainer trigger now ships aria-expanded + aria-controls and the surrounding delta text gets a parent-owned aria-describedby thread to the explainer body. The mobile path drops the <span onClick onKeyDown> wrapper around the button so the a11y tree shows one interactive element where it used to show two; the button handles Enter/Space natively. The HealthScoreCard threads a useId pair across the delta line and the explainer so the connection is structural, not visual.
The per-card Coach button on the targets page rode the shadcn icon variant (size-10, 40 px) and the HealthScore delta explainer trigger collapsed to a 16 px chip. Both sit under the project's 44 px floor (WCAG 2.5.5). The targets Coach button gains min-h-11 min-w-11 on the icon variant; the delta explainer keeps a 12 px visible glyph but inflates the hit surface to 44 px and pulls the row stride back via negative margin so the optical chip stays small.
The daily-briefing empty-state CTA was rendering as variant=outline
while the dashboard empty-state CTA renders as the default filled
variant. Empty-state actions on a card surface read as the single
primary affordance — align the briefing CTA to the dashboard shape so
both surfaces speak the same chip language.
Closes BK-M2.
Lift the section-header chrome on /medications/[id] to one canonical
shape so the page reads as one document. Every section header now uses
text-base font-semibold leading-6 tracking-tight via the shared
<MedicationDetailSection> primitive (which keeps the surrounding
border-border/60 + px-3 py-2.5 + border-t contract). DrugLevelChart's
standalone header migrates to <h2> with the same classes.
Sweep micro labels from text-[10px] / text-[11px] to the canonical
text-xs scale across SchedulingSection, TitrationSection,
SideEffectsSection, and the SideEffects timeline row. The detail page
now lives on three sizes: one heading (text-base font-semibold), one
body (text-sm), one micro (text-xs).
Closes FB-F3 and FB-F4. Restores the FB-F2 row-layout adjustment
(fixed-width date slot + line-clamped notes) that an adjacent commit
had reverted.
The "vs last week" delta line on the HealthScore card has been
shipping a digit without explaining what the comparison covers, why
the digit moved, or what the user can do next. FB-I1 — Marc asked
for a tappable `?` glyph next to the delta line that opens a short
read explaining the three things in one shot.
The new `<HealthScoreDeltaExplainer>` mounts a single icon-only
`HelpCircle` button next to the existing delta caption. Tapping the
glyph opens a popover on `md+` and a bottom-sheet on phone-class
viewports — the existing `<ResponsiveSheet>` primitive handles the
branch, mirroring the same pattern landed in v1.4.27 MB1.
Body copy is a three-sentence Marc-Voice read: which components
contributed, what window the comparison covers, what concrete next
step the user can take. Six locales ship native translations — no
English fallback strings on the new keys.
The explainer only mounts when the parent surfaces a numeric delta;
the "no history yet" branch keeps the existing caption untouched
because the explainer would have nothing to compare against.
Tests pin the trigger button's `data-slot` and locale-grounded
accessible label across EN/DE, lock in the "closed by default" SSR
snapshot, and verify the body copy ships in every locale via the
existing `t()` payload. The interactive open behaviour is exercised
on the live page; the SSR contract is enough to catch a future
silent locale drop.
Shorten the add-CTA to a single verb (Log / Erfassen / Consigner /
Registrar / Registra / Dodaj) so the chip stops overflowing the
side-effects card on narrow viewports. The section title carries the
context; the qualifier was redundant. Tests updated to pin the new
contract.
Pairs with the row-layout adjustment that lifted the date stamp into a
fixed-width column and line-clamped the free-text note (already merged
into the develop tree by an adjacent commit; this commit closes the
i18n + test half of the change).