Commit Graph
1714 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 c847b80de3 chore(format): trend-card test file prettier sweep
Pure prettier collapse of the up-bad sentiment test JSX. No logic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:10:58 +02:00
Marc-André BombeckandClaude Opus 4.7 badd380272 style(admin): apply prettier to phase-6 reconcile changes
Reflows from the duplicate-h2 / tap-target / expanded-default fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:48:26 +02:00
Marc-André BombeckandClaude Opus 4.7 ef96e877b5 fix(admin): default api-tokens + login-overview routes to expanded
Pre-phase-4b these sections were cards on the long monolithic /admin
scroll, where the expand-to-reveal kept 100+ table rows from
dominating the overview. Post-phase-4b they each own a dedicated
route reachable via the sidebar / mobile strip — the user has already
opted into the topic by navigating there. Landing on a card with only
a header and an "Expand" button reads as "is this page broken?".

Default `expanded = true`; the toggle stays as an escape hatch.

Closes H3 from .planning/phase-6-design-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:45:33 +02:00
Marc-André BombeckandClaude Opus 4.7 499cfad107 fix(a11y): bump /admin/users + /admin/login-overview tap targets to WCAG 2.5.5
The four per-row action buttons on `/admin/users` (role toggle, edit,
reset password, force-logout) and the filter pills on both routes
were sized `h-7` (28px) — well below WCAG 2.5.5 AA's 44×44px target
that the rest of the app already honours (bottom-nav, dashboard
quick-add). Mobile users on a 360px viewport had four 28×28px buttons
crammed into the right edge of every user row.

Adds `min-h-11 min-w-11` to all six button sites. The visual icon-only
look is preserved — only the hit-area expands.

Closes H2 from .planning/phase-6-design-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:44:58 +02:00
Marc-André BombeckandClaude Opus 4.7 bd24f130ee fix(a11y): drop duplicate h2 inside single-section /admin/<slug> routes
Phase 4b's `<SectionFrame>` already paints the canonical h1 + subtitle
for each `/admin/<slug>` route, but every section card kept its v1.4
in-card h2 with the same (or near-same) text. The result was the
section title announcing twice — once as h1, then immediately again as
h2 — both visually redundant and a screen-reader landmark anti-pattern.

Demoting the in-card label to a `<div>` preserves the visual look
(same Tailwind classes) while removing the duplicate heading. The
four sub-cards inside `IntegrationsGroupSection` (umami, glitchtip,
web-push, bug-report) keep their h2 because they live under the
"Integrations" h1 and genuinely deserve a heading level — that is the
non-duplicating case.

Closes H1 from .planning/phase-6-design-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:44:05 +02:00
Marc-André BombeckandClaude Opus 4.7 88c8db1abc refactor(v1.5): apply simplify-review safe-to-apply suggestions
Drops dead `id` props and `scroll-mt-28` classes from admin section
components (load-bearing in v1.4 monolithic admin, dead since the v1.5
phase-4b dynamic-route split). Also removes the unused
`LEGACY_ANCHOR_TO_SLUG` map, the `admin-shell` re-export shim, the
`BERLIN_TZ` and duplicate `MS_PER_DAY` constants, the verbose
`AdminSectionPage` docstring, the `useEffect` paraphrase comment in
`ai-section`, and rewrites the `_exhaustive: never` exhaustiveness
guard to the modern `slug satisfies never` form. `UserManagementSection`
now reads `currentUserId` via `useAuth()` instead of accepting it as a
prop.

Findings 1, 2, 3, 4, 6, 7, 8, 11, 15, 16, 17 from
.planning/phase-6-simplify-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:41:02 +02:00
Marc-André BombeckandClaude Opus 4.7 569300eaca style(ui): use semantic tokens for mood and feedback indicators
Closes the v1.4.6 deferred polish item — feedback / mood / medication
status indicators now ask for *meaning* (success / warning / info)
instead of hard-coding `text-dracula-green` / `bg-dracula-orange/15`.

Adds three CSS aliases to `globals.css`:
  --success → dracula-green   (positive outcome)
  --warning → dracula-orange  (caution / attention)
  --info    → dracula-cyan    (neutral informational)

Dark mode (Dracula): each alias resolves to the same hex as before, so
the visual is pixel-identical to v1.4.6 — what changes is the *intent*
of the class.

Light mode (Alucard): the raw Dracula greens and oranges fail AA
contrast on a white card. The new tokens get higher-contrast
counterparts (#14720a, #a34d14, #036a96) that match the existing
`--destructive` / `--chart-*` light-mode swap.

Migrated callers:
  - `src/components/admin/feedback-inbox-section.tsx`:
      - BUG category badge → `destructive` (semantic — bug *is* broken)
      - "published to GitHub" badge → `success`
  - `src/components/medications/medication-card.tsx`:
      - in-window status → `success`
      - very-overdue status → `warning`
      - "late" intermediate stays on `dracula-yellow` (yellow doesn't
        map to either semantic bucket — it's mid-warning)
  - `src/components/settings/ai-section.tsx`:
      - "ChatGPT connected" badge + success toast → `success`
      - "Connection expired" badge + raw-data warning → `warning`

Charts (`src/components/charts/**`) untouched per
`feedback_charts_visual_identity.md`. Other dracula-token callsites in
settings/* (telegram, ntfy, web-push, account, about, test-connection)
are deferred to v1.5.1 — broader sweep, not in tonight's scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:18:56 +02:00
Marc-André BombeckandClaude Opus 4.7 788c8ad335 feat(settings): allow removing saved AI provider key
Closes the v1.4.6 deferred polish item. When the user has saved a key
for the active provider (OpenAI, Anthropic, or Local), a "Remove saved
key" button now sits next to Save / Test connection. Clicking opens an
AlertDialog confirmation, then PATCHes `{ <provider>Key: null }` — the
existing `/api/user/ai-provider` route already treats null/empty as
"clear" (encrypted column set to NULL).

Provider, model, and base URL stay untouched so users keep their
selection while just rotating the secret. The button hides itself when
no key is stored, so the only state where it shows is exactly the one
where it's actionable.

Insights for the wiped provider fall back to the admin/Codex provider
on the next request — surfaced in the confirm-dialog body so the
behaviour is explicit.

i18n: 4 new keys under `settings.ai.*` (`removeKeyCta`, `keyRemoved`,
`removeKeyConfirmTitle`, `removeKeyConfirmBody`), EN canonical, DE
mirror. Existing `i18n-locale-integrity.test.ts` enforces parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:16:14 +02:00
Marc-André BombeckandClaude Opus 4.7 38f12df737 feat(ui): trend arrow color reflects metric-specific direction sentiment
Restore the colour on the small ↑/↓ trend-arrow next to each tile's
latest reading — but make it metric-aware. v1.4.6 P4 stripped the colour
because the original rule (up=red, down=green) was wrong for half the
metrics: mood up is good, weight up is bad, pulse up is neither. v1.5
phase-5 brings back the colour through a `directionSentiment` prop:

  - `up-good` → ↑ green, ↓ orange    (mood, sleep, steps, BP-in-target%)
  - `up-bad`  → ↑ orange, ↓ green    (weight, BP, body fat)
  - `neutral` → muted in both        (pulse, glucose — range matters
                                       more than direction)

Stable-slope arrows ("→") and "no slope yet" placeholders ("—") always
stay muted. The default is `neutral` so any TrendCard call site that
hasn't been updated keeps the v1.4.6 muted look — back-compat is the
visible intent of the prop.

Affects only the icon next to the latest reading. The chart axes,
lines, colours, and the avg7/avg30 range-colour classes are untouched
(per Marc's hard-rule on chart visual identity, see
`feedback_charts_visual_identity.md` in user memory).

Tests: `src/components/charts/__tests__/trend-card.test.tsx` covers all
three sentiments × all three slope directions plus the
prop-omitted-back-compat case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:14:14 +02:00
Marc-André BombeckandClaude Opus 4.7 987ce0d5f6 fix(a11y): clear axe-core violations on /admin and key sub-routes
Re-enable the /admin axe-core spec that phase-3 parked while phase-4b
restructured the admin into per-section dynamic routes. The fixme is
gone, /admin/system-status and /admin/users are added as representative
sub-route audits, and the violations the original audit surfaced are
fixed:

- `src/components/admin/_shared.tsx`: drop the duplicate
  `<PasswordInput>` whose toggle button had no accessible name.
  Re-export the canonical `<PasswordInput>` from
  `src/components/settings/password-input.tsx` (added in phase-3) which
  already carries the `aria-label="Show password" / "Hide password"`
  pair. Affects `/admin/integrations` (bug-report token, web-push VAPID
  private key) and `/admin/users` (password-reset dialog).
- `src/components/admin/user-management-section.tsx`: icon-only edit /
  reset-password / force-logout buttons now carry `aria-label` (axe
  `button-name` is `serious`); the leading icon is `aria-hidden` so the
  accessible name is just the action verb.
- `src/components/admin/status-card-grid.tsx`: drop the placeholder
  `{ label: "—", value: "" }` tuple from the audit-log card. An empty
  `<dd>` next to a "—" `<dt>` is not navigable and the `<dl>` was the
  most likely source of an axe violation on `/admin`. The 3-column grid
  still works with two metrics; the empty cell at the end is unrendered
  because there is no `<div>` for it.
- `src/components/admin/__tests__/status-card-grid.test.tsx`: rework
  the 3-metric assertion to allow 1..3 metrics with non-empty,
  non-em-dash labels so the audit-log change doesn't regress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:11:01 +02:00
Marc-André BombeckandClaude Opus 4.7 1a7e3d6c2c test(admin): tighten status-card-grid + add per-section render smoke
* `status-card-grid.test.tsx` — replaces the v1.4.6 anchor-id allowlist
  with a route-existence check: every card's href must match `/admin/
  <slug>` AND `slug` must be in the `ADMIN_SECTION_SLUGS` source-of-
  truth, AND `src/app/admin/[section]/page.tsx` must exist on disk.
  This guarantees the dynamic-route file can never be silently moved
  out from under the cards.
* `sections.test.tsx` — SSR smoke test for every section component
  reachable from the dynamic-route renderer. Every section paints a
  recognizable heading, and the `<UserManagementSection>` filter pills
  + `<BackupsSection>` "Backup now" button are asserted so the v1.5
  fold-ins for T2.6 and T2.7 don't regress silently.

Locale parity for the new admin.section.* keys is enforced by the
existing `i18n-locale-integrity` test suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:00:47 +02:00
Marc-André BombeckandClaude Opus 4.7 961e2a9a5c fix(admin): status-card CTAs use real routes (not #anchor)
The v1.4.6 status cards linked to `/admin#section-*` anchors that
required server-side scroll into the monolithic admin page. With the
v1.5 dynamic-route refactor, every section is its own route, so each
href now points at `/admin/<slug>` — fixes the v1.4.6 T6 honesty
problem permanently.

Mapping:
  Users        -> /admin/users
  Integrations -> /admin/integrations
  Monitoring   -> /admin/integrations
  Backups      -> /admin/backups
  Maintenance  -> /admin/reminders
  Audit log    -> /admin/login-overview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:00:18 +02:00
Marc-André BombeckandClaude Opus 4.7 73965cd1ac feat(admin): expandable sidebar nav for admin sections
Adds an "Admin Console" link to the global sidebar (visible only to
ADMIN users). When the active route is under /admin/*, the link
expands into a sub-list of every section, mirroring the in-shell
section sidebar — same as Settings, where the global nav points to
the section landing and the shell drives in-context navigation.

Collapsed-sidebar mode keeps the icon-only Admin button without the
sub-list (consistent with the Bug Report and Settings buttons).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:00:07 +02:00
Marc-André BombeckandClaude Opus 4.7 5957e1827e feat(admin): users management with filters + force-logout action (T2.7 deferred)
Folds in the v1.4.6 deferred backlog item:

* `<UserManagementSection>` now exposes role filters (All / Admins /
  Users — "suspended" is documented as a no-op since the User model
  lacks a `suspended` field; mapped to passthrough for forward
  compatibility).
* New per-row "Force logout" action surfaces a confirmation dialog,
  then POSTs to the new endpoint below. Shows a toast with the
  number of sessions revoked.
* `POST /api/admin/users/[id]/force-logout` — admin-only kill switch
  that deletes every Session row for the target user. Audit-logged
  as `admin.user.force-logout`.

Existing role-toggle, edit, and reset-password actions are untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:59:48 +02:00
Marc-André BombeckandClaude Opus 4.7 69c5225cea feat(admin): backups view + manual backup trigger (T2.6 deferred)
Folds in the v1.4.6 deferred backlog item:

* `GET /api/admin/backups` — returns metadata-only listing of every
  DataBackup row (size in bytes, no payload). Encrypted blobs stay
  server-side.
* `POST /api/admin/backups/run` — enqueues an ad-hoc data-backup
  pg-boss job. Returns 503 if the worker isn't running; the existing
  weekly cron schedule is unchanged.
* `<BackupsSection>` — admin route at /admin/backups with the rows
  table + "Backup now" CTA.

No new dependencies; uses the global pg-boss instance already wired in
`src/lib/jobs/boss-instance.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:59:38 +02:00
Marc-André BombeckandClaude Opus 4.7 12b280bb55 refactor(admin): move sections to dynamic-route pattern + reduce overview
Each admin section component now accepts an optional `id` prop (was
required) so it works under the new /admin/[section] dynamic route
without scroll-anchor wrapping. Adds <IntegrationsGroupSection> as the
composite for /admin/integrations (Umami + GlitchTip + WebPush + Bug
Report). Reduces /admin to a true overview: status-card grid + welcome
card + quick-jump menu of sub-sections; the long monolithic scroll is
gone.

Bundle-size win: each section now ships under its own route bundle, so
the overview no longer drags Umami/GlitchTip/Feedback/Users/etc. trees.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:59:25 +02:00
Marc-André BombeckandClaude Opus 4.7 d8b71b3848 feat(admin): scaffold per-section dynamic routes (admin-shell + [section]/page.tsx)
Mirrors the v1.4 settings split for the v1.5 admin refactor. Adds the
shell, the slug constants, the dynamic-route page, and the client-only
renderer that picks the right section component for each slug. The
existing admin sections continue to live in src/components/admin/*.tsx;
this scaffolding wires them into per-route bundles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:59:08 +02:00
Marc-André BombeckandClaude Opus 4.7 7af1f59bb9 fix(a11y): close violations the new e2e axe-core sweep surfaced
Five real WCAG 2.1 AA violations the extended axe-core spec caught:

- top-bar.tsx: mobile user-menu DropdownMenuTrigger had no
  accessible name on viewports < sm (the username text is
  `hidden sm:inline`, leaving an icon-only button). Added
  aria-label using the existing nav.userMenu key.
- password-input.tsx: show/hide eye toggle was icon-only, no
  aria-label. Added common.showPassword / common.hidePassword
  in en + de and bound to the visible state.
- auth/login/page.tsx: "Sign up" link relied on colour alone
  (link-in-text-block). Switched hover:underline → underline so
  the link is distinguishable without colour.
- page.tsx (dashboard): "Add" dropdown trigger was 32px tall
  (size="sm"); on Pixel 5 below WCAG 2.5.5 44px threshold.
  Added min-h-11 — desktop visual unchanged because the explicit
  height only kicks in if it exceeds the size variant's h-8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:49:34 +02:00
Marc-André BombeckandClaude Opus 4.7 1a85da0b05 fix(insights): preserve chart visual parity in deferred bundle
bb2b1de wrapped seven Recharts primitives (ScatterChart, Scatter, XAxis,
YAxis, CartesianGrid, Tooltip, ResponsiveContainer) in next/dynamic to
push the ~108 KiB Recharts chunk off the /insights initial bundle. That
seam is one level too low: ScatterChart discovers its children by
comparing child.type against the imported component identity
(findAllByType). Once each child is wrapped in a next/dynamic HOC the
identity check fails and the chart renders an empty plot area —
no axes, no scatter points, no tooltip, even after Recharts lands.

Fix: extract the inline <ResponsiveContainer><ScatterChart>…</ScatterChart>
block into src/components/charts/scatter-correlation-chart.tsx, a
self-contained wrapper that statically imports every Recharts symbol it
needs. The insights page now next/dynamics that wrapper instead, matching
the existing HealthChart / MoodChart pattern in the same file. Recharts
still ships only in the deferred chunk; child-type detection keeps
working because primitives stay statically related to each other.

All four scatter cards (BP-medication continuity, mood-vs-BP,
weight-vs-BP, mood-vs-weight) rewritten as declarative
<ScatterCorrelationChart> calls. Identical height={250}, margins, grid
colour (hsl(var(--border))), axis colours (var(--dracula-fg) /
var(--dracula-comment)), per-chart fills (--dracula-orange / --cyan /
--lavender), tooltip styling, units, domains, ticks, labels, and
formatters. No color tokens, animations, or chart options changed.

Also adds a loading skeleton (h-[250px] w-full bg-muted/40 animate-pulse
motion-reduce:animate-none) that matches the chart footprint exactly so
the first paint of each card no longer shifts layout while Recharts
hydrates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:41:49 +02:00
Marc-André BombeckandClaude Opus 4.7 519e36e84f perf(dashboard): skip checklist API fetches once onboarding is complete
GettingStartedChecklist fired four useQuery calls on every dashboard
load even for users long past onboarding (Marc has
onboardingCompletedAt = 2026-02-20 and 1k+ measurements). The component
then short-circuits with `null` once shouldShowChecklist() reports
false, so the network round-trips are wasted.

The analytics + medications queries already share their cache with the
dashboard's own queryKeys, so they remain enabled. The other two —
withings/status (~303 ms) and notifications/preferences (~654 ms) —
are unique to the checklist and now gate on
`user.onboardingCompletedAt == null`. That removes ~950 ms of network
from /` for every established user.

Edge case (returning user with onboardingCompletedAt set but
measurementCount < 5 because they deleted readings post-onboarding) is
preserved by the inline comment + by the fact that the checklist
already renders unknown defaults gracefully.

v1.5 phase-4 perf audit, win #2 (docs/audit/v15-performance.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:23:44 +02:00
Marc-André BombeckandClaude Opus 4.7 512a6a619c fix(admin): wipe-all-data clears notification channels, push subs, telegram deletions
The "Wipe all data" admin transaction was scoped only to measurements,
medications, intakes, API tokens, Withings connections, and auth
challenges. Notification channels (encrypted Telegram bot tokens in
NotificationChannel.config), Web Push subscriptions, and queued
Telegram message deletions survived the wipe — a scope leak first
flagged in the v1.4.6 multi-agent QA pass.

Extends the transaction with `notificationChannel.deleteMany`,
`pushSubscription.deleteMany`, and `telegramScheduledDeletion.deleteMany`.
NotificationPreference rows cascade via the schema FK.

Feedback rows are intentionally preserved (T8 v1.4.6) and the i18n
copy now states this explicitly so admins know bug reports survive a
wipe. Counts for the three new tables are returned to the UI.

Adds an integration regression test that seeds all three tables,
invokes the route handler with a real admin session, and asserts the
wipe clears them while Feedback + AuditLog survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:56:24 +02:00
Marc-André BombeckandClaude Opus 4.7 19eb1d95d6 fix(ai): switch ChatGPT-OAuth to device-code flow (v1.4.7-followup)
v1.4.7 corrected the issuer (`auth.openai.com`) and added the
RFC-8693 api-key exchange, but the Connect button still failed in
production with `unknown_error`. Root cause: OpenAI's Hydra
allow-lists only `http://localhost:1455/auth/callback` and
`:1457/auth/callback` for the public Codex CLI client ID. Any hosted
redirect URI gets silently rejected — the v1.4.7 user landed on the
"An error occurred during authentication (unknown_error)" page.

The official Codex CLI ships a parallel **device-code flow** for
exactly this case (`codex-rs/login/src/device_code_auth.rs`):

  1. POST `https://auth.openai.com/api/accounts/deviceauth/usercode`
     with the client id → `{ device_auth_id, user_code, interval }`.
  2. Show the user the code and a link to
     `https://auth.openai.com/codex/device`. They open it on any
     device, log in, and type the code.
  3. POST `https://auth.openai.com/api/accounts/deviceauth/token`
     repeatedly until Hydra returns `{ authorization_code,
     code_challenge, code_verifier }` (otherwise 403 / 404).
  4. Standard PKCE token exchange against `/oauth/token` — but with
     `redirect_uri = https://auth.openai.com/deviceauth/callback`,
     which IS allow-listed for the public client. Then the same
     id_token → API-key exchange that v1.4.7 already added.

This sidesteps the redirect-URI allow-list entirely; HealthLog's
domain never appears in any OAuth round-trip.

Implementation:

- `src/lib/ai/codex-oauth.ts` adds `requestDeviceCode()` and
  `pollDeviceCode()`.
- `POST /api/auth/codex/device-start` initialises the flow, persists
  the `device_auth_id` + `user_code` in an encrypted httpOnly cookie
  (15 min, scoped to the requesting user), and returns the user-
  facing pieces.
- `POST /api/auth/codex/device-poll` does one poll attempt; on
  `pending` it returns `{ status: "pending" }`, on success it
  exchanges + persists the api key + refresh token and returns
  `{ status: "connected" }`.
- The Settings → KI section replaces the "redirect to OAuth"
  button with an inline card showing the user code, the
  verification URL, and a copy button. The page polls every
  `intervalSeconds` (5s default) until status flips to "connected"
  or the user cancels. EN + DE strings under
  `settings.ai.deviceCode*` cover the new copy.
- The legacy authorize / callback routes stay on disk for now —
  they're harmless when the redirect-URI allow-list is updated for
  a private client ID, so operators with an OpenAI partner client
  can still use the original flow via `CODEX_OAUTH_CLIENT_ID`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:31:30 +02:00
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00
Marc-André BombeckandClaude Opus 4.7 67575180b8 fix(qa): apply pre-release multi-agent QA findings
Three small follow-ups surfaced by the v1.4.6 QA pass:

- Settings → KI: the "raw data" warning still used raw Tailwind orange
  tokens instead of the Dracula `dracula-orange` ones, breaking theme
  consistency and tipping below the WCAG-AA contrast floor on the
  card surface.
- Admin status grid: the Backups card cta read "View backups" but
  jumped to the system-status section (the dedicated backups view
  does not exist yet — that's a v1.5 feature). Re-labelling the cta
  to "Open system status" and the Integrations cta to "Open
  integrations" makes the destination honest. The href targets
  themselves are unchanged.
- `withIdempotency` body-content guard previously did `text.includes("sk-")`
  which silently broke caching for benign payloads containing common
  English compounds like "task-id", "risk-management", "disk-io".
  Tightened to a regex requiring the canonical secret prefix plus a
  realistic suffix length, so only actual API keys trip the guard.
  Three regression tests cover the false-positive cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:22:12 +02:00
Marc-André BombeckandClaude Opus 4.7 26543378b5 fix(admin): surface query errors instead of an infinite spinner
P19. `useSystemStatus()` and `useAdminSettings()` already throw on
non-OK responses so React Query sets `isError`, but neither caller
read it — the system-status panel just kept spinning forever and the
settings sections silently rendered defaults as if they were live.

Now system-status renders a destructive inline alert when isError, and
the admin page top renders a one-line "Failed to load admin settings"
banner when the shared settings query fails so the admin notices the
toggles below aren't reflecting reality. Both messages have new i18n
keys (`admin.systemStatusLoadError`, `admin.adminSettingsLoadError`)
in en.json (canonical) + de.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:10:05 +02:00
Marc-André BombeckandClaude Opus 4.7 86a4b52053 fix(admin): danger-zone result colour driven by mutation state
P18. Replace the brittle "wipeMsg.startsWith(localized prefix)" success
heuristic with React Query's `wipeAllData.isError` flag. The previous
heuristic (a) re-formatted the success copy with empty placeholders to
extract a prefix, (b) string-prefix-matched against the rendered
message, and (c) silently broke whenever the i18n copy changed colon
position or got translated. Now the colour follows the mutation state
directly: error → destructive, success → dracula-green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:09:57 +02:00
Marc-André BombeckandClaude Opus 4.7 505f31844e style(admin): switch feedback-category badges to dracula tokens
P17. The category badges (BUG / FEATURE_REQUEST / QUESTION / OTHER)
were using raw Tailwind palette values (bg-red-500/15 text-red-400 etc.)
which clash with the rest of the app's Dracula tokens. Switch to the
dracula-red / dracula-purple / dracula-cyan equivalents so the inbox
matches the site theme — and dark/light mode parity is consistent
with every other status badge in the admin grid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:09:49 +02:00
Marc-André BombeckandClaude Opus 4.7 89b5b80493 fix(settings/ai): drop unreleased OpenAI presets + i18n the AI section
P15: drop `gpt-5` (unreleased) and `o3-mini` from `MODEL_PRESETS["OPENAI"]`.
gpt-5 would surface as model_not_found from OpenAI; o3-mini uses the
o-series param contract (max_completion_tokens, no temperature override)
which the current OpenAIClient body shape doesn't speak — so picking
either preset just produced a confusing 400. Keep gpt-4o, gpt-4o-mini,
gpt-4-turbo. Power users can still type any model via the Custom… option.

P16: pipe ~30 hardcoded German strings in ai-section.tsx through
`t("settings.ai.…")` and add the matching keys to both messages/en.json
(canonical English) and messages/de.json. Behaviour unchanged — the
Connect/Disconnect/Save/Test/Provider/Model labels and the connect-help
copy now respect the user's locale toggle. The provider-test flow keeps
its own DE strings localized too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:05:28 +02:00
Marc-André BombeckandClaude Opus 4.7 5c884b3d0e style(layout): tighter mobile bottom-nav buffer + muted trend-hint title + drop font-mono on progress
- P7: shrink the bottom-nav clearance from 5rem to 4rem on mobile (md:pb-0 unchanged) so content sits closer to the nav without overlap.
- P8: TrendHint title gets text-muted-foreground so it stops competing with chart titles after the T2 muted-foreground hierarchy fix.
- P9: drop font-mono from the onboarding "X%" progress label; tabular-nums alone is enough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:59:25 +02:00
Marc-André BombeckandClaude Opus 4.7 fda8dd8c47 style(dashboard): trend-card typography + tabular nums + match chart padding + always-show avg chips
- P1: tabular-nums on latest, unit, avg7, avg30 spans so digits stop jiggling on refresh.
- P2: bump value text-2xl -> text-3xl tracking-tight; label text-sm font-medium -> text-xs uppercase tracking-wide.
- P3: tile padding p-3 -> p-4 md:p-6 to align with chart card padding.
- P4: drop direction-tinted arrow color (orange/cyan) — direction-as-good-or-bad is metric-specific, deferred to v1.5+. Both directions now muted-foreground.
- P5: always render avg7/avg30 chips, with em-dash placeholder when null, so vertical rhythm stays consistent across tiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:58:06 +02:00
Marc-André BombeckandClaude Opus 4.7 6a64df0fad feat(charts): bucket > 1y ranges into weekly / monthly averages
Hitting "All" on a long-running account used to render thousands of
daily points — unreadable, and slow on mobile. The chart adapter now
picks an aggregation level based on the visible range:

  - ≤ 90 days  → daily (unchanged)
  - 91-730 days → ISO weekly mean
  - > 730 days  → Berlin-calendar monthly mean

Empty buckets are dropped instead of zero-padding so a sparse user
keeps a real trend instead of a baseline floor. Each metric is
aggregated independently so blood-pressure SYS/DIA pairs stay in sync
without one channel pulling the other to 0 on missing days.

A small chip beside the chart title surfaces the active bucket
("Wochendurchschnitt" / "Monatsdurchschnitt" in DE, "Weekly avg" /
"Monthly avg" in EN) so the user always knows what they're looking at.

The bucketer (`src/lib/charts/bucket-time-series.ts`) is pure and
covered by 14 unit tests including ISO-week boundary handling, the
multi-metric independence guarantee, and the empty-bucket skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:56:07 +02:00
Marc-André BombeckandClaude Opus 4.7 c3ca8614a6 fix(admin): preserve audit trail across the data wipe
Two related corrections to the "Wipe all data" admin action:

- Stop deleting the audit log inside the transaction. The whole point
  of the audit trail is to outlive the data it documents, and this is
  the single feature where that property matters most. AuditLog rows
  now survive the wipe.
- Audit-log the *intent* (`admin.data.clear.start`) before the
  transaction begins, so even if the wipe crashes mid-flight there is
  a record of who initiated it.
- The i18n copy and the success toast both undersold the scope: they
  only mentioned measurements/medications/intakes, but the wipe also
  clears API tokens, Withings connections, and any open auth
  challenges. EN + DE strings now enumerate every cleared category and
  call out that user accounts AND the audit log are preserved.
- Mutation result type widened so the toast can render the full
  count tuple.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:32:29 +02:00
Marc-André BombeckandClaude Opus 4.7 4aeb8c904e fix(admin): point status-card CTAs at real anchor IDs
Every "Manage users" / "View backups" CTA reloaded `/admin` to the top
because the hrefs were pointing at sub-routes that never shipped
(`/admin/users`, `/admin/audit-log`) and at anchors whose IDs do not
exist (`/admin#integrations`, `#monitoring`, `#backups`,
`#maintenance`). The actual section IDs in `src/app/admin/page.tsx`
follow a `section-admin-*` / `section-user-management` pattern.

Six href fixes plus a tightened test: instead of `startsWith("/admin")`
the test now asserts each href is `/admin#section-<known-id>` and that
the anchor is a member of the source-of-truth ID set rendered by the
admin page. Adding a new section requires adding the ID here too —
which is exactly the visibility we want to keep CTAs honest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:28:55 +02:00
Marc-André BombeckandClaude Opus 4.7 a75fbc6aaf fix(insights): primary recommendation parses metric: chart tokens
The base-system prompt invites the model to embed `metric:WEIGHT` /
`metric:BP` etc. tokens in `primaryRecommendation` so the UI can drop
inline charts under the prose. The hero "Key Takeaway" card was the
only renderer printing the field raw — when the model followed the
instructions, the user saw the literal string `metric:WEIGHT` inside
the recommendation. Apply the same `stripChartTokens` + `<InlineCharts>`
pattern that the summary already uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:24:47 +02:00
Marc-André BombeckandClaude Opus 4.7 8aae7d6dfe fix(dashboard): tile fill + muted-foreground hierarchy
Two related visual fixes for the dashboard tile strip and every
secondary string in the app:

- TrendCard now fills its grid cell. As a flex-child the card was
  taking content width, which made tiles in the same row 122-166 px
  wide on a 375 px viewport instead of even shares of the row.
  Adding `w-full` to the wrapping `flex` element gives every tile
  the same horizontal share that the parent grid hands out.
- `--muted-foreground` was identical to `--foreground` in both
  themes, so labels, "Ø7d:" prefixes, units, and inactive nav items
  all rendered at the same brightness as primary text. Dark theme
  now uses `#9aa3b3` (Dracula comment), light uses `#5b6273` —
  WCAG-AA contrast against `--background` and `--card` holds at
  ≥ 4.5:1 in both modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:24:22 +02:00
Marc-André BombeckandClaude Opus 4.7 d438a8520d feat(admin): bug-report toggle, unify "Trennen" button styling
Bucket E+F of the v1.4.3 polish pass.

**Disconnect-button styling unified.** Three integrations had three
different "Trennen" buttons: Withings used `outline` + `text-
destructive`; moodLog used solid `destructive` (red bg, white text);
the AI/Codex card used `ghost` + `text-destructive`. Same action,
three readings — confusing. All three now use the Withings pattern
(`outline` + `text-destructive`). The actual confirmation button
inside the AlertDialog keeps its solid-red treatment so the final
"yes, do it" still reads as a strong commit.

**Bug-report explicit on/off toggle.** Until v1.4.3 the bug-report
feature was implicitly gated on "is the GitHub token + repo
configured". An admin who wanted to *temporarily* hide the report
button (e.g. during a freeze window) had to clear the encrypted
token, forcing re-entry on resume — destructive for a reversible
intent. Migration `0027_bug_report_enabled_toggle` adds
`app_settings.bug_report_enabled BOOLEAN NOT NULL DEFAULT TRUE`,
the admin settings schema accepts `bugReportEnabled`, and a Switch
in the admin → Bug Report card lets the operator flip it.
`POST /api/bugreport` now returns 503 ("Bug reports are disabled by
the administrator") when the flag is off — the client already
renders a "not configured" screen on that status, so the report
button hides gracefully without further client-side wiring.

i18n keys added: `admin.bugReportEnabledLabel` + `…Description` in
both DE and EN.

Tests + typecheck green (671/671).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:45:18 +02:00
Marc-André BombeckandClaude Opus 4.7 dc01ab7934 feat(insights): metric:MOOD inline charts + evidence-contract prompt
Bucket D phase 1 of the v1.4.3 polish pass — the AI insights felt
"static" because (a) findings repeated phrasing with no concrete
numbers and (b) the inline-chart machinery existed but was prompted
permissively, so the model rarely used it.

Two concrete moves, scoped to ship in this hotfix:

1. **`metric:MOOD` is now an allowlisted chart token.** Renderer in
   `<InsightAdvisorCard>` branches on `tokenKind(token)` — measurement
   tokens still mount `<HealthChart>`, but `metric:MOOD` mounts the
   dedicated, self-fetching `<MoodChart>` instead. The mood timeline
   was previously the most under-served by the AI prose because it
   couldn't be charted inline; now the model can illustrate adherence-
   risk and mood-drift findings with the same chart users see on the
   /mood page. Drift test was updated in lockstep — the v1.4.2 guard
   that asserted MOOD was excluded now asserts it's present, plus a
   new guard asserts COMPLIANCE is still excluded (no self-fetching
   wrapper exists yet for `<ComplianceLineChart>`; ship that in v1.5).

2. **Prompt grew an EVIDENCE CONTRACT and a forbidden-phrase list.**
   The base system prompt (DE + EN both) now requires every
   `findings[]` entry to put a concrete number from the snapshot in
   `value` ("138/85 mmHg", "+0.4 mmol/L vs. 30d-avg") instead of
   adjective prose ("slightly elevated"). Findings without a snapshot
   anchor MUST be omitted — variable cardinality 0..8 explicitly
   stated, sorted by salience, NO padding. The forbidden-phrase list
   blocks generic boilerplate that signals ungrounded output: "achte
   auf ausreichend Schlaf", "drink enough water", "regular exercise",
   "consult your doctor" (except in the disclaimer field which exists
   precisely for that line). The chart-token instruction flipped from
   passive ("Du DARFST") to active ("Bevorzuge Tokens immer dann,
   wenn …") with three explicit triggers — temporal claims, threshold
   references, window comparisons — that reliably surface chart-worthy
   findings.

Carved out of v1.4.3 for v1.5: telescoping multi-resolution payload
(14d daily + 10w weekly + 21mo monthly + yearly summary per QA
research 1) and the evidence-ref output schema extension (QA 2).
Both touch the prompt's input shape and would change every cache
key + token bill — they want their own phase.

Tests + typecheck green (671/671 — one new chart-tokens drift
guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:41:29 +02:00
Marc-André BombeckandClaude Opus 4.7 c05f48f520 feat(settings): KI-provider — OpenAI key field, model preset dropdown, gated OAuth
Bucket C of the v1.4.3 polish pass.

Three independent bugs in the KI-provider settings, fixed together
because they all live on the same panel:

1. **Selecting "OpenAI" left no way to provide a key.** The schema
   stored Anthropic and Local API keys, but OPENAI was a valid
   `aiProvider` value with no matching credential column — the
   resolver silently fell through to the admin-level OpenAI key. So
   a self-hosted user who picked OpenAI and typed nothing got the
   admin's key (might not exist), and a user who *wanted* their own
   OpenAI key had no UI surface to enter it.

   Migration `0026_user_ai_openai_key` adds `users.ai_openai_key_
   encrypted`. The resolver's `OPENAI` branch now picks up the
   user-level key first and only falls back to admin if the user
   slot is empty. The settings UI grew an OpenAI-key field gated on
   `provider === "OPENAI"` mirroring the Anthropic flow. The
   `/api/ai/test` override schema accepts `openaiKey` so the
   "Verbindung testen" button can validate an unsaved key without
   persisting plaintext.

2. **Model field was a freetext Input, easy to typo.** Replaced
   with a `<select>` populated from `MODEL_PRESETS` per provider:
   gpt-4o / gpt-4o-mini / gpt-4-turbo / gpt-5 / o3-mini for OPENAI;
   claude-sonnet-4-6 / claude-opus-4-7 / claude-haiku-4-5 / claude-
   3-5-sonnet-latest for ANTHROPIC; llama3.1:8b / llama3.1:70b /
   mistral / qwen2.5 for LOCAL. An "Eigenes…" sentinel still
   surfaces the freetext Input so power users can target preview
   models or fine-tunes. The dropdown disables when no provider is
   chosen — saves the user from filling a model that won't be used.

3. **"Mit ChatGPT verbinden" silently bounced to chatgpt.com login
   with no OAuth grant.** The authorize URL was built without a
   `client_id` parameter, so chatgpt.com couldn't recognise the
   request as an OAuth handshake — it just rendered the regular
   login form. The user came back, nothing connected, the button
   "did nothing".

   Added `CODEX_OAUTH_CLIENT_ID` env var. The URL builder now
   throws `CodexOAuthNotConfiguredError` if it's unset; the
   authorize route maps that to a 503 with an actionable message
   instead of dispatching the dead URL. The settings card reads
   `codexOauthConfigured` from `/api/insights/settings` and hides
   the Connect button when the flag is false, replacing it with a
   tiny italicised "ChatGPT-OAuth ist auf dieser Instanz nicht
   konfiguriert — nutze stattdessen einen eigenen API-Key unten."
   `CHATGPT_OAUTH` was also removed as a manually-selectable
   provider option in the dropdown — the top connect button is the
   path; having both confused the affordance.

Refresh + token-exchange both forward the client_id now too. Test
fixture seeds `process.env.CODEX_OAUTH_CLIENT_ID="test_client_id"`
in `beforeEach` so the existing URL-shape assertion still works,
plus a new test asserts the unset case throws.

Tests + typecheck green (670/670 — one new assertion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:36:36 +02:00
Marc-André BombeckandClaude Opus 4.7 622099c0fd fix(settings): About + API surfaces — restructure, fix update check, fit tables
Bucket B of the v1.4.3 polish pass.

Three issues, one commit because they're the same surface:

1. **API & Tokens tables overflowed the settings content column.**
   Each table set `min-w-[760px]` (or 860px) so the table never
   compressed below that width. On mobile this is intentional (it
   gives the inner cells breathing room while the outer wrapper
   horizontally scrolls). On md+ the parent column is smaller than
   the min-width on common laptop sizings (~800px content area when
   the browser isn't full-width), which forced an inline scrollbar
   for no benefit. Switched to `min-w-[760px] md:min-w-0` per table
   so the constraint releases at the breakpoint where the layout
   stops scrolling.

2. **"Auf Updates prüfen" button silently did nothing.** The
   client-side handler called `https://api.github.com/.../releases/
   latest` directly. Production CSP locks `connect-src` down to
   `'self'` + `https://wbsapi.withings.net`, so the fetch was
   blocked at the browser before it left the tab. Replaced with a
   server-side proxy at `/api/version/check-updates` that fetches
   GitHub for the user, compares the running `package.json` version
   against `tag_name`, and returns one of `up_to_date | newer_
   available | unknown`. The route is auth-gated (cookie OR bearer)
   so anonymous traffic can't burn through GitHub's 60-req/hr anon
   rate limit on the deployment's IP. The route also annotates the
   external call into the Wide Event so a future spike is visible
   in the logs.

3. **The About surface had no headings and a stray Badge box.** The
   v1.4.2 version put the License inside a `<Badge variant=
   "outline">`, which created a small framed pill that read as a
   separate widget rather than a peer of the version number. Below
   that, the Sources/Changelog/Docs and the update-check button
   each lived in their own card with no h2 — three nameless cards
   stacked on the page. Restructured into three titled cards:

     - **HealthLog** — identity, with version + license rendered
       inline as plain monospace pairs (no Badge, no nested grid).
       Build SHA + build timestamp ride along on the same row when
       set.
     - **Quellen & Dokumentation / Sources & docs** — links unchanged
       but now under their own h2.
     - **Updates** — manual `Check now` button + a passive "Last
       checked: …" line that pulls from localStorage so the user
       can see when the previous check ran. On mount, if the last
       successful check is >24h ago (or has never run), the page
       auto-checks once. The 24h window is a deliberate compromise
       between "always fresh" and "GitHub anon rate limit". The
       error path renders a non-blocking "Couldn't reach GitHub"
       message with the alert role so screen readers announce it.

i18n keys touched in both `de.json` + `en.json`:
  - `settings.about.linksHeading` (new)
  - `settings.about.updatesHeading` (new)
  - `settings.about.checkUpdates` (label shortened)
  - `settings.about.checking` (button busy state — new)
  - `settings.about.lastChecked` / `lastCheckedNever` (new)
  - `settings.about.checkFailed` (new — replaces the misleading
    `testConnection.errors.connection_failed` reuse)

Tests updated for the new heading + button strings; 669/669 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:03 +02:00
Marc-André BombeckandClaude Opus 4.7 8c0593fe79 feat(dashboard): single BP tile, equal-height tile strip, settings dashboard/thresholds split
Bucket A of the v1.4.3 polish pass.

Tile-strip width drifted past the chart-strip width by 16px on >=md
because the strip used `-mx-2 px-2` to bleed two pixels past the
parent for a snap-scroll feel. The bleed is real on mobile — the
horizontal-scroll affordance only reads as a scroll if the first/last
tiles touch the viewport edge — but on tablet/desktop where the row
fits without scrolling it just made the tile group visibly wider than
the charts below. Now `md:mx-0 md:px-0` drops the bleed once charts
fit inline, so the two strips share the same content box.

Tile heights varied because the blood-pressure tile rendered as two
stacked TrendCards (sys + dia) inside one tile slot while every
other tile was a single TrendCard. The cleaner fix is one TrendCard
that displays the paired value as `117/79` — TrendCard now accepts an
optional `secondary: { latest, avg7, avg30 }` and the dashboard pushes
ONE BP entry instead of a Fragment of two. Tile count drops from 6
to 5, the strip stops wrapping awkwardly, and the BP card carries
the systolic sparkline + traffic-light tooltip while the diastolic
value rides along under the same heading. Each tile wrapper got
`flex` and the card itself `flex h-full flex-col`, so flex-stretch
inside the strip can do its job and the avg7/avg30 row pins to the
bottom.

Settings nav split: the section formerly titled "Übersicht" was
renamed to "Dashboard" (the term every user already knows from the
main nav), and the embedded Persönliche Zielwerte panel moved out
into its own top-level settings section under `/settings/thresholds`.
Two reasons: the panel is heavy (one card per metric) and was
visually buried behind the layout customizer, and "Übersicht" leaked
internal vocabulary — neither the route slug nor the surrounding nav
entries used it. Tests guarding slug order + section count updated
in lockstep.

Touched:
  - messages/de.json, messages/en.json
  - src/app/page.tsx (dashboard tile strip)
  - src/app/settings/[section]/page.tsx (route wiring)
  - src/components/charts/trend-card.tsx (secondary-metric prop)
  - src/components/settings/section-slugs.ts
  - src/components/settings/settings-shell.tsx (new entry + icon)
  - src/components/settings/section-placeholder.tsx
  - src/components/settings/dashboard-section.tsx (drop ThresholdsSection)
  - src/components/settings/thresholds-settings-section.tsx (new)
  - src/components/settings/__tests__/{sections,settings-shell}.test.tsx

Tests + typecheck green (669/669).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:24:30 +02:00
Marc-André BombeckandClaude Opus 4.7 58e9d1451a fix(dashboard): unwrap shared queryFn responses in onboarding checklist
GettingStartedChecklist intentionally shares TanStack Query cache
with the dashboard via `queryKey: ["analytics"]` etc., but its
queryFns returned the raw `res.json()` (the full `{data, error}`
envelope) while the dashboard, medications page, notifications page
and withings integrations section all return `json.data` (the
unwrapped inner shape). When the checklist's queryFn populated the
cache first, the dashboard's `data?.summaries?.WEIGHT` ended up
reading a property of the envelope `{data, error}` instead of the
analytics body, came back undefined, and silently failed every
per-tile data gate (`hasWeight`, `hasBp`, `hasPulse`, `hasBodyFat`).
Marc saw only the mood tile because mood lives under a different
cache key (`["mood-analytics"]`).

Same envelope/shape mismatch existed for `["medications"]`,
`["withings", "status"]` and `["notifications", "preferences"]` —
fixed in one pass since the failure mode is identical (silent
undefined reads on shared keys, latent bug for any consumer that
mounts after the checklist).

Each queryFn now `await`s and returns `json.data`; the local
accessors collapse from `res?.data?.X` to `res?.X`. As a
side-effect this also fixes a long-standing checklist bug where
`medsRes.data.medications.length` was always 0 (the medications
API returns the array directly under `data:`, not nested under a
`medications` field — so the count silently coerced to zero and
the "log a medication" checklist item never auto-completed).

Tests + typecheck stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:28:41 +02:00
Marc-André Bombeck 0810a859e1 fix(ui): defensive optional chains on all remaining var?.x.y sites
Marc reported many residual TypeError crashes after the targeted fix
in 680b001. A comprehensive perl regex sweep across src/app and
src/components turned up 19 more occurrences of the same anti-
pattern: `var?.x.y` only protects the outer object, so when the
inner field is undefined the access throws.

Touched files:
  - src/app/api/dashboard/summary/route.ts (server-side null guards)
  - src/app/api/health/route.ts (session check)
  - src/components/admin/feedback-inbox-section.tsx
  - src/components/medications/medication-card.tsx (compliance rates)
  - src/components/medications/medication-form.tsx
  - src/components/medications/intake-history-list.tsx
  - src/components/measurements/measurement-list.tsx
  - src/components/mood/mood-list.tsx
  - src/components/ui/password-strength.tsx

Pattern applied: `var?.outer.inner` → `var?.outer?.inner`. Tests
green, typecheck clean. Lint untouched (the rule we already enforce
catches set-state-in-effect, not optional-chain depth).
2026-05-08 18:47:00 +02:00
Marc-André Bombeck 680b0019c1 fix(ui): defensive optional chains across summaries/medications/preferences/entries/measurements
Same anti-pattern as the v1.4.2 hotfix on /dashboard and /insights —
the optional chain protected only the outer object, so any field on
the inner object that didn't exist threw at access time. Marc's
reports flagged dashboard, insights, medications,
notification-preferences, admin login overview, mood list, and
measurement list. Applied 'data?.X?.Y' to every spot the regex
caught.
2026-05-08 18:36:13 +02:00
Marc-André BombeckandMarc-André Bombeck 643e6ec3a0 release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"

This reverts commit e444a90b10.

* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4

Three production bugs surfaced after the v1.4.0 deploy stabilized:

1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
   on /dashboard and /insights for users without weight readings —
   the optional chain stopped at `data` when it needed to also cover
   `data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).

2. `TypeError: y?.filter is not a function` on /medications — the
   medications query value was non-array (likely a stale
   service-worker response from an older API shape). Replaced with
   `Array.isArray(medications) ? medications : []` so the page is
   immune to any future shape drift.

3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
   so 1.4.2 is the single coherent release). The docker-compose
   healthcheck used `localhost` which busybox-wget resolves to IPv6
   ::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
   to 127.0.0.1.

This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:46:31 +02:00
Marc-André Bombeck e444a90b10 revert: roll source tree back to v1.4.0 to restore production
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.

This commit resets the working tree to commit 21bd46d (v1.4.0
release). Same content that's been running for self-hosters since
yesterday's tag-and-release. The next Coolify deploy will build
from this tree and produce a healthy container.

The v1.4.1 work is NOT lost:
  - PRs #144, #145, #137, #146, #147, #148, #149, #150 remain in
    git history.
  - Their commits are still tagged (`v1.4.1`), still on the GHCR
    multi-arch image (`ghcr.io/mbombeck/healthlog:1.4.1`), still in
    the GitHub Release notes.
  - Self-hosters who have already pulled the v1.4.1 image keep it.
  - Local development continues from main HEAD with the v1.4.1
    code — the regression only surfaced under the Coolify build
    flow.

Re-applying v1.4.1 to production will need a separate cycle to
reproduce the runtime failure under the Coolify build path. That
work is tracked in docs/ops/v141-followup-issues.md (added back
when the tree is reapplied) and the deploy gating in
.github/workflows/e2e.yml will catch this class of bug going
forward.

No DB migration. No env-var change. No API contract change.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:15:34 +02:00
Marc-André Bombeck 6da94280b5 feat(admin): extract per-section components from admin page monolith (#145)
The admin page was a single 2,702-line client component with
nineteen inline functions covering everything from system status
to feedback inbox. The status-card grid foundation in #133 already
moved the at-a-glance dashboard out; this commit moves the inner
panels.

Split into one file per logical section in src/components/admin/:

  _shared.tsx                         276 LOC  shared types, hooks,
                                                primitives (PasswordInput,
                                                StatusItem, SettingsToggle,
                                                useAdminSettings,
                                                useUpdateSettings,
                                                useSystemStatus,
                                                getApiErrorMessage)
  system-status-section.tsx           155
  general-settings-section.tsx         54
  services-section.tsx                 68
  umami-section.tsx                   173
  glitchtip-section.tsx               172
  web-push-vapid-section.tsx          148
  bug-report-section.tsx              109
  reminders-section.tsx               318
  user-management-section.tsx         341
  api-token-overview-section.tsx      144
  login-overview-section.tsx          181
  danger-zone-section.tsx             118
  feedback-inbox-section.tsx          511 (inbox + category badge
                                            + detail dialog)

src/app/admin/page.tsx is now a 77-line shell that imports the
14 sections and the existing status-card-grid.tsx. No behavior
change — every section keeps the same DOM, the same query keys,
the same i18n keys, the same id="…" anchors so deep-links from
the sidebar still scroll into place. UserManagementSection no
longer takes a queryClient prop; it grabs its own via
useQueryClient() like every other section does.
2026-05-08 15:58:06 +02:00
Marc-André Bombeck 65e86e4a53 feat(settings): extract all six remaining sections — content lands at /settings/<slug> (#143)
Replace the SectionPlaceholder fallbacks for Account, AI, Integrations,
Notifications, Dashboard, API, Advanced, and About with real per-section
components. Every /settings/<slug> URL now renders working content; the
placeholder only fires for unknown slugs.

A2-content extraction
- Account: profile + passkeys + password change (account-section.tsx)
- AI: insights + per-user provider override (ai-section.tsx)
- Integrations: Withings + moodLog (integrations-section.tsx)
- Notifications: Telegram + ntfy + Web Push (split into telegram-card,
  ntfy-card, web-push-card to keep each file under ~600 LOC)
- Dashboard: layout + thresholds wrappers (dashboard-section.tsx)
- API: endpoints + tokens (api-section.tsx)
- Advanced: export + danger zone (advanced-section.tsx)
- About (greenfield): version, license, build SHA, repo/changelog/docs
  links + "Check for updates" against GitHub releases

A2-about
- Reads /api/version via TanStack Query, surfaces app version, AGPL-3.0
  badge, short build SHA, build timestamp, repo/changelog/docs links.
- "Check for updates" button compares against api.github.com latest
  release tag with up-to-date / newer-available / failure feedback.

A8-UI test buttons
- Shared TestConnectionButton primitive that POSTs to a test endpoint,
  surfaces ok-with-latency or translated meta.errorCode failures.
- Wired into Withings + moodLog (Integrations) and Web Push
  (Notifications). Glitchtip + Umami remain admin-only.

i18n
- New settings.about.* and settings.testConnection.* key groups in en.json
  and de.json.

Cleanup
- Delete src/app/settings/page.legacy.tsx (3230 LOC monolith).
- Convert sync-from-server patterns to the React-recommended
  prev-payload-id render-time pattern so the strict
  react-hooks/set-state-in-effect rule passes without a file-level disable.

Tests
- New src/components/settings/__tests__/sections.test.tsx with 9 SSR smoke
  tests (one per section) — total suite 514 (was 505).
2026-05-08 14:52:04 +02:00
Marc-André BombeckandClaude Opus 4.7 3221f86eea feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account (#139)
* feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account

Splits the 3218-LOC /settings monolith into a routing surface only — no
section content moves in this PR. Each of the eight slugs (account,
integrations, notifications, dashboard, ai, api, advanced, about) now lives
at its own /settings/[section] route and renders inside a new
<SettingsShell> sidebar+main layout. Every section currently shows a
placeholder ("Moving here with the next release update") so reviewers can
validate the URL structure in isolation. The historic monolith is preserved
verbatim as page.legacy.tsx for section-by-section extraction by the
follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
A2-notifications, A2-rest).

Behaviour change: existing entry points (sidebar nav, topbar dropdown,
onboarding hand-off, dashboard checklist, Withings callback, Codex
callback) now point at /settings/<slug> instead of /settings#anchor. The
/settings root is a 308 (permanentRedirect) to /settings/account so old
bookmarks and external links keep working.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): route every Codex OAuth callback redirect to /settings/integrations

Review-pass H1: only the late success/failure paths in the Codex OAuth
callback were updated to point at the new section route. The four early
branches — rate-limited, provider-supplied `?error=...`, missing params,
invalid state — still redirected to `/settings?codex_error=...`.

After the section split that path 308-redirects to `/settings/account`,
where the Codex error toast/banner doesn't live. Users hitting any of
those edge cases would land on the wrong screen with the error code
silently lost.

All six branches now go to `/settings/integrations?codex_error=...`,
matching the success and token-exchange-failure paths and the existing
Withings callback shape.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:15:15 +02:00
Marc-André BombeckandClaude Opus 4.7 ddab92e42b feat(insights): inline charts in AI findings via allowlisted metric tokens (#138)
* feat(insights): inline charts in AI findings via allowlisted metric tokens

Lets the AI insight advisor inline a Recharts panel directly under the
prose paragraph it talks about — e.g. a finding centred on systolic BP
renders the BP chart immediately beneath itself, instead of forcing the
user to scroll back to the dashboard charts.

Tokens are routed through a server-side allowlist:
- 13 fixed strings (one per MeasurementType plus MOOD and COMPLIANCE).
- A drift test fails if a new MeasurementType is added without a token.
- The greedy `[A-Z_]+` regex caps the parse so trailing junk like
  `metric:WEIGHT' onclick='alert(1)'` cleaves cleanly into the safe
  metric and inert React text — there is no markdown / no eval path.
- Hallucinated tokens (`metric:NUKE`) are silently dropped.
- The visible prose is run through `stripChartTokens` so the literal
  token never reaches the DOM.

Prompts (base + 7 domain prompts, EN + DE) tell the model it may embed
exactly one of the allowlisted tokens inside `summary` or a finding's
`label`/`guideline` — nothing else has any effect.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(insights): drop metric:MOOD and metric:COMPLIANCE until dedicated charts exist

Review-pass M1: those two tokens routed through HealthChart, which
validates `type` against the `MeasurementType` Zod enum. MOOD and
COMPLIANCE aren't in the enum, so the request 422'd and the chart
panel rendered "no data" — silently degrading the most-promoted UX
path under prose that explicitly talked about the user's mood or
adherence.

Allowlist + every prompt now lists only the 11 tokens that actually
render a real chart. The drift test inverts the previous "must
include MOOD/COMPLIANCE" assertion to "must NOT include them",
locking the contract until dedicated MoodChart / ComplianceChart
components ship.

494 tests green, typecheck clean.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:09:27 +02:00
Marc-André BombeckandMarc-André Bombeck 725cace865 feat(admin): status-first card grid + aggregator endpoint (#133)
Replaces the implicit, fluid status of the legacy admin monolith with a
6-card grid at the top of /admin per ui-guidelines.md §3.4. Each card
pairs a §2.6 colored dot with a text label so state never relies on
color alone (WCAG 1.4.1).

- New aggregator endpoint /api/admin/status-overview returns a single
  batched Promise.all (no N+1) covering Users, Integrations, Monitoring,
  Backups, Maintenance, Audit log — with severity precomputed server-side.
- New <StatusCardGrid> + reusable <StatusBadge> component in
  src/components/admin/. Pure buildCards() function makes severity
  mapping unit-testable.
- Tests: 14 new (8 aggregator severity/shape, 6 grid badge + a11y).
- Wires the grid above the existing admin sections; per-section
  extraction will land in follow-ups.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:47:21 +02:00