fix(insights): move coach-ask to the header and de-emphasise the assessment border

The Coach launch sat at the foot of every category page; move it into the
sub-page header as an icon-only button at heading height, top-right, so
"ask the coach" reads as a header action and the page no longer ends on a
stray button. The shared shell mounts it (gated on the Coach flag plus the
per-user opt-out), so every category page picks it up and the foot
instance is dropped. Also drop the purple left accent on the assessment
("Einschätzung") card — the coloured rule made it read as restless against
the calmer surrounding cards.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 22:50:59 +02:00
1 parent 4935a9b000
commit 6e61713fc6
12 files changed
+68 -53

No files matched your search

+17 -24
View File
@@ -8,13 +8,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
*
* Two contracts under test:
*
* 1. Every routed Insights sub-page mounts a `<CoachLaunchButton>`.
* The component renders BOTH a sticky-bottom FAB
* (`data-slot="coach-launch-fab"`, visible on `<lg`) and an
* inline action (`data-slot="coach-launch-inline"`, visible on
* `lg+`). The DOM carries both nodes; CSS picks the visible one.
* The spec asserts the FAB renders on Pixel-5 and the inline
* action renders on Desktop Chrome.
* 1. Every routed Insights sub-page mounts a Coach launch surface.
* The layout renders a sticky-bottom FAB
* (`data-slot="coach-launch-fab"`, visible on `<lg`); v1.8.6 moved
* the per-page action into the sub-page header as an icon-only
* button (`data-slot="coach-launch-icon"`, mounted by the shell at
* heading height across breakpoints). The spec asserts the FAB
* renders on Pixel-5 and the header icon renders on Desktop Chrome.
*
* 2. Opening the Coach drawer on `<sm` mounts it as a bottom-sheet
* (`data-variant="bottom-sheet"`). On `>=sm` it mounts as a
@@ -121,28 +121,23 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
.locator('[data-slot="coach-launch-fab"]')
.first();
await expect(fab).toBeVisible({ timeout: 10_000 });
// The inline branch is in the DOM but hidden via `hidden lg:inline-flex`.
const inline = page.locator('[data-slot="coach-launch-inline"]').first();
await expect(inline).toBeHidden();
});
test(`/insights/${slug} mounts the Coach launch button on Desktop Chrome (inline branch)`, async ({
test(`/insights/${slug} mounts the header Coach icon on Desktop Chrome`, async ({
page,
}, testInfo) => {
// Desktop Chrome viewport is 1280 — `lg` (1024) is matched so the
// inline branch wins; the FAB is hidden by `lg:hidden`.
// layout FAB is hidden by `lg:hidden`; the header icon the shell
// mounts is the Coach entry on the page itself.
test.skip(
testInfo.project.name !== "chromium-desktop",
"desktop inline branch",
"desktop header-icon branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
const inline = page
.locator('[data-slot="coach-launch-inline"]')
.first();
await expect(inline).toBeVisible({ timeout: 10_000 });
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
const fab = page.locator('[data-slot="coach-launch-fab"]').first();
await expect(fab).toBeHidden();
@@ -168,7 +163,7 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
});
test("clicking the inline action opens the Coach drawer as a side-sheet on Desktop Chrome", async ({
test("clicking the header Coach icon opens the Coach drawer as a side-sheet on Desktop Chrome", async ({
page,
}, testInfo) => {
test.skip(
@@ -178,11 +173,9 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
const inline = page
.locator('[data-slot="coach-launch-inline"]')
.first();
await expect(inline).toBeVisible({ timeout: 10_000 });
await inline.click();
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
await icon.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
+1 -3
View File
@@ -10,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
@@ -101,6 +100,7 @@ export default function InsightsBlutdruckPage() {
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="bloodPressure"
coachLaunch
>
<HealthChartDynamic
chartKey="bp"
@@ -126,8 +126,6 @@ export default function InsightsBlutdruckPage() {
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+1 -2
View File
@@ -110,6 +110,7 @@ export default function InsightsBmiPage() {
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
coachLaunch
>
<HealthChartDynamic
chartKey="bmi"
@@ -135,8 +136,6 @@ export default function InsightsBmiPage() {
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+1 -2
View File
@@ -187,6 +187,7 @@ export default function InsightsMedikamentePage() {
title={t("insights.medicationCompliance")}
description={t("insights.subPage.medikamenteDescription")}
explainerMetric="medications"
coachLaunch
>
<div
className={
@@ -290,8 +291,6 @@ export default function InsightsMedikamentePage() {
without an active GLP-1 medication, so the page collapses
back to the compliance grid for everyone else. */}
<TherapyTimeline />
<CoachLaunchButton />
</SubPageShell>
);
}
+1 -3
View File
@@ -12,7 +12,6 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
@@ -99,6 +98,7 @@ export default function InsightsStimmungPage() {
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
coachLaunch
>
<MoodChart
chartKey="mood"
@@ -120,8 +120,6 @@ export default function InsightsStimmungPage() {
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+1 -3
View File
@@ -10,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
@@ -129,6 +128,7 @@ export default function InsightsPulsPage() {
timeZone={user?.timezone ?? undefined}
/>
}
coachLaunch
showAllValuesType="PULSE"
>
<HealthChartDynamic
@@ -166,8 +166,6 @@ export default function InsightsPulsPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+1 -3
View File
@@ -6,7 +6,6 @@ import { Moon } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
@@ -62,6 +61,7 @@ export default function InsightsSchlafPage() {
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
explainerMetric="sleep"
coachLaunch
>
<SleepOverview />
@@ -78,8 +78,6 @@ export default function InsightsSchlafPage() {
structural slot waits for v1.5 where the route + hook key will
wire in.
*/}
<CoachLaunchButton />
</SubPageShell>
);
}
+1 -3
View File
@@ -10,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
@@ -86,6 +85,7 @@ export default function InsightsGewichtPage() {
timeZone={user?.timezone ?? undefined}
/>
}
coachLaunch
showAllValuesType="WEIGHT"
>
<HealthChartDynamic
@@ -111,8 +111,6 @@ export default function InsightsGewichtPage() {
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+2 -5
View File
@@ -4,7 +4,6 @@ import { Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts } from "@/hooks/use-workouts";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { WorkoutList } from "@/components/insights/workout-list";
@@ -36,6 +35,7 @@ export default function InsightsWorkoutsPage() {
title={t("insights.workouts.title")}
description={t("insights.workouts.description")}
explainerMetric="workouts"
coachLaunch
>
{isLoading ? (
<div data-slot="workouts-loading" className="space-y-2">
@@ -52,10 +52,7 @@ export default function InsightsWorkoutsPage() {
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
/>
) : data ? (
<>
<WorkoutList workouts={data.workouts} />
<CoachLaunchButton />
</>
<WorkoutList workouts={data.workouts} />
) : null}
</SubPageShell>
);
@@ -33,12 +33,24 @@ export interface CoachLaunchButtonProps {
prefill?: string;
/** Optional className passthrough for inline overrides. */
className?: string;
/**
* v1.8.6 — render the launch as an icon-only affordance instead of the
* labelled pill. The sub-page shell mounts this variant at heading
* height in the page header so "Coach fragen" sits top-right, aligned
* with the title, rather than at the foot of the page. The accessible
* label still resolves the shared CTA copy via `aria-label`. The icon
* variant stays visible across breakpoints — it is the header's own
* Coach entry, not the `lg+`-only inline pill the old foot placement
* used (the mobile FAB covers `<lg` for that legacy surface).
*/
variant?: "inline" | "icon";
}
export function CoachLaunchButton({
label,
prefill,
className,
variant = "inline",
}: CoachLaunchButtonProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
@@ -59,6 +71,26 @@ export function CoachLaunchButton({
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
if (variant === "icon") {
// v1.8.6 — heading-height icon button mounted by the sub-page shell.
// Icon-only so it reads as a compact action beside the title; the
// CTA copy moves to `aria-label` + a native tooltip.
return (
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-launch-icon"
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() => launch.askCoach(prefill ?? null)}
className={cn("text-muted-foreground hover:text-foreground", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
</Button>
);
}
// Inline header-style action on `lg+`. Hidden below `lg` because the
// layout-level FAB (`<LayoutCoachFab>`) covers that breakpoint —
// mounting both at the same time would put two affordances in front
@@ -10,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context";
import type { InsightMetric } from "@/lib/insights/metric-availability";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import { Button } from "@/components/ui/button";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
@@ -33,8 +32,9 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
* pass `null` to render an onboarding hint without a dead link.
* 3. `<HealthChartDynamic>` mounted on the canonical
* `ChartOverlayKey` so the chart-cog popover persists per metric.
* 4. `<CoachLaunchButton>` at the foot — feature-flag gating runs
* inside the button so this scaffold stays agnostic.
* 4. A header-height Coach launch icon (`coachLaunch` on the shell) —
* feature-flag gating runs inside the button so this scaffold stays
* agnostic.
*
* Adding a new metric is a four-line page module that hands the right
* Zod / MeasurementType + ChartOverlayKey + i18n-key prefix to this
@@ -181,6 +181,7 @@ export function HealthKitMetricPage({
timeZone={user?.timezone ?? undefined}
/>
}
coachLaunch
showAllValuesType={measurementType}
>
<HealthChartDynamic
@@ -198,7 +199,6 @@ export function HealthKitMetricPage({
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
<CoachLaunchButton />
</SubPageShell>
);
}
@@ -163,7 +163,12 @@ export function InsightStatusCard({
// shell padding to `py-4 md:py-5` so the assessment block reads tight
// in the denser sub-page rhythm. The same override lands on every
// status variant below so the gap is consistent across states.
className="animate-insight-in border-l-dracula-purple gap-2 border-l-2 py-4 md:gap-3 md:py-5"
//
// v1.8.6 — drop the purple left accent (`border-l-dracula-purple
// border-l-2`). The coloured rule made the assessment card read as
// restless against the calmer surrounding cards; the plain card
// border carries enough separation on its own.
className="animate-insight-in gap-2 py-4 md:gap-3 md:py-5"
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">