Commit Graph
47 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 ff1e335c19 feat(charts): storyboard annotations on HealthChart
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>
2026-05-10 17:17:45 +02:00
Marc-André BombeckandClaude Opus 4.7 77a3ad3bc8 fix(charts): mobile header layout + unified x-axis tick density
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>
2026-05-10 13:07:08 +02:00
Marc-André BombeckandClaude Opus 4.7 3048dd6637 style(prettier): sweep formatting across the tree
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>
2026-05-10 11:05:40 +02:00
Marc-André BombeckandClaude Opus 4.7 194ec2f9da fix(chart-overlay-controls): bump cog to 44x44 tap target
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>
2026-05-10 11:02:35 +02:00
Marc-André BombeckandClaude Opus 4.7 fbf14fceb8 fix(mood-chart): make chartKey opt-in and don't leak overlay state across pages
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>
2026-05-10 11:02:27 +02:00
Marc-André BombeckandClaude Opus 4.7 720e6c89b9 refactor(v1.4.18): apply simplify-review safe suggestions
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>
2026-05-10 11:01:01 +02:00
Marc-André BombeckandClaude Opus 4.7 d6e87c3a12 test(charts): coverage for revert + overlay-controls + persistence
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>
2026-05-10 10:36:08 +02:00
Marc-André BombeckandClaude Opus 4.7 d70a9b1ee9 feat(charts): persist per-chart overlay prefs (default off; clean line)
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>
2026-05-10 10:34:08 +02:00
Marc-André BombeckandClaude Opus 4.7 75f340e82b feat(charts): per-chart overlay-controls component with 3 toggles
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>
2026-05-10 10:23:20 +02:00
Marc-André BombeckandClaude Opus 4.7 b388fc0355 revert(charts): remove auto-overlay personal baseline (now opt-in via Trend toggle)
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>
2026-05-10 10:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 06f18dfbdb revert(charts): mood chart shows simple dots instead of emoji at data points
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>
2026-05-10 10:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 008a8fba3e revert(charts): remove gradient background fills (clean lines only)
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>
2026-05-10 10:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 56614392be fix(charts): medication-compliance shows comparison N/A caption when toggle is on (H4)
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>
2026-05-10 03:09:41 +02:00
Marc-André BombeckandClaude Opus 4.7 2f057f4a07 fix(i18n): trend-card comparison aria-label uses translation key (H3)
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>
2026-05-10 03:02:02 +02:00
Marc-André BombeckandClaude Opus 4.7 c3451a4c06 fix(a11y): 44px tap targets + focus-visible on rec-feedback, chart range tabs, export switch (H1/H2/H6/H7)
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>
2026-05-10 03:01:45 +02:00
Marc-André BombeckandClaude Opus 4.7 e4a408eaf5 feat(dashboard): tiles show comparison delta callout when toggle is active
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>
2026-05-10 02:30:25 +02:00
Marc-André BombeckandClaude Opus 4.7 2cf7b746ac feat(charts): comparison overlay shows prior-period as dimmed line with delta tooltip
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>
2026-05-10 02:25:46 +02:00
Marc-André BombeckandClaude Opus 4.7 680f84c426 feat(charts): mood chart mini-mode + windowOverride
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>
2026-05-10 01:17:15 +02:00
Marc-André BombeckandClaude Opus 4.7 7f54c0cad3 feat(charts): mini-mode + windowOverride prop for embedded rationale charts
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>
2026-05-10 01:17:15 +02:00
Marc-André BombeckandClaude Opus 4.7 80086130de feat(charts): medication chart polish (gradient + animation + rich tooltip)
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>
2026-05-10 00:33:09 +02:00
Marc-André BombeckandClaude Opus 4.7 901f44e6dc feat(charts): mood chart polish with emoji glyphs at data points
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>
2026-05-10 00:30:40 +02:00
Marc-André BombeckandClaude Opus 4.7 74c2eb8bff feat(charts): BP/weight/pulse polish (gradient, baseline, rich tooltip)
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>
2026-05-10 00:26:13 +02:00
Marc-André BombeckandClaude Opus 4.7 2611bb4fae feat(db): User.aiProviderChain for ordered fallback config
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>
2026-05-10 00:20:12 +02:00
Marc-André BombeckandClaude Opus 4.7 9b01c86e30 feat(charts): medication chart matches other charts (7d trend + indicator + target-range)
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>
2026-05-09 23:32:01 +02:00
Marc-André BombeckandClaude Opus 4.7 af77e5ecf8 fix(insights): trend on "all" filter computes meaningful delta (split-half comparison)
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>
2026-05-09 23:31:18 +02:00
Marc-André BombeckandClaude Opus 4.7 4df6dac036 fix(charts): "7-Tage-Trend" label + numeric trend indicator on every chart incl. mood
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>
2026-05-09 23:30:42 +02:00
Marc-André BombeckandClaude Opus 4.7 4dcaa08b42 chore(release): v1.4.15
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>
2026-05-09 22:27:13 +02:00
Marc-André BombeckandClaude Opus 4.7 8370b2d107 fix(mobile): bump chart controls + medication primary buttons + mood list icons to 44px
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>
2026-05-09 20:50:52 +02:00
Marc-André BombeckandClaude Opus 4.7 316c3b0824 fix(charts): chart wrappers allow vertical scroll passthrough on mobile
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>
2026-05-09 20:43:42 +02:00
Marc-André BombeckandClaude Opus 4.7 4e2386e2d1 feat(dashboard): rename 7-day-average to 7-day-trend with delta indicator per metric
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>
2026-05-09 20:31:37 +02:00
Marc-André BombeckandClaude Opus 4.7 47ac14b746 feat(insights): mood chart auto-aggregates to weekly/monthly like other metrics
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>
2026-05-09 20:26:22 +02:00
Marc-André BombeckandClaude Opus 4.7 bffdccbda6 fix(admin): make api-tokens table responsive on mobile
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>
2026-05-09 20:22:05 +02:00
Marc-André BombeckandClaude Opus 4.7 c847b80de3 chore(format): trend-card test file prettier sweep
Pure prettier collapse of the up-bad sentiment test JSX. No logic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:10:58 +02:00
Marc-André BombeckandClaude Opus 4.7 38f12df737 feat(ui): trend arrow color reflects metric-specific direction sentiment
Restore the colour on the small ↑/↓ trend-arrow next to each tile's
latest reading — but make it metric-aware. v1.4.6 P4 stripped the colour
because the original rule (up=red, down=green) was wrong for half the
metrics: mood up is good, weight up is bad, pulse up is neither. v1.5
phase-5 brings back the colour through a `directionSentiment` prop:

  - `up-good` → ↑ green, ↓ orange    (mood, sleep, steps, BP-in-target%)
  - `up-bad`  → ↑ orange, ↓ green    (weight, BP, body fat)
  - `neutral` → muted in both        (pulse, glucose — range matters
                                       more than direction)

Stable-slope arrows ("→") and "no slope yet" placeholders ("—") always
stay muted. The default is `neutral` so any TrendCard call site that
hasn't been updated keeps the v1.4.6 muted look — back-compat is the
visible intent of the prop.

Affects only the icon next to the latest reading. The chart axes,
lines, colours, and the avg7/avg30 range-colour classes are untouched
(per Marc's hard-rule on chart visual identity, see
`feedback_charts_visual_identity.md` in user memory).

