diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 3ff962f76..8eaa83771 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 123, - "total": 145551, + "total": 145465, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -86,7 +86,7 @@ "lib/insights/correlation-channel-series.ts": 1339, "lib/insights/correlation-discovery.ts": 1240, "lib/insights/correlations.ts": 1026, - "lib/insights/features.ts": 2009, + "lib/insights/features.ts": 1923, "lib/insights/mood-aggregates.ts": 1055, "lib/insights/mood-status.ts": 814, "lib/insights/status-cache.ts": 812, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 2c8a732b0..14f0c8f75 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2954, - "total": 10504, + "total": 10500, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2228,7 +2228,7 @@ "lib/insights/exclude-filter.ts": 1, "lib/insights/feature-blocks.ts": 5, "lib/insights/feature-cache.ts": 2, - "lib/insights/features.ts": 38, + "lib/insights/features.ts": 34, "lib/insights/general-status.ts": 14, "lib/insights/glp1-plateau.ts": 2, "lib/insights/graded-series.ts": 3, diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 6b72b6c26..18a7cbc3e 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -19,7 +19,7 @@ import { daysBetweenDateKeys, userDayKey, } from "@/lib/tz/format"; -import { dateOnlyKey } from "@/lib/tz/date-only"; +import { readMoodDailyPoints } from "@/lib/insights/mood-daily-points"; import { readingsMean } from "@/lib/measurements/day-mean"; import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; @@ -47,10 +47,6 @@ import { } from "@/lib/analytics/correlations"; import { getMedicationCategories } from "@/lib/medication-category"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; -import { - ensureUserMoodRollupsFresh, - readMoodDayRollups, -} from "@/lib/rollups/mood-rollups"; import { readAllTimeExtremes, readBucketedSeries, @@ -1008,114 +1004,12 @@ async function readMoodFeature( dailyPoints: Array<{ measuredAt: Date; value: number; count: number }>; }> { let feature: AggregatedFeatures["mood"]; - // Mood - // - // v1.4.40 — swap the unbounded `prisma.moodEntry.findMany` for the - // persistent mood-rollup DAY tier (audit Critical Finding #2). The - // feature block downstream consumes: - // - `avg7` / `avg30` over recent rollup means - // - `trend30` from first-half vs second-half of last 30 days - // - `latest` = newest individual entry score (one bounded row) - // - `totalEntries` = sum of rollup `count` across all DAY rows - // - `oldest` / `newest` = first and last rollup `bucketStart` - // - `moodPoints` for cross-metric correlations (one per-day mean - // DataPoint — same resolution as the legacy raw-score series for - // single-entry-per-day power users, slightly different on - // multi-entry days, which is the rollup-tier semantic the - // v1.4.39 `/api/mood/analytics` already shipped) - // - // Coverage-fallback: when raw entries exist but the rollup tier is - // still empty (legacy account before boot-time backfill caught up), - // we fall back to a raw walk over the same window and fire the warm-up so - // the next request lands on the rollup tier. Same posture as - // `/api/mood/analytics`. - void ensureUserMoodRollupsFresh(userId); - // Five-year window mirrors the mood-rollup writer default; covers - // every realistic user history span without an unbounded scan. - const moodSince = laterDate( - new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000), - historyFloor, - ); - const moodRollupDayRows = await readMoodDayRollups(userId, moodSince); - - type MoodDayPoint = { - measuredAt: Date; - /** The entry's own calendar day label (`MoodEntry.date`). */ - dayKey: string; - value: number; - count: number; - }; - let moodDailyPoints: MoodDayPoint[] = []; - let moodLatestScore: number | null = null; - let moodTotalEntries = 0; - - if (moodRollupDayRows.length > 0) { - moodDailyPoints = moodRollupDayRows.map((r) => ({ - measuredAt: r.bucketStart, - // The rollup encodes the day label as that label's UTC midnight. - dayKey: dateOnlyKey(r.bucketStart), - value: r.mean, - count: r.count, - })); - moodTotalEntries = moodRollupDayRows.reduce((s, r) => s + r.count, 0); - // Latest score = newest individual entry (one bounded row). - const latestEntry = await prisma.moodEntry.findFirst({ - // v1.7.0 sync — exclude tombstoned rows. - where: { - userId, - deletedAt: null, - ...(historyFloor ? { moodLoggedAt: { gte: historyFloor } } : {}), - }, - orderBy: { moodLoggedAt: "desc" }, - select: { score: true }, - }); - moodLatestScore = latestEntry?.score ?? null; - } else { - // Coverage-fallback over the same window the rollup read covers - // (`moodSince`, five years). A one-year fallback answered the same - // question with a different history while the tier was warming up; - // mood entries are a few a day, so five years stays a bounded read. - const moodEntriesRaw = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null, moodLoggedAt: { gte: moodSince } }, - orderBy: { moodLoggedAt: "asc" }, - select: { score: true, moodLoggedAt: true, date: true }, - }); - if (moodEntriesRaw.length > 0) { - // Bucket per `date` (TZ-anchored YYYY-MM-DD label, same key the - // rollup tier would emit for Berlin-anchored single-entry days) - // so the fallback shape matches the rollup-tier shape. - const byDay = new Map< - string, - { dayKey: string; sum: number; count: number; ts: Date } - >(); - for (const e of moodEntriesRaw) { - const k = e.date; - const cur = byDay.get(k) ?? { - dayKey: k, - sum: 0, - count: 0, - ts: e.moodLoggedAt, - }; - cur.sum += e.score; - cur.count += 1; - // Pin the bucket's measuredAt to the latest entry's timestamp - // — close enough for the trailing-window filters below. - if (e.moodLoggedAt > cur.ts) cur.ts = e.moodLoggedAt; - byDay.set(k, cur); - } - moodDailyPoints = Array.from(byDay.values()) - .map((b) => ({ - measuredAt: b.ts, - dayKey: b.dayKey, - value: b.sum / b.count, - count: b.count, - })) - .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); - moodTotalEntries = moodEntriesRaw.length; - moodLatestScore = moodEntriesRaw[moodEntriesRaw.length - 1].score; - } - } + const { + points: moodDailyPoints, + totalEntries: moodTotalEntries, + latestScore: moodLatestScore, + } = await readMoodDailyPoints(userId, historyFloor); + type MoodDayPoint = (typeof moodDailyPoints)[number]; if (moodDailyPoints.length > 0) { const moodNow = Date.now(); diff --git a/src/lib/insights/mood-daily-points.ts b/src/lib/insights/mood-daily-points.ts new file mode 100644 index 000000000..00b91b87a --- /dev/null +++ b/src/lib/insights/mood-daily-points.ts @@ -0,0 +1,127 @@ +/** + * The per-day mood points the Coach's mood block is built from: the rollup + * DAY tier when it holds rows, a walk over the raw entries otherwise. + * Split out of `features.ts`, which builds the block from them. + */ +import { prisma } from "@/lib/db"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/rollups/mood-rollups"; +import { dateOnlyKey } from "@/lib/tz/date-only"; + +export interface MoodDayPoint { + measuredAt: Date; + /** The entry's own calendar day label (`MoodEntry.date`). */ + dayKey: string; + value: number; + count: number; +} + +/** + * The rollup tier first, never an unbounded read of the entries. The mood + * block downstream consumes the 7- and 30-day means, the 30-day trend (first + * half against second half), the newest entry's score (one bounded row), the + * entry count (the sum of the DAY rows' counts), the oldest and newest day, + * and one per-day mean point for the cross-metric correlations. + * + * When raw entries exist but the tier is still empty (an account the boot + * backfill has not reached), the points come from a walk over the same + * window, and the refresh is fired so the next read lands on the tier. Same + * posture as `/api/mood/analytics`. + */ +export async function readMoodDailyPoints( + userId: string, + /** The Coach's lookback floor, or null. */ + historyFloor: Date | null, +): Promise<{ + points: MoodDayPoint[]; + totalEntries: number; + latestScore: number | null; +}> { + void ensureUserMoodRollupsFresh(userId); + // Five-year window mirrors the mood-rollup writer default; covers + // every realistic user history span without an unbounded scan. + const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000); + const moodSince = + historyFloor && historyFloor > fiveYearsAgo ? historyFloor : fiveYearsAgo; + const moodRollupDayRows = await readMoodDayRollups(userId, moodSince); + + let moodDailyPoints: MoodDayPoint[] = []; + let moodLatestScore: number | null = null; + let moodTotalEntries = 0; + + if (moodRollupDayRows.length > 0) { + moodDailyPoints = moodRollupDayRows.map((r) => ({ + measuredAt: r.bucketStart, + // The rollup encodes the day label as that label's UTC midnight. + dayKey: dateOnlyKey(r.bucketStart), + value: r.mean, + count: r.count, + })); + moodTotalEntries = moodRollupDayRows.reduce((s, r) => s + r.count, 0); + // Latest score = newest individual entry (one bounded row). + const latestEntry = await prisma.moodEntry.findFirst({ + // Tombstoned rows are not entries. + where: { + userId, + deletedAt: null, + ...(historyFloor ? { moodLoggedAt: { gte: historyFloor } } : {}), + }, + orderBy: { moodLoggedAt: "desc" }, + select: { score: true }, + }); + moodLatestScore = latestEntry?.score ?? null; + } else { + // Coverage-fallback over the same window the rollup read covers + // (`moodSince`, five years). A one-year fallback answered the same + // question with a different history while the tier was warming up; + // mood entries are a few a day, so five years stays a bounded read. + const moodEntriesRaw = await prisma.moodEntry.findMany({ + // Tombstoned rows are not entries. + where: { userId, deletedAt: null, moodLoggedAt: { gte: moodSince } }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true, date: true }, + }); + if (moodEntriesRaw.length > 0) { + // Bucket per `date` (TZ-anchored YYYY-MM-DD label, same key the + // rollup tier would emit for Berlin-anchored single-entry days) + // so the fallback shape matches the rollup-tier shape. + const byDay = new Map< + string, + { dayKey: string; sum: number; count: number; ts: Date } + >(); + for (const e of moodEntriesRaw) { + const k = e.date; + const cur = byDay.get(k) ?? { + dayKey: k, + sum: 0, + count: 0, + ts: e.moodLoggedAt, + }; + cur.sum += e.score; + cur.count += 1; + // Pin the bucket's measuredAt to the latest entry's timestamp + // — close enough for the trailing-window filters below. + if (e.moodLoggedAt > cur.ts) cur.ts = e.moodLoggedAt; + byDay.set(k, cur); + } + moodDailyPoints = Array.from(byDay.values()) + .map((b) => ({ + measuredAt: b.ts, + dayKey: b.dayKey, + value: b.sum / b.count, + count: b.count, + })) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + moodTotalEntries = moodEntriesRaw.length; + moodLatestScore = moodEntriesRaw[moodEntriesRaw.length - 1].score; + } + } + + return { + points: moodDailyPoints, + totalEntries: moodTotalEntries, + latestScore: moodLatestScore, + }; +}