From 3d95ace21878f18ad3c7f1ca46569fae1f3775a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 22:12:06 +0200 Subject: [PATCH] feat(ui): an always-there coach button and a tidied insights chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A round coach button sits fixed bottom-right on every authenticated page, above the bottom nav and safe-area inset, with a small dot when a nudge is unread — replacing the bubble that only existed while a nudge was pending. The per-chart target-adjust link is gone (the page-level control remains), the duplicated customize entry on insights sub-pages collapses onto the tab strip, and the full-width show-all-values foot button becomes an icon beside the target control. The tab strip's scroll container keeps four pixels of paint room so pill borders and focus rings stop clipping at fractional zoom, and the mood tag breakdown bars adopt the muted opacity the other mood charts use. --- e2e/v1427-coach-launch.spec.ts | 51 ++++++---- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/app/insights/blood-pressure/page.tsx | 1 - src/app/insights/layout.tsx | 24 ++--- src/app/insights/weight/page.tsx | 1 - .../health-chart-target-link-removed.test.ts | 46 +++++++++ src/components/charts/health-chart.tsx | 29 +----- .../data-list/selection-action-bar.tsx | 4 + .../__tests__/insights-tab-strip.test.tsx | 19 ++++ .../__tests__/layout-coach-fab.test.tsx | 73 +++++++++++--- .../__tests__/sub-page-shell.test.tsx | 42 ++++++++ .../insights/insights-tab-strip.tsx | 11 ++- src/components/insights/layout-coach-fab.tsx | 52 ++++++---- .../__tests__/mood-tag-bars-muted.test.tsx | 61 ++++++++++++ .../mood/mood-structured-tag-breakdown.tsx | 5 +- .../insights/mood/mood-tag-breakdown.tsx | 5 +- src/components/insights/sub-page-shell.tsx | 98 +++++++++---------- .../__tests__/auth-shell-coach-hoist.test.ts | 21 ++-- src/components/layout/auth-shell.tsx | 19 +++- 24 files changed, 402 insertions(+), 172 deletions(-) create mode 100644 src/components/charts/__tests__/health-chart-target-link-removed.test.ts create mode 100644 src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 7ad75be77..cabecdadb 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -5,20 +5,21 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** * Coach launch surfaces on `/insights/{slug}` sub-pages. * - * Contracts under test (v1.16.1): + * Contracts under test (v1.16.8): * - * 1. The floating bubble (`data-slot="coach-nudge-bubble"`) renders - * ONLY while an unread Coach-initiated nudge exists - * (`GET /api/insights/coach/nudge-status`). By default no bubble - * mounts on any viewport; the everyday entry point is the inline - * launch the sub-page shell mounts in the header - * (`data-slot="coach-launch-icon"`, visible across breakpoints). + * 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent + * launcher on every authenticated page. An unread Coach-initiated + * nudge (`GET /api/insights/coach/nudge-status`) paints an unread + * dot (`data-slot="coach-fab-unread"`) on its corner; with nothing + * unread the FAB renders without the dot. The inline launch the + * sub-page shell mounts in the header + * (`data-slot="coach-launch-icon"`) stays the contextual entry. * * 2. Clicking the inline launch opens the Coach drawer — as a * bottom-sheet (`data-variant="bottom-sheet"`) on `=sm`. * - * 3. With an unread nudge the bubble mounts and clicking it + * 3. With an unread nudge the FAB carries the dot and clicking it * navigates to the full-page chat at `/insights/coach`. * * 4. The drawer's "Conversations" button no longer opens an in-panel @@ -124,7 +125,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { }); for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) { - test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Pixel 5`, async ({ + test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({ page, }, testInfo) => { test.skip( @@ -134,18 +135,20 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" }); - // The header launch icon is the everyday entry point — visible - // on mobile too since the layout bubble is nudge-gated. + // The header launch icon stays the contextual entry point. const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); - // No unread nudge → no floating bubble in the DOM. + // The FAB is a permanent launcher; with no unread nudge it + // renders without the unread dot. + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible({ timeout: 10_000 }); await expect( - page.locator('[data-slot="coach-nudge-bubble"]'), + page.locator('[data-slot="coach-fab-unread"]'), ).toHaveCount(0); }); - test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Desktop Chrome`, async ({ + test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({ page, }, testInfo) => { test.skip( @@ -158,8 +161,10 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible({ timeout: 10_000 }); await expect( - page.locator('[data-slot="coach-nudge-bubble"]'), + page.locator('[data-slot="coach-fab-unread"]'), ).toHaveCount(0); }); } @@ -202,10 +207,10 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await expect(drawer).toHaveAttribute("data-variant", "side-sheet"); }); - test("an unread Coach nudge mounts the floating bubble and clicking it opens the full-page chat", async ({ + test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the full-page chat", async ({ page, }) => { - // The bubble contract is viewport-independent — the test runs on + // The FAB contract is viewport-independent — the test runs on // both projects. Override the default nudge mock with an unread // one (later registration wins). await page.route("**/api/insights/coach/nudge-status*", (route) => @@ -221,9 +226,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); - const bubble = page.locator('[data-slot="coach-nudge-bubble"]'); - await expect(bubble).toBeVisible({ timeout: 10_000 }); - await bubble.click(); + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible({ timeout: 10_000 }); + await expect(fab).toHaveAttribute("data-unread", "true", { + timeout: 10_000, + }); + await expect( + page.locator('[data-slot="coach-fab-unread"]'), + ).toBeVisible(); + await fab.click(); await page.waitForURL("**/insights/coach", { timeout: 10_000 }); await expect( diff --git a/messages/de.json b/messages/de.json index b35371b88..9850095b8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Diagramm wird geladen…", "loadingSlowHint": "Auswertungen werden berechnet — das kann einen Moment dauern.", - "adjustTargets": "Zielwerte anpassen", "weight": "Gewicht", "bloodPressure": "Blutdruck", "pulse": "Puls", @@ -2036,6 +2035,7 @@ "thinking": "Denke nach…", "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", + "fabLabel": "Coach öffnen", "selfContextAdopt": { "offer": "Antwort in deine Selbstauskunft übernehmen?", "confirm": "Übernehmen", diff --git a/messages/en.json b/messages/en.json index af05ebee5..27f63a816 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Loading chart…", "loadingSlowHint": "Computing analytics — this can take a moment.", - "adjustTargets": "Adjust targets", "weight": "Weight", "bloodPressure": "Blood Pressure", "pulse": "Pulse", @@ -2036,6 +2035,7 @@ "thinking": "Thinking…", "composerPlaceholder": "Ask anything about your data…", "nudgeBubbleLabel": "New message from the Coach — open chat", + "fabLabel": "Open the Coach", "selfContextAdopt": { "offer": "Add this answer to your self-context?", "confirm": "Add", diff --git a/messages/es.json b/messages/es.json index 869f354f4..f34d8725c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Cargando la gráfica…", "loadingSlowHint": "Calculando los análisis — puede tardar un momento.", - "adjustTargets": "Ajustar objetivos", "weight": "Peso", "bloodPressure": "Tensión arterial", "pulse": "Pulso", @@ -2036,6 +2035,7 @@ "thinking": "Pensando…", "composerPlaceholder": "Pregúntame sobre tus datos…", "nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat", + "fabLabel": "Abrir el coach", "selfContextAdopt": { "offer": "¿Añadir esta respuesta a tu información personal?", "confirm": "Añadir", diff --git a/messages/fr.json b/messages/fr.json index 8d6eda75c..2bd929dc7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Chargement du graphique…", "loadingSlowHint": "Calcul des analyses — cela peut prendre un instant.", - "adjustTargets": "Ajuster les valeurs cibles", "weight": "Poids", "bloodPressure": "Tension artérielle", "pulse": "Pouls", @@ -2036,6 +2035,7 @@ "thinking": "Réflexion en cours…", "composerPlaceholder": "Pose-moi une question sur tes données…", "nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat", + "fabLabel": "Ouvrir le coach", "selfContextAdopt": { "offer": "Ajouter cette réponse à vos informations personnelles ?", "confirm": "Ajouter", diff --git a/messages/it.json b/messages/it.json index 9162e2ef0..0425b7449 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Caricamento del grafico…", "loadingSlowHint": "Calcolo delle analisi — può richiedere un momento.", - "adjustTargets": "Regola i valori target", "weight": "Peso", "bloodPressure": "Pressione arteriosa", "pulse": "Pulsazioni", @@ -2036,6 +2035,7 @@ "thinking": "Sto pensando…", "composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…", "nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat", + "fabLabel": "Apri il coach", "selfContextAdopt": { "offer": "Aggiungere questa risposta alle tue informazioni personali?", "confirm": "Aggiungi", diff --git a/messages/pl.json b/messages/pl.json index f1bdaca93..60672612c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Ładowanie wykresu…", "loadingSlowHint": "Obliczanie analiz — to może chwilę potrwać.", - "adjustTargets": "Dostosuj wartości docelowe", "weight": "Waga", "bloodPressure": "Ciśnienie tętnicze", "pulse": "Tętno", @@ -2036,6 +2035,7 @@ "thinking": "Myślę…", "composerPlaceholder": "Zapytaj o swoje dane…", "nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat", + "fabLabel": "Otwórz trenera", "selfContextAdopt": { "offer": "Dodać tę odpowiedź do Twoich informacji o sobie?", "confirm": "Dodaj", diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index f5f6d3b9a..7cb330f84 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -160,7 +160,6 @@ export default function InsightsBlutdruckPage() { unit="mmHg" yAxisUnit="mmHg" targetZones={bpTargetZones} - targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} onVisibleStats={onVisibleStats} diff --git a/src/app/insights/layout.tsx b/src/app/insights/layout.tsx index 5d7a83631..e1150ee07 100644 --- a/src/app/insights/layout.tsx +++ b/src/app/insights/layout.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from "react"; import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell"; -import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; /** * v1.4.25 W4 — shared layout for the routed `/insights` sub-pages. @@ -18,25 +17,16 @@ import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; * the mother `/insights/page.tsx` consumes the same hook for its hero * strip + suggested-prompt chips. * - * v1.4.28 R3c — the mobile FAB also lives here via `` - * (carved out of `` so each sub-page mount no - * longer duplicates the FAB into the a11y tree). The inline - * `` pill stays on the per-page action rows where - * copy + position matter. - * * v1.4.34 IW-B — `` + `` now * live on the global `` (`src/components/layout/auth-shell.tsx`) * so every authenticated route (dashboard included) can open the drawer - * from the same context. The FAB stays scoped to `/insights/**` only — - * the floating action would distract from the dashboard hero on - * surfaces where a contextual inline button already covers the same - * affordance. + * from the same context. + * + * v1.16.8 — the floating Coach launcher (`LayoutCoachFab`) moved to + * `` as well: it renders once for every authenticated route + * and hides itself on the Coach page, so this layout no longer mounts + * any Coach surface of its own. */ export default function InsightsLayout({ children }: { children: ReactNode }) { - return ( - <> - {children} - - - ); + return {children}; } diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c1b946e72..b31c56ede 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -106,7 +106,6 @@ export default function InsightsGewichtPage() { colors={["#bd93f9"]} unit="kg" valueBands={weightBands} - targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} onVisibleStats={onVisibleStats} diff --git a/src/components/charts/__tests__/health-chart-target-link-removed.test.ts b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts new file mode 100644 index 000000000..69b884356 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts @@ -0,0 +1,46 @@ +/** + * v1.16.8 — the in-header "adjust targets" link is gone from the chart + * card. The affordance duplicated the page-level target-adjust control + * in the insights sub-page header (``); the chart + * stays a read surface. Pinned at the source level (same convention as + * the auth-shell hoist contract) so the link doesn't quietly return on + * a future header rework. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const HEALTH_CHART_PATH = resolve(__dirname, "..", "health-chart.tsx"); +const WEIGHT_PAGE_PATH = resolve( + __dirname, + "../../../app/insights/weight/page.tsx", +); +const BP_PAGE_PATH = resolve( + __dirname, + "../../../app/insights/blood-pressure/page.tsx", +); +const EN_BUNDLE_PATH = resolve(__dirname, "../../../../messages/en.json"); + +describe("v1.16.8 — chart-card target-adjust link removed", () => { + it("ships no targetSettingsHref prop or header link in the chart", () => { + const source = readFileSync(HEALTH_CHART_PATH, "utf8"); + expect(source).not.toContain("targetSettingsHref"); + expect(source).not.toContain("chart-target-settings-link"); + expect(source).not.toContain("charts.adjustTargets"); + }); + + it("weight and blood-pressure pages no longer pass the link href", () => { + for (const path of [WEIGHT_PAGE_PATH, BP_PAGE_PATH]) { + const source = readFileSync(path, "utf8"); + expect(source).not.toContain("targetSettingsHref"); + } + }); + + it("the orphaned locale key is gone", () => { + const bundle = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8")) as { + charts: Record; + }; + expect(bundle.charts.adjustTargets).toBeUndefined(); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index c8c6be1d7..8814bb555 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -18,8 +18,6 @@ import { ReferenceArea, ReferenceDot, } from "recharts"; -import { SlidersHorizontal } from "lucide-react"; -import Link from "next/link"; import { useState, useMemo, @@ -111,15 +109,6 @@ interface HealthChartProps { textColor?: string; lineOpacity?: number; }>; - /** - * Optional href for a small "adjust targets" link in the chart - * header. Callers whose charts paint a target band / zones (weight, - * blood pressure) pass `/settings/thresholds` so the band's origin — - * and the page that edits personal target ranges — is one click - * away. Omitted (every other mount) renders no link. Ignored in - * `mini` mode, which has no header. - */ - targetSettingsHref?: string; /** * v1.4.16 phase B5c — compact chart mode used by the Oura-style * rationale card. Drops the range tabs, the moving-average / trend @@ -537,7 +526,6 @@ export function HealthChart({ showYAxisUnit = true, valueBands, targetZones, - targetSettingsHref, mini = false, windowOverride, compareBaseline = "none", @@ -1471,19 +1459,10 @@ export function HealthChart({ )} )} - {/* Discoverability link from the painted target band to the - page that edits personal target ranges. Only mounts when - a caller passes `targetSettingsHref` (weight + BP). */} - {targetSettingsHref ? ( - -