temporal-hijri/src/calendars/HijriCalendar.ts
2026-05-30 18:48:59 -04:00

420 lines
14 KiB
TypeScript

import { Temporal } from '@js-temporal/polyfill';
import type { CalendarEngine } from 'hijri-core';
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.
*
* 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.
*/
export class HijriCalendar {
protected readonly engine: CalendarEngine;
readonly id: string;
constructor(engine: CalendarEngine) {
this.engine = engine;
this.id = `hijri-${engine.id}`;
}
toString(): string {
return this.id;
}
/**
* 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.
*/
protected toHijri(date: Temporal.PlainDate): { hy: number; hm: number; hd: number } {
const jsDate = new Date(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`);
}
return hijri;
}
/**
* Convert Hijri coordinates to a Temporal.PlainDate (ISO calendar).
*
* The engine returns a Date whose UTC components represent the Gregorian date.
* We extract those UTC components to construct the PlainDate.
*/
protected fromHijri(hy: number, hm: number, hd: number): Temporal.PlainDate {
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`,
);
}
return Temporal.PlainDate.from({
year: greg.getUTCFullYear(),
month: greg.getUTCMonth() + 1,
day: greg.getUTCDate(),
});
}
/**
* 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;
}
/**
* 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.
*/
monthCode(date: Temporal.PlainDate): string {
const { hm } = this.toHijri(date);
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);
}
/**
* 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.
*/
daysInYear(date: Temporal.PlainDate): number {
const { hy } = this.toHijri(date);
let total = 0;
for (let m = 1; m <= 12; m++) {
total += this.engine.daysInMonth(hy, m);
}
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;
}
// ── Day-of-week and day-of-year ────────────────────────────────────────────
/**
* ISO weekday: 1 = Monday, 7 = Sunday.
* PlainDate.dayOfWeek on an ISO-calendar date already gives ISO weekday,
* so no conversion is needed.
*/
dayOfWeek(date: Temporal.PlainDate): number {
return date.dayOfWeek;
}
/**
* Day within the Hijri year. Accumulates full months before the current one,
* then adds the day-of-month offset.
*/
dayOfYear(date: Temporal.PlainDate): number {
const { hy, hm, hd } = this.toHijri(date);
let total = hd;
for (let m = 1; m < hm; m++) {
total += this.engine.daysInMonth(hy, m);
}
return total;
}
/** Hijri week number counted from day 1 of Muharram (day 1-7 = week 1). No ISO week alignment. */
weekOfYear(date: Temporal.PlainDate): number {
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' },
): Temporal.PlainDate {
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);
}
/**
* 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.
*/
yearMonthFromFields(
fields: { year: number; month: number },
options?: { overflow?: 'constrain' | 'reject' },
): Temporal.PlainYearMonth {
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,
});
}
/**
* 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.
*/
monthDayFromFields(
fields: { month: number; day: number; year?: number },
options?: { overflow?: 'constrain' | 'reject' },
): Temporal.PlainMonthDay {
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,
});
}
// ── Arithmetic ─────────────────────────────────────────────────────────────
/**
* 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.
*/
dateAdd(
date: Temporal.PlainDate,
duration: Temporal.Duration,
_options?: { overflow?: 'constrain' | 'reject' },
): Temporal.PlainDate {
const { hy, hm, hd } = this.toHijri(date);
const years = duration.years ?? 0;
const months = duration.months ?? 0;
// 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--;
}
// Clamp day to the valid range for the target month.
const maxDay = this.engine.daysInMonth(newHy, newHm);
const clampedDay = Math.min(hd, maxDay);
// Convert the Hijri result back to ISO, then apply the day/week delta.
const intermediate = this.fromHijri(newHy, newHm, clampedDay);
const dayDelta = (duration.days ?? 0) + (duration.weeks ?? 0) * 7;
return dayDelta !== 0 ? intermediate.add({ days: dayDelta }) : intermediate;
}
/**
* 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.
*/
dateUntil(
one: Temporal.PlainDate,
two: Temporal.PlainDate,
options?: { largestUnit?: DateUnit },
): Temporal.Duration {
const largestUnit: DateUnit = options?.largestUnit ?? 'days';
if (largestUnit === 'years' || largestUnit === 'year') {
const h1 = this.toHijri(one);
const h2 = this.toHijri(two);
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') {
const h1 = this.toHijri(one);
const h2 = this.toHijri(two);
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, 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' });
}
return one.until(two, { largestUnit: 'days' });
}
mergeFields(
fields: Record<string, unknown>,
additionalFields: Record<string, unknown>,
): Record<string, unknown> {
return { ...fields, ...additionalFields };
}
}