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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The `/admin` overview rendered a six-card status grid plus a quick-jump
list of every admin section. Both duplicated navigation that already
lives in the sidebar (and the global nav after Phase 4b), so the
landing page told the admin nothing they didn't already see two clicks
away.
Replace the duplicated nav with two at-a-glance panes that *only* the
overview can show:
- Welcome card greets the signed-in admin by username and confirms the
admin context (so the role indicator the shell shows is reinforced
in the page body, not just the chrome).
- `<SystemStatusSummary>` reuses the existing `useSystemStatus()` hook
+ `/api/version` to print app version, DB up/down, worker state,
process start time, build SHA, and built-at. Same data source as
`<SystemStatusSection>` so we don't double-fetch.
- `<RecentAuditPreview>` lists the latest 10 audit entries from
`/api/admin/audit-log?limit=10&filter=auth` with a "View all" link to
`/admin/login-overview`. Reuses the same auth-action label map the
full section uses.
The previous `<StatusCardGrid>` component is now unused — removed
entirely. The test file kept its name (`status-card-grid.test.tsx`) so
git history follows the rename, but it now asserts the new overview
composition: welcome card paints with the admin's username, snapshot
heading present, audit preview links to `/admin/login-overview`, and
the legacy "Maintenance"/"Monitoring" card titles + "Manage users"
CTAs are *not* rendered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard "Add" dropdown rendered two menu items whose visible text
both resolved to bare nouns ("Messung" / "Stimmungseintrag" in DE,
"Measurement" / "Mood entry" in EN). Combined with the trigger label
("Hinzufügen" / "Add"), screen-reader users effectively heard the same
verb three times in a row with no signal which row did what.
Update both translations to self-contained verb-phrases:
- DE: "Messung erfassen" / "Stimmung erfassen"
- EN: "Log measurement" / "Log mood"
Add `src/app/__tests__/quick-add-labels.test.ts` to lock the contract:
both labels must be non-empty, distinct from each other, and distinct
from `common.add` (the trigger). The test loads `messages/{en,de}.json`
directly so any future translator who collapses the labels back to
"Hinzufügen" or "Add" gets a red bar before merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pg-boss does not deduplicate by payload, so each click on the manual
"Backup now" button enqueues a fresh data-backup job. Without a
rate-limit, an impatient admin (or compromised admin session via XSS
or token theft) can stack dozens of identical fan-out-per-user jobs
on the queue.
Three runs per minute per admin is plenty for ad-hoc snapshots — the
weekly cron in `reminder-worker` already carries the regular cadence.
Closes H3 from .planning/phase-6-code-review-findings.md (also addresses
M-1 from .planning/phase-6-security-findings.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The UI already disabled the per-row force-logout button for the active
admin (user-management-section.tsx:295), but the route handler had no
matching server-side check. A direct curl/DevTools call (or a
compromised admin session) could revoke the caller's own sessions and
bounce them to /auth/login mid-action.
Mirrors the UI gate with a 422 + an `admin.user.force-logout.denied`
audit entry tagged `reason: self_target`. Admin-on-admin force-logout
remains permitted (today's intentional behaviour).
Closes H1 from .planning/phase-6-code-review-findings.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Wrap the section quick-jump list under `/admin` in a `<nav>` with
`aria-labelledby` pointing at its heading. Without the landmark,
screen-reader users navigating by region heard "list of links" with no
contextual label distinguishing it from the sidebar nav (which already
carries its own `aria-label`).
The `<h2>` keeps its existing copy ("Sections") and now serves as the
nav's accessible name via `aria-labelledby`. Visual is identical.
Picked up from the v1.5 phase-5 friction audit (item #10) — small fix,
clear win, no scope creep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase-3 e2e suite documented an i18n collision: the dashboard's
quick-entry dropdown trigger says "Add" and both menu items underneath
also rendered the bare verb "Add" because `measurements.addMeasurement`
and `mood.addEntry` both translate to "Add". Screen-reader users heard
two indistinguishable rows; the e2e spec was forced to use
`getByRole("menuitem").first()` as a workaround.
The dropdown trigger already says "Add" — the menu items label what's
being added.
- Add `dashboard.quickAddMeasurement` ("Measurement" / "Messung")
- Add `dashboard.quickAddMood` ("Mood entry" / "Stimmungseintrag")
- Wire the two `<DropdownMenuItem>`s on `/`
- Mark the leading icons `aria-hidden` so the accessible name is
just the noun
- Update `e2e/measurement-flow.spec.ts` to target the menu item by
name (`/measurement|messung/i`) instead of `.first()`
Dialog titles still use the existing `addMeasurement` / `addEntry`
keys — those work in the larger dialog context where "Add" reads fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
The insights page imported seven Recharts symbols (ScatterChart, Scatter,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer) eagerly at
module top, which pulled the entire ~108 KiB Brotli Recharts chunk onto
the initial bundle even though every scatter card sits inside a
`length >= 5` gate below the fold.
Switching each symbol to next/dynamic with `ssr: false` matches the
existing HealthChart / MoodChart pattern in the same file. Recharts is
still loaded once via the shared chunk graph the first time any scatter
section mounts; until then, /insights ships the same JS payload as
chart-free pages.
Verified static-only — local pnpm build is blocked by the documented
Node-25 turbopack regression. Full deltas captured in
docs/audit/v15-performance.md (v1.5 phase-4 perf audit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the prettier line-wraps the v1.5 phase-1 planning docs and
codex-protocol-spec.md missed, and brings the redact.ts + insights
generate route test into the project's standard form. No behaviour
change — pnpm format:check now passes clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.6 T5 fixed only the parse-error branch (502 → 422 to keep
Cloudflare's HTML-error rewrite from breaking `await res.json()` on
the dashboard). Provider errors — `OpenAI request failed (401)` for
an invalid admin key, or transient `OpenAI request failed (5xx)` —
still propagated to the apiHandler's generic 500. Cloudflare rewrites
the 5xx to its own HTML page, so the React Query mutation crashed
with "Unexpected token '<'" instead of surfacing a readable message.
Wraps `provider.generateCompletion(...)` in try/catch and mirrors the
v1.4.5 ai/test categorisation:
- 401 / 403 from the provider → 422 with "AI provider rejected the
request — check your API key in Settings > AI"
- 5xx from the provider → 503 with "AI provider temporarily
unavailable, try again in a moment"
- 429 from the provider → 429 (rate-limit passthrough)
- any other status → 422 generic provider-connection failure
Full error details (message, httpStatus, bodyExcerpt, providerType)
land in the Wide Event via `annotate()` so the operator can debug
without leaking provider URLs / partial keys to the client.
Adds unit tests covering 401/403/429/500/503/unknown mapping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Stop iterating on individual 400 errors and implement the full Codex
Responses-API protocol once, from the source. Wrote a 713-line
reference at docs/codex-protocol-spec.md from the official
openai/codex Rust client; this commit is the TS implementation
against that spec.
Required pieces that were missing in v1.4.11:
- `ChatGPT-Account-ID` header — extracted from the `chatgpt_account_id`
claim of the OAuth id_token (also accepts the URI-namespaced
variant under `https://api.openai.com/auth`). Without it: 401.
- `originator`, `User-Agent`, `Accept: text/event-stream`,
`session_id`/`session-id` (both spellings), `thread_id`/`thread-id`.
- Body fields `reasoning: null` and `include: []` are required even
when no reasoning is requested.
- Model slug is `gpt-5-codex`, not `gpt-5.3-codex` (the latter is a
test placeholder upstream).
- Refresh endpoint takes a JSON body, not form-urlencoded.
Storage codec rewrite: `codexAccessTokenEncrypted` now holds an
encrypted JSON blob `{ accessToken, accountId, expiresAt }` instead
of just the raw access token, so the account id stays available
across requests. v1.4.7-v1.4.11 records that lack the field cannot
be revived — `decryptCodexCreds` returns null for those rows and
`resolveCodexProvider` flips the connection to `expired`, prompting
the user to re-link once.
Removed the dead browser-flow routes (`/api/auth/codex/authorize`
and `/callback`). They never worked on hosted domains because
Hydra's redirect-URI allow-list for the public Codex client ID
covers only localhost; device-code is the only viable path.
CodexClient SSE parsing extended to handle `response.failed` and
`response.incomplete` terminal events, and to read the actual
routed model from the `OpenAI-Model` response header.
Tests rebuilt: codex-oauth covers the full device-code flow with a
synthesised id_token JWT, the JSON-body refresh, and the
storage-codec round-trip. CodexClient tests carry an `accountId`
through the constructor. All 720 tests pass; typecheck clean.
Refs: docs/codex-protocol-spec.md §1-§6 for every claim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.8's device-code flow got the user as far as the chatgpt.com
approval, but the connect step at the end still failed with:
Codex api-key exchange failed (401): "Invalid ID token: missing
organization_id"
Root cause: the api-key exchange (RFC 8693, swap id_token for an
OpenAI API key) requires the id_token to carry an `organization_id`
claim. That claim only gets added when the authorize URL is hit with
`id_token_add_organizations=true` — which the browser/auth-code
flow does, but the device-code flow does NOT (the user types the
code on chatgpt.com's own page; we never control the authorize URL).
The official Codex CLI handles this by NOT calling the api-key
exchange in the device path: it persists the OAuth access_token /
refresh_token directly and uses them against
`https://chatgpt.com/backend-api/codex/responses` (the Codex backend
that bills against the ChatGPT subscription). v1.4.9 does the same.
`pollDeviceCode` no longer calls `obtainApiKey`; it returns the raw
OAuth access_token instead. New `refreshDeviceTokens()` mirrors that
on the refresh path. The provider rewires Codex back through the
existing `CodexClient` (bg backend-api), with proactive refresh
+ on-401 refresh both going through `refreshDeviceTokens`.
The browser/auth-code flow path is left in place — operators who
register a private OAuth app with OpenAI (e.g. for an enterprise
deployment) can still use the api-key exchange via the
`exchangeCodeForTokens` helper. For the public Codex client ID on
hosted instances, device-code is the only working path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The "Connect with ChatGPT" button never actually completed an OAuth
flow. The implementation hit `chatgpt.com/authorize` and
`chatgpt.com/oauth/token`, which are not OAuth endpoints — ChatGPT
silently rendered its normal web app, dropped our params on the
floor, and the user landed on a regular logged-in chat session with
nothing else happening. There was no error and no callback, which is
why the regression survived multiple releases.
The canonical issuer for ChatGPT-OAuth is `auth.openai.com`. The
official `openai/codex` CLI confirms this in
`codex-rs/login/src/auth/manager.rs` and `codex-rs/login/src/server.rs`.
The flow is also slightly more involved than a vanilla PKCE
authorisation:
1. PKCE authorize against `auth.openai.com/oauth/authorize` with the
`openid profile email offline_access api.connectors.read
api.connectors.invoke` scope set, plus `id_token_add_organizations`
and `codex_cli_simplified_flow` flags so the resulting `id_token`
carries the ChatGPT-subscription claims.
2. Token exchange against `auth.openai.com/oauth/token` (form-
urlencoded body, NOT JSON) yields `{ id_token, access_token,
refresh_token }`.
3. **Second token exchange** with grant
`urn:ietf:params:oauth:grant-type:token-exchange`, swapping the
`id_token` for an OpenAI API key (`requested_token=openai-api-key`).
This key bills against the ChatGPT subscription rather than a
separate API plan — exactly what the v1.2/v1.3 design intended.
4. HealthLog stores the API key in `codexAccessTokenEncrypted`
(re-purposing the existing column to avoid a migration) and the
OAuth refresh token in `codexRefreshTokenEncrypted`. When the
key is about to age out we refresh + re-exchange transparently
before the next request.
The downstream provider is now a regular `OpenAIClient` tagged with
`providerType: "codex"` so logs and analytics still distinguish
ChatGPT-subscription billing from plain BYOK. The bespoke
`codex-client.ts` (which hit the chatgpt.com/backend-api/codex/responses
endpoint with the OAuth access token) is still on disk for now so the
prisma columns and the provider tag stay stable, but is unreferenced
by the active path.
The public Codex CLI client ID `app_EMoamEEZ73f0CkXaXp7hrann` is
shipped as a default; operators who registered a private OAuth app
with OpenAI can override via `CODEX_OAUTH_CLIENT_ID`. The
`isCodexOAuthConfigured()` gate is a no-op now because the default
always has a value, so the UI surface no longer silently hides the
"Connect with ChatGPT" button.
Tests cover the new shape end-to-end: PKCE generation, the
authorize-URL parameters, the form-encoded token exchange, the
api-key exchange, the refresh path, and the encrypt/decrypt
round-trip. The provider integration test is updated to expect a
`providerType: "codex"` OpenAIClient instead of the legacy
`CodexClient`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
P20. Switch the 16-probe batch from `Promise.all` to
`Promise.allSettled`. Before: any single probe rejecting (a transient
DB timeout, a missing index after a schema migration, etc.) would
reject the whole route and the admin overview grid would render as
blank — costing the admin the other five healthy cards' info too.
Now each probe is labelled, the rejected ones map their card's severity
to "alert" + zero/null defaults so the StatusCardGrid still draws
something, and the failed probe names are surfaced in a Wide Event
annotation (`statusOverviewProbeFailures: [...]`) for ops triage.
Adds a vitest case that mocks a single rejection on the withings probe
and asserts the response still 200s, the integrations card flips to
alert with `withings: 0`, and untouched cards keep their normal
severity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
P13. Move `checkRateLimit` below the 24h cache-return path so a request
that gets served from the cached `insightsCachedText` never consumes one
of the 2/hour LLM-generation tokens. A user reloading the dashboard a
few times no longer locks themselves out of a fresh refresh for an hour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- P6: drop hidden sm:block from the welcome subtitle and add text-muted-foreground so it stays visible on mobile but recedes from the H1.
- P10: medications card rounded-lg border p-4 -> rounded-xl border p-4 md:p-6 to match the other dashboard chart cards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>