تبدیل تاریخ میلادی و شمسی

کد جاوااسکریپت بدون وابستگی برای تبدیل تاریخ میلادی و شمسی به هم، همراه با نام ماه‌ها، قالب‌بندی فارسی و آزمون‌های کوتاه.

کد · ۱۳ آبان ۱۴۰۵

javascript
'use strict';

// تبدیل تاریخ میلادی ⇄ شمسی بدون هیچ وابستگی خارجی
// قاعده‌ی کبیسه: چرخه‌ی ۳۳ ساله با باقی‌مانده‌های ۱، ۵، ۹، ۱۳، ۱۷، ۲۲، ۲۶، ۳۰
// دقت مورد انتظار: بازه‌ی سال‌های شمسی ۱۳۰۰ تا ۱۴۹۹

// نام ماه‌های شمسی
const JALALI_MONTHS = [
  'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
  'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'
];

const LEAP_REMAINDERS = [1, 5, 9, 13, 17, 22, 26, 30];
const CYCLE = 33;
const LEAPS_PER_CYCLE = LEAP_REMAINDERS.length; // ۸ کبیسه در هر چرخه
const DAYS_PER_YEAR = 365;

// لنگر: ۱ فروردین ۱۴۰۳ = ۲۰۲۴-۰۳-۲۰ (شماره‌ی روز ژولیانی ۲۴۶۰۳۹۰)
const ANCHOR_JALALI_YEAR = 1403;
const ANCHOR_JDN = 2460390;

// باقی‌مانده‌ی مثبت
function mod(a, n) {
  return ((a % n) + n) % n;
}

// آیا سال شمسی کبیسه است؟
function isJalaliLeap(jy) {
  return LEAP_REMAINDERS.includes(mod(jy, CYCLE));
}

// تعداد سال‌های کبیسه در بازه‌ی [0, jy)
function leapsBefore(jy) {
  const q = Math.floor(jy / CYCLE);
  const r = jy - q * CYCLE;
  let count = q * LEAPS_PER_CYCLE;
  for (const x of LEAP_REMAINDERS) {
    if (x < r) count++;
  }
  return count;
}

// شماره‌ی روز (از ۱ فروردین سال ۰) برای اولین روز سال jy
function yearStartIndex(jy) {
  return DAYS_PER_YEAR * jy + leapsBefore(jy);
}

// شماره‌ی روز در سال (از صفر) برای اولین روز ماه jm
function monthStartDoy(jm) {
  return jm <= 6 ? (jm - 1) * 31 : 186 + (jm - 7) * 30;
}

// تعداد روزهای ماه شمسی
function daysInJalaliMonth(jy, jm) {
  if (jm <= 6) return 31;
  if (jm <= 11) return 30;
  return isJalaliLeap(jy) ? 30 : 29;
}

// بررسی اعتبار تاریخ شمسی
function assertValidJalali(jy, jm, jd) {
  if (!Number.isInteger(jy) || !Number.isInteger(jm) || !Number.isInteger(jd)) {
    throw new RangeError('تاریخ باید عدد صحیح باشد');
  }
  if (jm < 1 || jm > 12) throw new RangeError('ماه نامعتبر است');
  if (jd < 1 || jd > daysInJalaliMonth(jy, jm)) throw new RangeError('روز نامعتبر است');
}

// میلادی به شماره‌ی روز ژولیانی (JDN)
function gregorianToJdn(gy, gm, gd) {
  const a = Math.floor((14 - gm) / 12);
  const y = gy + 4800 - a;
  const m = gm + 12 * a - 3;
  return gd
    + Math.floor((153 * m + 2) / 5)
    + 365 * y
    + Math.floor(y / 4)
    - Math.floor(y / 100)
    + Math.floor(y / 400)
    - 32045;
}

