mirror of
https://github.com/acamarata/temporal-hijri.git
synced 2026-06-30 19:04:29 +00:00
420 lines
14 KiB
TypeScript
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 };
|
|
}
|
|
}
|