日本の祝日はデータなしで計算できる — 導けないのは3年分だけ
営業日を数えるには祝日が要る。祝日が要ると、たいていは内閣府のCSVか祝日ライブラリを毎年更新することになる。
でも日本の祝日は祝日法に書いてある規則から起こせるので、年ごとの表はほとんど持たなくていい。2019年から2099年までを規則だけで計算させてみたら、規則で導けなかったのは2019年・2020年・2021年の3年分だけだった。
営業日計算ツールはその実装で動いている。祝日のデータファイルは1つも持っていない。
祝日は5種類の規則でできている
祝日法の中身をコードの都合で並べ直すと、こうなる。
- 固定日。元日は1/1、建国記念の日は2/11、文化の日は11/3。
- ハッピーマンデー。成人の日は1月第2月曜、海の日は7月第3月曜、敬老の日は9月第3月曜、スポーツの日は10月第2月曜。
- 春分の日・秋分の日。天文の話なので近似式で出す。
- 振替休日。日曜に当たった祝日を、その日より後の最初の「休日でない日」へ振り替える。
- 国民の休日。祝日に挟まれた平日を休みにする。
前の3つは日付を作る規則、後ろの2つは作られた日付から別の日を導く規則になっている。この2段構えが実装の骨になる。
第n月曜は素直に数えるだけでいい。
/** その月のn回目の月曜の日 */
function nthMonday(year: number, month: number, nth: number): number {
const firstWeekday = new Date(year, month - 1, 1).getDay()
// 1日から最初の月曜までの日数
const offset = (1 - firstWeekday + 7) % 7
return 1 + offset + (nth - 1) * 7
}
春分秋分の近似式はどこまで信じていいのか
春分の日と秋分の日は、その年の分点が日本時間のどちらの日付に入るかで決まる。よく使われるのはこの形の近似式。
/** 春分は base = 20.8431、秋分は base = 23.2488 */
function equinoxDay(year: number, base: number): number {
return Math.floor(base + 0.242194 * (year - 1980) - Math.floor((year - 1980) / 4))
}
0.242194は太陽年の端数、Math.floor((year - 1980) / 4)は閏年でその端数が1日戻る分。要するに「1年で0.242194日ずつ後ろにずれて、4年に1度1日戻る」を書いているだけだ。
これがどこまで当たるのか気になったので、Meeusの分点計算式を別に実装して突き合わせてみた1。分点の時刻を求めてJSTの日付に直し、近似式の出す日と比べる。
1980年から2099年までの240件は全部一致した。そして2100年から、春分も秋分も例外なく1日ずれた。
近似式の値をそのまま見ると、なぜ2099年で切ったのかが分かる。
| 年 | 式の値 | 採る日 | 実際の分点(JST) |
|---|---|---|---|
| 2025 春分 | 20.742 | 20日 | 3/20 18:01 |
| 2026 春分 | 20.984 | 20日 | 3/20 23:45 |
| 2012 秋分 | 22.999 | 22日 | 9/22 23:48 |
| 2074 秋分 | 23.015 | 23日 | 9/23 00:03 |
| 2100 春分 | 19.906 | 19日 | 3/20 22:02 |
式の小数部は分点の時刻にだいたい対応している。1980年から2099年で比べると、小数部を24倍した値と実際の時刻の差は最大0.6時間しかない。2012年の秋分が22.999、2026年の春分が20.984なのは、分点がその日の23時台にあるということで、実際そのとおりになっている。
2100年で壊れるのは閏年のせいだ。Math.floor((year - 1980) / 4)は「4年に1度必ず閏年」を前提にしている。2100年は100で割り切れて400で割り切れないから閏年ではないのに、式は1日ぶん多く引いてしまう。2100年以降のずれが全部同じ向きの1日なのはそのため。
ついでに言うと、余裕はそんなに無い。2074年の秋分は9/23の00:03で、境界まで3分しかない。2012年の秋分が9/22になって116年ぶりだと騒がれたときも、境界まで12分だった。近似式は当てにいく道具であって、確定した暦ではない。春分の日と秋分の日は毎年2月に官報で公表される暦要項で正式に決まる。
振替休日と国民の休日は順番で答えが変わる
祝日から導く2つの規則には順番がある。法律の順は振替休日が先で、国民の休日は「休日でない日」だけを対象にする。実装もその順にした。
// 振替休日: 日曜に当たった祝日を、その日より後の最初の祝日でない日へ振り替える
for (const holiday of national) {
const date = new Date(year, holiday.month - 1, holiday.day)
if (date.getDay() !== 0) {
continue
}
let moved = shift(date, 1)
while (allKeys.has(keyOfDate(moved))) {
moved = shift(moved, 1)
}
all.push({ month: moved.getMonth() + 1, day: moved.getDate(), name: '振替休日' })
allKeys.add(keyOfDate(moved))
}
whileで押し出しているのは、振替先がまた祝日のことがあるから。2026年の憲法記念日は5/3の日曜で、5/4みどりの日と5/5こどもの日を飛び越えて5/6が振替休日になる。
それで、順番を逆にしたらどこが壊れるのかを見ようと思って、国民の休日を先に決める版を書いて2019年から2099年まで全年を突き合わせた。差は1日も出なかった。
出ないのは条件が厳しいからだ。順番が効くには「日曜の祝日があって、その翌日は祝日でなくて、翌々日がまた祝日」という並びが要る。いまの祝日の組み合わせでは、この並びが1回も現れない。
ただし祝日が1つ動けばすぐ現れる。5/4がまだ祝日でなかった2006年以前の並びを模して、5/3が日曜だった1998年を計算させると答えが割れる。
| 順番 | 出てくる休日 |
|---|---|
| 振替休日が先 | 5/3 憲法記念日、5/4 振替休日、5/5 こどもの日 |
| 国民の休日が先 | 5/3 憲法記念日、5/4 国民の休日、5/5 こどもの日、5/6 振替休日 |
実際の1998年は5/4が振替休日で、5/6は平日だった。国民の休日を先に決めると5/4を取られて、振替休日が5/6まで押し出される。休みが1日増える方向に間違えるので、納期の計算に使うと気付きにくい。
いまの祝日では差が出ないと分かったうえで、順番は法律どおりに固定してある。
規則で導けない年は「動いた先」だけ持つ
例外は2019年から2021年の3年分しかない。天皇の即位に伴うものと、東京オリンピックに合わせた移動だ。
/**
* その年だけ日付が動いた祝日。東京オリンピックに合わせた移動で、名前で引く。
* 動いた先だけを持てば、規則どおりの日付が祝日でなくなることも同時に表せる。
*/
const MOVED: Record<number, Record<string, [number, number]>> = {
2020: { 海の日: [7, 23], スポーツの日: [7, 24], 山の日: [8, 10] },
2021: { 海の日: [7, 22], スポーツの日: [7, 23], 山の日: [8, 8] },
}
ここが差分表の作りどころで、日付ではなく祝日の名前を鍵にしてある。規則から起こした一覧を名前で引いて日付を差し替えるので、「動いた先を足す」と「元の日から消す」を1つの表で書ける。2020年の7/20・8/11・10/12は、この差し替えだけで祝日でなくなる。
同じ持ち方で振替休日も勝手に付いてくる。2021年の山の日は8/8に動いて、その8/8が日曜なので、翌8/9が振替休日になる。表には8/9を書いていない。動いた先を入れれば、そこから先は規則が拾ってくれる。
日付を足すほうの差分は2019年だけ。
const ADDED: Record<number, Holiday[]> = {
2019: [
{ month: 5, day: 1, name: '天皇の即位の日' },
{ month: 10, day: 22, name: '即位礼正殿の儀の行われる日' },
],
}
5/1が祝日になったことで、4/30と5/2が国民の休日になる。これも表には書かない。祝日を2日足しただけで、10連休のかたちは規則から出てくる。
残りは条件式で足りる。天皇誕生日の2/23は2020年から、10月第2月曜の名前は2019年までが体育の日で2020年からスポーツの日。
2024年は振替休日が5回あった
規則の実装を確かめるには、振替休日がよく出る年が向いている。2024年は5回あった。
| 年 | 祝日の数 | 振替休日 | 国民の休日 |
|---|---|---|---|
| 2022 | 16 | 0 | 0 |
| 2023 | 17 | 1 | 0 |
| 2024 | 21 | 5 | 0 |
| 2025 | 19 | 3 | 0 |
| 2026 | 18 | 1 | 1 |
| 2027 | 17 | 1 | 0 |
| 2028 | 16 | 0 | 0 |
2024年の内訳は2/12、5/6、8/12、9/23、11/4。建国記念の日・こどもの日・山の日・秋分の日・文化の日が全部日曜に当たった年で、5回は2019年から2099年で3年しかない(2024年・2052年・2080年)。5/6は5/4と5/5を飛び越えた押し出しで、9/23は秋分の日が9/22という珍しい年の振替。1年で規則のほとんどを踏んでくれる。
国民の休日のほうは出番が少ない。2019年の4/30と5/2を別にすると、2019年から2099年で12年あって、全部9月の敬老の日と秋分の日に挟まれた日だった。次は2026年9/22で、その次は2032年9/21になる。
営業日にすると、この差はならされる。土日と祝日を除いた2026年の営業日は244日、年末年始(12/29から1/3)も休みにすると240日。2022年から2028年で数えると244日から248日のあいだに収まっていて、いちばん多いのは振替休日が5回あった2024年の248日だった。日曜の祝日が月曜に移ってきても、元の日曜は数えていないので、増えた休みは1日ぶんずつしか効かない。
<input type="date">の値をnew Dateに渡さない
祝日の計算そのものより、入力の受け取りで踏むほうが多い。<input type="date">のvalueは2026-05-06の形の文字列で、これをnew Date(value)に渡すとUTCの0時として解釈される。ローカル時刻ではない。
同じ2つの式をタイムゾーンを変えて実行するとこうなる。
| TZ | new Date('2026-05-06')の日付 | new Date(2026, 4, 6).toISOString() |
|---|---|---|
| Asia/Tokyo | 2026/5/6 | 2026-05-05 |
| America/Los_Angeles | 2026/5/5 | 2026-05-06 |
| UTC | 2026/5/6 | 2026-05-06 |
日本から見ているぶんには前者は無事に見えるので、そのまま通ってしまう。壊れるのはUTCより西のタイムゾーンで、1日前になる。祝日の判定は日付が1日ずれた瞬間に答えが変わるから、ちょうど連休の境目で結果が変わる。
同じ罠は和暦変換でも踏んでいて、そちらは改元日をまたいで元号のほうが変わる(Intlの和暦は1年目を「元」で返す)。なので入力は文字列のまま分解して、ローカル時刻のコンストラクタに渡している。
const [year, month, day] = [matched[1], matched[2], matched[3]].map(Number)
const date = new Date(year, month - 1, day)
// 2桁の年を1900年代に寄せるDateの癖を外す
date.setFullYear(year)
setFullYearがあるのは、new Date(26, 4, 6)が1926年になるため(DateとIntlで踏んだ5つの罠)。年を2桁で書ける入力を受け取るなら要る。
まとめ
- 日本の祝日は、固定日・ハッピーマンデー・春分秋分の近似式・振替休日・国民の休日の5つで組める。年ごとの表は要らない。
- 規則で導けないのは2019年から2021年の3年分だけ。移動は名前を鍵にすると、動いた先を書くだけで元の日が祝日でなくなる。
- 春分秋分の近似式は1980年から2099年まで別計算と完全に一致した。2100年で壊れるのは閏年の数え方のせい。対応範囲をそこで切ればいい。
- 振替休日と国民の休日の順番は、いまの祝日では差が出ない。それでも法律どおりの順に置く。祝日が1つ動けば効いてくる。
- 日付文字列は
new Dateに渡さない。
Footnotes
-
Jean Meeus, Astronomical Algorithms の27章の式。既知の値との照合もしておいた。2025年の春分は3/20 18:01 JST、2024年の春分は3/20 12:06 JSTで、公表値と一致する。 ↩