From ed61b17f571168cd214c8c7d10d5485d2af231ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 19:21:48 +0200 Subject: [PATCH] feat(coach): snapshot includes day-level readings with weekday labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Coach snapshot used to ship only aggregated statistics (mean, slope, SD, range, count) per metric. A turn that asked "why was BP higher on Monday?" had no day-level rows to ground in and the model fell back to apologetic aggregate-only phrasing ("from the snapshot I cannot isolate the specific Monday"). Extend buildCoachSnapshot to fold in: - timeline.recent: one row per UTC day for the last 14 days, each tagged with its weekday so the model can answer day or weekday questions directly. BP rows pair systolic + diastolic; days with only one side measured are dropped to avoid fabricating a complement. - timeline.weekly: ISO-week buckets covering days 15..N where N is the configured window. Keeps the prompt budget tight on 30/90-day windows while still letting the Coach cite older weeks. - per-day medication adherence rows from MedicationIntakeEvent so the Coach can answer "did I miss my dose Tuesday?" without inventing the schedule. Also add the optional `scope` field to coachChatRequestSchema (sources + window) so the upcoming sources-rail picker can narrow the snapshot without breaking older clients — missing fields fall through to all-source last30days. A typical 30-day, 5-source snapshot lands around 2.4-2.8 KB JSON (≈700-900 prompt tokens) — comfortably inside the 25k/day Coach token budget. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/insights/chat/route.ts | 9 +- src/lib/ai/coach/__tests__/snapshot.test.ts | 138 +++++++ src/lib/ai/coach/snapshot.ts | 424 +++++++++++++++++++- src/lib/ai/coach/types.ts | 34 ++ 4 files changed, 581 insertions(+), 24 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/snapshot.test.ts diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 3b9bf1cd0..69b18b5c1 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -143,7 +143,12 @@ async function handleChatRequest(request: NextRequest): Promise { { status: 422 }, ); } - const { conversationId, message, locale: bodyLocale } = parsed.data; + const { + conversationId, + message, + locale: bodyLocale, + scope, + } = parsed.data; await enforceBudget(userId); @@ -201,7 +206,7 @@ async function handleChatRequest(request: NextRequest): Promise { // Build the prompt: system + (optional) snapshot + recent history + // the new user message. - const snapshot = await buildCoachSnapshot(userId); + const snapshot = await buildCoachSnapshot(userId, scope); const systemPrompt = getCoachSystemPrompt(locale); const window = buildHistoryWindow([ ...priorTurns, diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts new file mode 100644 index 000000000..1cb846662 --- /dev/null +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { buildCoachSnapshot } from "../snapshot"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/insights/features", () => ({ + extractFeatures: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { extractFeatures } from "@/lib/insights/features"; + +const prismaMock = prisma as unknown as { + measurement: { findMany: ReturnType }; + moodEntry: { findMany: ReturnType }; + medicationIntakeEvent: { findMany: ReturnType }; +}; +const featuresMock = extractFeatures as unknown as ReturnType; + +/** + * Helper: produce a measurement row N days before "now" at 09:00 UTC. + */ +function daysAgo(n: number, value: number, type: string): { + type: string; + value: number; + measuredAt: Date; +} { + const ms = Date.now() - n * 24 * 60 * 60 * 1000; + const d = new Date(ms); + d.setUTCHours(9, 0, 0, 0); + return { type, value, measuredAt: d }; +} + +describe("buildCoachSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.measurement.findMany.mockResolvedValue([]); + prismaMock.moodEntry.findMany.mockResolvedValue([]); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + featuresMock.mockResolvedValue({ + bloodPressure: undefined, + weight: undefined, + pulse: undefined, + mood: undefined, + }); + }); + + it("returns a 'general'-only provenance when nothing is in the log", async () => { + const out = await buildCoachSnapshot("user-1"); + expect(out.provenance.metrics).toContain("general"); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.scope.window).toBe("last30days"); + }); + + it("includes day-level BP rows with weekday labels for the recent window", async () => { + featuresMock.mockResolvedValue({ + bloodPressure: { + avgSys30: 138, + avgDia30: 85, + coverage: { count: 4 }, + }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 142, "BLOOD_PRESSURE_SYS"), + daysAgo(2, 92, "BLOOD_PRESSURE_DIA"), + daysAgo(5, 130, "BLOOD_PRESSURE_SYS"), + daysAgo(5, 80, "BLOOD_PRESSURE_DIA"), + ]); + + const out = await buildCoachSnapshot("user-1"); + const parsed = JSON.parse(out.snapshotJson); + const recent = parsed.bloodPressure.timeline.recent as Array<{ + date: string; + weekday: string; + sys: number; + dia: number; + }>; + expect(recent.length).toBe(2); + expect(recent[0]).toMatchObject({ sys: expect.any(Number) }); + expect(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]).toContain( + recent[0].weekday, + ); + expect(out.provenance.metrics).toContain("bp"); + }); + + it("respects the scope.sources filter — excluded metrics drop out", async () => { + featuresMock.mockResolvedValue({ + bloodPressure: { avgSys30: 138, coverage: { count: 4 } }, + weight: { latest: 80, coverage: { count: 4 } }, + }); + + const out = await buildCoachSnapshot("user-1", { + sources: ["weight"], + window: "last30days", + }); + expect(out.provenance.metrics).toContain("weight"); + expect(out.provenance.metrics).not.toContain("bp"); + // Snapshot shouldn't mention BP either + expect(out.snapshotJson).not.toContain("bloodPressure"); + }); + + it("respects the scope.window — last7days yields a tighter window", async () => { + featuresMock.mockResolvedValue({ + pulse: { avg7: 70, coverage: { count: 4 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 70, "PULSE"), + ]); + + const out = await buildCoachSnapshot("user-1", { + sources: ["pulse"], + window: "last7days", + }); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.scope.window).toBe("last7days"); + expect(parsed.pulse.timeline.recent.length).toBe(1); + }); + + it("defaults to all-source last30days when no scope is provided", async () => { + const out = await buildCoachSnapshot("user-1"); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.scope.sources).toEqual([ + "bp", + "weight", + "pulse", + "mood", + "compliance", + ]); + expect(parsed.scope.window).toBe("last30days"); + }); +}); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 96ddd8867..855fdaf2e 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -6,9 +6,23 @@ * truth for every "your avg30 BP is …" claim. The output is a compact * JSON block the system + user prompt frame as the SNAPSHOT for the * model to ground its reply in. + * + * v1.4.20.1 — extended with a day-level `timeline` block. The earlier + * shipping shape carried only aggregated statistics (mean, slope, SD, + * range, count) per metric, so a Coach turn could not answer questions + * keyed to a specific day or weekday ("why was BP higher last + * Monday?"). The timeline now ships the last 14 days as raw daily + * values with weekday labels and aggregates the older window into + * weekly buckets so the prompt budget stays tight. */ +import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; -import type { CoachProvenance } from "./types"; +import type { + CoachProvenance, + CoachScope, + CoachScopeSource, + CoachScopeWindow, +} from "./types"; export interface CoachSnapshotResult { snapshotJson: string; @@ -20,25 +34,234 @@ export interface CoachSnapshotResult { provenance: CoachProvenance; } +/** + * Day-level cap for the raw timeline. Days within this window are kept + * verbatim (one entry per day with weekday). Older days inside the + * snapshot window are folded into weekly means so a 90-day window + * lands at ~14 day-rows + ~11 week-rows ≈ 25 rows per metric — well + * under the 3 000-token Coach turn budget on a 5-metric snapshot. + */ +const DAILY_TIMELINE_DAYS = 14; + +/** Default window when the caller doesn't pass a scope. */ +const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; +const DEFAULT_SOURCES: ReadonlyArray = [ + "bp", + "weight", + "pulse", + "mood", + "compliance", +]; + +const WEEKDAY_KEYS = [ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", +] as const; + +function windowToDays(window: CoachScopeWindow): number { + switch (window) { + case "last7days": + return 7; + case "last30days": + return 30; + case "last90days": + return 90; + case "allTime": + // Cap "allTime" at one year for the timeline. The aggregate + // section already cites multi-year ranges via the features + // pipeline; the timeline stays tight to keep token budget sane. + return 365; + } +} + +/** UTC YYYY-MM-DD key — the same partition the dashboard uses. */ +function utcDayKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** 0..6 → "Sun".."Sat" using UTC weekday so display is timezone-stable. */ +function utcWeekday(date: Date): string { + return WEEKDAY_KEYS[date.getUTCDay()]; +} + +/** ISO week key like 2026-W19 — used for the weekly-bucket section. */ +function isoWeekKey(date: Date): string { + // Copy date and align to Thursday of the same ISO week so the + // year-week pair matches the standard ISO 8601 calendar. + const copy = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + const dayNum = copy.getUTCDay() || 7; + copy.setUTCDate(copy.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(copy.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil( + ((copy.getTime() - yearStart.getTime()) / 86400000 + 1) / 7, + ); + return `${copy.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; +} + +interface DailyValueRow { + date: string; + weekday: string; + value: number; +} + +interface DailyBpRow { + date: string; + weekday: string; + sys: number; + dia: number; +} + +interface WeeklyBucket { + weekISO: string; + mean: number; + count: number; +} + +/** + * Group raw measurements (one per row, possibly several per day) into + * one entry per UTC day. Multiple readings on the same day are folded + * into the daily mean — clinically the morning reading is what the + * Coach is usually asked about, but the snapshot stays pre-clinical + * and takes the straight mean to avoid presenting a fabricated number. + */ +function dailyMeans( + rows: T[], +): Map { + const grouped = new Map(); + for (const r of rows) { + const key = utcDayKey(r.measuredAt); + const existing = grouped.get(key); + if (existing) { + existing.values.push(r.value); + } else { + grouped.set(key, { date: r.measuredAt, values: [r.value] }); + } + } + return grouped; +} + +function bucketWeekly( + rows: Array<{ measuredAt: Date; value: number }>, +): WeeklyBucket[] { + const grouped = new Map(); + for (const r of rows) { + const key = isoWeekKey(r.measuredAt); + const list = grouped.get(key); + if (list) { + list.push(r.value); + } else { + grouped.set(key, [r.value]); + } + } + return Array.from(grouped.entries()) + .map(([weekISO, values]) => ({ + weekISO, + mean: Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) / 10, + count: values.length, + })) + .sort((a, b) => a.weekISO.localeCompare(b.weekISO)); +} + +function buildDailyValueRows( + rows: Array<{ measuredAt: Date; value: number }>, + recentCutoff: Date, +): DailyValueRow[] { + const recent = rows.filter((r) => r.measuredAt >= recentCutoff); + const grouped = dailyMeans(recent); + return Array.from(grouped.entries()) + .map(([date, info]) => { + const mean = + info.values.reduce((s, v) => s + v, 0) / info.values.length; + return { + date, + weekday: utcWeekday(info.date), + value: Math.round(mean * 10) / 10, + }; + }) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * Pair systolic + diastolic into one row per day. Days with only one + * side measured are dropped — the Coach is asked about "BP" as a pair, + * and a half-measured day would invite a fabricated complement. + */ +function buildDailyBpRows( + sysRows: Array<{ measuredAt: Date; value: number }>, + diaRows: Array<{ measuredAt: Date; value: number }>, + recentCutoff: Date, +): DailyBpRow[] { + const sysRecent = sysRows.filter((r) => r.measuredAt >= recentCutoff); + const diaRecent = diaRows.filter((r) => r.measuredAt >= recentCutoff); + const sysByDay = dailyMeans(sysRecent); + const diaByDay = dailyMeans(diaRecent); + const out: DailyBpRow[] = []; + for (const [day, info] of sysByDay) { + const dia = diaByDay.get(day); + if (!dia) continue; + const sysMean = + info.values.reduce((s, v) => s + v, 0) / info.values.length; + const diaMean = + dia.values.reduce((s, v) => s + v, 0) / dia.values.length; + out.push({ + date: day, + weekday: utcWeekday(info.date), + sys: Math.round(sysMean), + dia: Math.round(diaMean), + }); + } + return out.sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * Resolve the working scope. Missing fields fall through to defaults + * so older native clients (no scope picker) keep getting the full + * 30-day, all-source snapshot they had before v1.4.20.1. + */ +function resolveScope(scope?: CoachScope): { + sources: ReadonlySet; + window: CoachScopeWindow; +} { + const sources = + scope?.sources && scope.sources.length > 0 + ? new Set(scope.sources) + : new Set(DEFAULT_SOURCES); + return { + sources, + window: scope?.window ?? DEFAULT_WINDOW, + }; +} + /** * Build the Coach prompt snapshot for `userId`. Always uses * `includeRaw=false` because the Coach replies are conversational and * the user is asking the model — they should never depend on raw * measurement timestamps that the privacy mode controls. * - * Bounded to the last 90 days. Coach narrates the same windows the - * Daily Briefing renders (last7 / last30 / last90); fetching every - * historical measurement on each turn was unbounded I/O for power - * users with multi-year Withings imports. The 90-day floor is the - * widest window any Coach metric currently consumes. + * The snapshot now carries two sections per active metric: + * - aggregate: the v1.4.20 shape (mean, slope, SD, range, count) + * - timeline.recent: last `DAILY_TIMELINE_DAYS` days as raw daily + * values with weekday labels so the Coach can answer + * day/weekday-specific questions + * - timeline.weekly: ISO-week buckets covering the rest of the + * window so the Coach can still cite older weeks without ballooning + * the prompt */ -const COACH_SNAPSHOT_WINDOW_DAYS = 90; - export async function buildCoachSnapshot( userId: string, + scope?: CoachScope, ): Promise { + const { sources, window } = resolveScope(scope); + const windowDays = windowToDays(window); const features = await extractFeatures(userId, false, { - sinceDays: COACH_SNAPSHOT_WINDOW_DAYS, + sinceDays: windowDays, }); // Trim down to the metrics the Coach narrates. extractFeatures @@ -50,45 +273,202 @@ export async function buildCoachSnapshot( const metrics = new Set(); const counts: NonNullable = {}; - if (features.bloodPressure) { - snapshot.bloodPressure = features.bloodPressure; + // Pull raw measurement rows once for the configured window so day + // and week buckets share a single I/O hop. Mood + compliance live in + // separate tables and are loaded conditionally below. + const now = new Date(); + const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + const recentCutoff = new Date( + now.getTime() - DAILY_TIMELINE_DAYS * 24 * 60 * 60 * 1000, + ); + + const wantsBp = sources.has("bp"); + const wantsWeight = sources.has("weight"); + const wantsPulse = sources.has("pulse"); + const wantsMood = sources.has("mood"); + const wantsCompliance = sources.has("compliance"); + + // Single fetch for all measurement types — Prisma's filter pushes + // the type list into one SQL `WHERE type IN (…)` so we don't pay + // per-metric round-trips. + const wantedTypes: string[] = []; + if (wantsBp) wantedTypes.push("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"); + if (wantsWeight) wantedTypes.push("WEIGHT"); + if (wantsPulse) wantedTypes.push("PULSE"); + + const measurementRows = + wantedTypes.length > 0 + ? await prisma.measurement.findMany({ + where: { + userId, + type: { in: wantedTypes as never[] }, + measuredAt: { gte: cutoff }, + }, + orderBy: { measuredAt: "asc" }, + select: { type: true, value: true, measuredAt: true }, + }) + : []; + + const byType = (t: string) => + measurementRows.filter((r) => r.type === t).map((r) => ({ + measuredAt: r.measuredAt, + value: r.value, + })); + + if (wantsBp && features.bloodPressure) { + const sysRows = byType("BLOOD_PRESSURE_SYS"); + const diaRows = byType("BLOOD_PRESSURE_DIA"); + const recentDaily = buildDailyBpRows(sysRows, diaRows, recentCutoff); + const olderSys = sysRows.filter((r) => r.measuredAt < recentCutoff); + const olderDia = diaRows.filter((r) => r.measuredAt < recentCutoff); + snapshot.bloodPressure = { + aggregate: features.bloodPressure, + timeline: { + recent: recentDaily, + weeklySys: bucketWeekly(olderSys), + weeklyDia: bucketWeekly(olderDia), + }, + }; metrics.add("bp"); windows.add("last30days"); windows.add("last90days"); counts.bp = features.bloodPressure.coverage?.count ?? undefined; } - if (features.weight) { - snapshot.weight = features.weight; + if (wantsWeight && features.weight) { + const rows = byType("WEIGHT"); + snapshot.weight = { + aggregate: features.weight, + timeline: { + recent: buildDailyValueRows(rows, recentCutoff), + weekly: bucketWeekly(rows.filter((r) => r.measuredAt < recentCutoff)), + }, + }; metrics.add("weight"); windows.add("last7days"); windows.add("last30days"); counts.weight = features.weight.coverage?.count ?? undefined; } - if (features.pulse) { - snapshot.pulse = features.pulse; + if (wantsPulse && features.pulse) { + const rows = byType("PULSE"); + snapshot.pulse = { + aggregate: features.pulse, + timeline: { + recent: buildDailyValueRows(rows, recentCutoff), + weekly: bucketWeekly(rows.filter((r) => r.measuredAt < recentCutoff)), + }, + }; metrics.add("pulse"); windows.add("last7days"); windows.add("last30days"); windows.add("last90days"); counts.pulse = features.pulse.coverage?.count ?? undefined; } - if (features.mood) { - snapshot.mood = features.mood; + if (wantsMood && features.mood) { + // Mood entries live on a separate model. Pull only the recent + // window for the day-level rows + bucket the rest. + const moodRows = await prisma.moodEntry.findMany({ + where: { userId, moodLoggedAt: { gte: cutoff } }, + orderBy: { moodLoggedAt: "asc" }, + select: { moodLoggedAt: true, score: true }, + }); + const normalised = moodRows.map((m) => ({ + measuredAt: m.moodLoggedAt, + value: m.score, + })); + snapshot.mood = { + aggregate: features.mood, + timeline: { + recent: buildDailyValueRows(normalised, recentCutoff), + weekly: bucketWeekly( + normalised.filter((r) => r.measuredAt < recentCutoff), + ), + }, + }; metrics.add("mood"); windows.add("last7days"); windows.add("last30days"); counts.mood = features.mood.coverage?.count ?? undefined; } - // Medication compliance lives outside the structured features today; - // it is surfaced via a separate route. Mark it as "general" provenance - // so the chip row indicates "no compliance data in the snapshot" when - // the Coach is asked about it. Future iteration can pull a compliance - // window into the snapshot directly. + + if (wantsCompliance) { + // Medication compliance lives outside the structured features + // today — the legacy Coach surface labelled it as "general" + // provenance only. v1.4.20.1 ships a per-day adherence row built + // from the intake-event log so the Coach can answer "did I miss + // my dose on Tuesday?" without inventing the schedule. + const intakeRows = await prisma.medicationIntakeEvent.findMany({ + where: { userId, scheduledFor: { gte: cutoff } }, + orderBy: { scheduledFor: "asc" }, + select: { scheduledFor: true, takenAt: true, skipped: true }, + }); + if (intakeRows.length > 0) { + // Per-day adherence rate within the recent window. Older days + // collapse into a single weekly bucket. + const recent = intakeRows.filter((r) => r.scheduledFor >= recentCutoff); + const olderRows = intakeRows.filter( + (r) => r.scheduledFor < recentCutoff, + ); + const recentByDay = new Map< + string, + { date: Date; total: number; taken: number } + >(); + for (const r of recent) { + const key = utcDayKey(r.scheduledFor); + const e = recentByDay.get(key) ?? { + date: r.scheduledFor, + total: 0, + taken: 0, + }; + e.total += 1; + if (r.takenAt && !r.skipped) e.taken += 1; + recentByDay.set(key, e); + } + const recentRows = Array.from(recentByDay.entries()) + .map(([date, info]) => ({ + date, + weekday: utcWeekday(info.date), + rate: Math.round((info.taken / info.total) * 100) / 100, + taken: info.taken, + total: info.total, + })) + .sort((a, b) => a.date.localeCompare(b.date)); + const olderByWeek = new Map(); + for (const r of olderRows) { + const key = isoWeekKey(r.scheduledFor); + const e = olderByWeek.get(key) ?? { taken: 0, total: 0 }; + e.total += 1; + if (r.takenAt && !r.skipped) e.taken += 1; + olderByWeek.set(key, e); + } + const weeklyRows = Array.from(olderByWeek.entries()) + .map(([weekISO, v]) => ({ + weekISO, + rate: v.total > 0 ? Math.round((v.taken / v.total) * 100) / 100 : 0, + taken: v.taken, + total: v.total, + })) + .sort((a, b) => a.weekISO.localeCompare(b.weekISO)); + snapshot.compliance = { + timeline: { recent: recentRows, weekly: weeklyRows }, + }; + metrics.add("compliance"); + counts.compliance = intakeRows.length; + } + } if (Object.keys(snapshot).length === 0) { metrics.add("general"); } + // Pin the scope onto the snapshot itself so the model knows which + // windows + sources are in-bounds for the reply. The system prompt + // tells the model to read from this block for day-level questions. + snapshot.scope = { + window, + sources: Array.from(sources), + timelineRecentDays: DAILY_TIMELINE_DAYS, + }; + return { snapshotJson: JSON.stringify(snapshot, null, 2), provenance: { diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index ce3015849..328c580fc 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -30,11 +30,45 @@ export type CoachMessageRole = z.infer; * - `locale` lets the route render the refusal copy in the user's * language without having to re-resolve it from cookies. */ +/** + * v1.4.20.1 — optional scope picker shipped with the per-source toggles + * + window selector on the Coach drawer's sources rail. The body lets + * the user narrow which metrics the snapshot ships and which window the + * timeline covers. Server defaults fill in any missing field so older + * native clients keep working — the field is fully back-compat. + */ +export const coachScopeWindowSchema = z.enum([ + "last7days", + "last30days", + "last90days", + "allTime", +]); + +export const coachScopeSourceSchema = z.enum([ + "bp", + "weight", + "pulse", + "mood", + "compliance", +]); + +export const coachScopeSchema = z.object({ + /** Which sources the snapshot may include. Empty array → no metrics. */ + sources: z.array(coachScopeSourceSchema).max(5).optional(), + /** Window the day-level timeline covers. Defaults to last30days. */ + window: coachScopeWindowSchema.optional(), +}); + +export type CoachScope = z.infer; +export type CoachScopeSource = z.infer; +export type CoachScopeWindow = z.infer; + export const coachChatRequestSchema = z.object({ conversationId: z.string().min(1).max(64).optional(), message: z.string().min(1).max(4000), prefill: z.string().max(2000).optional(), locale: z.enum(["en", "de"]).optional(), + scope: coachScopeSchema.optional(), }); export type CoachChatRequest = z.infer;