diff --git a/.planning/research/v1427-r1-dashboard.md b/.planning/research/v1427-r1-dashboard.md new file mode 100644 index 000000000..23b58b681 --- /dev/null +++ b/.planning/research/v1427-r1-dashboard.md @@ -0,0 +1,203 @@ +# v1.4.27 R1.1 — Dashboard UX audit and GLP-1 secondary tile spec + +## Headline + +The dashboard's eight maintainer findings cluster into three coherent buckets: a GLP-1 tile rebuild (findings 1-3, plus an unused secondary inside the same card), a chart-row symmetry fix (finding 4), and four small leftover-removal jobs (findings 5-7) plus a hero-strip rebalance (finding 8). The biggest piece of work is finding 1: the existing `` already pulls every byte we need from `/api/dashboard/glp1` and the standalone `` already exists in `src/components/medications/`, so we wire the chart in as a second tile-internal pane behind a small range selector and rip the green seam off in the same edit. Findings 4, 5, 6, 7 are short and surgical (one CSS class, one component delete, one prose strip, one tile click). Finding 8 needs the most thought because the Health Score card lives inside the `/insights` ``, not on the dashboard, and the maintainer wants width and height. Total estimated touched files: ~12; estimated commits if split per finding: 6-8. The whole bucket is touch-disjoint with R1.2/R1.3/R1.4 and can land as a single fix-surface in Round 3. + +## Findings 1-8 — per-finding analysis + +### Finding 1 — GLP-1 drug-level secondary tile + +**Current state**: `src/components/dashboard/glp1-tile.tsx` renders one chart at lines 294-306, the mini weight chart with vertical injection markers. The drug-level chart already exists as a standalone surface in `src/components/medications/DrugLevelChart.tsx` (538 lines, full Research-Mode-gated AreaChart with one-compartment PK math). The standalone chart wraps its own `
` so dropping it into `` raw would paint a card inside a card. The chart accepts `medication: { id, name, dose }` as props (lines 99-107). The `/api/dashboard/glp1` route already returns `medicationId` (file lines 80-85 in `glp1-tile.tsx`) and the current dose value/unit, so we already have everything the standalone chart needs. + +**Problem**: A weight chart is a downstream proxy — the maintainer cares about the actual estimated drug-level curve because it shows the dose-response across a 21-day window with three weekly sawtooth cycles. The drug-level surface is the explicit W19c outcome that today lives only inside `/medications/[id]/history`. Marc's directive on this is unambiguous: the chart belongs on the dashboard tile and `/insights/medikamente`. + +**Proposed approach**: Add a small two-tab segmented control inside `` between the dl with last/next injection (line 252-288) and the chart slot (line 294-306). Tabs: "Gewicht" (current) / "Wirkspiegel" (new). Default to "Wirkspiegel" because that's the more informative pane Marc asked for. Extract a `` carve-out that renders either the existing `` or a slimmed ``. Slim means: pass a `compact` prop into `DrugLevelChart` that drops its outer `section.bg-card` wrapper (the parent tile already owns the card surface) and shrinks the chart-area from `h-[240px]` to `h-[180px]` to match mini-mode density. Keep the Research-Mode gate intact — when the gate is closed the "Wirkspiegel" tab still appears but the body paints the existing `` (with the Settings CTA). Estimated 80-120 LOC across the tile + 30-40 LOC of new `compact` plumbing inside `DrugLevelChart`. Low risk: the standalone surface and its tests stay intact; the new path is additive. + +**Files touched (estimated)**: +- `src/components/dashboard/glp1-tile.tsx` (+~80 LOC for tab strip + pane carve-out) +- `src/components/medications/DrugLevelChart.tsx` (+~30 LOC for `compact` prop) +- `messages/de.json` + `messages/en.json` (2 new keys: `dashboard.glp1.tabWeight`, `dashboard.glp1.tabLevel`) +- `src/components/dashboard/__tests__/glp1-tile.tsx` (new + updated assertions for the tab strip + default-to-level behaviour) + +### Finding 2 — green band optical clarity + +**Current state**: `src/components/dashboard/glp1-tile.tsx` line 213-223. The tile carries `border-l-dracula-green/60 border-l-2` on the outer wrapper as a faint left seam. The `
` block (lines 252-288) renders "Letzte Injektion" and "Nächste Injektion" mid-card. The two are visually disconnected: the seam reads as "active therapy" but the dl-block is regular muted-foreground prose with calendar icons and tabular-nums values. + +**Problem**: The maintainer reads the green band as decoration with no semantic tie to the labels next to it. The two visual languages (seam vs. dl) don't reinforce each other, so the band feels arbitrary. + +**Proposed approach**: Two clean options: + +1. **Strip the seam, promote the injection dates to a header pill row.** Drop the `border-l-dracula-green/60 border-l-2` classes entirely (the Syringe-icon-in-green at line 226 already carries the "active therapy" signal). Restructure the `
` into a single horizontal pill bar above the tab strip: a `Calendar`-iconed pill ("Letzte: Mo 12. Mai") and a `Syringe`-iconed pill ("Nächste: Mo 19. Mai · in 4 Tagen"). The pills carry a soft `bg-dracula-green/10 border-dracula-green/30` border-grouping so the user reads the two dates as one cohesive "therapy schedule" unit, not two separate dl entries. + +2. **Keep the seam but anchor it to the schedule.** Move the seam from the outer wrapper to a thin background strip running only behind the `
` rows so the green visually frames the schedule itself (the seam stops being a decoration on the whole card and starts being a "this is the schedule block" marker). + +Maintainer's brief language ("Letzte Indikation / nächste Indikation mid-card") strongly suggests they want the dates more prominent and the seam less arbitrary, so option 1 is the recommendation. Estimated 30-40 LOC. + +**Files touched (estimated)**: +- `src/components/dashboard/glp1-tile.tsx` (~40 LOC; new `` carve-out, drop seam classes) +- `src/components/dashboard/__tests__/glp1-tile.tsx` (assert new `data-slot="glp1-tile-schedule"` pill, drop the `border-l-dracula-green/60` selector test if one exists) + +### Finding 3 — point-count selector inconsistency + +**Current state**: `src/components/dashboard/glp1-tile.tsx` line 297-304 mounts ``. `mini={true}` short-circuits the header block in `health-chart.tsx` at line 1052 (`{!mini && (header)}`), so the range tabs (7d / 30d / 90d / All — see `TIME_RANGES_KEYS` line 46-67) never render in mini mode. The `` standalone surface also doesn't have a range picker — it hard-codes a 21-day window (`WINDOW_HOURS_BEFORE = 21 * 24` at line 71). Other dashboard charts (the bottom chart row) render range tabs at line 1118-1130. + +**Problem**: Every other chart-tile on the dashboard lets the user pick 7d / 30d / 90d / All. The GLP-1 tile is the lone exception. Inconsistency reads as a bug. + +**Proposed approach**: Mount a small four-button range strip inside `` above the chart pane, owned by the tile itself (not by the underlying ``/``). Re-use the same `TIME_RANGES_KEYS` constant from `health-chart.tsx` (export it as `RANGE_PRESETS` or copy four lines if the export would couple too tightly). For the weight pane pass `rangePoints` through as a new prop into `` so mini mode can honour it (one new `windowOverride`-like prop or a fork of the existing `windowOverride` flow). For the level pane pass `windowHoursBefore` into `` (new prop with current 21-day default). 7d/30d/90d/All maps cleanly: 7d ≈ 7·24h, 30d ≈ 30·24h, 90d ≈ 90·24h, All ≈ all dose events. Estimated 60-80 LOC. Risk: the level chart's sample step (`SAMPLE_STEP_HOURS = 6` at line 73) may need to scale with window so the AreaChart doesn't paint 1k+ points on the "All" path — pick `stepHours` proportional to window length. + +**Files touched (estimated)**: +- `src/components/dashboard/glp1-tile.tsx` (~50 LOC; range strip + state) +- `src/components/charts/health-chart.tsx` (~10 LOC; thread `windowOverride` through mini path if not already wired) +- `src/components/medications/DrugLevelChart.tsx` (~20 LOC; accept `windowHoursBefore` prop, scale `stepHours`) +- `src/components/dashboard/__tests__/glp1-tile.tsx` (assert range buttons, clicking re-renders chart) + +### Finding 4 — trend charts use different heights + +**Current state**: Chart heights on the dashboard chart row: +- `src/components/charts/health-chart.tsx` line 1048: `chartHeightClass = mini ? "h-[140px]" : "h-[240px]"`. +- `src/components/charts/mood-chart.tsx` line 632: `
`. +- `src/components/charts/medication-compliance-chart.tsx` line 386: `
`. + +So Weight / BP / Pulse / BodyFat / Sleep / Steps charts all render at 240 px. **MoodChart** renders at 280 px — the lone 40-px-taller outlier on the dashboard chart row. + +**Problem**: A single 40-px-taller member of the chart strip breaks vertical rhythm. The user scrolling past five chart cards sees the row swell when it hits the mood card. + +**Proposed approach**: Drop MoodChart's non-mini height to match the rest. Single-line CSS change: `"h-[240px]"` instead of `"h-[280px]"` at `mood-chart.tsx` line 632. The MoodChart's `` (line 634) is `ResponsiveContainer height="100%"` so the inner SVG re-flows automatically. Confirm by visual snapshot or a Playwright dashboard screenshot pre/post. Estimated 1-3 LOC plus a test update if the snapshot test pins the old 280 value. Zero risk. + +Optionally lift `CHART_BODY_HEIGHT_PX = 240` into `src/lib/charts/constants.ts` so all three chart files share a single source of truth and the maintainer can re-tune from one place. Recommended. + +**Files touched (estimated)**: +- `src/components/charts/mood-chart.tsx` (1-3 LOC) +- `src/lib/charts/constants.ts` (new file, ~10 LOC, optional but recommended) +- `src/components/charts/health-chart.tsx` + `medication-compliance-chart.tsx` (~2 LOC each to consume the constant) + +### Finding 5 — "KI-Gesundheitsanalyse" dead leftover + +**Current state**: Hunting the dashboard surface for "KI-Gesundheitsanalyse": +- `src/components/insights/insight-advisor-card.tsx` is the only consumer of the `insights.aiAnalysisTitle` ("KI-Gesundheitsanalyse") key (lines 348, 380, 472, 534). +- `` is mounted on `/insights` (`src/app/insights/page.tsx` line 213), **not** on `/`. +- On the dashboard the only insight-themed card is `` (`src/app/page.tsx` line 1258-1262), which renders the `insights.aiInsights` ("KI-Insights") key (`src/components/insights/insights-card.tsx` line 72). It sits pinned above the chart row, not at the bottom. + +The maintainer's "at bottom of dashboard" framing plus "KI-Gesundheitsanalyse" label is most likely loose wording for `` — it's the only insight-shaped surface on the dashboard, and "bottom-ish" because the chart row scrolls past it. The card self-hides when no recommendations exist (line 55 of `insights-card.tsx`), so when the user has a fresh advisor payload it does render — Marc reads it as a duplicate of the much-richer `/insights` advisor surface and wants it gone. + +**Problem**: The dashboard preview duplicates a surface that already lives a click away on `/insights` (which has the Daily Briefing, hero strip, correlation row, full advisor, weekly report banner). The preview is short and shallow; it competes visually with the Glp1Tile for the same vertical real estate above the charts. + +**Proposed approach**: Remove the dashboard preview entirely. +1. Drop the import + render at `src/app/page.tsx` lines 49, 246, 348, 1258-1262 (the `useInsightsAdvisorQuery` hook + `showInsightsPreview` gate + JSX). +2. Drop the `insightsPreview` widget entry from `src/lib/dashboard-layout.ts` line 229 (or set it default-invisible AND remove the Settings → Dashboard row at `src/components/settings/dashboard-layout-section.tsx` line 50). +3. Delete `src/components/insights/insights-card.tsx` and its test file `src/components/insights/__tests__/insights-card.test.tsx` (the component has no other consumers per grep). +4. Drop the `dashboard.insightsPreview` i18n key (de.json + en.json). +5. Re-survey `useInsightsAdvisorQuery` consumers — if `` is the only dashboard caller, the dashboard stops needing the advisor cache prefetch (it's still loaded by `/insights`). + +If the maintainer actually means the `` on `/insights`, that's out of scope for the dashboard finding bucket and belongs to R1.3. + +Estimated 40-60 LOC removed across 4-5 files. Zero risk — purely additive removal. + +**Files touched (estimated)**: +- `src/app/page.tsx` (-12 LOC) +- `src/lib/dashboard-layout.ts` (-3 LOC, drop the widget entry) +- `src/components/settings/dashboard-layout-section.tsx` (-3 LOC) +- `src/components/insights/insights-card.tsx` (delete file, -119 LOC) +- `src/components/insights/__tests__/insights-card.test.tsx` (delete file) +- `messages/de.json` + `messages/en.json` (-2 keys: `dashboard.insightsPreview`, `insights.aiInsights` if no other consumers) + +### Finding 6 — Daily Briefing duplicates the greeting + +**Current state**: +- `/insights` hero greeting: `src/components/insights/hero-strip.tsx` line 144 — `${greetingBase}, ${userName}` ("Guten Tag, Marc"). The narrative subtitle directly underneath at line 145: `subtitle = briefing?.paragraph ?? t("insights.heroFallbackSubtitle")` — when the briefing payload exists, the hero's subtitle **is the briefing paragraph**. +- The page then mounts `` directly below the hero (`src/app/insights/page.tsx` line 199-205). +- `` renders the same paragraph again at line 235-240 of `daily-briefing.tsx`: `

