diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index df2b81cf0..b701a3992 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -875,6 +875,21 @@ export default function InsightsPage() { const briefingPayload = advisor.payload?.dailyBriefing ?? null; const heroStripUpdatedAt = advisor.payload?.cachedAt ?? heroUpdatedAt; + // v1.4.20 phase B4 — surface a banner-card on the hero whenever the + // cached AI payload carries a weeklyReport block. The banner deep- + // links into `/insights/report/[week]` for Read / Share / Export PDF. + const weeklyReport = ( + advisor.payload?.insights as { + weeklyReport?: { weekISO: string } | null; + } | undefined + )?.weeklyReport; + const weeklyReportReady = weeklyReport + ? { + weekISO: weeklyReport.weekISO, + href: `/insights/report/${weeklyReport.weekISO}`, + } + : undefined; + return (
", () => { const html = render(); expect(html).toContain("Good morning"); }); + + // ── B4 — Weekly-report banner card ───────────────────────────────── + it("does not render the weekly-report banner when weeklyReportReady is omitted", () => { + const html = render(); + expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"'); + }); + + it("renders the weekly-report banner when weeklyReportReady is supplied", () => { + const html = render( + , + ); + 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( + , + ); + 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( + , + ); + // Read link points to the bare report URL. + const readLink = html.match( + /]*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( + /]*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( + , + "de", + ); + expect(html).toContain("Dein 2026-W19-Bericht ist bereit"); + expect(html).toContain("Lesen"); + expect(html).toContain("Als PDF"); + }); }); diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 1d11e31c6..d9634fe12 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -1,9 +1,13 @@ "use client"; +import Link from "next/link"; +import { toast } from "sonner"; import { + Download, FileText, Loader2, RefreshCw, + Share2, Sparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -65,6 +69,16 @@ interface HeroStripProps { * see the v1.4.20 phase B1 dispatch where the drawer was deferred. */ onAskCoach?: () => 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; + }; /** * Now() override for tests so the greeting bucket is deterministic. * Defaults to `new Date()`. Production callers omit this. @@ -117,6 +131,7 @@ export function HeroStrip({ regenerating = false, onPickPrompt, onAskCoach, + weeklyReportReady, now, }: HeroStripProps) { const { t } = useTranslations(); @@ -174,6 +189,13 @@ export function HeroStrip({
+ {weeklyReportReady && ( + + )} +
{/* * v1.4.20 phase B4 ships the weekly-report route. Disabled @@ -254,3 +276,112 @@ export function HeroStrip({
); } + +/** + * 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 ( +
+
+ ); +}