本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
会議室・備品の予約ボードを作っていて、その予約を Google カレンダーでも見られるようにしたくなりました。手段として iCalendar(.ics)フィードを用意したのですが、フォーマットを不勉強だったので、実装しながら調べた内容を整理します。iCalendar は RFC 5545(Request for Comments 5545)で定義されるカレンダーデータの交換フォーマットで、拡張子は .ics、Content-Type は text/calendar です。
デモ
- 予約ボード(Cloudflare Workers 上の公開版): https://yoyaku.ldas.jp
- iCalendar フィード:
https://yoyaku.ldas.jp/api/ical/{token}({token}は各予約表の共有トークン)
予約表ごとに共有用のトークンを持たせていて、その URL を Google カレンダーの「URL で追加」に貼ると購読できます。

全体の流れ
予約データを、リクエストのたびに .ics として組み立てて返し、カレンダーアプリがその URL を購読する、という構成です。
iCalendar の入れ物: VCALENDAR と VEVENT
.ics は行指向のテキストで、BEGIN:... と END:... で囲まれたコンポーネントの入れ子です。一番外側が VCALENDAR、その中に予定 1 件ごとの VEVENT が並びます。
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//yoyaku-board//JP
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:会議室予約
X-WR-TIMEZONE:Asia/Tokyo
BEGIN:VEVENT
...
END:VEVENT
END:VCALENDAR
VERSION:2.0とPRODID(生成元の識別子)は必須です。X-WR-CALNAME/X-WR-TIMEZONEはX-で始まる非標準の拡張プロパティで、カレンダー名と既定タイムゾーンのヒントです。標準ではありませんが、主要なカレンダーアプリが参照します(ただし後述のとおり、購読時にカレンダー名として使われるとは限りません)。
最小の VEVENT
予定 1 件は VEVENT で表します。予約 1 件をこの形にしています。
BEGIN:VEVENT
UID:yoyaku-42@yoyaku.ldas.jp
DTSTAMP:20260722T040000Z
DTSTART;TZID=Asia/Tokyo:20260728T130000
DTEND;TZID=Asia/Tokyo:20260728T150000
SUMMARY:大会議室 — 中村
LOCATION:大会議室
DESCRIPTION:定例ミーティング
END:VEVENT
UIDは予定を一意に識別する ID です(役割は後述)。DTSTAMPはこのデータを生成した時刻で、必須です。末尾Zは協定世界時(UTC, Coordinated Universal Time)を表します。DTSTART/DTENDが開始・終了時刻です。SUMMARYが予定のタイトル、LOCATIONが場所、DESCRIPTIONが説明です。
日時とタイムゾーンの書き方
つまずきやすいのが日時の書き方です。RFC 5545 では 3 通りあります。
DTSTART:20260728T040000Z (1) UTC。末尾 Z は「その瞬間」を指す
DTSTART:20260728T130000 (2) フローティング。タイムゾーン指定なし
DTSTART;TZID=Asia/Tokyo:20260728T130000 (3) TZID で地域を指定
- (1) UTC は末尾に
Zを付け、UTC の瞬間として一意に定まります。表示側で各自のタイムゾーンに変換されます。 - (2) フローティング はタイムゾーンを持たず、「見る人のローカル時刻」として扱われます。時刻がずれる原因になりやすいので、明確な時刻を持つ予定には向きません。
- (3) TZID は
TZID=に IANA(Internet Assigned Numbers Authority)タイムゾーンデータベースの地域名(Asia/Tokyoなど)を添えます。
今回は TZID=Asia/Tokyo を採用しました。日本は夏時間(DST, daylight saving time)がないので UTC 固定オフセットでも曖昧さは生じませんが、地域名で書いておくほうが読みやすく、DST のある地域にも素直に対応できます。
厳密には、RFC 5545(§3.2.19)は TZID で参照する地域ごとに、その定義を VTIMEZONE コンポーネントとして同梱することを要求しています(MUST)。ただし調査した限り、Google カレンダーは VTIMEZONE を省いて IANA の地域名を直接書いた .ics も受け入れて時刻を正しく解釈しました。仕様に厳密に従うなら、また互換性を重視するなら、VTIMEZONE を同梱するのが安全です。
UID: 更新になるか、重複するか
UID は予定の同一性の判断に使われます。フィードは購読側から定期的に取り直されるので、同じ予定には毎回同じ UID を返す必要があります。予約 ID から UID を組み立てて安定させておくと、内容が変わったときに「更新」として扱われ、予定が重複しません。逆に、生成のたびに変わる UID(時刻やランダム値を混ぜるなど)にすると、取得のたびに別予定として増えていきます。
UID:yoyaku-42@yoyaku.ldas.jp
<予約ID>@<ホスト名> の形にして、予約 ID が同じなら UID も不変になるようにしています。
テキストのエスケープと行折り
SUMMARY などの TEXT 値には、エスケープと行折りの規則があります。
- エスケープ: バックスラッシュ
\、セミコロン;、カンマ,はそれぞれ\\\;\,に、改行は\nに置き換えます。 - 行折り(line folding): 1 行は 75 オクテットまでが推奨で、超える場合は CRLF(キャリッジリターン+ラインフィード)のあとに 1 個の空白を置いて折り返します。展開側はこの「改行+空白」を取り除いて 1 行に戻します。日本語は 1 文字が複数バイトになるので、折り返し位置が UTF-8 の途中に来ないよう、バイト列で数えつつ多バイト文字の途中で切らないようにします。
// TEXT 値のエスケープ(RFC 5545)
const escapeText = (v) =>
v.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
// 75 オクテットで折り返す(多バイト文字の途中で切らない)
function fold(line) {
const bytes = Buffer.from(line, 'utf8');
if (bytes.length <= 75) return line;
const out = [];
let start = 0;
while (start < bytes.length) {
let end = Math.min(start + (out.length === 0 ? 75 : 74), bytes.length);
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; // 継続バイトなら戻す
out.push(bytes.subarray(start, end).toString('utf8'));
start = end;
}
return out.join('\r\n ');
}
HTTP で配信する
あとは組み立てた文字列を text/calendar で返すだけです。行区切りは CRLF にします。Next.js(App Router)のルートハンドラだと、次のような形になります。
export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const sheet = await findSheetByToken(token);
if (!sheet) return new Response('No such calendar', { status: 404 });
const bookings = await reservationsInRange(sheet, /* 期間 */);
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//yoyaku-board//JP',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${escapeText(sheet.name)}`,
'X-WR-TIMEZONE:Asia/Tokyo',
];
for (const r of bookings) {
lines.push(
'BEGIN:VEVENT',
`UID:yoyaku-${r.id}@${new URL(request.url).hostname}`,
`DTSTAMP:${utcStamp()}`,
`DTSTART;TZID=Asia/Tokyo:${localStamp(r.date, r.startAt)}`,
`DTEND;TZID=Asia/Tokyo:${localStamp(r.date, r.endAt)}`,
`SUMMARY:${escapeText(`${r.itemName} — ${r.reserver}`)}`,
'END:VEVENT',
);
}
lines.push('END:VCALENDAR');
return new Response(lines.map(fold).join('\r\n') + '\r\n', {
headers: { 'Content-Type': 'text/calendar; charset=utf-8' },
});
}
localStamp は 2026-07-28 と 13:00 を 20260728T130000 に整形するだけの関数、utcStamp は現在時刻を 20260722T040000Z の形にする関数です。予約データを持つ範囲を絞りたい場合は、当月前後などの期間で取得件数を抑えます。
配信の際に返した実際のヘッダは次のとおりでした。
HTTP/2 200
content-type: text/calendar; charset=utf-8
content-disposition: inline; filename="calendar.ics"; filename*=UTF-8''%E4%BC%9A%E8%AD%B0%E5%AE%A4%E4%BA%88%E7%B4%84.ics
日本語のファイル名は HTTP ヘッダに直接は書けない(ヘッダは Latin-1 の範囲)ため、RFC 5987 の filename*=UTF-8''... で percent-encoding して添えています。
購読して分かった、実際の挙動
Google カレンダーの「URL で追加」で購読したところ、フォーマットの理解だけでは分からなかった点がいくつかありました。
- カレンダー名はフィードの
X-WR-CALNAMEが使われるとは限りません。調査した限り、URL 購読で追加したカレンダーの名前は URL がそのまま入り、フィード側で名前を送っていても初期表示には反映されませんでした。カレンダー設定の「名前」欄で手動でリネームする運用になります。 - 取得はリアルタイムではありません。購読フィードの再取得はカレンダーアプリ側の都合で行われ、数時間から 1 日程度の間隔のようです。予約ボードで足した予定がすぐには反映されません。即時に反映したい場合は、双方向に同期する仕組み(例: Google カレンダー API を使って予定を書き込む)を別途用意することになります。
- フィード URL は購読側から到達できる必要があります。URL 購読はカレンダーの提供元サーバがその URL を取得しにくる方式なので、社内ネットワーク内だけで動くサーバの URL は、外部のカレンダーサービスからは取得できません。その場合は
.icsをダウンロードして「インポート」する(一度きりの取り込みで、以後の同期はなし)か、公開到達可能な場所に配信する必要があります。
まとめ
.ics は「BEGIN/END で囲んだテキストを組み立てて text/calendar で返すだけ」で作れて、思っていたより素直でした。一方で、日時のタイムゾーン指定、UID の安定化、行折りとエスケープ、そして購読側(今回は Google カレンダー)の挙動(名前の扱い・更新間隔・到達性)が、実際に運用するうえでの勘所でした。読み取り専用の共有としては十分で、双方向のリアルタイム同期が必要になった段階で API 連携を検討する、という順序が扱いやすいと感じています。



コメント
…