背景
営業日を数えるツールが自分の運営サイトに無くて、たまに困っていました。
「3営業日後っていつだっけ」を毎回カレンダーで指折り数えていて、効率は悪いです…。
作るにあたって最初に考えたのは、祝日をどこから取ってくるかでした。
祝日APIはいくつかありますが、外部への通信を1本増やすとオフラインで動かなくなります。
私のツールは全部ブラウザ内で完結させる方針なので、通信を足したくありませんでした。
というわけで自前で計算することにしたのですが、ここが思ったより厄介でした。
備忘録として書いておきます。
結論から
できたものは下記に置いてあります。
入力欄に起算日と営業日数を入れると、土日と祝日を飛ばした日付が出ます。
マイナスを入れると逆算します。
祝日は3種類ある
まず用語の整理からします。
日本の「休みの日」は、法律上3つに分かれています。
- 国民の祝日 … 元日、成人の日、憲法記念日など。「国民の祝日に関する法律」に列挙されているもの。
- 振替休日 … 祝日が日曜と重なったとき、その後の「祝日でない日」が休みになる。
- 国民の休日 … 祝日と祝日に平日1日だけが挟まれたとき、その平日が休みになる。
この3つ目を私は知りませんでした。
2026年の9月がまさにこれで、21日が敬老の日、23日が秋分の日、間の22日(火)が国民の休日になります。
つまり法律に書いてある祝日だけを実装すると、9月22日を営業日として数えてしまうわけです。
実装:まず「法定の祝日」だけの表を作る
コードは1つの関数にまとめました。jpHolidays(year) は年を受け取って、{"2026-09-23": "秋分の日", ...} という形のオブジェクトを返します。キーは YYYY-MM-DD の文字列です。
まず法定の祝日だけを base に入れます。
function jpHolidays(y){
var base = {}; // 法定の祝日だけ。振替の判定に使う
function put(d, name){ base[ymd(d)] = name; } // ymd() は Date を "YYYY-MM-DD" にする自作関数
put(new Date(y,0,1), '元日');
put(nthDow(y,1,2,1), '成人の日'); // 1月の第2月曜
put(new Date(y,1,11), '建国記念の日');
put(new Date(y,2,shunbunDay(y)), '春分の日');
// ...(以下、他の祝日も同じ形で)
nthDow(y, month, nth, dow) は「その月の第 nth ○曜日」を返す自作関数で、月の1日の曜日から必要な日数を足しているだけです。ハッピーマンデー制度で移動する祝日(成人の日・海の日・敬老の日・スポーツの日)がこれで書けます。
function nthDow(y, month1, nth, dow){
var first = new Date(y, month1-1, 1);
var shift = (dow - first.getDay() + 7) % 7;
return new Date(y, month1-1, 1 + shift + (nth-1)*7);
}
春分の日と秋分の日は日付が年ごとに動きます。天文計算をするわけにはいかないので、よく使われている近似式を使いました。
function shunbunDay(y){ return Math.floor(20.8431 + 0.242194*(y-1980) - Math.floor((y-1980)/4)); }
2026年を入れると Math.floor(20.8431 + 11.1409 - 11) で 20 が返り、3月20日になります。実際の2026年の春分の日と一致します。
この式は1980年から2099年で有効とされているので、ツール側の対応範囲もそこに合わせました。
実装:振替休日は「次の祝日でない日」まで進める
ここが1つ目のつまずきです。
振替休日を「日曜の祝日の翌日」と書くと間違えます。翌日も祝日だったら、さらにその次に押し出されるからです。
2026年の5月がその例で、5月3日(憲法記念日)が日曜です。翌4日はみどりの日、5日はこどもの日で、どちらも祝日です。
なので振替休日は**5月6日(水)**になります。
コードでは、祝日でない日に当たるまで進めます。
// base(法定の祝日だけ)を回して、日曜のものを探す
Object.keys(base).sort().forEach(function(k){
var d = mkDate(k); // "YYYY-MM-DD" → Date
if(d.getDay() !== 0) return; // 日曜でなければ振替なし
var n = addDays(d, 1);
while(base[ymd(n)]) n = addDays(n, 1); // 祝日が続く限り先へ進める
all[ymd(n)] = '振替休日';
});
判定に使っているのが base(法定の祝日だけ)で、書き込む先が all(結果)である点が大事です。
同じオブジェクトを見ながら書き込むと、いま足した振替休日を「祝日」と見て、さらに次の日へ押し出してしまいます。
実装:国民の休日は「祝日・平日・祝日」の並びを探す
2つ目のつまずきがこれです。
条件は「前後が祝日で、真ん中が祝日でない平日」です。土日は最初から休みなので対象外にします。
Object.keys(base).forEach(function(k){
var d = mkDate(k);
var mid = addDays(d, 1), next = addDays(d, 2);
if(base[ymd(next)] && !base[ymd(mid)] && mid.getDay() !== 0 && mid.getDay() !== 6){
if(!all[ymd(mid)]) all[ymd(mid)] = '国民の休日';
}
});
これも base を見て all に書きます。振替休日を含めた all を見てしまうと、振替休日と祝日に挟まれた平日まで国民の休日にしてしまい、実在しない休みが生まれます。
2026年で動かすと、9月22日が 国民の休日 として出ます。年間の休みは18日になりました。
例外は素直に埋め込んだ
2020年と2021年は、東京五輪に合わせて海の日・スポーツの日・山の日が移動しています。
2019年には即位の日と即位礼正殿の儀の日が休みになりました。天皇誕生日も2018年までは12月23日、2020年からは2月23日です。
こういうものは規則で書けないので、年で分岐して素直に上書きしました。
if(y === 2020){
put(new Date(y,6,23), '海の日');
put(new Date(y,6,24), 'スポーツの日');
put(new Date(y,7,10), '山の日');
} else if(y === 2021){
// ...
} else {
put(nthDow(y,7,3,1), '海の日');
put(new Date(y,7,11), '山の日');
}
無理に一般化しようとすると、かえって読めなくなる気がします。
実際の運用でも過去の日付を数えることはあるので、消さずに残しました。
テストは「知っている年」で書く
祝日の計算は、目で見て正しいかどうかが分かりません。
なので手元で確認できる年をテストに固定しました。
test("jpHolidays: 敬老の日と秋分に挟まれた平日は国民の休日(2026-09-22)", () => {
const h = t.jpHolidays(2026);
assert.equal(h["2026-09-21"], "敬老の日");
assert.equal(h["2026-09-22"], "国民の休日");
assert.equal(h["2026-09-23"], "秋分の日");
});
2026年の9月と5月、2020年と2021年の移動分、2018年と2020年の天皇誕生日。
この5つを押さえておけば、上に書いた「つまずき」は全部拾えます。
営業日の加減算のほうも、起算日を数えないことと、休みの日が起算日でも翌日から数え始めることを固定しました。
まとめの代わりに
祝日APIを使えば5分で終わる話ではあります。
それでも自前で書いてよかったのは、振替休日と国民の休日を「あとから足す」順序を間違えると、実在しない休みが増えるという構造に気づけたことでした。
外部APIを使っていたら、この2つが別の概念だということすら知らないままだったと思います。
日付まわりを自分で書くときは、法律に列挙されているものと、そこから派生して決まるものを、別々の表に分けて持ってください。同じ表に混ぜた瞬間に壊れます。
本記事はAI補助で執筆した、個人開発の紹介記事です。