本記事は生成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 を購読する、という構成です。

予約ボードのサーバが GET /api/ical/{token} を受けて .ics(text/calendar)をその場で生成し、Google カレンダー等がその 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.0PRODID(生成元の識別子)は必須です。
  • X-WR-CALNAME / X-WR-TIMEZONEX- で始まる非標準の拡張プロパティで、カレンダー名と既定タイムゾーンのヒントです。標準ではありませんが、主要なカレンダーアプリが参照します(ただし後述のとおり、購読時にカレンダー名として使われるとは限りません)。

最小の 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) TZIDTZID= に 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' },
  });
}

localStamp2026-07-2813:0020260728T130000 に整形するだけの関数、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 連携を検討する、という順序が扱いやすいと感じています。