スプレッドシートの予約一覧に入った予約を、Google Apps Script(GAS)でGoogleカレンダーの予定にする仕組みを作ります。例はサンプル料理教室で、体験レッスンと通常レッスンの予約を「レッスン予約」という共有カレンダーに載せ、キッチンの予約も同時に入れます。読み終わると、予約が入れば予定ができ、時間が変われば予定が動き、キャンセルなら予定が消える状態を、自分で組めます。前提は、Google Workspaceのアカウントがあり、予約一覧のスプレッドシートとカレンダーの両方を編集できることです。
この記事の内容
先に決めること
カレンダーの自動登録で後からやり直しになるのは、コードではなく次の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 です。
予約一覧から予定を作る手順
カレンダーを作って共有する
- 代表のアカウントでパソコンのGoogleカレンダーを開く
- 左の「他のカレンダー」の横の「+」を押し、「新しいカレンダーを作成」を押す
- 名前に「レッスン予約」と入れ、「カレンダーを作成」を押す
- 左の「マイカレンダー」で「レッスン予約」の右の「︙」を押し、「設定と共有」を押す
- 「共有する相手」の「ユーザーやグループを追加」で、講師と受付のグループを入れる
- 権限は、受付に「変更可能」、講師に「予定の詳細の表示」を選び、「送信」を押す
- 同じ画面の「カレンダーの統合」にある「カレンダー ID」を控える
カレンダーの作成はブラウザでしかできません。スマホのアプリからは作れないので、パソコンで作業します。共有の権限の違いは「Googleカレンダーの共有:社内の予定・教室や設備・予約スケジュール」に表でまとめています。講師に変更の権限を渡さないのは、カレンダーの画面で予定を動かしてもスプレッドシートに戻らないからです。

予約一覧に予定IDの列を作る
予約一覧には、予定IDを書き戻す列と、最後に反映した内容を覚えておく列を足します。予定IDがあれば、次の実行で同じ予約から2つ目の予定を作らずに済みます。
| 列 | 見出し | 例 | 誰が入れるか |
|---|---|---|---|
| A | 予約ID | R-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だけが書く列です。スプレッドシートの「範囲を保護」で、代表以外が編集できないようにしておくと、誤って消されて二重登録になる事故を防げます。保護のかけ方は「スプレッドシートの共有と保護:権限・範囲の保護・プルダウン」で扱っています。

スクリプトを貼る
- 予約一覧のスプレッドシートで「拡張機能」→「Apps Script」を開く
- 左の「プロジェクトの設定」(歯車)を押し、タイムゾーンが「(GMT+09:00) 日本標準時 – 東京」になっているかを見る
- 左の「エディタ」に戻り、
コード.gsに下のコードを貼る 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と突き合わせれば、どの予定がどの予約かを復元できます。

承認して試す
- 予約一覧に、明日の日付・「確定」のテスト行を1行入れる
- エディタで関数
syncReservationsを選び、「実行」を押す - 承認を求められたら代表のアカウントを選び、カレンダーとスプレッドシートの権限を許可する
- 「レッスン予約」カレンダーに予定ができ、I列に予定IDが入ったのを見る
- テスト行の開始を30分ずらして、もう一度「実行」を押し、予定が動いたのを見る
- 対応状況を「キャンセル」にして「実行」を押し、予定が消えてI列が「削除済み」になったのを見る
新規・変更・キャンセルの3つを必ず通しで試します。新規だけ試して運用に入ると、時間を変えたときに予定が2つになっていた、という事故に後から気づきます。

15分おきに動かす
- エディタ左の「トリガー」を押し、「トリガーを追加」を押す
- 実行する関数に
syncReservationsを選ぶ - イベントのソースに「時間主導型」を選ぶ
- タイプに「分ベースのタイマー」、間隔に「15分おき」を選ぶ
- 「保存」を押す
「編集時」のトリガーで即時に反映する形もできますが、サンプル料理教室では採っていません。受付が1つの予約で日付・開始・終了を順に打つと、そのたびに走って中途半端な時刻の予定ができること、GASやフォームが書いた行では編集時のトリガーが動かないこと、の2つが理由です。フォームから入る予約は「フォーム送信時」、手入力の分は15分おき、と分けるのが扱いやすい形です。トリガーの種類ごとの違いは「GASのトリガー:定期実行・フォーム送信時・編集時」にまとめています。

教室(リソース)と共有カレンダーに入れる
キッチンをリソースとして登録する
キッチンのように「同じ時間に1つしか使えないもの」は、管理コンソールでカレンダーのリソースにします。リソースの作成には、ビルディングとリソースを管理できる管理者の権限が要ります。
- 管理コンソールで「ディレクトリ」→「ビルディングとリソース」→「概要」を開く
- ビルディングが無ければ、先に「サンプル料理教室」のビルディングを作る
- 「リソース管理」の「開く」を押す
- 追加のアイコンを押す
- 「ビルディング ID」に先に作ったビルディングのIDを(完全に一致させて)入れ、リソース名に「キッチンA」、カテゴリに「その他」、収容人数に「8」を入れる
- 「リソースの追加」を押す
追加してから数分で使えるようになりますが、全員のカレンダーに出てくるまで最長で24時間ほどかかることがあります。作った当日にGASから入れて「見つからない」となったら、時間を置いてから試します。リソースにはそれぞれメールアドレスがあり、リソースを編集・共有するときは、このアドレスを「マイカレンダー」に追加して使います。コードの KITCHEN_ID には、「リソース管理」でキッチン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) を入れ、対象を分ける |
| 講師から「予定が見えない」と言われる | 講師が「レッスン予約」を自分の一覧に追加していない、または権限が「時間枠のみ」 | 権限を「予定の詳細の表示」にし、共有のメールのリンクから講師に追加してもらう |

次にやること
- Googleカレンダーの共有:社内の予定・教室や設備・予約スケジュール
- GASのトリガー:定期実行・フォーム送信時・編集時
- スプレッドシートで業務の台帳を作る:顧客・予約・在庫
- GoogleフォームとLINEをつなぐ:回答をLINEで知らせる・LINEから受け付ける
予約の入口をLINEに広げると、LINEで受けた予約を予約一覧に入れ、この記事のGASでカレンダーへ写す、という流れになります。予約一覧の列の設計を先に固めておくと、入口が増えても仕組みは変わりません。