「日本の祝日 API」を使えば祝日リストは取れる。でも、なぜ春分日が年によって 3/20 か 3/21 か変わるのか、なぜ毎年 2 月に内閣府が翌年の祝日を「告示」するのか、自前で計算しようとすると意外と面白い問題に当たる。1980〜2099 年の全祝日をブラウザだけで計算するツールを 500 行で書いた。実装の hinge は春分日の天文計算、振替休日の連続祝日処理、国民の休日の発動条件。
🌐 デモ: https://sen.ltd/portfolio/holiday-calendar-jp/
📦 GitHub: https://github.com/sen-ltd/holiday-calendar-jp
なぜ春分日が固定日付じゃないのか
国民の祝日に関する法律 (祝日法) の条文を読むと、春分日と秋分日だけ書き方が違う。
春分の日:春分日
秋分の日:秋分日
他の祝日は「2月11日 建国記念の日」のように日付が直接書かれているのに対し、これは「春分日」「秋分日」とだけ。日付が法律で固定されていない。
なぜなら春分・秋分は天文現象で、地球の公転周期 (約 365.2422 日) とグレゴリオ暦の 365 日との差で年に約 5.8 時間ずつズレるから。国立天文台が前年の 2 月に翌年の暦象年表を作り、内閣府が官報で告示することで「来年の春分日は 3/20」と確定する仕組みになっている。
実装としては、内閣府の API を叩くのが「正解」だが、それだと一回ネットワークを切ると動かない。1980〜2099 年程度の範囲なら、簡易式で誤差 ±1 日以内に収まることが知られているので、それを使う。
春分日・秋分日の簡易式
国立天文台の公開情報を元にした近似式 (1980-2099 用):
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)
);
}
要素の意味:
-
0.242194は「グレゴリオ暦の 1 年と回帰年の差 (日)」。1 年あたり約 0.242 日ずつ春分が遅れていく。 -
floor((year - 1980) / 4)は 4 年に 1 度の閏年補正。閏年は 366 日あるので春分日が一日「戻る」。 -
20.8431は 1980 年の春分日 (3/20.8431 ≈ 3/20 21:00 ごろ)。
検算してみる。2024 年の春分日:
20.8431 + 0.242194 * 44 - floor(44 / 4)
= 20.8431 + 10.6565 - 11
= 20.4996
→ floor → 20 ⇒ 3/20
2003 年:
20.8431 + 0.242194 * 23 - floor(23 / 4)
= 20.8431 + 5.5705 - 5
= 21.4136
→ floor → 21 ⇒ 3/21
実際の暦象年表と照合すると、1980〜2099 年では誤差ゼロ。テストは 23 年分を国立天文台のリストと突き合わせて確認した。
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);
});
}
⚠️ 1979 年以前 / 2100 年以降は係数が違う。広く使われている近似式の係数は時代ごとに 4 種類あって、特に 2100 年からは閏年補正が
floor((Y-1980)/4) - floor((Y-1980)/100)のように複雑化する。本ツールは UI で 1980-2099 にゲートしている。
ハッピーマンデーは「第n月曜日」
2000 年施行のハッピーマンデー法で、以下の 4 つが第n月曜に移動:
- 成人の日: 1 月の第2月曜 (2000〜)
- 海の日: 7 月の第3月曜 (2003〜)
- 敬老の日: 9 月の第3月曜 (2003〜)
- 体育の日 (→ スポーツの日): 10 月の第2月曜 (2000〜)
「ある月の第n月曜日が何日か」は、その月の 1 日が何曜日かから一発で求まる:
export function nthMonday(year, monthIdx, n) {
const first = new Date(year, monthIdx, 1).getDay(); // 0=Sun..6=Sat
// 1 日が日曜 (0) なら → 月曜は 2 日。月曜 (1) なら → 1 日。火曜 (2) なら → 7 日。
const firstMon = 1 + ((8 - first) % 7);
return firstMon + (n - 1) * 7;
}
(8 - first) % 7 のトリック: 1 日が日曜 (first=0) なら (8-0)%7 = 1 で第 1 月曜は 2 日。1 日が月曜 (first=1) なら (8-1)%7 = 0 で第 1 月曜は 1 日。簡潔。
振替休日: 連続祝日も対応
「祝日が日曜だったら翌月曜が振替休日」は 1973 年施行。ただし連続祝日のケースが要注意:
- 2007 年改正で「直後の平日」に変更
- これにより、
5/3 (土) - 5/4 (日: みどりの日) - 5/5 (月: こどもの日)のように振替が連鎖するケースに対応
実装は「日曜祝日を見つけたら、次の平日 (祝日でも日曜でもない日) まで進む」:
const fixed = new Set(out.map((h) => h.date));
for (const h of out) {
if (year < 1973) break;
if (dowOf(h.date) !== 0) continue; // 日曜以外スキップ
let cur = addDays(h.date, 1);
while (fixed.has(cur)) cur = addDays(cur, 1); // 祝日が連続するなら飛ばす
if (cur.slice(0, 4) !== String(year)) continue; // 翌年にはみ出たら無視
substitutes.push({ date: cur, name: "振替休日", kind: "substitute" });
}
2024 年で発動するケース:
- 2/11 建国記念の日 (日) → 2/12 振替
- 5/5 こどもの日 (日) → 5/6 振替
- 8/11 山の日 (日) → 8/12 振替
- 9/22 秋分の日 (日) → 9/23 振替
- 11/3 文化の日 (日) → 11/4 振替
実は 2024 年は振替休日が 5 回も発動する稀な年。「カレンダーが赤い」と話題になった理由がこれ。
国民の休日: 祝日サンドイッチ
「祝日と祝日に挟まれた平日も休日扱いになる」というルール。1985 年 12 月施行。
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 日後が祝日でない
const mid = addDays(a, 1);
if (allDates.has(mid)) continue; // 真ん中が既に祝日
if (dowOf(mid) === 0) continue; // 真ん中が日曜
citizens.push({ date: mid, name: "国民の休日", kind: "citizen" });
}
導入当初は 5/4 (憲法記念日 5/3 とこどもの日 5/5 の間) が「国民の休日」として機能していたが、2007 年に 5/4 がみどりの日に格上げされて以降、このルールが発動するケースはほぼなくなった。
ただし 9 月で稀に発動する。敬老の日が第3月曜で、秋分日が水曜のとき:
- 2009 年: 9/21 (月) 敬老の日, 9/22 (火) 国民の休日, 9/23 (水) 秋分の日
- 2015 年: 9/21 (月) 敬老の日, 9/22 (火) 国民の休日, 9/23 (水) 秋分の日
「シルバーウィーク」と呼ばれる年。次回発動は 2026 年だが、それは 9/21 月曜の敬老の日と 9/23 水曜の秋分の日のパターン (← 計算してみる)。
実は 2019 年も発動した。5/1 天皇即位の日 という臨時祝日が挟まったことで 4/30 (火) と 5/2 (木) の両方が国民の休日になった。10 連休になったあれ。
五輪特例と皇室行事
2020・2021 年は東京五輪で 3 つの祝日が特例で移動:
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" });
}
開会式の前日・当日に祝日を寄せることで、観光・交通の混雑を緩和する狙い。
皇室行事の臨時祝日も入れた:
- 1989-02-24 昭和天皇大喪の礼
- 1990-11-12 即位礼正殿の儀
- 1993-06-09 皇太子徳仁親王の結婚の儀
- 2019-05-01 天皇の即位の日
- 2019-10-22 即位礼正殿の儀
これらは祝日法に都度の特別措置法で追加された一回限りの祝日。
設計: 純粋関数とテスト
core.js ← 全祝日計算 (DOM-free, 70 tests)
calendar.js ← 6×7 月グリッドレイアウト
app.js ← UI glue
core.js は DOM に依存しない純粋関数の集合。Date を内部で YYYY-MM-DD の ISO 文字列に変換することで、タイムゾーンの罠 (new Date('2024-05-05') が UTC で解釈される問題) を回避している。
テストは 70 個。春分日の検算、ハッピーマンデー曜日計算、振替休日の連続処理、国民の休日の発動条件、五輪特例、皇室行事、すべての年が空配列にならない sanity check までカバー。
describe("computeHolidays — 2024", () => {
const h = computeHolidays(2024);
const byDate = new Map(h.map((x) => [x.date, x.name]));
test("5/5 が日曜のため 5/6 が振替休日", () =>
assert.equal(byDate.get("2024-05-06"), "振替休日"));
// …
});
describe("computeHolidays — 国民の休日", () => {
test("2009-09-22 が 国民の休日", () => {
const h = computeHolidays(2009);
assert.equal(h.find((x) => x.date === "2009-09-22").name, "国民の休日");
});
});
試してみる
- デモ: https://sen.ltd/portfolio/holiday-calendar-jp/
- GitHub: https://github.com/sen-ltd/holiday-calendar-jp
1980 年から 2099 年まで自由に年送りできる。1989-1990 の昭和天皇崩御 → 平成への切り替え、2019 年の即位 10 連休、2024 年の振替 5 連発、2089 年 (まだ未告示) の春分日が 3/20 になる年、いろいろ眺めると面白い。
まとめ
-
春分日 / 秋分日は天文現象なので暦と毎年ずれる。簡易式
floor(20.8431 + 0.242194(Y-1980) - floor((Y-1980)/4))で 1980-2099 を ±0 日精度でカバーできる。 -
ハッピーマンデーの「第n月曜日」は
1 + ((8 - first曜日) % 7) + (n-1)*7で一発。 - 振替休日は 2007 年改正で「直後の平日」になり、連続祝日への対応が必要。
- 国民の休日は祝日サンドイッチ。みどりの日昇格でほぼ発動しなくなったが、秋分・敬老の日の組み合わせで稀に発動する。
-
純粋関数 + ISO 文字列で書くと、
Dateのタイムゾーン罠を避けられて、Node で 70 個のテストが通せる。 - 内閣府の API を叩くより、計算式を持っていた方がオフラインで動くし高速。
これは SEN 合同会社の OSS ポートフォリオ #256 です。https://sen.ltd/portfolio/
