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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 21:23:19 +02:00
1 parent 8a9bcffaa2
commit 239319a762
3 files changed
+77 -15

No files matched your search

+33 -13
View File
@@ -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<string, { sum: number; count: number; ts: Date }>();
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),
},
};
}
+5 -1
View File
@@ -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
@@ -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);
});
});