Commit Graph
276 Commits
Author SHA1 Message Date
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
Marc-André Bombeck 267868c013 chore(cleanup): retire orphan exports and the stale legacy insights prompt module
Drop fourteen exported helpers and constants with zero call sites:
countMessages, buildGeneratePrompts, _resetSafetyContractsCacheForTests,
forEachLocaleAndRule, getPulseRange, trendLinePoints,
CHART_MINI_HEIGHT_PX, formatDateWithWeekday,
resolveGeneralStatusLocale, isSubPageSlug, recordHeartbeat,
EVENT_TYPE_LABELS, cleanupExpiredRateLimits, and sleepStageEnum.

Also retire the v1.4.20 legacy insights prompt scaffold —
getInsightsSystemPrompt, InsightsOutput, and the two dead
INSIGHTS_SYSTEM_PROMPT_EN/DE constants — plus the matching vi.mock
key in the generate route test that no longer maps to a live export.
Drop the empty __testables block in glp1-snapshot.ts, the legacy
helper reference in the insight-generator JSDoc, and the two stale
v1.4.16 InsightsPageHero comments in hero-strip + its test file.

Consume the MedicationComplianceChart compareBaseline prop via an
explicit void so the destructure documents the intentional accept-and-
defer pattern instead of looking like a missed wire-up.

Every symbol verified zero-caller via grep across src/, scripts/, and
__tests__/ before drop.
2026-05-15 21:16:35 +02:00
Marc-André Bombeck 6704579c25 fix(forms): wire the ResponsiveSheet footer slot across every page-level form mount
Until now every <ResponsiveSheet> consumer (except export-section) inlined the
form footer inside the body slot, so the primitive's sticky-pinned bottom rail
on the Sheet branch only painted empty. The Save / Cancel row therefore
scrolled with the body and slid out from under the soft keyboard on mobile.

This rewires the action-row through the footer prop on all seven page-level
mounts (dashboard quick-entry measurement + mood, /measurements, /mood,
/medications, side-effects sheet, inventory add sheet, intake-history edit +
create sheets, medication create/edit sheet). Each form receives an optional
footerSlot prop; when set, the kebab + Cancel + Save row is portalled into
the ResponsiveSheet footer slot so the bottom-sheet branch can sticky-pin it.
The Save button keeps its <form> association via the HTML form attribute so
submit-on-Enter and native validation continue to work end-to-end.
2026-05-15 21:05:50 +02:00
Marc-André Bombeck b1b0b1b9b1 feat(insights): add a Coach launch fallback to every empty-state sub-page and lift the CTA size
Every Insights sub-page's empty branch surfaced a single "Add
measurement" CTA and stopped there — a user with zero data had no
path to the assistant who can explain why the missing reading
matters. Mount CoachLaunchButton below the primary CTA with a
metric-specific prefill so the secondary fallback is one tap away,
and pair the change with the ctaSize="lg" prop that EmptyState picked
up in MB7 (CF-36) but never had a consumer wire — both findings hit
the same files so they fold cleanly into one change.

Covers blutdruck, gewicht, puls, stimmung, medikamente, bmi (both
the no-weight and no-height branches), and schlaf.
2026-05-15 21:04:13 +02:00
Marc-André Bombeck 374b410f46 docs(about): document the deliberate no-TOC call for the short-form page
The R4 design review flagged /about as missing the TOC the /privacy
page carries. /about is two sections (Project + Credits) and fits the
fold on every viewport, so a collapsible TOC costs a tap to expand
for negligible payoff. Pin the decision in the file header so a
future reader does not re-open the question on a future polish
sweep.
2026-05-15 21:00:30 +02:00
Marc-André Bombeck 25e63fcf02 fix(ui): widen scroll-mt on about and privacy anchors so the sticky header does not occlude the section start
Both pages anchored their sections at scroll-mt-20 (80 px). The sticky
header pads safe-area-inset-top (~47 px on a notch device) plus py-3
plus an inner min-h-11 link, landing around 115 px — past the 80 px
offset. Lifting to scroll-mt-28 (112 px) clears the header on every
viewport we ship to without over-padding the desktop case.
2026-05-15 20:59:52 +02:00
Marc-André Bombeck 7dc51a2177 fix(workouts): correct pagination under canonical dedup
Reading rows with skip/take and then deduping inside the window
double-counted cluster boundaries: a cluster split across two pages
would be dropped on page N-1 and surface again on page N, while
meta.total reflected the per-window canonical count rather than the
user's true canonical total.

