From fd5cc783774d6d2b2ca4784f5985883c92360cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:53:22 +0200 Subject: [PATCH] refactor(mood): read the dashboard and analytics mood series from one engine The dashboard snapshot and the /api/mood/analytics route each carried a hand-copied rollup read, coverage fallback, and daily summarize of the same numbers. Two reads of one metric is the seam where the dashboard tile and the insights mood sparkline can drift apart. Extract buildMoodDailySeries as the single mood engine and route both callers through it, so the same mood number reads identically on the dashboard, the insights surface, and the iOS client. --- src/app/api/mood/analytics/route.ts | 168 ++---------------- .../analytics/__tests__/mood-series.test.ts | 94 ++++++++++ src/lib/analytics/mood-series.ts | 149 ++++++++++++++++ src/lib/dashboard/snapshot.ts | 75 +------- 4 files changed, 264 insertions(+), 222 deletions(-) create mode 100644 src/lib/analytics/__tests__/mood-series.test.ts create mode 100644 src/lib/analytics/mood-series.ts diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts index 2d8f69a0d..5774a1095 100644 --- a/src/app/api/mood/analytics/route.ts +++ b/src/app/api/mood/analytics/route.ts @@ -1,177 +1,33 @@ -import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; -import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { - ensureUserMoodRollupsFresh, - readMoodDayRollups, -} from "@/lib/rollups/mood-rollups"; + buildMoodDailySeries, + type MoodDailySeries, +} from "@/lib/analytics/mood-series"; export const dynamic = "force-dynamic"; -/** - * v1.4.39 W-MOOD — five-year window for the rollup-tier read. - * - * Mirrors the legacy "unbounded findMany" semantics in practice: the - * legacy code passed every mood the user had ever logged into Node. - * The rollup tier stores one row per day, so a five-year cap is - * cheap (at most ~1 800 rows) and still covers every user the - * product has historic data for. - */ -const ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000; - -/** Aggregate multiple mood entries per day into daily averages. */ -function aggregateDailyAverages( - records: Array<{ date: string; score: number }>, -) { - const byDay = new Map(); - for (const record of records) { - const current = byDay.get(record.date) ?? { sum: 0, count: 0 }; - current.sum += record.score; - current.count += 1; - byDay.set(record.date, current); - } - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - date: day, - score: Math.round((stats.sum / stats.count) * 100) / 100, - samples: stats.count, - })); -} - -interface MoodAnalyticsResult { - entries: Array<{ date: string; score: number; samples: number }>; - summary: ReturnType; - entryCount: number; -} - -/** - * Format the rollup bucket's UTC `bucket_start` as a YYYY-MM-DD - * label. The legacy live path emitted the row's TZ-anchored `date` - * column; the rollup tier anchors on UTC midnight (same convention - * as the measurement rollup tier). For tenants within ±3 h of UTC - * (Berlin year-round) the two labels agree on every entry whose - * timestamp doesn't straddle the UTC boundary — i.e. every realistic - * mood log, which a human submits during waking hours. - * - * Trade-off (QA Specialist-H1, v1.4.39): on DST fall-back nights the - * UTC anchor and the user's local wall-clock day-key can diverge by - * one calendar day. Example: `2025-10-25T23:30:00Z` is 00:30 local - * in Europe/Berlin on `2025-10-26` (one hour after the fall-back - * transition); the rollup row is keyed on `2025-10-25` (UTC) while - * the legacy live-fallback path would emit `2025-10-26`. This is - * pinned by the route-parity DST test. v1.5 per-user-tz bucketing - * (audit P7) anchors the rollup on the same day-key the legacy path - * uses, closing the gap. - */ -function utcDayLabel(d: Date): string { - const yyyy = d.getUTCFullYear(); - const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(d.getUTCDate()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}`; -} - -async function buildMoodAnalyticsResponse( - userId: string, -): Promise { - // v1.4.39 W-MOOD — fire-and-forget warm-up so the next cold mount - // for this user lands on the rollup tier even when the boot-time - // backfill hasn't reached them yet. - void ensureUserMoodRollupsFresh(userId); - - const since = new Date(Date.now() - ROLLUP_WINDOW_MS); - const rollups = await readMoodDayRollups(userId, since); - - if (rollups.length > 0) { - // Fast path — one DAY-rollup row per calendar day. The legacy - // `aggregateDailyAverages` collapse is unnecessary because the - // rollup already carries the daily mean; we recreate its output - // shape directly so the response stays byte-compatible. - const entries = rollups - .map((r) => ({ - date: utcDayLabel(r.bucketStart), - score: Math.round(r.mean * 100) / 100, - samples: r.count, - })) - .sort((a, b) => a.date.localeCompare(b.date)); - - // Summarize feeds the slope7/30/90 windows. One DataPoint per - // day (mean) is the right resolution — the legacy path passed - // per-entry points, but the slope-window outputs match because - // a power user's mood is typically 1/day. Multi-entry days - // contribute their daily mean exactly once, which is the same - // semantic the dashboard tile renders. - const dataPoints: DataPoint[] = rollups.map((r) => ({ - date: r.bucketStart, - value: r.mean, - })); - const summary = summarize(dataPoints); - const entryCount = rollups.reduce((s, r) => s + r.count, 0); - - annotate({ meta: { mood_analytics_path: "rollup" } }); - - return { entries, summary, entryCount }; - } - - // Coverage-fallback — the user has no rollup rows yet. Probe for - // raw mood entries; when the table is empty for this user we - // return the same empty envelope the legacy path emitted. When - // mood entries exist but rollups don't (legacy account before the - // boot-backfill has caught up), we run the legacy live walk ONCE - // so the request still gets a correct response — the warm-up - // fired above will mint the rollups for the next request. - const moodEntries = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the analytics fallback. - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true, moodLoggedAt: true }, - }); - - const entries = aggregateDailyAverages( - moodEntries.map((e) => ({ date: e.date, score: e.score })), - ); - - // QA UX-H1 (v1.4.39): feed `summarize()` per-day means, not per-entry - // scores. The rollup fast-path emits one DataPoint per calendar day - // (the rollup's `mean`); the live fallback used to pass every raw - // entry, which silently shifted `summary.count / latest / min / max - // / mean / avg7 / avg30 / slope30` on power-user multi-entry days. - // Pre-aggregating through `aggregateDailyAverages` keeps the two - // branches byte-identical on multi-entry days too. Date anchor is - // local-noon for the day so the slope x-axis spans whole-day units — - // mirrors the dashboard tile's intuition (one number per day). - const dataPoints: DataPoint[] = entries.map((e) => ({ - date: new Date(`${e.date}T12:00:00.000Z`), - value: e.score, - })); - - const summary = summarize(dataPoints); - - annotate({ - meta: { - mood_analytics_path: moodEntries.length === 0 ? "rollup" : "live", - }, - }); - - return { entries, summary, entryCount: moodEntries.length }; -} - export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // v1.17.1 — single mood engine: the route and the dashboard snapshot + // both read through `buildMoodDailySeries`, so the dashboard tile, the + // insights mood sparkline, and the iOS client all see the same number. const result = await cached( - caches.moodAnalytics as ServerCache, + caches.moodAnalytics as ServerCache, user.id, - () => buildMoodAnalyticsResponse(user.id), + () => buildMoodDailySeries(user.id), annotate, ); annotate({ action: { name: "mood.analytics" }, - meta: { entryCount: result.entryCount }, + meta: { + entryCount: result.entryCount, + mood_analytics_path: result.source, + }, }); return apiSuccess({ entries: result.entries, summary: result.summary }); diff --git a/src/lib/analytics/__tests__/mood-series.test.ts b/src/lib/analytics/__tests__/mood-series.test.ts new file mode 100644 index 000000000..07ec24220 --- /dev/null +++ b/src/lib/analytics/__tests__/mood-series.test.ts @@ -0,0 +1,94 @@ +/** + * v1.17.1 — the single mood engine `buildMoodDailySeries`. + * + * Pins the one-engine contract: the dashboard snapshot (`buildMoodBlock`) + * and the `/api/mood/analytics` route both read through this function, so + * the same number must read identically on dashboard + insights. Covers + * the rollup fast-path and the legacy live fallback, and asserts the live + * fallback collapses multi-entry days into the daily mean exactly once. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + moodEntry: { findMany: vi.fn() }, + moodEntryRollup: { findMany: vi.fn() }, + }, +})); + +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 }), + readMoodDayRollups: vi.fn(), + }; +}); + +import { buildMoodDailySeries } from "../mood-series"; +import { prisma } from "@/lib/db"; +import { readMoodDayRollups } from "@/lib/rollups/mood-rollups"; + +beforeEach(() => { + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(readMoodDayRollups).mockResolvedValue([] as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildMoodDailySeries", () => { + it("reads the rollup tier and skips the raw walk when DAY rows exist", async () => { + vi.mocked(readMoodDayRollups).mockResolvedValue([ + { bucketStart: new Date("2026-06-01T00:00:00Z"), mean: 4, count: 2 }, + { bucketStart: new Date("2026-06-02T00:00:00Z"), mean: 3, count: 1 }, + ] as never); + + const series = await buildMoodDailySeries("user-1"); + + expect(series.source).toBe("rollup"); + expect(prisma.moodEntry.findMany).not.toHaveBeenCalled(); + expect(series.entries).toEqual([ + { date: "2026-06-01", score: 4, samples: 2 }, + { date: "2026-06-02", score: 3, samples: 1 }, + ]); + // entryCount sums the per-day samples (2 + 1). + expect(series.entryCount).toBe(3); + expect(series.summary.count).toBe(2); + }); + + it("falls back to the live walk once and collapses multi-entry days to the daily mean", async () => { + vi.mocked(readMoodDayRollups).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + { date: "2026-06-01", score: 5 }, + { date: "2026-06-01", score: 3 }, + { date: "2026-06-02", score: 4 }, + ] as never); + + const series = await buildMoodDailySeries("user-1"); + + expect(series.source).toBe("live"); + // Two distinct calendar days; day one is the mean of its two entries. + expect(series.entries).toEqual([ + { date: "2026-06-01", score: 4, samples: 2 }, + { date: "2026-06-02", score: 4, samples: 1 }, + ]); + expect(series.entryCount).toBe(3); + // summarize sees one DataPoint per day, not per raw entry. + expect(series.summary.count).toBe(2); + }); + + it("returns an empty series for a user with no rollups and no raw entries", async () => { + const series = await buildMoodDailySeries("user-1"); + expect(series.entries).toEqual([]); + expect(series.entryCount).toBe(0); + // An empty table reads as the rollup tier for annotation parity. + expect(series.source).toBe("rollup"); + expect(series.summary.count).toBe(0); + }); +}); diff --git a/src/lib/analytics/mood-series.ts b/src/lib/analytics/mood-series.ts new file mode 100644 index 000000000..6076602f4 --- /dev/null +++ b/src/lib/analytics/mood-series.ts @@ -0,0 +1,149 @@ +/** + * v1.17.1 — single mood daily-series engine. + * + * Before this module the dashboard snapshot (`buildMoodBlock` in + * `src/lib/dashboard/snapshot.ts`) and the `/api/mood/analytics` route + * (`buildMoodAnalyticsResponse`) each carried a hand-copied rollup read + + * coverage fallback + `summarize()` of the same numbers. Two engines for + * one metric is the classic seam where the dashboard and the insights + * surface can read mood differently. This module is the one canonical + * read; both callers delegate to it so the same number reads identically + * on the dashboard, the insights mood sparkline, and the iOS client. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/rollups/mood-rollups"; + +/** + * Five-year window for the rollup-tier read. The rollup tier stores one + * row per day, so a five-year cap is cheap (at most ~1 800 rows) and + * still covers every user the product has historic data for. The legacy + * route + snapshot both used this same window. + */ +export const MOOD_SERIES_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000; + +/** One daily mood point: the day's average score + how many entries fed it. */ +export interface MoodDailyEntry { + date: string; + score: number; + samples: number; +} + +export interface MoodDailySeries { + entries: MoodDailyEntry[]; + summary: ReturnType; + /** Total raw mood entries behind the series (sum of `samples`). */ + entryCount: number; + /** Which tier answered — for wide-event annotation by the caller. */ + source: "rollup" | "live"; +} + +/** + * Format a UTC `Date` as a YYYY-MM-DD label. The rollup tier anchors on + * UTC midnight (same convention as the measurement rollup tier). For + * tenants within ±3 h of UTC (Berlin year-round) the label agrees with + * the local wall-clock day on every entry whose timestamp doesn't + * straddle the UTC boundary — i.e. every realistic mood log. v1.5 + * per-user-tz bucketing (audit P7) closes the DST fall-back-night gap. + */ +function utcDayLabel(d: Date): string { + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +/** Aggregate multiple raw mood entries per day into daily averages. */ +function aggregateDailyAverages( + records: Array<{ date: string; score: number }>, +): MoodDailyEntry[] { + const byDay = new Map(); + for (const record of records) { + const current = byDay.get(record.date) ?? { sum: 0, count: 0 }; + current.sum += record.score; + current.count += 1; + byDay.set(record.date, current); + } + return Array.from(byDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ + date: day, + score: Math.round((stats.sum / stats.count) * 100) / 100, + samples: stats.count, + })); +} + +/** + * Canonical mood daily series for a user. Rollup fast-path first; a single + * live walk only on coverage miss (and the warm-up below mints the rollups + * for the next read). Feeds `summarize()` per-day means in both branches so + * the slope/latest/min/max windows are byte-identical on multi-entry days. + * + * `client` lets the snapshot builder pass its own Prisma handle; defaults to + * the shared singleton for the route caller. + */ +export async function buildMoodDailySeries( + userId: string, + client: PrismaClient = prisma, +): Promise { + // Fire-and-forget warm-up so the next cold mount for this user lands on + // the rollup tier even when the boot-time backfill hasn't reached them. + void ensureUserMoodRollupsFresh(userId); + + const since = new Date(Date.now() - MOOD_SERIES_WINDOW_MS); + const rollups = await readMoodDayRollups(userId, since); + + if (rollups.length > 0) { + const entries = rollups + .map((r) => ({ + date: utcDayLabel(r.bucketStart), + score: Math.round(r.mean * 100) / 100, + samples: r.count, + })) + .sort((a, b) => a.date.localeCompare(b.date)); + const dataPoints: DataPoint[] = rollups.map((r) => ({ + date: r.bucketStart, + value: r.mean, + })); + const summary = summarize(dataPoints); + const entryCount = rollups.reduce((s, r) => s + r.count, 0); + return { entries, summary, entryCount, source: "rollup" }; + } + + // Coverage fallback — no rollup rows yet. Run the legacy live walk ONCE; + // the warm-up above mints the rollups for the next request. + const moodEntries = await client.moodEntry.findMany({ + // v1.7.0 sync — exclude tombstoned rows from the fallback. + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "asc" }, + select: { date: true, score: true }, + }); + + const entries = aggregateDailyAverages( + moodEntries.map((e: { date: string; score: number }) => ({ + date: e.date, + score: e.score, + })), + ); + + // Date anchor is local-noon for the day so the slope x-axis spans + // whole-day units — mirrors the dashboard tile's one-number-per-day. + const dataPoints: DataPoint[] = entries.map((e) => ({ + date: new Date(`${e.date}T12:00:00.000Z`), + value: e.score, + })); + const summary = summarize(dataPoints); + + // An empty table reads as "rollup" (nothing to walk) for annotation + // parity with the historic route; a non-empty live walk reads "live". + return { + entries, + summary, + entryCount: moodEntries.length, + source: moodEntries.length === 0 ? "rollup" : "live", + }; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 14e7e3287..ebf86b95d 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -61,10 +61,7 @@ import { probeRollupCoverage, type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; -import { - ensureUserMoodRollupsFresh, - readMoodDayRollups, -} from "@/lib/rollups/mood-rollups"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; import { resolveDashboardLayout, DASHBOARD_WIDGET_CATALOGUE_IDS, @@ -85,9 +82,6 @@ import type { HealthScoreBand } from "@/lib/analytics/health-score"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; -/** Five-year mood window — mirrors `/api/mood/analytics`. */ -const MOOD_ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000; - const GLUCOSE_CONTEXTS = [ "FASTING", "POSTPRANDIAL", @@ -339,14 +333,6 @@ export interface SnapshotUserInput { dashboardWidgetsJson: unknown; } -/** Format a UTC `Date` as a YYYY-MM-DD label (matches mood rollup tier). */ -function utcDayLabel(d: Date): string { - const yyyy = d.getUTCFullYear(); - const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(d.getUTCDate()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}`; -} - /** Wall-clock hour in `tz`, used for the server-side greeting. */ function hourInTimezone(now: Date, tz: string): number { try { @@ -383,63 +369,20 @@ function enrichLastSeen( return out; } -/** Read-only mood block, lifting the rollup-tier read from `/api/mood/analytics`. */ +/** + * Read-only mood block. v1.17.1 — delegates to the single mood engine + * `buildMoodDailySeries` so the dashboard tile reads the exact numbers + * `/api/mood/analytics` (the insights mood sparkline source) returns. + */ async function buildMoodBlock( - prisma: PrismaClient, + client: PrismaClient, userId: string, ): Promise<{ summary: DataSummary | null; entries: DashboardSnapshotMoodEntry[]; }> { - void ensureUserMoodRollupsFresh(userId); - - const since = new Date(Date.now() - MOOD_ROLLUP_WINDOW_MS); - const rollups = await readMoodDayRollups(userId, since); - - if (rollups.length > 0) { - const entries = rollups - .map((r) => ({ - date: utcDayLabel(r.bucketStart), - score: Math.round(r.mean * 100) / 100, - samples: r.count, - })) - .sort((a, b) => a.date.localeCompare(b.date)); - const dataPoints: DataPoint[] = rollups.map((r) => ({ - date: r.bucketStart, - value: r.mean, - })); - return { summary: summarize(dataPoints), entries }; - } - - // Coverage fallback — mirror the live walk in `/api/mood/analytics`. - const moodEntries = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the dashboard snapshot. - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true }, - }); - if (moodEntries.length === 0) { - return { summary: summarize([]), entries: [] }; - } - const byDay = new Map(); - for (const e of moodEntries) { - const cur = byDay.get(e.date) ?? { sum: 0, count: 0 }; - cur.sum += e.score; - cur.count += 1; - byDay.set(e.date, cur); - } - const entries = Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - date: day, - score: Math.round((stats.sum / stats.count) * 100) / 100, - samples: stats.count, - })); - const dataPoints: DataPoint[] = entries.map((e) => ({ - date: new Date(`${e.date}T12:00:00.000Z`), - value: e.score, - })); - return { summary: summarize(dataPoints), entries }; + const series = await buildMoodDailySeries(userId, client); + return { summary: series.summary, entries: series.entries }; } /**