Informational reachability gate that runs after `Build & Publish
Docker Image` finishes a `main` (or tag) build. Triggered via
`workflow_run` so it always observes the actual completion state of
the build, then:
1. pulls `ghcr.io/${repo}:${tag}` (proves the manifest finished
publishing — the v1.4.14 main run "succeeded" from a wall-clock
POV but the image had to be retagged on the deploy host because
main's manifest never finished pushing; this catches that)
2. boots the container against an ephemeral Postgres service
3. probes `/api/version` (smoke for the build SHA + version slug)
4. probes `/api/health` (smoke for the runtime DB/worker probes)
Every probe runs `continue-on-error: true` — this workflow is
informational, not a deploy gate. A transient registry blip should
surface as a yellow run, not block subsequent builds.
Also wired up `workflow_dispatch` with an `image_tag` input so we
can spot-verify any tag (`v1.4.15`, `v1.4.14`, etc.) on demand
without re-publishing.
Marc-status: filed as proof-of-concept for v1.4.15 phase-C2's
auto-deploy work — Coolify webhook listener can build on top of
the same boot harness.
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>
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>
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>
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>
Five fixes for dashboard analytics shipped in phase A4 of the v1.4.15
marathon. The detailed report at .planning/phase-A4-report.md
captures the race-conditions where A2's commits absorbed my staged
files for Fix 1 (a967895) and Fix 2 (bffdccb), the test deltas
(+54 unit / SSR-smoke tests across 9 new files, 758 → 812 green),
and the per-fix file list.
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>
Companion to 9f80040 — that commit restored the STATE.md A1 status
block but the report file was missed during staging (it was untracked
and the parallel-agent storm of the moment swapped indexes between my
`git add` and `git commit`). This commit adds the actual
`.planning/phase-A1-report.md` so the cross-link in STATE.md resolves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A1 (nav conditionals + sidebar context-awareness) shipped four
atomic fixes earlier this session — see commits 85aa15b, 786b395,
73afae0, bde167d, c63e4de. The matching planning entries were
overwritten by a sibling agent's rebase mid-session; this commit
restores the A1 status block under STATE.md and adds the detailed
`.planning/phase-A1-report.md` that the agent contract requires.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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 "Medikamente" toggle in Settings → Dashboard has existed since v1.1
but the dashboard slot only rendered a static placeholder (an icon +
the section title). Flipping it on did nothing visible — the user got
no compliance graph the way they get one for weight, BP, pulse, mood,
etc. v1.4.15 wires the slot to a real chart.
The new `<MedicationComplianceChart>` consumes the existing
`/api/medications/intake?scope=compliance&days=N` endpoint (which
already returns `{ date, scheduled, taken }[]`) and renders daily
compliance % over the selected window with a 7d/30d/90d range chip in
the header, a 80 %-target ReferenceLine, and the same Dracula tokens
the other dashboard charts use.
Days where no doses were scheduled are *skipped* rather than drawn as
0 % — compliance is undefined when nothing was due, and a flat zero
streak would mis-represent a user who simply hadn't started a regimen
yet.
Static recharts imports inside the wrapper (the dashboard's
`next/dynamic` for the wrapper itself stays). v1.4.14 phase-4 visual-
verify proved that wrapping individual primitives in `next/dynamic`
breaks Recharts' `findAllByType` reconciliation; we keep the wrapper-
with-static-imports pattern the other charts already use.
Tests:
- Unit `aggregateMedicationCompliance()`: rate rounds to nearest int,
skips days with `scheduled === 0`, clamps over-100 % rates, sorts
ascending by date, returns empty array when nothing was due across
the window.
- Component SSR smoke: title resolves through i18n, "no data" empty
state surfaces when the query is empty, the `data-slot` hook is
present so future visual-verify can target the chart.
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>
Follow-up to bde167d — the test landed but the implementation file
was unstaged at commit time (the prior `git add` only picked up the
new test file). This commit applies the actual gating: import
`useAppSettings()` and wrap the `<Button asChild>` in
`{bugReportEnabled && (...)}` so the button disappears when the
admin disables the feature.
Without this, the test in bde167d would have failed for the disabled
branch (the button would still render). Verified locally with `pnpm
test --run src/components/__tests__/error-details.test.tsx` — both
branches green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `<ErrorDetails>` panel — rendered by every Next.js `error.tsx`
boundary in HealthLog — surfaces a "Report bug" button that links to
`/bugreport`. When the admin flips `bugReportEnabled = false`, that
page renders a "Bug reports are disabled" notice. Showing the button
in that state is dead-end UX.
Reuse the `useAppSettings()` hook introduced for the sidebar nav (Fix
1) and gate the button on the same flag. The other escape hatches
(retry, copy diagnostic payload) stay visible so the panel remains
useful even when in-app submission is disabled.
Tests: SSR-render `<ErrorDetails>` with both flag states and assert
the `/bugreport` href appears / disappears accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mood-list mobile branch (`md:hidden`) rendered each entry's score
TWICE: once as a large bold digit in the left badge, then again next
to it in the title line as "{score} ({label})". Desktop's table view
only ever showed it once. The redundancy made the row read like
"2 2 (schlecht)" — the badge already supplies the digit, the title
line just needs the textual label.
Drop the duplicate digit from the title line; the row now reads
"[2] Schlecht / 12.05.2026 18:30 / tags". Add `data-testid` hooks
on the row and the score badge so the Pixel-5 Playwright guard at
`e2e/mood-card-mobile.spec.ts` can assert "exactly one occurrence of
the score digit per row" — that contract is what would have caught the
duplicate before merge.
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>
The "Skip to content" link is `position: fixed; top-2 left-2 z-[100]`
on every authenticated page. Even with `opacity-0` it still occupied
its bounding box, so a mouse click on the desktop logo (also at the
top-left corner) sometimes hit the invisible link instead.
Fix: keep the visual treatment (opacity, focus styles) but combine
`pointer-events-none` + `-translate-y-full` in the unfocused state so
the element neither receives clicks nor occupies any visible space.
On focus, both modifiers reverse — the link slides into view and
becomes interactive again. Existing keyboard flow is unchanged: Tab
focuses the link, the focus ring + translucent slide-in is preserved.
Tests: extend `e2e/a11y.spec.ts` with a Playwright check that the
desktop logo is clickable from its centre on the dashboard and the
URL stays at "/".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an `<AppSettingsProvider>` that fetches `bugReportEnabled` once via
`/api/bugreport/status` and exposes it through `useAppSettings()`. The
sidebar consumes the flag and conditionally renders the Bug Report
entry — both in the collapsed icon mode and the expanded label mode.
The bottom-nav and topbar do not surface bugreport today, so no
changes were needed there.
The provider mounts inside `Providers` (above the toaster), so any
future flag-gated UI can reuse the same hook without re-fetching the
status payload. Defaults to `true` while the query is loading or the
user is unauthenticated so the entry doesn't flicker out under the
cursor on slow connections.
Tests: SSR-render `<SidebarNav>` with both flag states and assert the
`/bugreport` href appears / disappears accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pivot STATE.md + ROADMAP.md from the completed v1.4.14 marathon (live in
prod at /api/version=1.4.14, image digest 0ced46004a54) to the v1.4.15
scaffold: phases A1-A5 (quick fixes), B1-B6 (bigger features), C1-C5
(hardening including AI/Codex hallucination-resistance and Coolify
auto-deploy), D (multi-agent QA), E (release).
v1.4.14 entries archived in-place; docs/audit/v1414-summary.md remains
the canonical release record. Phase-0 report at
.planning/phase-0-report.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc-Brief (DE) at the top with what shipped, what's fixed, perf
deltas; below it the commit-by-commit landing report, deferred items,
CI/prod state, Codex-OAuth resolution, and the v1.4.15 backlog seed.
STATE.md flipped to finished.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- typecheck/lint/test/format clean (modulo the chart test file
blocked by Marc constraint — pre-existing)
- integration suite green after the test-isolation fix in the
preceding commit (10/10 consecutive runs)
- build + e2e blocked by the documented Node-25 Reflect.get bug
on this workstation; CI Docker (Node 22) is canonical and
Phase-3 already records 41/41 e2e specs green there
- format sweep on .planning/*.md + e2e/measurement-flow.spec.ts
to clear the warnings Phase 6 reconcile listed as
pre-existing-out-of-scope
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The integration suite runs with `vitest isolate: false` (one worker,
one container — migration cost demands it). Each test file declared
its own `vi.mock("next/headers", ...)` whose factory closed over a
file-local `cookieJar` Map. Vitest resolves a mock factory ONCE per
worker — whichever file loaded first won the registration, and the
other files' Map writes silently disappeared.
That made `admin-data-wipe.test.ts` flake intermittently (~12.5% per
empirical 8-run sample) when `idempotency-replay.test.ts` happened to
load first: its `vi.mock("@/lib/auth/session", ...)` then leaked,
returning a stub user without an admin role, so `requireAdmin()`
returned 401 and the wipe assertion failed with `expected 401 to be
200`.
Fix: extract cookieJar + headerJar into module-singleton Maps in
`tests/integration/mock-next-headers.ts`. All three integration files
import them and resolve them via dynamic-import inside the
`vi.mock("next/headers", ...)` factory body, so the factory always
sees the same backing Maps regardless of which file's registration
won the race. The `vi.mock("@/lib/auth/session", ...)` stub in
idempotency-replay was also removed; the test now seeds a real
Session row + cookie like the other two files.
After: 10/10 consecutive `pnpm test:integration` runs green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.5.0 release was redirected to v1.4.14 because it is bugfix-heavy
with only additive features, so semver-patch is the correct increment.
Rename the v15-* audit notes and the v15-1 backlog accordingly, and log
the docs/landing rebrand (commits 06bc616 docs, 92c6588 landing) under
Phase 9 in STATE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-phase-4b these sections were cards on the long monolithic /admin
scroll, where the expand-to-reveal kept 100+ table rows from
dominating the overview. Post-phase-4b they each own a dedicated
route reachable via the sidebar / mobile strip — the user has already
opted into the topic by navigating there. Landing on a card with only
a header and an "Expand" button reads as "is this page broken?".
Default `expanded = true`; the toggle stays as an escape hatch.
Closes H3 from .planning/phase-6-design-findings.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The Bearer rule already covers `Authorization: Bearer hlk_…` headers,
but standalone tokens in error bodies, JSON dumps, or raw debug
strings were untouched. The idempotency replay-cache rejects bodies
containing `hlk_` / `hlr_` (CLAUDE.md headless-client-API patterns)
exactly so cached responses can never echo a token back; this commit
adds the matching egress guard for log/error surfaces.
Boundary-aware shape mirrors the v1.5 phase-2 sk- regex tightening so
benign identifiers like `html_render` or short `hlk_short` strings are
not over-matched.
Closes H4 from .planning/phase-6-code-review-findings.md.
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>
Phase 5 closed: /admin a11y test reactivated and clean, top-10
friction audit filed at docs/audit/v15-ux-friction.md, three v1.4.6
deferred items shipped (trend-arrow metric-aware, remove saved AI
key, semantic mood/feedback tokens), plus two inline friction fixes
(dashboard quick-add disambiguation, /admin nav landmark).
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>
Top-10 friction points from the canonical user journey
(login → dashboard → add measurement → view insights → admin overview
→ admin users → admin backups → settings/integrations → settings AI →
logout). Each finding has severity, file location, status (fixed
tonight / deferred), and a v1.5.1 backlog of the LOW items.
Notable: the audit was driven by static code review because Node 25
on Marc's local machine surfaces the documented `Reflect.get
private-member` regression in `api-handler.ts`, blocking both `next
build` and `next dev`. CI runs on Node 22 (canonical) and will
validate the reactivated `/admin` axe-core spec on the next push.
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>
Closes the v1.4.6 deferred polish item. When the user has saved a key
for the active provider (OpenAI, Anthropic, or Local), a "Remove saved
key" button now sits next to Save / Test connection. Clicking opens an
AlertDialog confirmation, then PATCHes `{ <provider>Key: null }` — the
existing `/api/user/ai-provider` route already treats null/empty as
"clear" (encrypted column set to NULL).
Provider, model, and base URL stay untouched so users keep their
selection while just rotating the secret. The button hides itself when
no key is stored, so the only state where it shows is exactly the one
where it's actionable.
Insights for the wiped provider fall back to the admin/Codex provider
on the next request — surfaced in the confirm-dialog body so the
behaviour is explicit.
i18n: 4 new keys under `settings.ai.*` (`removeKeyCta`, `keyRemoved`,
`removeKeyConfirmTitle`, `removeKeyConfirmBody`), EN canonical, DE
mirror. Existing `i18n-locale-integrity.test.ts` enforces parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
* `status-card-grid.test.tsx` — replaces the v1.4.6 anchor-id allowlist
with a route-existence check: every card's href must match `/admin/
<slug>` AND `slug` must be in the `ADMIN_SECTION_SLUGS` source-of-
truth, AND `src/app/admin/[section]/page.tsx` must exist on disk.
This guarantees the dynamic-route file can never be silently moved
out from under the cards.
* `sections.test.tsx` — SSR smoke test for every section component
reachable from the dynamic-route renderer. Every section paints a
recognizable heading, and the `<UserManagementSection>` filter pills
+ `<BackupsSection>` "Backup now" button are asserted so the v1.5
fold-ins for T2.6 and T2.7 don't regress silently.
Locale parity for the new admin.section.* keys is enforced by the
existing `i18n-locale-integrity` test suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `LEGACY_ADMIN_ANCHORS` map in proxy.ts that 301-redirects any
`/admin/section-<id>` path to its v1.5 dynamic-route equivalent. Pure
URL fragments (`#foo`) never reach the server, so this only catches
path-style legacy URLs (a referrer rewriter could promote a fragment
to a path segment, or someone might bookmark a hand-crafted form). The
in-app callsites that previously emitted `/admin#section-*` anchors
have all been rewritten to use the new routes directly — see the
status-card-grid commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.6 status cards linked to `/admin#section-*` anchors that
required server-side scroll into the monolithic admin page. With the
v1.5 dynamic-route refactor, every section is its own route, so each
href now points at `/admin/<slug>` — fixes the v1.4.6 T6 honesty
problem permanently.
Mapping:
Users -> /admin/users
Integrations -> /admin/integrations
Monitoring -> /admin/integrations
Backups -> /admin/backups
Maintenance -> /admin/reminders
Audit log -> /admin/login-overview
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an "Admin Console" link to the global sidebar (visible only to
ADMIN users). When the active route is under /admin/*, the link
expands into a sub-list of every section, mirroring the in-shell
section sidebar — same as Settings, where the global nav points to
the section landing and the shell drives in-context navigation.
Collapsed-sidebar mode keeps the icon-only Admin button without the
sub-list (consistent with the Bug Report and Settings buttons).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>