mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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 briefingPayload = advisor.payload?.dailyBriefing ?? null;
|
||||||
const heroStripUpdatedAt = advisor.payload?.cachedAt ?? heroUpdatedAt;
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<HeroStrip
|
<HeroStrip
|
||||||
@@ -891,6 +906,7 @@ export default function InsightsPage() {
|
|||||||
setCoachPrefill(prompt);
|
setCoachPrefill(prompt);
|
||||||
setCoachOpen(true);
|
setCoachOpen(true);
|
||||||
}}
|
}}
|
||||||
|
weeklyReportReady={weeklyReportReady}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DailyBriefing
|
<DailyBriefing
|
||||||
|
|||||||
@@ -224,4 +224,88 @@ describe("<HeroStrip>", () => {
|
|||||||
const html = render(<HeroStrip briefing={null} now={justBeforeNoon} />);
|
const html = render(<HeroStrip briefing={null} now={justBeforeNoon} />);
|
||||||
expect(html).toContain("Good morning");
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Share2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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.
|
* see the v1.4.20 phase B1 dispatch where the drawer was deferred.
|
||||||
*/
|
*/
|
||||||
onAskCoach?: () => void;
|
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.
|
* Now() override for tests so the greeting bucket is deterministic.
|
||||||
* Defaults to `new Date()`. Production callers omit this.
|
* Defaults to `new Date()`. Production callers omit this.
|
||||||
@@ -117,6 +131,7 @@ export function HeroStrip({
|
|||||||
regenerating = false,
|
regenerating = false,
|
||||||
onPickPrompt,
|
onPickPrompt,
|
||||||
onAskCoach,
|
onAskCoach,
|
||||||
|
weeklyReportReady,
|
||||||
now,
|
now,
|
||||||
}: HeroStripProps) {
|
}: HeroStripProps) {
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
@@ -174,6 +189,13 @@ export function HeroStrip({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{weeklyReportReady && (
|
||||||
|
<WeeklyReportBanner
|
||||||
|
weekISO={weeklyReportReady.weekISO}
|
||||||
|
href={weeklyReportReady.href}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/*
|
{/*
|
||||||
* v1.4.20 phase B4 ships the weekly-report route. Disabled
|
* v1.4.20 phase B4 ships the weekly-report route. Disabled
|
||||||
@@ -254,3 +276,112 @@ export function HeroStrip({
|
|||||||
</div>
|
</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