From 8d34f124cc2e40c27d357a6086e21afca5ae2182 Mon Sep 17 00:00:00 2001 From: Mark Lavercombe Date: Thu, 30 Jul 2026 14:14:53 +1000 Subject: [PATCH] fix(gsc): make convenience date ranges span the days they name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GSC treats startDate and endDate as inclusive, but subtractRange took a full N days off the lagged end, so every convenience range covered one day too many: last_7_days spanned 8 days, last_28_days spanned 29, and the calendar-month ranges spanned n months plus a day. Reported clicks and impressions were correspondingly inflated (~3.6% on the 28-day default) and did not reconcile with the same range in the GSC UI. Subtract N-1 days for the day ranges, and start the month ranges the day after the month subtraction. sixteenMonthFloor keeps subtracting whole months — it is a data-availability clamp, not a range endpoint, so it does not take the same adjustment. Period-over-period comparison is unaffected: previousPeriod derives its length from the resolved window, so it tracked the old behaviour and tracks the new one. Tests now assert the inclusive day count rather than only hard-coded dates, so the off-by-one cannot return under a passing test, and last_7_days gains coverage it did not have. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/gsc/searchAnalytics.test.ts | 28 +++++++++++++++++-- src/server/features/gsc/searchAnalytics.ts | 20 +++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/server/features/gsc/searchAnalytics.test.ts b/src/server/features/gsc/searchAnalytics.test.ts index e62cae6aa..837e3ec2f 100644 --- a/src/server/features/gsc/searchAnalytics.test.ts +++ b/src/server/features/gsc/searchAnalytics.test.ts @@ -6,19 +6,39 @@ import { const TODAY = new Date("2026-05-28T00:00:00Z"); +/** GSC counts startDate and endDate inclusively, so the window a range asks + * for must match the days GSC will actually bill it for. */ +function inclusiveDays(startDate: string, endDate: string): number { + const dayMs = 24 * 60 * 60 * 1000; + const start = Date.parse(`${startDate}T00:00:00Z`); + const end = Date.parse(`${endDate}T00:00:00Z`); + return (end - start) / dayMs + 1; +} + describe("resolveDateRange", () => { it("ends convenience ranges 3 days back for GSC data lag", () => { const { endDate } = resolveDateRange({ dateRange: "last_28_days" }, TODAY); expect(endDate).toBe("2026-05-25"); }); - it("computes a 28-day window from the lagged end", () => { + it("spans 28 inclusive days from the lagged end", () => { const { startDate, endDate } = resolveDateRange( { dateRange: "last_28_days" }, TODAY, ); - expect(startDate).toBe("2026-04-27"); + expect(startDate).toBe("2026-04-28"); + expect(endDate).toBe("2026-05-25"); + expect(inclusiveDays(startDate, endDate)).toBe(28); + }); + + it("spans 7 inclusive days from the lagged end", () => { + const { startDate, endDate } = resolveDateRange( + { dateRange: "last_7_days" }, + TODAY, + ); + expect(startDate).toBe("2026-05-19"); expect(endDate).toBe("2026-05-25"); + expect(inclusiveDays(startDate, endDate)).toBe(7); }); it("clamps the start to the 16-month floor", () => { @@ -52,7 +72,9 @@ describe("resolveDateRange", () => { { dateRange: "last_3_months" }, new Date("2026-06-03T00:00:00Z"), ); - expect(startDate).toBe("2026-02-28"); + // Feb 28 is the clamped subtraction; the window starts the day after it so + // Mar 1 - May 31 covers three whole months rather than three months plus a day. + expect(startDate).toBe("2026-03-01"); expect(endDate).toBe("2026-05-31"); }); diff --git a/src/server/features/gsc/searchAnalytics.ts b/src/server/features/gsc/searchAnalytics.ts index 59cc8c7b3..36ef62006 100644 --- a/src/server/features/gsc/searchAnalytics.ts +++ b/src/server/features/gsc/searchAnalytics.ts @@ -85,27 +85,35 @@ function subtractUtcMonths(date: Date, months: number): Date { return d; } +// GSC treats startDate and endDate as inclusive, so an N-day range starts +// N-1 days back and a calendar-month range starts the day after the subtraction. function subtractRange(end: Date, range: GscDateRange): Date { const d = new Date(end); switch (range) { case "last_7_days": - d.setUTCDate(d.getUTCDate() - 7); + d.setUTCDate(d.getUTCDate() - 6); break; case "last_28_days": - d.setUTCDate(d.getUTCDate() - 28); + d.setUTCDate(d.getUTCDate() - 27); break; case "last_3_months": - return subtractUtcMonths(d, 3); + return nextUtcDay(subtractUtcMonths(d, 3)); case "last_6_months": - return subtractUtcMonths(d, 6); + return nextUtcDay(subtractUtcMonths(d, 6)); case "last_12_months": - return subtractUtcMonths(d, 12); + return nextUtcDay(subtractUtcMonths(d, 12)); case "last_16_months": - return subtractUtcMonths(d, 16); + return nextUtcDay(subtractUtcMonths(d, 16)); } return d; } +function nextUtcDay(date: Date): Date { + const d = new Date(date); + d.setUTCDate(d.getUTCDate() + 1); + return d; +} + function sixteenMonthFloor(today: Date): string { return formatDate(subtractUtcMonths(today, 16)); }