Full-flow integration test:
1. MockAIProvider returns a strict payload with rationale on every
rec.
2. generateInsight() validates + parses without retry (single
provider call).
3. The parsed payload is mapped onto the InsightResult UI shape
and fed through InsightAdvisorCard.
4. Assert the rec card is rendered, severity badge surfaces,
chevron is collapsed by default (aria-expanded="false").
Second case pins the legacyPayload CTA path: a string-only
recommendations[] array (the v1.4.14/v1.4.15 InsightResult shape)
with `legacyPayload` flagged surfaces the regenerate banner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each recommendation now lives in its own collapsible card. Collapsed
state shows: severity badge + rec text + named slot for the future
B5d confidence-ring + chevron toggle. Expanding reveals the
Oura-style "Contributors" panel:
- 3 rationale rows (Window / Compared to / Deviation)
- mini-chart pinned to the rec's data window via HealthChart's
new `mini` + `windowOverride` props (or MoodChart's mirror props
for mood-typed metrics)
- citation footnote (B5a) when referenceId resolves
- named slot for the future B5e feedback-thumbs
Default state: collapsed. ChevronDown / ChevronUp toggle aria-expanded
and a 200ms ease-out animation reveals the rationale block.
Recs without rationale (legacy payloads) and plain-string recs render
as a non-expandable list item with the inline citation footnote
preserved — defence in depth so a stale cached payload still
surfaces its source even though the rationale block is empty.
InsightAdvisorCard renders the new component and surfaces the
legacyPayload CTA when the route flagged the cached blob as pre-B5c
shape — clicking the regenerate button is the single trigger; we
never auto-regenerate.
InsightRecommendation type widens to optionally carry rationale +
metricSource + severity + id (mirrors the strict
aiRecommendationSchema). Optional in the UI type because legacy
cached payloads predate them; the server-side schema enforces
presence on fresh generations.
Layout slots are deliberately named so B5d (confidence) and B5e
(feedback) plug into named positions without touching this file:
- data-slot="rec-confidence-slot" → B5d ConfidenceRing
- data-slot="rec-feedback-slot" → B5e RecommendationFeedback
11 new tests in `recommendation-card.test.tsx` pin the contract
(severity badge, default-collapsed, rationale row labels in both
locales, mini-chart routing for BP / mood, slot positions, legacy
fallback). Existing 6 citation-footnote tests still green via the
inline-footnote fallback path.
7 new EN+DE i18n keys under `insights.recommendation.*`:
rationale, rationaleWindow, rationaleComparedTo, rationaleDeviation,
rationaleExpand, rationaleCollapse, legacyPayloadCta.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the health-chart B5c mini contract for the mood chart:
- mini: drops the range tabs, the toggle row (moving-average / trend
/ target-bands), and shrinks the chart height to 140px so it fits
inside the rationale card without dominating it. Emoji glyphs
+ lavender gradient + tooltip survive intact.
- windowOverride: pins the rangePoints state to the rationale enum
value on mount.
The mood chart's metric-source key is "mood" (not a measurement-type
enum value), so the recommendation-card metric-mapping helper routes
mood-rationale charts here rather than through HealthChart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Oura-style RecommendationCard (B5c) embeds a small chart pinned
to the recommendation's rationale.dataWindow. HealthChart gains two
opt-in props:
- mini: drops the range tabs, the moving-average / trend / target-
band toggle row, and shrinks padding (h-[140px] vs h-[240px]).
Tooltip + gradient + personal-baseline behaviour stay intact so
the small chart still tells the same visual story.
- windowOverride: pins the rangePoints state to the rationale enum
value (last7days / last30days / last90days / allTime) regardless
of any parent UI state. Mini mode hides the range tabs anyway,
so the user can't manually override the pinned window.
The pure helper resolveMiniRangePoints() (`mini-window.ts`) maps the
rationale enum onto the existing per-points TIME_RANGES_KEYS values.
Single source of truth so future schema changes touch one file.
7 new tests in `health-chart-mini.test.tsx` pin the contract:
- enum-to-points mapping for all 4 windows
- mini mode hides toggles + range tabs
- non-mini mode still renders them (regression guard)
- windowOverride seeds the rangePoints state on mount
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>
When a first-attempt response omits the rationale object (or emits
empty comparedTo / deviation strings), the corrective reprompt
now names the three rationale fields explicitly so the model has
a targeted fix instead of guessing what changed. The reprompt also
restates that rationale.dataWindow MUST equal metricSource.timeRange
— bracket-mismatch is the most likely model-side bug now that the
shape is new.
The wrapper's post-validation `annotate()` block additionally
emits three new Wide-Event meta fields:
- ai_rationale_total_recommendations
- ai_rationale_with_rationale
- ai_rationale_missing_recommendation_ids
Strict-parsed payloads always have rationale on every rec (the
schema rejects otherwise), so the missing-ids array is an
observability tripwire — it will only populate if a legacy payload
ever rides through .passthrough() on a back-compat path.
v1.4.17 admin AI dashboard plots rationale-coverage alongside the
existing citation-coverage breakdown.
4 new tests in `rationale-wrapper.test.ts` pin the contract:
- corrective retry message names dataWindow / comparedTo / deviation
- empty deviation triggers retry (not just absent rationale)
- annotate() carries the new ai_rationale_* keys on success
- rationale-coverage = totalRecommendations on a fully-shaped payload
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The output-format block now advertises the new `rationale` shape
(dataWindow / comparedTo / deviation) and a fifth GROUND RULE
mandates that every rec carry one. The dataWindow MUST equal
metricSource.timeRange so the UI's mini-chart can pin to the same
window the rec was derived from — bracket-mismatch prevention is
cheaper to enforce in the prompt than to detect post-parse.
Both EN + DE prompts updated symmetrically. PROMPT_VERSION stays
at 4.16.0 since B5a already bumped it for this milestone — the
content extension is a same-version revision, not a behavioural
shape change that warrants 5.0.0.
6 new prompt-assertion tests pin the contract:
- output-format block names the three rationale fields
- enum values (last7days...allTime) mentioned for both locales
- ground rule "MUST carry a rationale" present in EN + DE
- "factual" / "sachlich" language guard against placeholders
Existing 213 AI tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each recommendation now carries a 3-field `rationale` object that
powers the upcoming Oura-style "Contributors" expand-card:
- dataWindow: which time window the rec is based on (matches the
metricSource.timeRange enum so the UI mini-chart can pin to it)
- comparedTo: what the user's data is being compared against
(the user's own baseline or a guideline ceiling)
- deviation: size + direction of the deviation that triggered
the rec
Empty `comparedTo` or `deviation` is rejected so the model cannot
emit a placeholder rationale card. Optional `referenceId` rides
along for defence-in-depth; the rec-level referenceId stays the
canonical citation source.
`findRecommendationsMissingRationale()` lets the route detect
legacy (pre-B5c) cached payloads so the UI can show a "regenerate
for new explainability features" CTA. User-initiated regeneration
stays the trigger; nothing auto-runs.
10 new tests in `rationale-schema.test.ts` pin the contract:
- rationale required on every rec
- comparedTo / deviation reject empty strings
- dataWindow rejects unknown enum values
- legacy payload detector returns missing rec ids only
- mixed payloads flag only the legacy entries
Existing test fixtures in `generate-insight.test.ts`,
`reference-id-schema.test.ts`, `citation-enforcement.test.ts`,
and `citation-coverage-logging.test.ts` updated to include
rationale on every well-formed rec — typecheck-clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the v1.4.15-vintage two-card layout (Codex connect at the top,
personal-provider form at the bottom) with the dropdown-driven UX
called out in feedback_settings_no_split.md: one Pulldown picks the
active provider, the form below adapts to that choice, and the
fallback chain at the bottom reorders / toggles entries with arrow
controls.
Five provider config forms live in this file (Codex, OpenAI, Anthropic,
Local, Admin OpenAI) and share a single card frame so transitions feel
like updating one concept rather than swapping pages. The Codex form
keeps the device-code flow + OAuth callback handling unchanged. OpenAI
gets a model dropdown plus a collapsed Base URL override (advanced).
Anthropic and Local mirror that shape with the right credentials. The
fallback chain calls the new PUT /api/insights/provider-chain to
persist reorders.
The Select is URL-synced via ?provider=… so deep links work, the SSR
test can drive each branch deterministically, and a refresh keeps
the user on the form they were editing. Native <select> elements
keep the SSR-only render-to-static-markup test happy without bringing
a portal-based Radix tree into play. No new dependency — dnd-kit is
not in package.json so reorder uses up/down arrow buttons.
EN + DE i18n keys land alongside; the i18n parity guard test sees
both locales and the existing settings.ai.providerChain.types.* tree
keeps the per-provider labels stable across the chain summary and
the new chain-management UI.
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 7-item bottom nav collapsed each touch target to ~50 px wide on
a 375 px viewport — only just-passing WCAG 2.5.5's 44 px floor and
visibly cramped on iPhone-13-mini-class screens. The mobile audit
(`.planning/phase-A5-mobile-findings.md`) recommended 5+More.
Primary strip keeps the four most-used surfaces plus Insights:
Home, Measurements, Mood, Medications, Insights. Targets and
Achievements move into a Radix `<Sheet>` triggered by a sixth
"More" entry. Order matches the v1.4.15 layout for the four core
items so muscle memory holds.
The More button mirrors the same `min-h-11 min-w-11` hit area;
`aria-haspopup="dialog"` + `aria-expanded` keeps screen-reader
behaviour tight; the sheet renders bottom-anchored with safe-area
padding so the inner links stay reachable above the iPhone home
indicator. Active styling lights the More entry whenever any of
its overflow children is the current route, so the user always
knows which strip-level link "owns" their position.
When `/api/admin/status` 500s (rolling deploy, transient DB blip),
the section's error state previously rendered a static "refresh the
page" message — forcing a full reload + cookie revalidation just to
recover from a 5-second hiccup. Pairing the alert with an inline
Retry button (`refetch()` from React Query) lets the admin recover
in-place without losing scroll position or any other open admin tab.
The button is `min-h-9` desktop-tight (this is admin, not mobile-
critical), shows a spinner while `isFetching`, and disables itself
during the request. The German + English message strings drop the
"refresh the page" tail since the button now does that work.
Mobile audit (`.planning/phase-A5-mobile-findings.md`) flagged the
`<TabsList>` primitive as overflowing the viewport on `/insights`,
`/admin` and several `/settings/*` surfaces — six i18n-driven tab
labels at typical German widths blow past 375 px before the inline-
flex shrink-wraps. Labels can't be truncated deterministically (they
include "Auswertungen", "Sicherheit & Privatsphäre"-style strings)
and word-wrap inside a tab is the worst possible look.
The list now scrolls horizontally on mobile via `overflow-x-auto` +
`touch-pan-x` (iOS PWA gesture). `max-w-full` prevents the inline-
flex from forcing the parent wider. Desktop renders identically
because the existing parent containers all give the strip enough
room to lay out without scrollbars. Vertical-tabs orientation keeps
its `overflow-x-visible` to avoid double-overflow with the
flex-column layout.
The AI section now renders a small read-only summary of the user's
fallback chain: "Active provider: ChatGPT (Codex)" plus "Configured
chain: ChatGPT (Codex), OpenAI (your key), Admin OpenAI". When the
in-process cache reroutes to a fallback (the primary 401'd in the
last hour), the line gains "(cached: OpenAI)" so the user can see the
runner has shifted without digging through the wide-event log.
The full management UX (drag-to-reorder, on/off toggles, add/remove
provider entries) is owned by phase B2 — this surface is deliberately
tight so the two phases don't collide. The card hides itself when no
provider is configured at all so accounts that haven't set up AI
don't see an empty header. EN + DE i18n keys added under
settings.ai.providerChain.*; provider-type labels are localised so
"Local model" reads "Lokales Modell" in German.
The new GET /api/insights/provider-chain endpoint is the data source.
Returns the resolved chain in priority order plus the cached active
provider (when set), wrapped in the standard apiSuccess envelope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v1.4.16 B1a polish across all dashboard charts. The
medication-compliance chart already shipped 7-day-trend + target/
threshold reference lines from A6; this commit brings it into visual
parity with BP/weight/pulse/mood:
- ComposedChart replaces LineChart; a gradient-filled Area paints
under the compliance line. The medication gradient uses the purple
Dracula token, matching the existing line stroke.
- Rich tooltip via RichChartTooltip — date label, compliance
percentage with tabular numerals, delta-vs-target sub-line that
reads "−15 pp vs. target" so the gap to 100 % is immediately
visible.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 daily points.
Existing "no scheduled doses" empty state is preserved (the user
could have a long history of skipped scheduled days that drops
the chart to fewer than 3 actionable points).
The 80 % minimum-acceptable threshold and 100 % goal ReferenceLines
from A6 stay untouched; gradient plus those lines now make the
"target range" feel like a band rather than a pair of guides.
1 new test pins the gradient SSR contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
under the score line. The mood gradient uses the lavender token
(--dracula-lavender / #d6acff) which matches the existing line
stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
window (≥5 points required). Faint dashed line labelled "Your
normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
The Recharts `dot` prop accepts a function returning an SVG node;
we render <text> centred over (cx, cy) so the glyph sits on the
line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.
1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WCAG 2.5.5 Level AAA target-size floor for non-essential controls is
24×24 px and Level AA pointer-target is 24×24 with 24 px gap, but the
mobile-app convention everyone follows is the 44×44 hit-area Apple
HIG / Material recommend. The default `<Button size="sm">` in the
shadcn-base lands at `h-8` (32 px) — readable on desktop, tight on
phones, especially in the most-touched admin/settings/onboarding
surfaces called out in the v1.4.15 design review.
Cross-cutting `button.tsx` `h-9 → h-11` was rejected as too risky
mid-marathon (every UI affected, dozens of layout assumptions). The
targeted alternative bumps the listed call-sites with `min-h-11`
overrides: tour Skip / Back / Next, notification re-enable + send-
test, backups Restore / Download / Run-now / Upload. Admin-only
recent-audit-preview is a `<Link>` not a `<Button>` and is left
alone.
The shadcn `min-h-` Tailwind utility wins over the variant `h-`
because both apply but `min-h-` only floors. So desktop visuals stay
within ~4 px of the previous render while mobile reaches the 44 px
floor.
Both `healthlog-tour-session-dismissed` and `healthlog-tour-referrer`
were un-scoped global keys, so a browser shared by two HealthLog
identities (admin impersonating a target user, family laptop, etc.)
inherited the first user's "tour dismissed for this session" flag —
the second user logged in and silently never saw the spotlight tour.
Both keys are now suffixed with `:${userId}`, so each identity's
state stays independent. The two builders (`tourSessionDismissedKey`
and `tourReferrerKey`) are exported so the wizard's `persistAndExit`
shares the same wire format and a unit test pins the scheme against
future drift.
One-off cost: every currently-open tab re-fires the tour once on the
next load because the legacy un-prefixed keys are no longer read.
Acceptable tax for multi-tenant correctness ahead of v1.5.
`MoodEntry.tags` is stored as a JSON-array-as-string in
`mood_entries.tags`. The backup schema accepted any string here, so a
malformed blob (or a tampered backup) would persist into the DB and
crash downstream readers that blindly `JSON.parse` it. Restore-time
validation now refines the field to one of:
- null (or omitted) — no tags on this entry
- "" — legacy moodLog wire format (treated as null)
- a JSON string that parses to an array of strings
Anything else fails Zod with a 422 from the restore endpoint instead
of bubbling out of `prisma.createMany` with an opaque message. New
unit-test file covers the three accept cases plus three reject cases
(non-JSON, JSON object, JSON array of non-strings).
Phase B6 commit 4 of 4.
* `sections.test.tsx` gains two SSR smoke tests for the renamed
`<ThresholdsSection>` (was `<ThresholdsSettingsSection>`) — one
asserts EN heading + editor mount-point, one asserts the DE
heading. The mock for the inner editor was already updated in
commit 2 of this phase.
* New `sections-i18n-parity.test.ts` walks every slug in
`SETTINGS_SECTION_SLUGS` and asserts both `.title` and
`.description` resolve to non-empty strings in EN AND DE — and
that the DE description is not a verbatim copy of the EN one.
The test caught a real instance: notifications.description was
"Telegram, ntfy, Web Push." in both locales because the channel
names are proper nouns. Rewrote the description in both locales
so it actually reads as German vs. English while still listing
the channels.
Settings tests: 63/63 green. Typecheck clean. Lint clean (12
pre-existing warnings carried over from the baseline gate, 0 errors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6 commit 2 of 4.
* `thresholds-settings-section.tsx` (route wrapper) renamed to
`thresholds-section.tsx` so the filename + default export match
the slug — every other section in `src/components/settings/`
follows the `<slug>-section.tsx` `<SlugSection>` convention.
* The historic inner editor at `thresholds-section.tsx`
(`<ThresholdsSection>`) is moved out to
`thresholds-editor-section.tsx` (`<ThresholdsEditorSection>`)
so the names stop clashing.
* Rewrites three muddy section descriptions in EN + DE:
- account: lists every concern the section actually covers
- api: drops the misleading "headless clients" / "Drittanbieter"
framing for "your own scripts and apps"
- advanced: post-B7 there's no Import surface here — describes
the danger zone honestly.
No user-facing setting removed, no slug renamed, no i18n key removed.
Settings smoke tests still green; the route mount goes through
`<ThresholdsSection>` exactly as before, just from the renamed file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HealthChart wrapper is the single component the dashboard uses for
every numeric vital — BP systolic/diastolic, weight, pulse, body fat,
sleep, steps. This commit upgrades it once with the v1.4.16 visual
leap so all those metrics jump together.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area can
paint under the line. Each metric gets its own
chart-gradient-inline-{type} <linearGradient> definition; default
35 percent top alpha → 0 percent bottom matches the Apple Health
Trends look. A sibling 0×0 SVG mirrors the gradient so SSR markup
carries the slot for visual-regression and e2e probes.
- Personal baseline reference line: 90-day rolling median per type,
painted as a faint dashed line labelled "Your normal" / "Dein
Mittel". The new computePersonalBaseline() helper returns null when
fewer than 5 points exist so a baseline never renders off noise.
- Rich tooltip via the new RichChartTooltip primitive: rounded card
with drop shadow, per-row coloured dot, tabular-nums value cell, and
a delta-vs-baseline sub-line ("+3 mmHg vs. your normal"). Auxiliary
rows from the moving-average / trend overlays are filtered out so
the card stays focused on the primary metric.
- 600ms ease-out animation on first render via Recharts'
isAnimationActive prop, suppressed when prefers-reduced-motion is
set at the OS level (new src/lib/charts/reduced-motion.ts helper
with 4 unit tests).
- Sparse-data placeholder via ChartEmptyState when fewer than 3
daily points exist — a near-empty chart looks broken, the
placeholder reads as intentional.
In-target zone shading was already wired via the existing valueBands
prop and stays untouched. The dashed peer's labelling is unchanged.
7 new tests pin the contract (4 baseline-helper, 2 SSR markup, 1
gradient-id namespacing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Add a JSONB column on the users table that stores an ordered list of
provider preferences for multi-provider redundancy. Each entry carries
{ providerType, priority, enabled }; credentials still live in the
dedicated encrypted columns so AES-256-GCM key rotation keeps working
unchanged. Null preserves the legacy single-provider resolution path.
Ships a defensive parser/serializer pair with a sensible default chain
(codex -> openai -> anthropic -> local -> admin-openai) so a malformed
JSON on disk falls back to the default rather than 500-ing insight
generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the `app-logs` row to `<AdminShell>` so the new section shows up
in the admin sidebar (desktop) and mobile section strip alongside
Login Overview and Backups. Uses `lucide-react`'s `FileText` icon
because nothing else in the bundle reads as "structured log lines".
The mobile-strip / desktop-sidebar pattern follows the conditional
admin-only nav established in v1.4.15 A1 and v1.4.16 A1: the global
sidebar still surfaces a single `/admin` link, and the per-section
nav lives inside the admin shell.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four `/api/export/*` route handlers landed under plain segments
(`/api/export/measurements` instead of `/api/export/measurements.csv`)
because vitest's resolver chokes on dotted route segments. The
`<ExportSection>` component still pointed at the old dotted paths —
which 404'd in the browser even though the routes themselves were
healthy. Realign the four endpoint URLs so the consolidated Settings →
Export UI actually downloads.
Filename + Content-Disposition still carry the `.csv` / `.json`
extension so the browser writes a correctly-named file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the in-memory ring buffer (`src/lib/logging/in-memory-buffer.ts`)
into `transports.emitEvent()` so every wide event that survives the
sampler also lands in a 500-entry per-process buffer. Adds the
`app-logs` admin section slug, the renderer case, and the route entry
exports needed for the new section component to mount.
The buffer is volatile and per-process — a worker-only event won't
show up in the web buffer, and a restart drops everything. The section
header copy spells that out so admins know to keep Loki configured for
durable diagnostics. Egress through `/api/admin/app-logs` runs every
event through `redactSecrets()` so a stray Bearer / hlk_ / sk- token
in an `error.message` never leaks to the admin UI; storage stays raw
to keep the diagnostic value when shipped to Loki.
The companion buffer + transports tests live under
`src/lib/logging/__tests__/in-memory-buffer.test.ts` and
`transports-buffer.test.ts` (committed separately as part of the
buffer scaffolding). The route-level tests in
`src/app/api/admin/app-logs/__tests__/route.test.ts` cover newest-
first ordering, traceId / level / action filters, the `limit` cap,
secret redaction on egress, and the Zod-rejected invalid `level` case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>