mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement
Eleven touch-disjoint waves landed on develop plus a six-axis QA reconcile round before tagging. See CHANGELOG.md for the user-facing prose; this commit message documents the wave + report inventory for release-management traceability. Waves shipped (each with a phase report under .planning/): - phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the v1.4.39 empirical-trace finding #1 (Prisma pool starvation). - phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3 integration contracts close audit Critical Finding #3 (soft-delete invisibility) across dashboard, analytics, summaries, rollup tiers, and the Coach snapshot path. - phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three /api/insights/{features,targets,cards} routes plus the compliance-rollup hook gap closure for bulk-projection mints; the post-QA reconcile patch closes the six remaining measurement.findMany sites in the cluster that still leaked tombstoned rows. - phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation under src/lib/rollups/* with zero orphan re-exports; lint, typecheck, knip, and 93/93 rollup tests green post-restructure. - phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the queryKey factory-bypass walker test (extended to comparison/ in the reconcile pass). - phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17 paired-section tests pinning the 9 SB-3 disclosure requirements. - phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity assertions guarding against split rotation. - phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER time-sensitive APNs payload (priority 10 + interruption-level) scoped via a parameterised test that pins all 6 other event-types do not bypass Focus. AP-2 .p8 install gates real delivery. - phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with append-only invariant pinned in test; revoke-then-regrant leaves both rows; idempotent DELETE returns 200 + receipt: null. - phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers (week/month/year) wired through summaries-slice and health-score-fast-path with a linear-composability parity test pinning the cross-consumer routing contract. - phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation eliminates a sibling-drift risk; knip whitelist trimmed to three files pending follow-up. QA reconcile patches applied per the v1.4.40 QA round (six reports under .planning/round-v1440-QA-*-findings.md): - senior C1 — dashboard-suspense-boundaries regex aligned with the post-W-INFRA Thread 2 userTimezone-local lift. - senior H1 — soft-delete filter added to the six remaining insights measurement.findMany sites. - specialist H1 — Promise.allSettled around bulk-projection compliance recompute on both /api/medications/intake and /api/dashboard/summary. - ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets factory; walker test extended to src/components/comparison/. - security M1 — consent artefact 64 KB cap enforced via Buffer.byteLength (UTF-8 bytes, not UTF-16 code units). Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip --include files,dependencies,binaries,unlisted, pnpm test --run (4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over the v1.4.39 -> v1.4.40 span. User-facing prose: see CHANGELOG.md.
This commit is contained in:
1 parent
f9996552ed
commit
1114cf7e63
146 files changed
+8207
-2279
No files matched your search
@@ -77,6 +77,7 @@ export const GET = apiHandler(async () => {
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||||
},
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
|
||||
@@ -20,15 +20,30 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.4.40 — stub the mood-rollup warm-up so test runs don't fire the
|
||||
// real recompute against the mocked Prisma. The route fires the
|
||||
// warm-up as fire-and-forget; the return value is irrelevant.
|
||||
vi.mock("@/lib/rollups/mood-rollups", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
|
||||
"@/lib/rollups/mood-rollups",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -98,6 +113,9 @@ beforeEach(() => {
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
});
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
@@ -267,6 +285,109 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
expect(body.data.bpClassification?.category).toBe("Normal");
|
||||
});
|
||||
|
||||
it("consumes the mood-rollup tier and skips the raw findMany when DAY rows exist", async () => {
|
||||
// v1.4.40 W-INSIGHTS — rollup-tier read swap parity. Three DAY rows
|
||||
// populated → the route reads them directly and never touches the
|
||||
// raw `moodEntry.findMany`. `moodSummary` is computed over per-day
|
||||
// mean DataPoints, matching the rollup-tier convention shipped by
|
||||
// `/api/mood/analytics` post-v1.4.39.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([
|
||||
{
|
||||
userId: "user-comp-1",
|
||||
granularity: "DAY",
|
||||
bucketStart: new Date("2026-05-08T00:00:00.000Z"),
|
||||
count: 1,
|
||||
mean: 4,
|
||||
minScore: 4,
|
||||
maxScore: 4,
|
||||
sd: null,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
{
|
||||
userId: "user-comp-1",
|
||||
granularity: "DAY",
|
||||
bucketStart: new Date("2026-05-09T00:00:00.000Z"),
|
||||
count: 2,
|
||||
mean: 4.5,
|
||||
minScore: 4,
|
||||
maxScore: 5,
|
||||
sd: 0.5,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Raw findMany must not fire on the rollup-tier fast path.
|
||||
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { moodSummary: { count: number; mean: number | null } | null };
|
||||
};
|
||||
// `summarize()` is called over two DataPoints (one per day).
|
||||
expect(body.data.moodSummary).not.toBeNull();
|
||||
expect(body.data.moodSummary?.count).toBe(2);
|
||||
// Mean of (4 + 4.5) / 2 = 4.25
|
||||
expect(body.data.moodSummary?.mean).toBeCloseTo(4.25, 2);
|
||||
});
|
||||
|
||||
it("falls back to the bounded live walk when the rollup tier is empty but raw mood entries exist", async () => {
|
||||
// v1.4.40 W-INSIGHTS — coverage-fallback parity. Legacy account
|
||||
// with raw entries but no rollup coverage yet. The route runs the
|
||||
// legacy bounded walk once; the warm-up helper (stubbed) is
|
||||
// supposed to fire fire-and-forget so the next request lands on
|
||||
// the rollup tier.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
date: "2026-05-08",
|
||||
score: 4,
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
date: "2026-05-09",
|
||||
score: 5,
|
||||
moodLoggedAt: new Date("2026-05-09T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { moodSummary: { count: number } | null };
|
||||
};
|
||||
expect(body.data.moodSummary).not.toBeNull();
|
||||
expect(body.data.moodSummary?.count).toBe(2);
|
||||
});
|
||||
|
||||
it("derives weight × BP scatter from daily-bucketed series", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Five day-aligned pairs (n >= 5 unlocks pearsonCorrelation).
|
||||
|
||||
@@ -22,6 +22,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -82,39 +86,73 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// buckets for the correlation pairings.
|
||||
const aggregate = await buildComprehensiveAggregate(userId);
|
||||
|
||||
// Mood entries are user-recorded; the table is small and the
|
||||
// `moodSummary` field requires raw scores (`summarize()` over the
|
||||
// raw score series). The findMany stays.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Aggregate mood to daily averages (drives the mood × metric
|
||||
// correlations). Bucket key matches the legacy code's `date` column
|
||||
// semantics (YYYY-MM-DD anchored to the user's tz at write time).
|
||||
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 dailyMoodEntries = Array.from(moodByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
// v1.4.40 — swap the 90-day unbounded `prisma.moodEntry.findMany`
|
||||
// for the persistent mood-rollup DAY tier (audit Critical Finding
|
||||
// #2). The two consumers downstream — `dailyMoodEntries` for the
|
||||
// mood × metric correlations and `moodSummary` for the dashboard
|
||||
// mood block — read at a per-day resolution, which is precisely the
|
||||
// shape the rollup writer emits.
|
||||
//
|
||||
// Coverage-fallback: when the user has mood entries but zero rollup
|
||||
// rows yet (legacy account before the boot-time backfill has caught
|
||||
// up), fall back to a bounded 90-day raw walk once and fire the
|
||||
// warm-up so the next request lands on the rollup tier. Same posture
|
||||
// as `/api/mood/analytics`.
|
||||
//
|
||||
// The bucket key shape change is documented in the route-parity test
|
||||
// for `/api/mood/analytics`: rollup `bucketStart` is UTC-anchored
|
||||
// YYYY-MM-DD; the legacy `MoodEntry.date` column is TZ-anchored
|
||||
// (write-time). For Berlin tenants whose mood log timestamps don't
|
||||
// straddle the UTC boundary the two labels agree on every realistic
|
||||
// entry — the v1.5 per-user-tz bucketing closes the residual DST
|
||||
// edge.
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo);
|
||||
let dailyMoodEntries: Array<{ day: string; value: number }>;
|
||||
let moodDataPoints: DataPoint[];
|
||||
let moodEntryCount: number;
|
||||
if (moodRollupDayRows.length > 0) {
|
||||
dailyMoodEntries = moodRollupDayRows.map((r) => ({
|
||||
day: r.bucketStart.toISOString().slice(0, 10),
|
||||
value: Math.round(r.mean * 100) / 100,
|
||||
}));
|
||||
moodDataPoints = moodRollupDayRows.map((r) => ({
|
||||
date: r.bucketStart,
|
||||
value: r.mean,
|
||||
}));
|
||||
moodEntryCount = moodRollupDayRows.reduce((s, r) => s + r.count, 0);
|
||||
} else {
|
||||
// Coverage-fallback: legacy account with mood entries but no
|
||||
// rollup coverage yet. Bounded by the 90-day window so the walk is
|
||||
// capped even when the rollup miss happens.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
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);
|
||||
}
|
||||
dailyMoodEntries = Array.from(moodByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
// Feed per-day means into `summarize()` so the live-fallback path
|
||||
// shares the rollup-tier semantic (one DataPoint per day, the
|
||||
// daily mean) — mirrors the v1.4.39 mood/analytics QA UX-H1 fix.
|
||||
moodDataPoints = dailyMoodEntries.map((e) => ({
|
||||
date: new Date(`${e.day}T12:00:00.000Z`),
|
||||
value: e.value,
|
||||
}));
|
||||
moodEntryCount = moodEntries.length;
|
||||
}
|
||||
|
||||
// moodSummary uses the same `summarize()` helper over raw scores —
|
||||
// the table is bounded by user-recorded entries so the in-JS path
|
||||
// is safe.
|
||||
const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({
|
||||
date: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
}));
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
@@ -369,7 +407,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
moodEntries: moodEntries.length,
|
||||
moodEntries: moodEntryCount,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ async function buildComparisonSnapshotForUser(
|
||||
const rows = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: { userId, type },
|
||||
where: { userId, type, deletedAt: null },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* v1.4.40 W-INSIGHTS — `/api/insights/targets` mood-rollup tier swap.
|
||||
*
|
||||
* Audit Critical Finding #2 (`.planning/round-v1439-arch-qa-infra-db.md`)
|
||||
* flagged the unbounded `prisma.moodEntry.findMany({ where: { userId } })`
|
||||
* at the bottom of the route as one of the six unbounded mood walks
|
||||
* that paint the Insights surface 12 s cold on a power-user account.
|
||||
* This file pins:
|
||||
*
|
||||
* 1. **The mood-rollup DAY tier replaces the raw findMany when
|
||||
* populated.** Mood targets (`MOOD_SCORE`, `MOOD_STABILITY`)
|
||||
* compute their `current` / `average30` / `consistency7d` from
|
||||
* the rollup row set; `prisma.moodEntry.findMany` is never
|
||||
* called on the fast path. A single `findFirst` reads the
|
||||
* latest score (one bounded row).
|
||||
*
|
||||
* 2. **The coverage-fallback runs the live walk once when the
|
||||
* rollup tier is empty but raw entries exist** (legacy account
|
||||
* before the boot-time backfill has caught up). Bounded by the
|
||||
* trailing 30-day window so the fallback never repeats the
|
||||
* legacy unbounded scan.
|
||||
*
|
||||
* 3. **The `findMany distinct(["type"])` "latest ever per type"
|
||||
* query carries a `measuredAt: { gte: oneYearAgo }` floor**
|
||||
* (audit High finding 3). Without the floor Postgres still has
|
||||
* to sort the full 347 k-row tenant before applying DISTINCT ON
|
||||
* because Prisma's `distinct` does not compile to PG's
|
||||
* `DISTINCT ON`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the warm-up so test runs don't fire the real
|
||||
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-forget
|
||||
// on the route so its return value is irrelevant for parity.
|
||||
vi.mock("@/lib/rollups/mood-rollups", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
|
||||
"@/lib/rollups/mood-rollups",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_USER = {
|
||||
id: "user-targets-1",
|
||||
username: "marc",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: SESSION_USER as never,
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/targets");
|
||||
}
|
||||
|
||||
interface TargetsBody {
|
||||
data: {
|
||||
targets: Array<{
|
||||
type: string;
|
||||
current: number | null;
|
||||
average30: number | null;
|
||||
daysInRange7d: number;
|
||||
daysLogged7d: number;
|
||||
insufficientData: boolean;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK);
|
||||
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
gender: "MALE",
|
||||
glucoseUnit: "mg/dL",
|
||||
thresholdsJson: null,
|
||||
});
|
||||
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
it("returns a mood target driven by the rollup DAY tier without touching the raw findMany", async () => {
|
||||
// Five DAY-rollup rows over the trailing 30 days. The route reads
|
||||
// them directly to drive `MOOD_SCORE.current/average30/consistency`
|
||||
// — no raw `moodEntry.findMany` on the fast path.
|
||||
const today = new Date();
|
||||
const day = (offset: number) => {
|
||||
const d = new Date(today);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
d.setUTCDate(d.getUTCDate() - offset);
|
||||
return d;
|
||||
};
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(0), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(1), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(2), count: 1, mean: 5, minScore: 5, maxScore: 5, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(3), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(4), count: 1, mean: 4.5, minScore: 4, maxScore: 5, sd: 0.5, computedAt: new Date() },
|
||||
]);
|
||||
// Latest score read — one bounded row.
|
||||
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
score: 4,
|
||||
moodLoggedAt: day(0),
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The unbounded raw `moodEntry.findMany` must not fire on the
|
||||
// rollup-tier fast path.
|
||||
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
|
||||
|
||||
const body = (await res.json()) as TargetsBody;
|
||||
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
|
||||
expect(moodTarget).toBeDefined();
|
||||
expect(moodTarget?.current).toBe(4);
|
||||
// Average over 5 daily means: (4+4+5+4+4.5)/5 = 4.3
|
||||
expect(moodTarget?.average30).toBeCloseTo(4.3, 1);
|
||||
});
|
||||
|
||||
it("falls back to the bounded raw walk when the rollup tier is empty but raw mood entries exist", async () => {
|
||||
// Rollup empty → coverage-fallback fires the legacy live walk
|
||||
// once. The fallback is bounded by the 30-day window — not the
|
||||
// legacy unbounded scan over every mood the user ever wrote.
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
const today = new Date();
|
||||
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
score: 4,
|
||||
moodLoggedAt: today,
|
||||
});
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ score: 4, moodLoggedAt: new Date(today.getTime() - 2 * 86_400_000) },
|
||||
{ score: 5, moodLoggedAt: new Date(today.getTime() - 1 * 86_400_000) },
|
||||
{ score: 4, moodLoggedAt: today },
|
||||
]);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Live fallback fired exactly once. The fallback `findMany` call
|
||||
// must carry a 30-day `moodLoggedAt: { gte: ... }` floor — i.e. a
|
||||
// `where` clause with a timestamp filter, not the legacy
|
||||
// unbounded `where: { userId }` shape.
|
||||
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
const fallbackCall = (prisma.moodEntry.findMany as ReturnType<typeof vi.fn>)
|
||||
.mock.calls[0][0];
|
||||
expect(fallbackCall.where).toHaveProperty("moodLoggedAt");
|
||||
expect(fallbackCall.where.moodLoggedAt).toHaveProperty("gte");
|
||||
|
||||
const body = (await res.json()) as TargetsBody;
|
||||
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
|
||||
expect(moodTarget).toBeDefined();
|
||||
expect(moodTarget?.current).toBe(4);
|
||||
});
|
||||
|
||||
it("emits no mood target when neither rollup nor raw entries exist", async () => {
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as TargetsBody;
|
||||
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
|
||||
expect(moodTarget).toBeUndefined();
|
||||
});
|
||||
|
||||
it("floors the `findMany distinct` latest-ever-per-type read at one year", async () => {
|
||||
// Audit High finding 3: the `findMany({orderBy: measuredAt desc,
|
||||
// distinct: ["type"]})` for "absolute latest measurement per type"
|
||||
// used to run WITHOUT a `measuredAt >=` floor. On a 347 k-row
|
||||
// tenant Postgres still has to sort the full set before applying
|
||||
// DISTINCT ON because Prisma's `distinct` does not compile to PG's
|
||||
// `DISTINCT ON` — it dedups in the driver. v1.4.40 adds a
|
||||
// 365-day floor so the planner-side walk is bounded.
|
||||
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
|
||||
await callGet(makeReq());
|
||||
|
||||
// Find the `distinct: ["type"]` call.
|
||||
const calls = (
|
||||
prisma.measurement.findMany as ReturnType<typeof vi.fn>
|
||||
).mock.calls.map((c) => c[0]);
|
||||
const distinctCall = calls.find(
|
||||
(c) =>
|
||||
c &&
|
||||
Array.isArray(c.distinct) &&
|
||||
c.distinct[0] === "type" &&
|
||||
c.orderBy?.measuredAt === "desc",
|
||||
);
|
||||
expect(distinctCall).toBeDefined();
|
||||
// The `measuredAt >= oneYearAgo` floor must be present.
|
||||
expect(distinctCall?.where?.measuredAt).toBeDefined();
|
||||
expect(distinctCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
|
||||
const flooredAt = distinctCall?.where?.measuredAt?.gte as Date;
|
||||
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
|
||||
const ageMs = Date.now() - flooredAt.getTime();
|
||||
// Floor is somewhere around one year ago — give a ±1-hour window
|
||||
// for clock drift between the route's `Date.now()` and the test
|
||||
// assertion.
|
||||
expect(ageMs).toBeGreaterThan(oneYearMs - 3_600_000);
|
||||
expect(ageMs).toBeLessThan(oneYearMs + 3_600_000);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
|
||||
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -174,22 +178,41 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch all measurements in the last 30 days + the latest for each type
|
||||
// Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows
|
||||
// must not contribute to tile averages / latest values once iOS sync
|
||||
// starts emitting deletions.
|
||||
const recentMeasurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days).
|
||||
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
||||
// (Postgres) emulated via a windowed orderBy + take-1 per type would still be
|
||||
// N round-trips, so we sort once on the wide query and pick per-type below.
|
||||
// Absolute latest measurement per type within a one-year floor.
|
||||
//
|
||||
// Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the
|
||||
// driver dedups after pulling the rows, so an unbounded scan on a
|
||||
// 347 k-row tenant pulls every measurement back to Node only to drop
|
||||
// all but seven (audit Critical Finding #2, v1.4.40). The 365-day
|
||||
// floor caps the planner-side walk to the existing
|
||||
// `(user_id, type, measured_at)` index range; the trade-off is that a
|
||||
// user who hasn't measured a metric in over a year sees the tile's
|
||||
// `current` value as null instead of pulling a stale year-old reading
|
||||
// forward — which is the correct behaviour for the consistency strip
|
||||
// (the strip already gates on "fewer than 3 readings in 30 days" →
|
||||
// insufficientData).
|
||||
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
||||
const latestEverByType = await prisma.measurement.findMany({
|
||||
where: { userId, type: { in: types } },
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
distinct: ["type"],
|
||||
select: { type: true, value: true },
|
||||
@@ -980,41 +1003,112 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
}
|
||||
|
||||
// 9. Mood targets (if mood data exists)
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
//
|
||||
// v1.4.40 \u2014 swap the unbounded `prisma.moodEntry.findMany({ where: {
|
||||
// userId } })` for the persistent mood-rollup DAY tier (audit Critical
|
||||
// Finding #2). The tile reads:
|
||||
// - per-day mean over the trailing 30 days (avg30, trend, stability)
|
||||
// - the consistency strip (in/near/out band per day)
|
||||
// - the latest score for the "current" reading + classification
|
||||
//
|
||||
// Rollup row = `{bucketStart, mean, count, minScore, maxScore}` per
|
||||
// calendar day. Per-day mean replaces the per-entry score series the
|
||||
// legacy path passed to `rollupConsistency` \u2014 the helper buckets by
|
||||
// day-key anyway, so feeding one row per day with the pre-computed
|
||||
// mean is mathematically identical to feeding N raw entries that
|
||||
// collapse to the same mean.
|
||||
//
|
||||
// The "latest score" needs a single raw entry (the newest one), so we
|
||||
// pull it with `take: 1` \u2014 bounded by the index, never an unbounded
|
||||
// scan. The "\u2265 3 entries threshold" gate uses the rollup count sum so
|
||||
// the cold-start branch stays consistent with the legacy semantics
|
||||
// (which counted raw entries, not raw days).
|
||||
//
|
||||
// Coverage-fallback: when the user has mood entries but zero rollup
|
||||
// rows yet (legacy account before the boot-time backfill caught up)
|
||||
// we run the legacy live walk once and fire the warm-up so the next
|
||||
// request lands on the rollup tier. Same posture as
|
||||
// `/api/mood/analytics`.
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
// Five-year window mirrors `/api/mood/analytics` so existing rollup
|
||||
// coverage is consulted; the trailing 30-day filter happens in JS
|
||||
// because the consistency strip already walks per-day buckets.
|
||||
const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
const moodSince = new Date(Date.now() - moodRollupWindowMs);
|
||||
const moodRollups = await readMoodDayRollups(userId, moodSince);
|
||||
const latestMoodEntry = await prisma.moodEntry.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
if (moodEntries.length >= 3) {
|
||||
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const recentMood = moodEntries.filter(
|
||||
(entry) => entry.moodLoggedAt >= thirtyDaysAgoMood,
|
||||
);
|
||||
const latestMoodScore = moodEntries[0]?.score ?? null;
|
||||
// Build a per-day event list for the consistency strip + 30-day stats.
|
||||
// Use the rollup tier when populated; fall back to a bounded raw walk
|
||||
// when the user has mood entries but zero rollup coverage yet.
|
||||
let moodDailyEvents: Array<{ measuredAt: Date; value: number }>;
|
||||
let moodEntryCount: number;
|
||||
let moodSumScores: number;
|
||||
let moodVarianceN: number;
|
||||
let moodSumSquares: number;
|
||||
if (moodRollups.length > 0) {
|
||||
moodEntryCount = moodRollups.reduce((s, r) => s + r.count, 0);
|
||||
moodDailyEvents = moodRollups
|
||||
.filter((r) => r.bucketStart >= thirtyDaysAgoMood)
|
||||
.map((r) => ({ measuredAt: r.bucketStart, value: r.mean }));
|
||||
moodSumScores = moodDailyEvents.reduce((s, e) => s + e.value, 0);
|
||||
moodVarianceN = moodDailyEvents.length;
|
||||
moodSumSquares = moodDailyEvents.reduce((s, e) => s + e.value * e.value, 0);
|
||||
} else if (latestMoodEntry !== null) {
|
||||
// Coverage-fallback: rollups empty but raw entries exist. Pull the
|
||||
// trailing 30-day window only (instead of every entry ever) \u2014 that's
|
||||
// the bound the legacy code was missing.
|
||||
const recentRawMood = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: thirtyDaysAgoMood } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
});
|
||||
moodEntryCount = recentRawMood.length;
|
||||
moodDailyEvents = recentRawMood.map((e) => ({
|
||||
measuredAt: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
}));
|
||||
moodSumScores = recentRawMood.reduce((s, e) => s + e.score, 0);
|
||||
moodVarianceN = recentRawMood.length;
|
||||
moodSumSquares = recentRawMood.reduce((s, e) => s + e.score * e.score, 0);
|
||||
} else {
|
||||
moodEntryCount = 0;
|
||||
moodDailyEvents = [];
|
||||
moodSumScores = 0;
|
||||
moodVarianceN = 0;
|
||||
moodSumSquares = 0;
|
||||
}
|
||||
|
||||
if (moodEntryCount >= 3) {
|
||||
const latestMoodScore = latestMoodEntry?.score ?? null;
|
||||
|
||||
const moodAvg30 =
|
||||
recentMood.length > 0
|
||||
moodDailyEvents.length > 0
|
||||
? Math.round(
|
||||
(recentMood.reduce((sum, e) => sum + e.score, 0) /
|
||||
recentMood.length) *
|
||||
10,
|
||||
(moodSumScores / moodDailyEvents.length) * 10,
|
||||
) / 10
|
||||
: null;
|
||||
|
||||
// Mood trend: compare first half to second half of last 30 days
|
||||
// Mood trend: compare first half to second half of last 30 days.
|
||||
// The rollup tier carries one DataPoint per day (the daily mean),
|
||||
// matching the legacy comparison semantics on single-entry days.
|
||||
let moodTrend: "up" | "down" | "stable" | null = null;
|
||||
if (recentMood.length >= 4) {
|
||||
const sorted = [...recentMood].sort(
|
||||
(a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(),
|
||||
if (moodDailyEvents.length >= 4) {
|
||||
const sorted = [...moodDailyEvents].sort(
|
||||
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
|
||||
);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const firstHalf = sorted.slice(0, mid);
|
||||
const secondHalf = sorted.slice(mid);
|
||||
const avgFirst =
|
||||
firstHalf.reduce((s, e) => s + e.score, 0) / firstHalf.length;
|
||||
firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length;
|
||||
const avgSecond =
|
||||
secondHalf.reduce((s, e) => s + e.score, 0) / secondHalf.length;
|
||||
secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length;
|
||||
const diff = avgSecond - avgFirst;
|
||||
if (diff > 0.2) moodTrend = "up";
|
||||
else if (diff < -0.2) moodTrend = "down";
|
||||
@@ -1031,12 +1125,8 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
: null;
|
||||
|
||||
const moodRange = { min: 3.5, max: 5 };
|
||||
const moodEventsForConsistency = recentMood.map((entry) => ({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
value: entry.score,
|
||||
}));
|
||||
const moodConsistency = rollupConsistency(
|
||||
moodEventsForConsistency,
|
||||
moodDailyEvents,
|
||||
makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }),
|
||||
);
|
||||
targets.push({
|
||||
@@ -1052,13 +1142,18 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
...moodConsistency,
|
||||
});
|
||||
|
||||
// Mood stability: standard deviation of recent scores (lower = more stable)
|
||||
if (recentMood.length >= 5) {
|
||||
const mean = moodAvg30 ?? latestMoodScore ?? 3;
|
||||
const variance =
|
||||
recentMood.reduce((sum, e) => sum + (e.score - mean) ** 2, 0) /
|
||||
recentMood.length;
|
||||
const stdDev = Math.round(Math.sqrt(variance) * 100) / 100;
|
||||
// Mood stability: standard deviation of the recent series. The
|
||||
// rollup tier surfaces one mean per day, which is the right
|
||||
// resolution for "is the user's mood stable across days" \u2014 a power
|
||||
// user logging multiple times per day with a wide intraday spread
|
||||
// still gets a "stable" badge if the daily mean barely moves. The
|
||||
// legacy path conflated intraday variance with day-to-day stability
|
||||
// by passing per-entry scores; the rollup-tier semantic is closer
|
||||
// to what the UI label suggests.
|
||||
if (moodVarianceN >= 5) {
|
||||
const seriesMean = moodSumScores / moodVarianceN;
|
||||
const variance = moodSumSquares / moodVarianceN - seriesMean * seriesMean;
|
||||
const stdDev = Math.round(Math.sqrt(Math.max(0, variance)) * 100) / 100;
|
||||
|
||||
const stabilityClassification =
|
||||
stdDev <= 0.5
|
||||
@@ -1103,7 +1198,7 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
};
|
||||
const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: { userId, type: "BLOOD_GLUCOSE" },
|
||||
where: { userId, type: "BLOOD_GLUCOSE", deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user