Tests: `src/components/charts/__tests__/trend-card.test.tsx` covers all
three sentiments × all three slope directions plus the
prop-omitted-back-compat case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:14:14 +02:00
Marc-André BombeckandClaude Opus 4.7 1a85da0b05 fix(insights): preserve chart visual parity in deferred bundle
bb2b1de wrapped seven Recharts primitives (ScatterChart, Scatter, XAxis,
YAxis, CartesianGrid, Tooltip, ResponsiveContainer) in next/dynamic to
push the ~108 KiB Recharts chunk off the /insights initial bundle. That
seam is one level too low: ScatterChart discovers its children by
comparing child.type against the imported component identity
(findAllByType). Once each child is wrapped in a next/dynamic HOC the
identity check fails and the chart renders an empty plot area —
no axes, no scatter points, no tooltip, even after Recharts lands.

Fix: extract the inline <ResponsiveContainer><ScatterChart>…</ScatterChart>
block into src/components/charts/scatter-correlation-chart.tsx, a
self-contained wrapper that statically imports every Recharts symbol it
needs. The insights page now next/dynamics that wrapper instead, matching
the existing HealthChart / MoodChart pattern in the same file. Recharts
still ships only in the deferred chunk; child-type detection keeps
working because primitives stay statically related to each other.

All four scatter cards (BP-medication continuity, mood-vs-BP,
weight-vs-BP, mood-vs-weight) rewritten as declarative
<ScatterCorrelationChart> calls. Identical height={250}, margins, grid
colour (hsl(var(--border))), axis colours (var(--dracula-fg) /
var(--dracula-comment)), per-chart fills (--dracula-orange / --cyan /
--lavender), tooltip styling, units, domains, ticks, labels, and
formatters. No color tokens, animations, or chart options changed.

Also adds a loading skeleton (h-[250px] w-full bg-muted/40 animate-pulse
motion-reduce:animate-none) that matches the chart footprint exactly so
the first paint of each card no longer shifts layout while Recharts
hydrates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:41:49 +02:00
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00
Marc-André BombeckandClaude Opus 4.7 5c884b3d0e style(layout): tighter mobile bottom-nav buffer + muted trend-hint title + drop font-mono on progress
- P7: shrink the bottom-nav clearance from 5rem to 4rem on mobile (md:pb-0 unchanged) so content sits closer to the nav without overlap.
- P8: TrendHint title gets text-muted-foreground so it stops competing with chart titles after the T2 muted-foreground hierarchy fix.
- P9: drop font-mono from the onboarding "X%" progress label; tabular-nums alone is enough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:59:25 +02:00
Marc-André BombeckandClaude Opus 4.7 fda8dd8c47 style(dashboard): trend-card typography + tabular nums + match chart padding + always-show avg chips
- P1: tabular-nums on latest, unit, avg7, avg30 spans so digits stop jiggling on refresh.
- P2: bump value text-2xl -> text-3xl tracking-tight; label text-sm font-medium -> text-xs uppercase tracking-wide.
- P3: tile padding p-3 -> p-4 md:p-6 to align with chart card padding.
- P4: drop direction-tinted arrow color (orange/cyan) — direction-as-good-or-bad is metric-specific, deferred to v1.5+. Both directions now muted-foreground.
- P5: always render avg7/avg30 chips, with em-dash placeholder when null, so vertical rhythm stays consistent across tiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:58:06 +02:00
Marc-André BombeckandClaude Opus 4.7 6a64df0fad feat(charts): bucket > 1y ranges into weekly / monthly averages
Hitting "All" on a long-running account used to render thousands of
daily points — unreadable, and slow on mobile. The chart adapter now
picks an aggregation level based on the visible range:

  - ≤ 90 days  → daily (unchanged)
  - 91-730 days → ISO weekly mean
  - > 730 days  → Berlin-calendar monthly mean

Empty buckets are dropped instead of zero-padding so a sparse user
keeps a real trend instead of a baseline floor. Each metric is
aggregated independently so blood-pressure SYS/DIA pairs stay in sync
without one channel pulling the other to 0 on missing days.

A small chip beside the chart title surfaces the active bucket
("Wochendurchschnitt" / "Monatsdurchschnitt" in DE, "Weekly avg" /
"Monthly avg" in EN) so the user always knows what they're looking at.

The bucketer (`src/lib/charts/bucket-time-series.ts`) is pure and
covered by 14 unit tests including ISO-week boundary handling, the
multi-metric independence guarantee, and the empty-bucket skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:56:07 +02:00
Marc-André BombeckandClaude Opus 4.7 8aae7d6dfe fix(dashboard): tile fill + muted-foreground hierarchy
Two related visual fixes for the dashboard tile strip and every
secondary string in the app:

- TrendCard now fills its grid cell. As a flex-child the card was
  taking content width, which made tiles in the same row 122-166 px
  wide on a 375 px viewport instead of even shares of the row.
  Adding `w-full` to the wrapping `flex` element gives every tile
  the same horizontal share that the parent grid hands out.
- `--muted-foreground` was identical to `--foreground` in both
  themes, so labels, "Ø7d:" prefixes, units, and inactive nav items
  all rendered at the same brightness as primary text. Dark theme
  now uses `#9aa3b3` (Dracula comment), light uses `#5b6273` —
  WCAG-AA contrast against `--background` and `--card` holds at
  ≥ 4.5:1 in both modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:24:22 +02:00
Marc-André BombeckandClaude Opus 4.7 8c0593fe79 feat(dashboard): single BP tile, equal-height tile strip, settings dashboard/thresholds split
Bucket A of the v1.4.3 polish pass.

Tile-strip width drifted past the chart-strip width by 16px on >=md
because the strip used `-mx-2 px-2` to bleed two pixels past the
parent for a snap-scroll feel. The bleed is real on mobile — the
horizontal-scroll affordance only reads as a scroll if the first/last
tiles touch the viewport edge — but on tablet/desktop where the row
fits without scrolling it just made the tile group visibly wider than
the charts below. Now `md:mx-0 md:px-0` drops the bleed once charts
fit inline, so the two strips share the same content box.

Tile heights varied because the blood-pressure tile rendered as two
stacked TrendCards (sys + dia) inside one tile slot while every
other tile was a single TrendCard. The cleaner fix is one TrendCard
that displays the paired value as `117/79` — TrendCard now accepts an
optional `secondary: { latest, avg7, avg30 }` and the dashboard pushes
ONE BP entry instead of a Fragment of two. Tile count drops from 6
to 5, the strip stops wrapping awkwardly, and the BP card carries
the systolic sparkline + traffic-light tooltip while the diastolic
value rides along under the same heading. Each tile wrapper got
`flex` and the card itself `flex h-full flex-col`, so flex-stretch
inside the strip can do its job and the avg7/avg30 row pins to the
bottom.

Settings nav split: the section formerly titled "Übersicht" was
renamed to "Dashboard" (the term every user already knows from the
main nav), and the embedded Persönliche Zielwerte panel moved out
into its own top-level settings section under `/settings/thresholds`.
Two reasons: the panel is heavy (one card per metric) and was
visually buried behind the layout customizer, and "Übersicht" leaked
internal vocabulary — neither the route slug nor the surrounding nav
entries used it. Tests guarding slug order + section count updated
in lockstep.

