0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

終日予定の終了日をそのまま DTEND に書くと長さ0になる。iCalendar の終わりは「最終日の翌日」

0
Posted at

背景

カレンダーに取り込むための .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補助で執筆した、個人開発の紹介記事です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?