Compare commits

...

30 commits
v1.0.0 ... main

Author SHA1 Message Date
Aric Camarata
e7fe51d7b3
add opt-in anonymous telemetry (#1)
Some checks failed
CI / Test (Node 20) (push) Failing after 42s
CI / Test (Node 22) (push) Failing after 31s
CI / Test (Node 24) (push) Failing after 38s
CI / Lint (push) Failing after 38s
CI / Typecheck (push) Failing after 26s
CI / Pack check (push) Failing after 28s
CI / Coverage (push) Failing after 2s
* 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:49 -04:00
Aric Camarata
515c554763 chore: stop tracking generated coverage artifacts 2026-06-13 10:27:12 -04:00
Aric Camarata
1f201ac895 build: use prepack hook so npm pack/publish reliably emit index.d.mts 2026-06-13 10:11:20 -04:00
Aric Camarata
5bd50384f2 chore: bump to v1.0.3 2026-06-10 16:50:58 -04:00
Aric Camarata
8e5b35c3a3 chore: update hijri-core to 1.0.3 2026-06-10 16:50:22 -04:00
Aric Camarata
4dd246f27a fix: build Dates via Date.UTC for hijri-core's UTC-day contract
HijriCalendar.toHijri() previously used new Date(y, m, d) (local-time
constructor). Under hijri-core's new UTC-day contract the engine reads
the UTC calendar day, so on east-of-UTC hosts (e.g. UTC+5) the local
midnight falls on the previous UTC date, producing a one-day-off Hijri
result.

Fix: use Date.UTC(y, m, d) so PlainDate calendar fields land in the
Date's UTC components, matching what hijri-core reads.

Lock-step dependency: this fix requires the unreleased hijri-core change
on fix/utc-day-boundary (commit 3419378). Both packages will be released
together per ADR-013.

Tests: round-trip regression added (2025-03-01 = 1 Ramadan 1446 AH);
all 37 ESM + 9 CJS tests pass at TZ=UTC, TZ=America/New_York,
TZ=Pacific/Auckland.
2026-06-10 16:35:38 -04:00
Aric Camarata
70bd956179 ci: format all files with prettier to fix format:check 2026-05-31 08:50:45 -04:00
Aric Camarata
40478b6f7b ci: fix eslint - add parserOptions.project for typed linting rules 2026-05-31 08:49:46 -04:00
Aric Camarata
cae2726766 ci: fix eslint flat config - add files pattern to match TS files in src/ 2026-05-31 08:48:45 -04:00
Aric Camarata
e6fd9d14df ci: fix eslint - add @typescript-eslint/parser and eslint-plugin as explicit devDeps 2026-05-31 08:47:02 -04:00
Aric Camarata
977d4323eb chore: bump to v1.0.2 2026-05-30 19:21:29 -04:00
Aric Camarata
c4ee5efe96 chore: P1 standardization — finalize src/wiki/ci 2026-05-30 18:48:59 -04:00
Aric Camarata
bc8f70ba0b docs: refresh TypeDoc API output (T-E8-03 QA-A verify) 2026-05-30 17:48:47 -04:00
Aric Camarata
077861c7dc 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
cdcede1c58 chore: adopt shared config packages (tsconfig, eslint, prettier) 2026-05-30 15:12:16 -04:00
Aric Camarata
1dea36eda8 ci: corepack before setup-node, scope prettier to src/, emit d.mts 2026-05-29 20:05:46 -04:00
Aric Camarata
250dda20d4 chore: E6 polish wiki content + ADR-015 CI updates (P1) 2026-05-29 07:15:50 -04:00
Aric Camarata
2289dd3718 chore: untrack AGENTS.md (AI working memory, not source code) 2026-05-29 06:36:40 -04:00
Aric Camarata
908e48f9f7 chore: bump to v1.0.1
- Flatten exports map to ADR-015 standard
- Add coverage script (c8)
- Migrate CI to corepack enable
2026-05-28 13:55:03 -04:00
Aric Camarata
3492f1f4b2 chore(config): add AGENTS.md for dual-harness parity 2026-05-25 15:51:25 -04:00
Aric Camarata
e2ca6468cb chore: align repository structure with portfolio documentation standards 2026-05-15 15:27:55 -04:00
Aric Camarata
eb5a9fe00d Add GitHub Sponsors funding config 2026-03-28 18:19:04 -04:00
Aric Camarata
f0e2985849 style: fix prettier table formatting in wiki 2026-03-08 17:30:55 -04:00
Aric Camarata
69b08f5971 style: replace em dashes with colons in docs 2026-03-08 17:28:04 -04:00
Aric Camarata
9e61d6e594 docs: add Architecture section to README 2026-03-08 17:10:48 -04:00
Aric Camarata
aace8d097a ci: let pnpm/action-setup read version from packageManager field 2026-03-08 16:52:31 -04:00
Aric Camarata
937c4296f7 docs: add Acknowledgments section to README 2026-03-08 16:46:28 -04:00
Aric Camarata
ebe5c05bda ci: pin pnpm to version 10 in all CI jobs
Also enable sourcemap: true in tsup config
2026-03-08 16:37:46 -04:00
Aric Camarata
41956c0dc4 refactor: code quality improvements and Temporal Protocol compliance
- Replace O(n) while-loops in dateAdd() with O(1) modular arithmetic
- Implement overflow option handling in dateFromFields, yearMonthFromFields, monthDayFromFields
- Add fields() method per Temporal Calendar Protocol
- Extract shared borrow logic from dateUntil() into borrowHijriDiff helper
- Replace magic number 1444 with REFERENCE_YEAR constant
- Convert test suites to node:test runner with describe/it blocks
- Add tests for dateUntil, dateAdd with days/weeks, overflow reject/constrain, fields(), yearMonthFromFields, monthDayFromFields
- Add ESLint + Prettier with typescript-eslint config
- Add lint job to CI workflow
- Add noImplicitReturns and noFallthroughCasesInSwitch to tsconfig
- Disable unused sourcemap generation in tsup
- Update .editorconfig to include .mts and .cts extensions
- Add missing AI agent dirs to .gitignore
2026-03-08 11:37:22 -04:00
Aric Camarata
997ce2a097 chore: add forceConsistentCasingInFileNames to tsconfig 2026-02-25 15:25:32 -05:00
44 changed files with 4637 additions and 503 deletions

View file

@ -6,7 +6,7 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,mjs,cjs,ts,json,yaml,yml,md}]
[*.{js,mjs,cjs,ts,mts,cts,json,yaml,yml,md}]
indent_style = space
indent_size = 2

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: [acamarata]

19
.github/docs/CHANGELOG.md vendored Normal file
View file

@ -0,0 +1,19 @@
# Changelog
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.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-02-25
### Added
- `HijriCalendar` base class implementing the TC39 Temporal Calendar Protocol
- `UaqCalendar`: Umm al-Qura calendar (table-driven, 1318-1500 AH coverage)
- `FcnaCalendar`: FCNA/ISNA calendar (astronomical new moon calculation via Meeus)
- `uaqCalendar` and `fcnaCalendar` convenience singletons
- Full Temporal protocol: `year`, `month`, `monthCode`, `day`, `daysInMonth`, `daysInYear`, `monthsInYear`, `inLeapYear`, `dayOfWeek`, `dayOfYear`, `weekOfYear`, `daysInWeek`, `dateFromFields`, `yearMonthFromFields`, `monthDayFromFields`, `dateAdd`, `dateUntil`, `mergeFields`, `toString`
- Dual CJS and ESM builds with TypeScript declarations
- Peer dependency on `hijri-core ^1.0.0` for conversion logic
- Optional peer dependency on `@js-temporal/polyfill ^0.4.0`

View file

