fix(insights): strip the duplicate briefing paragraph and rebalance the Health Score column

The /insights hero strip already renders briefing.paragraph as its
subtitle directly above the Daily Briefing card; the card then mounted
the same paragraph a second time inside its CardContent before the
key-findings list. The user read the same string twice within 200 px.

Drop the leading paragraph render from <DailyBriefing>; the card now
opens directly on the structured key-findings list (the part the hero
subtitle cannot surface). The empty-state branch keeps its 'Generate
briefing' CTA so the populated → empty flow remains intact.

Rebalance the Health Score card so it reads as a co-equal column of
the hero rather than a small inset:
  - lg:w-[260px] → lg:w-[360px] xl:w-[400px]
  - score number text-4xl → text-5xl sm:text-6xl
  - progress bar h-1.5 → h-2
  - inline Ask-the-Coach button retires; the hero strip's existing
    action covers it. onAskCoach stays on the prop signature so callers
    don't break.

Fold in BL-P4-9 L2: disclaimer text-[10px] → text-[11px] (the 10 px
size was borderline against the 12 px mobile floor flagged in the
W10 design review).

Tests updated: daily-briefing test asserts the paragraph slot is
absent in the populated branch; health-score-card tests pin the new
no-button render across English and German locales.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 16:48:03 +02:00
1 parent 25952d7d05
commit 5b8a47b4ff
4 files changed
+60 -54

No files matched your search

