Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -32,11 +32,12 @@ export function parseDashboardUrlState(params: URLSearchParams): DashboardUrlSta
const preset: DatePreset | undefined = rawPreset === "custom"
? "custom"
: PRESETS.has(numericPreset) ? numericPreset as Exclude<DatePreset, "custom"> : 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<AnalyticsDimension>();
for (const raw of params.getAll("filter").slice(0, 10)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
56 changes: 49 additions & 7 deletions src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnalyticsDateRange, "from" | "to">): number {
const from = Date.parse(range.from);
const to = Date.parse(range.to);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down