Commit Graph
1415 Commits
Author SHA1 Message Date
Marc-André Bombeck 5daa78daf3 docs(planning): document apps-01 env-var duplicate sections
Read-only audit captured via `mcp__coolify-apps01__env_vars` (list)
plus an attempted `mcp__coolify-edge01__env_vars` call. apps-01
returned 56 entries with the section-1/section-2 split documented
under CHANGELOG.md v1.3.1 — every key in section 2 is either a
duplicate of section 1, an empty placeholder, or (for
POSTGRES_PASSWORD) the literal default string left over from the
Compose template. Runtime reads the first match so containers stay
healthy; the duplicates are cosmetic clutter that make root-cause
debugging slower on the next deploy regression.

Edge-01 MCP returned a connection error (matches the v1.4.33
closure-report iceberg note) — env audit on that host needs SSH
until the MCP daemon is restarted.

No deletes performed — operator action only. The report seeds the
priority order: delete the `POSTGRES_PASSWORD` placeholder first,
then optionally bulk-prune the remaining section-2 duplicates,
then confirm intent for the section-2-only `CODEX_OAUTH_CLIENT_ID`
entry.
2026-05-16 20:29:05 +02:00
Marc-André Bombeck 24b8d52f3c ci(docker): fire the release build once per tag
The Build & Publish Docker Image workflow fired twice on every
release. The squash-merge to `main` and the `v*` tag push landed
within a second of each other and each kicked off its own run
against the same SHA. Both runs finished green and pushed
overlapping tag sets — the main-branch run added the `latest`
alias, the tag run added the semver tags — but the duplication
doubled GHCR CI minutes per release with no operational benefit.

Drop the `push.branches: [main]` trigger so only `v*` tag pushes
fire the build on a release. Tighten the `latest` raw-tag rule in
docker/metadata-action so it enables on stable release tags
(`refs/tags/v*` without a pre-release suffix) rather than the
`is_default_branch` condition (false on tag refs). Net result: a
release tag pushes a single multi-arch image carrying both the
semver tags and the `latest` alias in one run, halving the CI
minutes per release. PR + workflow_dispatch triggers are
preserved so pre-merge validation and manual reruns still build.
2026-05-16 20:27:22 +02:00
Marc-André Bombeck 424328324a chore(build): silence NFT-trace warnings and ship the bfcache header
Two additive `next.config.ts` tweaks for v1.4.34:

- `outputFileTracingExcludes: { "*": ["./next.config.ts"] }` — silences
  the Turbopack tracer warnings emitted during `next build`. The tracer
  follows the `MAXMIND_LICENSE_KEY` env access in `src/lib/geo.ts` back
  into the config file and emits "cannot be traced" warnings for every
  path it tried to walk afterwards. The standalone bundle is still
  controlled by `output: "standalone"`; the exclude only narrows trace
  reporting and has no runtime effect.

- A second `headers()` rule (source: `/((?!api|_next).*)`) stamps
  `Cache-Control: private, max-age=0, must-revalidate` on every
  authenticated HTML page response. The Next.js framework default for
  pages that touch cookies is `no-store, must-revalidate`, which
  Chromium counts as a hard bfcache breaker; the rule above replaces
  that on the page tree while leaving `/api/*` and `/_next/*` alone so
  API routes keep their explicit directives and static assets keep
  immutable caching. Pairs with the `Permissions-Policy: unload=()`
  hint already in place to restore back/forward-cache eligibility for
  every authed route.
2026-05-16 20:27:11 +02:00
Marc-André Bombeck 8e05f9227d feat(http): centralise authed Cache-Control with bfcache-friendly preset
Add `src/lib/http/cache-headers.ts` exporting two presets and one
applier. `NO_STORE_BUT_BFCACHE` (`private, max-age=0, must-revalidate`)
replaces the framework's stock `no-store, must-revalidate` on
authenticated page responses; Chromium treats the latter as a hard
bfcache breaker, while the former keeps shared caches out, forces
revalidation on every navigation, and stays bfcache-eligible.
`SHORT_LIVED_PUBLIC` covers static-ish one-hour public payloads.
`applyAuthedHeaders(res)` stamps the bfcache-friendly directive onto
a Response in-place for route handlers that opt into the same posture.

