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..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,6 +52,28 @@ describe("analytics dashboard URL state", () => { }); }); + 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-06T13:00:00.001Z", + to: "2026-07-13T14: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.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts index 0e85cee..8ae002c 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,48 @@ 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", () => { - expect(dateRangeForPreset(30, new Date("2026-07-15T12:00:00Z"), "UTC")).toEqual({ + 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-15T12:00:00.000Z", + to: "2026-07-15T13:00:00.000Z", interval: "Day", timeZone: "UTC", }); }); + 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-06T13:00:00.001Z", + to: "2026-07-13T14: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 +53,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 +101,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..64f4b55 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts @@ -28,21 +28,38 @@ 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, - }; + 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)!; } 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 }; + } + return { from: fromInstant, to: toInstant, interval: intervalForRange(preset), timeZone }; +} + export function inclusiveRangeDays(range: Pick): number { const from = Date.parse(range.from); const to = Date.parse(range.to); @@ -54,6 +71,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); @@ -211,6 +229,30 @@ 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 }); + return true; + } catch { + return false; + } +} + function isZonedMidnight(timestamp: string, timeZone: string): boolean { const dateOnly = analyticsDateOnly(timestamp, timeZone); const midnight = dateOnly ? zonedMidnightToIso(dateOnly, timeZone) : 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() {