GASでカレンダーに予定を自動登録する:予約の一覧からカレンダーへ

公開 書いた人 大林 諒

スプレッドシートの予約一覧に入った予約を、Google Apps Script(GAS)でGoogleカレンダーの予定にする仕組みを作ります。例はサンプル料理教室で、体験レッスンと通常レッスンの予約を「レッスン予約」という共有カレンダーに載せ、キッチンの予約も同時に入れます。読み終わると、予約が入れば予定ができ、時間が変われば予定が動き、キャンセルなら予定が消える状態を、自分で組めます。前提は、Google Workspaceのアカウントがあり、予約一覧のスプレッドシートとカレンダーの両方を編集できることです。

この記事の内容
  1. 先に決めること
  2. CalendarAppでできること
  3. 予約一覧から予定を作る手順
  4. 教室(リソース)と共有カレンダーに入れる
  5. 予約スケジュールとの使い分け
  6. 上限
  7. できないことと、その代わり
  8. よく詰まるところ
  9. 次にやること

先に決めること

カレンダーの自動登録で後からやり直しになるのは、コードではなく次の5つの決めごとです。

1つ目は、どのカレンダーに入れるかです。個人のカレンダーに入れると、その人が退職したときに予定ごと持っていかれます。サンプル料理教室では、代表のアカウントで「レッスン予約」というカレンダーを新しく作り、講師と受付に共有しています。

2つ目は、正はどちらかです。スプレッドシートを正にし、カレンダーは「見るための写し」と決めます。カレンダーの画面で予定をドラッグして時間を変えても、スプレッドシートには戻りません。変更は必ず予約一覧で行う、とスタッフに伝えておきます。両方を正にして双方向に合わせようとすると、どちらが新しいかの判定で必ず揉めます。

3つ目は、会員を予定のゲストにするかです。ゲストに入れると会員のカレンダーにも予定が出ますが、招待メールや変更の通知が会員に飛ぶ可能性が出てきます。サンプル料理教室では会員はゲストに入れず、連絡はメールのリマインドで行っています(「GASでメールを自動送信する:スプレッドシートの宛先に1通ずつ差し込む」)。ゲストに入れるのは担当の講師だけです。

4つ目は、教室(キッチン)を予約するかです。キッチンを管理コンソールの「リソース」として登録しておくと、予定のゲストにキッチンを加えるだけで予約になり、ほかの予定と重なっていないかがカレンダー上で見えます。

5つ目は、どのタイミングで反映するかです。編集のたびにすぐ反映するか、15分おきにまとめて反映するかを決めます。サンプル料理教室は15分おきにしています。理由は手順のところで書きます。

決めることサンプル料理教室の答え
入れるカレンダー代表のアカウントで作った「レッスン予約」
正はどちらか予約一覧(スプレッドシート)。カレンダーは写し
ゲスト担当講師とキッチンだけ。会員は入れない
招待メール送らない(sendInvites を付けない)
反映のタイミング15分おきの時間主導型トリガー

CalendarAppでできること

GASからカレンダーを扱う標準の仕組みがCalendarAppです。予定の作成・変更・削除、ゲストの追加、色、リマインダーまでこれで足ります。もっと細かい設定(Calendar APIの全機能)が必要なときは、高度なサービスの「Calendar」を有効にして使います。

やりたいことCalendarAppの書き方補足
カレンダーを取るCalendarApp.getCalendarById(id)見つからないと null
名前でカレンダーを探すCalendarApp.getCalendarsByName('レッスン予約')同名が複数あると配列に複数入る
時間のある予定を作るcal.createEvent(件名, 開始, 終了, オプション)オプションは description・location・guests・sendInvites
終日の予定を作るcal.createAllDayEvent(件名, 日付)期間指定のときは終了日が含まれない
予定をIDで取るcal.getEventById(id)見つからない・権限が無いと null
期間内の予定を取るcal.getEvents(開始, 終了)終了時刻ちょうどは含まれない
時間を変えるevent.setTime(開始, 終了)
件名・説明・場所を変えるsetTitle・setDescription・setLocation
色を付けるevent.setColor(CalendarApp.EventColor.GREEN)11色。画面の「バジル」が GREEN
ゲストを足す・外すevent.addGuest(メール)・event.removeGuest(メール)リソースのアドレスもゲストとして足す
リマインダーaddPopupReminder(分)・addEmailReminder(分)・removeAllReminders()
目印を付けるevent.setTag('予約ID', 'R-001')・getTag('予約ID')画面には出ない。照合に使える
削除するevent.deleteEvent()

