/** * Converts an ISO date string or Date to Jalali (Persian) date and time. * @param input - ISO date string (e.g. 2026-02-08T13:51:20.747+00:00) or Date * @returns Tuple [dateStr, timeStr] e.g. ['1404/11/24', '13:37'] */ export function toJalaliDateAndTime(input: string | Date): [string, string] { const d = new Date(input); const dateStr = toJalaliDate(d); const timeStr = toJalaliTime(d); return [dateStr, timeStr]; } /** * Converts a Jalali (Persian) date in any of the common shapes used across * this codebase to a Gregorian `YYYY-MM-DD` string. * * Accepted input shapes: * - number `13770624` (packed `YYYYMMDD`) * - string `"13770624"` / `"1377-06-24"` / `"1377/06/24"` / `"1377/6/24"` * - already-Gregorian `YYYY-MM-DD` strings are returned untouched as long as * they parse (we treat them as Gregorian only when the year is clearly * Gregorian, i.e. >= 1900). Otherwise the year is treated as Jalali. * * Returns `null` when the input cannot be parsed into a valid Jalali date. */ export function jalaliToGregorianDate( input: string | number | null | undefined, ): string | null { if (input === null || input === undefined) return null; const raw = typeof input === "number" ? String(input) : String(input).trim(); if (!raw) return null; let year = 0; let month = 0; let day = 0; const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/); if (separated) { year = parseInt(separated[1], 10); month = parseInt(separated[2], 10); day = parseInt(separated[3], 10); } else { const digits = raw.replace(/\D/g, ""); if (digits.length === 8) { year = parseInt(digits.slice(0, 4), 10); month = parseInt(digits.slice(4, 6), 10); day = parseInt(digits.slice(6, 8), 10); } else { return null; } } if (!year || !month || !day) return null; // If the year already looks Gregorian (>= 1900), just normalise formatting. if (year >= 1900) { const mm = String(month).padStart(2, "0"); const dd = String(day).padStart(2, "0"); const d = new Date(`${year}-${mm}-${dd}`); if (isNaN(d.getTime())) return null; return `${year}-${mm}-${dd}`; } return jalaliPartsToGregorian(year, month, day); } /** * Converts a Date to Jalali date string (e.g. 1404/11/24). */ export function toJalaliDate(d: Date): string { const persianDate = d.toLocaleDateString("fa-IR", { year: "numeric", month: "2-digit", day: "2-digit", }); return persianDigitsToEnglish(persianDate, "/"); } /** * Converts a Date to 24h time string (e.g. 13:37). */ export function toJalaliTime(d: Date): string { const hours = d.getHours().toString().padStart(2, "0"); const minutes = d.getMinutes().toString().padStart(2, "0"); return `${hours}:${minutes}`; } /** * Converts Jalali year/month/day parts to a Gregorian `YYYY-MM-DD` string * using the algorithmic conversion (no external deps). * Returns `null` if the resulting Gregorian date is invalid. */ function jalaliPartsToGregorian( jYear: number, jMonth: number, jDay: number, ): string | null { const jy = jYear - 979; const jm = jMonth - 1; const jd = jDay - 1; // Jalali day number — correct leap cycle is every 33 years with 8 leaps let jDayNo = 365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4); const jalaliMonthDays = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29]; for (let i = 0; i < jm; i++) { jDayNo += jalaliMonthDays[i]; } jDayNo += jd; // Offset to Gregorian day number anchored at 1600 let gDayNo = jDayNo + 79; let gy = 1600 + 400 * Math.floor(gDayNo / 146097); gDayNo %= 146097; let leap = true; if (gDayNo >= 36525) { gDayNo--; gy += 100 * Math.floor(gDayNo / 36524); gDayNo %= 36524; if (gDayNo >= 365) gDayNo++; else leap = false; } gy += 4 * Math.floor(gDayNo / 1461); gDayNo %= 1461; if (gDayNo >= 366) { leap = false; gDayNo--; gy += Math.floor(gDayNo / 365); gDayNo %= 365; } const gregorianMonthDays = [ 31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, ]; let gm = 0; for (let i = 0; i < 12; i++) { if (gDayNo < gregorianMonthDays[i]) { gm = i + 1; break; } gDayNo -= gregorianMonthDays[i]; } const gd = gDayNo + 1; const mm = String(gm).padStart(2, "0"); const dd = String(gd).padStart(2, "0"); const result = `${gy}-${mm}-${dd}`; const check = new Date(result); if (isNaN(check.getTime())) return null; return result; } const PERSIAN_TO_ENGLISH: Record = { "۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4", "۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9", }; function persianDigitsToEnglish(str: string, separator: string): string { return str .split(separator) .map((part) => part .split("") .map((char) => PERSIAN_TO_ENGLISH[char] ?? char) .join(""), ) .join("/"); }