mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
The dashboard snapshot and the /api/mood/analytics route each carried a hand-copied rollup read, coverage fallback, and daily summarize of the same numbers. Two reads of one metric is the seam where the dashboard tile and the insights mood sparkline can drift apart. Extract buildMoodDailySeries as the single mood engine and route both callers through it, so the same mood number reads identically on the dashboard, the insights surface, and the iOS client.
150 lines
5.5 KiB
TypeScript
150 lines
5.5 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 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<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",
|
|
};
|
|
}
|