chore(insights): retire the weekly-report surface

Drop the entire weekly-report path: the /insights/report/[week] route,
the WeeklyReportView component, the hero strip's banner mount and
"Wochenbericht erstellen" action button, the ISO-week helpers, the
schema slot, the AI prompt rule + OUTPUT FORMAT block, the openapi
field, the i18n keys and every associated test. Coach now owns the
sole hero-row action. iOS contract is unaffected — no native consumer
reads any weekly-report endpoint or schema slot per the v1.4.28 iOS
contract inventory.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 01:14:26 +02:00
1 parent 8e5f71b1b9
commit cad53a68a3
24 files changed
+64 -1632

No files matched your search

@@ -112,10 +112,12 @@ describe("<HeroStrip>", () => {
});
// ── Action row ─────────────────────────────────────────────────────
it("renders the weekly-report action button as disabled with a 'Coming soon' title", () => {
// v1.4.28 retired the weekly-report button — Coach is now the only
// hero-row action. The weekly-report path is gone from the codebase.
it("does NOT render a weekly-report action button", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).toMatch(
/data-slot="insights-hero-strip-action-weekly-report"[^>]*disabled[^>]*title="Coming soon"/,
expect(html).not.toContain(
'data-slot="insights-hero-strip-action-weekly-report"',
);
});
@@ -190,91 +192,14 @@ describe("<HeroStrip>", () => {
});
// ── B4 — Weekly-report banner card ─────────────────────────────────
it("does not render the weekly-report banner when weeklyReportReady is omitted", () => {
// v1.4.28 retired the weekly-report path. The banner, the report
// route, the AI schema slot and the i18n keys are gone; the hero
// strip never paints the banner under any prop combination now.
it("does not render the weekly-report banner under any prop combination", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"');
});
it("renders the weekly-report banner when weeklyReportReady is supplied", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
weeklyReportReady={{
weekISO: "2026-W19",
href: "/insights/report/2026-W19",
}}
/>,
);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner"/);
expect(html).toContain("Your 2026-W19 report is ready");
});
it("renders Read / Share / Export PDF actions on the banner", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
weeklyReportReady={{
weekISO: "2026-W19",
href: "/insights/report/2026-W19",
}}
/>,
);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-read"/);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-share"/);
expect(html).toMatch(
/data-slot="insights-hero-strip-weekly-banner-export"/,
);
expect(html).toContain("Read");
expect(html).toContain("Share");
expect(html).toContain("Export PDF");
});
it("read action links to the report URL; export action appends print=1", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
weeklyReportReady={{
weekISO: "2026-W19",
href: "/insights/report/2026-W19",
}}
/>,
);
// Read link points to the bare report URL.
const readLink = html.match(
/<a[^>]*data-slot="insights-hero-strip-weekly-banner-read"[^>]*>/,
);
expect(readLink).not.toBeNull();
expect(readLink?.[0]).toContain('href="/insights/report/2026-W19"');
// Export link adds ?print=1.
const exportLink = html.match(
/<a[^>]*data-slot="insights-hero-strip-weekly-banner-export"[^>]*>/,
);
expect(exportLink).not.toBeNull();
expect(exportLink?.[0]).toContain(
'href="/insights/report/2026-W19?print=1"',
);
});
it("renders the German banner copy", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
weeklyReportReady={{
weekISO: "2026-W19",
href: "/insights/report/2026-W19",
}}
/>,
"de",
);
expect(html).toContain("Dein 2026-W19-Bericht ist bereit");
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} />);
@@ -1,150 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { WeeklyReportPresentation } from "../weekly-report-view";
import type { WeeklyReport } from "@/lib/ai/schema";
/**
* v1.4.20 phase B4 — pure-presentational layer of the printable
* weekly report. The wrapper component (`WeeklyReportView`) consumes
* `useInsightsAdvisorQuery` + `useAuth`; the tests target the
* presentation slot directly so SSR rendering doesn't need a TanStack
* Query setup.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const sampleReport: WeeklyReport = {
weekISO: "2026-W19",
summary:
"Strong week — BP held under 130/85 on 9 of 10 readings and weight is down 0.6 kg.",
goingWell: [
"BP under 130/85 on 9 of 10 readings.",
"Compliance 94 % over the past 7 days.",
],
worthWatching: ["Monday-morning systolic +6 mmHg over 6 weeks."],
tips: ["Consider a brief walk after dinner on Mondays."],
dataQualityNotes: "Only 5 BP readings this week — n is borderline.",
};
describe("<WeeklyReportPresentation>", () => {
it("renders the article wrapper with the locale on lang", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report"/);
expect(html).toMatch(/lang="en"/);
});
it("renders the eyebrow chip + title with the week", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-eyebrow"/);
expect(html).toContain("Weekly report");
expect(html).toMatch(/data-slot="weekly-report-title"/);
expect(html).toContain("Your week — 2026-W19");
});
it("renders the date range derived from the ISO week", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-daterange"/);
// Range is May 04..May 10, 2026; locale-aware formatting picks dd/mm/yyyy
// for English. Anchor on the year so the assertion stays stable.
expect(html).toContain("2026");
});
it("renders all five sections when the report is fully populated", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-summary"/);
expect(html).toMatch(/data-slot="weekly-report-going-well"/);
expect(html).toMatch(/data-slot="weekly-report-worth-watching"/);
expect(html).toMatch(/data-slot="weekly-report-tips"/);
expect(html).toMatch(/data-slot="weekly-report-data-quality"/);
});
it("hides the going-well section when the array is empty", () => {
const html = render(
<WeeklyReportPresentation
weekISO="2026-W19"
report={{ ...sampleReport, goingWell: [] }}
/>,
);
expect(html).not.toContain('data-slot="weekly-report-going-well"');
});
it("hides the data-quality section when no notes were emitted", () => {
const html = render(
<WeeklyReportPresentation
weekISO="2026-W19"
report={{ ...sampleReport, dataQualityNotes: undefined }}
/>,
);
expect(html).not.toContain('data-slot="weekly-report-data-quality"');
});
it("renders the empty-state with a Generate CTA when report is null", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={null} />,
);
expect(html).toMatch(/data-slot="weekly-report-empty"/);
expect(html).toContain("No report for this week yet");
expect(html).toContain("Open Insights");
});
it("renders the toolbar with back link + print button", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-toolbar"/);
expect(html).toContain("Back to insights");
expect(html).toMatch(/data-slot="weekly-report-print"/);
expect(html).toContain("Print / Export PDF");
});
it("includes the not-medical-advice footer when report is present", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toMatch(/data-slot="weekly-report-footer"/);
expect(html).toContain("Not medical advice");
});
it("renders the German locale", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
"de",
);
expect(html).toMatch(/lang="de"/);
expect(html).toContain("Wochenbericht");
expect(html).toContain("Was gut läuft");
expect(html).toContain("Was Beachtung verdient");
});
it("applies print-friendly Tailwind variants on the article wrapper", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
expect(html).toContain("print:max-w-none");
expect(html).toContain("print:px-0");
});
it("hides the toolbar in print output via print:hidden", () => {
const html = render(
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
);
const toolbarMatch = html.match(
/<header[^>]*data-slot="weekly-report-toolbar"[^>]*>/,
);
expect(toolbarMatch).not.toBeNull();
expect(toolbarMatch?.[0]).toContain("print:hidden");
});
});
+9 -179
View File
@@ -1,8 +1,6 @@
"use client";
import Link from "next/link";
import { toast } from "sonner";
import { Download, FileText, Share2, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
@@ -24,11 +22,11 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
* "Guten Morgen, …") + a narrative subtitle (the briefing
* paragraph when one is cached, else a fallback)
* - personal-baseline + "Generated <relative-time>" meta row
* - action row: "Generate weekly report" + "Ask the coach"
* (v1.4.25 W3 dropped the third "Re-run analysis" button — the
* regenerate affordance moved to `<InsightsTabStrip>` so the user
* can re-run the analysis from any scroll position without
* returning to the hero band).
* - action row: single "Ask the coach" affordance
* (v1.4.25 W3 dropped the "Re-run analysis" button — the
* regenerate affordance moved to `<InsightsTabStrip>`. v1.4.28
* retired the weekly-report path, leaving Coach as the sole
* hero-row action.)
* - <SuggestedPrompts> chip strip below the action band
* - Dracula gradient + soft purple glow via the new `.hero-gradient`
* + `.glow-purple` utilities in `globals.css`
@@ -70,23 +68,6 @@ interface HeroStripProps {
* panel calls it with "Why is my health score X out of 100?".
*/
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
* slim banner card ("Your Week N report is ready" with Read · Share
* · Export PDF actions). Omit to hide the banner.
*/
weeklyReportReady?: {
weekISO: string;
href: string;
};
/**
* v1.4.20 phase D reconcile — href to the current week's report. When
* supplied, the action-row "Generate weekly report" button becomes a
* real link instead of the disabled placeholder. B4 shipped the
* route, so the button should not paint as disabled-primary anymore.
*/
weeklyReportHref?: string;
/**
* Now() override for tests so the greeting bucket is deterministic.
* Defaults to `new Date()`. Production callers omit this.
@@ -133,8 +114,6 @@ export function HeroStrip({
userName,
onPickPrompt,
onAskCoach,
weeklyReportReady,
weeklyReportHref,
now,
healthScore,
}: HeroStripProps) {
@@ -216,58 +195,13 @@ export function HeroStrip({
</div>
</div>
{weeklyReportReady && (
<WeeklyReportBanner
weekISO={weeklyReportReady.weekISO}
href={weeklyReportReady.href}
/>
)}
<div className="flex flex-wrap items-center gap-2">
{/*
* v1.4.20 phase B4 shipped /insights/report/[week]; phase D
* reconcile enables this button as a real link to the
* current ISO week. Older parents that haven't adopted the
* weeklyReportHref prop still get the disabled affordance.
*/}
{(() => {
// v1.4.22 W5 reconcile (S-02) — collapse the
// enabled/disabled twin Buttons (variant, size, slot,
// class, icon, label all identical) into a single
// shared structure with the Link wrapping conditionally.
const weeklyLabel = (
<>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</>
);
return weeklyReportHref ? (
<Button
asChild
variant="default"
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<Link href={weeklyReportHref}>{weeklyLabel}</Link>
</Button>
) : (
<Button
type="button"
variant="default"
disabled
title={comingSoon}
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
{weeklyLabel}
</Button>
);
})()}
{/* B2b wires this into the Coach drawer. The button is
enabled whenever the parent supplies an `onAskCoach`
handler; older parents that haven't adopted B2b yet
still get the disabled "Coming soon" affordance so the
hero doesn't break. */}
hero doesn't break. v1.4.28 retired the weekly-report
button, leaving Coach as the only hero-row action. */}
<Button
type="button"
variant="outline"
@@ -284,8 +218,7 @@ export function HeroStrip({
* v1.4.25 W3 — the regenerate button moved to the new
* `<InsightsTabStrip>` (icon-only RefreshCw, sticky next
* to the pill nav) so the user can re-run the analysis
* without scrolling back to the hero band. The hero's
* action row is now Weekly-report + Ask-the-Coach only.
* without scrolling back to the hero band.
*/}
</div>
@@ -318,106 +251,3 @@ export function HeroStrip({
</div>
);
}
/**
* v1.4.20 phase B4 — slim banner card surfacing the fresh weekly report.
*
* Sits between the hero's title block and the action row. The banner
* carries three actions:
* - Read → in-app navigation to `/insights/report/[week]`.
* - Share → `navigator.share` when supported, else clipboard fallback
* with a sonner toast acknowledgement.
* - Export PDF → opens the report URL with `?print=1` so the report
* page auto-fires `window.print()` after first paint.
*/
function WeeklyReportBanner({
weekISO,
href,
}: {
weekISO: string;
href: string;
}) {
const { t } = useTranslations();
const printHref = href.includes("?") ? `${href}&print=1` : `${href}?print=1`;
const shareUrl =
typeof window !== "undefined"
? new URL(href, window.location.origin).toString()
: href;
async function handleShare() {
const title = t("insights.heroBanner.shareTitle");
if (typeof navigator !== "undefined" && "share" in navigator) {
try {
await navigator.share({ url: shareUrl, title });
return;
} catch (err) {
// Web Share rejects with AbortError when the user cancels —
// not an error worth surfacing. Other failures fall through to
// the clipboard fallback below.
if ((err as DOMException)?.name === "AbortError") return;
}
}
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t("insights.heroBanner.shareCopied"));
return;
} catch {
// fallthrough
}
}
toast.error(t("insights.heroBanner.shareFailed"));
}
return (
<div
data-slot="insights-hero-strip-weekly-banner"
className="border-dracula-purple/30 bg-dracula-purple/10 flex flex-wrap items-center gap-3 rounded-lg border px-3 py-2.5 sm:px-4"
>
<Sparkles
className="text-dracula-purple h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p
data-slot="insights-hero-strip-weekly-banner-label"
className="min-w-0 flex-1 text-sm leading-snug"
>
{t("insights.heroBanner.ready", { week: weekISO })}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Button
asChild
variant="default"
data-slot="insights-hero-strip-weekly-banner-read"
className="gap-1.5"
>
<Link href={href}>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroBanner.read")}</span>
</Link>
</Button>
<Button
type="button"
variant="ghost"
onClick={handleShare}
data-slot="insights-hero-strip-weekly-banner-share"
className="gap-1.5"
>
<Share2 className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroBanner.share")}</span>
</Button>
<Button
asChild
variant="ghost"
data-slot="insights-hero-strip-weekly-banner-export"
className="gap-1.5"
>
<Link href={printHref}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroBanner.exportPdf")}</span>
</Link>
</Button>
</div>
</div>
);
}
@@ -1,324 +0,0 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import {
ArrowLeft,
CheckCircle2,
Eye,
FileText,
Lightbulb,
Loader2,
Printer,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { weekISOToRange } from "@/lib/insights/week-iso";
import type { WeeklyReport } from "@/lib/ai/schema";
import { useAuth } from "@/hooks/use-auth";
/**
* v1.4.20 phase B4 — Newsletter-style printable weekly report.
*
* The page is intentionally client-side so it can read the cached AI
* advisor payload via `useInsightsAdvisorQuery`. Each section (Summary,
* What's going well, What's worth watching, Tips, Data-quality notes)
* mirrors the `weeklyReportSchema` shape one-to-one so the layout is
* deterministic — no surprise sections, no missing sections.
*
* Print export is `window.print()` for v1.4.20 (the artboard's "PDF"
* button maps to the same call). The page carries a `print:` Tailwind
* stylesheet that hides the app shell + tightens margins so an A4 /
* Letter print lands cleanly without margin clipping.
*
* The `?print=1` query forces an automatic `window.print()` call once
* the report has hydrated — used by the hero strip's banner-card
* "Export PDF" button so the user lands on a print-ready surface
* without a second click.
*/
export interface WeeklyReportViewProps {
/** ISO-week identifier the route parsed (`YYYY-Www`). */
weekISO: string;
/** Honour `?print=1` and call `window.print()` after first paint. */
autoPrint?: boolean;
}
export function WeeklyReportView({
weekISO,
autoPrint = false,
}: WeeklyReportViewProps) {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const advisor = useInsightsAdvisorQuery(isAuthenticated);
const cachedReport: WeeklyReport | null =
(advisor.payload?.insights as { weeklyReport?: WeeklyReport | null })
?.weeklyReport ?? null;
// The advisor query keys the cached payload to the freshest insight,
// not a specific week — we only show the cached report when its
// weekISO matches the route param. Cross-week mismatches resolve to
// the empty state with a Generate CTA so the user understands what's
// happening.
const matchedReport =
cachedReport && cachedReport.weekISO === weekISO ? cachedReport : null;
// Auto-print when the banner card's "Export PDF" deep-link landed
// here with `?print=1`. We fire after a short paint delay so the
// print preview surfaces the report content, not the loading state.
useEffect(() => {
if (!autoPrint) return;
if (advisor.isLoading) return;
if (!matchedReport) return;
const timer = window.setTimeout(() => {
try {
window.print();
} catch {
// Some embedded webviews disallow print(); fall through silently.
}
}, 300);
return () => window.clearTimeout(timer);
}, [autoPrint, advisor.isLoading, matchedReport]);
if (authLoading || advisor.isLoading) {
return (
<div
data-slot="weekly-report-loading"
className="flex h-64 items-center justify-center"
>
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
return <WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />;
}
/**
* Pure presentational layer used by both the live route and the
* vitest renderer. Decoupling it from `useInsightsAdvisorQuery` /
* `useAuth` keeps the unit tests free of TanStack-Query setup.
*/
export interface WeeklyReportPresentationProps {
weekISO: string;
report: WeeklyReport | null;
}
export function WeeklyReportPresentation({
weekISO,
report,
}: WeeklyReportPresentationProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const range = weekISOToRange(weekISO);
const dateRangeLabel = range
? `${fmt.date(range.start)} — ${fmt.date(range.end)}`
: weekISO;
return (
<article
data-slot="weekly-report"
lang={locale}
className="mx-auto max-w-3xl space-y-6 px-4 py-6 sm:px-6 print:max-w-none print:px-0 print:py-0"
>
{/* Top bar — back link + print button. Hidden in print output. */}
<header
data-slot="weekly-report-toolbar"
className="flex flex-wrap items-center justify-between gap-2 print:hidden"
>
<Button variant="ghost" size="sm" asChild className="gap-1.5">
<Link href="/insights">
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
{t("insights.report.backToInsights")}
</Link>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
try {
window.print();
} catch {
// No-op — older webviews disallow programmatic print().
}
}}
data-slot="weekly-report-print"
className="gap-1.5"
>
<Printer className="h-3.5 w-3.5" aria-hidden="true" />
{t("insights.report.printAction")}
</Button>
</header>
{/* Hero — title + date range + meta. Always visible (incl. print). */}
<section
data-slot="weekly-report-hero"
className="border-border/60 from-dracula-purple/10 space-y-2 rounded-xl border bg-gradient-to-b to-transparent p-5 sm:p-6 print:rounded-none print:border-0 print:bg-none print:p-0"
>
<span
data-slot="weekly-report-eyebrow"
className="bg-dracula-purple/15 text-dracula-purple print:text-foreground inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium print:bg-transparent print:px-0"
>
<FileText className="h-3 w-3" aria-hidden="true" />
{t("insights.report.eyebrow")}
</span>
<h1
data-slot="weekly-report-title"
className="text-2xl font-semibold tracking-tight sm:text-3xl"
>
{t("insights.report.title", { week: weekISO })}
</h1>
<p
data-slot="weekly-report-daterange"
className="text-muted-foreground text-sm"
>
{dateRangeLabel}
</p>
</section>
{!report ? (
<EmptyReportState weekISO={weekISO} />
) : (
<>
{/* TL;DR — Summary */}
<Section
slot="weekly-report-summary"
title={t("insights.report.sectionSummary")}
accent="text-dracula-purple"
>
<p className="text-sm leading-relaxed">
{stripChartTokens(report.summary)}
</p>
</Section>
{/* Going well */}
{report.goingWell.length > 0 && (
<Section
slot="weekly-report-going-well"
title={t("insights.report.sectionGoingWell")}
accent="text-dracula-green"
icon={<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.goingWell.map(stripChartTokens)} />
</Section>
)}
{/* Worth watching */}
{report.worthWatching.length > 0 && (
<Section
slot="weekly-report-worth-watching"
title={t("insights.report.sectionWorthWatching")}
accent="text-dracula-orange"
icon={<Eye className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.worthWatching.map(stripChartTokens)} />
</Section>
)}
{/* Tips */}
{report.tips.length > 0 && (
<Section
slot="weekly-report-tips"
title={t("insights.report.sectionTips")}
accent="text-dracula-cyan"
icon={<Lightbulb className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.tips.map(stripChartTokens)} />
</Section>
)}
{/* Data-quality notes — surfaced ONLY when the model emitted one. */}
{report.dataQualityNotes && (
<Section
slot="weekly-report-data-quality"
title={t("insights.report.sectionDataQuality")}
accent="text-muted-foreground"
>
<p className="text-muted-foreground text-sm leading-relaxed">
{stripChartTokens(report.dataQualityNotes)}
</p>
</Section>
)}
<footer
data-slot="weekly-report-footer"
className="text-muted-foreground border-border/60 border-t pt-4 text-[11px] leading-relaxed"
>
<p>
<Sparkles
className="mr-1.5 inline-block h-3 w-3 align-text-bottom"
aria-hidden="true"
/>
{t("insights.report.footerNotMedicalAdvice")}
</p>
</footer>
</>
)}
</article>
);
}
interface SectionProps {
slot: string;
title: string;
accent: string;
icon?: React.ReactNode;
children: React.ReactNode;
}
function Section({ slot, title, accent, icon, children }: SectionProps) {
return (
<section
data-slot={slot}
className="border-border/60 space-y-2 rounded-xl border p-4 sm:p-5 print:rounded-none print:border-0 print:p-0"
>
<h2
className={`flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase ${accent}`}
>
{icon}
<span>{title}</span>
</h2>
{children}
</section>
);
}
function BulletList({ items }: { items: string[] }) {
return (
<ul className="list-disc space-y-1.5 pl-5 text-sm leading-relaxed">
{items.map((item, index) => (
<li key={`${index}-${item.slice(0, 16)}`}>{item}</li>
))}
</ul>
);
}
function EmptyReportState({ weekISO }: { weekISO: string }) {
const { t } = useTranslations();
return (
<section
data-slot="weekly-report-empty"
className="border-border/60 space-y-3 rounded-xl border border-dashed p-6 text-center"
>
<Sparkles
className="text-muted-foreground mx-auto h-6 w-6"
aria-hidden="true"
/>
<h2 className="text-base font-medium">
{t("insights.report.emptyTitle")}
</h2>
<p className="text-muted-foreground text-sm">
{t("insights.report.emptyDescription", { week: weekISO })}
</p>
<div className="flex justify-center">
<Button asChild size="sm" variant="outline">
<Link href="/insights">{t("insights.report.emptyAction")}</Link>
</Button>
</div>
</section>
);
}