fix: convert the displayed calendar date in toHijri for hijri-core's UTC-day contract

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.
This commit is contained in:
Aric Camarata 2026-06-10 16:35:48 -04:00
parent 1e3044c859
commit c6e9a49d19
5 changed files with 71 additions and 5 deletions

View file

@ -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

View file

@ -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,

View file

@ -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()]);
};
}

View file

@ -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);
});
});

View file

@ -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);
});
});