mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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}
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user