From 8f18c7ff15cb80cc924b29e03f061bd291cec678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 17:11:45 +0200 Subject: [PATCH] feat(insights): /insights/report/[week] printable weekly report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a newsletter-style report route at /insights/report/[week] that parses the YYYY-Www slug, reads the cached weeklyReport block off the AI advisor payload, and renders Summary / Going-well / Worth-watching / Tips / Data-quality sections in a print-friendly layout. Print export uses window.print() with Tailwind print: variants on the toolbar (hidden) and article wrapper (full-width margins). The route honours ?print=1 to deep-link the export PDF flow from the upcoming hero banner card. Includes: - src/lib/insights/week-iso.ts — parseWeekISO / toWeekISO / weekISOToRange - WeeklyReportView wrapper + WeeklyReportPresentation pure layer - Empty-state with Generate CTA when the cached report's weekISO doesn't match the route param - EN + DE i18n keys under insights.report.* and insights.heroBanner.* Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 24 ++ messages/en.json | 24 ++ src/app/insights/report/[week]/page.tsx | 40 +++ .../__tests__/weekly-report-view.test.tsx | 150 ++++++++ .../insights/weekly-report-view.tsx | 323 ++++++++++++++++++ src/lib/insights/__tests__/week-iso.test.ts | 75 ++++ src/lib/insights/week-iso.ts | 86 +++++ 7 files changed, 722 insertions(+) create mode 100644 src/app/insights/report/[week]/page.tsx create mode 100644 src/components/insights/__tests__/weekly-report-view.test.tsx create mode 100644 src/components/insights/weekly-report-view.tsx create mode 100644 src/lib/insights/__tests__/week-iso.test.ts create mode 100644 src/lib/insights/week-iso.ts diff --git a/messages/de.json b/messages/de.json index 62f406533..1fdc427a1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -760,6 +760,30 @@ "heroActionWeeklyReport": "Wochenbericht erstellen", "heroActionAskCoach": "Coach fragen", "heroActionRerun": "Analyse neu starten", + "heroBanner": { + "ready": "Dein {week}-Bericht ist bereit", + "read": "Lesen", + "share": "Teilen", + "exportPdf": "Als PDF", + "shareTitle": "HealthLog · Wochenbericht", + "shareCopied": "Berichts-Link in die Zwischenablage kopiert.", + "shareFailed": "Teilen fehlgeschlagen — der Browser unterstützt das nicht." + }, + "report": { + "eyebrow": "Wochenbericht", + "title": "Deine Woche — {week}", + "backToInsights": "Zurück zu Insights", + "printAction": "Drucken / Als PDF", + "sectionSummary": "Zusammenfassung", + "sectionGoingWell": "Was gut läuft", + "sectionWorthWatching": "Was Beachtung verdient", + "sectionTips": "Tipps", + "sectionDataQuality": "Hinweise zur Datenqualität", + "footerNotMedicalAdvice": "Erstellt vom HealthLog Coach. Keine medizinische Beratung — bei klinischen Entscheidungen teile den Bericht mit deinem Arzt.", + "emptyTitle": "Noch kein Bericht für diese Woche", + "emptyDescription": "Wir haben keinen Bericht für {week}. Geh zurück zu Insights und starte die Analyse neu, um ihn zu befüllen.", + "emptyAction": "Insights öffnen" + }, "suggestedPrompts": { "label": "Frag mich", "whyMonday": "Warum war der Blutdruck am Montag höher?", diff --git a/messages/en.json b/messages/en.json index dea5e05a8..e523d229a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -760,6 +760,30 @@ "heroActionWeeklyReport": "Generate weekly report", "heroActionAskCoach": "Ask the coach", "heroActionRerun": "Re-run analysis", + "heroBanner": { + "ready": "Your {week} report is ready", + "read": "Read", + "share": "Share", + "exportPdf": "Export PDF", + "shareTitle": "HealthLog · Weekly report", + "shareCopied": "Report link copied to clipboard.", + "shareFailed": "Couldn't share — link copy is unavailable in this browser." + }, + "report": { + "eyebrow": "Weekly report", + "title": "Your week — {week}", + "backToInsights": "Back to insights", + "printAction": "Print / Export PDF", + "sectionSummary": "Summary", + "sectionGoingWell": "What's going well", + "sectionWorthWatching": "What's worth watching", + "sectionTips": "Tips", + "sectionDataQuality": "Data-quality notes", + "footerNotMedicalAdvice": "Generated by HealthLog Coach. Not medical advice — share with your physician for clinical decisions.", + "emptyTitle": "No report for this week yet", + "emptyDescription": "We don't have a generated report for {week}. Head back to Insights and re-run the analysis to populate it.", + "emptyAction": "Open Insights" + }, "suggestedPrompts": { "label": "Try asking", "whyMonday": "Why was BP higher on Monday?", diff --git a/src/app/insights/report/[week]/page.tsx b/src/app/insights/report/[week]/page.tsx new file mode 100644 index 000000000..f3baf28d0 --- /dev/null +++ b/src/app/insights/report/[week]/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { use } from "react"; +import { useSearchParams, notFound } from "next/navigation"; + +import { WeeklyReportView } from "@/components/insights/weekly-report-view"; +import { parseWeekISO } from "@/lib/insights/week-iso"; + +/** + * v1.4.20 phase B4 — Newsletter-style printable weekly report at + * `/insights/report/[week]` (e.g. `/insights/report/2026-W19`). + * + * The page is a thin client wrapper around ``: + * - `[week]` is parsed via `parseWeekISO`; malformed input → 404 so + * the route never rends a half-broken report. + * - `?print=1` triggers an automatic `window.print()` after first + * paint; this is the deep-link target for the hero strip's banner- + * card "Export PDF" action. + * + * The view itself reads the cached AI advisor payload and shows an + * empty-state with a Generate CTA when no report covers `[week]`. + * + * Print stylesheet lives on Tailwind `print:` variants directly inside + * the component — no separate CSS file needed for the v1.4.20 surface. + */ +export default function WeeklyReportPage({ + params, +}: { + params: Promise<{ week: string }>; +}) { + const { week } = use(params); + const parsed = parseWeekISO(week); + if (!parsed) { + notFound(); + } + const searchParams = useSearchParams(); + const autoPrint = searchParams?.get("print") === "1"; + + return ; +} diff --git a/src/components/insights/__tests__/weekly-report-view.test.tsx b/src/components/insights/__tests__/weekly-report-view.test.tsx new file mode 100644 index 000000000..49821d01b --- /dev/null +++ b/src/components/insights/__tests__/weekly-report-view.test.tsx @@ -0,0 +1,150 @@ +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( + {node}, + ); +} + +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("", () => { + it("renders the article wrapper with the locale on lang", () => { + const html = render( + , + ); + expect(html).toMatch(/data-slot="weekly-report"/); + expect(html).toMatch(/lang="en"/); + }); + + it("renders the eyebrow chip + title with the week", () => { + const html = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).not.toContain('data-slot="weekly-report-going-well"'); + }); + + it("hides the data-quality section when no notes were emitted", () => { + const html = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toMatch(/data-slot="weekly-report-footer"/); + expect(html).toContain("Not medical advice"); + }); + + it("renders the German locale", () => { + const html = render( + , + "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( + , + ); + 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( + , + ); + const toolbarMatch = html.match( + /]*data-slot="weekly-report-toolbar"[^>]*>/, + ); + expect(toolbarMatch).not.toBeNull(); + expect(toolbarMatch?.[0]).toContain("print:hidden"); + }); +}); diff --git a/src/components/insights/weekly-report-view.tsx b/src/components/insights/weekly-report-view.tsx new file mode 100644 index 000000000..278dfbb2a --- /dev/null +++ b/src/components/insights/weekly-report-view.tsx @@ -0,0 +1,323 @@ +"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 { 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 ( +
+ +
+ ); + } + + return ( + + ); +} + +/** + * 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 ( +
+ {/* Top bar — back link + print button. Hidden in print output. */} +
+ + +
+ + {/* Hero — title + date range + meta. Always visible (incl. print). */} +
+ + +

+ {t("insights.report.title", { week: weekISO })} +

+

+ {dateRangeLabel} +

+
+ + {!report ? ( + + ) : ( + <> + {/* TL;DR — Summary */} +
+

{report.summary}

+
+ + {/* Going well */} + {report.goingWell.length > 0 && ( + + )} + + {/* Worth watching */} + {report.worthWatching.length > 0 && ( + + )} + + {/* Tips */} + {report.tips.length > 0 && ( + + )} + + {/* Data-quality notes — surfaced ONLY when the model emitted one. */} + {report.dataQualityNotes && ( +
+

+ {report.dataQualityNotes} +

+
+ )} + +
+

+

+
+ + )} +
+ ); +} + +interface SectionProps { + slot: string; + title: string; + accent: string; + icon?: React.ReactNode; + children: React.ReactNode; +} + +function Section({ slot, title, accent, icon, children }: SectionProps) { + return ( +
+

+ {icon} + {title} +

+ {children} +
+ ); +} + +function BulletList({ items }: { items: string[] }) { + return ( +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+ ); +} + +function EmptyReportState({ weekISO }: { weekISO: string }) { + const { t } = useTranslations(); + return ( +
+
+ ); +} diff --git a/src/lib/insights/__tests__/week-iso.test.ts b/src/lib/insights/__tests__/week-iso.test.ts new file mode 100644 index 000000000..ffc1df374 --- /dev/null +++ b/src/lib/insights/__tests__/week-iso.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { parseWeekISO, toWeekISO, weekISOToRange } from "../week-iso"; + +describe("parseWeekISO", () => { + it("accepts a valid YYYY-Www identifier", () => { + expect(parseWeekISO("2026-W19")).toEqual({ + weekISO: "2026-W19", + year: 2026, + week: 19, + }); + }); + + it("accepts the boundary weeks 01 and 53", () => { + expect(parseWeekISO("2026-W01")?.week).toBe(1); + expect(parseWeekISO("2026-W53")?.week).toBe(53); + }); + + it("rejects malformed input", () => { + expect(parseWeekISO("2026-W9")).toBeNull(); // single-digit week + expect(parseWeekISO("2026/W19")).toBeNull(); + expect(parseWeekISO("26-W19")).toBeNull(); + expect(parseWeekISO("not-a-week")).toBeNull(); + }); + + it("rejects out-of-range week numbers", () => { + expect(parseWeekISO("2026-W00")).toBeNull(); + expect(parseWeekISO("2026-W54")).toBeNull(); + }); +}); + +describe("toWeekISO", () => { + it("returns 2026-W19 for a date inside ISO week 19 of 2026", () => { + // 2026-05-07 is a Thursday; ISO week 19. + expect(toWeekISO(new Date(Date.UTC(2026, 4, 7)))).toBe("2026-W19"); + }); + + it("rolls Sunday into the same week as the preceding Monday", () => { + // 2026-05-10 is the Sunday closing ISO week 19. + expect(toWeekISO(new Date(Date.UTC(2026, 4, 10)))).toBe("2026-W19"); + }); + + it("returns the new week starting Monday", () => { + // 2026-05-11 is the Monday opening ISO week 20. + expect(toWeekISO(new Date(Date.UTC(2026, 4, 11)))).toBe("2026-W20"); + }); + + it("zero-pads the week number to two digits", () => { + // 2026-01-02 is a Friday, still in ISO week 1 of 2026. + expect(toWeekISO(new Date(Date.UTC(2026, 0, 2)))).toMatch(/^2026-W01$/); + }); +}); + +describe("weekISOToRange", () => { + it("returns Monday + Sunday for a known week", () => { + const range = weekISOToRange("2026-W19"); + expect(range).not.toBeNull(); + expect(range?.start.toISOString().slice(0, 10)).toBe("2026-05-04"); + expect(range?.end.toISOString().slice(0, 10)).toBe("2026-05-10"); + }); + + it("round-trips toWeekISO -> weekISOToRange -> toWeekISO", () => { + const date = new Date(Date.UTC(2026, 4, 7)); + const week = toWeekISO(date); + const range = weekISOToRange(week); + expect(range).not.toBeNull(); + if (range) { + expect(toWeekISO(range.start)).toBe(week); + expect(toWeekISO(range.end)).toBe(week); + } + }); + + it("returns null for malformed input", () => { + expect(weekISOToRange("not-a-week")).toBeNull(); + }); +}); diff --git a/src/lib/insights/week-iso.ts b/src/lib/insights/week-iso.ts new file mode 100644 index 000000000..fa1987334 --- /dev/null +++ b/src/lib/insights/week-iso.ts @@ -0,0 +1,86 @@ +/** + * v1.4.20 phase B4 — ISO-week helpers shared between the weekly-report + * route, the AI schema, and the hero strip's banner card. + * + * Format: `YYYY-Www` where ww is the two-digit ISO week number + * (01-53). ISO 8601 weeks start on Monday and Week 1 is the week + * containing the first Thursday of the calendar year. + */ + +const WEEK_ISO_PATTERN = /^(\d{4})-W(\d{2})$/; + +export interface ParsedWeekISO { + /** Original normalised string ("YYYY-Www"). */ + weekISO: string; + /** Numeric ISO year (the year of the week's Thursday). */ + year: number; + /** Numeric ISO week (1-53). */ + week: number; +} + +/** + * Parse a `YYYY-Www` string into its numeric pieces. Returns `null` + * when the input is malformed, the year is out of range, or the week + * number is not in 1..53. The route uses this guard to 404 instead + * of rendering a half-broken report. + */ +export function parseWeekISO(input: string): ParsedWeekISO | null { + const match = WEEK_ISO_PATTERN.exec(input); + if (!match) return null; + const year = Number(match[1]); + const week = Number(match[2]); + if (!Number.isFinite(year) || year < 1900 || year > 2999) return null; + if (!Number.isFinite(week) || week < 1 || week > 53) return null; + return { weekISO: input, year, week }; +} + +/** + * Compute the ISO-week identifier for a given date. Mirrors the + * algorithm used by `bucketTimeSeries` so a Berlin-calendar Monday + * lands in the correct week. + */ +export function toWeekISO(date: Date): string { + // Copy to UTC at midnight so DST shifts don't bump us across a day. + const utc = new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), + ); + // ISO weekday with Monday=1..Sunday=7. + const dow = ((utc.getUTCDay() + 6) % 7) + 1; + // Move to the Thursday of this week (ISO week number = week of that + // Thursday's calendar year). + utc.setUTCDate(utc.getUTCDate() - dow + 4); + const isoYear = utc.getUTCFullYear(); + // Find the first Thursday of the ISO year. + const jan4 = new Date(Date.UTC(isoYear, 0, 4)); + const jan4Dow = ((jan4.getUTCDay() + 6) % 7) + 1; + const firstThursday = new Date(jan4); + firstThursday.setUTCDate(jan4.getUTCDate() + (4 - jan4Dow)); + const week = Math.round( + (utc.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000) + 1, + ); + return `${isoYear}-W${String(week).padStart(2, "0")}`; +} + +/** + * Inverse of `toWeekISO` — return the Monday + Sunday (UTC midnights) + * that bound the given ISO-week identifier. Returns `null` for + * malformed input. Used by the report header to print the "May 4 to + * May 10" date range below the title. + */ +export function weekISOToRange( + input: string, +): { start: Date; end: Date } | null { + const parsed = parseWeekISO(input); + if (!parsed) return null; + const { year, week } = parsed; + // Jan 4 is always in ISO week 1. + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4Dow = ((jan4.getUTCDay() + 6) % 7) + 1; + const week1Monday = new Date(jan4); + week1Monday.setUTCDate(jan4.getUTCDate() - (jan4Dow - 1)); + const start = new Date(week1Monday); + start.setUTCDate(week1Monday.getUTCDate() + (week - 1) * 7); + const end = new Date(start); + end.setUTCDate(start.getUTCDate() + 6); + return { start, end }; +}