Commit Graph
199 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 3f6e670894 i18n(admin,settings): copy consistency sweep
Several smaller A8 copy nits in one pass:

- Notifications settings subtitle no longer hard-codes "Telegram,
  ntfy, Web Push" — the channel-reliability board only shows
  configured channels, so naming all three made one of them look
  missing.
- Admin /feedback page subtitle widened to "feedback, bug reports,
  and feature requests" so it matches what the inbox actually
  contains; the duplicate card-level title + description on
  /admin/feedback is gone (page header already provides them).
- /admin/services subtitle now mentions integrations alongside
  notification channels (the API and moodLog toggles live there
  too); per-row "system-wide" suffixes dropped — the section
  subtitle already establishes the scope.
- /admin/ai-quality subtitle reads as a sentence instead of the
  jargon "(severity x provider)" expression.
- /admin/feedback empty state is "All caught up" / "Alles erledigt"
  instead of the English-idiom "Inbox zero".
- /achievements subtitle rewritten in both EN + DE to drop the
  dev-speak "from feature rollout onward".
- /admin/api-tokens permission badges show "All" (with a tooltip)
  instead of the bare regex glob `*`.
- Brand spelling normalized: every Glitchtip → GlitchTip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:47:51 +02:00
Marc-André BombeckandClaude Opus 4.7 1f4d6a28a5 fix(settings): unify threshold page naming
The thresholds settings route was carrying three different
names: nav label "Personal targets", page header "Personal
targets", inner card header "Personal thresholds". The URL
slug is `/settings/thresholds`, so settled on "Personal
thresholds" everywhere in EN; DE keeps the established
"Persönliche Zielwerte". While here, dropped the duplicate
title + subtitle inside the editor card — the page-level
header already provides them and the second copy was just
visual noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:43:04 +02:00
Marc-André BombeckandClaude Opus 4.7 42b0f110ba fix(admin): explicit role-change button label
The user-management role-change button rendered a bare arrow
followed by "User" / "Admin" — direction was ambiguous
("does the arrow mean 'now they are User' or 'send them down
to User'?") and the label carried no aria-label. Replaced the
arrow text with explicit "Make user" / "Make admin"
("Zum Benutzer" / "Zum Admin"), and added the same string as
aria-label so screen readers announce the action verb instead
of the visible-only arrow glyph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:41:52 +02:00
Marc-André BombeckandClaude Opus 4.7 1ae9b9dcad fix(admin,measurements): humanise raw enum badges
Two badges were rendering raw enum values: backups showed
`WEEKLY_AUTO` / `MANUAL` and the measurement list showed
`WITHINGS` instead of `Withings`. Backup type now uses two new
i18n keys (`Weekly (auto)` / `Manuell`); measurement source
reuses the existing `measurements.source*` keys. Unknown
enum values still fall through to the raw string so any
schema additions remain visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:40:52 +02:00
Marc-André BombeckandClaude Opus 4.7 d2631253a4 fix(insights): match legacy-payload CTA button to its banner copy
The legacy-payload yellow banner read "Insights updated —
regenerate for new explainability features" but the CTA button
underneath said "Start analysis", so the verb in the help text
and the verb on the button disagreed. Switch the legacy CTA
button to the existing `insights.heroRegenerate` key so both
banner and button now talk about regeneration. The empty-state
"Start analysis" CTA stays untouched — that one's never had
analysis to start from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:38:19 +02:00
Marc-André BombeckandClaude Opus 4.7 ff6e18473a fix(forms): hint browser locale on date and datetime inputs
Native `<input type="date">` and `<input type="datetime-local">`
were rendering `mm/dd/yyyy` even when the German locale was
active, because Chromium picks the input's locale from the
nearest `lang` attribute on the element rather than from
`<html lang>`. Pass `lang={locale}` on every date/datetime input
in the app — onboarding DOB, account DOB, export filters,
doctor-report range, measurement / medication / mood timestamp
fields — so DE users now see `TT.MM.JJJJ`. Display formatting
already routed through `useFormatters()`; this closes the form
half of the same gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:36:23 +02:00
Marc-André BombeckandClaude Opus 4.7 dc75fe846a fix(admin): restrict login overview to auth events
The Login Overview page promised "authentication and admin events"
in its subtitle but the underlying audit-log query never set
`filter=auth`, so the table mixed insights.* / admin.* / data.*
rows in with passkey + password logins. Default the page request
to `filter=auth` (an explicit action picker still overrides), and
restrict the action dropdown to actions starting with `auth.` so
the filter UI stays in sync with what the page actually shows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:30:51 +02:00
Marc-André BombeckandClaude Opus 4.7 180f46c24e fix(medications): localise time-window range string
`formatTimeWindowRange` used to hard-code the German "bis ... Uhr"
suffix, so an English-locale user saw "Today, 19:00 bis 23:00 Uhr"
on the medication card and the medication form. Pass the active
locale through and emit "19:00 – 23:00" for English while keeping
the existing German output for DE users (and unchanged for legacy
call sites that still don't pass a locale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:29:07 +02:00
Marc-André BombeckandClaude Opus 4.7 7a70db6c86 fix(admin): hide collapse button when page has only one section
`/admin/api-tokens` carried a Collapse / Expand toggle inherited
from the v1.4 shared admin page where 13 sections lived together.
On the v1.5 dedicated route the toggle just hides the only card on
the page — Marc reported it as "sinnlos" (pointless): the page
title already says "API Tokens", the only thing below it is the
tokens card, and collapsing it leaves an empty surface with the
section heading staring back at you.

Drop the local `expanded` state and the toggle button from the
section. The card always renders, the query always fetches, and the
header is now a plain icon + title row. The shared
`settings.collapse` / `settings.expand` i18n keys stay in messages
because `<LoginOverviewSection>` still uses them.

The matching test asserts no Collapse / Expand / Einklappen /
Ausklappen string appears in either locale, and that no
`aria-expanded` button shows up on the section header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00
Marc-André BombeckandClaude Opus 4.7 dd8212e202 fix(admin): api-tokens table truncate-with-tooltip (no scrollbar 4th attempt)
Marc has now reported a residual scrollbar on `/admin/api-tokens`
four times running. Earlier fixes addressed the obvious overflow
culprits (column-hide in v1.4.15, mobile card-list in v1.4.16,
admin-shell mobile-strip `no-scrollbar` in v1.4.18), but the
section itself still leaked a few quirks:

- The mobile token-name span carried `truncate` and `break-all` on
  the same element. `truncate` sets `white-space: nowrap`, which
  beats `word-break: break-all` per CSS spec — `break-all` was dead
  code AND it was misleading. Remove it.
- The desktop table had no per-column upper bound. A long token
  name could expand the row past the card on 768-1024 px viewports.
  Lock the layout with `table-fixed` + `<colgroup>` widths.
- Permission badges had no `max-width` cap. A single long
  permission string (`measurements:write:audit:legacy:extended`)
  could push the cell past the card. Cap each badge with
  `max-w-full truncate` and a native `title` attribute.

Wrap every potentially-long string (token name, username, permission
badge) in a small `<TruncatedCell>` helper that pairs the visual
truncation with a radix tooltip. The tooltip is the discoverable
hover/long-press path so the full value is still readable; the
native `title` attribute is a graceful fallback for screen readers
and right-click → "view full name" on desktop.

Adds an e2e regression that walks every element inside the mobile
card-list and asserts none of them carry `scrollWidth > clientWidth`,
exercised against a worst-case payload (long token name + long
username + long permission string).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00
Marc-André BombeckandClaude Opus 4.7 088832aa39 fix(admin): remove spurious scrollbar from feedback tab strip
Marc reported a stray "mini scroll-button" on the right side of the
admin feedback inbox tab strip (Open / Acknowledged / Resolved /
Archived). The four short tabs do not horizontally overflow the
viewport, so the painted bar surprised him every time he opened the
inbox.

Root cause is a CSS overflow coupling: setting only `overflow-x: auto`
silently forces `overflow-y` to `auto` too, because a single axis
cannot be `visible` while the other clips. The strip is locked at
`h-9` (36 CSS px) but its badge children (`py-0.5` + text-xs digits)
ride 1-2 px taller than the strip on some glyph stacks, so the
implicit `overflow-y: auto` paints a tiny vertical scrollbar on the
right edge.

Fix is a single class on `tabsListVariants`: `overflow-y-hidden`
(plus `overflow-y-visible` in the vertical-orientation override so
vertical tab columns are not affected). Horizontal swipe and
keyboard-arrow scrolling still work — only the implicit y-axis bar
is suppressed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00
Marc-André BombeckandClaude Opus 4.7 98a3d10705 refactor(insights): single page-level refresh and drop orphan subtitle
The /insights page used to surface the same regenerate handler three
times — the page hero's "Neu generieren" button, the advisor card
header's icon-only refresh button, and the empty-state "Analyse
starten" CTA inside the same card. Marc described the trio as
"gefuehlt drei Buttons, alle machen das gleiche".

Drop `onRegenerate` from the page's `<InsightAdvisorCard>` mount.
The hero remains the single page-level refresh affordance; the
card-level icon button and empty-state CTA disappear because they
short-circuit on `!onRegenerate`. The per-recommendation Regenerate
(inside `<RecommendationCard>`) is unrelated — that's per-card
context, not page-level — and stays as-is per the v1.4.16 spec.

Same patch drops the placeholder "Persoenlicher Berater" subtitle
(`insights.aiOverviewTitle`) the page passed in as the card's
secondary line. With no provider configured, the empty card
rendered just an icon and that subtitle above blank space, which
Marc described as "Titel da aber NICHTS PASSIERT". The CardTitle
("KI-Gesundheitsanalyse") is enough framing on its own; the
subtitle prop on `<InsightAdvisorCard>` becomes optional and
existing callers that still pass one keep their line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:12:24 +02:00
Marc-André BombeckandClaude Opus 4.7 60a91afaee feat(insights): comparison toggle on-surface in hero meta band
Move the Vormonat / Vorjahr comparison toggle from a standalone row
below the page hero into the hero's own meta slot. The previous
layout read as three stacked cards with separate header / control
bands — hero, then comparison toggle, then advisor card — and the
toggle row had no other content, so it just hung there in the gap.

Folding the toggle into the hero clusters every page-level control
(personal-baseline indicator, Generated timestamp, Regenerate
button, comparison baseline) into one band. The hero now exposes a
generic `metaSlot` prop so future page-level controls can join the
band without another component refactor.

Settings → Dashboard remains the power-user backdoor for either
surface; the dashboard's on-surface toggle stays gone (Marc said it
crowded the tile strip too much).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:11:39 +02:00
Marc-André BombeckandClaude Opus 4.7 717a025f0c style(settings): uniform vertical spacing across all sections
Pre-fix the per-card vertical rhythm was either `space-y-3` (AI
provider config forms), `space-y-5` (dashboard layout card) or
`space-y-4` (Account → Profile form). Three competing strides for
forms that visually live in the same shell, so the eye picks up the
discontinuity even though the section-level `space-y-6` outer rhythm
was already consistent.

Standardize on `space-y-4` between fields inside any single Settings
card:

- Dashboard "Customize layout" card: `space-y-5` -> `space-y-4`
- AI Codex / OpenAI / Anthropic / Local provider forms: `space-y-3` ->
  `space-y-4`

Account, Notifications and Export already used `space-y-4`. Section
roots and the AI parent card keep their `space-y-6` so distinct cards
still read as distinct surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:09:11 +02:00
Marc-André BombeckandClaude Opus 4.7 737b533089 style(settings): uniform vertical spacing across all sections
Pre-fix the per-card vertical rhythm was either `space-y-3` (AI
provider config forms), `space-y-5` (dashboard layout card) or
`space-y-4` (Account → Profile form). Three competing strides for
forms that visually live in the same shell, so the eye picks up the
discontinuity even though the section-level `space-y-6` outer rhythm
was already consistent.

Standardize on `space-y-4` between fields inside any single Settings
card:

- Dashboard "Customize layout" card: `space-y-5` -> `space-y-4`
- AI Codex / OpenAI / Anthropic / Local provider forms: `space-y-3` ->
  `space-y-4`

Account, Notifications and Export already used `space-y-4`. Section
roots and the AI parent card keep their `space-y-6` so distinct cards
still read as distinct surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:09:00 +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 10757846bb fix(settings): Sprache select position + width consistent
The Sprache (language) select used to share a 2-column grid row with
"Geburtsdatum" inside the Profile card. That pairing was misleading on
two counts:

1. Language is a per-session UI preference (cookie-backed) — not a
   profile attribute stored on `users`. Sitting next to a column-
   stored field (date of birth) implied the wrong persistence model.
2. The two-column grid collapsed to single-column on `<sm`, leaving
   a four-line stack of dob -> dob-hint -> language -> language-hint
   inside a card that already had three other field rows. Marc
   flagged the visual stride as "awkward".

Lift Language out of the Profile-data block into its own row at the
bottom of the Profile card. On `>=sm` it uses `max-w-xs` so its width
matches the other half-width fields rather than stretching to the
full card width (which would make it the visually heaviest control on
the page). On `<sm` it stays full-width — same as every other field.

Drive-by: pull the duplicated native-select class string into a single
`NATIVE_SELECT_CLASS` constant so gender + language render identically
(previous gender select used `placeholder:text-muted-foreground` which
language did not — minor but visible at the focus-ring level).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:06:33 +02:00
Marc-André BombeckandClaude Opus 4.7 9fda634369 fix(settings): action-button placement consistent across sections (right-side desktop, stacked mobile)
The Account → Password and Account → Restart-onboarding-tour cards
both used `flex items-center justify-between gap-2` to put the action
button right-aligned next to the card title. On a 393-px Pixel 5
viewport the tour button (216 px wide) overflowed the card's right
edge by ~48 px because nothing forced it to wrap.

Apply a single shared layout to every "title + action" row in
Settings: stacked column on `<sm` (button below title + description,
full-width), title + action on the same row on `>=sm`. Same pattern
applied to Dashboard's "Reset to defaults" so the long German copy
("Auf Standard zurücksetzen") cannot push past the card edge either.

Pulled the description paragraph inside the title cluster on the two
account cards so each row is now a clean `<title-block + button>`
flex pair — easier to reason about than the previous title-only flex
with the description orphaned outside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:05:11 +02:00
Marc-André BombeckandClaude Opus 4.7 0dcc91aae8 refactor(integrations): single status pill, drop redundant containers
Withings card had three status displays (top-right badge, mid-card
banner with the same state + two timestamps, and an inner badge).
Mood Log was even worse — four, including a "letzter Sync" line at
the very bottom. The pill in the card header is now the canonical
surface for both. Withings keeps its actionable error message under
the pill (the pill can't fit "Withings refresh error: 503 - upstream"),
the duplicate {n}/{threshold} badge is gone. Mood Log gains the visual
divider Withings always had — Marc called the asymmetry out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:04:27 +02:00
Marc-André BombeckandClaude Opus 4.7 957f8e9b93 fix(settings): equalize input heights across all settings sections
Settings inputs and selects rendered at four different heights on
Pixel 5: 32 px (AI add-provider), 36 px (account form, export, ntfy /
telegram), 40 px (AI active-provider) and 44 px (dashboard "Compare to"
trigger). The mismatch was visible whenever two cards lived next to
each other, e.g. the 40-px provider select sat directly above a 32-px
"Add provider" select inside the same column.

Standardize on the shared 36-px contract (`h-9`, matching `<Input>` and
the default `<SelectTrigger>` size) for every text-entry field across
Settings:

- AI active-provider native select: `h-10` -> `h-9`
- AI fallback-chain "Add provider" native select: `h-8` -> `h-9`
- Dashboard "Compare to" `<SelectTrigger>`: drop `min-h-11` so the
  default `h-9` applies

The 44-px tap-target floor (WCAG 2.5.5) still applies to mobile
nav-level controls; for inline form fields the 36-px height matches
the rest of the settings shell and the desktop sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:04:01 +02:00
Marc-André BombeckandClaude Opus 4.7 ba0d6b87d1 feat(integrations): IntegrationStatusPill component (single tag, mobile-safe)
Settings → Integrations had three- to four-fold redundant status
displays per card. The new pill is the one place a connection status
appears: top-right, Dracula-tokenized chip, "Connected · 12 min ago"
relative-time pattern. Locale-aware (EN + DE), mobile-safe (whitespace-
nowrap, abbreviated "min" / "h" / "d" forms so it fits a 360px card).

Reusable for v1.4.20's Apple Health card on iOS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:00:11 +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 b88f2de865 feat(dashboard): recent-achievements card celebrates hidden unlocks
When the unlock notifier sees a newly-unlocked achievement with
`isHidden: true`, it fires a longer-running (8 s vs default 4 s)
toast with the `<Sparkles>` icon and the localized
"You unlocked a hidden achievement!" headline. The toast body
reveals the real title + description for the first time so the
user finally learns what they tripped — that's the reward.

Public unlocks keep the existing `<Trophy>` toast unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +02:00
Marc-André BombeckandClaude Opus 4.7 19b08442b4 feat(achievements): definitions for v1.4.18 expanded set + hidden Easter-eggs
Lifts the roster from 38 to 59. Adds three buckets:

- mood (4 badges): first entry, 7-day diarist, 30-day diarist,
  brighter-week (mood mean improved by ≥1.0 vs prior 7-day mean)
- vitals counts (7): first/50/200 weigh-ins, first/50/200 BP readings,
  first pulse log
- engagement (4): consistent month (≥25 distinct days in a calendar
  month), entry-streak 7d/30d, weekend warrior

Plus 6 hidden Easter-eggs: night-owl, early-bird, leap-day,
doctor-PDF export, locale-flip, bug-buddy (≥5 reports). Hidden
achievements never leak their conditions to the DOM — locked cards
paint a generic "Hidden achievement" placeholder.

Adds `EarnabilityFlags` + `applyDiscoveryFilter` so a public locked
achievement only renders when the user has the underlying data
(e.g. a mood-streak badge stays hidden until they log their first
mood). Hidden Easter-eggs ignore the flags. Already-unlocked badges
always render so deleting data doesn't make completed achievements
disappear.

EN+DE strings for every new badge plus the hidden-card placeholder
copy and the hidden-unlock toast strings. CATEGORY_LABEL_KEY now
covers mood + hidden so the discriminated-union check passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +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 3e1607471b fix(admin): suppress painted scrollbar on mobile section strip
Production probe at /admin/api-tokens on a Pixel-5 viewport pinned the
scrollbar that kept showing up after two earlier fixes: it lives in
the AdminShell mobile section strip (13 entries, scrollWidth ~1700 vs
clientWidth ~360), not in the api-tokens table. The strip sits right
above the api-tokens card, which is why every previous attempt
targeted the table while the bar stayed put.

Adds a `no-scrollbar` utility in globals.css (scrollbar-width: none +
::-webkit-scrollbar { display: none }) and applies it to the strip in
both AdminShell and SettingsShell. Swipe and keyboard-arrow scrolling
keep working; the painted bar simply doesn't draw.

New AdminShell unit suite locks in the class so a future refactor
can't silently regress it; settings-shell test gets the matching
assertion. The api-tokens e2e spec gains a regression check that the
strip declares `no-scrollbar` and overflow-x-auto together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:16:56 +02:00
Marc-André BombeckandClaude Opus 4.7 da7070e222 style(insights): prettier sweep on legacy-payload hotfix files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:51:02 +02:00
Marc-André BombeckandClaude Opus 4.7 79bfa27d65 fix(insights): handle legacy cached payload without rationale (regenerate CTA)
Cached insight blobs from before the v1.4.16 strict schema landed
crashed /insights with `Cannot read properties of undefined (reading
'replace')` on `stripChartTokens(insight.summary)`. The pre-strict
shape (`{changed, stable, drivers, nextSteps, confidence,
limitations}`) has no `summary`, no `recommendations[]`, and no
`findings[]`, so the rich card had nothing to render and the
chart-token helper crashed on undefined input.

The advisor card now short-circuits to a self-contained legacy-payload
card with a one-click regenerate CTA when the shape is unrenderable.
`isLegacyInsightPayload()` also flags the v1.4.14 pre-strict shape so
the API surfaces the right `legacyPayload: true` flag from a cache
hit. Defense-in-depth: `stripChartTokens()` and `parseChartTokens()`
treat null/undefined as the empty string instead of crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:49:17 +02:00
Marc-André BombeckandClaude Opus 4.7 c63cddc6dd style(insights): prettier sweep on C1 + C2 wire-up files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:34:09 +02:00
Marc-André BombeckandClaude Opus 4.7 8a5b6de27e fix(dashboard): mount InsightsCardPreview on root page (wire B1b dashboard preview to live route)
The polished InsightsCardPreview component shipped in B1b commit 5
(d2cdf9d) was an orphan with zero non-test imports. The dashboard read
no advisor payload, so the top severity-ordered AI recommendation +
ring confidence meter + "View all" CTA was unreachable from any live
route. Wave-D design review flagged this as CRITICAL C2.

Wires the preview into the root dashboard page above the chart row,
sharing the advisor cache key with /insights so a regenerate on either
surface refreshes the other without a second LLM call. The preview
self-hides when the user has no provider configured (route 422 → null)
or the advisor returns zero recommendations.

Adds insightsPreview to DASHBOARD_WIDGET_IDS so the layout toggle
persists through Settings → Dashboard. Pins the widget into
DEFAULT_DASHBOARD_LAYOUT (visible: true, tileVisible: false) so users
discover the feature without hunting through settings, and adds a
contract test mirroring the A5 widget-enum-drift guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:33:01 +02:00
Marc-André BombeckandClaude Opus 4.7 aae968a6d5 fix(insights): mount InsightAdvisorCard on /insights page (wire B5c/d/e + B1b polish to live route)
The /insights page rendered only the text-only InsightStatusCard per
section, hiding all of B5c (rec rationale + mini-chart), B5d (confidence
meter), B5e (thumbs feedback) and the broader B1b polish from users.
Wave-D design review flagged this as CRITICAL C1.

Wires the InsightAdvisorCard component into the live route, consuming
the existing /api/insights/generate POST. The route already returns the
24h-cached payload without burning a rate-limit token, so a single
POST-without-force is functionally a GET-or-generate — no new API
surface needed (the reconcile report deferred C1 because it assumed
otherwise).

The cache is shared with the dashboard preview via the new
queryKeys.insightsAdvisor() so a regenerate on either surface refreshes
the other without a second LLM call.

Per-section InsightStatusCards stay below as supplemental detail; the
hero now also wires its regenerate button to the same handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:28:52 +02:00
Marc-André BombeckandClaude Opus 4.7 54bbc68eff docs(planning): phase-D reconcile report + v1.5 backlog seed + STATE.md update
Captures the v1.4.16 phase-D reconcile outcome:
- 1 of 3 CRITICAL fixed inline (C3 on-surface comparison toggle).
  C1 + C2 deferred to v1.4.17 with documented rationale (root cause:
  /insights + dashboard preview need API surface changes that exceed
  reconcile scope).
- 9 HIGH fixed (a11y tap targets, i18n leak, security redaction,
  feedback rate-limit, fallback chain enabled flag, medication
  comparison caption); 11 deferred.
- 7 of 8 simplify-yes applied.
- v15-backlog.md seeded with deferred items + Product-Lead C.1-C.11
  roadmap + v1.6+ follow-on initiatives.

Final verification green: typecheck 0, lint 0 errors, test 1539/1539,
integration 59/59.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:21:21 +02:00
Marc-André BombeckandClaude Opus 4.7 5f7b9d8652 fix(api): provider-chain GET preserves disabled entries (H2 code-review)
The GET response used to surface the resolveProviderChain()-filtered
list, which DROPS disabled entries upstream. The Settings UI then
seeded `enabled: true` for every returned row, so disabling an entry
made it disappear on next refetch and lose its toggle position —
the toggle was functionally a "remove from chain" button.

Changed GET to read the *persisted* chain via parseProviderChain() so
disabled rows still round-trip with their flag. The active-provider
summary still uses resolveProviderChain so the displayed "Active: …"
line continues to reflect the actual runner ordering (enabled-only).
The Settings UI seeds entries with the wire-supplied `enabled` value
instead of always-true, so re-enabling becomes possible without a
page reload.

4 unit tests pin the GET shape (persisted-chain ordering, disabled
entries surfaced, cached-active overlay, default-chain fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:14:29 +02:00
Marc-André BombeckandClaude Opus 4.7 6863ecb87d fix(api): rate-limit feedback endpoint + drop overly broad cache invalidation (H5/H6 code-review)
H5 — `POST /api/insights/feedback` had no rate limit. The unique
index `(userId, recommendationId, recommendationText)` blocks exact
replays but a malicious client could distort the daily aggregator's
bucket slice by varying recommendationId/text per request. Added
`checkRateLimit("insights-feedback:<user.id>", 60, 60 * 60 * 1000)`.
60/h is generous given a comprehensive insight rarely has >10 recs.

H6 — `<RecommendationFeedback>` invalidated the entire `["insights"]`
prefix on every thumbs click, which matches all 8 insights queries
(comprehensive + 7 per-status). None of them depend on the feedback
row. The 24h server cache absorbs the actual LLM round-trip but the
network thrash + stale-cache refetch was waste. Dropped the
invalidation; user-visible verdict state lives in localStorage +
local React state. v1.4.17's prompt-version ratchet will scope its
own invalidation when feedback feeds the next generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:11:34 +02:00
Marc-André BombeckandClaude Opus 4.7 f3025a8dd5 refactor(v1.4.16): apply simplify-review safe suggestions
7 of 8 apply-yes simplify findings landed:

F2 — `generateInsight()` confidenceContext config-bag dropped. No
production caller passed the resolver; the wrapper now derives inputs
from the parsed payload directly (n from metricSource.n, recencyDays
=0, deviationStdRatio=null). Two confidence-wrapper tests rewritten
to test the parsed-payload path. v1.4.17 will re-introduce a
resolver injection when the feedback ratchet ships a real second
caller.

F3 — `aiRecommendationRationaleSchema.referenceId` speculative
optional field removed. No consumer reads it; rec-level referenceId
is the canonical citation pointer.

F4 — `applyLastWorkingCache` doc-comment trimmed: dropped the
"NEW array" defensive note (no caller compares the input ref).

F5 — `<RecommendationFeedback>` re-uses the canonical
`RecommendationFeedbackRequest` types from the validation schema
instead of hand-maintaining the same enum vocabulary. Removes a
silent-drift risk if v1.4.17 adds another time-range value.

F6 — `formatRelativeTime` doc-comment trimmed: dropped the stale
`Intl.RelativeTimeFormat` polyfill claim (baseline-supported since
2018) — kept the genuine WHY (i18n consistency).

F7 — `<RecommendationCard>` 26-line slot-architecture header trimmed
to a 4-line WHY. The slots being narrated as "future" were filled by
B5d / B5e in the same file.

F9 — `pickProviderType` doc-comment trimmed: removed the
mis-anchored PROMPT_VERSION snapshot paragraph (the function only
picks providerType — promptVersion is read elsewhere).

F10 — `legacy-payload.ts` `UnknownRec` interface removed; cast
inlined as `(rec as { rationale?: unknown })`.

F12 — `<ConfidenceMeter>` 20-line band-policy header trimmed to a
3-line WHY. The bandFor() helper + colour-by-band record below are
the documentation now.

(F1 + F8 deferred — both flagged "no" in simplify review and need
Marc's call.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:10:09 +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 6e74d38213 fix(design): on-surface comparison toggle on dashboard + insights (C3)
The B8 comparison overlay shipped behind a Settings → Dashboard Select
buried 3 clicks deep. The brief asked for "mobile-friendly comparison
toggle (not a fragile hover-only interaction)" and the toggle that
shipped failed that bar — flipping Vormonat / Vorjahr meant a 5-step
round-trip every time.

New `<CompareToggle>` segmented control (None / Vormonat / Vorjahr)
sits next to the dashboard greeting and on the insights page hero.
Persists via the same `/api/dashboard/widgets` PUT the Settings
section already uses, with optimistic update + cache invalidation so
the chart overlays + tile callouts respond instantly. 44 px (min-h-11)
tap targets, focus-visible ring, role=group + aria-label for screen
readers, EN+DE i18n.

6 SSR tests pin segment count, persisted-active state, tap-target
floor, EN+DE labels, and ARIA shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:00:12 +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 775df8cfe9 feat(comparison): comparison toggle persists baseline preference
Add a "Compare to" pulldown to the Settings → Dashboard section that
lets the user pick None / Last month / Last year. The preference rides
on the existing User.dashboardWidgetsJson blob — research §7 Q3 settled
on the JSON-pivot path so v1.4.16 ships migration-free.

The resolver defaults to "none" for legacy layouts and clamps unknown
values back to "none", so a stale client cannot poison the dashboard
with a baseline the renderer doesn't know how to draw. The PUT
endpoint accepts the new optional comparisonBaseline field; pre-B8
clients keep PUTing without it and see no behavioural change.

EN+DE i18n keys land for the toggle label, the three options, the
"unavailable" fallback, and the chart caption — Vormonat / Vorjahr per
Marc's brief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:16:41 +02:00