refactor(insights): read the mood block's daily points in a module of their own

The rollup-or-fallback read of the per-day mood points moves out of features.ts into mood-daily-points.ts, unchanged, which keeps features.ts under its size baseline after the calendar-day ages.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 21:31:27 +02:00
1 parent 239319a762
commit bafda973ee
4 files changed
+138 -117

No files matched your search

@@ -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,
@@ -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,
+7 -113
View File
@@ -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();
+127
View File
@@ -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,
};
}