diff --git a/CHANGELOG.md b/CHANGELOG.md index 0892d0f..a3ada92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9ea2509..ebf9048 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/calendars/HijriCalendar.ts b/src/calendars/HijriCalendar.ts index 0214b87..c32c87e 100644 --- a/src/calendars/HijriCalendar.ts +++ b/src/calendars/HijriCalendar.ts @@ -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`); diff --git a/test.mjs b/test.mjs index 995d31a..eccbccb 100644 --- a/test.mjs +++ b/test.mjs @@ -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); + }); +});