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>
Host-load chart above /admin/system-status renders the last 2h of
load1, memory %, and disk-IO % from a per-minute pg-boss sampler.
Schema migration 0032_host_metric, /api/admin/host-metrics endpoint,
HostMetricsChart component (Recharts wrapper, next/dynamic from the
consumer), system-status section wired with the chart on top, EN+DE
i18n keys, 13 unit + 6 integration tests.
Three commits landed cleanly under their own titles (db, jobs, api);
the chart + wiring + i18n got absorbed into another agent's commit
due to a parallel-agent staging race during the verification gate's
stash/restore loop. Functionally everything is on origin/main.
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>
Backs the new chart above /admin/system-status. Reads HostMetric rows
captured by the per-minute sampler and returns the window the chart
asks for via `?since=` (30m / 1h / 2h / 6h / 12h / 24h, default 2h).
Per-row response shape is what Recharts can plot directly:
- capturedAt ISO-8601
- loadAvg1 number
- memUsedPercent 0..100, rounded to 2 decimals
- diskReadBps / diskWriteBps number | null
Disk fields are derived server-side from the cumulative byte counters
the sampler captured. Computing the per-second delta in the route
keeps the chart component stateless and means a hypothetical CLI / iOS
admin view gets the same units. The first row in any window has no
predecessor, so disk fields are null. A negative delta — counter reset
on host reboot — also surfaces as null instead of a misleading negative
BPS spike.
`meta.memTotalBytes` carries the most recent total so the tooltip can
render an absolute "4.2 GiB / 8 GiB" without a second round-trip.
requireAdmin()-gated: host telemetry is admin-only, never exposed via
Bearer tokens. apiHandler wrapper handles the wide-event annotation
("admin.host-metrics.list") and request-id propagation for free.
6 integration tests against the testcontainer Postgres pin auth
gating, the 2h default window, the diskBps derivation across a
counter-reset, the `?since=30m` filter, and the empty-DB happy path.
After the schema parse and citation cross-check pass, the wrapper
counts how many recommendations make a normative claim ("target",
"should", "normal range", "above", "below") and how many of those
carry a `referenceId` pointing into the curated medical-reference
bundle. The result lands in the active Wide-Event meta annotation
under `ai_total_recommendations`,
`ai_normative_recommendations`,
`ai_cited_normative_recommendations` and
`ai_uncited_normative_recommendation_ids` so the admin AI quality
dashboard can chart citation-coverage over time.
Observational only in v1.4.16 — a normative rec without a referenceId
is logged, never raised as a parse error. Phase B5c will flip the
check to required for severity >= "important".
15 unit tests pin the keyword heuristic (EN + DE), the coverage
counts across cited / uncited / observational mixes, and the
`generateInsight()` integration that emits the annotation only on
success (skipped on schema-failure throw).
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>
Powers the 2-hour host-load chart Marc asked for above the
/admin/system-status section. The pg-boss `host-metric-sample` queue
runs `* * * * *` and writes one HostMetric row per tick.
Sampler implementation:
- `os.loadavg()` → load_avg_1/5/15 (rounded to 2 decimals)
- `os.totalmem() - os.freemem()` → mem_used_bytes / mem_total_bytes
- On Linux: parses /proc/diskstats and sums sector counters across
physical block devices (skipping loop/ram/dm-/md/sr/fd/nbd/zram
pseudo-devices that double-count). Multiplies by the canonical
512-byte sector size for raw bytes
- On macOS / Windows / inside containers without /proc: disk fields
are NULL, the chart gracefully degrades
The disk-stats reader is injected so the unit test can stub it without
touching `node:fs`. `runHostMetricTick()` does the insert + the
`deleteMany` for rows older than HOST_METRIC_RETENTION_DAYS (default 7,
env override capped at >= 1d to guard misconfigs).
Wired into reminder-worker.ts alongside the other recurring queues.
The worker handler swallows errors to a Wide-Event warning rather than
re-queueing, because a single missed tick costs a minute of chart data
— not worth a retry storm.
8 unit tests pin retention env handling, the disk-stats null/throw
fallback paths, and the create + deleteMany call shape.
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>
Builds a SOURCES block dynamically — only references whose
applicability overlaps the metrics in the current snapshot get
listed, so a weight-only call doesn't burn tokens on ESH BP guidance.
Each line carries the id (lowercase, parser-validated), org, year,
title (locale-routed) and direct URL so the model can cite by id.
Adds a GROUND RULE that tells the model to set
`recommendation.referenceId` to the matching SOURCES id when making a
target-range or normative claim, and to leave it empty for purely
observational comparisons. The schema check from the previous commit
rejects fabricated ids — defence in depth on the prompt instruction.
Plain `getStrictInsightsSystemPrompt(locale)` stays unchanged for
back-compat with the existing assertions and any call-site that
hasn't migrated yet. The new
`buildSystemPromptWithReferences(locale, metrics)` is the path
forward.
10 unit tests pin the SOURCES-block contents, locale-routed titles,
deduplication when metrics repeat, and that the existing scope-
hardened guards (refusal pattern, citation cross-check) survive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recommendations now carry an optional `referenceId` that the parser
checks against the curated bundle from the previous commit. A
fabricated id ("invented-guideline-2099") fails parse so the model
cannot synthesise a citation for a guideline that does not exist —
defence in depth on top of the prompt-side instruction.
Optional in v1.4.16 so existing payloads still validate; v1.4.16
phase B5c ratchets it to required when severity >= "important". Also
bumps PROMPT_VERSION to 4.16.0 for the system-prompt update landing
in the next commit.
8 unit tests pin the accept-known / reject-fabricated / mixed-payload
behaviour and the round-trip preservation of referenceId through the
strict response schema.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recommendations that assert a normative claim ("BP target < 140/90",
"BMI 18.5-24.9") need to point at the guideline they came from. This
adds the static reference bundle the schema and prompt will hang
citations off in the next two commits.
Bundle covers AHA / ESH / ESC / WHO / DGE / ADA across blood pressure,
weight, pulse, mood and medication buckets. Each entry carries an EN
+ DE title, a direct https URL, the publication year, and the metric
buckets it applies to. `selectReferencesForMetrics()` picks the
overlap so a snapshot focused on BP only sees the relevant references
in the system prompt.
15 unit tests pin shape, slug uniqueness, https URL validity, year
sanity, bucket coverage and the selector helpers — so a typo in a URL
or a missing bucket fails CI before the model ever sees it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In /admin/login-overview I caught a row that should have rendered
"Nürnberg, DE" showing up as "Nrnberg, DE" instead — the umlaut was
gone. The geo source (ipwho.is) returns the city as proper UTF-8 over
the wire, and the audit-log render path is a straight pass-through, so
the corruption had to be in the geo helper itself.
Two defensive fixes, in lib/geo.ts:
1. Decode the response body as UTF-8 explicitly via `arrayBuffer()` +
`TextDecoder('utf-8')` instead of `Response.json()`. The latter
defers its charset decision to whatever the upstream
`Content-Type: application/json; charset=…` claims, which an
intermediate proxy can strip or rewrite — exactly the kind of
transit-layer surprise that produces "Nrnberg" out of "Nürnberg"
when the byte stream stays UTF-8 but the parser thinks it's
latin-1. Reading via TextDecoder bypasses that entirely; a
malformed byte surfaces as a parse failure, not silent character
loss.
2. Send `Accept-Language: de, en;q=0.5` on the lookup request.
ipwho.is and ip-api both honour it, and without the hint ip-api
falls back to the English ASCII fold ("Nuremberg" instead of
"Nürnberg"). German is preferred (DACH-skewed user base) with
English as a safety net for cities that don't have a German name.
Also audited the rest of src/ for ASCII-fold patterns that could chew
umlauts elsewhere — `String.prototype.normalize("NFD")`, slugify
calls, `[^a-zA-Z0-9]/g` strips: zero hits. So this is the only fix
needed for now.
Regression tests: roundtrip 7 known-umlaut cities (Nürnberg, München,
Düsseldorf, Köln, Würzburg, Bückeburg, Weißenfels) plus eszett, plus
a Content-Type-without-charset case to lock in the explicit UTF-8
decode, plus an Accept-Language assertion.
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 the toggles in Settings → Dashboard didn't filter
the dashboard tile-strip on reload — the v1.4.15 A4 claim was
correct in spirit but broken in the API contract.
Root cause: `widgetIdEnum` in
`src/app/api/dashboard/widgets/route.ts` was missing
`achievements` even though the default layout has it. Every PUT
that included the achievements widget therefore failed schema
validation with 422 — the toast surfaced "Layout konnte nicht
gespeichert werden" but the user never noticed because the toggle
visually flipped (local draft state) before the network call. On
reload the saved layout was the previous one, so the filter looked
like a no-op.
Fix:
- Extract `DASHBOARD_WIDGET_IDS` as the single source of truth in
`src/lib/dashboard-layout.ts`. The Zod enum in the API route is
built from it so the two lists cannot drift again.
- Hide the tile-strip wrapper entirely when the user toggled off
every tile (Marc's constraint: "immer die gesamte Spalte breit
und immer der gleichen Höhe ausfüllen" — an empty grid wrapper
was leaving a thin gap on the page).
Tests: contract test pinning that every widget id in
DEFAULT_DASHBOARD_LAYOUT round-trips through serialize → resolve,
plus an explicit assertion that `achievements` is in the default
layout.
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>
The BD-Zielbereich tile rendered 0% for users whose readings sat below
the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65).
Marc's actual production data has many readings like 117/79 — textbook
normotensive, well-controlled — that v1.4.15 A4 still counted as OUT
of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs
should be in-target by clinical interpretation; v1.4.15 reported 0.
Root cause: "in target" was defined as the narrow goal band, which is
the ESH treatment goal for hypertensive patients ON therapy — NOT the
desirable range for a normotensive HealthLog user. Users (and the
dashboard label) intuitively mean "BP is well-controlled = at or below
the ceiling, not implausibly hypotensive".
Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <=
diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50
floors guard against counting symptomatic hypotension as "good control".
Centralised the check in `isBpReadingInTarget()` so the 6 historical
call sites (analytics route, blood-pressure-status, features,
general-status, insights/cards, insights/comprehensive, insights/targets)
share one definition — pre-fix they had 5 independent inline copies of
the narrow-band formula, easy to drift on a future refactor.
Tests:
- 6 new unit tests pin the new ceiling+floor semantics including Marc's
exact production fixture (10 paired readings -> 50% under v1.4.16,
was 0% under v1.4.15).
- New `tests/integration/bp-in-target.test.ts` seeds Postgres
testcontainer with Marc's reading shape + null-tolerance + hypotension
edge cases.
Cache invalidation already correct: `measurementDependentKeys` includes
`analytics()` so adding a BP measurement invalidates the tile's
TanStack Query cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 senior-dev review HIGH H4: the mock's default response carried
the legacy v1.4.14 rich shape (classification, findings, correlations,
dataQuality, disclaimer). The v1.4.15 strict schema requires
{summary, recommendations, citations, warnings}. The default passed
only because of `aiInsightResponseSchema.passthrough()`; once v1.4.16
retires the passthrough, every test using the default mock would
silently start failing schema validation before its asserts ran.
Switch to a strict-schema-conformant default. Tests needing the
legacy shape opt in via `responses: [...]`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Phase C1 (v1.4.15) — slug-drift defence per Marc's hard mandate:
"Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht
sein." ("Slug-drift risk must NEVER happen.")
Background: the ChatGPT-Codex backend rotates its allow-list silently.
v1.4.7..v1.4.13 lost a week to chasing the rotation manually
(`gpt-5-codex` → `gpt-5.3-codex`). A single hard-coded `CODEX_MODEL`
flips invalid the moment upstream rotates it.
Solution — the client now walks an ordered fallback chain on every
fresh request series and caches the first slug that returns 200 +
`response.completed` for 1 hour. Subsequent requests skip the chain
walk entirely on a cache hit.
Spec: extends `docs/codex-protocol-spec.md` with new §7b documenting
the chain semantics, cache TTL, walk triggers (400 + "not supported
when using Codex with a ChatGPT account" / `model_not_found` /
"does not exist" / 404), non-walk responses (5xx, 429, 401-with-
refresh, `invalid_prompt`), and the all-failed structured-503 error
shape. Per Marc's v1.4.6 hard rule ("read source-of-truth before
iterating; no more 6-iteration line-fixes"), the spec was extended
FIRST and the implementation cross-referenced against it.
Implementation:
- New `src/lib/ai/codex-slug-cache.ts` — process-local positive
cache. Single-slot Map (per-account scoping is a one-line change
when v1.4.16 multi-tenant rolls out). 1 h TTL. `getCachedCodexSlug`
auto-evicts expired entries on read.
- Rewritten `CodexClient.generateCompletion()` — walks the chain
`[cached?, ...slugChain]` in order. On 401 → refresh + retry SAME
slug (auth ≠ slug-state). On 400/404 with rejection body → drop
cache, walk to next. On 5xx/429 → propagate immediately (walking
won't help). On all-rejected → throw structured 503 with
`attempted[]` list, `httpStatus: 503`, message "AI provider
unreachable — all configured Codex slugs were rejected".
- `CodexAttemptDiagnostics` exposes `{ attempted, cacheState,
workingSlug }` for Wide-Event annotations the route can read via
`client.getLastDiagnostics()`.
- `CODEX_MODEL_FALLBACK_CHAIN` env override (comma-separated). When
`CODEX_MODEL` is also set, it folds into chain position 0; the
default chain (`gpt-5.3-codex` → `gpt-5-codex` → `gpt-5` →
`gpt-4o`) survives behind it. Stable de-dup.
Tests: +20 across 2 new files.
`codex-slug-cache.test.ts` (7 tests) — TTL-edge boundaries,
invalidation, replacement semantics.
`codex-slug-fallback.test.ts` (13 tests) — cold-cache happy path,
walk on canonical 400 body, walk on `model_not_found`, walk on 404,
no-walk on 500/429/401, all-rejected → 503-with-attempted-list,
cache-hit shortcut, cache invalidation on rejection, isSlugRejection
body classifier, loadFallbackChain env-pin merge.
The 5 existing `codex-client.test.ts` cases continue to pass without
edit — the new `slugChain` constructor parameter is opt-in and the
single-slug behaviour reduces to chain length 1.
Tests: 1028 → 1048 unit pass (+20). Typecheck clean for AI files.
Lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last two surfaces from the C5 audit.
- /insights — Top-level "no data" path replaces a bare "No data
available" sentence with EmptyState (TrendingUp icon) + "Add a
measurement" CTA into /measurements. The BMI section's
height-not-set fallback gets a compact plain-variant EmptyState
(Ruler icon) + "Open account settings" CTA instead of a single
grey line that looked like a bug.
- / (dashboard) — When trendCards.length === 0 AND charts.length ===
0 (a brand-new account with no data and the Getting-started
checklist already dismissed) the dashboard rendered an empty
0-px tile strip below the welcome banner. Now paints an
EmptyState with the same quick-entry dialog wired to the CTA so
the user can log their first measurement without leaving the page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 9a74f8e — that commit's pathspec didn't pick up the
implementation files, only the new test. This commit lands the actual
EmptyState wiring described in the previous message:
- src/components/measurements/measurement-list.tsx — Activity icon,
filter-aware empty state, optional onAddFirst CTA prop.
- src/components/mood/mood-list.tsx — Smile icon, mirroring filter
semantics + optional onAddFirst.
- src/app/measurements/page.tsx + src/app/mood/page.tsx — pass
onAddFirst so the empty-state CTA opens the same dialog as the
header button.
- src/app/medications/page.tsx — refactor inline icon+text+button
block to the EmptyState primitive (Pill icon).
- src/app/achievements/page.tsx — Trophy icon for the no-achievements
state, reusing the existing noneUnlockedYet string.
Together with 9a74f8e the four feature lists now share a consistent
empty-state surface with the rest of the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Phase C1 (v1.4.15) — adds the v1.4.15 baseline scope-hardened prompt.
New file `src/lib/ai/prompts/insight-generator.ts` exports
`getStrictInsightsSystemPrompt(locale)` and a stable `PROMPT_VERSION`
tag (`4.15.0`) so future iterations ratchet the prompt without
breaking deterministic tests. The prompt's load-bearing instructions:
- Role: clinical-context summariser ONLY. Explicitly forbids
diagnosis, prescription and out-of-scope answers.
- Out-of-scope refusal pattern: when the snapshot is empty or off-
topic, the model returns a fixed JSON refusal payload (also
exported as `OUT_OF_SCOPE_REFUSAL_EN/DE` so tests can pin it
against the strict response schema).
- Zero-hallucination guard: every recommendation MUST cite a
specific snapshot field via `metricSource`; missing → omit.
- Citation cross-check: every recommendation's metricSource must
also appear in `citations[]` (enforced by the schema parser).
- Generic guideline targets only: ESH/ESC 2024 < 140/90 mmHg, AASM
≥ 7 h sleep, Saint-Maurice 2020 ≥ 8 000 steps; explicitly
forbids citing WHO as the source for a step number.
- Doctor-consult call-to-action required for every actionable
recommendation.
- JSON-only output, no markdown fences.
The prompt is NOT yet wired into `/api/insights/generate`. The route
continues to consume the rich legacy schema (`{summary, classification,
findings, correlations, dataQuality, ...}`) which the existing
`<InsightAdvisorCard />` and `<InsightsCard />` UIs render. The
integration step is scheduled for v1.4.16 alongside the UI migration —
see `docs/audit/v1416-ai-roadmap.md` (lands in the next commit of this
phase).
Tests: +18 (1005 → 1023 unit). Pin both locale variants for
diagnosis/prescription forbiddance, citation cross-check instruction,
ESH/ESC 140/90 generic guidance, doctor-consult CTA, fabricate-no-
measurements rule, and out-of-scope refusal pattern. The refusal
payloads are also fed through the wrapper to prove the model can
return them verbatim and the wrapper passes them through cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Phase C1 (v1.4.15) — citation-from-data hard requirement.
Marc, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf
medizinische Dinge." ("It must ground on medical facts / user data.")
Builds on the strict response schema (`src/lib/ai/schema.ts`) and
schema-enforced wrapper (`src/lib/ai/generate-insight.ts`) — which were
swept into sibling commit 5510ed5 by a parallel-agent index race
(documented across A2 / A4 / B1-B6 / C2 / C3 of the v1.4.15 marathon;
diff is correct on main, message scope misleading).
This commit adds a focused 11-test invariant suite that nails down the
contract end-to-end:
1. Schema-level: recommendation without metricSource fails parse;
empty metricSource.summary fails; missing type or timeRange fails.
2. Cross-check: findUncitedRecommendations() flags any recommendation
whose (type, timeRange) does not appear in citations[]. Partial
match (type alone) is rejected — both fields must align.
3. Wrapper end-to-end: orphan-citation triggers retry; second attempt
either fixes the citation OR drops the recommendation (both
valid per the retry-correction message; model is explicitly
told an empty recommendations[] is acceptable).
The two-pronged guard (schema + cross-check) ensures the model cannot
fabricate a recommendation that points at "nothing" AND cannot cite
data it hasn't surfaced — closing the v1.4.16 medical-grounding work's
prerequisite.
Tests: +11 (988 → 999 unit). All AI tests green (67 / 67).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every list/table/tile a brand-new user hits and records the empty
path. 13 surfaces flagged for upgrade across admin, feature pages,
insights and dashboard. Adds matching EN+DE i18n keys
(emptyTitle/emptyDescription/CTA copy) under measurements, mood,
medications, admin.section.users/backups, admin login-overview/api-tokens,
admin.feedback, insights, dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase C4 audit found EN/DE locale files in perfect parity (1817 keys
each, 0 drift, 0 empty values, 0 TODO/FIXME placeholders) — the five
B-agents and the C2/C3 phases each shipped their own translations
inline.
This commit hardens the parity test to lock that state in:
- empty-string assertion (per locale, fails on `"key": ""`)
- key-equals-value assertion (fails when EN==DE==key.lastSegment, with
an explicit allow-list for 5 legitimate cases: `settings.ntfy`,
`classifications.{bp.Optimal,bp.Normal,pulse.Normal,bodyFat.Fitness}`)
- TODO/FIXME/XXX/TBD assertion (per locale)
EN-only key=value cases (e.g. `"of": "of"` where DE = `"von"`) are
deliberately NOT flagged — those are natural, since key names derive
from the EN word. The dual-locale check catches the real placeholder
pattern (`"bugReport": "bugReport"` in both locales) without false
positives.
Audit report: docs/audit/v1415-i18n-coverage.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase C1 (v1.4.15) — provider abstraction review.
Adds `MockAIProvider` (`src/lib/ai/mock-client.ts`) so contract tests
can exercise the route-level interface without burning network calls
on every CI run. The mock satisfies `AIProvider`, lets tests queue a
list of canned responses, and records every `CompletionParams`
invocation for later assertion.
Adds a contract test file (`provider-contract.test.ts`) that walks
every concrete provider — Codex, OpenAI, Anthropic, Local, Mock — and
asserts each one returns a `CompletionResult` with the four
contract-required fields (`content`, `tokensUsed`, `model`,
`providerType`). Network is stubbed per provider; the test exists to
catch interface drift, not to retest each provider's wire format.
The route at `/api/insights/generate` already imports the abstract
`AIProvider` via `resolveProvider()`, so the consumer side needs no
change — this commit ratchets the contract with explicit tests so
future provider additions (medical-grounding, multi-provider redundancy
in v1.4.16) cannot silently regress the interface.
Tests: +7 (132 → 139 ai-domain). 972 / 972 unit pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
New endpoint `POST /api/internal/deploy-webhook` closes the loop on the
auto-deploy work started in the previous commit. Coolify (configured via
Settings → Notifications → Webhook with the `X-Deploy-Webhook-Secret`
custom header) calls this URL after every deploy attempt; we:
* Verify the request via `DEPLOY_WEBHOOK_SECRET` env var with a
timing-safe compare (same pattern as the Withings webhook).
* Rate-limit by client IP (60/min — comfortably above Coolify's
typical 1-2 callbacks per deploy, well below a brute-force probe).
* Normalise Coolify's free-form payload into a small struct
(`outcome`, `applicationName`, `error`, etc.) — preserving the
full JSON in the audit row so future Coolify schema drift is
forward-compatible without code change.
* Write an audit-log row with one of `system.deploy.{success,failure,unknown}`
— `unknown` covers in-progress states like "queued" so we observe
them without paging.
* On `failure`, fan a `SYSTEM_ALERT` through the existing
`dispatchNotification()` to every admin user (silent no-op if no
Telegram channel configured — matches `maybeAlertAdmins()` pattern
in `src/lib/integrations/status.ts`).
Also exports a `GET` handler so Coolify's "Test" button on the
notification config page returns 200 with a valid secret.
Tests: 12 unit cases covering auth (header missing / wrong / env var
unset), happy path (success skips alert), failure path (audit + 2
admin alerts), unknown-status path (no alert), no-admin warning,
rate-limit short-circuit, malformed status type, and forward-compat
preservation of unknown payload fields. 952 / 952 unit pass total.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Pre-v1.4.15 the page silently dropped locked achievements, so the user
only saw what they had already earned. Now every badge renders — locked
ones are grayed out with a "Locked" badge, criterion hint
({current} / {target}), and a progress bar. Unlocked entries keep the
existing primary-gradient card with the "Completed on …" footer.
Achievements are grouped by a new `category` axis (medication, vitals,
security, engagement) — derived in `getAchievementCategory()` so a
metric is always in exactly one bucket. Inside a category, unlocked
items sort first; among locked items the closest-to-unlock one ranks
first so the user always sees their immediate next goal at the top.
The sidebar link to /achievements was already in place under the
user-area group; this commit only adds the new locked/grouped render
plus an e2e smoke spec that walks the desktop sidebar → click →
heading visible path.
i18n: 9 new keys under `achievements.*` (locked, criterionHint,
progressPercent, categories.{medication,vitals,security,engagement},
dashboardCard.{title,viewAll,empty}) — EN + DE. C4's parity audit will
sweep later.
Tests: +9 unit (groupByCategory ordering + page render in EN/DE) +
1 e2e (sidebar → /achievements). 909 / 909 unit pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B2, criterion 3. The status helper introduced in commit
4db72a8 already trips the dispatcher when the consecutive-failure
counter crosses INTEGRATION_FAILURE_ALERT_THRESHOLD (default 3) and
the 24h re-alert window has lapsed. This commit:
1. Extracts the message formatter into a pure exported function
formatAdminAlertPayload() so the message shape is unit-testable
without standing up Prisma + the dispatcher. Same byte-identical
output, refactor only.
2. Locks the alert payload contract via 7 focused unit tests:
- integration display name uses "Withings" / "moodLog"
(not the lowercase storage key)
- subjectLabel falls back to userId when no email/label
- reauth_required maps to "ask the user to reconnect"
- transient maps to "investigate the upstream service"
- 4 KB stack traces are trimmed to 280 chars + ellipsis
(Telegram cap is 4096; envelope eats ~150 so 280 keeps margin)
- errorCode renders as "(503)" suffix on the reason line, or
is omitted entirely when undefined (no "(undefined)" leak)
- metadata block shapes affectedUserId / integration /
consecutiveFailures / errorCode (null when absent)
The dispatcher itself is reused as-is — no new sender, no new channel
type, no new retry path. B3 owns the dispatcher reliability surface;
we are exclusively a caller. Admins who haven't configured Telegram
get silently skipped (the dispatcher's documented behaviour).
The 24h re-alert window prevents a flapping integration from paging
on every failure: alertedAt is stamped on the trip, cleared by the
next success, and re-trips only after 24h on the same streak. The
flap-resistance is covered by status.test.ts (commit 4db72a8) and
the integration-status.test.ts case landing in the next commit.
Tests: +7 unit (formatAdminAlertPayload) on top of the +16 already
guarding the threshold/window state machine. 116 / 890 unit pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B2, criterion 2. The Phase B2 status helper (commit
4db72a8) defined a markReauthRequired / recordSyncFailure API but
nothing called it. A Withings refresh token that had revoked got the
same "transient error" treatment as a 503 from the upstream API,
scheduled syncs kept hammering the OAuth endpoint, and the Settings
UI showed "Connected" until the user manually triggered a sync and
read the inline error.
This commit wires every sync / OAuth path:
- getValidToken() (Withings): refresh failures classified by
Withings status code. 100/101/102 (auth failed / invalid token /
user gone) and 200..299 (OAuth grant errors) -> error_reauth.
Anything else (5xx, network) -> error_transient. Same logic
inlined in /api/withings/status so the proactive refresh path
parks the row too.
- syncUserMeasurements() and syncMoodLogEntries() short-circuit
immediately when isReauthRequired() returns true. Scheduled
pg-boss jobs no longer burn API quota on a parked connection.
- moodLog 401/403 from the upstream -> error_reauth. 5xx / 3xx
redirect / network / invalid-JSON / bad-format -> error_transient.
SSRF refusal also writes a status row so it surfaces in the UI.
- /api/withings/callback calls markReconnected() after writing the
new tokens, the next sync runs.
- /api/withings/disconnect and DELETE /api/settings/moodlog call
markDisconnected() to clear any prior error state. moodLog
PUT /api/settings/moodlog (re-save credentials) markReconnected()s
for the same reason.
The helper writes AuditLog action=integrations.sync.failed on every
failure (criterion 4) and dispatches a SYSTEM_ALERT notification to
admins after consecutiveFailures >= 3 with a 24h re-alert window
(criterion 3). Both paths are covered by the unit + integration test
suites that landed in 4db72a8 — this commit makes them live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>