From c6e9a49d19fda63023f2c4b61d1b52ac297007da Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 10 Jun 2026 16:35:48 -0400 Subject: [PATCH] fix: convert the displayed calendar date in toHijri for hijri-core's UTC-day contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moment.fn.toHijri now passes new Date(Date.UTC(this.year(), this.month(), this.date())) to hijri-core instead of the raw instant (this.toDate()). This converts the calendar date the moment instance displays, respecting utc() mode, rather than the underlying millisecond value — eliminating wrong-Hijri-day results around UTC-midnight for hosts east or west of UTC. Lock-step with hijri-core fix/utc-day-boundary (commit 3419378). fromHijri path was already correct; its comment updated for clarity. --- CHANGELOG.md | 6 ++++++ README.md | 9 +++++++++ src/index.ts | 19 ++++++++++++++----- test-cjs.cjs | 19 +++++++++++++++++++ test.mjs | 23 +++++++++++++++++++++++ 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 665daae..d1879eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `.toHijri()` now converts the calendar date the moment instance displays (year/month/day + components, respecting `.utc()` mode) rather than passing the raw instant to hijri-core. + This eliminates wrong-Hijri-day results around UTC-midnight for hosts east or west of UTC. + Lock-step with the unreleased hijri-core fix on `fix/utc-day-boundary`. + ## [1.0.2] - 2026-05-30 ### Changed diff --git a/README.md b/README.md index 33ae12e..efaae88 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,15 @@ Pass `{ calendar: 'fcna' }` to switch from the default Umm al-Qura calendar to F Full API reference, format token table, and examples are in the [project wiki](https://github.com/acamarata/moment-hijri-plus/wiki). +## Day boundaries and time zones + +Conversions use the calendar date the moment instance displays, not the underlying UTC +instant. A `moment("2025-03-01")` parsed in any local timezone returns the Hijri date +for March 1st, 2025. A moment created with `.utc()` uses its UTC components. + +Religious day-start at sunset is outside the scope of this package; it depends on +location and madhab, and must be handled at the application layer. + ## Note on Moment.js Moment.js is in maintenance mode. For new projects, diff --git a/src/index.ts b/src/index.ts index 83aa290..a7f5975 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,8 +28,13 @@ declare module "moment" { /** * Convert this moment to a Hijri date. * - * Passes the underlying `Date` to hijri-core's `toHijri()`. The calendar engine - * performs a table lookup (UAQ) or astronomical calculation (FCNA). + * Converts the calendar date this moment instance displays (year/month/day) to a + * Hijri date via hijri-core's `toHijri()`. The conversion is independent of the + * host machine's timezone: a moment in UTC mode uses its UTC components, and a + * moment in local mode uses its local components. The raw instant (milliseconds + * since epoch) is never passed directly to the calendar engine. + * + * The calendar engine performs a table lookup (UAQ) or astronomical calculation (FCNA). * * @param options - Calendar selection. Default: `{ calendar: 'uaq' }`. * @returns A `HijriDate` object `{ hy, hm, hd }`, or `null` if the date is outside @@ -149,7 +154,11 @@ function escapeLiteral(value: string): string { */ function install(momentInstance: typeof moment): void { momentInstance.fn.toHijri = function (opts?: ConversionOptions): HijriDate | null { - return toHijri(this.toDate(), opts); + // Use the calendar date this instance displays rather than the raw instant. + // this.year()/.month()/.date() respect utc mode automatically, so a moment + // created with .utc() uses UTC components and a local moment uses local components. + // moment.month() is 0-based, matching Date.UTC's month parameter exactly. + return toHijri(new Date(Date.UTC(this.year(), this.month(), this.date())), opts); }; momentInstance.fn.hijriYear = function (opts?: ConversionOptions): number | null { @@ -236,8 +245,8 @@ function install(momentInstance: typeof moment): void { if (!greg) { throw new Error(`Invalid or out-of-range Hijri date: ${hy}/${hm}/${hd}`); } - // Construct from explicit year/month/day to avoid UTC-to-local timezone - // shift when the Date object represents midnight UTC. + // Construct from explicit year/month/day components so the moment displays + // the correct calendar date regardless of the host timezone offset. return momentInstance([greg.getUTCFullYear(), greg.getUTCMonth(), greg.getUTCDate()]); }; } diff --git a/test-cjs.cjs b/test-cjs.cjs index 124bbde..9975a8d 100644 --- a/test-cjs.cjs +++ b/test-cjs.cjs @@ -61,3 +61,22 @@ describe('CJS: isValidHijri', () => { assert.equal(moment(new Date(2023, 2, 23, 12)).isValidHijri(), true); }); }); + +describe('CJS: UTC-day boundary (regression)', () => { + it('fromHijri → toHijri round-trip: 1446/9/1', () => { + const m = moment.fromHijri(1446, 9, 1); + const h = m.toHijri(); + assert.notEqual(h, null); + assert.equal(h.hy, 1446); + assert.equal(h.hm, 9); + assert.equal(h.hd, 1); + }); + + it('moment("2025-03-01") toHijri => 1446/9/1 (timezone-invariant)', () => { + const h = moment('2025-03-01').toHijri(); + assert.notEqual(h, null); + assert.equal(h.hy, 1446); + assert.equal(h.hm, 9); + assert.equal(h.hd, 1); + }); +}); diff --git a/test.mjs b/test.mjs index e8c0d33..ef3cf6c 100644 --- a/test.mjs +++ b/test.mjs @@ -110,3 +110,26 @@ describe('FCNA calendar', () => { assert.equal(typeof h.hd, 'number'); }); }); + +describe('UTC-day boundary (regression)', () => { + it('fromHijri → toHijri round-trip: 1446/9/1', () => { + // Construct from Hijri, convert back — must be exact regardless of host TZ. + const m = moment.fromHijri(1446, 9, 1); + const h = m.toHijri(); + assert.notEqual(h, null); + assert.equal(h.hy, 1446); + assert.equal(h.hm, 9); + assert.equal(h.hd, 1); + }); + + it('moment("2025-03-01") toHijri => 1446/9/1 (timezone-invariant)', () => { + // moment parses date-only ISO strings as LOCAL midnight. + // toHijri must convert the displayed calendar date (2025-03-01), not the raw + // instant, so the result is the same regardless of the host timezone offset. + const h = moment('2025-03-01').toHijri(); + assert.notEqual(h, null); + assert.equal(h.hy, 1446); + assert.equal(h.hm, 9); + assert.equal(h.hd, 1); + }); +});