feat(insights): /insights/report/[week] printable weekly report

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 17:11:45 +02:00
1 parent 6d4cba1aad
commit 8f18c7ff15
7 files changed
+722

No files matched your search

+24
View File
@@ -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?",
+24
View File
@@ -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?",
+40
View File
@@ -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 `<WeeklyReportView>`:
* - `[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 <WeeklyReportView weekISO={parsed.weekISO} autoPrint={autoPrint} />;
}
@@ -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(
<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");
});
});
@@ -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 (
<div
data-slot="weekly-report-loading"
className="flex h-64 items-center justify-center"
>
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</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:p-0 print:bg-none"
>
<span
data-slot="weekly-report-eyebrow"
className="bg-dracula-purple/15 text-dracula-purple inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wide"
>
<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">{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} />
</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} />
</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} />
</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">
{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 uppercase tracking-wider ${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>
);
}
@@ -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();
});
});
+86
View File
@@ -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 };
}