mirror of
https://github.com/acamarata/date-fns-hijri.git
synced 2026-06-30 18:54:25 +00:00
fix: local-day adapter semantics with exact round-trips on hijri-core's UTC-day contract
Behavior changes (lock-step with hijri-core fix/utc-day-boundary): - toHijriDate and all field/format/comparison/arithmetic functions now lift input Dates through localDayToUtcSlot() before calling coreToHijri(), reading the caller's LOCAL calendar day (date-fns convention). Previously passed the raw Date which caused off-by-one results in timezones west of UTC against the new UTC-day core contract. - fromHijriDate now returns local-midnight Dates (new Date(y, m, d)) instead of UTC midnight. Local field accessors and date-fns format() render the intended calendar day on every host timezone. toISOString() is no longer the right API for this value. - addHijriMonths, addHijriYears, startOfHijriMonth, endOfHijriMonth call fromHijriDate directly; the utcMidnightToLocalNoon shim is removed. - Round-trip toHijriDate(fromHijriDate(y, m, d)) is now exact on every timezone. Verified: 58/58 ESM tests, 10/10 CJS tests, 16/16 vitest assertions across TZ=UTC, TZ=America/New_York, and TZ=Pacific/Auckland.
This commit is contained in:
parent
f260912927
commit
d12117f000
10 changed files with 883 additions and 61 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.tgz
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- `toHijriDate` and all field getters now produce exact round-trips on every host timezone (input Date interpreted by its local calendar day, matching date-fns conventions; previously used raw Date which failed in timezones west of UTC against hijri-core's UTC-day contract).
|
||||
|
||||
### Changed
|
||||
- `fromHijriDate` and all arithmetic/boundary helpers (`addHijriMonths`, `addHijriYears`, `startOfHijriMonth`, `endOfHijriMonth`) now return **local-midnight** Dates instead of UTC midnight / local noon. Use `getFullYear()`/`getMonth()`/`getDate()` (or date-fns `format()`) on the result — not `toISOString()`.
|
||||
- Lock-step with unreleased hijri-core `fix/utc-day-boundary` (UTC-day contract).
|
||||
|
||||
## [1.0.2] - 2026-05-30
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -51,6 +51,31 @@ Full API reference, guides, and examples: **[Wiki](https://github.com/acamarata/
|
|||
- [Architecture](https://github.com/acamarata/date-fns-hijri/wiki/Architecture): design decisions and hijri-core integration
|
||||
- [Quick Start](https://github.com/acamarata/date-fns-hijri/wiki/guides/quickstart)
|
||||
|
||||
## Day boundaries and time zones
|
||||
|
||||
This package follows date-fns local-time conventions:
|
||||
|
||||
- **Inputs** (`toHijriDate`, `getHijri*`, `formatHijriDate`, arithmetic, comparisons) — the input `Date` is read by its **local calendar day** (using `getFullYear`/`getMonth`/`getDate`). This matches how date-fns' own `format()` and field accessors work.
|
||||
- **Outputs** (`fromHijriDate` and all arithmetic/boundary functions) — returned `Date` values are **local midnight** of the equivalent Gregorian day. Local field accessors and date-fns' `format()` will render the intended date on every timezone.
|
||||
|
||||
Round-trips are exact on every host timezone:
|
||||
|
||||
```typescript
|
||||
toHijriDate(fromHijriDate(1446, 9, 1)); // always { hy: 1446, hm: 9, hd: 1 }
|
||||
```
|
||||
|
||||
**Pitfall:** `new Date("2025-03-01")` parses as UTC midnight. In timezones west of UTC this resolves to the previous local day (Feb 28), giving an off-by-one result. Use the local-date constructor instead:
|
||||
|
||||
```typescript
|
||||
// Wrong in timezones west of UTC:
|
||||
toHijriDate(new Date("2025-03-01")); // may return 29 Shaban in some zones
|
||||
|
||||
// Correct everywhere:
|
||||
toHijriDate(new Date(2025, 2, 1)); // always 1 Ramadan 1446
|
||||
```
|
||||
|
||||
Religious day-start (sunset boundary) is out of scope — this package only handles civil calendar day alignment.
|
||||
|
||||
## Related
|
||||
|
||||
- [hijri-core](https://github.com/acamarata/hijri-core): the calendar engine powering this library
|
||||
|
|
|
|||
126
date-fns-hijri.test.ts
Normal file
126
date-fns-hijri.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Purpose: Vitest suite for date-fns-hijri — functional Hijri date utilities.
|
||||
* Inputs: Pure functions from src/index.ts wrapping hijri-core. No network, no I/O.
|
||||
* Outputs: Vitest pass/fail assertions.
|
||||
* Constraints: UAQ range 1318–1500 AH; fromHijriDate throws on invalid input (null path).
|
||||
* Use local-date constructor new Date(y, m, d) — not string "YYYY-MM-DD" which
|
||||
* parses as UTC midnight and can be the previous LOCAL day west of UTC.
|
||||
* Usage: pnpm vitest run
|
||||
* SOT: packages.md — date-fns-hijri row
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toHijriDate,
|
||||
fromHijriDate,
|
||||
isValidHijriDate,
|
||||
getHijriYear,
|
||||
getHijriMonth,
|
||||
getHijriDay,
|
||||
getDaysInHijriMonth,
|
||||
getHijriMonthName,
|
||||
getHijriWeekdayName,
|
||||
} from "./src/index";
|
||||
|
||||
// Anchor: 1 Ramadan 1446 = 2025-03-01 in the Gregorian calendar.
|
||||
// Use local-date constructor to avoid the UTC-parsing pitfall with string form.
|
||||
// At local noon the local calendar day is unambiguous on every timezone.
|
||||
const RAMADAN_1446_NOON = new Date(2025, 2, 1, 12); // local noon 2025-03-01
|
||||
|
||||
describe("toHijriDate", () => {
|
||||
it("converts noon 2025-03-01 UTC to 1 Ramadan 1446", () => {
|
||||
const result = toHijriDate(RAMADAN_1446_NOON);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.hy).toBe(1446);
|
||||
expect(result!.hm).toBe(9);
|
||||
expect(result!.hd).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null for dates outside UAQ range (2100)", () => {
|
||||
expect(toHijriDate(new Date("2100-01-01"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromHijriDate", () => {
|
||||
it("converts 1 Ramadan 1446 to local 2025-03-01 (via local accessors)", () => {
|
||||
const result = fromHijriDate(1446, 9, 1);
|
||||
// Returns local midnight: local accessors show the intended calendar day
|
||||
// on every host timezone. Do NOT use toISOString() — it shows UTC which
|
||||
// will be the previous day in timezones west of UTC.
|
||||
expect(result.getFullYear()).toBe(2025);
|
||||
expect(result.getMonth()).toBe(2); // March
|
||||
expect(result.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it("round-trip: toHijriDate(fromHijriDate(1446, 9, 1)) === {1446, 9, 1}", () => {
|
||||
const d = fromHijriDate(1446, 9, 1);
|
||||
const h = toHijriDate(d);
|
||||
expect(h).not.toBeNull();
|
||||
expect(h!.hy).toBe(1446);
|
||||
expect(h!.hm).toBe(9);
|
||||
expect(h!.hd).toBe(1);
|
||||
});
|
||||
|
||||
it("throws on an out-of-range Hijri year (1501)", () => {
|
||||
expect(() => fromHijriDate(1501, 1, 1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidHijriDate", () => {
|
||||
it("returns true for 1 Ramadan 1446", () => {
|
||||
expect(isValidHijriDate(1446, 9, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for month 13", () => {
|
||||
expect(isValidHijriDate(1446, 13, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("field getters", () => {
|
||||
it("getHijriYear returns 1446 for noon 2025-03-01", () => {
|
||||
expect(getHijriYear(RAMADAN_1446_NOON)).toBe(1446);
|
||||
});
|
||||
|
||||
it("getHijriMonth returns 9 for Ramadan", () => {
|
||||
expect(getHijriMonth(RAMADAN_1446_NOON)).toBe(9);
|
||||
});
|
||||
|
||||
it("getHijriDay returns 1", () => {
|
||||
expect(getHijriDay(RAMADAN_1446_NOON)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDaysInHijriMonth", () => {
|
||||
it("returns 29 or 30 for Ramadan 1446", () => {
|
||||
const days = getDaysInHijriMonth(1446, 9);
|
||||
expect([29, 30]).toContain(days);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHijriMonthName", () => {
|
||||
it("returns Ramadan for month 9 (long)", () => {
|
||||
expect(getHijriMonthName(9, "long")).toBe("Ramadan");
|
||||
});
|
||||
|
||||
it("throws RangeError for month 0", () => {
|
||||
expect(() => getHijriMonthName(0)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("returns a non-empty medium name for month 1", () => {
|
||||
const name = getHijriMonthName(1, "medium");
|
||||
expect(name.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHijriWeekdayName", () => {
|
||||
it("returns a non-empty long weekday name for 2025-03-01 (Saturday)", () => {
|
||||
const name = getHijriWeekdayName(RAMADAN_1446_NOON, "long");
|
||||
expect(typeof name).toBe("string");
|
||||
expect(name.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("short name is no longer than long name for the same date", () => {
|
||||
const long = getHijriWeekdayName(RAMADAN_1446_NOON, "long");
|
||||
const short = getHijriWeekdayName(RAMADAN_1446_NOON, "short");
|
||||
expect(short.length).toBeLessThanOrEqual(long.length);
|
||||
});
|
||||
});
|
||||
|
|
@ -40,7 +40,8 @@
|
|||
"prepublishOnly": "pnpm run build",
|
||||
"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": [
|
||||
"date-fns",
|
||||
|
|
@ -74,7 +75,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
140
src/index.ts
140
src/index.ts
|
|
@ -15,6 +15,27 @@ export type { HijriDate, CalendarEngine, ConversionOptions } from "./types";
|
|||
|
||||
import type { HijriDate, ConversionOptions } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Purpose: Lift a Date's LOCAL calendar components (year, month, day) into the
|
||||
* UTC slot so that hijri-core's UTC-day contract reads the caller's
|
||||
* intended calendar day regardless of host timezone.
|
||||
* Inputs: Any Gregorian Date.
|
||||
* Outputs: A new Date whose UTC year/month/date equal the input's LOCAL year/month/date.
|
||||
* Constraints: Used only as input to coreToHijri; the returned value is an ephemeral
|
||||
* intermediate — never hand it to Date#getFullYear or date-fns functions.
|
||||
* WHY: date-fns is a LOCAL-time library: its functions read local components.
|
||||
* hijri-core (after fix/utc-day-boundary) reads the UTC calendar day.
|
||||
* Without this shim, hosts west of UTC see the previous UTC day for
|
||||
* a local-midnight Date, causing off-by-one conversions.
|
||||
*/
|
||||
function localDayToUtcSlot(date: Date): Date {
|
||||
return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -22,19 +43,39 @@ import type { HijriDate, ConversionOptions } from "./types";
|
|||
/**
|
||||
* Convert a Gregorian `Date` to a Hijri date object.
|
||||
*
|
||||
* Follows date-fns conventions: the input `Date` is interpreted by its
|
||||
* **local calendar day** (year/month/date in the host timezone). This matches
|
||||
* how date-fns' own `format()` and field accessors work, so there are no
|
||||
* timezone surprises when chaining with other date-fns functions.
|
||||
*
|
||||
* Returns `null` when the date falls outside the calendar's supported range
|
||||
* (UAQ: 1318–1500 AH / 1900–2076 CE; FCNA extends slightly further).
|
||||
*
|
||||
* @example
|
||||
* // Use local-date constructor, not the string form "2025-03-01" (parses as UTC)
|
||||
* toHijriDate(new Date(2025, 2, 1)); // { hy: 1446, hm: 9, hd: 1 }
|
||||
*/
|
||||
export function toHijriDate(date: Date, options?: ConversionOptions): HijriDate | null {
|
||||
return coreToHijri(date, options);
|
||||
return coreToHijri(localDayToUtcSlot(date), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Hijri date to a Gregorian `Date`.
|
||||
*
|
||||
* The returned `Date` is set to midnight UTC of the equivalent Gregorian day.
|
||||
* Returns a **local-midnight** Date so that local field accessors
|
||||
* (`getFullYear`, `getMonth`, `getDate`) and date-fns' `format()` render the
|
||||
* intended calendar day on every host timezone.
|
||||
*
|
||||
* Round-trips exactly: `toHijriDate(fromHijriDate(y, m, d))` returns
|
||||
* `{ hy: y, hm: m, hd: d }` on every timezone.
|
||||
*
|
||||
* @throws {Error} If the Hijri date is invalid or outside the calendar's range.
|
||||
*
|
||||
* @example
|
||||
* const d = fromHijriDate(1446, 9, 1);
|
||||
* d.getFullYear(); // 2025
|
||||
* d.getMonth(); // 2 (March)
|
||||
* d.getDate(); // 1
|
||||
*/
|
||||
export function fromHijriDate(
|
||||
hy: number,
|
||||
|
|
@ -42,11 +83,13 @@ export function fromHijriDate(
|
|||
hd: number,
|
||||
options?: ConversionOptions,
|
||||
): Date {
|
||||
const result = coreToGregorian(hy, hm, hd, options);
|
||||
if (result === null) {
|
||||
const greg = coreToGregorian(hy, hm, hd, options);
|
||||
if (greg === null) {
|
||||
throw new Error(`Hijri date ${hy}/${hm}/${hd} is invalid or outside the supported range.`);
|
||||
}
|
||||
return result;
|
||||
// coreToGregorian returns UTC midnight; lift to local midnight so that
|
||||
// local field accessors and date-fns format() show the right calendar day.
|
||||
return new Date(greg.getUTCFullYear(), greg.getUTCMonth(), greg.getUTCDate());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -75,28 +118,34 @@ export function isValidHijriDate(
|
|||
/**
|
||||
* Get the Hijri year for a Gregorian date.
|
||||
*
|
||||
* The input Date is interpreted by its **local calendar day** (date-fns convention).
|
||||
*
|
||||
* Returns `null` when the date is outside the supported range.
|
||||
*/
|
||||
export function getHijriYear(date: Date, options?: ConversionOptions): number | null {
|
||||
return coreToHijri(date, options)?.hy ?? null;
|
||||
return coreToHijri(localDayToUtcSlot(date), options)?.hy ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Hijri month (1–12) for a Gregorian date.
|
||||
*
|
||||
* The input Date is interpreted by its **local calendar day** (date-fns convention).
|
||||
*
|
||||
* Returns `null` when the date is outside the supported range.
|
||||
*/
|
||||
export function getHijriMonth(date: Date, options?: ConversionOptions): number | null {
|
||||
return coreToHijri(date, options)?.hm ?? null;
|
||||
return coreToHijri(localDayToUtcSlot(date), options)?.hm ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Hijri day of month (1–30) for a Gregorian date.
|
||||
*
|
||||
* The input Date is interpreted by its **local calendar day** (date-fns convention).
|
||||
*
|
||||
* Returns `null` when the date is outside the supported range.
|
||||
*/
|
||||
export function getHijriDay(date: Date, options?: ConversionOptions): number | null {
|
||||
return coreToHijri(date, options)?.hd ?? null;
|
||||
return coreToHijri(localDayToUtcSlot(date), options)?.hd ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -141,6 +190,8 @@ export function getHijriMonthName(
|
|||
* Get the Arabic weekday name for a Gregorian date.
|
||||
*
|
||||
* Uses `Date.getDay()` (0 = Sunday, 6 = Saturday) as the index.
|
||||
* `getDay()` reads the local weekday, which is correct — weekday display
|
||||
* follows the host's local calendar day just like date-fns.
|
||||
*
|
||||
* @param date - Any Gregorian `Date`.
|
||||
* @param length - `'long'` (default) or `'short'`.
|
||||
|
|
@ -162,6 +213,9 @@ const TOKEN_RE = /iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo/g;
|
|||
/**
|
||||
* Format a Gregorian date using Hijri calendar tokens.
|
||||
*
|
||||
* The input Date is interpreted by its **local calendar day** (date-fns convention),
|
||||
* matching the behavior of date-fns' own `format()`.
|
||||
*
|
||||
* Supported tokens:
|
||||
*
|
||||
* | Token | Output | Example |
|
||||
|
|
@ -187,10 +241,10 @@ export function formatHijriDate(
|
|||
formatStr: string,
|
||||
options?: ConversionOptions,
|
||||
): string {
|
||||
const h = coreToHijri(date, options);
|
||||
const h = coreToHijri(localDayToUtcSlot(date), options);
|
||||
if (!h) return "";
|
||||
|
||||
const day = date.getDay(); // 0–6
|
||||
const day = date.getDay(); // 0–6 local weekday — correct for display
|
||||
|
||||
return formatStr.replace(TOKEN_RE, (token): string => {
|
||||
switch (token) {
|
||||
|
|
@ -233,24 +287,6 @@ export function formatHijriDate(
|
|||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `coreToGregorian` returns a UTC-midnight Date. When `coreToHijri` is then
|
||||
* called on that Date, it normalises using local year/month/day components
|
||||
* (`getFullYear`, `getMonth`, `getDate`). In timezones west of UTC the local
|
||||
* date of a UTC-midnight instant is the *previous* calendar day, which causes
|
||||
* the round-trip to drift by one day.
|
||||
*
|
||||
* This helper converts a UTC-midnight Date to a local-noon Date so that local
|
||||
* calendar components always match the intended Gregorian date.
|
||||
*/
|
||||
function utcMidnightToLocalNoon(d: Date): Date {
|
||||
return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arithmetic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -258,6 +294,9 @@ function utcMidnightToLocalNoon(d: Date): Date {
|
|||
/**
|
||||
* Add a number of Hijri months to a Gregorian date.
|
||||
*
|
||||
* The input Date is interpreted by its **local calendar day** (date-fns convention).
|
||||
* Returns a **local-midnight** Date.
|
||||
*
|
||||
* Handles year rollover automatically. Month addition wraps at month 12 and
|
||||
* increments the year. If the result's month has fewer days than the original
|
||||
* day, the day is clamped to the last day of the new month.
|
||||
|
|
@ -265,7 +304,7 @@ function utcMidnightToLocalNoon(d: Date): Date {
|
|||
* @throws {Error} If the resulting Hijri date is outside the supported range.
|
||||
*/
|
||||
export function addHijriMonths(date: Date, months: number, options?: ConversionOptions): Date {
|
||||
const h = coreToHijri(date, options);
|
||||
const h = coreToHijri(localDayToUtcSlot(date), options);
|
||||
if (!h) {
|
||||
throw new Error("Date is outside the supported Hijri calendar range.");
|
||||
}
|
||||
|
|
@ -279,19 +318,22 @@ export function addHijriMonths(date: Date, months: number, options?: ConversionO
|
|||
const maxDay = coreDaysInHijriMonth(newYear, newMonth, options);
|
||||
const newDay = Math.min(h.hd, maxDay);
|
||||
|
||||
return utcMidnightToLocalNoon(fromHijriDate(newYear, newMonth, newDay, options));
|
||||
return fromHijriDate(newYear, newMonth, newDay, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a number of Hijri years to a Gregorian date.
|
||||
*
|
||||
* If the resulting year has a shorter Ramadan (or any month) than the original
|
||||
* day, the day is clamped to the last day of that month.
|
||||
* The input Date is interpreted by its **local calendar day** (date-fns convention).
|
||||
* Returns a **local-midnight** Date.
|
||||
*
|
||||
* If the resulting year has a shorter month than the original day, the day is
|
||||
* clamped to the last day of that month.
|
||||
*
|
||||
* @throws {Error} If the resulting Hijri date is outside the supported range.
|
||||
*/
|
||||
export function addHijriYears(date: Date, years: number, options?: ConversionOptions): Date {
|
||||
const h = coreToHijri(date, options);
|
||||
const h = coreToHijri(localDayToUtcSlot(date), options);
|
||||
if (!h) {
|
||||
throw new Error("Date is outside the supported Hijri calendar range.");
|
||||
}
|
||||
|
|
@ -300,7 +342,7 @@ export function addHijriYears(date: Date, years: number, options?: ConversionOpt
|
|||
const maxDay = coreDaysInHijriMonth(newYear, h.hm, options);
|
||||
const newDay = Math.min(h.hd, maxDay);
|
||||
|
||||
return utcMidnightToLocalNoon(fromHijriDate(newYear, h.hm, newDay, options));
|
||||
return fromHijriDate(newYear, h.hm, newDay, options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -310,28 +352,34 @@ export function addHijriYears(date: Date, years: number, options?: ConversionOpt
|
|||
/**
|
||||
* Get the first day of the Hijri month that contains the given date.
|
||||
*
|
||||
* The input Date is interpreted by its **local calendar day** (date-fns convention).
|
||||
* Returns a **local-midnight** Date.
|
||||
*
|
||||
* @throws {Error} If the date is outside the supported range.
|
||||
*/
|
||||
export function startOfHijriMonth(date: Date, options?: ConversionOptions): Date {
|
||||
const h = coreToHijri(date, options);
|
||||
const h = coreToHijri(localDayToUtcSlot(date), options);
|
||||
if (!h) {
|
||||
throw new Error("Date is outside the supported Hijri calendar range.");
|
||||
}
|
||||
return utcMidnightToLocalNoon(fromHijriDate(h.hy, h.hm, 1, options));
|
||||
return fromHijriDate(h.hy, h.hm, 1, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last day of the Hijri month that contains the given date.
|
||||
*
|
||||
* The input Date is interpreted by its **local calendar day** (date-fns convention).
|
||||
* Returns a **local-midnight** Date.
|
||||
*
|
||||
* @throws {Error} If the date is outside the supported range.
|
||||
*/
|
||||
export function endOfHijriMonth(date: Date, options?: ConversionOptions): Date {
|
||||
const h = coreToHijri(date, options);
|
||||
const h = coreToHijri(localDayToUtcSlot(date), options);
|
||||
if (!h) {
|
||||
throw new Error("Date is outside the supported Hijri calendar range.");
|
||||
}
|
||||
const lastDay = coreDaysInHijriMonth(h.hy, h.hm, options);
|
||||
return utcMidnightToLocalNoon(fromHijriDate(h.hy, h.hm, lastDay, options));
|
||||
return fromHijriDate(h.hy, h.hm, lastDay, options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -341,11 +389,13 @@ export function endOfHijriMonth(date: Date, options?: ConversionOptions): Date {
|
|||
/**
|
||||
* Check whether two Gregorian dates fall in the same Hijri month.
|
||||
*
|
||||
* Both input Dates are interpreted by their **local calendar days** (date-fns convention).
|
||||
*
|
||||
* Returns `false` if either date is outside the supported range.
|
||||
*/
|
||||
export function isSameHijriMonth(dateA: Date, dateB: Date, options?: ConversionOptions): boolean {
|
||||
const a = coreToHijri(dateA, options);
|
||||
const b = coreToHijri(dateB, options);
|
||||
const a = coreToHijri(localDayToUtcSlot(dateA), options);
|
||||
const b = coreToHijri(localDayToUtcSlot(dateB), options);
|
||||
if (!a || !b) return false;
|
||||
return a.hy === b.hy && a.hm === b.hm;
|
||||
}
|
||||
|
|
@ -353,11 +403,13 @@ export function isSameHijriMonth(dateA: Date, dateB: Date, options?: ConversionO
|
|||
/**
|
||||
* Check whether two Gregorian dates fall in the same Hijri year.
|
||||
*
|
||||
* Both input Dates are interpreted by their **local calendar days** (date-fns convention).
|
||||
*
|
||||
* Returns `false` if either date is outside the supported range.
|
||||
*/
|
||||
export function isSameHijriYear(dateA: Date, dateB: Date, options?: ConversionOptions): boolean {
|
||||
const a = coreToHijri(dateA, options);
|
||||
const b = coreToHijri(dateB, options);
|
||||
const a = coreToHijri(localDayToUtcSlot(dateA), options);
|
||||
const b = coreToHijri(localDayToUtcSlot(dateB), options);
|
||||
if (!a || !b) return false;
|
||||
return a.hy === b.hy;
|
||||
}
|
||||
|
|
@ -369,12 +421,14 @@ export function isSameHijriYear(dateA: Date, dateB: Date, options?: ConversionOp
|
|||
/**
|
||||
* Get the Hijri quarter (1–4) for a Gregorian date.
|
||||
*
|
||||
* The input Date is interpreted by its **local calendar day** (date-fns convention).
|
||||
*
|
||||
* Months 1–3 = Q1, 4–6 = Q2, 7–9 = Q3, 10–12 = Q4.
|
||||
*
|
||||
* Returns `null` when the date is outside the supported range.
|
||||
*/
|
||||
export function getHijriQuarter(date: Date, options?: ConversionOptions): number | null {
|
||||
const h = coreToHijri(date, options);
|
||||
const h = coreToHijri(localDayToUtcSlot(date), options);
|
||||
if (!h) return null;
|
||||
return Math.ceil(h.hm / 3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,12 @@ describe('CJS: toHijriDate', () => {
|
|||
});
|
||||
|
||||
describe('CJS: fromHijriDate', () => {
|
||||
it('converts to correct Gregorian date', () => {
|
||||
it('converts to correct Gregorian date (local midnight)', () => {
|
||||
const d = fromHijriDate(1444, 9, 1);
|
||||
assert.equal(d.getUTCFullYear(), 2023);
|
||||
assert.equal(d.getUTCMonth(), 2);
|
||||
assert.equal(d.getUTCDate(), 23);
|
||||
// Returns local midnight — use local accessors, not UTC
|
||||
assert.equal(d.getFullYear(), 2023);
|
||||
assert.equal(d.getMonth(), 2);
|
||||
assert.equal(d.getDate(), 23);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
43
test.mjs
43
test.mjs
|
|
@ -43,21 +43,48 @@ describe('toHijriDate', () => {
|
|||
const h = toHijriDate(new Date(1800, 0, 1));
|
||||
assert.equal(h, null);
|
||||
});
|
||||
|
||||
it('toHijriDate(new Date(2025, 2, 1, 12)) -> {1446, 9, 1}', () => {
|
||||
// Local-noon: verifies local-day interpretation ignores the time component
|
||||
const h = toHijriDate(new Date(2025, 2, 1, 12));
|
||||
assert.ok(h !== null, 'expected non-null');
|
||||
assert.equal(h.hy, 1446);
|
||||
assert.equal(h.hm, 9);
|
||||
assert.equal(h.hd, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromHijriDate', () => {
|
||||
it('1 Ramadan 1444 -> 2023-03-23', () => {
|
||||
it('1 Ramadan 1444 -> local 2023-03-23', () => {
|
||||
const d = fromHijriDate(1444, 9, 1);
|
||||
assert.equal(d.getUTCFullYear(), 2023);
|
||||
assert.equal(d.getUTCMonth(), 2);
|
||||
assert.equal(d.getUTCDate(), 23);
|
||||
// Returns local midnight: local accessors show the intended calendar day
|
||||
assert.equal(d.getFullYear(), 2023);
|
||||
assert.equal(d.getMonth(), 2);
|
||||
assert.equal(d.getDate(), 23);
|
||||
});
|
||||
|
||||
it('1 Muharram 1446 -> 2024-07-07', () => {
|
||||
it('1 Muharram 1446 -> local 2024-07-07', () => {
|
||||
const d = fromHijriDate(1446, 1, 1);
|
||||
assert.equal(d.getUTCFullYear(), 2024);
|
||||
assert.equal(d.getUTCMonth(), 6);
|
||||
assert.equal(d.getUTCDate(), 7);
|
||||
assert.equal(d.getFullYear(), 2024);
|
||||
assert.equal(d.getMonth(), 6);
|
||||
assert.equal(d.getDate(), 7);
|
||||
});
|
||||
|
||||
it('round-trip: toHijriDate(fromHijriDate(1446, 9, 1)) === {1446, 9, 1}', () => {
|
||||
const d = fromHijriDate(1446, 9, 1);
|
||||
const h = toHijriDate(d);
|
||||
assert.ok(h !== null, 'expected non-null round-trip result');
|
||||
assert.equal(h.hy, 1446);
|
||||
assert.equal(h.hm, 9);
|
||||
assert.equal(h.hd, 1);
|
||||
});
|
||||
|
||||
it('fromHijriDate(1446,9,1) local accessors show 2025-03-01', () => {
|
||||
const d = fromHijriDate(1446, 9, 1);
|
||||
// Local accessors — not toISOString() — are the correct API for this adapter
|
||||
assert.equal(d.getFullYear(), 2025);
|
||||
assert.equal(d.getMonth(), 2); // March
|
||||
assert.equal(d.getDate(), 1);
|
||||
});
|
||||
|
||||
it('throws on invalid month', () => {
|
||||
|
|
|
|||
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: ["date-fns-hijri.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue