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