Lighthouse v1.4.33 run flagged a 0.186 CLS attached to a
`div.space-y-2 > Button > Loader-Circle` element. Root cause: the
two heavy submit buttons in `/bugreport` and the medications import
dialog drew `{loading && <Loader2/>}<Icon/>` -- rendering BOTH icons
during the in-flight request and growing the button by ~24 px. The
fix is to swap (not append) the leading icon, so Loader2 occupies
the same 16x16 slot the static icon would.
- src/app/bugreport/page.tsx: ternary swap on `loading`, pass
`aria-busy` for screen-reader narration.
- src/app/medications/page.tsx (Import dialog): same shape.
- src/components/ui/button.tsx: document the loading-state contract
on the primitive so future contributors apply the swap convention
by default. (Callsites inside `src/components/settings/**` are
owned by IW4 -- left untouched, the doc-comment makes the convention
visible from the primitive.)
Both the `/notifications` inbox and the Settings -> Notifications
section used to read as plain "Notifications" in every nav surface, so
the user-card dropdown and the settings sidebar looked like two pointers
at the same destination. Rename the inbox to "Notification Center" and
the settings section to "Notification channels"; add a small breadcrumb
to either surface so the user always knows which page they landed on
and can jump to the sibling in one tap.
Locale parity across de / en / es / fr / it / pl.
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.
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.
- 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.
The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped
chunked findMany reads to feed `summarize()` for every
MeasurementType. For a multi-year HealthKit user the PULSE branch
alone walks hundreds of thousands of rows before the dashboard tile
strip can paint a single arrow — even though the strip only consumes
`count / latest / avg7 / avg30 / min / max / mean` plus the three
slope tuples per type.
Add `?slice=summaries` that resolves the same `DataSummary` shape
from two SQL passes (no JS-side spread anywhere along the chain):
1. One `$queryRaw` carrying `GROUP BY type` with `COUNT`/`MIN`/
`MAX`/`AVG` plus 7d/30d windowed averages via `FILTER` plus
Postgres `regr_slope` + `regr_r2` at 7/30/90-day windows.
2. One `DISTINCT ON (type)` `$queryRaw` returning the most-recent
`(type, value)` pair per type so the slim shape can populate
`latest` (the value at MAX(measured_at), not MAX(value)).
Slope `direction` keeps the JS helper's 0.01-units-per-day "stable"
threshold; `confidence` is `regr_r2` rounded to 2 d.p. — both match
the existing `trendSlope()` contract so the dashboard's arrow / color
logic does not have to branch on slice. Anomaly count, lastMonth, and
lastYear means stay at `null` / `0` because the dashboard tile strip
does not paint them on first paint; the comparison-baseline widget
already opts into the default slice.
The thick `correlations` / `healthScore` / `bpInTargetPct` /
`glucoseByContext` / `sleepStages` / `bmi` blocks stay on the default
path behind a hover-prefetch / explicit slice param. C1 is purely
additive — the route's default behaviour is byte-identical.
Pinned by a 4-case unit test (mocked `$queryRaw`, slope thresholds,
null-slope branch) and a 3-case real-Postgres integration test
covering the empty user, the populated descending-WEIGHT fixture,
and the default-slice-still-thick contract. 199 unit tests pass
across `src/lib/analytics` + `src/app/api/analytics`; the
integration suite picks up `tests/integration/analytics-summaries-slice.test.ts`
on next `pnpm test:integration` run.
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.
Production GET /api/analytics returned HTTP 500 for users with very long
measurement histories (request-id d86db8e5… 2026-05-16 14:39:51 UTC).
The wide-event log pinned the failure at Promise.all index 3 (PULSE) with
RangeError: Maximum call stack size exceeded — a power user's Apple-Health
heart-rate stream crossed V8's ~125k function-arity ceiling on the
Math.min(...values) / Math.max(...values) spread inside summarize().
Fold sum, min, and max into a single pass over values so the transient
argument array goes away and the working set stays bounded at the array
itself. Mean is now derived from the same fold's sum/length instead of
a second reduce. No public API change; min/max semantics for an empty
series remain null via the early-exit branch.
Adds a 250 000-row regression in trends.test.ts that raises the exact
production stacktrace before the fix and passes after, plus a new
src/app/api/analytics/__tests__/route.test.ts that pins the route-entry
contract (5 000-row PULSE + zero-row brand-new user) so the upcoming
C1 architectural rewrite can't silently reintroduce a spread anywhere
along the call chain. Touched-file lint and pnpm typecheck clean;
broader src/lib + src/app/api/analytics sweep is 2671 / 1 skipped.
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.
Swap the v1.4.27 cluster picker on `GET /api/workouts` for the
source-priority-aware `pickCanonicalWorkoutRows()` (v1.4.30) so the
per-user ladder governs the canonical pick, fix the latent
Prisma-field-name bug (`distanceMeters` / `energyKcal` → the actual
`totalDistanceM` / `totalEnergyKcal` columns), and align the wire
shape with the iOS handoff contract (`distanceM`,
`activeEnergyKcal`, `avgHr`, `maxHr`).
Add `GET /api/workouts/{id}` for the workout-detail surface — owns
the optional `WorkoutRoute` GeoJSON geometry, exposes `canonicalId`
so deep-link redirects can land on the cluster winner, and seals
the existence channel with a cross-user 404 guard.
Register both routes in the OpenAPI registry so the pre-commit
drift hook catches the new shape going forward.
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.
Every LLM-driven endpoint now reads the operator flag set near the
top of the handler and throws `AssistantDisabledError` when the
relevant surface is off. The api-handler catches the typed error
and returns the 403 envelope:
{ data: null, error: "...", meta: { errorCode: "assistant.disabled.<surface>" } }
Gates per the v1.4.31 brief:
/api/insights/chat → coach (POST + GET rail)
/api/insights/generate → coach
/api/insights/comprehensive → coach
/api/insights/cards → insightStatus
/api/insights/correlations → correlations
/api/insights/{weight,bmi,blood-pressure,pulse,mood,
medication-compliance}-status → insightStatus
Master `assistantEnabled = false` forces every sub-flag false in
the resolver, so flipping the master 403s every endpoint at once.
Older iOS clients see a generic 403 (their existing 403-handler
covers the case); v1.4.31+ clients read the `errorCode` to render
the `<AssistantDisabledNotice>` empty state. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
`GET /api/feature-flags` projects the six `AppSettings.assistant*`
booleans over HTTP as a single typed envelope:
{ data: { assistant: { enabled, coach, briefing, insightStatus,
correlations, healthScoreExplainer } } }
`requireAuth()` gates the route — any logged-in user passes. The
resolver in `src/lib/feature-flags/index.ts` forces every sub-flag
false when the master is off, so the response is always
operator-effective and clients never have to compose
`master && sub`. `Cache-Control: private, max-age=60` caps the
read cost on the hot /insights mount path while still propagating
admin changes within a minute.
iOS reads this endpoint on cold start and gates every Coach,
Briefing, status-card, correlation and explainer surface against
the matrix — regardless of whether the surface ends up calling the
server or running on-device. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
Adds `GET /api/measurement-categories`, a thin projection of the
v1.4.30 `MEASUREMENT_CATEGORIES` overlay at
`src/lib/measurements/categories.ts`. iOS reads the response on cold
start to drive the HealthKit permission picker grouping; the future
web Insights nav and the Coach evidence shelf consume the same shape.
Response envelope carries a `version: 1` marker, an ordered
`categories` list with `labelKey` translation keys, and the
`assignments` map keyed on `MeasurementType`. `requireAuth()` gates
the route — any logged-in user passes — and the response carries
`Cache-Control: public, max-age=600` so clients honour a 10-minute
edge cache.
Locked per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1.
R-E H-5. Up to v0.4.1.1 the iOS app stored mood-entry prose as
`tags: ["note:<text>"]` because the server had no dedicated column.
The workaround contaminated the tags axis with prose the Coach
evidence shelf misread as taxonomic labels.
Schema (migration 0063, additive):
- mood_entries.note (TEXT, nullable)
Validation + routes:
- createMoodEntrySchema gains optional note (max 500 chars)
- updateMoodEntrySchema accepts note + null to clear
- POST /api/mood-entries persists note alongside tags
- PUT /api/mood-entries/[id] honours partial note updates
- POST /api/mood-entries/bulk carries note through the bulk path
Backfill script at scripts/backfill-mood-note-column.ts lifts the
legacy note:<text> tag entries into the new column. Idempotent
dry-run mode is the default; --confirm commits. Helper logic lives
in src/lib/mood/note-backfill.ts for tight unit coverage.
iOS contract: additive. Old iOS payloads (no note field) round-trip
unchanged. New iOS code paths can read or write note without
touching tags.
R-E C-2 server-side. Three additive columns + three new endpoints
let the iOS SyncMode store pair, drain, and reconcile against a
fresh paired-server.
Schema (migration 0062, additive):
- Measurement.sync_version (Int, default 1) for last-writer-wins
- Measurement.deleted_at (Timestamp, nullable) for soft-deletes
- User.last_synced_at (Timestamp, nullable) for the handshake
checkpoint
Endpoints:
- GET /api/sync/state — handshake response (lastSyncedAt, server
clock, live + tombstoned measurement counters); call also bumps
the checkpoint so iOS reads the OLD value then trusts subsequent
writes
- POST /api/mood-entries/bulk — UPSERT per entry keyed by
(userId, date, moodLoggedAt); probe-then-upsert so the response
reliably reports inserted vs duplicate; rate-limited at 60/min
- POST /api/medications/intake/bulk — INSERT per entry with
P2002-on-idempotencyKey → duplicate; up-front user-owns-medication
check skips foreign rows with a documented reason
iOS contract: every change is additive. Existing per-sample POSTs
keep round-tripping (sync_version defaults to 1 server-side;
deleted_at stays NULL on every existing read path; last_synced_at is
opt-in). The new endpoints are net-new — no iOS consumer yet.
Tests cover the handshake checkpoint advance, live-vs-tombstoned
counters, clean batch ingest, re-post upsert semantics, ownership
filtering, idempotency-key collision, and over-cap rejection — all
against the real-Postgres container.
Idempotent maintenance tool that collapses pre-Option-A per-sample
APPLE_HEALTH cumulative rows (steps, active energy, flights climbed,
walking + running distance, time in daylight) into one row per day
per type, keyed by the locked dailyStatsExternalId shape. Per
user × type × calendar-day (anchored to User.timezone): SUM the values,
UPSERT a daily row at canonical midday-local (matching the Withings
activity-sync convention), DELETE the per-sample rows.
Three entry points share src/lib/measurements/drain-per-sample-cumulative.ts:
- Library helper for unit + integration tests
- CLI at scripts/drain-per-sample-cumulative.ts with --user / --all
+ --confirm flags (default dry-run)
- Admin endpoint POST /api/admin/drain-per-sample-cumulative gated
by requireAdmin() with dryRun defaulting to true
Operator runs the drain once after the iOS TestFlight build adopts
HealthKitStatisticsService.swift. Re-running on a fully-collapsed
account reports zero buckets touched.
Tests cover bucketing semantics, the canonical-timestamp math across
DST + half-hour zones, idempotent re-runs, dry-run preview, spot-metric
exclusion, and already-collapsed-row skip via real-Postgres.
Three small fixes that compound on a power-user dashboard mount:
- **H2**: The per-context glucose summaries walked every persisted
`BLOOD_GLUCOSE` row a multi-year user has written. The summaries
only feed the trailing tile, so bound the read to the last 30
days.
- **M1**: The BP-in-target chunked walk passed no `since` bound
even though `computeBpInTargetWindows`'s longest sub-window is
`priorYear`. Pass a 365-day floor through both
`fetchMeasurementSeriesChunked` calls.
- **M5**: The three inline dashboard `useQuery` blocks shared no
`staleTime` / `refetchOnWindowFocus` settings, so a tab-focus-
and-return triggered an immediate refetch storm. Lift a shared
`DASHBOARD_QUERY_OPTS` object so every query inherits the
1-minute staleness window the chart queries already use.
iOS-neutral. No contract change; the analytics response shape
stays identical.
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`.
`aggregate=daily|weekly|monthly` averaged step counts (and every
other cumulative HK type) instead of summing — partial-day rows
collapsed to a per-bucket mean rather than the daily total.
Five rows of 1000 steps reported 1000, not 5000.
Add `CUMULATIVE_HK_TYPES` in `apple-health-mapping.ts` listing the
five cumulative MeasurementTypes the iOS / Withings ingest path
emits (`ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
`WALKING_RUNNING_DISTANCE`, `TIME_IN_DAYLIGHT`). The route picks
`SUM(value)` for those types and keeps `AVG(value)` for every spot
metric (BP, pulse, weight, BG, body fat, mood, sleep).
Two new integration cases pin the contract: a step day collapses
to the per-row sum; a pulse day still averages.
The `aggregate=daily|weekly|monthly` branch on `GET /api/measurements`
500'd in production for every grain. Two root causes that the
mocked-`$queryRaw` unit suite hid end-to-end:
1. `date_trunc(${truncUnit}, …)` passed the grain as a bound
parameter; Postgres rejects — the unit argument must be a SQL
literal. Switch to `Prisma.raw` for the unit string; safe because
the grain is upstream-restricted to the `AggregateGrain` Zod enum.
2. The `daily → day` mapping ignored the `weekly` and `monthly`
forms; Postgres rejects those as units. Map all three grains to
the singular Postgres unit (`day` / `week` / `month`).
The user-id cast `${type}::"MeasurementType"` also referenced a
non-existent type name; the actual Postgres enum is `measurement_type`.
Corrected alongside.
Add `tests/integration/measurements-aggregate-daily.test.ts` against a
real Postgres so every grain compiles + executes end-to-end. The
mocked unit suite stays for the route-shape contracts.
R4 product-lead flagged the "≈ 1.1 for a 33-point delta" wording in
the dashboard BD-tile rewrite note. The illustration ties to a real
account's reading; the comment now describes the rounding artefact in
plain prose without quoting a numeric example.
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.
The /medications/[id]/history page wraps its sections in space-y-4 (16 px); each section paints a 1 px border so the effective gap reads ~14 px against the text-xs body density. Lifting the wrapper to space-y-6 (24 px) matches the /insights/* sub-page stride and gives the page room to breathe after the heading-scale collapse.
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.
`/api/workouts/[id]` PUT does not exist — workouts are ingested via
POST `/api/workouts/batch` only, and the maintainer's "Sport edit"
flow routes through `/api/measurements/[id]` for the sport-typed
samples (ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_
DISTANCE). Pin the FB-B1 fix at the surface the maintainer actually
hits — re-pointing a sport-typed measurement onto an existing
timestamp returns 409 + `measurement.duplicate_timestamp`.
The public POST surface lacked an origin gate, a rate-limit, and a
schema — a peer could flood the wide-event log with arbitrary text
under the `name` slot. Tighten the route:
- Same-origin Referer check when `NEXT_PUBLIC_APP_URL` is set.
- Per-IP `checkRateLimit` (60/min) matching the deploy-webhook shape.
- Zod schema locks `name` to the six documented web-vitals
identifiers; `id` capped at 50 chars; `value`/`delta` must be
finite. Only validated fields enter the log envelope — the raw
payload is never observed by the wide-event pipeline.
204 No Content on a successful enqueue keeps the `sendBeacon`
contract intact; 400 on schema reject; 429 on rate-limit overflow.
The "All time" range tab now resolves to a monthly aggregation across
the user's full measurement history (or weekly when total history sits
under two years) instead of silently narrowing to a trailing 365-day
window. The route's `BUCKET_CAP` ceiling bounds the series at 24
monthly buckets so a multi-decade account stays payload-flat without
losing the "earliest reading to today" framing.
Push range-aware aggregation into Postgres via `date_trunc` so the
bucket cap applies AFTER bucketising, not before. A 1-year
`aggregate=daily` request on a minute-by-minute pulse account now
walks every row in the window and returns up to 365 daily buckets
instead of silently truncating to the first N raw rows.
Gate the aggregation branch on an explicit `aggregate` query param.
Callers that pass `from`/`to` without `aggregate` (the iOS path)
keep the raw `MeasurementWireDTO` wire shape — the v1.5 locked
contract stays byte-stable. The new `monthly` grain bounds
multi-year "All time" charts at 24 buckets per type.
The targets-page Coach CTA collapsed to an icon-only affordance in
1b0e81ae; the old assertion still expected the text-bearing
`w-full sm:w-auto` wrapper. Pin the new shape: data-slot,
aria-label, and the size-10 icon button.
Lift the mobile FAB out of <CoachLaunchButton> into a new
<LayoutCoachFab> mounted once next to <CoachLaunchProvider> in the
Insights layout. Each sub-page previously mounted <CoachLaunchButton>
which rendered BOTH the FAB and the inline pill, so a phone user
ended up with 2-3 duplicate FAB nodes in the a11y tree (collapsed
visually by fixed positioning). The inline pill stays on the per-page
action rows where copy + position matter; the FAB now paints exactly
once per Insights surface.
Retire the inline dose-history `<details>` block on the GLP-1
medication card and the IntakeHistoryList mount on the standalone
medication detail page. The IntakeHistoryList component had no other
consumer, so the file retires together with its orphan i18n keys.
The iOS-consumed GET /api/medications/[id]/intake endpoint, its Zod
schema and the doseChanges wire field on /api/medications/[id]/glp1
all stay byte-stable.
Drop the InsightAdvisorCard component, its four integration tests
under src/components/insights/__tests__, the page-level mount on
/insights, the orphan i18n keys (advisorTitle, aiOverviewTitle), the
e2e advisor-card spec, the dashboard.spec.ts advisor assertion and
every related comment. The InsightsTabStrip regenerate icon stays
untouched as the sole power-user refresh affordance. The iOS-consumed
POST /api/insights/generate endpoint and its request/response shape
stay byte-stable.
Drop the entire weekly-report path: the /insights/report/[week] route,
the WeeklyReportView component, the hero strip's banner mount and
"Wochenbericht erstellen" action button, the ISO-week helpers, the
schema slot, the AI prompt rule + OUTPUT FORMAT block, the openapi
field, the i18n keys and every associated test. Coach now owns the
sole hero-row action. iOS contract is unaffected — no native consumer
reads any weekly-report endpoint or schema slot per the v1.4.28 iOS
contract inventory.
Drop the standalone GLP-1 dashboard tile and its data-fetch route.
The /api/dashboard/glp1 endpoint has no consumer outside the retired
tile (no native client reads it), so the route, the tile component,
its test, the e2e spec, the locale keys, the query-key entry and the
drift-guard assertion all retire together.
The medication detail page and its bidirectional contracts stay
untouched. An opt-in dashboard widget that surfaces the same level
chart will land later in this cycle, anchored on the existing
dashboard-layout toggle list.
The BD-Zielbereich tile rendered "1.1." instead of the expected
percent. Root cause: the dashboard mount synthesised a fake
`slope30 = bpTrendDelta / 30` so the trend arrow could move. The
result was a small float (≈ 1.1 for a 33-point delta) that the
TrendCard's downstream formatter pipeline rendered as a
date-shaped artefact — the maintainer's screenshot regression
across v1.4.22 / v1.4.25 / v1.4.26 patches that kept treating the
symptom instead of the divergence.
Rewrite the tile against the shared `<TrendCard>` primitive with
the same prop contract every other tile uses (`latest`, `avg7`,
`avg30`, `slope30=null`, `trend7Delta`, `compareBaseline`,
`compareDelta`). Drop the synthetic-slope block, drop the
`avgAllTime` prop family from `<TrendCard>` (the BD tile was the
only consumer), drop the orphan `charts.avgAllTimeShort` i18n key
across six locales. The all-time aggregate stays visible on the
`/targets` Blutdruck card with full context.
New regression spec at `src/components/charts/__tests__/bd-tile.test.tsx`
locks the BD tile's headline to a numeric percent string, asserts
DOM-shape parity with the Weight tile, and pins the absence of the
retired `trend-card-all-time` slot.
iOS contract: none. `/api/analytics` still emits the
`bpInTargetPct*` family unchanged (the `bpInTargetPctAllTime`
field stays for the /targets page consumer); iOS does not consume
the dashboard tile.
`/insights` landed at the wrong vertical position when the user
returned from a sub-page, and vertical swipes that began on the
sticky `<InsightsTabStrip>` were eaten by the strip's
`overflow-x-auto` scroll container so the page "felt stuck"
mid-gesture. Two roots, two fixes:
1. Move the horizontal pill scroll from the outer `<nav>` onto the
inner pill-row `<div>` (the inner row already declared
`overflow-x-auto`; the outer one is redundant once it's gone).
Add `touch-action: pan-y` to the outer `<nav>` so vertical
swipes pass through.
2. Defer the `<SubPageShell>` scroll-reset to the next animation
frame so the sub-page's first paint settles before the scroll
lands. Mirror the same deferred reset on the `/insights` mother
page so a back-navigation lands cleanly at the top instead of
inheriting the sub-page's vertical position through cached
scroll state.
A Playwright spec at `e2e/insights-scroll-restoration.spec.ts`
locks both regressions: (a) /insights → /insights/<sub-page> → back
lands within 50 px of the top, (b) the outer `<nav>` declares
`touch-action: pan-y` and no longer owns the horizontal scroll.
iOS contract: none — both surfaces are web-only.
The chart-data query in health-chart.tsx walked the full measurement
history with a `while (true)` paginated loop on every visit. For
Apple-Health-rich accounts (pulse every minute, months of backfill)
this meant tens of thousands of rows per type per page navigation,
which is the root cause behind FB-D2's "pulse insights loads forever"
report.
Derive `from` / `to` from the active range tab (plus the
comparison-baseline shift when overlay is on), pass both to
/api/measurements, and drop the unbounded paginator. The query key
re-keys on the window so a range-tab change refetches the right
slice. staleTime 60s + gcTime 5min hold the bounded payload across
tab navigation between insights sub-pages.
New `aggregate` query param + range-aggregation helper let the GET
handler downsample server-side for ranges over 90 d (one row per
day) and over 365 d (one row per ISO week). The per-request `limit`
ceiling lifts from 500 to 5000 — still bounded, but enough headroom
for an explicit from/to window without re-introducing pagination.
iOS contract — GET /api/measurements: additive. The `from`, `to`,
`aggregate` params are all optional; callers that omit them (the
iOS client, the existing measurements page) see the unchanged
response shape and the historical 500-row default cap. The
aggregated branch is only entered when both from + to are present
AND the window is wide enough for the threshold ladder to engage.
The PUT /api/measurements/[id] handler dispatched the Prisma
update without a try/catch, so re-pointing measuredAt onto an
existing (userId, type, measuredAt, source, sleepStage) tuple
bubbled the P2002 violation as a 500 and the row-edit Sheet
rendered the generic save-error toast.
Mirror the POST handler's P2002 catch: return a clean 409 with a
stable `meta.errorCode = "measurement.duplicate_timestamp"` and a
new measurements.duplicateTimestamp i18n key across the six
locales. The mutation in measurement-list.tsx reads the error
code from the response envelope and surfaces the localised
banner instead of the upstream English fallback.
iOS contract — PATCH /api/measurements/{id}: additive. The 200
happy-path response shape (MeasurementWireDTO), the 422
validation path and the updateMeasurementSchema are untouched.
The new 409 path is strictly clearer than the 500 it replaces.
`/api/version` now returns an additional `offlineGeoEnabled: boolean`
field that mirrors `offlineGeoReady()` — the same source the runtime
resolver checks before deciding whether to fall back to ipwho.is. A
monitor (or the admin status surface in the next commit) can read the
flag without crawling logs.
The route flips from `force-static` to `force-dynamic` because the
readiness check hits the filesystem on each request; the previous
static caching would have frozen the answer at build time. Two new
test cases cover the true / false branches alongside the existing
shape assertions.