sendInvites の既定は false で、付けなければ招待メールは送られません。時刻は、タイムゾーンを指定しない限りスクリプトのタイムゾーンで解釈されます。スクリプトのタイムゾーンとカレンダーのタイムゾーンが違うと、予定が9時間ずれる原因になります。

色の名前は、画面とコードで違います。画面の「トマト」が RED、「フラミンゴ」が PALE_RED、「ミカン」が ORANGE、「バナナ」が YELLOW、「セージ」が PALE_GREEN、「バジル」が GREEN、「ピーコック」が CYAN、「ブルーベリー」が BLUE、「ラベンダー」が PALE_BLUE、「ブドウ」が MAUVE、「グラファイト」が GRAY です。

予約一覧から予定を作る手順

カレンダーを作って共有する

  1. 代表のアカウントでパソコンのGoogleカレンダーを開く
  2. 左の「他のカレンダー」の横の「+」を押し、「新しいカレンダーを作成」を押す
  3. 名前に「レッスン予約」と入れ、「カレンダーを作成」を押す
  4. 左の「マイカレンダー」で「レッスン予約」の右の「︙」を押し、「設定と共有」を押す
  5. 「共有する相手」の「ユーザーやグループを追加」で、講師と受付のグループを入れる
  6. 権限は、受付に「変更可能」、講師に「予定の詳細の表示」を選び、「送信」を押す
  7. 同じ画面の「カレンダーの統合」にある「カレンダー ID」を控える

カレンダーの作成はブラウザでしかできません。スマホのアプリからは作れないので、パソコンで作業します。共有の権限の違いは「Googleカレンダーの共有:社内の予定・教室や設備・予約スケジュール」に表でまとめています。講師に変更の権限を渡さないのは、カレンダーの画面で予定を動かしてもスプレッドシートに戻らないからです。

カレンダーの「設定と共有」。「共有する相手」に講師と受付が並び、下に「カレンダー ID」が見えている状態
カレンダーの「設定と共有」。「共有する相手」に講師と受付が並び、下に「カレンダー ID」が見えている状態

予約一覧に予定IDの列を作る

予約一覧には、予定IDを書き戻す列と、最後に反映した内容を覚えておく列を足します。予定IDがあれば、次の実行で同じ予約から2つ目の予定を作らずに済みます。

列見出し例誰が入れるか
A予約IDR-20261105-03受付
Bレッスン日2026/11/05受付
C開始18:30受付
D終了21:00受付
Eレッスン名季節の和食コース受付
F講師佐藤(sato@sample-cooking.example)受付がプルダウンで選ぶ
G氏名山田 花子受付
H対応状況確定/キャンセル受付
I予定ID(GASが書く)GAS
J反映した内容(GASが書く)GAS

I列とJ列はGASだけが書く列です。スプレッドシートの「範囲を保護」で、代表以外が編集できないようにしておくと、誤って消されて二重登録になる事故を防げます。保護のかけ方は「スプレッドシートの共有と保護:権限・範囲の保護・プルダウン」で扱っています。

予約一覧。I列に予定IDが入った行と、まだ空の行が並び、右の「保護されているシートと範囲」にI〜J列が出ている状態
予約一覧。I列に予定IDが入った行と、まだ空の行が並び、右の「保護されているシートと範囲」にI〜J列が出ている状態

スクリプトを貼る

  1. 予約一覧のスプレッドシートで「拡張機能」→「Apps Script」を開く
  2. 左の「プロジェクトの設定」(歯車)を押し、タイムゾーンが「(GMT+09:00) 日本標準時 – 東京」になっているかを見る
  3. 左の「エディタ」に戻り、コード.gs に下のコードを貼る
  4. CALENDAR_ID と KITCHEN_ID を自分の値に替えて保存する
