Compare commits

...

6 commits
v1.0.2 ... main

Author SHA1 Message Date
Aric Camarata
ee6c78d68f chore: bump to v1.0.4 2026-06-13 11:52:28 -04:00
Aric Camarata
4d414b2056 build: use prepack hook so npm pack/publish reliably emit index.d.mts 2026-06-13 10:11:20 -04:00
Aric Camarata
750f7e19ad chore: bump to v1.0.3 2026-06-10 16:50:19 -04:00
Aric Camarata
a8e72ac2b2 chore: update hijri-core to 1.0.3 2026-06-10 16:49:33 -04:00
Aric Camarata
d12117f000 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.
2026-06-10 16:38:32 -04:00
Aric Camarata
f260912927 ci: fix eslint config files pattern, add @typescript-eslint direct devDeps, fix prettier formatting 2026-05-31 08:47:50 -04:00
12 changed files with 947 additions and 108 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules/
dist/
coverage/
*.tgz
*.log
.DS_Store

View file

@ -5,7 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.4] - 2026-06-13
### Fixed
- Published package now includes `dist/index.d.mts` so ESM type resolution under `node16`/`nodenext` resolves the import condition correctly.
## [1.0.3] - 2026-06-10
### 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()`.
- Requires hijri-core 1.0.3 (UTC-day contract).
## [1.0.2] - 2026-05-30

View file

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

View file

@ -4,13 +4,17 @@ import eslintConfigPrettier from 'eslint-config-prettier';
import { typescript } from '@acamarata/eslint-config';
export default [
{
plugins: { '@typescript-eslint': tsPlugin },
languageOptions: { parser: tsParser },
},
...typescript,
eslintConfigPrettier,
{
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'],
},
{
files: ['src/**/*.ts'],
plugins: { '@typescript-eslint': tsPlugin },
languageOptions: {
parser: tsParser,
parserOptions: { project: true, tsconfigRootDir: import.meta.dirname },
},
},
...typescript.map((cfg) => ({ ...cfg, files: ['src/**/*.ts'] })),
{ ...eslintConfigPrettier, files: ['src/**/*.ts'] },
];

View file

@ -1,6 +1,6 @@
{
"name": "date-fns-hijri",
"version": "1.0.2",
"version": "1.0.4",
"description": "date-fns-style utility functions for Hijri calendar operations. Wraps hijri-core with a functional API for converting, formatting, and validating Hijri dates.",
"author": "Aric Camarata",
"license": "MIT",
@ -37,10 +37,11 @@
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"prepublishOnly": "pnpm run build",
"prepack": "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",
@ -63,16 +64,19 @@
"@acamarata/tsconfig": "^0.1.0",
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.5",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "^10.1.3",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"hijri-core": "^1.0.0",
"hijri-core": "^1.0.3",
"prettier": "^3.8.1",
"tsup": "^8.0.0",
"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",

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,32 @@ import {
hwLong,
hwShort,
hwNumeric,
} from 'hijri-core';
} from "hijri-core";
export type { HijriDate, CalendarEngine, ConversionOptions } from './types';
export type { HijriDate, CalendarEngine, ConversionOptions } from "./types";
import type { HijriDate, 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: 13181500 AH / 19002076 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 (112) 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 (130) 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;
}
/**
@ -122,7 +171,7 @@ export function getDaysInHijriMonth(hy: number, hm: number, options?: Conversion
*/
export function getHijriMonthName(
hm: number,
length: 'long' | 'medium' | 'short' = 'long',
length: "long" | "medium" | "short" = "long",
): string {
if (hm < 1 || hm > 12) {
throw new RangeError(`Hijri month must be 112, got ${hm}.`);
@ -130,9 +179,9 @@ export function getHijriMonthName(
const idx = hm - 1;
// Non-null: hm validated 1-12 above; idx is always 0-11, within all hm* array bounds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (length === 'medium') return hmMedium[idx]!;
if (length === "medium") return hmMedium[idx]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (length === 'short') return hmShort[idx]!;
if (length === "short") return hmShort[idx]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hmLong[idx]!;
}
@ -141,15 +190,17 @@ 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'`.
*/
export function getHijriWeekdayName(date: Date, length: 'long' | 'short' = 'long'): string {
export function getHijriWeekdayName(date: Date, length: "long" | "short" = "long"): string {
const day = date.getDay(); // 06
// Non-null: day is always 0-6 from getDay(), within hw* array bounds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return length === 'short' ? hwShort[day]! : hwLong[day]!;
return length === "short" ? hwShort[day]! : hwLong[day]!;
}
// ---------------------------------------------------------------------------
@ -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,70 +241,52 @@ export function formatHijriDate(
formatStr: string,
options?: ConversionOptions,
): string {
const h = coreToHijri(date, options);
if (!h) return '';
const h = coreToHijri(localDayToUtcSlot(date), options);
if (!h) return "";
const day = date.getDay(); // 06
const day = date.getDay(); // 06 local weekday — correct for display
return formatStr.replace(TOKEN_RE, (token): string => {
switch (token) {
case 'iYYYY':
case "iYYYY":
return String(h.hy);
case 'iYY':
return String(h.hy).slice(-2).padStart(2, '0');
case 'iMMMM':
case "iYY":
return String(h.hy).slice(-2).padStart(2, "0");
case "iMMMM":
// Non-null: hm is a valid Hijri month 1-12; index hm-1 is within hmLong bounds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hmLong[h.hm - 1]!;
case 'iMMM':
case "iMMM":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hmMedium[h.hm - 1]!;
case 'iMM':
return String(h.hm).padStart(2, '0');
case 'iM':
case "iMM":
return String(h.hm).padStart(2, "0");
case "iM":
return String(h.hm);
case 'iDD':
return String(h.hd).padStart(2, '0');
case 'iD':
case "iDD":
return String(h.hd).padStart(2, "0");
case "iD":
return String(h.hd);
case 'iEEEE':
case "iEEEE":
// Non-null: day is always 0-6 from getDay(), within hwLong bounds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hwLong[day]!;
case 'iEEE':
case "iEEE":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hwShort[day]!;
case 'iE':
case "iE":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return String(hwNumeric[day]!);
case 'ioooo':
return 'AH';
case 'iooo':
return 'AH';
case "ioooo":
return "AH";
case "iooo":
return "AH";
default:
return token;
}
});
}
// ---------------------------------------------------------------------------
// 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,9 +304,9 @@ 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.');
throw new Error("Date is outside the supported Hijri calendar range.");
}
// Total months from epoch: 0-based
@ -279,28 +318,31 @@ 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.');
throw new Error("Date is outside the supported Hijri calendar range.");
}
const newYear = h.hy + years;
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.');
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.');
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 (14) for a Gregorian date.
*
* The input Date is interpreted by its **local calendar day** (date-fns convention).
*
* Months 13 = Q1, 46 = Q2, 79 = Q3, 1012 = 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);
}

View file

@ -1 +1 @@
export type { HijriDate, CalendarEngine, ConversionOptions } from 'hijri-core';
export type { HijriDate, CalendarEngine, ConversionOptions } from "hijri-core";

View file

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

View file

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

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["date-fns-hijri.test.ts"],
},
});