diff --git a/messages/de.json b/messages/de.json index d31ca3d5e..85c334109 100644 --- a/messages/de.json +++ b/messages/de.json @@ -958,17 +958,7 @@ "heroGreetingAfternoon": "Guten Tag", "heroGreetingEvening": "Guten Abend", "heroComingSoonTooltip": "Kommt bald", - "heroActionWeeklyReport": "Wochenbericht erstellen", "heroActionAskCoach": "Coach fragen", - "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." - }, "healthScore": { "label": "Gesundheitsscore", "progressAria": "Gesundheitsscore-Fortschritt", @@ -997,21 +987,6 @@ } } }, - "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 209c17c06..825fc8add 100644 --- a/messages/en.json +++ b/messages/en.json @@ -958,17 +958,7 @@ "heroGreetingAfternoon": "Good afternoon", "heroGreetingEvening": "Good evening", "heroComingSoonTooltip": "Coming soon", - "heroActionWeeklyReport": "Generate weekly report", "heroActionAskCoach": "Ask the coach", - "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." - }, "healthScore": { "label": "Health Score", "progressAria": "Health Score progress", @@ -997,21 +987,6 @@ } } }, - "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/messages/es.json b/messages/es.json index eab24fe2b..fcd9146e3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -958,17 +958,7 @@ "heroGreetingAfternoon": "Good afternoon", "heroGreetingEvening": "Good evening", "heroComingSoonTooltip": "Coming soon", - "heroActionWeeklyReport": "Generate weekly report", "heroActionAskCoach": "Ask the coach", - "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." - }, "healthScore": { "label": "Health Score", "progressAria": "Health Score progress", @@ -997,21 +987,6 @@ } } }, - "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/messages/fr.json b/messages/fr.json index 24a99c8bc..ff0afd091 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -958,17 +958,7 @@ "heroGreetingAfternoon": "Good afternoon", "heroGreetingEvening": "Good evening", "heroComingSoonTooltip": "Coming soon", - "heroActionWeeklyReport": "Generate weekly report", "heroActionAskCoach": "Ask the coach", - "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." - }, "healthScore": { "label": "Health Score", "progressAria": "Health Score progress", @@ -997,21 +987,6 @@ } } }, - "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/messages/it.json b/messages/it.json index 226bb994b..781ff5a8e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -958,17 +958,7 @@ "heroGreetingAfternoon": "Good afternoon", "heroGreetingEvening": "Good evening", "heroComingSoonTooltip": "Coming soon", - "heroActionWeeklyReport": "Generate weekly report", "heroActionAskCoach": "Ask the coach", - "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." - }, "healthScore": { "label": "Health Score", "progressAria": "Health Score progress", @@ -997,21 +987,6 @@ } } }, - "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/messages/pl.json b/messages/pl.json index 1442feb35..ef9e1bcd2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -958,17 +958,7 @@ "heroGreetingAfternoon": "Good afternoon", "heroGreetingEvening": "Good evening", "heroComingSoonTooltip": "Coming soon", - "heroActionWeeklyReport": "Generate weekly report", "heroActionAskCoach": "Ask the coach", - "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." - }, "healthScore": { "label": "Health Score", "progressAria": "Health Score progress", @@ -997,21 +987,6 @@ } } }, - "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/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 457300985..df73885f9 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -274,7 +274,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // block to the user prompt when the active GLP-1 medication has been // on a stable dose for ≥21 days with weight delta within ±0.5 kg. // The block instructs the model to emit a glp1_plateau keyFinding - // framed observationally (no dose recommendation per GROUND RULE 14). + // framed observationally (no dose recommendation per GROUND RULE 13). // Returns null when no GLP-1 is active OR no plateau condition; // 99% of users land in the null branch and pay zero token cost. const plateauContext = await detectGlp1Plateau(userId); @@ -298,7 +298,7 @@ export const POST = apiHandler(async (request: NextRequest) => { params: { // The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8 // — emit a top-level `dailyBriefing` block when the snapshot has - // analysable signal — plus the trendAnnotations / weeklyReport / + // analysable signal — plus the trendAnnotations and // storyboardAnnotations rules. The legacy `getInsightsSystemPrompt` // returned the v1.4.5 `{changed, stable, drivers, …}` shape and // never asked the model for dailyBriefing, so the hero strip's diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index c2f96b5ff..c166a6e70 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -17,7 +17,6 @@ import { CorrelationRow } from "@/components/insights/correlation-row"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import type { CorrelationResult } from "@/lib/insights/correlations"; -import { toWeekISO } from "@/lib/insights/week-iso"; import type { DataSummary } from "@/lib/analytics/trends"; /** @@ -177,21 +176,6 @@ export default function InsightsPage() { const briefingPayload = advisor.payload?.dailyBriefing ?? null; const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null; - const weeklyReport = ( - advisor.payload?.insights as - | { - weeklyReport?: { weekISO: string } | null; - } - | undefined - )?.weeklyReport; - const weeklyReportReady = weeklyReport - ? { - weekISO: weeklyReport.weekISO, - href: `/insights/report/${weeklyReport.weekISO}`, - } - : undefined; - const currentWeekHref = `/insights/report/${toWeekISO(new Date())}`; - return (
coachLaunch.askCoach(prompt) : undefined } - weeklyReportReady={weeklyReportReady} - weeklyReportHref={currentWeekHref} healthScore={analytics?.healthScore ?? undefined} /> diff --git a/src/app/insights/report/[week]/page.tsx b/src/app/insights/report/[week]/page.tsx deleted file mode 100644 index f3baf28d0..000000000 --- a/src/app/insights/report/[week]/page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"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__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 35f748a20..6b28d3412 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -112,10 +112,12 @@ describe("", () => { }); // ── 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(); - 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("", () => { }); // ── 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(); expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"'); }); - it("renders the weekly-report banner when weeklyReportReady is supplied", () => { - const html = render( - , - ); - 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( - , - ); - 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( - , - ); - // Read link points to the bare report URL. - const readLink = html.match( - /]*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( - /]*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( - , - "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(); diff --git a/src/components/insights/__tests__/weekly-report-view.test.tsx b/src/components/insights/__tests__/weekly-report-view.test.tsx deleted file mode 100644 index 49821d01b..000000000 --- a/src/components/insights/__tests__/weekly-report-view.test.tsx +++ /dev/null @@ -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( - {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/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 2f3d84e95..52a270a8a 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -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 " 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 `` 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 ``. v1.4.28 + * retired the weekly-report path, leaving Coach as the sole + * hero-row action.) * - 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({
- {weeklyReportReady && ( - - )} -
- {/* - * 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 = ( - <> -
@@ -318,106 +251,3 @@ export function HeroStrip({ ); } - -/** - * 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 ( -
-
- ); -} diff --git a/src/components/insights/weekly-report-view.tsx b/src/components/insights/weekly-report-view.tsx deleted file mode 100644 index 5c783d8e0..000000000 --- a/src/components/insights/weekly-report-view.tsx +++ /dev/null @@ -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 ( -
- -
- ); - } - - 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 */} -
-

- {stripChartTokens(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 && ( -
-

- {stripChartTokens(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 ( - - ); -} - -function EmptyReportState({ weekISO }: { weekISO: string }) { - const { t } = useTranslations(); - return ( -
-
- ); -} diff --git a/src/lib/ai/__tests__/coach-prompt-v423.test.ts b/src/lib/ai/__tests__/coach-prompt-v423.test.ts index 017ef1b0b..cb42a0162 100644 --- a/src/lib/ai/__tests__/coach-prompt-v423.test.ts +++ b/src/lib/ai/__tests__/coach-prompt-v423.test.ts @@ -10,7 +10,9 @@ import { * * Asserts: * 1. PROMPT_VERSION sits on the 4.23.x train. - * 2. GROUND RULE 12 ships in the EN prompt and tells the model to + * 2. The Apple Health silent-fallback rule (#11 after the v1.4.28 + * renumber that retired the weekly-report rule) ships in the EN + * prompt and tells the model to * stay silent about HealthKit metrics when the snapshot doesn't * carry them — defending against the "you don't have HRV data" * apologetic-opener regression on web-only accounts. @@ -20,7 +22,8 @@ import { */ describe("PROMPT_VERSION (v1.4.23 Apple Health ratchet)", () => { it("matches the 4.23+ train", () => { - // v1.4.25 W5b ratchet to 4.24.0 — GROUND RULE 13 bans internal + // v1.4.25 W5b ratchet to 4.24.0 — the enum-identifier ban rule + // (numbered #12 after v1.4.28 renumber, formerly #13) bans internal // metric enum identifiers from prose. The pin loosens to accept // any 4.{>=23}.x revision so future additive ratchets don't // force a hostile test rewrite each time the rules grow. @@ -33,8 +36,8 @@ describe("PROMPT_VERSION (v1.4.23 Apple Health ratchet)", () => { describe("getStrictInsightsSystemPrompt — EN", () => { const prompt = getStrictInsightsSystemPrompt("en"); - it("carries GROUND RULE 12 with the Apple Health silent-fallback contract", () => { - expect(prompt).toMatch(/12\. v1\.4\.23/); + it("carries the Apple Health silent-fallback contract (rule 11)", () => { + expect(prompt).toMatch(/11\. v1\.4\.23/); expect(prompt).toMatch(/Apple Health metric categories/); expect(prompt).toMatch(/HRV/); expect(prompt).toMatch(/sleep duration/); @@ -44,17 +47,18 @@ describe("getStrictInsightsSystemPrompt — EN", () => { }); it("does not bake live tenant figures into the new rule", () => { - // PII rule (per the marathon brief): GROUND RULE 12 frames in terms - // of metric categories, never a specific number / email / username. - // The block starts at "12. v1.4.23" and ends at the - // GUIDELINE TARGETS heading that closes the ground-rules section. - const rule12 = - prompt.split("12. v1.4.23")[1]?.split("GUIDELINE TARGETS")[0] ?? ""; - expect(rule12.length).toBeGreaterThan(0); - expect(rule12).not.toMatch(/@.+\.(com|net|org|de|test)/); + // PII rule: the Apple Health rule frames in terms of metric + // categories, never a specific number / email / username. The block + // starts at "11. v1.4.23" (renumbered when v1.4.28 dropped the + // weekly-report rule) and ends at the GUIDELINE TARGETS heading + // that closes the ground-rules section. + const ruleAppleHealth = + prompt.split("11. v1.4.23")[1]?.split("GUIDELINE TARGETS")[0] ?? ""; + expect(ruleAppleHealth.length).toBeGreaterThan(0); + expect(ruleAppleHealth).not.toMatch(/@.+\.(com|net|org|de|test)/); // The rule mentions metric category names but no concrete sample // values — categories only. - expect(rule12).not.toMatch(/\b\d{1,3}\s*(mmHg|bpm|kcal)\b/); + expect(ruleAppleHealth).not.toMatch(/\b\d{1,3}\s*(mmHg|bpm|kcal)\b/); }); it("documents the additive sourceMetric tokens in the OUTPUT FORMAT", () => { @@ -67,8 +71,8 @@ describe("getStrictInsightsSystemPrompt — EN", () => { describe("getStrictInsightsSystemPrompt — DE", () => { const prompt = getStrictInsightsSystemPrompt("de"); - it("carries GROUND RULE 12 with the German silent-fallback wording", () => { - expect(prompt).toMatch(/12\. v1\.4\.23/); + it("carries the German silent-fallback wording (rule 11)", () => { + expect(prompt).toMatch(/11\. v1\.4\.23/); expect(prompt).toMatch(/Apple-Health-Metrik-Kategorien/); expect(prompt).toMatch(/HRV/); expect(prompt).toMatch(/Schlafdauer/); @@ -85,17 +89,18 @@ describe("getStrictInsightsSystemPrompt — DE", () => { }); /** - * v1.4.25 W5b — pin GROUND RULE 13 (no internal metric identifiers + * v1.4.25 W5b — pin the enum-identifier ban rule (no internal metric + * identifiers * in prose). Marc saw the "Metric Pressure_Sys" leak on the * Einschätzungen surface 2026-05-14; the stripper covers the leak at - * render time, GROUND RULE 13 closes the loop at generation time so + * render time, the rule closes the loop at generation time so * the cleaned-up prose was the only version emitted in the first * place. */ -describe("getStrictInsightsSystemPrompt — GROUND RULE 13 (no enum names in prose)", () => { +describe("getStrictInsightsSystemPrompt — enum-identifier ban (no enum names in prose, rule 12)", () => { it("EN prompt carries the rule and enumerates the banned identifiers", () => { const prompt = getStrictInsightsSystemPrompt("en"); - expect(prompt).toMatch(/13\. v1\.4\.25/); + expect(prompt).toMatch(/12\. v1\.4\.25/); expect(prompt).toMatch(/Internal metric identifiers/); // The banned-list is enumerated verbatim so the model has no // ambiguity about which strings are off-limits in prose. @@ -117,7 +122,7 @@ describe("getStrictInsightsSystemPrompt — GROUND RULE 13 (no enum names in pro it("DE prompt carries the rule with the same banned list", () => { const prompt = getStrictInsightsSystemPrompt("de"); - expect(prompt).toMatch(/13\. v1\.4\.25/); + expect(prompt).toMatch(/12\. v1\.4\.25/); expect(prompt).toMatch(/Interne Metrik-Identifier/); expect(prompt).toMatch(/"Pressure_Sys"/); expect(prompt).toMatch(/"BLOOD_PRESSURE_SYS"/); diff --git a/src/lib/ai/__tests__/weekly-report-schema.test.ts b/src/lib/ai/__tests__/weekly-report-schema.test.ts deleted file mode 100644 index 486863eba..000000000 --- a/src/lib/ai/__tests__/weekly-report-schema.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - aiInsightResponseSchema, - weeklyReportSchema, - storyboardAnnotationSchema, - storyboardAnnotationsSchema, - type AIInsightResponse, -} from "../schema"; -import { - PROMPT_VERSION, - getStrictInsightsSystemPrompt, -} from "../prompts/insight-generator"; - -/** - * v1.4.20 phase B4 — Weekly Report + Storyboard Annotations schema + - * prompt validation. - * - * Acceptance covered here: - * 1. PROMPT_VERSION matches `^4\.20\.\d+$` and is `4.20.2` (B4 bump). - * 2. weeklyReportSchema enforces section caps (summary 10-800, - * bullet ≤280, ≤5 bullets, dataQualityNotes optional ≤280). - * 3. storyboardAnnotationSchema enforces date / label / category / - * detail; the array is capped at 20. - * 4. aiInsightResponseSchema treats both blocks as optional + nullable - * so legacy 4.20.1 caches round-trip. - * 5. EN + DE prompts both contain GROUND RULE 10 (weeklyReport) + - * GROUND RULE 11 (storyboardAnnotations) with conservative - * phrasing + section-name match. - */ - -const baseResponse: AIInsightResponse = { - summary: "Things are trending well this week.", - recommendations: [], - citations: [], - warnings: [], -}; - -describe("PROMPT_VERSION (B4 bump)", () => { - it("stays on the 4.x train", () => { - expect(PROMPT_VERSION).toMatch(/^4\.\d+\.\d+$/); - }); - - it("is bumped past 4.20.1 to signal weeklyReport + storyboardAnnotations", () => { - expect(PROMPT_VERSION).not.toBe("4.20.0"); - expect(PROMPT_VERSION).not.toBe("4.20.1"); - }); -}); - -describe("weeklyReportSchema", () => { - const validReport = { - 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.", - "Weight down 0.6 kg this week.", - "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."], - }; - - it("accepts a fully-populated report", () => { - expect(weeklyReportSchema.safeParse(validReport).success).toBe(true); - }); - - it("accepts a report with optional dataQualityNotes", () => { - const result = weeklyReportSchema.safeParse({ - ...validReport, - dataQualityNotes: "Only 5 BP readings this week — n is borderline.", - }); - expect(result.success).toBe(true); - }); - - it("rejects a malformed weekISO", () => { - expect( - weeklyReportSchema.safeParse({ ...validReport, weekISO: "2026-W9" }) - .success, - ).toBe(false); - expect( - weeklyReportSchema.safeParse({ ...validReport, weekISO: "2026/W19" }) - .success, - ).toBe(false); - }); - - it("rejects a summary < 10 chars", () => { - expect( - weeklyReportSchema.safeParse({ ...validReport, summary: "short" }) - .success, - ).toBe(false); - }); - - it("rejects a summary > 800 chars", () => { - const tooLong = "x".repeat(801); - expect( - weeklyReportSchema.safeParse({ ...validReport, summary: tooLong }) - .success, - ).toBe(false); - }); - - it("rejects a goingWell entry > 280 chars", () => { - const tooLong = "x".repeat(281); - expect( - weeklyReportSchema.safeParse({ - ...validReport, - goingWell: [tooLong], - }).success, - ).toBe(false); - }); - - it("rejects more than 5 entries in any bullet list", () => { - const six = ["a", "b", "c", "d", "e", "f"]; - expect( - weeklyReportSchema.safeParse({ ...validReport, goingWell: six }).success, - ).toBe(false); - expect( - weeklyReportSchema.safeParse({ ...validReport, worthWatching: six }) - .success, - ).toBe(false); - expect( - weeklyReportSchema.safeParse({ ...validReport, tips: six }).success, - ).toBe(false); - }); - - it("accepts empty bullet arrays (a quiet week is allowed)", () => { - const result = weeklyReportSchema.safeParse({ - ...validReport, - goingWell: [], - worthWatching: [], - tips: [], - }); - expect(result.success).toBe(true); - }); - - it("rejects empty strings in bullet arrays", () => { - expect( - weeklyReportSchema.safeParse({ ...validReport, goingWell: [""] }).success, - ).toBe(false); - }); -}); - -describe("storyboardAnnotationSchema", () => { - const valid = { - date: "2026-04-14", - label: "Started Ramipril 5 mg", - category: "medication" as const, - detail: - "Logged a new medication entry on April 14. Average systolic for the prior 7 days was 146 mmHg.", - }; - - it("accepts a fully-formed entry", () => { - expect(storyboardAnnotationSchema.safeParse(valid).success).toBe(true); - }); - - it("rejects a malformed date", () => { - expect( - storyboardAnnotationSchema.safeParse({ ...valid, date: "April 14" }) - .success, - ).toBe(false); - expect( - storyboardAnnotationSchema.safeParse({ ...valid, date: "2026-4-14" }) - .success, - ).toBe(false); - }); - - it("rejects an unknown category", () => { - expect( - storyboardAnnotationSchema.safeParse({ - ...valid, - category: "celebration" as never, - }).success, - ).toBe(false); - }); - - it("rejects a label > 80 chars", () => { - const tooLong = "x".repeat(81); - expect( - storyboardAnnotationSchema.safeParse({ ...valid, label: tooLong }) - .success, - ).toBe(false); - }); - - it("rejects a detail > 400 chars", () => { - const tooLong = "x".repeat(401); - expect( - storyboardAnnotationSchema.safeParse({ ...valid, detail: tooLong }) - .success, - ).toBe(false); - }); - - it("accepts each of the four canonical categories", () => { - for (const category of [ - "medication", - "event", - "milestone", - "warning", - ] as const) { - expect( - storyboardAnnotationSchema.safeParse({ ...valid, category }).success, - ).toBe(true); - } - }); - - it("array form caps at 20 entries", () => { - const entry = valid; - const ok = Array.from({ length: 20 }, () => entry); - const tooMany = Array.from({ length: 21 }, () => entry); - expect(storyboardAnnotationsSchema.safeParse(ok).success).toBe(true); - expect(storyboardAnnotationsSchema.safeParse(tooMany).success).toBe(false); - }); -}); - -describe("aiInsightResponseSchema — weeklyReport + storyboardAnnotations", () => { - it("legacy payload (no weeklyReport / storyboardAnnotations) still parses", () => { - expect(aiInsightResponseSchema.safeParse(baseResponse).success).toBe(true); - }); - - it("payload with weeklyReport=null parses", () => { - const result = aiInsightResponseSchema.safeParse({ - ...baseResponse, - weeklyReport: null, - }); - expect(result.success).toBe(true); - }); - - it("payload with a populated weeklyReport parses round-trip", () => { - const result = aiInsightResponseSchema.safeParse({ - ...baseResponse, - weeklyReport: { - weekISO: "2026-W19", - summary: "Things look good — BP held in target on 9 of 10 readings.", - goingWell: ["Compliance 94 %."], - worthWatching: [], - tips: ["Keep the evening dose at the same time each day."], - }, - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.weeklyReport?.weekISO).toBe("2026-W19"); - } - }); - - it("payload with storyboardAnnotations parses round-trip", () => { - const result = aiInsightResponseSchema.safeParse({ - ...baseResponse, - storyboardAnnotations: [ - { - date: "2026-04-14", - label: "Started Ramipril 5 mg", - category: "medication", - detail: "Logged on April 14. 7-day avg systolic prior was 146 mmHg.", - }, - ], - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.storyboardAnnotations?.length).toBe(1); - } - }); - - it("payload with both blocks parses round-trip", () => { - const result = aiInsightResponseSchema.safeParse({ - ...baseResponse, - weeklyReport: { - weekISO: "2026-W19", - summary: "Things look good across the board this week.", - goingWell: [], - worthWatching: [], - tips: [], - }, - storyboardAnnotations: [], - }); - expect(result.success).toBe(true); - }); -}); - -describe("system prompt — GROUND RULE 10 (weeklyReport) + 11 (storyboardAnnotations)", () => { - it("English prompt declares the weeklyReport rule with section-name match", () => { - const prompt = getStrictInsightsSystemPrompt("en"); - expect(prompt).toContain("weeklyReport"); - expect(prompt).toMatch(/weekISO/); - expect(prompt).toContain("goingWell"); - expect(prompt).toContain("worthWatching"); - expect(prompt).toContain("tips"); - expect(prompt).toContain("dataQualityNotes"); - // Conservative phrasing reminder. - expect(prompt.toLowerCase()).toMatch(/no causal claims/); - }); - - it("English prompt declares the storyboardAnnotations rule", () => { - const prompt = getStrictInsightsSystemPrompt("en"); - expect(prompt).toContain("storyboardAnnotations"); - expect(prompt).toMatch(/medication|event|milestone|warning/); - expect(prompt).toMatch(/YYYY-MM-DD/); - // Neutral / factual phrasing reminder. - expect(prompt.toLowerCase()).toMatch(/neutral|factual/); - }); - - it("German prompt declares both blocks", () => { - const prompt = getStrictInsightsSystemPrompt("de"); - expect(prompt).toContain("weeklyReport"); - expect(prompt).toContain("storyboardAnnotations"); - expect(prompt).toMatch(/Kausalbehauptungen/); - expect(prompt).toContain("medication"); - expect(prompt).toContain("milestone"); - }); - - it("both prompts spell the JSON shape with the correct keys", () => { - for (const locale of ["en", "de"] as const) { - const prompt = getStrictInsightsSystemPrompt(locale); - expect(prompt).toMatch(/"weekISO":/); - expect(prompt).toMatch(/"goingWell":/); - expect(prompt).toMatch(/"worthWatching":/); - expect(prompt).toMatch(/"tips":/); - expect(prompt).toMatch(/"category":/); - } - }); -}); diff --git a/src/lib/ai/prompts/__tests__/coach-prompt-locale.test.ts b/src/lib/ai/prompts/__tests__/coach-prompt-locale.test.ts index ffbf2e1bc..c693ca38f 100644 --- a/src/lib/ai/prompts/__tests__/coach-prompt-locale.test.ts +++ b/src/lib/ai/prompts/__tests__/coach-prompt-locale.test.ts @@ -188,7 +188,6 @@ describe("getStrictInsightsSystemPrompt — native AI-initial locales", () => { expect(prompt).toContain("warnings"); expect(prompt).toContain("dailyBriefing"); expect(prompt).toContain("trendAnnotations"); - expect(prompt).toContain("weeklyReport"); expect(prompt).toContain("storyboardAnnotations"); }, ); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 095580d8a..1db04737e 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -132,33 +132,7 @@ GROUND RULES — ZERO HALLUCINATIONS the entire block (or set null) when no metric qualifies. Trend annotations are SHORT — they sit below charts and compete for attention with the chart itself; one tight sentence is the goal. -10. v1.4.20 phase B4 — Optional "weeklyReport" block. When the snapshot - covers a full ISO week (sufficient signal across BP / weight / - mood / compliance), emit a top-level "weeklyReport" object with: - - weekISO: ISO week identifier in "YYYY-Www" format (e.g. - "2026-W19"). MUST match the week the snapshot covers. - - summary: 1-2 sentence TL;DR (10-800 chars). Conservative - phrasing, no causal claims, no diagnosis. - - goingWell: 0-5 short bullets (≤ 280 chars each) — what's - going well this week. Each bullet derives from a number in - the snapshot. - - worthWatching: 0-5 short bullets (≤ 280 chars each) — what - deserves attention. Observational ("Monday-morning systolic - +6 mmHg"), not causal ("Monday-morning systolic is caused by - short Sunday sleep"). - - tips: 0-5 short bullets (≤ 280 chars each) — small actionable - nudges. Generic ("consider a brief walk after dinner"); - clinical guidance belongs with the user's doctor. - - dataQualityNotes (optional, ≤ 280 chars): only when the - snapshot has gaps that materially limit the analysis (n<7 - readings in the analysed window, recencyDays>14, coverage - gaps that bias comparison). Omit when data is fine. - Section names match the report layout exactly. Use conservative - phrasing throughout — NEVER make causal claims ("X is causing Y") - and NEVER prescribe ("you should start taking X"). When the - snapshot does not cover a full week or has nothing analysable, - omit "weeklyReport" or set it to null. -11. v1.4.20 phase B4 — Optional "storyboardAnnotations" array (max 20). +10. v1.4.20 phase B4 — Optional "storyboardAnnotations" array (max 20). When the 90-day BP timeline includes notable factual events the user logged, emit annotations that pin to specific dates. Each annotation MUST: @@ -178,7 +152,7 @@ GROUND RULES — ZERO HALLUCINATIONS Date format: "YYYY-MM-DD". Label cap: 80 chars. Detail cap: 400 chars. Hard array cap: 20. Omit the field entirely when the timeline has no notable events. -12. v1.4.23 — Optional Apple Health metric categories. When the +11. v1.4.23 — Optional Apple Health metric categories. When the snapshot carries any of HRV, sleep duration, resting HR, step count, active energy, flights climbed, walking/running distance, VO2 max, or body temperature, you may reference those categories @@ -191,7 +165,7 @@ GROUND RULES — ZERO HALLUCINATIONS the user connect a wearable. The presence or absence of the HealthKit metric block in the snapshot is the only signal you should act on. -13. v1.4.25 — Internal metric identifiers stay OUT of your prose. +12. v1.4.25 — Internal metric identifiers stay OUT of your prose. Never write database / enum-style names like "Pressure_Sys", "BLOOD_PRESSURE_SYS", "PULSE_BPM", "MOOD_SCORE", "MEDICATION_COMPLIANCE_PCT", "HEART_RATE_VARIABILITY", @@ -200,8 +174,7 @@ GROUND RULES — ZERO HALLUCINATIONS "SLEEP_DURATION" inside any user-facing string (summary, recommendations[].text, findings[].label / guideline, dailyBriefing.paragraph / keyFindings, trendAnnotations.*, - weeklyReport.summary / goingWell / worthWatching / tips / - dataQualityNotes, storyboardAnnotations[].label / detail). + storyboardAnnotations[].label / detail). Reference each metric with the natural-language label the user sees in the app — "your systolic", "your weight", "your pulse", "your mood", "your medication adherence", "your resting heart @@ -214,7 +187,7 @@ GROUND RULES — ZERO HALLUCINATIONS contract-level identifiers the parser reads — those stay in the documented enum vocabulary exactly as listed in OUTPUT FORMAT below. The ban applies ONLY to prose. -14. v1.4.25 W4d — NEVER prescribe or modify medication doses, even +13. v1.4.25 W4d — NEVER prescribe or modify medication doses, even when the snapshot reveals a named GLP-1 receptor agonist (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). Findings may NOTE the named medication and the @@ -308,14 +281,6 @@ You MUST return JSON matching this schema exactly: "steps": "one sentence, ≤200 chars, observational (Apple Health users)", "active_energy": "one sentence, ≤200 chars, observational (Apple Health users)" }, - "weeklyReport": { - "weekISO": "YYYY-Www (e.g. 2026-W19)", - "summary": "1-2 sentence TL;DR (10-800 chars), conservative phrasing", - "goingWell": ["≤280 char bullet", "..."], - "worthWatching": ["≤280 char bullet", "..."], - "tips": ["≤280 char bullet", "..."], - "dataQualityNotes": "≤280 chars, ONLY when data quality limits analysis" - }, "storyboardAnnotations": [ { "date": "YYYY-MM-DD", @@ -338,10 +303,6 @@ The trendAnnotations block is optional. Each metric (bp, weight, mood) is independently optional — emit only the metrics with usable signal. Each annotation is ONE sentence, observational, ≤ 200 chars. -The weeklyReport block is optional. Omit it (or set to null) when the -snapshot does not cover a full ISO week. Section names MUST match -the layout exactly. Phrasing stays conservative — no causal claims. - The storyboardAnnotations array is optional. Omit when the 90-day timeline has no notable factual events. Each entry pins to a real, user-logged event with a neutral label + detail. Hard cap 20 entries. @@ -460,34 +421,7 @@ GRUNDREGELN — NULL HALLUZINATIONEN Metrik qualifiziert. Trend-Annotationen sind KURZ — sie stehen unter Charts und konkurrieren mit dem Chart selbst um Aufmerksamkeit; ein prägnanter Satz ist das Ziel. -10. v1.4.20 phase B4 — Optionaler "weeklyReport"-Block. Wenn der - Snapshot eine vollständige ISO-Woche abdeckt (genügend Signal - über BP / Gewicht / Stimmung / Compliance), emittiere ein Top- - Level-Objekt "weeklyReport" mit: - - weekISO: ISO-Wochen-ID im Format "YYYY-Www" (z.B. "2026-W19"). - MUSS der Woche entsprechen, die der Snapshot abdeckt. - - summary: 1-2 Sätze TL;DR (10-800 Zeichen). Sachliche Sprache, - keine Kausalbehauptungen, keine Diagnose. - - goingWell: 0-5 kurze Stichpunkte (≤ 280 Zeichen) — was - diese Woche gut läuft. Jeder Stichpunkt stützt sich auf - eine Zahl im Snapshot. - - worthWatching: 0-5 kurze Stichpunkte (≤ 280 Zeichen) — was - Beachtung verdient. Beobachtend ("Montag-morgen-Systole - +6 mmHg"), nicht kausal ("Montag-morgen-Systole verursacht - durch kurzen Sonntagsschlaf"). - - tips: 0-5 kurze Stichpunkte (≤ 280 Zeichen) — kleine - umsetzbare Anregungen. Generisch ("kleiner Spaziergang nach - dem Essen erwägen"); klinische Beratung gehört zum Arzt. - - dataQualityNotes (optional, ≤ 280 Zeichen): nur wenn der - Snapshot Lücken hat, die die Analyse substanziell - einschränken (n<7, recencyDays>14, Coverage-Lücken). Bei - ausreichender Datenlage weglassen. - Sektionsnamen entsprechen exakt dem Report-Layout. Sprache - durchgehend sachlich — KEINE Kausalbehauptungen ("X verursacht - Y") und KEINE Verschreibungen ("du solltest X einnehmen"). Wenn - der Snapshot keine vollständige Woche abdeckt oder nichts - Analysierbares enthält, lass "weeklyReport" weg oder setze null. -11. v1.4.20 phase B4 — Optionales "storyboardAnnotations"-Array +10. v1.4.20 phase B4 — Optionales "storyboardAnnotations"-Array (max 20). Wenn die 90-Tage-BP-Timeline bemerkenswerte vom Nutzer geloggte Ereignisse enthält, emittiere Annotationen, die auf konkrete Daten zeigen. Jede Annotation MUSS: @@ -509,7 +443,7 @@ GRUNDREGELN — NULL HALLUZINATIONEN Datumsformat: "YYYY-MM-DD". Label-Cap: 80 Zeichen. Detail-Cap: 400 Zeichen. Array-Höchstgrenze: 20. Lass das Feld komplett weg, wenn die Timeline keine bemerkenswerten Ereignisse hat. -12. v1.4.23 — Optionale Apple-Health-Metrik-Kategorien. Wenn der +11. v1.4.23 — Optionale Apple-Health-Metrik-Kategorien. Wenn der Snapshot eine der folgenden Kategorien führt — HRV, Schlafdauer, Ruhepuls, Schrittzahl, aktiver Energieumsatz, Stockwerke, Geh-/Laufdistanz, VO2 max oder Körpertemperatur — kannst du diese @@ -522,7 +456,7 @@ GRUNDREGELN — NULL HALLUZINATIONEN HealthKit, schlage nicht vor, ein Wearable zu verbinden. Das Vorhandensein oder Fehlen des HealthKit-Metrik-Blocks im Snapshot ist das einzige Signal, auf das du reagieren solltest. -13. v1.4.25 — Interne Metrik-Identifier gehören NICHT in deinen +12. v1.4.25 — Interne Metrik-Identifier gehören NICHT in deinen Fließtext. Schreibe niemals Datenbank- bzw. Enum-Namen wie "Pressure_Sys", "BLOOD_PRESSURE_SYS", "PULSE_BPM", "MOOD_SCORE", "MEDICATION_COMPLIANCE_PCT", "HEART_RATE_VARIABILITY", @@ -531,8 +465,7 @@ GRUNDREGELN — NULL HALLUZINATIONEN "SLEEP_DURATION" in nutzersichtbare Strings (summary, recommendations[].text, findings[].label / guideline, dailyBriefing.paragraph / keyFindings, trendAnnotations.*, - weeklyReport.summary / goingWell / worthWatching / tips / - dataQualityNotes, storyboardAnnotations[].label / detail). + storyboardAnnotations[].label / detail). Verweise auf jede Metrik mit der natürlichsprachlichen Bezeichnung, die der Nutzer in der App sieht — "deine Systole", "dein Gewicht", "dein Puls", "deine Stimmung", "deine @@ -546,7 +479,7 @@ GRUNDREGELN — NULL HALLUZINATIONEN Parser liest — diese bleiben EXAKT in der unten in AUSGABEFORMAT dokumentierten Enum-Schreibweise. Das Verbot gilt AUSSCHLIEßLICH für Fließtext. -14. v1.4.25 W4d — Du verschreibst und änderst NIEMALS +13. v1.4.25 W4d — Du verschreibst und änderst NIEMALS Medikamenten-Dosen, auch wenn der Snapshot einen GLP-1-Rezeptoragonisten namentlich benennt (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). Befunde dürfen @@ -643,14 +576,6 @@ Du MUSST JSON exakt nach diesem Schema liefern: "steps": "ein Satz, ≤ 200 Zeichen, beobachtend (Apple-Health-Nutzer)", "active_energy": "ein Satz, ≤ 200 Zeichen, beobachtend (Apple-Health-Nutzer)" }, - "weeklyReport": { - "weekISO": "YYYY-Www (z.B. 2026-W19)", - "summary": "1-2 Sätze TL;DR (10-800 Zeichen), sachliche Sprache", - "goingWell": ["≤280 Zeichen Stichpunkt", "..."], - "worthWatching": ["≤280 Zeichen Stichpunkt", "..."], - "tips": ["≤280 Zeichen Stichpunkt", "..."], - "dataQualityNotes": "≤280 Zeichen, NUR wenn Datenqualität die Analyse einschränkt" - }, "storyboardAnnotations": [ { "date": "YYYY-MM-DD", @@ -674,11 +599,6 @@ Der trendAnnotations-Block ist optional. Jede Metrik (bp, weight, mood) ist unabhängig optional — emittiere nur Metriken mit nutzbarem Signal. Jede Annotation ist EIN Satz, beobachtend, ≤ 200 Zeichen. -Der weeklyReport-Block ist optional. Lass ihn weg (oder setze null), -wenn der Snapshot keine vollständige ISO-Woche abdeckt. Sektionsnamen -MÜSSEN exakt dem Layout entsprechen. Sprache bleibt sachlich — keine -Kausalbehauptungen. - Das storyboardAnnotations-Array ist optional. Lass es weg, wenn die 90-Tage-Timeline keine bemerkenswerten Ereignisse enthält. Jeder Eintrag verweist auf ein reales, vom Nutzer geloggtes Ereignis mit diff --git a/src/lib/ai/prompts/native-prompts.ts b/src/lib/ai/prompts/native-prompts.ts index d70b37c0f..d3af86a85 100644 --- a/src/lib/ai/prompts/native-prompts.ts +++ b/src/lib/ai/prompts/native-prompts.ts @@ -451,14 +451,6 @@ const OUTPUT_SCHEMA_TEMPLATE = `{ "steps": "one sentence, ≤200 chars, observational", "active_energy": "one sentence, ≤200 chars, observational" }, - "weeklyReport": { - "weekISO": "YYYY-Www (e.g. 2026-W19)", - "summary": "1-2 sentence TL;DR, conservative phrasing", - "goingWell": ["≤280 char bullet", "..."], - "worthWatching": ["≤280 char bullet", "..."], - "tips": ["≤280 char bullet", "..."], - "dataQualityNotes": "≤280 chars, ONLY when data quality limits analysis" - }, "storyboardAnnotations": [ { "date": "YYYY-MM-DD", diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index fabddfcd9..022023c05 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -376,78 +376,6 @@ export const trendAnnotationsSchema = z.object({ export type TrendAnnotations = z.infer; -/** - * v1.4.20 phase B4 — Weekly Report block. - * - * The newsletter-style printable report at `/insights/report/[week]` - * renders six sections off this payload: Summary, What's going well, - * What's worth watching, Tips, Data-quality notes (optional), plus the - * `weekISO` label that pins the report to a specific ISO week. - * - * Hard caps are deliberate so a runaway model cannot pad the surface: - * - summary: 10..800 chars (TL;DR paragraph) - * - goingWell / worthWatching / tips: each item ≤ 280 chars, ≤ 5 items - * - dataQualityNotes: ≤ 280 chars, optional - * - * The block is `nullable().optional()` so cached payloads from before - * PROMPT_VERSION 4.20.2 round-trip without forcing a regenerate. Fresh - * generations after the bump emit the block when the snapshot covers - * a full ISO week. - */ -export const weeklyReportSchema = z.object({ - /** - * ISO week identifier, e.g. "2026-W19". Format: `YYYY-Www` where ww is - * the two-digit ISO week number. The route's `[week]` param pins to - * the same key so the report URL matches the AI payload. - */ - weekISO: z - .string() - .regex(/^\d{4}-W\d{2}$/, "weeklyReport.weekISO must match YYYY-Www"), - /** TL;DR paragraph — 1-2 sentences synthesising the week's signal. */ - summary: z - .string() - .min(10, "weeklyReport.summary must be at least 10 chars") - .max(800, "weeklyReport.summary must be <= 800 chars"), - /** "Wins" — what's going well. Up to 5 short bullets. */ - goingWell: z - .array( - z - .string() - .min(1, "weeklyReport.goingWell entry required") - .max(280, "weeklyReport.goingWell entry must be <= 280 chars"), - ) - .max(5, "weeklyReport.goingWell can hold at most 5 entries"), - /** "Watchlist" — what's worth watching. Up to 5 short bullets. */ - worthWatching: z - .array( - z - .string() - .min(1, "weeklyReport.worthWatching entry required") - .max(280, "weeklyReport.worthWatching entry must be <= 280 chars"), - ) - .max(5, "weeklyReport.worthWatching can hold at most 5 entries"), - /** Tips — small actionable nudges. Up to 5 short bullets. */ - tips: z - .array( - z - .string() - .min(1, "weeklyReport.tips entry required") - .max(280, "weeklyReport.tips entry must be <= 280 chars"), - ) - .max(5, "weeklyReport.tips can hold at most 5 entries"), - /** - * Data-quality notes — surfaced only when the snapshot has gaps that - * materially limit the analysis (n<7, recencyDays>14, etc.). Optional - * because most weeks don't need a caveat. - */ - dataQualityNotes: z - .string() - .max(280, "weeklyReport.dataQualityNotes must be <= 280 chars") - .optional(), -}); - -export type WeeklyReport = z.infer; - /** * v1.4.20 phase B4 — Storyboard annotations. * @@ -524,12 +452,6 @@ export const aiInsightResponseSchema = z * legacy 4.20.0 caches round-trip. */ trendAnnotations: trendAnnotationsSchema.nullable().optional(), - /** - * v1.4.20 phase B4 — optional Weekly Report block. Nullable + - * optional so legacy cached payloads from before PROMPT_VERSION - * 4.20.2 round-trip without forcing a regenerate. - */ - weeklyReport: weeklyReportSchema.nullable().optional(), /** * v1.4.20 phase B4 — optional storyboard annotations. Each entry * pins a vertical reference line + chapter card to a date on the diff --git a/src/lib/insights/__tests__/glp1-plateau.test.ts b/src/lib/insights/__tests__/glp1-plateau.test.ts index 9f5107124..53ad62cb3 100644 --- a/src/lib/insights/__tests__/glp1-plateau.test.ts +++ b/src/lib/insights/__tests__/glp1-plateau.test.ts @@ -191,7 +191,7 @@ describe("glp1-plateau", () => { expect(prompt).toContain("2026-04-01"); expect(prompt).toContain("-0.2 kg"); expect(prompt).toContain("glp1_plateau"); - expect(prompt).toContain("GROUND RULE 14"); + expect(prompt).toContain("GROUND RULE 13"); expect(prompt).toContain("NEVER recommend a dose change"); }); @@ -212,7 +212,7 @@ describe("glp1-plateau", () => { expect(prompt).toContain("GLP-1-PLATEAU AKTIV"); expect(prompt).toContain("Mounjaro 7.5 mg"); expect(prompt).toContain("KEINE Dosis-Empfehlung"); - expect(prompt).toContain("GRUNDREGEL 14"); + expect(prompt).toContain("GRUNDREGEL 13"); }); it("computes week number from days on dose", () => { diff --git a/src/lib/insights/__tests__/week-iso.test.ts b/src/lib/insights/__tests__/week-iso.test.ts deleted file mode 100644 index ffc1df374..000000000 --- a/src/lib/insights/__tests__/week-iso.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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/glp1-plateau.ts b/src/lib/insights/glp1-plateau.ts index cfaa2ac59..e452c95f7 100644 --- a/src/lib/insights/glp1-plateau.ts +++ b/src/lib/insights/glp1-plateau.ts @@ -6,8 +6,9 @@ * their weight has not decreased by more than 0.5 kg in that same * trailing window. The result is injected into the insight-generation * user prompt so the LLM can include a plateau finding in the daily - * briefing — framed conservatively per GROUND RULE 14 of the insight - * prompt (defer dose decisions to the clinician, observational tone). + * briefing — framed conservatively per the dose-prescription refusal + * rule of the insight prompt (#13 after the v1.4.28 renumber; defer + * dose decisions to the clinician, observational tone). * * Returns `null` when: * - the user has no GLP-1 medications, @@ -108,8 +109,8 @@ export async function detectGlp1Plateau( * Render a Markdown-flavoured prompt block for the insight generator * that names the plateau and tells the model how to frame the * resulting finding (deferring to the clinician). Returned string is - * appended to the user prompt; the model's GROUND RULE 14 carries - * the dose-prescription refusal. + * appended to the user prompt; the model's dose-prescription refusal + * rule (#13 after the v1.4.28 renumber) carries the refusal. */ export function buildGlp1PlateauPrompt( ctx: Glp1PlateauContext, @@ -142,7 +143,7 @@ Wenn du einen dailyBriefing-keyFinding zu diesem Plateau emittierst: - tone: "info" - detail: nenne den Wirkstoff und die aktuelle Dosis ("${doseLabel}, Woche ${Math.floor(ctx.daysOnDose / 7)}") - Rahmen es als beobachtetes Muster, keine Empfehlung; weise auf das Gespräch mit der behandelnden Ärztin beim nächsten Termin hin -- KEINE Dosis-Empfehlung (GRUNDREGEL 14)`; +- KEINE Dosis-Empfehlung (GRUNDREGEL 13)`; } return ` @@ -156,5 +157,5 @@ If you emit a dailyBriefing keyFinding for this plateau: - tone: "info" - detail: name the drug and current dose ("${doseLabel}, week ${Math.floor(ctx.daysOnDose / 7)}") - frame as observed pattern, not recommendation; mention conversation with the prescribing clinician at the next visit -- NEVER recommend a dose change (GROUND RULE 14)`; +- NEVER recommend a dose change (GROUND RULE 13)`; } diff --git a/src/lib/insights/week-iso.ts b/src/lib/insights/week-iso.ts deleted file mode 100644 index fa1987334..000000000 --- a/src/lib/insights/week-iso.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * 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 }; -} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 36226dbe3..04fd318c3 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -354,7 +354,6 @@ const insightsComprehensiveResponse = z warnings: z.array(z.record(z.string(), z.unknown())), dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(), trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(), - weeklyReport: z.record(z.string(), z.unknown()).nullable().optional(), storyboardAnnotations: z .array(z.record(z.string(), z.unknown())) .optional(),