feat(ui): an always-there coach button and a tidied insights chrome

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 22:12:06 +02:00
1 parent 657f2323a3
commit 3d95ace218
24 files changed
+402 -172

No files matched your search

+31 -20
View File
@@ -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`, as a
* right-side sheet (`data-variant="side-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(
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
-1
View File
@@ -160,7 +160,6 @@ export default function InsightsBlutdruckPage() {
unit="mmHg"
yAxisUnit="mmHg"
targetZones={bpTargetZones}
targetSettingsHref="/settings/thresholds"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
onVisibleStats={onVisibleStats}
+7 -17
View File
@@ -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 `<LayoutCoachFab>`
* (carved out of `<CoachLaunchButton>` so each sub-page mount no
* longer duplicates the FAB into the a11y tree). The inline
* `<CoachLaunchButton>` pill stays on the per-page action rows where
* copy + position matter.
*
* v1.4.34 IW-B — `<CoachLaunchProvider>` + `<LayoutCoachMount>` now
* live on the global `<AuthShell>` (`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
* `<AuthShell>` 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 (
<>
<InsightsLayoutShell>{children}</InsightsLayoutShell>
<LayoutCoachFab />
</>
);
return <InsightsLayoutShell>{children}</InsightsLayoutShell>;
}
-1
View File
@@ -106,7 +106,6 @@ export default function InsightsGewichtPage() {
colors={["#bd93f9"]}
unit="kg"
valueBands={weightBands}
targetSettingsHref="/settings/thresholds"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
onVisibleStats={onVisibleStats}
@@ -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 (`<TargetAdjustButton>`); 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<string, unknown>;
};
expect(bundle.charts.adjustTargets).toBeUndefined();
});
});
+4 -25
View File
@@ -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({
)}
</span>
)}
{/* 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 ? (
<Link
href={targetSettingsHref}
data-slot="chart-target-settings-link"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center gap-1 text-xs underline underline-offset-2 transition-colors sm:min-h-9"
>
<SlidersHorizontal className="h-3 w-3" aria-hidden="true" />
{t("charts.adjustTargets")}
</Link>
) : null}
{/* v1.16.8 — the in-header "adjust targets" link is gone.
The page-level target-adjust control in the sub-page
header (`<TargetAdjustButton>`) owns the affordance; the
chart card stays a read surface. */}
</div>
<div
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
@@ -53,6 +53,10 @@ export function SelectionActionBar({
<div
role="region"
aria-label={t("dataList.selectionBarLabel")}
// v1.16.8 — the Coach FAB watches for this slot via a CSS
// `:has()` gate and yields while the bar is mounted, so the
// floating launcher never sits over the delete action.
data-slot="selection-action-bar"
// v1.15.13 HIGH-1 — on mobile the fixed bottom-nav (64px, `md:hidden`,
// z-50) plus the iOS home-indicator inset sit over the page's lower
// edge. A `sticky bottom-2` bar lands BEHIND the nav, hiding the
@@ -388,3 +388,22 @@ describe("<InsightsTabStrip> — saved-layout visibility gate (v1.15.14 W2)", ()
expect(html).toContain(">Environment<");
});
});
describe("<InsightsTabStrip> — pill row clip box (v1.16.8)", () => {
it("keeps vertical paint room inside the horizontal scroller", () => {
// `overflow-x-auto` clips vertically too (overflow-y computes to
// auto), so a pill border or focus ring sitting exactly on the
// content-box edge lost its bottom pixel at fractional zoom levels.
// The scroller reserves 4 px of inner padding (`py-1`) and hands the
// height back with `-my-1` — pinned here so a class cleanup doesn't
// resurrect the clipped borders.
const html = render(<InsightsTabStrip />);
const scroller = html.match(
/<div[^>]*data-slot="insights-tab-strip-scroller"[^>]*>/,
)?.[0];
expect(scroller).toBeTruthy();
expect(scroller).toContain("overflow-x-auto");
expect(scroller).toContain("py-1");
expect(scroller).toContain("-my-1");
});
});
@@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// SSR test render has no Next.js app-router context; stub the two
// navigation hooks the bubble uses.
// navigation hooks the FAB uses. The pathname is mutable so the
// hide-on-coach-page branch can be exercised.
let mockPathname = "/insights";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
usePathname: () => mockPathname,
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -15,16 +17,16 @@ import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { isNudgeUnread, LayoutCoachFab } from "../layout-coach-fab";
/**
* v1.16.1 — the layout FAB is nudge-driven now: it renders ONLY while
* an unseen proactive `COACH_NUDGE` exists (per
* `/api/insights/coach/nudge-status` + the device-local seen stamp).
* No permanent launcher remains — the inline pill is the everyday
* entry point.
* v1.16.8 — the FAB is a permanent launcher on every authenticated
* page (mounted once in `<AuthShell>`). The v1.16.1 nudge-only bubble
* folded into it: an unseen proactive `COACH_NUDGE` paints a small
* unread dot on the button's corner instead of toggling the whole
* button, and the FAB hides itself on the Coach page.
*
* Project convention is SSR-only tests (no `@testing-library/react`).
* On first paint the nudge-status query has no data, so the bubble
* must render nothing; the unread derivation is pinned through the
* exported pure helper instead.
* On first paint the nudge-status query has no data, so the dot must
* be absent while the launcher itself renders; the unread derivation
* is pinned through the exported pure helper.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -38,20 +40,59 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
describe("<LayoutCoachFab>", () => {
it("returns nothing when no <CoachLaunchProvider> is mounted", () => {
mockPathname = "/insights";
const html = render(<LayoutCoachFab />);
expect(html).not.toContain('data-slot="coach-nudge-bubble"');
expect(html).not.toContain('data-slot="coach-fab"');
});
it("renders nothing on first paint (no nudge-status data yet)", () => {
// The bubble only exists for an unseen nudge; before the status
// query resolves there is nothing to show — no permanent FAB.
it("renders the always-on launcher under the provider (no nudge needed)", () => {
mockPathname = "/";
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).not.toContain('data-slot="coach-nudge-bubble"');
expect(html).not.toContain('data-slot="coach-launch-fab"');
expect(html).toContain('data-slot="coach-fab"');
// Accessible name carries the launcher label, not the nudge copy.
expect(html).toContain('aria-label="Open the Coach"');
// Fixed bottom-right, above the mobile bottom-nav band.
expect(html).toContain("fixed right-4");
expect(html).toContain(
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
);
});
it("paints no unread dot on first paint (no nudge-status data yet)", () => {
mockPathname = "/";
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).not.toContain('data-slot="coach-fab-unread"');
expect(html).not.toContain('data-unread="true"');
});
it("hides itself on the Coach page", () => {
mockPathname = "/insights/coach";
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).not.toContain('data-slot="coach-fab"');
});
it("yields to the data-list selection bar via the :has() gate", () => {
mockPathname = "/measurements";
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
// The selection bar's delete action lands in the same lower-right
// band; the FAB carries a CSS gate keyed off the bar's data-slot.
expect(html).toContain("selection-action-bar");
});
});
@@ -109,6 +109,48 @@ describe("<SubPageShell>", () => {
expect(backIdx).toBeLessThan(headerIdx);
});
it("renders the show-all-values entry as an icon button inside the header cluster (v1.16.8)", () => {
const html = render(
<SubPageShell title="Weight" showAllValuesType="WEIGHT">
<span />
</SubPageShell>,
);
const idx = html.indexOf('data-slot="metric-show-all-values"');
expect(idx).toBeGreaterThan(-1);
// Lives in the header action cluster, not at the page foot.
expect(idx).toBeLessThan(html.indexOf("</header>"));
// Icon-only control: the label travels via aria-label + title.
expect(html).toContain('aria-label="Show all readings"');
expect(html).toContain('title="Show all readings"');
expect(html).toContain("/insights/values/WEIGHT");
// Exactly one entry — the old foot-of-page button must not return.
expect(html.indexOf('data-slot="metric-show-all-values"')).toBe(
html.lastIndexOf('data-slot="metric-show-all-values"'),
);
expect(html).not.toContain("w-full sm:w-auto");
});
it("omits the show-all-values control without a type", () => {
const html = render(
<SubPageShell title="BMI">
<span />
</SubPageShell>,
);
expect(html).not.toContain('data-slot="metric-show-all-values"');
});
it("no longer renders the duplicate customise cog in the header (v1.16.8)", () => {
const html = render(
<SubPageShell title="Weight" showAllValuesType="WEIGHT">
<span />
</SubPageShell>,
);
// The sticky tab strip above the page body owns the single
// "customise insights" entry point; the header copy was removed.
expect(html).not.toContain('data-slot="insights-subpage-customize"');
expect(html).not.toContain("/settings/insights");
});
it("mounts no coach launch surface when coachLaunch is omitted", () => {
const html = render(
<SubPageShell title="Pulse">
+10 -1
View File
@@ -626,7 +626,16 @@ function InsightsTabStripImpl({
<div
ref={scrollerRef}
onScroll={updateScrollAffordance}
className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden"
data-slot="insights-tab-strip-scroller"
// `overflow-x-auto` clips vertically too (overflow-y computes
// to auto, and the row never scrolls vertically), so a pill
// border sitting exactly on the content-box edge lost its
// bottom 1 px at fractional zoom levels — and the 2 px focus
// ring (+2 px offset) was cut on every side. `py-1` keeps
// 4 px of paint room inside the clip box; the matching `-my-1`
// gives that room back to the layout so the strip height is
// unchanged.
className="-my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto py-1 [&::-webkit-scrollbar]:hidden"
>
{tabs.map((tab) => {
if (tab.kind === "link") {
+35 -17
View File
@@ -17,21 +17,23 @@ import { useChartTooltipActive } from "./use-chart-tooltip-active";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.16.1 — the layout FAB is no longer a permanent launcher. It now
* renders ONLY while an unseen Coach-initiated nudge exists (the
* proactive `COACH_NUDGE` dispatch, surfaced by
* `/api/insights/coach/nudge-status`): a floating circle at the bottom
* right that opens the full-page Coach chat. It disappears once the
* nudge counts as read — the user sent a Coach message after the
* v1.16.8 — the Coach FAB is a permanent launcher again, now on EVERY
* authenticated page (mounted once in `<AuthShell>`, no longer scoped
* to `/insights/**`). The v1.16.1 nudge-only bubble folded into it: the
* FAB always renders, and an unseen proactive `COACH_NUDGE` (surfaced
* by `/api/insights/coach/nudge-status`) paints a small unread dot on
* its corner instead of toggling the whole button. The dot clears once
* the nudge counts as read — the user sent a Coach message after the
* nudge (server-derived), or opened the Coach on this device (local
* seen stamp keyed by the nudge timestamp).
*
* The previous always-on mobile FAB drew attention to nothing; the
* inline "Ask Coach" pill in each page's action row stays the everyday
* entry point.
* The FAB hides inside the Coach view itself (`/insights/coach`) — a
* launcher pointing at the page the user is already on is noise.
*
* v1.4.33 (F15) — the bubble keeps the chart-tooltip auto-hide so it
* never overlays a Recharts tooltip on the lower right.
* v1.4.33 (F15) — keeps the chart-tooltip auto-hide so it never
* overlays a Recharts tooltip on the lower right; it also yields while
* a data-list selection bar is mounted (CSS `:has()` gate) so it never
* covers the bar's delete action.
*/
const NUDGE_SEEN_STORAGE_KEY = "healthlog-coach-nudge-seen";
@@ -83,7 +85,7 @@ export function LayoutCoachFab() {
queryFn: async (): Promise<CoachNudgeStatus> => {
return apiGet<CoachNudgeStatus>("/api/insights/coach/nudge-status");
},
enabled: coachAvailable,
enabled: coachAvailable && !onCoachPage,
staleTime: 5 * 60 * 1000,
});
@@ -91,21 +93,24 @@ export function LayoutCoachFab() {
const unread = isNudgeUnread(status, seenStamp);
// Visiting the Coach page itself counts as reading the nudge on this
// device — persist the stamp so the bubble stays gone after leaving.
// device — persist the stamp so the dot stays gone after leaving.
useEffect(() => {
if (!onCoachPage || nudgedAt === null) return;
try {
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
} catch {
// Storage unavailable (private mode) — the server-side read
// signal still clears the bubble once the user sends a message.
// signal still clears the dot once the user sends a message.
}
}, [onCoachPage, nudgedAt]);
if (!coachAvailable) return null;
if (onCoachPage || !unread) return null;
// The Coach chat page IS the destination — no launcher on top of it.
if (onCoachPage) return null;
const accessibleLabel = t("insights.coach.nudgeBubbleLabel");
const accessibleLabel = unread
? t("insights.coach.nudgeBubbleLabel")
: t("insights.coach.fabLabel");
const handleOpen = () => {
if (nudgedAt !== null) {
@@ -123,7 +128,8 @@ export function LayoutCoachFab() {
<Button
type="button"
size="icon"
data-slot="coach-nudge-bubble"
data-slot="coach-fab"
data-unread={unread ? "true" : undefined}
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
onClick={handleOpen}
aria-label={accessibleLabel}
@@ -139,10 +145,22 @@ export function LayoutCoachFab() {
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
// Fade out while a Recharts tooltip is open (see header note).
"transition-opacity duration-150 motion-reduce:transition-none",
// Yield to the data-list selection bar: its delete action lands
// in the same lower-right band, and the destructive control
// wins. The `:has()` gate keys off the bar's `data-slot`.
"[body:has([data-slot=selection-action-bar])_&]:pointer-events-none",
"[body:has([data-slot=selection-action-bar])_&]:opacity-0",
tooltipActive && "pointer-events-none opacity-0",
)}
>
<Sparkles className="size-5" aria-hidden="true" />
{unread ? (
<span
data-slot="coach-fab-unread"
aria-hidden="true"
className="border-background bg-dracula-red absolute top-0.5 right-0.5 size-3 rounded-full border-2"
/>
) : null}
</Button>
);
}
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { MoodTagBreakdown } from "../mood-tag-breakdown";
import { MoodStructuredTagBreakdown } from "../mood-structured-tag-breakdown";
/**
* v1.16.8 — the tag-breakdown bars carry the same muted treatment as
* the mood Recharts charts (`fillOpacity={0.55}` on distribution /
* weekday / time-of-day). The CSS bars rendered the raw `--dracula-*`
* hues at full opacity, which made the two tag cards shout next to the
* matte charts above them. Pinned: every fill bar ships `opacity-55`.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("mood tag bars — muted palette (v1.16.8)", () => {
it("free-text tag breakdown bars render at the shared chart opacity", () => {
const html = render(
<MoodTagBreakdown
tags={[
{ tag: "stress", count: 6, avgScore: 1.8 },
{ tag: "sport", count: 4, avgScore: 4.2 },
]}
/>,
);
const fills = html.match(/class="[^"]*absolute inset-y-0 left-0[^"]*"/g);
expect(fills).toHaveLength(2);
for (const fill of fills ?? []) {
expect(fill).toContain("opacity-55");
}
// The level hue itself stays a Dracula token.
expect(html).toContain("var(--dracula-red)");
expect(html).toContain("var(--dracula-green)");
});
it("structured tag breakdown bars render at the shared chart opacity", () => {
const html = render(
<MoodStructuredTagBreakdown
tags={[
{
key: "social.friends",
categoryKey: "social",
labelKey: "mood.tags.friends",
icon: null,
count: 3,
avgScore: 3.9,
},
]}
/>,
);
const fills = html.match(/class="[^"]*absolute inset-y-0 left-0[^"]*"/g);
expect(fills).toHaveLength(1);
expect(fills?.[0]).toContain("opacity-55");
});
});
@@ -50,8 +50,11 @@ export function MoodStructuredTagBreakdown({
{t(row.labelKey)}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
the mood Recharts bars carry (distribution / weekday /
time-of-day), so the tag bars read equally matte. */}
<div
className="absolute inset-y-0 left-0 rounded-full"
className="absolute inset-y-0 left-0 rounded-full opacity-55"
style={{
width: `${(row.count / maxCount) * 100}%`,
backgroundColor: colorForScore(row.avgScore),
@@ -38,8 +38,11 @@ export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
{row.tag}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
the mood Recharts bars carry (distribution / weekday /
time-of-day), so the tag bars read equally matte. */}
<div
className="absolute inset-y-0 left-0 rounded-full"
className="absolute inset-y-0 left-0 rounded-full opacity-55"
style={{
width: `${(row.count / maxCount) * 100}%`,
backgroundColor: colorForScore(row.avgScore),
+44 -54
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ListOrdered, Settings2 } from "lucide-react";
import { ListOrdered } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -112,10 +112,13 @@ export interface SubPageShellProps {
*/
coachLaunch?: boolean;
/**
* v1.8.5 W4b — a "show all readings" entry rendered at the foot of the
* page, after the chart + cards. Sub-pages pass the metric's
* `MeasurementType`; the shell renders a button that links to the
* dedicated `/insights/values/<type>` subpage.
* v1.8.5 W4b — a "show all readings" entry linking to the dedicated
* `/insights/values/<type>` subpage. Sub-pages pass the metric's
* `MeasurementType`.
*
* v1.16.8 — renders as an icon button in the header action cluster
* (left of the target-adjust gear), no longer as a full-width button
* at the page foot.
*/
showAllValuesType?: string;
children: ReactNode;
@@ -225,34 +228,41 @@ export function SubPageShell({
opt-out — so the cluster collapses cleanly to one, both, or
neither control. */}
<div className="flex shrink-0 items-center gap-0.5">
{/* v1.16.8 — "show all readings" rides the header cluster
as an icon button, LEFT of the target-adjust gear (it
used to be a full-width outline button at the page foot).
Same 40 px box + extended hit area as its siblings; the
label travels via aria-label + title. */}
{showAllValuesType ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="metric-show-all-values"
className={cn(
// Match the cluster's 40 px icon box + extended hit
// area (see `<TargetAdjustButton>` for the WCAG
// 2.5.5 note).
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Link
href={`/insights/values/${showAllValuesType}${
pathname ? `?from=${encodeURIComponent(pathname)}` : ""
}`}
aria-label={t("insights.subPage.showAllValues")}
title={t("insights.subPage.showAllValues")}
>
<ListOrdered className="size-4" aria-hidden="true" />
</Link>
</Button>
) : null}
<TargetAdjustButton />
{coachLaunch ? <CoachLaunchButton variant="icon" /> : null}
{/* v1.16.4 — customise cog on every routed sub-page,
mirroring the mother page's tab-strip cog: same Settings2
glyph, same `/settings/insights` destination (overview
arrange + pill sort). Rightmost in the cluster so it sits
where the mother page's cog sits — far top-right — and
clearly apart from the self-gating target-adjust gear. */}
<Button
asChild
variant="ghost"
size="icon"
data-slot="insights-subpage-customize"
className={cn(
// Match the cluster's 40 px icon box + extended hit area
// (see `<TargetAdjustButton>` for the WCAG 2.5.5 note).
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Link
href="/settings/insights"
aria-label={t("insights.customize")}
title={t("insights.customize")}
>
<Settings2 className="size-4" aria-hidden="true" />
</Link>
</Button>
{/* v1.16.8 — the v1.16.4 customise cog is gone: the sticky
tab strip above already carries the same control on every
insights surface, so the header copy was a duplicate. */}
</div>
</div>
{explainerMetric ? (
@@ -298,29 +308,9 @@ export function SubPageShell({
(above) → chart → target card → assessment. The page renders
chart → target → assessment as `children`. */}
{children}
{/* v1.8.5 W4b — "show all readings" entry at the foot, linking to
the dedicated per-metric values subpage.
v1.8.6 — normalised to the `h-10` secondary-button height so it
reads as a consistent control now that the foot-of-page Coach
button (which set the old visual baseline) has moved to the
header. */}
{showAllValuesType ? (
<Button
asChild
variant="outline"
data-slot="metric-show-all-values"
className="h-10 w-full sm:w-auto"
>
<Link
href={`/insights/values/${showAllValuesType}${
pathname ? `?from=${encodeURIComponent(pathname)}` : ""
}`}
>
<ListOrdered className="size-4" aria-hidden="true" />
{t("insights.subPage.showAllValues")}
</Link>
</Button>
) : null}
{/* v1.16.8 — the foot-of-page "show all readings" button moved
into the header cluster as an icon button (left of the
target-adjust gear), so the page body ends on its content. */}
</div>
</TargetAdjustProvider>
);
@@ -46,17 +46,24 @@ describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => {
expect(source).toContain("<LayoutCoachMount />");
});
it("no longer mounts the provider in the routed insights layout", () => {
it("mounts the LayoutCoachFab inside the auth-shell so the launcher exists on every authenticated route (v1.16.8)", () => {
const source = readFileSync(AUTH_SHELL_PATH, "utf8");
expect(source).toContain("<LayoutCoachFab />");
});
it("no longer mounts any Coach surface in the routed insights layout", () => {
const source = readFileSync(INSIGHTS_LAYOUT_PATH, "utf8");
// The provider import drops; the FAB import stays because the FAB
// is still scoped to `/insights/**` only.
// The provider import drops; the drawer mount lives on the shell.
expect(source).not.toContain(
'from "@/lib/insights/coach-launch-context"',
);
// The drawer mount lives on the shell now — verify the routed
// layout doesn't double-mount it.
expect(source).not.toContain("import { LayoutCoachMount }");
// FAB stays scoped to the routed insights surface.
expect(source).toContain("LayoutCoachFab");
// v1.16.8 — the FAB moved to the shell too; the routed layout must
// not double-mount it. (The docblock may still narrate the move, so
// pin the import + JSX, not the bare name.)
expect(source).not.toContain(
'from "@/components/insights/layout-coach-fab"',
);
expect(source).not.toContain("<LayoutCoachFab");
});
});
+14 -5
View File
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier";
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -194,10 +195,12 @@ export function AuthShell({
// call `askCoach()` from the same context. Pre-hoist the drawer was
// only reachable from `/insights/**`; the dashboard hero CTA now opens
// it without a route hop. `<LayoutCoachMount>` consumes the same
// context to render the drawer once at the shell level — the
// insights-only mobile FAB stays where it is, inside the routed
// insights layout, so the bottom-right floating action sits beside
// the chart tooltips only on the surfaces that need it.
// context to render the drawer once at the shell level.
//
// v1.16.8 — `<LayoutCoachFab>` joins the shell too: the floating
// Coach launcher renders once here for every authenticated route
// (it hides itself on `/insights/coach` and carries the unread-nudge
// dot). The routed insights layout no longer mounts it.
return (
<CoachLaunchProvider>
{showUnlockNotifier && user?.id ? (
@@ -255,8 +258,13 @@ export function AuthShell({
settings/admin shells used `max-w-screen-xl`, producing a
52 px lateral wobble on every route switch. Same audit
note in `.planning/round-v1433-audit-polish.md` §4.2.
v1.16.8 — the bottom padding clears the always-on Coach
FAB (48 px button + its offset above the bottom-nav band /
the desktop bottom-6 anchor), so the last line of content
can always scroll out from under the floating button.
*/}
<div className="mx-auto max-w-screen-xl px-4 py-6 md:px-6">
<div className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6">
{children}
</div>
</main>
@@ -264,6 +272,7 @@ export function AuthShell({
<BottomNav />
</div>
<LayoutCoachMount />
<LayoutCoachFab />
</CoachLaunchProvider>
);
}