mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
8e5f71b1b9
commit
cad53a68a3
24 files changed
+64
-1632
No files matched your search
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user