diff --git a/src/app/api/feedback/__tests__/route.test.ts b/src/app/api/feedback/__tests__/route.test.ts index 48af20465..dc9c96c7f 100644 --- a/src/app/api/feedback/__tests__/route.test.ts +++ b/src/app/api/feedback/__tests__/route.test.ts @@ -53,12 +53,11 @@ describe("POST /api/feedback bug-report toggle gate", () => { category: "BUG", subject: "test", description: "Anything at least ten characters long.", - }), + }) as never, ); expect(res.status).toBe(503); const body = (await res.json()) as { error: string }; expect(body.error).toMatch(/disabled/i); - // Must not even attempt to create the row when the toggle is off. expect(prisma.feedback.create).not.toHaveBeenCalled(); }); @@ -78,7 +77,7 @@ describe("POST /api/feedback bug-report toggle gate", () => { category: "BUG", subject: "test", description: "Anything at least ten characters long.", - }), + }) as never, ); expect(res.status).toBe(201); expect(prisma.feedback.create).toHaveBeenCalledTimes(1); @@ -98,7 +97,7 @@ describe("POST /api/feedback bug-report toggle gate", () => { category: "BUG", subject: "test", description: "Anything at least ten characters long.", - }), + }) as never, ); expect(res.status).toBe(201); expect(prisma.feedback.create).toHaveBeenCalledTimes(1); diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts new file mode 100644 index 000000000..0ca9a300b --- /dev/null +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +vi.mock("@/lib/medication-category", () => ({ + getMedicationCategories: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { resolveProvider } from "@/lib/ai/provider"; +import { getMedicationCategories } from "@/lib/medication-category"; +import { generateBloodPressureStatusForUser } from "../blood-pressure-status"; + +const dayMs = 24 * 60 * 60 * 1000; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getMedicationCategories).mockResolvedValue({}); +}); + +describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => { + it("emits {daily, monthly} per metric series with correct field shape", async () => { + const now = new Date(); + + // 3 years of SYS data — covers daily window + monthly window. + const records: Array<{ + type: string; + value: number; + measuredAt: Date; + }> = []; + for (let day = 0; day < 1000; day++) { + records.push({ + type: "BLOOD_PRESSURE_SYS", + value: 120 + (day % 10), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + records.push({ + type: "BLOOD_PRESSURE_DIA", + value: 80 + (day % 5), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async (args: { userPrompt: string }) => { + captured.userPrompt = args.userPrompt; + return { + content: '{"summary":"OK"}', + model: "x", + tokensUsed: 1, + }; + }), + } as never); + + await generateBloodPressureStatusForUser("user-1", { locale: "en" }); + + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + + const sys = snapshot.bloodPressure.systolic.series; + expect(sys).toHaveProperty("daily"); + expect(sys).toHaveProperty("monthly"); + expect(sys.daily.length).toBeGreaterThan(0); + expect(sys.monthly.length).toBeGreaterThan(0); + expect(sys.daily[0]).toHaveProperty("dayOffset"); + expect(sys.daily[0]).toHaveProperty("value"); + expect(sys.daily[0]).toHaveProperty("n"); + expect(sys.monthly[0]).toHaveProperty("monthOffset"); + expect(sys.monthly[0]).toHaveProperty("value"); + expect(sys.monthly[0]).toHaveProperty("n"); + + const dia = snapshot.bloodPressure.diastolic.series; + expect(dia.daily.length).toBeGreaterThan(0); + expect(dia.monthly.length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts new file mode 100644 index 000000000..7e7652b1e --- /dev/null +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +import { prisma } from "@/lib/db"; +import { resolveProvider } from "@/lib/ai/provider"; +import { generateBmiStatusForUser } from "../bmi-status"; + +const dayMs = 24 * 60 * 60 * 1000; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => { + it("emits {daily, monthly} BMI series derived from bucketed weight", async () => { + const now = new Date(); + const records: Array<{ value: number; measuredAt: Date }> = []; + for (let day = 0; day < 1000; day++) { + records.push({ + value: 80 + (day % 5), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: 175, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async (args: { userPrompt: string }) => { + captured.userPrompt = args.userPrompt; + return { + content: '{"summary":"OK"}', + model: "x", + tokensUsed: 1, + }; + }), + } as never); + + await generateBmiStatusForUser("user-1", { locale: "en" }); + + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + + const bmi = snapshot.bmi.series; + expect(bmi).toHaveProperty("daily"); + expect(bmi).toHaveProperty("monthly"); + expect(bmi.daily.length).toBeGreaterThan(0); + expect(bmi.monthly.length).toBeGreaterThan(0); + expect(bmi.daily[0]).toHaveProperty("dayOffset"); + expect(bmi.daily[0]).toHaveProperty("value"); + expect(bmi.daily[0]).toHaveProperty("n"); + expect(bmi.monthly[0]).toHaveProperty("monthOffset"); + expect(bmi.monthly[0]).toHaveProperty("value"); + expect(bmi.monthly[0]).toHaveProperty("n"); + }); +}); diff --git a/src/lib/insights/__tests__/bucket-series.test.ts b/src/lib/insights/__tests__/bucket-series.test.ts new file mode 100644 index 000000000..8ffb16c61 --- /dev/null +++ b/src/lib/insights/__tests__/bucket-series.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { applyPayloadBudget, bucketSeries } from "../bucket-series"; + +const dayMs = 24 * 60 * 60 * 1000; + +function pointsAtOffsets( + now: Date, + offsets: number[], + value: number, +): Array<{ measuredAt: Date; value: number }> { + return offsets.map((offset) => ({ + measuredAt: new Date(now.getTime() - offset * dayMs), + value, + })); +} + +describe("bucketSeries()", () => { + const now = new Date("2026-05-09T12:00:00Z"); + + it("buckets the most recent dailyDays as daily means", () => { + const records = pointsAtOffsets(now, [0, 0, 1, 1, 1, 5, 359], 100); + const { daily, monthly } = bucketSeries(records, { now }); + + // 4 distinct day offsets 0/1/5/359 + expect(daily.map((d) => d.dayOffset)).toEqual([0, 1, 5, 359]); + expect(daily[0]).toEqual({ dayOffset: 0, value: 100, n: 2 }); + expect(daily[1]).toEqual({ dayOffset: 1, value: 100, n: 3 }); + expect(monthly).toHaveLength(0); + }); + + it("rolls older history into 30-day monthly buckets up to 36 months", () => { + // dayOffset 360..1080 spans monthOffset 12..35 in the default config. + const records = pointsAtOffsets(now, [360, 389, 390, 1079, 1080], 50); + const { daily, monthly } = bucketSeries(records, { now }); + + // Day 360 falls into the monthly window — daily empty. + expect(daily).toHaveLength(0); + // 360..389 → monthOffset 12, 390..419 → monthOffset 13, + // 1050..1079 → monthOffset 35, 1080 → out of range. + const offsets = monthly.map((m) => m.monthOffset); + expect(offsets).toEqual([12, 13, 35]); + // 360 and 389 collapse into one bucket + expect(monthly[0].n).toBe(2); + }); + + it("skips empty buckets entirely", () => { + const records = pointsAtOffsets(now, [0, 720], 1); + const { daily, monthly } = bucketSeries(records, { now }); + expect(daily).toHaveLength(1); + expect(monthly).toHaveLength(1); + }); + + it("ignores future timestamps", () => { + const records = [ + { measuredAt: new Date(now.getTime() + dayMs), value: 5 }, + { measuredAt: now, value: 5 }, + ]; + const { daily } = bucketSeries(records, { now }); + expect(daily).toHaveLength(1); + expect(daily[0].dayOffset).toBe(0); + }); + + it("monthOffset labelling is stable regardless of dailyDays", () => { + // With dailyDays=180, monthOffsetBase shifts to 6. + const records = pointsAtOffsets(now, [180, 209, 210], 1); + const { daily, monthly } = bucketSeries(records, { now, dailyDays: 180 }); + expect(daily).toHaveLength(0); + expect(monthly[0].monthOffset).toBe(6); + expect(monthly[1].monthOffset).toBe(7); + }); +}); + +describe("applyPayloadBudget()", () => { + const now = new Date("2026-05-09T12:00:00Z"); + + it("returns the canonical 360+24 bucketing when payload is small", () => { + const records = pointsAtOffsets(now, [0, 1, 2], 100); + const result = applyPayloadBudget(records, { now }); + expect(result.daily).toHaveLength(3); + }); + + it("falls back to 180 daily days when the canonical payload exceeds the byte budget", () => { + // Synthesise enough records to bloat the canonical payload past 1 KB + // (test budget). 360 unique daily offsets each with 30 samples will + // serialise to ~30 KB in canonical form. + const offsets: number[] = []; + for (let day = 0; day < 360; day++) { + for (let i = 0; i < 30; i++) offsets.push(day); + } + const records = pointsAtOffsets(now, offsets, 12.5); + const result = applyPayloadBudget(records, { now }, 1_000); + // Daily window must have shrunk to 180 buckets at most. + expect(result.daily.length).toBeLessThanOrEqual(180); + }); +}); diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts new file mode 100644 index 000000000..fe98ba83e --- /dev/null +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +import { prisma } from "@/lib/db"; +import { resolveProvider } from "@/lib/ai/provider"; +import { generateGeneralStatusForUser } from "../general-status"; + +const dayMs = 24 * 60 * 60 * 1000; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => { + it("emits {daily, monthly} per metric series with dayOffset/monthOffset/value/n", async () => { + const now = new Date(); + + // 3 years of weight data — daily across the whole window so the + // bucketed output covers months 1-12 (daily) and 13-36 (monthly). + const weightRecords: Array<{ + type: string; + value: number; + measuredAt: Date; + }> = []; + for (let day = 0; day < 1000; day++) { + weightRecords.push({ + type: "WEIGHT", + value: 80 + (day % 5), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue( + weightRecords as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async (args: { userPrompt: string }) => { + captured.userPrompt = args.userPrompt; + return { + content: '{"summary":"OK"}', + model: "x", + tokensUsed: 1, + }; + }), + } as never); + + await generateGeneralStatusForUser("user-1", { locale: "en" }); + + expect(captured.userPrompt).not.toBeNull(); + // The snapshot JSON is embedded in the user prompt as a fenced block. + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + expect(match).not.toBeNull(); + const snapshot = JSON.parse(match![0]); + + const weight = snapshot.measurementSeries.WEIGHT.series; + expect(weight).toHaveProperty("daily"); + expect(weight).toHaveProperty("monthly"); + expect(Array.isArray(weight.daily)).toBe(true); + expect(Array.isArray(weight.monthly)).toBe(true); + expect(weight.daily.length).toBeGreaterThan(0); + expect(weight.monthly.length).toBeGreaterThan(0); + expect(weight.daily[0]).toHaveProperty("dayOffset"); + expect(weight.daily[0]).toHaveProperty("value"); + expect(weight.daily[0]).toHaveProperty("n"); + expect(weight.monthly[0]).toHaveProperty("monthOffset"); + expect(weight.monthly[0]).toHaveProperty("value"); + expect(weight.monthly[0]).toHaveProperty("n"); + }); +}); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts new file mode 100644 index 000000000..0a8cfa0ae --- /dev/null +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +vi.mock("@/lib/medication-category", () => ({ + getMedicationCategories: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { resolveProvider } from "@/lib/ai/provider"; +import { getMedicationCategories } from "@/lib/medication-category"; +import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status"; + +const dayMs = 24 * 60 * 60 * 1000; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getMedicationCategories).mockResolvedValue({}); +}); + +describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload", () => { + it("emits {daily, monthly} compliance series per medication spanning 3 years", async () => { + const now = new Date(); + const medication = { + id: "med-1", + name: "TestMed", + dose: "5mg", + active: true, + createdAt: new Date(now.getTime() - 1100 * dayMs), + schedules: [{ id: "s1", time: "08:00" }], + }; + + const events: Array<{ + medicationId: string; + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; + }> = []; + for (let day = 0; day < 1000; day++) { + const scheduledFor = new Date(now.getTime() - day * dayMs); + events.push({ + medicationId: "med-1", + scheduledFor, + takenAt: day % 3 === 0 ? null : scheduledFor, + skipped: false, + }); + } + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + events as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async (args: { userPrompt: string }) => { + captured.userPrompt = args.userPrompt; + return { + content: '{"summary":"OK","medications":[]}', + model: "x", + tokensUsed: 1, + }; + }), + } as never); + + await generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + + expect(snapshot.medications).toBeInstanceOf(Array); + expect(snapshot.medications.length).toBe(1); + const dailySeries = snapshot.medications[0].dailySeries; + expect(dailySeries).toHaveProperty("daily"); + expect(dailySeries).toHaveProperty("monthly"); + expect(dailySeries.daily.length).toBeGreaterThan(0); + expect(dailySeries.monthly.length).toBeGreaterThan(0); + expect(dailySeries.daily[0]).toHaveProperty("dayOffset"); + expect(dailySeries.daily[0]).toHaveProperty("value"); + expect(dailySeries.daily[0]).toHaveProperty("n"); + expect(dailySeries.monthly[0]).toHaveProperty("monthOffset"); + expect(dailySeries.monthly[0]).toHaveProperty("value"); + expect(dailySeries.monthly[0]).toHaveProperty("n"); + }); +}); diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts new file mode 100644 index 000000000..fec54dbf0 --- /dev/null +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +import { prisma } from "@/lib/db"; +import { resolveProvider } from "@/lib/ai/provider"; +import { generateMoodStatusForUser } from "../mood-status"; + +const dayMs = 24 * 60 * 60 * 1000; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => { + it("emits {daily, monthly} mood series spanning 3 years", async () => { + const now = new Date(); + const entries: Array<{ + date: string; + score: number; + tags: string[]; + moodLoggedAt: Date; + }> = []; + for (let day = 0; day < 1000; day++) { + const t = new Date(now.getTime() - day * dayMs); + entries.push({ + // production code keys mood buckets by `moodLoggedAt`, so the + // `date` string here is irrelevant for bucketing — keep it + // ISO-shaped so other downstream consumers don't choke. + date: t.toISOString().slice(0, 10), + score: 3 + ((day % 3) * 0.5), + tags: [], + moodLoggedAt: t, + }); + } + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue(entries as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async (args: { userPrompt: string }) => { + captured.userPrompt = args.userPrompt; + return { + content: '{"summary":"OK"}', + model: "x", + tokensUsed: 1, + }; + }), + } as never); + + await generateMoodStatusForUser("user-1", { locale: "en" }); + + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + + const mood = snapshot.mood.series; + expect(mood).toHaveProperty("daily"); + expect(mood).toHaveProperty("monthly"); + expect(mood.daily.length).toBeGreaterThan(0); + expect(mood.monthly.length).toBeGreaterThan(0); + expect(mood.daily[0]).toHaveProperty("dayOffset"); + expect(mood.daily[0]).toHaveProperty("value"); + expect(mood.daily[0]).toHaveProperty("n"); + expect(mood.monthly[0]).toHaveProperty("monthOffset"); + expect(mood.monthly[0]).toHaveProperty("value"); + expect(mood.monthly[0]).toHaveProperty("n"); + }); +}); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts new file mode 100644 index 000000000..8d77f1c2b --- /dev/null +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +import { prisma } from "@/lib/db"; +import { resolveProvider } from "@/lib/ai/provider"; +import { generatePulseStatusForUser } from "../pulse-status"; + +const dayMs = 24 * 60 * 60 * 1000; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => { + it("emits {daily, monthly} pulse series with dayOffset/monthOffset/value/n", async () => { + const now = new Date(); + const records: Array<{ value: number; measuredAt: Date }> = []; + for (let day = 0; day < 1000; day++) { + records.push({ + value: 70 + (day % 8), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async (args: { userPrompt: string }) => { + captured.userPrompt = args.userPrompt; + return { + content: '{"summary":"OK"}', + model: "x", + tokensUsed: 1, + }; + }), + } as never); + + await generatePulseStatusForUser("user-1", { locale: "en" }); + + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + + const pulse = snapshot.pulse.series; + expect(pulse).toHaveProperty("daily"); + expect(pulse).toHaveProperty("monthly"); + expect(pulse.daily.length).toBeGreaterThan(0); + expect(pulse.monthly.length).toBeGreaterThan(0); + expect(pulse.daily[0]).toHaveProperty("dayOffset"); + expect(pulse.daily[0]).toHaveProperty("value"); + expect(pulse.daily[0]).toHaveProperty("n"); + expect(pulse.monthly[0]).toHaveProperty("monthOffset"); + expect(pulse.monthly[0]).toHaveProperty("value"); + expect(pulse.monthly[0]).toHaveProperty("n"); + }); +}); diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts new file mode 100644 index 000000000..7d2101ae1 --- /dev/null +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +import { prisma } from "@/lib/db"; +import { resolveProvider } from "@/lib/ai/provider"; +import { generateWeightStatusForUser } from "../weight-status"; + +const dayMs = 24 * 60 * 60 * 1000; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => { + it("emits {daily, monthly} weight series with bucket fields", async () => { + const now = new Date(); + const records: Array<{ + type: string; + value: number; + measuredAt: Date; + }> = []; + for (let day = 0; day < 1000; day++) { + records.push({ + type: "WEIGHT", + value: 80 + (day % 5), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn(async (args: { userPrompt: string }) => { + captured.userPrompt = args.userPrompt; + return { + content: '{"summary":"OK"}', + model: "x", + tokensUsed: 1, + }; + }), + } as never); + + await generateWeightStatusForUser("user-1", { locale: "en" }); + + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + + const weight = snapshot.weight.series; + expect(weight).toHaveProperty("daily"); + expect(weight).toHaveProperty("monthly"); + expect(weight.daily.length).toBeGreaterThan(0); + expect(weight.monthly.length).toBeGreaterThan(0); + expect(weight.daily[0]).toHaveProperty("dayOffset"); + expect(weight.daily[0]).toHaveProperty("value"); + expect(weight.daily[0]).toHaveProperty("n"); + expect(weight.monthly[0]).toHaveProperty("monthOffset"); + expect(weight.monthly[0]).toHaveProperty("value"); + expect(weight.monthly[0]).toHaveProperty("n"); + }); +}); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 2fd7a7023..a607a5e8e 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -17,8 +17,13 @@ import { calculateCompliance } from "@/lib/analytics/compliance"; import { getMedicationCategories } from "@/lib/medication-category"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { getNoKeyBloodPressureStatusText } from "@/lib/insights/no-key-fallbacks"; +import { + applyPayloadBudget, + type DailyBucket, +} from "@/lib/insights/bucket-series"; +import { annotate } from "@/lib/logging/context"; -const BLOOD_PRESSURE_STATUS_POINTS = 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", @@ -60,28 +65,6 @@ function normalizeLocale(value: string | null | undefined): SupportedLocale { return value === "en" ? "en" : "de"; } -function aggregateDailyAverageSeries( - records: Array<{ measuredAt: Date; value: number }>, -) { - const byDay = new Map(); - - for (const record of records) { - const dayKey = toBerlinDayKey(record.measuredAt); - const current = byDay.get(dayKey) ?? { sum: 0, count: 0 }; - current.sum += record.value; - current.count += 1; - byDay.set(dayKey, current); - } - - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - samples: stats.count, - })); -} - function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; @@ -97,20 +80,22 @@ function summarizeSeries(series: Array<{ value: number }>) { }; } -function pairDailySeries( - seriesA: Array<{ day: string; value: number }>, - seriesB: Array<{ day: string; value: number }>, +function pairDailyBuckets( + seriesA: DailyBucket[], + seriesB: DailyBucket[], + now: Date, ): PairedPoint[] { - const mapB = new Map(seriesB.map((entry) => [entry.day, entry.value])); + const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); + const nowMs = now.getTime(); return seriesA .map((entry) => { - const b = mapB.get(entry.day); + const b = mapB.get(entry.dayOffset); if (b == null) return null; return { a: entry.value, b, - date: new Date(`${entry.day}T00:00:00.000Z`), + date: new Date(nowMs - entry.dayOffset * MS_PER_DAY), }; }) .filter((entry): entry is PairedPoint => entry !== null); @@ -194,48 +179,55 @@ export async function generateBloodPressureStatusForUser( }, }); - const weightSeries = aggregateDailyAverageSeries( + const now = new Date(); + + const weightSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "WEIGHT") .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - ).slice(-BLOOD_PRESSURE_STATUS_POINTS); + { now }, + ); - const sysSeries = aggregateDailyAverageSeries( + const sysSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "BLOOD_PRESSURE_SYS") .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - ).slice(-BLOOD_PRESSURE_STATUS_POINTS); + { now }, + ); - const diaSeries = aggregateDailyAverageSeries( + const diaSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "BLOOD_PRESSURE_DIA") .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - ).slice(-BLOOD_PRESSURE_STATUS_POINTS); + { now }, + ); const bpTargets = getBpTargets(user?.dateOfBirth ?? null); - const pairedBloodPressure = pairDailySeries(sysSeries, diaSeries).map( - (entry) => ({ - day: toBerlinDayKey(entry.date), - sys: entry.a, - dia: entry.b, - inTarget: - bpTargets == null - ? null - : entry.a >= bpTargets.sysLow && - entry.a <= bpTargets.sysHigh && - entry.b >= bpTargets.diaLow && - entry.b <= bpTargets.diaHigh, - }), - ); + const pairedBloodPressure = pairDailyBuckets( + sysSeries.daily, + diaSeries.daily, + now, + ).map((entry) => ({ + day: toBerlinDayKey(entry.date), + sys: entry.a, + dia: entry.b, + inTarget: + bpTargets == null + ? null + : entry.a >= bpTargets.sysLow && + entry.a <= bpTargets.sysHigh && + entry.b >= bpTargets.diaLow && + entry.b <= bpTargets.diaHigh, + })); const bpInTargetPctLast30DailyPoints = bpTargets == null || pairedBloodPressure.length === 0 @@ -248,7 +240,11 @@ export async function generateBloodPressureStatusForUser( 1, ); - const weightVsSystolicPairs = pairDailySeries(weightSeries, sysSeries); + const weightVsSystolicPairs = pairDailyBuckets( + weightSeries.daily, + sysSeries.daily, + now, + ); const weightVsSystolicCorrelation = pearsonCorrelation(weightVsSystolicPairs); const activeMedications = await prisma.medication.findMany({ @@ -326,14 +322,18 @@ export async function generateBloodPressureStatusForUser( takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1); } - const continuityVsSystolicSeries = sysSeries.map((point) => { - const taken = takenByDay.get(point.day) ?? 0; + const nowMs = now.getTime(); + const continuityVsSystolicSeries = sysSeries.daily.map((point) => { + const dayDate = new Date(nowMs - point.dayOffset * MS_PER_DAY); + const dayKey = toBerlinDayKey(dayDate); + const taken = takenByDay.get(dayKey) ?? 0; const continuityPct = expectedBpIntakesPerDay > 0 ? round(Math.min(1, taken / expectedBpIntakesPerDay) * 100, 1) : null; return { - day: point.day, + day: dayKey, + dayOffset: point.dayOffset, sys: point.value, continuityPct, }; @@ -346,29 +346,17 @@ export async function generateBloodPressureStatusForUser( select: { date: true, score: true, moodLoggedAt: true }, }); - const moodByDay = new Map(); - for (const entry of moodEntries) { - const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; - current.sum += entry.score; - current.count += 1; - moodByDay.set(entry.date, current); - } - const dailyMoodSeries = Array.from(moodByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - })) - .slice(-30); - - const moodMean = - dailyMoodSeries.length > 0 - ? round( - dailyMoodSeries.reduce((s, e) => s + e.value, 0) / - dailyMoodSeries.length, - 2, - ) - : null; + const moodSeries = applyPayloadBudget( + moodEntries.map((entry) => ({ + measuredAt: entry.moodLoggedAt, + value: entry.score, + })), + { now }, + ); + const moodSummary = summarizeSeries( + moodSeries.daily.map((bucket) => ({ value: bucket.value })), + ); + const moodMean = moodSummary?.mean ?? null; const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries .map((entry) => { @@ -376,7 +364,7 @@ export async function generateBloodPressureStatusForUser( return { a: entry.continuityPct, b: entry.sys, - date: new Date(`${entry.day}T00:00:00.000Z`), + date: new Date(nowMs - entry.dayOffset * MS_PER_DAY), }; }) .filter((entry): entry is PairedPoint => entry !== null); @@ -414,11 +402,15 @@ export async function generateBloodPressureStatusForUser( }, bloodPressure: { systolic: { - summary: summarizeSeries(sysSeries), + summary: summarizeSeries( + sysSeries.daily.map((bucket) => ({ value: bucket.value })), + ), series: sysSeries, }, diastolic: { - summary: summarizeSeries(diaSeries), + summary: summarizeSeries( + diaSeries.daily.map((bucket) => ({ value: bucket.value })), + ), series: diaSeries, }, paired: { @@ -453,16 +445,17 @@ export async function generateBloodPressureStatusForUser( }, bpMedications: medicationCompliance, moodContext: - dailyMoodSeries.length >= 3 + moodSeries.daily.length >= 3 ? { - points: dailyMoodSeries.length, + points: moodSeries.daily.length, mean: moodMean, - latest: dailyMoodSeries.at(-1)?.value ?? null, - series: dailyMoodSeries.slice(-10), + latest: moodSeries.daily[0]?.value ?? null, + series: moodSeries, moodVsSystolicCorrelation: (() => { - const moodVsSysPairs = pairDailySeries( - dailyMoodSeries, - sysSeries, + const moodVsSysPairs = pairDailyBuckets( + moodSeries.daily, + sysSeries.daily, + now, ); return pearsonCorrelation(moodVsSysPairs); })(), @@ -472,6 +465,11 @@ export async function generateBloodPressureStatusForUser( const snapshotJson = JSON.stringify(snapshot, null, 2); + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + const previousContext = await getPreviousInsightContext( userId, "blood-pressure-status", @@ -529,7 +527,6 @@ export async function generateBloodPressureStatusForUser( text: summary, providerType: provider.type, model: result.model ?? "unknown", - pointsPerMetric: BLOOD_PRESSURE_STATUS_POINTS, tokensUsed: result.tokensUsed ?? null, }), }, diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 5cf41d168..7c405e91f 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -7,8 +7,8 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; - -const BMI_STATUS_POINTS = 30; +import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", @@ -50,28 +50,6 @@ function normalizeLocale(value: string | null | undefined): SupportedLocale { return value === "en" ? "en" : "de"; } -function aggregateDailyAverageSeries( - records: Array<{ measuredAt: Date; value: number }>, -) { - const byDay = new Map(); - - for (const record of records) { - const dayKey = toBerlinDayKey(record.measuredAt); - const current = byDay.get(dayKey) ?? { sum: 0, count: 0 }; - current.sum += record.value; - current.count += 1; - byDay.set(dayKey, current); - } - - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - samples: stats.count, - })); -} - function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; @@ -174,21 +152,32 @@ export async function generateBmiStatusForUser( }, }); - const weightSeries = aggregateDailyAverageSeries( + const now = new Date(); + const weightSeries = applyPayloadBudget( measurements.map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - ).slice(-BMI_STATUS_POINTS); + { now }, + ); const heightFactor = (user.heightCm / 100) ** 2; - const bmiSeries = weightSeries.map((entry) => ({ - day: entry.day, - value: round(entry.value / heightFactor, 2), - })); + const bmiSeries = { + daily: weightSeries.daily.map((bucket) => ({ + dayOffset: bucket.dayOffset, + value: round(bucket.value / heightFactor, 2), + n: bucket.n, + })), + monthly: weightSeries.monthly.map((bucket) => ({ + monthOffset: bucket.monthOffset, + value: round(bucket.value / heightFactor, 2), + n: bucket.n, + })), + }; - const latestBmi = bmiSeries.at(-1) ?? null; - const previousBmi = bmiSeries.length > 1 ? (bmiSeries.at(-2) ?? null) : null; + // daily[0] = newest bucket (lowest dayOffset). + const latestBmi = bmiSeries.daily[0] ?? null; + const previousBmi = bmiSeries.daily[1] ?? null; const latestClassification = latestBmi ? classifyBMI(latestBmi.value) : null; const oldestMeasurement = @@ -220,11 +209,13 @@ export async function generateBmiStatusForUser( newestMeasurementDaysAgo, }, bmi: { - summary: summarizeSeries(bmiSeries), + summary: summarizeSeries( + bmiSeries.daily.map((bucket) => ({ value: bucket.value })), + ), series: bmiSeries, latestDayFocus: latestBmi ? { - day: latestBmi.day, + dayOffset: latestBmi.dayOffset, value: latestBmi.value, classification: latestClassification, deltaToPreviousDailyPoint: @@ -242,6 +233,11 @@ export async function generateBmiStatusForUser( const snapshotJson = JSON.stringify(snapshot, null, 2); + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + const previousContext = await getPreviousInsightContext( userId, "bmi-status", @@ -297,7 +293,6 @@ export async function generateBmiStatusForUser( text: summary, providerType: provider.type, model: result.model ?? "unknown", - pointsPerMetric: BMI_STATUS_POINTS, tokensUsed: result.tokensUsed ?? null, }), }, diff --git a/src/lib/insights/bucket-series.ts b/src/lib/insights/bucket-series.ts new file mode 100644 index 000000000..07040b70e --- /dev/null +++ b/src/lib/insights/bucket-series.ts @@ -0,0 +1,178 @@ +/** + * Shared "daily + monthly" bucketing for per-card insight payloads. + * + * The seven KI insight generators previously all clipped to `.slice(-30)` + * which gave the model a one-month window. v1.4.6 widens the window to + * roughly three years per metric while keeping the prompt token budget + * under control: + * + * - The most recent `dailyDays` (default 360) days are summarised as + * daily means, indexed by `dayOffset` (0 = today, 359 = 359 days ago). + * - Older history up to ~36 months ago is summarised as 30-day + * monthly means, indexed by `monthOffset` (12 = the bucket starting + * 360 days ago and going back 30 more days, …, 35 = ~36 months ago). + * + * Empty buckets are skipped — a user with only a handful of measurements + * does not produce 360 zero-rows of noise. + */ + +const BERLIN_TZ = "Europe/Berlin"; + +const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { + timeZone: BERLIN_TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface DailyBucket { + /** 0 = today (Berlin), 359 = 359 days ago. */ + dayOffset: number; + value: number; + n: number; +} + +export interface MonthlyBucket { + /** + * 12 = the 30-day window starting `dailyDays` days ago, + * 35 = the 30-day window starting (`dailyDays` + 23 * 30) days ago. + * The number is consistent with the convention that monthOffset 12 + * is "between 12 and 13 months back" so months 1-12 live in `daily`. + */ + monthOffset: number; + value: number; + n: number; +} + +export interface BucketedSeries { + daily: DailyBucket[]; + monthly: MonthlyBucket[]; +} + +export interface BucketOptions { + /** Number of recent days summarised as daily means. Default 360. */ + dailyDays?: number; + /** Number of 30-day monthly windows to add after the daily window. Default 24. */ + monthlyMonths?: number; + /** Reference "now". Default new Date(). */ + now?: Date; +} + +interface BerlinYmd { + year: number; + month: number; + day: number; +} + +function toBerlinYmd(date: Date): BerlinYmd { + const parts = BERLIN_DAY_FORMATTER.formatToParts(date); + const year = Number(parts.find((p) => p.type === "year")?.value); + const month = Number(parts.find((p) => p.type === "month")?.value); + const day = Number(parts.find((p) => p.type === "day")?.value); + if (!year || !month || !day) { + throw new Error("Could not derive Berlin Y-M-D"); + } + return { year, month, day }; +} + +/** UTC midnight of the Berlin calendar day containing `date`. */ +function utcMidnightOfBerlinDay(date: Date): number { + const { year, month, day } = toBerlinYmd(date); + return Date.UTC(year, month - 1, day); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * Day offset between two Berlin-day timestamps, in whole days. + * Always non-negative when `today >= record`. + */ +function dayOffsetBerlin(record: Date, todayMidnight: number): number { + const recordMidnight = utcMidnightOfBerlinDay(record); + return Math.round((todayMidnight - recordMidnight) / MS_PER_DAY); +} + +export function bucketSeries( + records: Array<{ measuredAt: Date; value: number }>, + options: BucketOptions = {}, +): BucketedSeries { + const dailyDays = options.dailyDays ?? 360; + const monthlyMonths = options.monthlyMonths ?? 24; + const monthSize = 30; + + const todayMidnight = utcMidnightOfBerlinDay(options.now ?? new Date()); + + const daily = new Map(); + const monthly = new Map(); + + // monthOffset 12 → first 30-day window past the daily horizon. Keeping + // the labelling stable regardless of `dailyDays` so prompts stay + // readable across the canonical 360 / fallback 180 configurations. + const monthOffsetBase = Math.round(dailyDays / monthSize); + const monthlyMaxOffset = monthOffsetBase + monthlyMonths - 1; + + for (const record of records) { + const offset = dayOffsetBerlin(record.measuredAt, todayMidnight); + if (offset < 0) continue; // future timestamp — ignore + + if (offset < dailyDays) { + const bucket = daily.get(offset) ?? { sum: 0, n: 0 }; + bucket.sum += record.value; + bucket.n += 1; + daily.set(offset, bucket); + continue; + } + + const monthOffset = + monthOffsetBase + Math.floor((offset - dailyDays) / monthSize); + if (monthOffset > monthlyMaxOffset) continue; + const bucket = monthly.get(monthOffset) ?? { sum: 0, n: 0 }; + bucket.sum += record.value; + bucket.n += 1; + monthly.set(monthOffset, bucket); + } + + const dailyOut: DailyBucket[] = Array.from(daily.entries()) + .sort(([a], [b]) => a - b) + .map(([dayOffset, agg]) => ({ + dayOffset, + value: round(agg.sum / agg.n), + n: agg.n, + })); + + const monthlyOut: MonthlyBucket[] = Array.from(monthly.entries()) + .sort(([a], [b]) => a - b) + .map(([monthOffset, agg]) => ({ + monthOffset, + value: round(agg.sum / agg.n), + n: agg.n, + })); + + return { daily: dailyOut, monthly: monthlyOut }; +} + +/** + * Apply the v1.4.6 "shrink the daily window if the JSON is too big" + * guard. Returns the input unchanged when the serialised payload would + * stay under `maxBytes`; otherwise re-buckets with the smaller daily + * window so the model still sees the monthly slice. + * + * The guard is intentionally a re-bucket rather than a slice because + * the daily array is ordered oldest → newest (low offset is recent); + * `.slice` would silently bias the window away from the present. + */ +export function applyPayloadBudget( + records: Array<{ measuredAt: Date; value: number }>, + options: BucketOptions = {}, + maxBytes = 50_000, + fallbackDailyDays = 180, +): BucketedSeries { + const series = bucketSeries(records, options); + const size = JSON.stringify(series).length; + if (size <= maxBytes) return series; + return bucketSeries(records, { ...options, dailyDays: fallbackDailyDays }); +} diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 0ea21c1c8..99ac459ea 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -11,8 +11,8 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; - -const GENERAL_STATUS_POINTS = 30; +import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", @@ -53,28 +53,6 @@ function normalizeSummaryText(value: string): string { return value.replace(/\s+/g, " ").trim(); } -function aggregateDailyAverageSeries( - records: Array<{ measuredAt: Date; value: number }>, -) { - const byDay = new Map(); - - for (const record of records) { - const dayKey = toBerlinDayKey(record.measuredAt); - const current = byDay.get(dayKey) ?? { sum: 0, count: 0 }; - current.sum += record.value; - current.count += 1; - byDay.set(dayKey, current); - } - - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - samples: stats.count, - })); -} - function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; @@ -172,21 +150,25 @@ export async function generateGeneralStatusForUser( }, }); + const now = new Date(); + const measurementSeries = Object.fromEntries( MEASUREMENT_TYPES.map((type) => { - const series = aggregateDailyAverageSeries( - measurements - .filter((measurement) => measurement.type === type) - .map((measurement) => ({ - measuredAt: measurement.measuredAt, - value: measurement.value, - })), - ).slice(-GENERAL_STATUS_POINTS); + const records = measurements + .filter((measurement) => measurement.type === type) + .map((measurement) => ({ + measuredAt: measurement.measuredAt, + value: measurement.value, + })); + + const series = applyPayloadBudget(records, { now }); return [ type, { - summary: summarizeSeries(series), + summary: summarizeSeries( + series.daily.map((bucket) => ({ value: bucket.value })), + ), series, }, ]; @@ -223,16 +205,17 @@ export async function generateGeneralStatusForUser( adherenceByDay.set(dayKey, bucket); } - const adherenceSeries = Array.from(adherenceByDay.entries()) + // Adherence series — bucket the per-day rate so the model gets the + // canonical 360+24 view. We collapse one rate per day first, then feed + // it into applyPayloadBudget over a synthesised `{measuredAt, value}` + // record list. + const adherenceRecords = Array.from(adherenceByDay.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([day, value]) => ({ - day, - rate: value.total > 0 ? round((value.taken / value.total) * 100, 1) : 0, - taken: value.taken, - skipped: value.skipped, - total: value.total, - })) - .slice(-GENERAL_STATUS_POINTS); + measuredAt: new Date(`${day}T00:00:00.000Z`), + value: value.total > 0 ? round((value.taken / value.total) * 100, 1) : 0, + })); + const adherenceSeries = applyPayloadBudget(adherenceRecords, { now }); // Fetch mood context (optional — for enrichment only) const moodEntries = await prisma.moodEntry.findMany({ @@ -241,52 +224,38 @@ export async function generateGeneralStatusForUser( select: { date: true, score: true, moodLoggedAt: true }, }); - const moodByDay = new Map(); - for (const entry of moodEntries) { - const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; - current.sum += entry.score; - current.count += 1; - moodByDay.set(entry.date, current); - } - const dailyMoodSeries = Array.from(moodByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - })) - .slice(-30); - - const moodMean = - dailyMoodSeries.length > 0 - ? round( - dailyMoodSeries.reduce((s, e) => s + e.value, 0) / - dailyMoodSeries.length, - 2, - ) - : null; + const moodRecords = moodEntries.map((entry) => ({ + measuredAt: entry.moodLoggedAt, + value: entry.score, + })); + const moodSeries = applyPayloadBudget(moodRecords, { now }); + const moodSummary = summarizeSeries( + moodSeries.daily.map((bucket) => ({ value: bucket.value })), + ); + const moodMean = moodSummary?.mean ?? null; const bpTargets = getBpTargets(user?.dateOfBirth ?? null); let bpInTargetLast30Days: number | null = null; if (bpTargets) { - const sysSeries = (measurementSeries.BLOOD_PRESSURE_SYS?.series ?? []).map( - (entry) => [entry.day, entry.value] as const, - ); + const sysDaily = measurementSeries.BLOOD_PRESSURE_SYS?.series.daily ?? []; const diaMap = new Map( - (measurementSeries.BLOOD_PRESSURE_DIA?.series ?? []).map((entry) => [ - entry.day, - entry.value, - ]), + (measurementSeries.BLOOD_PRESSURE_DIA?.series.daily ?? []).map( + (bucket) => [bucket.dayOffset, bucket.value] as const, + ), ); - const paired = sysSeries - .map(([day, sys]) => { - const dia = diaMap.get(day); + const paired = sysDaily + .map((bucket) => { + const dia = diaMap.get(bucket.dayOffset); if (dia == null) return null; - return { day, sys, dia }; + return { dayOffset: bucket.dayOffset, sys: bucket.value, dia }; }) .filter( - (entry): entry is { day: string; sys: number; dia: number } => !!entry, + (entry): entry is { dayOffset: number; sys: number; dia: number } => + !!entry, ) - .slice(-GENERAL_STATUS_POINTS); + // Keep the legacy "last 30 daily points" semantics — newest 30 + // paired daily buckets (dayOffset 0..29). + .filter((point) => point.dayOffset < 30); if (paired.length > 0) { const inTargetCount = paired.filter( @@ -333,7 +302,7 @@ export async function generateGeneralStatusForUser( measurementSeries, medicationAdherence: { summary: summarizeSeries( - adherenceSeries.map((entry) => ({ value: entry.rate })), + adherenceSeries.daily.map((bucket) => ({ value: bucket.value })), ), series: adherenceSeries, }, @@ -345,18 +314,23 @@ export async function generateGeneralStatusForUser( } : null, moodContext: - dailyMoodSeries.length >= 3 + moodSeries.daily.length >= 3 ? { - points: dailyMoodSeries.length, + points: moodSeries.daily.length, mean: moodMean, - latest: dailyMoodSeries.at(-1)?.value ?? null, - series: dailyMoodSeries.slice(-10), + latest: moodSeries.daily[0]?.value ?? null, + series: moodSeries, } : null, }; const snapshotJson = JSON.stringify(snapshot, null, 2); + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + // v1.4: pull the previous cached general-status into the prompt so // the model can compare to the user's last analysis. Falls back // gracefully when there's no history (first-run users). @@ -415,7 +389,6 @@ export async function generateGeneralStatusForUser( text: summary, providerType: provider.type, model: result.model ?? "unknown", - pointsPerMetric: GENERAL_STATUS_POINTS, tokensUsed: result.tokensUsed ?? null, }), }, diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 4dea86da3..2c163b918 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -12,8 +12,12 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { annotate } from "@/lib/logging/context"; -const MEDICATION_COMPLIANCE_STATUS_POINTS = 30; +// 360 daily days + 24 monthly windows ≈ 1080 days of intake history. +const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", @@ -146,8 +150,10 @@ export async function generateMedicationComplianceStatusForUser( ); const now = new Date(); + // v1.4.6 widens the audit window to ~3 years so the bucketed payload + // has both daily (last 360 days) and monthly (months 12-36) coverage. const rangeStart = new Date( - now.getTime() - MEDICATION_COMPLIANCE_STATUS_POINTS * 24 * 60 * 60 * 1000, + now.getTime() - COMPLIANCE_HISTORY_DAYS * MS_PER_DAY, ); const medicationEvents = await prisma.medicationIntakeEvent.findMany({ @@ -183,50 +189,37 @@ export async function generateMedicationComplianceStatusForUser( medication.createdAt, ); + // Collapse the events into one rate-per-day record, then run them + // through the shared bucketing helper so the model receives the + // canonical 360 daily + 24 monthly view per medication. const byDay = new Map< string, - { expected: number; taken: number; skipped: number } + { expected: number; taken: number; skipped: number; date: Date } >(); - for ( - let dayOffset = 0; - dayOffset < MEDICATION_COMPLIANCE_STATUS_POINTS; - dayOffset++ - ) { - const dayDate = new Date( - now.getTime() - - (MEDICATION_COMPLIANCE_STATUS_POINTS - 1 - dayOffset) * - 24 * - 60 * - 60 * - 1000, - ); - const dayKey = toBerlinDayKey(dayDate); - byDay.set(dayKey, { - expected: medication.schedules.length, - taken: 0, - skipped: 0, - }); - } + const expectedPerDay = Math.max(1, medication.schedules.length); for (const event of events) { const dayKey = toBerlinDayKey(event.scheduledFor); - const bucket = byDay.get(dayKey); - if (!bucket) continue; + const bucket = byDay.get(dayKey) ?? { + expected: medication.schedules.length, + taken: 0, + skipped: 0, + date: event.scheduledFor, + }; if (event.takenAt && !event.skipped) bucket.taken += 1; if (event.skipped) bucket.skipped += 1; + byDay.set(dayKey, bucket); } - const dailySeries = Array.from(byDay.entries()).map(([day, stats]) => { - const expected = Math.max(1, stats.expected); - return { - day, - expected: stats.expected, - taken: stats.taken, - skipped: stats.skipped, - missed: Math.max(0, stats.expected - stats.taken - stats.skipped), - rate: round(Math.min(100, (stats.taken / expected) * 100), 1), - }; - }); + const perDayRecords = Array.from(byDay.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map((stats) => ({ + measuredAt: stats.date, + value: round(Math.min(100, (stats.taken / expectedPerDay) * 100), 1), + })); + + const dailySeries = applyPayloadBudget(perDayRecords, { now }); + const latestDay = dailySeries.daily[0] ?? null; return { medicationId: medication.id, @@ -241,7 +234,7 @@ export async function generateMedicationComplianceStatusForUser( skipped7: compliance7.skipped, missed7: compliance7.missed, dailySeries, - latestDay: dailySeries.at(-1) ?? null, + latestDay, }; }); @@ -298,6 +291,11 @@ export async function generateMedicationComplianceStatusForUser( const snapshotJson = JSON.stringify(snapshot, null, 2); + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + const previousContext = await getPreviousInsightContext( userId, "medication-compliance-status", @@ -386,7 +384,6 @@ export async function generateMedicationComplianceStatusForUser( medications: medicationSummaries, providerType: provider.type, model: result.model ?? "unknown", - pointsPerMetric: MEDICATION_COMPLIANCE_STATUS_POINTS, tokensUsed: result.tokensUsed ?? null, }), }, diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 1b9a9e320..74714f9e2 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -10,8 +10,13 @@ import { pearsonCorrelation, type PairedPoint, } from "@/lib/analytics/correlations"; +import { + applyPayloadBudget, + type DailyBucket, +} from "@/lib/insights/bucket-series"; +import { annotate } from "@/lib/logging/context"; -const MOOD_STATUS_POINTS = 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", @@ -53,28 +58,6 @@ function normalizeLocale(value: string | null | undefined): SupportedLocale { return value === "en" ? "en" : "de"; } -function aggregateDailyAverageSeries( - records: Array<{ date: string; score: number }>, -) { - const byDay = new Map(); - - for (const record of records) { - const dayKey = record.date; - const current = byDay.get(dayKey) ?? { sum: 0, count: 0 }; - current.sum += record.score; - current.count += 1; - byDay.set(dayKey, current); - } - - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - samples: stats.count, - })); -} - function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; @@ -90,41 +73,22 @@ function summarizeSeries(series: Array<{ value: number }>) { }; } -function aggregateMeasurementDailySeries( - records: Array<{ measuredAt: Date; value: number }>, -) { - const byDay = new Map(); - - for (const record of records) { - const dayKey = toBerlinDayKey(record.measuredAt); - const current = byDay.get(dayKey) ?? { sum: 0, count: 0 }; - current.sum += record.value; - current.count += 1; - byDay.set(dayKey, current); - } - - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - })); -} - -function pairDailySeries( - seriesA: Array<{ day: string; value: number }>, - seriesB: Array<{ day: string; value: number }>, +function pairDailyBuckets( + seriesA: DailyBucket[], + seriesB: DailyBucket[], + now: Date, ): PairedPoint[] { - const mapB = new Map(seriesB.map((entry) => [entry.day, entry.value])); + const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); + const nowMs = now.getTime(); return seriesA .map((entry) => { - const b = mapB.get(entry.day); + const b = mapB.get(entry.dayOffset); if (b == null) return null; return { a: entry.value, b, - date: new Date(`${entry.day}T00:00:00.000Z`), + date: new Date(nowMs - entry.dayOffset * MS_PER_DAY), }; }) .filter((entry): entry is PairedPoint => entry !== null); @@ -199,33 +163,43 @@ export async function generateMoodStatusForUser( }, }); - const moodSeries = aggregateDailyAverageSeries( + const now = new Date(); + + const moodSeries = applyPayloadBudget( entries.map((entry) => ({ - date: entry.date, - score: entry.score, + measuredAt: entry.moodLoggedAt, + value: entry.score, })), - ).slice(-MOOD_STATUS_POINTS); + { now }, + ); + const moodSummary = summarizeSeries( + moodSeries.daily.map((bucket) => ({ value: bucket.value })), + ); const greenMin = 3.5; const greenMax = 5; const orangeMin = 2; const orangeMax = 3.5; + // "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29). + const recentMoodDaily = moodSeries.daily.filter( + (bucket) => bucket.dayOffset < 30, + ); const inTargetPctLast30DailyPoints = - moodSeries.length === 0 + recentMoodDaily.length === 0 ? null : round( - (moodSeries.filter( + (recentMoodDaily.filter( (entry) => entry.value >= greenMin && entry.value <= greenMax, ).length / - moodSeries.length) * + recentMoodDaily.length) * 100, 1, ); - const latestMood = moodSeries.at(-1) ?? null; - const previousMood = - moodSeries.length > 1 ? (moodSeries.at(-2) ?? null) : null; + // daily[0] = newest bucket (lowest dayOffset). + const latestMood = moodSeries.daily[0] ?? null; + const previousMood = moodSeries.daily[1] ?? null; const oldestEntry = entries.length > 0 ? entries[0].moodLoggedAt : null; const newestEntry = @@ -251,36 +225,58 @@ export async function generateMoodStatusForUser( select: { type: true, value: true, measuredAt: true }, }); - const weightSeries = aggregateMeasurementDailySeries( + const weightSeries = applyPayloadBudget( measurements .filter((m) => m.type === "WEIGHT") .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - ).slice(-MOOD_STATUS_POINTS); + { now }, + ); - const sysSeries = aggregateMeasurementDailySeries( + const sysSeries = applyPayloadBudget( measurements .filter((m) => m.type === "BLOOD_PRESSURE_SYS") .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - ).slice(-MOOD_STATUS_POINTS); + { now }, + ); - const pulseSeries = aggregateMeasurementDailySeries( + const pulseSeries = applyPayloadBudget( measurements .filter((m) => m.type === "PULSE") .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - ).slice(-MOOD_STATUS_POINTS); + { now }, + ); - // Correlations between mood and other metrics - const moodVsWeightPairs = pairDailySeries(moodSeries, weightSeries); + // Correlations between mood and other metrics — pair on dayOffset so + // the bucketed daily window aligns across metrics. + const moodVsWeightPairs = pairDailyBuckets( + moodSeries.daily, + weightSeries.daily, + now, + ); const moodVsWeightCorrelation = pearsonCorrelation(moodVsWeightPairs); - const moodVsSysPairs = pairDailySeries(moodSeries, sysSeries); + const moodVsSysPairs = pairDailyBuckets( + moodSeries.daily, + sysSeries.daily, + now, + ); const moodVsSysCorrelation = pearsonCorrelation(moodVsSysPairs); - const moodVsPulsePairs = pairDailySeries(moodSeries, pulseSeries); + const moodVsPulsePairs = pairDailyBuckets( + moodSeries.daily, + pulseSeries.daily, + now, + ); const moodVsPulseCorrelation = pearsonCorrelation(moodVsPulsePairs); - // Extract tag frequencies from recent entries - const recentEntries = entries.slice(-MOOD_STATUS_POINTS * 3); + // Extract tag frequencies from recent entries — keep the v1.4.5 + // ~90-day window so the model still gets a recency-weighted view of + // tag patterns. The mood DB query already pulls 3 years (no where + // filter); we slice by date here, not by record count. + const tagWindowCutoff = new Date(now.getTime() - 90 * MS_PER_DAY); + const recentEntries = entries.filter( + (entry) => entry.moodLoggedAt.getTime() >= tagWindowCutoff.getTime(), + ); const tagCounts = new Map(); for (const entry of recentEntries) { if (entry.tags && Array.isArray(entry.tags)) { @@ -312,11 +308,11 @@ export async function generateMoodStatusForUser( newestEntryDaysAgo, }, mood: { - summary: summarizeSeries(moodSeries), + summary: moodSummary, series: moodSeries, latestDayFocus: latestMood ? { - day: latestMood.day, + dayOffset: latestMood.dayOffset, value: latestMood.value, deltaToPreviousDailyPoint: previousMood == null @@ -334,28 +330,40 @@ export async function generateMoodStatusForUser( tags: tagSummary.length > 0 ? tagSummary : null, }, crossMetricContext: - weightSeries.length >= 3 || - sysSeries.length >= 3 || - pulseSeries.length >= 3 + weightSeries.daily.length >= 3 || + sysSeries.daily.length >= 3 || + pulseSeries.daily.length >= 3 ? { weight: - weightSeries.length >= 3 + weightSeries.daily.length >= 3 ? { - summary: summarizeSeries(weightSeries), + summary: summarizeSeries( + weightSeries.daily.map((bucket) => ({ + value: bucket.value, + })), + ), correlation: moodVsWeightCorrelation, } : null, bloodPressureSystolic: - sysSeries.length >= 3 + sysSeries.daily.length >= 3 ? { - summary: summarizeSeries(sysSeries), + summary: summarizeSeries( + sysSeries.daily.map((bucket) => ({ + value: bucket.value, + })), + ), correlation: moodVsSysCorrelation, } : null, pulse: - pulseSeries.length >= 3 + pulseSeries.daily.length >= 3 ? { - summary: summarizeSeries(pulseSeries), + summary: summarizeSeries( + pulseSeries.daily.map((bucket) => ({ + value: bucket.value, + })), + ), correlation: moodVsPulseCorrelation, } : null, @@ -365,6 +373,11 @@ export async function generateMoodStatusForUser( const snapshotJson = JSON.stringify(snapshot, null, 2); + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + const previousContext = await getPreviousInsightContext( userId, "mood-status", @@ -420,7 +433,6 @@ export async function generateMoodStatusForUser( text: summary, providerType: provider.type, model: result.model ?? "unknown", - pointsPerMetric: MOOD_STATUS_POINTS, tokensUsed: result.tokensUsed ?? null, }), }, diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index eb4663e33..e1a7e5a24 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -13,8 +13,8 @@ import { getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks"; - -const PULSE_STATUS_POINTS = 30; +import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", @@ -56,28 +56,6 @@ function normalizeLocale(value: string | null | undefined): SupportedLocale { return value === "en" ? "en" : "de"; } -function aggregateDailyAverageSeries( - records: Array<{ measuredAt: Date; value: number }>, -) { - const byDay = new Map(); - - for (const record of records) { - const dayKey = toBerlinDayKey(record.measuredAt); - const current = byDay.get(dayKey) ?? { sum: 0, count: 0 }; - current.sum += record.value; - current.count += 1; - byDay.set(dayKey, current); - } - - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - samples: stats.count, - })); -} - function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; @@ -169,12 +147,18 @@ export async function generatePulseStatusForUser( }, }); - const pulseSeries = aggregateDailyAverageSeries( + const now = new Date(); + + const pulseSeries = applyPayloadBudget( measurements.map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - ).slice(-PULSE_STATUS_POINTS); + { now }, + ); + const pulseSummary = summarizeSeries( + pulseSeries.daily.map((bucket) => ({ value: bucket.value })), + ); // Fetch mood context (optional — for enrichment only) const moodEntries = await prisma.moodEntry.findMany({ @@ -183,29 +167,17 @@ export async function generatePulseStatusForUser( select: { date: true, score: true, moodLoggedAt: true }, }); - const moodByDay = new Map(); - for (const entry of moodEntries) { - const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; - current.sum += entry.score; - current.count += 1; - moodByDay.set(entry.date, current); - } - const dailyMoodSeries = Array.from(moodByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - })) - .slice(-30); - - const moodMean = - dailyMoodSeries.length > 0 - ? round( - dailyMoodSeries.reduce((s, e) => s + e.value, 0) / - dailyMoodSeries.length, - 2, - ) - : null; + const moodSeries = applyPayloadBudget( + moodEntries.map((entry) => ({ + measuredAt: entry.moodLoggedAt, + value: entry.score, + })), + { now }, + ); + const moodSummary = summarizeSeries( + moodSeries.daily.map((bucket) => ({ value: bucket.value })), + ); + const moodMean = moodSummary?.mean ?? null; const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); const pulseTarget = getPersonalizedPulseTarget( @@ -213,23 +185,27 @@ export async function generatePulseStatusForUser( (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, ); + // "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29). + const recentPulseDaily = pulseSeries.daily.filter( + (bucket) => bucket.dayOffset < 30, + ); const inTargetPctLast30DailyPoints = - pulseSeries.length === 0 + recentPulseDaily.length === 0 ? null : round( - (pulseSeries.filter( + (recentPulseDaily.filter( (entry) => entry.value >= pulseTarget.greenMin && entry.value <= pulseTarget.greenMax, ).length / - pulseSeries.length) * + recentPulseDaily.length) * 100, 1, ); - const latestPulse = pulseSeries.at(-1) ?? null; - const previousPulse = - pulseSeries.length > 1 ? (pulseSeries.at(-2) ?? null) : null; + // Daily buckets are sorted oldest-first by dayOffset (lower = newer). + const latestPulse = pulseSeries.daily[0] ?? null; + const previousPulse = pulseSeries.daily[1] ?? null; const oldestMeasurement = measurements.length > 0 ? measurements[0].measuredAt : null; @@ -260,11 +236,11 @@ export async function generatePulseStatusForUser( newestMeasurementDaysAgo, }, pulse: { - summary: summarizeSeries(pulseSeries), + summary: pulseSummary, series: pulseSeries, latestDayFocus: latestPulse ? { - day: latestPulse.day, + dayOffset: latestPulse.dayOffset, value: latestPulse.value, deltaToPreviousDailyPoint: previousPulse == null @@ -281,18 +257,23 @@ export async function generatePulseStatusForUser( }, }, moodContext: - dailyMoodSeries.length >= 3 + moodSeries.daily.length >= 3 ? { - points: dailyMoodSeries.length, + points: moodSeries.daily.length, mean: moodMean, - latest: dailyMoodSeries.at(-1)?.value ?? null, - series: dailyMoodSeries.slice(-10), + latest: moodSeries.daily[0]?.value ?? null, + series: moodSeries, } : null, }; const snapshotJson = JSON.stringify(snapshot, null, 2); + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + const previousContext = await getPreviousInsightContext( userId, "pulse-status", @@ -348,7 +329,6 @@ export async function generatePulseStatusForUser( text: summary, providerType: provider.type, model: result.model ?? "unknown", - pointsPerMetric: PULSE_STATUS_POINTS, tokensUsed: result.tokensUsed ?? null, }), }, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index ceee5457d..b67d5b65c 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -13,8 +13,13 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { + applyPayloadBudget, + type DailyBucket, +} from "@/lib/insights/bucket-series"; +import { annotate } from "@/lib/logging/context"; -const WEIGHT_STATUS_POINTS = 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", @@ -56,28 +61,6 @@ function normalizeLocale(value: string | null | undefined): SupportedLocale { return value === "en" ? "en" : "de"; } -function aggregateDailyAverageSeries( - records: Array<{ measuredAt: Date; value: number }>, -) { - const byDay = new Map(); - - for (const record of records) { - const dayKey = toBerlinDayKey(record.measuredAt); - const current = byDay.get(dayKey) ?? { sum: 0, count: 0 }; - current.sum += record.value; - current.count += 1; - byDay.set(dayKey, current); - } - - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - samples: stats.count, - })); -} - function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; @@ -93,20 +76,22 @@ function summarizeSeries(series: Array<{ value: number }>) { }; } -function pairDailySeries( - seriesA: Array<{ day: string; value: number }>, - seriesB: Array<{ day: string; value: number }>, +function pairDailyBuckets( + seriesA: DailyBucket[], + seriesB: DailyBucket[], + now: Date, ): PairedPoint[] { - const mapB = new Map(seriesB.map((entry) => [entry.day, entry.value])); + const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); + const nowMs = now.getTime(); return seriesA .map((entry) => { - const b = mapB.get(entry.day); + const b = mapB.get(entry.dayOffset); if (b == null) return null; return { a: entry.value, b, - date: new Date(`${entry.day}T00:00:00.000Z`), + date: new Date(nowMs - entry.dayOffset * MS_PER_DAY), }; }) .filter((entry): entry is PairedPoint => entry !== null); @@ -183,32 +168,38 @@ export async function generateWeightStatusForUser( }, }); - const weightSeries = aggregateDailyAverageSeries( + const now = new Date(); + const nowMs = now.getTime(); + + const weightSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "WEIGHT") .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - ).slice(-WEIGHT_STATUS_POINTS); + { now }, + ); - const sysSeries = aggregateDailyAverageSeries( + const sysSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "BLOOD_PRESSURE_SYS") .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - ).slice(-WEIGHT_STATUS_POINTS); + { now }, + ); - const diaSeries = aggregateDailyAverageSeries( + const diaSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "BLOOD_PRESSURE_DIA") .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - ).slice(-WEIGHT_STATUS_POINTS); + { now }, + ); // Fetch mood context (optional — for enrichment only) const moodEntries = await prisma.moodEntry.findMany({ @@ -217,54 +208,67 @@ export async function generateWeightStatusForUser( select: { date: true, score: true, moodLoggedAt: true }, }); - const moodByDay = new Map(); - for (const entry of moodEntries) { - const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; - current.sum += entry.score; - current.count += 1; - moodByDay.set(entry.date, current); - } - const dailyMoodSeries = Array.from(moodByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: round(stats.sum / stats.count, 2), - })) - .slice(-30); + const moodSeries = applyPayloadBudget( + moodEntries.map((entry) => ({ + measuredAt: entry.moodLoggedAt, + value: entry.score, + })), + { now }, + ); + const moodSummary = summarizeSeries( + moodSeries.daily.map((bucket) => ({ value: bucket.value })), + ); + const moodMean = moodSummary?.mean ?? null; - const moodMean = - dailyMoodSeries.length > 0 - ? round( - dailyMoodSeries.reduce((s, e) => s + e.value, 0) / - dailyMoodSeries.length, - 2, - ) - : null; - - const weightVsSystolicPairs = pairDailySeries(weightSeries, sysSeries); + const weightVsSystolicPairs = pairDailyBuckets( + weightSeries.daily, + sysSeries.daily, + now, + ); const weightVsSystolicCorrelation = pearsonCorrelation(weightVsSystolicPairs); - const pairedSystolicDiastolic = pairDailySeries(sysSeries, diaSeries).map( - (entry) => ({ - day: toBerlinDayKey(entry.date), - sys: entry.a, - dia: entry.b, - mean: round((entry.a + entry.b) / 2, 2), - }), - ); - - const bpMeanSeries = pairedSystolicDiastolic.map((entry) => ({ - day: entry.day, - value: entry.mean, + const pairedSystolicDiastolic = pairDailyBuckets( + sysSeries.daily, + diaSeries.daily, + now, + ).map((entry) => ({ + day: toBerlinDayKey(entry.date), + sys: entry.a, + dia: entry.b, + mean: round((entry.a + entry.b) / 2, 2), })); - const weightVsMeanBpPairs = pairDailySeries(weightSeries, bpMeanSeries); + // Synthesise a daily-bucket-shaped BP-mean series so we can reuse + // pairDailyBuckets — derive dayOffset from the offsets in sysSeries. + const sysOffsetByDay = new Map( + sysSeries.daily.map((bucket) => { + const dayKey = toBerlinDayKey( + new Date(nowMs - bucket.dayOffset * MS_PER_DAY), + ); + return [dayKey, bucket.dayOffset]; + }), + ); + const bpMeanDaily: DailyBucket[] = pairedSystolicDiastolic + .map((entry) => { + const dayOffset = sysOffsetByDay.get(entry.day); + if (dayOffset == null) return null; + return { dayOffset, value: entry.mean, n: 1 }; + }) + .filter((entry): entry is DailyBucket => entry !== null); + + const weightVsMeanBpPairs = pairDailyBuckets( + weightSeries.daily, + bpMeanDaily, + now, + ); const weightVsMeanBpCorrelation = pearsonCorrelation(weightVsMeanBpPairs); - const latestWeight = weightSeries.at(-1) ?? null; - const previousWeight = - weightSeries.length > 1 ? (weightSeries.at(-2) ?? null) : null; - const latestWeightDay = latestWeight?.day ?? null; + // daily[0] = newest bucket (lowest dayOffset). + const latestWeight = weightSeries.daily[0] ?? null; + const previousWeight = weightSeries.daily[1] ?? null; + const latestWeightDay = latestWeight + ? toBerlinDayKey(new Date(nowMs - latestWeight.dayOffset * MS_PER_DAY)) + : null; const sameDayBp = latestWeightDay ? (pairedSystolicDiastolic.find((entry) => entry.day === latestWeightDay) ?? null) @@ -299,11 +303,14 @@ export async function generateWeightStatusForUser( newestMeasurementDaysAgo, }, weight: { - summary: summarizeSeries(weightSeries), + summary: summarizeSeries( + weightSeries.daily.map((bucket) => ({ value: bucket.value })), + ), series: weightSeries, latestDayFocus: latestWeight ? { - day: latestWeight.day, + day: latestWeightDay, + dayOffset: latestWeight.dayOffset, value: latestWeight.value, deltaToPreviousDailyPoint: previousWeight == null @@ -315,11 +322,15 @@ export async function generateWeightStatusForUser( }, bloodPressureContext: { systolic: { - summary: summarizeSeries(sysSeries), + summary: summarizeSeries( + sysSeries.daily.map((bucket) => ({ value: bucket.value })), + ), series: sysSeries, }, diastolic: { - summary: summarizeSeries(diaSeries), + summary: summarizeSeries( + diaSeries.daily.map((bucket) => ({ value: bucket.value })), + ), series: diaSeries, }, pairedDaily: pairedSystolicDiastolic, @@ -341,16 +352,17 @@ export async function generateWeightStatusForUser( })), }, moodContext: - dailyMoodSeries.length >= 3 + moodSeries.daily.length >= 3 ? { - points: dailyMoodSeries.length, + points: moodSeries.daily.length, mean: moodMean, - latest: dailyMoodSeries.at(-1)?.value ?? null, - series: dailyMoodSeries.slice(-10), + latest: moodSeries.daily[0]?.value ?? null, + series: moodSeries, moodVsWeightCorrelation: (() => { - const moodVsWeightPairs = pairDailySeries( - dailyMoodSeries, - weightSeries, + const moodVsWeightPairs = pairDailyBuckets( + moodSeries.daily, + weightSeries.daily, + now, ); return pearsonCorrelation(moodVsWeightPairs); })(), @@ -360,6 +372,11 @@ export async function generateWeightStatusForUser( const snapshotJson = JSON.stringify(snapshot, null, 2); + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + const previousContext = await getPreviousInsightContext( userId, "weight-status", @@ -415,7 +432,6 @@ export async function generateWeightStatusForUser( text: summary, providerType: provider.type, model: result.model ?? "unknown", - pointsPerMetric: WEIGHT_STATUS_POINTS, tokensUsed: result.tokensUsed ?? null, }), },