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 }; } /**