feat(insights): hero strip weekly-report banner card

Add a slim banner-card on <HeroStrip> that surfaces a fresh weeklyReport
between the title block and the action row. Three actions:

- Read   -> Next.js Link to /insights/report/[week]
- Share  -> navigator.share() with clipboard fallback + sonner toast
- Export PDF -> deep-link to the report URL with ?print=1 so the report
  page auto-fires window.print() after first paint

The banner only renders when the parent passes weeklyReportReady; the
insights page derives that prop from advisor.payload.insights.
weeklyReport so the banner appears the moment a fresh PROMPT_VERSION
4.20.2 generation lands.

EN + DE translations under insights.heroBanner.* (Read / Share / Export
PDF + share success/failure toasts).

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 17:14:27 +02:00
1 parent 8f18c7ff15
commit f1868453af
3 files changed
+231

No files matched your search

+16
View File
@@ -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 (
<div className="space-y-8">
<HeroStrip
@@ -891,6 +906,7 @@ export default function InsightsPage() {
setCoachPrefill(prompt);
setCoachOpen(true);
}}
weeklyReportReady={weeklyReportReady}
/>
<DailyBriefing
@@ -224,4 +224,88 @@ describe("<HeroStrip>", () => {
const html = render(<HeroStrip briefing={null} now={justBeforeNoon} />);
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(<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");
});
});
+131
View File
@@ -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({
</div>
</div>
{weeklyReportReady && (
<WeeklyReportBanner
weekISO={weeklyReportReady.weekISO}
href={weeklyReportReady.href}
/>
)}
<div className="flex flex-wrap items-center gap-2">
{/*
* v1.4.20 phase B4 ships the weekly-report route. Disabled
@@ -254,3 +276,112 @@ 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
size="sm"
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"
size="sm"
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
size="sm"
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>
);
}