mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user