The GET response used to surface the resolveProviderChain()-filtered
list, which DROPS disabled entries upstream. The Settings UI then
seeded `enabled: true` for every returned row, so disabling an entry
made it disappear on next refetch and lose its toggle position —
the toggle was functionally a "remove from chain" button.
Changed GET to read the *persisted* chain via parseProviderChain() so
disabled rows still round-trip with their flag. The active-provider
summary still uses resolveProviderChain so the displayed "Active: …"
line continues to reflect the actual runner ordering (enabled-only).
The Settings UI seeds entries with the wire-supplied `enabled` value
instead of always-true, so re-enabling becomes possible without a
page reload.
4 unit tests pin the GET shape (persisted-chain ordering, disabled
entries surfaced, cached-active overlay, default-chain fallback).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
H5 — `POST /api/insights/feedback` had no rate limit. The unique
index `(userId, recommendationId, recommendationText)` blocks exact
replays but a malicious client could distort the daily aggregator's
bucket slice by varying recommendationId/text per request. Added
`checkRateLimit("insights-feedback:<user.id>", 60, 60 * 60 * 1000)`.
60/h is generous given a comprehensive insight rarely has >10 recs.
H6 — `<RecommendationFeedback>` invalidated the entire `["insights"]`
prefix on every thumbs click, which matches all 8 insights queries
(comprehensive + 7 per-status). None of them depend on the feedback
row. The 24h server cache absorbs the actual LLM round-trip but the
network thrash + stale-cache refetch was waste. Dropped the
invalidation; user-visible verdict state lives in localStorage +
local React state. v1.4.17's prompt-version ratchet will scope its
own invalidation when feedback feeds the next generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The comparison overlay was added to BP / weight / pulse / mood charts
in B8 but the medication-compliance heatmap doesn't render a
prior-period overlay (the underlying intake-event window comparison
is deferred to v1.4.17). Without an explicit indicator the user
toggling Vormonat would see this card stay static and read it as a
broken toggle. Added a short "Comparison N/A for compliance" caption
under the header when the global compareBaseline is active so the
asymmetry is visible. EN+DE i18n.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`redactEventForEgress()` only redacted `error.message`, `error.stack`,
`warnings`, `http.user_agent` and `external_calls[*].error`. The
free-shape `meta` field and `action.details` blob carry annotated
key=value pairs from `annotate()` calls — including the
provider-runner's `ai_chain_hop_<n>_reason` which captures the upstream
error body capped at 240 chars. If an upstream provider ever echoes a
Bearer token in its error body, the raw token would land in `meta`
without any scrub. Added a recursive `redactDeep()` walker that runs
`redactSecrets()` over every string-shaped value inside both blobs.
Same gap on `/api/admin/audit-log`: the `details` JSON column was
returned verbatim. A user-supplied identifier in a failed-login row
(e.g. a typo'd "Bearer hlk_..." in a username field) flowed through
the deepened B4 admin viewer + CSV export. Run `redactSecrets()` over
each row's `details` at egress so already-persisted rows are scrubbed
on read without a backfill migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The B8 comparison overlay shipped behind a Settings → Dashboard Select
buried 3 clicks deep. The brief asked for "mobile-friendly comparison
toggle (not a fragile hover-only interaction)" and the toggle that
shipped failed that bar — flipping Vormonat / Vorjahr meant a 5-step
round-trip every time.
New `<CompareToggle>` segmented control (None / Vormonat / Vorjahr)
sits next to the dashboard greeting and on the insights page hero.
Persists via the same `/api/dashboard/widgets` PUT the Settings
section already uses, with optimistic update + cache invalidation so
the chart overlays + tile callouts respond instantly. 44 px (min-h-11)
tap targets, focus-visible ring, role=group + aria-label for screen
readers, EN+DE i18n.
6 SSR tests pin segment count, persisted-active state, tap-target
floor, EN+DE labels, and ARIA shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend `buildUserPrompt()` to accept an optional `ComparisonSnapshot`
that, when supplied, appends a SYSTEM CONTEXT block listing each
metric's current 30d average, baseline 30d average (from 30 / 365
days back), absolute delta, and percentage delta. The strict system
prompt gains a sixth ground rule that instructs the model to narrate
the most clinically-significant non-null delta in the summary's first
sentence and forbids inventing comparison numbers.
The route reads the user's persisted `dashboardWidgetsJson.compari-
sonBaseline` and builds the snapshot from a per-metric `summarize()`
sweep so the numbers the LLM sees match what the dashboard tile
renders — single source of truth for the comparison delta. Comparison
is default-on whenever the toggle is on per research §7 Q4: there's
no secondary "include in AI" preference, the pulldown is the single
affordance.
PROMPT_VERSION bumped 4.16.0 → 4.16.1 so the Wide-Event annotations
attribute responses to the new prompt revision.
Eight new unit tests pin: empty-comparison block render, EN/DE
locale switch, lastMonth vs lastYear language, the
"summary first sentence" instruction, and the anti-hallucination
"do NOT invent" guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend DataSummary with `avg30LastMonth` and `avg30LastYear` fields —
30-day means over the [30, 60) and [365, 395) day windows. The
existing `summarize()` helper computes them once for every metric the
analytics API returns, so every dashboard tile picks them up
automatically without extra DB queries.
TrendCard gains optional `compareBaseline` + `compareDelta` props.
When both are set the tile renders a "Δ −2.3 kg vs. last month" line
below the latest value, colour-coded via the existing
`directionSentiment` rules — down on an up-bad metric (weight, BP)
paints green; down on an up-good metric (mood, sleep, steps) paints
orange; neutral metrics stay muted; deltas under |0.05| read as
stable and stay muted regardless.
Mobile-friendly: the callout sits on its own line below the latest
value so the tile stays scannable on Pixel-5 viewports without
horizontal scroll. Suppressed entirely when comparison is off OR
either side is missing data — strict regression guard for the
default-off path.
The dashboard's `tileCompareDelta()` helper resolves the prior-period
field from the active comparison toggle and feeds every TrendCard the
same delta value, so a single toggle flip in Settings → Dashboard
updates every tile atomically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `compareBaseline` prop to HealthChart and MoodChart that, when
set to "lastMonth" or "lastYear", overlays the prior-period series as
a dimmed dashed line beneath the current data. The shift is computed
in-memory by `shiftDailySeriesForward` — the chart already fetches the
full measurement history so no extra API call is needed.
The tooltip switches its delta line from "vs. your normal" to "vs. last
month / last year" when comparison is active and a prior value exists
for the hovered day, and adds a second row with the prior absolute
value so both numbers are visible. The chart header gains a
"vs. last month / last year" caption (or an "unavailable — no data
yet" fallback when the prior period has no overlap).
The dashboard page and the insights page both read the persisted
preference from `User.dashboardWidgetsJson.comparisonBaseline` so a
single toggle flip in Settings → Dashboard updates every chart on
both surfaces atomically.
Pure shift helper lives at `src/lib/charts/comparison-shift.ts` with
11 unit tests pinning the day-exact forward shift, the average helper,
and the delta computation (null-safe).
Regression guard: when comparisonBaseline is "none" (default), the
chart renders exactly as before — no extra paint, no caption, no
tooltip change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a "Compare to" pulldown to the Settings → Dashboard section that
lets the user pick None / Last month / Last year. The preference rides
on the existing User.dashboardWidgetsJson blob — research §7 Q3 settled
on the JSON-pivot path so v1.4.16 ships migration-free.
The resolver defaults to "none" for legacy layouts and clamps unknown
values back to "none", so a stale client cannot poison the dashboard
with a baseline the renderer doesn't know how to draw. The PUT
endpoint accepts the new optional comparisonBaseline field; pre-B8
clients keep PUTing without it and see no behavioural change.
EN+DE i18n keys land for the toggle label, the three options, the
"unavailable" fallback, and the chart caption — Vormonat / Vorjahr per
Marc's brief.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the /insights heading in a Dracula-gradient hero band with the
"Generated <relative-time>" caption, "Based on your last 90 days"
personal-baseline indicator, and a regenerate slot. Pure shell so the
page only passes timestamps in. Animation uses the existing
animate-insight-in keyframes which already gates on
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: ship the `/admin/ai-quality` route that surfaces
the daily aggregator's helpful-rate per (severity x metricSourceType
x provider x prompt-version) bucket. Renders as a compact 7-column
table with severity tinted by clinical-importance colour and the
helpful-rate column tinted green/yellow/orange by 0.8/0.5
thresholds.
New endpoint `GET /api/admin/ai-quality` (requireAdmin-gated) returns
the raw `AppSettings.adminAiInsightsFeedbackSummary` blob so the
v1.4.17 ratchet that consumes the same shape for prompt-tuning can
read what the worker writes without an envelope-reshape step.
Empty state ("no summary yet" / "buckets[] is empty") rendered as a
quiet message rather than a fake zero row so the operator can tell
"no feedback" apart from "all-zeros". Loading + error banners follow
the existing host-metrics pattern.
Section wiring:
- new `ai-quality` slug in ADMIN_SECTION_SLUGS (between
integrations and feedback so the IA flows General → Integrations
→ AI Quality → Feedback Inbox)
- new Sparkles icon entry in admin-shell
- new switch case in [section]/renderer.tsx
- EN+DE i18n keys: admin.section.ai-quality.{title,subtitle} +
admin.aiQuality.{title,loading,loadError,empty,windowLabel,
generatedAtLabel,col*}
Tests:
- 5 new ai-quality-section tests (loading, empty-null, empty-buckets,
table-render with multiple buckets, error)
- 1 admin-sections smoke test ensures the empty branch renders +
1 slug-list parity test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.
Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.
The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.
Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.
Tests:
- 7 unit tests for the Zod request schema (valid path, severity
poisoning, time-range poisoning, empty fields, stringified bool,
extra-field stripping)
- 5 unit tests for `pickProviderType()` priority + fallback
- 4 integration tests: 201 happy path with provider attribution,
409 dedup, 422 validation, fallback to unknown when no audit row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cached insight blobs persisted before B5c shipped don't carry the
new `rationale` field — the strict parser would reject them, and
auto-regenerating on every cache-hit would burn rate-limit tokens
silently. Instead the route now detects the legacy shape and
surfaces a `legacyPayload: true` flag in the response so the UI
can render a one-time "regenerate for new explainability features"
CTA. User-initiated regeneration stays the trigger.
`isLegacyInsightPayload(payload)` lives in `src/lib/ai/legacy-payload.ts`
and is intentionally lenient: any rec missing rationale (or the
v1.4.14/v1.4.15 string-only InsightResult shape) flags. Empty
recommendations[] is NOT legacy — it's a legitimate refusal payload.
The annotate() Wide-Event also carries `legacyPayload` on cached
hits so the admin AI quality dashboard can chart the migration
ratio over time. v1.4.17 can flip auto-eviction on once the
ratio drops near zero.
7 new tests in `legacy-payload-detection.test.ts` pin the contract.
Existing 223 AI tests still green; typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The B5b read-only endpoint now has a write counterpart so the new
Settings → AI dropdown UX can save reorder + enable/disable changes.
Server-side priority is recomputed from insertion order (so the UI's
visual contract — "first row = priority 1" — cannot drift from what
gets persisted), unknown provider types and duplicates 422, and an
empty chain is rejected outright. parseProviderChain still falls
back to the default chain when the persisted JSON is malformed,
so a stale-tab resubmit cannot poison generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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 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 catch block around the restore transaction echoed the raw error
message back to the admin client (`Restore transaction failed: <full
prisma message>`). Even on an admin-only endpoint that leaks column
names, constraint names and query fragments to anyone with admin
access — lower than CRITICAL but unnecessary surface for a future
supply-chain or impersonation scenario.
Response now returns a stable "Restore failed" 500. The verbose text
still lands in the audit-log row (admin-readable, forensic) and in
the Wide Event under `meta.restoreFailReason` (operator-readable),
so root-cause investigation is unaffected.
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>
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>
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.
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>
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 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>
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>
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry
covering backup completeness, achievements UI, onboarding tour,
doctor-report v2, auto-deploy on GHCR push, empty-states sweep,
notification + integration status UI, AI hallucination guardrails,
mobile UX fixes, and the BD-Zielbereich percentage fix.
Includes the formatting drift left over by the v1.4.15 development
phases (Prettier sweep on touched files) and a typecheck fix in
`dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`, `pnpm test`, and `pnpm test:integration` all
pass green on the release tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last two surfaces from the C5 audit.
- /insights — Top-level "no data" path replaces a bare "No data
available" sentence with EmptyState (TrendingUp icon) + "Add a
measurement" CTA into /measurements. The BMI section's
height-not-set fallback gets a compact plain-variant EmptyState
(Ruler icon) + "Open account settings" CTA instead of a single
grey line that looked like a bug.
- / (dashboard) — When trendCards.length === 0 AND charts.length ===
0 (a brand-new account with no data and the Getting-started
checklist already dismissed) the dashboard rendered an empty
0-px tile strip below the welcome banner. Now paints an
EmptyState with the same quick-entry dialog wired to the CTA so
the user can log their first measurement without leaving the page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 9a74f8e — that commit's pathspec didn't pick up the
implementation files, only the new test. This commit lands the actual
EmptyState wiring described in the previous message:
- src/components/measurements/measurement-list.tsx — Activity icon,
filter-aware empty state, optional onAddFirst CTA prop.
- src/components/mood/mood-list.tsx — Smile icon, mirroring filter
semantics + optional onAddFirst.
- src/app/measurements/page.tsx + src/app/mood/page.tsx — pass
onAddFirst so the empty-state CTA opens the same dialog as the
header button.
- src/app/medications/page.tsx — refactor inline icon+text+button
block to the EmptyState primitive (Pill icon).
- src/app/achievements/page.tsx — Trophy icon for the no-achievements
state, reusing the existing noneUnlockedYet string.
Together with 9a74f8e the four feature lists now share a consistent
empty-state surface with the rest of the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc wanted the addressee visible at first glance on the printout —
"Praxis Dr. Müller" should be the first thing the GP sees, so the
report doubles as a polite opening line.
- New optional free-text field "Praxis / clinic name" in the export
dialog. The dialog pre-fills it with the user's last-used value so
recurring exports don't require re-typing.
- Persisted as a user preference: new `User.lastReportPracticeName`
column (migration `0031_user_last_report_practice_name`). The value
is updated only on a successful POST that supplied a non-empty
string — passing `null` does NOT clear the stored preference so the
value sticks across years of follow-ups. Best-effort write: a DB
failure here MUST NOT break the report (caught + ignored).
- `sanitisePracticeName()` enforces a strict envelope upstream: strips
ASCII C0 + DEL controls (which jsPDF can't render), collapses
whitespace, and hard-caps at 120 characters. Defends the PDF layout
from runaway input + protects the PDF text stream from
control-character corruption.
- PDF cover prints the line in 11pt bold above the separator when set;
omitted entirely when `null` so the cover stays compact for
self-archive use. Both client (`doctor-report-pdf.ts`) and server
(`doctor-report-pdf-core.ts`) renderers carry the change so the
visual output matches whichever path the user takes (browser
download vs iOS Safari fallback).
- `/api/auth/me` now echoes `lastReportPracticeName` so the dialog
pre-fills without an extra round-trip.
- Audit-log + Wide-Event annotations record `practiceNameProvided:
boolean` (NOT the value itself — practice names are personal and we
already store one canonical copy on the user record).
i18n: 6 new keys (`doctorReport.practiceLabel`, `doctorReport.dialog.{practiceLabel,practicePlaceholder}`)
in EN + DE.
Tests: 12 new unit cases covering sanitisation (whitespace, controls,
length cap, type guard), PDF cover rendering (DE label "Praxis:", EN
"Practice:", omission when null), API persistence (prisma.user.update
called with the right shape, audit detail flag set). 965 / 965 unit
pass, 31 / 31 integration pass. Lint 0 errors. Typecheck clean for B6
files (pre-existing dashboard-layout.test.ts errors are A4 territory).
v1.4.15 phase B6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PDF used to render a fixed 90-day window. Marc wants the period
selectable so chronic-condition follow-ups can pull a longer history and
acute issues a shorter one.
- New `<DoctorReportDialog>` (Radix Dialog + native `<input type="date">`)
prompts the user before the request fires. Defaults: end=today,
start=today-90d. Inline validation: end >= start, span <= 2 years.
Three convenience presets (90d / 6mo / 12mo).
- `normaliseDateRange()` (in `doctor-report-data.ts`) parses
`{ startDate?, endDate?, days? }` and falls back to last-90-days for
invalid input — silent fallback rather than 422 so a malformed retry
still yields a useful report.
- `collectDoctorReportData()` accepts a `DoctorReportRange` and filters
measurements / intake events / mood entries with both `gte` AND `lte`,
so a custom window (not just "last N days") is honoured.
- The PDF cover ("Auswertungszeitraum: 01.02.2026 — 09.05.2026") now
reads from `period.start` / `period.end` instead of the legacy
(since, now()) calculation. Old `period.since` field preserved for
backwards compatibility.
- Audit-log + Wide-Event annotations now record `startDate`/`endDate`
alongside `days` so a forensic replay can reconstruct the requested
window.
Tests: 18 new unit tests in `doctor-report-data.test.ts` (range parse,
fallback ladder, edge cases), 1 new test in `doctor-report-pdf-core`
(period rendering on cover), 2 new in pdf-route (range + invalid range),
13 new dialog i18n + module-export contract tests. Total +34 unit cases
(955 -> 955 with the suite reorg). Typecheck clean for B6 files; lint
clean (existing dashboard-layout test errors are A4 territory). v1.4.15
phase B6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B5 — third commit. Wires the spotlight tour
(`<OnboardingTour>` from previous commit) to auto-fire for new users
the first time they land on the dashboard, persists completion to
the DB, and exposes a window-event hook for replay.
Pieces:
- `<TourLauncher>` (`src/components/onboarding/tour-launcher.tsx`)
— gates and mounts the tour. Self-decides via:
* `user.onboardingTourCompleted` (DB flag from migration 0030)
* sessionStorage dismiss guard (defensive against in-flight
`/api/auth/me` refetches re-opening the tour after Skip)
* post-wizard 1500ms grace window (avoids the wizard's
`doneToast` and the spotlight stacking on the same z-layer)
* a `ready` prop the dashboard sets once analytics has
resolved (so the spotlight cutout doesn't snap to a 0×0
placeholder before tiles render)
Listens for `healthlog:tour-restart` to force-reopen.
- `POST /api/onboarding/tour` — single endpoint with body
`{ completed: boolean, outcome?: "completed" | "skipped" }`.
Flips `users.onboarding_tour_completed`. Dispatches a Wide
Event annotation with the outcome so Loki can split completion
from dismiss without a second column.
- `/api/auth/me` now returns `onboardingTourCompleted`. The
`AuthUser` type in `src/hooks/use-auth.ts` gains the field;
no consumer outside the launcher reads it today.
- `data-tour-id` attributes added to:
* dashboard tile-strip wrapper (`dashboard-tile-strip`)
* dashboard "Add" dropdown trigger (`dashboard-quick-add`)
* sidebar nav items (`nav-insights`, `nav-settings`,
`nav-achievements`, plus the rest for symmetry —
targeted attribute, not class names, so renames in
Tailwind classes don't silently break the spotlight)
- The `/onboarding` wizard's `persistAndExit()` writes
`sessionStorage["healthlog-tour-referrer"] = "1"` immediately
before navigating to `/`. The launcher consumes-and-clears that
on first mount via a `useState` lazy initialiser (presence-only
flag — no `Date.now()` so the render-phase decision stays pure
under `react-hooks/purity`).
ESLint compliance:
- `react-hooks/set-state-in-effect` — no synchronous setState in
a useEffect body. The launch decision happens in the render
phase using the React-recommended "previous input id" guard
(mirrors `account-section.tsx`'s `seededUserId`), and the
post-wizard timer's setState lives inside a setTimeout
callback (an event handler, not the effect body).
- `react-hooks/purity` — render-phase setState only branches on
prop values + sessionStorage-presence helpers. No `Date.now()`
/ `Math.random()` / fetch in render.
Tests: my 39 unit tests pass (was 27 — +12 across tour-state and
tour SSR shape). Pre-existing test failures in
`doctor-report-data.test.ts` are uncommitted B6 work in the
working tree, not B5. Lint clean for B5 files; one pre-existing
B6 lint error in `doctor-report-dialog.tsx` is out of scope.
Typecheck has the same pre-existing `dashboard-layout.test.ts`
errors documented in STATE.md (A4 / phase-D scope).
Restart UX (Settings → Account → "Restart tour") lands in the next
commit (B5-4) so this commit stays orthogonal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Small dashboard card surfacing the user's three most-recently unlocked
achievements (sorted by `completedAt` descending). When nothing is
unlocked yet, the card paints a discovery CTA + link to /achievements
so brand-new users learn the feature exists. Reuses
`/api/gamification/achievements` — TanStack Query dedupes when both
this card and the dedicated page mount in the same session.
Layout integration mirrors the existing widget pattern: new
`achievements` widget id in `DashboardWidgetId`, default order 13
(below the chart row, matching the brief), `tileVisible: false`
because there is no tile surface for this widget. Visible by default
(small footprint, self-handles the empty state). Toggleable from
Settings → Dashboard via the existing `<DashboardLayoutSection>`
machinery — added the `achievements: "achievements.title"` entry to
`WIDGET_LABEL_KEYS`. Other-agent prettier-formatting changes for
table-header `<span>` wrapping + Tailwind class ordering in the same
file are folded in (the working tree had them when I started; they
match prettier's expected output).
Tests: +6 unit (`pickRecentUnlocks` ordering / cap / no-date fallback,
empty-state CTA, three-most-recent render shape). 922 / 922 unit pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New endpoint `POST /api/internal/deploy-webhook` closes the loop on the
auto-deploy work started in the previous commit. Coolify (configured via
Settings → Notifications → Webhook with the `X-Deploy-Webhook-Secret`
custom header) calls this URL after every deploy attempt; we:
* Verify the request via `DEPLOY_WEBHOOK_SECRET` env var with a
timing-safe compare (same pattern as the Withings webhook).
* Rate-limit by client IP (60/min — comfortably above Coolify's
typical 1-2 callbacks per deploy, well below a brute-force probe).
* Normalise Coolify's free-form payload into a small struct
(`outcome`, `applicationName`, `error`, etc.) — preserving the
full JSON in the audit row so future Coolify schema drift is
forward-compatible without code change.
* Write an audit-log row with one of `system.deploy.{success,failure,unknown}`
— `unknown` covers in-progress states like "queued" so we observe
them without paging.
* On `failure`, fan a `SYSTEM_ALERT` through the existing
`dispatchNotification()` to every admin user (silent no-op if no
Telegram channel configured — matches `maybeAlertAdmins()` pattern
in `src/lib/integrations/status.ts`).
Also exports a `GET` handler so Coolify's "Test" button on the
notification config page returns 200 with a valid secret.
Tests: 12 unit cases covering auth (header missing / wrong / env var
unset), happy path (success skips alert), failure path (audit + 2
admin alerts), unknown-status path (no alert), no-admin warning,
rate-limit short-circuit, malformed status type, and forward-compat
preservation of unknown payload fields. 952 / 952 unit pass total.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-v1.4.15 the page silently dropped locked achievements, so the user
only saw what they had already earned. Now every badge renders — locked
ones are grayed out with a "Locked" badge, criterion hint
({current} / {target}), and a progress bar. Unlocked entries keep the
existing primary-gradient card with the "Completed on …" footer.
Achievements are grouped by a new `category` axis (medication, vitals,
security, engagement) — derived in `getAchievementCategory()` so a
metric is always in exactly one bucket. Inside a category, unlocked
items sort first; among locked items the closest-to-unlock one ranks
first so the user always sees their immediate next goal at the top.
The sidebar link to /achievements was already in place under the
user-area group; this commit only adds the new locked/grouped render
plus an e2e smoke spec that walks the desktop sidebar → click →
heading visible path.
i18n: 9 new keys under `achievements.*` (locked, criterionHint,
progressPercent, categories.{medication,vitals,security,engagement},
dashboardCard.{title,viewAll,empty}) — EN + DE. C4's parity audit will
sweep later.
Tests: +9 unit (groupByCategory ordering + page render in EN/DE) +
1 e2e (sidebar → /achievements). 909 / 909 unit pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B2, criterion 2. The Phase B2 status helper (commit
4db72a8) defined a markReauthRequired / recordSyncFailure API but
nothing called it. A Withings refresh token that had revoked got the
same "transient error" treatment as a 503 from the upstream API,
scheduled syncs kept hammering the OAuth endpoint, and the Settings
UI showed "Connected" until the user manually triggered a sync and
read the inline error.
This commit wires every sync / OAuth path:
- getValidToken() (Withings): refresh failures classified by
Withings status code. 100/101/102 (auth failed / invalid token /
user gone) and 200..299 (OAuth grant errors) -> error_reauth.
Anything else (5xx, network) -> error_transient. Same logic
inlined in /api/withings/status so the proactive refresh path
parks the row too.
- syncUserMeasurements() and syncMoodLogEntries() short-circuit
immediately when isReauthRequired() returns true. Scheduled
pg-boss jobs no longer burn API quota on a parked connection.
- moodLog 401/403 from the upstream -> error_reauth. 5xx / 3xx
redirect / network / invalid-JSON / bad-format -> error_transient.
SSRF refusal also writes a status row so it surfaces in the UI.
- /api/withings/callback calls markReconnected() after writing the
new tokens, the next sync runs.
- /api/withings/disconnect and DELETE /api/settings/moodlog call
markDisconnected() to clear any prior error state. moodLog
PUT /api/settings/moodlog (re-save credentials) markReconnected()s
for the same reason.
The helper writes AuditLog action=integrations.sync.failed on every
failure (criterion 4) and dispatches a SYSTEM_ALERT notification to
admins after consecutiveFailures >= 3 with a 24h re-alert window
(criterion 3). Both paths are covered by the unit + integration test
suites that landed in 4db72a8 — this commit makes them live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.
This commit adds:
- New `IntegrationStatus` table (`integration_statuses` migration
`0029`). One row per (user, integration) capturing `state`
(`connected | error_transient | error_reauth | disconnected`),
`lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
`consecutiveFailures` counter, `alertedAt` window-guard.
- `src/lib/integrations/status.ts` — the only writers
(`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
`markReconnected`, `markDisconnected`) plus a read-only
`getIntegrationStatus()` for the API. The error column is
AES-256-GCM encrypted at rest because Withings/moodLog 401s can
echo URL/apiKey fragments — we can't ship that into a future
backup tarball.
- `GET /api/integrations/status` — single roundtrip returns both
Withings and moodLog snapshots plus the global threshold (so the
UI's "{n}/{threshold}" string is server-sourced and rotates with
the env var).
- Settings → Integrations cards both gain an
`IntegrationStatusBanner` row above the credentials form: state
badge ("Connected" / "Reconnect required" / "Error" /
"Disconnected"), `{n}/{threshold} consecutive failures` chip,
last-success / last-attempt times, and a destructive-toned
last-error line. The banner is suppressed entirely when the
integration is fresh-connected with no history (no false alarms).
Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.
Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Right-rail "Documentation" link in the Backups section header points
at https://docs.healthlog.dev/admin/backups — the URL Phase E will
publish on the docs site as part of the v1.4.15 release.
Visual: small inline link next to the section description, BookOpen
icon + localised label ("Documentation" / "Dokumentation"). Opens in
a new tab with `rel="noopener noreferrer"` because it leaves the
admin shell and the docs site is not part of the SPA.
i18n: new `admin.section.backups.docsLink` key in EN + DE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.
Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
"blocked by the user") → channel auto-disabled, disabled_reason
captured, audit log notification.channel.auto_disabled written.
Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
[30s, 5min, 30min, 2h]. Counter consecutive_failures increments
per failure; after the 5th in-a-row the channel is auto-disabled
with reason give_up_after_5_failures.
- COOLDOWN window (next_retry_at > now) → dispatcher skips the
channel without calling the sender — defeats the API-quota-burn
loop a flapping upstream would otherwise create.
Implementation:
- Migration 0028 adds 6 columns to notification_channels (all
nullable / zero-default → no-op for existing rows).
- src/lib/notifications/retry-policy.ts: classifiers + frozen
BACKOFF_SCHEDULE_MS + nextRetryAt() helpers.
- src/lib/notifications/channel-state.ts: only writer of the new
columns (recordChannelSuccess, recordChannelHardReject,
recordChannelTransientFailure, reEnableChannel,
isChannelInCooldown). Each transient/hard path writes audit.
- Senders return a structured SendOutcome instead of bare booleans.
- Dispatcher: cooldown-skip → success → hard-reject → transient/
give-up. Sender-throws absorbed as transient failures.
Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts,
dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint
clean (no new warnings).
Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts
had if (!ok) against a SendMessageResult object — always truthy,
so the route never reported test-send failures. Now reads result.ok.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.
Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
"blocked by the user") → channel auto-disabled, `disabled_reason`
captured, audit log `notification.channel.auto_disabled` written.
Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
[30s, 5min, 30min, 2h]. Counter `consecutive_failures` increments
per failure; after the 5th in-a-row the channel is auto-disabled
with reason `give_up_after_5_failures` (criterion 2's "treat as
hard reject after N failures" rule).
- COOLDOWN window (`next_retry_at > now`) → dispatcher skips the
channel without calling the sender — defeats the API-quota-burn
loop a flapping upstream would otherwise create.
Implementation:
- Migration 0028 adds 6 columns to `notification_channels`:
`disabled_reason`, `consecutive_failures`, `last_success_at`,
`last_failure_at`, `last_failure_reason`, `next_retry_at`. All
nullable / zero-default → no-op for existing rows.
- `src/lib/notifications/retry-policy.ts` exports the classifier
(`classifyTelegramError`, `classifyHttpStatus`) plus
`BACKOFF_SCHEDULE_MS` (frozen) and `nextRetryAt(count, now)` /
`shouldAutoDisableAfterTransient(count)` helpers.
- `src/lib/notifications/channel-state.ts` is the only writer of
the new columns: `recordChannelSuccess`, `recordChannelHardReject`,
`recordChannelTransientFailure`, `reEnableChannel`,
`isChannelInCooldown`. Each transient/hard path writes an audit
entry so the user can trace WHY the channel went silent.
- Senders now return a structured `SendOutcome` (`{ ok, hardReject?,
statusCode?, reason?, message? }`) instead of bare booleans. The
Telegram sender additionally exposes `errorDescription` from the
Bot API so "chat not found" / "bot was blocked" can be classified
without parsing free-form text in the dispatcher.
- Dispatcher: cooldown-skip → success → hard-reject branch →
transient/give-up branch. Sender-throws are absorbed as transient
failures (defence-in-depth).
Tests: +20 unit tests (`retry-policy.test.ts`, `channel-state.test.ts`,
`dispatcher.test.ts`). 883/883 pass. Hard-reject scenarios covered:
web-push 410, Telegram chat-not-found, ntfy 410. Soft-reject
scenarios: 429 schedules backoff without disabling; cooldown skip
prevents repeat call; 5th in-a-row triggers give-up + audit.
Pre-existing bug fix: `src/app/api/settings/telegram/test/route.ts`
had `if (!ok)` against a `SendMessageResult` object — always truthy,
so the route never reported test-send failures. Now reads
`result.ok`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/admin/backups/[id]/restore replaces the target user's
personal data with the contents of the snapshot. This is the riskiest
endpoint in the backup family, hardened with five independent gates:
1. requireAdmin() — cookie-session admins only, never a Bearer token.
2. Body must carry confirm: "RESTORE" (rejected with 422 + audit
denial otherwise).
3. UI gates the destructive button behind a typed-string match —
three independent steps before fire (open dialog, read warning,
type RESTORE).
4. withIdempotency() wraps the handler so duplicate clicks with the
same Idempotency-Key replay the original outcome instead of
re-executing the destructive transaction.
5. Pre-validation of measurement-type / measurement-source /
intake-source enums OUTSIDE the transaction so a malformed
payload can't half-wipe the user before failing the create step.
Restore scope mirrors the v1.4.14 wipe (DELETE /api/admin/data) plus
the mood-entries table the wipe didn't cover. AuditLog rows are
intentionally PRESERVED across the restore — the audit trail is the
last thing that should be touched by a destructive op.
Transaction:
- Delete user's measurements, medications (cascades to schedules +
intake events + reminder configs + telegram messages), mood
entries, notification channels, push subscriptions, telegram
scheduled deletions.
- Re-create from payload: measurements (skipDuplicates), medications
+ their schedules in one create call, intake events keyed by
medication name -> id (orphans dropped silently), mood entries
(skipDuplicates).
Audit:
- admin.backups.restore.start (intent, before transaction)
- admin.backups.restore (success, after transaction)
- admin.backups.restore.denied (5 reasons, no transaction ran)
- admin.backups.restore.failed (decrypt / schema / enum / tx error)
UI:
- New <RestoreRowDialog> subcomponent renders an AlertDialog gated
on a typed-RESTORE Input. Description carries the target username
and the snapshot timestamp so the admin sees exactly which
backup they're about to restore. Confirm button stays disabled
until typed string matches.
- Restore button sits next to Download in each row's action column.
- Idempotency-Key built client-side as restore-<rowId>-<uuid> to
de-duplicate accidental double-clicks.
Tests: 3 new integration tests in
tests/integration/admin-backups-restore.test.ts covering the round-
trip (wipes pre-existing data + recreates from snapshot),
confirmation-token rejection, and 404 on unknown id. 21/21
integration tests pass; 69/69 unit tests on src/lib/validations and
src/components/admin still green.
Note for v1.4.16: the restore test queries `medication_schedules`
with explicit field selection because a sibling branch added
`days_of_week` to the schema without a matching migration; the
default `findMany` would 500 against the deployed test DB. When that
migration lands, the explicit select can be relaxed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A5 / B-mobile MEDIUM: at 393px the login screen's two CTA
buttons ("Sign in with Passkey" / "Sign in with password" / "Login")
were rendered at default size — `h-9` (36px) — below the WCAG 2.5.5
44px minimum for touch targets. Login is the most-tapped flow on a
fresh install; this is the first screen a new user sees on mobile.
Fix: switch the three login buttons to `size="lg"` plus an explicit
`min-h-11` so they reach the 44px floor. The "lg" CVA variant gives
`h-10` natively; the `min-h-11` upgrade keeps us above the WCAG
threshold even if the variant is later tuned for desktop density.
Files: `src/app/auth/login/page.tsx` only.
Findings ID: A5/MEDIUM `/auth/login` — Sign-in button tap-target.
Verification: 853 / 853 unit tests pass; one pre-existing unrelated
failure in `notification-status-card.test.tsx` from B3's
in-progress channel-reliability work is excluded — see B3's report.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A5 / B-mobile HIGH cluster — closes 4 audit findings:
1. Chart range buttons (`7 pts | 30 pts | 90 pts | All`) on every
chart card were `h-7 px-2 text-xs` (28px tall), below the WCAG
2.5.5 / Apple HIG / Material 44px minimum. Bumped to
`min-h-9 px-2.5` (36px) — that's still compact enough to fit in
the chart header strip on a 393px viewport but raises tap-target
pass rate substantially. Files: health-chart, mood-chart,
medication-compliance-chart, compliance-line-chart,
compliance-charts.
2. Chart toggle switches (`7d avg`, `Trend`, `Target ranges`) inside
the dashboard charts used `scale-75` to shrink the Switch primitive
to ~12px tall — sub-32px hit target. Dropped the `scale-75` so each
Switch returns to its default size. Files: health-chart, mood-chart.
3. Medication card primary actions ("Taken" / "Skipped"): these are
the most-tapped controls in HealthLog (every dose intake is one
tap). Were `size="sm"` (32px). Bumped to default size + explicit
`min-h-11`. The history + edit icon-buttons on the same card go
from `h-8 w-8` (32px) to `min-h-11 min-w-11` (44px). Glyph stays
16px so the visual weight doesn't change.
4. Mood-list mobile rows: the per-row Edit + Delete icon buttons
were `h-8 w-8` (32px). Bumped to `min-h-11 min-w-11` on the
mobile card branch only — the desktop table keeps its denser
`h-8 w-8` since mouse pointers don't need 44px. `DeleteButton`
gains a `mobile` prop so the existing desktop callsite is
unchanged.
Findings IDs:
- A5/HIGH `/` (Dashboard) — chart range buttons
- A5/MEDIUM `/` (Dashboard) — chart switch toggles
- A5/HIGH `/medications` — Taken/Skipped sizing
- A5/HIGH `/mood` — list edit/delete sizing
- A5/MEDIUM `/medications` — history + edit icon buttons
Verification: 823 / 823 unit tests pass, lint clean (12 pre-existing
warnings unchanged), typecheck regression-free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A5 / B-mobile CRITICAL #2: at 393 CSS px the desktop user table
truncated the role badge ("ADMI", "USE") and pushed the action column
entirely off the right edge. The destructive force-logout action was
unreachable on mobile, plus the card-header filter pills collided
with the title's count badge.
Fix:
- Header switches from horizontal to vertical stack at `< sm` so the
filter pills get their own row instead of fighting the title for
space.
- Card padding `p-4 sm:p-6` matches the api-tokens responsive
template.
- Desktop table is gated behind `hidden md:block` (instead of just
letting it overflow).
- New mobile card-list at `< md` renders each user as a self-
contained card with the username + role badge + meta line + a
flex-wrapping action row carrying all four buttons (toggle role,
edit, reset password, force logout). Nothing hidden behind a
horizontal scroll, every action stays tap-targetable.
- Action buttons extracted into a single `renderUserActions()` helper
so desktop + mobile stay in lock-step on future admin actions.
Evidence: new `user-management-responsive.test.tsx` asserts the
desktop table is `hidden md:block`, the mobile list is `md:hidden`,
the role badge appears in both layouts (no column lost), all four
ARIA-labelled action buttons are present in the mobile card, the
header stacks on `< sm`, and the card uses `p-4 sm:p-6`. 6 / 6 tests
green.
Findings ID: A5/CRITICAL-#2 (top of `.planning/phase-A5-mobile-findings.md`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Until v1.4.14 the layout schema in `lib/dashboard-layout.ts` had a
single `visible` flag per widget that controlled BOTH the upper-row
strip tile AND the lower-row chart for the same metric. Marc wanted
them as independent toggles — to keep the chart visible for tracking
without the tile cluttering the strip, or vice versa.
Schema upgrade (back-compat):
- New optional `tileVisible: boolean` on `DashboardWidgetConfig`. When
omitted on a saved layout, `resolveDashboardLayout()` mirrors it
from `visible` so legacy users see no behavioural change until they
flip the new switch. `serializeDashboardLayout()` always writes the
field explicitly so a re-read keeps the user's choice.
- Zod input schema (`/api/dashboard/widgets`) marks the field
optional so older clients (web v1.4.14 cache, native iOS) can keep
PUTting their existing payload shape.
- Default layout sets `tileVisible: true` for the seven default-on
widgets and `tileVisible: false` for the six default-off ones.
Settings UI:
- The single-column toggle list grows a SECOND switch column. Header
row labels them "Tile" / "Kachel" and "Chart" / "Chart". Each widget
now paints two switches (`data-slot="widget-tile-switch"` and
`widget-chart-switch`) with discrete `aria-label`s
`<metric> — Tile` / `<metric> — Chart` so a screen-reader user can
tell them apart.
Dashboard render (`src/app/page.tsx`):
- `isWidgetVisible(id)` split into `isTileVisible(id)` (new flag
with mirror fallback) and `isChartVisible(id)` (legacy flag). Each
metric now has a `showXxxTile` AND `showXxxChart` gate. Glucose
has no separate chart slot today, so its single flag still maps
to the tile flag.
Tests:
- `resolveDashboardLayout()`: mirrors tileVisible from visible for
legacy layouts, respects an explicit value, auto-appends new
widgets with both flags `false`, defaults gracefully on null
input.
- `serializeDashboardLayout()`: persists tileVisible explicitly,
derives from visible when missing, normalises order to dense
0-based.
- `<DashboardLayoutSection>` SSR smoke: column headers paint EN +
DE, both switch slots present, exactly one tile-switch + one
chart-switch per widget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard tiles labelled the 7-day average as "7T:" / "7d:" with
the mean value beside it. Marc found that label unclear when the
goal of the tile is to convey *direction* over the past week, not a
mid-point average:
Before: 7T: 80,5 30T: 79,8
After: 7T-Trend: 80,5 (+1.2) 30T: 79,8
The avg7 line now flips its label to `charts.trend7dShort` ("7d trend"
/ "7T-Trend") when a `trend7Delta` prop is supplied, and renders a
signed delta indicator next to the average. Color of the delta
follows the same `directionSentiment` rules the headline arrow
already uses:
- up-bad metrics (BP, weight, body fat): +delta paints orange,
-delta paints green.
- up-good metrics (mood, sleep, steps): +delta paints green,
-delta paints orange.
- neutral metrics (pulse): both signs stay muted.
Tiny absolute deltas (< 0.05) read as "no movement" and stay muted
regardless of sentiment, so a flat metric never paints a colour.
Delta source is the existing 7-day linear-regression slope from
`DataSummary.slope7` (already computed by `summarize()` in
`lib/analytics/trends.ts`). Projection: `slope.slope * 7 days =
end-to-end delta of the regression line` — extracted into the pure
helper `summaryToTrend7Delta()` in `lib/analytics/trend-delta.ts` so
the rule stays unit-testable.
Wired to weight, BP-sys, BP-dia, pulse, body fat, mood, sleep, steps.
Glucose tiles keep the legacy avg label until v1.5 (per-context
slopes need a wider summary contract).
Tests:
- Unit `summaryToTrend7Delta()`: null when summary missing /
slope7 null, projects rate × 7 for positive and negative slopes,
zero for stable slope.
- Component `<TrendCard>`: trend label ("7d trend" / "7T-Trend")
shows in EN + DE, legacy label preserved when no delta supplied
(back-compat), delta renders with `+`/`−` sign and one decimal,
±0 for near-zero, +delta on up-bad → orange, +delta on up-good →
green, neutral metric → muted regardless of sign.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `/admin/api-tokens` table rendered six full-width columns at every
breakpoint. On Pixel-5-class viewports (360 CSS px) the row exceeded
the card and forced the document itself to scroll horizontally — a
clear violation of the v1.4.x mobile-viewport contract guarded by
`e2e/mobile-viewport.spec.ts`.
Hide three lower-priority columns at narrow widths and surface the
hidden user inline so no information is lost:
- User column: `hidden sm:table-cell` (resurfaces ≥640px). On mobile
the username paints under the token name as a small secondary line.
- Last-used column: `hidden md:table-cell` (resurfaces ≥768px).
- Created column: `hidden md:table-cell`.
- Card padding shrinks from `p-6` → `p-4 sm:p-6` to claw back 16 CSS
px on each side at mobile widths.
The `<div className="overflow-x-auto">` wrapper stays as a
belt-and-suspenders guard for ultra-narrow viewports where even the
trimmed three-column layout might overflow (very long permission lists
or long token names). Without it the table would push the card itself
wider than the viewport.
Add `api-token-overview-responsive.test.tsx` to lock the contract:
the user / last-used / created columns must keep their
`hidden + (sm|md):table-cell` classes, the inline mobile fallback
span (`sm:hidden`) must duplicate the username so hiding the column
doesn't drop data, and the card root must keep the responsive
`p-4 sm:p-6` padding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>