Googleカレンダーからスケジュールの空きを調べてくれるアプリを作った

XやFacebookなどで発表しましたが、2025/2/3にレップ株式会社の代表取締役に就任しました。これまでの株式会社ケイカも継続しますので、兼任という形になります。どうぞ今後ともよろしくお願いいたします。

で、就任してみたらめちゃくちゃ忙しくて、とにかくスケジュールの調整がめんどいので、GASで何とかできないかなと思って作成しました。ほぼChatGPT先生が作ってくれてます。

# 使うもの

- GAS
    - 今回はスクリプトを2つ作成します
- スプレッドシート
    - カレンダーの予定を事前に取得しておき、記録するために使います
    - 最新を直接しようとしたらSlackコマンドのタイムアウトに引っかかってしまったのでこういう構成になっています
- Slack
    - Slackコマンドを使って、GASのアプリを叩き、スケジュールを取得します

# おおまかな流れ

1. Googleカレンダーから必要な人の予定をすべて取得して、スプレッドシートに記録します
1. Slackコマンドからウェブアプリを叩き、スケジュールを参照して結果を返します

# 作り方

## カレンダーデータ取得部

最初に、「カレンダーID」を取得します。
Googleカレンダーの設定から、マイカレンダーの設定に移動します。
「カレンダーの統合」にある「カレンダーID」が該当します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/edb6276e-6022-4aad-8b01-ea9e4b4966c0.png)

そのIDを、スプレッドシートの「カレンダーID」というシートに記録します。
自分がアクセスできるカレンダーなら取得できるはずなので、必要な分を全部記録します。
私は会社のみんなのカレンダーIDを記録しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/9e3eb961-1873-4736-8f0e-9960ec525021.png)

そして、このスプレッドシートにGASを仕込みます。
ただ、取得して記録するのに時間がかかるため、Slackからアクセスしたときに正しいデータが読み込めないという可能性があります。
そこで、最初は「予定一覧_tmp」というシートに書き込み、最後にシート名を差し替える形を取っています。

```JavaScript
function syncAllCalendarsToSheet() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const listSheet = ss.getSheetByName('カレンダー一覧');
  const tempSheetName = '予定一覧_tmp';
  const finalSheetName = '予定一覧';

  // 既存の temp シートがあれば削除
  const existingTemp = ss.getSheetByName(tempSheetName);
  if (existingTemp) ss.deleteSheet(existingTemp);

  // 一時シートを新規作成
  const tempSheet = ss.insertSheet(tempSheetName);
  tempSheet.appendRow(['カレンダーID', 'タイトル', '開始日時', '終了日時', '場所']);

  const now = new Date();
  const oneWeekLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
  const values = listSheet.getDataRange().getValues();

  for (let i = 1; i < values.length; i++) {
    const calendarId = values[i][0]?.toString().trim();
    if (!calendarId) continue;

    const calendar = CalendarApp.getCalendarById(calendarId);
    if (!calendar) {
      Logger.log(`カレンダーが見つかりません: ${calendarId}`);
      continue;
    }

    const events = calendar.getEvents(now, oneWeekLater);
    for (let event of events) {
      tempSheet.appendRow([
        calendarId,
        event.getTitle(),
        Utilities.formatDate(event.getStartTime(), Session.getScriptTimeZone(), "yyyy/MM/dd HH:mm"),
        Utilities.formatDate(event.getEndTime(), Session.getScriptTimeZone(), "yyyy/MM/dd HH:mm"),
        event.getLocation()
      ]);
    }
  }

  // 既存の「予定一覧」シートを削除（あれば）
  const oldSheet = ss.getSheetByName(finalSheetName);
  if (oldSheet) ss.deleteSheet(oldSheet);

  // 一時シートをリネーム
  tempSheet.setName(finalSheetName);
}
```

テストで実行すると、カレンダーへのアクセス許可を求めるダイアログが出ると思います。
適宜許可しておいてください。
時間ベースのトリガーを作成して、5分おきに実行します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/8687ad31-bf96-4514-9b66-6213fa1757bd.png)

## Slackコマンド応答部

次に、Slackコマンドからアクセスされる部分を作成していきます。
今回は別のファイルに分けましたが、1ファイルに集約することもできると思います。お好みで。
新規作成から「その他」＞「Google Apps Script」を選択します。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/ff2e0596-2db0-4836-a751-66effb8e9da0.png)

コードは以下です。
`const ss = SpreadsheetApp.openById('<<スプレッドシートID>>');` の箇所は、スプレッドシートのURLの一部を抜き出します。
`https://docs.google.com/spreadsheets/d/<<ここ>>/edit` の `<<ここ>>` がスプレッドシートIDです。

