mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
067bf312c3
commit
91ad5ec115
3 files changed
+162
-5
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user