mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(mood-analytics): pre-aggregate live fallback to match rollup-path summarize semantics
QA UX-H1 (v1.4.39): the rollup fast-path feeds `summarize()` one DataPoint per calendar day (the rollup's `mean`); the live-fallback used to pass every raw entry, which silently shifted `summary.count / latest / min / max / mean / avg7 / avg30 / slope30` on multi-entry days. Pre-aggregating the live-fallback through `aggregateDailyAverages` before `summarize()` keeps both branches byte-identical on multi-entry days too. Pinned with a two-entries-on-the-same-day parity test that compares the live-fallback and rollup-tier summary fields directly.
This commit is contained in:
1 parent
362f87d4c4
commit
3c1a50cbb2
2 files changed
+66
-2
No files matched your search
@@ -246,6 +246,61 @@ describe("GET /api/mood/analytics", () => {
|
||||
expect(secondBody).toEqual(firstBody);
|
||||
});
|
||||
|
||||
it("pre-aggregates the live fallback through daily averages so multi-entry days match the rollup-path summarize semantics", async () => {
|
||||
// QA UX-H1 (v1.4.39): two entries on the same day (score 3 + 5)
|
||||
// must produce identical `summary.count / latest / min / max /
|
||||
// mean` on the rollup tier (one DataPoint with mean=4) AND on the
|
||||
// live-fallback path (which used to pass per-entry points).
|
||||
const moodLoggedAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
|
||||
// Live-fallback first: two entries on the same day.
|
||||
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ date: "2026-05-10", score: 3, moodLoggedAt },
|
||||
{ date: "2026-05-10", score: 5, moodLoggedAt },
|
||||
] as never);
|
||||
const liveRes = await callGet();
|
||||
const liveBody = (await liveRes.json()) as MoodAnalyticsBody;
|
||||
|
||||
__resetAllCachesForTests();
|
||||
|
||||
// Rollup tier: one DAY row with count=2 / mean=4.
|
||||
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([
|
||||
{
|
||||
userId: "user-mood-1",
|
||||
granularity: "DAY",
|
||||
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: 2,
|
||||
mean: 4,
|
||||
minScore: 3,
|
||||
maxScore: 5,
|
||||
sd: 1,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
] as never);
|
||||
const rollupRes = await callGet();
|
||||
const rollupBody = (await rollupRes.json()) as MoodAnalyticsBody;
|
||||
|
||||
// Both branches emit a single daily-average entry with score=4.
|
||||
expect(liveBody.data.entries).toEqual([
|
||||
{ date: "2026-05-10", score: 4, samples: 2 },
|
||||
]);
|
||||
expect(rollupBody.data.entries).toEqual([
|
||||
{ date: "2026-05-10", score: 4, samples: 2 },
|
||||
]);
|
||||
|
||||
// Summary stats now match across both branches — pre-v1.4.39 the
|
||||
// live path passed per-entry points so `count` was 2 (per entry)
|
||||
// and `mean` was the unweighted average over entries. After the
|
||||
// pre-aggregate the live path emits one DataPoint per day (mean=4)
|
||||
// exactly as the rollup tier does.
|
||||
expect(liveBody.data.summary.count).toBe(rollupBody.data.summary.count);
|
||||
expect(liveBody.data.summary.latest).toBe(rollupBody.data.summary.latest);
|
||||
expect(liveBody.data.summary.min).toBe(rollupBody.data.summary.min);
|
||||
expect(liveBody.data.summary.max).toBe(rollupBody.data.summary.max);
|
||||
expect(liveBody.data.summary.mean).toBe(rollupBody.data.summary.mean);
|
||||
});
|
||||
|
||||
it("emits byte-identical entries + summary shape between rollup tier and live fallback for the same canonical data", async () => {
|
||||
// Same one-entry-per-day series, surfaced once via the rollup
|
||||
// tier and once via the legacy live walk. Response shape is
|
||||
|
||||
@@ -123,8 +123,17 @@ async function buildMoodAnalyticsResponse(
|
||||
moodEntries.map((e) => ({ date: e.date, score: e.score })),
|
||||
);
|
||||
|
||||
const dataPoints: DataPoint[] = moodEntries.map((e) => ({
|
||||
date: e.moodLoggedAt,
|
||||
// QA UX-H1 (v1.4.39): feed `summarize()` per-day means, not per-entry
|
||||
// scores. The rollup fast-path emits one DataPoint per calendar day
|
||||
// (the rollup's `mean`); the live fallback used to pass every raw
|
||||
// entry, which silently shifted `summary.count / latest / min / max
|
||||
// / mean / avg7 / avg30 / slope30` on power-user multi-entry days.
|
||||
// Pre-aggregating through `aggregateDailyAverages` keeps the two
|
||||
// branches byte-identical on multi-entry days too. Date anchor is
|
||||
// local-noon for the day so the slope x-axis spans whole-day units —
|
||||
// mirrors the dashboard tile's intuition (one number per day).
|
||||
const dataPoints: DataPoint[] = entries.map((e) => ({
|
||||
date: new Date(`${e.date}T12:00:00.000Z`),
|
||||
value: e.score,
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user