Commit Graph
200 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 4df6dac036 fix(charts): "7-Tage-Trend" label + numeric trend indicator on every chart incl. mood
Marc reported v1.4.15 A4 didn't fully land:
- DE label still read "7-Tage-Schnitt" / "7T-Schnitt" in places
- mood tile showed no numeric trend delta even though other tiles did

i18n rename:
- de.json + en.json: trend7dShort, movingAverage7d, moodMA all read
  "7-Tage-Trend" / "7-day trend". The legacy "7T:" / "7d:" header on
  the avg7 line is preserved for tiles that haven't been migrated to
  the trend7Delta API.

trend-delta fallback:
- summaryToTrend7Delta() now falls back to slope30 (projected onto
  7 days) when slope7 is null. Mood entries are typically logged
  less than daily, so slope7 needs ≥ 2 points within the trailing
  7-day window — for sparser users that came back null and the tile
  silently dropped the delta indicator. slope30 still answers "is
  this metric drifting?" so we project that onto the 7-day window
  for label parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:30:42 +02:00
Marc-André BombeckandClaude Opus 4.7 577d8dd29f fix(insights): BD-Zielbereich computes correctly for real measurement data
The BD-Zielbereich tile rendered 0% for users whose readings sat below
the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65).
Marc's actual production data has many readings like 117/79 — textbook
normotensive, well-controlled — that v1.4.15 A4 still counted as OUT
of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs
should be in-target by clinical interpretation; v1.4.15 reported 0.

Root cause: "in target" was defined as the narrow goal band, which is
the ESH treatment goal for hypertensive patients ON therapy — NOT the
desirable range for a normotensive HealthLog user. Users (and the
dashboard label) intuitively mean "BP is well-controlled = at or below
the ceiling, not implausibly hypotensive".

Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <=
diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50
floors guard against counting symptomatic hypotension as "good control".
Centralised the check in `isBpReadingInTarget()` so the 6 historical
call sites (analytics route, blood-pressure-status, features,
general-status, insights/cards, insights/comprehensive, insights/targets)
share one definition — pre-fix they had 5 independent inline copies of
the narrow-band formula, easy to drift on a future refactor.

Tests:
- 6 new unit tests pin the new ceiling+floor semantics including Marc's
  exact production fixture (10 paired readings -> 50% under v1.4.16,
  was 0% under v1.4.15).
- New `tests/integration/bp-in-target.test.ts` seeds Postgres
  testcontainer with Marc's reading shape + null-tolerance + hypotension
  edge cases.

Cache invalidation already correct: `measurementDependentKeys` includes
`analytics()` so adding a BP measurement invalidates the tile's
TanStack Query cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:28:54 +02:00
Marc-André BombeckandClaude Opus 4.7 277a5aa0e7 fix(admin): api-tokens table no horizontal overflow on Pixel-5 viewport
v1.4.15 A2 hid columns + tightened card padding, but Marc still saw a
scrollbar on prod at /admin/api-tokens on Pixel-5 (393 CSS px). The
fundamental issue is that even with three columns visible, long
permission lists or token names could push the scroll wrapper wide
inside the card. Mirror the `<UserManagementSection>` pattern instead:
gate the desktop `<table>` behind `hidden md:block` and render a real
mobile card-list at <md.

The mobile card-list:
- One <li> per token, padding p-3, no horizontal scroll wrapper.
- Token name uses `truncate break-all` so even a hex-suffixed name
  wraps within card width.
- Permission badges sit in a `flex-wrap` row.
- Last-used + Created collapse to short meta lines (no nowrap).
- Status badge floats next to the name on the same flex-wrap row.

Tests:
- `api-token-overview-responsive.test.tsx` rewritten to assert the new
  invariants: hidden md:block on the desktop table; md:hidden on the
  card-list <ul> with `data-testid="admin-tokens-mobile-list"`; no
  overflow-x-auto inside the mobile container; meta paragraphs wrap.
- New e2e spec `admin-api-tokens-mobile.spec.ts` mocks a worst-case
  payload (long username, multiple permissions, long token name) and
  asserts `documentElement.scrollWidth <= window.innerWidth + 1` at the
  Pixel-5 viewport on /admin/api-tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:25:34 +02:00
Marc-André BombeckandClaude Opus 4.7 77fe256d9e fix(nav): admin sub-items don't expand from gravatar dropdown or admin-link click
Mirror the Settings entry pattern verbatim: the global sidebar's Admin
entry is now a single link with no sub-item expansion at any route.
v1.4.15 A1 had gated the expansion behind `onAdminPage`, but Marc
re-flagged it — the in-shell `<AdminShell>` already provides per-section
nav inside the page, so the global sidebar duplicating it on `/admin/*`
was unwanted UX. Removing the entire `{onAdminPage && <ul>...</ul>}`
block also kills the perceived link between the gravatar dropdown and
sidebar state — they're independent concerns now.

Tests updated:
- `sidebar-nav.test.tsx` — every admin-route case now asserts ONLY the
  single `/admin` link is present, never sub-route links.
