diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts index 81f145a57..738408f41 100644 --- a/src/app/api/auth/me/cycle-prefs/route.ts +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -28,6 +28,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { isCycleEnabled } from "@/lib/cycle/gate"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { toCycleProfileDTO } from "@/lib/cycle/dto"; import { cyclePrefsSchema } from "@/lib/validations/cycle"; @@ -118,6 +119,9 @@ export const PATCH = apiHandler(async (req: Request) => { }); const resolved = isCycleEnabled(user.gender, updated); + // The typical lengths, the prediction switch and tracking itself all feed + // the dashboard's cycle line, which is cached in the digest extras cell. + invalidateUserHealthContext(user.id); await auditLog("user.cycle-prefs.update", { userId: user.id, diff --git a/src/lib/cycle/today-verdict.ts b/src/lib/cycle/today-verdict.ts index 1ca4c1984..be98e93d4 100644 --- a/src/lib/cycle/today-verdict.ts +++ b/src/lib/cycle/today-verdict.ts @@ -41,8 +41,13 @@ export async function readTodayCycle( ? from : addDays(today, -BBT_WINDOW); - const [profile, cycles, dayLogRows, nightlyTemps] = await Promise.all([ - prisma.cycleProfile.findUnique({ where: { userId } }), + // A record that never set up cycle tracking has no profile: one point read + // and done, rather than three history reads that cannot be used. + const profile = await prisma.cycleProfile.findUnique({ where: { userId } }); + if (!profile) return null; + // The whole history, as the calendar route reads it: the forecast rests on + // every observed cycle, and this read must agree with the cycle page. + const [cycles, dayLogRows, nightlyTemps] = await Promise.all([ prisma.menstrualCycle.findMany({ where: { userId, deletedAt: null }, orderBy: { startDate: "asc" }, @@ -74,7 +79,7 @@ export async function readTodayCycle( select: { measuredAt: true, value: true }, }), ]); - if (!profile || cycles.length === 0) return null; + if (cycles.length === 0) return null; // The intent fields and the presence flags never reach a phase or a day // count; the grid carries them for the calendar's own overlay only. diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index cc726ed42..498cae53f 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -81,6 +81,7 @@ 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 { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { prisma } from "@/lib/db"; @@ -617,6 +618,18 @@ describe("loadDailyDigest — Today overview inputs", () => { expect(digest.restMode).toBeNull(); }); + it("reads the cycle once per extras cell, and again after a cycle write", async () => { + // The digest polls every 120 s. The cycle read (the whole cycle history, + // 90 days of day logs and wrist temperature) rides the cached cell, which + // a cycle write hard-evicts. + await loadDailyDigest(USER, NOW); + await loadDailyDigest(USER, NOW); + expect(readTodayCycle).toHaveBeenCalledTimes(1); + invalidateUserHealthContext(USER.id); + await loadDailyDigest(USER, NOW); + expect(readTodayCycle).toHaveBeenCalledTimes(2); + }); + it("reads the cycle in the profile timezone and keeps a failed read quiet", async () => { vi.mocked(readTodayCycle).mockRejectedValueOnce(new Error("boom")); const digest = await loadDailyDigest(USER, NOW); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index df1943241..95c609b3a 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -79,7 +79,7 @@ import { type UnitPreferences, } from "@/lib/measurements/display-transform"; import { resolveRestMode } from "@/lib/illness/rest-mode"; -import { readTodayCycle } from "@/lib/cycle/today-verdict"; +import { readTodayCycle, type TodayCycleRead } from "@/lib/cycle/today-verdict"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale"; import type { @@ -243,6 +243,13 @@ interface DailyDigestExtras { vitals: ExtrasVital[]; /** The stored daily scores the "steady for" line is read from. */ scoreDays: StoredScoreDay[]; + /** + * Today's place in the cycle, read only while the cycle module is on. A + * cycle write hard-evicts the cell (`invalidateUserHealthContext`), and a + * module switch does too (`invalidateUserHealthScore`), so a cached + * answer never outlives the rows or the switch it was read under. + */ + cycle: TodayCycleRead | null; } interface ExtrasVital { @@ -391,6 +398,7 @@ async function loadDailyDigestExtrasCached( timezone: string, todayLocalDate: string, now: Date, + cycleOn: boolean, ): Promise { return cachedSwr( caches.analytics as ServerCache, @@ -400,7 +408,7 @@ async function loadDailyDigestExtrasCached( // revision ran them sequentially). Each is already fault-isolated // internally (a milestone read-hiccup or a tension-read failure leaves // its own field quiet rather than breaking the other). - const [milestone, tensionWindow, sameTime, vitals, scoreDays] = + const [milestone, tensionWindow, sameTime, vitals, scoreDays, cycle] = await Promise.all([ gatherFreshMilestone(userId, timezone, now), loadIntradayPulse(userId, timezone, todayLocalDate) @@ -432,8 +440,13 @@ async function loadDailyDigestExtrasCached( .catch(() => null), gatherVitals(userId, timezone, now), gatherScoreDays(userId, todayLocalDate), + // The whole cycle history, 90 days of day logs and wrist + // temperature: too much to read on every 120 s poll. + cycleOn + ? readTodayCycle(userId, timezone, now).catch(() => null) + : Promise.resolve(null), ]); - return { milestone, tensionWindow, sameTime, vitals, scoreDays }; + return { milestone, tensionWindow, sameTime, vitals, scoreDays, cycle }; }, annotate, DIGEST_EXTRAS_CACHE_TTL_MS, @@ -761,19 +774,12 @@ export async function loadDailyDigest( timeLabel: visitTime(row.occurredAt), })); - // Rest Mode and today's cycle day. Both are read per request rather than - // through the extras cell: an episode or a period logged a minute ago is not - // a measurement write, so nothing would evict a cached answer. Each read is - // skipped outright when its module is off, and fault-isolated, because the - // digest is a must-not-fail path. - const [restModeContext, cycleRead] = await Promise.all([ - modules.illness !== false - ? resolveRestMode(user.id, now) - : Promise.resolve(null), - modules.cycle !== false - ? readTodayCycle(user.id, user.timezone, now).catch(() => null) - : Promise.resolve(null), - ]); + // Rest Mode is read per request; today's cycle day rides the extras cell + // below, which every cycle write hard-evicts. The read is skipped outright + // when its module is off, and fault-isolated, because the digest is a + // must-not-fail path. + const restModeContext = + modules.illness !== false ? await resolveRestMode(user.id, now) : null; const restMode: TodayRestMode | null = restModeContext?.active && restModeContext.since ? { @@ -787,9 +793,6 @@ export async function loadDailyDigest( ) + 1, } : null; - const cycle: TodayCycle | null = cycleRead - ? { dayOfCycle: cycleRead.dayOfCycle, phase: cycleRead.phase } - : null; // Last night, when it is in: the snapshot's sleep summary carries per-night // time asleep in minutes, `latest` being the newest night and `avg30` the @@ -845,13 +848,23 @@ export async function loadDailyDigest( // a 120 s poll of every open tab doesn't re-run the ~8.5k-row streak + // per-sample tension reads on every request — see its docblock for the // cache/invalidation contract. - const { milestone, tensionWindow, sameTime, vitals, scoreDays } = - await loadDailyDigestExtrasCached( - user.id, - user.timezone, - todayLocalDate, - now, - ); + const { + milestone, + tensionWindow, + sameTime, + vitals, + scoreDays, + cycle: cycleRead, + } = await loadDailyDigestExtrasCached( + user.id, + user.timezone, + todayLocalDate, + now, + modules.cycle !== false, + ); + const cycle: TodayCycle | null = cycleRead + ? { dayOfCycle: cycleRead.dayOfCycle, phase: cycleRead.phase } + : null; // Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds // (milestone / ecg_new_recording / tension_window) can ever be dismissed, so diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 152c4401f..83f75301e 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -231,17 +231,25 @@ export async function computeCoincidentDeviation( const todayKey = userDayKey(now, tz); // Fan the baseline engine across the supported vitals. Each baseline read - // shares the one coverage probe (no per-vital re-probe). - for (const type of VITALS_BASELINE_TYPES) { - const baseline = await computeVitalsBaseline(userId, profile, { - type, - windowDays, - now, - coverage, - }); + // shares the one coverage probe (no per-vital re-probe). The types are + // independent, so their reads run together; the results are folded in the + // registry's order, so the output does not depend on which finished first. + const perType = await Promise.all( + VITALS_BASELINE_TYPES.map(async (type) => { + const baseline = await computeVitalsBaseline(userId, profile, { + type, + windowDays, + now, + coverage, + }); + if (baseline.status !== "ok") return { type, baseline, latest: null }; + const latest = await readLatestDayMean(userId, type, windowDays, now, tz); + return { type, baseline, latest }; + }), + ); + for (const { type, baseline, latest } of perType) { if (baseline.status !== "ok") continue; if (baseline.provenance.source === "DAY") anyDaySource = true; - const latest = await readLatestDayMean(userId, type, windowDays, now, tz); if (!latest) continue; if (latest.day > latestDay) latestDay = latest.day; if (baseline.coverage.historyDays > maxHistoryDays) {