chore(insights): retire the weekly-report surface

Drop the entire weekly-report path: the /insights/report/[week] route,
the WeeklyReportView component, the hero strip's banner mount and
"Wochenbericht erstellen" action button, the ISO-week helpers, the
schema slot, the AI prompt rule + OUTPUT FORMAT block, the openapi
field, the i18n keys and every associated test. Coach now owns the
sole hero-row action. iOS contract is unaffected — no native consumer
reads any weekly-report endpoint or schema slot per the v1.4.28 iOS
contract inventory.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 01:14:26 +02:00
1 parent 8e5f71b1b9
commit cad53a68a3
24 files changed
+64 -1632

No files matched your search

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