mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(insights): wire Coach drawer into /insights via hero strip
Closes the loop between the hero strip's "Ask the coach" /
suggested-prompt chips and the Coach drawer:
- `<HeroStrip>` 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.
- `<SuggestedPrompts>`'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) <noreply@anthropic.com>
This commit is contained in:
1 parent
8dac3891b8
commit
e54179997b
4 files changed
+96
-11
No files matched your search
@@ -64,6 +64,30 @@ describe("v1.4.19 A3 — dashboard polish", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("v1.4.20 B2b — /insights wires the Coach drawer", () => {
|
||||||
|
it("imports <CoachDrawer> 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(/<CoachDrawer\b[\s\S]*?\/>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(/<HeroStrip[\s\S]*?onAskCoach=\{/);
|
||||||
|
expect(src).toMatch(/<HeroStrip[\s\S]*?onPickPrompt=\{/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("page owns coachOpen + coachPrefill state", () => {
|
||||||
|
const src = load(INSIGHTS_PATH);
|
||||||
|
expect(src).toContain("setCoachOpen");
|
||||||
|
expect(src).toContain("setCoachPrefill");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("v1.4.19 A3 — /insights polish", () => {
|
describe("v1.4.19 A3 — /insights polish", () => {
|
||||||
it("does NOT mount the legacy advisor-card regenerate handler", () => {
|
it("does NOT mount the legacy advisor-card regenerate handler", () => {
|
||||||
const src = load(INSIGHTS_PATH);
|
const src = load(INSIGHTS_PATH);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
|||||||
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
|
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
|
||||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||||
import { DailyBriefing } from "@/components/insights/daily-briefing";
|
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 { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||||
import { CompareToggle } from "@/components/comparison/compare-toggle";
|
import { CompareToggle } from "@/components/comparison/compare-toggle";
|
||||||
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
|
// 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 { isAuthenticated, user } = useAuth();
|
||||||
const { t, locale } = useTranslations();
|
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<boolean>(false);
|
||||||
|
const [coachPrefill, setCoachPrefill] = useState<string | null>(null);
|
||||||
|
|
||||||
const STRENGTH_LABELS: Record<string, string> = {
|
const STRENGTH_LABELS: Record<string, string> = {
|
||||||
stark: t("insights.strengthStrong"),
|
stark: t("insights.strengthStrong"),
|
||||||
moderat: t("insights.strengthModerate"),
|
moderat: t("insights.strengthModerate"),
|
||||||
@@ -861,6 +869,14 @@ export default function InsightsPage() {
|
|||||||
userName={heroGreetingName}
|
userName={heroGreetingName}
|
||||||
onRegenerate={advisor.regenerate}
|
onRegenerate={advisor.regenerate}
|
||||||
regenerating={advisor.isRegenerating}
|
regenerating={advisor.isRegenerating}
|
||||||
|
onAskCoach={() => {
|
||||||
|
setCoachPrefill(null);
|
||||||
|
setCoachOpen(true);
|
||||||
|
}}
|
||||||
|
onPickPrompt={(prompt) => {
|
||||||
|
setCoachPrefill(prompt);
|
||||||
|
setCoachOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DailyBriefing
|
<DailyBriefing
|
||||||
@@ -1490,6 +1506,17 @@ export default function InsightsPage() {
|
|||||||
loading={isBmiStatusLoading}
|
loading={isBmiStatusLoading}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<CoachDrawer
|
||||||
|
key={coachPrefill ?? "blank"}
|
||||||
|
open={coachOpen}
|
||||||
|
onOpenChange={setCoachOpen}
|
||||||
|
prefill={coachPrefill}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,11 +124,30 @@ describe("<HeroStrip>", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(<HeroStrip briefing={null} now={morningLocal} />);
|
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||||
expect(html).toMatch(
|
const coachTag = html.match(
|
||||||
/data-slot="insights-hero-strip-action-coach"[^>]*disabled[^>]*title="Coming soon"/,
|
/<button[^>]*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(
|
||||||
|
<HeroStrip
|
||||||
|
briefing={null}
|
||||||
|
now={morningLocal}
|
||||||
|
onAskCoach={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const coachTag = html.match(
|
||||||
|
/<button[^>]*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", () => {
|
it("renders the regenerate button when onRegenerate is supplied", () => {
|
||||||
|
|||||||
@@ -53,10 +53,18 @@ interface HeroStripProps {
|
|||||||
/** Disables the regenerate button + flips its icon to a spinner. */
|
/** Disables the regenerate button + flips its icon to a spinner. */
|
||||||
regenerating?: boolean;
|
regenerating?: boolean;
|
||||||
/**
|
/**
|
||||||
* Click handler for the suggested prompts. The Coach drawer (B2)
|
* Click handler for the suggested prompts. The Coach drawer (B2b)
|
||||||
* will own the routing; for B1 the parent passes a no-op.
|
* 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;
|
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.
|
* Now() override for tests so the greeting bucket is deterministic.
|
||||||
* Defaults to `new Date()`. Production callers omit this.
|
* Defaults to `new Date()`. Production callers omit this.
|
||||||
@@ -108,6 +116,7 @@ export function HeroStrip({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
regenerating = false,
|
regenerating = false,
|
||||||
onPickPrompt,
|
onPickPrompt,
|
||||||
|
onAskCoach,
|
||||||
now,
|
now,
|
||||||
}: HeroStripProps) {
|
}: HeroStripProps) {
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
@@ -185,13 +194,18 @@ export function HeroStrip({
|
|||||||
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
|
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
<span>{t("insights.heroActionWeeklyReport")}</span>
|
<span>{t("insights.heroActionWeeklyReport")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/* B2 wires this into the Coach drawer. */}
|
{/* B2b wires this into the Coach drawer. The button is
|
||||||
|
enabled whenever the parent supplies an `onAskCoach`
|
||||||
|
handler; older parents that haven't adopted B2b yet
|
||||||
|
still get the disabled "Coming soon" affordance so the
|
||||||
|
hero doesn't break. */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled
|
onClick={onAskCoach}
|
||||||
title={comingSoon}
|
disabled={!onAskCoach}
|
||||||
|
title={onAskCoach ? undefined : comingSoon}
|
||||||
data-slot="insights-hero-strip-action-coach"
|
data-slot="insights-hero-strip-action-coach"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
@@ -227,9 +241,10 @@ export function HeroStrip({
|
|||||||
className="border-border/50 border-t pt-4"
|
className="border-border/50 border-t pt-4"
|
||||||
>
|
>
|
||||||
{/*
|
{/*
|
||||||
* TODO(B2): wire onPickPrompt into the Coach drawer so a
|
* v1.4.20 phase B2b — chip clicks open the Coach drawer
|
||||||
* chip click pre-fills the input + opens the drawer. For
|
* with the localised prompt pre-filled in the composer.
|
||||||
* B1 the parent passes a no-op.
|
* The parent owns drawer state so the chip strip stays
|
||||||
|
* presentational.
|
||||||
*/}
|
*/}
|
||||||
<SuggestedPrompts
|
<SuggestedPrompts
|
||||||
onPick={onPickPrompt ?? (() => undefined)}
|
onPick={onPickPrompt ?? (() => undefined)}
|
||||||
|
|||||||
Reference in new issue
Block a user