feat(insights): add <DailyBriefing> card component

New `src/components/insights/daily-briefing.tsx` renders the
full-width briefing card from the v1.4.20 redesign — narrative
paragraph + 0-5 key-finding rows below it, each row carries a
tone-coloured left bar (green/orange/cyan), metric icon, headline,
detail line, and an optional delta badge in the matching tone.

States covered:
  - loaded: paragraph + findings + optional 'Generated <time>' meta
  - empty (briefing=null + !loading): EmptyState with 'Generate
    briefing' CTA wired through onRegenerate
  - loading: animate-pulse skeleton matching the rest of the app's
    loading affordance
  - empty findings: paragraph still renders; the findings block
    hides cleanly so the card doesn't paint a void section header

Optional metaSlot in the card header so the comparison toggle can
migrate from the hero to the briefing card in commit 5.

EN + DE translation keys added (insights.dailyBriefing.*). Test
suite: +18 specs covering rendering + states + locale + tone bars.

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 15:38:52 +02:00
1 parent 9713bf1606
commit 92f332efe6
2 files changed
+476

No files matched your search

@@ -0,0 +1,203 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { DailyBriefing } from "../daily-briefing";
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
/**
* v1.4.20 phase B1 — full-width Daily Briefing card.
*
* The card sits below the hero strip on `/insights` and renders the
* narrative paragraph + 0-5 key-finding rows produced by the AI
* pipeline. Tests cover: paragraph rendering, finding rows + tones +
* deltas, locale-aware title, empty state with CTA, loading skeleton,
* meta slot.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const baseBriefing: DailyBriefingPayload = {
paragraph:
"You're trending well this week. Blood pressure is in target band on 9 of 10 readings, weight is down 2.5 kg over the last 30 days, and your medication compliance streak is now 21 days.",
keyFindings: [
{
tone: "good",
headline: "Blood pressure entered target",
detail: "9 of last 10 readings under 130/85.",
delta: "↓ 4 mmHg",
sourceWindow: "30d",
sourceMetric: "bp",
},
{
tone: "watch",
headline: "Monday-morning systolic spike",
detail: "+6 mmHg vs other weekdays.",
delta: "+6 mmHg",
sourceWindow: "30d",
sourceMetric: "bp",
},
{
tone: "info",
headline: "Weight down 30 d",
detail: "Linear, sustainable rate. BMI now 26.0.",
delta: null,
sourceWindow: "30d",
sourceMetric: "weight",
},
],
};
describe("<DailyBriefing>", () => {
it("renders the title in English", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toMatch(/data-slot="daily-briefing"/);
expect(html).toContain("Daily Briefing");
});
it("renders the title in German", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />, "de");
expect(html).toContain("Tagesbriefing");
});
it("renders the narrative paragraph", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toMatch(/data-slot="daily-briefing-paragraph"/);
expect(html).toContain(
"Blood pressure is in target band on 9 of 10 readings",
);
});
it("renders one row per key finding", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
const matches = html.match(/data-slot="daily-briefing-finding"/g) ?? [];
expect(matches.length).toBe(3);
expect(html).toContain("Blood pressure entered target");
expect(html).toContain("Monday-morning systolic spike");
expect(html).toContain("Weight down 30 d");
});
it("renders the delta string when supplied", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toMatch(/data-slot="daily-briefing-delta"/);
expect(html).toContain("↓ 4 mmHg");
expect(html).toContain("+6 mmHg");
});
it("does NOT render a delta badge when delta is null", () => {
const html = render(
<DailyBriefing
briefing={{
...baseBriefing,
keyFindings: [
{
...baseBriefing.keyFindings[2],
},
],
}}
/>,
);
// Only one finding rendered, and it has delta=null.
const deltas = html.match(/data-slot="daily-briefing-delta"/g) ?? [];
expect(deltas.length).toBe(0);
});
it("uses tone-specific colour classes per finding", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toContain("bg-dracula-green"); // tone=good bar
expect(html).toContain("bg-dracula-orange"); // tone=watch bar
expect(html).toContain("bg-dracula-cyan"); // tone=info bar
});
it("renders the 'Generated <time>' caption when updatedAt is supplied", () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
const html = render(
<DailyBriefing briefing={baseBriefing} updatedAt={fiveMinutesAgo} />,
);
expect(html).toMatch(/data-slot="daily-briefing-updated"/);
expect(html).toContain("Generated");
});
it("does NOT render the updated caption when updatedAt is missing", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).not.toContain('data-slot="daily-briefing-updated"');
});
it("renders the keyFindings title separator", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toMatch(/data-slot="daily-briefing-findings-title"/);
expect(html).toContain("Key findings");
});
it("hides the keyFindings section when the array is empty", () => {
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");
});
it("renders the empty state when briefing is null and not loading", () => {
const html = render(<DailyBriefing briefing={null} />);
expect(html).toMatch(/data-slot="daily-briefing-empty"/);
expect(html).toContain("No briefing yet");
});
it("renders the empty-state CTA when onRegenerate is supplied", () => {
const html = render(
<DailyBriefing briefing={null} onRegenerate={() => {}} />,
);
expect(html).toMatch(/data-slot="daily-briefing-empty-cta"/);
expect(html).toContain("Generate briefing");
});
it("hides the empty-state CTA when onRegenerate is not supplied", () => {
const html = render(<DailyBriefing briefing={null} />);
expect(html).not.toContain('data-slot="daily-briefing-empty-cta"');
});
it("disables the empty-state CTA while regenerating", () => {
const html = render(
<DailyBriefing
briefing={null}
onRegenerate={() => {}}
regenerating
/>,
);
expect(html).toMatch(
/data-slot="daily-briefing-empty-cta"[^>]*disabled/,
);
expect(html).toContain("Regenerating");
});
it("renders the shimmer skeleton when loading", () => {
const html = render(<DailyBriefing briefing={null} loading />);
expect(html).toMatch(/data-slot="daily-briefing-skeleton"/);
expect(html).toContain("animate-pulse");
// Empty state must NOT render while loading.
expect(html).not.toContain('data-slot="daily-briefing-empty"');
});
it("renders the meta slot when supplied", () => {
const html = render(
<DailyBriefing
briefing={baseBriefing}
metaSlot={<span data-testid="meta-token">meta-content</span>}
/>,
);
expect(html).toMatch(/data-slot="daily-briefing-meta-slot"/);
expect(html).toContain("meta-content");
});
it("does NOT render the meta-slot wrapper when metaSlot is missing", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).not.toContain('data-slot="daily-briefing-meta-slot"');
});
});
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { Activity, FileText, Heart, Pill, Scale, Smile, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type {
DailyBriefing as DailyBriefingPayload,
DailyBriefingKeyFinding,
} from "@/lib/ai/schema";
/**
* v1.4.20 phase B1 — full-width Daily Briefing card.
*
* Renders the narrative paragraph + 0-5 key-finding rows synthesised
* by the AI insight pipeline. Lives directly below the hero strip on
* `/insights`. Keeps the existing per-section advisor + status cards
* untouched — this card is additive, not a replacement.
*
* Loading state: shimmer skeleton via Tailwind's `animate-pulse` so
* it matches the rest of the app.
*
* Empty state: when `briefing === null && !loading`, renders an
* <EmptyState> with a "Generate briefing" CTA wired through
* `onRegenerate`. The CTA hides cleanly when no handler is supplied.
*/
interface DailyBriefingProps {
/** Briefing payload — null when the cache has none. */
briefing: DailyBriefingPayload | null;
/** ISO timestamp of the briefing's generation, surfaced as a meta line. */
updatedAt?: string | null;
/** Loading state — shimmer skeleton replaces content. */
loading?: boolean;
/** Optional CTA wiring for the empty-state generate button. */
onRegenerate?: () => void;
/** Disables the regenerate CTA while a generation is in flight. */
regenerating?: boolean;
/**
* Optional slot for a meta control mounted in the card header — the
* comparison toggle migrates here from the hero in commit 5.
*/
metaSlot?: React.ReactNode;
}
const METRIC_ICON: Record<DailyBriefingKeyFinding["sourceMetric"], React.ComponentType<{ className?: string }>> = {
bp: Heart,
weight: Scale,
pulse: Activity,
mood: Smile,
compliance: Pill,
};
const TONE_BAR_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
good: "bg-dracula-green",
watch: "bg-dracula-orange",
info: "bg-dracula-cyan",
};
const TONE_TEXT_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
good: "text-dracula-green",
watch: "text-dracula-orange",
info: "text-dracula-cyan",
};
function DeltaBadge({
delta,
tone,
}: {
delta: string | null;
tone: DailyBriefingKeyFinding["tone"];
}) {
if (!delta) return null;
return (
<span
data-slot="daily-briefing-delta"
className={cn("text-xs font-semibold tabular-nums", TONE_TEXT_CLASSNAME[tone])}
>
{delta}
</span>
);
}
function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
const Icon = METRIC_ICON[finding.sourceMetric];
return (
<div
data-slot="daily-briefing-finding"
className="border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3"
>
<span
aria-hidden="true"
className={cn(
"absolute top-3 bottom-3 left-0 w-[3px] rounded-r",
TONE_BAR_CLASSNAME[finding.tone],
)}
/>
<Icon
className={cn("mt-0.5 h-4 w-4 shrink-0", TONE_TEXT_CLASSNAME[finding.tone])}
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-3">
<p className="text-sm leading-snug font-medium">{finding.headline}</p>
<DeltaBadge delta={finding.delta} tone={finding.tone} />
</div>
<p className="text-muted-foreground text-xs leading-snug">{finding.detail}</p>
</div>
</div>
);
}
function BriefingSkeleton() {
return (
<div
data-slot="daily-briefing-skeleton"
className="space-y-4 motion-reduce:animate-none"
aria-hidden="true"
>
<div className="space-y-2">
<div className="bg-muted/60 h-3 w-11/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-10/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-9/12 animate-pulse rounded" />
<div className="bg-muted/60 h-3 w-8/12 animate-pulse rounded" />
</div>
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className="border-border/40 bg-card/30 flex h-16 items-center rounded-md border p-3"
>
<div className="bg-muted/60 h-3 w-1/3 animate-pulse rounded" />
</div>
))}
</div>
</div>
);
}
export function DailyBriefing({
briefing,
updatedAt,
loading = false,
onRegenerate,
regenerating = false,
metaSlot,
}: DailyBriefingProps) {
const { t } = useTranslations();
return (
<Card data-slot="daily-briefing" className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-4 w-4 shrink-0"
aria-hidden="true"
/>
<CardTitle className="text-base font-semibold">
{t("insights.dailyBriefing.title")}
</CardTitle>
</div>
{metaSlot && (
<div data-slot="daily-briefing-meta-slot" className="flex items-center gap-2">
{metaSlot}
</div>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<>
<span className="sr-only" aria-live="polite">
{t("insights.dailyBriefing.loadingLabel")}
</span>
<BriefingSkeleton />
</>
) : briefing ? (
<div className="space-y-4">
<p
data-slot="daily-briefing-paragraph"
className="text-foreground text-sm leading-relaxed"
>
{briefing.paragraph}
</p>
{briefing.keyFindings.length > 0 && (
<div className="space-y-2">
<p
data-slot="daily-briefing-findings-title"
className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase"
>
{t("insights.dailyBriefing.keyFindingsTitle")}
</p>
<div
data-slot="daily-briefing-findings"
className="space-y-2"
>
{briefing.keyFindings.map((finding, index) => (
<KeyFindingRow
key={`${finding.sourceMetric}-${index}`}
finding={finding}
/>
))}
</div>
</div>
)}
{updatedAt && (
<p
data-slot="daily-briefing-updated"
className="text-muted-foreground border-border/60 border-t pt-3 text-xs"
>
{t("insights.heroGenerated", {
time: formatRelativeTime(updatedAt, t),
})}
</p>
)}
</div>
) : (
<EmptyState
data-slot="daily-briefing-empty"
variant="plain"
icon={<FileText className="size-5" />}
title={t("insights.dailyBriefing.emptyTitle")}
description={t("insights.dailyBriefing.emptyDescription")}
action={
onRegenerate ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRegenerate}
disabled={regenerating}
data-slot="daily-briefing-empty-cta"
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.dailyBriefing.emptyAction")}
</span>
</Button>
) : null
}
/>
)}
</CardContent>
</Card>
);
}
/**
* Bucketed relative-time using i18n translation keys so every surface
* shares vocabulary. Buckets: <1m, <1h, <24h, else days. Mirrors the
* helper in `<InsightsPageHero>` so the two cards format the same way.
*/
function formatRelativeTime(
iso: string,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const target = new Date(iso).getTime();
if (Number.isNaN(target)) return "";
const diffMs = Date.now() - target;
if (diffMs < 60_000) return t("insights.relativeJustNow");
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
const days = Math.floor(hours / 24);
return t("insights.relativeDaysAgo", { count: days });
}