const CALENDAR_ID = 'YOUR_CALENDAR_ID@group.calendar.google.com';
const KITCHEN_ID = 'YOUR_KITCHEN_RESOURCE_ID@resource.calendar.google.com';
const SHEET_NAME = '予約一覧';
// 列番号(A列=0)
const C = { id: 0, date: 1, start: 2, end: 3, lesson: 4, teacher: 5, name: 6, status: 7, eventId: 8, synced: 9 };

function syncReservations() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30000)) return;
  try {
    const cal = CalendarApp.getCalendarById(CALENDAR_ID);
    if (!cal) throw new Error('カレンダーが見つかりません。IDと共有の権限を確かめてください');
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    const range = sheet.getDataRange();
    const values = range.getValues();
    const shown = range.getDisplayValues();

    for (let i = 1; i < values.length; i++) {
      const row = values[i];
      const s = shown[i];
      if (row[C.id] === '') continue;
      const eventId = row[C.eventId];

      // キャンセル:予定があれば消す
      if (row[C.status] === 'キャンセル') {
        if (eventId && eventId !== '削除済み') {
          const ev = cal.getEventById(eventId);
          if (ev) ev.deleteEvent();
          writeBack(sheet, i, '削除済み', '');
        }
        continue;
      }
      if (row[C.status] !== '確定') continue;

      const start = toDate(row[C.date], s[C.start]);
      const end = toDate(row[C.date], s[C.end]);
      if (!start || !end || end <= start) {
        writeBack(sheet, i, eventId, '時刻エラー');
        continue;
      }
      const teacherEmail = extractEmail(row[C.teacher]);
      const title = row[C.lesson] + '(' + row[C.name] + '様)';
      const desc = '予約ID:' + row[C.id] + '\n講師:' + row[C.teacher];
      const fingerprint = [title, start.getTime(), end.getTime(), teacherEmail].join('|');
      if (fingerprint === row[C.synced]) continue; // 変わっていない

      let ev = (eventId && eventId !== '削除済み') ? cal.getEventById(eventId) : null;
      if (!ev) {
        // 新規:作って予定IDを書き戻す
        ev = cal.createEvent(title, start, end, {
          description: desc,
          location: 'サンプル料理教室 キッチン',
          guests: [teacherEmail, KITCHEN_ID].filter(String).join(','),
        });
        ev.setTag('予約ID', String(row[C.id]));
      } else {
        // 変更:時間・件名・講師を直す
        ev.setTime(start, end);
        ev.setTitle(title);
        ev.setDescription(desc);
        replaceTeacher(ev, teacherEmail);
      }
      ev.setColor(String(row[C.lesson]).indexOf('体験') !== -1
        ? CalendarApp.EventColor.ORANGE : CalendarApp.EventColor.GREEN);
      writeBack(sheet, i, ev.getId(), fingerprint);
      Utilities.sleep(300); // 短時間に呼びすぎない
    }
  } finally {
    lock.releaseLock();
  }
}

function writeBack(sheet, i, eventId, synced) {
  sheet.getRange(i + 1, C.eventId + 1, 1, 2).setValues([[eventId, synced]]);
  SpreadsheetApp.flush();
}

// 日付のセルと「18:30」の表示から、日本時間の日時を作る
function toDate(dateCell, timeText) {
  if (!(dateCell instanceof Date)) return null;
  const m = String(timeText).match(/^(\d{1,2}):(\d{2})/);
  if (!m) return null;
  return new Date(dateCell.getFullYear(), dateCell.getMonth(), dateCell.getDate(), Number(m[1]), Number(m[2]));
}

function extractEmail(text) {
  const m = String(text).match(/[^\s(())]+@[^\s(())]+/);
  return m ? m[0] : '';
}

// 講師が替わったとき、前の講師を外して新しい講師を足す(キッチンは残す)
function replaceTeacher(ev, teacherEmail) {
  ev.getGuestList().forEach(function (g) {
    const email = g.getEmail();
    if (email !== KITCHEN_ID && email !== teacherEmail) ev.removeGuest(email);
  });
  if (teacherEmail && !ev.getGuestByEmail(teacherEmail)) ev.addGuest(teacherEmail);
}

