feat(coach): mount the drawer at the insights layout with a launch context

Promote the Coach drawer mount from the mother /insights page up to
/insights/layout.tsx so every routed sub-page can reach it. A new
CoachLaunchProvider exposes useCoachLaunch() for the hero strip,
suggested-prompt chips, and the seven sub-pages; the layout renders
the drawer beside the provider so navigation across Insights surfaces
no longer unmounts the conversation.

Each sub-page (blutdruck, gewicht, puls, stimmung, medikamente, bmi,
schlaf) mounts a new CoachLaunchButton that pins to the bottom on
narrow viewports and renders as an inline header action on lg+.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 18:59:55 +02:00
1 parent de0c16338f
commit 246c1def21
13 files changed
+347 -28

No files matched your search

+3
View File
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -139,6 +140,8 @@ export default function InsightsBlutdruckPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+3
View File
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -140,6 +141,8 @@ export default function InsightsBmiPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+3
View File
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
@@ -117,6 +118,8 @@ export default function InsightsGewichtPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+16 -6
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
/**
* v1.4.25 W4 — shared layout for the routed `/insights` sub-pages.
@@ -10,12 +12,20 @@ import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell
* into a sub-page. The actual page body still owns its own data fetches
* — the layout is a thin presentational frame.
*
* Critical design rule (Marc directive 2026-05-11):
* The `<CoachDrawer>` is NOT mounted in this layout. It stays inside
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer (matches the Apple Health convention that AI/
* coach surfaces only live on the overview, never on a metric page).
* v1.4.27 R3d MB4 — the Coach drawer now mounts here (above the routed
* children) inside a `<CoachLaunchProvider>` so navigating into a sub-
* page no longer unmounts the drawer. Every sub-page mounts a
* `<CoachLaunchButton>` that calls `askCoach()` on the same context;
* the mother `/insights/page.tsx` consumes the same hook for its hero
* strip + suggested-prompt chips. Decision F (audit MA3) drove the
* promotion: a mobile user who navigates from `/insights` to
* `/insights/blutdruck` keeps a one-tap path back into the Coach.
*/
export default function InsightsLayout({ children }: { children: ReactNode }) {
return <InsightsLayoutShell>{children}</InsightsLayoutShell>;
return (
<CoachLaunchProvider>
<InsightsLayoutShell>{children}</InsightsLayoutShell>
<LayoutCoachMount />
</CoachLaunchProvider>
);
}
+3
View File
@@ -12,6 +12,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Progress } from "@/components/ui/progress";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
@@ -225,6 +226,8 @@ 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>
);
}
+17 -22
View File
@@ -1,6 +1,5 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Loader2, TrendingUp } from "lucide-react";
@@ -12,10 +11,10 @@ import { EmptyState } from "@/components/ui/empty-state";
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
import { HeroStrip } from "@/components/insights/hero-strip";
import { DailyBriefing } from "@/components/insights/daily-briefing";
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { TrendsRow } from "@/components/insights/trends-row";
import { CorrelationRow } from "@/components/insights/correlation-row";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import type { CorrelationResult } from "@/lib/insights/correlations";
import { toWeekISO } from "@/lib/insights/week-iso";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -96,12 +95,12 @@ export default function InsightsPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
// CoachDrawer state. The hero strip's "Ask the coach" button +
// suggested-prompt chips toggle the drawer here; the drawer ingests
// `coachPrefill` once on open and resets on close so the next open
// starts blank.
const [coachOpen, setCoachOpen] = useState<boolean>(false);
const [coachPrefill, setCoachPrefill] = useState<string | null>(null);
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
// `<CoachLaunchProvider>` so every routed sub-page can reach it.
// The hero strip + suggested-prompt chips call `askCoach(prefill)`
// on the same context, and the drawer itself is mounted next to
// the provider in `src/app/insights/layout.tsx`.
const coachLaunch = useCoachLaunch();
const { data, isLoading } = useQuery({
queryKey: ["insights", "comprehensive"],
@@ -183,14 +182,16 @@ export default function InsightsPage() {
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
userName={heroGreetingName}
onAskCoach={(prefill?: string) => {
setCoachPrefill(prefill ?? null);
setCoachOpen(true);
}}
onPickPrompt={(prompt) => {
setCoachPrefill(prompt);
setCoachOpen(true);
}}
onAskCoach={
coachLaunch
? (prefill?: string) => coachLaunch.askCoach(prefill ?? null)
: undefined
}
onPickPrompt={
coachLaunch
? (prompt) => coachLaunch.askCoach(prompt)
: undefined
}
weeklyReportReady={weeklyReportReady}
weeklyReportHref={currentWeekHref}
healthScore={analytics?.healthScore ?? undefined}
@@ -217,12 +218,6 @@ export default function InsightsPage() {
cachedAt={advisor.payload?.cachedAt ?? null}
legacyPayload={advisor.payload?.legacyPayload ?? false}
/>
<CoachDrawer
open={coachOpen}
onOpenChange={setCoachOpen}
prefill={coachPrefill}
/>
</div>
);
}
+3
View File
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
@@ -164,6 +165,8 @@ export default function InsightsPulsPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+3
View File
@@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -79,6 +80,8 @@ export default function InsightsSchlafPage() {
description={t("insights.sleep.description")}
>
<SleepOverview />
<CoachLaunchButton />
</SubPageShell>
);
}
+3
View File
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -99,6 +100,8 @@ export default function InsightsStimmungPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
@@ -0,0 +1,93 @@
"use client";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
/**
* v1.4.27 R3d MB4 — Coach launch button.
*
* Decision F: every routed Insights sub-page mounts this button so the
* Coach drawer (mounted at the layout root) is always reachable. The
* button renders as a sticky-bottom FAB on `<lg` viewports (so a phone
* user always sees it within thumb range) and as an inline header
* action on `lg+` (where the desktop drawer slides in from the right
* edge and the FAB would overlap the chart legend).
*
* The button is a thin wrapper around `useCoachLaunch()` — it only
* renders when the context provider is mounted (so dropping the button
* onto a non-Insights page is a no-op rather than a crash).
*/
export interface CoachLaunchButtonProps {
/** Optional override for the visual label. Defaults to the shared CTA. */
label?: string;
/** Optional prefill seed for the next Coach turn. */
prefill?: string;
/** Optional className passthrough for inline overrides. */
className?: string;
}
export function CoachLaunchButton({
label,
prefill,
className,
}: CoachLaunchButtonProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
if (!launch) {
// The button only makes sense beneath the provider. Render nothing
// so the sub-page doesn't paint a dead control.
return null;
}
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
return (
<>
{/* Sticky FAB on `<lg` viewports — pinned to the bottom-right so
the user's thumb always finds it. `bottom-20` clears the
mobile bottom navigation; `safe-area-inset-bottom` plus the
existing app shell padding keep it clear of the iOS home bar.
Hidden on `lg+` where the inline button below takes over. */}
<Button
type="button"
size="lg"
data-slot="coach-launch-fab"
onClick={() => launch.askCoach(prefill ?? null)}
aria-label={accessibleLabel}
title={accessibleLabel}
className={cn(
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"lg:hidden",
className,
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
{/* Inline header-style action on `lg+`. Lives in the page body so
it picks up the sub-page layout's spacing instead of fighting
a sticky header overlay. */}
<Button
type="button"
variant="outline"
size="sm"
data-slot="coach-launch-inline"
onClick={() => launch.askCoach(prefill ?? null)}
className={cn(
"hidden h-10 gap-2 self-end lg:inline-flex",
className,
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
</>
);
}
@@ -0,0 +1,26 @@
"use client";
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
/**
* v1.4.27 R3d MB4 — bridge between the layout-level
* `<CoachLaunchProvider>` and the Coach drawer mount.
*
* The layout file is a server component (it lives next to other server
* components and feeds the `metadata` chain), so the actual `useState`
* + `<CoachDrawer>` consumer has to live inside a client island. This
* file is intentionally tiny — its only job is to read the context and
* render the drawer at the layout's mount site.
*/
export function LayoutCoachMount() {
const launch = useCoachLaunch();
if (!launch) return null;
return (
<CoachDrawer
open={launch.open}
onOpenChange={launch.setOpen}
prefill={launch.prefill}
/>
);
}
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
CoachLaunchProvider,
useCoachLaunch,
} from "../coach-launch-context";
/**
* v1.4.27 R3d MB4 — Coach launch context smoke contract.
*
* The project's test convention is `renderToStaticMarkup` (no
* `@testing-library/react` dependency). We can't simulate interaction
* inside SSR, but we can verify:
* 1. The provider mounts and renders its children.
* 2. The hook returns a value-shape (open, prefill, askCoach,
* setOpen) when called beneath the provider.
* 3. The hook returns `null` when called outside the provider —
* consumers degrade gracefully (e.g. the launch button renders
* nothing rather than crashing).
*/
function Probe({ output }: { output: string[] }) {
const launch = useCoachLaunch();
if (!launch) {
output.push("null");
return null;
}
output.push(
JSON.stringify({
hasAskCoach: typeof launch.askCoach,
hasSetOpen: typeof launch.setOpen,
open: launch.open,
prefill: launch.prefill,
}),
);
return <span data-slot="probe-output">{output.join(",")}</span>;
}
describe("CoachLaunchProvider", () => {
it("returns null when consumed outside the provider", () => {
const output: string[] = [];
renderToStaticMarkup(<Probe output={output} />);
expect(output).toEqual(["null"]);
});
it("exposes the full context shape when consumed beneath the provider", () => {
const output: string[] = [];
renderToStaticMarkup(
<CoachLaunchProvider>
<Probe output={output} />
</CoachLaunchProvider>,
);
expect(output).toHaveLength(1);
const parsed = JSON.parse(output[0]);
expect(parsed.hasAskCoach).toBe("function");
expect(parsed.hasSetOpen).toBe("function");
expect(parsed.open).toBe(false);
expect(parsed.prefill).toBeNull();
});
it("renders the children inside the provider mount", () => {
const html = renderToStaticMarkup(
<CoachLaunchProvider>
<div data-slot="child-mount">child</div>
</CoachLaunchProvider>,
);
expect(html).toContain('data-slot="child-mount"');
expect(html).toContain("child");
});
});
+103
View File
@@ -0,0 +1,103 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
/**
* v1.4.27 R3d MB4 — Coach launch context.
*
* Until v1.4.27 the Coach drawer was mounted inline on
* `/insights/page.tsx` body, which meant navigating to a routed
* sub-page (`/insights/blutdruck`, etc.) unmounted the drawer and
* removed every entry point to the Coach. The MA3 audit + Decision F
* promoted the drawer up to `/insights/layout.tsx` so every routed
* Insights surface can launch it.
*
* Shape:
* - `<CoachLaunchProvider>` owns the drawer's open / prefill state
* and renders the children beneath the launch surface.
* - `useCoachLaunch()` returns `{ open, askCoach, setOpen, prefill }`
* so any descendant can open the drawer with an optional prefill
* string (the hero-strip suggested-prompt chips feed prefills;
* the sticky `<CoachLaunchButton>` opens without one).
*
* The provider is intentionally tiny — the actual drawer mount sits
* next to it in the layout, reading the same state via the hook.
*/
export interface CoachLaunchScope {
/**
* Optional metric the user is looking at when they open the Coach.
* Reserved for v1.4.28 so the drawer can pre-narrow the source rail
* to the active metric; ignored in v1.4.27.
*/
metric?: string;
}
interface CoachLaunchValue {
/** Whether the Coach drawer is currently open. */
open: boolean;
/** Current prefill string (or null when the next open should start blank). */
prefill: string | null;
/** Open the drawer with an optional prefill + scope hint. */
askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void;
/** Direct setter for the open state — the drawer consumes this. */
setOpen: (next: boolean) => void;
}
const CoachLaunchContext = createContext<CoachLaunchValue | null>(null);
export interface CoachLaunchProviderProps {
children: ReactNode;
}
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const [open, setOpen] = useState<boolean>(false);
const [prefill, setPrefill] = useState<string | null>(null);
const askCoach = useCallback(
(nextPrefill?: string | null, scope?: CoachLaunchScope) => {
// `scope` is currently reserved for v1.4.28 (metric-narrow on the
// sources rail). Accept the parameter today so the call sites
// don't need to change shape when the rail starts honouring it.
void scope;
setPrefill(nextPrefill ?? null);
setOpen(true);
},
[],
);
const handleSetOpen = useCallback((next: boolean) => {
setOpen(next);
if (!next) {
// Drop the prefill on close so the next open starts blank.
setPrefill(null);
}
}, []);
const value = useMemo<CoachLaunchValue>(
() => ({ open, prefill, askCoach, setOpen: handleSetOpen }),
[open, prefill, askCoach, handleSetOpen],
);
return (
<CoachLaunchContext.Provider value={value}>
{children}
</CoachLaunchContext.Provider>
);
}
/**
* Read the Coach launch context. Returns `null` when called outside a
* `<CoachLaunchProvider>` so consumer components can degrade gracefully
* (e.g. the hero strip's "Ask the coach" action stays disabled until
* the provider mounts).
*/
export function useCoachLaunch(): CoachLaunchValue | null {
return useContext(CoachLaunchContext);
}