From d1440d88d9ac3d03817f7a513535c5a0c61005a6 Mon Sep 17 00:00:00 2001 From: John Gozde Date: Thu, 27 Nov 2025 20:44:01 -0700 Subject: [PATCH 1/3] Fix package-lock.json --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 467919a..6c11242 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chronoshift", - "version": "1.2.0", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chronoshift", - "version": "1.2.0", + "version": "1.2.2", "license": "Apache-2.0", "dependencies": { "@internationalized/date": "^3.5.6", From c4f3303ea1d2c542f0322f5431f1be0bb0908d17 Mon Sep 17 00:00:00 2001 From: John Gozde Date: Thu, 27 Nov 2025 20:44:29 -0700 Subject: [PATCH 2/3] Fix minute.round with non-integer TZ offsets --- src/duration/duration.spec.ts | 25 +++++++++++++++++++ src/floor-shift-ceil/floor-shift-ceil.spec.ts | 23 +++++++++++++++++ src/floor-shift-ceil/floor-shift-ceil.ts | 21 +++++++++++++--- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/duration/duration.spec.ts b/src/duration/duration.spec.ts index efef28c..345f453 100644 --- a/src/duration/duration.spec.ts +++ b/src/duration/duration.spec.ts @@ -264,6 +264,31 @@ describe('Duration', () => { ); }); + it('works for PT20M with Asia/Kolkata (UTC+5:30)', () => { + const pt20m = new Duration('PT20M'); + const kolkata = Timezone.fromJS('Asia/Kolkata'); + + // 12:00 UTC = 17:30 IST → floor to 17:20 IST = 11:50 UTC + expect(pt20m.floor(new Date('2025-11-27T12:00:00Z'), kolkata)).toEqual( + new Date('2025-11-27T11:50:00.000Z'), + ); + + // 12:25 UTC = 17:55 IST → floor to 17:40 IST = 12:10 UTC + expect(pt20m.floor(new Date('2025-11-27T12:25:00Z'), kolkata)).toEqual( + new Date('2025-11-27T12:10:00.000Z'), + ); + }); + + it('works for PT5M with Asia/Kolkata (UTC+5:30)', () => { + const pt5m = new Duration('PT5M'); + const kolkata = Timezone.fromJS('Asia/Kolkata'); + + // 12:02 UTC = 17:32 IST → floor to 17:30 IST = 12:00 UTC + expect(pt5m.floor(new Date('2025-11-27T12:02:00Z'), kolkata)).toEqual( + new Date('2025-11-27T12:00:00.000Z'), + ); + }); + it('works for P2H', () => { const pt2h = new Duration('PT2H'); expect(pt2h.floor(new Date('2013-09-29T03:02:03.456-07:00'), TZ_LA)).toEqual( diff --git a/src/floor-shift-ceil/floor-shift-ceil.spec.ts b/src/floor-shift-ceil/floor-shift-ceil.spec.ts index 27fa8c1..a236fc2 100644 --- a/src/floor-shift-ceil/floor-shift-ceil.spec.ts +++ b/src/floor-shift-ceil/floor-shift-ceil.spec.ts @@ -204,4 +204,27 @@ describe('floor/shift/ceil', () => { year.shift(new Date('2010-01-01T00:00:00-08:00'), tz, 1), ); }); + + it('rounds minutes with non-integer timezone offsets', () => { + const kolkata = Timezone.fromJS('Asia/Kolkata'); + const kathmandu = Timezone.fromJS('Asia/Kathmandu'); + + // Asia/Kolkata (UTC+5:30) with 20 minute boundary + expect(shifters.minute.round(new Date('2025-11-27T12:00:00Z'), 20, kolkata)).toEqual( + new Date('2025-11-27T11:50:00.000Z'), + ); + expect(shifters.minute.round(new Date('2025-11-27T12:25:00Z'), 20, kolkata)).toEqual( + new Date('2025-11-27T12:10:00.000Z'), + ); + + // Asia/Kolkata (UTC+5:30) with 5 minute boundary + expect(shifters.minute.round(new Date('2025-11-27T12:02:00Z'), 5, kolkata)).toEqual( + new Date('2025-11-27T12:00:00.000Z'), + ); + + // Asia/Kathmandu (UTC+5:45) with 20 minute boundary + expect(shifters.minute.round(new Date('2025-11-27T12:00:00Z'), 20, kathmandu)).toEqual( + new Date('2025-11-27T11:55:00.000Z'), + ); + }); }); diff --git a/src/floor-shift-ceil/floor-shift-ceil.ts b/src/floor-shift-ceil/floor-shift-ceil.ts index 39facb6..dfc015e 100644 --- a/src/floor-shift-ceil/floor-shift-ceil.ts +++ b/src/floor-shift-ceil/floor-shift-ceil.ts @@ -79,6 +79,13 @@ export const second = timeShifterFiller({ }, }); +// Movement by minute is tz independent because in every timezone a minute is 60 seconds +function minuteMove(dt: Date, _tz: Timezone, step: number) { + dt = new Date(dt.valueOf()); + dt.setUTCMinutes(dt.getUTCMinutes() + step); + return dt; +} + export const minute = timeShifterFiller({ canonicalLength: 60000, siblings: 60, @@ -88,10 +95,16 @@ export const minute = timeShifterFiller({ dt.setUTCSeconds(0, 0); return dt; }, - round: (dt, roundTo, _tz) => { - const cur = dt.getUTCMinutes(); - const adj = floorTo(cur, roundTo); - if (cur !== adj) dt.setUTCMinutes(adj); + round: (dt, roundTo, tz) => { + if (tz.isUTC()) { + const cur = dt.getUTCMinutes(); + const adj = floorTo(cur, roundTo); + if (cur !== adj) dt.setUTCMinutes(adj); + } else { + const cur = fromDate(dt, tz.toString()).minute; + const adj = floorTo(cur, roundTo); + if (cur !== adj) return minuteMove(dt, tz, adj - cur); + } return dt; }, shift: (dt, _tz, step) => { From f23b87084b5ddff845e7a7d0cdefd9a98b6bb564 Mon Sep 17 00:00:00 2001 From: John Gozde Date: Thu, 27 Nov 2025 20:47:05 -0700 Subject: [PATCH 3/3] Changeset --- .changeset/ninety-cycles-hug.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ninety-cycles-hug.md diff --git a/.changeset/ninety-cycles-hug.md b/.changeset/ninety-cycles-hug.md new file mode 100644 index 0000000..d34c6fa --- /dev/null +++ b/.changeset/ninety-cycles-hug.md @@ -0,0 +1,5 @@ +--- +'chronoshift': patch +--- + +Fix minute.round with non-integer TZ offsets