Pull the full filtered set, dedupe once, then slice. Workout volume
for a single user stays well within budget for an unbounded read; if
the workload ever grows past that, switch to a (startedAt, id) cursor
with a deterministic over-fetch window. A new test case exercises a
window with eight twin clusters split across two pages to pin the
contract.
2026-05-15 20:57:13 +02:00
Marc-André Bombeck 617d451864 fix(tests): flip the insights-coach mount assertions to the new layout owner
MB4 promoted the Coach drawer mount from the mother page up to
`/insights/layout.tsx` so navigating into a routed sub-page no longer
unmounts the drawer. The launch lifecycle now lives in the
`<CoachLaunchProvider>` context that the layout supplies; the page
consumes it via `useCoachLaunch`.

The three insights-polish assertions still pinned the old contract
(import + mount in the page, local `setCoachOpen` / `setCoachPrefill`
state). Re-pointed them at the new owner:

- layout imports the launch context, wraps children in
  `<CoachLaunchProvider>`, and mounts `<LayoutCoachMount>`
- page's hero strip still wires `onAskCoach` / `onPickPrompt` but now
  delegates to `coachLaunch.askCoach(prefill)`
- the local `setCoachOpen` / `setCoachPrefill` pair is gone
2026-05-15 19:28:04 +02:00
Marc-André Bombeck 0b3f9e3e2c chore(misc): auth padding, daily-briefing links, tab fade, trends min-h, list metadata, passkey badge
v1.4.27 MB7 — residual surface polish bundled into a single
commit:

- CF-61: the auth login + register cards drop `p-8` to `p-6 sm:p-8`
  so Galaxy Fold / Pixel 5 keep the card breathable instead of
  surrendering half the visible viewport to padding.
- CF-68: each daily-briefing key-finding row wraps in a `<Link>` to
  the matching routed sub-page so the whole card is tappable on
  mobile. Metrics without a dedicated sub-page (HRV, resting HR,
  steps, …) render the legacy static row.
- CF-70: the sleep-stage window-toggle row bumps from `gap-1` to
  `gap-1.5` so the active pill border doesn't fuse with the
  inactive neighbour on Pixel 5.
- CF-71: the trends-row card `min-h-[300px]` floor moves behind a
  `md:` gate so mobile single-column doesn't eat dead space below
  a short chart.
- CF-72: the insights tab-strip paints a right-edge fade gradient on
  `<sm` so the user reads "there's more to scroll" when the pill
  row overflows.
- CF-46: the measurement-list filter row stacks vertically on `<sm`
  so the `w-48` SelectTrigger doesn't crowd the count caption to a
  2-line wrap.
- CF-76: the measurement-list mobile-row badges bump from
  `text-[10px]` to `text-[11px]` so the metadata holds the
  legibility floor.
- CF-75: the passkey mobile card promotes the device-type from
  plain text to an outline Badge so the chip row reads consistent
  with the desktop table column.
- MA2-F8: the medikamente page card header wraps the title in
  `min-w-0` + `truncate` so long medication names don't push the
  streak badge off the card on Galaxy Fold.