このコードの要は、予定IDと「反映した内容」の2列です。予定IDが空の行だけ新しく作り、作ったらすぐIDを書き戻します。途中で止まっても、作った分のIDは残っているので、次の実行で同じ予定をもう1つ作ることはありません。「反映した内容」には件名・開始・終了・講師をつないだ文字列を入れ、前回と同じなら何もしません。15分ごとに全行を見ても、変わった行だけをカレンダーに書きに行くので、呼び出しの回数が増えずに済みます。

setTag で予約IDを予定に埋め込んでいるのは、万一I列が消えたときの保険です。カレンダーから期間内の予定を取り、getTag('予約ID') で予約IDと突き合わせれば、どの予定がどの予約かを復元できます。

Apps Scriptの「プロジェクトの設定」。タイムゾーンが東京になっている状態
Apps Scriptの「プロジェクトの設定」。タイムゾーンが東京になっている状態

承認して試す

  1. 予約一覧に、明日の日付・「確定」のテスト行を1行入れる
  2. エディタで関数 syncReservations を選び、「実行」を押す
  3. 承認を求められたら代表のアカウントを選び、カレンダーとスプレッドシートの権限を許可する
  4. 「レッスン予約」カレンダーに予定ができ、I列に予定IDが入ったのを見る
  5. テスト行の開始を30分ずらして、もう一度「実行」を押し、予定が動いたのを見る
  6. 対応状況を「キャンセル」にして「実行」を押し、予定が消えてI列が「削除済み」になったのを見る

新規・変更・キャンセルの3つを必ず通しで試します。新規だけ試して運用に入ると、時間を変えたときに予定が2つになっていた、という事故に後から気づきます。

「レッスン予約」カレンダー。体験はミカン色、通常はバジル色で並び、予定の詳細にキッチンと講師がゲストとして出ている状態
「レッスン予約」カレンダー。体験はミカン色、通常はバジル色で並び、予定の詳細にキッチンと講師がゲストとして出ている状態

15分おきに動かす

  1. エディタ左の「トリガー」を押し、「トリガーを追加」を押す
  2. 実行する関数に syncReservations を選ぶ
  3. イベントのソースに「時間主導型」を選ぶ
  4. タイプに「分ベースのタイマー」、間隔に「15分おき」を選ぶ
  5. 「保存」を押す

「編集時」のトリガーで即時に反映する形もできますが、サンプル料理教室では採っていません。受付が1つの予約で日付・開始・終了を順に打つと、そのたびに走って中途半端な時刻の予定ができること、GASやフォームが書いた行では編集時のトリガーが動かないこと、の2つが理由です。フォームから入る予約は「フォーム送信時」、手入力の分は15分おき、と分けるのが扱いやすい形です。トリガーの種類ごとの違いは「GASのトリガー:定期実行・フォーム送信時・編集時」にまとめています。

トリガーの追加画面。時間主導型・分ベースのタイマー・15分おきを選んだ状態
トリガーの追加画面。時間主導型・分ベースのタイマー・15分おきを選んだ状態

教室(リソース)と共有カレンダーに入れる

キッチンをリソースとして登録する

キッチンのように「同じ時間に1つしか使えないもの」は、管理コンソールでカレンダーのリソースにします。リソースの作成には、ビルディングとリソースを管理できる管理者の権限が要ります。

  1. 管理コンソールで「ディレクトリ」→「ビルディングとリソース」→「概要」を開く
  2. ビルディングが無ければ、先に「サンプル料理教室」のビルディングを作る
  3. 「リソース管理」の「開く」を押す
  4. 追加のアイコンを押す
  5. 「ビルディング ID」に先に作ったビルディングのIDを(完全に一致させて)入れ、リソース名に「キッチンA」、カテゴリに「その他」、収容人数に「8」を入れる
  6. 「リソースの追加」を押す

