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); + }); +});