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