diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index ece84d573..fb28f6e5a 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -70,6 +70,7 @@ import { import { isFullyCovered, probeRollupCoverage, + type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; import { @@ -220,6 +221,16 @@ export interface SummariesSlice { export async function computeSummariesSlice( userId: string, + /** + * v1.12.7 A5 — optional pre-probed coverage map. The dashboard + * snapshot already calls `probeRollupCoverage` up front to decide + * whether to run the thick `extras` phase, then called this slice + * which re-probed the identical query. Threading the map in lets the + * slice reuse it and saves one round-trip on the snapshot's hot path. + * Omit it (every other caller) to keep the internal probe as the + * fallback — byte-stable for those callers. + */ + precomputedCoverage?: RollupCoverageMap, ): Promise { // v1.4.37.1 hotfix — fire-and-forget. See `src/app/api/analytics/route.ts` // for the full rationale: awaiting this on the read path can stall @@ -242,7 +253,7 @@ export async function computeSummariesSlice( // only take the rollup path when EVERY type the user has logged is // covered. Partial coverage falls back to the live aggregate so the // brand-new-type case stays correct. - const coverage = await probeRollupCoverage(userId); + const coverage = precomputedCoverage ?? (await probeRollupCoverage(userId)); if (isFullyCovered(coverage)) { return withSleepNightTotals(userId, await computeFromRollups(userId)); } diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index ff331f27f..b8fa2643f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -145,6 +145,28 @@ describe("buildDashboardSnapshot — envelope shape", () => { expect(snap.layout).toBeDefined(); expect(typeof snap.generatedAt).toBe("string"); }); + + it("probes rollup coverage once and threads it into the summaries slice (A5)", async () => { + const coverageMap = new Map([["WEIGHT", true]]); + probeRollupCoverage.mockResolvedValue(coverageMap); + isFullyCovered.mockReturnValue(true); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: { pct: 70 }, + last30Days: { pct: 80 }, + allTime: { pct: 75 }, + priorMonth: { pct: 60 }, + priorYear: { pct: 50 }, + }); + + await buildDashboardSnapshot(fakePrisma, baseUser()); + + // The snapshot probes coverage exactly once up front — the slice no + // longer re-runs the identical probe internally. + expect(probeRollupCoverage).toHaveBeenCalledTimes(1); + // The already-probed map is passed as the slice's second argument so + // it reuses it instead of probing again. + expect(computeSummariesSlice).toHaveBeenCalledWith("user-1", coverageMap); + }); }); describe("buildDashboardSnapshot — two-phase null extras", () => { diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index a6b4218fe..71ec5be29 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -609,7 +609,9 @@ export async function buildDashboardSnapshot( const warm = isFullyCovered(coverage); const [slim, mood, extras, flags] = await Promise.all([ - time("summaries", () => computeSummariesSlice(user.id)), + // A5 — reuse the coverage map already probed above so the slice + // doesn't re-run the identical `probeRollupCoverage` query. + time("summaries", () => computeSummariesSlice(user.id, coverage)), time("mood", () => buildMoodBlock(prisma, user.id)), warm ? time("extras", () => buildExtras(prisma, user, userTz, coverage))