mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in new issue
Block a user