From 7570a3c5759372b29c3738a9be86f78ebafcd8ae Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 20 Jul 2026 16:18:41 +0200 Subject: [PATCH 1/2] fix: align preset ranges with Vercel Expand multi-day presets to the local calendar days touched by the rolling selection and use daily trend buckets for short multi-day ranges. Canonicalize existing shared URLs and keep the date picker end date inclusive. --- .../src/analytics/dashboard-url-state.test.ts | 22 ++++++ .../src/analytics/dashboard-url-state.ts | 13 ++-- .../analytics/date-range-picker.element.ts | 5 +- .../Client/src/analytics/date-range.test.ts | 28 ++++++-- .../Client/src/analytics/date-range.ts | 67 +++++++++++++++---- 5 files changed, 108 insertions(+), 27 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts index 3f24e48..e8aa8c9 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts @@ -52,6 +52,28 @@ describe("analytics dashboard URL state", () => { }); }); + it("migrates a rolling seven day URL to Vercel's timezone-adjusted daily window", () => { + const state = parseDashboardUrlState(new URLSearchParams( + "range=7&from=2026-07-06T13%3A00%3A00.001Z&to=2026-07-13T14%3A00%3A00.000Z&tz=Europe%2FCopenhagen", + )); + + expect(state.range).toEqual({ + from: "2026-07-05T22:00:00.000Z", + to: "2026-07-13T22:00:00.000Z", + interval: "Day", + timeZone: "Europe/Copenhagen", + }); + }); + + it("ignores a preset range with an invalid timezone", () => { + const state = parseDashboardUrlState(new URLSearchParams( + "range=7&from=2026-07-06T13%3A00%3A00Z&to=2026-07-13T14%3A00%3A00Z&tz=Not%2FA_Timezone", + )); + + expect(state.preset).toBe(7); + expect(state.range).toBeUndefined(); + }); + it("defaults invalid UTM tabs to source", () => { expect(parseDashboardUrlState(new URLSearchParams("utm=Nope")).utm).toBe("UtmSource"); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts index f69c0f9..885b04e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts @@ -1,5 +1,5 @@ import type { AnalyticsDimension } from "../api/types.gen.js"; -import { normalizeCustomRange, type AnalyticsDateRange, type DatePreset } from "./date-range.js"; +import { normalizeCustomRange, normalizePresetRange, type AnalyticsDateRange, type DatePreset } from "./date-range.js"; export type AnalyticsFilter = { dimension: AnalyticsDimension; value: string }; export type DashboardMetric = "visitors" | "pageViews"; @@ -32,11 +32,12 @@ export function parseDashboardUrlState(params: URLSearchParams): DashboardUrlSta const preset: DatePreset | undefined = rawPreset === "custom" ? "custom" : PRESETS.has(numericPreset) ? numericPreset as Exclude : undefined; - const range = normalizeCustomRange( - params.get("from") ?? "", - params.get("to") ?? "", - params.get("tz") || undefined, - ); + const from = params.get("from") ?? ""; + const to = params.get("to") ?? ""; + const timeZone = params.get("tz") || undefined; + const range = preset && preset !== "custom" + ? normalizePresetRange(preset, from, to, timeZone) + : normalizeCustomRange(from, to, timeZone); const filters: AnalyticsFilter[] = []; const seen = new Set(); for (const raw of params.getAll("filter").slice(0, 10)) { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts index 1359ac8..075386b 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts @@ -11,11 +11,11 @@ import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import { analyticsDateOnly, + analyticsRangeEndDate, calendarMonthDays, dateRangeForPreset, formatAnalyticsRangeLabel, normalizeCustomRange, - shiftCalendarDate, shiftCalendarMonth, type AnalyticsCalendarDay, type AnalyticsDateRange, @@ -73,8 +73,7 @@ export class VercelAnalyticsDateRangePickerElement extends UmbElementMixin(LitEl } #draftToForRange(): string { - const to = analyticsDateOnly(this.range.to, this.range.timeZone); - return this.preset === "custom" ? shiftCalendarDate(to, -1) ?? "" : to; + return analyticsRangeEndDate(this.range); } #onToggle(event: Event): void { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts index 0e85cee..bbccc0a 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts @@ -1,16 +1,30 @@ import { describe, expect, it } from "vitest"; -import { calendarMonthDays, dateRangeForPreset, formatAnalyticsDate, formatAnalyticsRangeLabel, formatAnalyticsTooltipDate, inclusiveRangeDays, intervalForRange, isAnalyticsPeriodInProgress, normalizeCustomRange, shiftCalendarMonth } from "./date-range.js"; +import { calendarMonthDays, dateRangeForPreset, formatAnalyticsDate, formatAnalyticsRangeLabel, formatAnalyticsTooltipDate, inclusiveRangeDays, intervalForRange, isAnalyticsPeriodInProgress, normalizeCustomRange, normalizePresetRange, shiftCalendarMonth } from "./date-range.js"; describe("analytics date ranges", () => { - it("creates an exact rolling 30 day range", () => { + it("aligns a multi-day preset to every calendar day touched by the rolling range", () => { expect(dateRangeForPreset(30, new Date("2026-07-15T12:00:00Z"), "UTC")).toEqual({ - from: "2026-06-15T12:00:00.000Z", - to: "2026-07-15T12:00:00.000Z", + from: "2026-06-15T00:00:00.000Z", + to: "2026-07-16T00:00:00.000Z", interval: "Day", timeZone: "UTC", }); }); + it("matches Vercel's timezone-adjusted daily window for a seven day request", () => { + expect(normalizePresetRange( + 7, + "2026-07-06T13:00:00.001Z", + "2026-07-13T14:00:00.000Z", + "Europe/Copenhagen", + )).toEqual({ + from: "2026-07-05T22:00:00.000Z", + to: "2026-07-13T22:00:00.000Z", + interval: "Day", + timeZone: "Europe/Copenhagen", + }); + }); + it("creates an hourly rolling 24 hour range", () => { expect(dateRangeForPreset(1, new Date("2026-07-15T12:00:00Z"), "Europe/Copenhagen")).toEqual({ from: "2026-07-14T12:00:00.000Z", @@ -21,7 +35,9 @@ describe("analytics date ranges", () => { }); it("selects granularity from the reporting window", () => { - expect(intervalForRange(7)).toBe("Hour"); + expect(intervalForRange(1)).toBe("Hour"); + expect(intervalForRange(2)).toBe("Day"); + expect(intervalForRange(7)).toBe("Day"); expect(intervalForRange(30)).toBe("Day"); expect(intervalForRange(90)).toBe("Day"); expect(intervalForRange(91)).toBe("Week"); @@ -67,7 +83,7 @@ describe("analytics date ranges", () => { expect(range).toMatchObject({ from: "2026-03-27T23:00:00.000Z", to: "2026-03-29T22:00:00.000Z", - interval: "Hour", + interval: "Day", timeZone: "Europe/Copenhagen", }); expect(inclusiveRangeDays(range!)).toBe(2); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts index d64fbfb..72520db 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts @@ -28,21 +28,43 @@ export function dateRangeForPreset( ): AnalyticsDateRange { const to = new Date(now); const from = new Date(to.valueOf() - preset * DAY_MS); - return { - from: from.toISOString(), - to: to.toISOString(), - interval: intervalForRange(preset), - timeZone, - }; + return normalizePresetRange(preset, from.toISOString(), to.toISOString(), timeZone)!; } export function intervalForRange(days: number): AnalyticsInterval { - if (days <= 7) return "Hour"; + if (days <= 1) return "Hour"; if (days <= 90) return "Day"; if (days <= 365) return "Week"; return "Month"; } +export function normalizePresetRange( + preset: number, + from: string, + to: string, + timeZone = browserTimeZone(), +): AnalyticsDateRange | undefined { + if (!isValidTimeZone(timeZone)) return undefined; + const fromInstant = validIso(from); + const toInstant = validIso(to); + if (!fromInstant || !toInstant || Date.parse(fromInstant) >= Date.parse(toInstant)) return undefined; + if (preset <= 1) { + return { from: fromInstant, to: toInstant, interval: "Hour", timeZone }; + } + + const firstDate = analyticsDateOnly(fromInstant, timeZone); + const rawLastDate = analyticsDateOnly(toInstant, timeZone); + const lastDate = isZonedMidnight(toInstant, timeZone) + ? shiftCalendarDate(rawLastDate, -1) + : rawLastDate; + const dayAfterLast = lastDate && shiftCalendarDate(lastDate, 1); + const alignedFrom = firstDate && zonedMidnightToIso(firstDate, timeZone); + const alignedTo = dayAfterLast && zonedMidnightToIso(dayAfterLast, timeZone); + if (!alignedFrom || !alignedTo || Date.parse(alignedFrom) >= Date.parse(alignedTo)) return undefined; + + return { from: alignedFrom, to: alignedTo, interval: intervalForRange(preset), timeZone }; +} + export function inclusiveRangeDays(range: Pick): number { const from = Date.parse(range.from); const to = Date.parse(range.to); @@ -54,6 +76,7 @@ export function normalizeCustomRange( to: string, timeZone = browserTimeZone(), ): AnalyticsDateRange | undefined { + if (!isValidTimeZone(timeZone)) return undefined; const fromInstant = dateOnlyPattern.test(from) ? zonedMidnightToIso(from, timeZone) : validIso(from); const nextToDate = dateOnlyPattern.test(to) ? shiftCalendarDate(to, 1) : undefined; const toInstant = dateOnlyPattern.test(to) ? nextToDate && zonedMidnightToIso(nextToDate, timeZone) : validIso(to); @@ -134,11 +157,8 @@ export function formatAnalyticsRangeLabel( if (Number.isNaN(from.valueOf()) || Number.isNaN(to.valueOf())) return "Custom range"; const fromDate = analyticsDateOnly(range.from, range.timeZone); - const exclusiveCalendarEnd = isZonedMidnight(range.from, range.timeZone) - && isZonedMidnight(range.to, range.timeZone); - const toDate = exclusiveCalendarEnd - ? shiftCalendarDate(analyticsDateOnly(range.to, range.timeZone), -1) - : analyticsDateOnly(range.to, range.timeZone); + const exclusiveCalendarEnd = hasExclusiveCalendarEnd(range); + const toDate = analyticsRangeEndDate(range); if (!toDate) return "Custom range"; const toDisplayDate = exclusiveCalendarEnd ? new Date(zonedMidnightToIso(toDate, range.timeZone) ?? range.to) @@ -155,6 +175,13 @@ export function formatAnalyticsRangeLabel( return `${monthDayYear.format(from)} – ${monthDayYear.format(toDisplayDate)}`; } +export function analyticsRangeEndDate( + range: Pick, +): string { + const toDate = analyticsDateOnly(range.to, range.timeZone); + return hasExclusiveCalendarEnd(range) ? shiftCalendarDate(toDate, -1) ?? "" : toDate; +} + export function formatAnalyticsDate( timestamp: string, interval: AnalyticsInterval, @@ -211,12 +238,28 @@ function validIso(value: string): string | undefined { return value && !Number.isNaN(date.valueOf()) ? date.toISOString() : undefined; } +function isValidTimeZone(timeZone: string): boolean { + try { + new Intl.DateTimeFormat("en", { timeZone }); + return true; + } catch { + return false; + } +} + function isZonedMidnight(timestamp: string, timeZone: string): boolean { const dateOnly = analyticsDateOnly(timestamp, timeZone); const midnight = dateOnly ? zonedMidnightToIso(dateOnly, timeZone) : undefined; return midnight !== undefined && Date.parse(midnight) === Date.parse(timestamp); } +function hasExclusiveCalendarEnd( + range: Pick, +): boolean { + return isZonedMidnight(range.from, range.timeZone) + && isZonedMidnight(range.to, range.timeZone); +} + function zonedMidnightToIso(dateOnly: string, timeZone: string): string | undefined { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOnly); if (!match) return undefined; From 19a6ed2fcb8553f33d49af2f88ade4f4ec5b5d5a Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 20 Jul 2026 17:19:53 +0200 Subject: [PATCH 2/2] fix: preserve Vercel rolling date windows Keep multi-day presets on the client-local hour boundaries Vercel requests while using daily trend granularity. Add frontend and upstream-query regressions for the Jul 13 through Jul 20 window. --- .../src/analytics/dashboard-url-state.test.ts | 6 +- .../analytics/date-range-picker.element.ts | 5 +- .../Client/src/analytics/date-range.test.ts | 32 ++++++++--- .../Client/src/analytics/date-range.ts | 55 +++++++++---------- .../Services/VercelAnalyticsClientTests.cs | 22 ++++++++ 5 files changed, 80 insertions(+), 40 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts index e8aa8c9..cde3323 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts @@ -52,14 +52,14 @@ describe("analytics dashboard URL state", () => { }); }); - it("migrates a rolling seven day URL to Vercel's timezone-adjusted daily window", () => { + it("preserves a rolling seven day URL while restoring daily granularity", () => { const state = parseDashboardUrlState(new URLSearchParams( "range=7&from=2026-07-06T13%3A00%3A00.001Z&to=2026-07-13T14%3A00%3A00.000Z&tz=Europe%2FCopenhagen", )); expect(state.range).toEqual({ - from: "2026-07-05T22:00:00.000Z", - to: "2026-07-13T22:00:00.000Z", + from: "2026-07-06T13:00:00.001Z", + to: "2026-07-13T14:00:00.000Z", interval: "Day", timeZone: "Europe/Copenhagen", }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts index 075386b..1359ac8 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts @@ -11,11 +11,11 @@ import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import { analyticsDateOnly, - analyticsRangeEndDate, calendarMonthDays, dateRangeForPreset, formatAnalyticsRangeLabel, normalizeCustomRange, + shiftCalendarDate, shiftCalendarMonth, type AnalyticsCalendarDay, type AnalyticsDateRange, @@ -73,7 +73,8 @@ export class VercelAnalyticsDateRangePickerElement extends UmbElementMixin(LitEl } #draftToForRange(): string { - return analyticsRangeEndDate(this.range); + const to = analyticsDateOnly(this.range.to, this.range.timeZone); + return this.preset === "custom" ? shiftCalendarDate(to, -1) ?? "" : to; } #onToggle(event: Event): void { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts index bbccc0a..8ae002c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts @@ -2,24 +2,42 @@ import { describe, expect, it } from "vitest"; import { calendarMonthDays, dateRangeForPreset, formatAnalyticsDate, formatAnalyticsRangeLabel, formatAnalyticsTooltipDate, inclusiveRangeDays, intervalForRange, isAnalyticsPeriodInProgress, normalizeCustomRange, normalizePresetRange, shiftCalendarMonth } from "./date-range.js"; describe("analytics date ranges", () => { - it("aligns a multi-day preset to every calendar day touched by the rolling range", () => { - expect(dateRangeForPreset(30, new Date("2026-07-15T12:00:00Z"), "UTC")).toEqual({ - from: "2026-06-15T00:00:00.000Z", - to: "2026-07-16T00:00:00.000Z", + it("aligns a multi-day preset to Vercel's rolling hour boundaries", () => { + expect(dateRangeForPreset(30, new Date("2026-07-15T12:34:56Z"), "UTC")).toEqual({ + from: "2026-06-15T12:00:00.000Z", + to: "2026-07-15T13:00:00.000Z", interval: "Day", timeZone: "UTC", }); }); - it("matches Vercel's timezone-adjusted daily window for a seven day request", () => { + it("creates Vercel's inclusive Jul 13 through Jul 20 daily window", () => { + expect(dateRangeForPreset(7, new Date("2026-07-20T15:05:47.886Z"), "Europe/Copenhagen")).toEqual({ + from: "2026-07-13T15:00:00.000Z", + to: "2026-07-20T16:00:00.000Z", + interval: "Day", + timeZone: "Europe/Copenhagen", + }); + }); + + it("aligns presets to the client hour in fractional-offset timezones", () => { + expect(dateRangeForPreset(7, new Date("2026-07-20T11:20:47.886Z"), "Asia/Kathmandu")).toEqual({ + from: "2026-07-13T11:15:00.000Z", + to: "2026-07-20T12:15:00.000Z", + interval: "Day", + timeZone: "Asia/Kathmandu", + }); + }); + + it("preserves Vercel's rolling instants while using daily granularity", () => { expect(normalizePresetRange( 7, "2026-07-06T13:00:00.001Z", "2026-07-13T14:00:00.000Z", "Europe/Copenhagen", )).toEqual({ - from: "2026-07-05T22:00:00.000Z", - to: "2026-07-13T22:00:00.000Z", + from: "2026-07-06T13:00:00.001Z", + to: "2026-07-13T14:00:00.000Z", interval: "Day", timeZone: "Europe/Copenhagen", }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts index 72520db..64f4b55 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts @@ -28,6 +28,12 @@ export function dateRangeForPreset( ): AnalyticsDateRange { const to = new Date(now); const from = new Date(to.valueOf() - preset * DAY_MS); + if (preset > 1) { + const fromHour = startOfZonedHour(from, timeZone); + const toHour = startOfZonedHour(to, timeZone); + from.setTime(fromHour.valueOf()); + to.setTime(toHour.valueOf() + 60 * 60 * 1000); + } return normalizePresetRange(preset, from.toISOString(), to.toISOString(), timeZone)!; } @@ -51,18 +57,7 @@ export function normalizePresetRange( if (preset <= 1) { return { from: fromInstant, to: toInstant, interval: "Hour", timeZone }; } - - const firstDate = analyticsDateOnly(fromInstant, timeZone); - const rawLastDate = analyticsDateOnly(toInstant, timeZone); - const lastDate = isZonedMidnight(toInstant, timeZone) - ? shiftCalendarDate(rawLastDate, -1) - : rawLastDate; - const dayAfterLast = lastDate && shiftCalendarDate(lastDate, 1); - const alignedFrom = firstDate && zonedMidnightToIso(firstDate, timeZone); - const alignedTo = dayAfterLast && zonedMidnightToIso(dayAfterLast, timeZone); - if (!alignedFrom || !alignedTo || Date.parse(alignedFrom) >= Date.parse(alignedTo)) return undefined; - - return { from: alignedFrom, to: alignedTo, interval: intervalForRange(preset), timeZone }; + return { from: fromInstant, to: toInstant, interval: intervalForRange(preset), timeZone }; } export function inclusiveRangeDays(range: Pick): number { @@ -157,8 +152,11 @@ export function formatAnalyticsRangeLabel( if (Number.isNaN(from.valueOf()) || Number.isNaN(to.valueOf())) return "Custom range"; const fromDate = analyticsDateOnly(range.from, range.timeZone); - const exclusiveCalendarEnd = hasExclusiveCalendarEnd(range); - const toDate = analyticsRangeEndDate(range); + const exclusiveCalendarEnd = isZonedMidnight(range.from, range.timeZone) + && isZonedMidnight(range.to, range.timeZone); + const toDate = exclusiveCalendarEnd + ? shiftCalendarDate(analyticsDateOnly(range.to, range.timeZone), -1) + : analyticsDateOnly(range.to, range.timeZone); if (!toDate) return "Custom range"; const toDisplayDate = exclusiveCalendarEnd ? new Date(zonedMidnightToIso(toDate, range.timeZone) ?? range.to) @@ -175,13 +173,6 @@ export function formatAnalyticsRangeLabel( return `${monthDayYear.format(from)} – ${monthDayYear.format(toDisplayDate)}`; } -export function analyticsRangeEndDate( - range: Pick, -): string { - const toDate = analyticsDateOnly(range.to, range.timeZone); - return hasExclusiveCalendarEnd(range) ? shiftCalendarDate(toDate, -1) ?? "" : toDate; -} - export function formatAnalyticsDate( timestamp: string, interval: AnalyticsInterval, @@ -238,6 +229,21 @@ function validIso(value: string): string | undefined { return value && !Number.isNaN(date.valueOf()) ? date.toISOString() : undefined; } +function startOfZonedHour(date: Date, timeZone: string): Date { + const parts = new Intl.DateTimeFormat("en", { + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + timeZone, + }).formatToParts(date); + const value = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((part) => part.type === type)?.value ?? 0); + const elapsedInHour = value("minute") * 60_000 + + value("second") * 1000 + + date.getUTCMilliseconds(); + return new Date(date.valueOf() - elapsedInHour); +} + function isValidTimeZone(timeZone: string): boolean { try { new Intl.DateTimeFormat("en", { timeZone }); @@ -253,13 +259,6 @@ function isZonedMidnight(timestamp: string, timeZone: string): boolean { return midnight !== undefined && Date.parse(midnight) === Date.parse(timestamp); } -function hasExclusiveCalendarEnd( - range: Pick, -): boolean { - return isZonedMidnight(range.from, range.timeZone) - && isZonedMidnight(range.to, range.timeZone); -} - function zonedMidnightToIso(dateOnly: string, timeZone: string): string | undefined { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOnly); if (!match) return undefined; diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs index eda352b..6a0db8a 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs @@ -118,6 +118,28 @@ await client.GetTrendAsync( Assert.Contains("by=hour", query); } + [Fact] + public async Task Trend_sends_the_rolling_daily_window_without_calendar_expansion() + { + var handler = new RecordingHandler("""{"data":[]}"""); + var client = CreateClient(handler); + var connection = CreateConnection(); + + await client.GetTrendAsync( + connection, + new AnalyticsQuery( + connection.Key, + new DateTimeOffset(2026, 7, 13, 15, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 20, 16, 0, 0, TimeSpan.Zero), + AnalyticsInterval.Day), + CancellationToken.None); + + var query = Uri.UnescapeDataString(handler.Request!.RequestUri!.Query); + Assert.Contains("since=2026-07-13T15:00:00.0000000+00:00", query); + Assert.Contains("until=2026-07-20T15:59:59.9990000+00:00", query); + Assert.Contains("by=day", query); + } + [Fact] public async Task Page_view_total_sums_all_partition_rows_including_others_and_unknown() {