Japan has 16 statutory national holidays. Fifteen are pinned to fixed dates or "Nth Monday of the month." The sixteenth and seventeenth are not. The spring and autumn equinoxes are written in law as just "spring equinox day" and "autumn equinox day" — the actual dates are determined astronomically and announced by the National Astronomical Observatory each February for the following year. I built a 1980–2099 Japanese holiday calendar that does the equinox math itself, in 500 lines of vanilla JavaScript. The implementation hinges: astronomical equinox formula, chained substitute-holiday handling, and the obscure "citizen's holiday" sandwich rule that fires in maybe 1 year out of 8.
🌐 Demo: https://sen.ltd/portfolio/holiday-calendar-jp/
📦 GitHub: https://github.com/sen-ltd/holiday-calendar-jp
Why the equinox isn't a fixed date
The Japanese Public Holiday Act describes 14 holidays with explicit dates ("February 11 — National Foundation Day") and 4 with "Nth Monday of month X" formulas (Happy Monday law, 2000). Two are written differently:
- Vernal Equinox Day: spring equinox day
- Autumnal Equinox Day: autumn equinox day
No date. The reason is that Earth's orbital period (≈365.2422 days) doesn't quite match the Gregorian calendar's 365 days, so the equinox drifts by about 5.8 hours per year. Whether the equinox falls before or after midnight UTC+9 on a given calendar date can flip year to year.
In Japan, the official date is announced by the National Astronomical Observatory each February — the prime minister's office publishes a notice in the official gazette saying "next year's spring equinox is March 20." Until then, the date is technically undetermined.
For a self-contained calendar app, hitting a government API would be silly. For 1980–2099, an approximation formula is accurate to within ±0 days.
The equinox formula
export function springEquinox(year) {
return Math.floor(
20.8431 + 0.242194 * (year - 1980) - Math.floor((year - 1980) / 4)
);
}
export function autumnEquinox(year) {
return Math.floor(
23.2488 + 0.242194 * (year - 1980) - Math.floor((year - 1980) / 4)
);
}
What each term means:
-
0.242194: the drift per year between the Gregorian calendar and the tropical year. Each year, the equinox slips forward by about 0.242 days. -
floor((year - 1980) / 4): the leap-year correction. Every 4 years adds an extra day, so the equinox jumps back by one. -
20.8431: the 1980 baseline — March 20 at roughly 21:00 JST, expressed as a fractional day.
Plug in 2024:
20.8431 + 0.242194 * 44 - floor(44 / 4)
= 20.8431 + 10.6565 - 11
= 20.4996
→ floor → 20 ⇒ March 20
Plug in 2003:
20.8431 + 0.242194 * 23 - floor(23 / 4)
= 20.8431 + 5.5705 - 5
= 21.4136
→ floor → 21 ⇒ March 21
For 1980–2099, this matches the National Astronomical Observatory's records exactly. I cross-checked 23 sample years in the test suite:
const cases = [
[1980, 20], [1984, 20], [2000, 20], [2020, 20], [2024, 20],
[1981, 21], [1985, 21], [1990, 21], [1999, 21], [2007, 21],
// …
];
for (const [y, expected] of cases) {
test(`${y} → 3/${expected}`, () => {
assert.equal(springEquinox(y), expected);
});
}
⚠️ The constants differ for 1900–1979 and 2100–2150 (the leap-year correction also has to account for the once-per-400-year rule). The UI gates input to 1980–2099 to stay inside the formula's validated range.
Happy Monday: Nth Monday
In 2000, Japan moved four holidays to "Nth Monday of month X" to create 3-day weekends:
- Coming of Age Day: 2nd Monday of January (2000–)
- Marine Day: 3rd Monday of July (2003–)
- Respect for the Aged Day: 3rd Monday of September (2003–)
- Sports Day (formerly Health and Sports Day): 2nd Monday of October (2000–)
The Nth Monday of month M is one closed-form expression:
export function nthMonday(year, monthIdx, n) {
const first = new Date(year, monthIdx, 1).getDay(); // 0=Sun..6=Sat
// If month-1st is Sun (0), first Monday is the 2nd. If Mon (1), first Monday is the 1st.
const firstMon = 1 + ((8 - first) % 7);
return firstMon + (n - 1) * 7;
}
The (8 - first) % 7 trick: when first = 0 (Sunday), (8-0) % 7 = 1, so the first Monday is the 2nd. When first = 1 (Monday), (8-1) % 7 = 0, so the first Monday is the 1st. Compact.
Substitute holidays: chained handling
"If a holiday falls on a Sunday, the next day off becomes the substitute." That law (振替休日) was enacted in 1973. The 2007 amendment changed the wording from "the next day" to "the next non-holiday weekday," which matters when holidays cluster:
In May 2024, the chain 5/3 (Fri, Constitution Day) - 5/4 (Sat, Greenery Day) - 5/5 (Sun, Children's Day) means 5/5 is the Sunday holiday, so the substitute walks forward past 5/5 to find the next non-holiday weekday: 5/6 (Monday).
const fixed = new Set(out.map((h) => h.date));
for (const h of out) {
if (year < 1973) break;
if (dowOf(h.date) !== 0) continue; // not Sunday
let cur = addDays(h.date, 1);
while (fixed.has(cur)) cur = addDays(cur, 1); // skip consecutive holidays
if (cur.slice(0, 4) !== String(year)) continue; // out of year
substitutes.push({ date: cur, name: "振替休日", kind: "substitute" });
}
2024 turned out to be a heavy year — five substitute holidays fire (2/11, 5/5, 8/11, 9/22, 11/3 all on Sundays). Japanese media made jokes about it.
Citizen's holidays: the sandwich rule
This one is delightfully obscure. Since 1985, a weekday sandwiched between two holidays becomes a holiday itself, called 国民の休日 ("citizen's holiday"):
const sorted = [...allDates].sort();
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i];
const b = addDays(a, 2);
if (!allDates.has(b)) continue; // 2 days later not a holiday
const mid = addDays(a, 1);
if (allDates.has(mid)) continue; // mid already a holiday
if (dowOf(mid) === 0) continue; // mid is Sunday
citizens.push({ date: mid, name: "国民の休日", kind: "citizen" });
}
When this law was new, May 4 was the canonical case — sandwiched between May 3 (Constitution Day) and May 5 (Children's Day). But in 2007, May 4 was promoted to a proper holiday (Greenery Day moved from April 29 onto May 4), and the sandwich rule mostly stopped firing.
Mostly. It still fires in September in years when Respect for the Aged Day (3rd Monday of September) is followed by autumn equinox on Wednesday with one weekday between:
- 2009: Sep 21 (Mon, Aged), Sep 22 (Tue, Citizen), Sep 23 (Wed, Equinox)
- 2015: Sep 21 (Mon, Aged), Sep 22 (Tue, Citizen), Sep 23 (Wed, Equinox)
Japanese papers call this "Silver Week." Next occurrence: 2026 (Sep 21 Mon Aged, Sep 22 Tue Citizen, Sep 23 Wed Equinox).
And it fired unexpectedly in 2019 because Emperor Naruhito's accession (5/1) was made a one-time holiday, sandwiching 4/30 (Tue) and 5/2 (Thu) between fixed holidays. That's the famous 10-day holiday week.
Olympic special dates and imperial transitions
2020 and 2021 had three holidays moved to support the Tokyo Olympics:
if (year === 2020) {
out.push({ date: "2020-07-23", name: "海の日", kind: "fixed" });
out.push({ date: "2020-07-24", name: "スポーツの日", kind: "fixed" });
out.push({ date: "2020-08-10", name: "山の日", kind: "fixed" });
}
Each was clustered around the opening / closing ceremonies to ease traffic and tourism.
Imperial one-time holidays I also encoded:
- 1989-02-24: Funeral of Emperor Showa
- 1990-11-12: Enthronement of Emperor Akihito
- 1993-06-09: Wedding of Crown Prince Naruhito
- 2019-05-01: Accession of Emperor Naruhito
- 2019-10-22: Enthronement ceremony of Emperor Naruhito
Each was added by a one-shot bill, not by the standing Holiday Act.
Architecture
core.js ← all holiday logic (DOM-free, 70 tests)
calendar.js ← 6×7 month grid layout
app.js ← UI glue
core.js is pure functions over ISO date strings ("YYYY-MM-DD"). That sidesteps the JavaScript Date timezone trap: new Date('2024-05-05') is interpreted as UTC, which becomes May 4 at JST-9 — silently off by one. Stringly-typed dates pay no timezone cost.
Tests cover equinox math for 23 spot years, Happy Monday for arbitrary years, substitute-holiday chains for May 2024's 5/5 case, citizen-holiday firing in 2009/2015/2019, Olympic specials, imperial one-shots, and a sanity sweep over MIN_YEAR..2030 verifying no year returns empty.
describe("computeHolidays — 2024", () => {
const h = computeHolidays(2024);
const byDate = new Map(h.map((x) => [x.date, x.name]));
test("5/5 Sunday → 5/6 substitute", () =>
assert.equal(byDate.get("2024-05-06"), "振替休日"));
});
describe("computeHolidays — citizen", () => {
test("2009-09-22 is a citizen's holiday", () => {
const h = computeHolidays(2009);
assert.equal(h.find((x) => x.date === "2009-09-22").name, "国民の休日");
});
});
Try it
- Demo: https://sen.ltd/portfolio/holiday-calendar-jp/
- GitHub: https://github.com/sen-ltd/holiday-calendar-jp
Page through 1980 to 2099. The Showa-Heisei transition in 1989-1990, the 2019 imperial 10-day week, the 2024 5-substitute year, the 2089 equinox edge — all interesting eras to scroll through.
Takeaways
-
The Japanese equinox holidays are written in law without dates — they're astronomical and announced annually by NAOJ. The formula
floor(20.8431 + 0.242194(Y-1980) - floor((Y-1980)/4))is good for 1980–2099 to ±0 days. -
Nth Monday of a month is a one-liner:
1 + ((8 - dowOfFirst) % 7) + (n-1)*7. No loop needed. - Substitute holidays after 2007 mean "next weekday that isn't a holiday," not "next day." You need to walk forward.
- Citizen's holidays (祝日サンドイッチ) used to fire on May 4 yearly. Since 2007 they only fire in rare September alignments and the 2019 imperial week.
-
String-typed ISO dates beat
Dateobjects for date-only logic. No timezone surprises. - Approximation formulas beat a remote API for offline use, when the math is within tolerance.
This is OSS portfolio #256 from SEN LLC (Tokyo). https://sen.ltd/portfolio/

Top comments (0)