Touched:
  - messages/de.json, messages/en.json
  - src/app/page.tsx (dashboard tile strip)
  - src/app/settings/[section]/page.tsx (route wiring)
  - src/components/charts/trend-card.tsx (secondary-metric prop)
  - src/components/settings/section-slugs.ts
  - src/components/settings/settings-shell.tsx (new entry + icon)
  - src/components/settings/section-placeholder.tsx
  - src/components/settings/dashboard-section.tsx (drop ThresholdsSection)
  - src/components/settings/thresholds-settings-section.tsx (new)
  - src/components/settings/__tests__/{sections,settings-shell}.test.tsx

Tests + typecheck green (669/669).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:24:30 +02:00
Marc-André BombeckandMarc-André Bombeck f4a2e13985 feat(v1.4): onboarding redesign — dashboard-first checklist, 3 focused steps, inline trend hints (#131)
Redesigns the first-run experience around three patterns we see in
Linear, Vercel and Notion: a short focused wizard, a persistent
getting-started checklist on the dashboard, and contextual hints
inside still-empty charts.

- Onboarding shrinks from 4 steps (one was marketing-only) to 3:
  About you (display name + locale + height + DOB + gender) → log
  your first measurement (real action via the existing form) → pick
  one notification channel (or skip).
- Every step except the first now has a Back button and exactly one
  Skip link with the honest "you can finish setup later from
  Settings" tail. The legacy duplicate Skip is removed.
- A new dashboard "Getting started" card lists the five quick wins
  (profile, first measurement, medication, Withings, notifications).
  Each row links to the right place. Items can be hidden one-by-one
  or the whole card can be dismissed; both states persist in
  localStorage. The card auto-hides once you finish onboarding and
  log your fifth measurement.
- Charts that have ≥1 but <5 readings now show a subtle inline hint
  ("First trend after 5 readings — N more to go") so the user knows
  the line will appear soon.
- Display name is captured during onboarding and saved to the
  profile.

All new strings are translated to English and German via the
existing i18n pipeline.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:42:48 +02:00
Marc-André BombeckandClaude Opus 4.7 98a57eb786 audit(v1.3.3): SpO2 + body-composition + 14 CRITICAL/HIGH security/correctness fixes (#121)
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds

User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in
the measurements filter, badge, mobile icon, and edit dialog (issue #109).
Server-rendered doctor report now includes body composition rows (audit
C-9). Body water + bone mass have proper effective-range thresholds so
severity logic doesn't silently return `nominal` (audit C-15).

Structural: extracted the per-type display maps from measurement-list.tsx
into measurement-list-meta.ts as a deliberate first step toward the
single-source manifest planned for phase P5. Coverage tests now fail-fast
if a future enum addition skips any of: list label/icon/color, doctor-PDF
label/unit/vital-types, chart label, threshold metric order/label/bounds.

Audit-2026-05-07 / phase P0:
- closes #109 (filter UI gap, root cause for the German raw-enum display)
- closes audit C-9 (server PDF used a stale type map)
- closes audit C-15 (no warn/critical bands for body composition)

Tests: 282 → 296 (14 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: align OpenAPI spec + migration comment with body-composition reality

- OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at
  v1.3.0 baseline; spec drifted past the v1.3 server release)
- Bump openapi version 1.3.0 → 1.3.2 to match package.json
- Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content),
  not percent of body weight. Validators (VALUE_RANGES) and the Withings
  client (type 77 → kg) treat it as kg already; the comment was the lone
  outlier and would mislead the next reviewer.

Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum)
and M-B2 (migration unit comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip)

C-3 (Withings webhook secret in URL):
Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=…
fallback retained for legacy Withings configurations and emits a Wide
Event warning so operators can spot still-using-the-old-flow integrators.
The query-string secret was leaking into Coolify access logs and any
GlitchTip event whose request.url got captured.

C-4 (idempotency dead for Bearer-token clients):
The default userIdResolver only consulted getSession(). Bearer-authed
clients (the iOS / n8n / headless-API fleet that PR #117 was built for)
fell through, so every retry created duplicate measurements. The default
now tries cookie session first, then Bearer token via
hashToken+apiToken.findUnique with revoked/expiry checks. All three
existing call sites (measurements, mood-entries, medications/intake) get
the fix automatically.

H-B7 (request.url with secrets shipped to GlitchTip):
reportToGlitchtip now strips the query string before forwarding the URL,
so Withings legacy ?secret=… and any OAuth ?code=… that errors during
the callback don't end up in someone else's incident UI.

Audit-2026-05-07 / phase P2.
Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback +
revoked/expired/null cases).
Deferred to a follow-up (needs DB migration / lock primitive):
  C-2  moodLog webhook → AES-GCM at rest + HMAC-over-body
  C-5  idempotency TOCTOU advisory_lock or in-flight sentinel row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3

Closes the OxygenSaturation feature request from #109.

- Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS)
- Zod validators: plausibility 50–100% (below 50 is incompatible with
  sustained life; above 100 is physically impossible)
- effective-range thresholds: green 95–100, orange 92–94, red <92.
  Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice
  (≥95% at rest is healthy), AHA/clinical literature (<92% = call
  provider, <88% = ER). Lower-only concern — upper orange wing
  collapses to greenMax since saturation cannot exceed 100%.
- Personalisation: existing thresholds-override UI lets COPD /
  chronic-respiratory users with a doctor-set 88–92 baseline tune the
  bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in
  thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>`
  forces TS to catch any missed surface.
- Withings ScanWatch type 54 mapping (best-effort — only fires for
  pulse-ox-capable devices).
- Wired through measurement-form, measurement-list-meta (icon: Wind),
  health-chart, both doctor-report-pdf renderers (core + browser-side
  near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in
  4 sections × 2 locales.
- iOS DTO already declared `OXYGEN_SATURATION` — server enum addition
  closes the long-standing drift flagged in audit V2.
- 4 new tests: numeric green band, upper-wing collapse,
  user-override (COPD baseline), METRIC_BOUNDS shape.

Tests: 301 → 305. Typecheck: clean.

Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(analytics): truthfulness pass on medical citations + clamp override orangeMax

Doctor-agent + hallucination-hunter audit V3 found six medical citations
in effective-range.ts that the code did not actually implement. Plus a
latent bug in the user-override path that produced physically-impossible
orangeMax values (>100% saturation, <0% body-fat).

Citation rewrites (no numeric changes):

- ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020.
  WHO publishes activity in min/week, not steps. Step-count was a
  misattribution that also leaked into AI prompts.
- TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson
  formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance,
  it does not publish kg cut-offs.
- BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance-
  estimated; not DEXA-comparable". Withings BIA values typically run
  5–7% below DEXA — calling them DEXA-equivalent in a doctor-report
  PDF was a real legal-exposure misattribution.
- OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS
  clinical practice (≥95% at rest is healthy)" → "consumer pulse-
  oximeter consensus (≥95% normal at rest); slightly tighter than
  BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA
  pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100
  band — that's an ATS/consumer-consensus number, not BTS's literal
  94-98 target. Soften the citation to match the code, not vice versa.

Bug fix (1 latent, 1 real-today):

- effective-range override path now clamps orangeMin/orangeMax to
  METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting
  SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible
  saturation), and a BODY_FAT override of {2,80} emitted orangeMin
  = -9.7 (negative percent). Default-range case 210 already
  hard-coded orangeMax: 100 for the same reason — the override path
  was the only branch missing the invariant.

Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%).
Typecheck: clean.

Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure

Two CRITICAL fixes from audit V3.

NEW-V3-1: Bearer-scope bypass.
  apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent"
  scope (the iOS app receives this on login). The previous check
    permissions.includes(requiredPermission)
  treated "*" as a literal string — never matched. Today many sensitive
  routes call requireAuth() *without* a requiredPermission, so a leaked iOS
  token can act as a full-scope token (account delete, settings wipe). Once
  those routes adopt requireAuth("scope:name"), this wildcard handling keeps
  the iOS app working while narrower-scoped tokens (e.g. user-issued
  ["medication:ingest"]) get correctly 403'd.

  Same wildcard handling added to the standalone permission checks in
  src/app/api/ingest/medication/route.ts:65, 119.

NEW-V3-2: GDPR Art. 17 account-delete erasure completeness.
  Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which
  preserved PII (free-text symptom descriptions, IP addresses, login-city
  geolocation) after the user was deleted. We now explicitly
  prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the
  cascade-delete, making account deletion genuinely complete erasure.

Tests 306/306 still pass; typecheck clean. Audit V3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(pdf): real text-content assertions for body composition + SpO2 rows

Replaces bytes-only "renders body composition rows" theatre test with
pdf-parse-driven text extraction that asserts the actual rendered labels
+ values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN).

Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified
"%PDF-" header + byte-count, so a regression that drops a row would
still pass. Now a missing label fails the test.

- adds dev dep: pdf-parse@^2.4.5
- 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums

V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays
silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective
domains. Adding a new measurement type required hunting these down.

Now all aggregation paths derive from `measurementTypeEnum.options`:
- /api/insights/comprehensive (AI insights)
- /api/dashboard/summary (iOS dashboard adapter)
- /api/analytics (analytics aggregator)
- /lib/insights/general-status (AI general-status fetch)
- /api/import (Zod schema — round-trip now covers all 11 types)

External-contract enums extended additively (no breakage for old clients):
- /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass
- /api/dashboard/widgets widgetIdEnum: + oxygenSaturation
- DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row
- formatMeasurementsForExport: glucoseContext now round-trips

Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the
canonical enum stays the source of truth and documents PDF exclusions
(BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only).

313/313 tests pass (was 308, +5 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit)

- CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was
  blanket connect-src on every page including /auth/login → DOM-XSS exfil
  channel)
- web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl
  refinement; was z.url() only → blind SSRF probe via Push subscription
- isPublicUrl: also fixes a regression where DNS labels starting with
  "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6
  unique-local. Check is now gated on a colon being present in the host.
- /api/ai/test: stop returning provider err.message + bodyExcerpt to the
  client (leaked provider URLs / partial keys / internal headers). Now
  returns categorised generic message; full details land in the wide-event
  via annotate() for the operator.
- /lib/geo (auditLog IP geo lookup): switch default provider to
  ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes,
  add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs.
  Previously leaked IP+timestamp over plaintext HTTP every auth event
  (GDPR Art. 32 + Art. 44).
- /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector)

Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF
guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider
shapes, geo opt-out env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening

V3 audit follow-on bundle covering 4 HIGH findings.

(I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF
trust. Reads chain right-to-left so client-supplied leftmost entries
cannot rotate the per-IP rate-limit bucket. Default 1 matches typical
single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces
x-real-ip / null. New 7-test suite covers the rotation attack +
malformed entries + zero-hop fallback.

(F) Audit-log retention job — daily 03:15 cleanup of audit_logs older
than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e)
"storage limitation" gap; previously IPs + city + login events
accumulated forever. 6-test suite covers default + override +
misconfig guard (rejects <7 days).

(G) isCachableStatus — extracted the inline 4xx/5xx filter into a
named, exported, tested function. 7 cases cover the full do-not-cache
contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx
validation are still cached.

(H) Bearer-mock tightening — apiToken.findUnique mocks in
require-auth-bearer.test + idempotency.test now assert the
where.tokenHash shape uses the hashed value (not the raw bearer).
A regression that switched back to raw-token comparison would have
been silent without this assertion.

Tests: 345/345 pass (was 332, +13 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2)

The mood_log_webhook_secret column previously stored a plaintext secret
that the webhook handler compared timing-safe against every enabled
user. A DB dump or backup snapshot leaked active webhook credentials.

New contract — no schema migration required:
- Writes go through encryptMoodLogSecret() (AES-256-GCM via existing
  crypto helper, ENCRYPTION_KEY).
- Reads go through readMoodLogSecret() which transparently decrypts
  envelopes and tolerates legacy plaintext rows during the transition
  (graceful upgrade path — no breaking change for self-hosted users).
- Webhook lookup decrypts each candidate before timing-safe compare.
- Status endpoint decrypts for the user's settings page.

One-shot startup migration in reminder-worker rotates any leftover
plaintext rows on next worker boot. Idempotent; encrypted rows are
skipped.

Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough,
isLegacyPlaintext semantics, rotateLegacy idempotence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift)

V3 audit Doku Specialist findings — drift between code and meta-docs.

- README §Key Features now mentions pulse oximetry (SpO₂) + Withings
  ScanWatch sync; Tech Stack model count corrected 23 → 25.
- AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected
  `vitest.config.ts` → `vitest.config.mts`; migration range corrected
  `0001–0022` → `0001–0024`; model count + list updated to 25 (adds
  Device, IdempotencyKey).
- CLAUDE.md model count updated (2 occurrences).
- CHANGELOG.md v1.3.3 expanded with Security + Internal sections that
  were missing the audit-fix-marathon work (Bearer-scope, account-delete
  cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo
  HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF,
  audit-log retention, idempotency cachable filter, Bearer mock
  tightening, enum-drift cousins, PDF text-content tests).
- OpenAPI Bearer description corrected `SHA-256 hashes` →
  `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs
  match the actual server-side implementation. Wildcard scope contract
  documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass

Multi-agent review of the audit-fix branch surfaced four HIGH issues
and a couple of MEDIUM hardenings. All addressed before merge.

HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk).
The dashboard-summary route now emits totalBodyWater/boneMass/
oxygenSaturation MetricCards, but the spec still listed only the
original 7 kinds. Generated iOS Codable would have crashed the dash
the moment a user logged SpO2. MetricKind extended with the 3 new
kinds + comment documenting unknown-value defensive decoding.

HIGH-2 — kindEnum naming consistency on /api/measurements/series.
The series adapter shipped "oxygen" while the dashboard summary used
"oxygenSaturation"; both routes now use "oxygenSaturation" so iOS
decoders can share a single MetricKind type across endpoints.

HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that
mock provider errors with sk-leaked-key + provider URLs in the err
fields and assert the response body never echoes them, plus the
3-status-categorised generic messages (401/429/5xx/other).

HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that
mock checkRateLimit allowed/disallowed and assert the 429 path uses
the right key shape (`import:<userId>`, 5, 1h) and that the within-
quota path proceeds.

MEDIUM-3 — getClientIp under-length XFF chain regression. With
TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp
returned the leftmost (attacker-controlled) entry, silently
re-introducing the rotation attack the helper was meant to close.
Now: when chain is shorter than configured hops, refuse XFF entirely
(fall back to x-real-ip / null). Added regression test.

LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of
silently degrading to hops=0. Added regression test.

Tests: 358/358 pass (was 350, +8 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:05:56 +02:00
8533755d8d release(v1.2): i18n, thresholds, glucose, dashboard customization, feedback, multi-provider AI (#77)
* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX

Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.

- i18n: add 21 missing translation keys (targets.* namespace was only
  partially populated after the English-first migration; common.reset and
  settings.configured were referenced but absent), plus common helpers
  (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
  to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
  measurement/mood/medication-dependent key sets. Wire them into
  measurement-form/list, mood-form/list, medication-form/card,
  intake-history-list, intake-timeline, medications page, and Withings
  sync in settings. Fixes dashboard not auto-refreshing after adding a
  measurement (old code only invalidated ["measurements"], not
  ["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
  messages via sonner toasts and invoke getApiErrorMessage() so saves
  fail loudly instead of silently. Success also gets a toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(i18n): package B — locale-aware formatters throughout

Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.

New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
  Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
  dateTime, time, dateWithWeekday, monthShort. Also exports
  parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
  locale, memoized per render.

Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
  hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)

Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.

Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
  alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
  Accept-Language) and sets <html lang> server-side, avoiding an
  EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
  SSR pass is correct.

Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
  translator + locale. All section titles, column headers, BMI / BP
  classifications, mood labels, and the footer disclaimer are
  i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ux): package C — typed passkey errors, bug-report gating, error boundary

Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.

Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
  to specific i18n keys instead of swallowing every failure as
  "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
  Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
  now, so origin mismatches and previous-registration collisions show
  actionable copy.
- 5 new passkey.* i18n keys in EN + DE.

Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
  isAdmin}.
- /bugreport page now queries status first. When not configured it shows
  a notice (different copy for admins vs. regular users) with a direct
  link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
  of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.

Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
  diagnostics (message, digest, stack (trimmed), URL, user-agent,
  request-id, locale, timestamp) + report-issue link. Clipboard fallback
  prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
  it replaces the root layout on catastrophic failures — hardcoded
  English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
  retry, reportIssue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper

Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: small simplifications in i18n/UX changes

- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): capture upstream response body in OpenAI errors

A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.

Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address code-review + security findings

Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.