@@ -63,10 +63,15 @@ describe("<DailyBriefing>", () => {
expect(html).toContain("Tagesbriefing");
});
it("renders the narrative paragraph", () => {
it("v1.4.27 B1 — does NOT render the leading narrative paragraph", () => {
// The hero strip subtitle on /insights renders the same
// `briefing.paragraph` directly above this card, so the card now
// opens straight on the key-findings list. The paragraph slot is
// gone from the populated branch; the empty-state branch still
// owns its CTA copy.
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toMatch(/data-slot="daily-briefing-paragraph"/);
expect(html).toContain(
expect(html).not.toContain('data-slot="daily-briefing-paragraph"');
expect(html).not.toContain(
"Blood pressure is in target band on 9 of 10 readings",
);
});
@@ -133,13 +138,16 @@ describe("<DailyBriefing>", () => {
});
it("hides the keyFindings section when the array is empty", () => {
// v1.4.27 B1 — the leading paragraph dropped; the card now renders
// only the header + (when present) the findings list. With both
// gone the populated branch produces no body content, which the
// hero strip already covers.
const html = render(
<DailyBriefing briefing={{ ...baseBriefing, keyFindings: [] }} />,
);
expect(html).not.toContain('data-slot="daily-briefing-findings-title"');
expect(html).not.toContain('data-slot="daily-briefing-findings"');
// Paragraph still renders.
expect(html).toContain("trending well this week");
expect(html).not.toContain('data-slot="daily-briefing-paragraph"');
});
it("renders the empty state when briefing is null and not loading", () => {
@@ -179,8 +179,12 @@ describe("<HealthScoreCard>", () => {
expect(html).toContain("Indicative");
});
it("does NOT render the Ask-the-Coach button when onAskCoach is omitted", () => {
const html = ssr(
it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => {
// The hero strip's existing "Ask the coach" action covers this
// surface; the card-internal duplicate button retired. The
// onAskCoach prop stays on the type signature so callers don't
// break, but the card no longer renders it.
const omitted = ssr(
<HealthScoreCard
score={86}
band="green"
@@ -188,11 +192,7 @@ describe("<HealthScoreCard>", () => {
delta={null}
/>,
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("renders the Ask-the-Coach button when onAskCoach is supplied", () => {
const html = ssr(
const supplied = ssr(
<HealthScoreCard
score={86}
band="green"
@@ -201,8 +201,8 @@ describe("<HealthScoreCard>", () => {
onAskCoach={() => {}}
/>,
);
expect(html).toMatch(/data-slot="health-score-card-ask-coach"/);
expect(html).toContain("Ask the Coach");
expect(omitted).not.toContain('data-slot="health-score-card-ask-coach"');
expect(supplied).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("captures the score-aware prefill argument when the button is invoked", () => {
@@ -244,9 +244,11 @@ describe("<HealthScoreCard>", () => {
"de",
);
expect(html).toContain("Gesundheitsscore");
expect(html).toContain("Coach fragen");
expect(html).toContain("im Vergleich zur Vorwoche");
expect(html).toContain("Orientierungshilfe");
// v1.4.27 B1 — the inline Coach button retired; hero strip carries
// the action now.
expect(html).not.toContain("Coach fragen");
});
it("uses tabular-nums on the headline number for stable layout", () => {
+7 -6
View File
@@ -232,12 +232,13 @@ export function DailyBriefing({
</>
) : briefing ? (
<div className="space-y-4">
<p
data-slot="daily-briefing-paragraph"
className="text-foreground text-sm leading-relaxed"
>
{stripChartTokens(briefing.paragraph)}
</p>
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
The hero strip subtitle on `/insights` already renders
the same `briefing.paragraph` text directly above this
card, so the user used to read the same string twice
within 200 px. The card now opens straight on the
structured key-findings list, which is the part the
hero subtitle cannot surface. */}
{briefing.keyFindings.length > 0 && (
<div className="space-y-2">
<p
+28 -33
View File
@@ -1,8 +1,7 @@
"use client";
import { useId, useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ChevronDown, Minus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ArrowDown, ArrowUp, ChevronDown, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -149,7 +148,12 @@ export function HealthScoreCard({
band,
components,
delta,
onAskCoach,
// v1.4.27 B1 — onAskCoach stays on the prop signature so the hero
// strip's existing wire-up doesn't break, but the card no longer
// mounts an inline button (the hero strip already carries the same
// action). Destructure-and-ignore is intentional.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onAskCoach: _onAskCoach,
initiallyExpanded = false,
}: HealthScoreCardProps) {
const { t, locale } = useTranslations();
@@ -228,17 +232,13 @@ export function HealthScoreCard({
className={cn(
"bg-card/65 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
BAND_BORDER_CLASS[band],
// v1.4.25 W3 — Marc reported the German "Einnahmetreue" label
// overlapping the band-coloured component-value pill at the
// right of each row. The original `w-[220px]` left only ~64px
// for the label column after the bar + value chip ate the
// rest, which fits "Mood" / "BP" but truncates the longer
// German strings. Bumped to `w-[260px]` so the card sits
// ~18 % wider on `lg+` without disturbing the title block's
// visual centre of gravity; the label column is widened in
// step with this so the breathing room actually lands on the
// text.
"w-full lg:w-[260px] lg:shrink-0",
// v1.4.27 B1 — bumped from `lg:w-[260px]` to `lg:w-[360px]
// xl:w-[400px]` so the score occupies ~28-31 % of the hero on
// 1280-px viewports and ~36 % on 1536+, reading as a true
// co-equal column rather than a small inset. The narrower
// earlier value was a reaction to a label-overflow bug that
// is now fixed by the wider label column below.
"w-full lg:w-[360px] lg:shrink-0 xl:w-[400px]",
)}
>
<div className="flex flex-col gap-3">
@@ -277,7 +277,11 @@ export function HealthScoreCard({
<span
data-slot="health-score-card-number"
className={cn(
"text-4xl leading-none font-semibold tabular-nums",
// v1.4.27 B1 — bumped from text-4xl to text-5xl
// (sm:text-6xl on wider viewports) so the headline
// number becomes the visual centre of gravity of the
// expanded card.
"text-5xl leading-none font-semibold tabular-nums sm:text-6xl",
BAND_NUMBER_CLASS[band],
)}
>
@@ -293,7 +297,7 @@ export function HealthScoreCard({
<div
data-slot="health-score-card-progress"
className="bg-muted/50 h-1.5 w-full overflow-hidden rounded-full"
className="bg-muted/50 h-2 w-full overflow-hidden rounded-full"
role="progressbar"
aria-valuenow={score}
aria-valuemin={0}
@@ -533,28 +537,19 @@ export function HealthScoreCard({
)}
</div>
{/* v1.4.27 B1 — disclaimer bumped from text-[10px] to
text-[11px] (BL-P4-9 L2: the 10 px size was borderline
against the 12 px mobile floor). The hero strip already
carries an "Ask the coach" action so the inline button
here retires; the parent still receives onAskCoach via
other surfaces but the card no longer mounts a duplicate
CTA. */}
<p
data-slot="health-score-card-disclaimer"
className="text-muted-foreground text-[10px] leading-snug"
className="text-muted-foreground text-[11px] leading-snug"
>
{t("insights.healthScore.disclaimer")}
</p>
{onAskCoach && (
<Button
type="button"
size="sm"
variant="outline"
data-slot="health-score-card-ask-coach"
className="w-full gap-1.5"
onClick={() =>
onAskCoach(t("insights.healthScore.coachPrompt", { score }))
}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.healthScore.askCoach")}</span>
</Button>
)}
</div>
</div>
);