背景
カレンダーに取り込むための .ics ファイルを作るツールを、自分用に置いています。
https://hashitosystem.com/tools/icsgen/
日付の扱いが昔から苦手で、ここのコードも何度か読み直してようやく腑に落ちました…
いちばん「なんで?」となったのが、終日予定の終了日に1を足しているところです。
.ics は iCalendar という形式のテキストファイルだ。
仕様は RFC 5545 で決まっていて、RFC はインターネットの標準を定めた文書のことである。
1つの予定は BEGIN:VEVENT から END:VEVENT の間に書き、DTSTART が開始、DTEND が終了を表す。
結論
9月26日だけの終日予定は、下記のように書きます。
終了日は 26 ではなく 27 です。
DTSTART;VALUE=DATE:20260926
DTEND;VALUE=DATE:20260927
RFC 5545 の 3.6.1 節は DTEND を "non-inclusive end" と書いています。
その時点を含まない終わり、という意味だ。
同じ節に、6月28日から7月8日までの予定で DTEND を7月9日にしている例が載っていました。
https://www.rfc-editor.org/rfc/rfc5545
さらに 3.8.2.2 節には、DTEND は DTSTART より後でなければならない(MUST)とあります。
つまり DTSTART と DTEND を両方 20260926 にしたファイルは、1日の予定どころか仕様違反になります。
カレンダーアプリごとの実際の表示までは試していないので、そこは何とも言えません。
ただ、アプリの親切さに任せてよい書き方ではない気がします。
ちなみに開始が日付だけで DTEND を書かない場合は1日の長さとして扱う、と同じ節に書いてあります。
1日だけの予定ならそれでも正しいです。
素直に1日足すと、日本ではずれる
「じゃあ1日足せばいい」と思って書きがちなのが下記です。
s は 2026-09-26 の形の文字列で、画面の日付入力から来る値を想定しています。
const naive = (s) => {
const d = new Date(s + 'T00:00:00');
d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10);
};
console.log(naive('2026-09-26'));
console.log(naive('2026-09-30'));
naive.js として保存し、日本時間で動かした結果は下記です。
$ TZ=Asia/Tokyo node naive.js
2026-09-26
2026-09-30
1日足したはずなのに、日付が変わっていません。
new Date(s + 'T00:00:00') は実行環境の時刻で解釈されるので、足した後は9月27日 0時(日本時間)になります。
ところが toISOString() は UTC(世界標準時)で文字列を作る。
日本時間は UTC より9時間進んでいるので、UTC では9月26日 15時で、前の日付に戻ってしまうわけです。
結果として DTEND が DTSTART と同じ値になり、さっきの仕様違反をそのまま踏みます。
ツールの実装
ツールでは、下記のように最初から最後まで UTC だけで計算しています。
入力は YYYY-MM-DD の文字列で、時刻は一切持ち込みません。
そのまま動くように、ゼロ埋めの部分だけ toISOString に置き換えて短くしてあります。
function addDays(dateStr, n) {
const [y, m, d] = dateStr.split('-').map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
date.setUTCDate(date.getUTCDate() + n);
return date.toISOString().slice(0, 10);
}
for (const s of ['2026-09-26', '2026-09-30', '2026-12-31', '2028-02-28', '2027-02-28']) {
console.log(s, '->', addDays(s, 1));
}
実行結果は下記です。
月末も年末も、うるう年の2月も繰り上がっています。
2026-09-26 -> 2026-09-27
2026-09-30 -> 2026-10-01
2026-12-31 -> 2027-01-01
2028-02-28 -> 2028-02-29
2027-02-28 -> 2027-03-01
こちらは日本時間で動かしても UTC で動かしても同じ結果になります。
Date.UTC で作って UTC のメソッドだけで読み書きするので、環境のタイムゾーンが入り込む場所がないからです。
画面と仕様で「終わり」が違う
ツールの入力欄は、26日だけの予定なら終了日に26日と入れる作りにしています。
人間の感覚は「最終日」で、ファイルの中は「その翌日」。
入力欄まで翌日にしてしまうと、使う人が毎回1日足して考えることになるので(私なら絶対に間違えます…)、変換はファイルに書き出す瞬間の1か所だけにしています。
時刻を指定した予定のほうには、この1日を足しません。
10時から11時の会議の DTEND は 11時そのもので、これも「11時ちょうどは含まない」と読めば同じルールで説明がつく。
1を足す必要があるのは、日付を単位にして「含む最終日」を受け取っている終日予定だけです。
終日予定を .ics に書き出すコードを持っている方は、DTEND が最終日の翌日になっているかを確認してください。
本記事はAI補助で執筆した、個人開発の紹介記事です。