きっかけ
「4 で割り切れればうるう年」で済ませている実装をたまに見ます。でも本当は:
- 4 で割り切れる → うるう年
- ただし 100 で割り切れる → 平年
- ただし 400 で割り切れる → うるう年(例外の例外)
なので 2000 年はうるう年だけど 1900 年は平年 です。この話は「2000 年問題」の時によく流れたんですが、もう 25 年前の話なので、若い世代には身近じゃない。
ついでに調べているうちに、うるう秒 (leap second) という別物があり、これが歴史上 27 回挿入されていることを知って、記録として面白いと思ったので一覧に入れました。
作ったもの
うるう年・うるう秒 — https://sen.ltd/portfolio/leap-years/
- 単年判定: 年を入力すると即「うるう年 / 平年」と表示
-
範囲一覧:
1900-2100を入れると、その間のうるう年を全部列挙 - うるう秒履歴: 1972 年から 2016 年までの 27 件の挿入履歴
vanilla JS + HTML + CSS、ゼロ依存、ビルドツール不要。ロジックは 30 行、うるう秒データは 27 行。node --test で 14 ケース。
isLeapYear は 3 行で正しく書ける
しばしば間違えられますが、ルールはシンプル:
export function isLeapYear(year) {
if (!Number.isInteger(year)) return false
if (year % 400 === 0) return true
if (year % 100 === 0) return false
return year % 4 === 0
}
3 つの if の順番が大事。
- 先に 400 を見る(例外の例外)
- 次に 100 を見る(例外)
- 最後に 4 を見る(通常ルール)
逆順に書くと 2000 年を「4 で割り切れる → 100 で割り切れる → 平年」と判定してしまう。
条件を 1 つの式にまとめた版はこんな感じですが:
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
読みにくいので、3 つの if に分けたほうが圧倒的に意図が伝わる。SRP っぽい分岐は読み手を助ける。
範囲内のうるう年一覧
leapYearsIn(1900, 2100) で 1900-2100 年のうるう年を全部返す:
export function leapYearsIn(startYear, endYear) {
const out = []
for (let y = startYear; y <= endYear; y++) {
if (isLeapYear(y)) out.push(y)
}
return out
}
1900 年と 2100 年が「100 で割り切れるけど 400 で割り切れない」ので 両方とも平年。一方 2000 年はうるう年。「1900 → 1904 → 1908 → ...」と始まって、2000 だけスキップされずに残り、2100 はスキップされる というリストになります。この「2000 年だけ特別」感が視覚的に楽しい。
うるう秒: 27 件の挿入履歴
うるう年と別の概念として、うるう秒があります。地球の自転の微妙な揺らぎと原子時計がズレるのを補正するために、UTC に 1 秒挿入する仕組み。1972 年から 2016 年までに 27 回挿入されました:
export const LEAP_SECONDS = [
{ date: '1972-06-30', delta: 1 },
{ date: '1972-12-31', delta: 1 },
{ date: '1973-12-31', delta: 1 },
// ...
{ date: '2016-12-31', delta: 1 },
]
挿入タイミングは常に 6 月 30 日か 12 月 31 日の 23:59:60 UTC。この時刻だけ「60 秒目」が存在します。23:59:59 → 23:59:60 → 00:00:00 という流れ。「秒が 60 まである瞬間」がシステム時計で発生するのは、アプリケーションが想定しないことが多く、過去に何度もインシデントの原因になってきました。
2017 年以降は挿入されていません。そして 2022 年の第 27 回国際度量衡総会 (CGPM) で、うるう秒は 2035 年までに廃止されることが決まりました。つまり本ツールのリストは、これ以上増えないで close される可能性が高い歴史資料。
削除のうるう秒は 1 度も発生していない
delta: 1 は「+1 秒」を意味しますが、仕様上は -1 秒(削除)もあり得ます。ただし 過去 50 年で 1 度も実行されたことがない。地球の自転は長期的には遅くなる傾向なので、常に +1 秒だけで補正できてきた、という歴史的経緯。
delta フィールドを持たせているのは概念的な正しさのため。将来もし -1 が発生することがあれば、このフィールドに書き込めば UI が自動で対応できる、という設計の保険。実際にはほぼ発動しない保険ですが、モデルとして正しい。
次のうるう年を計算する
「次のうるう年はいつ?」という質問にも答えるヘルパー:
export function nextLeapYear(year) {
let y = year
while (!isLeapYear(y)) y++
return y
}
ナイーブな線形探索で、最悪 4 イテレーション。数学的には最長でも 4 年しか進まないので、速度は気にしない。
さらに、単年判定の結果と次のうるう年を組み合わせて**「2024 年はうるう年。次は 2028 年」**みたいな表示を UI で出せます。
テスト
node --test で 14 ケース。境界年が中心:
test('2024 is a leap year', () => {
assert.equal(isLeapYear(2024), true)
})
test('2100 is NOT a leap year (100 exception)', () => {
assert.equal(isLeapYear(2100), false)
})
test('2000 IS a leap year (400 exception to the 100 exception)', () => {
assert.equal(isLeapYear(2000), true)
})
test('1900 is NOT a leap year', () => {
assert.equal(isLeapYear(1900), false)
})
test('2400 IS a leap year', () => {
assert.equal(isLeapYear(2400), true)
})
test('leapYearsIn 1990-2020 contains 2000', () => {
const list = leapYearsIn(1990, 2020)
assert.ok(list.includes(2000))
assert.ok(list.includes(1996))
assert.ok(list.includes(2020))
})
test('leapYearsIn 1800-2100 excludes 1800, 1900, 2100', () => {
const list = leapYearsIn(1800, 2100)
assert.ok(!list.includes(1800))
assert.ok(!list.includes(1900))
assert.ok(!list.includes(2100))
assert.ok(list.includes(2000))
})
test('leap seconds list has 27 entries', () => {
assert.equal(LEAP_SECONDS.length, 27)
})
1900 と 2000 と 2100 を全て明示的にテストするのが鉄則。片方でも抜けると、4/100/400 ルールのバグを素通りさせてしまう。
おわりに
SEN 合同会社の ポートフォリオシリーズ 100+ の 18 件目です。
- 📦 レポジトリ: https://github.com/sen-ltd/leap-years
- 🌐 ライブデモ: https://sen.ltd/portfolio/leap-years/
- 🏢 会社: https://sen.ltd/
うるう秒廃止で 2035 年にこのリストは完結します。歴史の一部を保存するツールとして。
