W10 design-review carry-over items. M7 lifts the medication-form
day-of-week buttons, the more-horizontal trigger, and the schedule
label-input gaps to the 44 px / space-y-2 standard used by every
other settings surface. L1 pairs every animate-spin/pulse/bounce
with motion-reduce:animate-none across the component tree so users
with prefers-reduced-motion get a still surface. L3 adds the
aria-controls + panel-id pairing to the inventory + coach-evidence
<details> blocks for parity with the Health Score provenance
accordion. L4 inserts a sr-only h4 per therapy-timeline entry so
screen-reader users can jump between medication blocks.
MoodChart rendered at 280 px while every sibling chart on the dashboard
card row (weight, BP, pulse, body fat, sleep, steps, medication
compliance) renders at 240 px. The 40-px outlier broke vertical rhythm
when scrolling past the trend strip — the row swelled when it hit the
mood card.
Consume CHART_HEIGHT_PX from the shared constants module so the empty
state and the populated chart now stride with the rest of the row. Bump
the populated-chart override from 280 to 240 in the same edit.
The Dracula tokens are stored as hex values; wrapping them in
hsl() yielded an invalid CSS color string that the browser fell
back from to default. Mirrors the W10 reconcile-A C1 fix already
applied to the sleep-stage chart. Three call sites remained:
mood-chart CartesianGrid stroke, scatter-correlation CartesianGrid
stroke, and the scatter-correlation tooltip background + border.
Pins the load-bearing CSS class names introduced in W20a so a future
refactor that drops the heading-row height lock or the value-row
baseline alignment fails CI before reaching the dashboard.
- Heading-row contract: `h-5` + `whitespace-nowrap` + `truncate`
on the label slot. Defensive — the live label is locale-abbreviated
upstream, but a missing short-key fallback or an unexpectedly long
fallback string should still not wrap to a second line.
- Value-row contract: `items-baseline` + `text-3xl` + `leading-none`
across all five top-row tiles (Weight, BP, Pulse, Body Fat, Mood) so
the digits land at the same y-coordinate inside the grid.
- Arrow-slot contract: always rendered, end-anchored via `ml-auto`,
fixed 16×16 frame; renders a muted "—" placeholder when slope30
is null so the slot width is deterministic across tiles.
- i18n integrity: every `dashboard.*Short` key resolves to a
non-empty string in en / de / fr / es / it / pl. Complements
the broader i18n-locale-integrity test with a precise diagnostic
for the dashboard tile namespace.
Locks the TrendCard layout so the top-row Dashboard tiles align across
the strip:
- Heading row gets a fixed `h-5` so a one-line label and a (defensive)
truncate behave identically. The label class adds `whitespace-nowrap`
+ `truncate` so even an unexpectedly long string never wraps to a
second line — pairs with the locale-abbreviated keys wired in the
previous commit.
- Value row drops `flex-wrap` and pins `items-baseline` so the
headline number, unit, and trend arrow all sit on the same y-baseline.
At the same font-size (`text-3xl leading-none`) every tile's digits
now land at the identical height (Weight 80 kg used to sit above BP
122 mmHg when one tile's heading wrapped).
- Trend arrow moves to the end of the value row (after the unit) per
Marc's brief, with a deterministic 16×16 slot. When the metric has no
slope yet, the slot renders a muted placeholder dash so the layout
width stays consistent across tiles.
Marc verbatim: "rechts neben der Zahl, also nachdem die zum Beispiel
Kilo oder so kommt, der Pfeil kommt, wie gerade der Trend ist".
Verified at Pixel-5 (393 px, 2-col grid ≈ 180 px per tile) and
iPad-portrait (5-col ≈ 140 px) — heading row stays single-line, value
row baseline stays flat across the strip.
The wrapper composed `<ComplianceHeatmap />` and `<ComplianceLineChart />`
into a single panel, but no surface ever rendered the wrapper —
analytics pages mount the two child charts directly. The children stay
intact; only the dead wrapper is removed.
7 conflict files resolved by taking the upstream/HEAD side (which carries
the W4d/W5/W6c/W3f feature additions). Stashed-side changes that were
genuine new features re-land in subsequent commits if any were lost.
This sweeps up the loose state the W4e+W5e+W5d combined agent left
mid-stash. Resolves develop's working tree for clean push.
Marc directive 2026-05-14 — when a chart has a comparisonBaseline
selected but the prior period (lastMonth / lastYear) carries zero
overlay-able rows, the chart settings cog now shows the comparison
buttons in a visibly disabled state (opacity-50, aria-disabled, native
title tooltip, and a sub-label hint) instead of silently rendering
nothing. The 'none' button always stays enabled — it's the escape
hatch.
Plumbing:
- chart-overlay-controls.tsx: new `hasComparisonData` prop (defaults
to true so legacy callers keep their behaviour). The popover body is
extracted as `<ChartOverlayControlsBody>` so unit tests can render
it without Radix's portalled popover stripping it out of the SSR
output.
- health-chart.tsx + mood-chart.tsx: thread the already-computed
`hasComparisonData` memo (line 768 / 457) into the controls.
- medication-compliance-chart.tsx: passes `hasComparisonData={false}`
unconditionally because the compliance heatmap never paints a
prior-period overlay (already documented in the existing N/A
caption block).
Tests: 5 new cases under chart-overlay-controls.test.tsx cover the
normal / greyed / none-baseline / locale / default-prop branches.
The pre-W3f assertion suite (3 tests) keeps passing unchanged.
i18n: appended chart.overlay.controls.comparisonUnavailable to both
locales — no key restructure.
Adds an optional `verticalMarkers` prop to `<HealthChart>` so the
v1.4.25 GLP-1 dashboard tile + the Insights /medikamente sub-page can
paint dashed vertical reference lines at each recorded injection day.
Each marker = `{ date: 'YYYY-MM-DD', label?, color? }`. Defaults to
the strip-tile green (`#50fa7b`) so a row of markers reads as
"injection cadence" at a glance; callers can override per marker.
Rendering:
- Recharts `<ReferenceLine>` with `strokeDasharray='3 3'`,
`strokeOpacity=0.55`, no label by default (inline labels would
crowd a once-weekly cadence over a 90-day window).
- A small filled `<ReferenceDot>` (r=3) at the y-axis minimum so
the markers also read along the x-axis baseline.
- Off-window markers silently drop via `ifOverflow='discard'`,
matching the existing storyboard-annotation contract.
The marker math is factored into a pure helper
`resolveVerticalMarkerPositions` so the unit suite can pin behaviour
without mounting Recharts. Day-keys are matched by EXACT string
comparison against `chartData[i].date` (no ±7-day snap like the
storyboard annotations) — drawing a marker on a day the chart has no
data point reads as a rendering bug rather than as orientation.
Tests pin: empty input, undefined input, single in-window match,
multi-marker order, caller-supplied color/label overrides, off-window
drop, sparse weekly-bucket exact-day matching.
Four chart components (HealthChart, MoodChart, MedicationComplianceChart,
ComplianceLineChart) now accept a userTimezone prop, default
"Europe/Berlin" for backwards compat. Inside each chart, the X-axis
tick formatter, tooltip date label, and per-day bucketing all run
through a tz-aware makeFormatters(locale, userTz) instance instead of
the legacy formatDateShort() which pinned to Europe/Berlin.
Mount sites that already pull useAuth() pass user?.timezone:
dashboard /page.tsx, trends-row, recommendation-card,
insight-advisor-card. Ad-hoc usages without a user (storybook-style
or test harnesses) keep the default Europe/Berlin rendering
bit-for-bit.
The HealthChart's daily-aggregation key (toDayKey) was also rewritten
to depend on the userTimezone prop so a 23:50 Pacific/Auckland reading
buckets to the Auckland day instead of the Berlin day for chart data
display. The TanStack Query cache key gains userTimezone so a tz
change inside a session re-fetches and re-buckets correctly.
Tests: 109 chart-component tests stay green.
v1.4.25 W7 phase 5 — proposal §5 admin half.
GET /api/admin/settings now returns defaultUserTimezone (null = use
Europe/Berlin fallback). PUT accepts the field:
- empty string clears the column (resolver falls back)
- non-empty must pass isValidTimezone(); otherwise 422
- successful writes invalidate the server-default resolver cache
so the registration handler sees the new value on the next
signup without waiting 60s for the TTL
The admin form gets a third row in the General Settings section
between 'default language' and the toggles, mirroring the user
picker UX (native select over Intl.supportedValuesOf, fallback
'Europe/Berlin (default)' option clears the override). 3 new i18n
keys per locale (defaultUserTimezone +
defaultUserTimezoneDescription + defaultUserTimezoneFallback);
descriptions follow proposal §5: 'Used for new accounts that don't
carry a browser-detected zone. Does not affect existing users.'
Tests: 3 new cases in src/app/api/admin/settings/__tests__/route.test.ts
covering valid IANA write, empty-string clear, and invalid-zone
rejection.
Two coupled fixes pinned by Marc on the v1.4.25 W3 walkthrough:
1. Trends row equal-height
The three trend cards (BP / Weight / Mood) on /insights sit in a
`grid-cols-1 md:grid-cols-3` grid. The `min-h-[300px]` floor
guarantees a sensible mobile height, but the AI annotation prose
varies card-to-card on desktop, leaving the tallest card pushing
only its own row member down — siblings stayed short. Added
`md:auto-rows-fr md:items-stretch` on the grid container and
`h-full` on each direct child so the row pins every card to the
tallest member's height.
2. Mood chart gridline density parity
The mood chart's YAxis pins ticks to [1,2,3,4,5] (mood scale). By
default Recharts syncs CartesianGrid coordinates with YAxis ticks,
so the mini variant painted five horizontal lines while the
BP/Weight/Pulse minis painted six (their auto-generated YAxis
ticks land on ~six bands in typical health ranges). Added a
`horizontalCoordinatesGenerator` that emits six evenly-spaced
lines inside the plot area so the trends row reads as a single
rhythm.
Both changes are presentational — no chart data shape changes, no
behaviour changes, no API touched.
The BD-Zielbereich tile is the only tile that ships an `avgAllTime`
sub-value (W2 A1+A2). Combined with the comparison-delta callout
above it, the tile rendered four stacked rows below the headline on
mobile: the comparison delta, then 7d/30d/all-time on a single
flex row that wrapped to three lines at 280 px (Galaxy Fold). The
BP tile read 2× as dense as every other trend card.
At `<sm`: hide the all-time span on the 7d/30d row, hide the
standalone comparison-delta callout, and render a single combined
secondary row beneath: "All-time 11% · Δ −0.4 last month". Full
layout returns at `>=sm` for users with the screen real-estate.
Closes Design-H2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Maintainer-voice sweep across `src/`. ~190 references to the
maintainer by first name in code comments and JSDoc neutralised to
"the maintainer", "the live tenant", "users", or "the user" depending
on context. Three sed passes covering ~50 distinct patterns
(`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.).
Test fixtures (`displayName: "Marc B."`, `username: "marc"`,
`<HeroStrip userName="Marc">`) kept as opaque test data per the brief.
CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a
German bullet with its English translation by historical design — the
in-app copy is English-default and the user docs are English, so the
mixed-language style was a holdover from the early-marathon notes.
A one-off node script extracted the `_..._` English paragraph from
each bilingual bullet and rewrote the entry as that text. ~44 bullets
reduced; one Docs bullet that lost a leading word in the regex
fixed by hand.
CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer
AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md`
stays in place for multi-agent compatibility (the conventions doc is
shared with Codex / Cursor / etc.).
Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete;
v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20
items the marathon landed; `.planning/phase-W4-report.md` documents
the per-commit decisions, test deltas, and the 23 items deferred to
v1.4.23 with reasoning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to v1.4.21 the BD-Zielbereich tile was the only one in the strip
that didn't ship the standard tile feature set. Other tiles paint a
trend arrow next to the headline, a 7-day-trend chip on the `7d:`
sub-row, and a comparison-overlay caption when the dashboard's global
compare toggle is active. The BP-in-target tile shipped none of these
because we only had a single % series instead of a `DataSummary`.
Synthesise the slope from the difference between the 7-day and 30-day
in-target shares: a recent week above the recent month means the
metric is improving (up-good ⇒ green arrow); below means slipping.
The 7-day-trend chip uses the same delta. The comparison overlay uses
`bpInTargetPctAllTime` as the long-arc baseline so the caption reads
correctly when comparison is on.
Extend `<TrendCard>` with an optional `avgAllTime` sub-value so the
all-time aggregate stays visible alongside `7d:` and `30d:` after the
v1.4.22 A1 headline re-anchor. Other tiles leave the field undefined
so the third sub-row never renders for them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an additive annotations[] prop to <HealthChart> that paints a
vertical Recharts <ReferenceLine> per AI-narrated event on the
underlying timeline. The line + tooltip + comparison overlay remain
untouched — annotations sit between the cartesian grid and the data
lines so they read as orientation, never as data.
The /insights page wires advisor.payload.insights.storyboardAnnotations
into the 90-day BP chart, mapping the four canonical categories to
Dracula colours (medication=pink, event=cyan, milestone=green,
warning=orange).
Pure helper resolveAnnotationPositions() handles the date->pointIndex
mapping with a 7-day snap window (so annotations don't stack on
monthly buckets) and a 24-char truncation for the small-viewport
label. The unit suite pins exact-day pinning, near-day snapping,
out-of-window dropping, and label truncation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pixel-5 audit against PROD (393 px) showed two issues with the
chart cards on /dashboard and /insights:
1. The bucket-aggregation chip ("Wochendurchschnitt" / "Weekly avg"),
the optional comparison-period chip, the four range tabs and the
cog dropdown all sat on a single justify-between row in the chart
header. On viewports below the sm breakpoint the chips ate enough
horizontal space that the tabs wrapped to a second row inside the
same flex container, doubling the header height. On Galaxy Fold
compact (280 px) the tabs split across three or four rows and the
medication card overflowed horizontally.
2. The medication compliance chart drew one tick per data point on
the x-axis. A 30-day Pixel-5 window painted thirty overlapping
labels into a 393 px gutter while the weight / BMI charts looked
sparser by accident, so the chart row read inconsistently.
The header is now stacked into two rows below the sm breakpoint:
title + chips on row 1, range tabs + cog right-aligned on row 2 with
flex-nowrap so the tabs always fit. The decorative bucket-aggregation
chip and comparison captions hide on mobile (the range tabs already
communicate the visible window). Above sm the original side-by-side
layout is preserved.
A new helper at src/lib/charts/x-axis-density.ts caps visible ticks
at 4 (≤360 px Fold), 6 (≤480 px Pixel-5 / iPhone-12), 8 (small tablet)
and 10 (desktop) and is wired into HealthChart, MoodChart,
MedicationComplianceChart and ComplianceLineChart so every chart row
reads the same density. A new useViewportWidth hook keeps it reactive
without an SSR penalty (desktop default during server render, real
measurement after mount).
The scatter-correlation chart was deliberately left alone — its
x-axis is numeric (BP / weight values, not a time series) and already
supplies an explicit `ticks` array.
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>
The settings-cog trigger was 28x28 CSS px (h-7 w-7), below the
WCAG 2.1 AA 44x44 minimum. Sat in the same flex row as the 44 px
range tabs, so a Pixel-5 thumb missed the cog more reliably than
the surrounding tabs. Switch to min-h-11 min-w-11; icon stays at
h-3.5 w-3.5 so visual rhythm doesn't bloat.
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>
Adds a Playwright spec that drives the dashboard end-to-end — opens
the chart overlay-controls popover, toggles target-range on, and
verifies the PUT /api/dashboard/chart-overlay-prefs round-trip fires.
Adds a vitest SSR test that pins the medication-compliance chart's
prefs-gated rendering: with the trend toggle off the 7-day trend chip
stays hidden; flipping it on paints the chip in the header. Mocks the
useChartOverlayPrefs hook so each test can dial the persisted state
directly without round-tripping the layout API.
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>
Each dashboard chart card gets its own settings cog in the top-right
corner that opens a small popover with three independent switches:
- 7-day trend (the moving-average overlay + numeric delta chip)
- Trend arrow (the regression line + arrow direction + personal
baseline reference line — all the "your normal" affordances)
- Target range (the BP / BMI / medication threshold shading and
reference lines)
The component is controlled — parents own the prefs object and pass
an onChange callback. Default state is everything OFF; the chart
loads as a clean line and the user opts in to whichever overlays
they want, per chart, independently.
Adds the matching i18n keys (EN + DE) for the popover label, the
three switch labels, and the trigger button's aria-label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 90-day-rolling-median baseline used to render unconditionally on
every health-chart and the mood-chart as a faint dashed reference line
labelled "Your normal" / "Dein Mittel". Marc's rule is that the mean
should only appear when a trend is actively being displayed, not as a
default chrome.
Gate the baseline ReferenceLine behind the Trend toggle on both
HealthChart and MoodChart. Default state stays OFF, so a fresh chart
loads as a clean line without the dashed mean. Toggling Trend on
brings back the baseline alongside the regression line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
Mirrors the health-chart B5c mini contract for the mood chart:
- mini: drops the range tabs, the toggle row (moving-average / trend
/ target-bands), and shrinks the chart height to 140px so it fits
inside the rationale card without dominating it. Emoji glyphs
+ lavender gradient + tooltip survive intact.
- windowOverride: pins the rangePoints state to the rationale enum
value on mount.
The mood chart's metric-source key is "mood" (not a measurement-type
enum value), so the recommendation-card metric-mapping helper routes
mood-rationale charts here rather than through HealthChart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Oura-style RecommendationCard (B5c) embeds a small chart pinned
to the recommendation's rationale.dataWindow. HealthChart gains two
opt-in props:
- mini: drops the range tabs, the moving-average / trend / target-
band toggle row, and shrinks padding (h-[140px] vs h-[240px]).
Tooltip + gradient + personal-baseline behaviour stay intact so
the small chart still tells the same visual story.
- windowOverride: pins the rangePoints state to the rationale enum
value (last7days / last30days / last90days / allTime) regardless
of any parent UI state. Mini mode hides the range tabs anyway,
so the user can't manually override the pinned window.
The pure helper resolveMiniRangePoints() (`mini-window.ts`) maps the
rationale enum onto the existing per-points TIME_RANGES_KEYS values.
Single source of truth so future schema changes touch one file.
7 new tests in `health-chart-mini.test.tsx` pin the contract:
- enum-to-points mapping for all 4 windows
- mini mode hides toggles + range tabs
- non-mini mode still renders them (regression guard)
- windowOverride seeds the rangePoints state on mount
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v1.4.16 B1a polish across all dashboard charts. The
medication-compliance chart already shipped 7-day-trend + target/
threshold reference lines from A6; this commit brings it into visual
parity with BP/weight/pulse/mood:
- ComposedChart replaces LineChart; a gradient-filled Area paints
under the compliance line. The medication gradient uses the purple
Dracula token, matching the existing line stroke.
- Rich tooltip via RichChartTooltip — date label, compliance
percentage with tabular numerals, delta-vs-target sub-line that
reads "−15 pp vs. target" so the gap to 100 % is immediately
visible.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 daily points.
Existing "no scheduled doses" empty state is preserved (the user
could have a long history of skipped scheduled days that drops
the chart to fewer than 3 actionable points).
The 80 % minimum-acceptable threshold and 100 % goal ReferenceLines
from A6 stay untouched; gradient plus those lines now make the
"target range" feel like a band rather than a pair of guides.
1 new test pins the gradient SSR contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
under the score line. The mood gradient uses the lavender token
(--dracula-lavender / #d6acff) which matches the existing line
stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
window (≥5 points required). Faint dashed line labelled "Your
normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
The Recharts `dot` prop accepts a function returning an SVG node;
we render <text> centred over (cx, cy) so the glyph sits on the
line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.
1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HealthChart wrapper is the single component the dashboard uses for
every numeric vital — BP systolic/diastolic, weight, pulse, body fat,
sleep, steps. This commit upgrades it once with the v1.4.16 visual
leap so all those metrics jump together.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area can
paint under the line. Each metric gets its own
chart-gradient-inline-{type} <linearGradient> definition; default
35 percent top alpha → 0 percent bottom matches the Apple Health
Trends look. A sibling 0×0 SVG mirrors the gradient so SSR markup
carries the slot for visual-regression and e2e probes.
- Personal baseline reference line: 90-day rolling median per type,
painted as a faint dashed line labelled "Your normal" / "Dein
Mittel". The new computePersonalBaseline() helper returns null when
fewer than 5 points exist so a baseline never renders off noise.
- Rich tooltip via the new RichChartTooltip primitive: rounded card
with drop shadow, per-row coloured dot, tabular-nums value cell, and
a delta-vs-baseline sub-line ("+3 mmHg vs. your normal"). Auxiliary
rows from the moving-average / trend overlays are filtered out so
the card stays focused on the primary metric.
- 600ms ease-out animation on first render via Recharts'
isAnimationActive prop, suppressed when prefers-reduced-motion is
set at the OS level (new src/lib/charts/reduced-motion.ts helper
with 4 unit tests).
- Sparse-data placeholder via ChartEmptyState when fewer than 3
daily points exist — a near-empty chart looks broken, the
placeholder reads as intentional.
In-target zone shading was already wired via the existing valueBands
prop and stays untouched. The dashed peer's labelling is unchanged.
7 new tests pin the contract (4 baseline-helper, 2 SSR markup, 1
gradient-id namespacing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a JSONB column on the users table that stores an ordered list of
provider preferences for multi-provider redundancy. Each entry carries
{ providerType, priority, enabled }; credentials still live in the
dedicated encrypted columns so AES-256-GCM key rotation keeps working
unchanged. Null preserves the legacy single-provider resolution path.
Ships a defensive parser/serializer pair with a sensible default chain
(codex -> openai -> anthropic -> local -> admin-openai) so a malformed
JSON on disk falls back to the default rather than 500-ing insight
generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc: "Bei Medikamenten zum Beispiel wird jetzt nicht 7-Tage-Schnitt,
Trend und Zielbereiche angezeigt. Das möchte ich natürlich für alle
Charts."
The medication-compliance chart shipped in v1.4.15 A4 (replacing the
static placeholder) was missing the trend chip + target-range
visualization the BP/weight/mood charts already paint. Bringing it to
parity:
- 7-day trend chip in the header: signed delta in percentage points
with a metric-aware sentiment colour (rising compliance → green,
falling → orange) and an arrow icon. Mirrors the bg-muted/40 chip
pattern HealthChart uses for its weekly/monthly bucket label.
- New `computeMedicationTrend7d()` helper computes the delta as the
mean of the most-recent 7 daily rates minus the mean of the prior 7
(capped at a 14-day window). Mean-difference rather than slope so a
single outlier day doesn't wag the indicator.
- Target-range visualization: added a 100 % goal ReferenceLine (green,
solid-ish) ALONGSIDE the existing 80 % minimum-acceptable threshold
(yellow, paler dash). Two lines together visualise the target range
the way HealthChart's targetZone band does for BP/weight tiles.
Tests:
- 6 new unit tests pin computeMedicationTrend7d() including direction,
delta, the 14-day cap, short windows, stable noise rejection.
- The existing chart-render snapshot is extended with an explicit
assertion that the trend chip is suppressed in the empty state
(so the empty-state header doesn't paint a misleading "0 pp"
indicator).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported clicking "All" on a chart's range buttons displayed
"+0.0 kg/Woche" even when his weight clearly drifted across years.
Root cause: `weeklyDelta = (lastTrend - firstTrend) / windowDays *
7` is mathematically correct but the per-week rate becomes
vanishingly small for multi-year windows (~0.07 kg/week for
10 kg over 3 years), and the 1-decimal `fmt.number(delta, 1)`
formatter rounds it to "0.0".
Fix: extract a pure `computeWindowTrend()` helper at
`src/lib/analytics/window-trend.ts`. For windows ≥ 90 days we
additionally surface a split-half delta — the mean of the
second half of the visible series minus the mean of the first
half. That number cannot round to zero unless the metric truly
didn't move, and it's exactly what Marc wants to see when he
asks "what changed across all my data?". For short windows the
per-week rate stays the only display because it's still the most
actionable metric there.
The chart trend line surfaces both: "WEIGHT: +0.1 kg/week
(0.1 %/week) · Total +5.4 kg (6.7 %)" on the All view of a
multi-year account.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported v1.4.15 A4 didn't fully land:
- DE label still read "7-Tage-Schnitt" / "7T-Schnitt" in places
- mood tile showed no numeric trend delta even though other tiles did
i18n rename:
- de.json + en.json: trend7dShort, movingAverage7d, moodMA all read
"7-Tage-Trend" / "7-day trend". The legacy "7T:" / "7d:" header on
the avg7 line is preserved for tiles that haven't been migrated to
the trend7Delta API.
trend-delta fallback:
- summaryToTrend7Delta() now falls back to slope30 (projected onto
7 days) when slope7 is null. Mood entries are typically logged
less than daily, so slope7 needs ≥ 2 points within the trailing
7-day window — for sparser users that came back null and the tile
silently dropped the delta indicator. slope30 still answers "is
this metric drifting?" so we project that onto the 7-day window
for label parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Phase A5 / B-mobile HIGH cluster — closes 4 audit findings:
1. Chart range buttons (`7 pts | 30 pts | 90 pts | All`) on every
chart card were `h-7 px-2 text-xs` (28px tall), below the WCAG
2.5.5 / Apple HIG / Material 44px minimum. Bumped to
`min-h-9 px-2.5` (36px) — that's still compact enough to fit in
the chart header strip on a 393px viewport but raises tap-target
pass rate substantially. Files: health-chart, mood-chart,
medication-compliance-chart, compliance-line-chart,
compliance-charts.
2. Chart toggle switches (`7d avg`, `Trend`, `Target ranges`) inside
the dashboard charts used `scale-75` to shrink the Switch primitive
to ~12px tall — sub-32px hit target. Dropped the `scale-75` so each
Switch returns to its default size. Files: health-chart, mood-chart.
3. Medication card primary actions ("Taken" / "Skipped"): these are
the most-tapped controls in HealthLog (every dose intake is one
tap). Were `size="sm"` (32px). Bumped to default size + explicit
`min-h-11`. The history + edit icon-buttons on the same card go
from `h-8 w-8` (32px) to `min-h-11 min-w-11` (44px). Glyph stays
16px so the visual weight doesn't change.
4. Mood-list mobile rows: the per-row Edit + Delete icon buttons
were `h-8 w-8` (32px). Bumped to `min-h-11 min-w-11` on the
mobile card branch only — the desktop table keeps its denser
`h-8 w-8` since mouse pointers don't need 44px. `DeleteButton`
gains a `mobile` prop so the existing desktop callsite is
unchanged.
Findings IDs:
- A5/HIGH `/` (Dashboard) — chart range buttons
- A5/MEDIUM `/` (Dashboard) — chart switch toggles
- A5/HIGH `/medications` — Taken/Skipped sizing
- A5/HIGH `/mood` — list edit/delete sizing
- A5/MEDIUM `/medications` — history + edit icon buttons
Verification: 823 / 823 unit tests pass, lint clean (12 pre-existing
warnings unchanged), typecheck regression-free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A5 mobile audit / B-mobile CRITICAL #1: Recharts wraps every
chart in a `<ResponsiveContainer>` whose default `touch-action: auto`
makes the browser wait for the chart's JS touchmove handler before
starting page scroll. Recharts then dispatches a Redux action +
tooltip re-render synchronously per touchmove. On slow devices that
manifests as Marc's "Scrolling-Hänger im Dashboard auf Charts".
Add the Tailwind `touch-pan-y` utility on every chart's wrapping
`<div>` so the browser starts vertical scroll immediately without
waiting for the JS handler. Chart-internal gestures (tap for tooltip,
horizontal swipe inside the chart) still flow through to JS.
Affected charts: health-chart, mood-chart, medication-compliance-chart,
compliance-line-chart, scatter-correlation-chart. The latter two were
rendering `<ResponsiveContainer>` without an explicit wrapper div, so
both gain a `<div className="touch-pan-y">` parent.
Evidence: new `touch-action-guard.test.ts` reads each chart source and
asserts `touch-pan-y` (or the inline `touchAction: "pan-y"` style) is
present. The test was first verified to fail on every chart before the
fix, then turned green after.
Findings ID: A5/CRITICAL-#1 (top of `.planning/phase-A5-mobile-findings.md`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard tiles labelled the 7-day average as "7T:" / "7d:" with
the mean value beside it. Marc found that label unclear when the
goal of the tile is to convey *direction* over the past week, not a
mid-point average:
Before: 7T: 80,5 30T: 79,8
After: 7T-Trend: 80,5 (+1.2) 30T: 79,8
The avg7 line now flips its label to `charts.trend7dShort` ("7d trend"
/ "7T-Trend") when a `trend7Delta` prop is supplied, and renders a
signed delta indicator next to the average. Color of the delta
follows the same `directionSentiment` rules the headline arrow
already uses:
- up-bad metrics (BP, weight, body fat): +delta paints orange,
-delta paints green.
- up-good metrics (mood, sleep, steps): +delta paints green,
-delta paints orange.
- neutral metrics (pulse): both signs stay muted.
Tiny absolute deltas (< 0.05) read as "no movement" and stay muted
regardless of sentiment, so a flat metric never paints a colour.
Delta source is the existing 7-day linear-regression slope from
`DataSummary.slope7` (already computed by `summarize()` in
`lib/analytics/trends.ts`). Projection: `slope.slope * 7 days =
end-to-end delta of the regression line` — extracted into the pure
helper `summaryToTrend7Delta()` in `lib/analytics/trend-delta.ts` so
the rule stays unit-testable.
Wired to weight, BP-sys, BP-dia, pulse, body fat, mood, sleep, steps.
Glucose tiles keep the legacy avg label until v1.5 (per-context
slopes need a wider summary contract).
Tests:
- Unit `summaryToTrend7Delta()`: null when summary missing /
slope7 null, projects rate × 7 for positive and negative slopes,
zero for stable slope.
- Component `<TrendCard>`: trend label ("7d trend" / "7T-Trend")
shows in EN + DE, legacy label preserved when no delta supplied
(back-compat), delta renders with `+`/`−` sign and one decimal,
±0 for near-zero, +delta on up-bad → orange, +delta on up-good →
green, neutral metric → muted regardless of sign.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The other dashboard charts (BP, weight, pulse, body fat) auto-bucket
to weekly when the visible range exceeds 90 days and to monthly past
730 days, with a small chip in the header that names the active
bucket. The mood chart used to show raw daily points only — multi-
month windows turned into a wall of dots that the eye couldn't read.
Apply the same `pickBucket()` thresholds and `bucketTimeSeries()`
helper the rest of the dashboard already uses (introduced in v1.4.6,
commit `6a64df0`). Aggregation is the mean mood-score per bucket, the
same shape `<HealthChart>` produces for the other metrics. Empty
buckets are skipped, never reported as 0, so a sparse user does not
get a flat zero baseline.
Header chip — the same `bg-muted/40` 10 px uppercase pill that
`<HealthChart>` paints, gated on `activeBucket !== "day"` so the
default short-range view stays uncluttered. Reuses `charts.bucketWeekly`
/ `charts.bucketMonthly` i18n keys.
Tests:
- `pickMoodBucket()`: returns "day" for ≤ 90d, "week" for 91-730d,
"month" for > 730d, "day" for empty / single-point series.
- `aggregateMoodEntries()`: returns daily points unchanged for short
windows; produces weekly buckets (≪ daily count, mean preserved)
for medium ranges; produces monthly buckets for multi-year ranges.
- Component SSR smoke: a 30-point series spaced 5 days apart spans
~150 days and surfaces the "Weekly avg" chip in the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `/admin/api-tokens` table rendered six full-width columns at every
breakpoint. On Pixel-5-class viewports (360 CSS px) the row exceeded
the card and forced the document itself to scroll horizontally — a
clear violation of the v1.4.x mobile-viewport contract guarded by
`e2e/mobile-viewport.spec.ts`.
Hide three lower-priority columns at narrow widths and surface the
hidden user inline so no information is lost:
- User column: `hidden sm:table-cell` (resurfaces ≥640px). On mobile
the username paints under the token name as a small secondary line.
- Last-used column: `hidden md:table-cell` (resurfaces ≥768px).
- Created column: `hidden md:table-cell`.
- Card padding shrinks from `p-6` → `p-4 sm:p-6` to claw back 16 CSS
px on each side at mobile widths.
The `<div className="overflow-x-auto">` wrapper stays as a
belt-and-suspenders guard for ultra-narrow viewports where even the
trimmed three-column layout might overflow (very long permission lists
or long token names). Without it the table would push the card itself
wider than the viewport.
Add `api-token-overview-responsive.test.tsx` to lock the contract:
the user / last-used / created columns must keep their
`hidden + (sm|md):table-cell` classes, the inline mobile fallback
span (`sm:hidden`) must duplicate the username so hiding the column
doesn't drop data, and the card root must keep the responsive
`p-4 sm:p-6` padding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>