hldays

Building hldays #7: Religious and cultural observances

A public holiday is a legal fact: the law says the office is closed. A religious observance is something else. Ramadan, Passover or Lunar New Year don't close anything in Lisbon, but a lot of people still plan around them: shift rotas, delivery windows, a marketing calendar, a meeting you shouldn't put on Yom Kippur.

So hldays has a second layer next to holidays: observances. There are 46 of them across six traditions (Western Christian, Orthodox Christian, Jewish, Islamic, Chinese and Buddhist), served by /v1/observances and attached to the date endpoints on paid plans.

I added them for exactly that kind of scheduling. The API already knew when offices close, and it felt incomplete not to know when a lot of people are busy anyway.

This post is mostly about the calendars, because that's where the interesting problems were.

Data first

Each tradition is one YAML file in data/observances/. An observance looks a lot like a holiday rule, with three extra ideas: a span (days or an end rule), an evening start (eve), and whether the date is a calculation that might be off (estimated):

- id: passover
  name: { en: "Passover", he: "פסח" }
  rule: { calendar_date: { calendar: hebrew, month: Nisan, day: 15 } }
  days: 8
  eve: true
  notes: "8 days in the diaspora, 7 in Israel."

The rule kinds are the same ones the holiday engine uses (src/core/dates.ts), so an observance can be a fixed date, an offset from Easter, an offset from Orthodox Easter, or a day in another calendar.

Easter: the one with an algorithm

Western Easter is the anonymous Gregorian algorithm, straight from the textbook:

/** Gregorian Easter Sunday (anonymous Gregorian algorithm). */
export function easterSunday(year: number): Date {
  const a = year % 19;
  const b = Math.floor(year / 100);
  const c = year % 100;
  // ... eleven more lines of integer arithmetic
  const month = Math.floor((h + l - 7 * m + 114) / 31);
  const day = ((h + l - 7 * m + 114) % 31) + 1;
  return new Date(Date.UTC(year, month - 1, day));
}

Everything movable in the Western calendar is an offset from it: Ash Wednesday is { easter: -46 }, Pentecost { easter: 49 }. The same function already drives holidays like Good Friday and Corpus Christi in the national data, so observances got it for free.

Orthodox Easter: same idea, different calendar

Orthodox churches compute Easter on the Julian calendar. The code uses Meeus' Julian algorithm and then shifts the result onto the Gregorian calendar:

/** Orthodox Easter Sunday as a Gregorian date (Meeus' Julian algorithm + Julian→Gregorian shift; valid 1900–2099). */
export function orthodoxEasterSunday(year: number): Date {
  const a = year % 4;
  const b = year % 7;
  const c = year % 19;
  const d = (19 * c + 15) % 30;
  const e = (2 * a + 4 * b - d + 34) % 7;
  const month = Math.floor((d + e + 114) / 31);
  const day = ((d + e + 114) % 31) + 1;
  const shift = Math.floor(year / 100) - Math.floor(year / 400) - 2; // 13 days in 1900–2099
  return addDays(new Date(Date.UTC(year, month - 1, day)), shift);
}

The shift line is the bit worth noticing: the gap between the two calendars grows by a day in most century years, and it's 13 days for the whole range hldays supports (2016 to 2099). The test includes the year both Easters coincide, which is a good sanity check:

expect(formatDate(orthodoxEasterSunday(2025))).toBe("2025-04-20"); // same day as Western Easter
expect(formatDate(orthodoxEasterSunday(2026))).toBe("2026-04-12");
expect(expandRule({ orthodox_easter: -48 }, 2026)).toEqual(["2026-02-23"]); // Greek Clean Monday

The YAML also notes a split inside Orthodoxy: Julian-calendar churches celebrate Christmas on 7 January, while Revised Julian churches (Greek, Romanian, Bulgarian) use 25 December but share the Orthodox Easter cycle. That's a note on the record, not two records.

Hebrew and Islamic dates: let Intl do it

I didn't want to ship conversion tables for every calendar. JavaScript's Intl.DateTimeFormat already knows the Hebrew and Islamic calendars (through ICU), so the engine builds an index for a Gregorian year by formatting every day of it:

fmt = new Intl.DateTimeFormat(`en-u-ca-${calendar}`, { timeZone: "UTC", month: "numeric", day: "numeric" });
// Intl silently falls back to Gregorian when a runtime lacks the calendar; wrong dates are worse than an error.
if (fmt.resolvedOptions().calendar !== calendar) throw new Error(`Intl calendar ${calendar} is not supported`);

That second line is my favourite in the file. If a runtime doesn't support a calendar, Intl doesn't throw: it quietly formats Gregorian dates instead. Without the check, a runtime without full ICU would treat Islamic month 10, day 1 as 1 October and put Eid al-Fitr there. I checked that the output on Cloudflare Workers matches Node before relying on it.

The Hebrew calendar had one naming trap. In leap years there are two Adars, and Intl calls the second one "Adar II". Purim is in Adar II in leap years, so the index stores "Adar II" under "Adar":

const month = parts.find((p) => p.type === "month")!.value.replace(/^Adar II$/, "Adar");
// "Adar" is Adar II in leap years (Purim)
expect(expandRule(purim, 2024)).toEqual(["2024-03-24"]);
expect(expandRule(purim, 2025)).toEqual(["2025-03-14"]);

Islamic dates are estimates, and the API says so

