DEV Community

Alain Airom
Alain Airom

Posted on

Personal Project This Week: Building a (Sort of) Universal Calendar Converter with IBM Bob

Using Bob to provide me a calendar converter!

Introduction

A little introduction… I am originally from Iran and migrated to France almost 40 years ago. While I have lived in Europe for a long time, I remain deeply tied to my ancestral origins and cultural celebrations, such as the Persian New Year, Nowruz, and the longest night of the year, Yalda. For years, I found myself constantly juggling various phone apps and web pages just to synchronize the Persian solar calendar with the Gregorian system. This week, I finally decided to streamline that process by using Bob to build a universal calendar converter. To make it even more useful for a broader public, I also worked with Bob to include Hijri (Islamic) lunar calculations, creating a synchronized tool that bridges these three distinct systems in one place.


Bridging the Gap: How Bob and I Built the Converter

To turn my vision of a cultural bridge into a functional tool, I collaborated with Bob to create a single-file application that is both lightweight and powerful. We focused on high-precision synchronization and a “glassmorphism” design that feels modern yet intuitive.

The Logic: Julian Day Number (JDN) as the Pivot

The secret sauce of this project is the Julian Day Number (JDN). Instead of trying to convert directly between complex calendars, Bob helped me implement a system where every date is first converted into a universal JDN — a continuous count of days since 4713 BC.

  • Universal Translation: Any date selected in one system is converted to JDN, which then acts as a “master key” to unlock the corresponding date in the other two systems.
  • Mathematical Accuracy: We used specific algorithms like jalaali-js for the Persian Solar year and a 30-year tabular cycle for the Hijri calendar to ensure religious and cultural dates are spot-on.
<script>
// ============================================================
// UTILITY
// ============================================================
function div(a, b) { return Math.floor(a / b); }
function mod(a, b) { return a - b * Math.floor(a / b); }

