本文へ移動
myowchick

日本の祝日はデータなしで計算できる — 導けないのは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.74220日3/20 18:01
2026 春分20.98420日3/20 23:45
2012 秋分22.99922日9/22 23:48
2074 秋分23.01523日9/23 00:03
2100 春分19.90619日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回あった。

年祝日の数振替休日国民の休日
20221600
20231710
20242150
20251930
20261811
20271710
20281600

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つの式をタイムゾーンを変えて実行するとこうなる。

TZnew Date('2026-05-06')の日付new Date(2026, 4, 6).toISOString()
Asia/Tokyo2026/5/62026-05-05
America/Los_Angeles2026/5/52026-05-06
UTC2026/5/62026-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

  1. Jean Meeus, Astronomical Algorithms の27章の式。既知の値との照合もしておいた。2025年の春分は3/20 18:01 JST、2024年の春分は3/20 12:06 JSTで、公表値と一致する。 ↩

← 一覧へ戻る