Also add `scripts/print-bundle-report.mjs` (wired earlier as the
`bundle-report` npm script). Turbopack dropped the webpack "First
Load JS" summary line from `next build`; the script reads back the
`.next/analyze/client.json` payload that `@next/bundle-analyzer`
writes when `pnpm analyze` runs and prints a sorted table of the
largest client chunks (parsed, gzip, stat sizes plus totals). Output
restores an at-a-glance signal for client-bundle regressions on
release rounds without re-running Lighthouse.
2026-05-16 20:26:59 +02:00
Marc-André Bombeck 30c8555cee refactor(charts): drop the v1.4.33 heatmap classifier-bug fallback
v1.4.33 shipped a defensive fallthrough in `getColor` that detected
the unambiguous "every taken dose landed in veryLate" pattern and
recoloured those cells via the rate-based palette. With the
classifier hardening landed in `compliance.ts` the pattern can no
longer arise from a UTC offset between `takenAt` and the schedule
window, so the workaround is dead code. Replace the long
`looksClassifierBug` block with a single comment pointing at the
root fix and let the timing-based palette run unconditionally when
timing data is available.
2026-05-16 20:25:09 +02:00
Marc-André Bombeck 4984bce577 fix(compliance): route early intakes through the compliant bucket
The medication compliance route and the achievement perfect-day
heuristic both bucket the classifier output into `on_time` / `late` /
`very_late`. After the classifier started emitting `early`, both
paths would have silently dropped those doses into `very_late`
(via the trailing `else`). Treat `early` as compliant alongside
`on_time` so the heatmap stays green and the perfect-day streak
survives a proactive logger. The compliance route also surfaces a
dedicated `early` count on `DailyComplianceEntry` for any future
consumer that wants to break it out from `on_time`.

Updates the cadence-pairing docstring to reflect the widened grace.
2026-05-16 20:24:57 +02:00
Marc-André Bombeck 36d5398c92 fix(compliance): widen grace window and add early bucket
The classifier flushed any intake outside the 1h grace into
`very_late`, so a stored UTC offset between `takenAt` and the
schedule window (e.g. a 06:00 UTC log against an 08:00 UTC window)
flagged a fully-compliant day as orange. Widen the pre-window grace
to 3h, introduce a dedicated `early` bucket for doses logged inside
that 3h pre-window band, and extend the on-time tolerance after
windowEnd to 3h as well. The configurable `lateMinutes` option now
sizes the `late` band past the 3h tail; doses beyond that tail still
fall into `very_late`.

Tests rebuild the boundary matrix with a parameterised offset suite
covering -3.5h .. +6h around the window, exercises overnight
schedules with an `early` case, and pins the `lateMinutes` knob.
2026-05-16 20:24:44 +02:00
Marc-André Bombeck 3d309bb91b feat(import): add ImportJob model for Apple Health export ingest
Additive Prisma model mirroring a `pg-boss` `apple-health-import`
job's lifecycle. The polling endpoint reads `progress` and `result`
live from this row rather than the one-shot terminal `output` column
on `pg_boss.job` (pg-boss v12 only populates `output` on completion).

Carries `pgBossJobId` for the worker's reverse lookup, an upload
sha-256 for content-hash idempotency on re-upload, and per-phase
status (`queued | unpacking | parsing | upserting | done | failed`).
2026-05-16 20:24:31 +02:00
Marc-André Bombeck 2a3fb0e913 chore(deps): add sax for streaming Apple Health XML import
Streaming SAX parser for the v1.4.34 Apple Health export.zip ingest
path. Event-driven SAX keeps peak RSS bounded (~30 MB) regardless of
input size, sidestepping the per-element buffering that fast-xml-parser
hits on multi-year HealthKit exports.
2026-05-16 20:22:59 +02:00
Marc-André Bombeck 72a722664a docs(planning): v1.4.33 closure 2026-05-16 20:01:13 +02:00
Marc-André Bombeck b92f2b1ecc docs(changelog): note the AASA addition for v1.4.33
Records the Apple App Site Association handler + the proxy
`/.well-known/` allowlist entry under the v1.4.33 Added section so the
release notes capture the iOS-foundation work that landed alongside the
polish pass.
2026-05-16 18:57:57 +02:00
Marc-André Bombeck 03b0be18ea feat(ios): serve apple-app-site-association on the app domains
Adds a Next.js Route Handler at `/.well-known/apple-app-site-association`
that answers 200 with `application/json` on every host that fronts the
main app — both `healthlog.bombeck.io` (maintainer prod) and
`demo.healthlog.dev` (demo) share this handler since they ride the same
standalone runtime. The body advertises the iOS bundle's App ID prefix
(`S8WDX4W5KX.dev.healthlog.app`) under `webcredentials.apps` so the
passkey ceremony shares cleanly between the web origin and the iOS app —
the same ceremony that today exists at `/api/auth/passkey/*` on both
domains.