// ============================================================
// GREGORIAN <-> JDN
// ============================================================
function g2d(gy, gm, gd) {
  if (gm <= 2) { gy -= 1; gm += 12; }
  const A = div(gy, 100);
  const B = 2 - A + div(A, 4);
  return Math.floor(365.25 * (gy + 4716)) + Math.floor(30.6001 * (gm + 1)) + gd + B - 1524;
}
function d2g(jdn) {
  const aa = Math.floor((jdn - 1867216.25) / 36524.25);
  const A = jdn + 1 + aa - div(aa, 4);
  const B = A + 1524;
  const C = Math.floor((B - 122.1) / 365.25);
  const D = Math.floor(365.25 * C);
  const E = Math.floor((B - D) / 30.6001);
  const day = B - D - Math.floor(30.6001 * E);
  const month = (E < 14) ? E - 1 : E - 13;
  const year = (month > 2) ? C - 4716 : C - 4715;
  return { gy: year, gm: month, gd: day };
}
function isLeapGregorian(y) {
  return (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
}
function gregorianMonthLen(y, m) {
  const days = [0, 31, isLeapGregorian(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  return days[m];
}

// ============================================================
// PERSIAN (JALALI) <-> JDN  — jalaali-js algorithm
// ============================================================
function jalCal(jy) {
  const breaks = [-61,9,38,199,426,686,756,818,1111,1181,1210,1635,2060,2097,2192,2262,2324,2394,2456,3178];
  const bl = breaks.length;
  let gy = jy + 621, leapJ = -14, jp = breaks[0], jm, jump, leap, leapG, march, n, i;
  if (jy < jp || jy >= breaks[bl - 1]) throw new Error('Invalid Jalaali year ' + jy);
  for (i = 1; i < bl; i++) {
    jm = breaks[i]; jump = jm - jp;
    if (jy < jm) break;
    leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
    jp = jm;
  }
  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;
  leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
  march = 20 + leapJ - leapG;
  if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
  leap = mod(mod(n + 1, 33) - 1, 4);
  if (leap === -1) leap = 4;
  return { leap, gy, march };
}
function j2d(jy, jm, jd) {
  const r = jalCal(jy);
  return g2d(r.gy, 3, r.march) + (jm - 1) * 30 + Math.min(jm - 1, 6) + jd - 1;
}
function d2j(jdn) {
  let gy = d2g(jdn).gy;
  let jy = gy - 622;
  let jdn_start = j2d(jy, 1, 1);
  let jdn_next  = j2d(jy + 1, 1, 1);
  while (jdn >= jdn_next) { jy++; jdn_start = jdn_next; jdn_next = j2d(jy + 1, 1, 1); }
  while (jdn < jdn_start) { jy--; jdn_next = jdn_start; jdn_start = j2d(jy, 1, 1); }
  let j = jdn - jdn_start;
  let jm = 0;
  while (true) { const dm = (jm < 6) ? 31 : 30; if (j < dm) break; j -= dm; jm++; }
  return { jy, jm: jm + 1, jd: j + 1 };
}
function isLeapJalali(jy) { return jalCal(jy).leap === 0; }
function jalaliMonthLen(jy, jm) {
  if (jm <= 6) return 31;
  if (jm <= 11) return 30;
  return isLeapJalali(jy) ? 30 : 29;
}

// ============================================================
// ISLAMIC (TABULAR) <-> JDN
// Epoch: JDN 1948440 (1 Muharram 1 AH)
// Leap years: positions 3,6,8,11,14,17,19,22,25,27,30 in 30-yr cycle
// (consistent with floor((11y+3)/30) formula)
// ============================================================
function i2d(iy, im, id) {
  return 1948440 + (iy - 1) * 354 + div(11 * iy + 3, 30) + 29 * (im - 1) + div(im, 2) + id - 1;
}
function d2i(jdn) {
  let year = div(30 * (jdn - 1948440) + 10646, 10631);
  while (i2d(year + 1, 1, 1) <= jdn) year++;
  while (i2d(year, 1, 1) > jdn) year--;
  let month = 1;
  while (month < 12 && i2d(year, month + 1, 1) <= jdn) month++;
  const day = jdn - i2d(year, month, 1) + 1;
  return { iy: year, im: month, id: day };
}
function isLeapIslamic(iy) {
  return div(11 * iy + 3, 30) > div(11 * (iy - 1) + 3, 30);
}
function islamicMonthLen(iy, im) {
  if (im < 12) return (im % 2 === 1) ? 30 : 29;
  return isLeapIslamic(iy) ? 30 : 29;
}

// ============================================================
// NAMES
// ============================================================
const GREG_MONTHS = ['January','February','March','April','May','June',
                     'July','August','September','October','November','December'];
const GREG_DAYS   = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];

const PERS_MONTHS = ['Farvardin','Ordibehesht','Khordad','Tir','Mordad','Shahrivar',
                     'Mehr','Aban','Azar','Dey','Bahman','Esfand'];
// Persian week starts Saturday: Sha, Yek, Do, Se, Cha, Pan, Jom
const PERS_DAYS   = ['Sha','Yek','Do','Se','Cha','Pan','Jom'];

const HIJRI_MONTHS = ['Muharram','Safar','Rabi\' al-Awwal','Rabi\' al-Thani',
                      'Jumada al-Awwal','Jumada al-Thani','Rajab','Sha\'ban',
                      'Ramadan','Shawwal','Dhu al-Qi\'dah','Dhu al-Hijjah'];
// Hijri week starts Sunday (same as Gregorian)
const HIJRI_DAYS  = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];

// ============================================================
// STATE
// ============================================================
// currentJDN: the "selected" day (today initially)
// viewJDN: the JDN of the 1st of the currently displayed month for each calendar
let currentJDN;
let viewGreg  = {}; // {y, m}
let viewPers  = {}; // {jy, jm}
let viewHijri = {}; // {iy, im}
let todayJDN;

// ============================================================
// INIT
// ============================================================
function init() {
  const now = new Date();
  const gy = now.getFullYear(), gm = now.getMonth() + 1, gd = now.getDate();
  todayJDN = g2d(gy, gm, gd);
  currentJDN = todayJDN;
  syncViewFromJDN(currentJDN);
  populateMonthSelects();
  renderAll();
}

function syncViewFromJDN(jdn) {
  const g = d2g(jdn);
  viewGreg = { y: g.gy, m: g.gm };
  const p = d2j(jdn);
  viewPers = { jy: p.jy, jm: p.jm };
  const h = d2i(jdn);
  viewHijri = { iy: h.iy, im: h.im };
}

// ============================================================
// NAVIGATION
// ============================================================
function navigate(cal, delta) {
  if (cal === 'greg') {
    viewGreg.m += delta;
    if (viewGreg.m > 12) { viewGreg.m = 1; viewGreg.y++; }
    if (viewGreg.m < 1)  { viewGreg.m = 12; viewGreg.y--; }
  } else if (cal === 'pers') {
    viewPers.jm += delta;
    if (viewPers.jm > 12) { viewPers.jm = 1; viewPers.jy++; }
    if (viewPers.jm < 1)  { viewPers.jm = 12; viewPers.jy--; }
  } else {
    viewHijri.im += delta;
    if (viewHijri.im > 12) { viewHijri.im = 1; viewHijri.iy++; }
    if (viewHijri.im < 1)  { viewHijri.im = 12; viewHijri.iy--; }
  }
  renderAll();
}

// ============================================================
// GO TO DATE
// ============================================================
function goToDate(cal) {
  let jdn;
  if (cal === 'greg') {
    const y = parseInt(document.getElementById('greg-in-year').value);
    const m = parseInt(document.getElementById('greg-in-month').value);
    const d = parseInt(document.getElementById('greg-in-day').value);
    if (!y || !m || !d) return;
    const maxD = gregorianMonthLen(y, m);
    if (d < 1 || d > maxD) { alert('Day out of range (1–' + maxD + ')'); return; }
    jdn = g2d(y, m, d);
  } else if (cal === 'pers') {
    const jy = parseInt(document.getElementById('pers-in-year').value);
    const jm = parseInt(document.getElementById('pers-in-month').value);
    const jd = parseInt(document.getElementById('pers-in-day').value);
    if (!jy || !jm || !jd) return;
    const maxD = jalaliMonthLen(jy, jm);
    if (jd < 1 || jd > maxD) { alert('Day out of range (1–' + maxD + ')'); return; }
    jdn = j2d(jy, jm, jd);
  } else {
    const iy = parseInt(document.getElementById('hijri-in-year').value);
    const im = parseInt(document.getElementById('hijri-in-month').value);
    const id = parseInt(document.getElementById('hijri-in-day').value);
    if (!iy || !im || !id) return;
    const maxD = islamicMonthLen(iy, im);
    if (id < 1 || id > maxD) { alert('Day out of range (1–' + maxD + ')'); return; }
    jdn = i2d(iy, im, id);
  }
  currentJDN = jdn;
  syncViewFromJDN(jdn);
  renderAll();
}

function goToToday() {
  currentJDN = todayJDN;
  syncViewFromJDN(currentJDN);
  renderAll();
}

// ============================================================
// POPULATE MONTH SELECTS
// ============================================================
function populateMonthSelects() {
  const gs = document.getElementById('greg-in-month');
  GREG_MONTHS.forEach((n, i) => {
    const o = document.createElement('option'); o.value = i + 1; o.textContent = n; gs.appendChild(o);
  });
  const ps = document.getElementById('pers-in-month');
  PERS_MONTHS.forEach((n, i) => {
    const o = document.createElement('option'); o.value = i + 1; o.textContent = n; ps.appendChild(o);
  });
  const hs = document.getElementById('hijri-in-month');
  HIJRI_MONTHS.forEach((n, i) => {
    const o = document.createElement('option'); o.value = i + 1; o.textContent = n; hs.appendChild(o);
  });
}

// ============================================================
// RENDER ALL
// ============================================================
function renderAll() {
  renderGregorian();
  renderPersian();
  renderHijri();
  updateInputFields();
}

// ============================================================
// GREGORIAN RENDER
// ============================================================
function renderGregorian() {
  const { y, m } = viewGreg;
  const leap = isLeapGregorian(y);

  document.getElementById('greg-month-name').innerHTML =
    GREG_MONTHS[m - 1] + (leap ? '<span class="leap-badge">Leap</span>' : '');
  document.getElementById('greg-year-num').textContent = y;

  const firstJDN = g2d(y, m, 1);
  const daysInMonth = gregorianMonthLen(y, m);
  // Day of week of 1st: 0=Sun..6=Sat
  const firstDow = mod(firstJDN + 1, 7); // 0=Sun

  // Selected date in this calendar
  const sel = d2g(currentJDN);
  const today = d2g(todayJDN);

  let html = '<thead><tr>';
  GREG_DAYS.forEach(d => { html += '<th>' + d + '</th>'; });
  html += '</tr></thead><tbody><tr>';

  // Empty cells before first day
  for (let i = 0; i < firstDow; i++) html += '<td class="empty"></td>';

  let col = firstDow;
  for (let day = 1; day <= daysInMonth; day++) {
    const jdn = firstJDN + day - 1;
    const dow = mod(jdn + 1, 7);
    let cls = '';
    if (jdn === todayJDN) cls += ' today';
    if (jdn === currentJDN) cls += ' selected';
    if (dow === 0 || dow === 6) cls += ' weekend';
    html += '<td class="' + cls.trim() + '" onclick="selectDay(' + jdn + ')">' + day + '</td>';
    col++;
    if (col % 7 === 0 && day < daysInMonth) html += '</tr><tr>';
  }
  // Fill remaining cells
  const remaining = (7 - (col % 7)) % 7;
  for (let i = 0; i < remaining; i++) html += '<td class="empty"></td>';
  html += '</tr></tbody>';
  document.getElementById('greg-grid').innerHTML = html;
}

// ============================================================
// PERSIAN RENDER
// ============================================================
function renderPersian() {
  const { jy, jm } = viewPers;
  const leap = isLeapJalali(jy);

  document.getElementById('pers-month-name').innerHTML =
    PERS_MONTHS[jm - 1] + (leap ? '<span class="leap-badge">Leap</span>' : '');
  document.getElementById('pers-year-num').textContent = jy;

  const firstJDN = j2d(jy, jm, 1);
  const daysInMonth = jalaliMonthLen(jy, jm);

  // Persian week starts Saturday (dow=6 in Sun=0 system)
  // firstDow in Persian: 0=Sat, 1=Sun, ..., 6=Fri
  const gregDow = mod(firstJDN + 1, 7); // 0=Sun
  const persDow = mod(gregDow + 1, 7);  // 0=Sat

  const sel = d2j(currentJDN);
  const tod = d2j(todayJDN);

  let html = '<thead><tr>';
  PERS_DAYS.forEach(d => { html += '<th>' + d + '</th>'; });
  html += '</tr></thead><tbody><tr>';

  for (let i = 0; i < persDow; i++) html += '<td class="empty"></td>';

  let col = persDow;
  for (let day = 1; day <= daysInMonth; day++) {
    const jdn = firstJDN + day - 1;
    const gregDow2 = mod(jdn + 1, 7);
    const persDow2 = mod(gregDow2 + 1, 7); // 0=Sat, 6=Fri
    let cls = '';
    if (jdn === todayJDN) cls += ' today';
    if (jdn === currentJDN) cls += ' selected';
    // Weekend in Persian calendar: Friday (persDow2=6)
    if (persDow2 === 6) cls += ' weekend';
    html += '<td class="' + cls.trim() + '" onclick="selectDay(' + jdn + ')">' + day + '</td>';
    col++;
    if (col % 7 === 0 && day < daysInMonth) html += '</tr><tr>';
  }
  const remaining = (7 - (col % 7)) % 7;
  for (let i = 0; i < remaining; i++) html += '<td class="empty"></td>';
  html += '</tr></tbody>';
  document.getElementById('pers-grid').innerHTML = html;
}

// ============================================================
// HIJRI RENDER
// ============================================================
function renderHijri() {
  const { iy, im } = viewHijri;
  const leap = isLeapIslamic(iy);

  document.getElementById('hijri-month-name').innerHTML =
    HIJRI_MONTHS[im - 1] + (leap ? '<span class="leap-badge">Leap</span>' : '');
  document.getElementById('hijri-year-num').textContent = iy + ' AH';

  const firstJDN = i2d(iy, im, 1);
  const daysInMonth = islamicMonthLen(iy, im);

  // Hijri week starts Sunday (same as Gregorian)
  const firstDow = mod(firstJDN + 1, 7); // 0=Sun

  let html = '<thead><tr>';
  HIJRI_DAYS.forEach(d => { html += '<th>' + d + '</th>'; });
  html += '</tr></thead><tbody><tr>';

  for (let i = 0; i < firstDow; i++) html += '<td class="empty"></td>';

  let col = firstDow;
  for (let day = 1; day <= daysInMonth; day++) {
    const jdn = firstJDN + day - 1;
    const dow = mod(jdn + 1, 7);
    let cls = '';
    if (jdn === todayJDN) cls += ' today';
    if (jdn === currentJDN) cls += ' selected';
    // Weekend in Islamic calendar: Friday (dow=5) and Saturday (dow=6)
    if (dow === 5 || dow === 6) cls += ' weekend';
    html += '<td class="' + cls.trim() + '" onclick="selectDay(' + jdn + ')">' + day + '</td>';
    col++;
    if (col % 7 === 0 && day < daysInMonth) html += '</tr><tr>';
  }
  const remaining = (7 - (col % 7)) % 7;
  for (let i = 0; i < remaining; i++) html += '<td class="empty"></td>';
  html += '</tr></tbody>';
  document.getElementById('hijri-grid').innerHTML = html;
}

// ============================================================
// SELECT DAY (click on a cell)
// ============================================================
function selectDay(jdn) {
  currentJDN = jdn;
  syncViewFromJDN(jdn);
  renderAll();
}

// ============================================================
// UPDATE INPUT FIELDS to reflect currentJDN
// ============================================================
function updateInputFields() {
  const g = d2g(currentJDN);
  document.getElementById('greg-in-year').value  = g.gy;
  document.getElementById('greg-in-month').value = g.gm;
  document.getElementById('greg-in-day').value   = g.gd;

  const p = d2j(currentJDN);
  document.getElementById('pers-in-year').value  = p.jy;
  document.getElementById('pers-in-month').value = p.jm;
  document.getElementById('pers-in-day').value   = p.jd;

  const h = d2i(currentJDN);
  document.getElementById('hijri-in-year').value  = h.iy;
  document.getElementById('hijri-in-month').value = h.im;
  document.getElementById('hijri-in-day').value   = h.id;
}

// ============================================================
// START
// ============================================================
init();
</script>
Enter fullscreen mode Exit fullscreen mode

Key Features we Implemented

With Bob’s coding assistance, we packed the following features into a single HTML file!

| Feature                  | Cultural & Technical Benefit                                 |
| ------------------------ | ------------------------------------------------------------ |
| **Three-Way Sync**       | Clicking a date on one calendar (like Feb 27) instantly highlights the Persian (8 Esfand) and Hijri (10 Ramadan) equivalents. |
| **RTL Support**          | The Persian and Hijri grids render **Right-to-Left**, respecting the linguistic traditions of those regions. |
| **Leap Year Indicators** | A dedicated "Leap" badge appears automatically, which is vital for calculating the exact start of **Nowruz**. |
| **Weekend Highlighting** | The tool visually distinguishes weekends differently for each system: Friday for Persian, and Friday/Saturday for Hijri. |
| **Zero Dependencies**    | The entire app is built with pure HTML, CSS, and JavaScript—meaning it requires no installation and works in any browser. |

Enter fullscreen mode Exit fullscreen mode

From Code to Culture

By using Bob to handle the heavy lifting of the JDN math and the responsive CSS layout, I was able to focus on the details that matter to our community—like ensuring the Persian months (Farvardin to Esfand) and the Islamic months (Muharram to Dhu al-Hijjah) were represented with their correct historical significance.


Closing: Honoring the Past, Planning the Future

This project was more than just a coding exercise; it was about bringing a piece of my heritage into my digital daily life. By collaborating with Bob, I transformed a tedious manual task — checking multiple apps for Nowruz or Yalda — into a seamless, synchronized experience. It’s a small tool, but for someone living between cultures, it provides a much-needed sense of alignment.

The beauty of this “universal” converter lies in its simplicity. Built as a self-contained, single-file application with zero dependencies, it is lightweight enough to live on any device while being robust enough to handle the complex mathematics of the Julian Day Number. Whether you are planning for the next lunar month or simply curious about when the Persian solar year begins, this tool is designed to be a clear window into different ways of measuring time. I hope it proves as useful to you as it has been to me.

Last but not least; don’t hesitate to provide feedbacks if you find this tool buggy. 🫠

>>> Thanks for reading <<<

Link

Top comments (0)