```JavaScript
function doPost(e) {
  const args = (e.parameter.text || '').trim().toLowerCase().split(/\s+/).filter(n => n);

  if (args.length === 0) {
    return ContentService.createTextOutput("⚠ 名前を指定してください（例: /schedule suzuki tanaka 5）")
      .setMimeType(ContentService.MimeType.TEXT);
  }

  // 数値が含まれていれば最大件数として使う
  let maxCount = 10;
  if (!isNaN(args[args.length - 1])) {
    maxCount = parseInt(args.pop(), 10);
  }

  const names = args;

  const result = getCommonFreeTimeFromSheet(names, maxCount);
  const fullMessage = `✅ 指定されたメンバー: ${names.join(', ')}\n表示件数: ${maxCount}件\n${result}`;

  return ContentService.createTextOutput(fullMessage).setMimeType(ContentService.MimeType.TEXT);
}

function getCommonFreeTimeFromSheet(names, maxCount) {
  const ss = SpreadsheetApp.openById('<<スプレッドシートID>>');
  const sheet = ss.getSheetByName('予定一覧');

  const now = new Date();
  const start = new Date(now);
  const end = new Date(start.getTime() + 14 * 24 * 60 * 60 * 1000); // 2週間

  const ignoreKeywords = ["オフィス"]; // 無視したいキーワード

  const data = sheet.getDataRange().getValues();
  const rows = data.slice(1); // 1行目はヘッダー

  const busy = [];

  for (const row of rows) {
    const name = row[0]?.toString().trim().toLowerCase().replace(/@.*$/, '');
    const title = row[1]?.toString().trim();
    const startTime = new Date(row[2]);
    const endTime = new Date(row[3]);

    // 対象の人かどうか、除外対象かどうか
    if (!names.includes(name)) continue;
    if (ignoreKeywords.some(keyword => title.includes(keyword))) continue;

    if (startTime < end && endTime > now) {
      busy.push({ start: startTime, end: endTime });
    }
  }

  // 空き時間を探す
  const freeSlots = [];
  let cursor = new Date(start);
  cursor.setHours(9, 0, 0, 0);

  while (cursor < end && freeSlots.length < maxCount) {
    const day = cursor.getDay();
    if (day >= 1 && day <= 5) {
      const slotStart = new Date(cursor);
      const slotEnd = new Date(slotStart.getTime() + 60 * 60 * 1000);

      // 開始時刻が 9:00〜19:00 か確認
      if (slotStart.getHours() < 9 || slotStart.getHours() > 19) {
        cursor = new Date(cursor.getTime() + 60 * 60 * 1000);
        continue;
      }

      const overlapping = busy.some(b => b.start < slotEnd && b.end > slotStart);
      if (!overlapping && slotStart > now) {
        freeSlots.push(slotStart);
      }
    }

    cursor = new Date(cursor.getTime() + 60 * 60 * 1000);
  }

  if (freeSlots.length === 0) {
    return "全員が空いている枠が見つかりませんでした。";
  }

  return freeSlots.map((d, i) => {
    const end = new Date(d.getTime() + 60 * 60 * 1000);
    const dayOfWeek = ['日', '月', '火', '水', '木', '金', '土'][d.getDay()];
    const dateStr = Utilities.formatDate(d, Session.getScriptTimeZone(), "yyyy/MM/dd");
    const startStr = Utilities.formatDate(d, Session.getScriptTimeZone(), "HH:mm");
    const endStr = Utilities.formatDate(end, Session.getScriptTimeZone(), "HH:mm");
    return `${dateStr} (${dayOfWeek}) ${startStr}〜${endStr}`;
  }).join("\n");

}

```

`ignoreKeywords` に入っているのは、カレンダー上で無視したい内容です。
私は「勤務場所」をオフィスに設定したところ、全予定に入ってしまったので無視するようにしました。

コードを保存したら、デプロイを作成します。
右上の「デプロイ」ボタンから、「新しいデプロイ」を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/fc7bbf4c-509f-4bde-982a-fc250fc5a8e4.png)

「次のユーザーとして実行」は「自分」
「アクセスできるユーザー」は「全員」にして「デプロイ」を選択します。

デプロイが完了したら、ウェブアプリのURLをコピーしておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/2f912599-de1f-4150-9f14-abef21e920e9.png)

続いて、Slackアプリを作成します。
https://api.slack.com/apps から「Create New App」＞「From Scratch」を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/21c5309c-41a0-470f-b702-fc4948a6ef07.png)

アプリ名を適当につけて、ワークスペースを選択し、「Create App」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/3e96a230-82c8-4908-8d79-cf2f04386f5b.png)

アプリの設定画面が表示されたら、左メニューにある「Slash Commands」を選択して、「Create New Command」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/b26cb7f0-22dc-404c-828b-6e25c26bbb75.png)

表示された設定画面から、コマンドを設定します。
「Request URL」には、先ほどコピーしたURL（デプロイしたWebアプリのURL）を入れます。
「Usage Hint」はただの説明なので、入れなくても構いません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/d8a1122b-aad5-497b-abe9-956e36fe7067.png)

コマンドの設定を保存して、準備は完了です。
Slackから、コマンドを入力してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/66eae9a5-713c-4890-8318-d9ed0a118ba1.png)

スケジュールが表示されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/b27052fa-36f0-4868-9926-2f6347cf75d2.png)


3人分のスケジュールから、空き時間を5件出してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/67218c4e-484b-4456-9655-cc2033d5f60e.png)

抽出できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106534/95def177-9dea-4b1d-8f7d-6b9ae5fdaa33.png)

これでスケジュール調整が捗りますね！

# まとめ

今回はやりたいことがハッキリしていたので、ChatGPTのプロンプトもあまり迷うことはありませんでした。
もっとこういうことしたいとかリクエストあれば修正します。