Commit Graph
302 Commits
Author SHA1 Message Date
Marc-André Bombeck e65cdc97b2 fix(ui): reserve Button loader space to eliminate CLS during in-flight requests
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.)
2026-05-16 17:49:46 +02:00
Marc-André Bombeck 523ee0c7d1 fix(notifications): disambiguate inbox vs channel-config naming
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.
2026-05-16 17:49:31 +02:00
Marc-André Bombeck fe94299122 fix(insights): consolidate route scroll-reset into a single hook
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.
2026-05-16 17:47:27 +02:00
Marc-André Bombeck 87e4cdcb1b fix(layout): normalise auth-shell container width on max-w-screen-xl
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.
2026-05-16 17:45:20 +02:00
Marc-André Bombeck 8131fcb369 fix(dashboard): tile sentiment, mobile grid, BD-Ziel gate, summary metadata
- 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.
2026-05-16 17:36:31 +02:00
Marc-André Bombeck 67d05c0de9 perf(analytics): slim /api/analytics?slice=summaries slice (C1)
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.
2026-05-16 17:33:58 +02:00
Marc-André Bombeck 02410b2394 fix(insights): BP chart Y-axis unit reads 'mmHg' not 'Hg'
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.
2026-05-16 17:12:17 +02:00
Marc-André Bombeck 61107e0c68 fix(api): stop /api/analytics 500 from Math.min/max spread overflow
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.
2026-05-16 16:51:58 +02:00
Marc-André Bombeck 1f01b941ae feat(insights): chart cards for HRV, resting HR, SpO2, body temperature, active energy
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.
2026-05-16 15:53:18 +02:00
Marc-André Bombeck 3f5816112b feat(dashboard): recent workouts tile
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.
2026-05-16 15:47:44 +02:00
Marc-André Bombeck 61969fb025 feat(insights): workout detail page with HR + route + stats
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.
2026-05-16 15:43:17 +02:00
Marc-André Bombeck beaee5791a feat(insights): workouts list page at /insights/workouts
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.
2026-05-16 15:38:07 +02:00
Marc-André Bombeck 8d514ed127 feat(api): expose workouts list + detail endpoints
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.
2026-05-16 15:29:33 +02:00
Marc-André Bombeck 3b56bac59a feat(admin): assistant feature-flag toggle panel
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).
2026-05-16 14:00:10 +02:00
Marc-André Bombeck 03d0b64bb8 feat(insights): gate assistant surfaces on operator feature flags
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.
2026-05-16 13:54:00 +02:00
Marc-André Bombeck aeff47afde feat(api): gate assistant endpoints on the feature-flag matrix
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.
2026-05-16 13:46:17 +02:00
Marc-André Bombeck 61c9a1e792 feat(api): expose the assistant feature-flag matrix
`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.
2026-05-16 13:40:02 +02:00
Marc-André Bombeck bd3a4470e5 feat(api): expose the measurement-categories map as a public endpoint
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.
2026-05-16 13:09:27 +02:00
Marc-André Bombeck 11325e31a0 feat(mood): add a first-class note column replacing the tag-based workaround
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.
2026-05-16 12:08:53 +02:00
Marc-André Bombeck 0ccc00b774 feat(api): SyncMode foundation — syncVersion column, sync-state endpoint, bulk backfill routes
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.
2026-05-16 12:04:45 +02:00
Marc-André Bombeck 0e92f96e72 feat(scripts): drain per-sample cumulative HealthKit rows into daily-aggregated rows
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.
2026-05-16 11:56:39 +02:00
Marc-André Bombeck c1a52c3268 perf(dashboard): bound glucose + bp-in-target windows and stale-time the inline queries
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.
2026-05-16 10:46:49 +02:00
Marc-André Bombeck d381f23e59 fix(dashboard): equal-height tile contract on mobile
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`.
2026-05-16 10:44:29 +02:00
Marc-André Bombeck 642e78a6f7 fix(api): aggregate cumulative measurement types as a sum
`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.
2026-05-16 10:36:39 +02:00
Marc-André Bombeck 6e75463fef fix(api): pass aggregate grain as a SQL literal to date_trunc
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.
2026-05-16 10:34:46 +02:00
Marc-André Bombeck 90a62cc9da test(version): accept the four-segment hotfix version shape 2026-05-16 09:07:54 +02:00
Marc-André Bombeck b74851f9d6 chore(comments): scrub the health-figure example from the BD-tile note
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.
2026-05-16 02:52:41 +02:00
Marc-André Bombeck f0e3e055f5 chore(comments): scrub forbidden vocabulary from v1.4.28 code comments
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.
2026-05-16 02:48:35 +02:00
Marc-André Bombeck d46d0e7ee8 fix(medications): align detail-page spacing ladder
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.
2026-05-16 02:44:49 +02:00
Marc-André Bombeck 9768066354 refactor(coach): unify launch glyph and propagate medication row shape
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.
2026-05-16 02:42:30 +02:00
Marc-André Bombeck f91b4732ea test(api): cover workout-edit duplicate-timestamp via measurements
`/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`.
2026-05-16 02:36:09 +02:00
Marc-André Bombeck 1920a763be fix(api): rate-limit and validate the web-vitals beacon
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.
2026-05-16 02:35:36 +02:00
Marc-André Bombeck 8144281d1e fix(charts): keep all-time charts full-history via monthly aggregation
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.
2026-05-16 02:33:52 +02:00
Marc-André Bombeck 0d256230ef fix(api): aggregate measurements before applying the take limit
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.
2026-05-16 02:32:36 +02:00
Marc-André Bombeck 5570971f3d test(targets): update coach CTA assertion to the icon-only shape
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.
2026-05-16 02:08:08 +02:00
Marc-André Bombeck ebf83b1ee5 feat(perf): wire bundle analyzer and web-vitals beacon 2026-05-16 02:04:02 +02:00
Marc-André Bombeck 8f7cbd49b9 fix(insights): document the missing sleep status slot 2026-05-16 01:59:26 +02:00
Marc-André Bombeck 8c89ddaca7 refactor(insights): consolidate sub-page data-fetch and empty state 2026-05-16 01:58:16 +02:00
Marc-André Bombeck 8f3bfc37a0 refactor(charts): collapse health-chart dynamic imports onto re-export 2026-05-16 01:52:18 +02:00
Marc-André Bombeck d286220bed perf(charts): wire chart-skeleton loading state across dynamic imports 2026-05-16 01:43:37 +02:00
Marc-André Bombeck 4c6d877921 refactor(coach): consolidate launch button to inline + layout-FAB shape
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.
2026-05-16 01:42:12 +02:00
Marc-André Bombeck 8c81af10d6 chore(medications): drop the Dosis-Historie disclosure from GLP-1 detail
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.
2026-05-16 01:27:40 +02:00
Marc-André Bombeck 52edf85f02 chore(insights): retire the InsightAdvisorCard surface
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.
2026-05-16 01:19:14 +02:00
Marc-André Bombeck cad53a68a3 chore(insights): retire the weekly-report surface
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.
2026-05-16 01:14:26 +02:00
Marc-André Bombeck 8e5f71b1b9 chore(dashboard): retire the GLP-1 tile
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.
2026-05-16 01:00:09 +02:00
Marc-André Bombeck 59ef95f284 fix(dashboard): align BD-Zielbereich tile with shared TrendCard primitive
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.
2026-05-16 00:35:28 +02:00
Marc-André Bombeck ac80c099e2 fix(insights): unstick scroll on tab-strip and mother-page navigation
`/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.
2026-05-16 00:31:05 +02:00
Marc-André Bombeck b00be2863d perf(charts): bound health-chart fetches to the active range window
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.
2026-05-16 00:20:27 +02:00
Marc-André Bombeck 538b44f74d fix(api): return 409 on duplicate-timestamp measurement edit
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.
2026-05-16 00:15:47 +02:00
Marc-André Bombeck 4eb22387a2 feat(version): expose offlineGeoEnabled on the public version endpoint for monitoring
`/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.
2026-05-15 22:17:16 +02:00