はじめに
普段は、言語勉強のアプリを作っている個人開発者です。
2026ワールドカップ楽しみなので、見逃さないためにWebサイトをつくりました。「自分が観たい試合だけカレンダーに入れたい」と思って、観たい試合をチェックして .ics でダウンロードできる静的サイトです。github pagesです。
FIFAの公式サイトにあってもよさそうな機能ですがないようなので、バイブコーディング作りました。各種イベントサイトに「カレンダーに追加」が増えてほしいので記事を書きます。
「カレンダーに追加」は、、.ics(iCalendar)ファイルを生成して配るだけです。サーバーもAPIも一切いりません。HTML/CSS/JS だけの静的サイトで完結します。実際このツールも、単一のHTMLファイルを GitHub Pages に置いているだけです。
この記事では、その「意外と簡単」な部分を、実際のコードとともに解説します。とくにハマりがちなタイムゾーンの扱いを重点的に書きます。
そもそも .ics とは何か
.ics は iCalendar という形式のプレーンテキストファイルです。RFC 5545 で標準化されていて、Google Calendar、Apple Calendar、Outlook など主要なカレンダーアプリがほぼすべて対応しています。
中身は単純で、このようなテキストです。
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//example//mytool//EN
BEGIN:VEVENT
UID:match-1@example.com
DTSTAMP:20260604T120000Z
DTSTART:20260611T190000Z
DTEND:20260611T210000Z
SUMMARY:🇲🇽 MEX v 🇿🇦 RSA
LOCATION:Estadio Azteca, Mexico City
END:VEVENT
END:VCALENDAR
VCALENDAR の中に VEVENT(予定)を並べるだけ。ユーザーがこのファイルを開くと、カレンダーアプリが「この予定を追加しますか?」と聞いてくれます。
つまり、この文字列を組み立ててダウンロードさせれば、それだけで「カレンダーに追加」機能が完成します。
最小実装:文字列を組み立ててダウンロードさせる
やることは2つだけです。
-
.ics形式の文字列を組み立てる - その文字列を
Blobにしてダウンロードリンクから落とす
1. 文字列を組み立てる
function buildICS(events) {
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//example//worldcup//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
];
for (const ev of events) {
lines.push(
"BEGIN:VEVENT",
`UID:${ev.id}@example.com`,
`DTSTAMP:${icsStamp(new Date())}`,
`DTSTART:${icsStamp(ev.start)}`,
`DTEND:${icsStamp(ev.end)}`,
`SUMMARY:${esc(ev.title)}`,
`LOCATION:${esc(ev.location)}`,
"END:VEVENT"
);
}
lines.push("END:VCALENDAR");
return lines.join("\r\n"); // ← 改行は CRLF
}
ポイントが2つあります。
改行は \r\n(CRLF)にする。 RFC 5545 は改行コードを CRLF と定めています。\n だけでも多くのアプリは寛容に読んでくれますが、Outlook など一部の実装は厳格なので、素直に CRLF にしておくのが安全です。
特殊文字はエスケープする。 , ; \ は予約文字なのでバックスラッシュでエスケープ、改行は \n(リテラルの2文字)に変換します。
function esc(s) {
return String(s)
.replace(/[\\;,]/g, (m) => "\\" + m)
.replace(/\n/g, "\\n");
}
2. Blob にしてダウンロードさせる
ここがサーバー不要のキモです。生成した文字列を Blob にして、URL.createObjectURL で一時URLを作り、<a download> をプログラムからクリックするだけ。
function download(events) {
const ics = buildICS(events);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "worldcup.ics";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000); // 後始末
}
これで完成です。
本題:タイムゾーンをどう扱うか
用意する時間は、UTCにすればOKです。.ics には、UTC をそのまま書ける記法があります。末尾に Z を付けるだけです。
DTSTART:20260611T190000Z
末尾の Z(Zulu time = UTC)が「これはUTCの絶対時刻だ」という意味になります。
Z 付きの UTC で書いておくと、カレンダーアプリ側が、ユーザーのローカルタイムゾーンに自動変換して表示してくれます。
UTC への変換コード
JavaScript の Date.UTC() を使えば、UTC の絶対時刻を簡単に作れます。
// 例:メキシコシティ現地 6/11 15:00 (CDT, UTC-5) のキックオフ
// → UTC では 6/11 20:00 ... のように、会場TZのオフセットを足して UTC を求める
const kickoffUTC = new Date(Date.UTC(2026, 5, 11, 20, 0, 0));
// 月は 0 始まりなので 5 = 6月
そして .ics に書き出すフォーマッタ。getUTCHours() など UTC 系のゲッターを使うのがポイントです(ローカル系の getHours() を使うと、生成する人の環境によって結果が変わってしまう)。
function pad(n) {
return String(n).padStart(2, "0");
}
function icsStamp(d) {
return (
d.getUTCFullYear() +
pad(d.getUTCMonth() + 1) +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
pad(d.getUTCMinutes()) +
pad(d.getUTCSeconds()) +
"Z" // ← これが UTC の目印
);
}
これで、どの国の誰がいつダウンロードしても、生成される .ics は同一。そしてそれぞれのカレンダーアプリが、見る人のタイムゾーンに勝手に直してくれます。
つまずきやすいポイントまとめ
実際に作ってみて、ハマった/気をつけた点を挙げておきます。
-
改行は CRLF(
\r\n)。 厳格なアプリ対策。 -
,;\のエスケープ。 会場名にカンマが入ると壊れがち。 -
UTC 系ゲッター(
getUTCHours等)を使う。 ローカル系を使うと生成環境依存になる。 -
UIDは予定ごとに一意に。 同じ UID だとカレンダー側で「同じ予定」と見なされ、再ダウンロード時に重複登録を防げる一方、UID が衝突すると上書きされる。用途に応じて設計する。 -
DTENDを忘れない。 終了時刻がないと終日予定扱いになるアプリがある。今回は一律「開始+2時間」にした。 - 日付の繰り上がり。 「現地 深夜0:00」の試合を UTC に直すと日付がまたぐ。ここはデータ作成時にミスりやすいので要検算(私も最初いくつか1日ずれていました)。
主張:日付を扱うサービスは、.ics をつけてもいいのではないか
「カレンダー連携」と聞いて身構えていた過去の自分に、「.ics を文字列で吐くだけだよ」と言ってあげたいです。同じように身構えている人の参考になれば。
おわりに:期間限定のサイトです
最後に宣伝を少しだけ。今回作ったツールはこれです。
無料・広告もないです。
このサイトは 2026年7月19日(決勝の日)までしか意味がありません。賞味期限のあるサイトです。もしワールドカップを観る予定があるなら、ぜひ使ってみてください!