2026-05-15 19:23:34 +02:00
Marc-André Bombeck 7bfd5beeb2 chore(dashboard): horizontal-scroll the tile strip on narrow viewports and wrap the onboarding header
v1.4.27 MB7 / CF-42 + CF-44 — the dashboard tile strip switches to a
`flex overflow-x-auto` row at `<sm` so the tiles scroll horizontally
instead of wrapping to 3-4 rows on Pixel 5 / Galaxy Fold. From `sm:`
upwards the strip falls back to the canonical grid `auto-fit +
minmax(9rem, 1fr)` layout the maintainer pinned in v1.4.4. The
getting-started checklist header wraps onto a second row when the
title + dismiss button overflow, and `min-w-0` + `truncate` keep
the title from pushing the chevron off-screen on Galaxy Fold.
2026-05-15 19:15:20 +02:00
Marc-André Bombeck e52b64bf34 fix(insights): point the empty-state CTAs at the existing measurements route
The B4 insights empty-state CTAs (blutdruck, gewicht, puls, bmi)
linked to `/measurements/new`, a route that does not exist; a tap
landed on a Next.js 404. Re-route via `/measurements?add=<TYPE>`,
the parameter the measurements page now consumes. Each CTA picks a
sensible default: BLOOD_PRESSURE for the BP page, WEIGHT for the
weight and BMI pages, PULSE for the pulse page. The mood, sleep,
and medication empty-states already pointed at their own dedicated
routes and need no change.

Add a `?add=<TYPE>` consumer to the measurements page. The consumer
opens the add-measurement dialog with the right `defaultType` on
first render, then `router.replace` strips the param so the back
button returns to a clean `/measurements`. The allow-list mirrors
MEASUREMENT_TYPES inside `measurement-form.tsx` so a stale link
cannot trap the user on an unknown form. The render-driven set-state
pattern follows account-section.tsx and satisfies the
`react-hooks/set-state-in-effect` rule.
2026-05-15 19:09:34 +02:00
Marc-André Bombeck 246c1def21 feat(coach): mount the drawer at the insights layout with a launch context
Promote the Coach drawer mount from the mother /insights page up to
/insights/layout.tsx so every routed sub-page can reach it. A new
CoachLaunchProvider exposes useCoachLaunch() for the hero strip,
suggested-prompt chips, and the seven sub-pages; the layout renders
the drawer beside the provider so navigation across Insights surfaces
no longer unmounts the conversation.

Each sub-page (blutdruck, gewicht, puls, stimmung, medikamente, bmi,
schlaf) mounts a new CoachLaunchButton that pins to the bottom on
narrow viewports and renders as an inline header action on lg+.
2026-05-15 18:59:55 +02:00
Marc-André Bombeck de0c16338f fix(auth): lift the register submit button and add a branded not-found page
Promote the register page's submit button to the 44 px tap-target
floor (size="lg" + min-h-11 + w-full) so the primary action stays
finger-tap reachable on a narrow viewport.

Add a new branded not-found.tsx page. The previous implicit fallback
was Next.js's bare framework text; this introduces a lighter wrapper
than ErrorDetails (404 is not an application error) — Logo, headline,
explanatory paragraph, and a single "Back to dashboard" link. The
wrapper carries min-h-dvh and safe-area-inset-top so the panel
anchors centred under iOS Safari's animated URL bar and stays clear
of the notch.

Apply the same dvh + safe-area pattern on the root-layout global
error boundary and lift its inline button heights to 44 px. Wrap the
ErrorDetails panel in a min-h-dvh flex column so server-error pages
also centre instead of hugging the notch.
2026-05-15 18:58:35 +02:00
Marc-André Bombeck fc8a58557a fix(auth): unblock the about page for unauthenticated visitors
The /about page (GeoLite2 attribution) was listed in proxy.ts
PUBLIC_PATHS but missed in AuthShell's client-side PUBLIC_PATHS, so
unauthenticated visitors bounced through /auth/login. Add /about to
the AuthShell list and treat it as a standalone public page (same
edge-to-edge render as /privacy).

