The 7-item bottom nav collapsed each touch target to ~50 px wide on
a 375 px viewport — only just-passing WCAG 2.5.5's 44 px floor and
visibly cramped on iPhone-13-mini-class screens. The mobile audit
(`.planning/phase-A5-mobile-findings.md`) recommended 5+More.
Primary strip keeps the four most-used surfaces plus Insights:
Home, Measurements, Mood, Medications, Insights. Targets and
Achievements move into a Radix `<Sheet>` triggered by a sixth
"More" entry. Order matches the v1.4.15 layout for the four core
items so muscle memory holds.
The More button mirrors the same `min-h-11 min-w-11` hit area;
`aria-haspopup="dialog"` + `aria-expanded` keeps screen-reader
behaviour tight; the sheet renders bottom-anchored with safe-area
padding so the inner links stay reachable above the iPhone home
indicator. Active styling lights the More entry whenever any of
its overflow children is the current route, so the user always
knows which strip-level link "owns" their position.
When `/api/admin/status` 500s (rolling deploy, transient DB blip),
the section's error state previously rendered a static "refresh the
page" message — forcing a full reload + cookie revalidation just to
recover from a 5-second hiccup. Pairing the alert with an inline
Retry button (`refetch()` from React Query) lets the admin recover
in-place without losing scroll position or any other open admin tab.
The button is `min-h-9` desktop-tight (this is admin, not mobile-
critical), shows a spinner while `isFetching`, and disables itself
during the request. The German + English message strings drop the
"refresh the page" tail since the button now does that work.
Mobile audit (`.planning/phase-A5-mobile-findings.md`) flagged the
`<TabsList>` primitive as overflowing the viewport on `/insights`,
`/admin` and several `/settings/*` surfaces — six i18n-driven tab
labels at typical German widths blow past 375 px before the inline-
flex shrink-wraps. Labels can't be truncated deterministically (they
include "Auswertungen", "Sicherheit & Privatsphäre"-style strings)
and word-wrap inside a tab is the worst possible look.
The list now scrolls horizontally on mobile via `overflow-x-auto` +
`touch-pan-x` (iOS PWA gesture). `max-w-full` prevents the inline-
flex from forcing the parent wider. Desktop renders identically
because the existing parent containers all give the strip enough
room to lay out without scrollbars. Vertical-tabs orientation keeps
its `overflow-x-visible` to avoid double-overflow with the
flex-column layout.
The AI section now renders a small read-only summary of the user's
fallback chain: "Active provider: ChatGPT (Codex)" plus "Configured
chain: ChatGPT (Codex), OpenAI (your key), Admin OpenAI". When the
in-process cache reroutes to a fallback (the primary 401'd in the
last hour), the line gains "(cached: OpenAI)" so the user can see the
runner has shifted without digging through the wide-event log.
The full management UX (drag-to-reorder, on/off toggles, add/remove
provider entries) is owned by phase B2 — this surface is deliberately
tight so the two phases don't collide. The card hides itself when no
provider is configured at all so accounts that haven't set up AI
don't see an empty header. EN + DE i18n keys added under
settings.ai.providerChain.*; provider-type labels are localised so
"Local model" reads "Lokales Modell" in German.
The new GET /api/insights/provider-chain endpoint is the data source.
Returns the resolved chain in priority order plus the cached active
provider (when set), wrapped in the standard apiSuccess envelope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v1.4.16 B1a polish across all dashboard charts. The
medication-compliance chart already shipped 7-day-trend + target/
threshold reference lines from A6; this commit brings it into visual
parity with BP/weight/pulse/mood:
- ComposedChart replaces LineChart; a gradient-filled Area paints
under the compliance line. The medication gradient uses the purple
Dracula token, matching the existing line stroke.
- Rich tooltip via RichChartTooltip — date label, compliance
percentage with tabular numerals, delta-vs-target sub-line that
reads "−15 pp vs. target" so the gap to 100 % is immediately
visible.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 daily points.
Existing "no scheduled doses" empty state is preserved (the user
could have a long history of skipped scheduled days that drops
the chart to fewer than 3 actionable points).
The 80 % minimum-acceptable threshold and 100 % goal ReferenceLines
from A6 stay untouched; gradient plus those lines now make the
"target range" feel like a band rather than a pair of guides.
1 new test pins the gradient SSR contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
under the score line. The mood gradient uses the lavender token
(--dracula-lavender / #d6acff) which matches the existing line
stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
window (≥5 points required). Faint dashed line labelled "Your
normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
The Recharts `dot` prop accepts a function returning an SVG node;
we render <text> centred over (cx, cy) so the glyph sits on the
line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.
1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WCAG 2.5.5 Level AAA target-size floor for non-essential controls is
24×24 px and Level AA pointer-target is 24×24 with 24 px gap, but the
mobile-app convention everyone follows is the 44×44 hit-area Apple
HIG / Material recommend. The default `<Button size="sm">` in the
shadcn-base lands at `h-8` (32 px) — readable on desktop, tight on
phones, especially in the most-touched admin/settings/onboarding
surfaces called out in the v1.4.15 design review.
Cross-cutting `button.tsx` `h-9 → h-11` was rejected as too risky
mid-marathon (every UI affected, dozens of layout assumptions). The
targeted alternative bumps the listed call-sites with `min-h-11`
overrides: tour Skip / Back / Next, notification re-enable + send-
test, backups Restore / Download / Run-now / Upload. Admin-only
recent-audit-preview is a `<Link>` not a `<Button>` and is left
alone.
The shadcn `min-h-` Tailwind utility wins over the variant `h-`
because both apply but `min-h-` only floors. So desktop visuals stay
within ~4 px of the previous render while mobile reaches the 44 px
floor.
Both `healthlog-tour-session-dismissed` and `healthlog-tour-referrer`
were un-scoped global keys, so a browser shared by two HealthLog
identities (admin impersonating a target user, family laptop, etc.)
inherited the first user's "tour dismissed for this session" flag —
the second user logged in and silently never saw the spotlight tour.
Both keys are now suffixed with `:${userId}`, so each identity's
state stays independent. The two builders (`tourSessionDismissedKey`
and `tourReferrerKey`) are exported so the wizard's `persistAndExit`
shares the same wire format and a unit test pins the scheme against
future drift.
One-off cost: every currently-open tab re-fires the tour once on the
next load because the legacy un-prefixed keys are no longer read.
Acceptable tax for multi-tenant correctness ahead of v1.5.
Phase B6 commit 4 of 4.
* `sections.test.tsx` gains two SSR smoke tests for the renamed
`<ThresholdsSection>` (was `<ThresholdsSettingsSection>`) — one
asserts EN heading + editor mount-point, one asserts the DE
heading. The mock for the inner editor was already updated in
commit 2 of this phase.
* New `sections-i18n-parity.test.ts` walks every slug in
`SETTINGS_SECTION_SLUGS` and asserts both `.title` and
`.description` resolve to non-empty strings in EN AND DE — and
that the DE description is not a verbatim copy of the EN one.
The test caught a real instance: notifications.description was
"Telegram, ntfy, Web Push." in both locales because the channel
names are proper nouns. Rewrote the description in both locales
so it actually reads as German vs. English while still listing
the channels.
Settings tests: 63/63 green. Typecheck clean. Lint clean (12
pre-existing warnings carried over from the baseline gate, 0 errors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6 commit 2 of 4.
* `thresholds-settings-section.tsx` (route wrapper) renamed to
`thresholds-section.tsx` so the filename + default export match
the slug — every other section in `src/components/settings/`
follows the `<slug>-section.tsx` `<SlugSection>` convention.
* The historic inner editor at `thresholds-section.tsx`
(`<ThresholdsSection>`) is moved out to
`thresholds-editor-section.tsx` (`<ThresholdsEditorSection>`)
so the names stop clashing.
* Rewrites three muddy section descriptions in EN + DE:
- account: lists every concern the section actually covers
- api: drops the misleading "headless clients" / "Drittanbieter"
framing for "your own scripts and apps"
- advanced: post-B7 there's no Import surface here — describes
the danger zone honestly.
No user-facing setting removed, no slug renamed, no i18n key removed.
Settings smoke tests still green; the route mount goes through
`<ThresholdsSection>` exactly as before, just from the renamed file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HealthChart wrapper is the single component the dashboard uses for
every numeric vital — BP systolic/diastolic, weight, pulse, body fat,
sleep, steps. This commit upgrades it once with the v1.4.16 visual
leap so all those metrics jump together.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area can
paint under the line. Each metric gets its own
chart-gradient-inline-{type} <linearGradient> definition; default
35 percent top alpha → 0 percent bottom matches the Apple Health
Trends look. A sibling 0×0 SVG mirrors the gradient so SSR markup
carries the slot for visual-regression and e2e probes.
- Personal baseline reference line: 90-day rolling median per type,
painted as a faint dashed line labelled "Your normal" / "Dein
Mittel". The new computePersonalBaseline() helper returns null when
fewer than 5 points exist so a baseline never renders off noise.
- Rich tooltip via the new RichChartTooltip primitive: rounded card
with drop shadow, per-row coloured dot, tabular-nums value cell, and
a delta-vs-baseline sub-line ("+3 mmHg vs. your normal"). Auxiliary
rows from the moving-average / trend overlays are filtered out so
the card stays focused on the primary metric.
- 600ms ease-out animation on first render via Recharts'
isAnimationActive prop, suppressed when prefers-reduced-motion is
set at the OS level (new src/lib/charts/reduced-motion.ts helper
with 4 unit tests).
- Sparse-data placeholder via ChartEmptyState when fewer than 3
daily points exist — a near-empty chart looks broken, the
placeholder reads as intentional.
In-target zone shading was already wired via the existing valueBands
prop and stays untouched. The dashed peer's labelling is unchanged.
7 new tests pin the contract (4 baseline-helper, 2 SSR markup, 1
gradient-id namespacing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a JSONB column on the users table that stores an ordered list of
provider preferences for multi-provider redundancy. Each entry carries
{ providerType, priority, enabled }; credentials still live in the
dedicated encrypted columns so AES-256-GCM key rotation keeps working
unchanged. Null preserves the legacy single-provider resolution path.
Ships a defensive parser/serializer pair with a sensible default chain
(codex -> openai -> anthropic -> local -> admin-openai) so a malformed
JSON on disk falls back to the default rather than 500-ing insight
generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the `app-logs` row to `<AdminShell>` so the new section shows up
in the admin sidebar (desktop) and mobile section strip alongside
Login Overview and Backups. Uses `lucide-react`'s `FileText` icon
because nothing else in the bundle reads as "structured log lines".
The mobile-strip / desktop-sidebar pattern follows the conditional
admin-only nav established in v1.4.15 A1 and v1.4.16 A1: the global
sidebar still surfaces a single `/admin` link, and the per-section
nav lives inside the admin shell.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four `/api/export/*` route handlers landed under plain segments
(`/api/export/measurements` instead of `/api/export/measurements.csv`)
because vitest's resolver chokes on dotted route segments. The
`<ExportSection>` component still pointed at the old dotted paths —
which 404'd in the browser even though the routes themselves were
healthy. Realign the four endpoint URLs so the consolidated Settings →
Export UI actually downloads.
Filename + Content-Disposition still carry the `.csv` / `.json`
extension so the browser writes a correctly-named file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the in-memory ring buffer (`src/lib/logging/in-memory-buffer.ts`)
into `transports.emitEvent()` so every wide event that survives the
sampler also lands in a 500-entry per-process buffer. Adds the
`app-logs` admin section slug, the renderer case, and the route entry
exports needed for the new section component to mount.
The buffer is volatile and per-process — a worker-only event won't
show up in the web buffer, and a restart drops everything. The section
header copy spells that out so admins know to keep Loki configured for
durable diagnostics. Egress through `/api/admin/app-logs` runs every
event through `redactSecrets()` so a stray Bearer / hlk_ / sk- token
in an `error.message` never leaks to the admin UI; storage stays raw
to keep the diagnostic value when shipped to Loki.
The companion buffer + transports tests live under
`src/lib/logging/__tests__/in-memory-buffer.test.ts` and
`transports-buffer.test.ts` (committed separately as part of the
buffer scaffolding). The route-level tests in
`src/app/api/admin/app-logs/__tests__/route.test.ts` cover newest-
first ordering, traceId / level / action filters, the `limit` cap,
secret redaction on egress, and the Zod-rejected invalid `level` case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local clean-clone verification confirms Wave-A introduced no
regressions: typecheck/lint/unit/integration all green at origin/main
HEAD `94c748d`. CI Security & Quality consistently passes; the failing
Integration tests + e2e workflows are pre-existing infrastructure issues
(failure shape identical at v1.4.15 release commit `0985c93`), not
Wave-A source regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 atomic commits on origin/main land the curated reference bundle,
schema validation, prompt injection, citation-coverage logging and
the UI footnote. PROMPT_VERSION bumped to 4.16.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a recommendation carries a `referenceId` pointing into the
curated medical-reference bundle, render a small footnote underneath
linking to the guideline. Format: "Source: ESH 2023 — ESH 2023
Guidelines for the Management of Arterial Hypertension" (locale-
routed title), with a tiny external-link icon, opens in a new tab via
target="_blank" rel="noreferrer". Per the v1.4.16 research doc §7
the URL never opens in an in-app webview.
`InsightResult.recommendations` widens to accept either the legacy
plain-string shape OR a structured `{ text, referenceId? }` object so
existing rendering paths keep working while the new citation surface
plugs in through the same component. `getMedicalReferenceById()`
returns undefined for unknown ids — the UI silently drops the
footnote in that case rather than rendering a broken link (defence
in depth on top of the schema validation).
i18n keys `insights.recommendation.source` and
`insights.recommendation.viewSource` land in both EN and DE.
6 component tests pin the no-footnote / EN-locale / DE-locale /
unknown-id-defence / multi-rec paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The doctor-report PDF entry-point used to share the Advanced section
with CSV/JSON exports — three buttons crammed onto one row with no
filter UI in front of them. v1.4.16 phase B7 promotes export to a
first-class settings section, so the doctor-report card now lives
inside `<ExportSection>` (its own card with the date-range dialog
prominently surfaced via "Configure & generate").
`<AdvancedSection>` is reduced to the danger-zone card alone — the
"wipe all data" surface should never live next to a one-click export
button anyway. The matching e2e spec follows the move from
`/settings/advanced` to `/settings/export` and now drives the dialog
through the submit step instead of expecting an inline button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new `export` slug is now part of `SETTINGS_SECTION_SLUGS`, so the
dynamic `/settings/[section]/page.tsx` route picks it up via
`generateStaticParams()` and the existing 404-on-unknown guard. The
shell sidebar gains a Download-iconed entry between API and Advanced;
the placeholder fallback also learned the new icon so a future slug
addition can't crash type-checking.
The shell-test suite now expects ten sections (was nine) and asserts
that `/settings/export` is a real link in the rendered nav. Old
"advanced" copy already trimmed in the previous commit, so the export
entry is the new home for every "give me my data out" path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the consolidated `<ExportSection>` component for the new
`/settings/export` route — one card per export type (Doctor Report PDF,
Measurements CSV, Medications CSV with optional intake-history toggle,
Mood CSV, Full JSON backup) with date-range filters where applicable.
Mobile-first: cards stack on small viewports and switch to a 2-column
grid on `md+`. Each card carries a stable `data-testid` so the e2e
suite can target them without relying on the localised label.
i18n keys live under `settings.sections.export.*` in EN + DE and the
old "advanced.description" copy was tightened so the upcoming wiring
commit can move the export entry-point out of `/settings/advanced`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc: "Bei Medikamenten zum Beispiel wird jetzt nicht 7-Tage-Schnitt,
Trend und Zielbereiche angezeigt. Das möchte ich natürlich für alle
Charts."
The medication-compliance chart shipped in v1.4.15 A4 (replacing the
static placeholder) was missing the trend chip + target-range
visualization the BP/weight/mood charts already paint. Bringing it to
parity:
- 7-day trend chip in the header: signed delta in percentage points
with a metric-aware sentiment colour (rising compliance → green,
falling → orange) and an arrow icon. Mirrors the bg-muted/40 chip
pattern HealthChart uses for its weekly/monthly bucket label.
- New `computeMedicationTrend7d()` helper computes the delta as the
mean of the most-recent 7 daily rates minus the mean of the prior 7
(capped at a 14-day window). Mean-difference rather than slope so a
single outlier day doesn't wag the indicator.
- Target-range visualization: added a 100 % goal ReferenceLine (green,
solid-ish) ALONGSIDE the existing 80 % minimum-acceptable threshold
(yellow, paler dash). Two lines together visualise the target range
the way HealthChart's targetZone band does for BP/weight tiles.
Tests:
- 6 new unit tests pin computeMedicationTrend7d() including direction,
delta, the 14-day cap, short windows, stable noise rejection.
- The existing chart-render snapshot is extended with an explicit
assertion that the trend chip is suppressed in the empty state
(so the empty-state header doesn't paint a misleading "0 pp"
indicator).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported clicking "All" on a chart's range buttons displayed
"+0.0 kg/Woche" even when his weight clearly drifted across years.
Root cause: `weeklyDelta = (lastTrend - firstTrend) / windowDays *
7` is mathematically correct but the per-week rate becomes
vanishingly small for multi-year windows (~0.07 kg/week for
10 kg over 3 years), and the 1-decimal `fmt.number(delta, 1)`
formatter rounds it to "0.0".
Fix: extract a pure `computeWindowTrend()` helper at
`src/lib/analytics/window-trend.ts`. For windows ≥ 90 days we
additionally surface a split-half delta — the mean of the
second half of the visible series minus the mean of the first
half. That number cannot round to zero unless the metric truly
didn't move, and it's exactly what Marc wants to see when he
asks "what changed across all my data?". For short windows the
per-week rate stays the only display because it's still the most
actionable metric there.
The chart trend line surfaces both: "WEIGHT: +0.1 kg/week
(0.1 %/week) · Total +5.4 kg (6.7 %)" on the All view of a
multi-year account.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported v1.4.15 A4 didn't fully land:
- DE label still read "7-Tage-Schnitt" / "7T-Schnitt" in places
- mood tile showed no numeric trend delta even though other tiles did
i18n rename:
- de.json + en.json: trend7dShort, movingAverage7d, moodMA all read
"7-Tage-Trend" / "7-day trend". The legacy "7T:" / "7d:" header on
the avg7 line is preserved for tiles that haven't been migrated to
the trend7Delta API.
trend-delta fallback:
- summaryToTrend7Delta() now falls back to slope30 (projected onto
7 days) when slope7 is null. Mood entries are typically logged
less than daily, so slope7 needs ≥ 2 points within the trailing
7-day window — for sparser users that came back null and the tile
silently dropped the delta indicator. slope30 still answers "is
this metric drifting?" so we project that onto the 7-day window
for label parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 A2 hid columns + tightened card padding, but Marc still saw a
scrollbar on prod at /admin/api-tokens on Pixel-5 (393 CSS px). The
fundamental issue is that even with three columns visible, long
permission lists or token names could push the scroll wrapper wide
inside the card. Mirror the `<UserManagementSection>` pattern instead:
gate the desktop `<table>` behind `hidden md:block` and render a real
mobile card-list at <md.
The mobile card-list:
- One <li> per token, padding p-3, no horizontal scroll wrapper.
- Token name uses `truncate break-all` so even a hex-suffixed name
wraps within card width.
- Permission badges sit in a `flex-wrap` row.
- Last-used + Created collapse to short meta lines (no nowrap).
- Status badge floats next to the name on the same flex-wrap row.
Tests:
- `api-token-overview-responsive.test.tsx` rewritten to assert the new
invariants: hidden md:block on the desktop table; md:hidden on the
card-list <ul> with `data-testid="admin-tokens-mobile-list"`; no
overflow-x-auto inside the mobile container; meta paragraphs wrap.
- New e2e spec `admin-api-tokens-mobile.spec.ts` mocks a worst-case
payload (long username, multiple permissions, long token name) and
asserts `documentElement.scrollWidth <= window.innerWidth + 1` at the
Pixel-5 viewport on /admin/api-tokens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the Settings entry pattern verbatim: the global sidebar's Admin
entry is now a single link with no sub-item expansion at any route.
v1.4.15 A1 had gated the expansion behind `onAdminPage`, but Marc
re-flagged it — the in-shell `<AdminShell>` already provides per-section
nav inside the page, so the global sidebar duplicating it on `/admin/*`
was unwanted UX. Removing the entire `{onAdminPage && <ul>...</ul>}`
block also kills the perceived link between the gravatar dropdown and
sidebar state — they're independent concerns now.
Tests updated:
- `sidebar-nav.test.tsx` — every admin-route case now asserts ONLY the
single `/admin` link is present, never sub-route links.
- New e2e spec `sidebar-admin-no-expand.spec.ts` covers (a) /admin/* shows
one link, no sub-list; (b) clicking Admin from /dashboard navigates
without flashing sub-items; (c) opening the gravatar user-menu does
not expand sidebar admin sub-items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Class-order + line-length normalisation on tour.tsx, mock-client.ts,
and codex-slug-fallback.test.ts after the focus-trap / strict-schema /
inspect-helper-removal commits. No functional changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase D design review HIGH H5: Withings refresh-token failures can
arrive as 250+ char URL-encoded JSON bodies. Without `break-words`
the inline span pushed the parent card past viewport width on mobile,
causing horizontal scroll on the Settings -> Integrations page during
a degraded state. Add `min-w-0 break-words` to the error span so
long error text wraps within the banner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase D design review HIGH cluster (H1, H2, H3) on the v1.4.15 B5
onboarding spotlight tour:
- H1: tab cycles inside the tooltip's three actions (Skip/Back/Next),
shift+tab honoured; on close the previously-focused element regains
focus so Settings -> Account "Restart" returns the caret correctly
- H2: tooltip card gains `max-h-[80vh] overflow-y-auto` so DE copy
with longer translations can scroll inside the card without ejecting
the Next/Done button below the fold on small viewports
- H3: backdrop button gets `focus-visible:ring-2 ring-primary` so
keyboard users see a visible focus signal when the tour is active
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Wires the EmptyState primitive into the four feature lists. Each path
now exposes an icon + localized title + description + (where it
applies) a CTA bound to the parent dialog so brand-new accounts get a
single-click path to logging their first datapoint.
- /measurements — Activity icon, "Add your first measurement" CTA
wired to the page's Add dialog. Filter-aware: switches to "no
measurements match this filter" with a "Show all types" reset
whenever a non-default type filter is active.
- /mood — Smile icon, "Log your first mood" CTA, identical
filter-aware split for the mood-level filter.
- /medications — Refactor inline icon+text+button block to the shared
primitive. Pill icon, "Add first medication" CTA → openCreate.
- /achievements — Trophy icon. Reuses the existing
achievements.noneUnlockedYet string to keep C4 i18n sweep work
minimal.
New unit test (measurement-list-empty) asserts the primitive mounts,
the Add-first CTA respects the optional onAddFirst prop (no orphaned
button when the parent doesn't wire it), and the reset-filter CTA
stays out of the default-filter render.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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 — fourth (and final) commit. Closes acceptance
criterion 4.
Adds a Tour-replay card to Settings → Account, between Password and
the password dialog. The card:
- Calls `POST /api/onboarding/tour` with `{ completed: false }`
so the server flips `users.onboarding_tour_completed` back to
false. Idempotent: same DB write either direction; no audit
log noise on the reset.
- Refetches `/api/auth/me` so any in-memory <TourLauncher>
re-evaluates with the fresh flag.
- Dispatches a `healthlog:tour-restart` window event so a
launcher already mounted on the dashboard (e.g. user has a
dashboard tab open in another window) reopens the spotlight
immediately, without waiting for the user to navigate. The
launcher's listener clears the session-dismiss flag too.
- Surfaces a confirmation message via the same status pattern as
every other settings save (success / error / null).
i18n: reuses the `onboarding.tour.restart`,
`onboarding.tour.restartHint`, `onboarding.tour.restartConfirmation`
keys that landed in commit 2/4 (EN + DE). No new strings.
SSR test in `sections.test.tsx` locks the contract: the Restart
button is present in the static markup and no raw `onboarding.tour.*`
key leaks past the i18n layer.
Tests: my 40 unit tests pass (was 39 — +1 for the new SSR
assertion). The 12 unrelated failures observed in the full suite
all live in uncommitted B6 doctor-report work in the working tree
(`useEffect not defined` in advanced-section.tsx, missing
`lastReportPracticeName` in unmigrated DB schema fixtures, etc.).
Lint clean for B5 files; the single B6 lint error in
`doctor-report-dialog.tsx` is out of scope.
End-to-end shape:
Phase B5 acceptance criteria:
[x] DB migration 0030 + Prisma schema flag
[x] Tour component (state machine + spotlight + i18n + a11y)
[x] Auto-launch on first dashboard load
[x] Settings → Account → Restart tour button
This is the last commit in the B5 batch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Adds the actual `<RecentAchievementsCard>` component referenced by the
dashboard wiring in the previous commit. The previous commit's `-o`
pathspec scope accidentally dropped these new untracked files; without
them the dashboard import resolves to an empty module and breaks the
build. Splitting into two commits per `verification-before-completion`
rather than amending so the parallel-agent push race can't lose the
landing.
The card itself: TanStack Query against
`/api/gamification/achievements`, `pickRecentUnlocks(achievements, 3)`
to slice + sort by completedAt descending, locked entries dropped.
Empty state painted with a CTA + link to `/achievements`.
Test file: `pickRecentUnlocks` ordering / cap / no-date fallback +
empty-state CTA render + three-most-recent render shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
v1.4.15 Phase B5 — second commit. Adds the spotlight-tour overlay
that walks new users through 5 dashboard features after they finish
the wizard at /onboarding:
1. Tile strip (BP / weight / pulse / mood)
2. Quick-add menu
3. Insights (AI-assisted summaries)
4. Settings → Integrations
5. Achievements (omittable via prop if B4 hasn't shipped)
Architecture:
- `src/lib/onboarding/tour-state.ts` — pure state machine. Steps,
navigation helpers (`nextStep`, `prevStep`, `skipTour`),
completion outcome (`"completed" | "skipped"`). DOM-free so the
Node-environment vitest setup can exercise it directly. 13 unit
tests cover step list, navigation, idempotency once finished,
and the 1-based step counter.
- `src/components/onboarding/tour.tsx` — UI layer. In-house
spotlight (no new dependency — package.json had no
react-joyride / intro.js / shepherd already, and the surface
area didn't justify a 30 KB add). Renders a fixed full-viewport
overlay with a polygon `clip-path` cutout aligned to the live
target, plus a tooltip card next to it. Targets are looked up
via the stable `data-tour-id` attribute (NOT class names — the
contract has to survive Tailwind churn). Re-measures on resize
and scroll. Falls back to a centred-screen tooltip if the
target is not in the DOM (mobile breakpoint, conditional
render) so the tour never blocks indefinitely.
- i18n: 19 new keys under `onboarding.tour.*` (EN + DE), one
title + body per stop plus `next`/`back`/`skip`/`done`/
`restart`/`stepOf` strings. The component test asserts the
German strings resolve end-to-end via `<I18nProvider initialLocale="de">`.
Accessibility:
- `role="dialog"` + `aria-modal="true"` + `aria-labelledby`,
polite live region announces step transitions, focus moves to
the primary action on every step.
- Keyboard: Esc = skip, ArrowRight / Enter = next, ArrowLeft =
back. Inputs / textareas / contenteditable nodes are
explicitly NOT hijacked (defensive against future tour content
that might embed an input).
- `prefers-reduced-motion` disables the backdrop fade.
Tests: 917 / 917 unit pass (was 890 — +27 across 2 new files).
Lint clean (13 pre-existing warnings unchanged). Typecheck clean
for B5 files; dashboard-layout / sibling-agent typecheck issues
are pre-existing and out of scope.
The component is "dumb" — it takes `onClose(outcome)` and lets the
caller persist the flag. The launcher wiring lands in the next
commit (B5-3) so this commit is small and orthogonal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B3, criterion 3. Wires the new <NotificationStatusCard />
into the Notifications settings section as the FIRST card, so the
state of every channel (Active / Auto-disabled / Sending paused) is
visible above the per-channel config.
Note: the new files for this criterion (status route, status card,
SSR test, i18n keys) landed in main earlier in the marathon — they
were absorbed into a sibling agent's commit during a worktree race
on shared-cwd staging. Commit 7c32d63 contains those files; this
commit completes the wiring.
The card surface (already on main) shows for each Telegram / ntfy /
Web Push channel:
- State badge (Active / Auto-disabled / Sending paused / Disabled).
- Last successful send timestamp (locale-formatted).
- Last failure + reason (e.g. web_push_410_gone).
- Consecutive failure counter (only when > 0).
- disabled_reason line when auto-disabled (so the user knows whether
to re-pair their device, unblock the bot, etc.).
- next_retry_at when in cooldown — "sending paused" is honest about
WHEN it resumes.
- "Re-enable" button — only painted for auto_disabled. Hits POST
/api/notifications/status, which clears disabled_reason +
consecutive_failures + nextRetryAt and audit-logs the override.
- "Send test" button — delegates to the existing per-channel test
endpoints. Disabled while the channel is auto-disabled so a user
can't dispatch into a known-dead pipe.
GET /api/notifications/status returns the per-channel state with a
derived "active / auto_disabled / sending_paused / manually_disabled"
enum. POST { channelId } re-enables an auto_disabled channel via
channel-state.reEnableChannel().
Tests: 6 SSR-smoke tests cover every state branch + EN/DE locale
labels. 883/883 unit tests pass; typecheck clean; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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 HIGH: at 393px the passkey table rendered with
`min-w-[620px]` inside `overflow-x-auto`, which hid the right-most
column entirely — including the destructive delete action. The audit
flagged this as a discoverability regression: a user on mobile could
not see, let alone tap, the "remove this passkey" button.
Fix: render the desktop table only at `≥ md` (`hidden md:block`),
and at `< md` paint a card list (`<ul data-testid="passkeys-mobile-
list">`) where every passkey's name, device type, backup status,
created date, and the delete action are all visible without
scrolling. The mobile delete button uses `min-h-11 min-w-11` (44px)
to meet WCAG 2.5.5; desktop keeps its denser `h-8 w-8` because mouse
pointers don't need 44px.
Findings ID: A5/HIGH `/settings/account` — passkey table responsive.
Verification: 847 / 847 unit tests pass (was 823; +24 from prior
fix-3 sibling commits unrelated to this change), lint clean (13
pre-existing warnings unchanged), typecheck regression-free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 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>
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>