diff --git a/packages/core/src/time.ts b/packages/core/src/time.ts index 6ffde07..ce449a1 100644 --- a/packages/core/src/time.ts +++ b/packages/core/src/time.ts @@ -66,9 +66,17 @@ export function startOfDay(ts: number, timeZone = 'UTC'): number { export function bucketStart(ts: number, granularity: Granularity, timeZone = 'UTC'): number { switch (granularity) { case 'minute': + // Every zone offset is a whole number of minutes, so UTC and local + // minute boundaries always coincide. return Math.floor(ts / MINUTE) * MINUTE; - case 'hour': - return Math.floor(ts / HOUR) * HOUR; + case 'hour': { + // Align to the local hour, like startOfDay does for the day: half-offset + // zones (e.g. Asia/Kolkata) start their hours at :30 past the UTC hour. + const offset = tzOffsetMinutes(ts, timeZone) * MINUTE; + const localHour = Math.floor((ts + offset) / HOUR) * HOUR; + const offsetAtHour = tzOffsetMinutes(localHour - offset, timeZone) * MINUTE; + return localHour - offsetAtHour; + } case 'day': return startOfDay(ts, timeZone); } diff --git a/packages/core/test/time.test.ts b/packages/core/test/time.test.ts index f6ea8c4..0fbdb37 100644 --- a/packages/core/test/time.test.ts +++ b/packages/core/test/time.test.ts @@ -20,6 +20,27 @@ describe('time buckets', () => { expect(bucketStart(ts, 'hour')).toBe(at('2026-05-10T13:00:00Z')); }); + it('aligns hour buckets to a half-offset zone', () => { + // 13:47 UTC is 19:17 in Kolkata (+5:30); the local hour starts at 19:00 IST = 13:30 UTC. + const ts = at('2026-05-10T13:47:31.500Z'); + expect(bucketStart(ts, 'hour', 'Asia/Kolkata')).toBe(at('2026-05-10T13:30:00Z')); + // Minute buckets are unaffected: every zone offset is a whole number of minutes. + expect(bucketStart(ts, 'minute', 'Asia/Kolkata')).toBe(at('2026-05-10T13:47:00Z')); + expect(bucketsBetween(ts, ts + 2 * HOUR, 'hour', 'Asia/Kolkata')).toEqual([ + at('2026-05-10T13:30:00Z'), + at('2026-05-10T14:30:00Z'), + at('2026-05-10T15:30:00Z'), + ]); + }); + + it('keeps hour buckets an hour apart across a whole-hour DST change', () => { + const tz = 'America/New_York'; + // The fall-back hour (EDT -> EST) still steps by one real hour per local hour. + const b = bucketStart(at('2026-11-01T05:30:00Z'), 'hour', tz); + expect(b).toBe(at('2026-11-01T05:00:00Z')); + expect(nextBucket(b, 'hour', tz) - b).toBe(HOUR); + }); + it('computes local midnight for fixed-offset zones', () => { expect(tzOffsetMinutes(at('2026-05-10T12:00:00Z'), 'Europe/Moscow')).toBe(180); expect(startOfDay(at('2026-05-10T22:30:00Z'), 'Europe/Moscow')).toBe(