The Islamic calendar is lunar, and in many countries a month starts when the new moon is actually sighted. No algorithm can know that in advance. hldays calculates with the Umm al-Qura calendar (Saudi Arabia's), and the YAML says up front what that means:

# Islamic observances, calculated with the Umm al-Qura calendar (Saudi Arabia). Many countries start months on
# a local moon sighting, so the observed date can differ by a day: every entry is marked estimated.

Every Islamic observance has estimated: true, and it comes through in the response so clients can show "around" instead of a firm date. Here's Eid al-Fitr on a business-day call (paid plan, run locally):

{
  "jurisdiction": { "id": "PT-M-1106", "name": "Lisboa", ... },
  "date": "2026-03-20",
  "business_day": true,
  "holidays": [],
  "observances": [
    { "observance_id": "eid-al-fitr", "tradition": "islamic", "name": { "en": "Eid al-Fitr", "ar": "عيد الفطر" },
      "start": "2026-03-20", "end": "2026-03-22", "starts_previous_evening": true, "estimated": true }
  ]
}

starts_previous_evening comes from eve: true: Jewish and Islamic days begin at sunset, so Eid "on" 20 March starts on the evening of the 19th.

A lunar year is about 11 days shorter than a Gregorian one, which breaks a quiet assumption: that a date happens once a year. Eid al-Fitr happens twice in 2033. The rule expander returns a list for that reason, and the test makes it explicit:

// The Islamic year is ~11 days shorter, so some Gregorian years hold the same date twice
expect(expandRule(eid, 2033)).toHaveLength(2);

Spans that cross New Year

Ramadan doesn't have a fixed length: it's 29 or 30 days. So instead of days, it has an end rule: the day before 1 Shawwal.

- id: ramadan
  rule: { calendar_date: { calendar: islamic-umalqura, month: 9, day: 1 } }
  end: { calendar_date: { calendar: islamic-umalqura, month: 10, day: 1, offset: -1 } }

Around 2030 Ramadan starts in December and ends in January. Asking "what's observed on 5 January 2031?" has to find a span that started the previous year, so Observances.between always looks one year back:

// A span can start in the previous Gregorian year (Ramadan across New Year).
for (let y = Number(from.slice(0, 4)) - 1; y <= Number(to.slice(0, 4)); y++) {
{ "date": "2031-01-05", "data": [ { "observance_id": "ramadan", "start": "2030-12-26", "end": "2031-01-23", "estimated": true, ... } ] }

The Chinese calendar: where Intl was wrong

I started with Intl for the Chinese calendar too. Then I compared it with the Hong Kong Observatory's published tables and found that ICU gets 5 of the 1039 month starts between 2016 and 2099 wrong by a day. Two of them are Chinese New Year: 2027 and 2030.

You can see it in Node (ICU 78.3):

2027-02-06 12/30    <- ICU: last day of the 12th month
2027-02-07 1/1      <- ICU: New Year
2030-02-02 1/1      <- ICU: New Year

The HKO tables say 6 February 2027 and 3 February 2030. Being a day off on the biggest date in the calendar wasn't acceptable, so for the Chinese calendar only, hldays uses HKO's data. scripts/import-chinese-calendar.ts downloads one text file per year and keeps just the month starts:

const m = /^(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2})(?:st|nd|rd|th) Lunar Month/.exec(line);
if (!m) continue;
const month = Number(m[4]);
// HKO repeats the month number for a leap month
const leap = month === prevMonth;
starts.push(`${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")} ${month}${leap ? "L" : ""}`);

The result is src/core/chinese-months.json: 1077 entries like "2014-01-31 1", from 2014 to 2100. Leap months are marked L and never match a rule, so "2nd month, day 1" in 2023 is the regular second month and not the leap one that follows it. The tests check the exact days ICU gets wrong:

// 2027 and 2030 are days ICU's Chinese calendar gets wrong; the engine uses the HKO table
expect([2025, 2026, 2027, 2030].flatMap((y) => expandRule(lny, y))).toEqual(["2025-01-29", "2026-02-17", "2027-02-06", "2030-02-03"]);

The table starts in 2014, not 2016, and git history explains why. The first version guarded the range strictly and rejected 2014, but /v1/observances?year=2016 needs it: observances always look one year back (for spans like Ramadan), and Lunar New Year's Eve is "1st month, day 1, offset -1", where an offset makes the engine expand the neighbouring years too. 2016 reaches 2015, and 2015 reaches 2014. A test now asks for the first and last day of the supported range, so that can't come back.

Observances never affect business days

This was a design rule from the first commit of the feature: observances are information, not days off. The business-day code doesn't read them at all. isBusinessDay looks at the weekend and at holidays, and observances are attached to the response afterwards:

const result = calendar.isBusinessDay(j, d, types(c), municipal);
return c.json({
  jurisdiction: summary(j),
  date: d,
  ...result,
  holidays: withSources(c)(result.holidays),
  ...observancesOn(c, d),
  ...
});

Where an observance is a day off, like Good Friday in Portugal, it's also a holiday in that country's data, with a source. The observance list just tells you it's Good Friday; the holiday list tells you the office is closed. Mixing the two would mean Eid closes Lisbon, which it doesn't.

Plans and limits

Observances are on the paid plans only. On the free plan, /v1/observances returns 403 paid_plan_required, and the date endpoints return observances: [] with an observances_notice that says how many were hidden, so a free client still knows something is there.

What isn't covered yet:

  • Theravada Vesak. The Buddhist file has only the East Asian Buddha's Birthday; Vesak needs a lunar calendar the engine doesn't support.
  • Hindu and Sikh calendars. Nothing yet.
  • Regional variants. Jewish festivals use diaspora lengths (Israel's are in notes), Shia dates for Mawlid are a note, and Korea and Vietnam can differ from China by a day where their time zone does.

Which calendar would you want next: Hindu, Vesak, or something else? Tell me in the comments.

Next in the series: making an API agent-friendly with MCP, and designing tools for Claude and other agents.

hldays.dev · Docs