Merge pull request #1830 from useautumn/fix/analytics-tz-bucket-offset

bucket usage-chart periods in viewer timezone
This commit is contained in:
Owen
2026-06-05 17:06:57 +01:00
committed by GitHub
6 changed files with 229 additions and 49 deletions

View File

@@ -26,7 +26,7 @@ env:
# staging repo (autumn-staging) -> us-east-1
# Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging.
# Add short-lived PR branches here when you need staging without merging to dev.
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset
jobs:
checks:

View File

@@ -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<string, Record<string, number>>();
@@ -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,
});
}

View File

@@ -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);
});
}

View File

@@ -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 }}
/>
<span className="flex-1 truncate text-tertiary-foreground">
{label}
</span>
<span className="flex-1 truncate text-tertiary-foreground">{label}</span>
<span className="tabular-nums text-muted-foreground">
{Number(item.value).toLocaleString()}
</span>
@@ -80,7 +73,9 @@ export const EventsBarChart = memo(function EventsBarChart({
const selectedInterval = queryStates.interval;
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
const [activeRow, setActiveRow] = useState<Row | null>(null);
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(
null,
);
const containerRef = useRef<HTMLDivElement>(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 (
<div
@@ -240,7 +239,10 @@ export const EventsBarChart = memo(function EventsBarChart({
style={{
top: mousePos.y - 12,
...((containerRef.current?.offsetWidth ?? 0) - mousePos.x < 200
? { right: (containerRef.current?.offsetWidth ?? 0) - mousePos.x + 12 }
? {
right:
(containerRef.current?.offsetWidth ?? 0) - mousePos.x + 12,
}
: { left: mousePos.x + 12 }),
}}
>
@@ -250,14 +252,19 @@ export const EventsBarChart = memo(function EventsBarChart({
<TooltipItem
key={item.dataKey}
item={item}
label={rechartsConfig[item.dataKey]?.label as string ?? item.dataKey}
label={
(rechartsConfig[item.dataKey]?.label as string) ??
item.dataKey
}
/>
))}
{overflow > 0 && (
<div className="flex items-center gap-2 text-muted-foreground">
<span className="h-2.5 w-2.5 shrink-0" />
<span className="flex-1">+{overflow} more</span>
<span className="tabular-nums">{overflowSum.toLocaleString()}</span>
<span className="tabular-nums">
{overflowSum.toLocaleString()}
</span>
</div>
)}
</div>

View File

@@ -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)

View File

@@ -0,0 +1,40 @@
// Day buckets are emitted by the pipe in the viewer's local zone, so the chart
// label must read the bare string as local, not UTC (else it lands a day behind
// for non-UTC viewers). These assertions are timezone-independent so they pass
// under any CI runner zone.
// Ref: tickets/ANALYTICS_TIMEZONE_BUCKET_OFFSET.md
import { expect, test } from "bun:test";
import {
formatPeriodLabel,
parseLocalTimestamp,
parseUTCTimestamp,
} from "@/views/customers/customer/analytics/utils/parseTimestamp";
test("day bucket label round-trips the local calendar day in any timezone", () => {
// parseLocalTimestamp parses local and formatDateShort renders local, so the
// wall-clock day round-trips regardless of the runner's zone. Before the fix
// (parse-as-UTC) this was a day behind for west-of-UTC viewers.
const label = formatPeriodLabel({
period: "2026-06-04 00:00:00",
interval: "30d",
});
expect(label).toBe("4 Jun");
});
test("parseLocalTimestamp keeps the bare string's wall-clock as local", () => {
const date = parseLocalTimestamp("2026-06-04 13:00:00");
expect(date.getFullYear()).toBe(2026);
expect(date.getMonth()).toBe(5); // June (0-indexed)
expect(date.getDate()).toBe(4);
expect(date.getHours()).toBe(13);
});
test("parseUTCTimestamp still treats bare strings as UTC (hour view / raw events)", () => {
// Hour buckets and the raw-events table are genuine UTC and must not change.
const date = parseUTCTimestamp("2026-06-04 13:00:00");
expect(date.getUTCFullYear()).toBe(2026);
expect(date.getUTCMonth()).toBe(5);
expect(date.getUTCDate()).toBe(4);
expect(date.getUTCHours()).toBe(13);
});