Apply safe-area-inset-top on the privacy and about sticky headers so
iOS notch / Dynamic Island viewports don't overlap the brand row. Add
a default-closed Contents <details> TOC at the top of the privacy
policy and wrap every HealthKit identifier <code> with break-all so
long camelCase strings (heartRateVariabilitySDNN,
environmentalAudioExposure) wrap inside narrow viewports.
2026-05-15 18:56:49 +02:00
Marc-André Bombeck 4464d2c9b9 chore(charts,errors): apply the tap-target floor across charts and error chrome
Final surface sweep for the v1.4.27 tap-target work: the chart band
overlay, the error-details action row, and the public-page header
links.

- `health-chart.tsx`: band overlay used to inset `right: 18px` while
  the underlying ComposedChart margin is `right: 8`, so the colour
  band drifted left of the plotted line by 10 px on every viewport.
  Pin the overlay to the chart's actual right edge.
- `error-details.tsx`: drop `size="sm"` from the retry, copy, and
  report-issue buttons so they sit at the default 40 px primitive
  height — clears the WCAG 2.5.5 floor on touch.
- `auth/login/page.tsx`: back-to-passkey toggle wraps in `inline-flex
  min-h-11 w-full` so the form-mode swap is reachable on touch.
- `privacy/page.tsx` and `about/page.tsx`: header HealthLog + Sign-in
  links wrap in `inline-flex min-h-11` so the public-page chrome
  clears the floor.
2026-05-15 18:48:07 +02:00
Marc-André Bombeck 95bc87f5ab feat(auth): wire keyboard hints and aria error wiring on login and register
Bring the auth flows to parity with the rest of the v1.4.27 form
sweep so screen-reader users hear the form-level error banner tied
to the offending field and the mobile keyboard surfaces the right
on-screen affordance per step.

Login: email field flips to `inputMode="email"` +
`enterKeyHint="next"` and disables capitalize / spell-check;
password field carries `enterKeyHint="go"`. Both inputs reference
the error banner via `aria-describedby` and the banner itself gains
`aria-live="polite"`.

Register: same pattern across email, username, and the new-password
field. The submit key on password is `"go"` to fire the create-
account submit on virtual-keyboard return.
2026-05-15 18:38:28 +02:00
Marc-André Bombeck 04cce8d9c2 refactor(medications): mount the entry surfaces via ResponsiveSheet
Swaps the medication create/edit, intake create/edit, side-effect
log, and inventory add-pen mounts from the centred `<Dialog>` to
the shared `<ResponsiveSheet>` primitive so the same surfaces flip
to a bottom-anchored sheet on narrow viewports. The "log this
thing" mental model — measurement, mood, medication, intake,
side-effect, inventory — now lands on a consistent mobile shape.
Configuration surfaces (IntakeImport, ApiEndpoint, PhaseConfig)
stay centred per Decision G; the dialog primitive's new
`max-h-[calc(100dvh-2rem)]` default keeps them inside the viewport
on short / soft-keyboard screens without the previous per-callsite
`max-h-[90vh]` ceiling.
2026-05-15 18:30:15 +02:00
Marc-André Bombeck bd7cb938c9 feat(ui): lift the password toggle into a shared primitive
Move `<PasswordInput>` from `src/components/settings/password-input.tsx`
to `src/components/ui/password-input.tsx` so the auth surfaces (login
and register) and every settings card that masks a secret consume one
primitive. The two surfaces previously diverged on toggle markup and
aria-label wiring; the new home in `ui/` keeps them in lock-step.

While the primitive moves, lift the icon-only show/hide toggle to a
44 px hit area per WCAG 2.5.5 — `inline-flex h-11 w-11` wrapper with
the eye icon centred inside. The input itself gets `pr-12` so user
input never collides with the toggle glyph.

Consumer imports updated in:
- `src/components/settings/account-section.tsx`
- `src/components/settings/ai-section.tsx`
- `src/components/settings/integrations-section.tsx`
- `src/components/settings/ntfy-card.tsx`
- `src/components/settings/telegram-card.tsx`
- `src/components/admin/_shared.tsx`

