diff --git a/src/__tests__/api/analytics/since-cap.test.ts b/src/__tests__/api/analytics/since-cap.test.ts index 5ad0f7f9c..c152852a6 100644 --- a/src/__tests__/api/analytics/since-cap.test.ts +++ b/src/__tests__/api/analytics/since-cap.test.ts @@ -13,11 +13,13 @@ * * This file pins: * - * 1. **The 90-day floor reaches `prisma.measurement.findMany`.** + * 1. **The 425-day floor reaches `prisma.measurement.findMany`.** * Every per-type read inside the default slice carries - * `where.measuredAt.gte` ≈ now − 90 days. If a future refactor - * drops the `since` option from the chunked helper this test - * breaks loudly. + * `where.measuredAt.gte` ≈ now − 425 days. QA Specialist-H2 + * (v1.4.39) widened the original 90-day cap to 425 days so + * `summarize().avg30LastYear` (year-ago tile) stays populated + * on the live-fallback path. If a future refactor drops the + * `since` option from the chunked helper this test breaks loudly. * * 2. **The `meta.analytics.bp_aggregate.live_since` annotate fires.** * Lets the next perf-verify see how far back the live-fallback @@ -26,7 +28,7 @@ * * 3. **The slim slice (`?slice=summaries`) is unaffected.** That * branch never hit the per-type loop in the first place and - * keeps its own 90-day SQL aggregator. + * keeps its own SQL aggregator. */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -193,7 +195,7 @@ beforeEach(async () => { }); describe("GET /api/analytics — live-fallback `since` cap (W-SINCE)", () => { - it("passes a trailing-90-day `where.measuredAt.gte` to every per-type findMany", async () => { + it("passes a trailing-425-day `where.measuredAt.gte` to every per-type findMany", async () => { const before = Date.now(); const response = await callGet( new Request("http://localhost/api/analytics"), @@ -204,9 +206,11 @@ describe("GET /api/analytics — live-fallback `since` cap (W-SINCE)", () => { const findMany = prisma.measurement.findMany as ReturnType; expect(findMany.mock.calls.length).toBeGreaterThan(0); - const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000; - const expectedMinSince = before - NINETY_DAYS_MS; - const expectedMaxSince = after - NINETY_DAYS_MS; + // QA Specialist-H2 (v1.4.39): 425 days = 365 (year-ago window) + + // 30 (avg30LastYear bucket span) + 30 (cache-aging buffer). + const FOUR_TWENTY_FIVE_DAYS_MS = 425 * 24 * 60 * 60 * 1000; + const expectedMinSince = before - FOUR_TWENTY_FIVE_DAYS_MS; + const expectedMaxSince = after - FOUR_TWENTY_FIVE_DAYS_MS; // The per-type loop (A2) issues chunked reads with an `orderBy` // *array* (`(measuredAt asc, id asc)`); the 30-day glucose and @@ -232,7 +236,7 @@ describe("GET /api/analytics — live-fallback `since` cap (W-SINCE)", () => { } }); - it("annotates `meta.analytics.bp_aggregate.live_since` with the 90-day cutoff ISO", async () => { + it("annotates `meta.analytics.bp_aggregate.live_since` with the 425-day cutoff ISO", async () => { const before = Date.now(); await callGet(new Request("http://localhost/api/analytics")); const after = Date.now(); @@ -256,10 +260,12 @@ describe("GET /api/analytics — live-fallback `since` cap (W-SINCE)", () => { const bpAggregate = fields.meta.analytics.bp_aggregate; expect(typeof bpAggregate.live_since).toBe("string"); - const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000; + const FOUR_TWENTY_FIVE_DAYS_MS = 425 * 24 * 60 * 60 * 1000; const liveSinceMs = new Date(bpAggregate.live_since).getTime(); - expect(liveSinceMs).toBeGreaterThanOrEqual(before - NINETY_DAYS_MS); - expect(liveSinceMs).toBeLessThanOrEqual(after - NINETY_DAYS_MS); + expect(liveSinceMs).toBeGreaterThanOrEqual( + before - FOUR_TWENTY_FIVE_DAYS_MS, + ); + expect(liveSinceMs).toBeLessThanOrEqual(after - FOUR_TWENTY_FIVE_DAYS_MS); }); it("does not invoke the per-type loop on the slim `?slice=summaries` branch", async () => { diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 827e18675..e4e305f42 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -220,24 +220,24 @@ async function buildAnalyticsResponse(user: AuthedUser) { // rows each, dominating the cold full-slice critical path (Marc's // 74.6 s cold mount). // - // After the cap: the trailing 90 days mirror the comprehensive - // aggregator + summaries-slice windows; the worst case drops to - // roughly the same row count those paths already absorb (~5 k - // estimated on Marc's account). Cumulative HealthKit types - // (ACTIVITY_STEPS, ACTIVE_ENERGY, …) collapse to one-row-per-day - // after the per-day-sum pass downstream so the 90-day window is - // ample for every consumer summarise() feeds. + // After the cap: the trailing 425 days bound the per-type walk + // while preserving `summarize().avg30LastYear` (points 365-395 days + // ago) and `summarize().avg30LastMonth` (30-60 days ago) for the + // dashboard's year-over-year and month-over-month comparison tiles. + // Cumulative HealthKit types (ACTIVITY_STEPS, ACTIVE_ENERGY, …) + // collapse to one-row-per-day after the per-day-sum pass downstream + // so a 425-day window stays bounded (≤ ~25 k rows per type on a + // saturated tenant). // - // Trade-off: `summarize().avg30LastYear` (points 365-395 days ago) - // returns null on this fall-through path; the dashboard tile's - // year-over-year overlay shows "no prior data" until the rollup - // tier warms. This is acceptable because the live-fallback is - // already a degraded path — users hit it only when the per-type - // coverage gate flips false, which v1.4.38.8 made rare. The slim - // slice (`?slice=summaries`) and the rollup-backed comprehensive - // aggregator already operate on the 90-day window, so the cap - // brings the full slice into line with the slim slice's contract. - const ANALYTICS_LIVE_WINDOW_DAYS = 90; + // QA Specialist-H2 (v1.4.39): the original 90-day cap stripped the + // year-ago window so `summary.avg30LastYear` returned null on every + // fall-through path, making the "vs last year" tile render "no + // prior data" on every cold mount. The 425-day cap (365 + 30 + 30 + // buffer) keeps the year-ago window populated; the slim slice + // (`?slice=summaries`) and the rollup-backed comprehensive + // aggregator already operate on tighter windows so this cap only + // affects the legacy live-fallback path the fast-path gate misses. + const ANALYTICS_LIVE_WINDOW_DAYS = 425; const liveSince = new Date( Date.now() - ANALYTICS_LIVE_WINDOW_DAYS * 24 * 60 * 60 * 1000, ); @@ -262,10 +262,10 @@ async function buildAnalyticsResponse(user: AuthedUser) { types.map((type) => fetchMeasurementSeriesChunked(user.id, type, { includeSleepStage: true, - // v1.4.39 W-SINCE — trailing 90-day floor for the live read. - // Mirrors the comprehensive-aggregator + summaries-slice - // windows so the full slice stops over-reading on the - // fall-through path. See comment block above. + // v1.4.39 W-SINCE — trailing 425-day floor for the live read. + // Wide enough to populate `summarize().avg30LastYear` (the + // year-ago comparison tile) while still bounding the per-type + // row count. See comment block above. since: liveSince, }).then((measurements) => { totalRowsReadForAggregate += measurements.length; @@ -362,7 +362,7 @@ async function buildAnalyticsResponse(user: AuthedUser) { // // v1.4.39 W-SINCE — also surface `live_since` (ISO) so the next // perf-verify can see how far back the live-fallback read actually - // went. With the 90-day cap in place this should be ~now-90d on + // went. With the 425-day cap in place this should be ~now-425d on // every full-slice request; an older value would signal that a // future refactor accidentally widened the window again. annotate({