From 239319a7628e5609a4c59d388ce20aa292ffd227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 21:23:19 +0200 Subject: [PATCH] fix(mood): count the mood block's ages in calendar days, and fold today in every zone The Coach's mood block gave its newest and oldest entry ages as elapsed hours rounded to days. A point from the rollup tier sits at its day label's UTC midnight, so from noon UTC on this morning's entry read as a day old; the integration run failed at 19:02 UTC for that reason. The ages and the span are now whole days between the entries' own day labels and the reader's today. Pinning the clock to the edges of the Berlin day showed a second gap: the open-ended rollup fold stopped at now, and a day label runs up to fourteen hours ahead of UTC, so between a reader's local midnight and UTC midnight the fold left their today out and the tier called yesterday the newest day. The open-ended fold now reaches a day past now. --- src/lib/insights/features.ts | 46 +++++++++++++------ src/lib/rollups/mood-rollups.ts | 6 ++- .../mood-dimension-features.test.ts | 40 +++++++++++++++- 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 4c52bcd09..6b72b6c26 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -14,7 +14,12 @@ import { SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { resolveUserTimezone } from "@/lib/tz/resolver"; -import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; +import { + DEFAULT_TIMEZONE, + daysBetweenDateKeys, + userDayKey, +} from "@/lib/tz/format"; +import { dateOnlyKey } from "@/lib/tz/date-only"; import { readingsMean } from "@/lib/measurements/day-mean"; import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; @@ -1035,6 +1040,8 @@ async function readMoodFeature( type MoodDayPoint = { measuredAt: Date; + /** The entry's own calendar day label (`MoodEntry.date`). */ + dayKey: string; value: number; count: number; }; @@ -1045,6 +1052,8 @@ async function readMoodFeature( 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, })); @@ -1076,10 +1085,18 @@ async function readMoodFeature( // 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(); + 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) ?? { sum: 0, count: 0, ts: e.moodLoggedAt }; + 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 @@ -1090,6 +1107,7 @@ async function readMoodFeature( moodDailyPoints = Array.from(byDay.values()) .map((b) => ({ measuredAt: b.ts, + dayKey: b.dayKey, value: b.sum / b.count, count: b.count, })) @@ -1134,11 +1152,17 @@ async function readMoodFeature( } } - const oldest = moodDailyPoints[0].measuredAt; - const newest = moodDailyPoints[moodDailyPoints.length - 1].measuredAt; - const spanDays = Math.round( - (newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000), + // Ages and span in calendar days: the entries' own day labels against + // the reader's today. A rollup point sits at its label's UTC midnight, + // and elapsed hours rounded to days called a morning entry a day old + // from noon UTC on, so the answer depended on the hour of the question. + const todayKey = userDayKey( + new Date(moodNow), + await resolveUserTimezone(userId), ); + const oldestKey = moodDailyPoints[0].dayKey; + const newestKey = moodDailyPoints[moodDailyPoints.length - 1].dayKey; + const spanDays = daysBetweenDateKeys(oldestKey, newestKey); const avgDaysBetween = moodTotalEntries > 1 ? Math.round((spanDays / (moodTotalEntries - 1)) * 10) / 10 @@ -1158,12 +1182,8 @@ async function readMoodFeature( count: moodTotalEntries, spanDays, avgDaysBetween, - oldestDaysAgo: Math.round( - (moodNow - oldest.getTime()) / (24 * 60 * 60 * 1000), - ), - newestDaysAgo: Math.round( - (moodNow - newest.getTime()) / (24 * 60 * 60 * 1000), - ), + oldestDaysAgo: daysBetweenDateKeys(oldestKey, todayKey), + newestDaysAgo: daysBetweenDateKeys(newestKey, todayKey), }, }; } diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts index cd86d10c6..b7fccea27 100644 --- a/src/lib/rollups/mood-rollups.ts +++ b/src/lib/rollups/mood-rollups.ts @@ -192,7 +192,11 @@ export async function recomputeUserMoodRollups( const granularities = opts.granularities ?? ALL_MOOD_GRANULARITIES; const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000); const from = opts.from ?? fiveYearsAgo; - const to = opts.to ?? new Date(); + // A day label runs up to fourteen hours ahead of UTC (the zones east of + // it), so the open-ended fold reaches a day past now: bounded by now, the + // fold left out the reader's today between their local midnight and UTC + // midnight, and every read of the tier saw yesterday as the newest day. + const to = opts.to ?? new Date(Date.now() + 24 * 60 * 60 * 1000); if (opts.replace) { // Empty the folded granularities first so the refold below is the diff --git a/tests/integration/mood-dimension-features.test.ts b/tests/integration/mood-dimension-features.test.ts index 2dac517fe..7ba305f42 100644 --- a/tests/integration/mood-dimension-features.test.ts +++ b/tests/integration/mood-dimension-features.test.ts @@ -14,9 +14,10 @@ * stress is six days old. One of those may back a present-tense sentence and * the other may not. */ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { extractFeatures } from "@/lib/insights/features"; +import { ensureUserMoodRollupsFresh } from "@/lib/rollups/mood-rollups"; import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -268,3 +269,40 @@ describe("level-A blocks reach the feature set with their own ages", () => { ); }); }); + +/** + * The block's ages are calendar days in the reader's zone, whatever the hour. + * The rollup tier pins a day's point to its local midnight, and the ages were + * elapsed hours rounded to days: from noon on, this morning's entry read as a + * day old. The clock is pinned to the edges of the Berlin day, on both sides + * of noon, with the rollup tier built so the read takes that path. The days + * are in the past: the database clock is not pinned, and the rollup tier + * leaves out rows from its future. + */ +describe("the mood block's ages hold at every hour of the day", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + "2026-10-14T22:05:00.000Z", // 00:05 in Berlin + "2026-10-15T09:59:00.000Z", // 11:59 + "2026-10-15T10:01:00.000Z", // 12:01 + "2026-10-15T19:30:00.000Z", // 21:30, the hour the CI run failed at + "2026-10-15T21:55:00.000Z", // 23:55 + "2026-03-29T00:30:00.000Z", // 01:30 on the morning the clocks go forward + ])("today is 0 days old at %s", async (instant) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(instant)); + await seedUser(); + await seedEntry(3, { mood: "OKAY", score: 3, a1: 5 }); + await seedEntry(0, { mood: "GUT", score: 4, a1: 7 }); + await ensureUserMoodRollupsFresh(USER); + + const features = await extractFeatures(USER, false); + + expect(features.mood?.coverage.newestDaysAgo).toBe(0); + expect(features.mood?.coverage.oldestDaysAgo).toBe(3); + expect(features.mood?.coverage.spanDays).toBe(3); + }); +});