- {/*
- * 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 = (
- <>
-
- {t("insights.heroActionWeeklyReport")}
- >
- );
- return weeklyReportHref ? (
-
- ) : (
-
- );
- })()}
{/* 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. */}
@@ -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.eyebrow")}
-
-
-
- );
-}
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(),