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}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
onAskCoach={() => {
setCoachPrefill(null);
onAskCoach={(prefill?: string) => {
// 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);
}}
onPickPrompt={(prompt) => {
@@ -951,6 +955,7 @@ export default function InsightsPage() {
setCoachOpen(true);
}}
weeklyReportReady={weeklyReportReady}
healthScore={analytics?.healthScore ?? undefined}
/>
<DailyBriefing
@@ -308,4 +308,102 @@ describe("<HeroStrip>", () => {
expect(html).toContain("Lesen");
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 { cn } from "@/lib/utils";
import { SuggestedPrompts } from "./suggested-prompts";
import {
HealthScoreCard,
type HealthScoreCardProps,
} from "./health-score-card";
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
* opens the drawer (B2b). When omitted the button stays disabled —
* 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
* 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.
*/
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,
weeklyReportReady,
now,
healthScore,
}: HeroStripProps) {
const { t } = useTranslations();
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",
)}
>
<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 items-center gap-2">
<Sparkles
@@ -225,7 +264,7 @@ export function HeroStrip({
type="button"
variant="outline"
size="sm"
onClick={onAskCoach}
onClick={onAskCoach ? () => onAskCoach() : undefined}
disabled={!onAskCoach}
title={onAskCoach ? undefined : comingSoon}
data-slot="insights-hero-strip-action-coach"
@@ -272,6 +311,21 @@ export function HeroStrip({
onPick={onPickPrompt ?? (() => undefined)}
/>
</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>
);