diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index c52a90995..631dd4b65 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -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); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index b85fb5311..dcfebbdb3 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -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 { - 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), diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index d6319880f..821ab52d6 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -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 }));