Cached insight blobs from before the v1.4.16 strict schema landed
crashed /insights with `Cannot read properties of undefined (reading
'replace')` on `stripChartTokens(insight.summary)`. The pre-strict
shape (`{changed, stable, drivers, nextSteps, confidence,
limitations}`) has no `summary`, no `recommendations[]`, and no
`findings[]`, so the rich card had nothing to render and the
chart-token helper crashed on undefined input.
The advisor card now short-circuits to a self-contained legacy-payload
card with a one-click regenerate CTA when the shape is unrenderable.
`isLegacyInsightPayload()` also flags the v1.4.14 pre-strict shape so
the API surfaces the right `legacyPayload: true` flag from a cache
hit. Defense-in-depth: `stripChartTokens()` and `parseChartTokens()`
treat null/undefined as the empty string instead of crashing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /insights page rendered only the text-only InsightStatusCard per
section, hiding all of B5c (rec rationale + mini-chart), B5d (confidence
meter), B5e (thumbs feedback) and the broader B1b polish from users.
Wave-D design review flagged this as CRITICAL C1.
Wires the InsightAdvisorCard component into the live route, consuming
the existing /api/insights/generate POST. The route already returns the
24h-cached payload without burning a rate-limit token, so a single
POST-without-force is functionally a GET-or-generate — no new API
surface needed (the reconcile report deferred C1 because it assumed
otherwise).
The cache is shared with the dashboard preview via the new
queryKeys.insightsAdvisor() so a regenerate on either surface refreshes
the other without a second LLM call.
Per-section InsightStatusCards stay below as supplemental detail; the
hero now also wires its regenerate button to the same handler.
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>
7 of 8 apply-yes simplify findings landed:
F2 — `generateInsight()` confidenceContext config-bag dropped. No
production caller passed the resolver; the wrapper now derives inputs
from the parsed payload directly (n from metricSource.n, recencyDays
=0, deviationStdRatio=null). Two confidence-wrapper tests rewritten
to test the parsed-payload path. v1.4.17 will re-introduce a
resolver injection when the feedback ratchet ships a real second
caller.
F3 — `aiRecommendationRationaleSchema.referenceId` speculative
optional field removed. No consumer reads it; rec-level referenceId
is the canonical citation pointer.
F4 — `applyLastWorkingCache` doc-comment trimmed: dropped the
"NEW array" defensive note (no caller compares the input ref).
F5 — `<RecommendationFeedback>` re-uses the canonical
`RecommendationFeedbackRequest` types from the validation schema
instead of hand-maintaining the same enum vocabulary. Removes a
silent-drift risk if v1.4.17 adds another time-range value.
F6 — `formatRelativeTime` doc-comment trimmed: dropped the stale
`Intl.RelativeTimeFormat` polyfill claim (baseline-supported since
2018) — kept the genuine WHY (i18n consistency).
F7 — `<RecommendationCard>` 26-line slot-architecture header trimmed
to a 4-line WHY. The slots being narrated as "future" were filled by
B5d / B5e in the same file.
F9 — `pickProviderType` doc-comment trimmed: removed the
mis-anchored PROMPT_VERSION snapshot paragraph (the function only
picks providerType — promptVersion is read elsewhere).
F10 — `legacy-payload.ts` `UnknownRec` interface removed; cast
inlined as `(rec as { rationale?: unknown })`.
F12 — `<ConfidenceMeter>` 20-line band-policy header trimmed to a
3-line WHY. The bandFor() helper + colour-by-band record below are
the documentation now.
(F1 + F8 deferred — both flagged "no" in simplify review and need
Marc's call.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WCAG 2.5.5 minimum hit-area is 44×44 CSS px and the Wave-C polish set
that as the project floor. Several v1.4.16 surfaces missed it:
- Recommendation thumbs (h-7 = 28 px) — `<RecommendationFeedback>` up
+ down both bumped to `min-h-11 min-w-11` with focus-visible ring.
- Recommendation chevron expand toggle (~24 px, no focus state) —
`<RecommendationCard>` rec-expand-toggle bumped to `min-h-11
min-w-11` with focus-visible ring.
- Chart range tabs (7d/30d/90d/All) — `min-h-9 px-2.5` ≈ 36×28 px on
Pixel 5; bumped to `min-h-11 px-3` across HealthChart, MoodChart,
MedicationComplianceChart, ComplianceCharts, ComplianceLineChart.
- Medication CSV "include intake history" raw `<input
type="checkbox">` (16×16, system-blue on iOS Safari) — replaced
with shadcn `<Switch>` inside a `min-h-11` label so the row is
properly tappable and visually consistent with every other
settings toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an SSR integration suite that exercises InsightAdvisorCard +
RecommendationsGrid + RecommendationCard + ConfidenceMeter +
RecommendationFeedback all painting together so the cross-feature
slot positions don't silently regress. The B5c phase report flagged
that no e2e is feasible because /insights doesn't currently mount
the advisor card; the SSR test covers the same surface end-to-end.
Pins the severity-ordering preserves under low-confidence recs (an
urgent rec with 12% confidence still sorts above an important rec
with 80%) and confirms the grid wrapper unmounts cleanly when there
are no recs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps the page-hero gradient + border opacity from /10 + /20 to /15 +
/25 so the band reads more clearly against the dark Dracula
background; the cumulative effect on the dark page bg now sits at
~17% lightness delta which clears the 3:1 UI-element contrast bar.
Skeleton placeholders went from /50 + /40 + /30 to /60 + /50 + /40
so the loading state is visible on the dark canvas without being so
loud it competes with the live content next to it.
Pins the contrast utility classes via a regression test on the hero
shell so a future refactor can't silently drop them below the
visibility threshold.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the orphan v1.4.0 InsightsCard with a leaner
<InsightsCardPreview> that surfaces only the top 1-2 severity-ordered
recommendations + a "View all" CTA pointing to /insights. Same visual
language as the page (Dracula sparkle glyph, severity-coloured left
border, ring-variant confidence meter inline).
The legacy InsightsCard handled its own data fetching against the
deprecated changed/stable/drivers/nextSteps shape; preview now takes
an `insight: InsightResult | null` prop so the dashboard wires its
existing `/api/insights/generate` data without redundant fetches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loading state replaces the spinner-only placeholder with a 3-card
skeleton grid that mirrors the final layout, so the page doesn't
visually jump when content lands. Each placeholder pulses with a
100ms staggered delay.
Empty state gains an illustrated sparkle glyph + a centred copy block
(max-w-prose) so the affordance reads as an invitation rather than a
muted dead-end.
Error state moves from a flat banner to a card with a discoverable
retry button (data-slot=insight-retry-button) when an onRegenerate
handler is supplied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps the InsightAdvisorCard summary surface to text-base / leading-
relaxed and constrains it to max-w-prose so the prose stays readable
on a wide viewport. Wraps the existing chart-token renderer in a
new mini flag so the summary's inline charts shrink to sparkline
form (h-[140px], no range tabs); per-finding inline charts keep the
full chart so the user can still interact with the data series.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the flat <ol> list inside InsightAdvisorCard with a responsive
2-col grid wrapper that severity-orders the recs (urgent → important →
suggestion → info), paints a Dracula-token left border per severity,
and staggers a 100ms fade-in across the row so the cards sweep in on
first render. Hover on desktop lifts the card by 0.5px and grows the
shadow.
Internal slots of RecommendationCard untouched — the severity border +
hover-lift + animation-delay live on a thin shell wrapper around the
card so the B5c/d/e plumbing (rationale, confidence, feedback) keeps
working unchanged.
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>
After B5e wired the feedback thumbs into rec-card-feedback-slot, the
B5d test needs the same useAuth + useMutation + tanstack-query SSR
mocks that the existing rec-card.test.tsx carries. The test that
asserted "rec-feedback-slot is empty" is relaxed to "rec-feedback-slot
is still rendered" — B5e legitimately fills it now, and the slot's
mere presence is what B5d needs to guarantee its plug-in point stayed
intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end integration test that pins the wrapper-override contract
across the full stack:
1. MockAIProvider returns a payload where the model claims
confidence=99.
2. generateInsight() parses + overwrites with the deterministic
value (n=9, recencyDays=0, ratio=null → 65).
3. InsightAdvisorCard renders the parsed payload through
RecommendationCard → ConfidenceMeter.
4. The rendered card shows the SERVER-computed band ("medium" /
yellow), NOT the model-claimed 99 ("high" / green). The
aria-label announces "Confidence: 65 of 100".
A second case pins the small-data shield: with n=1 the wrapper
hard-caps at <=15, the meter renders the draft pill (red), and the
"Draft" tag is visible in the markup — Apple's quiet-when-unsure
affordance flows through end-to-end.
Coverage now includes:
- Unit: computeConfidence() — 12+3 fixtures (input matrix +
monotonicity + integer invariants) (commit 1).
- Wrapper: 6 cases pinning the override contract (commit 2).
- Component: 17 cases on ConfidenceMeter band rendering, both
variants, EN/DE aria-label, clamp behaviour (commit 3).
- Card wiring: 8 cases on slot fill, low-confidence caption,
feedback-slot left untouched (commit 4).
- Integration: 2 cases on full-flow override + draft band
(this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `data-slot="rec-confidence-slot"` placeholder reserved by B5c is
now filled with `<ConfidenceMeter value={rec.confidence} />`. The
slot stays empty when `rec.confidence` is undefined (legacy cached
payloads predate the field) so a missing value never gets dressed up
as a draft pill.
Below 50, the expanded rationale card surfaces a "Low confidence —
based on limited data" caption so the rec stays visible but framed
as preliminary. The caption suppresses above 50 — the meter alone
speaks. Below 25 the meter itself is replaced by a draft pill
(handled inside `<ConfidenceMeter>`); the caption still applies
because draft <= low.
The B5e feedback-thumbs slot
(`data-slot="rec-feedback-slot"`) is untouched. The wiring test
explicitly asserts the feedback slot is still present and empty so
the parallel-running B5e agent has a stable plug-in point.
Threshold lives in a single named const
(`LOW_CONFIDENCE_CAPTION_THRESHOLD = 50`) co-located with the band
ladder in `<ConfidenceMeter>` so a future tune-pass can adjust the
ratchet in one place.
8 component tests cover: meter renders in slot at 85, slot stays
empty when confidence is undefined, medium/low/draft band rendering,
caption shows below 50, caption hidden above, German caption
translation, feedback-slot untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders the deterministic 0..100 score from `computeConfidence()` as
either a 5-bar meter (default) or a 28x28 SVG ring. Color bands:
- >=80: green ("high"), 5 bars / full ring
- 50..79: yellow ("medium"), 3-4 bars
- 25..49: orange ("low"), 2 bars / partial ring
- <25: red "Draft" pill REPLACES the meter — Apple's
quiet-when-unsure affordance per research §2.A. The rec is
drafted but the model isn't asserting it.
Bars rise in height across the row (4 / 6 / 8 / 10 / 12 px) so the
"more is more" cue is independent of color, satisfying the
non-color-vision a11y baseline. SVG ring uses stroke-dasharray with
the dracula band-stroke per band so the same color story is reused.
aria-label always announces the numeric score
("Confidence: 67 of 100" / "Vertrauen: 67 von 100"), even in the
draft-pill state, so screenreaders aren't blind to the visual band
distinction.
Inputs are clamped: NaN / Infinity / negative -> 0 (draft); >100 ->
100 (high). The component is otherwise a pure render — no state, no
effects.
5 i18n keys per locale: confidence, confidenceAria (interpolates
{value}), confidenceHigh, confidenceMedium, confidenceLow,
confidenceDraft. EN+DE parity test green.
17 component tests cover all 5 value bands (high/medium/low/draft +
band edges), both variants (bars/ring), aria-label translation in
EN+DE, and clamp behaviour at the negative + >100 ends.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: fill the `data-slot="rec-feedback-slot"` reserved
by B5c. The RationaleCard now mounts `<RecommendationFeedback>` when
the rec carries every attribute the feedback endpoint validates —
id, severity, metricSource.type, and a metricSource.timeRange that's
in the four-window vocabulary (`last7days` / `last30days` /
`last90days` / `allTime`).
Defence-in-depth via `asFeedbackTimeRange()`: a rec with an
out-of-vocabulary timeRange (e.g. `"lastFortnight"` from a future
provider that drifted) leaves the slot empty rather than rendering a
feedback row that would 422 on submit. Same gate handles legacy recs
without an id — the slot stays empty so plain-string recs from
v1.4.14/v1.4.15 cached payloads can't suddenly produce thumbs that
have nothing to attribute server-side.
Confidence-meter slot (B5d's territory) is untouched — that's
filled by the parallel B5d worker.
Tests: 3 new rec-card scenarios pin the wiring (thumbs render when
fully attributed, hidden when no id, hidden on bad timeRange).
Existing rec-card tests retrofit `useMutation` + `useAuth` mocks so
the component's TanStack Query mutation stays inert during SSR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: ship the `<RecommendationFeedback>` component that
plugs into the `data-slot="rec-feedback-slot"` reserved by B5c's
RecommendationCard. Two icon buttons (thumb-up / thumb-down) under
each rec; TanStack Query mutation against `/api/insights/feedback`.
State machine:
default → submitting-{up,down} → submitted-{up,down} (success)
→ already-rated-{up,down} (409)
→ default (network err, silent)
On success the buttons morph into a "Thanks for your feedback"
confirmation row with a green check + the chosen verdict's
glyph for context. On 409 (server says the user already rated this
exact rec text in a prior session) the row renders the prior
verdict highlighted and the buttons stay un-clickable — the
aggregator already has the signal.
Local cache via localStorage keyed on
`(userId, recId, truncatedRecText)` so a page refresh doesn't allow
re-rating before the server's 409 fires. The local cache is best-
effort; the server-side dedup (rec_feedback_user_rec_text_key
unique constraint) is the source of truth.
Auth-resolution race: when `useAuth()` returns null on first render
(in-flight fetch), the component re-syncs state from the local
cache during the next render — via setState-during-render, NOT
inside useEffect, to honour the project's
`react-hooks/set-state-in-effect` lint rule.
Accessibility: both buttons are real `<button type="button">`,
focusable, Enter/Space-activatable. aria-label translates to EN+DE.
i18n: 5 new keys under `insights.recommendation.*`:
feedbackHelpful, feedbackNotHelpful, feedbackThanks,
feedbackConfirmed, feedbackAlreadyRated.
EN+DE parity verified by the existing i18n parity guard.
6 component tests (renders both buttons, aria-labels, German labels,
submitted-up confirmation, already-rated highlight, type=button form
safety).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The base-system prompt invites the model to embed `metric:WEIGHT` /
`metric:BP` etc. tokens in `primaryRecommendation` so the UI can drop
inline charts under the prose. The hero "Key Takeaway" card was the
only renderer printing the field raw — when the model followed the
instructions, the user saw the literal string `metric:WEIGHT` inside
the recommendation. Apply the same `stripChartTokens` + `<InlineCharts>`
pattern that the summary already uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bucket D phase 1 of the v1.4.3 polish pass — the AI insights felt
"static" because (a) findings repeated phrasing with no concrete
numbers and (b) the inline-chart machinery existed but was prompted
permissively, so the model rarely used it.
Two concrete moves, scoped to ship in this hotfix:
1. **`metric:MOOD` is now an allowlisted chart token.** Renderer in
`<InsightAdvisorCard>` branches on `tokenKind(token)` — measurement
tokens still mount `<HealthChart>`, but `metric:MOOD` mounts the
dedicated, self-fetching `<MoodChart>` instead. The mood timeline
was previously the most under-served by the AI prose because it
couldn't be charted inline; now the model can illustrate adherence-
risk and mood-drift findings with the same chart users see on the
/mood page. Drift test was updated in lockstep — the v1.4.2 guard
that asserted MOOD was excluded now asserts it's present, plus a
new guard asserts COMPLIANCE is still excluded (no self-fetching
wrapper exists yet for `<ComplianceLineChart>`; ship that in v1.5).
2. **Prompt grew an EVIDENCE CONTRACT and a forbidden-phrase list.**
The base system prompt (DE + EN both) now requires every
`findings[]` entry to put a concrete number from the snapshot in
`value` ("138/85 mmHg", "+0.4 mmol/L vs. 30d-avg") instead of
adjective prose ("slightly elevated"). Findings without a snapshot
anchor MUST be omitted — variable cardinality 0..8 explicitly
stated, sorted by salience, NO padding. The forbidden-phrase list
blocks generic boilerplate that signals ungrounded output: "achte
auf ausreichend Schlaf", "drink enough water", "regular exercise",
"consult your doctor" (except in the disclaimer field which exists
precisely for that line). The chart-token instruction flipped from
passive ("Du DARFST") to active ("Bevorzuge Tokens immer dann,
wenn …") with three explicit triggers — temporal claims, threshold
references, window comparisons — that reliably surface chart-worthy
findings.
Carved out of v1.4.3 for v1.5: telescoping multi-resolution payload
(14d daily + 10w weekly + 21mo monthly + yearly summary per QA
research 1) and the evidence-ref output schema extension (QA 2).
Both touch the prompt's input shape and would change every cache
key + token bill — they want their own phase.
Tests + typecheck green (671/671 — one new chart-tokens drift
guard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(insights): inline charts in AI findings via allowlisted metric tokens
Lets the AI insight advisor inline a Recharts panel directly under the
prose paragraph it talks about — e.g. a finding centred on systolic BP
renders the BP chart immediately beneath itself, instead of forcing the
user to scroll back to the dashboard charts.
Tokens are routed through a server-side allowlist:
- 13 fixed strings (one per MeasurementType plus MOOD and COMPLIANCE).
- A drift test fails if a new MeasurementType is added without a token.
- The greedy `[A-Z_]+` regex caps the parse so trailing junk like
`metric:WEIGHT' onclick='alert(1)'` cleaves cleanly into the safe
metric and inert React text — there is no markdown / no eval path.
- Hallucinated tokens (`metric:NUKE`) are silently dropped.
- The visible prose is run through `stripChartTokens` so the literal
token never reaches the DOM.
Prompts (base + 7 domain prompts, EN + DE) tell the model it may embed
exactly one of the allowlisted tokens inside `summary` or a finding's
`label`/`guideline` — nothing else has any effect.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(insights): drop metric:MOOD and metric:COMPLIANCE until dedicated charts exist
Review-pass M1: those two tokens routed through HealthChart, which
validates `type` against the `MeasurementType` Zod enum. MOOD and
COMPLIANCE aren't in the enum, so the request 422'd and the chart
panel rendered "no data" — silently degrading the most-promoted UX
path under prose that explicitly talked about the user's mood or
adherence.
Allowlist + every prompt now lists only the 11 tokens that actually
render a real chart. The drift test inverts the previous "must
include MOOD/COMPLIANCE" assertion to "must NOT include them",
locking the contract until dedicated MoodChart / ComplianceChart
components ship.
494 tests green, typecheck clean.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The AI insights surface had two long-standing weaknesses:
1. **Every analysis read like the user's first one.** The general-status
and blood-pressure prompts saw only the latest 30 days of data, not
the previous analysis the user already read last week. So the model
restated the same status every day instead of saying "your BP is down
4 mmHg from your last check" or "same elevated trend as last week".
2. **All findings looked the same.** The card listed every finding in a
uniform compact row. The model is instructed to put the most
clinically relevant finding first, but the UI didn't honour that
ordering with any visual weight.
This PR closes both for the two highest-impact insight surfaces (general
status and blood pressure). Same pattern lifts to the other five
generators in a follow-up.
**`src/lib/insights/memory.ts`** — new helper that reads the user's
previous cached insight from the existing `audit_logs` cache rows
(scope-keyed `insights.{scope}.{locale}`), returns it as a typed
`PreviousInsightContext`, and formats it into a locale-aware
prompt-injection block. Default 12-hour age floor keeps the model from
treating today's earlier run as "previous". The block ends with an
explicit comparison instruction in the user's locale; passing `null`
context produces a "first analysis — no delta to surface" instruction
so the model doesn't hallucinate one.
**Wired into `general-status` and `blood-pressure-status` generators.**
Both prompts now accept an optional `previousContextBlock` argument.
When supplied, the model gets the previous narrative + the comparison
instruction inline above the snapshot. No schema change — caches keep
writing to the same audit-log rows the helper reads from. Empty for
first-run users.
**Hero finding card.** Top finding gets a severity-tinted gradient
(green/cyan/orange/red), 2-px coloured left border, larger typography,
and a descriptive eyebrow label ("Top finding" / "Worth attention" /
"Top alert"). Secondary findings keep the previous compact layout so
the visual hierarchy reads at a glance. Eyebrow labels translated
under `insights.heroFinding{Positive,Neutral,Attention,Warning}`.
Tests: 400 → 410 (+10 for the memory helper covering JSON cache,
legacy plaintext fallback, age-floor semantics, locale-aware
formatting).
Follow-up PRs:
- Wire the same memory helper into the remaining five generators
(weight, pulse, BMI, mood, medication-compliance). Pattern is
mechanical — the prompt signatures already accept the new optional
argument.
- Dynamic chart inclusion via an allowlist token map
(`metric:BLOOD_PRESSURE_SYS` → `<HealthChart>` inline). Out of scope
for this PR.
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX
Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.
- i18n: add 21 missing translation keys (targets.* namespace was only
partially populated after the English-first migration; common.reset and
settings.configured were referenced but absent), plus common helpers
(copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
measurement/mood/medication-dependent key sets. Wire them into
measurement-form/list, mood-form/list, medication-form/card,
intake-history-list, intake-timeline, medications page, and Withings
sync in settings. Fixes dashboard not auto-refreshing after adding a
measurement (old code only invalidated ["measurements"], not
["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
messages via sonner toasts and invoke getApiErrorMessage() so saves
fail loudly instead of silently. Success also gets a toast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(i18n): package B — locale-aware formatters throughout
Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.
New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
dateTime, time, dateWithWeekday, monthShort. Also exports
parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
locale, memoized per render.
Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)
Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.
Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
Accept-Language) and sets <html lang> server-side, avoiding an
EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
SSR pass is correct.
Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
translator + locale. All section titles, column headers, BMI / BP
classifications, mood labels, and the footer disclaimer are
i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ux): package C — typed passkey errors, bug-report gating, error boundary
Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.
Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
to specific i18n keys instead of swallowing every failure as
"cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
now, so origin mismatches and previous-registration collisions show
actionable copy.
- 5 new passkey.* i18n keys in EN + DE.
Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
isAdmin}.
- /bugreport page now queries status first. When not configured it shows
a notice (different copy for admins vs. regular users) with a direct
link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.
Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
diagnostics (message, digest, stack (trimmed), URL, user-agent,
request-id, locale, timestamp) + report-issue link. Clipboard fallback
prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
it replaces the root layout on catastrophic failures — hardcoded
English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
retry, reportIssue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper
Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: small simplifications in i18n/UX changes
- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): capture upstream response body in OpenAI errors
A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.
Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(review): address code-review + security findings
Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.
Must-fix:
- security: error-details.tsx + global-error.tsx were copying
window.location.href into the clipboard, which on OAuth callback
routes (/api/withings/*, password reset) would leak code/state
tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
transient invalidate failure doesn't abort the chain and no
unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
in try/catch — a dynamic-server error in the root layout now
degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
"24:00" → "00:00" (some ICU builds emit that for midnight under
en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
tokens before logging, and moves the excerpt off Error.message into
a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)
Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
(matches SSR) before falling back to localStorage or browser
detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.
Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
semantics (incl. partial-failure test)
118 → 142 tests. All pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace hardcoded hex color values with Dracula CSS var tokens
(text-dracula-purple, text-dracula-green, etc.) for theme consistency.
Add prefers-reduced-motion support for insight animations, aria-expanded
on the data quality toggle, and extract nested ternary into lookup map.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New insight card components for the health advisor feature:
- InsightAdvisorCard: full structured display with primary recommendation,
findings, correlations, recommendations, and collapsible data quality
- InsightStatusCard: compact per-metric status card with classification indicator
- Subtle fade-in animation for insight cards in globals.css
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace direct OpenAI API calls with resolveProvider() in all insight
generators (general, blood-pressure, weight, pulse, bmi, medication-
compliance, mood). Each generator now uses domain-specific prompts from
@/lib/ai/prompts/ and the AIProvider.generateCompletion() interface.
hasKey -> hasProvider in return signatures and all UI consumers.
Remove openaiKeyEncrypted references from data reset routes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch defaultLocale from "de" to "en" and make English the code-level
language throughout. All ~145 hardcoded German strings translated:
- API error messages (52 routes) now return English
- Zod validation messages in English
- Classification/analytics functions return English categories
- Notification type labels in English
- Component strings use i18n t() calls (heatmap, mood chart,
medication form, intake history, password strength, insights card)
- ~50 new i18n keys in both de.json and en.json
Route migration:
- /stimmung → /mood, /zielwerte → /targets (new pages)
- 301 redirects in proxy.ts for legacy URLs
- Sidebar + bottom nav updated
Bump version to 1.0.1.
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.