temporal-hijri/.github/wiki/Architecture.md

6.9 KiB

Architecture

The Temporal Calendar Protocol

The TC39 Temporal proposal (Stage 3, actively shipping in browsers) replaces the legacy Date object with a family of types: PlainDate, PlainTime, PlainDateTime, ZonedDateTime, Instant, and more. Central to its design is an extensible calendar system.

A Temporal.PlainDate always stores its fields (year, month, day) in terms of a specific calendar. The ISO 8601 (Gregorian) calendar is the default. Custom calendars implement an interface (informally called the Temporal Calendar Protocol) that lets the PlainDate compute calendar-specific values from those fields, perform arithmetic, and convert between calendar systems.

temporal-hijri implements this protocol for the Hijri (Islamic) calendar.

Protocol Implementation

The protocol requires these methods on a calendar object:

year(date)          month(date)         monthCode(date)
day(date)           daysInMonth(date)   daysInYear(date)
monthsInYear(date)  inLeapYear(date)    dayOfWeek(date)
dayOfYear(date)     weekOfYear(date)    daysInWeek(date)
dateFromFields(fields, options)
yearMonthFromFields(fields, options)
monthDayFromFields(fields, options)
dateAdd(date, duration, options)
dateUntil(one, two, options)
mergeFields(fields, additionalFields)
toString()

HijriCalendar implements all of these. The key challenge is that Temporal.PlainDate stores ISO coordinates (Gregorian year/month/day), while the methods must return Hijri coordinates. dateFromFields does the reverse.

Coordinate Bridging

Every calendar method follows the same two-step pattern:

  1. Receive a Temporal.PlainDate with ISO coordinates.
  2. Convert to Hijri coordinates using toHijri(), which constructs a JavaScript Date from the ISO fields and passes it to the hijri-core engine.
Temporal.PlainDate (ISO)  →  toHijri()  →  {hy, hm, hd}

The inverse path:

{hy, hm, hd}  →  fromHijri()  →  Temporal.PlainDate (ISO)

fromHijri() calls the engine's toGregorian(), reads UTC components from the returned Date, and constructs a PlainDate.

Date object construction

The UAQ engine reads local date components (getFullYear, getMonth, getDate). To ensure the local date always matches the intended calendar date regardless of the host's timezone, toHijri() uses the local Date constructor: new Date(year, month - 1, day). This avoids the UTC-to-local shift that would occur with Date.UTC.

The FCNA engine reads UTC components for its astronomical calculations. The UTC-local discrepancy is at most one day, which falls within the tolerance of FCNA's calculation window.

dateAdd: Arithmetic Strategy

Adding a duration to a Hijri date requires different handling for different duration components:

  • Years and months must be applied in Hijri space. Adding "1 month" to 1 Ramadan should yield 1 Shawwal, not a fixed 30-day offset. The Hijri calendar has months of 29 and 30 days in no fixed pattern, so month arithmetic must account for actual month lengths.

  • Days and weeks can be applied in ISO (Gregorian) space after the Hijri-space year/month addition. Adding 7 days means exactly 7 days, and ISO arithmetic handles that correctly.

The implementation:

1. Extract Hijri coordinates from the input PlainDate.
2. Add years and months to the Hijri coordinates.
3. Normalize: roll months > 12 or < 1 into years.
4. Clamp the day to the target month's actual length.
5. Convert back to ISO PlainDate.
6. Apply the day and week delta with ISO PlainDate.add().

Clamping (step 4) follows the Temporal specification's "constrain" overflow behavior. Adding 1 month to 30 Rajab (a 30-day month) where Shaban is 29 days would yield 30 Shaban. The result is clamped to 29 Shaban.

dateUntil: Difference Strategy

For largestUnit: 'days' or 'weeks', the difference between two ISO dates is always an exact number of days. ISO arithmetic handles this directly and correctly.

For largestUnit: 'years' or 'months', the difference is computed in Hijri space:

years  = h2.hy - h1.hy
months = h2.hm - h1.hm
days   = h2.hd - h1.hd

Borrow operations normalize negative days (by borrowing from months, adding the actual length of the preceding Hijri month) and negative months (by borrowing from years). This matches the behavior expected by the Temporal specification for calendar-aware subtraction.

Class Hierarchy

HijriCalendar          ← base, accepts any CalendarEngine
  UaqCalendar          ← wraps hijri-core 'uaq' engine
  FcnaCalendar         ← wraps hijri-core 'fcna' engine

The subclasses exist for naming and documentation clarity. All logic is in HijriCalendar. Extending with a custom engine requires only instantiating HijriCalendar directly with a registered engine.

Dependency on hijri-core

hijri-core provides:

  • CalendarEngine interface (the contract this package relies on)
  • getCalendar(id) registry function
  • Built-in UAQ engine (table-driven, Hijri years 1318-1500)
  • Built-in FCNA engine (Meeus Chapter 49 astronomical calculations)

temporal-hijri is a pure adapter layer. It does not implement any calendar arithmetic itself. It translates between the Temporal protocol and hijri-core's engine interface.

Build Output

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

Both hijri-core and @js-temporal/polyfill are declared external in the build config and listed as peer dependencies. They are not bundled.

Limitations

  • Temporal is still a proposal. Native Temporal is available in Chrome 127+, Firefox 139+, and Safari 18.2+. Node.js ships Temporal behind a flag. The package works with @js-temporal/polyfill for full compatibility today.
  • UAQ coverage is bounded. Dates before 1318 AH (1900 CE) or after 1500 AH (2076 CE) throw RangeError from the UAQ calendar. Use FCNA for dates outside this range.
  • monthDayFromFields requires a reference year. Without a year, the function defaults to 1444 AH. The resulting PlainMonthDay is a structural type in ISO space and does not preserve the Hijri year.
  • weekOfYear is approximate. The Hijri calendar has no standardized week numbering system. The implementation uses ceil(dayOfYear / 7), which gives a consistent ordering but does not align with any official standard.

Home · API Reference · Architecture