追加してから数分で使えるようになりますが、全員のカレンダーに出てくるまで最長で24時間ほどかかることがあります。作った当日にGASから入れて「見つからない」となったら、時間を置いてから試します。リソースにはそれぞれメールアドレスがあり、リソースを編集・共有するときは、このアドレスを「マイカレンダー」に追加して使います。コードの KITCHEN_ID には、「リソース管理」でキッチンAを開いたときに出るリソースのメールアドレスを入れます。

管理コンソールの「リソース管理」。キッチンAが一覧に並んでいる状態
管理コンソールの「リソース管理」。キッチンAが一覧に並んでいる状態

リソースを予定に入れるときの注意

GASからキッチンを予約するのは、人のゲストを足すのと同じで、guests や addGuest にリソースのアドレスを入れます。重なった予約をリソースが受けるか辞退するかは、リソース側の承諾の設定によります。GASの createEvent はキッチンが取れたかどうかを返さないので、予定はできているのにキッチンは取れていない、ということが起こりえます。

重なりを防ぎたいときは、予定を作る前にキッチンのカレンダーで同じ時間の予定を取って確かめます。

// キッチンの同じ時間帯に、ほかの予定が無いかを見る
function isKitchenFree(start, end, selfEventId) {
  const kitchen = CalendarApp.getCalendarById(KITCHEN_ID);
  if (!kitchen) return true; // 見えないときは確かめられない
  return kitchen.getEvents(start, end).every(function (ev) {
    return ev.getId() === selfEventId;
  });
}

getCalendarById でリソースのカレンダーを取るには、実行するアカウントのカレンダーの一覧にそのリソースが追加されている必要があります。空いていなかった行は、予定を作らずにJ列へ「キッチン重複」と書けば、受付が朝に一覧で気づけます。

講師ごとのカレンダーに出したいとき

講師が自分のカレンダーで担当レッスンを見たい場合、方法は2つあります。1つは、この記事のコードのように講師を予定のゲストに入れる方法で、講師のカレンダーに予定が自動で出ます。もう1つは、講師が「レッスン予約」カレンダーを自分の一覧に追加する方法で、全員分のレッスンが見えます。担当以外は見なくてよいならゲスト、全体を見たいなら共有、と分けます。

ゲストに入れた講師が自分のカレンダーで予定を「いいえ」にしても、スプレッドシートには戻りません。講師の都合の連絡はChatのスペースで受け、受付が予約一覧の講師を直す運用にしています。

予約スケジュールとの使い分け

Googleカレンダーの予約スケジュールは、空き時間を予約ページで公開して、相手に枠を選んでもらう機能です。この記事のGASは、すでに決まった予約をカレンダーに写す仕組みです。入口が違います。

比べること予約スケジュールGASで予約一覧から登録
予約の入口予約ページで相手が枠を選ぶフォーム・電話・LINEなど、何でもよい
予約の正カレンダーの予定スプレッドシートの予約一覧
定員のあるレッスン1枠1予約の考え方1レッスンに複数の予約を並べられる
教室(リソース)の予約予約スケジュールの予定には追加できないゲストに足せる
準備時間・1日の上限設定できる自分でコードに書く
確認メール・リマインダー標準で出せる(リマインダーは最大5件)自分でGASのメールを組む
支払いStripeと連携できる別の仕組みが要る
作れる場所パソコンのブラウザだけスプレッドシートとGAS

サンプル料理教室では、1対1の体験の相談(オンラインの面談)は予約スケジュールで受け、定員のあるレッスンの予約は予約一覧とGASで回しています。1つのレッスンに6人が入る、キャンセル待ちを持つ、月謝と出席に紐付ける、といった話は予約スケジュールの外にあるからです。予約スケジュールの設定のしかたは「Googleカレンダーの共有:社内の予定・教室や設備・予約スケジュール」で扱っています。

上限

Apps Scriptの割り当てでは、カレンダーの予定の作成は1日あたり、無料のGoogleアカウントで5,000件、Google Workspaceで10,000件です(2026年10月時点)。1回の実行は6分までです。料理教室の予約で1日の上限に当たることはまずありませんが、過去の予約を一度にまとめて登録するときは、短時間に呼びすぎて「Service invoked too many times in a short time: Calendar.」のエラーが出ることがあります。コードの Utilities.sleep(300) はこのための間で、まとめて登録するときは1,000ミリ秒に延ばします。数百行を一度に流すなら、6分に収まるように対象を月ごとに分けて実行します。