// شماره‌ی روز ژولیانی به میلادی
function jdnToGregorian(j) {
  const a = j + 32044;
  const b = Math.floor((4 * a + 3) / 146097);
  const c = a - Math.floor((146097 * b) / 4);
  const d = Math.floor((4 * c + 3) / 1461);
  const e = c - Math.floor((1461 * d) / 4);
  const m = Math.floor((5 * e + 2) / 153);
  const gd = e - Math.floor((153 * m + 2) / 5) + 1;
  const gm = m + 3 - 12 * Math.floor(m / 10);
  const gy = 100 * b + d - 4800 + Math.floor(m / 10);
  return { gy, gm, gd };
}

// میلادی ← شمسی
// ورودی: سال، ماه، روز میلادی | خروجی: { jy, jm, jd }
function gregorianToJalali(gy, gm, gd) {
  if (!Number.isInteger(gy) || gm < 1 || gm > 12 || gd < 1 || gd > 31) {
    throw new RangeError('تاریخ میلادی نامعتبر است');
  }
  const jdn = gregorianToJdn(gy, gm, gd);
  // شماره‌ی روز نسبت به صفر شمسی
  const idx = jdn - ANCHOR_JDN + yearStartIndex(ANCHOR_JALALI_YEAR);

  // تخمین سال و اصلاح آن
  let jy = Math.floor(idx / 365.2422);
  while (yearStartIndex(jy) > idx) jy--;
  while (yearStartIndex(jy + 1) <= idx) jy++;

  const doy = idx - yearStartIndex(jy);
  const jm = doy < 186
    ? Math.floor(doy / 31) + 1
    : Math.floor((doy - 186) / 30) + 7;
  const jd = doy - monthStartDoy(jm) + 1;

  return { jy, jm, jd };
}

// شمسی ← میلادی
// ورودی: سال، ماه، روز شمسی | خروجی: { gy, gm, gd }
function jalaliToGregorian(jy, jm, jd) {
  assertValidJalali(jy, jm, jd);
  const idx = yearStartIndex(jy) + monthStartDoy(jm) + jd - 1;
  const jdn = idx - yearStartIndex(ANCHOR_JALALI_YEAR) + ANCHOR_JDN;
  return jdnToGregorian(jdn);
}

// تبدیل رقم‌های لاتین به فارسی
function toPersianDigits(value) {
  const fa = '۰۱۲۳۴۵۶۷۸۹';
  return String(value).replace(/\d/g, (d) => fa[d]);
}

// قالب‌بندی: مثلاً «۱ فروردین ۱۴۰۳»
function formatJalali(jy, jm, jd) {
  assertValidJalali(jy, jm, jd);
  return `${toPersianDigits(jd)} ${JALALI_MONTHS[jm - 1]} ${toPersianDigits(jy)}`;
}

// ---------- آزمون‌ها ----------

const eqGreg = (a, b) => a.gy === b.gy && a.gm === b.gm && a.gd === b.gd;
const eqJal = (a, b) => a.jy === b.jy && a.jm === b.jm && a.jd === b.jd;

// ۱ فروردین ۱۴۰۳ = 2024-03-20
console.assert(eqGreg(jalaliToGregorian(1403, 1, 1), { gy: 2024, gm: 3, gd: 20 }), 'ا ف ۱۴۰۳ به میلادی');
console.assert(eqJal(gregorianToJalali(2024, 3, 20), { jy: 1403, jm: 1, jd: 1 }), '2024-03-20 به شمسی');

// ۱ فروردین ۱۴۰۰ = 2021-03-21
console.assert(eqGreg(jalaliToGregorian(1400, 1, 1), { gy: 2021, gm: 3, gd: 21 }), 'ا ف ۱۴۰۰ به میلادی');
console.assert(eqJal(gregorianToJalali(2021, 3, 21), { jy: 1400, jm: 1, jd: 1 }), '2021-03-21 به شمسی');

