From e54179997b90f460a73795efd04b2a35add76fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 16:32:14 +0200 Subject: [PATCH] feat(insights): wire Coach drawer into /insights via hero strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop between the hero strip's "Ask the coach" / suggested-prompt chips and the Coach drawer: - `` gains an `onAskCoach` prop. When supplied, the "Ask the coach" button drops its disabled state + Coming-soon tooltip and clicking calls the handler. Older parents that haven't adopted the drawer still get the disabled affordance — no breaking change. - ``'s `onPick` already routed up via `onPickPrompt`; the page wires it to a setter that pre-fills the drawer's input AND opens it in the same tick. - `/insights` owns `coachOpen` + `coachPrefill` state. The drawer mounts with `key={prefill ?? "blank"}` so the lazy `useState` initialiser fires fresh on every chip transition, sidestepping the `react-hooks/set-state-in-effect` rule. Tests: - Updates the hero-strip ratchet to allow either disabled (no handler) or enabled (handler supplied) states. - Adds three guards under `insights-polish.test.ts` that pin the page-side wiring (CoachDrawer import, hero handler hook-up, state ownership). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/__tests__/insights-polish.test.ts | 24 ++++++++++++++ src/app/insights/page.tsx | 27 ++++++++++++++++ .../insights/__tests__/hero-strip.test.tsx | 25 +++++++++++++-- src/components/insights/hero-strip.tsx | 31 ++++++++++++++----- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index a6dc177d8..2aac0daeb 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -64,6 +64,30 @@ describe("v1.4.19 A3 — dashboard polish", () => { }); }); +describe("v1.4.20 B2b — /insights wires the Coach drawer", () => { + it("imports and mounts it at the bottom of the page", () => { + const src = load(INSIGHTS_PATH); + expect(src).toContain( + 'from "@/components/insights/coach-panel/coach-drawer"', + ); + expect(src).toMatch(//); + }); + + it("hero strip's onAskCoach handler opens the drawer with no prefill", () => { + const src = load(INSIGHTS_PATH); + // The hero supplies onAskCoach which sets coachPrefill to null and + // flips coachOpen. + expect(src).toMatch(/ { + const src = load(INSIGHTS_PATH); + expect(src).toContain("setCoachOpen"); + expect(src).toContain("setCoachPrefill"); + }); +}); + describe("v1.4.19 A3 — /insights polish", () => { it("does NOT mount the legacy advisor-card regenerate handler", () => { const src = load(INSIGHTS_PATH); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 8e5f73817..1feb81d4c 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -43,6 +43,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card"; 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 { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { CompareToggle } from "@/components/comparison/compare-toggle"; // Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter @@ -489,6 +490,13 @@ export default function InsightsPage() { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); + // v1.4.20 phase B2b — Coach drawer 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(false); + const [coachPrefill, setCoachPrefill] = useState(null); + const STRENGTH_LABELS: Record = { stark: t("insights.strengthStrong"), moderat: t("insights.strengthModerate"), @@ -861,6 +869,14 @@ export default function InsightsPage() { userName={heroGreetingName} onRegenerate={advisor.regenerate} regenerating={advisor.isRegenerating} + onAskCoach={() => { + setCoachPrefill(null); + setCoachOpen(true); + }} + onPickPrompt={(prompt) => { + setCoachPrefill(prompt); + setCoachOpen(true); + }} /> + + {/* v1.4.20 phase B2b — AI Coach drawer. The drawer reads its + initial input value from `prefill`; we change the React `key` + on every prefill transition so the lazy `useState` initialiser + fires fresh and the composer surfaces the latest chip. */} + ); } diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index cb356c29c..d3d44e179 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -124,11 +124,30 @@ describe("", () => { ); }); - it("renders the ask-the-coach action button as disabled with a 'Coming soon' title", () => { + it("renders the ask-the-coach action button as disabled with a 'Coming soon' title when no handler is supplied", () => { const html = render(); - expect(html).toMatch( - /data-slot="insights-hero-strip-action-coach"[^>]*disabled[^>]*title="Coming soon"/, + const coachTag = html.match( + /]*data-slot="insights-hero-strip-action-coach"[^>]*>/, ); + expect(coachTag).not.toBeNull(); + expect(coachTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/); + expect(coachTag?.[0]).toContain('title="Coming soon"'); + }); + + it("enables the ask-the-coach button when onAskCoach is supplied (B2b)", () => { + const html = render( + {}} + />, + ); + const coachTag = html.match( + /]*data-slot="insights-hero-strip-action-coach"[^>]*>/, + ); + expect(coachTag).not.toBeNull(); + expect(coachTag?.[0]).not.toMatch(/\sdisabled(=""|\s|>)/); + expect(coachTag?.[0]).not.toContain('title="Coming soon"'); }); it("renders the regenerate button when onRegenerate is supplied", () => { diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 4a9a3f129..1d11e31c6 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -53,10 +53,18 @@ interface HeroStripProps { /** Disables the regenerate button + flips its icon to a spinner. */ regenerating?: boolean; /** - * Click handler for the suggested prompts. The Coach drawer (B2) - * will own the routing; for B1 the parent passes a no-op. + * Click handler for the suggested prompts. The Coach drawer (B2b) + * routes a chip click into the drawer's input; the parent owns the + * drawer state. When omitted the chip click is a no-op. */ onPickPrompt?: (prompt: string) => void; + /** + * Click handler for the "Ask the coach" action button. When supplied, + * the button is enabled and the coming-soon tooltip drops; clicking + * opens the drawer (B2b). When omitted the button stays disabled — + * see the v1.4.20 phase B1 dispatch where the drawer was deferred. + */ + onAskCoach?: () => void; /** * Now() override for tests so the greeting bucket is deterministic. * Defaults to `new Date()`. Production callers omit this. @@ -108,6 +116,7 @@ export function HeroStrip({ onRegenerate, regenerating = false, onPickPrompt, + onAskCoach, now, }: HeroStripProps) { const { t } = useTranslations(); @@ -185,13 +194,18 @@ export function HeroStrip({