Skip to content

fix(gsc): make convenience date ranges span the days they name - #173

Open
mlava wants to merge 1 commit into
every-app:mainfrom
mlava:fix/gsc-inclusive-date-ranges
Open

fix(gsc): make convenience date ranges span the days they name#173
mlava wants to merge 1 commit into
every-app:mainfrom
mlava:fix/gsc-inclusive-date-ranges

Conversation

@mlava

@mlava mlava commented Jul 30, 2026

Copy link
Copy Markdown

Summary

Every GSC convenience date range currently covers one day more than its name promises. last_28_days asks Google for 29 days of data, last_7_days for 8, and the calendar-month ranges for n months plus a day.

The cause is an inclusive/exclusive mismatch. Google's Search Analytics API documents both bounds as inclusive — for startDate and endDate alike, "This value is included in the range." But subtractRange in src/server/features/gsc/searchAnalytics.ts subtracted a full N days from the lagged end date, which produces an N+1 day window once both endpoints are counted.

Why this matters

Absolute metrics are inflated by the extra day's traffic:

Range Actual span Inflation vs. the named window
last_7_days 8 days ~14%
last_28_days (the default) 29 days ~3.6%
last_3_months 3 months + 1 day ~1%
last_6/12/16_months n months + 1 day <1%

last_28_days is the default for both the Search Performance page and the get_search_console_performance MCP tool, so the 3.6% figure is the one most users see.

The practical failure is reconciliation. A user comparing OpenSEO's "Last 28 days" clicks against the identically-labelled range in the Google Search Console UI gets two different numbers, with no visible reason for the gap. For a tool whose value rests on being a faithful view of first-party data, silently disagreeing with the source is a worse failure than the size of the discrepancy suggests. The same applies to anything downstream that divides by the window — per-day averages and the striking-distance calculations are all computed over a denominator that is one day too wide.

What is not affected

Period-over-period comparison is correct, before and after. previousPeriod in searchPerformanceReport.ts derives its length from endDate - startDate rather than from the range name, so the prior window was also 29 days and abutted the current one with no gap or overlap. The deltas were apples-to-apples under the bug and remain so under the fix. Only absolute totals move.

The 16-month floor is unchanged. sixteenMonthFloor still subtracts whole calendar months. It is a data-availability clamp expressing how far back GSC retains data, not a range endpoint, so it does not take the inclusive-bounds adjustment. This is why the fix adds a separate nextUtcDay helper instead of changing subtractUtcMonths, which both call sites share.

The change

  • Day ranges subtract N - 1 days (-6 and -27).
  • Calendar-month ranges start the day after the month subtraction, via a new nextUtcDay helper. Short-month clamping in subtractUtcMonths is preserved: for an end date of 31 May, the 3-month window is now 1 Mar – 31 May (three whole months) rather than 28 Feb – 31 May.
  • A comment records the inclusive-bounds constraint at the point where it is easy to get wrong again.

Resolved windows for TODAY = 2026-05-28, end date 2026-05-25 after the existing 3-day data-lag offset:

Range Before After
last_7_days 2026-05-18 (8 days) 2026-05-19 (7 days)
last_28_days 2026-04-27 (29 days) 2026-04-28 (28 days)
last_3_months 2026-02-28 2026-03-01

Tests

The existing suite asserted the bug outright — a case titled "computes a 28-day window from the lagged end" expected 2026-04-272026-05-25, which is 29 inclusive days. A hard-coded date pair cannot tell you whether the window is the length it claims to be, which is how this survived.

The day-range cases now assert the inclusive day count through a small inclusiveDays helper alongside the dates, so the off-by-one cannot reappear under a green test. last_7_days gains coverage it did not previously have.

The two 16-month clamp tests pass unchanged, though one now exercises the clamp slightly harder: the raw start it produces has moved a day later and lands exactly on the floor rather than below it.

Verification

  • 58 GSC-related tests pass: searchAnalytics, searchPerformanceReport, GscService, gscClient, search-console-tools.
  • prettier --check and oxlint --type-aware clean on the changed files.

Branched from upstream/main and verified there rather than on a fork base. Two pre-existing failures in npm run ci:check are unrelated to this change and reproduce identically on a clean upstream/main checkout with the change stashed: tsc --noEmit errors in alchemy.run.ts from unresolved @distilled.cloud/cloudflare/* modules, and a large knip unused-file count. Neither touches src/server/features/gsc/.

Note for reviewers

Expect reported clicks and impressions to tick down slightly after this lands. That is the fix taking effect, not a regression — the numbers were previously counting a day the label did not include.

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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant