fix: build Dates via Date.UTC for hijri-core's UTC-day contract

HijriCalendar.toHijri() previously used new Date(y, m, d) (local-time
constructor). Under hijri-core's new UTC-day contract the engine reads
the UTC calendar day, so on east-of-UTC hosts (e.g. UTC+5) the local
midnight falls on the previous UTC date, producing a one-day-off Hijri
result.

Fix: use Date.UTC(y, m, d) so PlainDate calendar fields land in the
Date's UTC components, matching what hijri-core reads.

Lock-step dependency: this fix requires the unreleased hijri-core change
on fix/utc-day-boundary (commit 3419378). Both packages will be released
together per ADR-013.

Tests: round-trip regression added (2025-03-01 = 1 Ramadan 1446 AH);
all 37 ESM + 9 CJS tests pass at TZ=UTC, TZ=America/New_York,
TZ=Pacific/Auckland.
This commit is contained in:
Aric Camarata 2026-06-10 16:35:38 -04:00
parent 70bd956179
commit 4dd246f27a
4 changed files with 44 additions and 6 deletions

View file

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- Date handed to hijri-core is now built via `Date.UTC()` to match hijri-core's UTC-day
contract; fixes previous-day results on east-of-UTC hosts (e.g. UTC+5, UTC+8).
Lock-step: requires the matching unreleased hijri-core fix (`fix/utc-day-boundary`).
## [1.0.2] - 2026-05-30
### Added

View file

@ -56,6 +56,17 @@ Full reference in the [wiki](https://github.com/acamarata/temporal-hijri/wiki).
- [Architecture](https://github.com/acamarata/temporal-hijri/wiki/Architecture)
- [Examples](https://github.com/acamarata/temporal-hijri/wiki/examples/basic-usage)
## Conversion behavior
Conversions between ISO and Hijri dates are pure calendar-date mappings: the same
ISO date always maps to the same Hijri date on every machine, regardless of the host's
timezone. `Temporal.PlainDate` carries no time-of-day information, and the underlying
hijri-core engine operates on UTC calendar days, so there is no timezone dependency.
Note: the Islamic calendar begins a new day at sunset, not midnight. This library
follows the civil-calendar convention (midnight boundary) used by most software. Sunset
day-start determination is out of scope.
## Related
- [hijri-core](https://github.com/acamarata/hijri-core): the underlying calendar engine

View file

@ -70,14 +70,14 @@ export class HijriCalendar {
/**
* Convert a Temporal.PlainDate (ISO calendar) to Hijri coordinates.
*
* Uses the local-time Date constructor so that the date components passed to
* the engine match the calendar date exactly, regardless of host timezone.
* The UAQ engine reads local components; the FCNA engine reads UTC components.
* Because we construct with new Date(y, m, d) the local date always matches
* the intended calendar date.
* PlainDate calendar fields are placed in the Date's UTC components via
* Date.UTC() because hijri-core reads the UTC calendar day. This ensures
* the conversion returns the correct Hijri date on every host timezone:
* without Date.UTC, on east-of-UTC hosts (e.g. UTC+5) the local midnight
* falls on the previous UTC day, causing a one-day-off result.
*/
protected toHijri(date: Temporal.PlainDate): { hy: number; hm: number; hd: number } {
const jsDate = new Date(date.year, date.month - 1, date.day);
const jsDate = new Date(Date.UTC(date.year, date.month - 1, date.day));
const hijri = this.engine.toHijri(jsDate);
if (!hijri) {
throw new RangeError(`Date ${date.toString()} is out of range for the ${this.id} calendar`);

View file

@ -263,3 +263,25 @@ describe("monthDayFromFields", () => {
assert.ok(result);
});
});
// ── 14. UTC-day round-trip regression ─────────────────────────────────────────
// Verifies that ISO→Hijri→ISO is exact regardless of host timezone.
// 2025-03-01 = 1 Ramadan 1446 AH (UAQ).
describe("UTC-day round-trip regression (ISO → Hijri → ISO)", () => {
const isoRamadan1446 = Temporal.PlainDate.from("2025-03-01");
it("2025-03-01 maps to 1 Ramadan 1446 AH", () => {
assert.equal(uaqCalendar.year(isoRamadan1446), 1446);
assert.equal(uaqCalendar.month(isoRamadan1446), 9);
assert.equal(uaqCalendar.day(isoRamadan1446), 1);
});
it("round-trip: 1446-09-01 → ISO → Hijri returns 1446-09-01", () => {
const iso = uaqCalendar.dateFromFields({ year: 1446, month: 9, day: 1 });
assert.equal(iso.toString(), "2025-03-01");
assert.equal(uaqCalendar.year(iso), 1446);
assert.equal(uaqCalendar.month(iso), 9);
assert.equal(uaqCalendar.day(iso), 1);
});
});