{stripChartTokens(briefing.paragraph)}

`. + +So the same paragraph paints twice within 200 px of vertical space. + +**Problem**: The hero already carries the paragraph as its subtitle. The Daily Briefing card mounts the same string a second time inside its CardContent before the key-findings list. The user reads the same text twice in a row. + +**Proposed approach**: Strip the paragraph render from `` and keep only the structured key-findings block plus the `updatedAt` meta line. Concrete edit: +- `src/components/insights/daily-briefing.tsx` lines 234-258: delete the `

` block (lines 235-240) so the card opens directly with the "WICHTIGSTE BEFUNDE" eyebrow + the key-findings list. +- The card header (Sparkles + "Daily Briefing" title) and the `updatedAt` footer stay. +- Empty-state branch (lines 270-296) stays — when there's no briefing payload the empty-state still drives the "Generate" CTA. The paragraph-strip applies only to the populated branch. +- Add a regression test asserting `

` no longer renders when a briefing is provided. + +Alternative: keep the paragraph in the card and strip it from the hero subtitle (use `t("insights.heroFallbackSubtitle")` regardless). Less attractive because the maintainer's reading is "the briefing duplicates the hero greeting text", not "the hero duplicates the briefing". + +Estimated 10-15 LOC removed plus one new test. Zero risk. + +**Files touched (estimated)**: +- `src/components/insights/daily-briefing.tsx` (-10 LOC) +- `src/components/insights/__tests__/daily-briefing.test.tsx` (new assertion: paragraph-slot absent in populated render) + +### Finding 7 — weekly report click is dead + +**Current state**: The route DOES exist: +- `src/app/insights/report/[week]/page.tsx` mounts ``. +- `src/components/insights/weekly-report-view.tsx` renders the full report surface (hero, summary, going-well, worth-watching, tips, data-quality, footer). +- The hero strip at `/insights` already wires two routes into the report: + - "Generate weekly report" action button at `src/components/insights/hero-strip.tsx` line 240-248 links to `weeklyReportHref` (the current ISO week). + - The `` (`hero-strip.tsx` line 332-425) renders only when a fresh `weeklyReport` payload arrived and links to `/insights/report/[week]`. + +So the route is implemented. The dead-click is probably a different tile that visually offers "view weekly report" but isn't wired. Best guess: the `` card's footer or the `` on the dashboard — neither of which has a weekly-report click target today. A second possibility: a tile on the dashboard that the maintainer thinks is "weekly report" but is actually `` (which routes to `/achievements`, not weekly report). A third possibility: the maintainer means the dashboard side of the surface and wants a "View weekly report" entry point on `/` itself. + +**Proposed approach**: Two options: + +**Option A — add a dashboard surface that links to the weekly report.** Add a slim banner under the tile strip on `/` that mirrors the `` from the hero: "Dein Wochenreport für KW {N} ist bereit — Öffnen". Gated on the advisor payload carrying a fresh `weeklyReport`. Re-use the same banner component. Estimated 30 LOC plus one wire-up in `src/app/page.tsx`. Acceptable because the dashboard now grows a real entry point and the route already exists. + +**Option B — find and remove the dead affordance.** Audit every "weekly report" / "Wochenreport" string in `src/` and confirm each affordance routes correctly. Take 30 minutes to scan. If something is genuinely dead, delete it. + +Recommendation: do B first (cheap), then if nothing turns up, ask the maintainer to point to the dead element with a screenshot. If they confirm the dashboard side, do A. + +Effort estimate: Option B = 30 minutes + 0-10 LOC. Option A = 1-2 hours + ~30 LOC plus tests. + +**Files touched (estimated)**: +- TBD pending maintainer screenshot. Probably `src/app/page.tsx` (Option A) or one of the components surfacing a "report" string today. + +### Finding 8 — Health Score card should fill more of the hero + +**Current state**: +- `src/components/insights/health-score-card.tsx` line 241: outer wrapper is `"w-full lg:w-[260px] lg:shrink-0"`. On `lg+` the card pins to **260 px** wide. Vertically it carries: label row + score number (`text-4xl`) + progress bar + delta line + 4-row sub-bar list + provenance accordion + disclaimer + "Ask the Coach" button — roughly 320-380 px tall depending on the provenance state. +- `src/components/insights/hero-strip.tsx` lines 172-316: on `lg+` the hero splits into a title block (left, `flex-1`) and the Health Score (right, fixed 260 px). On smaller viewports the score stacks below the title (`flex-col` default, `lg:flex-row` switches at 1024 px). +- The hero band itself is `px-4 py-5 sm:px-6 sm:py-6` plus `rounded-xl` — roughly 24-px padding all around. + +**Problem**: 260 px is roughly 20-25 % of the hero width on a 1280-px viewport. The maintainer reads the score as a small inset, not as a co-equal column of the hero band. They want it to occupy more of the hero, both width AND height. + +**Proposed approach**: Three calibrated changes: + +1. **Width**: bump `lg:w-[260px]` to `lg:w-[360px] xl:w-[400px]`. On 1280-px viewports the score now takes ~28-31 % of the hero; on 1536+ it grows to 36 % and reads as a true second column. Reflow the title block via `min-w-0` on the left flex child (already in place at line 178) so the narrative subtitle wraps gracefully. +2. **Height**: drop the `