// ۱ فروردین ۱۳۹۹ = 2020-03-20
console.assert(eqGreg(jalaliToGregorian(1399, 1, 1), { gy: 2020, gm: 3, gd: 20 }), 'ا ف ۱۳۹۹ به میلادی');

// ۱ فروردین ۱۴۰۴ = 2025-03-21 (سال ۱۴۰۳ کبیسه است)
console.assert(eqGreg(jalaliToGregorian(1404, 1, 1), { gy: 2025, gm: 3, gd: 21 }), 'ا ف ۱۴۰۴ به میلادی');

// اسفند ۱۴۰۳ سی‌روزه است و آخرین روزش 2025-03-20 است
console.assert(daysInJalaliMonth(1403, 12) === 30, 'طول اسفند ۱۴۰۳');
console.assert(eqGreg(jalaliToGregorian(1403, 12, 30), { gy: 2025, gm: 3, gd: 20 }), 'آخر اسفند ۱۴۰۳');

// 2024-02-29 = ۱۰ اسفند ۱۴۰۲
console.assert(eqJal(gregorianToJalali(2024, 2, 29), { jy: 1402, jm: 12, jd: 10 }), '2024-02-29 به شمسی');

// کبیسه‌ها
console.assert(isJalaliLeap(1403) === true, '۱۴۰۳ کبیسه است');
console.assert(isJalaliLeap(1399) === true, '۱۳۹۹ کبیسه است');
console.assert(isJalaliLeap(1404) === false, '۱۴۰۴ کبیسه نیست');
console.assert(isJalaliLeap(1402) === false, '۱۴۰۲ کبیسه نیست');

// قالب‌بندی فارسی
console.assert(formatJalali(1403, 1, 1) === '۱ فروردین ۱۴۰۳', 'قالب‌بندی ۱ فروردین ۱۴۰۳');
console.assert(formatJalali(1402, 12, 29) === '۲۹ اسفند ۱۴۰۲', 'قالب‌بندی ۲۹ اسفند ۱۴۰۲');

// آزمون رفت‌وبرگشت برای همه‌ی روزهای بازه‌ی ۱۳۰۰ تا ۱۴۹۹
let roundTripErrors = 0;
let consecutiveErrors = 0;
let prevG = null;
for (let jy = 1300; jy <= 1499; jy++) {
  for (let jm = 1; jm <= 12; jm++) {
    const dim = daysInJalaliMonth(jy, jm);
    for (let jd = 1; jd <= dim; jd++) {
      const g = jalaliToGregorian(jy, jm, jd);
      if (!eqJal(gregorianToJalali(g.gy, g.gm, g.gd), { jy, jm, jd })) roundTripErrors++;
      // هر روز باید دقیقاً یک روز بعد از روز قبل باشد
      if (prevG) {
        const diff = gregorianToJdn(g.gy, g.gm, g.gd) - gregorianToJdn(prevG.gy, prevG.gm, prevG.gd);
        if (diff !== 1) consecutiveErrors++;
      }
      prevG = g;
    }
  }
}
console.assert(roundTripErrors === 0, 'خطای رفت‌وبرگشت در بازه‌ی ۱۳۰۰–۱۴۹۹: ' + roundTripErrors);
console.assert(consecutiveErrors === 0, 'خطای پیوستگی روزها در بازه‌ی ۱۳۰۰–۱۴۹۹: ' + consecutiveErrors);

// تعداد کبیسه‌ها در هر چرخه‌ی ۳۳ ساله باید ۸ باشد
let leapCount = 0;
for (let y = 1300; y < 1333; y++) if (isJalaliLeap(y)) leapCount++;
console.assert(leapCount === 8, 'تعداد کبیسه‌ها در ۳۳ سال');

if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    gregorianToJalali,
    jalaliToGregorian,
    isJalaliLeap,
    formatJalali,
    daysInJalaliMonth,
    toPersianDigits,
    JALALI_MONTHS
  };
}