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 (
+
+
+
+ {t("insights.heroBanner.ready", { week: weekISO })}
+
+
+
+
+
+
+
+ );
+}