mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
chore(insights): retire the weekly-report surface
Drop the entire weekly-report path: the /insights/report/[week] route, the WeeklyReportView component, the hero strip's banner mount and "Wochenbericht erstellen" action button, the ISO-week helpers, the schema slot, the AI prompt rule + OUTPUT FORMAT block, the openapi field, the i18n keys and every associated test. Coach now owns the sole hero-row action. iOS contract is unaffected — no native consumer reads any weekly-report endpoint or schema slot per the v1.4.28 iOS contract inventory.
This commit is contained in:
1 parent
8e5f71b1b9
commit
cad53a68a3
24 files changed
+64
-1632
No files matched your search
@@ -112,10 +112,12 @@ describe("<HeroStrip>", () => {
|
||||
});
|
||||
|
||||
// ── Action row ─────────────────────────────────────────────────────
|
||||
it("renders the weekly-report action button as disabled with a 'Coming soon' title", () => {
|
||||
// v1.4.28 retired the weekly-report button — Coach is now the only
|
||||
// hero-row action. The weekly-report path is gone from the codebase.
|
||||
it("does NOT render a weekly-report action button", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
expect(html).toMatch(
|
||||
/data-slot="insights-hero-strip-action-weekly-report"[^>]*disabled[^>]*title="Coming soon"/,
|
||||
expect(html).not.toContain(
|
||||
'data-slot="insights-hero-strip-action-weekly-report"',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,91 +192,14 @@ describe("<HeroStrip>", () => {
|
||||
});
|
||||
|
||||
// ── B4 — Weekly-report banner card ─────────────────────────────────
|
||||
it("does not render the weekly-report banner when weeklyReportReady is omitted", () => {
|
||||
// v1.4.28 retired the weekly-report path. The banner, the report
|
||||
// route, the AI schema slot and the i18n keys are gone; the hero
|
||||
// strip never paints the banner under any prop combination now.
|
||||
it("does not render the weekly-report banner under any prop combination", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
// ── B5 — Health Score panel ────────────────────────────────────────
|
||||
it("does NOT render the Health Score panel when healthScore is omitted", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WeeklyReportPresentation } from "../weekly-report-view";
|
||||
import type { WeeklyReport } from "@/lib/ai/schema";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B4 — pure-presentational layer of the printable
|
||||
* weekly report. The wrapper component (`WeeklyReportView`) consumes
|
||||
* `useInsightsAdvisorQuery` + `useAuth`; the tests target the
|
||||
* presentation slot directly so SSR rendering doesn't need a TanStack
|
||||
* Query setup.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const sampleReport: WeeklyReport = {
|
||||
weekISO: "2026-W19",
|
||||
summary:
|
||||
"Strong week — BP held under 130/85 on 9 of 10 readings and weight is down 0.6 kg.",
|
||||
goingWell: [
|
||||
"BP under 130/85 on 9 of 10 readings.",
|
||||
"Compliance 94 % over the past 7 days.",
|
||||
],
|
||||
worthWatching: ["Monday-morning systolic +6 mmHg over 6 weeks."],
|
||||
tips: ["Consider a brief walk after dinner on Mondays."],
|
||||
dataQualityNotes: "Only 5 BP readings this week — n is borderline.",
|
||||
};
|
||||
|
||||
describe("<WeeklyReportPresentation>", () => {
|
||||
it("renders the article wrapper with the locale on lang", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report"/);
|
||||
expect(html).toMatch(/lang="en"/);
|
||||
});
|
||||
|
||||
it("renders the eyebrow chip + title with the week", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-eyebrow"/);
|
||||
expect(html).toContain("Weekly report");
|
||||
expect(html).toMatch(/data-slot="weekly-report-title"/);
|
||||
expect(html).toContain("Your week — 2026-W19");
|
||||
});
|
||||
|
||||
it("renders the date range derived from the ISO week", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-daterange"/);
|
||||
// Range is May 04..May 10, 2026; locale-aware formatting picks dd/mm/yyyy
|
||||
// for English. Anchor on the year so the assertion stays stable.
|
||||
expect(html).toContain("2026");
|
||||
});
|
||||
|
||||
it("renders all five sections when the report is fully populated", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-summary"/);
|
||||
expect(html).toMatch(/data-slot="weekly-report-going-well"/);
|
||||
expect(html).toMatch(/data-slot="weekly-report-worth-watching"/);
|
||||
expect(html).toMatch(/data-slot="weekly-report-tips"/);
|
||||
expect(html).toMatch(/data-slot="weekly-report-data-quality"/);
|
||||
});
|
||||
|
||||
it("hides the going-well section when the array is empty", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation
|
||||
weekISO="2026-W19"
|
||||
report={{ ...sampleReport, goingWell: [] }}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="weekly-report-going-well"');
|
||||
});
|
||||
|
||||
it("hides the data-quality section when no notes were emitted", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation
|
||||
weekISO="2026-W19"
|
||||
report={{ ...sampleReport, dataQualityNotes: undefined }}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="weekly-report-data-quality"');
|
||||
});
|
||||
|
||||
it("renders the empty-state with a Generate CTA when report is null", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={null} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-empty"/);
|
||||
expect(html).toContain("No report for this week yet");
|
||||
expect(html).toContain("Open Insights");
|
||||
});
|
||||
|
||||
it("renders the toolbar with back link + print button", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-toolbar"/);
|
||||
expect(html).toContain("Back to insights");
|
||||
expect(html).toMatch(/data-slot="weekly-report-print"/);
|
||||
expect(html).toContain("Print / Export PDF");
|
||||
});
|
||||
|
||||
it("includes the not-medical-advice footer when report is present", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-footer"/);
|
||||
expect(html).toContain("Not medical advice");
|
||||
});
|
||||
|
||||
it("renders the German locale", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toMatch(/lang="de"/);
|
||||
expect(html).toContain("Wochenbericht");
|
||||
expect(html).toContain("Was gut läuft");
|
||||
expect(html).toContain("Was Beachtung verdient");
|
||||
});
|
||||
|
||||
it("applies print-friendly Tailwind variants on the article wrapper", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toContain("print:max-w-none");
|
||||
expect(html).toContain("print:px-0");
|
||||
});
|
||||
|
||||
it("hides the toolbar in print output via print:hidden", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
const toolbarMatch = html.match(
|
||||
/<header[^>]*data-slot="weekly-report-toolbar"[^>]*>/,
|
||||
);
|
||||
expect(toolbarMatch).not.toBeNull();
|
||||
expect(toolbarMatch?.[0]).toContain("print:hidden");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { Download, FileText, Share2, Sparkles } from "lucide-react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
@@ -24,11 +22,11 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
* "Guten Morgen, …") + a narrative subtitle (the briefing
|
||||
* paragraph when one is cached, else a fallback)
|
||||
* - personal-baseline + "Generated <relative-time>" meta row
|
||||
* - action row: "Generate weekly report" + "Ask the coach"
|
||||
* (v1.4.25 W3 dropped the third "Re-run analysis" button — the
|
||||
* regenerate affordance moved to `<InsightsTabStrip>` so the user
|
||||
* can re-run the analysis from any scroll position without
|
||||
* returning to the hero band).
|
||||
* - action row: single "Ask the coach" affordance
|
||||
* (v1.4.25 W3 dropped the "Re-run analysis" button — the
|
||||
* regenerate affordance moved to `<InsightsTabStrip>`. v1.4.28
|
||||
* retired the weekly-report path, leaving Coach as the sole
|
||||
* hero-row action.)
|
||||
* - <SuggestedPrompts> chip strip below the action band
|
||||
* - Dracula gradient + soft purple glow via the new `.hero-gradient`
|
||||
* + `.glow-purple` utilities in `globals.css`
|
||||
@@ -70,23 +68,6 @@ interface HeroStripProps {
|
||||
* panel calls it with "Why is my health score X out of 100?".
|
||||
*/
|
||||
onAskCoach?: (prefill?: string) => 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;
|
||||
};
|
||||
/**
|
||||
* v1.4.20 phase D reconcile — href to the current week's report. When
|
||||
* supplied, the action-row "Generate weekly report" button becomes a
|
||||
* real link instead of the disabled placeholder. B4 shipped the
|
||||
* route, so the button should not paint as disabled-primary anymore.
|
||||
*/
|
||||
weeklyReportHref?: string;
|
||||
/**
|
||||
* Now() override for tests so the greeting bucket is deterministic.
|
||||
* Defaults to `new Date()`. Production callers omit this.
|
||||
@@ -133,8 +114,6 @@ export function HeroStrip({
|
||||
userName,
|
||||
onPickPrompt,
|
||||
onAskCoach,
|
||||
weeklyReportReady,
|
||||
weeklyReportHref,
|
||||
now,
|
||||
healthScore,
|
||||
}: HeroStripProps) {
|
||||
@@ -216,58 +195,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 shipped /insights/report/[week]; phase D
|
||||
* reconcile enables this button as a real link to the
|
||||
* current ISO week. Older parents that haven't adopted the
|
||||
* weeklyReportHref prop still get the disabled affordance.
|
||||
*/}
|
||||
{(() => {
|
||||
// v1.4.22 W5 reconcile (S-02) — collapse the
|
||||
// enabled/disabled twin Buttons (variant, size, slot,
|
||||
// class, icon, label all identical) into a single
|
||||
// shared structure with the Link wrapping conditionally.
|
||||
const weeklyLabel = (
|
||||
<>
|
||||
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.heroActionWeeklyReport")}</span>
|
||||
</>
|
||||
);
|
||||
return weeklyReportHref ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
data-slot="insights-hero-strip-action-weekly-report"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Link href={weeklyReportHref}>{weeklyLabel}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled
|
||||
title={comingSoon}
|
||||
data-slot="insights-hero-strip-action-weekly-report"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{weeklyLabel}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
{/* B2b wires this into the Coach drawer. The button is
|
||||
enabled whenever the parent supplies an `onAskCoach`
|
||||
handler; older parents that haven't adopted B2b yet
|
||||
still get the disabled "Coming soon" affordance so the
|
||||
hero doesn't break. */}
|
||||
hero doesn't break. v1.4.28 retired the weekly-report
|
||||
button, leaving Coach as the only hero-row action. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -284,8 +218,7 @@ export function HeroStrip({
|
||||
* v1.4.25 W3 — the regenerate button moved to the new
|
||||
* `<InsightsTabStrip>` (icon-only RefreshCw, sticky next
|
||||
* to the pill nav) so the user can re-run the analysis
|
||||
* without scrolling back to the hero band. The hero's
|
||||
* action row is now Weekly-report + Ask-the-Coach only.
|
||||
* without scrolling back to the hero band.
|
||||
*/}
|
||||
</div>
|
||||
|
||||
@@ -318,106 +251,3 @@ 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
|
||||
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"
|
||||
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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
FileText,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Printer,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { weekISOToRange } from "@/lib/insights/week-iso";
|
||||
import type { WeeklyReport } from "@/lib/ai/schema";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B4 — Newsletter-style printable weekly report.
|
||||
*
|
||||
* The page is intentionally client-side so it can read the cached AI
|
||||
* advisor payload via `useInsightsAdvisorQuery`. Each section (Summary,
|
||||
* What's going well, What's worth watching, Tips, Data-quality notes)
|
||||
* mirrors the `weeklyReportSchema` shape one-to-one so the layout is
|
||||
* deterministic — no surprise sections, no missing sections.
|
||||
*
|
||||
* Print export is `window.print()` for v1.4.20 (the artboard's "PDF"
|
||||
* button maps to the same call). The page carries a `print:` Tailwind
|
||||
* stylesheet that hides the app shell + tightens margins so an A4 /
|
||||
* Letter print lands cleanly without margin clipping.
|
||||
*
|
||||
* The `?print=1` query forces an automatic `window.print()` call once
|
||||
* the report has hydrated — used by the hero strip's banner-card
|
||||
* "Export PDF" button so the user lands on a print-ready surface
|
||||
* without a second click.
|
||||
*/
|
||||
|
||||
export interface WeeklyReportViewProps {
|
||||
/** ISO-week identifier the route parsed (`YYYY-Www`). */
|
||||
weekISO: string;
|
||||
/** Honour `?print=1` and call `window.print()` after first paint. */
|
||||
autoPrint?: boolean;
|
||||
}
|
||||
|
||||
export function WeeklyReportView({
|
||||
weekISO,
|
||||
autoPrint = false,
|
||||
}: WeeklyReportViewProps) {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const advisor = useInsightsAdvisorQuery(isAuthenticated);
|
||||
|
||||
const cachedReport: WeeklyReport | null =
|
||||
(advisor.payload?.insights as { weeklyReport?: WeeklyReport | null })
|
||||
?.weeklyReport ?? null;
|
||||
// The advisor query keys the cached payload to the freshest insight,
|
||||
// not a specific week — we only show the cached report when its
|
||||
// weekISO matches the route param. Cross-week mismatches resolve to
|
||||
// the empty state with a Generate CTA so the user understands what's
|
||||
// happening.
|
||||
const matchedReport =
|
||||
cachedReport && cachedReport.weekISO === weekISO ? cachedReport : null;
|
||||
|
||||
// Auto-print when the banner card's "Export PDF" deep-link landed
|
||||
// here with `?print=1`. We fire after a short paint delay so the
|
||||
// print preview surfaces the report content, not the loading state.
|
||||
useEffect(() => {
|
||||
if (!autoPrint) return;
|
||||
if (advisor.isLoading) return;
|
||||
if (!matchedReport) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
window.print();
|
||||
} catch {
|
||||
// Some embedded webviews disallow print(); fall through silently.
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [autoPrint, advisor.isLoading, matchedReport]);
|
||||
|
||||
if (authLoading || advisor.isLoading) {
|
||||
return (
|
||||
<div
|
||||
data-slot="weekly-report-loading"
|
||||
className="flex h-64 items-center justify-center"
|
||||
>
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure presentational layer used by both the live route and the
|
||||
* vitest renderer. Decoupling it from `useInsightsAdvisorQuery` /
|
||||
* `useAuth` keeps the unit tests free of TanStack-Query setup.
|
||||
*/
|
||||
export interface WeeklyReportPresentationProps {
|
||||
weekISO: string;
|
||||
report: WeeklyReport | null;
|
||||
}
|
||||
|
||||
export function WeeklyReportPresentation({
|
||||
weekISO,
|
||||
report,
|
||||
}: WeeklyReportPresentationProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const range = weekISOToRange(weekISO);
|
||||
const dateRangeLabel = range
|
||||
? `${fmt.date(range.start)} — ${fmt.date(range.end)}`
|
||||
: weekISO;
|
||||
|
||||
return (
|
||||
<article
|
||||
data-slot="weekly-report"
|
||||
lang={locale}
|
||||
className="mx-auto max-w-3xl space-y-6 px-4 py-6 sm:px-6 print:max-w-none print:px-0 print:py-0"
|
||||
>
|
||||
{/* Top bar — back link + print button. Hidden in print output. */}
|
||||
<header
|
||||
data-slot="weekly-report-toolbar"
|
||||
className="flex flex-wrap items-center justify-between gap-2 print:hidden"
|
||||
>
|
||||
<Button variant="ghost" size="sm" asChild className="gap-1.5">
|
||||
<Link href="/insights">
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("insights.report.backToInsights")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
try {
|
||||
window.print();
|
||||
} catch {
|
||||
// No-op — older webviews disallow programmatic print().
|
||||
}
|
||||
}}
|
||||
data-slot="weekly-report-print"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("insights.report.printAction")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* Hero — title + date range + meta. Always visible (incl. print). */}
|
||||
<section
|
||||
data-slot="weekly-report-hero"
|
||||
className="border-border/60 from-dracula-purple/10 space-y-2 rounded-xl border bg-gradient-to-b to-transparent p-5 sm:p-6 print:rounded-none print:border-0 print:bg-none print:p-0"
|
||||
>
|
||||
<span
|
||||
data-slot="weekly-report-eyebrow"
|
||||
className="bg-dracula-purple/15 text-dracula-purple print:text-foreground inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium print:bg-transparent print:px-0"
|
||||
>
|
||||
<FileText className="h-3 w-3" aria-hidden="true" />
|
||||
{t("insights.report.eyebrow")}
|
||||
</span>
|
||||
<h1
|
||||
data-slot="weekly-report-title"
|
||||
className="text-2xl font-semibold tracking-tight sm:text-3xl"
|
||||
>
|
||||
{t("insights.report.title", { week: weekISO })}
|
||||
</h1>
|
||||
<p
|
||||
data-slot="weekly-report-daterange"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{dateRangeLabel}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{!report ? (
|
||||
<EmptyReportState weekISO={weekISO} />
|
||||
) : (
|
||||
<>
|
||||
{/* TL;DR — Summary */}
|
||||
<Section
|
||||
slot="weekly-report-summary"
|
||||
title={t("insights.report.sectionSummary")}
|
||||
accent="text-dracula-purple"
|
||||
>
|
||||
<p className="text-sm leading-relaxed">
|
||||
{stripChartTokens(report.summary)}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Going well */}
|
||||
{report.goingWell.length > 0 && (
|
||||
<Section
|
||||
slot="weekly-report-going-well"
|
||||
title={t("insights.report.sectionGoingWell")}
|
||||
accent="text-dracula-green"
|
||||
icon={<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
>
|
||||
<BulletList items={report.goingWell.map(stripChartTokens)} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Worth watching */}
|
||||
{report.worthWatching.length > 0 && (
|
||||
<Section
|
||||
slot="weekly-report-worth-watching"
|
||||
title={t("insights.report.sectionWorthWatching")}
|
||||
accent="text-dracula-orange"
|
||||
icon={<Eye className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
>
|
||||
<BulletList items={report.worthWatching.map(stripChartTokens)} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
{report.tips.length > 0 && (
|
||||
<Section
|
||||
slot="weekly-report-tips"
|
||||
title={t("insights.report.sectionTips")}
|
||||
accent="text-dracula-cyan"
|
||||
icon={<Lightbulb className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
>
|
||||
<BulletList items={report.tips.map(stripChartTokens)} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Data-quality notes — surfaced ONLY when the model emitted one. */}
|
||||
{report.dataQualityNotes && (
|
||||
<Section
|
||||
slot="weekly-report-data-quality"
|
||||
title={t("insights.report.sectionDataQuality")}
|
||||
accent="text-muted-foreground"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{stripChartTokens(report.dataQualityNotes)}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<footer
|
||||
data-slot="weekly-report-footer"
|
||||
className="text-muted-foreground border-border/60 border-t pt-4 text-[11px] leading-relaxed"
|
||||
>
|
||||
<p>
|
||||
<Sparkles
|
||||
className="mr-1.5 inline-block h-3 w-3 align-text-bottom"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("insights.report.footerNotMedicalAdvice")}
|
||||
</p>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
slot: string;
|
||||
title: string;
|
||||
accent: string;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ slot, title, accent, icon, children }: SectionProps) {
|
||||
return (
|
||||
<section
|
||||
data-slot={slot}
|
||||
className="border-border/60 space-y-2 rounded-xl border p-4 sm:p-5 print:rounded-none print:border-0 print:p-0"
|
||||
>
|
||||
<h2
|
||||
className={`flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase ${accent}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BulletList({ items }: { items: string[] }) {
|
||||
return (
|
||||
<ul className="list-disc space-y-1.5 pl-5 text-sm leading-relaxed">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${index}-${item.slice(0, 16)}`}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyReportState({ weekISO }: { weekISO: string }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<section
|
||||
data-slot="weekly-report-empty"
|
||||
className="border-border/60 space-y-3 rounded-xl border border-dashed p-6 text-center"
|
||||
>
|
||||
<Sparkles
|
||||
className="text-muted-foreground mx-auto h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-base font-medium">
|
||||
{t("insights.report.emptyTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.report.emptyDescription", { week: weekISO })}
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/insights">{t("insights.report.emptyAction")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user