From 2ff6f7c5ba76bf7adfdd7895e1af6cf8159f0985 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Fri, 5 Jun 2026 16:00:01 +0100 Subject: [PATCH] bucket usage-chart periods in viewer timezone --- knip.json | 2 +- .../internal/analytics/actions/aggregate.ts | 78 +++++++++++++------ .../analytics/period-grid-timezone.test.ts | 74 ++++++++++++++++++ .../customer/analytics/AnalyticsGraph.tsx | 55 +++++++------ .../analytics/utils/parseTimestamp.test.ts | 36 +++++++++ .../analytics/utils/parseTimestamp.ts | 29 +++++++ 6 files changed, 225 insertions(+), 49 deletions(-) create mode 100644 server/tests/unit/analytics/period-grid-timezone.test.ts create mode 100644 vite/src/views/customers/customer/analytics/utils/parseTimestamp.test.ts diff --git a/knip.json b/knip.json index 017170f15..0babc2672 100644 --- a/knip.json +++ b/knip.json @@ -42,7 +42,7 @@ "includeEntryExports": false }, "vite": { - "entry": ["tests/**/*.{ts,tsx}"], + "entry": ["tests/**/*.{ts,tsx}", "src/**/*.test.{ts,tsx}"], "project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"], "ignore": ["src/components/ai-elements/**", "src/hooks/useControllableState.ts", "src/types/**/*.d.ts"], "ignoreDependencies": [ diff --git a/server/src/internal/analytics/actions/aggregate.ts b/server/src/internal/analytics/actions/aggregate.ts index 6c93450e7..56752befd 100644 --- a/server/src/internal/analytics/actions/aggregate.ts +++ b/server/src/internal/analytics/actions/aggregate.ts @@ -5,6 +5,7 @@ import { type ClickHouseResult, type TimeseriesEventsParams, } from "@autumn/shared"; +import { TZDate } from "@date-fns/tz"; import { UTCDate } from "@date-fns/utc"; import { addDays, addHours, addMonths, format, sub } from "date-fns"; import { Decimal } from "decimal.js"; @@ -117,22 +118,25 @@ const calculateDateRange = async ({ }; }; -/** Generates all periods between start and end dates based on bin size */ -const generateAllPeriods = ({ +// Grid must match the pipe's buckets for the string join: hour buckets are UTC, +// day/month buckets are in the viewer's timezone. startDate/endDate are UTC. +export const generateAllPeriods = ({ startDate, endDate, binSize, + timezone, }: { startDate: string; endDate: string; binSize: string; + timezone?: string; }): string[] => { const periods: string[] = []; - let current = new UTCDate(startDate); - const end = new UTCDate(endDate); - // Truncate to bin start + // Hour buckets stay on UTC to match the pipe's raw `hour` column. if (binSize === "hour") { + const end = new UTCDate(endDate); + let current = new UTCDate(startDate); current = new UTCDate( current.getFullYear(), current.getMonth(), @@ -142,26 +146,36 @@ const generateAllPeriods = ({ 0, 0, ); - } else if (binSize === "month") { - current = new UTCDate(current.getFullYear(), current.getMonth(), 1); - } else { - // day - current = new UTCDate( - current.getFullYear(), - current.getMonth(), - current.getDate(), - ); + while (current <= end) { + periods.push(format(current, "yyyy-MM-dd HH:mm:ss")); + current = addHours(current, 1); + } + return periods; } - while (current <= end) { + // Day/month: build the grid in the viewer's zone ("UTC" = old behavior). + const tz = timezone ?? "UTC"; + const startInViewerTz = new TZDate(new UTCDate(startDate).getTime(), tz); + const end = new TZDate(new UTCDate(endDate).getTime(), tz); + + let current = + binSize === "month" + ? new TZDate( + startInViewerTz.getFullYear(), + startInViewerTz.getMonth(), + 1, + tz, + ) + : new TZDate( + startInViewerTz.getFullYear(), + startInViewerTz.getMonth(), + startInViewerTz.getDate(), + tz, + ); + + while (current.getTime() <= end.getTime()) { periods.push(format(current, "yyyy-MM-dd HH:mm:ss")); - if (binSize === "hour") { - current = addHours(current, 1); - } else if (binSize === "month") { - current = addMonths(current, 1); - } else { - current = addDays(current, 1); - } + current = binSize === "month" ? addMonths(current, 1) : addDays(current, 1); } return periods; @@ -186,6 +200,7 @@ const formatSimpleResults = ({ startDate, endDate, binSize, + timezone, }: { rows: AggregateSimplePipeRow[]; eventNames: string[]; @@ -193,8 +208,14 @@ const formatSimpleResults = ({ startDate: string; endDate: string; binSize: string; + timezone?: string; }): ClickHouseResult => { - const allPeriods = generateAllPeriods({ startDate, endDate, binSize }); + const allPeriods = generateAllPeriods({ + startDate, + endDate, + binSize, + timezone, + }); // Initialize with all periods and all event columns set to 0 const periodMap = new Map>(); @@ -245,6 +266,7 @@ const formatGroupableResults = ({ startDate, endDate, binSize, + timezone, }: { rows: AggregateGroupablePipeRow[]; eventNames: string[]; @@ -253,9 +275,15 @@ const formatGroupableResults = ({ startDate: string; endDate: string; binSize: string; + timezone?: string; maxGroups?: number; }): ClickHouseResult => { - const allPeriods = generateAllPeriods({ startDate, endDate, binSize }); + const allPeriods = generateAllPeriods({ + startDate, + endDate, + binSize, + timezone, + }); const groupByColumn = groupBy; // Collect all unique group values across all bins (for backfilling zeros). @@ -428,6 +456,7 @@ export const aggregate = async ({ startDate, endDate, binSize, + timezone, maxGroups: params.max_groups, }); } else { @@ -454,6 +483,7 @@ export const aggregate = async ({ startDate, endDate, binSize, + timezone, }); } diff --git a/server/tests/unit/analytics/period-grid-timezone.test.ts b/server/tests/unit/analytics/period-grid-timezone.test.ts new file mode 100644 index 000000000..0a344b19d --- /dev/null +++ b/server/tests/unit/analytics/period-grid-timezone.test.ts @@ -0,0 +1,74 @@ +// generateAllPeriods must build the day/month grid in the viewer's timezone so +// it lines up with the pipe's toStartOfDay(hour, tz) buckets; a UTC grid drops +// the newest local day for non-UTC viewers. +// Ref: tickets/ANALYTICS_TIMEZONE_BUCKET_OFFSET.md + +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { generateAllPeriods } from "@/internal/analytics/actions/aggregate.js"; + +// Window expressed in UTC wall-clock (what calculateDateRange produces and the +// Tinybird pipe filters `hour` on). 2026-06-05 03:00 UTC is still 2026-06-04 +// 23:00 in America/New_York (EDT, UTC-4) -> the viewer's "today" is Jun 4. +const START_UTC = "2026-05-29 03:00:00"; +const END_UTC = "2026-06-05 03:00:00"; + +test(`${chalk.yellowBright( + "analytics period grid: non-UTC viewer's latest day labeled by local calendar day", +)}`, () => { + const periods = generateAllPeriods({ + startDate: START_UTC, + endDate: END_UTC, + binSize: "day", + timezone: "America/New_York", + }); + + // The pipe buckets the live "today" data into the viewer's local day + // (Jun 4 in New York). The grid's newest bucket must match that string, + // not the UTC day (Jun 5). + expect(periods[periods.length - 1]).toBe("2026-06-04 00:00:00"); + // And the earliest bucket should be the viewer's local start day, not the + // UTC start day. + expect(periods[0]).toBe("2026-05-28 00:00:00"); +}); + +test(`${chalk.yellowBright( + "analytics period grid: UTC viewer unchanged (no regression)", +)}`, () => { + const periods = generateAllPeriods({ + startDate: START_UTC, + endDate: END_UTC, + binSize: "day", + timezone: "UTC", + }); + + expect(periods[0]).toBe("2026-05-29 00:00:00"); + expect(periods[periods.length - 1]).toBe("2026-06-05 00:00:00"); +}); + +// Spot-check the acceptance-criteria zones at one instant just past UTC +// midnight (2026-06-05 02:00 UTC). West-of-UTC viewers are still on Jun 4 +// locally; UTC / UTC+1 viewers have rolled to Jun 5. +const BOUNDARY_END_UTC = "2026-06-05 02:00:00"; +const BOUNDARY_START_UTC = "2026-06-01 02:00:00"; + +const zoneCases: { timezone: string; expectedLatest: string }[] = [ + { timezone: "America/Los_Angeles", expectedLatest: "2026-06-04 00:00:00" }, + { timezone: "America/New_York", expectedLatest: "2026-06-04 00:00:00" }, + { timezone: "UTC", expectedLatest: "2026-06-05 00:00:00" }, + { timezone: "Europe/London", expectedLatest: "2026-06-05 00:00:00" }, +]; + +for (const { timezone, expectedLatest } of zoneCases) { + test(`${chalk.yellowBright( + `analytics period grid: latest local day for ${timezone}`, + )}`, () => { + const periods = generateAllPeriods({ + startDate: BOUNDARY_START_UTC, + endDate: BOUNDARY_END_UTC, + binSize: "day", + timezone, + }); + expect(periods[periods.length - 1]).toBe(expectedLatest); + }); +} diff --git a/vite/src/views/customers/customer/analytics/AnalyticsGraph.tsx b/vite/src/views/customers/customer/analytics/AnalyticsGraph.tsx index a86e613e6..27b4ec73c 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsGraph.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsGraph.tsx @@ -8,7 +8,7 @@ import { useState, } from "react"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; -import { ChartContainer, type ChartConfig } from "@/components/ui/chart"; +import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; import { cn } from "@/lib/utils"; import type { Row } from "./components/analytics-types"; import { useAnalyticsQueryState } from "./hooks/useAnalyticsQueryState"; @@ -17,12 +17,7 @@ import { type PlotInsets, Y_AXIS_WIDTH, } from "./utils/chartGeometry"; -import { - formatCompactNumber, - formatDateShort, - formatHourMinute, - parseUTCTimestamp, -} from "./utils/parseTimestamp"; +import { formatCompactNumber, formatPeriodLabel } from "./utils/parseTimestamp"; interface ChartSeriesConfig { xKey: string; @@ -51,9 +46,7 @@ function TooltipItem({ item, label }: { item: any; label: string }) { className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: item.color }} /> - - {label} - + {label} {Number(item.value).toLocaleString()} @@ -80,7 +73,9 @@ export const EventsBarChart = memo(function EventsBarChart({ const selectedInterval = queryStates.interval; const [hoveredKey, setHoveredKey] = useState(null); const [activeRow, setActiveRow] = useState(null); - const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>( + null, + ); const containerRef = useRef(null); useLayoutEffect(() => { @@ -121,7 +116,8 @@ export const EventsBarChart = memo(function EventsBarChart({ ); const handleMouseMove = useCallback((e: React.MouseEvent) => { const rect = containerRef.current?.getBoundingClientRect(); - if (rect) setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + if (rect) + setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); }, []); const handleChartMouseLeave = useCallback(() => { setHoveredKey(null); @@ -131,11 +127,7 @@ export const EventsBarChart = memo(function EventsBarChart({ const formatXAxis = useCallback( (value: string): string => { - const date = parseUTCTimestamp(value); - if (!Number.isFinite(date.getTime())) return value; - return selectedInterval === "24h" - ? formatHourMinute(date) - : formatDateShort(date); + return formatPeriodLabel({ period: value, interval: selectedInterval }); }, [selectedInterval], ); @@ -151,7 +143,11 @@ export const EventsBarChart = memo(function EventsBarChart({ const tooltipData = useMemo(() => { if (!activeRow) return null; const allItems = chartConfig - .map((s) => ({ dataKey: s.yKey, value: Number(activeRow[s.yKey] ?? 0), color: s.fill })) + .map((s) => ({ + dataKey: s.yKey, + value: Number(activeRow[s.yKey] ?? 0), + color: s.fill, + })) .filter((i) => i.value !== 0); const items = hoveredKey ? allItems.filter((i) => i.dataKey === hoveredKey) @@ -167,9 +163,12 @@ export const EventsBarChart = memo(function EventsBarChart({ const visible = tooltipData?.items.slice(0, MAX_TOOLTIP_ITEMS) ?? []; const overflow = (tooltipData?.items.length ?? 0) - visible.length; - const overflowSum = overflow > 0 - ? tooltipData!.items.slice(MAX_TOOLTIP_ITEMS).reduce((s, i) => s + i.value, 0) - : 0; + const overflowSum = + overflow > 0 + ? tooltipData!.items + .slice(MAX_TOOLTIP_ITEMS) + .reduce((s, i) => s + i.value, 0) + : 0; return (
@@ -250,14 +252,19 @@ export const EventsBarChart = memo(function EventsBarChart({ ))} {overflow > 0 && (
+{overflow} more - {overflowSum.toLocaleString()} + + {overflowSum.toLocaleString()} +
)}
diff --git a/vite/src/views/customers/customer/analytics/utils/parseTimestamp.test.ts b/vite/src/views/customers/customer/analytics/utils/parseTimestamp.test.ts new file mode 100644 index 000000000..e56276f70 --- /dev/null +++ b/vite/src/views/customers/customer/analytics/utils/parseTimestamp.test.ts @@ -0,0 +1,36 @@ +// Day buckets are in the viewer's local zone, so a non-UTC viewer's latest day +// must label as the local day, not a day behind. Run with a non-UTC zone: +// cd vite && TZ=America/New_York bun test +// Ref: tickets/ANALYTICS_TIMEZONE_BUCKET_OFFSET.md + +import { expect, test } from "bun:test"; +import { formatPeriodLabel } from "./parseTimestamp"; + +const guardTimezone = () => { + if (process.env.TZ !== "America/New_York") { + throw new Error( + `This test must run with TZ=America/New_York (got ${process.env.TZ ?? "unset"}).`, + ); + } +}; + +test("day bucket labels as the viewer's local calendar day (not a day behind)", () => { + guardTimezone(); + // Pipe-emitted local-midnight bucket for the viewer's Jun 4. + const label = formatPeriodLabel({ + period: "2026-06-04 00:00:00", + interval: "30d", + }); + expect(label).toBe("4 Jun"); +}); + +test("hour bucket (24h view) stays on UTC and renders in local time", () => { + guardTimezone(); + // Hour buckets are emitted by the pipe in UTC. 13:00 UTC -> 09:00 in + // America/New_York (EDT). This must not regress when day buckets go local. + const label = formatPeriodLabel({ + period: "2026-06-04 13:00:00", + interval: "24h", + }); + expect(label).toBe("09:00"); +}); diff --git a/vite/src/views/customers/customer/analytics/utils/parseTimestamp.ts b/vite/src/views/customers/customer/analytics/utils/parseTimestamp.ts index 3c2a5a0cc..affcae208 100644 --- a/vite/src/views/customers/customer/analytics/utils/parseTimestamp.ts +++ b/vite/src/views/customers/customer/analytics/utils/parseTimestamp.ts @@ -11,6 +11,19 @@ export function parseUTCTimestamp(timestamp: string): Date { return parseISO(timestamp); } +// Day/month buckets are already in the viewer's local zone (the pipe uses +// toStartOfDay(hour, tz)), so parse a bare string as local, not UTC. +export function parseLocalTimestamp(timestamp: string): Date { + if ( + !timestamp.includes("Z") && + !timestamp.includes("+") && + !timestamp.includes("-", 10) + ) { + return new Date(timestamp.replace(" ", "T")); + } + return parseISO(timestamp); +} + export function formatDateShort(date: Date): string { return format(date, "d MMM"); } @@ -23,6 +36,22 @@ export function formatFullTimestamp(date: Date): string { return format(date, "d MMM 'at' HH:mm:ss"); } +// Hour buckets are UTC, day/month buckets are local — parse each in its own zone. +export function formatPeriodLabel({ + period, + interval, +}: { + period: string; + interval: string | null; +}): string { + const date = + interval === "24h" + ? parseUTCTimestamp(period) + : parseLocalTimestamp(period); + if (!Number.isFinite(date.getTime())) return period; + return interval === "24h" ? formatHourMinute(date) : formatDateShort(date); +} + export function formatCompactNumber(value: number): string { const absValue = Math.abs(value); if (absValue >= 1_000_000_000)