DEV Community

UpcomingDays
UpcomingDays

Posted on AI-assisted

Computing Eid, Rosh Hashanah and Lunar New Year dates in JavaScript with Intl calendars, and where it goes wrong

Most "how many days until…" code is easy until someone asks about Eid al-Fitr, Hanukkah or Lunar New Year. These holidays follow lunar or lunisolar calendars, so they move against the Gregorian calendar every year.

You don't need a library for most of them. Modern JavaScript engines ship ICU, and Intl.DateTimeFormat can render any date in the Hebrew, Islamic (Umm al-Qura) or Chinese calendar. With a little scanning, that gives you the Gregorian date of a holiday. It also has a few traps. Below is the approach we use, plus the three places it went wrong for us.

The idea: scan the year and ask Intl what day it is

Intl converts from Gregorian to another calendar, but there's no built-in conversion back. So for a target year, walk through every day and keep the ones whose calendar month and day match what you want. That's 365 formatter calls, which is fast enough, and easy to cache per year.

const DAY_MS = 86_400_000;

// ICU spells some month names with special characters ("Dhuʻl-Hijjah"),
// so compare normalised strings.
function normalize(s) {
  return s.toLowerCase().normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")   // strip accents
    .replace(/[ʻʼ'’`-]/g, "")          // "Dhuʻl-Hijjah" -> "dhulhijjah"
    .trim();
}

// Every Gregorian date in `year` that falls on `day` of `month` in `calendar`.
function findDates(calendar, year, month, day) {
  const fmt = new Intl.DateTimeFormat(`en-u-ca-${calendar}`, {
    timeZone: "UTC", month: "long", day: "numeric",
  });
  const hits = [];
  for (let t = Date.UTC(year, 0, 1); t < Date.UTC(year + 1, 0, 1); t += DAY_MS) {
    const parts = fmt.formatToParts(new Date(t));
    const m = parts.find((p) => p.type === "month").value;
    const d = Number(parts.find((p) => p.type === "day").value);
    if (normalize(m) === normalize(month) && d === day) {
      hits.push(new Date(t).toISOString().slice(0, 10));
    }
  }
  return hits;
}

console.log(findDates("hebrew", 2026, "Tishri", 1));                // Rosh Hashanah
console.log(findDates("hebrew", 2026, "Kislev", 25));               // Hanukkah, first day
console.log(findDates("islamic-umalqura", 2026, "Shawwal", 1));      // Eid al-Fitr
console.log(findDates("islamic-umalqura", 2026, "Dhul-Hijjah", 10)); // Eid al-Adha
Enter fullscreen mode Exit fullscreen mode

Output on Node 24 (ICU 78):

[ '2026-09-12' ]
[ '2026-12-05' ]
[ '2026-03-20' ]
[ '2026-05-27' ]
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  • Use timeZone: "UTC" and UTC midnights. Otherwise the formatter applies your machine's offset, and dates near midnight drift by a day depending on where the code runs.
  • Return an array, not a single date. That's the first trap.

Trap 1: one Gregorian year can contain the same Islamic date twice

The Islamic year is about 354 days, about 11 days shorter than the Gregorian year. Every 33 years or so, a date comes round twice in one Gregorian year:

console.log(findDates("islamic-umalqura", 2030, "Ramadan", 1));
// [ '2030-01-05', '2030-12-26' ]
Enter fullscreen mode Exit fullscreen mode

Ramadan starts twice in 2030. If your function returns only the first match, a countdown in mid-2030 will look backwards and say "Ramadan already started". For "next occurrence" logic, search this year and next year, then pick the first date on or after today.

Trap 2: Umm al-Qura is a prediction, not what every country observes

islamic-umalqura is Saudi Arabia's calculated calendar. Many countries and communities start Ramadan and the Eids based on a local sighting of the crescent moon. Their dates can differ by a day, and they aren't known for certain until the night before.

Treat the computed date as an estimate, and say so in the UI. For public-holiday purposes, governments often publish their own dates. Those should win over anything you calculate.

Hebrew calendar dates don't have this problem: that calendar is arithmetic, so ICU's results are exact. Do remember that Jewish holidays begin at sundown on the previous evening. Rosh Hashanah 2026 is Saturday, September 12, but it begins on the evening of the 11th.

Trap 3: ICU's Chinese calendar can be off by a day

You can also pull the start of the Chinese calendar's first month out of Intl:

const cfmt = new Intl.DateTimeFormat("en-u-ca-chinese", {
  timeZone: "UTC", month: "numeric", day: "numeric",
});

for (const y of [2026, 2027, 2028]) {
  for (let t = Date.UTC(y, 0, 15); t < Date.UTC(y, 2, 1); t += DAY_MS) {
    const p = cfmt.formatToParts(new Date(t));
    if (p.find((x) => x.type === "month").value === "1" &&
        p.find((x) => x.type === "day").value === "1") {
      console.log(y, new Date(t).toISOString().slice(0, 10));
      break;
    }
  }
}
// 2026 2026-02-17
// 2027 2027-02-07   <- wrong
// 2028 2028-01-26
Enter fullscreen mode Exit fullscreen mode

2026 and 2028 are right, but the Hong Kong Observatory's 2027 conversion table puts the first day of the first lunar month on February 6, 2027, and Hong Kong's gazetted public holiday is also February 6. ICU says the 7th.

So for Lunar New Year we stopped computing and use a small table checked against an official source instead. It's 20 lines of data, and it can't drift. We do the same for Diwali, which follows regional Hindu almanacs (panchang) that no Intl calendar implements.

What we ended up with

  • Hebrew holidays: computed with Intl, exact. Remember the sundown start.
  • Islamic holidays: computed with islamic-umalqura, always shown as an estimate, and overridden by official government dates where they exist. Return all matches in a year.
  • Lunar New Year and Diwali: curated tables with sources and an explicit coverage range. Outside that range we say "not listed for this year" rather than guess.

The tests matter more than the code. Our test suite pins known dates, for example Eid al-Fitr on March 20, 2026, Eid al-Adha on May 27, 2026, and Rosh Hashanah starting on the evening of September 11, 2026. That way an ICU update can't silently move a holiday.

This is the approach behind the holiday countdowns on UpcomingDays, where I work. The snippets above are standalone and run as they are in Node 18+ or any modern browser.

This article was written with the help of AI. The code was run and the dates were checked against the sources named above before publishing.

Top comments (1)

Collapse
 
destinybook profile image
Destiny Book •

The 2027 off-by-one is a different bug from the UTC-midnight trap. I re-ran the scan on ICU 76 with the formatter set to Asia/Hong_Kong and Asia/Shanghai, and month 1 day 1 was still 2027-02-07. The civil date is the China Standard Time day of the astronomical new moon, so ICU stays consistent across time zones and still disagrees with the Hong Kong Observatory.

Which rule failed matters. A leap month in the wrong place (the lunar month with no principal solar term) shifts the month number by about 29 days. A one-day miss means the new moon fell near local midnight and two ephemerides differed by minutes. A short observatory-checked table is the right call for a public holiday.

Lunar New Year is also not Start of Spring. The lunar month begins at the new moon, while Start of Spring is the solar term at 315 degrees of solar longitude, around 4 February. That solar term, not the first lunar month, is the year boundary the traditional almanac uses. When the two dates are weeks apart, treating month 1 as the Chinese year gets the holiday right and still names the wrong year for a birth in the gap.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.