mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
c3ca8614a6
commit
1adda80944
17 files changed
+1379
-481
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user