The `<DialogContent>` close-X tap-target lift to `min-h-9 min-w-9`
landed in the parallel `ResponsiveSheet` commit because both buckets
share the same `dialog.tsx` file. See Decision I — 36 px is the
intentional exception for fixed icon-only close affordances.
2026-05-15 18:26:06 +02:00
Marc-André Bombeck 3a7599c846 chore(api): delete the orphan /api/audit-log surface and seed the five README-tied admin orphans into v1.4.28
The /api/audit-log GET handler has zero runtime callers in src/, tests/,
scripts/, or docs/ — verified via grep against the route path plus the
auditLog model name. The admin counterpart at /api/admin/audit-log is
the surface every consumer reads from. Drops the orphan route file
outright per the v1.4.27 fix-plan Section 4 Decision A.

The five admin / monitoring endpoints flagged for the scope-
maximization sweep all carry a README + CHANGELOG documentation
reference, which the fix-plan classes as a consumer for the
defer-on-tie rule:

  /api/admin/ai-settings           — README L362–363, CHANGELOG L752
  /api/admin/backup/test           — README L368,    CHANGELOG L752
  /api/admin/status-overview       — README L367,    CHANGELOG L753
  /api/monitoring/glitchtip/test   — README L381
  /api/monitoring/umami/test       — README L382

All five defer to v1.4.28 for a maintainer call — either wire each to
a real consumer or drop the route + README mention together. Seeds
the v1.4.28 backlog entry with the per-endpoint grep findings so the
decision is one read away.

Also seeds the v1.4.28 backlog with the missing `insights.coach.window.lastYear`
i18n key across the six locale bundles — owned by the i18n bucket.
2026-05-15 17:37:22 +02:00
Marc-André Bombeck 988bbf1a2f feat(workouts): wire pickCanonicalWorkout into the list read path and collapse the attach route N+1
Adds `GET /api/workouts` as a paginated read surface that runs every
fetch through `pickCanonicalWorkout()` so a duplicated workout that
Apple Health and Withings both publish collapses to the canonical
row before reaching the client. The picker stays a pure function;
the route owns the query window, the fetch multiplier that keeps the
page full after dedup, and the meta envelope that surfaces the
dedup ratio.

Collapses the batch-route route-attach lookup from `Promise.all` of
`tx.workout.findFirst` per manual workout to a single batched
`findMany` keyed by the deterministic `(source, startedAt, sportType)`
tuple. For a 100-row batch this cuts the per-batch round-trip count
from 1 + N to 2 while preserving the most-recent createdAt tie-break
through in-memory grouping.

Adds `__tests__/canonical-dedup.test.ts` covering twin collapse,
proximity-window separation, sport-type isolation, and the limit
clamp.
2026-05-15 17:31:37 +02:00
Marc-André Bombeck 32a023dfff chore(ui): apply M7 + L1 + L3 + L4 polish across medication form, motion-reduce, details aria, therapy timeline
W10 design-review carry-over items. M7 lifts the medication-form
day-of-week buttons, the more-horizontal trigger, and the schedule
label-input gaps to the 44 px / space-y-2 standard used by every
other settings surface. L1 pairs every animate-spin/pulse/bounce
with motion-reduce:animate-none across the component tree so users
with prefers-reduced-motion get a still surface. L3 adds the
aria-controls + panel-id pairing to the inventory + coach-evidence
<details> blocks for parity with the Health Score provenance
accordion. L4 inserts a sr-only h4 per therapy-timeline entry so
screen-reader users can jump between medication blocks.
2026-05-15 17:13:42 +02:00
Marc-André Bombeck 3299fd822c refactor(dashboard): clear B1+B4 reconcile markers
The dashboard already gates each tile through the existing
hasWeight / hasBp / hasPulse / hasBodyFat / hasMood / hasSleep /
hasSteps flags. Those flags read summaries[METRIC].count > 0, which
is exactly the contract hasMetricData() codifies for the routed
Insights surfaces. Both paths now point at each other in code
comments; no behaviour change.
2026-05-15 17:00:16 +02:00
Marc-André Bombeck 5886fea8d7 fix(tests): repair hero-strip and telegram-test-locale assertions after R3a
Two test-contract fixes for fallout from concurrent R3a contributors:

- hero-strip.test.tsx — collapses the two onAskCoach forwarding tests
  into a single regression guard that asserts the inline Health Score
  card Ask-the-Coach button stays retired and the hero strip's
  action-row button carries the affordance.

- telegram-test-locale.test.ts — the route's POST handler takes zero
  arguments (apiHandler wraps an async () => function). Drops the
  Request argument from all seven call sites.
2026-05-15 17:00:13 +02:00
Marc-André Bombeck f3ff262451 feat(insights): gate sub-pages and tab pills on metric data availability
Brand-new accounts (no manual logs, no Apple Health upload yet) used to
land on chart skeletons over blank target bands when they clicked any
metric tab. Apple Health hides those surfaces entirely until the first
observation lands — match the pattern.

Introduce `src/lib/insights/metric-availability.ts` as the single
decision point. `hasMetricData(metric, inputs)` reads `summaries[METRIC].count`
for sensor metrics (PULSE / WEIGHT / BMI / BLOOD_PRESSURE_* / SLEEP_DURATION
/ VO2_MAX / STEPS / ACTIVE_ENERGY), the `hasMood` / `hasMedication`
booleans for event-driven metrics, and BMI inherits from WEIGHT.

Wire three render gates:

- `<InsightsTabStrip>` filters its pills via the new `availability`
  prop. Backward compatible: undefined `availability` keeps every pill.
- `<InsightsLayoutShell>` fetches `/api/analytics` +
  `/api/insights/comprehensive` once per route and threads the inputs
  into the strip; both queries share their cache keys with sub-page
  consumers so the payload lands single-fetch.
- Each `src/app/insights/{slug}/page.tsx` early-returns an
  `<EmptyState>` with the right CTA — `/measurements/new` for sensor
  metrics, `/mood` for Mood, `/medications` for Medication,
  `/settings/data-sources` for Sleep.

Auto-light-up: once the iOS native client uploads its first measurement
the same gate flips on the next refetch — no per-metric feature flag.

