feat(insights): mount Health Score panel in hero strip

The hero strip's right side, empty by design since B1, now paints the
<HealthScoreCard> when `analytics?.healthScore` is non-null. Layout
flips from `flex-col` to `flex-row` on `lg+` so the panel sits beside
the title block on desktop and stacks below on mobile.

The `onAskCoach` prop signature picks up an optional `prefill`
argument so the same handler powers both surfaces — the action-row
button passes nothing (drawer opens blank), the score panel passes
"Why is my health score X out of 100?". One drawer instance, one
state pair, one consistent UX.

Tests: 5 new hero-strip cases pin the score panel slot, the lg-row
layout switch, and the conditional ask-coach button mounting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 17:42:53 +02:00
1 parent 067bf312c3
commit 91ad5ec115
3 files changed
+162 -5

No files matched your search

+7 -2
View File
@@ -942,8 +942,12 @@ export default function InsightsPage() {
userName={heroGreetingName} userName={heroGreetingName}
onRegenerate={advisor.regenerate} onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating} regenerating={advisor.isRegenerating}
onAskCoach={() => { onAskCoach={(prefill?: string) => {
setCoachPrefill(null); // The action-row button passes no prefill (drawer opens blank);
// the Health Score panel passes a score-aware question. Both
// share the same drawer state so the user only ever sees one
// drawer instance.
setCoachPrefill(prefill ?? null);
setCoachOpen(true); setCoachOpen(true);
}} }}
onPickPrompt={(prompt) => { onPickPrompt={(prompt) => {
@@ -951,6 +955,7 @@ export default function InsightsPage() {
setCoachOpen(true); setCoachOpen(true);
}} }}
weeklyReportReady={weeklyReportReady} weeklyReportReady={weeklyReportReady}
healthScore={analytics?.healthScore ?? undefined}
/> />
<DailyBriefing <DailyBriefing
@@ -308,4 +308,102 @@ describe("<HeroStrip>", () => {
expect(html).toContain("Lesen"); expect(html).toContain("Lesen");
expect(html).toContain("Als PDF"); expect(html).toContain("Als PDF");
}); });
// ── B5 — Health Score panel ────────────────────────────────────────
it("does NOT render the Health Score panel when healthScore is omitted", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain('data-slot="health-score-card"');
});
it("renders the Health Score panel when the score is supplied", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: 5,
}}
/>,
);
expect(html).toMatch(/data-slot="health-score-card"/);
expect(html).toMatch(/data-band="green"/);
expect(html).toContain(">86<");
});
it("forwards onAskCoach into the Health Score panel only when both are supplied", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: 5,
}}
onAskCoach={() => {}}
/>,
);
// HSC panel renders its own Ask-the-Coach button with the score-
// aware label (the action-row button uses the shorter "Ask the
// coach" copy). The HSC button mirrors the panel's slot.
expect(html).toMatch(/data-slot="health-score-card-ask-coach"/);
});
it("hides the Health Score panel's Ask button when no onAskCoach is supplied", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: 5,
}}
/>,
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("uses the lg row layout when healthScore is supplied (smoke test on container class)", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: null,
}}
/>,
);
// The wrapper picks up the `lg:flex-row` modifier when the score
// is present so the panel sits beside the title block on desktop.
expect(html).toContain("lg:flex-row");
});
}); });
+57 -3
View File
@@ -14,6 +14,10 @@ import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context"; import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SuggestedPrompts } from "./suggested-prompts"; import { SuggestedPrompts } from "./suggested-prompts";
import {
HealthScoreCard,
type HealthScoreCardProps,
} from "./health-score-card";
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
/** /**
@@ -67,8 +71,14 @@ interface HeroStripProps {
* the button is enabled and the coming-soon tooltip drops; clicking * the button is enabled and the coming-soon tooltip drops; clicking
* opens the drawer (B2b). When omitted the button stays disabled — * opens the drawer (B2b). When omitted the button stays disabled —
* see the v1.4.20 phase B1 dispatch where the drawer was deferred. * see the v1.4.20 phase B1 dispatch where the drawer was deferred.
*
* v1.4.20 phase B5 — the same handler powers the Health Score
* panel's "Ask the Coach" button, with an optional `prefill` string
* that opens the drawer with a score-aware question. The action-row
* button calls it without an argument (drawer opens blank); the HSC
* panel calls it with "Why is my health score X out of 100?".
*/ */
onAskCoach?: () => void; onAskCoach?: (prefill?: string) => void;
/** /**
* v1.4.20 phase B4 — when the cached AI payload carries a fresh * v1.4.20 phase B4 — when the cached AI payload carries a fresh
* weeklyReport block, the parent passes this so the hero paints a * weeklyReport block, the parent passes this so the hero paints a
@@ -84,6 +94,20 @@ interface HeroStripProps {
* Defaults to `new Date()`. Production callers omit this. * Defaults to `new Date()`. Production callers omit this.
*/ */
now?: Date; now?: Date;
/**
* v1.4.20 phase B5 — Personal Health Score panel data. When supplied
* the right side of the hero band paints the score card. The
* `onAskCoach` handler is intentionally re-used from the action-row
* "Ask the coach" button — same drawer state, different prefill
* string. When the parent omits the field the right side stays empty
* (current B1 behaviour).
*/
healthScore?: {
score: HealthScoreCardProps["score"];
band: HealthScoreCardProps["band"];
components: HealthScoreCardProps["components"];
delta: HealthScoreCardProps["delta"];
} | null;
} }
/** /**
@@ -133,6 +157,7 @@ export function HeroStrip({
onAskCoach, onAskCoach,
weeklyReportReady, weeklyReportReady,
now, now,
healthScore,
}: HeroStripProps) { }: HeroStripProps) {
const { t } = useTranslations(); const { t } = useTranslations();
const greetingKey = resolveGreetingKey(now ?? new Date()); const greetingKey = resolveGreetingKey(now ?? new Date());
@@ -152,7 +177,21 @@ export function HeroStrip({
"relative overflow-hidden rounded-xl px-4 py-5 sm:px-6 sm:py-6", "relative overflow-hidden rounded-xl px-4 py-5 sm:px-6 sm:py-6",
)} )}
> >
<div className="flex flex-col gap-5"> {/*
* v1.4.20 phase B5 — split layout. On `lg+` the title block sits
* left, the Health Score panel sits right. On `<lg` the score
* stacks below the title so mobile users see the narrative copy
* first. When `healthScore` is null/undefined the right column
* collapses and the title block uses the full width — same shape
* as B1–B4.
*/}
<div
className={cn(
"flex flex-col gap-5",
healthScore && "lg:flex-row lg:items-start lg:gap-6",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles <Sparkles
@@ -225,7 +264,7 @@ export function HeroStrip({
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={onAskCoach} onClick={onAskCoach ? () => onAskCoach() : undefined}
disabled={!onAskCoach} disabled={!onAskCoach}
title={onAskCoach ? undefined : comingSoon} title={onAskCoach ? undefined : comingSoon}
data-slot="insights-hero-strip-action-coach" data-slot="insights-hero-strip-action-coach"
@@ -272,6 +311,21 @@ export function HeroStrip({
onPick={onPickPrompt ?? (() => undefined)} onPick={onPickPrompt ?? (() => undefined)}
/> />
</div> </div>
</div>
{healthScore && (
<HealthScoreCard
score={healthScore.score}
band={healthScore.band}
components={healthScore.components}
delta={healthScore.delta}
onAskCoach={
onAskCoach
? (prefill: string) => onAskCoach(prefill)
: undefined
}
/>
)}
</div> </div>
</div> </div>
); );