The `applinks.details` array stays empty for now; Universal Links are
out of scope for the first cut and pick up later once the iOS side opts
in to deep-linking specific routes.

Pairs with the proxy `/.well-known/` allowlist entry — without that
bypass the auth gate would 307 Apple's CDN fetch to `/auth/login`
before the handler ran.
2026-05-16 18:57:53 +02:00
Marc-André Bombeck 2acb11f80e feat(auth): admit /.well-known/* without a session
Adds the `/.well-known/` prefix to the proxy's `PUBLIC_PATHS` allowlist so
RFC 8615 discovery endpoints reach a 200 response before the auth gate
runs. Apple in particular reads
`/.well-known/apple-app-site-association` without credentials to wire Web
Credentials (passkey sharing) and Universal Links to the iOS bundle, and
the previous 307 redirect to `/auth/login` broke that fetch on every host
the app serves (`healthlog.bombeck.io` and `demo.healthlog.dev` both).

The trailing-slash entry future-proofs the namespace for `/security.txt`,
`/openid-configuration`, and the other well-known endpoints the IETF
registry covers — adding a new file under `src/app/.well-known/` won't
need a second proxy edit.

A regression test pins the allowlist in place: unauthenticated requests
for the AASA file (and a synthetic `/.well-known/security.txt` peer) must
answer 200 with no location header, while a protected page like
`/insights` still 307s to the auth gate.
2026-05-16 18:57:42 +02:00
Marc-André Bombeck ab1700a843 fix(onboarding): really stop spotlight tour from blocking the quick-add button
The first attempt at the F2 fix (f9b8f3bd) split the spotlight tour
backdrop into four rectangular dim panels around the spotlight rect
and opted each one back into pointer-events: auto with its own
onClick={handleSkip}. The intent was to keep the spotlighted target
clickable while preserving the "tap backdrop to dismiss" affordance.

The flaw: tour-step 1 on the dashboard spotlights the tile-strip,
which sits in the middle of the page. The header "Add" button sits
ABOVE the spotlight. The TOP dim strip (rendered from y=0 down to
where the spotlight begins) therefore covered the entire header
area, so the very target the F2 audit named — the header Add
dropdown — was still being eaten, just by a smaller rectangle.
The four-panel split fixed exactly ONE coordinate (inside the
spotlight rect) and broke every coordinate outside it that a real
user might want to click during the tour: header buttons, the
user-menu avatar, sidebar links, the locale switcher.

This commit ships the contract that Joyride / Shepherd / Intro.js
have all standardised on: the dim is purely visual. The whole
tour layer is pointer-events: none; only the tooltip card opts
back into hit-testing. The four-panel split survives because it
still serves the visual purpose of NOT dimming the spotlighted
target, but each panel is now a <div aria-hidden="true"> with
pointer-events: none — no onClick, no aria-label, no interactive
semantics. The center-placement fallback follows the same shape.
The tooltip's onClick={(e) => e.stopPropagation()} guard came off
because nothing bubbles to the dim any more. Tour state (Skip,
Back, Next) lives entirely on the tooltip's footer buttons,
matching the affordance users expect and keyboard-accessible via
the existing Esc shortcut.

The e2e regression spec at e2e/onboarding-tour-passthrough.spec.ts
gained a third case and tightened the first one. Case 1 now runs
document.elementsFromPoint at the quick-add button's centre, walks
the stack, and asserts that NO element belonging to the tour
overlay has computed pointer-events !== "none". This pinpoints the
regression instead of observing the symptom. Case 2 swapped from
"click a dim strip skips" (an artefact of the failed intermediate
fix) to "the explicit Skip button in the tooltip dismisses the
tour". Case 3 reads computed pointer-events from every
[data-testid="onboarding-tour-dim"] and asserts each is "none" —
the load-bearing invariant the F2 regression violated.

Quality gates: typecheck clean, eslint clean on the touched files,
all 43 onboarding unit tests pass, all 6 F2 e2e cases pass locally
(3 cases x chromium-desktop + chromium-mobile).
2026-05-16 18:42:02 +02:00
Marc-André Bombeck 6ccb7b3c65 docs(planning): release-prep closure report for v1.4.33
PR #178 open in draft state; CI green on lint/typecheck/test +
integration + builds + dependency audit + secret scanning. One
e2e workflow regression on the F2 onboarding-tour-passthrough
spec is documented as a v1.4.34 carry-over — IW8's fix passed
its unit-level integration test but fails its own paired e2e
spec.
2026-05-16 18:21:37 +02:00
Marc-André Bombeck de6bc673e4 Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	CHANGELOG.md
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	package.json
#	src/components/insights/insights-layout-shell.tsx
#	src/components/insights/insights-tab-strip.tsx
#	src/lib/insights/sub-page-metric.ts
2026-05-16 18:14:16 +02:00
Marc-André Bombeck b8f6b13aa5 chore(release): v1.4.33
Polish and reliability release between two HealthKit milestones.
Closes a P0 stack-overflow in `/api/analytics`, defers three
below-the-fold Insights blocks behind `next/dynamic`, slims the
analytics summaries slice, lands a Coach snapshot LRU, and runs a
deep accessibility + settings consolidation sweep.
2026-05-16 18:12:16 +02:00
Marc-André Bombeck 0f3ae822cd docs(changelog): v1.4.33 polish and reliability
P0 analytics-500 hotfix, six per-metric status helper folds,
Insights mother-page next/dynamic deferral, slim analytics slice,
Coach snapshot LRU, Settings consolidation, navigation polish,
extensive accessibility sweep across icon-only buttons, Progress
bars, heading hierarchy, and Coach rail labels.
2026-05-16 18:11:57 +02:00
Marc-André Bombeck e3590e77bb test(insights): align b3-wiring import guard with next/dynamic wrapping
The page now resolves CorrelationRow and TrendsRow through dynamic()
imports rather than top-level `from "..."` statements; the static-source
scan accepts either spelling so a future revert to eager imports still
flags the load-bearing module path.
2026-05-16 18:07:20 +02:00
Marc-André Bombeck da0229911b docs(planning): IW7 implementation report for v1.4.33 2026-05-16 18:03:51 +02:00
Marc-André Bombeck bafac84f8d chore(i18n): retire the now-orphan settings.kiInsights key
The dedicated card H2 was retired in the previous "drop AI prefix"
pass; the `settings.kiInsights` translation key it consumed has no
remaining call-site in source. Drop it from de / en / es / fr / it /
pl. Also update the message-thread test to the new "Insights
provider" copy.
2026-05-16 18:02:59 +02:00
Marc-André Bombeck 81225a764a fix(settings): scroll-snap on the mobile section strip
`no-scrollbar` already suppresses the painted scrollbar so the strip
doesn't look like every settings card has an overflow problem. The
`snap-x snap-mandatory` layer on the strip plus `snap-start` on each
chip means a swipe-flick lands on the next chip's leading edge instead
of the in-between dead zone — the auto-scroll-into-view effect on
mount stays the canonical positioner for the active chip; snap is the
polish layer for manual flicks.
2026-05-16 18:00:38 +02:00
Marc-André Bombeck ac78b422c0 docs(planning): IW9 implementation report for v1.4.33
Eight atomic commits over the polish-and-reliability surface flagged
in `.planning/round-v1433-audit-polish.md` + the Lighthouse-derived
punch list. Covers container normalisation, scroll-reset
consolidation, Card padding parity, Button loader CLS, three a11y
sweeps (progressbar names, icon-button names, heading order),
bfcache-breaker client-side verification, and legal-page width
documentation.
2026-05-16 18:00:32 +02:00
Marc-André Bombeck 0de1e2ebe7 fix(settings): fold the About section into the user-card dropdown
Eleven sections on the settings sidebar was one over the comfort
line. About is three small cards (identity, links, update check) that
collectively get read once or twice a year and didn't earn a top-level
slot. Hide it from the in-shell nav and surface it as "About HealthLog"
in the sidebar user-card dropdown (mobile dropdown mirrors). The
`/settings/about` route stays alive so direct links, the public
`/about` permalink, and the dropdown entry continue to resolve.
2026-05-16 17:59:52 +02:00
Marc-André Bombeck bececda630 docs(planning): IW2 implementation report for v1.4.33
Summarises the shared `useAnalyticsQuery()` hook + 7-consumer
migration, the Insights mother-page below-fold deferral, the
`removeConsole` + `Permissions-Policy: unload=()` bundle hygiene
pass, and the bfcache + query-cache audits.
2026-05-16 17:58:28 +02:00
Marc-André Bombeck 2438dce63d docs(layout): pin the legal-page narrow-column convention in code comments
The privacy + about pages constrain their main column to `max-w-3xl`
(768 px) while the rest of the app uses `max-w-screen-xl` (1280 px).
That's a deliberate readability choice -- long-form legal text is
easier to read at 70-80 chars per line -- but the convention wasn't
documented inline, so a future contributor could see the deviation
from the shell width as a stale token and "fix" it.

Inline rationale comment on both `<main>` blocks per the audit
finding §3.4. No layout change.
2026-05-16 17:58:20 +02:00
Marc-André Bombeck 2620064ef1 fix(a11y): repair non-sequential heading order on three surfaces
Lighthouse v1.4.33 a11y pass flagged "heading elements not in
sequentially descending order" (score 91/94). Three offenders:

- `<TherapyTimeline>` (insights/medikamente sub-page): sr-only
  drug-name heading was an h4, but the parent SubPageShell ships
  the only h1 and CardTitle is a <div>. Promoted to h2 so the
  outline stays h1 -> h2 without skipping levels.
- `<FeedbackInboxDetailSheet>`: metadata + screenshot section
  headings rendered as h4 inside a ResponsiveSheet whose dialog
  title is h2 (Radix default). Demoted to h3 to stay sequential
  beneath the sheet title.
- `<RemindersSection>`: results heading was h4 inside the admin
  SectionFrame's h1. Promoted to h2.

Coach panel headings (IW5 surface) audited but not changed.
2026-05-16 17:57:28 +02:00
Marc-André Bombeck 972c8a56dd fix(nav): drop the redundant "Home" group label from the desktop sidebar
The sidebar has exactly one nav group, and the first entry is already
"Dashboard" pointing to `/`, so the "HOME / Dashboard" pairing used to
read as if there were two separate destinations for the homepage. Drop
the group label and retire the `nav.home` locale key in every bundle.
The collapse toggle keeps its position at the top-right of the strip.
2026-05-16 17:57:21 +02:00
Marc-André Bombeck d254e9cd98 perf(insights): defer below-fold mother-page blocks behind next/dynamic + bundle config hygiene
Lighthouse v1.4.33 flagged ~463 KiB of unused JS on first /insights
paint plus ~211 KiB of bundled JS as unminified. Two coordinated
moves close the gap without touching the Coach panel internals or
the dashboard tree.

next.config.ts
- `compiler.removeConsole` with `exclude: ["error", "warn"]` strips
  the chart-wiring + Coach SSE breadcrumb literals from the prod
  bundle. Error + warn paths still flow through to GlitchTip.
- `headers()` advertises `Permissions-Policy: unload=()` so Chromium
  admits the page to the back/forward cache when navigating away. We
  carry no `beforeunload` / `unload` listeners in-tree so the hint is
  honest.

src/app/insights/page.tsx
- `<DailyBriefing>`, `<CorrelationRow>`, `<TrendsRow>` move behind
  `next/dynamic({ ssr: false, loading: <Skeleton /> })`. All three
  paint below the hero on every viewport and were eagerly imported
  before. Each loader skeleton matches the dimensions of the resolved
  block so layout doesn't shift.
- `<HeroStrip>` stays an eager import — the greeting + health-score
  badge are the visible-first-paint surface.
2026-05-16 17:57:03 +02:00
Marc-André Bombeck 432ebccdae fix(i18n): drop the "KI"/"AI" prefix from user-facing copy
Marc-Voice rule — the product never names itself "KI" or "AI" in
copy. Settings -> "KI-Auswertungen" becomes "Auswertungen" / "Insights",
the inner "KI-Insights" card heading collapses into the parent section
heading, "Aktiver KI-Provider" becomes "Aktiver Provider", and the
admin "AI Quality" view becomes "Insights Quality".

Sweep across de / en / es / fr / it / pl. Provider product names
(Anthropic, Claude, ChatGPT, OpenAI) are unchanged — those are
proper nouns, not the "AI" label.
2026-05-16 17:56:34 +02:00
Marc-André Bombeck 0cac3d3545 fix(a11y): give icon-only buttons accessible names on the medications page
Lighthouse v1.4.33 a11y run flagged "links without discernible name"
(score 91/94). The medications page renders five icon-only buttons
(reset-form dropdown, three copy actions, status dropdown) without
an aria-label, so the SR announcement collapses to the role name.

This patch reuses existing common.* translation keys (no new strings
added; messages/*.json off-limits for IW9):

- the reset / status DropdownMenu triggers get `common.moreOptions`,
- the three copy icon-buttons get `common.copy`.

Icon-only buttons in `src/components/settings/**` (IW4),
`src/components/insights/coach-panel/**` (IW5), and the bottom-nav
already carry aria-labels per earlier polish rounds.
2026-05-16 17:53:29 +02:00
Marc-André Bombeck 2c0d0d2969 docs(planning): IW5 implementation report for v1.4.33
Document the coach-rail heading promotion, mobile-FAB auto-hide on
chart-tooltip activity, and F20 scroll-hang verification.
2026-05-16 17:51:51 +02:00
Marc-André Bombeck b763424b1f fix(a11y): give every Progress bar an accessible name
Lighthouse v1.4.33 a11y run flagged "ARIA progressbar elements missing
accessible names" (score 91/94). The Radix Progress primitive renders
a `role="progressbar"` host element but doesn't synthesise an
`aria-label` from context -- the consumer must supply one. Reusing
the on-screen label keys keeps the SR announcement matched to the
visible caption.

- src/app/insights/medikamente/page.tsx: compliance7/compliance30
- src/app/achievements/page.tsx: criterion meter + summary
  earned-points + next-goal progress
- src/components/medications/medication-card.tsx: compliance7/30
- src/components/medications/glp1-medication-card.tsx: same

`role="progressbar"` divs in `health-score-card.tsx`,
`getting-started-checklist.tsx`, and `OnboardingShell.tsx` already
carry aria-labels -- verified, no change needed.

Refs Lighthouse-derived punch list (v1.4.33 perf audit).
2026-05-16 17:51:24 +02:00
Marc-André Bombeck a5a91e40bc fix(insights): auto-hide mobile Coach FAB while a chart tooltip is open
The Coach launch FAB sits fixed at bottom-right on mobile viewports
and used to overlay any Recharts tooltip painted near the chart's
lower-right corner. Tapping a data point on /insights/blutdruck
opened the tooltip but the FAB covered the value readout, so the
user couldn't read it.

Add a singleton MutationObserver that watches every
.recharts-tooltip-wrapper in the DOM for inline visibility flips
(Recharts toggles `style.visibility: visible` when the tooltip
should paint, and clears it otherwise). A useSyncExternalStore-
backed hook surfaces the live boolean to the FAB, which fades to
opacity-0 + pointer-events-none + aria-hidden while a tooltip is
active. When the user releases / scrolls away and Recharts clears
the wrapper the FAB fades back in.

The observer is module-scoped: it attaches on the first subscriber
and disconnects on the last unmount, so it costs nothing on routes
that don't mount the FAB. SSR snapshot is always false so the
initial server-rendered FAB stays fully interactive before
hydration.

End-to-end auto-hide behaviour belongs to the Playwright mobile
suite (chart hover/long-press → FAB fades); the unit tests pin the
SSR-safe contract and the FAB markup so the regression cannot
silently revert.
2026-05-16 17:50:08 +02:00
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 3a5f7debdb fix(ui): normalise Card defaults to p-4 md:p-6
Pre-v1.4.33 the shadcn Card defaults shipped `py-6` + `px-6` (uniform
24 px every breakpoint) while the dashboard tile-strip + chart cards
used `p-4 md:p-6` (16 px mobile, 24 px md+). Side-by-side on
`/insights` the correlation cards + daily-briefing read as denser
than the chart cards next to them; the visual reads as two card
languages on the same page.

This patch lifts the Card primitive (and its CardHeader / CardContent
/ CardFooter siblings) onto the same `p-4 md:p-6` token. Existing
consumers that paint their own explicit padding continue to win
through the `cn(...)` merge. Tests across `src/components/ui` /
`src/components/insights/__tests__` / `src/components/charts/__tests__`
(400 tests) still green.

Refs .planning/round-v1433-audit-polish.md §4.1 + Win 4.
2026-05-16 17:48:24 +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 bfb1b43af2 fix(insights): promote coach-rail labels to semantic h3 headings
The history and sources rails mount inline next to the message thread
on lg+ / xl+ viewports, where no SheetTitle wrapper covers them.
Both rails previously rendered their section labels as <span> with
uppercase styling, so the drawer's semantic outline disappeared above
the message thread on desktop. Screen-reader users navigating by
heading skipped straight from the drawer's <h2> title to the
message-thread region with no intermediate landmark.

Promote both labels to <h3> while keeping the existing visual
treatment intact. The mobile rail-tray (Sheet-wrapped) still surfaces
its SheetTitle <h2>, so the rail's own <h3> simply nests below it on
small screens. The desktop layout now carries a coherent h2 (drawer)
> h3 (rail) hierarchy.

Regression tests assert that each rail renders its label inside a
real <h3> element so the semantic outline cannot silently drop again.
2026-05-16 17:46:21 +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 6135c32507 docs(planning): v1.4.33 quality-and-reliability audit pass
Five-agent audit pass plus the Playwright runtime sweep against v1.4.32.
The five audit documents land alongside the 58 runtime screenshots and
the runtime-results.json that drove the seven highest-severity
implementation rounds (IW1, IW3, IW4, IW6, IW8) on this branch. Future
contributors can trace each implementation commit back to a finding
without re-running the audits.
2026-05-16 17:40:18 +02:00
Marc-André BombeckandClaude Opus 4.7 f389408169 docs(planning): IW4 implementation report for v1.4.33
Polish + reliability slice of the v1.4.33 audit cycle: DOB date-input
height, timezone aria/label split, F13 username readability, F14
mobile bottom-nav padding, Mood Log overflow + save-button
right-align, Notifications redundancy collapse with Badge-pair
deletion, copy compression on telegram + moodLog + notificationStatus
descriptions, Export card padding parity, passkey table breakpoint
flip from md to lg, settings shell chip-strip scroll-into-view on
route change, F17 threshold-toggle relabel, `<SettingsCardHeader>`
primitive extracted with three call sites migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:38:35 +02:00
Marc-André Bombeck b543988c3a docs(planning): IW3 implementation report for v1.4.33 2026-05-16 17:38:08 +02:00
Marc-André BombeckandClaude Opus 4.7 981a3d55e1 feat(settings): extract <SettingsCardHeader> primitive
Every Settings card paints the same shape — icon + title + optional
status surface + optional muted description — but the codebase
carried five different `flex-*` permutations across
`account-section.tsx`, `integrations-section.tsx`,
`notification-status-card.tsx`, the three notification channel
cards, `api-section.tsx`, and `advanced-section.tsx`. The mechanical
drift table in `.planning/round-v1433-audit-settings.md` §2.5
itemised the spread.

Consolidate the contract into `_card-header.tsx`: icon left, title
right of it, status slot lives top-right and falls below the title
on `<sm`, description sits muted below the title row. Call sites
keep ownership of their body forms and action footers.

First three call sites switched: `telegram-card.tsx`,
`ntfy-card.tsx`, `web-push-card.tsx`. The remaining cards
(`account-section.tsx`, `integrations-section.tsx`, `api-section.tsx`,
`advanced-section.tsx`, `notification-status-card.tsx`) are wired
in a follow-up — the primitive is now available without forcing
every consumer to migrate in one commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:37:16 +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 86aa27e68e docs(planning): IW1 implementation report for v1.4.33
Records the four commits landed on develop (6-helper fold, per-
request memo on `getAssistantFlags`, 60 s LRU on
`buildCoachSnapshot`, slim `?slice=summaries` slice + integration
test) plus the test delta (+114 unit, +3 integration) and the
deferred follow-ups (mislabelled `2d630994` commit, dashboard
wiring, slim slice's anomaly/prior-window stubs).
2026-05-16 17:35:26 +02:00
Marc-André BombeckandClaude Opus 4.7 73043d42d1 fix(settings): tile-padding parity, passkey breakpoint, scroll-into-view, F17 threshold toggle
Five small polish items from the v1.4.33 audit, plus the
maintainer's scroll-into-view item:

* Export-section card padding promoted from `p-5` to `p-6` so the
  Export grid rhythm matches every other Settings card.
* Passkey list flips from a desktop table to a mobile card list at
  `lg` instead of `md`. iPad portrait (768 px) used to sit on the
  `md` inflection and flip-flop between the two layouts on
  rotation; the card layout reads fine on iPad portrait, the table
  needs ~620 px of column width to show the destructive delete
  action without horizontal scroll.
* `<SettingsShell>` mobile chip strip now scrolls its active chip
  into view on route change (`block: "nearest", inline: "center",
  behavior: "smooth"`). On a 393 CSS px viewport the 11-chip strip
  pushes the rightmost four chips below the fold; without the
  scroll-into-view, tapping one of those chips left the strip
  pinned to the leftmost chip on the new page, which read as a
  broken nav. Smooth scroll honours the user's
  `prefers-reduced-motion` via the browser's own auto-detection.
* F17 — `thresholds-editor-section` toggle label anchors on the
  *action* it performs ("Eigene Werte" / "Custom range") instead
  of mirroring the current state ("Auto" / "Überschrieben"). The
  old label read as "Auto is off, why are there no inputs?" when
  the toggle was off and inputs were hidden; the new label
  unambiguously announces "flip me to enter a custom range". The
  `thresholds.sourceOverride` badge still surfaces when the
  override is the active source. Adds
  `thresholds.overrideToggleLabel`.

Locale parity across de, en, es, fr, it, pl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:35:07 +02:00
Marc-André Bombeck a8a5887768 docs(planning): IW8 implementation report for v1.4.33
Records the four landed fixes: F2 (onboarding tour click pass-through),
F14 (bottom-nav defence-in-depth), F19 (web-vitals sampling at 10%),
and the A4 dead-code sweep (AssistantDisabledNotice + settings
placeholder copy). Notes the coordination boundary with IW1, IW3,
IW4, and IW6 plus the regression guard at
e2e/onboarding-tour-passthrough.spec.ts.
2026-05-16 17:34:41 +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 1a101505c4 fix(monitoring): defer web-vitals sample draw to useEffect to satisfy react-hooks/purity
The 10% sampling decision in WebVitalsReporter (v1.4.33 F19) was
computed inline in useMemo(), which the react-hooks/purity lint rule
rejects: Math.random() is impure and the render body must be
idempotent. The original useMemo() happened to be stable in practice
(empty dep array) but the rule fires regardless because the
expression inside the memo factory is impure.

Move the draw into useEffect(() => {}, []) and stash the result in a
useRef so every reportWebVitals callback in the same navigation reads
the same decision. SSR seeds the ref to null, so a pre-hydration
beacon (which would have no rate-limit relief value) is dropped; the
first post-hydration callback finds the ref populated and decides
whether to POST. Bundle and runtime cost unchanged — same one draw
per mount, same all-or-none per page-load. ESLint clean.
2026-05-16 17:33:08 +02:00