fix(digest): decide a milestone's day on the user's calendar

Today's key, each new record's day and the streak series were UTC days, so
through a New York evening a best set that afternoon counted as yesterday's
and dropped out of the digest. All three are now the user's own days; the
streak series is folded live on local days for this read.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 15:44:25 +02:00
1 parent a1d94a33ed
commit 6a422542f1
3 files changed
+51 -11

No files matched your search

@@ -63,6 +63,7 @@ import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { resolveModuleMap } from "@/lib/modules/gate";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
@@ -118,6 +119,36 @@ beforeEach(() => {
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
});
describe("loadDailyDigest — milestone day", () => {
// 21:30 on 16 July in New York is already 17 July in UTC. A best set at
// 19:00 local that evening was reached today, on the user's calendar.
const NY_USER = { ...USER, timezone: "America/New_York" } as User;
const NY_EVENING = new Date("2026-07-17T01:30:00.000Z");
it("keys today and a new record on the user's own day", async () => {
vi.mocked(prisma.personalRecord.findMany).mockResolvedValueOnce([
{
metricType: "RESTING_HEART_RATE",
achievedAt: new Date("2026-07-16T23:00:00.000Z"),
},
] as never);
const digest = await loadDailyDigest(NY_USER, NY_EVENING);
expect(digest.worthALook.some((i) => i.kind === "milestone")).toBe(true);
});
it("folds the streak series on the user's days", async () => {
vi.mocked(probeRollupCoverage).mockResolvedValue(
new Map([["RESTING_HEART_RATE", true]]) as never,
);
await loadDailyDigest(NY_USER, NY_EVENING);
const calls = vi.mocked(readDayMeanSeries).mock.calls;
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) expect(call[5]).toBe("America/New_York");
});
});
describe("loadDailyDigest — S11/S12 extras cache", () => {
it("gathers the milestone + tension inputs once, then serves the second poll from cache", async () => {
await loadDailyDigest(USER, NOW);
+11 -8
View File
@@ -101,11 +101,6 @@ const MILESTONE_STREAK_WINDOW_DAYS = 30;
/** How far back to scan for a just-set personal best (a couple of days is ample). */
const MILESTONE_RECORD_LOOKBACK_MS = 2 * 24 * 60 * 60 * 1000;
/** UTC-ISO day key — the space the streak series + rollup tier already emit. */
function utcDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
/**
* S12 — gather today's single freshly-reached milestone (or null) from the
* engines that ALREADY exist. Two cheap, fail-soft reads reused verbatim:
@@ -117,9 +112,13 @@ function utcDayKey(date: Date): string {
*/
async function gatherFreshMilestone(
userId: string,
timezone: string,
now: Date,
): Promise<Milestone | null> {
const todayKey = utcDayKey(now);
// Every day key here is the user's own calendar day: today's key, the
// streak series (folded on local days) and each record's day. UTC keys
// named tomorrow "today" through a New York evening.
const todayKey = userDayKey(now, timezone);
const candidates: Milestone[] = [];
const coverage = await probeRollupCoverage(userId).catch(() => null);
@@ -133,6 +132,7 @@ async function gatherFreshMilestone(
MILESTONE_STREAK_WINDOW_DAYS,
now,
coverage,
timezone,
);
if (points.length === 0) return [] as Milestone[];
const series: StreakPoint[] = points.map((p) => ({
@@ -164,7 +164,10 @@ async function gatherFreshMilestone(
});
for (const record of records) {
candidates.push(
milestoneFromRecord(record.metricType, utcDayKey(record.achievedAt)),
milestoneFromRecord(
record.metricType,
userDayKey(record.achievedAt, timezone),
),
);
}
} catch {
@@ -234,7 +237,7 @@ async function loadDailyDigestExtrasCached(
// internally (a milestone read-hiccup or a tension-read failure leaves
// its own field quiet rather than breaking the other).
const [milestone, tensionWindow, sameTime] = await Promise.all([
gatherFreshMilestone(userId, now),
gatherFreshMilestone(userId, timezone, now),
loadIntradayPulse(userId, timezone, todayLocalDate)
.then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
.catch(() => null),
+9 -3
View File
@@ -220,6 +220,10 @@ export function buildBaselineBand(
* Rollup tier first (DAY-native, `mean` composes); per-type bounded
* live-SQL fallback on a coverage miss. Returns the series plus the
* provenance source the read resolved against.
*
* Days are UTC days, the DAY rollup grain. A caller that compares the days
* with the user's own calendar (today's key, a record's local day) passes
* `timeZone`; the series is then folded live on the user's local days.
*/
export async function readDayMeanSeries(
userId: string,
@@ -227,8 +231,9 @@ export async function readDayMeanSeries(
windowDays: number,
now: Date,
coverage: RollupCoverageMap,
timeZone?: string,
): Promise<{ points: DayMeanPoint[]; source: DerivedProvenanceSource }> {
const hasBuckets = coverage.get(type) === true;
const hasBuckets = timeZone === undefined && coverage.get(type) === true;
if (hasBuckets) {
// DAY granularity only — the spread invariant forbids composing a
@@ -266,13 +271,14 @@ export async function readDayMeanSeries(
// stored value the application declares impossible would drag the day's
// mean with it and the median would carry it into the band. A day whose
// every reading is implausible produces no point at all. Days are UTC
// days, matching the DAY rollup path above.
// days, matching the DAY rollup path above, unless the caller asked for
// the user's own days.
const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const days = await readDayAggregates({
userId,
type,
since,
timeZone: "UTC",
timeZone: timeZone ?? "UTC",
valueRange: plausibleMetricRange(type),
});
const points = days.map((d) => ({ day: d.day, mean: d.sum / d.n }));