@ -5,16 +5,16 @@
```typescript
// Classes
export { HijriCalendar } from 'temporal-hijri';
export { UaqCalendar } from 'temporal-hijri';
export { FcnaCalendar } from 'temporal-hijri';
export { UaqCalendar } from 'temporal-hijri';
export { FcnaCalendar } from 'temporal-hijri';
// Singletons
export { uaqCalendar } from 'temporal-hijri'; // new UaqCalendar()
export { uaqCalendar } from 'temporal-hijri'; // new UaqCalendar()
export { fcnaCalendar } from 'temporal-hijri'; // new FcnaCalendar()
// Types (re-exported from hijri-core)
export type { HijriDate, ConversionOptions } from 'temporal-hijri';
export type { HijriCalendarOptions } from 'temporal-hijri';
export type { HijriCalendarOptions } from 'temporal-hijri';
```
---
@ -36,7 +36,7 @@ Accepts any engine registered via hijri-core's `registerCalendar()`. The calenda
## `UaqCalendar`
```typescript
new UaqCalendar()
new UaqCalendar();
```
Wraps the `uaq` engine from hijri-core. Calendar ID: `"hijri-uaq"`.
@ -46,7 +46,7 @@ Wraps the `uaq` engine from hijri-core. Calendar ID: `"hijri-uaq"`.
## `FcnaCalendar`
```typescript
new FcnaCalendar()
new FcnaCalendar();
```
Wraps the `fcna` engine from hijri-core. Calendar ID: `"hijri-fcna"`.
@ -59,38 +59,38 @@ All methods are available on `HijriCalendar`, `UaqCalendar`, and `FcnaCalendar`.
### Field accessors
| Method | Signature | Returns | Notes |
|---|---|---|---|
| `year` | `(date: PlainDate) => number` | Hijri year | |
| `month` | `(date: PlainDate) => number` | Hijri month (1-12) | |
| `monthCode` | `(date: PlainDate) => string` | `"M01"` `"M12"` | Zero-padded |
| `day` | `(date: PlainDate) => number` | Day of month (1-29/30) | |
| Method | Signature | Returns | Notes |
| ----------- | ----------------------------- | ---------------------- | ----------- |
| `year` | `(date: PlainDate) => number` | Hijri year | |
| `month` | `(date: PlainDate) => number` | Hijri month (1-12) | |
| `monthCode` | `(date: PlainDate) => string` | `"M01"` `"M12"` | Zero-padded |
| `day` | `(date: PlainDate) => number` | Day of month (1-29/30) | |
### Year and month metrics
| Method | Signature | Returns | Notes |
|---|---|---|---|
| `daysInMonth` | `(date: PlainDate) => number` | 29 or 30 | Varies by month and calendar |
| `daysInYear` | `(date: PlainDate) => number` | 354 or 355 | Sum of all 12 months |
| `monthsInYear` | `(date: PlainDate) => number` | Always 12 | |
| `inLeapYear` | `(date: PlainDate) => boolean` | `true` if 355-day year | |
| Method | Signature | Returns | Notes |
| -------------- | ------------------------------ | ---------------------- | ---------------------------- |
| `daysInMonth` | `(date: PlainDate) => number` | 29 or 30 | Varies by month and calendar |
| `daysInYear` | `(date: PlainDate) => number` | 354 or 355 | Sum of all 12 months |
| `monthsInYear` | `(date: PlainDate) => number` | Always 12 | |
| `inLeapYear` | `(date: PlainDate) => boolean` | `true` if 355-day year | |
### Week and day position
| Method | Signature | Returns | Notes |
|---|---|---|---|
| `dayOfWeek` | `(date: PlainDate) => number` | 1-7 (Mon=1, Sun=7) | ISO weekday |
| `dayOfYear` | `(date: PlainDate) => number` | 1-354 or 1-355 | Within the Hijri year |
| `weekOfYear` | `(date: PlainDate) => number` | 1-51 | `ceil(dayOfYear / 7)` |
| `daysInWeek` | `(date: PlainDate) => number` | Always 7 | |
| Method | Signature | Returns | Notes |
| ------------ | ----------------------------- | ------------------ | --------------------- |
| `dayOfWeek` | `(date: PlainDate) => number` | 1-7 (Mon=1, Sun=7) | ISO weekday |
| `dayOfYear` | `(date: PlainDate) => number` | 1-354 or 1-355 | Within the Hijri year |
| `weekOfYear` | `(date: PlainDate) => number` | 1-51 | `ceil(dayOfYear / 7)` |
| `daysInWeek` | `(date: PlainDate) => number` | Always 7 | |
### Construction from fields
| Method | Signature | Returns |
|---|---|---|
| `dateFromFields` | `(fields: {year, month, day}, options?) => PlainDate` | ISO `PlainDate` |
| `yearMonthFromFields` | `(fields: {year, month}, options?) => PlainYearMonth` | ISO `PlainYearMonth` |
| `monthDayFromFields` | `(fields: {month, day, year?}, options?) => PlainMonthDay` | ISO `PlainMonthDay` |
| Method | Signature | Returns |
| --------------------- | ---------------------------------------------------------- | -------------------- |
| `dateFromFields` | `(fields: {year, month, day}, options?) => PlainDate` | ISO `PlainDate` |
| `yearMonthFromFields` | `(fields: {year, month}, options?) => PlainYearMonth` | ISO `PlainYearMonth` |
| `monthDayFromFields` | `(fields: {month, day, year?}, options?) => PlainMonthDay` | ISO `PlainMonthDay` |
`monthDayFromFields` uses year 1444 AH as a default reference if no year is supplied.
@ -124,10 +124,10 @@ Computes the difference between two dates. When `largestUnit` is `'years'` or `'
### Other
| Method | Signature | Returns |
|---|---|---|
| Method | Signature | Returns |
| ------------- | -------------------------------------- | ------------------- |
| `mergeFields` | `(fields, additionalFields) => Record` | Merged field object |
| `toString` | `() => string` | Calendar identifier |
| `toString` | `() => string` | Calendar identifier |
---

View file

@ -113,12 +113,12 @@ hijri-core provides:
The package ships two formats from a single TypeScript source:
| File | Format | Usage |
|---|---|---|
| `dist/index.mjs` | ESM | `import { uaqCalendar } from 'temporal-hijri'` |
| `dist/index.cjs` | CommonJS | `const { uaqCalendar } = require('temporal-hijri')` |
| `dist/index.d.ts` | Type declarations (CJS) | TypeScript + CJS |
| `dist/index.d.mts` | Type declarations (ESM) | TypeScript + ESM |
| File | Format | Usage |
| ------------------ | ----------------------- | --------------------------------------------------- |
| `dist/index.mjs` | ESM | `import { uaqCalendar } from 'temporal-hijri'` |
| `dist/index.cjs` | CommonJS | `const { uaqCalendar } = require('temporal-hijri')` |
| `dist/index.d.ts` | Type declarations (CJS) | TypeScript + CJS |
| `dist/index.d.mts` | Type declarations (ESM) | TypeScript + ESM |
Both `hijri-core` and `@js-temporal/polyfill` are declared `external` in the build config and listed as peer dependencies. They are not bundled.

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.

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

@ -0,0 +1,54 @@
# Contributing to temporal-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/temporal-hijri.git
cd temporal-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/temporal-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.
- Class-based implementation following the Temporal Calendar Protocol interface.
- Each class method: 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 method 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.
- The polyfill (`@js-temporal/polyfill`) must be installed for tests to run.
## Temporal specification
This package implements the [TC39 Temporal proposal](https://tc39.es/proposal-temporal/) Calendar Protocol (Stage 3). Before adding or changing behavior, read the relevant section of the specification. Deviations from the spec are not accepted unless the spec itself is ambiguous.
## 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.

View file

@ -18,10 +18,10 @@ This package provides `UaqCalendar` and `FcnaCalendar` as plug-in calendar objec
## Calendar systems
| Calendar | ID | Description |
|---|---|---|
| Umm al-Qura | `hijri-uaq` | Official Saudi calendar, table-driven, covers 1318-1500 AH |
| FCNA/ISNA | `hijri-fcna` | North American standard, astronomical new moon calculation |
| Calendar | ID | Description |
| ----------- | ------------ | ---------------------------------------------------------- |
| Umm al-Qura | `hijri-uaq` | Official Saudi calendar, table-driven, covers 1318-1500 AH |
| FCNA/ISNA | `hijri-fcna` | North American standard, astronomical new moon calculation |
## Requirements

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
temporal-hijri is a pure calendar computation library. It implements the Temporal Calendar Protocol over Hijri calendar data. 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: temporal-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 @@
[temporal-hijri](https://github.com/acamarata/temporal-hijri) · MIT License · [npm](https://www.npmjs.com/package/temporal-hijri) · [Issues](https://github.com/acamarata/temporal-hijri/issues)

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

@ -0,0 +1,25 @@
**[Home](Home)**
**Guides**
- [Quick Start](guides/quickstart)
- [Advanced Usage](guides/advanced)
**Examples**
- [Basic Usage](examples/basic-usage)
- [Scheduling Display](examples/scheduling-display)
**API**
- [HijriCalendar](api/HijriCalendar)
- [UaqCalendar](api/UaqCalendar)
- [FcnaCalendar](api/FcnaCalendar)
- [Singletons](api/singletons)
- [Full API Reference](API-Reference)
**Reference**
- [Architecture](Architecture)
- [Benchmarks](benchmarks/index)
**Community**
- [Contributing](CONTRIBUTING)
- [Code of Conduct](CODE_OF_CONDUCT)
- [Security](SECURITY)

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

@ -0,0 +1,22 @@
**temporal-hijri v1.0.1**
***
# temporal-hijri v1.0.1
## Classes
- [FcnaCalendar](classes/FcnaCalendar.md)
- [HijriCalendar](classes/HijriCalendar.md)
- [UaqCalendar](classes/UaqCalendar.md)
## Interfaces
- [CalendarEngine](interfaces/CalendarEngine.md)
- [ConversionOptions](interfaces/ConversionOptions.md)
- [HijriDate](interfaces/HijriDate.md)
## Variables
- [fcnaCalendar](variables/fcnaCalendar.md)
- [uaqCalendar](variables/uaqCalendar.md)

649
.github/wiki/api/classes/FcnaCalendar.md vendored Normal file
View file

@ -0,0 +1,649 @@
[**temporal-hijri v1.0.1**](../README.md)
***
[temporal-hijri](../README.md) / FcnaCalendar
# Class: FcnaCalendar
Defined in: [src/calendars/FcnaCalendar.ts:18](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/FcnaCalendar.ts#L18)
Temporal calendar implementation for the FCNA/ISNA calendar.
The Fiqh Council of North America (FCNA) calendar, also used by the Islamic
Society of North America (ISNA), determines month starts through astronomical
calculation: a new month begins the day after the conjunction (new moon) if
that conjunction occurs before 12:00 noon UTC, or two days after if at or
after noon. This criterion enables global date-setting without local moon
sighting, making it popular for diaspora Muslim communities in North America
and Europe.
Calendar engine: hijri-core FCNA (Meeus Chapter 49 calculations).
Calendar ID: "hijri-fcna"
## Extends
- [`HijriCalendar`](HijriCalendar.md)
## Constructors
### Constructor
> **new FcnaCalendar**(): `FcnaCalendar`
Defined in: [src/calendars/FcnaCalendar.ts:19](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/FcnaCalendar.ts#L19)
#### Returns
`FcnaCalendar`
#### Overrides
[`HijriCalendar`](HijriCalendar.md).[`constructor`](HijriCalendar.md#constructor)
## Properties
### id
> `readonly` **id**: `string`
Defined in: [src/calendars/HijriCalendar.ts:59](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L59)
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`id`](HijriCalendar.md#id)
## Methods
### dateAdd()
> **dateAdd**(`date`, `duration`, `_options?`): `PlainDate`
Defined in: [src/calendars/HijriCalendar.ts:346](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L346)
Add a duration to a Hijri date.
Year and month additions are handled in Hijri space to preserve calendar
semantics (e.g., adding one month to 1 Ramadan yields 1 Shawwal, not a
fixed 30-day offset). Day and week additions are then applied in ISO space
so that they always represent exact day counts.
Month normalization uses O(1) modular arithmetic instead of iterative loops.
When the day-of-month exceeds the target month's length after a Hijri-space
adjustment, it is clamped to the last valid day of that month.
#### Parameters
##### date
`PlainDate`
##### duration
`Duration`
##### \_options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainDate`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dateAdd`](HijriCalendar.md#dateadd)
***
### dateFromFields()
> **dateFromFields**(`fields`, `options?`): `PlainDate`
Defined in: [src/calendars/HijriCalendar.ts:279](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L279)
#### Parameters
##### fields
###### day
`number`
###### month
`number`
###### year
`number`
##### options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainDate`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dateFromFields`](HijriCalendar.md#datefromfields)
***
### dateUntil()
> **dateUntil**(`one`, `two`, `options?`): `Duration`
Defined in: [src/calendars/HijriCalendar.ts:384](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L384)
Compute the difference between two Hijri dates.
For simplicity and correctness across variable-length Hijri months, this
delegates to the underlying ISO PlainDate difference when the largest unit
is days or weeks. Year/month differences require a Hijri-space calculation.
#### Parameters
##### one
`PlainDate`
##### two
`PlainDate`
##### options?
###### largestUnit?
`DateUnit`
#### Returns
`Duration`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dateUntil`](HijriCalendar.md#dateuntil)
***
### day()
> **day**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:169](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L169)
Returns the day of the Hijri month (1-29 or 1-30).
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
Day of month within the Hijri calendar.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`day`](HijriCalendar.md#day)
***
### dayOfWeek()
> **dayOfWeek**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:234](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L234)
ISO weekday: 1 = Monday, 7 = Sunday.
PlainDate.dayOfWeek on an ISO-calendar date already gives ISO weekday,
so no conversion is needed.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dayOfWeek`](HijriCalendar.md#dayofweek)
***
### dayOfYear()
> **dayOfYear**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:242](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L242)
Day within the Hijri year. Accumulates full months before the current one,
then adds the day-of-month offset.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dayOfYear`](HijriCalendar.md#dayofyear)
***
### daysInMonth()
> **daysInMonth**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:184](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L184)
Returns the number of days in the Hijri month containing the given date.
Hijri months alternate between 29 and 30 days, but the exact pattern
differs by calendar system (UAQ uses fixed tables; FCNA uses calculation).
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
29 or 30.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`daysInMonth`](HijriCalendar.md#daysinmonth)
***
### daysInWeek()
> **daysInWeek**(`_date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:263](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L263)
Returns the number of days in a week.
Always 7. Required by the Temporal Calendar Protocol.
#### Parameters
##### \_date
`PlainDate`
#### Returns
`number`
Always 7.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`daysInWeek`](HijriCalendar.md#daysinweek)
***
### daysInYear()
> **daysInYear**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:193](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L193)
Sum all 12 month lengths for the Hijri year. Standard lunar years are 354
days; leap years (with an added day in Dhul-Hijja) are 355 days.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`daysInYear`](HijriCalendar.md#daysinyear)
***
### fields()
> **fields**(`fields`): `string`[]
Defined in: [src/calendars/HijriCalendar.ts:273](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L273)
Return the list of fields that the calendar adds to a Temporal object.
Non-era calendars return the input array unchanged.
#### Parameters
##### fields
`string`[]
#### Returns
`string`[]
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`fields`](HijriCalendar.md#fields)
***
### inLeapYear()
> **inLeapYear**(`date`): `boolean`
Defined in: [src/calendars/HijriCalendar.ts:223](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L223)
Returns whether the Hijri year is a leap year (355 days).
Standard Hijri years have 354 days. A leap year adds one day to
Dhul-Hijja (month 12), making it 355 days total.
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`boolean`
`true` if the year has 355 days.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`inLeapYear`](HijriCalendar.md#inleapyear)
***
### mergeFields()
> **mergeFields**(`fields`, `additionalFields`): `Record`\<`string`, `unknown`\>
Defined in: [src/calendars/HijriCalendar.ts:414](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L414)
#### Parameters
##### fields
`Record`\<`string`, `unknown`\>
##### additionalFields
`Record`\<`string`, `unknown`\>
#### Returns
`Record`\<`string`, `unknown`\>
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`mergeFields`](HijriCalendar.md#mergefields)
***
### month()
> **month**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:149](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L149)
Returns the Hijri month (1-12) for the given ISO date.
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
Month number 1 (Muharram) through 12 (Dhul-Hijja).
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`month`](HijriCalendar.md#month)
***
### monthCode()
> **monthCode**(`date`): `string`
Defined in: [src/calendars/HijriCalendar.ts:158](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L158)
Month code per the Temporal proposal: "M01".."M12".
Hijri months are always 1-12 (no leap/intercalary month), so the code is
simply the zero-padded month number.
#### Parameters
##### date
`PlainDate`
#### Returns
`string`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`monthCode`](HijriCalendar.md#monthcode)
***
### monthDayFromFields()
> **monthDayFromFields**(`fields`, `options?`): `PlainMonthDay`
Defined in: [src/calendars/HijriCalendar.ts:317](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L317)
ISO-anchored PlainMonthDay per the Temporal Calendar Protocol.
Reference year 1444 is intentional: it is a recent, well-covered UAQ year
used to anchor the ISO coordinates when no year is supplied.
#### Parameters
##### fields
###### day
`number`
###### month
`number`
###### year?
`number`
##### options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainMonthDay`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`monthDayFromFields`](HijriCalendar.md#monthdayfromfields)
***
### monthsInYear()
> **monthsInYear**(`_date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:210](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L210)
Returns the number of months in the Hijri year.
Always 12. Unlike the Hebrew calendar, the Hijri lunar calendar has no
intercalary (leap) month — only a possible extra day in Dhul-Hijja.
#### Parameters
##### \_date
`PlainDate`
#### Returns
`number`
Always 12.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`monthsInYear`](HijriCalendar.md#monthsinyear)
***
### toString()
> **toString**(): `string`
Defined in: [src/calendars/HijriCalendar.ts:66](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L66)
#### Returns
`string`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`toString`](HijriCalendar.md#tostring)
***
### weekOfYear()
> **weekOfYear**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:252](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L252)
Hijri week number counted from day 1 of Muharram (day 1-7 = week 1). No ISO week alignment.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`weekOfYear`](HijriCalendar.md#weekofyear)
***
### year()
> **year**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:139](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L139)
Returns the Hijri year for the given ISO date.
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
The Hijri year, e.g. 1444.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`year`](HijriCalendar.md#year)
***
### yearMonthFromFields()
> **yearMonthFromFields**(`fields`, `options?`): `PlainYearMonth`
Defined in: [src/calendars/HijriCalendar.ts:294](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L294)
ISO-anchored PlainYearMonth per the Temporal Calendar Protocol.
The resulting PlainYearMonth stores ISO coordinates internally, representing
the Hijri month that starts on that ISO year/month.
#### Parameters
##### fields
###### month
`number`
###### year
`number`
##### options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainYearMonth`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`yearMonthFromFields`](HijriCalendar.md#yearmonthfromfields)

View file

@ -0,0 +1,568 @@
[**temporal-hijri v1.0.1**](../README.md)
***
[temporal-hijri](../README.md) / HijriCalendar
# Class: HijriCalendar
Defined in: [src/calendars/HijriCalendar.ts:57](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L57)
Base class implementing the TC39 Temporal Calendar Protocol for Hijri calendars.
Coordinate bridging: Temporal.PlainDate operates in the ISO (Gregorian) calendar.
Every calendar method receives a PlainDate with ISO year/month/day, and must
return results in the Hijri calendar's coordinate system. The bridge is
toHijri() and fromHijri(), which delegate to the injected CalendarEngine.
Arithmetic strategy for dateAdd():
- Year and month deltas are applied in Hijri space (correct handling of
variable month lengths).
- Day and week deltas are applied in ISO space after the Hijri addition,
so that "add 30 days" always means exactly 30 days.
## Extended by
- [`UaqCalendar`](UaqCalendar.md)
- [`FcnaCalendar`](FcnaCalendar.md)
## Constructors
### Constructor
> **new HijriCalendar**(`engine`): `HijriCalendar`
Defined in: [src/calendars/HijriCalendar.ts:61](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L61)
#### Parameters
##### engine
[`CalendarEngine`](../interfaces/CalendarEngine.md)
#### Returns
`HijriCalendar`
## Properties
### id
> `readonly` **id**: `string`
Defined in: [src/calendars/HijriCalendar.ts:59](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L59)
## Methods
### dateAdd()
> **dateAdd**(`date`, `duration`, `_options?`): `PlainDate`
Defined in: [src/calendars/HijriCalendar.ts:346](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L346)
Add a duration to a Hijri date.
Year and month additions are handled in Hijri space to preserve calendar
semantics (e.g., adding one month to 1 Ramadan yields 1 Shawwal, not a
fixed 30-day offset). Day and week additions are then applied in ISO space
so that they always represent exact day counts.
Month normalization uses O(1) modular arithmetic instead of iterative loops.
When the day-of-month exceeds the target month's length after a Hijri-space
adjustment, it is clamped to the last valid day of that month.
#### Parameters
##### date
`PlainDate`
##### duration
`Duration`
##### \_options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainDate`
***
### dateFromFields()
> **dateFromFields**(`fields`, `options?`): `PlainDate`
Defined in: [src/calendars/HijriCalendar.ts:279](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L279)
#### Parameters
##### fields
###### day
`number`
###### month
`number`
###### year
`number`
##### options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainDate`
***
### dateUntil()
> **dateUntil**(`one`, `two`, `options?`): `Duration`
Defined in: [src/calendars/HijriCalendar.ts:384](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L384)
Compute the difference between two Hijri dates.
For simplicity and correctness across variable-length Hijri months, this
delegates to the underlying ISO PlainDate difference when the largest unit
is days or weeks. Year/month differences require a Hijri-space calculation.
#### Parameters
##### one
`PlainDate`
##### two
`PlainDate`
##### options?
###### largestUnit?
`DateUnit`
#### Returns
`Duration`
***
### day()
> **day**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:169](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L169)
Returns the day of the Hijri month (1-29 or 1-30).
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
Day of month within the Hijri calendar.
***
### dayOfWeek()
> **dayOfWeek**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:234](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L234)
ISO weekday: 1 = Monday, 7 = Sunday.
PlainDate.dayOfWeek on an ISO-calendar date already gives ISO weekday,
so no conversion is needed.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
***
### dayOfYear()
> **dayOfYear**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:242](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L242)
Day within the Hijri year. Accumulates full months before the current one,
then adds the day-of-month offset.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
***
### daysInMonth()
> **daysInMonth**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:184](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L184)
Returns the number of days in the Hijri month containing the given date.
Hijri months alternate between 29 and 30 days, but the exact pattern
differs by calendar system (UAQ uses fixed tables; FCNA uses calculation).
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
29 or 30.
***
### daysInWeek()
> **daysInWeek**(`_date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:263](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L263)
Returns the number of days in a week.
Always 7. Required by the Temporal Calendar Protocol.
#### Parameters
##### \_date
`PlainDate`
#### Returns
`number`
Always 7.
***
### daysInYear()
> **daysInYear**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:193](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L193)
Sum all 12 month lengths for the Hijri year. Standard lunar years are 354
days; leap years (with an added day in Dhul-Hijja) are 355 days.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
***
### fields()
> **fields**(`fields`): `string`[]
Defined in: [src/calendars/HijriCalendar.ts:273](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L273)
Return the list of fields that the calendar adds to a Temporal object.
Non-era calendars return the input array unchanged.
#### Parameters
##### fields
`string`[]
#### Returns
`string`[]
***
### inLeapYear()
> **inLeapYear**(`date`): `boolean`
Defined in: [src/calendars/HijriCalendar.ts:223](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L223)
Returns whether the Hijri year is a leap year (355 days).
Standard Hijri years have 354 days. A leap year adds one day to
Dhul-Hijja (month 12), making it 355 days total.
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`boolean`
`true` if the year has 355 days.
***
### mergeFields()
> **mergeFields**(`fields`, `additionalFields`): `Record`\<`string`, `unknown`\>
Defined in: [src/calendars/HijriCalendar.ts:414](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L414)
#### Parameters
##### fields
`Record`\<`string`, `unknown`\>
##### additionalFields
`Record`\<`string`, `unknown`\>
#### Returns
`Record`\<`string`, `unknown`\>
***
### month()
> **month**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:149](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L149)
Returns the Hijri month (1-12) for the given ISO date.
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
Month number 1 (Muharram) through 12 (Dhul-Hijja).
***
### monthCode()
> **monthCode**(`date`): `string`
Defined in: [src/calendars/HijriCalendar.ts:158](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L158)
Month code per the Temporal proposal: "M01".."M12".
Hijri months are always 1-12 (no leap/intercalary month), so the code is
simply the zero-padded month number.
#### Parameters
##### date
`PlainDate`
#### Returns
`string`
***
### monthDayFromFields()
> **monthDayFromFields**(`fields`, `options?`): `PlainMonthDay`
Defined in: [src/calendars/HijriCalendar.ts:317](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L317)
ISO-anchored PlainMonthDay per the Temporal Calendar Protocol.
Reference year 1444 is intentional: it is a recent, well-covered UAQ year
used to anchor the ISO coordinates when no year is supplied.
#### Parameters
##### fields
###### day
`number`
###### month
`number`
###### year?
`number`
##### options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainMonthDay`
***
### monthsInYear()
> **monthsInYear**(`_date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:210](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L210)
Returns the number of months in the Hijri year.
Always 12. Unlike the Hebrew calendar, the Hijri lunar calendar has no
intercalary (leap) month — only a possible extra day in Dhul-Hijja.
#### Parameters
##### \_date
`PlainDate`
#### Returns
`number`
Always 12.
***
### toString()
> **toString**(): `string`
Defined in: [src/calendars/HijriCalendar.ts:66](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L66)
#### Returns
`string`
***
### weekOfYear()
> **weekOfYear**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:252](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L252)
Hijri week number counted from day 1 of Muharram (day 1-7 = week 1). No ISO week alignment.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
***
### year()
> **year**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:139](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L139)
Returns the Hijri year for the given ISO date.
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
The Hijri year, e.g. 1444.
***
### yearMonthFromFields()
> **yearMonthFromFields**(`fields`, `options?`): `PlainYearMonth`
Defined in: [src/calendars/HijriCalendar.ts:294](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L294)
ISO-anchored PlainYearMonth per the Temporal Calendar Protocol.
The resulting PlainYearMonth stores ISO coordinates internally, representing
the Hijri month that starts on that ISO year/month.
#### Parameters
##### fields
###### month
`number`
###### year
`number`
##### options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainYearMonth`

647
.github/wiki/api/classes/UaqCalendar.md vendored Normal file
View file

@ -0,0 +1,647 @@
[**temporal-hijri v1.0.1**](../README.md)
***
[temporal-hijri](../README.md) / UaqCalendar
# Class: UaqCalendar
Defined in: [src/calendars/UaqCalendar.ts:16](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/UaqCalendar.ts#L16)
Temporal calendar implementation for the Umm al-Qura calendar.
Umm al-Qura is the official calendar of Saudi Arabia, maintained by the
King Abdulaziz City for Science and Technology (KACST). It is the most
widely used Hijri calendar standard for civil and religious purposes across
the Muslim world. Month boundaries are determined by pre-calculated tables
rather than real-time moon sighting.
Calendar engine: hijri-core UAQ (table-driven, covers 1318-1500 AH).
Calendar ID: "hijri-uaq"
## Extends
- [`HijriCalendar`](HijriCalendar.md)
## Constructors
### Constructor
> **new UaqCalendar**(): `UaqCalendar`
Defined in: [src/calendars/UaqCalendar.ts:17](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/UaqCalendar.ts#L17)
#### Returns
`UaqCalendar`
#### Overrides
[`HijriCalendar`](HijriCalendar.md).[`constructor`](HijriCalendar.md#constructor)
## Properties
### id
> `readonly` **id**: `string`
Defined in: [src/calendars/HijriCalendar.ts:59](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L59)
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`id`](HijriCalendar.md#id)
## Methods
### dateAdd()
> **dateAdd**(`date`, `duration`, `_options?`): `PlainDate`
Defined in: [src/calendars/HijriCalendar.ts:346](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L346)
Add a duration to a Hijri date.
Year and month additions are handled in Hijri space to preserve calendar
semantics (e.g., adding one month to 1 Ramadan yields 1 Shawwal, not a
fixed 30-day offset). Day and week additions are then applied in ISO space
so that they always represent exact day counts.
Month normalization uses O(1) modular arithmetic instead of iterative loops.
When the day-of-month exceeds the target month's length after a Hijri-space
adjustment, it is clamped to the last valid day of that month.
#### Parameters
##### date
`PlainDate`
##### duration
`Duration`
##### \_options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainDate`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dateAdd`](HijriCalendar.md#dateadd)
***
### dateFromFields()
> **dateFromFields**(`fields`, `options?`): `PlainDate`
Defined in: [src/calendars/HijriCalendar.ts:279](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L279)
#### Parameters
##### fields
###### day
`number`
###### month
`number`
###### year
`number`
##### options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainDate`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dateFromFields`](HijriCalendar.md#datefromfields)
***
### dateUntil()
> **dateUntil**(`one`, `two`, `options?`): `Duration`
Defined in: [src/calendars/HijriCalendar.ts:384](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L384)
Compute the difference between two Hijri dates.
For simplicity and correctness across variable-length Hijri months, this
delegates to the underlying ISO PlainDate difference when the largest unit
is days or weeks. Year/month differences require a Hijri-space calculation.
#### Parameters
##### one
`PlainDate`
##### two
`PlainDate`
##### options?
###### largestUnit?
`DateUnit`
#### Returns
`Duration`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dateUntil`](HijriCalendar.md#dateuntil)
***
### day()
> **day**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:169](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L169)
Returns the day of the Hijri month (1-29 or 1-30).
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
Day of month within the Hijri calendar.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`day`](HijriCalendar.md#day)
***
### dayOfWeek()
> **dayOfWeek**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:234](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L234)
ISO weekday: 1 = Monday, 7 = Sunday.
PlainDate.dayOfWeek on an ISO-calendar date already gives ISO weekday,
so no conversion is needed.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dayOfWeek`](HijriCalendar.md#dayofweek)
***
### dayOfYear()
> **dayOfYear**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:242](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L242)
Day within the Hijri year. Accumulates full months before the current one,
then adds the day-of-month offset.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`dayOfYear`](HijriCalendar.md#dayofyear)
***
### daysInMonth()
> **daysInMonth**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:184](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L184)
Returns the number of days in the Hijri month containing the given date.
Hijri months alternate between 29 and 30 days, but the exact pattern
differs by calendar system (UAQ uses fixed tables; FCNA uses calculation).
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
29 or 30.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`daysInMonth`](HijriCalendar.md#daysinmonth)
***
### daysInWeek()
> **daysInWeek**(`_date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:263](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L263)
Returns the number of days in a week.
Always 7. Required by the Temporal Calendar Protocol.
#### Parameters
##### \_date
`PlainDate`
#### Returns
`number`
Always 7.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`daysInWeek`](HijriCalendar.md#daysinweek)
***
### daysInYear()
> **daysInYear**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:193](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L193)
Sum all 12 month lengths for the Hijri year. Standard lunar years are 354
days; leap years (with an added day in Dhul-Hijja) are 355 days.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`daysInYear`](HijriCalendar.md#daysinyear)
***
### fields()
> **fields**(`fields`): `string`[]
Defined in: [src/calendars/HijriCalendar.ts:273](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L273)
Return the list of fields that the calendar adds to a Temporal object.
Non-era calendars return the input array unchanged.
#### Parameters
##### fields
`string`[]
#### Returns
`string`[]
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`fields`](HijriCalendar.md#fields)
***
### inLeapYear()
> **inLeapYear**(`date`): `boolean`
Defined in: [src/calendars/HijriCalendar.ts:223](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L223)
Returns whether the Hijri year is a leap year (355 days).
Standard Hijri years have 354 days. A leap year adds one day to
Dhul-Hijja (month 12), making it 355 days total.
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`boolean`
`true` if the year has 355 days.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`inLeapYear`](HijriCalendar.md#inleapyear)
***
### mergeFields()
> **mergeFields**(`fields`, `additionalFields`): `Record`\<`string`, `unknown`\>
Defined in: [src/calendars/HijriCalendar.ts:414](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L414)
#### Parameters
##### fields
`Record`\<`string`, `unknown`\>
##### additionalFields
`Record`\<`string`, `unknown`\>
#### Returns
`Record`\<`string`, `unknown`\>
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`mergeFields`](HijriCalendar.md#mergefields)
***
### month()
> **month**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:149](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L149)
Returns the Hijri month (1-12) for the given ISO date.
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
Month number 1 (Muharram) through 12 (Dhul-Hijja).
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`month`](HijriCalendar.md#month)
***
### monthCode()
> **monthCode**(`date`): `string`
Defined in: [src/calendars/HijriCalendar.ts:158](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L158)
Month code per the Temporal proposal: "M01".."M12".
Hijri months are always 1-12 (no leap/intercalary month), so the code is
simply the zero-padded month number.
#### Parameters
##### date
`PlainDate`
#### Returns
`string`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`monthCode`](HijriCalendar.md#monthcode)
***
### monthDayFromFields()
> **monthDayFromFields**(`fields`, `options?`): `PlainMonthDay`
Defined in: [src/calendars/HijriCalendar.ts:317](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L317)
ISO-anchored PlainMonthDay per the Temporal Calendar Protocol.
Reference year 1444 is intentional: it is a recent, well-covered UAQ year
used to anchor the ISO coordinates when no year is supplied.
#### Parameters
##### fields
###### day
`number`
###### month
`number`
###### year?
`number`
##### options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainMonthDay`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`monthDayFromFields`](HijriCalendar.md#monthdayfromfields)
***
### monthsInYear()
> **monthsInYear**(`_date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:210](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L210)
Returns the number of months in the Hijri year.
Always 12. Unlike the Hebrew calendar, the Hijri lunar calendar has no
intercalary (leap) month — only a possible extra day in Dhul-Hijja.
#### Parameters
##### \_date
`PlainDate`
#### Returns
`number`
Always 12.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`monthsInYear`](HijriCalendar.md#monthsinyear)
***
### toString()
> **toString**(): `string`
Defined in: [src/calendars/HijriCalendar.ts:66](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L66)
#### Returns
`string`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`toString`](HijriCalendar.md#tostring)
***
### weekOfYear()
> **weekOfYear**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:252](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L252)
Hijri week number counted from day 1 of Muharram (day 1-7 = week 1). No ISO week alignment.
#### Parameters
##### date
`PlainDate`
#### Returns
`number`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`weekOfYear`](HijriCalendar.md#weekofyear)
***
### year()
> **year**(`date`): `number`
Defined in: [src/calendars/HijriCalendar.ts:139](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L139)
Returns the Hijri year for the given ISO date.
#### Parameters
##### date
`PlainDate`
A Temporal.PlainDate with ISO (Gregorian) coordinates.
#### Returns
`number`
The Hijri year, e.g. 1444.
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`year`](HijriCalendar.md#year)
***
### yearMonthFromFields()
> **yearMonthFromFields**(`fields`, `options?`): `PlainYearMonth`
Defined in: [src/calendars/HijriCalendar.ts:294](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/calendars/HijriCalendar.ts#L294)
ISO-anchored PlainYearMonth per the Temporal Calendar Protocol.
The resulting PlainYearMonth stores ISO coordinates internally, representing
the Hijri month that starts on that ISO year/month.
#### Parameters
##### fields
###### month
`number`
###### year
`number`
##### options?
###### overflow?
`"constrain"` \| `"reject"`
#### Returns
`PlainYearMonth`
#### Inherited from
[`HijriCalendar`](HijriCalendar.md).[`yearMonthFromFields`](HijriCalendar.md#yearmonthfromfields)

View file

@ -0,0 +1,111 @@
[**temporal-hijri v1.0.1**](../README.md)
***
[temporal-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 @@
[**temporal-hijri v1.0.1**](../README.md)
***
[temporal-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 @@
[**temporal-hijri v1.0.1**](../README.md)
***
[temporal-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

View file

@ -0,0 +1,11 @@
[**temporal-hijri v1.0.1**](../README.md)
***
[temporal-hijri](../README.md) / fcnaCalendar
# Variable: fcnaCalendar
> `const` **fcnaCalendar**: [`FcnaCalendar`](../classes/FcnaCalendar.md)
Defined in: [src/index.ts:12](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/index.ts#L12)

View file

@ -0,0 +1,11 @@
[**temporal-hijri v1.0.1**](../README.md)
***
[temporal-hijri](../README.md) / uaqCalendar
# Variable: uaqCalendar
> `const` **uaqCalendar**: [`UaqCalendar`](../classes/UaqCalendar.md)
Defined in: [src/index.ts:11](https://github.com/acamarata/temporal-hijri/blob/077861c7dc33e5562fb75c1defbee0f2db4e2f2a/src/index.ts#L11)

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

@ -0,0 +1,47 @@
# Performance Benchmarks
## Conversion performance
Measured on Node 22, Apple M2. Input: 1,000 random dates in range 1900-2076 CE.
| Operation | UAQ calendar | FCNA calendar |
|---|---|---|
| `uaqCalendar.year(date)` | ~0.5 µs/call | ~14 µs/call |
| `uaqCalendar.dateFromFields(fields)` | ~0.7 µs/call | ~15 µs/call |
| `uaqCalendar.dateUntil(d1, d2)` | ~1.1 µs/call | ~16 µs/call |
| `uaqCalendar.dateAdd(date, duration)` | ~1.3 µs/call | ~17 µs/call |
UAQ uses a precomputed lookup table (O(1) lookup). FCNA uses an arithmetic algorithm per call, which accounts for the ~26x difference.
The Temporal polyfill itself adds overhead on top of these numbers. With native Temporal support (future Node.js versions and browsers), the overhead will be lower.
## Bundle size
| Module | Min+gz |
|---|---|
| temporal-hijri (wrapper only) | ~1.4 KB |
| hijri-core/uaq (peer dep, UAQ engine) | ~5.3 KB |
| hijri-core/fcna (peer dep, FCNA engine) | ~3.1 KB |
| @js-temporal/polyfill (peer dep, optional) | ~39 KB |
When native `Temporal` is available in the runtime, the polyfill is not needed, which removes its bundle cost entirely.
## Reproducing the benchmarks
```javascript
import { Temporal } from '@js-temporal/polyfill';
import { uaqCalendar } from 'temporal-hijri';
const dates = Array.from({ length: 1000 }, (_, i) =>
Temporal.PlainDate.from('1900-01-01').add({ days: i * 26 })
);
const start = performance.now();
for (const d of dates) {
uaqCalendar.year(d);
}
const elapsed = performance.now() - start;
console.log(`${(elapsed / dates.length * 1000).toFixed(1)} µs/call`);
```
Run with `node --version` >= 20.

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

@ -0,0 +1,76 @@
# Basic Usage Examples
## Setup
```typescript
import { Temporal } from '@js-temporal/polyfill';
import { uaqCalendar } from 'temporal-hijri';
```
## Convert a Gregorian date to Hijri
```typescript
// 23 March 2023 = 1 Ramadan 1444 AH
const isoDate = Temporal.PlainDate.from('2023-03-23');
console.log(uaqCalendar.year(isoDate)); // 1444
console.log(uaqCalendar.month(isoDate)); // 9 (Ramadan is the 9th month)
console.log(uaqCalendar.day(isoDate)); // 1
```
## Read today's Hijri date
```typescript
import { Temporal } from '@js-temporal/polyfill';
import { uaqCalendar } from 'temporal-hijri';
const today = Temporal.Now.plainDateISO();
const hy = uaqCalendar.year(today);
const hm = uaqCalendar.month(today);
const hd = uaqCalendar.day(today);
console.log(`${hd} / ${hm} / ${hy}`);
```
## Create a Hijri date and convert to ISO
```typescript
const ramadan1 = uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 1 });
console.log(ramadan1.toString()); // '2023-03-23'
```
## Add Hijri months
```typescript
const start = uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 1 });
const twoMonthsLater = uaqCalendar.dateAdd(start, new Temporal.Duration(0, 0, 0, 2 * 29));
// Durations use days — calculate from expected month length
// Or use dateUntil to measure between two dates
const end = Temporal.PlainDate.from('2023-05-20');
const diff = uaqCalendar.dateUntil(start, end, { largestUnit: 'months' });
console.log(diff.months, diff.days);
```
## Use FCNA calendar
```typescript
import { fcnaCalendar } from 'temporal-hijri';
const isoDate = Temporal.PlainDate.from('2023-03-23');
console.log(fcnaCalendar.year(isoDate)); // 1444
console.log(fcnaCalendar.month(isoDate)); // 9
console.log(fcnaCalendar.day(isoDate)); // 1
// Near month boundaries, UAQ and FCNA may differ by one day
```
## CJS usage
```javascript
const { Temporal } = require('@js-temporal/polyfill');
const { uaqCalendar } = require('temporal-hijri');
const d = Temporal.PlainDate.from('2023-03-23');
console.log(uaqCalendar.year(d)); // 1444
```

View file

@ -0,0 +1,99 @@
# Example: Scheduling Display with Hijri Dates
A common need in calendaring apps for Muslim communities is displaying both the
Gregorian and Hijri dates for an event. This example shows how to take a list of
event dates, annotate each with its Hijri date, and display it in a human-readable
format.
## Setup
```typescript
import { Temporal } from '@js-temporal/polyfill';
import { uaqCalendar } from 'temporal-hijri';
```
## Month name lookup
The calendar returns numeric months (1-12). Map them to names:
```typescript
const HIJRI_MONTHS = [
'Muharram', 'Safar', 'Rabi al-Awwal', 'Rabi al-Thani',
'Jumada al-Ula', 'Jumada al-Akhira', 'Rajab', 'Shaban',
'Ramadan', 'Shawwal', 'Dhul-Qadah', 'Dhul-Hijja',
];
function hijriMonthName(month: number): string {
return HIJRI_MONTHS[month - 1] ?? 'Unknown';
}
```
## Format a single date
```typescript
function formatWithHijri(isoDateStr: string): string {
const isoDate = Temporal.PlainDate.from(isoDateStr);
const hy = uaqCalendar.year(isoDate);
const hm = uaqCalendar.month(isoDate);
const hd = uaqCalendar.day(isoDate);
const monthName = hijriMonthName(hm);
return `${isoDateStr} (${hd} ${monthName} ${hy} AH)`;
}
```
## Annotate a schedule
```typescript
const events = [
{ title: 'Project kickoff', date: '2025-01-01' },
{ title: 'Mid-year review', date: '2025-06-15' },
{ title: 'Year-end summary', date: '2025-12-31' },
];
for (const event of events) {
console.log(`${event.title}: ${formatWithHijri(event.date)}`);
}
```
Output:
```
Project kickoff: 2025-01-01 (2 Rajab 1446 AH)
Mid-year review: 2025-06-15 (19 Dhul-Hijja 1446 AH)
Year-end summary: 2025-12-31 (11 Jumada al-Akhira 1447 AH)
```
## Find the start of Ramadan for a given Hijri year
```typescript
function ramadanStart(hijriYear: number): Temporal.PlainDate {
// 1 Ramadan = month 9, day 1
return uaqCalendar.dateFromFields({ year: hijriYear, month: 9, day: 1 });
}
const ramadan1447 = ramadanStart(1447);
console.log(ramadan1447.toString()); // 2026-02-18 (approximate)
```
## Count days until an event in Hijri months
```typescript
const today = Temporal.Now.plainDateISO();
const eid = uaqCalendar.dateFromFields({ year: 1447, month: 10, day: 1 });
const diff = uaqCalendar.dateUntil(today, eid, { largestUnit: 'months' });
console.log(`Eid al-Fitr 1447 is in ${diff.months} month(s) and ${diff.days} day(s)`);
```
## Notes
- Month names are transliterated from Arabic. Adapt the spelling to your style guide.
- UAQ covers 1318-1500 AH. For dates outside that range, substitute `fcnaCalendar`.
- `Temporal.Now.plainDateISO()` returns the current date in the host's local calendar.
It does not return a Hijri date directly; pass the result to the calendar methods
to get Hijri coordinates.
---
[Home](../Home) · [Basic Usage](basic-usage) · [API Reference](../API-Reference)

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

@ -0,0 +1,91 @@
# Advanced Usage
## Custom calendar engines
Any engine registered in hijri-core can be wrapped in a Temporal calendar:
```typescript
import { HijriCalendar } from 'temporal-hijri';
import { registerCalendar, getCalendar } from 'hijri-core';
import type { CalendarEngine } from 'hijri-core';
const myEngine: CalendarEngine = {
id: 'local-sighting',
toHijri(date) { /* ... */ return { hy, hm, hd }; },
toGregorian(hy, hm, hd) { /* ... */ return new Date(...); },
isValid(hy, hm, hd) { /* ... */ return true; },
daysInMonth(hy, hm) { /* ... */ return 29; },
};
registerCalendar('local-sighting', myEngine);
const cal = new HijriCalendar(getCalendar('local-sighting'));
// cal.id === 'hijri-local-sighting'
```
## DateUntil with different largestUnit values
`dateUntil` respects the `largestUnit` option:
```typescript
import { Temporal } from '@js-temporal/polyfill';
import { uaqCalendar } from 'temporal-hijri';
const start = Temporal.PlainDate.from('2023-01-01');
const end = Temporal.PlainDate.from('2023-12-31');
const inYears = uaqCalendar.dateUntil(start, end, { largestUnit: 'years' });
const inMonths = uaqCalendar.dateUntil(start, end, { largestUnit: 'months' });
const inDays = uaqCalendar.dateUntil(start, end, { largestUnit: 'days' });
console.log(inYears.years, inYears.months, inYears.days);
console.log(inMonths.months, inMonths.days);
console.log(inDays.days);
```
Note: the result measures in Hijri units. One Hijri year is 354 or 355 days, so `inYears.days` may differ from what you would expect in Gregorian.
## PlainYearMonth and PlainMonthDay
```typescript
import { Temporal } from '@js-temporal/polyfill';
import { uaqCalendar } from 'temporal-hijri';
// Year-month in Hijri
const ym = uaqCalendar.yearMonthFromFields({ year: 1444, month: 9 });
console.log(ym.toString()); // ISO year-month of 1 Ramadan 1444
// Month-day in Hijri
const md = uaqCalendar.monthDayFromFields({ month: 9, day: 1 });
console.log(md.toString()); // ISO month-day of Ramadan 1st
```
## Out-of-range behavior
UAQ covers 1318-1500 AH (1900-2076 CE). Requesting dates outside that range throws `RangeError`:
```typescript
const earlyDate = Temporal.PlainDate.from('1800-01-01');
try {
uaqCalendar.year(earlyDate); // throws RangeError
} catch (e) {
if (e instanceof RangeError) {
// Use FCNA for unbounded coverage
import { fcnaCalendar } from 'temporal-hijri';
console.log(fcnaCalendar.year(earlyDate));
}
}
```
## Using with native Temporal
When native `Temporal` is available (future Node.js or browsers), you can use it directly without the polyfill:
```typescript
// No import from @js-temporal/polyfill
import { uaqCalendar } from 'temporal-hijri';
const isoDate = Temporal.PlainDate.from('2023-03-23');
console.log(uaqCalendar.year(isoDate)); // 1444
```
The `uaqCalendar` and `fcnaCalendar` objects implement the `Temporal.CalendarProtocol` interface and work with any spec-conforming implementation.

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

@ -0,0 +1,98 @@
# Quick Start
This guide covers the most common use cases in temporal-hijri. All examples use `uaqCalendar` (Umm al-Qura). For FCNA/ISNA output, substitute `fcnaCalendar`.
## Installation
```bash
pnpm add temporal-hijri hijri-core @js-temporal/polyfill
```
`hijri-core` is required. `@js-temporal/polyfill` is required in environments without native `Temporal` support. In environments with native Temporal (Node 22+ with the flag, or future standard support), omit the polyfill.
## Import
```typescript
import { Temporal } from '@js-temporal/polyfill'; // or use native Temporal
import { uaqCalendar } from 'temporal-hijri';
```
## Convert an ISO date to Hijri
```typescript
const isoDate = Temporal.PlainDate.from('2023-03-23');
console.log(uaqCalendar.year(isoDate)); // 1444
console.log(uaqCalendar.month(isoDate)); // 9
console.log(uaqCalendar.day(isoDate)); // 1
console.log(uaqCalendar.monthCode(isoDate)); // 'M09'
```
## Convert Hijri coordinates to ISO
```typescript
const ramadan = uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 1 });
console.log(ramadan.toString()); // '2023-03-23'
```
## Date arithmetic in Hijri space
```typescript
const { Duration } = Temporal;
const isoDate = Temporal.PlainDate.from('2023-03-23');
// Add one Hijri month
const nextMonth = uaqCalendar.dateAdd(isoDate, new Duration(0, 1));
console.log(uaqCalendar.month(nextMonth)); // 10 (Shawwal)
console.log(nextMonth.toString()); // '2023-04-21'
// Get the difference between two dates
const earlier = Temporal.PlainDate.from('2023-01-01');
const later = Temporal.PlainDate.from('2023-03-23');
const diff = uaqCalendar.dateUntil(earlier, later, { largestUnit: 'months' });
console.log(diff.months); // 2 (in Hijri months)
```
## Use the FCNA calendar
```typescript
import { fcnaCalendar } from 'temporal-hijri';
const isoDate = Temporal.PlainDate.from('2023-03-23');
console.log(fcnaCalendar.year(isoDate)); // 1444
console.log(fcnaCalendar.month(isoDate)); // 9 or may differ by 1 near month start
```
## Singletons vs classes
The package exports convenience singletons for the common case:
```typescript
import { uaqCalendar, fcnaCalendar } from 'temporal-hijri';
```
If you need to construct a calendar from a custom hijri-core engine:
```typescript
import { HijriCalendar } from 'temporal-hijri';
import { registerCalendar, getCalendar } from 'hijri-core';
registerCalendar('my-engine', myEngine);
const cal = new HijriCalendar(getCalendar('my-engine'));
```
## CommonJS
```js
const { Temporal } = require('@js-temporal/polyfill');
const { uaqCalendar } = require('temporal-hijri');
const isoDate = Temporal.PlainDate.from('2023-03-23');
console.log(uaqCalendar.year(isoDate)); // 1444
```
## Next steps
- [API Reference](API-Reference) for all calendar protocol methods
- [Architecture](Architecture) for how the Temporal Calendar Protocol is implemented

View file

@ -15,22 +15,39 @@ 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 }}
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
- 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 lint
- run: pnpm run format:check
typecheck:
name: Typecheck
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
@ -43,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
@ -60,3 +78,25 @@ 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
- name: Coverage
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

View file

@ -4,7 +4,7 @@ on:
push:
branches: [main]
paths:
- '.wiki/**'
- '.github/wiki/**'
permissions:
contents: write
@ -16,10 +16,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sync .wiki/ to GitHub Wiki
- name: Sync .github/wiki/ to GitHub Wiki
uses: Andrew-Chen-Wang/github-wiki-action@v4
with:
path: .wiki/
path: .github/wiki/
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}

11
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules/
dist/
coverage/
*.tgz
*.log
.DS_Store
@ -9,5 +10,15 @@ dist/
# AI agent directories
.aider*
.copilot/
.cursor/
.continue/
.windsurf/
.codeium/
.tabnine/
.vscode/*
.idea/
.codex/
.aider/
.aider.chat.history.md
.gemini/

View file

@ -2,18 +2,35 @@
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.0.0/),
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).
## [1.0.0] - 2026-02-25
## [1.0.3] - 2026-06-10
### Fixed
- Date handed to hijri-core is now built via `Date.UTC()` to match hijri-core's UTC-day
contract; fixes previous-day results on east-of-UTC hosts (e.g. UTC+5, UTC+8).
Requires hijri-core 1.0.3.
## [1.0.2] - 2026-05-30
### Added
- TSDoc comments on all public `HijriCalendar` methods
- `HijriCalendar` base class implementing the TC39 Temporal Calendar Protocol
- `UaqCalendar`: Umm al-Qura calendar (table-driven, 1318-1500 AH coverage)
- `FcnaCalendar`: FCNA/ISNA calendar (astronomical new moon calculation via Meeus)
- `uaqCalendar` and `fcnaCalendar` convenience singletons
- Full Temporal protocol: `year`, `month`, `monthCode`, `day`, `daysInMonth`, `daysInYear`, `monthsInYear`, `inLeapYear`, `dayOfWeek`, `dayOfYear`, `weekOfYear`, `daysInWeek`, `dateFromFields`, `yearMonthFromFields`, `monthDayFromFields`, `dateAdd`, `dateUntil`, `mergeFields`, `toString`
- Dual CJS and ESM builds with TypeScript declarations
- Peer dependency on `hijri-core ^1.0.0` for conversion logic
- Optional peer dependency on `@js-temporal/polyfill ^0.4.0`
### Changed
- README condensed; quickstart trimmed to essential examples
- CI: corepack before setup-node, prettier scoped to src/, d.mts emitted via postbuild
- Adopt shared config packages (@acamarata/eslint-config, @acamarata/prettier-config, @acamarata/tsconfig)
## [1.0.1] - 2026-05-28
### Changed
- Flatten exports map to ADR-015 standard (import/require/types at top level)
- Add "./package.json" export condition
- Add coverage script (c8 --reporter=lcov)
- Migrate CI from pnpm/action-setup to corepack enable
## [1.0.0] - 2026-05-28
### Added
- Initial release

188
README.md
View file

@ -4,195 +4,79 @@
# temporal-hijri
Temporal Calendar Protocol implementation for the Hijri calendar. Works with the TC39 Temporal proposal and `@js-temporal/polyfill`.
Temporal Calendar Protocol implementation for the Hijri calendar. Works with the TC39
Temporal proposal (Stage 3) and `@js-temporal/polyfill`.
Provides `UaqCalendar` (Umm al-Qura) and `FcnaCalendar` (FCNA/ISNA) as plug-in calendars for `Temporal.PlainDate` and related types. The underlying conversion logic comes from [hijri-core](https://github.com/acamarata/hijri-core), a zero-dependency Hijri engine with table-driven UAQ data and astronomical FCNA calculations.
---
Provides `UaqCalendar` (Umm al-Qura) and `FcnaCalendar` (FCNA/ISNA) as plug-in
calendars for `Temporal.PlainDate`. The underlying conversion logic comes from
[hijri-core](https://github.com/acamarata/hijri-core).
## Installation
```bash
pnpm add temporal-hijri hijri-core
# Add the polyfill if native Temporal is unavailable:
pnpm add @js-temporal/polyfill
```
If you are using the polyfill instead of the native `Temporal` API:
```bash
pnpm add temporal-hijri hijri-core @js-temporal/polyfill
```
---
## Quick Start
```typescript
import { Temporal } from '@js-temporal/polyfill'; // or use native Temporal
import { Temporal } from '@js-temporal/polyfill';
import { uaqCalendar } from 'temporal-hijri';
// Convert an ISO date to Hijri coordinates
const isoDate = Temporal.PlainDate.from('2023-03-23');
console.log(uaqCalendar.year(isoDate)); // 1444
console.log(uaqCalendar.month(isoDate)); // 9 (Ramadan)
console.log(uaqCalendar.day(isoDate)); // 1
console.log(uaqCalendar.monthCode(isoDate)); // "M09"
console.log(uaqCalendar.inLeapYear(isoDate)); // false (1444 is 354 days)
// Convert Hijri coordinates back to ISO
// Convert Hijri coordinates to ISO
const ramadan = uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 1 });
console.log(ramadan.toString()); // "2023-03-23"
// Arithmetic in Hijri space
// Date arithmetic in Hijri space
const { Duration } = Temporal;
const nextMonth = uaqCalendar.dateAdd(isoDate, new Duration(0, 1));
console.log(uaqCalendar.month(nextMonth)); // 10 (Shawwal)
console.log(nextMonth.toString()); // "2023-04-21"
```
---
## Calendars
## Calendar Classes
### `UaqCalendar`
Implements the Umm al-Qura calendar, the official calendar of Saudi Arabia. Month boundaries come from pre-calculated tables covering 1318-1500 AH (Gregorian 1900-2076). The most widely used Hijri calendar standard for civil and religious purposes.
```typescript
import { UaqCalendar } from 'temporal-hijri';
const cal = new UaqCalendar(); // cal.id === 'hijri-uaq'
```
### `FcnaCalendar`
Implements the FCNA/ISNA calendar used by the Fiqh Council of North America and the Islamic Society of North America. Month starts are determined by astronomical new moon calculation (Meeus Chapter 49): if conjunction occurs before 12:00 UTC, the month begins the next day; if at or after noon, it begins the day after that.
```typescript
import { FcnaCalendar } from 'temporal-hijri';
const cal = new FcnaCalendar(); // cal.id === 'hijri-fcna'
```
### `HijriCalendar` (base class)
The base implementation. Accepts any `CalendarEngine` from hijri-core. Use this to build a Temporal calendar from a custom engine registered via `hijri-core`'s `registerCalendar()`.
```typescript
import { HijriCalendar } from 'temporal-hijri';
import { getCalendar, registerCalendar } from 'hijri-core';
// Register a custom engine first
registerCalendar('my-calendar', myEngine);
const cal = new HijriCalendar(getCalendar('my-calendar'));
// cal.id === 'hijri-my-calendar'
```
### Convenience singletons
`uaqCalendar` and `fcnaCalendar` are pre-constructed instances. They are shared objects and safe to reuse across calls.
```typescript
import { uaqCalendar, fcnaCalendar } from 'temporal-hijri';
```
---
## API
All methods receive a `Temporal.PlainDate` with an ISO (Gregorian) calendar. The PlainDate carries the ISO year/month/day; the calendar object interprets those coordinates.
| Method | Returns | Description |
|---|---|---|
| `year(date)` | `number` | Hijri year |
| `month(date)` | `number` | Hijri month (1-12) |
| `monthCode(date)` | `string` | Month code: `"M01"` through `"M12"` |
| `day(date)` | `number` | Day of the Hijri month (1-29 or 1-30) |
| `daysInMonth(date)` | `number` | Length of the Hijri month (29 or 30) |
| `daysInYear(date)` | `number` | Days in the Hijri year (354 or 355) |
| `monthsInYear(date)` | `number` | Always `12` |
| `inLeapYear(date)` | `boolean` | `true` if the year has 355 days |
| `dayOfWeek(date)` | `number` | ISO weekday: 1=Monday, 7=Sunday |
| `dayOfYear(date)` | `number` | Day position within the Hijri year |
| `weekOfYear(date)` | `number` | Week position within the Hijri year |
| `daysInWeek(date)` | `number` | Always `7` |
| `dateFromFields(fields)` | `Temporal.PlainDate` | Construct ISO PlainDate from `{year, month, day}` in Hijri |
| `yearMonthFromFields(fields)` | `Temporal.PlainYearMonth` | Construct from `{year, month}` in Hijri |
| `monthDayFromFields(fields)` | `Temporal.PlainMonthDay` | Construct from `{month, day}` in Hijri |
| `dateAdd(date, duration)` | `Temporal.PlainDate` | Add a duration; years/months applied in Hijri space, days in ISO space |
| `dateUntil(one, two, options)` | `Temporal.Duration` | Difference between two dates; supports `largestUnit: 'years'|'months'|'days'|'weeks'` |
| `mergeFields(fields, additional)` | `Record` | Merge field objects (Temporal protocol requirement) |
| `toString()` | `string` | Calendar identifier (`"hijri-uaq"` or `"hijri-fcna"`) |
---
## Calendar Systems
| System | ID | Authority | Method | Coverage |
|---|---|---|---|---|
| Umm al-Qura | `hijri-uaq` | KACST / Saudi Arabia | Pre-calculated tables | 1318-1500 AH (1900-2076 CE) |
| FCNA/ISNA | `hijri-fcna` | Fiqh Council of North America | Astronomical new moon (Meeus) | Unlimited (calculated) |
UAQ dates outside 1318-1500 AH throw `RangeError`. FCNA is unbounded but loses precision for very early dates.
---
## Custom Calendars
Any engine registered in hijri-core can be wrapped in a Temporal calendar:
```typescript
import { HijriCalendar } from 'temporal-hijri';
import { registerCalendar, getCalendar } from 'hijri-core';
import type { CalendarEngine } from 'hijri-core';
const myEngine: CalendarEngine = {
id: 'local-sighting',
toHijri(date) { /* ... */ return { hy, hm, hd }; },
toGregorian(hy, hm, hd) { /* ... */ return new Date(...); },
isValid(hy, hm, hd) { /* ... */ return true; },
daysInMonth(hy, hm) { /* ... */ return 29; },
};
registerCalendar('local-sighting', myEngine);
const cal = new HijriCalendar(getCalendar('local-sighting'));
// cal.id === 'hijri-local-sighting'
```
---
## TypeScript
All types are exported:
```typescript
import type { HijriDate, ConversionOptions, HijriCalendarOptions } from 'temporal-hijri';
```
The package ships dual CJS/ESM builds with full `.d.ts` and `.d.mts` declarations.
---
## Compatibility
- Node.js 20, 22, 24
- Any bundler supporting `exports` field (`Vite`, `Webpack 5`, `Rollup`, `esbuild`)
- ESM (`import`) and CommonJS (`require`): both provided
- No native `Temporal` required: works entirely with `@js-temporal/polyfill`
---
| Calendar | ID | Authority | Method | Coverage |
|-------------|--------------|--------------------|--------------------------|------------------|
| Umm al-Qura | `hijri-uaq` | KACST, Saudi Arabia | Pre-calculated tables | 1318-1500 AH |
| FCNA/ISNA | `hijri-fcna` | Fiqh Council of NA | Astronomical new moon | Unbounded |
## Documentation
Full reference, architecture notes, and algorithmic detail in the [wiki](https://github.com/acamarata/temporal-hijri/wiki).
Full reference in the [wiki](https://github.com/acamarata/temporal-hijri/wiki).
---
- [API Reference](https://github.com/acamarata/temporal-hijri/wiki/API-Reference)
- [Architecture](https://github.com/acamarata/temporal-hijri/wiki/Architecture)
- [Examples](https://github.com/acamarata/temporal-hijri/wiki/examples/basic-usage)
## Conversion behavior
Conversions between ISO and Hijri dates are pure calendar-date mappings: the same
ISO date always maps to the same Hijri date on every machine, regardless of the host's
timezone. `Temporal.PlainDate` carries no time-of-day information, and the underlying
hijri-core engine operates on UTC calendar days, so there is no timezone dependency.
Note: the Islamic calendar begins a new day at sunset, not midnight. This library
follows the civil-calendar convention (midnight boundary) used by most software. Sunset
day-start determination is out of scope.
## Related
- [hijri-core](https://github.com/acamarata/hijri-core): zero-dependency Hijri engine powering this package
- [luxon-hijri](https://github.com/acamarata/luxon-hijri): Hijri/Gregorian conversion for Luxon
- [hijri-core](https://github.com/acamarata/hijri-core): the underlying calendar engine
- [luxon-hijri](https://github.com/acamarata/luxon-hijri): Hijri support for Luxon
- [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer times
---
## Telemetry
This package supports opt-in anonymous usage telemetry — off by default.
Enable: `ACAMARATA_TELEMETRY=1`. See [TELEMETRY.md](./TELEMETRY.md) for what is sent and how to disable.
## License

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)

20
eslint.config.mjs Normal file
View file

@ -0,0 +1,20 @@
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 [
{
files: ["**/*.ts"],
plugins: { "@typescript-eslint": tsPlugin },
languageOptions: {
parser: tsParser,
parserOptions: { project: true, tsconfigRootDir: import.meta.dirname },
},
},
...typescript.map((config) => ({ files: ["**/*.ts"], ...config })),
eslintConfigPrettier,
{
ignores: ["dist/", "node_modules/", "test.mjs", "test-cjs.cjs"],
},
];

View file

@ -1,6 +1,6 @@
{
"name": "temporal-hijri",
"version": "1.0.0",
"version": "1.0.3",
"description": "Temporal Calendar Protocol implementation for the Hijri calendar system. Supports Umm al-Qura and FCNA calendars via hijri-core.",
"author": "Aric Camarata",
"license": "MIT",
@ -9,9 +9,11 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.cjs" }
}
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
@ -23,14 +25,22 @@
"CHANGELOG.md",
"LICENSE"
],
"engines": { "node": ">=20" },
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@10.30.1",
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"format": "prettier --write src/ test.mjs test-cjs.cjs eslint.config.mjs tsup.config.ts",
"format:check": "prettier --check src/ test.mjs test-cjs.cjs eslint.config.mjs tsup.config.ts",
"pretest": "tsup",
"test": "node test.mjs && node test-cjs.cjs",
"prepublishOnly": "tsup"
"test": "node --test test.mjs && node --test test-cjs.cjs",
"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"
},
"keywords": [
"temporal",
@ -45,8 +55,8 @@
"typescript"
],
"peerDependencies": {
"hijri-core": "^1.0.0",
"@js-temporal/polyfill": "^0.4.0"
"@js-temporal/polyfill": "^0.4.0",
"hijri-core": "^1.0.0"
},
"peerDependenciesMeta": {
"@js-temporal/polyfill": {
@ -54,14 +64,38 @@
}
},
"devDependencies": {
"@acamarata/eslint-config": "^0.1.0",
"@acamarata/prettier-config": "^0.1.0",
"@acamarata/tsconfig": "^0.1.0",
"@eslint/js": "^10.0.1",
"@js-temporal/polyfill": "^0.4.4",
"@types/node": "^22.0.0",
"hijri-core": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "^11.0.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"hijri-core": "^1.0.3",
"prettier": "^3.8.1",
"tsup": "^8.0.0",
"typescript": "^5.5.0"
"typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.56.1",
"@acamarata/telemetry": "^0.1.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/acamarata/temporal-hijri.git"
},
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
"repository": { "type": "git", "url": "git+https://github.com/acamarata/temporal-hijri.git" },
"homepage": "https://github.com/acamarata/temporal-hijri#readme",
"bugs": { "url": "https://github.com/acamarata/temporal-hijri/issues" }
"bugs": {
"url": "https://github.com/acamarata/temporal-hijri/issues"
},
"type": "module",
"prettier": "@acamarata/prettier-config"
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import { getCalendar } from 'hijri-core';
import { HijriCalendar } from './HijriCalendar';
import { getCalendar } from "hijri-core";
import { HijriCalendar } from "./HijriCalendar";
/**
* Temporal calendar implementation for the FCNA/ISNA calendar.
@ -17,6 +17,6 @@ import { HijriCalendar } from './HijriCalendar';
*/
export class FcnaCalendar extends HijriCalendar {
constructor() {
super(getCalendar('fcna'));
super(getCalendar("fcna"));
}
}

View file

@ -1,7 +1,44 @@
import { Temporal } from '@js-temporal/polyfill';
import type { CalendarEngine } from 'hijri-core';
import { Temporal } from "@js-temporal/polyfill";
import type { CalendarEngine } from "hijri-core";
type DateUnit = 'year' | 'years' | 'month' | 'months' | 'week' | 'weeks' | 'day' | 'days';
type DateUnit = "year" | "years" | "month" | "months" | "week" | "weeks" | "day" | "days";
/** Reference year for monthDay construction when no year is specified. */
const REFERENCE_YEAR = 1444;
/**
* Borrow days/months so that a Hijri date difference has non-negative components.
*
* Shared by the 'years' and 'months' branches of dateUntil() to avoid
* duplicating the borrow logic.
*/
function borrowHijriDiff(
engine: CalendarEngine,
years: number,
months: number,
days: number,
h2: { hy: number; hm: number },
): { years: number; months: number; days: number } {
// Borrow from months when days are negative.
if (days < 0) {
months--;
let borrowHm = h2.hm - 1;
let borrowHy = h2.hy;
if (borrowHm < 1) {
borrowHm = 12;
borrowHy--;
}
days += engine.daysInMonth(borrowHy, borrowHm);
}
// Borrow from years when months are negative.
if (months < 0) {
years--;
months += 12;
}
return { years, months, days };
}
/**
* Base class implementing the TC39 Temporal Calendar Protocol for Hijri calendars.
@ -33,19 +70,17 @@ export class HijriCalendar {
/**
* Convert a Temporal.PlainDate (ISO calendar) to Hijri coordinates.
*
* Uses the local-time Date constructor so that the date components passed to
* the engine match the calendar date exactly, regardless of host timezone.
* The UAQ engine reads local components; the FCNA engine reads UTC components.
* Because we construct with new Date(y, m, d) the local date always matches
* the intended calendar date.
* PlainDate calendar fields are placed in the Date's UTC components via
* Date.UTC() because hijri-core reads the UTC calendar day. This ensures
* the conversion returns the correct Hijri date on every host timezone:
* without Date.UTC, on east-of-UTC hosts (e.g. UTC+5) the local midnight
* falls on the previous UTC day, causing a one-day-off result.
*/
protected toHijri(date: Temporal.PlainDate): { hy: number; hm: number; hd: number } {
const jsDate = new Date(date.year, date.month - 1, date.day);
const jsDate = new Date(Date.UTC(date.year, date.month - 1, date.day));
const hijri = this.engine.toHijri(jsDate);
if (!hijri) {
throw new RangeError(
`Date ${date.toString()} is out of range for the ${this.id} calendar`
);
throw new RangeError(`Date ${date.toString()} is out of range for the ${this.id} calendar`);
}
return hijri;
}
@ -60,7 +95,7 @@ export class HijriCalendar {
const greg = this.engine.toGregorian(hy, hm, hd);
if (!greg) {
throw new RangeError(
`Hijri date ${hy}/${hm}/${hd} is out of range for the ${this.id} calendar`
`Hijri date ${hy}/${hm}/${hd} is out of range for the ${this.id} calendar`,
);
}
return Temporal.PlainDate.from({
@ -70,12 +105,47 @@ export class HijriCalendar {
});
}
/**
* Resolve the overflow option from a Temporal options bag.
* Returns 'constrain' (the default) or 'reject'.
*/
private resolveOverflow(options?: { overflow?: "constrain" | "reject" }): "constrain" | "reject" {
return options?.overflow ?? "constrain";
}
/**
* Clamp or reject a day value based on the overflow setting.
*/
private applyOverflow(
day: number,
maxDay: number,
month: number,
overflow: "constrain" | "reject",
): number {
if (overflow === "reject" && day > maxDay) {
throw new RangeError(`Day ${day} exceeds ${maxDay} days in month ${month}`);
}
return Math.min(day, maxDay);
}
// ── Field accessors ───────────────────────────────────────────────────────
/**
* Returns the Hijri year for the given ISO date.
*
* @param date - A Temporal.PlainDate with ISO (Gregorian) coordinates.
* @returns The Hijri year, e.g. 1444.
*/
year(date: Temporal.PlainDate): number {
return this.toHijri(date).hy;
}
/**
* Returns the Hijri month (1-12) for the given ISO date.
*
* @param date - A Temporal.PlainDate with ISO (Gregorian) coordinates.
* @returns Month number 1 (Muharram) through 12 (Dhul-Hijja).
*/
month(date: Temporal.PlainDate): number {
return this.toHijri(date).hm;
}
@ -87,15 +157,30 @@ export class HijriCalendar {
*/
monthCode(date: Temporal.PlainDate): string {
const { hm } = this.toHijri(date);
return `M${String(hm).padStart(2, '0')}`;
return `M${String(hm).padStart(2, "0")}`;
}
/**
* Returns the day of the Hijri month (1-29 or 1-30).
*
* @param date - A Temporal.PlainDate with ISO (Gregorian) coordinates.
* @returns Day of month within the Hijri calendar.
*/
day(date: Temporal.PlainDate): number {
return this.toHijri(date).hd;
}
// ── Month and year metrics ─────────────────────────────────────────────────
/**
* Returns the number of days in the Hijri month containing the given date.
*
* Hijri months alternate between 29 and 30 days, but the exact pattern
* differs by calendar system (UAQ uses fixed tables; FCNA uses calculation).
*
* @param date - A Temporal.PlainDate with ISO (Gregorian) coordinates.
* @returns 29 or 30.
*/
daysInMonth(date: Temporal.PlainDate): number {
const { hy, hm } = this.toHijri(date);
return this.engine.daysInMonth(hy, hm);
@ -114,10 +199,27 @@ export class HijriCalendar {
return total;
}
/**
* Returns the number of months in the Hijri year.
*
* Always 12. Unlike the Hebrew calendar, the Hijri lunar calendar has no
* intercalary (leap) month only a possible extra day in Dhul-Hijja.
*
* @returns Always 12.
*/
monthsInYear(_date: Temporal.PlainDate): number {
return 12;
}
/**
* Returns whether the Hijri year is a leap year (355 days).
*
* Standard Hijri years have 354 days. A leap year adds one day to
* Dhul-Hijja (month 12), making it 355 days total.
*
* @param date - A Temporal.PlainDate with ISO (Gregorian) coordinates.
* @returns `true` if the year has 355 days.
*/
inLeapYear(date: Temporal.PlainDate): boolean {
return this.daysInYear(date) === 355;
}
@ -151,17 +253,37 @@ export class HijriCalendar {
return Math.ceil(this.dayOfYear(date) / 7);
}
/**
* Returns the number of days in a week.
*
* Always 7. Required by the Temporal Calendar Protocol.
*
* @returns Always 7.
*/
daysInWeek(_date: Temporal.PlainDate): number {
return 7;
}
// ── Temporal Calendar Protocol: fields() ──────────────────────────────────
/**
* Return the list of fields that the calendar adds to a Temporal object.
* Non-era calendars return the input array unchanged.
*/
fields(fields: string[]): string[] {
return fields;
}
// ── Construction from fields ───────────────────────────────────────────────
dateFromFields(
fields: { year: number; month: number; day: number },
_options?: { overflow?: 'constrain' | 'reject' }
options?: { overflow?: "constrain" | "reject" },
): Temporal.PlainDate {
return this.fromHijri(fields.year, fields.month, fields.day);
const overflow = this.resolveOverflow(options);
const maxDay = this.engine.daysInMonth(fields.year, fields.month);
const day = this.applyOverflow(fields.day, maxDay, fields.month, overflow);
return this.fromHijri(fields.year, fields.month, day);
}
/**
@ -171,9 +293,16 @@ export class HijriCalendar {
*/
yearMonthFromFields(
fields: { year: number; month: number },
_options?: { overflow?: 'constrain' | 'reject' }
options?: { overflow?: "constrain" | "reject" },
): Temporal.PlainYearMonth {
const isoDate = this.fromHijri(fields.year, fields.month, 1);
const overflow = this.resolveOverflow(options);
// Clamp month to 1-12 or reject.
const maxMonth = 12;
if (overflow === "reject" && (fields.month < 1 || fields.month > maxMonth)) {
throw new RangeError(`Month ${fields.month} is out of range 1-${maxMonth}`);
}
const month = Math.max(1, Math.min(fields.month, maxMonth));
const isoDate = this.fromHijri(fields.year, month, 1);
return Temporal.PlainYearMonth.from({
year: isoDate.year,
month: isoDate.month,
@ -187,12 +316,13 @@ export class HijriCalendar {
*/
monthDayFromFields(
fields: { month: number; day: number; year?: number },
_options?: { overflow?: 'constrain' | 'reject' }
options?: { overflow?: "constrain" | "reject" },
): Temporal.PlainMonthDay {
// A reference year is needed to resolve the Hijri month/day to an ISO date.
// Default to 1444 AH (2022-2023 CE), a recent well-covered year.
const year = fields.year ?? 1444;
const isoDate = this.fromHijri(year, fields.month, fields.day);
const overflow = this.resolveOverflow(options);
const year = fields.year ?? REFERENCE_YEAR;
const maxDay = this.engine.daysInMonth(year, fields.month);
const day = this.applyOverflow(fields.day, maxDay, fields.month, overflow);
const isoDate = this.fromHijri(year, fields.month, day);
return Temporal.PlainMonthDay.from({
month: isoDate.month,
day: isoDate.day,
@ -209,25 +339,27 @@ export class HijriCalendar {
* fixed 30-day offset). Day and week additions are then applied in ISO space
* so that they always represent exact day counts.
*
* Month normalization uses O(1) modular arithmetic instead of iterative loops.
* When the day-of-month exceeds the target month's length after a Hijri-space
* adjustment, it is clamped to the last valid day of that month.
*/
dateAdd(
date: Temporal.PlainDate,
duration: Temporal.Duration,
_options?: { overflow?: 'constrain' | 'reject' }
_options?: { overflow?: "constrain" | "reject" },
): Temporal.PlainDate {
const { hy, hm, hd } = this.toHijri(date);
let newHy = hy + (duration.years ?? 0);
let newHm = hm + (duration.months ?? 0);
const years = duration.years ?? 0;
const months = duration.months ?? 0;
// Normalize month overflow into years.
while (newHm > 12) {
newHm -= 12;
newHy++;
}
while (newHm < 1) {
// O(1) month normalization via modular arithmetic.
const totalMonths = (hy - 1) * 12 + (hm - 1) + years * 12 + months;
let newHy = Math.floor(totalMonths / 12) + 1;
let newHm = (totalMonths % 12) + 1;
// Handle negative modulo (JS % can return negative values).
if (newHm < 1) {
newHm += 12;
newHy--;
}
@ -252,79 +384,36 @@ export class HijriCalendar {
dateUntil(
one: Temporal.PlainDate,
two: Temporal.PlainDate,
options?: { largestUnit?: DateUnit }
options?: { largestUnit?: DateUnit },
): Temporal.Duration {
const largestUnit: DateUnit = options?.largestUnit ?? 'days';
const largestUnit: DateUnit = options?.largestUnit ?? "days";
if (largestUnit === 'years' || largestUnit === 'year') {
if (largestUnit === "years" || largestUnit === "year") {
const h1 = this.toHijri(one);
const h2 = this.toHijri(two);
let years = h2.hy - h1.hy;
let months = h2.hm - h1.hm;
let days = h2.hd - h1.hd;
// Borrow from months when days are negative.
if (days < 0) {
months--;
// Add the day count of the previous Hijri month to resolve the borrow.
let borrowHm = h2.hm - 1;
let borrowHy = h2.hy;
if (borrowHm < 1) {
borrowHm = 12;
borrowHy--;
}
days += this.engine.daysInMonth(borrowHy, borrowHm);
}
// Borrow from years when months are negative.
if (months < 0) {
years--;
months += 12;
}
return new Temporal.Duration(years, months, 0, days);
const diff = borrowHijriDiff(this.engine, h2.hy - h1.hy, h2.hm - h1.hm, h2.hd - h1.hd, h2);
return new Temporal.Duration(diff.years, diff.months, 0, diff.days);
}
if (largestUnit === 'months' || largestUnit === 'month') {
if (largestUnit === "months" || largestUnit === "month") {
const h1 = this.toHijri(one);
const h2 = this.toHijri(two);
let years = h2.hy - h1.hy;
let months = h2.hm - h1.hm;
let days = h2.hd - h1.hd;
if (days < 0) {
months--;
let borrowHm = h2.hm - 1;
let borrowHy = h2.hy;
if (borrowHm < 1) {
borrowHm = 12;
borrowHy--;
}
days += this.engine.daysInMonth(borrowHy, borrowHm);
}
if (months < 0) {
years--;
months += 12;
}
const diff = borrowHijriDiff(this.engine, h2.hy - h1.hy, h2.hm - h1.hm, h2.hd - h1.hd, h2);
// Roll years into months.
return new Temporal.Duration(0, years * 12 + months, 0, days);
return new Temporal.Duration(0, diff.years * 12 + diff.months, 0, diff.days);
}
// For weeks and days, delegate to ISO arithmetic which is exact.
if (largestUnit === 'weeks' || largestUnit === 'week') {
return one.until(two, { largestUnit: 'weeks' });
if (largestUnit === "weeks" || largestUnit === "week") {
return one.until(two, { largestUnit: "weeks" });
}
return one.until(two, { largestUnit: 'days' });
return one.until(two, { largestUnit: "days" });
}
mergeFields(
fields: Record<string, unknown>,
additionalFields: Record<string, unknown>
additionalFields: Record<string, unknown>,
): Record<string, unknown> {
return { ...fields, ...additionalFields };
}

View file

@ -1,5 +1,5 @@
import { getCalendar } from 'hijri-core';
import { HijriCalendar } from './HijriCalendar';
import { getCalendar } from "hijri-core";
import { HijriCalendar } from "./HijriCalendar";
/**
* Temporal calendar implementation for the Umm al-Qura calendar.
@ -15,6 +15,6 @@ import { HijriCalendar } from './HijriCalendar';
*/
export class UaqCalendar extends HijriCalendar {
constructor() {
super(getCalendar('uaq'));
super(getCalendar("uaq"));
}
}

View file

@ -1,12 +1,21 @@
export { HijriCalendar } from './calendars/HijriCalendar';
export { UaqCalendar } from './calendars/UaqCalendar';
export { FcnaCalendar } from './calendars/FcnaCalendar';
export { HijriCalendar } from "./calendars/HijriCalendar";
export { UaqCalendar } from "./calendars/UaqCalendar";
export { FcnaCalendar } from "./calendars/FcnaCalendar";
export type { HijriDate, CalendarEngine, ConversionOptions } from 'hijri-core';
export type { HijriDate, CalendarEngine, ConversionOptions } from "hijri-core";
// Pre-built singletons. Import and use directly; no need to instantiate.
import { UaqCalendar } from './calendars/UaqCalendar';
import { FcnaCalendar } from './calendars/FcnaCalendar';
import { UaqCalendar } from "./calendars/UaqCalendar";
import { FcnaCalendar } from "./calendars/FcnaCalendar";
export const uaqCalendar = new UaqCalendar();
export const fcnaCalendar = new FcnaCalendar();
// ── 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: "temporal-hijri", version: "1.0.3" }))
.catch(() => {
// telemetry not installed or disabled — that is fine
});

View file

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

View file

@ -1,4 +1,4 @@
'use strict';
"use strict";
/**
* CJS test suite for temporal-hijri.
@ -6,74 +6,66 @@
* Verifies that the CommonJS build loads and functions correctly via require().
*/
const assert = require('node:assert/strict');
const { Temporal } = require('@js-temporal/polyfill');
const { UaqCalendar, FcnaCalendar, uaqCalendar, fcnaCalendar } = require('./dist/index.cjs');
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { Temporal } = require("@js-temporal/polyfill");
const { UaqCalendar, FcnaCalendar, uaqCalendar, fcnaCalendar } = require("./dist/index.cjs");
let passed = 0;
let failed = 0;
const total = 8;
function test(name, fn) {
try {
fn();
console.log(`[${name}]... PASS`);
passed++;
} catch (err) {
console.error(`[${name}]... FAIL: ${err.message}`);
failed++;
}
}
const isoRamadan = Temporal.PlainDate.from('2023-03-23');
const isoRamadan = Temporal.PlainDate.from("2023-03-23");
// ── Class and singleton exports ───────────────────────────────────────────────
test('UaqCalendar class loads via require', () => {
assert(typeof UaqCalendar === 'function', 'UaqCalendar should be a constructor');
const cal = new UaqCalendar();
assert.equal(cal.id, 'hijri-uaq');
});
describe("CJS class exports", () => {
it("UaqCalendar class loads via require", () => {
assert(typeof UaqCalendar === "function", "UaqCalendar should be a constructor");
const cal = new UaqCalendar();
assert.equal(cal.id, "hijri-uaq");
});
test('FcnaCalendar class loads via require', () => {
assert(typeof FcnaCalendar === 'function', 'FcnaCalendar should be a constructor');
const cal = new FcnaCalendar();
assert.equal(cal.id, 'hijri-fcna');
});
it("FcnaCalendar class loads via require", () => {
assert(typeof FcnaCalendar === "function", "FcnaCalendar should be a constructor");
const cal = new FcnaCalendar();
assert.equal(cal.id, "hijri-fcna");
});
test('uaqCalendar singleton id', () => {
assert.equal(uaqCalendar.id, 'hijri-uaq');
});
it("uaqCalendar singleton id", () => {
assert.equal(uaqCalendar.id, "hijri-uaq");
});
test('fcnaCalendar singleton id', () => {
assert.equal(fcnaCalendar.id, 'hijri-fcna');
it("fcnaCalendar singleton id", () => {
assert.equal(fcnaCalendar.id, "hijri-fcna");
});
});
// ── Field accessors ───────────────────────────────────────────────────────────
test('uaqCalendar.year(2023-03-23) = 1444', () => {
assert.equal(uaqCalendar.year(isoRamadan), 1444);
});
describe("CJS field accessors", () => {
it("uaqCalendar.year(2023-03-23) = 1444", () => {
assert.equal(uaqCalendar.year(isoRamadan), 1444);
});
test('uaqCalendar.month(2023-03-23) = 9', () => {
assert.equal(uaqCalendar.month(isoRamadan), 9);
});
it("uaqCalendar.month(2023-03-23) = 9", () => {
assert.equal(uaqCalendar.month(isoRamadan), 9);
});
test('uaqCalendar.day(2023-03-23) = 1', () => {
assert.equal(uaqCalendar.day(isoRamadan), 1);
it("uaqCalendar.day(2023-03-23) = 1", () => {
assert.equal(uaqCalendar.day(isoRamadan), 1);
});
});
// ── dateFromFields ─────────────────────────────────────────────────────────────
test('uaqCalendar.dateFromFields({year:1444, month:9, day:1}) = 2023-03-23', () => {
const result = uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 1 });
assert.equal(result.toString(), '2023-03-23');
describe("CJS dateFromFields", () => {
it("uaqCalendar.dateFromFields({year:1444, month:9, day:1}) = 2023-03-23", () => {
const result = uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 1 });
assert.equal(result.toString(), "2023-03-23");
});
});
// ── Summary ───────────────────────────────────────────────────────────────────
// ── fields() ──────────────────────────────────────────────────────────────────
console.log(`\n${passed}/${total} tests passed`);
if (failed > 0) {
console.error(`${failed} test(s) failed`);
process.exit(1);
}
describe("CJS fields()", () => {
it("returns the input array unchanged", () => {
assert.deepEqual(uaqCalendar.fields(["year", "month", "day"]), ["year", "month", "day"]);
});
});

334
test.mjs
View file

@ -5,147 +5,283 @@
* Reference point: 2023-03-23 = 1 Ramadan 1444 AH (both UAQ and FCNA agree).
*/
import assert from 'node:assert/strict';
import { Temporal } from '@js-temporal/polyfill';
import { UaqCalendar, FcnaCalendar, uaqCalendar, fcnaCalendar } from './dist/index.mjs';
let passed = 0;
let failed = 0;
const total = 18;
function test(name, fn) {
try {
fn();
console.log(`[${name}]... PASS`);
passed++;
} catch (err) {
console.error(`[${name}]... FAIL: ${err.message}`);
failed++;
}
}
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { Temporal } from "@js-temporal/polyfill";
import { UaqCalendar, FcnaCalendar, uaqCalendar, fcnaCalendar } from "./dist/index.mjs";
// Reference date: 2023-03-23 = 1 Ramadan 1444 AH
const isoRamadan = Temporal.PlainDate.from('2023-03-23');
const isoRamadan = Temporal.PlainDate.from("2023-03-23");
// 2023-04-21 = 1 Shawwal 1444 AH (first day after Ramadan)
const isoShawwal = Temporal.PlainDate.from('2023-04-21');
const isoShawwal = Temporal.PlainDate.from("2023-04-21");
// 2021-08-09 = 1 Muharram 1443 AH (a 355-day / leap year)
const isoLeapYear = Temporal.PlainDate.from('2021-08-09');
const isoLeapYear = Temporal.PlainDate.from("2021-08-09");
// ── 1. Class exports ──────────────────────────────────────────────────────────
test('UaqCalendar class export', () => {
assert(UaqCalendar, 'UaqCalendar should be exported');
const cal = new UaqCalendar();
assert(cal instanceof UaqCalendar, 'UaqCalendar should be instantiable');
});
describe("Class exports", () => {
it("UaqCalendar class export", () => {
assert(UaqCalendar, "UaqCalendar should be exported");
const cal = new UaqCalendar();
assert(cal instanceof UaqCalendar, "UaqCalendar should be instantiable");
});
test('FcnaCalendar class export', () => {
assert(FcnaCalendar, 'FcnaCalendar should be exported');
const cal = new FcnaCalendar();
assert(cal instanceof FcnaCalendar, 'FcnaCalendar should be instantiable');
it("FcnaCalendar class export", () => {
assert(FcnaCalendar, "FcnaCalendar should be exported");
const cal = new FcnaCalendar();
assert(cal instanceof FcnaCalendar, "FcnaCalendar should be instantiable");
});
});
// ── 2. Calendar IDs ───────────────────────────────────────────────────────────
test('uaqCalendar.id', () => {
assert.equal(uaqCalendar.id, 'hijri-uaq');
});
describe("Calendar IDs", () => {
it("uaqCalendar.id", () => {
assert.equal(uaqCalendar.id, "hijri-uaq");
});
test('fcnaCalendar.id', () => {
assert.equal(fcnaCalendar.id, 'hijri-fcna');
it("fcnaCalendar.id", () => {
assert.equal(fcnaCalendar.id, "hijri-fcna");
});
});
// ── 3. Field accessors on 1 Ramadan 1444 (2023-03-23) ────────────────────────
test('uaqCalendar.year(2023-03-23) = 1444', () => {
assert.equal(uaqCalendar.year(isoRamadan), 1444);
});
describe("Field accessors (UAQ, 1 Ramadan 1444)", () => {
it("year = 1444", () => {
assert.equal(uaqCalendar.year(isoRamadan), 1444);
});
test('uaqCalendar.month(2023-03-23) = 9 (Ramadan)', () => {
assert.equal(uaqCalendar.month(isoRamadan), 9);
});
it("month = 9 (Ramadan)", () => {
assert.equal(uaqCalendar.month(isoRamadan), 9);
});
test('uaqCalendar.day(2023-03-23) = 1', () => {
assert.equal(uaqCalendar.day(isoRamadan), 1);
});
it("day = 1", () => {
assert.equal(uaqCalendar.day(isoRamadan), 1);
});
test('uaqCalendar.monthCode(2023-03-23) = "M09"', () => {
assert.equal(uaqCalendar.monthCode(isoRamadan), 'M09');
});
it('monthCode = "M09"', () => {
assert.equal(uaqCalendar.monthCode(isoRamadan), "M09");
});
test('uaqCalendar.daysInMonth(2023-03-23) = 29 (Ramadan 1444 is 29 days)', () => {
assert.equal(uaqCalendar.daysInMonth(isoRamadan), 29);
});
it("daysInMonth = 29 (Ramadan 1444)", () => {
assert.equal(uaqCalendar.daysInMonth(isoRamadan), 29);
});
test('uaqCalendar.monthsInYear(2023-03-23) = 12', () => {
assert.equal(uaqCalendar.monthsInYear(isoRamadan), 12);
});
it("monthsInYear = 12", () => {
assert.equal(uaqCalendar.monthsInYear(isoRamadan), 12);
});
test('uaqCalendar.daysInWeek(2023-03-23) = 7', () => {
assert.equal(uaqCalendar.daysInWeek(isoRamadan), 7);
});
it("daysInWeek = 7", () => {
assert.equal(uaqCalendar.daysInWeek(isoRamadan), 7);
});
// 2023-03-23 is a Thursday. ISO weekday: 1=Mon, ..., 4=Thu, ..., 7=Sun.
test('uaqCalendar.dayOfWeek(2023-03-23) = 4 (Thursday)', () => {
assert.equal(uaqCalendar.dayOfWeek(isoRamadan), 4);
});
it("dayOfWeek = 4 (Thursday)", () => {
assert.equal(uaqCalendar.dayOfWeek(isoRamadan), 4);
});
// dayOfYear: sum of months 1-8 in 1444 + 1 (first day of month 9).
// Months 1-8 of 1444 total 236 days, so day 237 of the year.
test('uaqCalendar.dayOfYear(2023-03-23) = 237', () => {
assert.equal(uaqCalendar.dayOfYear(isoRamadan), 237);
it("dayOfYear = 237", () => {
assert.equal(uaqCalendar.dayOfYear(isoRamadan), 237);
});
});
// ── 4. dateFromFields ─────────────────────────────────────────────────────────
test('uaqCalendar.dateFromFields({year:1444, month:9, day:1}) = 2023-03-23', () => {
const result = uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 1 });
assert.equal(result.toString(), '2023-03-23');
describe("dateFromFields", () => {
it("dateFromFields({year:1444, month:9, day:1}) = 2023-03-23", () => {
const result = uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 1 });
assert.equal(result.toString(), "2023-03-23");
});
});
// ── 5. dateAdd ────────────────────────────────────────────────────────────────
test('uaqCalendar.dateAdd: adding 1 month from 1 Ramadan 1444 lands on 1 Shawwal 1444', () => {
const oneMonth = new Temporal.Duration(0, 1, 0, 0);
const result = uaqCalendar.dateAdd(isoRamadan, oneMonth);
// 1 Shawwal 1444 = 2023-04-21
assert.equal(result.toString(), isoShawwal.toString());
// Verify the result is in Shawwal (month 10)
assert.equal(uaqCalendar.month(result), 10);
describe("dateAdd", () => {
it("adding 1 month from 1 Ramadan 1444 lands on 1 Shawwal 1444", () => {
const oneMonth = new Temporal.Duration(0, 1, 0, 0);
const result = uaqCalendar.dateAdd(isoRamadan, oneMonth);
assert.equal(result.toString(), isoShawwal.toString());
assert.equal(uaqCalendar.month(result), 10);
});
it("adding 7 days from 1 Ramadan 1444", () => {
const sevenDays = new Temporal.Duration(0, 0, 0, 7);
const result = uaqCalendar.dateAdd(isoRamadan, sevenDays);
assert.equal(uaqCalendar.day(result), 8);
assert.equal(uaqCalendar.month(result), 9);
});
it("adding 1 week from 1 Ramadan 1444", () => {
const oneWeek = new Temporal.Duration(0, 0, 1, 0);
const result = uaqCalendar.dateAdd(isoRamadan, oneWeek);
assert.equal(uaqCalendar.day(result), 8);
assert.equal(uaqCalendar.month(result), 9);
});
it("adding 12 months rolls the year forward", () => {
const twelveMonths = new Temporal.Duration(0, 12, 0, 0);
const result = uaqCalendar.dateAdd(isoRamadan, twelveMonths);
assert.equal(uaqCalendar.year(result), 1445);
assert.equal(uaqCalendar.month(result), 9);
});
it("subtracting months via negative duration", () => {
const negMonth = new Temporal.Duration(0, -1, 0, 0);
const result = uaqCalendar.dateAdd(isoShawwal, negMonth);
assert.equal(uaqCalendar.month(result), 9);
assert.equal(uaqCalendar.year(result), 1444);
});
});
// ── 6. inLeapYear ─────────────────────────────────────────────────────────────
// ── 6. dateUntil ──────────────────────────────────────────────────────────────
test('uaqCalendar.inLeapYear: 1443 AH (355 days) is a leap year, 1444 AH (354) is not', () => {
// 2021-08-09 = 1 Muharram 1443 (355-day year)
assert.equal(uaqCalendar.inLeapYear(isoLeapYear), true);
// 2023-03-23 = in 1444 (354-day year)
assert.equal(uaqCalendar.inLeapYear(isoRamadan), false);
describe("dateUntil", () => {
it("days between 1 Ramadan and 1 Shawwal 1444", () => {
const dur = uaqCalendar.dateUntil(isoRamadan, isoShawwal, { largestUnit: "days" });
assert.equal(dur.days, 29);
});
it("months between 1 Ramadan and 1 Shawwal 1444", () => {
const dur = uaqCalendar.dateUntil(isoRamadan, isoShawwal, { largestUnit: "months" });
assert.equal(dur.months, 1);
assert.equal(dur.days, 0);
});
it("years between dates spanning one Hijri year", () => {
const iso1443 = uaqCalendar.dateFromFields({ year: 1443, month: 1, day: 1 });
const iso1444 = uaqCalendar.dateFromFields({ year: 1444, month: 1, day: 1 });
const dur = uaqCalendar.dateUntil(iso1443, iso1444, { largestUnit: "years" });
assert.equal(dur.years, 1);
assert.equal(dur.months, 0);
assert.equal(dur.days, 0);
});
it("weeks between dates", () => {
const dur = uaqCalendar.dateUntil(isoRamadan, isoShawwal, { largestUnit: "weeks" });
assert.equal(dur.weeks, 4);
assert.equal(dur.days, 1);
});
});
// ── 7. FCNA calendar ──────────────────────────────────────────────────────────
// ── 7. inLeapYear ─────────────────────────────────────────────────────────────
// Both UAQ and FCNA agree on 1 Ramadan 1444 = 2023-03-23
test('fcnaCalendar.year(2023-03-23) returns a valid Hijri year', () => {
const year = fcnaCalendar.year(isoRamadan);
assert(typeof year === 'number' && year > 1400, `Expected a Hijri year > 1400, got ${year}`);
describe("inLeapYear", () => {
it("1443 AH (355 days) is a leap year, 1444 AH (354) is not", () => {
assert.equal(uaqCalendar.inLeapYear(isoLeapYear), true);
assert.equal(uaqCalendar.inLeapYear(isoRamadan), false);
});
});
// ── 8. Out-of-range error ─────────────────────────────────────────────────────
// ── 8. FCNA calendar ──────────────────────────────────────────────────────────
test('uaqCalendar.year throws RangeError for out-of-range date (1800-01-01)', () => {
// UAQ table covers 1318-1500 AH (Gregorian 1900-2076). 1800 is out of range.
const outOfRange = Temporal.PlainDate.from('1800-01-01');
assert.throws(
() => uaqCalendar.year(outOfRange),
(err) => err instanceof RangeError
);
describe("FCNA calendar", () => {
it("fcnaCalendar.year(2023-03-23) returns a valid Hijri year", () => {
const year = fcnaCalendar.year(isoRamadan);
assert(typeof year === "number" && year > 1400, `Expected a Hijri year > 1400, got ${year}`);
});
});
// ── Summary ───────────────────────────────────────────────────────────────────
// ── 9. Out-of-range error ─────────────────────────────────────────────────────
console.log(`\n${passed}/${total} tests passed`);
if (failed > 0) {
console.error(`${failed} test(s) failed`);
process.exit(1);
}
describe("Out-of-range error", () => {
it("uaqCalendar.year throws RangeError for out-of-range date (1800-01-01)", () => {
const outOfRange = Temporal.PlainDate.from("1800-01-01");
assert.throws(
() => uaqCalendar.year(outOfRange),
(err) => err instanceof RangeError,
);
});
});
// ── 10. overflow option ───────────────────────────────────────────────────────
describe("overflow option", () => {
it('dateFromFields with overflow: "constrain" clamps day', () => {
const result = uaqCalendar.dateFromFields(
{ year: 1444, month: 9, day: 31 },
{ overflow: "constrain" },
);
assert.equal(uaqCalendar.day(result), 29);
assert.equal(uaqCalendar.month(result), 9);
});
it('dateFromFields with overflow: "reject" throws RangeError', () => {
assert.throws(
() => uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 31 }, { overflow: "reject" }),
(err) => err instanceof RangeError,
);
});
it('monthDayFromFields with overflow: "constrain" clamps day', () => {
const result = uaqCalendar.monthDayFromFields({ month: 9, day: 31 }, { overflow: "constrain" });
assert.ok(result);
});
it('monthDayFromFields with overflow: "reject" throws RangeError', () => {
assert.throws(
() => uaqCalendar.monthDayFromFields({ month: 9, day: 31 }, { overflow: "reject" }),
(err) => err instanceof RangeError,
);
});
});
// ── 11. fields() ──────────────────────────────────────────────────────────────
describe("fields()", () => {
it("returns the input array unchanged", () => {
const input = ["year", "month", "day"];
const result = uaqCalendar.fields(input);
assert.deepEqual(result, ["year", "month", "day"]);
});
it("returns an empty array for empty input", () => {
assert.deepEqual(uaqCalendar.fields([]), []);
});
});
// ── 12. yearMonthFromFields ─────────────────────────────────────────────────
describe("yearMonthFromFields", () => {
it("creates a PlainYearMonth for Ramadan 1444", () => {
const result = uaqCalendar.yearMonthFromFields({ year: 1444, month: 9 });
assert.ok(result);
assert.equal(result.month, 3);
assert.equal(result.year, 2023);
});
});
// ── 13. monthDayFromFields ──────────────────────────────────────────────────
describe("monthDayFromFields", () => {
it("creates a PlainMonthDay for 15 Ramadan (default reference year)", () => {
const result = uaqCalendar.monthDayFromFields({ month: 9, day: 15 });
assert.ok(result);
});
it("creates a PlainMonthDay with explicit year", () => {
const result = uaqCalendar.monthDayFromFields({ month: 9, day: 1, year: 1445 });
assert.ok(result);
});
});
// ── 14. UTC-day round-trip regression ─────────────────────────────────────────
// Verifies that ISO→Hijri→ISO is exact regardless of host timezone.
// 2025-03-01 = 1 Ramadan 1446 AH (UAQ).
describe("UTC-day round-trip regression (ISO → Hijri → ISO)", () => {
const isoRamadan1446 = Temporal.PlainDate.from("2025-03-01");
it("2025-03-01 maps to 1 Ramadan 1446 AH", () => {
assert.equal(uaqCalendar.year(isoRamadan1446), 1446);
assert.equal(uaqCalendar.month(isoRamadan1446), 9);
assert.equal(uaqCalendar.day(isoRamadan1446), 1);
});
it("round-trip: 1446-09-01 → ISO → Hijri returns 1446-09-01", () => {
const iso = uaqCalendar.dateFromFields({ year: 1446, month: 9, day: 1 });
assert.equal(iso.toString(), "2025-03-01");
assert.equal(uaqCalendar.year(iso), 1446);
assert.equal(uaqCalendar.month(iso), 9);
assert.equal(uaqCalendar.day(iso), 1);
});
});

View file

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

View file

@ -1,17 +1,17 @@
import { defineConfig } from 'tsup';
import { defineConfig } from "tsup";
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: true,
outDir: 'dist',
outDir: "dist",
splitting: false,
sourcemap: true,
target: 'es2020',
platform: 'node',
external: ['hijri-core', '@js-temporal/polyfill'],
target: "es2020",
platform: "node",
external: ["hijri-core", "@js-temporal/polyfill"],
outExtension({ format }) {
return { js: format === 'esm' ? '.mjs' : '.cjs' };
return { js: format === "esm" ? ".mjs" : ".cjs" };
},
});

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
}