References the new `insights.emptyState.<metric>.{title,description,cta}`
translation keys (B6 lands them in messages/*.json):

- insights.emptyState.bloodPressure.{title,description,cta}
- insights.emptyState.weight.{title,description,cta}
- insights.emptyState.pulse.{title,description,cta}
- insights.emptyState.mood.{title,description,cta}
- insights.emptyState.medication.{title,description,cta}
- insights.emptyState.bmi.{title,description,cta}
- insights.emptyState.sleep.{title,description,cta}
2026-05-15 16:54:51 +02:00
Marc-André Bombeck 25952d7d05 feat(admin): surface the carrier chip under the auth provider on the login overview
Adds a `carrierShortLabel()` helper in `_shared.tsx` that folds the
verbose GeoLite2 `autonomous_system_organization` strings down to
the glanceable DACH short labels Marc asked for: "Telekom",
"Vodafone", "1&1", "O2". Unknown organisations fall through to the
raw string so the chip stays informative.

`LoginOverviewSection` now renders the short label as a smaller
chip below the existing auth-provider chip, gated on
`entry.carrier !== null` so private-IP and offline-miss rows keep
the legacy single-chip layout.

The CSV export grows a matching `carrier` column between `location`
and `provider`. The API selector pulls the new `asn` + `carrier`
fields. New translation keys (`admin.carrier`, `admin.carrierUnknown`)
are referenced in code; bucket B6 lands the JSON entries.
2026-05-15 16:47:36 +02:00
Marc-André Bombeck 3e930e68f6 feat(notifications): localise admin and user Telegram messages to the recipient locale
Several call-sites composed notification bodies in English or hardcoded
German without consulting User.locale. Swap each surface to a
translator-aware path:

- src/app/api/internal/deploy-webhook/route.ts uses
  dispatchLocalisedNotification per admin, reading
  notifications.admin.deployFailedTitle and
  notifications.admin.deployFailedBody with param substitution.

- src/app/api/admin/notifications/test/route.ts resolves the admin
  locale once at the top of the handler, then composes the test
  notification via getServerTranslator. This route talks to the
  senders directly because it needs per-channel success/error
  feedback for the UI, so it stays off the dispatcher.

- src/app/api/settings/telegram/test/route.ts has the same shape for
  the per-user path; it reads the user locale and translates
  notifications.user.telegramTestBody before sending.

- src/app/api/admin/notifications/reminder-check/route.ts uses
  dispatchLocalisedNotification with per-row recipient locale, so the
  affected user (not the admin running the check) sees the message
  in their language.

Reminder-worker is already localised via getPhaseMessage; no change.
src/lib/integrations/status.ts is out of scope and left for a later
symmetry-sweep.

Tests:
- src/app/api/settings/__tests__/telegram-test-locale.test.ts covers
  de, fr, null, unsupported locale paths plus rate-limit,
  missing-token, and send-failed branches.
- src/app/api/internal/deploy-webhook/__tests__/route.test.ts swaps
  its admin-alert assertion from dispatchNotification to
  dispatchLocalisedNotification.

New translation keys (B6 picks up the JSON edits):
- notifications.admin.deployFailedTitle
- notifications.admin.deployFailedBody
- notifications.admin.testNotificationTitle
- notifications.admin.testNotificationBody
- notifications.admin.reminderCheckMissedTitle
- notifications.admin.reminderCheckMissedBody
- notifications.admin.reminderCheckOverdueTitle
- notifications.admin.reminderCheckOverdueBody
- notifications.user.telegramTestBody
2026-05-15 16:42:24 +02:00
Marc-André Bombeck 9afb31bd15 chore(dashboard): retire the standalone InsightsCardPreview surface
The dashboard preview duplicated the much-richer /insights advisor surface
that already lives one click away (Daily Briefing, hero strip, correlation
row, full advisor, weekly-report banner). The preview also competed
visually with the GLP-1 tile for the same vertical real estate above the
chart row.

Drop the import + render + isChartVisible gate in src/app/page.tsx, drop
the insightsPreview widget entry from src/lib/dashboard-layout.ts, drop
the matching row in src/components/settings/dashboard-layout-section.tsx,
and delete src/components/insights/insights-card.tsx and its test file.
The advisor query hook stays on /insights — the dashboard no longer
mounts it. Update the dashboard-layout contract test to assert the
widget id is absent.

F7 weekly-report scan: ran grep -rinE 'Wochenreport|weekly report|/insights/report' across src/ and messages/. All weekly-report click affordances on /insights are correctly wired (HeroStrip Generate button → currentWeekHref, WeeklyReportBanner → /insights/report/[week], InsightAdvisorCard banner → /insights/report/[week]). The /insights/report/[week] route mounts WeeklyReportView and renders the full surface. No dead click affordance was located inside the 30-minute scan budget. The retired InsightsCardPreview was the only dashboard-anchored insight card with a CTA — its removal in this commit is the most likely reason the maintainer perceived a dead click. F7 deferred to v1.4.28 with a maintainer-screenshot ask; recorded in .planning/v1428-backlog.md.

i18n keys still referenced after this change (B6 sweep will reconcile):
none from this commit. Keys 'dashboard.insightsPreview' and 'insights.aiInsights' are now unreferenced in code and become candidates for B6's dead-key sweep.
2026-05-15 16:38:22 +02:00