mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
8f18c7ff15
commit
f1868453af
3 files changed
+231
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user