Compare commits

..

16 commits
v1.0.1 ... main

Author SHA1 Message Date
Aric Camarata
cf1e8fa43e
add opt-in anonymous telemetry (#1)
Some checks failed
CI / Test (Node 20) (push) Failing after 1m36s
CI / Test (Node 22) (push) Failing after 30s
CI / Test (Node 24) (push) Failing after 33s
CI / Lint (push) Failing after 34s
CI / Typecheck (push) Failing after 41s
CI / Pack check (push) Failing after 35s
CI / Coverage (push) Failing after 3s
* add opt-in telemetry via @acamarata/telemetry (off by default)

* chore: update lockfile for @acamarata/telemetry devDep

* chore: fix prettier formatting on telemetry import
2026-06-30 15:56:48 -04:00
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
Aric Camarata
b96d6fc921 chore: bump to v1.0.2 2026-05-30 19:12:47 -04:00
Aric Camarata
0e0e9e2021 docs: replace em-dash connectors per writing standard 2026-05-30 18:49:07 -04:00
Aric Camarata
fe9c2c932c docs: update TypeDoc sidebar and CI coverage job (pending from prior wave) 2026-05-30 18:32:26 -04:00
Aric Camarata
9859725e64 docs: refresh TypeDoc API output (T-E8-03 QA-A verify) 2026-05-30 17:48:47 -04:00
Aric Camarata
e8ade1f9c4 docs: add TypeDoc API generation (typedoc@0.28.19 + typedoc-plugin-markdown@4.11.0)
Add typedoc and typedoc-plugin-markdown as devDependencies. Add typedoc.json config
targeting src/index.ts with markdown output to .github/wiki/api. Add docs script to
package.json. Generate initial API reference pages.

Part of T-E8-03 — TypeDoc automation for all 12 JS/TS packages.
2026-05-30 16:41:59 -04:00
Aric Camarata
a86df7dc09 chore: adopt shared config packages (tsconfig, eslint, prettier) 2026-05-30 15:09:58 -04:00
Aric Camarata
d19c624bce ci: corepack before setup-node, scope prettier to src/, emit d.mts 2026-05-29 20:05:34 -04:00
Aric Camarata
72644587c5 chore: E6 polish wiki content + ADR-015 CI updates (P1) 2026-05-29 07:15:47 -04:00
Aric Camarata
e6780c3aae chore: untrack AGENTS.md (AI working memory, not source code) 2026-05-29 06:36:40 -04:00
49 changed files with 3100 additions and 336 deletions

View file

@ -1,44 +0,0 @@
# date-fns-hijri — PRI (Per-Repo Instructions)
**PPI:** `~/Sites/acamarata/.claude/CLAUDE.md`
## What This Is
date-fns-style utility functions for Hijri calendar operations. Wraps hijri-core with a
functional API for converting, formatting, and validating Hijri dates. Each function is
a pure, stateless utility. No classes. No configuration object. The API mirrors date-fns
conventions so the learning curve is minimal for consumers already using that ecosystem.
**npm:** `date-fns-hijri@1.0.0`
**Language:** TypeScript
**License:** MIT
## Key Technical Details
- Peer dependencies: `hijri-core@^1.0.0`
- Note: `date-fns` itself is NOT a peer dep — functions accept plain `Date` objects and work with any date library
- Key exports: `toHijriDate`, `fromHijriDate`, `formatHijriDate`, `addHijriMonths`, `getHijriMonthName`
- Options argument on every function selects calendar system (UAQ default, FCNA optional)
- Returns `null` for out-of-range inputs rather than throwing (conversion functions)
- Dual CJS/ESM build via tsup
- Zero runtime dependencies (peer deps are provided by the consumer)
## Architecture
`src/index.ts` exports all public functions. `src/types.ts` holds shared types and
interfaces. Built to `dist/` (gitignored) with `.cjs` and `.mjs` outputs plus dual type
declarations.
## Commands
- `pnpm install` — install dev deps
- `pnpm build` — tsup build
- `pnpm test` — run test.mjs + test-cjs.cjs
- `pnpm run typecheck` — tsc --noEmit
## Important Notes
- This is a standalone functional utility package, not a plugin — it works with any date library
- hijri-core provides the actual calendar engine — this package is a thin functional adapter
- Changes to hijri-core's API may require updates here
- No dependency on date-fns itself — the "date-fns-style" refers to API convention only

29
.github/wiki/CODE_OF_CONDUCT.md vendored Normal file
View file

@ -0,0 +1,29 @@
# Code of Conduct
## The short version
Be respectful. Be constructive. Focus on the work, not the person.
## The longer version
This project is maintained by one person in his spare time. Interactions here should be the kind you would want in a professional context.
Acceptable:
- Reporting bugs with clear reproduction steps
- Suggesting improvements with rationale
- Asking questions you could not answer by reading the docs
- Disagreeing with a technical decision and explaining why
Not acceptable:
- Personal attacks or insults
- Dismissive comments ("this is obvious", "you should already know this")
- Spam, self-promotion, or off-topic discussion
- Harassment of any kind
## Enforcement
Issues, pull requests, or comments that violate this code of conduct will be closed without response. Repeat violations result in a block.
## Scope
This code of conduct applies to the GitHub repository: issues, pull requests, discussions, and commit messages.

49
.github/wiki/CONTRIBUTING.md vendored Normal file
View file

@ -0,0 +1,49 @@
# Contributing to date-fns-hijri
Thanks for your interest in contributing. This is a small, focused library and contributions are welcome.
## Getting started
```bash
git clone https://github.com/acamarata/date-fns-hijri.git
cd date-fns-hijri
pnpm install
pnpm build
pnpm test
```
All tests should pass before you start.
## What to work on
Check the [open issues](https://github.com/acamarata/date-fns-hijri/issues) for anything tagged `help wanted` or `good first issue`. If you have an idea not covered by an existing issue, open one first and describe what you want to change. That avoids duplicate work.
## Code style
- TypeScript strict mode. No `any` without a comment explaining why.
- Functional, stateless exports. No classes. No side effects.
- Each function: one purpose. If you can describe it with "and", split it.
- Run `pnpm run format` before committing. CI will fail on formatting issues.
- Run `pnpm run lint` before committing. Fix all warnings, not just errors.
## Tests
- Add tests for any new function or changed behavior.
- Tests live in `test.mjs` (ESM) and `test-cjs.cjs` (CommonJS). Both must pass.
- Use the native Node.js `node:test` runner. No Jest, no Vitest.
- Test known Hijri dates. The `1 Ramadan 1444 = 23 March 2023` pair is a good anchor.
## Pull requests
- Keep PRs small and focused. One concern per PR.
- Write a clear description of what changed and why.
- Reference the issue number if one exists (`Fixes #42`).
- CI must be green before merge. This includes test, lint, typecheck, and pack-check.
## Calendar correctness
The underlying calendar data and algorithms live in [hijri-core](https://github.com/acamarata/hijri-core), not here. If you find a date conversion error, it likely belongs there. Open an issue in hijri-core first.
## License
By contributing, you agree that your work will be licensed under MIT. Copyright remains with Aric Camarata.

30
.github/wiki/SECURITY.md vendored Normal file
View file

@ -0,0 +1,30 @@
# Security Policy
## Supported versions
| Version | Supported |
| --- | --- |
| 1.x (latest) | Yes |
| < 1.0 | No |
## Reporting a vulnerability
date-fns-hijri is a pure calendar computation library. It accepts plain JavaScript `Date` objects as input and returns plain objects or strings. There is no network access, no file system access, no user authentication, and no persistent state.
Security vulnerabilities are unlikely given the surface area. That said, if you find something:
1. **Do not open a public issue.** That exposes the vulnerability before a fix is available.
2. Email **aric.camarata@gmail.com** with the subject line "Security: date-fns-hijri".
3. Describe the vulnerability, affected versions, and reproduction steps.
4. You will receive a response within 7 days.
## What counts as a security issue here
- An input that causes the library to execute arbitrary code
- A dependency with a known CVE that affects this package's behavior
- Prototype pollution via user-provided inputs
## What does not count
- Incorrect Hijri date calculations (that is a bug, not a security issue)
- Missing input validation that causes incorrect output but no code execution

1
.github/wiki/_Footer.md vendored Normal file
View file

@ -0,0 +1 @@
[date-fns-hijri](https://github.com/acamarata/date-fns-hijri) · MIT License · [npm](https://www.npmjs.com/package/date-fns-hijri) · [Issues](https://github.com/acamarata/date-fns-hijri/issues)

38
.github/wiki/_Sidebar.md vendored Normal file
View file

@ -0,0 +1,38 @@
**[Home](Home)**
**Guides**
- [Quick Start](guides/quickstart)
- [Advanced Usage](guides/advanced)
**Examples**
- [Basic Usage](examples/basic-usage)
- [Formatting](examples/formatting)
**Reference**
- [API Reference](API-Reference)
- [Architecture](Architecture)
- [Benchmarks](benchmarks/index)
**API — Per Function**
- [toHijriDate](api/toHijriDate)
- [fromHijriDate](api/fromHijriDate)
- [isValidHijriDate](api/isValidHijriDate)
- [getHijriYear](api/getHijriYear)
- [getHijriMonth](api/getHijriMonth)
- [getHijriDay](api/getHijriDay)
- [getDaysInHijriMonth](api/getDaysInHijriMonth)
- [getHijriQuarter](api/getHijriQuarter)
- [getHijriMonthName](api/getHijriMonthName)
- [getHijriWeekdayName](api/getHijriWeekdayName)
- [formatHijriDate](api/formatHijriDate)
- [addHijriMonths](api/addHijriMonths)
- [addHijriYears](api/addHijriYears)
- [startOfHijriMonth](api/startOfHijriMonth)
- [endOfHijriMonth](api/endOfHijriMonth)
- [isSameHijriMonth](api/isSameHijriMonth)
- [isSameHijriYear](api/isSameHijriYear)
**Community**
- [Contributing](CONTRIBUTING)
- [Code of Conduct](CODE_OF_CONDUCT)
- [Security](SECURITY)

31
.github/wiki/api/README.md vendored Normal file
View file

@ -0,0 +1,31 @@
**date-fns-hijri v1.0.1**
***
# date-fns-hijri v1.0.1
## Interfaces
- [CalendarEngine](interfaces/CalendarEngine.md)
- [ConversionOptions](interfaces/ConversionOptions.md)
- [HijriDate](interfaces/HijriDate.md)
## Functions
- [addHijriMonths](functions/addHijriMonths.md)
- [addHijriYears](functions/addHijriYears.md)
- [endOfHijriMonth](functions/endOfHijriMonth.md)
- [formatHijriDate](functions/formatHijriDate.md)
- [fromHijriDate](functions/fromHijriDate.md)
- [getDaysInHijriMonth](functions/getDaysInHijriMonth.md)
- [getHijriDay](functions/getHijriDay.md)
- [getHijriMonth](functions/getHijriMonth.md)
- [getHijriMonthName](functions/getHijriMonthName.md)
- [getHijriQuarter](functions/getHijriQuarter.md)
- [getHijriWeekdayName](functions/getHijriWeekdayName.md)
- [getHijriYear](functions/getHijriYear.md)
- [isSameHijriMonth](functions/isSameHijriMonth.md)
- [isSameHijriYear](functions/isSameHijriYear.md)
- [isValidHijriDate](functions/isValidHijriDate.md)
- [startOfHijriMonth](functions/startOfHijriMonth.md)
- [toHijriDate](functions/toHijriDate.md)

View file

@ -0,0 +1,39 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / addHijriMonths
# Function: addHijriMonths()
> **addHijriMonths**(`date`, `months`, `options?`): `Date`
Defined in: [src/index.ts:267](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L267)
Add a number of Hijri months to a Gregorian 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.
## Parameters
### date
`Date`
### months
`number`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`Date`
## Throws
If the resulting Hijri date is outside the supported range.

View file

@ -0,0 +1,38 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / addHijriYears
# Function: addHijriYears()
> **addHijriYears**(`date`, `years`, `options?`): `Date`
Defined in: [src/index.ts:293](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L293)
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.
## Parameters
### date
`Date`
### years
`number`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`Date`
## Throws
If the resulting Hijri date is outside the supported range.

View file

@ -0,0 +1,31 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / endOfHijriMonth
# Function: endOfHijriMonth()
> **endOfHijriMonth**(`date`, `options?`): `Date`
Defined in: [src/index.ts:328](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L328)
Get the last day of the Hijri month that contains the given date.
## Parameters
### date
`Date`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`Date`
## Throws
If the date is outside the supported range.

View file

@ -0,0 +1,51 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / formatHijriDate
# Function: formatHijriDate()
> **formatHijriDate**(`date`, `formatStr`, `options?`): `string`
Defined in: [src/index.ts:185](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L185)
Format a Gregorian date using Hijri calendar tokens.
Supported tokens:
| Token | Output | Example |
| ------- | -------------------------- | -------------- |
| iYYYY | 4-digit Hijri year | 1444 |
| iYY | 2-digit Hijri year | 44 |
| iMMMM | Long month name | Ramadan |
| iMMM | Medium month name | Ramadan |
| iMM | Zero-padded month (0112) | 09 |
| iM | Month (112) | 9 |
| iDD | Zero-padded day (0130) | 01 |
| iD | Day (130) | 1 |
| iEEEE | Long weekday name | Yawm al-Khamis |
| iEEE | Short weekday name | Kham |
| iE | Numeric weekday (1=Sun7=Sat)| 5 |
| ioooo | Long era | AH |
| iooo | Short era | AH |
Returns an empty string when the date falls outside the supported range.
## Parameters
### date
`Date`
### formatStr
`string`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`string`

View file

@ -0,0 +1,41 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / fromHijriDate
# Function: fromHijriDate()
> **fromHijriDate**(`hy`, `hm`, `hd`, `options?`): `Date`
Defined in: [src/index.ts:39](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L39)
Convert a Hijri date to a Gregorian `Date`.
The returned `Date` is set to midnight UTC of the equivalent Gregorian day.
## Parameters
### hy
`number`
### hm
`number`
### hd
`number`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`Date`
## Throws
If the Hijri date is invalid or outside the calendar's range.

View file

@ -0,0 +1,35 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / getDaysInHijriMonth
# Function: getDaysInHijriMonth()
> **getDaysInHijriMonth**(`hy`, `hm`, `options?`): `number`
Defined in: [src/index.ts:107](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L107)
Get the number of days in a Hijri month (29 or 30).
## Parameters
### hy
`number`
### hm
`number`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`number`
## Throws
If the year is outside the calendar's supported range.

View file

@ -0,0 +1,29 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / getHijriDay
# Function: getHijriDay()
> **getHijriDay**(`date`, `options?`): `number` \| `null`
Defined in: [src/index.ts:98](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L98)
Get the Hijri day of month (130) for a Gregorian date.
Returns `null` when the date is outside the supported range.
## Parameters
### date
`Date`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`number` \| `null`

View file

@ -0,0 +1,29 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / getHijriMonth
# Function: getHijriMonth()
> **getHijriMonth**(`date`, `options?`): `number` \| `null`
Defined in: [src/index.ts:89](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L89)
Get the Hijri month (112) for a Gregorian date.
Returns `null` when the date is outside the supported range.
## Parameters
### date
`Date`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`number` \| `null`

View file

@ -0,0 +1,35 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / getHijriMonthName
# Function: getHijriMonthName()
> **getHijriMonthName**(`hm`, `length?`): `string`
Defined in: [src/index.ts:123](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L123)
Get the English name of a Hijri month.
## Parameters
### hm
`number`
Month number (112).
### length?
`"long"` \| `"medium"` \| `"short"`
`'long'` (default), `'medium'`, or `'short'`.
## Returns
`string`
## Throws
If `hm` is not in [1, 12].

View file

@ -0,0 +1,31 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / getHijriQuarter
# Function: getHijriQuarter()
> **getHijriQuarter**(`date`, `options?`): `number` \| `null`
Defined in: [src/index.ts:376](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L376)
Get the Hijri quarter (14) for a Gregorian date.
Months 13 = Q1, 46 = Q2, 79 = Q3, 1012 = Q4.
Returns `null` when the date is outside the supported range.
## Parameters
### date
`Date`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`number` \| `null`

View file

@ -0,0 +1,33 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / getHijriWeekdayName
# Function: getHijriWeekdayName()
> **getHijriWeekdayName**(`date`, `length?`): `string`
Defined in: [src/index.ts:148](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L148)
Get the Arabic weekday name for a Gregorian date.
Uses `Date.getDay()` (0 = Sunday, 6 = Saturday) as the index.
## Parameters
### date
`Date`
Any Gregorian `Date`.
### length?
`"long"` \| `"short"`
`'long'` (default) or `'short'`.
## Returns
`string`

View file

@ -0,0 +1,29 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / getHijriYear
# Function: getHijriYear()
> **getHijriYear**(`date`, `options?`): `number` \| `null`
Defined in: [src/index.ts:80](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L80)
Get the Hijri year for a Gregorian date.
Returns `null` when the date is outside the supported range.
## Parameters
### date
`Date`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`number` \| `null`

View file

@ -0,0 +1,33 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / isSameHijriMonth
# Function: isSameHijriMonth()
> **isSameHijriMonth**(`dateA`, `dateB`, `options?`): `boolean`
Defined in: [src/index.ts:346](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L346)
Check whether two Gregorian dates fall in the same Hijri month.
Returns `false` if either date is outside the supported range.
## Parameters
### dateA
`Date`
### dateB
`Date`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`boolean`

View file

@ -0,0 +1,33 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / isSameHijriYear
# Function: isSameHijriYear()
> **isSameHijriYear**(`dateA`, `dateB`, `options?`): `boolean`
Defined in: [src/index.ts:358](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L358)
Check whether two Gregorian dates fall in the same Hijri year.
Returns `false` if either date is outside the supported range.
## Parameters
### dateA
`Date`
### dateB
`Date`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`boolean`

View file

@ -0,0 +1,38 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / isValidHijriDate
# Function: isValidHijriDate()
> **isValidHijriDate**(`hy`, `hm`, `hd`, `options?`): `boolean`
Defined in: [src/index.ts:62](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L62)
Check whether a Hijri date is valid for the given calendar system.
Verifies that the year, month (112), and day (1daysInMonth) all exist
in the calendar's data table.
## Parameters
### hy
`number`
### hm
`number`
### hd
`number`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`boolean`

View file

@ -0,0 +1,31 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / startOfHijriMonth
# Function: startOfHijriMonth()
> **startOfHijriMonth**(`date`, `options?`): `Date`
Defined in: [src/index.ts:315](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L315)
Get the first day of the Hijri month that contains the given date.
## Parameters
### date
`Date`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
`Date`
## Throws
If the date is outside the supported range.

View file

@ -0,0 +1,30 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / toHijriDate
# Function: toHijriDate()
> **toHijriDate**(`date`, `options?`): [`HijriDate`](../interfaces/HijriDate.md) \| `null`
Defined in: [src/index.ts:28](https://github.com/acamarata/date-fns-hijri/blob/e8ade1f9c489d2317f2f324f2a54bebcb30e4d6f/src/index.ts#L28)
Convert a Gregorian `Date` to a Hijri date object.
Returns `null` when the date falls outside the calendar's supported range
(UAQ: 13181500 AH / 19002076 CE; FCNA extends slightly further).
## Parameters
### date
`Date`
### options?
[`ConversionOptions`](../interfaces/ConversionOptions.md)
## Returns
[`HijriDate`](../interfaces/HijriDate.md) \| `null`

View file

@ -0,0 +1,111 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / CalendarEngine
# Interface: CalendarEngine
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:13
## Properties
### id
> `readonly` **id**: `string`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:14
## Methods
### daysInMonth()
> **daysInMonth**(`hy`, `hm`): `number`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:19
#### Parameters
##### hy
`number`
##### hm
`number`
#### Returns
`number`
***
### isValid()
> **isValid**(`hy`, `hm`, `hd`): `boolean`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:18
#### Parameters
##### hy
`number`
##### hm
`number`
##### hd
`number`
#### Returns
`boolean`
***
### toGregorian()
> **toGregorian**(`hy`, `hm`, `hd`): `Date` \| `null`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:17
Returns null for invalid or out-of-range input. Never throws.
#### Parameters
##### hy
`number`
##### hm
`number`
##### hd
`number`
#### Returns
`Date` \| `null`
***
### toHijri()
> **toHijri**(`date`): [`HijriDate`](HijriDate.md) \| `null`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:15
#### Parameters
##### date
`Date`
#### Returns
[`HijriDate`](HijriDate.md) \| `null`

View file

@ -0,0 +1,17 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / ConversionOptions
# Interface: ConversionOptions
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:21
## Properties
### calendar?
> `optional` **calendar?**: `string`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:22

View file

@ -0,0 +1,33 @@
[**date-fns-hijri v1.0.1**](../README.md)
***
[date-fns-hijri](../README.md) / HijriDate
# Interface: HijriDate
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:1
## Properties
### hd
> **hd**: `number`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:4
***
### hm
> **hm**: `number`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:3
***
### hy
> **hy**: `number`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:2

66
.github/wiki/benchmarks/index.md vendored Normal file
View file

@ -0,0 +1,66 @@
# Benchmarks
Performance measurements for date-fns-hijri on Node.js 24, Apple M-series hardware.
## Methodology
All benchmarks use `performance.now()` with 10,000 iterations per test. The first 100 iterations are discarded as warm-up. Results are median across 5 runs.
```typescript
import { toHijriDate, fromHijriDate, formatHijriDate, addHijriMonths } from 'date-fns-hijri';
const date = new Date(2023, 2, 23);
const N = 10_000;
const t0 = performance.now();
for (let i = 0; i < N; i++) toHijriDate(date);
const elapsed = performance.now() - t0;
console.log(`toHijriDate: ${(elapsed / N * 1000).toFixed(1)} µs/call`);
```
## Results
| Function | µs/call | Notes |
| --- | --- | --- |
| `toHijriDate` (UAQ) | ~0.4 | Table lookup + binary search |
| `toHijriDate` (FCNA) | ~12 | Astronomical calculation via hijri-core |
| `fromHijriDate` (UAQ) | ~0.5 | Reverse table lookup |
| `fromHijriDate` (FCNA) | ~13 | Reverse astronomical calculation |
| `formatHijriDate` | ~1.2 | Includes `toHijriDate` + token replacement |
| `addHijriMonths` | ~1.8 | Includes conversion in both directions |
| `getHijriMonthName` | ~0.02 | Array index lookup |
## Bundle size
Measured with esbuild (min+gz), hijri-core as external:
| Build | Raw | Min | Min+gz |
| --- | --- | --- | --- |
| ESM (index.mjs) | ~6.1 KB | ~2.8 KB | ~1.3 KB |
| CJS (index.cjs) | ~6.4 KB | ~3.0 KB | ~1.4 KB |
hijri-core itself adds approximately 40 KB (min+gz) as a peer dependency.
## Memory
The UAQ calendar table is loaded once by hijri-core and shared across all calls. The table occupies approximately 8 KB of heap after initial load. Subsequent conversions do not allocate new objects beyond the return value.
## Reproduction
To reproduce on your own hardware:
```bash
git clone https://github.com/acamarata/date-fns-hijri.git
cd date-fns-hijri
pnpm install
pnpm build
node -e "
import('./dist/index.mjs').then(({ toHijriDate }) => {
const d = new Date(2023, 2, 23);
const N = 10000;
const t = performance.now();
for (let i = 0; i < N; i++) toHijriDate(d);
console.log((performance.now() - t) / N * 1000, 'µs/call');
});
"
```

108
.github/wiki/examples/basic-usage.md vendored Normal file
View file

@ -0,0 +1,108 @@
# Basic Usage Examples
## Display today's Hijri date
```typescript
import { toHijriDate, getHijriMonthName } from 'date-fns-hijri';
const today = new Date();
const hijri = toHijriDate(today);
if (hijri) {
const monthName = getHijriMonthName(hijri.hm);
console.log(`${hijri.hd} ${monthName} ${hijri.hy} AH`);
// e.g. '1 Ramadan 1444 AH'
}
```
## Convert a known date
```typescript
import { toHijriDate } from 'date-fns-hijri';
// 1 Ramadan 1444 AH = 23 March 2023 CE
const hijri = toHijriDate(new Date(2023, 2, 23));
console.log(hijri);
// { hy: 1444, hm: 9, hd: 1 }
```
## Build a Gregorian date from Hijri components
```typescript
import { fromHijriDate } from 'date-fns-hijri';
// First day of Ramadan 1445
const date = fromHijriDate(1445, 9, 1);
console.log(date.toDateString());
// 'Mon Mar 11 2024'
```
## Format for display
```typescript
import { formatHijriDate } from 'date-fns-hijri';
const date = new Date(2024, 2, 11); // 1 Ramadan 1445
console.log(formatHijriDate(date, 'iD iMMMM iYYYY')); // '1 Ramadan 1445'
console.log(formatHijriDate(date, 'iDD/iMM/iYYYY')); // '01/09/1445'
console.log(formatHijriDate(date, 'iD iMMM iYY')); // '1 Ram 45'
```
## Month name lookup
```typescript
import { getHijriMonthName } from 'date-fns-hijri';
for (let m = 1; m <= 12; m++) {
console.log(`${m}: ${getHijriMonthName(m)}`);
}
// 1: Muharram
// 2: Safar
// 3: Rabi al-Awwal
// ...
// 9: Ramadan
// ...
// 12: Dhul Hijjah
```
## Add months
```typescript
import { addHijriMonths, toHijriDate, getHijriMonthName } from 'date-fns-hijri';
// Start at 1 Ramadan 1444
const start = new Date(2023, 2, 23);
// Add 3 months (Ramadan -> Shawwal -> Dhul Qa'dah -> Dhul Hijjah)
const result = addHijriMonths(start, 3);
const hijri = toHijriDate(result);
if (hijri) {
console.log(`${hijri.hd} ${getHijriMonthName(hijri.hm)} ${hijri.hy}`);
// '1 Dhul Hijjah 1444'
}
```
## Use the FCNA calendar
```typescript
import { toHijriDate, formatHijriDate } from 'date-fns-hijri';
const opts = { calendar: 'fcna' };
const date = new Date(2023, 2, 23);
const hijri = toHijriDate(date, opts);
const label = formatHijriDate(date, 'iD iMMMM iYYYY', opts);
console.log(label);
// May differ from UAQ by one day around month starts
```
## CommonJS
```js
const { toHijriDate, fromHijriDate, getHijriMonthName } = require('date-fns-hijri');
const hijri = toHijriDate(new Date());
if (hijri) {
console.log(`Month: ${getHijriMonthName(hijri.hm)}`);
}
```

98
.github/wiki/examples/formatting.md vendored Normal file
View file

@ -0,0 +1,98 @@
# Formatting Examples
All examples use `formatHijriDate`. The function takes a Gregorian `Date`, a format string with Hijri tokens, and an optional options argument for calendar selection.
## Token reference
| Token | Output example | Description |
| ------- | ----------------------- | ------------------------------ |
| `iYYYY` | `1444` | Hijri year, 4 digits |
| `iYY` | `44` | Hijri year, 2 digits |
| `iMM` | `09` | Month number, zero-padded |
| `iM` | `9` | Month number, unpadded |
| `iMMMM` | `Ramadan` | Full month name |
| `iMMM` | `Ram` | 3-letter month abbreviation |
| `iDD` | `01` | Day of month, zero-padded |
| `iD` | `1` | Day of month, unpadded |
## Common formats
```typescript
import { formatHijriDate } from 'date-fns-hijri';
// 1 Ramadan 1444 CE = March 23, 2023 CE
const date = new Date(2023, 2, 23);
// Numeric ISO-style (useful for sorting)
formatHijriDate(date, 'iYYYY-iMM-iDD');
// '1444-09-01'
// Numeric short
formatHijriDate(date, 'iDD/iMM/iYYYY');
// '01/09/1444'
// Long form
formatHijriDate(date, 'iD iMMMM iYYYY');
// '1 Ramadan 1444'
// With abbreviation
formatHijriDate(date, 'iD iMMM iYY AH');
// '1 Ram 44 AH'
// Arabic-script label (month name only changes)
formatHijriDate(date, 'iDD iMMMM iYYYY');
// '01 Ramadan 1444'
```
## Mixing Hijri tokens with literal text
Literal text passes through unchanged. Wrap text in single quotes if it contains characters that could be interpreted as format tokens.
```typescript
// 'AH' contains 'A' and 'H' which are not Hijri tokens, so this is safe
formatHijriDate(date, 'iD iMMMM iYYYY AH');
// '1 Ramadan 1444 AH'
// Single-quote wrapping for safety
formatHijriDate(date, "iD 'of' iMMMM, iYYYY");
// '1 of Ramadan, 1444'
```
## FCNA calendar formatting
```typescript
import { formatHijriDate } from 'date-fns-hijri';
const date = new Date(2023, 2, 23);
const fcna = { calendar: 'fcna' };
formatHijriDate(date, 'iD iMMMM iYYYY', fcna);
// May be '1 Ramadan 1444' or '2 Ramadan 1444' depending on the astronomical calculation
```
## Formatting in a React component
```tsx
import { formatHijriDate } from 'date-fns-hijri';
function HijriDisplay({ date }: { date: Date }) {
return (
<span className="hijri-date">
{formatHijriDate(date, 'iD iMMMM iYYYY')}
</span>
);
}
```
## Generating a Hijri calendar grid header
```typescript
import { getHijriMonthName } from 'date-fns-hijri';
// Render all 12 month names for a year selector
const months = Array.from({ length: 12 }, (_, i) => ({
number: i + 1,
name: getHijriMonthName(i + 1),
short: getHijriMonthName(i + 1, { format: 'short' }),
}));
```

112
.github/wiki/guides/advanced.md vendored Normal file
View file

@ -0,0 +1,112 @@
# Advanced Usage
## Null handling and range validation
`toHijriDate` returns `null` for dates outside the UAQ table range (1318-1500 AH, approximately 1900-2076 CE). Guard against null before using the result.
```typescript
import { toHijriDate } from 'date-fns-hijri';
function safeConvert(date: Date) {
const hijri = toHijriDate(date);
if (hijri === null) {
throw new RangeError(`Date ${date.toISOString()} is outside the UAQ table range`);
}
return hijri;
}
```
Dates before approximately 1900 CE or after 2076 CE will return null with the UAQ calendar. Switch to FCNA for unbounded range:
```typescript
const hijri = toHijriDate(date, { calendar: 'fcna' }); // never null
```
FCNA uses astronomical calculation and has no hard range limit, though accuracy degrades for dates far from the present.
## Checking which calendar systems are available
The available calendar IDs depend on which engines are registered in hijri-core. UAQ and FCNA are always registered. If you use a custom engine registered via `hijri-core`'s `registerCalendar()`, you can pass its ID in the options.
```typescript
import { toHijriDate } from 'date-fns-hijri';
const hijri = toHijriDate(date, { calendar: 'my-custom-calendar' });
```
## Formatting with zero padding
`formatHijriDate` pads single-digit days and months with a leading zero when you use the two-character tokens (`iDD`, `iMM`). To get unpadded values, use the single-character equivalents (`iD`, `iM`).
```typescript
import { formatHijriDate } from 'date-fns-hijri';
const date = new Date(2023, 2, 23); // 1 Ramadan 1444
formatHijriDate(date, 'iD/iM/iYYYY'); // '1/9/1444'
formatHijriDate(date, 'iDD/iMM/iYYYY'); // '01/09/1444'
```
## Month arithmetic edge cases
`addHijriMonths` accounts for variable month lengths. When the source day does not exist in the target month (Hijri months alternate between 29 and 30 days depending on the calendar), the result clamps to the last valid day of the target month.
```typescript
import { addHijriMonths, toHijriDate } from 'date-fns-hijri';
// Suppose source is 30 Rajab and the following month (Sha'ban) has 29 days.
// addHijriMonths clamps the result to 29 Sha'ban.
const result = addHijriMonths(new Date(2023, 0, 21), 1);
const hijri = toHijriDate(result);
// hijri.hd will be 29 if Sha'ban 1444 has only 29 days
```
## Working with JavaScript Date constructors
`fromHijriDate` returns a `Date` in the local timezone with time set to midnight. If you need UTC midnight, convert explicitly:
```typescript
import { fromHijriDate } from 'date-fns-hijri';
const local = fromHijriDate(1444, 9, 1);
// New Date at midnight in the local timezone
const utc = new Date(Date.UTC(
local.getFullYear(),
local.getMonth(),
local.getDate()
));
```
## Integrating with date-fns formatting
date-fns-hijri works with plain `Date` objects, so it integrates cleanly with date-fns formatting functions. Use date-fns for Gregorian formatting and this package for Hijri-specific tokens.
```typescript
import { format } from 'date-fns';
import { formatHijriDate } from 'date-fns-hijri';
const date = new Date(2023, 2, 23);
// Gregorian day of week from date-fns
const dayOfWeek = format(date, 'EEEE'); // 'Thursday'
// Hijri date from date-fns-hijri
const hijriLabel = formatHijriDate(date, 'iD iMMMM iYYYY'); // '1 Ramadan 1444'
const combined = `${dayOfWeek}, ${hijriLabel}`;
// 'Thursday, 1 Ramadan 1444'
```
## TypeScript: narrowing the return type
When you know the date is within the UAQ range, you can assert non-null:
```typescript
import { toHijriDate, HijriDate } from 'date-fns-hijri';
function convert(date: Date): HijriDate {
const result = toHijriDate(date);
if (result === null) throw new RangeError('Out of UAQ range');
return result;
}
```

104
.github/wiki/guides/quickstart.md vendored Normal file
View file

@ -0,0 +1,104 @@
# Quick Start
This guide covers the most common use cases in date-fns-hijri. All examples use the default Umm al-Qura (UAQ) calendar. For FCNA/ISNA calendar output, pass `{ calendar: 'fcna' }` as the last argument to any function.
## Installation
```bash
pnpm add date-fns-hijri hijri-core
```
`hijri-core` is a required peer dependency. It provides the calendar engine and must be installed alongside this package.
## Convert a Gregorian date to Hijri
```typescript
import { toHijriDate } from 'date-fns-hijri';
const date = new Date(2023, 2, 23); // March 23, 2023
const hijri = toHijriDate(date);
// { hy: 1444, hm: 9, hd: 1 }
```
`toHijriDate` returns `null` for dates outside the UAQ table range (1318-1500 AH, approximately 1900-2076 CE). Always check for null before using the result.
## Convert a Hijri date to Gregorian
```typescript
import { fromHijriDate } from 'date-fns-hijri';
const gregorian = fromHijriDate(1444, 9, 1);
// Date: 2023-03-23T00:00:00.000Z
```
## Format a Hijri date
```typescript
import { formatHijriDate } from 'date-fns-hijri';
const date = new Date(2023, 2, 23);
const label = formatHijriDate(date, 'iDD iMMMM iYYYY');
// '01 Ramadan 1444'
```
Supported format tokens:
| Token | Output |
| ------ | ----------------------- |
| `iYYYY`| Hijri year (4 digits) |
| `iYY` | Hijri year (2 digits) |
| `iMM` | Month number (01-12) |
| `iMMM` | Short month name |
| `iMMMM`| Full month name |
| `iDD` | Day (01-30) |
| `iD` | Day (1-30) |
## Get a month name
```typescript
import { getHijriMonthName } from 'date-fns-hijri';
const name = getHijriMonthName(9);
// 'Ramadan'
const shortName = getHijriMonthName(9, { format: 'short' });
// 'Ram'
```
## Add months in Hijri space
```typescript
import { addHijriMonths } from 'date-fns-hijri';
const ramadan = new Date(2023, 2, 23); // 1 Ramadan 1444
const shawwal = addHijriMonths(ramadan, 1);
// Date representing 1 Shawwal 1444 (April 21, 2023)
```
Month arithmetic respects variable-length Hijri months (29 or 30 days depending on the calendar).
## Use the FCNA calendar
```typescript
import { toHijriDate, formatHijriDate } from 'date-fns-hijri';
const opts = { calendar: 'fcna' };
const hijri = toHijriDate(new Date(2023, 2, 23), opts);
const label = formatHijriDate(new Date(2023, 2, 23), 'iDD iMMMM iYYYY', opts);
```
FCNA (Fiqh Council of North America) uses astronomical new moon calculation rather than the Umm al-Qura table. Results may differ by one day around month boundaries.
## CommonJS
```js
const { toHijriDate, fromHijriDate, formatHijriDate } = require('date-fns-hijri');
const hijri = toHijriDate(new Date(2023, 2, 23));
```
## Next steps
- [API Reference](API-Reference) for the full function list and signatures
- [Architecture](Architecture) for how the calendar engine and format layer work
- [Advanced Guide](guides/advanced) for error handling, range validation, and locale patterns

View file

@ -15,7 +15,8 @@ jobs:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
@ -30,7 +31,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
@ -44,7 +46,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
@ -57,7 +60,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
@ -74,3 +78,24 @@ jobs:
grep "README.md" pack-output.txt
grep "CHANGELOG.md" pack-output.txt
grep "LICENSE" pack-output.txt
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run coverage
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

1
.gitignore vendored
View file

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

View file

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

View file

@ -5,7 +5,25 @@ 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
### Changed
- Trim README to concise reference format; remove redundant em-dash connectors
- Add TypeDoc API documentation generation
## [1.0.1] - 2026-05-28

202
README.md
View file

@ -4,9 +4,9 @@
[![CI](https://github.com/acamarata/date-fns-hijri/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/date-fns-hijri/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
date-fns-style functions for Hijri calendar operations. Works with any date library.
date-fns-style functions for Hijri calendar operations. Each function is a pure, stateless utility. Pass a `Date`, get a result. No classes, no global configuration.
Each function is a pure, stateless utility. No classes. No configuration object. Pass a `Date`, get a result. Pass options to switch calendar systems. The API mirrors date-fns conventions so the learning curve is minimal.
Built on [hijri-core](https://github.com/acamarata/hijri-core). Supports Umm al-Qura (UAQ) and FCNA/ISNA calendar systems.
## Installation
@ -14,7 +14,7 @@ Each function is a pure, stateless utility. No classes. No configuration object.
pnpm add date-fns-hijri hijri-core
```
`hijri-core` is a peer dependency. It provides the underlying calendar engine and must be installed alongside this package.
`hijri-core` is a peer dependency. It provides the underlying calendar engine.
## Quick Start
@ -27,164 +27,60 @@ import {
getHijriMonthName,
} from 'date-fns-hijri';
// Convert a Gregorian date to Hijri
const hijri = toHijriDate(new Date(2023, 2, 23));
// { hy: 1444, hm: 9, hd: 1 } - 1 Ramadan 1444
// Convert back
const gregorian = fromHijriDate(1444, 9, 1);
// Date: 2023-03-23T00:00:00.000Z
// Convert Gregorian to Hijri
const hijri = toHijriDate(new Date(2023, 2, 23, 12));
// { hy: 1444, hm: 9, hd: 1 } (1 Ramadan 1444)
// Format with Hijri tokens
const label = formatHijriDate(new Date(2023, 2, 23), 'iDD iMMMM iYYYY');
// '01 Ramadan 1444'
// Get the month name directly
const name = getHijriMonthName(9);
// 'Ramadan'
// Add months in the Hijri calendar
const nextMonth = addHijriMonths(new Date(2023, 2, 23), 1);
// Date in Shawwal 1444
```
## API
All functions accept an optional `options` argument for selecting the calendar system. When omitted, Umm al-Qura (UAQ) is used.
### Conversion
| Function | Signature | Description |
| --- | --- | --- |
| `toHijriDate` | `(date: Date, options?) => HijriDate \| null` | Convert Gregorian to Hijri. Returns `null` if out of range. |
| `fromHijriDate` | `(hy, hm, hd, options?) => Date` | Convert Hijri to Gregorian. Throws if invalid. |
### Validation
| Function | Signature | Description |
| --- | --- | --- |
| `isValidHijriDate` | `(hy, hm, hd, options?) => boolean` | Check if a Hijri date exists in the calendar table. |
### Field Getters
| Function | Signature | Description |
| --- | --- | --- |
| `getHijriYear` | `(date, options?) => number \| null` | Hijri year. Null if out of range. |
| `getHijriMonth` | `(date, options?) => number \| null` | Hijri month (1-12). Null if out of range. |
| `getHijriDay` | `(date, options?) => number \| null` | Hijri day of month. Null if out of range. |
| `getDaysInHijriMonth` | `(hy, hm, options?) => number` | Days in a Hijri month (29 or 30). |
| `getHijriQuarter` | `(date, options?) => number \| null` | Quarter (1-4). Null if out of range. |
### Names
| Function | Signature | Description |
| --- | --- | --- |
| `getHijriMonthName` | `(hm, length?) => string` | English month name. `length`: `'long'` (default), `'medium'`, `'short'`. |
| `getHijriWeekdayName` | `(date, length?) => string` | Arabic weekday name. `length`: `'long'` (default), `'short'`. |
### Formatting
| Function | Signature | Description |
| --- | --- | --- |
| `formatHijriDate` | `(date, formatStr, options?) => string` | Format a date with Hijri tokens. Returns `''` if out of range. |
### Arithmetic
| Function | Signature | Description |
| --- | --- | --- |
| `addHijriMonths` | `(date, months, options?) => Date` | Add N Hijri months. Clamps day to month length. |
| `addHijriYears` | `(date, years, options?) => Date` | Add N Hijri years. Clamps day to month length. |
### Month Boundaries
| Function | Signature | Description |
| --- | --- | --- |
| `startOfHijriMonth` | `(date, options?) => Date` | First day of the containing Hijri month. |
| `endOfHijriMonth` | `(date, options?) => Date` | Last day of the containing Hijri month. |
### Comparisons
| Function | Signature | Description |
| --- | --- | --- |
| `isSameHijriMonth` | `(dateA, dateB, options?) => boolean` | Both dates in the same Hijri month. |
| `isSameHijriYear` | `(dateA, dateB, options?) => boolean` | Both dates in the same Hijri year. |
## Calendar Systems
Two calendar systems are available via the `options.calendar` property.
**Umm al-Qura (default):**
The official calendar of Saudi Arabia. Covers 13181500 AH (19002076 CE). Tabular data; deterministic.
```typescript
import { toHijriDate } from 'date-fns-hijri';
const uaq = toHijriDate(new Date(2023, 2, 23));
// uses UAQ by default
```
**FCNA/ISNA:**
The calendar used by the Fiqh Council of North America. Astronomical calculation; extends slightly beyond UAQ's range.
```typescript
const fcna = toHijriDate(new Date(2023, 2, 23), { calendar: 'fcna' });
```
## Format Tokens
| Token | Output | Example |
| --- | --- | --- |
| `iYYYY` | 4-digit Hijri year | `1444` |
| `iYY` | 2-digit Hijri year | `44` |
| `iMMMM` | Long month name | `Ramadan` |
| `iMMM` | Medium month name | `Ramadan` |
| `iMM` | Zero-padded month | `09` |
| `iM` | Month number | `9` |
| `iDD` | Zero-padded day | `01` |
| `iD` | Day number | `1` |
| `iEEEE` | Long weekday name | `Yawm al-Khamis` |
| `iEEE` | Short weekday name | `Kham` |
| `iE` | Numeric weekday (1=Sun) | `5` |
| `ioooo` | Long era | `AH` |
| `iooo` | Short era | `AH` |
Non-token text in the format string passes through unchanged:
```typescript
formatHijriDate(new Date(2023, 2, 23), 'iYYYY-iMM-iDD')
// '1444-09-01'
formatHijriDate(new Date(2023, 2, 23), 'iD iMMMM iYYYY ioooo')
const label = formatHijriDate(new Date(2023, 2, 23, 12), 'iD iMMMM iYYYY ioooo');
// '1 Ramadan 1444 AH'
// Add Hijri months
const eid = addHijriMonths(new Date(2023, 2, 23, 12), 1);
// Date in Shawwal 1444
// Get the month name
getHijriMonthName(9); // 'Ramadan'
```
## TypeScript
Full type definitions are included. Re-exported from `hijri-core`:
```typescript
import type { HijriDate, ConversionOptions } from 'date-fns-hijri';
const h: HijriDate = { hy: 1444, hm: 9, hd: 1 };
const opts: ConversionOptions = { calendar: 'fcna' };
```
## Architecture
A thin adapter over [hijri-core](https://github.com/acamarata/hijri-core). Each function is a stateless wrapper that delegates to the registered calendar engine. No global state, no configuration object: pass options per call.
For more detail see the [Architecture wiki page](https://github.com/acamarata/date-fns-hijri/wiki/Architecture).
## Documentation
Full API reference, architecture notes, and examples: [Wiki](https://github.com/acamarata/date-fns-hijri/wiki)
Full API reference, guides, and examples: **[Wiki](https://github.com/acamarata/date-fns-hijri/wiki)**
- [API Reference](https://github.com/acamarata/date-fns-hijri/wiki/API-Reference): all 17 functions with signatures and examples
- [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) - Zero-dependency Hijri engine powering this library
- [luxon-hijri](https://github.com/acamarata/luxon-hijri) - Hijri support for Luxon DateTime objects
- [pray-calc](https://github.com/acamarata/pray-calc) - Islamic prayer times
- [nrel-spa](https://github.com/acamarata/nrel-spa) - Solar position algorithm
- [hijri-core](https://github.com/acamarata/hijri-core): the calendar engine powering this library
- [luxon-hijri](https://github.com/acamarata/luxon-hijri): Hijri support for Luxon DateTime objects
- [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer times
## Compatibility
@ -193,10 +89,6 @@ Full API reference, architecture notes, and examples: [Wiki](https://github.com/
- TypeScript definitions bundled
- Works in browsers and all major bundlers
## Acknowledgments
Calendar data and algorithms provided by [hijri-core](https://github.com/acamarata/hijri-core). The Umm al-Qura table is derived from data published by the King Abdulaziz City for Science and Technology (KACST). FCNA new moon calculations follow Jean Meeus, "Astronomical Algorithms," 2nd ed., Chapter 49.
## License
MIT. Copyright (c) 2026 Aric Camarata.

8
TELEMETRY.md Normal file
View file

@ -0,0 +1,8 @@
# Telemetry Disclosure
This package supports opt-in anonymous usage telemetry via [`@acamarata/telemetry`](https://github.com/acamarata/telemetry).
Telemetry is **off by default**. No data is sent unless you set `ACAMARATA_TELEMETRY=1`.
Full disclosure (what is sent, where it goes, how to disable):
[github.com/acamarata/telemetry/blob/main/TELEMETRY.md](https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md)

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

@ -1,12 +1,20 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import eslintConfigPrettier from 'eslint-config-prettier';
import { typescript } from '@acamarata/eslint-config';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
export default [
{
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.1",
"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,8 +37,11 @@
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"prepublishOnly": "tsup",
"coverage": "c8 --reporter=lcov --reporter=text node --test"
"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",
"test:vitest": "vitest run"
},
"keywords": [
"date-fns",
@ -56,15 +59,25 @@
"hijri-core": "^1.0.0"
},
"devDependencies": {
"@acamarata/eslint-config": "^0.1.0",
"@acamarata/prettier-config": "^0.1.0",
"@acamarata/telemetry": "^0.1.0",
"@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",
@ -78,5 +91,6 @@
"bugs": {
"url": "https://github.com/acamarata/date-fns-hijri/issues"
},
"type": "module"
"type": "module",
"prettier": "@acamarata/prettier-config"
}

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,28 +171,36 @@ 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}.`);
}
const idx = hm - 1;
if (length === 'medium') return hmMedium[idx];
if (length === 'short') return hmShort[idx];
return hmLong[idx];
// 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]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (length === "short") return hmShort[idx]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hmLong[idx]!;
}
/**
* 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
return length === 'short' ? hwShort[day] : hwLong[day];
// 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]!;
}
// ---------------------------------------------------------------------------
@ -156,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 |
@ -181,63 +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) => {
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':
return hmLong[h.hm - 1];
case 'iMMM':
return hmMedium[h.hm - 1];
case 'iMM':
return String(h.hm).padStart(2, '0');
case 'iM':
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":
// 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":
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':
return hwLong[day];
case 'iEEE':
return hwShort[day];
case 'iE':
return String(hwNumeric[day]);
case 'ioooo':
return 'AH';
case 'iooo':
return 'AH';
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":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hwShort[day]!;
case "iE":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return String(hwNumeric[day]!);
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
// ---------------------------------------------------------------------------
@ -245,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.
@ -252,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
@ -266,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);
}
// ---------------------------------------------------------------------------
@ -297,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);
}
// ---------------------------------------------------------------------------
@ -328,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;
}
@ -340,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;
}
@ -356,12 +421,23 @@ 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);
}
// ── Opt-in anonymous telemetry ────────────────────────────────────────────────
// Off by default. Enable: ACAMARATA_TELEMETRY=1
// What is sent + how to disable: https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md
import("@acamarata/telemetry")
.then(({ track }) => track("load", { package: "date-fns-hijri", version: "1.0.4" }))
.catch(() => {
// telemetry not installed or disabled — that's fine
});

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', () => {

View file

@ -1,17 +1,9 @@
{
"extends": "@acamarata/tsconfig/tsconfig.library.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"outDir": "dist",
"esModuleInterop": true,
"rootDir": "src",
"types": ["node"]
},

10
typedoc.json Normal file
View file

@ -0,0 +1,10 @@
{
"entryPoints": ["src/index.ts"],
"out": ".github/wiki/api",
"plugin": ["typedoc-plugin-markdown"],
"readme": "none",
"skipErrorChecking": false,
"excludePrivate": true,
"excludeProtected": true,
"includeVersion": true
}

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"],
},
});