- New e2e spec `sidebar-admin-no-expand.spec.ts` covers (a) /admin/* shows
  one link, no sub-list; (b) clicking Admin from /dashboard navigates
  without flashing sub-items; (c) opening the gravatar user-menu does
  not expand sidebar admin sub-items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:24:41 +02:00
Marc-André BombeckandClaude Opus 4.7 4dcaa08b42 chore(release): v1.4.15
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry
covering backup completeness, achievements UI, onboarding tour,
doctor-report v2, auto-deploy on GHCR push, empty-states sweep,
notification + integration status UI, AI hallucination guardrails,
mobile UX fixes, and the BD-Zielbereich percentage fix.

Includes the formatting drift left over by the v1.4.15 development
phases (Prettier sweep on touched files) and a typecheck fix in
`dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`, `pnpm test`, and `pnpm test:integration` all
pass green on the release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:27:13 +02:00
Marc-André BombeckandClaude Opus 4.7 d94756389f style(v1.4.15): prettier sweep on phase-D reconcile touched files
Class-order + line-length normalisation on tour.tsx, mock-client.ts,
and codex-slug-fallback.test.ts after the focus-trap / strict-schema /
inspect-helper-removal commits. No functional changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:17:17 +02:00
Marc-André BombeckandClaude Opus 4.7 79bb167333 fix(ai): MockAIProvider DEFAULT_RESPONSE conforms to v1.4.15 strict schema
Phase D senior-dev review HIGH H4: the mock's default response carried
the legacy v1.4.14 rich shape (classification, findings, correlations,
dataQuality, disclaimer). The v1.4.15 strict schema requires
{summary, recommendations, citations, warnings}. The default passed
only because of `aiInsightResponseSchema.passthrough()`; once v1.4.16
retires the passthrough, every test using the default mock would
silently start failing schema validation before its asserts ran.

Switch to a strict-schema-conformant default. Tests needing the
legacy shape opt in via `responses: [...]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:16:33 +02:00
Marc-André BombeckandClaude Opus 4.7 66d2e0773a fix(a11y): break long Withings error strings inside status banner
Phase D design review HIGH H5: Withings refresh-token failures can
arrive as 250+ char URL-encoded JSON bodies. Without `break-words`
the inline span pushed the parent card past viewport width on mobile,
causing horizontal scroll on the Settings -> Integrations page during
a degraded state. Add `min-w-0 break-words` to the error span so
long error text wraps within the banner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:16:07 +02:00
Marc-André BombeckandClaude Opus 4.7 646541245d fix(a11y): trap focus + restore + ring on onboarding tour
Phase D design review HIGH cluster (H1, H2, H3) on the v1.4.15 B5
onboarding spotlight tour:

- H1: tab cycles inside the tooltip's three actions (Skip/Back/Next),
  shift+tab honoured; on close the previously-focused element regains
  focus so Settings -> Account "Restart" returns the caret correctly
- H2: tooltip card gains `max-h-[80vh] overflow-y-auto` so DE copy
  with longer translations can scroll inside the card without ejecting
  the Next/Done button below the fold on small viewports
- H3: backdrop button gets `focus-visible:ring-2 ring-primary` so
  keyboard users see a visible focus signal when the tour is active

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:15:50 +02:00
Marc-André BombeckandClaude Opus 4.7 cd3b890067 refactor(v1.4.15): apply simplify-review safe suggestions
Drop dead exports + misleading comments + unreachable defaults flagged
by phase-D simplify review (autonomous-yes items only):

- channel-state: drop unused TEST_CONSTANTS re-export
- retry-policy: drop unused RejectKind type alias
- achievements: drop misleading "fallback" comment on exhaustive switch
- notification-status-card: drop unreachable default in stateBadgeFor
- withings: export isWithingsRefreshReauthFailure + dedupe inline
  classifier in /api/withings/status route
- codex-slug-cache: drop test-only inspectCodexSlugCache helper; tests
  read getCachedCodexSlug() instead

Findings 1, 2, 8, 9 deferred to v1.4.16 (require product call).
Finding 11 is an explicit anti-finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:14:43 +02:00
Marc-André BombeckandClaude Opus 4.7 4bba951121 feat(ai): fallback-chain slug discovery with 1h positive cache
Phase C1 (v1.4.15) — slug-drift defence per Marc's hard mandate:
"Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht
sein." ("Slug-drift risk must NEVER happen.")

Background: the ChatGPT-Codex backend rotates its allow-list silently.
v1.4.7..v1.4.13 lost a week to chasing the rotation manually
(`gpt-5-codex` → `gpt-5.3-codex`). A single hard-coded `CODEX_MODEL`
flips invalid the moment upstream rotates it.

Solution — the client now walks an ordered fallback chain on every
fresh request series and caches the first slug that returns 200 +
`response.completed` for 1 hour. Subsequent requests skip the chain
walk entirely on a cache hit.

Spec: extends `docs/codex-protocol-spec.md` with new §7b documenting
the chain semantics, cache TTL, walk triggers (400 + "not supported
when using Codex with a ChatGPT account" / `model_not_found` /
"does not exist" / 404), non-walk responses (5xx, 429, 401-with-
refresh, `invalid_prompt`), and the all-failed structured-503 error
shape. Per Marc's v1.4.6 hard rule ("read source-of-truth before
iterating; no more 6-iteration line-fixes"), the spec was extended
FIRST and the implementation cross-referenced against it.

Implementation:

  - New `src/lib/ai/codex-slug-cache.ts` — process-local positive
    cache. Single-slot Map (per-account scoping is a one-line change
    when v1.4.16 multi-tenant rolls out). 1 h TTL. `getCachedCodexSlug`
    auto-evicts expired entries on read.
  - Rewritten `CodexClient.generateCompletion()` — walks the chain
    `[cached?, ...slugChain]` in order. On 401 → refresh + retry SAME
    slug (auth ≠ slug-state). On 400/404 with rejection body → drop
    cache, walk to next. On 5xx/429 → propagate immediately (walking
    won't help). On all-rejected → throw structured 503 with
    `attempted[]` list, `httpStatus: 503`, message "AI provider
    unreachable — all configured Codex slugs were rejected".
  - `CodexAttemptDiagnostics` exposes `{ attempted, cacheState,
    workingSlug }` for Wide-Event annotations the route can read via
    `client.getLastDiagnostics()`.
  - `CODEX_MODEL_FALLBACK_CHAIN` env override (comma-separated). When
    `CODEX_MODEL` is also set, it folds into chain position 0; the
    default chain (`gpt-5.3-codex` → `gpt-5-codex` → `gpt-5` →
    `gpt-4o`) survives behind it. Stable de-dup.

Tests: +20 across 2 new files.

  `codex-slug-cache.test.ts` (7 tests) — TTL-edge boundaries,
  invalidation, replacement semantics.

  `codex-slug-fallback.test.ts` (13 tests) — cold-cache happy path,
  walk on canonical 400 body, walk on `model_not_found`, walk on 404,
  no-walk on 500/429/401, all-rejected → 503-with-attempted-list,
  cache-hit shortcut, cache invalidation on rejection, isSlugRejection
  body classifier, loadFallbackChain env-pin merge.

The 5 existing `codex-client.test.ts` cases continue to pass without
edit — the new `slugChain` constructor parameter is opt-in and the
single-slug behaviour reduces to chain length 1.

Tests: 1028 → 1048 unit pass (+20). Typecheck clean for AI files.
Lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:55:38 +02:00
Marc-André BombeckandClaude Opus 4.7 65faf1d156 feat(insights,dashboard): empty states for first-run views
Closes the last two surfaces from the C5 audit.

- /insights — Top-level "no data" path replaces a bare "No data
  available" sentence with EmptyState (TrendingUp icon) + "Add a
  measurement" CTA into /measurements. The BMI section's
  height-not-set fallback gets a compact plain-variant EmptyState
  (Ruler icon) + "Open account settings" CTA instead of a single
  grey line that looked like a bug.
- / (dashboard) — When trendCards.length === 0 AND charts.length ===
  0 (a brand-new account with no data and the Getting-started
  checklist already dismissed) the dashboard rendered an empty
  0-px tile strip below the welcome banner. Now paints an
  EmptyState with the same quick-entry dialog wired to the CTA so
  the user can log their first measurement without leaving the page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:53:05 +02:00
Marc-André BombeckandClaude Opus 4.7 1d65f3b925 feat(lists): empty states for measurements, mood, medications, achievements (impl)
Follow-up to 9a74f8e — that commit's pathspec didn't pick up the
implementation files, only the new test. This commit lands the actual
EmptyState wiring described in the previous message:

- src/components/measurements/measurement-list.tsx — Activity icon,
  filter-aware empty state, optional onAddFirst CTA prop.
- src/components/mood/mood-list.tsx — Smile icon, mirroring filter
  semantics + optional onAddFirst.
- src/app/measurements/page.tsx + src/app/mood/page.tsx — pass
  onAddFirst so the empty-state CTA opens the same dialog as the
  header button.
- src/app/medications/page.tsx — refactor inline icon+text+button
  block to the EmptyState primitive (Pill icon).
- src/app/achievements/page.tsx — Trophy icon for the no-achievements
  state, reusing the existing noneUnlockedYet string.

Together with 9a74f8e the four feature lists now share a consistent
empty-state surface with the rest of the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:51:48 +02:00
Marc-André BombeckandClaude Opus 4.7 9a74f8e8f2 feat(lists): empty states for measurements, mood, medications, achievements
Wires the EmptyState primitive into the four feature lists. Each path
now exposes an icon + localized title + description + (where it
applies) a CTA bound to the parent dialog so brand-new accounts get a
single-click path to logging their first datapoint.

- /measurements — Activity icon, "Add your first measurement" CTA
  wired to the page's Add dialog. Filter-aware: switches to "no
  measurements match this filter" with a "Show all types" reset
  whenever a non-default type filter is active.
- /mood — Smile icon, "Log your first mood" CTA, identical
  filter-aware split for the mood-level filter.
- /medications — Refactor inline icon+text+button block to the shared
  primitive. Pill icon, "Add first medication" CTA → openCreate.
- /achievements — Trophy icon. Reuses the existing
  achievements.noneUnlockedYet string to keep C4 i18n sweep work
  minimal.

New unit test (measurement-list-empty) asserts the primitive mounts,
the Add-first CTA respects the optional onAddFirst prop (no orphaned
button when the parent doesn't wire it), and the reset-filter CTA
stays out of the default-filter render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:51:10 +02:00
Marc-André BombeckandClaude Opus 4.7 4e85c38c4b feat(ai): scope-hardened system prompt with refusal pattern
Phase C1 (v1.4.15) — adds the v1.4.15 baseline scope-hardened prompt.

New file `src/lib/ai/prompts/insight-generator.ts` exports
`getStrictInsightsSystemPrompt(locale)` and a stable `PROMPT_VERSION`
tag (`4.15.0`) so future iterations ratchet the prompt without
breaking deterministic tests. The prompt's load-bearing instructions:

  - Role: clinical-context summariser ONLY. Explicitly forbids
    diagnosis, prescription and out-of-scope answers.
  - Out-of-scope refusal pattern: when the snapshot is empty or off-
    topic, the model returns a fixed JSON refusal payload (also
    exported as `OUT_OF_SCOPE_REFUSAL_EN/DE` so tests can pin it
    against the strict response schema).
  - Zero-hallucination guard: every recommendation MUST cite a
    specific snapshot field via `metricSource`; missing → omit.
  - Citation cross-check: every recommendation's metricSource must
    also appear in `citations[]` (enforced by the schema parser).
  - Generic guideline targets only: ESH/ESC 2024 < 140/90 mmHg, AASM
    ≥ 7 h sleep, Saint-Maurice 2020 ≥ 8 000 steps; explicitly
    forbids citing WHO as the source for a step number.
  - Doctor-consult call-to-action required for every actionable
    recommendation.
  - JSON-only output, no markdown fences.

The prompt is NOT yet wired into `/api/insights/generate`. The route
continues to consume the rich legacy schema (`{summary, classification,
findings, correlations, dataQuality, ...}`) which the existing
`<InsightAdvisorCard />` and `<InsightsCard />` UIs render. The
integration step is scheduled for v1.4.16 alongside the UI migration —
see `docs/audit/v1416-ai-roadmap.md` (lands in the next commit of this
phase).

Tests: +18 (1005 → 1023 unit). Pin both locale variants for
diagnosis/prescription forbiddance, citation cross-check instruction,
ESH/ESC 140/90 generic guidance, doctor-consult CTA, fabricate-no-
measurements rule, and out-of-scope refusal pattern. The refusal
payloads are also fed through the wrapper to prove the model can
return them verbatim and the wrapper passes them through cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:50:56 +02:00
Marc-André BombeckandClaude Opus 4.7 0c20119c14 feat(admin): empty states for users, backups, login-overview, api-tokens, feedback
Replaces blank rectangles in 6 admin sub-routes with the EmptyState
primitive. Each surface now shows an icon, localized title +
description, and (where it makes sense) a primary action so a fresh
admin always knows what to do next.

- /admin/users — Users icon, filter-aware copy, "Show all users" reset
  CTA when a filter is active.
- /admin/backups — Database icon, "Backup now" duplicate CTA inside the
  empty card so the action is right next to the explanation.
- /admin/login-overview — ScrollText icon, splits "no auth events" from
  the safer "no failed sign-ins"; reset CTA on the failed-only filter.
- /admin/api-tokens — Key icon, copy points to native client sign-in
  and Settings -> Account where tokens are issued.
- /admin/feedback — Inbox icon, compact-size, per-tab description.
- /admin overview audit-preview — compact + plain so it nests inside
  the parent card without doubling the dashed border.

New unit tests for the users + backups paths assert the primitive
mounts (role=status / dashed border), the localized strings render,
and the reset/CTA actions appear only in the right contexts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:48:36 +02:00
Marc-André BombeckandClaude Opus 4.7 d657f798d0 feat(ai): enforce citation-from-data on every recommendation
Phase C1 (v1.4.15) — citation-from-data hard requirement.

Marc, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf
medizinische Dinge."  ("It must ground on medical facts / user data.")

Builds on the strict response schema (`src/lib/ai/schema.ts`) and
schema-enforced wrapper (`src/lib/ai/generate-insight.ts`) — which were
swept into sibling commit 5510ed5 by a parallel-agent index race
(documented across A2 / A4 / B1-B6 / C2 / C3 of the v1.4.15 marathon;
diff is correct on main, message scope misleading).

This commit adds a focused 11-test invariant suite that nails down the
contract end-to-end:

  1. Schema-level: recommendation without metricSource fails parse;
     empty metricSource.summary fails; missing type or timeRange fails.
  2. Cross-check: findUncitedRecommendations() flags any recommendation
     whose (type, timeRange) does not appear in citations[]. Partial
     match (type alone) is rejected — both fields must align.
  3. Wrapper end-to-end: orphan-citation triggers retry; second attempt
     either fixes the citation OR drops the recommendation (both
     valid per the retry-correction message; model is explicitly
     told an empty recommendations[] is acceptable).

The two-pronged guard (schema + cross-check) ensures the model cannot
fabricate a recommendation that points at "nothing" AND cannot cite
data it hasn't surfaced — closing the v1.4.16 medical-grounding work's
prerequisite.

Tests: +11 (988 → 999 unit). All AI tests green (67 / 67).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:47:37 +02:00
Marc-André BombeckandClaude Opus 4.7 5510ed529b docs(audit): v1.4.15 empty-states audit + i18n keys
Walks every list/table/tile a brand-new user hits and records the empty
path. 13 surfaces flagged for upgrade across admin, feature pages,
insights and dashboard. Adds matching EN+DE i18n keys
(emptyTitle/emptyDescription/CTA copy) under measurements, mood,
medications, admin.section.users/backups, admin login-overview/api-tokens,
admin.feedback, insights, dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:45:52 +02:00
Marc-André BombeckandClaude Opus 4.7 01a10de5d8 test(i18n): non-empty + non-placeholder parity assertion
Phase C4 audit found EN/DE locale files in perfect parity (1817 keys
each, 0 drift, 0 empty values, 0 TODO/FIXME placeholders) — the five
B-agents and the C2/C3 phases each shipped their own translations
inline.

This commit hardens the parity test to lock that state in:
- empty-string assertion (per locale, fails on `"key": ""`)
- key-equals-value assertion (fails when EN==DE==key.lastSegment, with
  an explicit allow-list for 5 legitimate cases: `settings.ntfy`,
  `classifications.{bp.Optimal,bp.Normal,pulse.Normal,bodyFat.Fitness}`)
- TODO/FIXME/XXX/TBD assertion (per locale)

EN-only key=value cases (e.g. `"of": "of"` where DE = `"von"`) are
deliberately NOT flagged — those are natural, since key names derive
from the EN word. The dual-locale check catches the real placeholder
pattern (`"bugReport": "bugReport"` in both locales) without false
positives.

Audit report: docs/audit/v1415-i18n-coverage.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:43:27 +02:00
Marc-André BombeckandClaude Opus 4.7 27310e4f9a refactor(ai): consolidate providers behind AIProvider interface
Phase C1 (v1.4.15) — provider abstraction review.

Adds `MockAIProvider` (`src/lib/ai/mock-client.ts`) so contract tests
can exercise the route-level interface without burning network calls
on every CI run. The mock satisfies `AIProvider`, lets tests queue a
list of canned responses, and records every `CompletionParams`
invocation for later assertion.

Adds a contract test file (`provider-contract.test.ts`) that walks
every concrete provider — Codex, OpenAI, Anthropic, Local, Mock — and
asserts each one returns a `CompletionResult` with the four
contract-required fields (`content`, `tokensUsed`, `model`,
`providerType`). Network is stubbed per provider; the test exists to
catch interface drift, not to retest each provider's wire format.

The route at `/api/insights/generate` already imports the abstract
`AIProvider` via `resolveProvider()`, so the consumer side needs no
change — this commit ratchets the contract with explicit tests so
future provider additions (medical-grounding, multi-provider redundancy
in v1.4.16) cannot silently regress the interface.

Tests: +7 (132 → 139 ai-domain). 972 / 972 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:42:22 +02:00
Marc-André BombeckandClaude Opus 4.7 28467b2590 feat(doctor-report): practice name on cover page (persisted as user preference)
Marc wanted the addressee visible at first glance on the printout —
"Praxis Dr. Müller" should be the first thing the GP sees, so the
report doubles as a polite opening line.

- New optional free-text field "Praxis / clinic name" in the export
  dialog. The dialog pre-fills it with the user's last-used value so
  recurring exports don't require re-typing.
- Persisted as a user preference: new `User.lastReportPracticeName`
  column (migration `0031_user_last_report_practice_name`). The value
  is updated only on a successful POST that supplied a non-empty
  string — passing `null` does NOT clear the stored preference so the
  value sticks across years of follow-ups. Best-effort write: a DB
  failure here MUST NOT break the report (caught + ignored).
- `sanitisePracticeName()` enforces a strict envelope upstream: strips
  ASCII C0 + DEL controls (which jsPDF can't render), collapses
  whitespace, and hard-caps at 120 characters. Defends the PDF layout
  from runaway input + protects the PDF text stream from
  control-character corruption.
- PDF cover prints the line in 11pt bold above the separator when set;
  omitted entirely when `null` so the cover stays compact for
  self-archive use. Both client (`doctor-report-pdf.ts`) and server
  (`doctor-report-pdf-core.ts`) renderers carry the change so the
  visual output matches whichever path the user takes (browser
  download vs iOS Safari fallback).
- `/api/auth/me` now echoes `lastReportPracticeName` so the dialog
  pre-fills without an extra round-trip.
- Audit-log + Wide-Event annotations record `practiceNameProvided:
  boolean` (NOT the value itself — practice names are personal and we
  already store one canonical copy on the user record).

i18n: 6 new keys (`doctorReport.practiceLabel`, `doctorReport.dialog.{practiceLabel,practicePlaceholder}`)
in EN + DE.

Tests: 12 new unit cases covering sanitisation (whitespace, controls,
length cap, type guard), PDF cover rendering (DE label "Praxis:", EN
"Practice:", omission when null), API persistence (prisma.user.update
called with the right shape, audit detail flag set). 965 / 965 unit
pass, 31 / 31 integration pass. Lint 0 errors. Typecheck clean for B6
files (pre-existing dashboard-layout.test.ts errors are A4 territory).
v1.4.15 phase B6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:36:47 +02:00
Marc-André BombeckandClaude Opus 4.7 d692119454 feat(doctor-report): configurable date range with default last-90-days
The PDF used to render a fixed 90-day window. Marc wants the period
selectable so chronic-condition follow-ups can pull a longer history and
acute issues a shorter one.

- New `<DoctorReportDialog>` (Radix Dialog + native `<input type="date">`)
  prompts the user before the request fires. Defaults: end=today,
  start=today-90d. Inline validation: end >= start, span <= 2 years.
  Three convenience presets (90d / 6mo / 12mo).
- `normaliseDateRange()` (in `doctor-report-data.ts`) parses
  `{ startDate?, endDate?, days? }` and falls back to last-90-days for
  invalid input — silent fallback rather than 422 so a malformed retry
  still yields a useful report.
- `collectDoctorReportData()` accepts a `DoctorReportRange` and filters
  measurements / intake events / mood entries with both `gte` AND `lte`,
  so a custom window (not just "last N days") is honoured.
- The PDF cover ("Auswertungszeitraum: 01.02.2026 — 09.05.2026") now
  reads from `period.start` / `period.end` instead of the legacy
  (since, now()) calculation. Old `period.since` field preserved for
  backwards compatibility.
- Audit-log + Wide-Event annotations now record `startDate`/`endDate`
  alongside `days` so a forensic replay can reconstruct the requested
  window.

Tests: 18 new unit tests in `doctor-report-data.test.ts` (range parse,
fallback ladder, edge cases), 1 new test in `doctor-report-pdf-core`
(period rendering on cover), 2 new in pdf-route (range + invalid range),
13 new dialog i18n + module-export contract tests. Total +34 unit cases
(955 -> 955 with the suite reorg). Typecheck clean for B6 files; lint
clean (existing dashboard-layout test errors are A4 territory). v1.4.15
phase B6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:34:28 +02:00
Marc-André BombeckandClaude Opus 4.7 fa1c6a62ca feat(settings): allow user to replay the onboarding tour
v1.4.15 Phase B5 — fourth (and final) commit. Closes acceptance
criterion 4.

Adds a Tour-replay card to Settings → Account, between Password and
the password dialog. The card:

  - Calls `POST /api/onboarding/tour` with `{ completed: false }`
    so the server flips `users.onboarding_tour_completed` back to
    false. Idempotent: same DB write either direction; no audit
    log noise on the reset.
  - Refetches `/api/auth/me` so any in-memory <TourLauncher>
    re-evaluates with the fresh flag.
  - Dispatches a `healthlog:tour-restart` window event so a
    launcher already mounted on the dashboard (e.g. user has a
    dashboard tab open in another window) reopens the spotlight
    immediately, without waiting for the user to navigate. The
    launcher's listener clears the session-dismiss flag too.
  - Surfaces a confirmation message via the same status pattern as
    every other settings save (success / error / null).

i18n: reuses the `onboarding.tour.restart`,
`onboarding.tour.restartHint`, `onboarding.tour.restartConfirmation`
keys that landed in commit 2/4 (EN + DE). No new strings.

SSR test in `sections.test.tsx` locks the contract: the Restart
button is present in the static markup and no raw `onboarding.tour.*`
key leaks past the i18n layer.

Tests: my 40 unit tests pass (was 39 — +1 for the new SSR
assertion). The 12 unrelated failures observed in the full suite
all live in uncommitted B6 doctor-report work in the working tree
(`useEffect not defined` in advanced-section.tsx, missing
`lastReportPracticeName` in unmigrated DB schema fixtures, etc.).
Lint clean for B5 files; the single B6 lint error in
`doctor-report-dialog.tsx` is out of scope.

End-to-end shape:

  Phase B5 acceptance criteria:
    [x] DB migration 0030 + Prisma schema flag
    [x] Tour component (state machine + spotlight + i18n + a11y)
    [x] Auto-launch on first dashboard load
    [x] Settings → Account → Restart tour button

This is the last commit in the B5 batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:31:38 +02:00
Marc-André BombeckandClaude Opus 4.7 8215e25876 feat(onboarding): auto-launch tour for new users on first dashboard load
v1.4.15 Phase B5 — third commit. Wires the spotlight tour
(`<OnboardingTour>` from previous commit) to auto-fire for new users
the first time they land on the dashboard, persists completion to
the DB, and exposes a window-event hook for replay.

Pieces:

  - `<TourLauncher>` (`src/components/onboarding/tour-launcher.tsx`)
    — gates and mounts the tour. Self-decides via:
      * `user.onboardingTourCompleted` (DB flag from migration 0030)
      * sessionStorage dismiss guard (defensive against in-flight
        `/api/auth/me` refetches re-opening the tour after Skip)
      * post-wizard 1500ms grace window (avoids the wizard's
        `doneToast` and the spotlight stacking on the same z-layer)
      * a `ready` prop the dashboard sets once analytics has
        resolved (so the spotlight cutout doesn't snap to a 0×0
        placeholder before tiles render)
    Listens for `healthlog:tour-restart` to force-reopen.

  - `POST /api/onboarding/tour` — single endpoint with body
    `{ completed: boolean, outcome?: "completed" | "skipped" }`.
    Flips `users.onboarding_tour_completed`. Dispatches a Wide
    Event annotation with the outcome so Loki can split completion
    from dismiss without a second column.

  - `/api/auth/me` now returns `onboardingTourCompleted`. The
    `AuthUser` type in `src/hooks/use-auth.ts` gains the field;
    no consumer outside the launcher reads it today.

  - `data-tour-id` attributes added to:
      * dashboard tile-strip wrapper (`dashboard-tile-strip`)
      * dashboard "Add" dropdown trigger (`dashboard-quick-add`)
      * sidebar nav items (`nav-insights`, `nav-settings`,
        `nav-achievements`, plus the rest for symmetry —
        targeted attribute, not class names, so renames in
        Tailwind classes don't silently break the spotlight)

  - The `/onboarding` wizard's `persistAndExit()` writes
    `sessionStorage["healthlog-tour-referrer"] = "1"` immediately
    before navigating to `/`. The launcher consumes-and-clears that
    on first mount via a `useState` lazy initialiser (presence-only
    flag — no `Date.now()` so the render-phase decision stays pure
    under `react-hooks/purity`).

ESLint compliance:

  - `react-hooks/set-state-in-effect` — no synchronous setState in
    a useEffect body. The launch decision happens in the render
    phase using the React-recommended "previous input id" guard
    (mirrors `account-section.tsx`'s `seededUserId`), and the
    post-wizard timer's setState lives inside a setTimeout
    callback (an event handler, not the effect body).
  - `react-hooks/purity` — render-phase setState only branches on
    prop values + sessionStorage-presence helpers. No `Date.now()`
    / `Math.random()` / fetch in render.

Tests: my 39 unit tests pass (was 27 — +12 across tour-state and
tour SSR shape). Pre-existing test failures in
`doctor-report-data.test.ts` are uncommitted B6 work in the
working tree, not B5. Lint clean for B5 files; one pre-existing
B6 lint error in `doctor-report-dialog.tsx` is out of scope.
Typecheck has the same pre-existing `dashboard-layout.test.ts`
errors documented in STATE.md (A4 / phase-D scope).

Restart UX (Settings → Account → "Restart tour") lands in the next
commit (B5-4) so this commit stays orthogonal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:28:51 +02:00
Marc-André BombeckandClaude Opus 4.7 81f5019b92 feat(dashboard): RecentAchievementsCard component + tests (b4 follow-up)
Adds the actual `<RecentAchievementsCard>` component referenced by the
dashboard wiring in the previous commit. The previous commit's `-o`
pathspec scope accidentally dropped these new untracked files; without
them the dashboard import resolves to an empty module and breaks the
build. Splitting into two commits per `verification-before-completion`
rather than amending so the parallel-agent push race can't lose the
landing.

The card itself: TanStack Query against
`/api/gamification/achievements`, `pickRecentUnlocks(achievements, 3)`
to slice + sort by completedAt descending, locked entries dropped.
Empty state painted with a CTA + link to `/achievements`.

Test file: `pickRecentUnlocks` ordering / cap / no-date fallback +
empty-state CTA render + three-most-recent render shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:21:48 +02:00
Marc-André BombeckandClaude Opus 4.7 a242047765 feat(dashboard): recent achievements card with toggle in layout settings
Small dashboard card surfacing the user's three most-recently unlocked
achievements (sorted by `completedAt` descending). When nothing is
unlocked yet, the card paints a discovery CTA + link to /achievements
so brand-new users learn the feature exists. Reuses
`/api/gamification/achievements` — TanStack Query dedupes when both
this card and the dedicated page mount in the same session.

Layout integration mirrors the existing widget pattern: new
`achievements` widget id in `DashboardWidgetId`, default order 13
(below the chart row, matching the brief), `tileVisible: false`
because there is no tile surface for this widget. Visible by default
(small footprint, self-handles the empty state). Toggleable from
Settings → Dashboard via the existing `<DashboardLayoutSection>`
machinery — added the `achievements: "achievements.title"` entry to
`WIDGET_LABEL_KEYS`. Other-agent prettier-formatting changes for
table-header `<span>` wrapping + Tailwind class ordering in the same
file are folded in (the working tree had them when I started; they
match prettier's expected output).

Tests: +6 unit (`pickRecentUnlocks` ordering / cap / no-date fallback,
empty-state CTA, three-most-recent render shape). 922 / 922 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:21:26 +02:00
Marc-André BombeckandClaude Opus 4.7 ad350fe68e feat(deploy): admin notification + audit log on deploy success/failure
New endpoint `POST /api/internal/deploy-webhook` closes the loop on the
auto-deploy work started in the previous commit. Coolify (configured via
Settings → Notifications → Webhook with the `X-Deploy-Webhook-Secret`
custom header) calls this URL after every deploy attempt; we:

  * Verify the request via `DEPLOY_WEBHOOK_SECRET` env var with a
    timing-safe compare (same pattern as the Withings webhook).
  * Rate-limit by client IP (60/min — comfortably above Coolify's
    typical 1-2 callbacks per deploy, well below a brute-force probe).
  * Normalise Coolify's free-form payload into a small struct
    (`outcome`, `applicationName`, `error`, etc.) — preserving the
    full JSON in the audit row so future Coolify schema drift is
    forward-compatible without code change.
  * Write an audit-log row with one of `system.deploy.{success,failure,unknown}`
    — `unknown` covers in-progress states like "queued" so we observe
    them without paging.
  * On `failure`, fan a `SYSTEM_ALERT` through the existing
    `dispatchNotification()` to every admin user (silent no-op if no
    Telegram channel configured — matches `maybeAlertAdmins()` pattern
    in `src/lib/integrations/status.ts`).

Also exports a `GET` handler so Coolify's "Test" button on the
notification config page returns 200 with a valid secret.

Tests: 12 unit cases covering auth (header missing / wrong / env var
unset), happy path (success skips alert), failure path (audit + 2
admin alerts), unknown-status path (no alert), no-admin warning,
rate-limit short-circuit, malformed status type, and forward-compat
preservation of unknown payload fields. 952 / 952 unit pass total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:20:56 +02:00
Marc-André BombeckandClaude Opus 4.7 e57fc0a8da feat(onboarding): tour component with spotlight + keyboard navigation
v1.4.15 Phase B5 — second commit. Adds the spotlight-tour overlay
that walks new users through 5 dashboard features after they finish
the wizard at /onboarding:

  1. Tile strip            (BP / weight / pulse / mood)
  2. Quick-add menu
  3. Insights              (AI-assisted summaries)
  4. Settings → Integrations
  5. Achievements          (omittable via prop if B4 hasn't shipped)

Architecture:

  - `src/lib/onboarding/tour-state.ts` — pure state machine. Steps,
    navigation helpers (`nextStep`, `prevStep`, `skipTour`),
    completion outcome (`"completed" | "skipped"`). DOM-free so the
    Node-environment vitest setup can exercise it directly. 13 unit
    tests cover step list, navigation, idempotency once finished,
    and the 1-based step counter.

  - `src/components/onboarding/tour.tsx` — UI layer. In-house
    spotlight (no new dependency — package.json had no
    react-joyride / intro.js / shepherd already, and the surface
    area didn't justify a 30 KB add). Renders a fixed full-viewport
    overlay with a polygon `clip-path` cutout aligned to the live
    target, plus a tooltip card next to it. Targets are looked up
    via the stable `data-tour-id` attribute (NOT class names — the
    contract has to survive Tailwind churn). Re-measures on resize
    and scroll. Falls back to a centred-screen tooltip if the
    target is not in the DOM (mobile breakpoint, conditional
    render) so the tour never blocks indefinitely.

  - i18n: 19 new keys under `onboarding.tour.*` (EN + DE), one
    title + body per stop plus `next`/`back`/`skip`/`done`/
    `restart`/`stepOf` strings. The component test asserts the
    German strings resolve end-to-end via `<I18nProvider initialLocale="de">`.

Accessibility:

  - `role="dialog"` + `aria-modal="true"` + `aria-labelledby`,
    polite live region announces step transitions, focus moves to
    the primary action on every step.
  - Keyboard: Esc = skip, ArrowRight / Enter = next, ArrowLeft =
    back. Inputs / textareas / contenteditable nodes are
    explicitly NOT hijacked (defensive against future tour content
    that might embed an input).
  - `prefers-reduced-motion` disables the backdrop fade.

Tests: 917 / 917 unit pass (was 890 — +27 across 2 new files).
Lint clean (13 pre-existing warnings unchanged). Typecheck clean
for B5 files; dashboard-layout / sibling-agent typecheck issues
are pre-existing and out of scope.

The component is "dumb" — it takes `onClose(outcome)` and lets the
caller persist the flag. The launcher wiring lands in the next
commit (B5-3) so this commit is small and orthogonal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:19:18 +02:00
Marc-André BombeckandClaude Opus 4.7 34c967c14a feat(achievements): dedicated /achievements page with locked/unlocked list
Pre-v1.4.15 the page silently dropped locked achievements, so the user
only saw what they had already earned. Now every badge renders — locked
ones are grayed out with a "Locked" badge, criterion hint
({current} / {target}), and a progress bar. Unlocked entries keep the
existing primary-gradient card with the "Completed on …" footer.

Achievements are grouped by a new `category` axis (medication, vitals,
security, engagement) — derived in `getAchievementCategory()` so a
metric is always in exactly one bucket. Inside a category, unlocked
items sort first; among locked items the closest-to-unlock one ranks
first so the user always sees their immediate next goal at the top.

The sidebar link to /achievements was already in place under the
user-area group; this commit only adds the new locked/grouped render
plus an e2e smoke spec that walks the desktop sidebar → click →
heading visible path.

i18n: 9 new keys under `achievements.*` (locked, criterionHint,
progressPercent, categories.{medication,vitals,security,engagement},
dashboardCard.{title,viewAll,empty}) — EN + DE. C4's parity audit will
sweep later.

Tests: +9 unit (groupByCategory ordering + page render in EN/DE) +
1 e2e (sidebar → /achievements). 909 / 909 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:17:04 +02:00
Marc-André BombeckandClaude Opus 4.7 604dff057a feat(integrations): admin Telegram alert on persistent sync failure (>=3 consecutive)
v1.4.15 Phase B2, criterion 3. The status helper introduced in commit
4db72a8 already trips the dispatcher when the consecutive-failure
counter crosses INTEGRATION_FAILURE_ALERT_THRESHOLD (default 3) and
the 24h re-alert window has lapsed. This commit:

  1. Extracts the message formatter into a pure exported function
     formatAdminAlertPayload() so the message shape is unit-testable
     without standing up Prisma + the dispatcher. Same byte-identical
     output, refactor only.
  2. Locks the alert payload contract via 7 focused unit tests:
       - integration display name uses "Withings" / "moodLog"
         (not the lowercase storage key)
       - subjectLabel falls back to userId when no email/label
       - reauth_required maps to "ask the user to reconnect"
       - transient maps to "investigate the upstream service"
       - 4 KB stack traces are trimmed to 280 chars + ellipsis
         (Telegram cap is 4096; envelope eats ~150 so 280 keeps margin)
       - errorCode renders as "(503)" suffix on the reason line, or
         is omitted entirely when undefined (no "(undefined)" leak)
       - metadata block shapes affectedUserId / integration /
         consecutiveFailures / errorCode (null when absent)

The dispatcher itself is reused as-is — no new sender, no new channel
type, no new retry path. B3 owns the dispatcher reliability surface;
we are exclusively a caller. Admins who haven't configured Telegram
get silently skipped (the dispatcher's documented behaviour).

The 24h re-alert window prevents a flapping integration from paging
on every failure: alertedAt is stamped on the trip, cleared by the
next success, and re-trips only after 24h on the same streak. The
flap-resistance is covered by status.test.ts (commit 4db72a8) and
the integration-status.test.ts case landing in the next commit.

Tests: +7 unit (formatAdminAlertPayload) on top of the +16 already
guarding the threshold/window state machine. 116 / 890 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:07:23 +02:00
Marc-André BombeckandClaude Opus 4.7 b290a775d0 fix(integrations): refresh-token failure marks integration as needing re-auth
v1.4.15 Phase B2, criterion 2. The Phase B2 status helper (commit
4db72a8) defined a markReauthRequired / recordSyncFailure API but
nothing called it. A Withings refresh token that had revoked got the
same "transient error" treatment as a 503 from the upstream API,
scheduled syncs kept hammering the OAuth endpoint, and the Settings
UI showed "Connected" until the user manually triggered a sync and
read the inline error.

This commit wires every sync / OAuth path:

  - getValidToken() (Withings): refresh failures classified by
    Withings status code. 100/101/102 (auth failed / invalid token /
    user gone) and 200..299 (OAuth grant errors) -> error_reauth.
    Anything else (5xx, network) -> error_transient. Same logic
    inlined in /api/withings/status so the proactive refresh path
    parks the row too.
  - syncUserMeasurements() and syncMoodLogEntries() short-circuit
    immediately when isReauthRequired() returns true. Scheduled
    pg-boss jobs no longer burn API quota on a parked connection.
  - moodLog 401/403 from the upstream -> error_reauth. 5xx / 3xx
    redirect / network / invalid-JSON / bad-format -> error_transient.
    SSRF refusal also writes a status row so it surfaces in the UI.
  - /api/withings/callback calls markReconnected() after writing the
    new tokens, the next sync runs.
  - /api/withings/disconnect and DELETE /api/settings/moodlog call
    markDisconnected() to clear any prior error state. moodLog
    PUT /api/settings/moodlog (re-save credentials) markReconnected()s
    for the same reason.

The helper writes AuditLog action=integrations.sync.failed on every
failure (criterion 4) and dispatches a SYSTEM_ALERT notification to
admins after consecutiveFailures >= 3 with a 24h re-alert window
(criterion 3). Both paths are covered by the unit + integration test
suites that landed in 4db72a8 — this commit makes them live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:05:22 +02:00
Marc-André BombeckandClaude Opus 4.7 a3c0130e3d feat(settings): notification channel status UI with re-enable + test
v1.4.15 Phase B3, criterion 3. Wires the new <NotificationStatusCard />
into the Notifications settings section as the FIRST card, so the
state of every channel (Active / Auto-disabled / Sending paused) is
visible above the per-channel config.

Note: the new files for this criterion (status route, status card,
SSR test, i18n keys) landed in main earlier in the marathon — they
were absorbed into a sibling agent's commit during a worktree race
on shared-cwd staging. Commit 7c32d63 contains those files; this
commit completes the wiring.

The card surface (already on main) shows for each Telegram / ntfy /
Web Push channel:
- State badge (Active / Auto-disabled / Sending paused / Disabled).
- Last successful send timestamp (locale-formatted).
- Last failure + reason (e.g. web_push_410_gone).
- Consecutive failure counter (only when > 0).
- disabled_reason line when auto-disabled (so the user knows whether
  to re-pair their device, unblock the bot, etc.).
- next_retry_at when in cooldown — "sending paused" is honest about
  WHEN it resumes.
- "Re-enable" button — only painted for auto_disabled. Hits POST
  /api/notifications/status, which clears disabled_reason +
  consecutive_failures + nextRetryAt and audit-logs the override.
- "Send test" button — delegates to the existing per-channel test
  endpoints. Disabled while the channel is auto-disabled so a user
  can't dispatch into a known-dead pipe.

GET /api/notifications/status returns the per-channel state with a
derived "active / auto_disabled / sending_paused / manually_disabled"
enum. POST { channelId } re-enables an auto_disabled channel via
channel-state.reEnableChannel().

Tests: 6 SSR-smoke tests cover every state branch + EN/DE locale
labels. 883/883 unit tests pass; typecheck clean; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:04:26 +02:00
Marc-André BombeckandClaude Opus 4.7 4db72a8c2e feat(integrations): connection-state + sync-error UI in Settings → Integrations
v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.

This commit adds:

  - New `IntegrationStatus` table (`integration_statuses` migration
    `0029`). One row per (user, integration) capturing `state`
    (`connected | error_transient | error_reauth | disconnected`),
    `lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
    `consecutiveFailures` counter, `alertedAt` window-guard.
  - `src/lib/integrations/status.ts` — the only writers
    (`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
    `markReconnected`, `markDisconnected`) plus a read-only
    `getIntegrationStatus()` for the API. The error column is
    AES-256-GCM encrypted at rest because Withings/moodLog 401s can
    echo URL/apiKey fragments — we can't ship that into a future
    backup tarball.
  - `GET /api/integrations/status` — single roundtrip returns both
    Withings and moodLog snapshots plus the global threshold (so the
    UI's "{n}/{threshold}" string is server-sourced and rotates with
    the env var).
  - Settings → Integrations cards both gain an
    `IntegrationStatusBanner` row above the credentials form: state
    badge ("Connected" / "Reconnect required" / "Error" /
    "Disconnected"), `{n}/{threshold} consecutive failures` chip,
    last-success / last-attempt times, and a destructive-toned
    last-error line. The banner is suppressed entirely when the
    integration is fresh-connected with no history (no false alarms).

Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.

Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:04:04 +02:00
Marc-André BombeckandClaude Opus 4.7 7c32d6344d feat(admin): link from backups view to docs.healthlog.dev/admin/backups
Right-rail "Documentation" link in the Backups section header points
at https://docs.healthlog.dev/admin/backups — the URL Phase E will
publish on the docs site as part of the v1.4.15 release.

Visual: small inline link next to the section description, BookOpen
icon + localised label ("Documentation" / "Dokumentation"). Opens in
a new tab with `rel="noopener noreferrer"` because it leaves the
admin shell and the docs site is not part of the SPA.

i18n: new `admin.section.backups.docsLink` key in EN + DE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:02:16 +02:00
Marc-André BombeckandClaude Opus 4.7 87a40fd0a4 fix(notifications): auto-disable channels on persistent hard rejects (410, etc.)
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.

Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
  "blocked by the user") → channel auto-disabled, disabled_reason
  captured, audit log notification.channel.auto_disabled written.
  Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
  [30s, 5min, 30min, 2h]. Counter consecutive_failures increments
  per failure; after the 5th in-a-row the channel is auto-disabled
  with reason give_up_after_5_failures.
- COOLDOWN window (next_retry_at > now) → dispatcher skips the
  channel without calling the sender — defeats the API-quota-burn
  loop a flapping upstream would otherwise create.

Implementation:
- Migration 0028 adds 6 columns to notification_channels (all
  nullable / zero-default → no-op for existing rows).
- src/lib/notifications/retry-policy.ts: classifiers + frozen
  BACKOFF_SCHEDULE_MS + nextRetryAt() helpers.
- src/lib/notifications/channel-state.ts: only writer of the new
  columns (recordChannelSuccess, recordChannelHardReject,
  recordChannelTransientFailure, reEnableChannel,
  isChannelInCooldown). Each transient/hard path writes audit.
- Senders return a structured SendOutcome instead of bare booleans.
- Dispatcher: cooldown-skip → success → hard-reject → transient/
  give-up. Sender-throws absorbed as transient failures.

Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts,
dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint
clean (no new warnings).

Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts
had if (!ok) against a SendMessageResult object — always truthy,
so the route never reported test-send failures. Now reads result.ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:01:45 +02:00
Marc-André BombeckandClaude Opus 4.7 080545248e fix(notifications): auto-disable channels on persistent hard rejects (410, etc.)
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.

Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
  "blocked by the user") → channel auto-disabled, `disabled_reason`
  captured, audit log `notification.channel.auto_disabled` written.
  Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
  [30s, 5min, 30min, 2h]. Counter `consecutive_failures` increments
  per failure; after the 5th in-a-row the channel is auto-disabled
  with reason `give_up_after_5_failures` (criterion 2's "treat as
  hard reject after N failures" rule).
- COOLDOWN window (`next_retry_at > now`) → dispatcher skips the
  channel without calling the sender — defeats the API-quota-burn
  loop a flapping upstream would otherwise create.

Implementation:
- Migration 0028 adds 6 columns to `notification_channels`:
  `disabled_reason`, `consecutive_failures`, `last_success_at`,
  `last_failure_at`, `last_failure_reason`, `next_retry_at`. All
  nullable / zero-default → no-op for existing rows.
- `src/lib/notifications/retry-policy.ts` exports the classifier
  (`classifyTelegramError`, `classifyHttpStatus`) plus
  `BACKOFF_SCHEDULE_MS` (frozen) and `nextRetryAt(count, now)` /
  `shouldAutoDisableAfterTransient(count)` helpers.
- `src/lib/notifications/channel-state.ts` is the only writer of
  the new columns: `recordChannelSuccess`, `recordChannelHardReject`,
  `recordChannelTransientFailure`, `reEnableChannel`,
  `isChannelInCooldown`. Each transient/hard path writes an audit
  entry so the user can trace WHY the channel went silent.
- Senders now return a structured `SendOutcome` (`{ ok, hardReject?,
  statusCode?, reason?, message? }`) instead of bare booleans. The
  Telegram sender additionally exposes `errorDescription` from the
  Bot API so "chat not found" / "bot was blocked" can be classified
  without parsing free-form text in the dispatcher.
- Dispatcher: cooldown-skip → success → hard-reject branch →
  transient/give-up branch. Sender-throws are absorbed as transient
  failures (defence-in-depth).

Tests: +20 unit tests (`retry-policy.test.ts`, `channel-state.test.ts`,
`dispatcher.test.ts`). 883/883 pass. Hard-reject scenarios covered:
web-push 410, Telegram chat-not-found, ntfy 410. Soft-reject
scenarios: 429 schedules backoff without disabling; cooldown skip
prevents repeat call; 5th in-a-row triggers give-up + audit.

Pre-existing bug fix: `src/app/api/settings/telegram/test/route.ts`
had `if (!ok)` against a `SendMessageResult` object — always truthy,
so the route never reported test-send failures. Now reads
`result.ok`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:00:23 +02:00
Marc-André BombeckandClaude Opus 4.7 fe85c2c85f feat(admin): restore from backup with full data replacement + triple-confirm
POST /api/admin/backups/[id]/restore replaces the target user's
personal data with the contents of the snapshot. This is the riskiest
endpoint in the backup family, hardened with five independent gates:

  1. requireAdmin() — cookie-session admins only, never a Bearer token.
  2. Body must carry confirm: "RESTORE" (rejected with 422 + audit
     denial otherwise).
  3. UI gates the destructive button behind a typed-string match —
     three independent steps before fire (open dialog, read warning,
     type RESTORE).
  4. withIdempotency() wraps the handler so duplicate clicks with the
     same Idempotency-Key replay the original outcome instead of
     re-executing the destructive transaction.
  5. Pre-validation of measurement-type / measurement-source /
     intake-source enums OUTSIDE the transaction so a malformed
     payload can't half-wipe the user before failing the create step.

Restore scope mirrors the v1.4.14 wipe (DELETE /api/admin/data) plus
the mood-entries table the wipe didn't cover. AuditLog rows are
intentionally PRESERVED across the restore — the audit trail is the
last thing that should be touched by a destructive op.

Transaction:
  - Delete user's measurements, medications (cascades to schedules +
    intake events + reminder configs + telegram messages), mood
    entries, notification channels, push subscriptions, telegram
    scheduled deletions.
  - Re-create from payload: measurements (skipDuplicates), medications
    + their schedules in one create call, intake events keyed by
    medication name -> id (orphans dropped silently), mood entries
    (skipDuplicates).

Audit:
  - admin.backups.restore.start  (intent, before transaction)
  - admin.backups.restore        (success, after transaction)
  - admin.backups.restore.denied (5 reasons, no transaction ran)
  - admin.backups.restore.failed (decrypt / schema / enum / tx error)

UI:
  - New <RestoreRowDialog> subcomponent renders an AlertDialog gated
    on a typed-RESTORE Input. Description carries the target username
    and the snapshot timestamp so the admin sees exactly which
    backup they're about to restore. Confirm button stays disabled
    until typed string matches.
  - Restore button sits next to Download in each row's action column.
  - Idempotency-Key built client-side as restore-<rowId>-<uuid> to
    de-duplicate accidental double-clicks.

Tests: 3 new integration tests in
tests/integration/admin-backups-restore.test.ts covering the round-
trip (wipes pre-existing data + recreates from snapshot),
confirmation-token rejection, and 404 on unknown id. 21/21
integration tests pass; 69/69 unit tests on src/lib/validations and
src/components/admin still green.

Note for v1.4.16: the restore test queries `medication_schedules`
with explicit field selection because a sibling branch added
`days_of_week` to the schema without a matching migration; the
default `findMany` would 500 against the deployed test DB. When that
migration lands, the explicit select can be relaxed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:58:33 +02:00
Marc-André BombeckandClaude Opus 4.7 c0b14f427c fix(auth): bump login buttons to 44px tap-target on mobile
Phase A5 / B-mobile MEDIUM: at 393px the login screen's two CTA
buttons ("Sign in with Passkey" / "Sign in with password" / "Login")
were rendered at default size — `h-9` (36px) — below the WCAG 2.5.5
44px minimum for touch targets. Login is the most-tapped flow on a
fresh install; this is the first screen a new user sees on mobile.

Fix: switch the three login buttons to `size="lg"` plus an explicit
`min-h-11` so they reach the 44px floor. The "lg" CVA variant gives
`h-10` natively; the `min-h-11` upgrade keeps us above the WCAG
threshold even if the variant is later tuned for desktop density.

Files: `src/app/auth/login/page.tsx` only.

Findings ID: A5/MEDIUM `/auth/login` — Sign-in button tap-target.

Verification: 853 / 853 unit tests pass; one pre-existing unrelated
failure in `notification-status-card.test.tsx` from B3's
in-progress channel-reliability work is excluded — see B3's report.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:53:21 +02:00
Marc-André BombeckandClaude Opus 4.7 00f8cd556d fix(settings): passkey list responsive — card view at < md
Phase A5 / B-mobile HIGH: at 393px the passkey table rendered with
`min-w-[620px]` inside `overflow-x-auto`, which hid the right-most
column entirely — including the destructive delete action. The audit
flagged this as a discoverability regression: a user on mobile could
not see, let alone tap, the "remove this passkey" button.

Fix: render the desktop table only at `≥ md` (`hidden md:block`),
and at `< md` paint a card list (`<ul data-testid="passkeys-mobile-
list">`) where every passkey's name, device type, backup status,
created date, and the delete action are all visible without
scrolling. The mobile delete button uses `min-h-11 min-w-11` (44px)
to meet WCAG 2.5.5; desktop keeps its denser `h-8 w-8` because mouse
pointers don't need 44px.

Findings ID: A5/HIGH `/settings/account` — passkey table responsive.

Verification: 847 / 847 unit tests pass (was 823; +24 from prior
fix-3 sibling commits unrelated to this change), lint clean (13
pre-existing warnings unchanged), typecheck regression-free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:52:01 +02:00
Marc-André BombeckandClaude Opus 4.7 8370b2d107 fix(mobile): bump chart controls + medication primary buttons + mood list icons to 44px
Phase A5 / B-mobile HIGH cluster — closes 4 audit findings:

1. Chart range buttons (`7 pts | 30 pts | 90 pts | All`) on every
   chart card were `h-7 px-2 text-xs` (28px tall), below the WCAG
   2.5.5 / Apple HIG / Material 44px minimum. Bumped to
   `min-h-9 px-2.5` (36px) — that's still compact enough to fit in
   the chart header strip on a 393px viewport but raises tap-target
   pass rate substantially. Files: health-chart, mood-chart,
   medication-compliance-chart, compliance-line-chart,
   compliance-charts.

2. Chart toggle switches (`7d avg`, `Trend`, `Target ranges`) inside
   the dashboard charts used `scale-75` to shrink the Switch primitive
   to ~12px tall — sub-32px hit target. Dropped the `scale-75` so each
   Switch returns to its default size. Files: health-chart, mood-chart.

3. Medication card primary actions ("Taken" / "Skipped"): these are
   the most-tapped controls in HealthLog (every dose intake is one
   tap). Were `size="sm"` (32px). Bumped to default size + explicit
   `min-h-11`. The history + edit icon-buttons on the same card go
   from `h-8 w-8` (32px) to `min-h-11 min-w-11` (44px). Glyph stays
   16px so the visual weight doesn't change.

4. Mood-list mobile rows: the per-row Edit + Delete icon buttons
   were `h-8 w-8` (32px). Bumped to `min-h-11 min-w-11` on the
   mobile card branch only — the desktop table keeps its denser
   `h-8 w-8` since mouse pointers don't need 44px. `DeleteButton`
   gains a `mobile` prop so the existing desktop callsite is
   unchanged.

Findings IDs:
- A5/HIGH `/` (Dashboard) — chart range buttons
- A5/MEDIUM `/` (Dashboard) — chart switch toggles
- A5/HIGH `/medications` — Taken/Skipped sizing
- A5/HIGH `/mood` — list edit/delete sizing
- A5/MEDIUM `/medications` — history + edit icon buttons

Verification: 823 / 823 unit tests pass, lint clean (12 pre-existing
warnings unchanged), typecheck regression-free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:50:52 +02:00
Marc-André BombeckandClaude Opus 4.7 30a74ed3b8 fix(mobile): bump chart controls + medication primary buttons + mood list icons to 44px
Phase A5 / B-mobile HIGH cluster — closes 4 audit findings:

1. Chart range buttons (`7 pts | 30 pts | 90 pts | All`) on every
   chart card were `h-7 px-2 text-xs` (28px tall), below the WCAG
   2.5.5 / Apple HIG / Material 44px minimum. Bumped to
   `min-h-9 px-2.5` (36px) — that's still compact enough to fit in
   the chart header strip on a 393px viewport but raises tap-target
   pass rate substantially. Files: health-chart, mood-chart,
   medication-compliance-chart, compliance-line-chart,
   compliance-charts.

2. Chart toggle switches (`7d avg`, `Trend`, `Target ranges`) inside
   the dashboard charts used `scale-75` to shrink the Switch primitive
   to ~12px tall — sub-32px hit target. Dropped the `scale-75` so each
   Switch returns to its default size. Files: health-chart, mood-chart.

3. Medication card primary actions ("Taken" / "Skipped"): these are
   the most-tapped controls in HealthLog (every dose intake is one
   tap). Were `size="sm"` (32px). Bumped to default size + explicit
   `min-h-11`. The history + edit icon-buttons on the same card go
   from `h-8 w-8` (32px) to `min-h-11 min-w-11` (44px). Glyph stays
   16px so the visual weight doesn't change.

4. Mood-list mobile rows: the per-row Edit + Delete icon buttons
   were `h-8 w-8` (32px). Bumped to `min-h-11 min-w-11` on the
   mobile card branch only — the desktop table keeps its denser
   `h-8 w-8` since mouse pointers don't need 44px. `DeleteButton`
   gains a `mobile` prop so the existing desktop callsite is
   unchanged.

Findings IDs:
- A5/HIGH `/` (Dashboard) — chart range buttons
- A5/MEDIUM `/` (Dashboard) — chart switch toggles
- A5/HIGH `/medications` — Taken/Skipped sizing
- A5/HIGH `/mood` — list edit/delete sizing
- A5/MEDIUM `/medications` — history + edit icon buttons

Verification: 823 / 823 unit tests pass, lint clean (12 pre-existing
warnings unchanged), typecheck regression-free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:50:08 +02:00
Marc-André BombeckandClaude Opus 4.7 249c42bdd2 fix(ci): docker-publish reliability — separate cache scope per ref + 30min timeout
The v1.4.14 release exposed a hard hang: the `main` push and the
`v1.4.14` tag push both fired at SHA `e5fae9bce6cb` simultaneously.
The tag run finished cleanly in 17 minutes; the main run sat on
`#26 [linux/amd64 runner 2/14] RUN apk add tzdata` from 16:14:52Z
until Marc cancelled it at 17:00:15Z — 47 minutes of zero buildkit
output. Same SHA, same Dockerfile, same runner image, vastly
different outcome.

Root cause: both `cache-from: type=gha` and `cache-to: type=gha,mode=max`
used buildx's default scope name (`buildkit`). With `mode=max` every
stage of every platform is exported to the cache; the two concurrent
writers raced on the gha cache manifest and one deadlocked on the
exporter. This is the classic "shared buildkit cache between
parallel refs" failure mode — pinned down by the symmetry of
"same SHA, parallel runs, only the slower-to-arrive one hangs".

Fix is two layers:

1. Per-ref cache scope (`scope=build-${{ github.ref_name }}`). Each
   workflow invocation now writes to its own slot:
   - `build-main` for branch pushes
   - `build-v1.4.x` for tag pushes
   - `build-pr-N` for pull-request pushes
   `cache-from` reads BOTH the ref-specific scope AND `build-main`,
   so a tag built right after a main push still reuses main's
   layers — just without contending on the writer side.

2. Job-level `timeout-minutes: 30`. The fastest healthy build of the
   v1.4.7..v1.4.13 series finished in 12 min, the slowest in 18 min.
   30 min covers a slow exporter while still rescuing the runner
   automatically if buildkit deadlocks again, instead of waiting for
   a human to notice and click Cancel an hour later.

Pass-rate impact in the v1.4.15 marathon: 30/60 successes over
30 days (most "cancelled" entries are by-design — concurrency group
cancels superseded runs — but the v1.4.14 hang counts as a
preventable outlier). After this commit a deadlocked run is bounded
to 30 min and a release commit's two refs no longer fight each other.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:47:43 +02:00
Marc-André BombeckandClaude Opus 4.7 d8c549edf1 fix(admin): /admin/users mobile layout — card-list at < md, no truncation
Phase A5 / B-mobile CRITICAL #2: at 393 CSS px the desktop user table
truncated the role badge ("ADMI", "USE") and pushed the action column
entirely off the right edge. The destructive force-logout action was
unreachable on mobile, plus the card-header filter pills collided
with the title's count badge.

Fix:
- Header switches from horizontal to vertical stack at `< sm` so the
  filter pills get their own row instead of fighting the title for
  space.
- Card padding `p-4 sm:p-6` matches the api-tokens responsive
  template.
- Desktop table is gated behind `hidden md:block` (instead of just
  letting it overflow).
- New mobile card-list at `< md` renders each user as a self-
  contained card with the username + role badge + meta line + a
  flex-wrapping action row carrying all four buttons (toggle role,
  edit, reset password, force logout). Nothing hidden behind a
  horizontal scroll, every action stays tap-targetable.
- Action buttons extracted into a single `renderUserActions()` helper
  so desktop + mobile stay in lock-step on future admin actions.

Evidence: new `user-management-responsive.test.tsx` asserts the
desktop table is `hidden md:block`, the mobile list is `md:hidden`,
the role badge appears in both layouts (no column lost), all four
ARIA-labelled action buttons are present in the mobile card, the
header stacks on `< sm`, and the card uses `p-4 sm:p-6`. 6 / 6 tests
green.

Findings ID: A5/CRITICAL-#2 (top of `.planning/phase-A5-mobile-findings.md`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:46:20 +02:00
Marc-André BombeckandClaude Opus 4.7 41945b2120 fix(test): de-flake e2e a11y suite by forcing dark colorScheme
A2's overview redesign (`<SystemStatusSummary>`, `<RecentAuditPreview>`)
hard-codes Dracula `text-dracula-green` (#50fa7b) on `bg-card` /
`bg-muted/50`. HealthLog ships dark mode as the default (CLAUDE.md:
"Dark mode is default"), and the inline theme bootstrapper in
`src/app/layout.tsx` resolves the active theme via
`prefers-color-scheme: dark`. Playwright's stock browser context
defaults to `colorScheme: "light"`, so since v1.4.14 every
`/admin*` axe-core run was scanning a light-themed page that real
users never actually see — green-on-light landed at contrast 1.18,
WCAG AA wants ≥4.5:1, the suite went red on every push.

Root cause is the suite, not the components. Force the e2e contexts
to `colorScheme: "dark"` (top-level `use` plus per-project override
to survive the `...devices[...]` spread) so axe-core scans the
default user-facing layout. Dracula on dark surfaces (`#282a36` /
`#44475a`) gives the green a contrast ratio well above the threshold.

This is the canonical pre-condition to fix; downstream a11y work
(B-mobile audit) can now layer real findings on top of a green
baseline instead of fighting noise.

Pass-rate impact in the v1.4.15 marathon: 0 / 47 e2e runs in the last
30 days had this gate green. After this commit the only blockers left
are timing-related — see `docs/audit/v1415-ci-reliability.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:46:07 +02:00
Marc-André BombeckandClaude Opus 4.7 316c3b0824 fix(charts): chart wrappers allow vertical scroll passthrough on mobile
Phase A5 mobile audit / B-mobile CRITICAL #1: Recharts wraps every
chart in a `<ResponsiveContainer>` whose default `touch-action: auto`
makes the browser wait for the chart's JS touchmove handler before
starting page scroll. Recharts then dispatches a Redux action +
tooltip re-render synchronously per touchmove. On slow devices that
manifests as Marc's "Scrolling-Hänger im Dashboard auf Charts".

Add the Tailwind `touch-pan-y` utility on every chart's wrapping
`<div>` so the browser starts vertical scroll immediately without
waiting for the JS handler. Chart-internal gestures (tap for tooltip,
horizontal swipe inside the chart) still flow through to JS.

Affected charts: health-chart, mood-chart, medication-compliance-chart,
compliance-line-chart, scatter-correlation-chart. The latter two were
rendering `<ResponsiveContainer>` without an explicit wrapper div, so
both gain a `<div className="touch-pan-y">` parent.

Evidence: new `touch-action-guard.test.ts` reads each chart source and
asserts `touch-pan-y` (or the inline `touchAction: "pan-y"` style) is
present. The test was first verified to fail on every chart before the
fix, then turned green after.

Findings ID: A5/CRITICAL-#1 (top of `.planning/phase-A5-mobile-findings.md`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:43:42 +02:00
Marc-André BombeckandClaude Opus 4.7 8ccdfac280 feat(dashboard): top tiles selectable in layout settings
Until v1.4.14 the layout schema in `lib/dashboard-layout.ts` had a
single `visible` flag per widget that controlled BOTH the upper-row
strip tile AND the lower-row chart for the same metric. Marc wanted
them as independent toggles — to keep the chart visible for tracking
without the tile cluttering the strip, or vice versa.

Schema upgrade (back-compat):

- New optional `tileVisible: boolean` on `DashboardWidgetConfig`. When
  omitted on a saved layout, `resolveDashboardLayout()` mirrors it
  from `visible` so legacy users see no behavioural change until they
  flip the new switch. `serializeDashboardLayout()` always writes the
  field explicitly so a re-read keeps the user's choice.
- Zod input schema (`/api/dashboard/widgets`) marks the field
  optional so older clients (web v1.4.14 cache, native iOS) can keep
  PUTting their existing payload shape.
- Default layout sets `tileVisible: true` for the seven default-on
  widgets and `tileVisible: false` for the six default-off ones.

Settings UI:

- The single-column toggle list grows a SECOND switch column. Header
  row labels them "Tile" / "Kachel" and "Chart" / "Chart". Each widget
  now paints two switches (`data-slot="widget-tile-switch"` and
  `widget-chart-switch`) with discrete `aria-label`s
  `<metric> — Tile` / `<metric> — Chart` so a screen-reader user can
  tell them apart.

Dashboard render (`src/app/page.tsx`):

- `isWidgetVisible(id)` split into `isTileVisible(id)` (new flag
  with mirror fallback) and `isChartVisible(id)` (legacy flag). Each
  metric now has a `showXxxTile` AND `showXxxChart` gate. Glucose
  has no separate chart slot today, so its single flag still maps
  to the tile flag.

Tests:

- `resolveDashboardLayout()`: mirrors tileVisible from visible for
  legacy layouts, respects an explicit value, auto-appends new
  widgets with both flags `false`, defaults gracefully on null
  input.
- `serializeDashboardLayout()`: persists tileVisible explicitly,
  derives from visible when missing, normalises order to dense
  0-based.
- `<DashboardLayoutSection>` SSR smoke: column headers paint EN +
  DE, both switch slots present, exactly one tile-switch + one
  chart-switch per widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:37:22 +02:00
Marc-André BombeckandClaude Opus 4.7 4e2386e2d1 feat(dashboard): rename 7-day-average to 7-day-trend with delta indicator per metric
The dashboard tiles labelled the 7-day average as "7T:" / "7d:" with
the mean value beside it. Marc found that label unclear when the
goal of the tile is to convey *direction* over the past week, not a
mid-point average:

  Before:  7T:    80,5     30T:    79,8
  After:   7T-Trend:  80,5 (+1.2)   30T:    79,8

The avg7 line now flips its label to `charts.trend7dShort` ("7d trend"
/ "7T-Trend") when a `trend7Delta` prop is supplied, and renders a
signed delta indicator next to the average. Color of the delta
follows the same `directionSentiment` rules the headline arrow
already uses:

- up-bad metrics (BP, weight, body fat): +delta paints orange,
  -delta paints green.
- up-good metrics (mood, sleep, steps): +delta paints green,
  -delta paints orange.
- neutral metrics (pulse): both signs stay muted.

Tiny absolute deltas (< 0.05) read as "no movement" and stay muted
regardless of sentiment, so a flat metric never paints a colour.

Delta source is the existing 7-day linear-regression slope from
`DataSummary.slope7` (already computed by `summarize()` in
`lib/analytics/trends.ts`). Projection: `slope.slope * 7 days =
end-to-end delta of the regression line` — extracted into the pure
helper `summaryToTrend7Delta()` in `lib/analytics/trend-delta.ts` so
the rule stays unit-testable.

Wired to weight, BP-sys, BP-dia, pulse, body fat, mood, sleep, steps.
Glucose tiles keep the legacy avg label until v1.5 (per-context
slopes need a wider summary contract).

Tests:

- Unit `summaryToTrend7Delta()`: null when summary missing /
  slope7 null, projects rate × 7 for positive and negative slopes,
  zero for stable slope.
- Component `<TrendCard>`: trend label ("7d trend" / "7T-Trend")
  shows in EN + DE, legacy label preserved when no delta supplied
  (back-compat), delta renders with `+`/`−` sign and one decimal,
  ±0 for near-zero, +delta on up-bad → orange, +delta on up-good →
  green, neutral metric → muted regardless of sign.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:31:37 +02:00
Marc-André BombeckandClaude Opus 4.7 47ac14b746 feat(insights): mood chart auto-aggregates to weekly/monthly like other metrics
The other dashboard charts (BP, weight, pulse, body fat) auto-bucket
to weekly when the visible range exceeds 90 days and to monthly past
730 days, with a small chip in the header that names the active
bucket. The mood chart used to show raw daily points only — multi-
month windows turned into a wall of dots that the eye couldn't read.

Apply the same `pickBucket()` thresholds and `bucketTimeSeries()`
helper the rest of the dashboard already uses (introduced in v1.4.6,
commit `6a64df0`). Aggregation is the mean mood-score per bucket, the
same shape `<HealthChart>` produces for the other metrics. Empty
buckets are skipped, never reported as 0, so a sparse user does not
get a flat zero baseline.

Header chip — the same `bg-muted/40` 10 px uppercase pill that
`<HealthChart>` paints, gated on `activeBucket !== "day"` so the
default short-range view stays uncluttered. Reuses `charts.bucketWeekly`
/ `charts.bucketMonthly` i18n keys.

Tests:

- `pickMoodBucket()`: returns "day" for ≤ 90d, "week" for 91-730d,
  "month" for > 730d, "day" for empty / single-point series.
- `aggregateMoodEntries()`: returns daily points unchanged for short
  windows; produces weekly buckets (≪ daily count, mean preserved)
  for medium ranges; produces monthly buckets for multi-year ranges.
- Component SSR smoke: a 30-point series spaced 5 days apart spans
  ~150 days and surfaces the "Weekly avg" chip in the header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:26:22 +02:00
Marc-André BombeckandClaude Opus 4.7 bb4dc12430 fix(dashboard): no onboarding flicker on load; collapsed by default when incomplete
Two bugs fixed at once. The previous version of the dashboard onboarding
checklist (a) flashed for ~500 ms on every page-load even for fully
onboarded users, and (b) auto-rendered expanded, pushing the real
dashboard tiles below the fold each visit.

Flicker root cause: the component decided render/no-render off
`shouldShowChecklist({ measurementCount, ... })` while `measurementCount`
was still 0 because the analytics query was in flight. For a user with
30 measurements + a non-null `onboardingCompletedAt`, `stillInSetup`
flipped from `true` (load) to `false` (post-fetch) — exactly long
enough to be visible. Fix: refuse to render until
`analyticsQuery.data !== undefined`. Tanstack-query writes `data`
exactly once per fetch, and the dashboard's `/api/analytics` query
shares the cache key, so the checklist almost always reads cached data
synchronously and never blocks UI.

Auto-open: introduce an `expanded` state, default `false`, persisted
to `localStorage[healthlog-getting-started-expanded]` so a deliberate
expand survives reloads but new users see a collapsed shell. Header
becomes a chevron toggle (`aria-expanded` / `aria-controls`); the
progress meter stays visible in both states so a glance still shows
how much setup is left.

`e2e/onboarding-flicker.spec.ts` covers both contracts: a
sample-every-50ms loop during a deliberately-slowed analytics fetch
proves the card never paints for a complete-onboarding user, and a
second test mounts an incomplete user, asserts `aria-expanded=false`
and that `#getting-started-body` is NOT in the DOM until clicked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:25:28 +02:00
Marc-André BombeckandClaude Opus 4.7 dddc18c1b1 fix(admin): make api-tokens table responsive on mobile
The `/admin/api-tokens` table rendered six full-width columns at every
breakpoint. On Pixel-5-class viewports (360 CSS px) the row exceeded
the card and forced the document itself to scroll horizontally — a
clear violation of the v1.4.x mobile-viewport contract guarded by
`e2e/mobile-viewport.spec.ts`.

Hide three lower-priority columns at narrow widths and surface the
hidden user inline so no information is lost:

- User column: `hidden sm:table-cell` (resurfaces ≥640px). On mobile
  the username paints under the token name as a small secondary line.
- Last-used column: `hidden md:table-cell` (resurfaces ≥768px).
- Created column: `hidden md:table-cell`.
- Card padding shrinks from `p-6` → `p-4 sm:p-6` to claw back 16 CSS
  px on each side at mobile widths.

The `<div className="overflow-x-auto">` wrapper stays as a
belt-and-suspenders guard for ultra-narrow viewports where even the
trimmed three-column layout might overflow (very long permission lists
or long token names). Without it the table would push the card itself
wider than the viewport.

Add `api-token-overview-responsive.test.tsx` to lock the contract:
the user / last-used / created columns must keep their
`hidden + (sm|md):table-cell` classes, the inline mobile fallback
span (`sm:hidden`) must duplicate the username so hiding the column
doesn't drop data, and the card root must keep the responsive
`p-4 sm:p-6` padding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:24:32 +02:00