Bump text-[10px]/text-[11px] to text-xs on muted description, hint, and
status-label surfaces that had drifted below the type-scale floor —
coach conversation captions, settings/admin status badges, mood
confidence pills, side-effect and cycle meta lines. Tabular-nums
badges, chart-plot legend/axis chrome, ring geometry, and
width-constrained tag tiles keep their compact size by design.
The two vault knobs become editable where operators already work: the
app-settings card gains the per-file upload cap (MB, 1-100, mirrors the
hard server ceiling) and the default per-account storage quota (GB,
commit-on-blur with clamped conversion to bytes); the user edit form
gains the per-user quota override (GB, empty = instance default). The
admin users list now carries the override so the form can show it.
Refs: document-vault plan W3-T7
Migrate every remaining *-dracula-* utility site (127 lint warnings,
193 class occurrences) onto the semantic vocabulary. The swaps are
pixel-identical in both themes — each target token aliases the same
primitive the utility read, including the light-theme AA overrides:
- green/orange/cyan/red → success/warning/info/destructive
- purple → primary (same value in both themes)
- pink → the new --brand-pink token (Coach gradient, Apple-Health
badge, recording mic): dark #ff79c6 — 6.86:1 card / 5.97:1 bg;
light #a3134d — 7.32:1 card / 6.83:1 bg (computed)
- yellow sites resolve to their status meaning: warn-level log icon,
update-available pill and offline-geo hint → warning; the admin
helpful-rate ramp → success/warning/destructive; the confidence
meter mirrors the coverage-meter precedent (medium + low share the
caution tint, the lit count carries the distinction)
- the confidence-badge low tier dropped dead *-dracula-comment/*
classes (no generated utility) for border-border + bg-muted/50
Close two rule blind spots the sweep surfaced — directional borders
(border-l-dracula-red) and the parenthesised CSS-var shorthand
(bg-(--dracula-green)) — and flip healthlog/no-dracula-utility from
warn to error so the class cannot regrow. var(--dracula-*) references
in chart code stay: they are token references the light theme
overrides, and the chart palette is pinned visually.
The admin tree hand-rolled its card surfaces and stepped padding at sm:
instead of md:, so it read denser than every other route on a tablet.
Move the sections onto SettingsCard/SettingsCardHeader and the mini-rows
onto ListRow; no behaviour, data, or copy changes.
Wire the previously-unused mfaEnforced flag into a real policy. An admin can
require a second factor instance-wide (an AppSettings toggle in the General
section) or per user (a toggle in user management); the effective requirement is
the OR of the two. When required and the account has no active factor — no
confirmed TOTP, no registered security key, and no primary passkey — the user is
walled behind a forced-enrollment interstitial after sign-in, with only the
security settings and sign-out reachable until a factor is enrolled.
The gate is surfaced like the onboarding gate: a hint cookie the proxy reads
without a DB round-trip, recomputed from the database on every sign-in and on
/api/auth/me so a tampered cookie self-corrects. Nobody is locked out — the
enrollment surface stays reachable, and toggling the policy off (or the
disable-mfa CLI) is the operator's recovery path.
The dashboard snapshot fires the moment login succeeds instead of
waiting for the page chunk (start ~670 to ~200 ms throttled). Guided
follow-ups are personalised from the user's snapshot and an answer gets
the coach's streamed reaction before the next question. Archived
schedule eras can be corrected through a superseding revision that
keeps the original as an audit trail, era writes serialise per
medication, and duplicate dose times collapse in validation. A rolling
medication whose next dose is tomorrow can no longer read as heavily
overdue — the card status derives strictly from the served next-due —
and the adherence bars keep a fixed geometry in every state. The
server-wide assistant key gains its admin card, the one duplicated
coach preference collapses onto a single owner, twenty-seven admin and
settings card headers move onto the shared pattern with sixty
containers normalised, and the remaining hydration warnings are gone.
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
A night with coarse apple-health samples next to granular watch data
showed only awake and asleep — the per-session source pick now prefers
the source that knows its stages. Tour popovers clamp into the
viewport, wrap their footer and fall back to a bottom sheet on narrow
screens. Settings and admin sections share one alignment rule — icon
column left, everything else flush on the content column — with neutral
header icons, no duplicated section titles under the shell heading, and
every select on the account-style idiom with a visible chevron. The
dashboard's customize icon matches insights, the target-settings link
leaves the home charts, and the mood heatmap adopts the damped fills
the other mood charts already use.
Icons inside buttons drop their manual margins in favour of the
button's built-in gap (two hundred call sites), form field stacks settle
on one spacing step, card titles on exactly two sizes, inline field
errors on one text size, and a dozen hand-rolled loading pulses move
onto the shared skeleton primitive while keeping their silhouettes. The
intake dialog wording joins the rest of the app on 'übersprungen', and
the UI guidelines now document the two deliberate footer and
touch-target systems.
The score ring's bloom was clipped by the SVG's own viewport — the
drop-shadow sits on a child of the viewBox-filling arc — so the glow
never extended past the circle; the SVG now overflows visibly on every
host. The insight pill strip gains chevron buttons and wheel scrolling
so an overflowing row is reachable with a mouse. Safari favourites
rendered the transparent icon on a white plate; a flattened
apple-touch-icon and a maskable manifest icon fix both. The
time-of-day mood chart adopts the damped fill the weekday chart already
uses, eighteen delete confirmations move onto the destructive button
variant (one of them had the wrong text colour), five confirmation
dialogs gain pending states, the sources dialog stacks its primary
action on top on mobile, and the token form aligns input and button
heights.
Six touch-disjoint waves landed on develop before this release:
- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
35 unused exports + 52 unused types triaged to 0/0; shadcn surface
+ zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
`/api/dashboard/widgets` 422 now returns every Zod issue + worker
audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
migrated to the queryKeys factory; 9 new factory entries; ESLint
rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
sites (seven status helpers + bucket-series + bp-in-target);
Suspense double-comment consolidated; doctor-report sanitiser regex
rewritten to escape-form so diffs are reviewable; pr-detection-
worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
(transient/reauth/persistent) + WithingsApiError typed throw + the
`pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
silent-disable gap the wave was conceived to close.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.
pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
Cluster of the v1.4.21 carry-over items that each landed in ≤5 LOC.
- F-32 — drop the duplicate help paragraph beneath the dashboard
settings header; the page-level description already covers it
- F-33 / F-34 — replace `&` with "and"/"und" in the measurements and
mood subtitles
- F-35 — unify the test-notification button label
(notificationTestSend) with the user-facing notificationStatus.sendTest
- F-44 — drop the `LOKI_ENDPOINT` env-var name from the user-facing
monitoring help text; reads as "Loki integration" instead
- F-45 — admin/users table column reads "Name" instead of repeating
the page title "Users"
- F-47 — /notifications event-matrix first column reads "Event" instead
of repeating the page title "Notifications" — new notifications.eventColumn key
- F-50 — soften every DE error-toast "Bitte erneut versuchen" to
"Bitte versuche es erneut", matching the friendlier tone we already
use in the Coach error path
- F-58 — admin/reminders section header swaps Clock for Bell so the
card icon matches the admin-shell nav tile
- F-62 — DE dashboard `greetingSalutation` reads "Hallo, {name}"
instead of the English "Hi, {name}"
- D-CR-M-03 — formatTokenName regex broadens to non-Z ISO offsets so a
`+02:00` timestamp suffix renders as the locale-aware chunk like a
`Z` suffix already does
- D-DSGN-M-04 — moodLog "Copy" button uses common.copy instead of
stripping the `!` off common.copied
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The user-management role-change button rendered a bare arrow
followed by "User" / "Admin" — direction was ambiguous
("does the arrow mean 'now they are User' or 'send them down
to User'?") and the label carried no aria-label. Replaced the
arrow text with explicit "Make user" / "Make admin"
("Zum Benutzer" / "Zum Admin"), and added the same string as
aria-label so screen readers announce the action verb instead
of the visible-only arrow glyph.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
The four per-row action buttons on `/admin/users` (role toggle, edit,
reset password, force-logout) and the filter pills on both routes
were sized `h-7` (28px) — well below WCAG 2.5.5 AA's 44×44px target
that the rest of the app already honours (bottom-nav, dashboard
quick-add). Mobile users on a 360px viewport had four 28×28px buttons
crammed into the right edge of every user row.
Adds `min-h-11 min-w-11` to all six button sites. The visual icon-only
look is preserved — only the hit-area expands.
Closes H2 from .planning/phase-6-design-findings.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4b's `<SectionFrame>` already paints the canonical h1 + subtitle
for each `/admin/<slug>` route, but every section card kept its v1.4
in-card h2 with the same (or near-same) text. The result was the
section title announcing twice — once as h1, then immediately again as
h2 — both visually redundant and a screen-reader landmark anti-pattern.
Demoting the in-card label to a `<div>` preserves the visual look
(same Tailwind classes) while removing the duplicate heading. The
four sub-cards inside `IntegrationsGroupSection` (umami, glitchtip,
web-push, bug-report) keep their h2 because they live under the
"Integrations" h1 and genuinely deserve a heading level — that is the
non-duplicating case.
Closes H1 from .planning/phase-6-design-findings.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Re-enable the /admin axe-core spec that phase-3 parked while phase-4b
restructured the admin into per-section dynamic routes. The fixme is
gone, /admin/system-status and /admin/users are added as representative
sub-route audits, and the violations the original audit surfaced are
fixed:
- `src/components/admin/_shared.tsx`: drop the duplicate
`<PasswordInput>` whose toggle button had no accessible name.
Re-export the canonical `<PasswordInput>` from
`src/components/settings/password-input.tsx` (added in phase-3) which
already carries the `aria-label="Show password" / "Hide password"`
pair. Affects `/admin/integrations` (bug-report token, web-push VAPID
private key) and `/admin/users` (password-reset dialog).
- `src/components/admin/user-management-section.tsx`: icon-only edit /
reset-password / force-logout buttons now carry `aria-label` (axe
`button-name` is `serious`); the leading icon is `aria-hidden` so the
accessible name is just the action verb.
- `src/components/admin/status-card-grid.tsx`: drop the placeholder
`{ label: "—", value: "" }` tuple from the audit-log card. An empty
`<dd>` next to a "—" `<dt>` is not navigable and the `<dl>` was the
most likely source of an axe violation on `/admin`. The 3-column grid
still works with two metrics; the empty cell at the end is unrendered
because there is no `<div>` for it.
- `src/components/admin/__tests__/status-card-grid.test.tsx`: rework
the 3-metric assertion to allow 1..3 metrics with non-empty,
non-em-dash labels so the audit-log change doesn't regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
* Revert "revert: roll source tree back to v1.4.0 to restore production"
This reverts commit e444a90b10.
* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4
Three production bugs surfaced after the v1.4.0 deploy stabilized:
1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
on /dashboard and /insights for users without weight readings —
the optional chain stopped at `data` when it needed to also cover
`data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).
2. `TypeError: y?.filter is not a function` on /medications — the
medications query value was non-array (likely a stale
service-worker response from an older API shape). Replaced with
`Array.isArray(medications) ? medications : []` so the page is
immune to any future shape drift.
3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
so 1.4.2 is the single coherent release). The docker-compose
healthcheck used `localhost` which busybox-wget resolves to IPv6
::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
to 127.0.0.1.
This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.
This commit resets the working tree to commit 21bd46d (v1.4.0
release). Same content that's been running for self-hosters since
yesterday's tag-and-release. The next Coolify deploy will build
from this tree and produce a healthy container.
The v1.4.1 work is NOT lost:
- PRs #144, #145, #137, #146, #147, #148, #149, #150 remain in
git history.
- Their commits are still tagged (`v1.4.1`), still on the GHCR
multi-arch image (`ghcr.io/mbombeck/healthlog:1.4.1`), still in
the GitHub Release notes.
- Self-hosters who have already pulled the v1.4.1 image keep it.
- Local development continues from main HEAD with the v1.4.1
code — the regression only surfaced under the Coolify build
flow.
Re-applying v1.4.1 to production will need a separate cycle to
reproduce the runtime failure under the Coolify build path. That
work is tracked in docs/ops/v141-followup-issues.md (added back
when the tree is reapplied) and the deploy gating in
.github/workflows/e2e.yml will catch this class of bug going
forward.
No DB migration. No env-var change. No API contract change.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
The admin page was a single 2,702-line client component with
nineteen inline functions covering everything from system status
to feedback inbox. The status-card grid foundation in #133 already
moved the at-a-glance dashboard out; this commit moves the inner
panels.
Split into one file per logical section in src/components/admin/:
_shared.tsx 276 LOC shared types, hooks,
primitives (PasswordInput,
StatusItem, SettingsToggle,
useAdminSettings,
useUpdateSettings,
useSystemStatus,
getApiErrorMessage)
system-status-section.tsx 155
general-settings-section.tsx 54
services-section.tsx 68
umami-section.tsx 173
glitchtip-section.tsx 172
web-push-vapid-section.tsx 148
bug-report-section.tsx 109
reminders-section.tsx 318
user-management-section.tsx 341
api-token-overview-section.tsx 144
login-overview-section.tsx 181
danger-zone-section.tsx 118
feedback-inbox-section.tsx 511 (inbox + category badge
+ detail dialog)
src/app/admin/page.tsx is now a 77-line shell that imports the
14 sections and the existing status-card-grid.tsx. No behavior
change — every section keeps the same DOM, the same query keys,
the same i18n keys, the same id="…" anchors so deep-links from
the sidebar still scroll into place. UserManagementSection no
longer takes a queryClient prop; it grabs its own via
useQueryClient() like every other section does.