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, additionalFields: Record, ): Record { return { ...fields, ...additionalFields }; } }