feat(insights): widen per-card window from 30 days to 360+24 buckets

Every per-card KI insight generator was clipping each metric to the
last 30 daily means before sending the prompt. The model therefore
saw a one-month window per metric — far too narrow to detect drift
that plays out over a year, and the cause of repeated complaints that
a "Stable" trend assessment ignored an obvious 9-month decline.

New shared helper `applyPayloadBudget()` builds a `{ daily, monthly }`
view: the most recent 360 days as daily means (`dayOffset 0..359`)
plus 24 thirty-day windows for the older history (`monthOffset
12..35`). Empty buckets are dropped. If the canonical payload would
serialise above 50 KB the helper transparently re-buckets at 180
daily days so the prompt token budget stays bounded; a per-generator
`annotate({ payload_size_bytes })` makes the chosen window
observable in Wide Events.

All seven generators now consume the bucketed view as the metric
`series` field (general-status, blood-pressure-status, pulse-status,
weight-status, bmi-status, mood-status, medication-compliance-
status) — including their cross-metric joins (BP-pair, weight ↔ mean
BP correlation, medication-compliance per day) which are rebuilt on
top of the shared `dayOffset` instead of date strings. Mood and
medication queries widened to ~3 years so the older monthly window
can actually be filled.

One focused vitest per generator captures the prompt that goes to the
provider, JSON-parses the embedded snapshot, and asserts every
metric series carries both `daily` and `monthly` arrays with the
expected shape. The bucketing helper also has its own unit tests
covering offsets, leap-year-safe Berlin-day boundaries, and the
50 KB → 180-day fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 01:50:10 +02:00
1 parent c3ca8614a6
commit 1adda80944
17 files changed
+1379 -481

No files matched your search

+3 -4
View File
@@ -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);
@@ -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);
});
});
@@ -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");
});
});
@@ -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);
});
});
@@ -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");
});
});
@@ -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");
});
});
@@ -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");
});
});
@@ -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");
});
});
@@ -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");
});
});
+84 -87
View File
@@ -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<string, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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,
}),
},
+30 -35
View File
@@ -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<string, { sum: number; count: number }>();
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,
}),
},
+178
View File
@@ -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<number, { sum: number; n: number }>();
const monthly = new Map<number, { sum: number; n: number }>();
// 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 });
}
+56 -83
View File
@@ -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<string, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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,
}),
},
@@ -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,
}),
},
+96 -84
View File
@@ -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<string, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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<string, { count: number; scoreSum: number }>();
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,
}),
},
+42 -62
View File
@@ -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<string, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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,
}),
},
+104 -88
View File
@@ -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<string, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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,
}),
},