refactor: code quality improvements across the board

This commit is contained in:
Aric Camarata 2026-03-08 11:42:29 -04:00
parent 20dc36541b
commit cc620328a0
16 changed files with 2118 additions and 834 deletions

View file

@ -22,8 +22,22 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: node test.mjs
- run: node test-cjs.cjs
- run: node --test test.mjs
- run: node --test test-cjs.cjs
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run format:check
typecheck:
name: Typecheck

6
.gitignore vendored
View file

@ -54,3 +54,9 @@ coverage/
.windsurf/
.cody/
.sourcegraph/
.vscode/*
.codex/
.aider/
.aider.chat.history.md
.continue/
.gemini/

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

View file

@ -7,14 +7,14 @@
Converts a Gregorian `Date` to a Hijri date object.
```typescript
function toHijri(date: Date, options?: ConversionOptions): HijriDate | null
function toHijri(date: Date, options?: ConversionOptions): HijriDate | null;
```
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `date` | `Date` | Any valid JavaScript `Date`. |
| Name | Type | Description |
| --------- | ------------------- | -------------------------------------------------------------------- |
| `date` | `Date` | Any valid JavaScript `Date`. |
| `options` | `ConversionOptions` | Optional. `{ calendar: 'uaq' }` (default) or `{ calendar: 'fcna' }`. |
For the default `'uaq'` calendar, the local year/month/day components of the Date are used. For `'fcna'`, UTC components are used (since FCNA month boundaries are defined in UTC).
@ -29,10 +29,10 @@ For FCNA: returns `null` only for dates before 1 Muharram 1 AH (pre-Islamic epoc
**Example**
```javascript
toHijri(new Date(2023, 2, 23, 12)) // { hy: 1444, hm: 9, hd: 1 } (UAQ)
toHijri(new Date(2025, 2, 1, 12)) // { hy: 1446, hm: 9, hd: 1 } (UAQ)
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }) // { hy: 1446, hm: 9, hd: 1 } (FCNA)
toHijri(new Date(1800, 0, 1), { calendar: 'uaq' }) // null (before table range)
toHijri(new Date(2023, 2, 23, 12)); // { hy: 1444, hm: 9, hd: 1 } (UAQ)
toHijri(new Date(2025, 2, 1, 12)); // { hy: 1446, hm: 9, hd: 1 } (UAQ)
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }); // { hy: 1446, hm: 9, hd: 1 } (FCNA)
toHijri(new Date(1800, 0, 1), { calendar: 'uaq' }); // null (before table range)
```
---
@ -42,16 +42,16 @@ toHijri(new Date(1800, 0, 1), { calendar: 'uaq' }) // null (before table range)
Converts a Hijri date to a Gregorian `Date`.
```typescript
function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date
function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date;
```
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `hy` | `number` | Hijri year (13181500 for UAQ; any year ≥ 1 for FCNA) |
| `hm` | `number` | Hijri month (112) |
| `hd` | `number` | Hijri day (129 or 130 depending on the month) |
| Name | Type | Description |
| --------- | ------------------- | -------------------------------------------------------------------- |
| `hy` | `number` | Hijri year (13181500 for UAQ; any year ≥ 1 for FCNA) |
| `hm` | `number` | Hijri month (112) |
| `hd` | `number` | Hijri day (129 or 130 depending on the month) |
| `options` | `ConversionOptions` | Optional. `{ calendar: 'uaq' }` (default) or `{ calendar: 'fcna' }`. |
**Returns** `Date`
@ -63,10 +63,10 @@ Returns a UTC Date at midnight.
**Example**
```javascript
toGregorian(1444, 9, 1) // 2023-03-23T00:00:00.000Z
toGregorian(1446, 9, 1, { calendar: 'fcna' }) // 2025-03-01T00:00:00.000Z
toGregorian(1446, 10, 1, { calendar: 'fcna' }) // 2025-03-30T00:00:00.000Z
toGregorian(1444, 0, 1) // throws: month 0 is invalid
toGregorian(1444, 9, 1); // 2023-03-23T00:00:00.000Z
toGregorian(1446, 9, 1, { calendar: 'fcna' }); // 2025-03-01T00:00:00.000Z
toGregorian(1446, 10, 1, { calendar: 'fcna' }); // 2025-03-30T00:00:00.000Z
toGregorian(1444, 0, 1); // throws: month 0 is invalid
```
---
@ -76,15 +76,15 @@ toGregorian(1444, 0, 1) // throws: month 0 is invalid
Formats a Hijri date using a format string with Hijri-specific tokens.
```typescript
function formatHijriDate(date: HijriDate, format: string): string
function formatHijriDate(date: HijriDate, format: string): string;
```
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `date` | `HijriDate` | A Hijri date object with `hy`, `hm`, `hd` properties |
| `format` | `string` | Format string with tokens listed below |
| Name | Type | Description |
| -------- | ----------- | ---------------------------------------------------- |
| `date` | `HijriDate` | A Hijri date object with `hy`, `hm`, `hd` properties |
| `format` | `string` | Format string with tokens listed below |
**Returns** `string`
@ -92,32 +92,32 @@ Tokens in the format string are replaced with the corresponding Hijri values. Un
**Format tokens**
| Token | Description | Example |
| --- | --- | --- |
| `iYYYY` | Year, 4 digits | `1444` |
| `iYY` | Year, last 2 digits | `44` |
| `iMMMM` | Month, full name | `Ramadan` |
| `iMMM` | Month, medium name | `Ramadan` |
| `iMM` | Month, 2 digits, zero-padded | `09` |
| `iM` | Month, no padding | `9` |
| `iDD` | Day, 2 digits, zero-padded | `01` |
| `iD` | Day, no padding | `1` |
| `iEEEE` | Weekday, full name | `Yawm al-Khamis` |
| `iEEE` | Weekday, abbreviated | `Kham` |
| `iE` | Weekday, numeric (Sunday=1) | `5` |
| `ioooo` | Era, full | `AH` |
| `iooo` | Era, abbreviated | `AH` |
| `HH` | Hour, 24h, zero-padded | `14` |
| `H` | Hour, 24h | `14` |
| `hh` | Hour, 12h, zero-padded | `02` |
| `h` | Hour, 12h | `2` |
| `mm` | Minute, zero-padded | `05` |
| `m` | Minute | `5` |
| `ss` | Second, zero-padded | `30` |
| `s` | Second | `30` |
| `a` | AM/PM | `AM` |
| `z`, `zz`, `zzz` | Timezone name | `UTC` |
| `Z`, `ZZ` | Timezone offset | `+00:00` |
| Token | Description | Example |
| ---------------- | ---------------------------- | ---------------- |
| `iYYYY` | Year, 4 digits | `1444` |
| `iYY` | Year, last 2 digits | `44` |
| `iMMMM` | Month, full name | `Ramadan` |
| `iMMM` | Month, medium name | `Ramadan` |
| `iMM` | Month, 2 digits, zero-padded | `09` |
| `iM` | Month, no padding | `9` |
| `iDD` | Day, 2 digits, zero-padded | `01` |
| `iD` | Day, no padding | `1` |
| `iEEEE` | Weekday, full name | `Yawm al-Khamis` |
| `iEEE` | Weekday, abbreviated | `Kham` |
| `iE` | Weekday, numeric (Sunday=1) | `5` |
| `ioooo` | Era, full | `AH` |
| `iooo` | Era, abbreviated | `AH` |
| `HH` | Hour, 24h, zero-padded | `14` |
| `H` | Hour, 24h | `14` |
| `hh` | Hour, 12h, zero-padded | `02` |
| `h` | Hour, 12h | `2` |
| `mm` | Minute, zero-padded | `05` |
| `m` | Minute | `5` |
| `ss` | Second, zero-padded | `30` |
| `s` | Second | `30` |
| `a` | AM/PM | `AM` |
| `z`, `zz`, `zzz` | Timezone name | `UTC` |
| `Z`, `ZZ` | Timezone offset | `+00:00` |
Time, timezone, and weekday tokens are computed from a Gregorian DateTime derived from the Hijri date using the UAQ calendar. For FCNA-derived dates in months where UAQ and FCNA start on different days, weekday and time tokens will reflect the UAQ Gregorian equivalent, not the FCNA one. Pure Hijri tokens (`iYYYY`, `iMM`, `iDD`, `iMMMM`, etc.) are always accurate regardless of which calendar system produced the date.
@ -125,24 +125,24 @@ Time, timezone, and weekday tokens are computed from a Gregorian DateTime derive
The weekday arrays follow the Islamic convention where Sunday is the first day:
| Index | Day | `iE` value |
| --- | --- | --- |
| 0 | Sunday | 1 |
| 1 | Monday | 2 |
| 2 | Tuesday | 3 |
| 3 | Wednesday | 4 |
| 4 | Thursday | 5 |
| 5 | Friday | 6 |
| 6 | Saturday | 7 |
| Index | Day | `iE` value |
| ----- | --------- | ---------- |
| 0 | Sunday | 1 |
| 1 | Monday | 2 |
| 2 | Tuesday | 3 |
| 3 | Wednesday | 4 |
| 4 | Thursday | 5 |
| 5 | Friday | 6 |
| 6 | Saturday | 7 |
**Example**
```javascript
const d = { hy: 1444, hm: 9, hd: 1 };
formatHijriDate(d, 'iYYYY-iMM-iDD') // "1444-09-01"
formatHijriDate(d, 'iMMMM iD, iYYYY') // "Ramadan 1, 1444"
formatHijriDate(d, 'iEEEE, iD iMMMM iYYYY ioooo') // "Yawm al-Khamis, 1 Ramadan 1444 AH"
formatHijriDate(d, 'iYYYY-iMM-iDD'); // "1444-09-01"
formatHijriDate(d, 'iMMMM iD, iYYYY'); // "Ramadan 1, 1444"
formatHijriDate(d, 'iEEEE, iD iMMMM iYYYY ioooo'); // "Yawm al-Khamis, 1 Ramadan 1444 AH"
```
---
@ -152,7 +152,7 @@ formatHijriDate(d, 'iEEEE, iD iMMMM iYYYY ioooo') // "Yawm al-Khamis, 1 Ram
Checks whether a Hijri date is valid for the given calendar system.
```typescript
function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean
function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean;
```
**Returns** `boolean`
@ -164,12 +164,12 @@ For FCNA: `hy` must be ≥ 1, `hm` must be 112, and `hd` must not exceed the
**Example**
```javascript
isValidHijriDate(1444, 9, 1) // true
isValidHijriDate(1444, 9, 30) // false - Ramadan 1444 has 29 days (UAQ)
isValidHijriDate(1317, 1, 1) // false - before table range
isValidHijriDate(1501, 1, 1) // false - sentinel boundary
isValidHijriDate(1, 1, 1, { calendar: 'fcna' }) // true - FCNA supports all years
isValidHijriDate(1600, 1, 1, { calendar: 'fcna' }) // true - beyond UAQ table, FCNA computed
isValidHijriDate(1444, 9, 1); // true
isValidHijriDate(1444, 9, 30); // false - Ramadan 1444 has 29 days (UAQ)
isValidHijriDate(1317, 1, 1); // false - before table range
isValidHijriDate(1501, 1, 1); // false - sentinel boundary
isValidHijriDate(1, 1, 1, { calendar: 'fcna' }); // true - FCNA supports all years
isValidHijriDate(1600, 1, 1, { calendar: 'fcna' }); // true - beyond UAQ table, FCNA computed
```
---
@ -193,11 +193,11 @@ interface ConversionOptions {
}
interface HijriYearRecord {
hy: number; // Hijri year
hy: number; // Hijri year
dpm: number; // days-per-month bitmask
gy: number; // Gregorian year of 1 Muharram
gm: number; // Gregorian month of 1 Muharram
gd: number; // Gregorian day of 1 Muharram
gy: number; // Gregorian year of 1 Muharram
gm: number; // Gregorian month of 1 Muharram
gd: number; // Gregorian day of 1 Muharram
}
```
@ -207,9 +207,9 @@ interface HijriYearRecord {
```typescript
import type {
HijriDate, // { hy, hm, hd }
HijriYearRecord, // UAQ table row
CalendarSystem, // 'uaq' | 'fcna'
HijriDate, // { hy, hm, hd }
HijriYearRecord, // UAQ table row
CalendarSystem, // 'uaq' | 'fcna'
ConversionOptions, // { calendar?: CalendarSystem }
} from 'luxon-hijri';
```
@ -218,45 +218,45 @@ import type {
```javascript
import {
hDatesTable, // HijriYearRecord[] - 184 entries (183 real years + 1 sentinel)
hmLong, // string[12] - full month names
hmMedium, // string[12] - medium month names
hmShort, // string[12] - abbreviated month names
hwLong, // string[7] - full weekday names (Sunday-first order)
hwShort, // string[7] - abbreviated weekday names
hwNumeric, // number[7] - weekday numbers (1-7, Sunday=1)
hDatesTable, // HijriYearRecord[] - 184 entries (183 real years + 1 sentinel)
hmLong, // string[12] - full month names
hmMedium, // string[12] - medium month names
hmShort, // string[12] - abbreviated month names
hwLong, // string[7] - full weekday names (Sunday-first order)
hwShort, // string[7] - abbreviated weekday names
hwNumeric, // number[7] - weekday numbers (1-7, Sunday=1)
formatPatterns, // Record<string, string> - token reference map
} from 'luxon-hijri';
```
**Month name arrays** (index 0 = Muharram, index 11 = Dhul Hijjah)
| Index | `hmLong` | `hmMedium` | `hmShort` |
| --- | --- | --- | --- |
| 0 | Muharram | Muharram | Muh |
| 1 | Safar | Safar | Saf |
| 2 | Rabi'l Awwal | Rabi1 | Ra1 |
| 3 | Rabi'l Thani | Rabi2 | Ra2 |
| 4 | Jumadal Awwal | Jumada1 | Ju1 |
| 5 | Jumadal Thani | Jumada2 | Ju2 |
| 6 | Rajab | Rajab | Raj |
| 7 | Sha'ban | Shaban | Shb |
| 8 | Ramadan | Ramadan | Ram |
| 9 | Shawwal | Shawwal | Shw |
| 10 | Dhul Qi'dah | Dhul-Qidah | DhQ |
| 11 | Dhul Hijjah | Dhul-Hijjah | DhH |
| Index | `hmLong` | `hmMedium` | `hmShort` |
| ----- | ------------- | ----------- | --------- |
| 0 | Muharram | Muharram | Muh |
| 1 | Safar | Safar | Saf |
| 2 | Rabi'l Awwal | Rabi1 | Ra1 |
| 3 | Rabi'l Thani | Rabi2 | Ra2 |
| 4 | Jumadal Awwal | Jumada1 | Ju1 |
| 5 | Jumadal Thani | Jumada2 | Ju2 |
| 6 | Rajab | Rajab | Raj |
| 7 | Sha'ban | Shaban | Shb |
| 8 | Ramadan | Ramadan | Ram |
| 9 | Shawwal | Shawwal | Shw |
| 10 | Dhul Qi'dah | Dhul-Qidah | DhQ |
| 11 | Dhul Hijjah | Dhul-Hijjah | DhH |
**Weekday arrays** (index 0 = Sunday, index 6 = Saturday)
| Index | `hwLong` | `hwShort` | `hwNumeric` |
| --- | --- | --- | --- |
| 0 | Yawm al-Ahad | Ahad | 1 |
| 1 | Yawm al-Ithnayn | Ithn | 2 |
| 2 | Yawm ath-Thulatha' | Thul | 3 |
| 3 | Yawm al-Arba`a' | Arba | 4 |
| 4 | Yawm al-Khamis | Kham | 5 |
| 5 | Yawm al-Jum`a | Jum`a | 6 |
| 6 | Yawm as-Sabt | Sabt | 7 |
| Index | `hwLong` | `hwShort` | `hwNumeric` |
| ----- | ------------------ | --------- | ----------- |
| 0 | Yawm al-Ahad | Ahad | 1 |
| 1 | Yawm al-Ithnayn | Ithn | 2 |
| 2 | Yawm ath-Thulatha' | Thul | 3 |
| 3 | Yawm al-Arba`a' | Arba | 4 |
| 4 | Yawm al-Khamis | Kham | 5 |
| 5 | Yawm al-Jum`a | Jum`a | 6 |
| 6 | Yawm as-Sabt | Sabt | 7 |
---

View file

@ -12,13 +12,13 @@ The table lives in `hijri-core` and is re-exported from this package as `hDatesT
Each row stores:
| Field | Type | Description |
| --- | --- | --- |
| `hy` | number | Hijri year |
| Field | Type | Description |
| ----- | ------ | ------------------------------------------------------------------------------------- |
| `hy` | number | Hijri year |
| `dpm` | number | 12-bit bitmask: bit 0 = month 1, bit 11 = month 12. 1 means 30 days, 0 means 29 days. |
| `gy` | number | Gregorian year of 1 Muharram |
| `gm` | number | Gregorian month of 1 Muharram (1-based) |
| `gd` | number | Gregorian day of 1 Muharram |
| `gy` | number | Gregorian year of 1 Muharram |
| `gm` | number | Gregorian month of 1 Muharram (1-based) |
| `gd` | number | Gregorian day of 1 Muharram |
Example entry: `{ hy: 1444, dpm: 0x0555, gy: 2022, gm: 7, gd: 30 }`. Year 1444 started on July 30, 2022 (Gregorian). The `dpm` bitmask tells us which months have 30 days vs 29.
@ -99,7 +99,7 @@ The Fiqh Council of North America uses a global visibility rule: if the astronom
### New Moon Computation
New moon times come from Jean Meeus, *Astronomical Algorithms* (2nd ed.), Chapter 49. The algorithm takes an integer k (count of new moons since a reference epoch near J2000) and returns the Julian Ephemeris Day (JDE) of the corrected new moon. The correction terms include the solar anomaly, lunar anomaly, argument of latitude, ascending node, and 14 additional planetary terms. Accuracy: within a few minutes for 10003000 CE.
New moon times come from Jean Meeus, _Astronomical Algorithms_ (2nd ed.), Chapter 49. The algorithm takes an integer k (count of new moons since a reference epoch near J2000) and returns the Julian Ephemeris Day (JDE) of the corrected new moon. The correction terms include the solar anomaly, lunar anomaly, argument of latitude, ascending node, and 14 additional planetary terms. Accuracy: within a few minutes for 10003000 CE.
### Anchor Strategy

View file

@ -15,20 +15,20 @@ Different countries and communities follow different approaches.
## Hijri Months
| No. | Arabic Name | Common Transliteration |
| --- | --- | --- |
| 1 | محرم | Muharram |
| 2 | صفر | Safar |
| 3 | ربيع الأول | Rabi' al-Awwal |
| 4 | ربيع الثاني | Rabi' al-Thani |
| 5 | جمادى الأولى | Jumada al-Awwal |
| 6 | جمادى الآخرة | Jumada al-Thani |
| 7 | رجب | Rajab |
| 8 | شعبان | Sha'ban |
| 9 | رمضان | Ramadan |
| 10 | شوال | Shawwal |
| 11 | ذو القعدة | Dhul Qi'dah |
| 12 | ذو الحجة | Dhul Hijjah |
| No. | Arabic Name | Common Transliteration |
| --- | ------------ | ---------------------- |
| 1 | محرم | Muharram |
| 2 | صفر | Safar |
| 3 | ربيع الأول | Rabi' al-Awwal |
| 4 | ربيع الثاني | Rabi' al-Thani |
| 5 | جمادى الأولى | Jumada al-Awwal |
| 6 | جمادى الآخرة | Jumada al-Thani |
| 7 | رجب | Rajab |
| 8 | شعبان | Sha'ban |
| 9 | رمضان | Ramadan |
| 10 | شوال | Shawwal |
| 11 | ذو القعدة | Dhul Qi'dah |
| 12 | ذو الحجة | Dhul Hijjah |
Months alternate between 29 and 30 days. Dhul Hijjah has 29 days in a normal year and 30 in a leap year.
@ -36,15 +36,15 @@ Months alternate between 29 and 30 days. Dhul Hijjah has 29 days in a normal yea
The Islamic week begins on Sunday. Friday (Yawm al-Jum'a) is the day of congregational prayer.
| No. | Arabic Name | Transliteration |
| --- | --- | --- |
| 1 | الأحد | Yawm al-Ahad (Sunday) |
| 2 | الاثنين | Yawm al-Ithnayn (Monday) |
| 3 | الثلاثاء | Yawm ath-Thulatha' (Tuesday) |
| 4 | الأربعاء | Yawm al-Arba'a' (Wednesday) |
| 5 | الخميس | Yawm al-Khamis (Thursday) |
| 6 | الجمعة | Yawm al-Jum'a (Friday) |
| 7 | السبت | Yawm as-Sabt (Saturday) |
| No. | Arabic Name | Transliteration |
| --- | ----------- | ---------------------------- |
| 1 | الأحد | Yawm al-Ahad (Sunday) |
| 2 | الاثنين | Yawm al-Ithnayn (Monday) |
| 3 | الثلاثاء | Yawm ath-Thulatha' (Tuesday) |
| 4 | الأربعاء | Yawm al-Arba'a' (Wednesday) |
| 5 | الخميس | Yawm al-Khamis (Thursday) |
| 6 | الجمعة | Yawm al-Jum'a (Friday) |
| 7 | السبت | Yawm as-Sabt (Saturday) |
## The Umm al-Qura Calendar
@ -84,11 +84,11 @@ A Hijri year has either 354 days (12 months × 29.5 days average) or 355 days. T
## Epoch and Date Range
| | Hijri | Gregorian |
| --- | --- | --- |
| Table start | 1 Muharram 1318 H | April 30, 1900 |
| Table end | Last day of Dhul Hijjah 1500 H | ~November 2076 |
| Sentinel boundary | 1 Muharram 1501 H | November 17, 2077 |
| | Hijri | Gregorian |
| ----------------- | ------------------------------ | ----------------- |
| Table start | 1 Muharram 1318 H | April 30, 1900 |
| Table end | Last day of Dhul Hijjah 1500 H | ~November 2076 |
| Sentinel boundary | 1 Muharram 1501 H | November 17, 2077 |
For the Umm al-Qura calendar (default), dates outside this range return `null` from `toHijri` and throw from `toGregorian`. The FCNA calendar supports all Hijri years and has no range limit.

View file

@ -30,8 +30,8 @@ formatHijriDate({ hy: 1444, hm: 9, hd: 1 }, 'iEEEE, iD iMMMM iYYYY ioooo');
// "Yawm al-Khamis, 1 Ramadan 1444 AH"
// FCNA/ISNA calendar (astronomical, works for all Hijri years)
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }); // { hy: 1446, hm: 9, hd: 1 }
toGregorian(1446, 9, 1, { calendar: 'fcna' }); // Date: 2025-03-01T00:00:00.000Z
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }); // { hy: 1446, hm: 9, hd: 1 }
toGregorian(1446, 9, 1, { calendar: 'fcna' }); // Date: 2025-03-01T00:00:00.000Z
```
## API
@ -41,7 +41,7 @@ toGregorian(1446, 9, 1, { calendar: 'fcna' }); // Date: 2025-03-01T0
Converts a Gregorian `Date` to a Hijri date object.
```typescript
function toHijri(date: Date, options?: ConversionOptions): HijriDate | null
function toHijri(date: Date, options?: ConversionOptions): HijriDate | null;
```
For `'uaq'` (default): returns `null` if the date falls outside the table range (before 1 Muharram 1318 H / 1900-04-30, or at/after 1 Muharram 1501 H / 2077-11-17). Uses local date components.
@ -51,9 +51,9 @@ For `'fcna'`: returns `null` only for dates before 1 AH. Uses UTC date component
Throws `Error("Invalid Gregorian date")` if `date` is not a valid `Date`.
```javascript
toHijri(new Date(2024, 6, 7, 12)) // { hy: 1446, hm: 1, hd: 1 } (UAQ)
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }) // { hy: 1446, hm: 9, hd: 1 } (FCNA)
toHijri(new Date(1800, 0, 1)) // null - before UAQ table range
toHijri(new Date(2024, 6, 7, 12)); // { hy: 1446, hm: 1, hd: 1 } (UAQ)
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }); // { hy: 1446, hm: 9, hd: 1 } (FCNA)
toHijri(new Date(1800, 0, 1)); // null - before UAQ table range
```
### `toGregorian(hy, hm, hd, options?)`
@ -61,15 +61,15 @@ toHijri(new Date(1800, 0, 1)) // null - before UAQ
Converts a Hijri date to a Gregorian `Date` at UTC midnight.
```typescript
function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date
function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date;
```
Throws `Error("Invalid Hijri date")` if the date is invalid for the selected calendar.
```javascript
toGregorian(1446, 1, 1) // Date: 2024-07-07T00:00:00.000Z (UAQ)
toGregorian(1446, 9, 1, { calendar: 'fcna' }) // Date: 2025-03-01T00:00:00.000Z (FCNA)
toGregorian(1, 1, 1, { calendar: 'fcna' }) // Date: 0622-07-18T00:00:00.000Z (Islamic epoch)
toGregorian(1446, 1, 1); // Date: 2024-07-07T00:00:00.000Z (UAQ)
toGregorian(1446, 9, 1, { calendar: 'fcna' }); // Date: 2025-03-01T00:00:00.000Z (FCNA)
toGregorian(1, 1, 1, { calendar: 'fcna' }); // Date: 0622-07-18T00:00:00.000Z (Islamic epoch)
```
### `formatHijriDate(date, format)`
@ -77,37 +77,37 @@ toGregorian(1, 1, 1, { calendar: 'fcna' }) // Date: 0622-07-18T00:00:00.0
Formats a Hijri date using the token patterns below. Tokens not listed pass through unchanged.
```typescript
function formatHijriDate(date: HijriDate, format: string): string
function formatHijriDate(date: HijriDate, format: string): string;
```
| Token | Output | Example |
| --- | --- | --- |
| `iYYYY` | Year, 4 digits | `1444` |
| `iYY` | Year, last 2 digits | `44` |
| `iMMMM` | Month, full name | `Ramadan` |
| `iMMM` | Month, medium name | `Ramadan` |
| `iMM` | Month, zero-padded | `09` |
| `iM` | Month, no padding | `9` |
| `iDD` | Day, zero-padded | `01` |
| `iD` | Day, no padding | `1` |
| `iEEEE` | Weekday, full name | `Yawm al-Khamis` |
| `iEEE` | Weekday, abbreviated | `Kham` |
| `iE` | Weekday, numeric (Sun=1) | `5` |
| `ioooo` | Era, full | `AH` |
| `iooo` | Era, abbreviated | `AH` |
| `HH`, `H`, `hh`, `h` | Hour (via Luxon) | `14`, `14`, `02`, `2` |
| `mm`, `m` | Minute (via Luxon) | `05`, `5` |
| `ss`, `s` | Second (via Luxon) | `30`, `30` |
| `a` | AM/PM | `AM` |
| `z`, `zz`, `zzz` | Timezone | `UTC` |
| `Z`, `ZZ` | Timezone offset | `+00:00` |
| Token | Output | Example |
| -------------------- | ------------------------ | --------------------- |
| `iYYYY` | Year, 4 digits | `1444` |
| `iYY` | Year, last 2 digits | `44` |
| `iMMMM` | Month, full name | `Ramadan` |
| `iMMM` | Month, medium name | `Ramadan` |
| `iMM` | Month, zero-padded | `09` |
| `iM` | Month, no padding | `9` |
| `iDD` | Day, zero-padded | `01` |
| `iD` | Day, no padding | `1` |
| `iEEEE` | Weekday, full name | `Yawm al-Khamis` |
| `iEEE` | Weekday, abbreviated | `Kham` |
| `iE` | Weekday, numeric (Sun=1) | `5` |
| `ioooo` | Era, full | `AH` |
| `iooo` | Era, abbreviated | `AH` |
| `HH`, `H`, `hh`, `h` | Hour (via Luxon) | `14`, `14`, `02`, `2` |
| `mm`, `m` | Minute (via Luxon) | `05`, `5` |
| `ss`, `s` | Second (via Luxon) | `30`, `30` |
| `a` | AM/PM | `AM` |
| `z`, `zz`, `zzz` | Timezone | `UTC` |
| `Z`, `ZZ` | Timezone offset | `+00:00` |
### `isValidHijriDate(hy, hm, hd, options?)`
Returns `true` if the Hijri date is valid for the selected calendar.
```typescript
function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean
function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean;
```
For `'uaq'` (default): year must be 13181500, month 112, day must not exceed the actual month length from the UAQ table.
@ -130,11 +130,11 @@ interface ConversionOptions {
}
interface HijriYearRecord {
hy: number; // Hijri year
hy: number; // Hijri year
dpm: number; // days-per-month bitmask (bit 0 = month 1, 1 = 30 days, 0 = 29 days)
gy: number; // Gregorian year of 1 Muharram
gm: number; // Gregorian month of 1 Muharram
gd: number; // Gregorian day of 1 Muharram
gy: number; // Gregorian year of 1 Muharram
gm: number; // Gregorian month of 1 Muharram
gd: number; // Gregorian day of 1 Muharram
}
```
@ -142,13 +142,13 @@ interface HijriYearRecord {
```javascript
import {
hDatesTable, // HijriYearRecord[] - the full Umm al-Qura table (184 entries)
hmLong, // string[12] - full month names
hmMedium, // string[12] - medium month names
hmShort, // string[12] - abbreviated month names
hwLong, // string[7] - full weekday names (Sunday first)
hwShort, // string[7] - abbreviated weekday names
hwNumeric, // number[7] - weekday numbers (1-7, Sunday=1)
hDatesTable, // HijriYearRecord[] - the full Umm al-Qura table (184 entries)
hmLong, // string[12] - full month names
hmMedium, // string[12] - medium month names
hmShort, // string[12] - abbreviated month names
hwLong, // string[7] - full weekday names (Sunday first)
hwShort, // string[7] - abbreviated weekday names
hwNumeric, // number[7] - weekday numbers (1-7, Sunday=1)
formatPatterns, // Record<string, string> - token reference
} from 'luxon-hijri';
```

10
eslint.config.mjs Normal file
View file

@ -0,0 +1,10 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
{ ignores: ['dist/', 'node_modules/', '*.cjs', '*.mjs'] },
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
);

View file

@ -37,8 +37,11 @@
"build": "tsup",
"typecheck": "tsc --noEmit",
"pretest": "tsup",
"test": "node test.mjs && node test-cjs.cjs",
"prepublishOnly": "tsup"
"test": "node --test test.mjs && node --test test-cjs.cjs",
"prepublishOnly": "tsup",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"keywords": [
"hijri",
@ -57,10 +60,15 @@
"luxon": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.0.0",
"@types/node": "^22.15.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"tsup": "^8.0.0",
"typescript": "^5.5.0"
"typescript": "^5.5.0",
"typescript-eslint": "^8.56.1"
},
"publishConfig": {
"access": "public",

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
// formatHijriDate.ts
import { DateTime } from 'luxon';
import { hmLong, hmMedium, hmShort } from './hMonths';
import { hmLong, hmMedium } from './hMonths';
import { hwLong, hwShort, hwNumeric } from './hWeekdays';
import { toGregorian } from './toGregorian';
import type { HijriDate } from './types';
@ -9,7 +9,23 @@ import type { HijriDate } from './types';
const TOKEN_RE =
/iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo|HH|H|hh|h|mm|m|ss|s|a|z{1,3}|ZZ|Z/g;
/**
* Format a Hijri date using a token-based format string.
*
* Hijri-specific tokens use the `i` prefix (iYYYY, iMM, iDD, iEEEE, etc.).
* Time and timezone tokens (HH, mm, ss, a, Z, z) are delegated to Luxon via the
* corresponding Gregorian date.
*
* @param hijriDate - the Hijri date to format
* @param format - a format string containing Hijri and/or Luxon tokens
* @returns the formatted date string
* @throws {RangeError} if the Hijri month is outside the 1-12 range
*/
export function formatHijriDate(hijriDate: HijriDate, format: string): string {
if (hijriDate.hm < 1 || hijriDate.hm > 12) {
throw new RangeError(`Hijri month must be 1-12, got ${hijriDate.hm}`);
}
// Lazy Gregorian DateTime, computed at most once per format call,
// only when a token that needs it is encountered.
let _gregDt: DateTime | undefined;

View file

@ -1,50 +1,47 @@
// formatPatterns.ts
// Define a mapping of Hijri format tokens to their meanings
export const formatPatterns = {
// Hijri Year
iYYYY: 'Hijri year (4 digits)',
iYY: 'Hijri year (2 digits)',
// Hijri Year
iYYYY: 'Hijri year (4 digits)',
iYY: 'Hijri year (2 digits)',
// Hijri Month
iMM: 'Hijri month (2 digits, zero-padded)',
iM: 'Hijri month (1 or 2 digits without zero-padding)',
iMMM: 'Hijri month (abbreviated name)',
iMMMM: 'Hijri month (full name)',
// Hijri Month
iMM: 'Hijri month (2 digits, zero-padded)',
iM: 'Hijri month (1 or 2 digits without zero-padding)',
iMMM: 'Hijri month (abbreviated name)',
iMMMM: 'Hijri month (full name)',
// Hijri Day
iDD: 'Hijri day of the month (2 digits, zero-padded)',
iD: 'Hijri day of the month (1 or 2 digits without zero-padding)',
// Hijri Day
iDD: 'Hijri day of the month (2 digits, zero-padded)',
iD: 'Hijri day of the month (1 or 2 digits without zero-padding)',
// Hijri Weekday
iE: 'Hijri weekday (1 digit)',
iEEE: 'Hijri weekday (abbreviated name)',
iEEEE: 'Hijri weekday (full name)',
// Hijri Weekday
iE: 'Hijri weekday (1 digit)',
iEEE: 'Hijri weekday (abbreviated name)',
iEEEE: 'Hijri weekday (full name)',
// Hour, Minute, Second
// These can remain the same as in Gregorian as they dont change in Hijri
HH: 'Hour (2 digits, zero-padded, 24-hour clock)',
H: 'Hour (1 or 2 digits without zero-padding, 24-hour clock)',
hh: 'Hour (2 digits, zero-padded, 12-hour clock)',
h: 'Hour (1 or 2 digits without zero-padding, 12-hour clock)',
mm: 'Minute (2 digits, zero-padded)',
m: 'Minute (1 or 2 digits without zero-padding)',
ss: 'Second (2 digits, zero-padded)',
s: 'Second (1 or 2 digits without zero-padding)',
// Hour, Minute, Second
// These can remain the same as in Gregorian as they dont change in Hijri
HH: 'Hour (2 digits, zero-padded, 24-hour clock)',
H: 'Hour (1 or 2 digits without zero-padding, 24-hour clock)',
hh: 'Hour (2 digits, zero-padded, 12-hour clock)',
h: 'Hour (1 or 2 digits without zero-padding, 12-hour clock)',
mm: 'Minute (2 digits, zero-padded)',
m: 'Minute (1 or 2 digits without zero-padding)',
ss: 'Second (2 digits, zero-padded)',
s: 'Second (1 or 2 digits without zero-padding)',
// AM/PM
a: 'AM/PM marker',
// AM/PM
a: 'AM/PM marker',
// Other
iooo: 'Hijri era (abbreviated)',
ioooo: 'Hijri era (full)',
// Other
iooo: 'Hijri era (abbreviated)',
ioooo: 'Hijri era (full)',
// Timezone
z: 'Timezone (abbreviated)',
zz: 'Timezone (medium)',
zzz: 'Timezone (full)',
Z: 'Timezone offset from UTC (+HH:MM)',
ZZ: 'Timezone offset from UTC (condensed)',
// Timezone
z: 'Timezone (abbreviated)',
zz: 'Timezone (medium)',
zzz: 'Timezone (full)',
Z: 'Timezone offset from UTC (+HH:MM)',
ZZ: 'Timezone offset from UTC (condensed)',
};
// Export the patterns for use in the rest of the package
export default formatPatterns;

View file

@ -2,6 +2,19 @@
import { toGregorian as coreToGregorian } from 'hijri-core';
import type { ConversionOptions } from './types';
/**
* Convert a Hijri date to a Gregorian Date object.
*
* Unlike the hijri-core function (which returns null for invalid input), this
* wrapper throws an Error so callers always receive a valid Date.
*
* @param hy - Hijri year
* @param hm - Hijri month (1-12)
* @param hd - Hijri day (1-30)
* @param options - conversion options (calendar engine selection)
* @returns a UTC Date corresponding to the given Hijri date
* @throws {Error} if the Hijri date is invalid or out of range
*/
export function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date {
const result = coreToGregorian(hy, hm, hd, options);
if (result === null) throw new Error('Invalid Hijri date');

View file

@ -1,5 +1,6 @@
// test-cjs.cjs — CJS test suite for luxon-hijri
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
@ -13,122 +14,110 @@ const {
hwNumeric,
} = require('./dist/index.cjs');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ${name}... PASS`);
passed++;
} catch (err) {
console.error(` ${name}... FAIL`);
console.error(` ${err.message}`);
failed++;
}
}
const FCNA = { calendar: 'fcna' };
// ─── Exports ────────────────────────────────────────────────────────────────
console.log('\nCJS exports');
test('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
test('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function'));
test('isValidHijriDate is a function', () => assert.strictEqual(typeof isValidHijriDate, 'function'));
test('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function'));
test('hDatesTable has 184 entries (183 real + 1 sentinel)', () => assert.strictEqual(hDatesTable.length, 184));
test('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7));
test('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7));
test('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7));
// ─── Core conversions ────────────────────────────────────────────────────────
console.log('\nCJS core conversions');
test('toGregorian(1444, 1, 1) = 2022-07-30', () => {
const d = toGregorian(1444, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30');
describe('CJS exports', () => {
it('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
it('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function'));
it('isValidHijriDate is a function', () =>
assert.strictEqual(typeof isValidHijriDate, 'function'));
it('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function'));
it('hDatesTable has 184 entries', () => assert.strictEqual(hDatesTable.length, 184));
it('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7));
it('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7));
it('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7));
});
test('toGregorian(1444, 9, 1) = 2023-03-23', () => {
const d = toGregorian(1444, 9, 1);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
// ─── Core conversions ──────────────────────────────────────────────────────
describe('CJS core conversions', () => {
it('toGregorian(1444, 1, 1) = 2022-07-30', () => {
const d = toGregorian(1444, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30');
});
it('toGregorian(1444, 9, 1) = 2023-03-23', () => {
const d = toGregorian(1444, 9, 1);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
});
it('toHijri(2022-07-30) = 1 Muharram 1444', () => {
const h = toHijri(new Date(2022, 6, 30, 12));
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
});
it('toHijri(2023-03-23) = 1 Ramadan 1444', () => {
const h = toHijri(new Date(2023, 2, 23, 12));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
});
});
test('toHijri(2022-07-30) = 1 Muharram 1444', () => {
const h = toHijri(new Date(2022, 6, 30, 12));
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
// ─── Validation ─────────────────────────────────────────────────────────────
describe('CJS validation', () => {
it('isValidHijriDate(1444, 9, 1) = true', () =>
assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
it('isValidHijriDate(1444, 0, 1) = false', () =>
assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
it('isValidHijriDate(1317, 1, 1) = false (out of range)', () =>
assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
});
test('toHijri(2023-03-23) = 1 Ramadan 1444', () => {
const h = toHijri(new Date(2023, 2, 23, 12));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
});
// ─── Validation ──────────────────────────────────────────────────────────────
console.log('\nCJS validation');
test('isValidHijriDate(1444, 9, 1) = true', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
test('isValidHijriDate(1444, 0, 1) = false', () => assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
test('isValidHijriDate(1317, 1, 1) = false (out of range)', () => assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
// ─── Formatting ──────────────────────────────────────────────────────────────
console.log('\nCJS formatting');
// ─── Formatting ─────────────────────────────────────────────────────────────
const ramadan1 = { hy: 1444, hm: 9, hd: 1 };
test('iYYYY-iMM-iDD = 1444-09-01', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
describe('CJS formatting', () => {
it('iYYYY-iMM-iDD = 1444-09-01', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
});
it('iMMMM = Ramadan', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
});
it('iEEEE = Yawm al-Khamis (Thursday)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
});
it('iooo = AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
});
});
test('iMMMM = Ramadan', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
describe('CJS formatHijriDate - invalid month', () => {
it('throws for month 0', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 0, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
it('throws for month 13', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 13, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
});
test('iEEEE = Yawm al-Khamis (Thursday)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
// ─── FCNA calendar ──────────────────────────────────────────────────────────
describe('CJS FCNA calendar', () => {
it('1 Ramadan 1446 = 2025-03-01', () => {
const d = toGregorian(1446, 9, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
});
it('2025-03-01 = 1 Ramadan 1446', () => {
const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
});
it('isValidHijriDate(1446, 9, 1) = true', () => {
assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true);
});
it('isValidHijriDate(1, 1, 1) = true (year 1 AH)', () => {
assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true);
});
it('round-trip 1446/9/1', () => {
const greg = toGregorian(1446, 9, 1, FCNA);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
});
});
test('iooo = AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
});
// ─── FCNA calendar ───────────────────────────────────────────────────────────
console.log('\nCJS FCNA calendar');
const FCNA = { calendar: 'fcna' };
test('FCNA: 1 Ramadan 1446 = 2025-03-01', () => {
const d = toGregorian(1446, 9, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
});
test('FCNA: 2025-03-01 = 1 Ramadan 1446', () => {
const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
});
test('FCNA: isValidHijriDate(1446, 9, 1) = true', () => {
assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true);
});
test('FCNA: isValidHijriDate(1, 1, 1) = true (year 1 AH)', () => {
assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true);
});
test('FCNA: round-trip 1446/9/1', () => {
const greg = toGregorian(1446, 9, 1, FCNA);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
});
// ─── Summary ────────────────────────────────────────────────────────────────
const total = passed + failed;
console.log(`\n${total} tests total: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

604
test.mjs
View file

@ -1,4 +1,5 @@
// test.mjs — ESM test suite for luxon-hijri
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
@ -18,382 +19,321 @@ import {
const FCNA = { calendar: 'fcna' };
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ${name}... PASS`);
passed++;
} catch (err) {
console.error(` ${name}... FAIL`);
console.error(` ${err.message}`);
failed++;
}
}
// ─── Exports ────────────────────────────────────────────────────────────────
console.log('\nExports');
test('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
test('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function'));
test('isValidHijriDate is a function', () => assert.strictEqual(typeof isValidHijriDate, 'function'));
test('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function'));
test('formatPatterns is an object', () => assert.strictEqual(typeof formatPatterns, 'object'));
test('hDatesTable is an array', () => assert(Array.isArray(hDatesTable)));
// 183 real year entries (13181500) + 1 sentinel entry (1501) marking the table boundary.
test('hDatesTable has 184 entries (13181500 + sentinel 1501)', () => assert.strictEqual(hDatesTable.length, 184));
test('hmLong has 12 entries', () => assert.strictEqual(hmLong.length, 12));
test('hmMedium has 12 entries', () => assert.strictEqual(hmMedium.length, 12));
test('hmShort has 12 entries', () => assert.strictEqual(hmShort.length, 12));
test('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7));
test('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7));
test('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7));
describe('exports', () => {
it('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
it('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function'));
it('isValidHijriDate is a function', () =>
assert.strictEqual(typeof isValidHijriDate, 'function'));
it('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function'));
it('formatPatterns is an object', () => assert.strictEqual(typeof formatPatterns, 'object'));
it('hDatesTable is an array', () => assert(Array.isArray(hDatesTable)));
it('hDatesTable has 184 entries (1318-1500 + sentinel 1501)', () =>
assert.strictEqual(hDatesTable.length, 184));
it('hmLong has 12 entries', () => assert.strictEqual(hmLong.length, 12));
it('hmMedium has 12 entries', () => assert.strictEqual(hmMedium.length, 12));
it('hmShort has 12 entries', () => assert.strictEqual(hmShort.length, 12));
it('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7));
it('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7));
it('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7));
});
// ─── toGregorian ────────────────────────────────────────────────────────────
console.log('\ntoGregorian — known dates');
describe('toGregorian - known dates', () => {
it('1 Muharram 1444 = 2022-07-30', () => {
const d = toGregorian(1444, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30');
});
test('1 Muharram 1444 = 2022-07-30', () => {
const d = toGregorian(1444, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30');
it('1 Ramadan 1444 = 2023-03-23', () => {
const d = toGregorian(1444, 9, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
});
it('1 Shawwal 1444 = 2023-04-21', () => {
const d = toGregorian(1444, 10, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-04-21');
});
it('1 Muharram 1446 = 2024-07-07', () => {
const d = toGregorian(1446, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2024-07-07');
});
it('first table entry: 1 Muharram 1318 = 1900-04-30', () => {
const d = toGregorian(1318, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '1900-04-30');
});
});
test('1 Ramadan 1444 = 2023-03-23', () => {
const d = toGregorian(1444, 9, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
});
test('1 Shawwal 1444 = 2023-04-21', () => {
const d = toGregorian(1444, 10, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-04-21');
});
test('1 Muharram 1446 = 2024-07-07', () => {
const d = toGregorian(1446, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2024-07-07');
});
test('first table entry: 1 Muharram 1318 = 1900-04-30', () => {
const d = toGregorian(1318, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '1900-04-30');
});
console.log('\ntoGregorian — error cases');
test('throws on invalid Hijri year (out of table range)', () => {
assert.throws(() => toGregorian(1317, 1, 1), /Invalid Hijri date/);
});
test('throws on month 0', () => {
assert.throws(() => toGregorian(1444, 0, 1), /Invalid Hijri date/);
});
test('throws on month 13', () => {
assert.throws(() => toGregorian(1444, 13, 1), /Invalid Hijri date/);
});
test('throws on day 0', () => {
assert.throws(() => toGregorian(1444, 9, 0), /Invalid Hijri date/);
});
test('throws on day 30 in 29-day month (Ramadan 1444)', () => {
// Ramadan 1444 has 29 days (1 Ramadan = Mar 23, 1 Shawwal = Apr 21 → 29 days)
assert.throws(() => toGregorian(1444, 9, 30), /Invalid Hijri date/);
describe('toGregorian - error cases', () => {
it('throws on invalid Hijri year (out of table range)', () => {
assert.throws(() => toGregorian(1317, 1, 1), /Invalid Hijri date/);
});
it('throws on month 0', () => {
assert.throws(() => toGregorian(1444, 0, 1), /Invalid Hijri date/);
});
it('throws on month 13', () => {
assert.throws(() => toGregorian(1444, 13, 1), /Invalid Hijri date/);
});
it('throws on day 0', () => {
assert.throws(() => toGregorian(1444, 9, 0), /Invalid Hijri date/);
});
it('throws on day 30 in 29-day month (Ramadan 1444)', () => {
assert.throws(() => toGregorian(1444, 9, 30), /Invalid Hijri date/);
});
});
// ─── toHijri ────────────────────────────────────────────────────────────────
console.log('\ntoHijri — known dates');
// Use noon (hour=12) to avoid date-boundary issues across timezones.
test('2022-07-30 = 1 Muharram 1444', () => {
const h = toHijri(new Date(2022, 6, 30, 12));
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
describe('toHijri - known dates', () => {
it('2022-07-30 = 1 Muharram 1444', () => {
const h = toHijri(new Date(2022, 6, 30, 12));
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
});
it('2023-03-23 = 1 Ramadan 1444', () => {
const h = toHijri(new Date(2023, 2, 23, 12));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
});
it('2023-04-21 = 1 Shawwal 1444', () => {
const h = toHijri(new Date(2023, 3, 21, 12));
assert.deepEqual(h, { hy: 1444, hm: 10, hd: 1 });
});
it('2024-07-07 = 1 Muharram 1446', () => {
const h = toHijri(new Date(2024, 6, 7, 12));
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
});
it('1900-04-30 = 1 Muharram 1318 (first table entry)', () => {
const h = toHijri(new Date(1900, 3, 30, 12));
assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 });
});
});
test('2023-03-23 = 1 Ramadan 1444', () => {
const h = toHijri(new Date(2023, 2, 23, 12));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
});
test('2023-04-21 = 1 Shawwal 1444', () => {
const h = toHijri(new Date(2023, 3, 21, 12));
assert.deepEqual(h, { hy: 1444, hm: 10, hd: 1 });
});
test('2024-07-07 = 1 Muharram 1446', () => {
const h = toHijri(new Date(2024, 6, 7, 12));
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
});
test('1900-04-30 = 1 Muharram 1318 (first table entry)', () => {
const h = toHijri(new Date(1900, 3, 30, 12));
assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 });
});
console.log('\ntoHijri — error cases');
test('throws on invalid Date', () => {
assert.throws(() => toHijri(new Date('not a date')), /Invalid Gregorian date/);
});
test('returns null for date before first table entry', () => {
const h = toHijri(new Date(1800, 0, 1, 12));
assert.strictEqual(h, null);
describe('toHijri - error cases', () => {
it('throws on invalid Date', () => {
assert.throws(() => toHijri(new Date('not a date')), /Invalid Gregorian date/);
});
it('returns null for date before first table entry', () => {
const h = toHijri(new Date(1800, 0, 1, 12));
assert.strictEqual(h, null);
});
});
// ─── isValidHijriDate ───────────────────────────────────────────────────────
console.log('\nisValidHijriDate');
test('1444-09-01 is valid', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
test('1444-09-29 is valid (last day of Ramadan 1444)', () => assert.strictEqual(isValidHijriDate(1444, 9, 29), true));
test('1318-01-01 is valid (first table entry)', () => assert.strictEqual(isValidHijriDate(1318, 1, 1), true));
test('1500-12-29 is valid (last table entry, last day)', () => assert.strictEqual(isValidHijriDate(1500, 12, 29), true));
test('year 1317 is out of range', () => assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
test('year 1501 is out of range', () => assert.strictEqual(isValidHijriDate(1501, 1, 1), false));
test('month 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
test('month 13 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 13, 1), false));
test('day 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 0), false));
test('day 30 in Ramadan 1444 (29-day month) is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 30), false));
describe('isValidHijriDate', () => {
it('1444-09-01 is valid', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
it('1444-09-29 is valid (last day of Ramadan 1444)', () =>
assert.strictEqual(isValidHijriDate(1444, 9, 29), true));
it('1318-01-01 is valid (first table entry)', () =>
assert.strictEqual(isValidHijriDate(1318, 1, 1), true));
it('1500-12-29 is valid (last table entry)', () =>
assert.strictEqual(isValidHijriDate(1500, 12, 29), true));
it('year 1317 is out of range', () => assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
it('year 1501 is out of range', () => assert.strictEqual(isValidHijriDate(1501, 1, 1), false));
it('month 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
it('month 13 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 13, 1), false));
it('day 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 0), false));
it('day 30 in Ramadan 1444 (29-day month) is invalid', () =>
assert.strictEqual(isValidHijriDate(1444, 9, 30), false));
});
// ─── formatHijriDate ────────────────────────────────────────────────────────
console.log('\nformatHijriDate — date tokens');
const ramadan1 = { hy: 1444, hm: 9, hd: 1 };
test('iYYYY-iMM-iDD', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
describe('formatHijriDate - date tokens', () => {
it('iYYYY-iMM-iDD', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
});
it('iYY (last 2 digits of year)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYY'), '44');
});
it('iM (month without padding)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iM'), '9');
});
it('iMM (month zero-padded)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMM'), '09');
});
it('iMMM (medium month name: Ramadan)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMM'), 'Ramadan');
});
it('iMMMM (full month name: Ramadan)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
});
it('iD (day without padding)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iD'), '1');
});
it('iDD (day zero-padded)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iDD'), '01');
});
});
test('iYY (last 2 digits of year)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYY'), '44');
describe('formatHijriDate - weekday tokens (1 Ramadan 1444 = Thursday)', () => {
it('iE = 5 (Thursday = 5th Islamic day, Sunday=1)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iE'), '5');
});
it('iEEE = Kham (Thursday abbreviated)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEE'), 'Kham');
});
it('iEEEE = Yawm al-Khamis (Thursday full)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
});
});
test('iM (month without padding)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iM'), '9');
describe('formatHijriDate - era tokens', () => {
it('iooo = AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
});
it('ioooo = AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'ioooo'), 'AH');
});
});
test('iMM (month zero-padded)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMM'), '09');
describe('formatHijriDate - composite format', () => {
it('iMMMM iD, iYYYY', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM iD, iYYYY'), 'Ramadan 1, 1444');
});
it('iDD/iMM/iYYYY', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iDD/iMM/iYYYY'), '01/09/1444');
});
it('iEEEE, iD iMMMM iYYYY ioooo', () => {
assert.strictEqual(
formatHijriDate(ramadan1, 'iEEEE, iD iMMMM iYYYY ioooo'),
'Yawm al-Khamis, 1 Ramadan 1444 AH',
);
});
});
test('iMMM (medium month name: Ramadan)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMM'), 'Ramadan');
});
test('iMMMM (full month name: Ramadan)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
});
test('iD (day without padding)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iD'), '1');
});
test('iDD (day zero-padded)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iDD'), '01');
});
console.log('\nformatHijriDate — weekday tokens (1 Ramadan 1444 = Thursday)');
test('iE → 5 (Thursday = 5th Islamic day, Sunday=1)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iE'), '5');
});
test('iEEE → Kham (Thursday abbreviated)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEE'), 'Kham');
});
test('iEEEE → Yawm al-Khamis (Thursday full)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
});
console.log('\nformatHijriDate — era tokens');
test('iooo → AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
});
test('ioooo → AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'ioooo'), 'AH');
});
console.log('\nformatHijriDate — composite format');
test('iMMMM iD, iYYYY', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM iD, iYYYY'), 'Ramadan 1, 1444');
});
test('iDD/iMM/iYYYY', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iDD/iMM/iYYYY'), '01/09/1444');
});
test('iEEEE, iD iMMMM iYYYY ioooo', () => {
assert.strictEqual(
formatHijriDate(ramadan1, 'iEEEE, iD iMMMM iYYYY ioooo'),
'Yawm al-Khamis, 1 Ramadan 1444 AH',
);
describe('formatHijriDate - invalid month', () => {
it('throws for month 0', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 0, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
it('throws for month 13', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 13, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
});
// ─── hDatesTable structure ──────────────────────────────────────────────────
console.log('\nhDatesTable structure');
test('first entry is 1318', () => assert.strictEqual(hDatesTable[0].hy, 1318));
test('last valid year is 1500 (index 182)', () => assert.strictEqual(hDatesTable[182].hy, 1500));
test('index 183 is sentinel year 1501 with dpm=0', () => {
assert.strictEqual(hDatesTable[183].hy, 1501);
assert.strictEqual(hDatesTable[183].dpm, 0);
});
test('table is sorted ascending by hy', () => {
for (let i = 1; i < hDatesTable.length; i++) {
assert(hDatesTable[i].hy > hDatesTable[i - 1].hy);
}
describe('hDatesTable structure', () => {
it('first entry is 1318', () => assert.strictEqual(hDatesTable[0].hy, 1318));
it('last valid year is 1500 (index 182)', () => assert.strictEqual(hDatesTable[182].hy, 1500));
it('index 183 is sentinel year 1501 with dpm=0', () => {
assert.strictEqual(hDatesTable[183].hy, 1501);
assert.strictEqual(hDatesTable[183].dpm, 0);
});
it('table is sorted ascending by hy', () => {
for (let i = 1; i < hDatesTable.length; i++) {
assert(hDatesTable[i].hy > hDatesTable[i - 1].hy);
}
});
});
// ─── FCNA calendar — toGregorian ────────────────────────────────────────────
//
// FCNA/ISNA criterion: conjunction before 12:00 UTC → month starts D+1; else D+2.
// New moon for 1 Ramadan 1446: Feb 28, 2025 ~00:45 UTC → before noon → March 1.
// New moon for 1 Shawwal 1446: March 29, 2025 ~10:57 UTC → before noon → March 30.
// Both match ISNA's publicly published 2025 Ramadan/Eid calendar.
// ─── FCNA calendar ──────────────────────────────────────────────────────────
console.log('\nFCNA — toGregorian known dates');
test('FCNA: 1 Ramadan 1446 = 2025-03-01 (ISNA 2025 calendar)', () => {
const d = toGregorian(1446, 9, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
describe('FCNA toGregorian', () => {
it('1 Ramadan 1446 = 2025-03-01 (ISNA 2025 calendar)', () => {
const d = toGregorian(1446, 9, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
});
it('1 Shawwal 1446 = 2025-03-30 (Eid al-Fitr per ISNA)', () => {
const d = toGregorian(1446, 10, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-30');
});
it('1 Ramadan 1445 = 2024-03-11 (ISNA 2024 calendar)', () => {
const d = toGregorian(1445, 9, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2024-03-11');
});
it('1 Muharram 1444 = ~2022-07-30', () => {
const d = toGregorian(1444, 1, 1, FCNA);
assert(d instanceof Date);
const iso = d.toISOString().slice(0, 10);
assert(
iso === '2022-07-29' || iso === '2022-07-30' || iso === '2022-07-31',
`Expected ~2022-07-30, got ${iso}`,
);
});
});
test('FCNA: 1 Shawwal 1446 = 2025-03-30 (Eid al-Fitr per ISNA)', () => {
const d = toGregorian(1446, 10, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-30');
describe('FCNA toHijri', () => {
it('2025-03-01 = 1 Ramadan 1446', () => {
const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
});
it('2025-03-30 = 1 Shawwal 1446', () => {
const h = toHijri(new Date(2025, 2, 30, 12), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 10, hd: 1 });
});
it('2024-03-11 = 1 Ramadan 1445', () => {
const h = toHijri(new Date(2024, 2, 11, 12), FCNA);
assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 });
});
});
test('FCNA: 1 Ramadan 1445 = 2024-03-11 (ISNA 2024 calendar)', () => {
// New moon: March 10, 2024 ~09:00 UTC → before noon → D+1 = March 11.
const d = toGregorian(1445, 9, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2024-03-11');
describe('FCNA round-trips', () => {
it('1446/9/1 toGregorian then toHijri', () => {
const greg = toGregorian(1446, 9, 1, FCNA);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
});
it('1446/10/15 toGregorian then toHijri', () => {
const greg = toGregorian(1446, 10, 15, FCNA);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 10, hd: 15 });
});
it('1318/1/1 toGregorian then toHijri', () => {
const greg = toGregorian(1318, 1, 1, FCNA);
assert(greg instanceof Date);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1318, hm: 1, hd: 1 });
});
it('out-of-range year 1200/6/1 round-trip', () => {
const greg = toGregorian(1200, 6, 1, FCNA);
assert(greg instanceof Date);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1200, hm: 6, hd: 1 });
});
});
test('FCNA: 1 Muharram 1444 = 2022-07-30', () => {
// New moon near July 28-29, 2022 → FCNA starts July 30 (same as UAQ for this month).
const d = toGregorian(1444, 1, 1, FCNA);
assert(d instanceof Date);
// Allow ±1 day: FCNA and UAQ can differ by 1 day on month boundaries.
const iso = d.toISOString().slice(0, 10);
assert(iso === '2022-07-29' || iso === '2022-07-30' || iso === '2022-07-31',
`Expected ~2022-07-30, got ${iso}`);
describe('FCNA isValidHijriDate', () => {
it('1446/9/1 = true', () => assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true));
it('month 0 = false', () => assert.strictEqual(isValidHijriDate(1446, 0, 1, FCNA), false));
it('month 13 = false', () => assert.strictEqual(isValidHijriDate(1446, 13, 1, FCNA), false));
it('day 0 = false', () => assert.strictEqual(isValidHijriDate(1446, 9, 0, FCNA), false));
it('day 31 = false', () => assert.strictEqual(isValidHijriDate(1446, 9, 31, FCNA), false));
it('year 1 AH = true', () => assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true));
it('year 1600 = true (beyond UAQ table)', () =>
assert.strictEqual(isValidHijriDate(1600, 1, 1, FCNA), true));
});
console.log('\nFCNA — toHijri known dates');
test('FCNA: 2025-03-01 = 1 Ramadan 1446', () => {
const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
describe('UAQ default regression', () => {
it('1 Ramadan 1446 = 2025-03-01 (UAQ matches FCNA here)', () => {
const d = toGregorian(1446, 9, 1);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
});
it('toHijri still works without options', () => {
const h = toHijri(new Date(2023, 2, 23, 12));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
});
it('isValidHijriDate still works without options', () => {
assert.strictEqual(isValidHijriDate(1444, 9, 1), true);
assert.strictEqual(isValidHijriDate(1501, 1, 1), false);
});
});
test('FCNA: 2025-03-30 = 1 Shawwal 1446', () => {
const h = toHijri(new Date(2025, 2, 30, 12), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 10, hd: 1 });
});
test('FCNA: 2024-03-11 = 1 Ramadan 1445', () => {
const h = toHijri(new Date(2024, 2, 11, 12), FCNA);
assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 });
});
console.log('\nFCNA — round-trip consistency');
test('FCNA round-trip: toGregorian → toHijri for 1446/9/1', () => {
const greg = toGregorian(1446, 9, 1, FCNA);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
});
test('FCNA round-trip: toGregorian → toHijri for 1446/10/15', () => {
const greg = toGregorian(1446, 10, 15, FCNA);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 10, hd: 15 });
});
test('FCNA round-trip: toGregorian → toHijri for 1318/1/1', () => {
const greg = toGregorian(1318, 1, 1, FCNA);
assert(greg instanceof Date);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1318, hm: 1, hd: 1 });
});
test('FCNA round-trip: toGregorian → toHijri for out-of-range year 1200/6/1', () => {
// Out of UAQ table range — uses mean k estimate + Meeus correction.
const greg = toGregorian(1200, 6, 1, FCNA);
assert(greg instanceof Date);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1200, hm: 6, hd: 1 });
});
console.log('\nFCNA — isValidHijriDate');
test('FCNA: isValidHijriDate(1446, 9, 1) = true', () => {
assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true);
});
test('FCNA: isValidHijriDate(1446, 0, 1) = false (month 0)', () => {
assert.strictEqual(isValidHijriDate(1446, 0, 1, FCNA), false);
});
test('FCNA: isValidHijriDate(1446, 13, 1) = false (month 13)', () => {
assert.strictEqual(isValidHijriDate(1446, 13, 1, FCNA), false);
});
test('FCNA: isValidHijriDate(1446, 9, 0) = false (day 0)', () => {
assert.strictEqual(isValidHijriDate(1446, 9, 0, FCNA), false);
});
test('FCNA: isValidHijriDate(1446, 9, 31) = false (day 31 always invalid)', () => {
assert.strictEqual(isValidHijriDate(1446, 9, 31, FCNA), false);
});
test('FCNA: isValidHijriDate(1, 1, 1) = true (year 1 AH supported)', () => {
// FCNA works for any year ≥ 1 AH, not limited to 13181500.
assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true);
});
test('FCNA: isValidHijriDate(1600, 1, 1) = true (beyond UAQ table)', () => {
assert.strictEqual(isValidHijriDate(1600, 1, 1, FCNA), true);
});
console.log('\nFCNA — UAQ default unchanged (regression)');
test('UAQ default: 1 Ramadan 1446 = 2025-03-01 (UAQ matches FCNA here)', () => {
const d = toGregorian(1446, 9, 1); // no options → UAQ
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
});
test('UAQ default: toHijri still works without options', () => {
const h = toHijri(new Date(2023, 2, 23, 12));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
});
test('UAQ default: isValidHijriDate still works without options', () => {
assert.strictEqual(isValidHijriDate(1444, 9, 1), true);
assert.strictEqual(isValidHijriDate(1501, 1, 1), false);
});
// ─── Summary ────────────────────────────────────────────────────────────────
const total = passed + failed;
console.log(`\n${total} tests total: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

View file

@ -8,10 +8,13 @@
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"outDir": "dist",
"rootDir": "src",
"forceConsistentCasingInFileNames": true,
"types": ["node"]
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}