Must-fix:
- security: error-details.tsx + global-error.tsx were copying
  window.location.href into the clipboard, which on OAuth callback
  routes (/api/withings/*, password reset) would leak code/state
  tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
  transient invalidate failure doesn't abort the chain and no
  unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
  in try/catch — a dynamic-server error in the root layout now
  degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
  "24:00" → "00:00" (some ICU builds emit that for midnight under
  en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
  tokens before logging, and moves the excerpt off Error.message into
  a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)

Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
  a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
  (matches SSR) before falling back to localStorage or browser
  detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.

Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
  makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
  name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
  semantics (incl. partial-failure test)

118 → 142 tests. All pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(v1.2): thresholds, glucose, dashboard customization, built-in feedback, multi-provider AI (#76)

* build(deps): bump @prisma/adapter-pg from 7.6.0 to 7.7.0 (#60)

Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/adapter-pg)

---
updated-dependencies:
- dependency-name: "@prisma/adapter-pg"
  dependency-version: 7.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump prisma from 7.6.0 to 7.7.0 (#61)

Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 7.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump eslint-config-next from 16.2.2 to 16.2.3 (#62)

Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.2 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.3/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump next from 16.2.2 to 16.2.3 (#65)

Bumps [next](https://github.com/vercel/next.js) from 16.2.2 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.2...v16.2.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump @types/node from 25.5.2 to 25.6.0 (#66)

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.2 to 25.6.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump dotenv from 17.4.0 to 17.4.1 (#67)

Bumps [dotenv](https://github.com/motdotla/dotenv) from 17.4.0 to 17.4.1.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.4.0...v17.4.1)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump @tanstack/react-query from 5.96.2 to 5.99.0 (#70)

Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.96.2 to 5.99.0.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.99.0/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.99.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump typescript from 5.9.3 to 6.0.2 (#64)

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump prettier from 3.8.1 to 3.8.2 (#68)

Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump react from 19.2.4 to 19.2.5 (#63)

Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump vitest from 4.1.2 to 4.1.4 (#69)

Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.2 to 4.1.4.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump @prisma/client from 7.6.0 to 7.7.0 (#71)

Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 7.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump shadcn from 4.1.2 to 4.2.0 (#72)

Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.1.2 to 4.2.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.2.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(thresholds): foundation + settings UI (v1.2)

Introduces per-user health threshold overrides with an auto-default /
user-override pattern. Default computation stays evidence-based (WHO,
ESC/ESH 2018, AHA, AASM, ADA 2024, DGIM/DDG S3). Overrides live in
User.thresholdsJson; any missing metric falls through to the default so
new users need zero configuration.

Schema (migration 0021):
- New enums: glucose_context (FASTING/POSTPRANDIAL/RANDOM/BEDTIME),
  feedback_category, feedback_status.
- measurement_type gains BLOOD_GLUCOSE; Measurement gets optional
  glucose_context (enforced via CHECK constraint — only set for glucose).
- User gains thresholds_json, dashboard_widgets_json, glucose_unit,
  ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted,
  ai_local_key_encrypted (stubs for the upcoming features).
- New Feedback table for the built-in feedback system (next commit).

Backend:
- src/lib/analytics/effective-range.ts is the single source of truth:
  getEffectiveRange(metric, profile, overrides) returns the resolved range
  + the pristine default + physiological bounds. Every chart, band,
  insight prompt, and doctor-report section should call this.
- Supports 11 metrics incl. four glucose contexts with ADA/DGIM ranges.
- src/lib/validations/thresholds.ts — Zod schema with per-metric bounds
  and min<max refinement.
- GET/PUT/DELETE /api/user/thresholds with full audit logging (before/
  after) so medical-ish data mutations are traceable.

UI:
- src/components/settings/thresholds-section.tsx — clean per-metric row
  with Auto/Override Switch, inline min/max, reset-per-row + reset-all,
  and a "this looks outside guideline ranges" warning banner that only
  shows when the override is >30% off the default.
- New Personalization section in /settings.
- 25 new i18n keys (EN + DE) in a `thresholds.*` namespace.

Tests: 8 new cases covering default computation, override application,
missing profile gracefully returning null, glucose-context defaults, and
per-metric bounds. 150/150 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(glucose): wire BLOOD_GLUCOSE + context through the write path

Extends measurement validation and the POST /api/measurements route to
accept blood-glucose samples with a required `glucoseContext`
(FASTING / POSTPRANDIAL / RANDOM / BEDTIME).

- Zod schema adds BLOOD_GLUCOSE to the type enum and glucoseContextEnum.
- refine(): BLOOD_GLUCOSE requires a context; every other type must not
  set one — mirrored by the Postgres CHECK constraint from migration 0021
  so callers bypassing validation still can't write inconsistent rows.
- VALUE_RANGES gains BLOOD_GLUCOSE (20-800 mg/dL) — covers severe hypo
  through severe hyperglycemia.
- getUnitForType(BLOOD_GLUCOSE) returns "mg/dL" (canonical storage).
  Display in mmol/L is a UI-level conversion per user preference.
- Error messages translated to English (removed two lingering German
  refine messages while here).

Measurement form + charts + targets UI follow in the next commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): localize AI insight prompts (en/de)

Each prompt builder (base-system, blood-pressure, bmi, general-status,
medication-compliance, mood, pulse, weight) now accepts a Locale and
returns the system + user prompt either fully in English or fully in
German. Stable contract keys (classification enum optimal/gut/grenzwertig
/erhoht/kritisch, confidence niedrig/mittel/hoch) are preserved verbatim
so the response parser keeps working — only the natural-language sections
are translated. The prompt explicitly tells the model to emit
classificationLabel / summary / recommendations in the user's language.

Wires the locale through every status generator (general / blood-pressure
/ bmi / weight / pulse / mood / medication-compliance) into the prompt
calls and adds a small server-side resolver
(src/lib/i18n/server-locale.ts: explicit override > healthlog-locale or
userLocale cookie > User.locale > Accept-Language header > default
English) used by all /api/insights/*-status routes plus
/api/insights/generate. The auditLog cache key already contains the
locale suffix, so cached entries stay disjoint per language.

The reminder worker continues to fall back to "de" when User.locale is
null, matching its prior behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(glucose): UI + unit conversion helpers

- src/lib/glucose.ts — mg/dL ↔ mmol/L conversion (factor 18.0182 per
  DGIM/DDG), resolveGlucoseUnit() defaulting to mg/dL,
  thresholdMetricForContext() mapping each context to the matching
  effective-range key.
- Measurement form gains a "Blood glucose" option (mg/dL) with a
  context selector (fasting / postprandial / random / bedtime). The
  glucoseContext field is only sent for BLOOD_GLUCOSE — the Zod
  refinement and the Postgres CHECK constraint both enforce that.
- 10 new i18n keys per locale (measurements.typeBloodGlucose,
  glucoseContext*, glucoseUnit, glucoseUnitHint).
- 6 new tests covering conversion roundtrip, unit resolution defaults,
  and context → threshold-metric mapping.

Charts, target cards, and doctor-PDF row for glucose follow in the
next commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(feedback): built-in feedback system (no GitHub required)

Implements the feedback-system design from the v1.2 spec. The submit
button on /bugreport now always works — even when no admin has
configured a GitHub repository. Feedback is stored in the Feedback
table (migration 0021) with an admin inbox for triage.

Endpoints:
- POST /api/feedback — user submits. Auth-gated, rate-limited 5/hour.
  Category (BUG / FEATURE_REQUEST / QUESTION / OTHER), subject,
  description, optional screenshot (≤5 MB, base64), optional metadata
  (locale, URL path, userAgent).
- GET /api/feedback — user's own history (paginated).
- GET /api/admin/feedback — admin inbox with status filter and
  counts-by-status for tab badges.
- PATCH /api/admin/feedback/[id] — update status / admin note.
- DELETE /api/admin/feedback/[id] — soft-archive.

Every mutation writes to AuditLog with before/after details.

UI:
- /bugreport rewritten: category dropdown, optional subject, always
  submits. Informational banner explains whether the server has GitHub
  escalation available or keeps feedback internal-only.
- Legacy "not configured" blocker is gone — the form is never hidden.
- 8 new bugreport.* i18n keys per locale.

The old POST /api/bugreport route and /api/bugreport/status endpoint
are left intact for backwards compatibility; they'll be removed in
v1.3 once no one depends on them.

Admin inbox UI lands in the next commit alongside the dashboard and
multi-provider work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(telegram): localize bot UI per User.locale

The Telegram webhook now resolves the user's locale (User.locale, falling
back to "de" so existing bots stay German) and runs every reply, inline-
keyboard label and callback toast through a small server-side translator.

Adds src/lib/i18n/server-translator.ts (getServerTranslator) — reads the
same messages/{en,de}.json bundles the client uses, with the same
"missing key falls back to English, then to the raw key" semantics, and
the same {param} interpolation. Importing the client useTranslations()
hook into a server route would have pulled React in, so this mirrors
just the parts the bot needs.

Adds the telegram.* namespace to messages/en.json and messages/de.json
covering: button labels, callback toasts, the /help body, /add prompt,
medication-not-found / inactive errors, snooze/skip/confirm confirmations
and the cancel button. The "genommen" / "taken" intake keyword and the
"hi/hallo/hey/moin/hello" greeting list are accepted regardless of UI
locale to keep backwards compatibility for existing users.

Also adds 6 vitest cases for getServerTranslator (basic resolution,
locale switching, parameter interpolation, English fallback, raw-key
fallback, exposed locale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dashboard): user-customizable widget layout

Implements the dashboard customization spec: users choose which widgets
appear on / and in what order. Auto-default keeps the current v1.1
behavior so nothing changes without opt-in.

Backend:
- src/lib/dashboard-layout.ts — single source of truth with
  DEFAULT_DASHBOARD_LAYOUT (10 widgets incl. the new sleep/steps/glucose
  ones that were previously hard-conditional), a version field for
  future schema evolution, and resolveDashboardLayout() that merges
  persisted state with defaults so new widgets auto-appear (invisible
  by default) when the schema bumps.
- GET / PUT / DELETE /api/dashboard/widgets with strict Zod validation
  (version literal, widget-id enum, order range).
- Persisted in User.dashboardWidgetsJson (column added in 0021).

UI:
- src/components/settings/dashboard-layout-section.tsx — mounted under
  the new Personalization section alongside Thresholds. Local edit
  buffer so toggling/reordering doesn't fire a network call per click;
  single "Save" commits. Each widget has Up/Down buttons + visibility
  toggle, plus a reset-to-defaults button at the section header.
- 11 new dashboard.* i18n keys per locale.

Dashboard page integration is staged — widgets currently still render
per the old data-conditional rules. Wiring the saved layout through
/app/page.tsx is a follow-up so this commit stays focused on the
plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(auth): locale-aware password-strength feedback

Completes the zxcvbn i18n migration by wiring the new
getZxcvbnTranslations(locale) helper into the client PasswordStrength
component and removing the obsolete zxcvbn-de helper.

- src/components/ui/password-strength.tsx reads the active locale from
  useTranslations() and memoizes the translator per locale — so
  changing language instantly retranslates open warnings/suggestions.
- src/lib/zxcvbn-de.ts deleted. Every consumer now routes through
  zxcvbn-i18n.ts which carries both EN and DE mappings.
- server-side checkPasswordStrength (already refactored earlier in this
  session) continues to accept locale; clients can pass it from the
  session.

No test impact — 162 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dashboard): replace setState-in-effect with draft-overlay pattern

The dashboard-layout-section.tsx initial implementation synced server
state into local state via useEffect, which trips the project's strict
react-hooks/set-state-in-effect rule. Rewrote using a nullable draft
pattern: `layout` is derived as `draft ?? remote`, and only user edits
set the draft. Save/Reset clear the draft so the server copy wins
again. Cancel also clears instead of re-seeding from server.

No behavior change, no new tests — the component contract stays the
same. Lint back to baseline (2 errors / 7 warnings, both pre-existing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: v1.2 README + CHANGELOG (thresholds, glucose, dashboard, feedback, multi-provider AI)

- CHANGELOG 1.2.0 entry covering 5 new features + locale polish
- README Key Features expanded: glucose, custom thresholds, dashboard
  customization, multi-provider AI, built-in feedback
- Doctor Report description updated for locale-aware output

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(targets): glucose cards per context with effective range + unit conversion

Render one target card per logged glucose context (FASTING, POSTPRANDIAL,
RANDOM, BEDTIME) on /targets, sourcing the threshold band from
getEffectiveRange() so user overrides apply consistently. The targets API
returns glucose values canonical mg/dL; the page converts to the user's
glucoseUnit preference via convertGlucose() before display.

- Extend /api/insights/targets to include per-context glucose summaries
  (latest, 30-day average, range, classification) and surface glucoseUnit
  in the profile envelope.
- Expose glucoseUnit on /api/auth/me and AuthUser type.
- Targets page resolves the i18n label key, applies unit conversion to
  current/avg/range, and uses Droplet iconography.
- Add targets.glucoseFasting/Postprandial/Random/Bedtime keys to en + de.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): address H1 (CSP nonce), H3 (SSRF), M1+M2 (XSS)

From the v1.2 full-repo security audit:

- **H1 / CWE-79** src/proxy.ts: CSP nonce was base64 of the UUID *string*
  form, which gives ~122 bits and a structured pattern (hyphens at fixed
  offsets). Replaced with 16 random bytes → proper 128-bit nonce.
- **H3 / CWE-918** src/app/api/admin/ai-settings/route.ts: the admin AI
  base URL only required "https://". A compromised admin cookie could
  point it at attacker.example and exfiltrate the bearer key on the
  next insight call. Now validates with URL parser, requires https://,
  and only accepts hosts on a small allowlist (OpenAI, Anthropic,
  Google AI, Mistral, Groq, OpenRouter). Ops can extend via
  `ADMIN_AI_BASE_URL_ALLOWLIST` env var.
- **M1 / CWE-79 (stored, off-platform)** src/app/api/bugreport/route.ts:
  bug-report description flowed to GitHub with only backtick/asterisk
  stripping. Now strips HTML tags entirely, neutralises dangerous
  markdown link schemes (javascript:/data:/vbscript:/file:), and wraps
  the whole body in a fenced code block so residual markdown renders
  literally.
- **M2 / CWE-79** Zod schemas for feedback + bugreport screenshot now
  require a base64 PNG/JPEG/WEBP/GIF data URL via regex. SVG
  deliberately excluded — it can carry inline JS that would execute
  when the admin inbox renders the `<img>` preview.

Medium rate-limiter semantics (audit M3) left as-is: the fixed-window
behaviour is acceptable for a single-tenant PWA and matches what ops
actually want. Remaining M4–M6 + L1–L7 are tracked for v1.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dashboard): glucose TrendCards + chart label support

Render one TrendCard per logged glucose context on the dashboard when
the user has the "glucose" widget enabled in their layout. Cards
respect the user's glucoseUnit preference via convertGlucose(); data
sources from the existing /api/analytics endpoint, which now emits a
glucoseByContext map alongside the summaries object.

- /api/analytics: include BLOOD_GLUCOSE in the type whitelist (so
  total counts include glucose) and add glucoseByContext containing a
  summarize() result per context.
- HealthChart: wire BLOOD_GLUCOSE into BASE_TYPE_LABEL_KEYS so the
  legend shows the localized "Blood glucose" label when the chart is
  invoked for that type.
- Dashboard page: fetch the dashboard layout, gate glucose cards on
  widget visibility AND data presence, render Droplet-icon TrendCards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(i18n): bugreport.subtitle no longer promises a GitHub issue

The visual QA pass flagged that bugreport.subtitle still said "a
GitHub issue will be created automatically" even though the banner
below the heading correctly explains the submission is stored locally
(built-in feedback system, GitHub is only an optional escalation).
Neutral copy now that matches the actual behaviour in both locales.

* feat(ai): user-level provider selection + test endpoint + UI

Adds the missing pieces for the multi-provider AI MVP that pair with
the AnthropicClient and LocalOpenAICompatibleClient already on disk:

* /api/user/ai-provider (GET + PATCH): user can pick OPENAI /
  ANTHROPIC / LOCAL / CHATGPT_OAUTH and store provider, model, baseUrl
  and per-provider API keys. Anthropic + local keys go through the
  existing AES-256-GCM crypto helper. Provider value is whitelisted.

* /api/ai/test (POST): rate-limited (5/min/user) round-trip that
  resolves the user's provider and asks for a tiny JSON ack so the UI
  surfaces real "does this config work?" feedback instead of a blind
  Save.

* settings/page.tsx: appends a per-user override card under the
  existing AI Insights section. Provider dropdown drives conditional
  fields (Anthropic key for ANTHROPIC, baseUrl + optional bearer for
  LOCAL, model for all), then Save / Test buttons.

* provider.test.ts: extended to cover the four new cases (ANTHROPIC
  with key, ANTHROPIC fallback, LOCAL, OPENAI routes through admin
  key, CHATGPT_OAUTH routes through Codex). Switched to
  resetAllMocks() in beforeEach to flush mockResolvedValueOnce queues
  between cases — the prior clearAllMocks() leaked them.

* local-client.test.ts: full happy / no-auth / bearer-auth / structured
  error / empty-content coverage mirroring the openai-client suite.

typecheck + tests green; lint stays at the pre-existing 2 errors / 7
warnings baseline (no new findings introduced).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: integrate late agent additions (glucose PDF row + i18n)

* test(glucose): cover display-unit conversion edge cases

Lock in the precision contract for the targets/dashboard/PDF surfaces:
canonical mg/dL stays as integers, mmol/L collapses to 1-decimal, and
the ADA diabetes thresholds (70/99/126) round-trip to their published
SI values (3.9 / 5.5 / 7.0 mmol/L).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(i18n): eliminate hydration flash by threading SSR-resolved locale to I18nProvider

Layout already resolves the initial locale from cookie + Accept-Language and
sets <html lang>. Until now the client I18nProvider re-detected locale at
mount via getSavedLocale() / detectSystemLocale(), causing a flash where
"Loading…" (EN) rendered first, then flipped to "Laden…" (DE) after
hydration. Pass initialLocale down layout -> Providers -> I18nProvider so
useState seeds the same locale the server used.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin/feedback): return 409 when GitHub issue already published

Spec requires 409 Conflict (not 200 with flag) when the feedback row
already has gitHubIssueUrl set. Drops the alreadyPublished response
field in favour of a clear error status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(crypto,dashboard): L4 hard-fail short ENCRYPTION_KEY in prod + dashboard WIP from parallel agent

* refactor(effective-range): collapse duplicated glucose case arms

Extract glucoseRange() helper and toDate() normalizer. Use METRIC_BOUNDS
keys as the source of truth for getAllEffectiveRanges() metric list so
additions stay in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(dashboard-layout-section): drop redundant setLayout alias

Remove the trivial setLayout wrapper and hoist the per-row label key
lookup so it runs once per widget instead of twice (span + aria-label).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): MED-1 SSRF on user AI baseUrl + MED-2 publish-to-GitHub race

Final-audit blockers before v1.2 merge.

MED-1 — SSRF via user-level LOCAL AI baseUrl
Authenticated user could set aiBaseUrl to http://169.254.169.254/,
http://localhost:9090/metrics, or any internal host and get response
bodies echoed back via the bodyExcerpt error field. Route the value
through the existing isPublicUrl() helper (exported from
lib/validations/notifications.ts). Ops can opt-in to private hosts on a
specific instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true — that's how
self-hosted Ollama / LM Studio stays usable without exposing the SSRF
surface by default.

MED-2 — Race in POST /api/admin/feedback/[id]/github
Two concurrent admin clicks both saw gitHubIssueUrl=null and both
created duplicate GitHub issues. Atomic claim now: a single
updateMany({ where: { id, gitHubIssueUrl: null }, data: PENDING }) —
only the caller whose count=1 proceeds. PENDING is released on failure
so retries work.

Tests + typecheck green. Baseline lint unchanged.

* fix: i18n completeness + UX polish (closes #73, #74) (#75)

* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX

Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.

- i18n: add 21 missing translation keys (targets.* namespace was only
  partially populated after the English-first migration; common.reset and
  settings.configured were referenced but absent), plus common helpers
  (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
  to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
  measurement/mood/medication-dependent key sets. Wire them into
  measurement-form/list, mood-form/list, medication-form/card,
  intake-history-list, intake-timeline, medications page, and Withings
  sync in settings. Fixes dashboard not auto-refreshing after adding a
  measurement (old code only invalidated ["measurements"], not
  ["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
  messages via sonner toasts and invoke getApiErrorMessage() so saves
  fail loudly instead of silently. Success also gets a toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(i18n): package B — locale-aware formatters throughout

Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.

New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
  Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
  dateTime, time, dateWithWeekday, monthShort. Also exports
  parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
  locale, memoized per render.

Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
  hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)

Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.

Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
  alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
  Accept-Language) and sets <html lang> server-side, avoiding an
  EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
  SSR pass is correct.

Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
  translator + locale. All section titles, column headers, BMI / BP
  classifications, mood labels, and the footer disclaimer are
  i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ux): package C — typed passkey errors, bug-report gating, error boundary

Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.

Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
  to specific i18n keys instead of swallowing every failure as
  "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
  Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
  now, so origin mismatches and previous-registration collisions show
  actionable copy.
- 5 new passkey.* i18n keys in EN + DE.

Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
  isAdmin}.
- /bugreport page now queries status first. When not configured it shows
  a notice (different copy for admins vs. regular users) with a direct
  link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
  of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.

Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
  diagnostics (message, digest, stack (trimmed), URL, user-agent,
  request-id, locale, timestamp) + report-issue link. Clipboard fallback
  prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
  it replaces the root layout on catastrophic failures — hardcoded
  English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
  retry, reportIssue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper

Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: small simplifications in i18n/UX changes

- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): capture upstream response body in OpenAI errors

A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.

Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address code-review + security findings

Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.

Must-fix:
- security: error-details.tsx + global-error.tsx were copying
  window.location.href into the clipboard, which on OAuth callback
  routes (/api/withings/*, password reset) would leak code/state
  tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
  transient invalidate failure doesn't abort the chain and no
  unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
  in try/catch — a dynamic-server error in the root layout now
  degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
  "24:00" → "00:00" (some ICU builds emit that for midnight under
  en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
  tokens before logging, and moves the excerpt off Error.message into
  a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)

Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
  a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
  (matches SSR) before falling back to localStorage or browser
  detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.

Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
  makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
  name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
  semantics (incl. partial-failure test)

118 → 142 tests. All pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: restore Link import lost in merge

* fix(bugreport): remove stale loading + not-configured blocks lost in merge

The v1.2 built-in feedback refactor made status informational only — form
always renders. Main's merge re-introduced the pre-refactor blocker blocks
referencing undefined statusLoading/AlertCircle and broke the build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-18 22:20:12 +02:00
9b59390224 fix: i18n completeness + UX polish (closes #73, #74) (#75)
* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX

Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.

- i18n: add 21 missing translation keys (targets.* namespace was only
  partially populated after the English-first migration; common.reset and
  settings.configured were referenced but absent), plus common helpers
  (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
  to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
  measurement/mood/medication-dependent key sets. Wire them into
  measurement-form/list, mood-form/list, medication-form/card,
  intake-history-list, intake-timeline, medications page, and Withings
  sync in settings. Fixes dashboard not auto-refreshing after adding a
  measurement (old code only invalidated ["measurements"], not
  ["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
  messages via sonner toasts and invoke getApiErrorMessage() so saves
  fail loudly instead of silently. Success also gets a toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(i18n): package B — locale-aware formatters throughout

Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.

New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
  Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
  dateTime, time, dateWithWeekday, monthShort. Also exports
  parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
  locale, memoized per render.

Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
  hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)

Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.

Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
  alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
  Accept-Language) and sets <html lang> server-side, avoiding an
  EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
  SSR pass is correct.

Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
  translator + locale. All section titles, column headers, BMI / BP
  classifications, mood labels, and the footer disclaimer are
  i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ux): package C — typed passkey errors, bug-report gating, error boundary

Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.

Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
  to specific i18n keys instead of swallowing every failure as
  "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
  Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
  now, so origin mismatches and previous-registration collisions show
  actionable copy.
- 5 new passkey.* i18n keys in EN + DE.

Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
  isAdmin}.
- /bugreport page now queries status first. When not configured it shows
  a notice (different copy for admins vs. regular users) with a direct
  link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
  of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.

Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
  diagnostics (message, digest, stack (trimmed), URL, user-agent,
  request-id, locale, timestamp) + report-issue link. Clipboard fallback
  prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
  it replaces the root layout on catastrophic failures — hardcoded
  English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
  retry, reportIssue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper

Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: small simplifications in i18n/UX changes

- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): capture upstream response body in OpenAI errors

A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.

Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address code-review + security findings

Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.

Must-fix:
- security: error-details.tsx + global-error.tsx were copying
  window.location.href into the clipboard, which on OAuth callback
  routes (/api/withings/*, password reset) would leak code/state
  tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
  transient invalidate failure doesn't abort the chain and no
  unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
  in try/catch — a dynamic-server error in the root layout now
  degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
  "24:00" → "00:00" (some ICU builds emit that for midnight under
  en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
  tokens before logging, and moves the excerpt off Error.message into
  a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)

Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
  a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
  (matches SSR) before falling back to localStorage or browser
  detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.

Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
  makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
  name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
  semantics (incl. partial-failure test)

118 → 142 tests. All pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:15:43 +02:00
Marc-André Bombeck 0c0c345e82 i18n: migrate from German-first to English-first default locale
Switch defaultLocale from "de" to "en" and make English the code-level
language throughout. All ~145 hardcoded German strings translated:

- API error messages (52 routes) now return English
- Zod validation messages in English
- Classification/analytics functions return English categories
- Notification type labels in English
- Component strings use i18n t() calls (heatmap, mood chart,
  medication form, intake history, password strength, insights card)
- ~50 new i18n keys in both de.json and en.json

Route migration:
- /stimmung → /mood, /zielwerte → /targets (new pages)
- 301 redirects in proxy.ts for legacy URLs
- Sidebar + bottom nav updated

Bump version to 1.0.1.
2026-03-12 21:31:48 +01:00
Marc-André Bombeck 38309932f3 Initial release v1.0.0
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.
2026-03-10 20:14:33 +01:00