diff --git a/web/src/App.tsx b/web/src/App.tsx index e739d3f..759d9f8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -32,6 +32,11 @@ export function App({ dataSource }: { dataSource: DataSource }) { .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))); }, [dataSource]); + // Day presets start at the selected site's midnight, as the server's day buckets do. + useEffect(() => { + useDashboard.getState().setTimeZone(sites?.find((x) => x.id === s.siteId)?.timeZone ?? null); + }, [sites, s.siteId]); + // Two-way sync between the URL hash and the current view / open monitor. useEffect(() => { const apply = () => useDashboard.setState(readHash(location.hash)); diff --git a/web/src/components/RangePicker.tsx b/web/src/components/RangePicker.tsx index 2d9cb0f..e097cf4 100644 --- a/web/src/components/RangePicker.tsx +++ b/web/src/components/RangePicker.tsx @@ -8,12 +8,13 @@ import { Icon } from './Icon'; export function RangePicker() { const preset = useDashboard((s) => s.preset); const custom = useDashboard((s) => s.custom); + const timeZone = useDashboard((s) => s.timeZone); const setPreset = useDashboard((s) => s.setPreset); const setCustom = useDashboard((s) => s.setCustom); const shift = useDashboard((s) => s.shift); const open = useDashboard((s) => s.rangeOpen); const setOpen = useDashboard((s) => s.setRangeOpen); - const range = selectRange({ preset, custom }); + const range = selectRange({ preset, custom, timeZone }); const [from, setFrom] = useState(toLocalInput(range.from)); const [to, setTo] = useState(toLocalInput(range.to)); const popRef = useRef(null); diff --git a/web/src/lib/ranges.ts b/web/src/lib/ranges.ts index 83fcf9e..e499042 100644 --- a/web/src/lib/ranges.ts +++ b/web/src/lib/ranges.ts @@ -1,4 +1,4 @@ -import { DAY, HOUR, MINUTE, isLiveRange } from '@pulse/core'; +import { DAY, HOUR, MINUTE, isLiveRange, startOfDay } from '@pulse/core'; import type { Range } from '../data/source'; export type PresetId = '30m' | 'today' | '24h' | '7d' | '30d' | 'custom'; @@ -27,19 +27,28 @@ export function startOfLocalDay(ts: number): number { return d.getTime(); } -/** Live presets end "now"; the query layer keeps the in-progress bucket for them. */ -export function presetRange(id: Exclude, now: number): Range { +/** + * Live presets end "now"; the query layer keeps the in-progress bucket for them. + * Day presets start at midnight in `timeZone` (the site's, which the server + * buckets by), or in the browser's zone while the site is not known yet. + */ +export function presetRange( + id: Exclude, + now: number, + timeZone?: string | null, +): Range { + const dayStart = (ts: number) => (timeZone ? startOfDay(ts, timeZone) : startOfLocalDay(ts)); switch (id) { case '30m': return { from: now - 30 * MINUTE, to: now }; case 'today': - return { from: startOfLocalDay(now), to: now }; + return { from: dayStart(now), to: now }; case '24h': return { from: now - 24 * HOUR, to: now }; case '7d': - return { from: startOfLocalDay(now - 6 * DAY), to: now }; + return { from: dayStart(now - 6 * DAY), to: now }; case '30d': - return { from: startOfLocalDay(now - 29 * DAY), to: now }; + return { from: dayStart(now - 29 * DAY), to: now }; } } diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 73fb984..44343cf 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -27,6 +27,7 @@ export const AnalyticsPage = forwardRef const ds = useDataSource(); const preset = useDashboard((s) => s.preset); const custom = useDashboard((s) => s.custom); + const timeZone = useDashboard((s) => s.timeZone); const refreshKey = useDashboard((s) => s.refreshKey); const metric = useDashboard((s) => s.metric); const setMetric = useDashboard((s) => s.setMetric); @@ -34,8 +35,8 @@ export const AnalyticsPage = forwardRef const pagesRef = useRef(null); // Presets are re-evaluated on each refresh tick, so live windows slide forward. - const range = selectRange({ preset, custom }); - const key = `${siteId}|${preset}|${custom?.from}|${custom?.to}|${refreshKey}`; + const range = selectRange({ preset, custom, timeZone }); + const key = `${siteId}|${preset}|${custom?.from}|${custom?.to}|${timeZone}|${refreshKey}`; const summary = useQuery(key, () => ds.summary(siteId, range)); const series = useQuery(key, () => ds.timeseries(siteId, range)); const realtime = useQuery(`${siteId}|${refreshKey}`, () => ds.realtime(siteId)); diff --git a/web/src/state/store.ts b/web/src/state/store.ts index d34242e..ad36642 100644 --- a/web/src/state/store.ts +++ b/web/src/state/store.ts @@ -8,6 +8,8 @@ export type Metric = 'visitors' | 'pageviews'; export interface DashboardState { view: View; siteId: string | null; + /** Time zone of the selected site; day presets start at its midnight. */ + timeZone: string | null; preset: PresetId; /** Only meaningful when `preset === 'custom'`. */ custom: Range | null; @@ -22,6 +24,7 @@ export interface DashboardState { setView(view: View): void; setSite(siteId: string): void; + setTimeZone(timeZone: string | null): void; setPreset(preset: Exclude): void; setCustom(range: Range): void; shift(direction: -1 | 1, now?: number): void; @@ -36,6 +39,7 @@ export interface DashboardState { export const useDashboard = create()((set, get) => ({ view: 'analytics', siteId: null, + timeZone: null, preset: '24h', custom: null, metric: 'visitors', @@ -47,6 +51,7 @@ export const useDashboard = create()((set, get) => ({ setView: (view) => set({ view }), setSite: (siteId) => set({ siteId }), + setTimeZone: (timeZone) => set({ timeZone }), setPreset: (preset) => set({ preset, custom: null }), setCustom: (range) => set({ preset: 'custom', custom: range }), shift: (direction, now = Date.now()) => { @@ -61,10 +66,13 @@ export const useDashboard = create()((set, get) => ({ openMonitor: (monitorId) => set({ monitorId }), })); -export function selectRange(s: Pick, now = Date.now()): Range { +export function selectRange( + s: Pick & Partial>, + now = Date.now(), +): Range { if (s.preset === 'custom' && s.custom) return s.custom; const preset = s.preset === 'custom' ? '24h' : s.preset; - return presetRange(preset, now); + return presetRange(preset, now, s.timeZone); } export function presetLabel(s: Pick): string { diff --git a/web/test/lib.test.ts b/web/test/lib.test.ts index 5d1e895..3e3221a 100644 --- a/web/test/lib.test.ts +++ b/web/test/lib.test.ts @@ -50,6 +50,17 @@ describe('ranges', () => { expect(isLive(presetRange('7d', now), now)).toBe(true); }); + it('starts day presets at midnight in the site time zone, not the browser one', () => { + expect(presetRange('today', now, 'UTC').from).toBe(Date.parse('2026-06-10T00:00:00Z')); + expect(presetRange('today', now, 'Asia/Kolkata').from).toBe(Date.parse('2026-06-09T18:30:00Z')); + expect(presetRange('7d', now, 'America/New_York').from).toBe( + Date.parse('2026-06-04T04:00:00Z'), + ); + expect(selectRange({ preset: 'today', custom: null, timeZone: 'Asia/Kolkata' }, now).from).toBe( + Date.parse('2026-06-09T18:30:00Z'), + ); + }); + it('shifts windows without leaving the retained history', () => { const r = { from: now - 2 * HOUR, to: now }; expect(shiftRange(r, -1, now)).toEqual({ from: now - 4 * HOUR, to: now - 2 * HOUR });