Move the Vormonat / Vorjahr comparison toggle from a standalone row
below the page hero into the hero's own meta slot. The previous
layout read as three stacked cards with separate header / control
bands — hero, then comparison toggle, then advisor card — and the
toggle row had no other content, so it just hung there in the gap.
Folding the toggle into the hero clusters every page-level control
(personal-baseline indicator, Generated timestamp, Regenerate
button, comparison baseline) into one band. The hero now exposes a
generic `metaSlot` prop so future page-level controls can join the
band without another component refactor.
Settings → Dashboard remains the power-user backdoor for either
surface; the dashboard's on-surface toggle stays gone (Marc said it
crowded the tile strip too much).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported the BD-Zielbereich tile pinned at exactly 50/50/50 for
7T, 30T, and the headline ("total") on his real production data — the
fourth attempt at this surface (v1.4.15 → v1.4.16 → v1.4.18 → v1.4.19).
Root cause: the analytics route routed `bpInTargetPct` (the headline)
through `windows.last30Days?.pct` — a literal copy of the `30T`
sub-value. Since `computeBpInTargetWindows` only returned 7d + 30d
windows, the headline could never legitimately differ from `30T`. For
Marc's real data (572 paired readings since 2022, recent 30d = 50 %,
all-time ≈ 11 %) the tile read 50 % / 50 % / 50 % and looked
algorithmically pinned — exactly the symptom he reported.
The previous three passes treated this as either a predicate bug
(v1.4.16 ceiling-vs-narrow-band, real and merged) or a missing
`avg7`/`avg30` wiring (v1.4.18, also real and merged). Both fixes
landed correctly, but neither addressed the headline duplication.
Fix:
- `computeBpInTargetWindows` now returns a third `allTime` window
that aggregates EVERY paired reading in the input series.
- The analytics route fetches all paired BP rows (no 30-day cutoff)
and routes the headline through `windows.allTime?.pct`. The route
only re-pairs once internally so the cost is paid once per request.
- The 7T / 30T sub-values are unchanged (still windowed slices of
the same input).
Failing test reproduces the bug with Marc's data shape (`allTime` MUST
not equal `last30Days` once older history is present); fix turns it
green. Integration test seeds 40 paired readings against real Postgres
and asserts all three windows compute independently.
Hand-counted against Marc's prod data:
- 7T: 50 % (1/2) — matches what he sees, real coincidence
- 30T: 50 % (5/10) — matches what he sees, real coincidence
- Headline: ~11 % (62/572) — was 50 %, now correct
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every insight summary opened with the same "Datengrundlage ist sehr
stark" / "Your data foundation is strong" filler — the user does
not see what fields the snapshot carries, so the opener reads as
awkward stock praise.
Add ground-rule 7 to both locales of the system prompt: don't open
with a compliment about the data quantity or quality, mention data
quality only when it materially limits the analysis (n<7 in the
analyzed window, recencyDays>14 since the last entry, or a coverage
gap). When data is fine, dive straight into the analysis.
Banned-opener phrases listed verbatim in both EN and DE so the
model does not paraphrase the same sentiment.
PROMPT_VERSION bumped 4.16.1 → 4.19.0 so feedback aggregation can
distinguish responses generated under the new rule.
New test file pins the rule, the trigger thresholds, the banned
phrases, and the dive-straight-in instruction in both locales.
Existing PROMPT_VERSION pins relaxed to allow any 4.x.x ratchet so
future minor bumps don't sweep up these assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two concurrent toggles on different charts (user opens two tabs,
flips BP "Target range" in one and Weight "Trend" in the other
within ~50ms) could drop one of the updates. Both reads landed on
the same dashboard-layout snapshot, then each wrote its merged blob
back, with the second write clobbering the first chart's change.
Wrap the read-modify-write in a $transaction with Serializable
isolation so the second writer either sees the first writer's
result or aborts. Same pattern any future widget-prefs route should
follow until we move the JSON to a jsonb_set-based atomic merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MoodChart component defaulted chartKey to "mood", which meant
every consumer that omitted the prop — including /insights and the
recommendation-card mini chart — picked up the dashboard's persisted
overlay prefs and rendered the settings cog. Toggling Trend on
/insights would silently flip the dashboard mood chart on next paint
because both reads shared the same persistence key.
Drop the default; the dashboard now passes chartKey="mood" explicitly
and ad-hoc surfaces stay clean (no cog, default overlays). Also wraps
the title row's right-side container with flex-wrap + justify-end so
the 4 range tabs + cog don't push the card horizontally on Pixel-5
when the comparison caption is also in the row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven mechanical cleanups, no behaviour changes:
- Drop the duplicate ChartOverlayPrefs type + DEFAULT in
chart-overlay-controls; re-export the canonical pair from
dashboard-layout so a future fourth toggle can't drift between
the UI and persistence layers.
- useChartOverlayPrefs accepts null/undefined chartKey and short-
circuits the dashboard-layout fetch when none is supplied. Drops
the chartKey ?? "bp" fallback and the three discard-the-result
ternaries in health-chart; mini-mode renders no longer subscribe
to the BP chart's persisted prefs.
- /api/analytics no longer pair-and-counts BP twice — the windowed
helper already produces last30Days, so the headline reads it back
instead of running computeBpInTargetPct on the same input.
- Drop the redundant moodEntries.map shape in the achievements
route; the prisma select already returns the matching shape.
- Merge the five hidden-metric cases into the no-precondition block
of isEarnable; the defensive comment admitted they couldn't fire.
- Remove the deprecated getAchievementCategory wrapper (zero callers).
- Strip six "v1.4.18 gradient removed" what-comments scattered
across the chart wrappers; the polish tests pin the absence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hidden Easter-eggs leaked their trigger semantics through the JSON
response: a curious user with DevTools open could read metric names
(nightOwlCount, leapDayCount, etc.), i18n keys (hiddenNightOwl,
hiddenLeapDay, etc.), icon names, and target values without unlocking
anything. The opaque-card UI was already correct, but the wire shape
spoiled the surprise.
Project hidden+locked entries to a sentinel shape before serialising
(achievements.hiddenCard.title key, HelpCircle icon, target 0,
metric totalTakenIntakes), and drop hidden-only metric counters from
the metrics block unless the matching achievement is unlocked. The
iOS branch reuses the same redacted view so native clients can't
short-circuit the surprise via the resolved-strings shape.
Once an Easter-egg unlocks the real fields ship — the unlock IS the
reveal moment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two trigger fixes so the new achievements actually fire end-to-end.
The doctor-PDF hidden Easter-egg used to watch a non-existent
`doctor-report.export` audit action. The real route emits two:
`doctor-report.generate` (the JSON helper) and
`doctor-report.pdf.generate` (the PDF route). Either now counts.
The locale-flip hidden Easter-egg used to watch
`settings.locale.update`, which no endpoint emits — the locale
field shares the catch-all `profile.update` audit row with every
other profile field. Profile-update now emits a separate
`settings.locale.update` row when (and only when) the locale value
actually changes (compares the new value to the prior persisted
locale, suppresses the row when they match), with the from/to
values in the details payload for traceability.
Already-existing TanStack Query invalidation map covers the new
metrics: `measurementDependentKeys`, `moodDependentKeys`, and
`medicationDependentKeys` already include
`queryKeys.gamificationAchievements()`, so any measurement / mood /
intake mutation triggers a refetch and the route persists the new
unlock to `UserAchievement` (existing v1.4.15 mechanism).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an opaque placeholder card for the new hidden Easter-eggs.
Locked hidden achievements paint a `<HelpCircle>` icon and use the
"Hidden achievement" / "Keep tracking — you might just stumble across
it." copy from i18n; the real title, description, metric and target
are never written to the DOM so a curious user can't pop the source
panel to learn the trigger. Once unlocked, the real strings reveal
themselves on the same card with the standard unlocked styling.
Adds the new lucide icons used by the v1.4.18 badges (Smile, Scale,
Moon, Sun, Sparkles, FileText, Languages, CalendarDays). Extends
`CATEGORY_LABEL_KEY` with the new mood + hidden buckets so the
discriminated-union check passes.
Page tests guard:
- locked hidden achievements paint the placeholder, not the real
title/description (regression: hidden cannot leak)
- unlocked hidden achievements paint the real title (the reward)
- hidden category appears under its own localized heading in EN+DE
- the `data-slot="achievement-card-hidden"` selector exists for E2E
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the v1.4.18 expansion metrics into the gamification route.
- Fetches `MoodEntry` rows alongside the existing measurement /
intake / passkey / audit fetches so the mood badges have data.
- Extends the audit-log query with `doctor-report.export` and
`settings.locale.update` (the new hidden Easter-egg triggers).
- Pushes the raw rows through `buildExpansionMetricValues` —
mood streak / improvement, weight/BP/pulse counts, consistent-month,
entry day-streak, weekend warrior, and the hidden trigger counts.
- Computes `EarnabilityFlags` from the same numbers and applies
`applyDiscoveryFilter` to the evaluated achievement list. Locked
public badges that the user has *no path* to earn yet are filtered
out; hidden Easter-eggs and already-unlocked badges always pass.
- Recomputes the headline summary (`unlockedCount`, `totalCount`,
`earnedPoints`, `totalPoints`, `nextAchievement`) from the visible
set so the dashboard counters match the rendered list. The
`nextAchievement` selector skips hidden cards so we never tease
the user with a "next goal: ?" suggestion.
The iOS format mock had to grow a `moodEntry.findMany` mock; that's
a one-line pure-test change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts the roster from 38 to 59. Adds three buckets:
- mood (4 badges): first entry, 7-day diarist, 30-day diarist,
brighter-week (mood mean improved by ≥1.0 vs prior 7-day mean)
- vitals counts (7): first/50/200 weigh-ins, first/50/200 BP readings,
first pulse log
- engagement (4): consistent month (≥25 distinct days in a calendar
month), entry-streak 7d/30d, weekend warrior
Plus 6 hidden Easter-eggs: night-owl, early-bird, leap-day,
doctor-PDF export, locale-flip, bug-buddy (≥5 reports). Hidden
achievements never leak their conditions to the DOM — locked cards
paint a generic "Hidden achievement" placeholder.
Adds `EarnabilityFlags` + `applyDiscoveryFilter` so a public locked
achievement only renders when the user has the underlying data
(e.g. a mood-streak badge stays hidden until they log their first
mood). Hidden Easter-eggs ignore the flags. Already-unlocked badges
always render so deleting data doesn't make completed achievements
disappear.
EN+DE strings for every new badge plus the hidden-card placeholder
copy and the hidden-unlock toast strings. CATEGORY_LABEL_KEY now
covers mood + hidden so the discriminated-union check passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hook the overlay-controls dropdown into a TanStack Query mutation so
each chart's three toggle states (7-day trend / Trend arrow / Target
range) save per user and survive reloads. The prefs piggy-back on the
existing User.dashboardWidgetsJson blob with a `chartOverlayPrefs`
keyed by chart id, mirroring the v1.4.16 phase B8 comparison-baseline
pattern — no Prisma migration needed.
Wires up:
- GET /api/dashboard/widgets surfaces the resolved prefs in its
response so a fresh dashboard load knows which overlays to paint.
- PUT /api/dashboard/chart-overlay-prefs replaces a single chart's
prefs without round-tripping the whole layout.
- Existing PUT /api/dashboard/widgets preserves any chartOverlayPrefs
the client doesn't send, so saving widget visibility doesn't wipe
chart prefs.
- useChartOverlayPrefs hook reads + optimistically updates the cache.
- HealthChart, MoodChart, MedicationComplianceChart all consume the
hook, render the controls dropdown next to the range tabs, and gate
their three overlays behind the persisted toggles.
Default state for every chart, every flag: false. The clean line is
the new default; users opt into overlays they care about per chart.
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>
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>
The comparison overlay was added to BP / weight / pulse / mood charts
in B8 but the medication-compliance heatmap doesn't render a
prior-period overlay (the underlying intake-event window comparison
is deferred to v1.4.17). Without an explicit indicator the user
toggling Vormonat would see this card stay static and read it as a
broken toggle. Added a short "Comparison N/A for compliance" caption
under the header when the global compareBaseline is active so the
asymmetry is visible. EN+DE i18n.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`redactEventForEgress()` only redacted `error.message`, `error.stack`,
`warnings`, `http.user_agent` and `external_calls[*].error`. The
free-shape `meta` field and `action.details` blob carry annotated
key=value pairs from `annotate()` calls — including the
provider-runner's `ai_chain_hop_<n>_reason` which captures the upstream
error body capped at 240 chars. If an upstream provider ever echoes a
Bearer token in its error body, the raw token would land in `meta`
without any scrub. Added a recursive `redactDeep()` walker that runs
`redactSecrets()` over every string-shaped value inside both blobs.
Same gap on `/api/admin/audit-log`: the `details` JSON column was
returned verbatim. A user-supplied identifier in a failed-login row
(e.g. a typo'd "Bearer hlk_..." in a username field) flowed through
the deepened B4 admin viewer + CSV export. Run `redactSecrets()` over
each row's `details` at egress so already-persisted rows are scrubbed
on read without a backfill migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The B8 comparison overlay shipped behind a Settings → Dashboard Select
buried 3 clicks deep. The brief asked for "mobile-friendly comparison
toggle (not a fragile hover-only interaction)" and the toggle that
shipped failed that bar — flipping Vormonat / Vorjahr meant a 5-step
round-trip every time.
New `<CompareToggle>` segmented control (None / Vormonat / Vorjahr)
sits next to the dashboard greeting and on the insights page hero.
Persists via the same `/api/dashboard/widgets` PUT the Settings
section already uses, with optimistic update + cache invalidation so
the chart overlays + tile callouts respond instantly. 44 px (min-h-11)
tap targets, focus-visible ring, role=group + aria-label for screen
readers, EN+DE i18n.
6 SSR tests pin segment count, persisted-active state, tap-target
floor, EN+DE labels, and ARIA shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend `buildUserPrompt()` to accept an optional `ComparisonSnapshot`
that, when supplied, appends a SYSTEM CONTEXT block listing each
metric's current 30d average, baseline 30d average (from 30 / 365
days back), absolute delta, and percentage delta. The strict system
prompt gains a sixth ground rule that instructs the model to narrate
the most clinically-significant non-null delta in the summary's first
sentence and forbids inventing comparison numbers.
The route reads the user's persisted `dashboardWidgetsJson.compari-
sonBaseline` and builds the snapshot from a per-metric `summarize()`
sweep so the numbers the LLM sees match what the dashboard tile
renders — single source of truth for the comparison delta. Comparison
is default-on whenever the toggle is on per research §7 Q4: there's
no secondary "include in AI" preference, the pulldown is the single
affordance.
PROMPT_VERSION bumped 4.16.0 → 4.16.1 so the Wide-Event annotations
attribute responses to the new prompt revision.
Eight new unit tests pin: empty-comparison block render, EN/DE
locale switch, lastMonth vs lastYear language, the
"summary first sentence" instruction, and the anti-hallucination
"do NOT invent" guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend DataSummary with `avg30LastMonth` and `avg30LastYear` fields —
30-day means over the [30, 60) and [365, 395) day windows. The
existing `summarize()` helper computes them once for every metric the
analytics API returns, so every dashboard tile picks them up
automatically without extra DB queries.
TrendCard gains optional `compareBaseline` + `compareDelta` props.
When both are set the tile renders a "Δ −2.3 kg vs. last month" line
below the latest value, colour-coded via the existing
`directionSentiment` rules — down on an up-bad metric (weight, BP)
paints green; down on an up-good metric (mood, sleep, steps) paints
orange; neutral metrics stay muted; deltas under |0.05| read as
stable and stay muted regardless.
Mobile-friendly: the callout sits on its own line below the latest
value so the tile stays scannable on Pixel-5 viewports without
horizontal scroll. Suppressed entirely when comparison is off OR
either side is missing data — strict regression guard for the
default-off path.
The dashboard's `tileCompareDelta()` helper resolves the prior-period
field from the active comparison toggle and feeds every TrendCard the
same delta value, so a single toggle flip in Settings → Dashboard
updates every tile atomically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `compareBaseline` prop to HealthChart and MoodChart that, when
set to "lastMonth" or "lastYear", overlays the prior-period series as
a dimmed dashed line beneath the current data. The shift is computed
in-memory by `shiftDailySeriesForward` — the chart already fetches the
full measurement history so no extra API call is needed.
The tooltip switches its delta line from "vs. your normal" to "vs. last
month / last year" when comparison is active and a prior value exists
for the hovered day, and adds a second row with the prior absolute
value so both numbers are visible. The chart header gains a
"vs. last month / last year" caption (or an "unavailable — no data
yet" fallback when the prior period has no overlap).
The dashboard page and the insights page both read the persisted
preference from `User.dashboardWidgetsJson.comparisonBaseline` so a
single toggle flip in Settings → Dashboard updates every chart on
both surfaces atomically.
Pure shift helper lives at `src/lib/charts/comparison-shift.ts` with
11 unit tests pinning the day-exact forward shift, the average helper,
and the delta computation (null-safe).
Regression guard: when comparisonBaseline is "none" (default), the
chart renders exactly as before — no extra paint, no caption, no
tooltip change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a "Compare to" pulldown to the Settings → Dashboard section that
lets the user pick None / Last month / Last year. The preference rides
on the existing User.dashboardWidgetsJson blob — research §7 Q3 settled
on the JSON-pivot path so v1.4.16 ships migration-free.
The resolver defaults to "none" for legacy layouts and clamps unknown
values back to "none", so a stale client cannot poison the dashboard
with a baseline the renderer doesn't know how to draw. The PUT
endpoint accepts the new optional comparisonBaseline field; pre-B8
clients keep PUTing without it and see no behavioural change.
EN+DE i18n keys land for the toggle label, the three options, the
"unavailable" fallback, and the chart caption — Vormonat / Vorjahr per
Marc's brief.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the /insights heading in a Dracula-gradient hero band with the
"Generated <relative-time>" caption, "Based on your last 90 days"
personal-baseline indicator, and a regenerate slot. Pure shell so the
page only passes timestamps in. Animation uses the existing
animate-insight-in keyframes which already gates on
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: ship the `/admin/ai-quality` route that surfaces
the daily aggregator's helpful-rate per (severity x metricSourceType
x provider x prompt-version) bucket. Renders as a compact 7-column
table with severity tinted by clinical-importance colour and the
helpful-rate column tinted green/yellow/orange by 0.8/0.5
thresholds.
New endpoint `GET /api/admin/ai-quality` (requireAdmin-gated) returns
the raw `AppSettings.adminAiInsightsFeedbackSummary` blob so the
v1.4.17 ratchet that consumes the same shape for prompt-tuning can
read what the worker writes without an envelope-reshape step.
Empty state ("no summary yet" / "buckets[] is empty") rendered as a
quiet message rather than a fake zero row so the operator can tell
"no feedback" apart from "all-zeros". Loading + error banners follow
the existing host-metrics pattern.
Section wiring:
- new `ai-quality` slug in ADMIN_SECTION_SLUGS (between
integrations and feedback so the IA flows General → Integrations
→ AI Quality → Feedback Inbox)
- new Sparkles icon entry in admin-shell
- new switch case in [section]/renderer.tsx
- EN+DE i18n keys: admin.section.ai-quality.{title,subtitle} +
admin.aiQuality.{title,loading,loadError,empty,windowLabel,
generatedAtLabel,col*}
Tests:
- 5 new ai-quality-section tests (loading, empty-null, empty-buckets,
table-render with multiple buckets, error)
- 1 admin-sections smoke test ensures the empty branch renders +
1 slug-list parity test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.
Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.
The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.
Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.
Tests:
- 7 unit tests for the Zod request schema (valid path, severity
poisoning, time-range poisoning, empty fields, stringified bool,
extra-field stripping)
- 5 unit tests for `pickProviderType()` priority + fallback
- 4 integration tests: 201 happy path with provider attribution,
409 dedup, 422 validation, fallback to unknown when no audit row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cached insight blobs persisted before B5c shipped don't carry the
new `rationale` field — the strict parser would reject them, and
auto-regenerating on every cache-hit would burn rate-limit tokens
silently. Instead the route now detects the legacy shape and
surfaces a `legacyPayload: true` flag in the response so the UI
can render a one-time "regenerate for new explainability features"
CTA. User-initiated regeneration stays the trigger.
`isLegacyInsightPayload(payload)` lives in `src/lib/ai/legacy-payload.ts`
and is intentionally lenient: any rec missing rationale (or the
v1.4.14/v1.4.15 string-only InsightResult shape) flags. Empty
recommendations[] is NOT legacy — it's a legitimate refusal payload.
The annotate() Wide-Event also carries `legacyPayload` on cached
hits so the admin AI quality dashboard can chart the migration
ratio over time. v1.4.17 can flip auto-eviction on once the
ratio drops near zero.
7 new tests in `legacy-payload-detection.test.ts` pin the contract.
Existing 223 AI tests still green; typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The B5b read-only endpoint now has a write counterpart so the new
Settings → AI dropdown UX can save reorder + enable/disable changes.
Server-side priority is recomputed from insertion order (so the UI's
visual contract — "first row = priority 1" — cannot drift from what
gets persisted), unknown provider types and duplicates 422, and an
empty chain is rejected outright. parseProviderChain still falls
back to the default chain when the persisted JSON is malformed,
so a stale-tab resubmit cannot poison generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The AI section now renders a small read-only summary of the user's
fallback chain: "Active provider: ChatGPT (Codex)" plus "Configured
chain: ChatGPT (Codex), OpenAI (your key), Admin OpenAI". When the
in-process cache reroutes to a fallback (the primary 401'd in the
last hour), the line gains "(cached: OpenAI)" so the user can see the
runner has shifted without digging through the wide-event log.
The full management UX (drag-to-reorder, on/off toggles, add/remove
provider entries) is owned by phase B2 — this surface is deliberately
tight so the two phases don't collide. The card hides itself when no
provider is configured at all so accounts that haven't set up AI
don't see an empty header. EN + DE i18n keys added under
settings.ai.providerChain.*; provider-type labels are localised so
"Local model" reads "Lokales Modell" in German.
The new GET /api/insights/provider-chain endpoint is the data source.
Returns the resolved chain in priority order plus the cached active
provider (when set), wrapped in the standard apiSuccess envelope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
under the score line. The mood gradient uses the lavender token
(--dracula-lavender / #d6acff) which matches the existing line
stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
window (≥5 points required). Faint dashed line labelled "Your
normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
The Recharts `dot` prop accepts a function returning an SVG node;
we render <text> centred over (cx, cy) so the glyph sits on the
line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.
1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both `healthlog-tour-session-dismissed` and `healthlog-tour-referrer`
were un-scoped global keys, so a browser shared by two HealthLog
identities (admin impersonating a target user, family laptop, etc.)
inherited the first user's "tour dismissed for this session" flag —
the second user logged in and silently never saw the spotlight tour.
Both keys are now suffixed with `:${userId}`, so each identity's
state stays independent. The two builders (`tourSessionDismissedKey`
and `tourReferrerKey`) are exported so the wizard's `persistAndExit`
shares the same wire format and a unit test pins the scheme against
future drift.
One-off cost: every currently-open tab re-fires the tour once on the
next load because the legacy un-prefixed keys are no longer read.
Acceptable tax for multi-tenant correctness ahead of v1.5.
Phase B6 commit 2 of 4.
* `thresholds-settings-section.tsx` (route wrapper) renamed to
`thresholds-section.tsx` so the filename + default export match
the slug — every other section in `src/components/settings/`
follows the `<slug>-section.tsx` `<SlugSection>` convention.
* The historic inner editor at `thresholds-section.tsx`
(`<ThresholdsSection>`) is moved out to
`thresholds-editor-section.tsx` (`<ThresholdsEditorSection>`)
so the names stop clashing.
* Rewrites three muddy section descriptions in EN + DE:
- account: lists every concern the section actually covers
- api: drops the misleading "headless clients" / "Drittanbieter"
framing for "your own scripts and apps"
- advanced: post-B7 there's no Import surface here — describes
the danger zone honestly.
No user-facing setting removed, no slug renamed, no i18n key removed.
Settings smoke tests still green; the route mount goes through
`<ThresholdsSection>` exactly as before, just from the renamed file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The catch block around the restore transaction echoed the raw error
message back to the admin client (`Restore transaction failed: <full
prisma message>`). Even on an admin-only endpoint that leaks column
names, constraint names and query fragments to anyone with admin
access — lower than CRITICAL but unnecessary surface for a future
supply-chain or impersonation scenario.
Response now returns a stable "Restore failed" 500. The verbose text
still lands in the audit-log row (admin-readable, forensic) and in
the Wide Event under `meta.restoreFailReason` (operator-readable),
so root-cause investigation is unaffected.
Wires the in-memory ring buffer (`src/lib/logging/in-memory-buffer.ts`)
into `transports.emitEvent()` so every wide event that survives the
sampler also lands in a 500-entry per-process buffer. Adds the
`app-logs` admin section slug, the renderer case, and the route entry
exports needed for the new section component to mount.
The buffer is volatile and per-process — a worker-only event won't
show up in the web buffer, and a restart drops everything. The section
header copy spells that out so admins know to keep Loki configured for
durable diagnostics. Egress through `/api/admin/app-logs` runs every
event through `redactSecrets()` so a stray Bearer / hlk_ / sk- token
in an `error.message` never leaks to the admin UI; storage stays raw
to keep the diagnostic value when shipped to Loki.
The companion buffer + transports tests live under
`src/lib/logging/__tests__/in-memory-buffer.test.ts` and
`transports-buffer.test.ts` (committed separately as part of the
buffer scaffolding). The route-level tests in
`src/app/api/admin/app-logs/__tests__/route.test.ts` cover newest-
first ordering, traceId / level / action filters, the `limit` cap,
secret redaction on egress, and the Zod-rejected invalid `level` case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Host-load chart above /admin/system-status renders the last 2h of
load1, memory %, and disk-IO % from a per-minute pg-boss sampler.
Schema migration 0032_host_metric, /api/admin/host-metrics endpoint,
HostMetricsChart component (Recharts wrapper, next/dynamic from the
consumer), system-status section wired with the chart on top, EN+DE
i18n keys, 13 unit + 6 integration tests.
Three commits landed cleanly under their own titles (db, jobs, api);
the chart + wiring + i18n got absorbed into another agent's commit
due to a parallel-agent staging race during the verification gate's
stash/restore loop. Functionally everything is on origin/main.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local clean-clone verification confirms Wave-A introduced no
regressions: typecheck/lint/unit/integration all green at origin/main
HEAD `94c748d`. CI Security & Quality consistently passes; the failing
Integration tests + e2e workflows are pre-existing infrastructure issues
(failure shape identical at v1.4.15 release commit `0985c93`), not
Wave-A source regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a recommendation carries a `referenceId` pointing into the
curated medical-reference bundle, render a small footnote underneath
linking to the guideline. Format: "Source: ESH 2023 — ESH 2023
Guidelines for the Management of Arterial Hypertension" (locale-
routed title), with a tiny external-link icon, opens in a new tab via
target="_blank" rel="noreferrer". Per the v1.4.16 research doc §7
the URL never opens in an in-app webview.
`InsightResult.recommendations` widens to accept either the legacy
plain-string shape OR a structured `{ text, referenceId? }` object so
existing rendering paths keep working while the new citation surface
plugs in through the same component. `getMedicalReferenceById()`
returns undefined for unknown ids — the UI silently drops the
footnote in that case rather than rendering a broken link (defence
in depth on top of the schema validation).
i18n keys `insights.recommendation.source` and
`insights.recommendation.viewSource` land in both EN and DE.
6 component tests pin the no-footnote / EN-locale / DE-locale /
unknown-id-defence / multi-rec paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backs the new chart above /admin/system-status. Reads HostMetric rows
captured by the per-minute sampler and returns the window the chart
asks for via `?since=` (30m / 1h / 2h / 6h / 12h / 24h, default 2h).
Per-row response shape is what Recharts can plot directly:
- capturedAt ISO-8601
- loadAvg1 number
- memUsedPercent 0..100, rounded to 2 decimals
- diskReadBps / diskWriteBps number | null
Disk fields are derived server-side from the cumulative byte counters
the sampler captured. Computing the per-second delta in the route
keeps the chart component stateless and means a hypothetical CLI / iOS
admin view gets the same units. The first row in any window has no
predecessor, so disk fields are null. A negative delta — counter reset
on host reboot — also surfaces as null instead of a misleading negative
BPS spike.
`meta.memTotalBytes` carries the most recent total so the tooltip can
render an absolute "4.2 GiB / 8 GiB" without a second round-trip.
requireAdmin()-gated: host telemetry is admin-only, never exposed via
Bearer tokens. apiHandler wrapper handles the wide-event annotation
("admin.host-metrics.list") and request-id propagation for free.
6 integration tests against the testcontainer Postgres pin auth
gating, the 2h default window, the diskBps derivation across a
counter-reset, the `?since=30m` filter, and the empty-DB happy path.
The new `export` slug is now part of `SETTINGS_SECTION_SLUGS`, so the
dynamic `/settings/[section]/page.tsx` route picks it up via
`generateStaticParams()` and the existing 404-on-unknown guard. The
shell sidebar gains a Download-iconed entry between API and Advanced;
the placeholder fallback also learned the new icon so a future slug
addition can't crash type-checking.
The shell-test suite now expects ten sections (was nine) and asserts
that `/settings/export` is a real link in the rendered nav. Old
"advanced" copy already trimmed in the previous commit, so the export
entry is the new home for every "give me my data out" path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc: "Bei Medikamenten zum Beispiel wird jetzt nicht 7-Tage-Schnitt,
Trend und Zielbereiche angezeigt. Das möchte ich natürlich für alle
Charts."
The medication-compliance chart shipped in v1.4.15 A4 (replacing the
static placeholder) was missing the trend chip + target-range
visualization the BP/weight/mood charts already paint. Bringing it to
parity:
- 7-day trend chip in the header: signed delta in percentage points
with a metric-aware sentiment colour (rising compliance → green,
falling → orange) and an arrow icon. Mirrors the bg-muted/40 chip
pattern HealthChart uses for its weekly/monthly bucket label.
- New `computeMedicationTrend7d()` helper computes the delta as the
mean of the most-recent 7 daily rates minus the mean of the prior 7
(capped at a 14-day window). Mean-difference rather than slope so a
single outlier day doesn't wag the indicator.
- Target-range visualization: added a 100 % goal ReferenceLine (green,
solid-ish) ALONGSIDE the existing 80 % minimum-acceptable threshold
(yellow, paler dash). Two lines together visualise the target range
the way HealthChart's targetZone band does for BP/weight tiles.
Tests:
- 6 new unit tests pin computeMedicationTrend7d() including direction,
delta, the 14-day cap, short windows, stable noise rejection.
- The existing chart-render snapshot is extended with an explicit
assertion that the trend chip is suppressed in the empty state
(so the empty-state header doesn't paint a misleading "0 pp"
indicator).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported the toggles in Settings → Dashboard didn't filter
the dashboard tile-strip on reload — the v1.4.15 A4 claim was
correct in spirit but broken in the API contract.
Root cause: `widgetIdEnum` in
`src/app/api/dashboard/widgets/route.ts` was missing
`achievements` even though the default layout has it. Every PUT
that included the achievements widget therefore failed schema
validation with 422 — the toast surfaced "Layout konnte nicht
gespeichert werden" but the user never noticed because the toggle
visually flipped (local draft state) before the network call. On
reload the saved layout was the previous one, so the filter looked
like a no-op.
Fix:
- Extract `DASHBOARD_WIDGET_IDS` as the single source of truth in
`src/lib/dashboard-layout.ts`. The Zod enum in the API route is
built from it so the two lists cannot drift again.
- Hide the tile-strip wrapper entirely when the user toggled off
every tile (Marc's constraint: "immer die gesamte Spalte breit
und immer der gleichen Höhe ausfüllen" — an empty grid wrapper
was leaving a thin gap on the page).
Tests: contract test pinning that every widget id in
DEFAULT_DASHBOARD_LAYOUT round-trips through serialize → resolve,
plus an explicit assertion that `achievements` is in the default
layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BD-Zielbereich tile rendered 0% for users whose readings sat below
the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65).
Marc's actual production data has many readings like 117/79 — textbook
normotensive, well-controlled — that v1.4.15 A4 still counted as OUT
of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs
should be in-target by clinical interpretation; v1.4.15 reported 0.
Root cause: "in target" was defined as the narrow goal band, which is
the ESH treatment goal for hypertensive patients ON therapy — NOT the
desirable range for a normotensive HealthLog user. Users (and the
dashboard label) intuitively mean "BP is well-controlled = at or below
the ceiling, not implausibly hypotensive".
Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <=
diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50
floors guard against counting symptomatic hypotension as "good control".
Centralised the check in `isBpReadingInTarget()` so the 6 historical
call sites (analytics route, blood-pressure-status, features,
general-status, insights/cards, insights/comprehensive, insights/targets)
share one definition — pre-fix they had 5 independent inline copies of
the narrow-band formula, easy to drift on a future refactor.
Tests:
- 6 new unit tests pin the new ceiling+floor semantics including Marc's
exact production fixture (10 paired readings -> 50% under v1.4.16,
was 0% under v1.4.15).
- New `tests/integration/bp-in-target.test.ts` seeds Postgres
testcontainer with Marc's reading shape + null-tolerance + hypotension
edge cases.
Cache invalidation already correct: `measurementDependentKeys` includes
`analytics()` so adding a BP measurement invalidates the tile's
TanStack Query cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry
covering backup completeness, achievements UI, onboarding tour,
doctor-report v2, auto-deploy on GHCR push, empty-states sweep,
notification + integration status UI, AI hallucination guardrails,
mobile UX fixes, and the BD-Zielbereich percentage fix.
Includes the formatting drift left over by the v1.4.15 development
phases (Prettier sweep on touched files) and a typecheck fix in
`dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`, `pnpm test`, and `pnpm test:integration` all
pass green on the release tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last two surfaces from the C5 audit.
- /insights — Top-level "no data" path replaces a bare "No data
available" sentence with EmptyState (TrendingUp icon) + "Add a
measurement" CTA into /measurements. The BMI section's
height-not-set fallback gets a compact plain-variant EmptyState
(Ruler icon) + "Open account settings" CTA instead of a single
grey line that looked like a bug.
- / (dashboard) — When trendCards.length === 0 AND charts.length ===
0 (a brand-new account with no data and the Getting-started
checklist already dismissed) the dashboard rendered an empty
0-px tile strip below the welcome banner. Now paints an
EmptyState with the same quick-entry dialog wired to the CTA so
the user can log their first measurement without leaving the page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 9a74f8e — that commit's pathspec didn't pick up the
implementation files, only the new test. This commit lands the actual
EmptyState wiring described in the previous message:
- src/components/measurements/measurement-list.tsx — Activity icon,
filter-aware empty state, optional onAddFirst CTA prop.
- src/components/mood/mood-list.tsx — Smile icon, mirroring filter
semantics + optional onAddFirst.
- src/app/measurements/page.tsx + src/app/mood/page.tsx — pass
onAddFirst so the empty-state CTA opens the same dialog as the
header button.
- src/app/medications/page.tsx — refactor inline icon+text+button
block to the EmptyState primitive (Pill icon).
- src/app/achievements/page.tsx — Trophy icon for the no-achievements
state, reusing the existing noneUnlockedYet string.
Together with 9a74f8e the four feature lists now share a consistent
empty-state surface with the rest of the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>