/** * Jalali (Shamsi) ⇄ Gregorian conversion. * * Tahesab wants a document date as separate Shamsi year/month/day integers and * its list filters as `1405-04-24`; the customer app sends report ranges as * `1405/04/24`; Zarrin wants Gregorian `2024-10-12` on most calls. One small * converter covers all three without a dependency. * * The arithmetic is the well-known jalaali-js algorithm (Borkowski's leap-year * break table), valid for Jalali years -61 … 3177. */ export interface JalaliDate { jy: number; jm: number; jd: number; } const BREAKS = [ -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178, ]; function div(a: number, b: number) { return ~~(a / b); } function mod(a: number, b: number) { return a - ~~(a / b) * b; } function jalCal(jy: number) { const gy = jy + 621; let leapJ = -14; let jp = BREAKS[0]; let jump = 0; if (jy < jp || jy >= BREAKS[BREAKS.length - 1]) throw new RangeError(`Invalid Jalali year ${jy}`); for (let i = 1; i < BREAKS.length; i += 1) { const jm = BREAKS[i]; jump = jm - jp; if (jy < jm) break; leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4); jp = jm; } let n = jy - jp; leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4); if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1; const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150; const march = 20 + leapJ - leapG; if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33; let leap = mod(mod(n + 1, 33) - 1, 4); if (leap === -1) leap = 4; return { leap, gy, march }; } function g2d(gy: number, gm: number, gd: number) { let d = div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + div(153 * mod(gm + 9, 12) + 2, 5) + gd - 34840408; d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752; return d; } function d2g(jdn: number) { let j = 4 * jdn + 139361631; j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908; const i = div(mod(j, 1461), 4) * 5 + 308; const gd = div(mod(i, 153), 5) + 1; const gm = mod(div(i, 153), 12) + 1; const gy = div(j, 1461) - 100100 + div(8 - gm, 6); return { gy, gm, gd }; } function j2d(jy: number, jm: number, jd: number) { const r = jalCal(jy); return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1; } function d2j(jdn: number): JalaliDate { const gy = d2g(jdn).gy; let jy = gy - 621; const r = jalCal(jy); const jdn1f = g2d(gy, 3, r.march); let k = jdn - jdn1f; if (k >= 0) { if (k <= 185) return { jy, jm: 1 + div(k, 31), jd: mod(k, 31) + 1 }; k -= 186; } else { jy -= 1; k += 179; if (r.leap === 1) k += 1; } return { jy, jm: 7 + div(k, 30), jd: mod(k, 30) + 1 }; } /** Iran has had no DST since 2022, so a fixed +03:30 gives the local calendar day. */ const TEHRAN_OFFSET_MS = (3 * 60 + 30) * 60 * 1000; /** The Jalali calendar day of an instant, as seen in Tehran. */ export function toJalali(date: Date): JalaliDate { const local = new Date(date.getTime() + TEHRAN_OFFSET_MS); return d2j(g2d(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate())); } /** Gregorian calendar day for a Jalali day: `{ gy, gm, gd }`. */ export function jalaliToGregorian(jy: number, jm: number, jd: number) { return d2g(j2d(jy, jm, jd)); } /** * Parses `1405/04/24`, `1405-4-24` or Persian digits. Returns null for anything * that is not a real Jalali date. */ export function parseJalali(input: string | null | undefined): JalaliDate | null { if (!input) return null; const ascii = String(input) .trim() .replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))) .replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d))); const m = ascii.match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/); if (!m) return null; const jy = Number(m[1]); const jm = Number(m[2]); const jd = Number(m[3]); if (jm < 1 || jm > 12 || jd < 1 || jd > 31 || (jm > 6 && jd > 30)) return null; try { // Round-trip rejects 30 Esfand in a common year. const back = d2j(j2d(jy, jm, jd)); return back.jy === jy && back.jm === jm && back.jd === jd ? { jy, jm, jd } : null; } catch { return null; } } const pad = (n: number) => String(n).padStart(2, '0'); /** `1405-04-24` (or with another separator). */ export function formatJalali(d: JalaliDate, sep = '-') { return `${d.jy}${sep}${pad(d.jm)}${sep}${pad(d.jd)}`; } /** `2026-07-15` for a Jalali day. */ export function jalaliToIsoDate(d: JalaliDate) { const g = jalaliToGregorian(d.jy, d.jm, d.jd); return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}`; } /** `2026-07-15` for an instant, Tehran calendar day. */ export function toIsoDateTehran(date: Date) { const local = new Date(date.getTime() + TEHRAN_OFFSET_MS); return `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`; }