mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The persistent mood-rollup writer bucketed on the UTC truncation of `mood_logged_at` and ignored the canonical per-row `MoodEntry.date` label. Every coverage-miss fallback keys on `entry.date`, so the rollup tier disagreed with the fallback and with the day the user logged the mood: a late-evening or after-midnight local mood landed on the wrong calendar day, and the label shifted the moment the boot backfill flipped a user from the live fallback onto the rollup tier. Key every bucket off the stored `date` label instead — DAY, WEEK, MONTH and YEAR — with `bucket_start` set to that label's UTC-midnight instant. The rollup is now byte-identical to the live `entry.date` fallback and timezone-independent by construction: DST correctness stays where it already lives, in `moodDateKey` at write time. No consumer changes, no schema change; every rollup reader already formats `bucket_start` as a UTC day label. Migration 0269 empties `mood_entry_rollups` so the boot-time full-backfill worker re-mints every user's buckets under the corrected keying; the worker now deletes-then-refolds so it is self-cleaning against any row left by an older instance. While the table is empty, reads fall back to the live `date`-keyed walk, which was already correct, so there is no window where a wrong day is shown.
152 lines
5.6 KiB
TypeScript
152 lines
5.6 KiB
TypeScript
/**
|
|
* 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<typeof summarize>;
|
|
/** 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 rollup `bucketStart` as a YYYY-MM-DD label from its UTC
|
|
* calendar parts. Since v1.32.12 the mood rollup writer stores
|
|
* `bucketStart` as the UTC-midnight encoding of the canonical per-row
|
|
* `MoodEntry.date` label, so reading the UTC parts back yields exactly
|
|
* that label — byte-identical to the live `entry.date` fallback below,
|
|
* across every timezone and DST boundary. (Before v1.32.12 the writer
|
|
* UTC-truncated `mood_logged_at`, which shifted the day for any local
|
|
* mood straddling the UTC boundary; that gap is now closed.)
|
|
*/
|
|
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<string, { sum: number; count: number }>();
|
|
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<MoodDailySeries> {
|
|
// 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",
|
|
};
|
|
}
|