Marc explicitly rejected smileys in the mood chart. Drop the emoji
glyph map and the SVG <text> dot factory; the line now renders plain
Recharts dots and the y-axis already labels each integer (very low /
low / okay / good / great), so the chart stays scannable without a
glyph.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Apple-Health-style gradient area underneath each chart line muddied
the visual. Drop the linearGradient defs and the area-fill primitives on
every chart wrapper and let the line stroke speak for itself. Tooltip,
animation, and Dracula tokens stay as they were.
Also delete the chart-gradient module — there are no remaining call
sites once the fills come out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production probe at /admin/api-tokens on a Pixel-5 viewport pinned the
scrollbar that kept showing up after two earlier fixes: it lives in
the AdminShell mobile section strip (13 entries, scrollWidth ~1700 vs
clientWidth ~360), not in the api-tokens table. The strip sits right
above the api-tokens card, which is why every previous attempt
targeted the table while the bar stayed put.
Adds a `no-scrollbar` utility in globals.css (scrollbar-width: none +
::-webkit-scrollbar { display: none }) and applies it to the strip in
both AdminShell and SettingsShell. Swipe and keyboard-arrow scrolling
keep working; the painted bar simply doesn't draw.
New AdminShell unit suite locks in the class so a future refactor
can't silently regress it; settings-shell test gets the matching
assertion. The api-tokens e2e spec gains a regression check that the
strip declares `no-scrollbar` and overflow-x-auto together.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tile rendered the 30-day headline correctly but the `7T:` and
`30T:` sub-values fell back to "—" even when the user had paired BP
readings in both windows. Root cause was that `/api/analytics` only
returned a single `bpInTargetPct`; the tile received `avg7={null}`
and `avg30={null}` and the TrendCard rendered the dash placeholder.
Add `computeBpInTargetWindows()` next to the existing `computeBpInTargetPct()`
helper. It filters the input series by `measuredAt` against the supplied
clock and runs the same pair-and-count logic over the trailing 7-day and
30-day windows. The route fetches the last 30 days of data once, derives
both shares from the result, and surfaces them as `bpInTargetPct7d` and
`bpInTargetPct30d` on the response. The dashboard tile maps them to
`avg7` and `avg30` so the sub-values now show the actual percentages.
Coverage:
- 6 unit tests for the windowed helper (empty input, mixed window data,
null-7d-with-real-30d, default-clock, Marc's production fixture).
- 2 integration tests against testcontainers seeding 30 days of paired
BP measurements; both windows produce the expected hand-counted shares.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scaffold STATE+ROADMAP for the v1.4.18 cycle. Wave A picks up four
reported items: BD-Zielbereich tile sub-values render "—" instead of
real numbers, `/admin/api-tokens` still scrolls horizontally on mobile
(third attempt — live-verify against prod with Playwright before
touching code), and a chart visual rollback (drop the gradient fill,
drop the mood emoji glyphs, drop the auto-overlay baseline line) with
three opt-in toggles per chart wired through Settings. Wave B is the
achievements expansion: research consumer-health badge systems first,
then ship 15-25 new ones plus 5-8 hidden cards. Wave D runs the usual
review loop, Phase E ships v1.4.18.
Tracked files clean on entry. The four stale dotted-segment route
directories under `src/app/api/export/` and the three v1.4.16 release
reports stay untracked — they belong to the previous milestone, not
this one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hotfix release for the legacy-payload /insights crash users hit on
2026-05-10 the morning after v1.4.16 shipped. See CHANGELOG.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Closing brief for the v1.4.16 release: live image digest, what shipped
across waves A-E, what was deferred to v1.5, CI/test status, production
state, docs/landing sync. STATE.md status flipped to finished.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The polished InsightsCardPreview component shipped in B1b commit 5
(d2cdf9d) was an orphan with zero non-test imports. The dashboard read
no advisor payload, so the top severity-ordered AI recommendation +
ring confidence meter + "View all" CTA was unreachable from any live
route. Wave-D design review flagged this as CRITICAL C2.
Wires the preview into the root dashboard page above the chart row,
sharing the advisor cache key with /insights so a regenerate on either
surface refreshes the other without a second LLM call. The preview
self-hides when the user has no provider configured (route 422 → null)
or the advisor returns zero recommendations.
Adds insightsPreview to DASHBOARD_WIDGET_IDS so the layout toggle
persists through Settings → Dashboard. Pins the widget into
DEFAULT_DASHBOARD_LAYOUT (visible: true, tileVisible: false) so users
discover the feature without hunting through settings, and adds a
contract test mirroring the A5 widget-enum-drift guard.
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>
The GET response used to surface the resolveProviderChain()-filtered
list, which DROPS disabled entries upstream. The Settings UI then
seeded `enabled: true` for every returned row, so disabling an entry
made it disappear on next refetch and lose its toggle position —
the toggle was functionally a "remove from chain" button.
Changed GET to read the *persisted* chain via parseProviderChain() so
disabled rows still round-trip with their flag. The active-provider
summary still uses resolveProviderChain so the displayed "Active: …"
line continues to reflect the actual runner ordering (enabled-only).
The Settings UI seeds entries with the wire-supplied `enabled` value
instead of always-true, so re-enabling becomes possible without a
page reload.
4 unit tests pin the GET shape (persisted-chain ordering, disabled
entries surfaced, cached-active overlay, default-chain fallback).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
H5 — `POST /api/insights/feedback` had no rate limit. The unique
index `(userId, recommendationId, recommendationText)` blocks exact
replays but a malicious client could distort the daily aggregator's
bucket slice by varying recommendationId/text per request. Added
`checkRateLimit("insights-feedback:<user.id>", 60, 60 * 60 * 1000)`.
60/h is generous given a comprehensive insight rarely has >10 recs.
H6 — `<RecommendationFeedback>` invalidated the entire `["insights"]`
prefix on every thumbs click, which matches all 8 insights queries
(comprehensive + 7 per-status). None of them depend on the feedback
row. The 24h server cache absorbs the actual LLM round-trip but the
network thrash + stale-cache refetch was waste. Dropped the
invalidation; user-visible verdict state lives in localStorage +
local React state. v1.4.17's prompt-version ratchet will scope its
own invalidation when feedback feeds the next generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The comparison overlay was added to BP / weight / pulse / mood charts
in B8 but the medication-compliance heatmap doesn't render a
prior-period overlay (the underlying intake-event window comparison
is deferred to v1.4.17). Without an explicit indicator the user
toggling Vormonat would see this card stay static and read it as a
broken toggle. Added a short "Comparison N/A for compliance" caption
under the header when the global compareBaseline is active so the
asymmetry is visible. EN+DE i18n.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`redactEventForEgress()` only redacted `error.message`, `error.stack`,
`warnings`, `http.user_agent` and `external_calls[*].error`. The
free-shape `meta` field and `action.details` blob carry annotated
key=value pairs from `annotate()` calls — including the
provider-runner's `ai_chain_hop_<n>_reason` which captures the upstream
error body capped at 240 chars. If an upstream provider ever echoes a
Bearer token in its error body, the raw token would land in `meta`
without any scrub. Added a recursive `redactDeep()` walker that runs
`redactSecrets()` over every string-shaped value inside both blobs.
Same gap on `/api/admin/audit-log`: the `details` JSON column was
returned verbatim. A user-supplied identifier in a failed-login row
(e.g. a typo'd "Bearer hlk_..." in a username field) flowed through
the deepened B4 admin viewer + CSV export. Run `redactSecrets()` over
each row's `details` at egress so already-persisted rows are scrubbed
on read without a backfill migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The compareDelta `aria-label` interpolated `"vs. last month"` and
`"vs. last year"` as literal English strings regardless of locale.
A DE user got an English screen-reader announcement next to a
visible German caption ("Δ −2.3 kg vs. Vormonat"). Routed through
`t("comparison.captionLastMonth" | "comparison.captionLastYear")`
so screen-reader output matches the visible text in both locales.
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>
The B8 comparison overlay shipped behind a Settings → Dashboard Select
buried 3 clicks deep. The brief asked for "mobile-friendly comparison
toggle (not a fragile hover-only interaction)" and the toggle that
shipped failed that bar — flipping Vormonat / Vorjahr meant a 5-step
round-trip every time.
New `<CompareToggle>` segmented control (None / Vormonat / Vorjahr)
sits next to the dashboard greeting and on the insights page hero.
Persists via the same `/api/dashboard/widgets` PUT the Settings
section already uses, with optimistic update + cache invalidation so
the chart overlays + tile callouts respond instantly. 44 px (min-h-11)
tap targets, focus-visible ring, role=group + aria-label for screen
readers, EN+DE i18n.
6 SSR tests pin segment count, persisted-active state, tap-target
floor, EN+DE labels, and ARIA shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend `buildUserPrompt()` to accept an optional `ComparisonSnapshot`
that, when supplied, appends a SYSTEM CONTEXT block listing each
metric's current 30d average, baseline 30d average (from 30 / 365
days back), absolute delta, and percentage delta. The strict system
prompt gains a sixth ground rule that instructs the model to narrate
the most clinically-significant non-null delta in the summary's first
sentence and forbids inventing comparison numbers.
The route reads the user's persisted `dashboardWidgetsJson.compari-
sonBaseline` and builds the snapshot from a per-metric `summarize()`
sweep so the numbers the LLM sees match what the dashboard tile
renders — single source of truth for the comparison delta. Comparison
is default-on whenever the toggle is on per research §7 Q4: there's
no secondary "include in AI" preference, the pulldown is the single
affordance.
PROMPT_VERSION bumped 4.16.0 → 4.16.1 so the Wide-Event annotations
attribute responses to the new prompt revision.
Eight new unit tests pin: empty-comparison block render, EN/DE
locale switch, lastMonth vs lastYear language, the
"summary first sentence" instruction, and the anti-hallucination
"do NOT invent" guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend DataSummary with `avg30LastMonth` and `avg30LastYear` fields —
30-day means over the [30, 60) and [365, 395) day windows. The
existing `summarize()` helper computes them once for every metric the
analytics API returns, so every dashboard tile picks them up
automatically without extra DB queries.
TrendCard gains optional `compareBaseline` + `compareDelta` props.
When both are set the tile renders a "Δ −2.3 kg vs. last month" line
below the latest value, colour-coded via the existing
`directionSentiment` rules — down on an up-bad metric (weight, BP)
paints green; down on an up-good metric (mood, sleep, steps) paints
orange; neutral metrics stay muted; deltas under |0.05| read as
stable and stay muted regardless.
Mobile-friendly: the callout sits on its own line below the latest
value so the tile stays scannable on Pixel-5 viewports without
horizontal scroll. Suppressed entirely when comparison is off OR
either side is missing data — strict regression guard for the
default-off path.
The dashboard's `tileCompareDelta()` helper resolves the prior-period
field from the active comparison toggle and feeds every TrendCard the
same delta value, so a single toggle flip in Settings → Dashboard
updates every tile atomically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `compareBaseline` prop to HealthChart and MoodChart that, when
set to "lastMonth" or "lastYear", overlays the prior-period series as
a dimmed dashed line beneath the current data. The shift is computed
in-memory by `shiftDailySeriesForward` — the chart already fetches the
full measurement history so no extra API call is needed.
The tooltip switches its delta line from "vs. your normal" to "vs. last
month / last year" when comparison is active and a prior value exists
for the hovered day, and adds a second row with the prior absolute
value so both numbers are visible. The chart header gains a
"vs. last month / last year" caption (or an "unavailable — no data
yet" fallback when the prior period has no overlap).
The dashboard page and the insights page both read the persisted
preference from `User.dashboardWidgetsJson.comparisonBaseline` so a
single toggle flip in Settings → Dashboard updates every chart on
both surfaces atomically.
Pure shift helper lives at `src/lib/charts/comparison-shift.ts` with
11 unit tests pinning the day-exact forward shift, the average helper,
and the delta computation (null-safe).
Regression guard: when comparisonBaseline is "none" (default), the
chart renders exactly as before — no extra paint, no caption, no
tooltip change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a "Compare to" pulldown to the Settings → Dashboard section that
lets the user pick None / Last month / Last year. The preference rides
on the existing User.dashboardWidgetsJson blob — research §7 Q3 settled
on the JSON-pivot path so v1.4.16 ships migration-free.
The resolver defaults to "none" for legacy layouts and clamps unknown
values back to "none", so a stale client cannot poison the dashboard
with a baseline the renderer doesn't know how to draw. The PUT
endpoint accepts the new optional comparisonBaseline field; pre-B8
clients keep PUTing without it and see no behavioural change.
EN+DE i18n keys land for the toggle label, the three options, the
"unavailable" fallback, and the chart caption — Vormonat / Vorjahr per
Marc's brief.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
v1.4.16 phase B5e: ship the `/admin/ai-quality` route that surfaces
the daily aggregator's helpful-rate per (severity x metricSourceType
x provider x prompt-version) bucket. Renders as a compact 7-column
table with severity tinted by clinical-importance colour and the
helpful-rate column tinted green/yellow/orange by 0.8/0.5
thresholds.
New endpoint `GET /api/admin/ai-quality` (requireAdmin-gated) returns
the raw `AppSettings.adminAiInsightsFeedbackSummary` blob so the
v1.4.17 ratchet that consumes the same shape for prompt-tuning can
read what the worker writes without an envelope-reshape step.
Empty state ("no summary yet" / "buckets[] is empty") rendered as a
quiet message rather than a fake zero row so the operator can tell
"no feedback" apart from "all-zeros". Loading + error banners follow
the existing host-metrics pattern.
Section wiring:
- new `ai-quality` slug in ADMIN_SECTION_SLUGS (between
integrations and feedback so the IA flows General → Integrations
→ AI Quality → Feedback Inbox)
- new Sparkles icon entry in admin-shell
- new switch case in [section]/renderer.tsx
- EN+DE i18n keys: admin.section.ai-quality.{title,subtitle} +
admin.aiQuality.{title,loading,loadError,empty,windowLabel,
generatedAtLabel,col*}
Tests:
- 5 new ai-quality-section tests (loading, empty-null, empty-buckets,
table-render with multiple buckets, error)
- 1 admin-sections smoke test ensures the empty branch renders +
1 slug-list parity test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
After zod parse, `generateInsight()` invokes `computeConfidence()` for
every recommendation and overwrites `rec.confidence` — the model's
self-reported number is discarded. Calibrated probabilities are not
a small-LLM strength, so this is the deterministic substrate the
v1.4.17 feedback ratchet will tune.
The wrapper does not have direct access to the user's bucket-series
data, so an optional `confidenceContext` resolver lets the caller
(typically the route layer) supply per-rec `ConfidenceInputs` derived
from `metricSource.n` + the freshest bucket + the deviation magnitude
vs. the user's 90-day baseline. When the resolver is omitted, the
wrapper falls back to inputs derivable from the parsed payload alone:
- n: `metricSource.n` (or 0 — triggers the n<3 hard-cap)
- recencyDays: 0 (defensive default; assumes window ends "now")
- deviationStdRatio: null (neutral 15-point signal contribution)
The override fires regardless of resolver presence so the contract
is "the confidence value on the response NEVER comes from the LLM".
A new structured Wide-Event annotation
(`ai_confidence_override_event` = `confidence.override.applied`)
carries per-rec `{id, model, computed}` triples so admin observability
can chart drift between the model's claimed confidence and the
deterministic value.
`annotateCitationCoverage()` now runs before `applyConfidenceOverride()`
so the existing citation/rationale-coverage tests can still match
against `mock.calls[0]`. The citation-coverage test that asserted
`toHaveBeenCalledTimes(1)` was relaxed to look up the right call by
meta-key rather than by ordinal.
6 new wrapper tests cover: model value discarded, wrapper-fills when
absent, resolver path overrides default, n<3 hard-cap survives strong
signal, missing `metricSource.n` -> n=0 floor, resolver receives each
rec in payload order.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each recommendation now carries an optional `confidence: 0..100`
integer in `aiRecommendationSchema`. New `src/lib/ai/confidence.ts`
exposes `computeConfidence({ n, recencyDays, deviationStdRatio })`,
the deterministic formula the wrapper will use to override whatever
number the model picks.
Score breakdown (sum, max 100):
- n contribution: log10-saturating curve, capped at 40. n<3 hard-
caps the whole score at 5*n (max 15) so a three-sample window
never produces a confident-looking meter.
- recency contribution: 30 at <2d, decays linearly to 0 at 30d.
- signal contribution: 30 at |z|>=1.5, scaled linearly to 0 at
z=0; null ratio (no baseline yet) returns a neutral 15.
Schema is `.optional()` so:
- The wrapper can fill it post-validation regardless of what the
LLM emitted — the model's calibrated probability is unreliable.
- Legacy cached payloads from before B5d landed still parse.
Integer-only and bounded so the meter component renders without
clamping; a renegade provider that emits 101 or 67.5 fails parse and
triggers the wrapper's corrective retry.
15 fixtures cover the input matrix: n hard-cap (n=0..2), n=3 floor,
mid-range (n=14, n=20), saturation (n=100 plateau, n=10000 cap),
recency clamp (45d -> 0), null-ratio neutral, negative-z magnitude,
plus monotonicity-in-n + integer-in-bounds invariants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: ship the pg-boss `feedback-aggregator` queue that
runs daily at 04:00 Europe/Berlin (after the rate-limit, idempotency,
audit-log cleanups). Aggregates the last 30 days of
`RecommendationFeedback` rows into per-(severity, metricSourceType,
providerType, promptVersion) buckets and writes a JSON summary to
the singleton `AppSettings.adminAiInsightsFeedbackSummary` row.
Summary shape:
{ generatedAt, windowDays, buckets:
[{ severity, metricSourceType, providerType, promptVersion,
helpful, notHelpful, total, helpfulRate }] }
Bucketing is split into a pure `buildFeedbackBuckets()` helper so the
admin's ad-hoc analysis tools can reuse it without going through
Prisma. helpfulRate is rounded to 2 decimals; sort order is
deterministic (severity → metricSourceType → providerType →
promptVersion) so the admin UI's row order stays stable across
aggregator runs.
Single-user-default-on policy (research §3): the aggregator runs
unconditionally for the user's own ratings. Cross-user prompt-tuning
stays admin-only via `requireAdmin()` gating on the consuming
endpoint; the aggregator just produces the data. Prompt mutation
itself is a v1.4.17 ratchet — this commit only delivers the loop
infrastructure.
Worker handler degrades gracefully on failure: warns instead of
throwing so a single-tick error doesn't poison the queue and block
the next cleanup window. Wide-Event annotations: feedback_buckets,
feedback_total_rows, feedback_window_days.
Tests: 5 new unit tests (3 bucketing scenarios + 2 aggregator
default-window + custom-window).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>