できないことと、その代わり

できないこと代わりの手
カレンダーで動かした予定をスプレッドシートに戻す正をスプレッドシートに決め、変更は予約一覧で行う。カレンダーは講師に閲覧だけ渡す
予定が辞退されたかをGASの作成時に知る作る前に getEvents でキッチンの空きを確かめる
繰り返しの予定の1回分をIDで確実に取る繰り返しの予定はIDが共通になるので、レッスン1回ずつを別の予定として作る
作った当日のリソースを確実に使う反映に最長24時間ほどかかることがある。前日までに作っておく
ゲストへの削除の通知を細かく制御する高度なサービスのCalendarでAPIの sendUpdates を使う
予約ページそのものをGASで作る予約スケジュールの画面で作る
1つの予約を複数のGoogleアカウントのカレンダーに直接書く共有カレンダー1つに書き、各自が自分の一覧に追加する

繰り返しの予定は、CalendarAppの説明でも「シリーズ内の予定は同じIDを持つことがあり、その場合はシリーズの最初の予定だけが返る」とされています。毎週のレッスンを繰り返しの予定で作り、1回分だけGASで直そうとすると、別の回が動きます。予約と予定を1対1にしておけば、この問題は起きません。

よく詰まるところ

症状原因直し方
予定が9時間ずれるスクリプトのタイムゾーンが東京になっていない「プロジェクトの設定」でタイムゾーンを東京にする。appsscript.json の timeZone を Asia/Tokyo にする
予定が1899年に入る・時刻がずれる時刻のセルを getValues() のまま使った(1899年12月30日の日付付きで返り、スプレッドシートとスクリプトのタイムゾーンが違うとずれる)getDisplayValues() で「18:30」を読み、日付と組み立てる
終日の予定が1日短い期間指定の終日の予定は終了日が含まれない11/5〜11/6の2日なら終了日を11/7にする
同じ予約から予定が2つできた予定IDの書き戻し前に止まった、トリガーが2つある、I列を人が消した作った直後にIDを書く。LockService を入れる。I列を保護する
getEventById が null になる予定を手で消した、または CalendarApp.getEventById で既定のカレンダーを探しているcal.getEventById のように対象のカレンダーから探す。null なら作り直す
「カレンダーが見つかりません」IDの写し間違い、実行するアカウントに共有されていない「設定と共有」でIDを確かめ、実行するアカウントに「変更可能」以上を渡す
講師や会員に招待メールが飛んだsendInvites: true を付けた、または手で予定を開いて保存したときに送信を選んだsendInvites を付けない。会員はゲストに入れない
キッチンが「辞退」で表示される同じ時間に別の予定がキッチンを押さえていて、リソースが重複を辞退する設定になっている作る前に isKitchenFree で確かめ、重なったら作らずに知らせる
編集してもすぐ反映されない15分おきのトリガーで動かしている、または編集時トリガーがGASやフォームの書き込みでは動かない急ぐときはエディタから手動で実行する。フォームの分は「フォーム送信時」で動かす
「Service invoked too many times in a short time: Calendar.」短時間に予定を作りすぎた呼び出しの間に Utilities.sleep(1000) を入れ、対象を分ける
講師から「予定が見えない」と言われる講師が「レッスン予約」を自分の一覧に追加していない、または権限が「時間枠のみ」権限を「予定の詳細の表示」にし、共有のメールのリンクから講師に追加してもらう
予定の詳細。ゲストにキッチンAが「辞退」で出ている状態(重複の例)
予定の詳細。ゲストにキッチンAが「辞退」で出ている状態(重複の例)

次にやること

予約の入口をLINEに広げると、LINEで受けた予約を予約一覧に入れ、この記事のGASでカレンダーへ写す、という流れになります。予約一覧の列の設計を先に固めておくと、入口が増えても仕組みは変わりません。

あわせて読む