Merge pull request #1830 from useautumn/fix/analytics-tz-bucket-offset
bucket usage-chart periods in viewer timezone
This commit is contained in:
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
74
server/tests/unit/analytics/period-grid-timezone.test.ts
Normal file
74
server/tests/unit/analytics/period-grid-timezone.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user