mirror of
https://github.com/acamarata/hijri-core.git
synced 2026-06-30 18:54:27 +00:00
fix: interpret Dates by UTC calendar day in UAQ engine for exact round-trips
Bug: uaqToHijri read local date components (getFullYear/getMonth/getDate) and passed them to Date.UTC(), producing a UTC midnight that does not match the input's intended Gregorian day on hosts west of UTC. Concretely, a Date returned by toGregorian() (UTC midnight) would map to the *previous* Hijri day on UTC-5 or UTC-13 hosts, breaking the toHijri(toGregorian(hy,hm,hd)) round-trip. Fix: switch line 44 to read UTC components (getUTCFullYear/getUTCMonth/ getUTCDate), matching how the FCNA engine already worked. Both engines now share the same UTC-day contract: toHijri reads the UTC calendar day of the input, and toGregorian returns a UTC-midnight Date. Round-trips are exact; results are host-timezone-independent. Behavior change: on non-UTC hosts, toHijri results may shift to the UTC calendar day rather than the local calendar day. Users passing local wall-clock dates should use new Date(Date.UTC(y, m-1, d)). Also: - Fix misleading comment in uaqToHijri (previously claimed local components were "timezone-safe") - Add UTC-day contract to toHijri JSDoc in src/index.ts - Fix wrong constraint comment in hijri-core.test.ts header - Add "day boundaries (UTC contract)" describe block to vitest suite - Convert LOCAL Date constructors in test.mjs and test-cjs.cjs to Date.UTC() form; add UAQ round-trip assertion to test.mjs - Add "Day boundaries and time zones" section to README.md - Add [Unreleased] Fixed entry to CHANGELOG.md
This commit is contained in:
parent
6caa9eed2c
commit
34193780f3
10 changed files with 777 additions and 12 deletions
|
|
@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- UAQ engine `toHijri` was reading local date components (`getFullYear/getMonth/getDate`) instead of UTC components, causing incorrect results on hosts west of UTC (e.g. `America/New_York`, `Pacific/Auckland`) when the input Date was a UTC-midnight value such as those returned by `toGregorian` or ISO date-only strings. `toHijri` now reads UTC calendar day components (`getUTCFullYear/getUTCMonth/getUTCDate`), matching the FCNA engine. **Behavior change:** on non-UTC hosts the converted Hijri day may shift to the UTC calendar day; round-trips via `toGregorian` are now exact on every machine.
|
||||
|
||||
## [1.0.2] - 2026-05-30
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -35,6 +35,27 @@ isValidHijriDate(1444, 9, 1); // true
|
|||
daysInHijriMonth(1444, 9); // 29
|
||||
```
|
||||
|
||||
## Day boundaries and time zones
|
||||
|
||||
hijri-core maps civil calendar days one-to-one (tabular UAQ, computed FCNA). The religious Hijri day beginning at sunset is intentionally out of scope.
|
||||
|
||||
`toHijri` reads the input Date's UTC calendar day (`getUTCFullYear`, `getUTCMonth`, `getUTCDate`). `toGregorian` returns a UTC-midnight Date. This means round-trips are exact and results are identical on every machine regardless of local time zone:
|
||||
|
||||
```typescript
|
||||
// Safe on any host — UTC-explicit construction
|
||||
const greg = toGregorian(1446, 9, 1); // 2025-03-01T00:00:00.000Z
|
||||
const back = toHijri(greg!); // { hy: 1446, hm: 9, hd: 1 } — always exact
|
||||
|
||||
// ISO date-only strings parse as UTC midnight — correct
|
||||
toHijri(new Date('2025-03-01')); // { hy: 1446, hm: 9, hd: 1 }
|
||||
|
||||
// For a local wall-clock date, construct explicitly in UTC
|
||||
toHijri(new Date(Date.UTC(2025, 2, 1))); // { hy: 1446, hm: 9, hd: 1 }
|
||||
|
||||
// Avoid local Date constructor for date-only conversions — breaks on UTC+13
|
||||
// toHijri(new Date(2025, 2, 1)) ← do NOT do this
|
||||
```
|
||||
|
||||
## Custom Calendars
|
||||
|
||||
Implement `CalendarEngine` and call `registerCalendar('my-id', engine)`. Pass `{ calendar: 'my-id' }` to any conversion function.
|
||||
|
|
|
|||
140
hijri-core.test.ts
Normal file
140
hijri-core.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Purpose: Vitest suite for hijri-core — conversion, validation, registry, and name exports.
|
||||
* Inputs: Pure functions from src/index.ts (no network; module-load registers uaq+fcna engines).
|
||||
* Outputs: Vitest pass/fail assertions.
|
||||
* Constraints: UAQ calendar covers 1318–1500 AH (≈1900–2076 CE). toHijri interprets the input
|
||||
* Date by its UTC calendar day (getUTC* components); pass UTC-explicit Dates for
|
||||
* deterministic results. toGregorian returns UTC midnight, so round-trips are exact.
|
||||
* Usage: pnpm vitest run
|
||||
* SOT: packages.md — hijri-core row
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toHijri,
|
||||
toGregorian,
|
||||
isValidHijriDate,
|
||||
daysInHijriMonth,
|
||||
listCalendars,
|
||||
hmLong,
|
||||
hwLong,
|
||||
} from "./src/index";
|
||||
|
||||
describe("toGregorian (UAQ default)", () => {
|
||||
it("converts 1 Ramadan 1446 to 2025-03-01 UTC midnight", () => {
|
||||
const result = toGregorian(1446, 9, 1);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.toISOString()).toBe("2025-03-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("converts 1 Muharram 1446 to 2024-07-07 UTC midnight", () => {
|
||||
const result = toGregorian(1446, 1, 1);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.toISOString()).toBe("2024-07-07T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns null for an out-of-range Hijri year (1501)", () => {
|
||||
expect(toGregorian(1501, 1, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toHijri (UAQ default)", () => {
|
||||
it("converts 2025-03-01 noon UTC to 1 Ramadan 1446", () => {
|
||||
// toGregorian(1446,9,1) = 2025-03-01 midnight; add 12h to stay on that Gregorian day
|
||||
const noonOnRamadanStart = new Date("2025-03-01T12:00:00Z");
|
||||
const result = toHijri(noonOnRamadanStart);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.hy).toBe(1446);
|
||||
expect(result!.hm).toBe(9);
|
||||
expect(result!.hd).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null for a date outside UAQ range (year 2100)", () => {
|
||||
expect(toHijri(new Date("2100-01-01T12:00:00Z"))).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on an invalid Date object", () => {
|
||||
expect(() => toHijri(new Date("invalid"))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidHijriDate", () => {
|
||||
it("accepts a known valid date", () => {
|
||||
expect(isValidHijriDate(1446, 9, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects month 13", () => {
|
||||
expect(isValidHijriDate(1446, 13, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects day 0", () => {
|
||||
expect(isValidHijriDate(1446, 1, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daysInHijriMonth", () => {
|
||||
it("returns 29 or 30 for a valid month", () => {
|
||||
const days = daysInHijriMonth(1446, 9);
|
||||
expect([29, 30]).toContain(days);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry", () => {
|
||||
it("lists at least uaq and fcna after module load", () => {
|
||||
const calendars = listCalendars();
|
||||
expect(calendars).toContain("uaq");
|
||||
expect(calendars).toContain("fcna");
|
||||
});
|
||||
});
|
||||
|
||||
describe("name tables", () => {
|
||||
it("hmLong has 12 entries and index 8 is Ramadan", () => {
|
||||
expect(hmLong).toHaveLength(12);
|
||||
expect(hmLong[8]).toBe("Ramadan");
|
||||
});
|
||||
|
||||
it("hwLong has 7 weekday entries", () => {
|
||||
expect(hwLong).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("day boundaries (UTC contract)", () => {
|
||||
it("UAQ round-trip: toHijri(toGregorian(1446, 9, 1)) returns {hy:1446, hm:9, hd:1}", () => {
|
||||
const greg = toGregorian(1446, 9, 1);
|
||||
expect(greg).not.toBeNull();
|
||||
const hijri = toHijri(greg!);
|
||||
expect(hijri).not.toBeNull();
|
||||
expect(hijri!.hy).toBe(1446);
|
||||
expect(hijri!.hm).toBe(9);
|
||||
expect(hijri!.hd).toBe(1);
|
||||
});
|
||||
|
||||
it("toHijri(new Date('2025-03-01T00:00:00Z')) = 1 Ramadan 1446", () => {
|
||||
const result = toHijri(new Date("2025-03-01T00:00:00Z"));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.hy).toBe(1446);
|
||||
expect(result!.hm).toBe(9);
|
||||
expect(result!.hd).toBe(1);
|
||||
});
|
||||
|
||||
it("last ms of 2025-02-28 UTC maps to the same Hijri day as noon on 2025-02-28 UTC", () => {
|
||||
const lastMs = toHijri(new Date("2025-02-28T23:59:59.999Z"));
|
||||
const noon = toHijri(new Date("2025-02-28T12:00:00Z"));
|
||||
expect(lastMs).not.toBeNull();
|
||||
expect(noon).not.toBeNull();
|
||||
expect(lastMs!.hy).toBe(noon!.hy);
|
||||
expect(lastMs!.hm).toBe(noon!.hm);
|
||||
expect(lastMs!.hd).toBe(noon!.hd);
|
||||
// and it is the day before 1 Ramadan 1446
|
||||
expect(lastMs!.hm).not.toBe(9);
|
||||
});
|
||||
|
||||
it("FCNA round-trip: toHijri(toGregorian(1446, 9, 1, {calendar:'fcna'}), {calendar:'fcna'}) is exact", () => {
|
||||
const greg = toGregorian(1446, 9, 1, { calendar: "fcna" });
|
||||
expect(greg).not.toBeNull();
|
||||
const hijri = toHijri(greg!, { calendar: "fcna" });
|
||||
expect(hijri).not.toBeNull();
|
||||
expect(hijri!.hy).toBe(1446);
|
||||
expect(hijri!.hm).toBe(9);
|
||||
expect(hijri!.hd).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -40,7 +40,8 @@
|
|||
"format:check": "prettier --check src/",
|
||||
"coverage": "c8 --reporter=lcov --reporter=text node test.mjs",
|
||||
"docs": "typedoc --out .github/wiki/api src/index.ts",
|
||||
"postbuild": "cp dist/index.d.ts dist/index.d.mts"
|
||||
"postbuild": "cp dist/index.d.ts dist/index.d.mts",
|
||||
"test:vitest": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"hijri",
|
||||
|
|
@ -70,7 +71,8 @@
|
|||
"typedoc": "^0.28.19",
|
||||
"typedoc-plugin-markdown": "^4.11.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.56.1"
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
|
|
|||
578
pnpm-lock.yaml
578
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -34,14 +34,17 @@ function findYearEntry(hy: number): HijriYearRecord | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
// toHijri uses local date components (getFullYear, getMonth, getDate) so that
|
||||
// the calendar-date lookup is timezone-safe regardless of the host environment.
|
||||
// toHijri interprets the input Date by its UTC calendar day (getUTC* components),
|
||||
// matching the FCNA engine and the UTC-midnight Dates returned by toGregorian.
|
||||
// This makes conversions host-timezone-independent and round-trips exact:
|
||||
// toHijri(toGregorian(hy, hm, hd)) === { hy, hm, hd } on any machine.
|
||||
// To convert a local wall-clock date, pass new Date(Date.UTC(y, m - 1, d)).
|
||||
function uaqToHijri(date: Date): HijriDate | null {
|
||||
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
||||
throw new Error("Invalid Gregorian date");
|
||||
}
|
||||
|
||||
const inputUtc = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const inputUtc = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
|
||||
// Binary search: find the last table entry whose Gregorian start date <= input.
|
||||
let lo = 0;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ import type { HijriDate, ConversionOptions } from "./types";
|
|||
* Uses the UAQ (Umm al-Qura) calendar by default. Pass `{ calendar: 'fcna' }`
|
||||
* or any registered calendar name via options to use a different engine.
|
||||
*
|
||||
* **Time-zone contract:** the Date is interpreted by its UTC calendar day
|
||||
* (`getUTCFullYear`, `getUTCMonth`, `getUTCDate`). `toGregorian` returns a
|
||||
* UTC-midnight Date, so round-trips are exact and results are identical on
|
||||
* every host regardless of its local time zone.
|
||||
*
|
||||
* To convert a local wall-clock date, pass `new Date(Date.UTC(y, m - 1, d))`.
|
||||
* Note that `new Date("2025-03-01")` parses as UTC midnight, which is correct.
|
||||
*
|
||||
* @param date - a valid JavaScript Date object
|
||||
* @param options - conversion options (calendar engine selection)
|
||||
* @returns the corresponding Hijri date, or null if the date is out of range
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ describe('CJS UAQ conversions', () => {
|
|||
assert.equal(d.toISOString().slice(0, 10), '2025-03-01');
|
||||
});
|
||||
it('toHijri: 2023-03-23 = 1444/9/1', () => {
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2023, 2, 23, 12)));
|
||||
assert.ok(h !== null);
|
||||
assert.equal(h.hy, 1444);
|
||||
assert.equal(h.hm, 9);
|
||||
|
|
|
|||
13
test.mjs
13
test.mjs
|
|
@ -97,19 +97,28 @@ describe('UAQ toGregorian', () => {
|
|||
|
||||
describe('UAQ toHijri', () => {
|
||||
it('2023-03-23 = 1444/9/1', () => {
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2023, 2, 23, 12)));
|
||||
assert.ok(h !== null);
|
||||
assert.equal(h.hy, 1444);
|
||||
assert.equal(h.hm, 9);
|
||||
assert.equal(h.hd, 1);
|
||||
});
|
||||
it('2025-03-01 = 1446/9/1', () => {
|
||||
const h = toHijri(new Date(2025, 2, 1, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2025, 2, 1, 12)));
|
||||
assert.ok(h !== null);
|
||||
assert.equal(h.hy, 1446);
|
||||
assert.equal(h.hm, 9);
|
||||
assert.equal(h.hd, 1);
|
||||
});
|
||||
it('UAQ round-trip: toHijri(toGregorian(1446, 9, 1)) = 1446/9/1', () => {
|
||||
const greg = toGregorian(1446, 9, 1);
|
||||
assert.ok(greg instanceof Date);
|
||||
const hijri = toHijri(greg);
|
||||
assert.ok(hijri !== null);
|
||||
assert.equal(hijri.hy, 1446);
|
||||
assert.equal(hijri.hm, 9);
|
||||
assert.equal(hijri.hd, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UAQ isValidHijriDate ───────────────────────────────────────────────────
|
||||
|
|
|
|||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["hijri-core.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue