BIGLOBEの「はたらく人」と「トガッた技術」

Googleカレンダーが超便利!会議室の空き状況を一覧表示する方法

Googleカレンダーで会議室を管理している組織向けに、自分のカレンダーに会議室を追加するURLの作り方と、空き状況を表示するウェブアプリの公開方法を紹介します。

開発部門(プロダクト技術本部)の高玉です。

BIGLOBEではオフィスツールにGoogle Workspaceを利用しています。特にGoogleカレンダーは便利に使っていて、Googleカレンダー上で会議室を予約することもできます。予定を作り開始時間と終了時間を指定すれば、空いている会議室を簡単に割り当てられます。

support.google.com

ただ「予定を決めてから、空き会議室を選ぶ」というやり方とは逆に、会議室の空き状況に合わせて打ち合わせを設定したい場合もあります。そんな時には、複数の会議室を並べて表示し、空き状況を一目で確認したくなります。

そこで、この記事では会議室の空き状況をまとめて表示する方法を2つご紹介します。Googleカレンダーに直接表示する方法と、Google Apps Scriptで作ったウェブアプリで表示する方法です。社内でも「これ、欲しかったやつ!」「助かります!」と好評です。

Googleカレンダーに会議室の空き状況を表示
Googleカレンダーに会議室の空き状況を表示

ウェブアプリに会議室の空き状況を表示
ウェブアプリに会議室の空き状況を表示

どちらの方法にも会議室を識別するIDが必要になります。そこで、このIDをスプレッドシートにまとめて表示する方法を先に解説します。

会議室のIDをスプレッドシートに表示する

Googleカレンダー内で会議室はカレンダーリソースとして管理されます。カレンダーリソースは会議室以外にも、貸出備品を管理することにも使えます。

Google Apps Scriptを使うと、Googleカレンダーで管理しているカレンダーリソースの情報をまとめて取得できます。取得するのに使うのはAdmin SDK Directoryです。APIの詳細な仕様はカレンダーリソースのREST APIで確認できます。

カレンダーリソースのデータ項目のうち、会議室に関係するものは次の通り1です。

  • リソースメール resourceEmail
    • カレンダーリソースの読み取り専用メールアドレス。新しいカレンダーリソースの作成時に生成されます。カレンダーリソースのIDとして使います。c_XXXXXXXXXX@resource.calendar.google.comという形式です。
  • リソース種別 resourceType
    • カレンダーリソースの種類
  • ビルディングID buildingId
    • リソースがある建物の一意のID
  • フロア名 floorName
    • リソースがある階の名前
  • リソース名 resourceName
    • カレンダー リソースの名前。例: 「トレーニング ルーム 1A」
  • 収容人数 capacity
    • リソースの収容人数、会議室の座席数
  • 説明 userVisibleDescription
    • リソースの説明。ユーザーと管理者に表示

ここからは、会議室のカレンダーリソースを、スプレッドシートに表示する方法をご紹介します。最初のシートに一覧を表示します。

(1) まずスプレッドシートを作成します。

(2) 次に、上部メニュー 拡張機能 > Apps Script を選択し、スクリプトエディターを開きます。

(3) スクリプトエディターの左ペイン一番下の「サービス」で、右横にある「+」(プラス)アイコンを押して、Admin SDK APIを選びます。バージョンはdirectory_v1を選んでください。

(4) スクリプトエディターで「コード.gs」を選び、次のコードを入力します。なお、後工程で使うため、チェックボックスも挿入しています。

ソースコード(クリックで展開)

function fetchCalendarResources_() {
  let pageToken;
  let page;
  const resources = [];
  const headers = ['選択', 'リソースメール', 'ビルディングID', 'フロア名', 'リソース名', '収容人数', '説明']; // 見出し

  // 見出しを最初に追加
  resources.push(headers);
  
  do {
    page = AdminDirectory.Resources.Calendars.list('my_customer', {
      maxResults: 100,
      pageToken: pageToken
    });
    if (!page.items) {
      console.log('No resources found.');
    } else {
      const items = page.items.filter(item => item.resourceType === '会議室'); // リソースタイプに「会議室」を指定している
      resources.push(...items.map(resource => [
        false, // チェックボックスの初期値としてfalseを設定
        resource.resourceEmail,
        resource.buildingId,
        resource.floorName,
        resource.resourceName,
        resource.capacity,
        resource.userVisibleDescription
      ]));
    }
    pageToken = page.nextPageToken;
  } while (pageToken);
  return resources;
}

function updateCalendarResourceSheet() {
  const resources = fetchCalendarResources_();
  // 1番目のシートに出力
  const sheet = SpreadsheetApp.getActive().getSheets()[0];
  const range = sheet.getRange(1, 1, resources.length, resources[0].length);
  range.setValues(resources);
  
  // チェックボックスを追加
  for (let i = 2; i <= resources.length; i++) { // 見出し行を除いてチェックボックスを追加
    sheet.getRange(i, 1).insertCheckboxes();
  }
}

ここまで準備ができたら、スクリプトエディターでupdateCalendarResourceSheetを実行します。次のように、会議室リソースの一覧が最初のシートに表示されます。

会議室リソースの一覧表示
会議室リソースの一覧表示

トラブルシューティング

  • Q1. エラー ReferenceError: AdminDirectory is not defined が表示される。

    • A1. 手順3でサービスAdminDirectoryを追加してください。
  • Q2. サービスにAdminDirectoryを追加しているが、エラー TypeError: Cannot read properties of undefined (reading 'Calendars’) が表示される。

    • A2. サービスに追加するAdminDirectoryのバージョンはdirectory_v1を選んでください。reports_v1ではありません。

Googleカレンダーに会議室の空き状況を表示する

先にご紹介するのは、Googleカレンダー上で会議室の空き状況を確認する方法です。URLをクリックするだけで、カレンダーに会議室の予定を並べて表示できます。

ただ、空き状況を確認した後で、会議室の予定を隠す作業が必要になります。左ペイン「他のカレンダー」で、会議室のチェックを外します。wを押すと週表示に戻ります。

URLは、会議室のリソースメール(ID)を使ってhttps://calendar.google.com/calendar/u/0/r/day?$cid=<リソースメール1>&cid=<リソースメール2>と記述します。

このURLを自動的に作成するカスタム関数を作ってみました。会議室のチェックボックスをONにするとURLに反映します。

(1) 先程のスクリプトエディターを開きます(上部メニュー 拡張機能 > Apps Script を選択)

(2) スクリプトエディターに以下のコードを記述します。

function CALENDAR_URL(rows) {
  const calendarIds = rows.filter(row => row[0]).map(row => `cid=${row[1]}`);
  if (calendarIds.length === 0) return `会議室を選択してください。カレンダーに表示するためのURLを作成します。`;
  return `https://calendar.google.com/calendar/u/0/r/day?${calendarIds.join('&')}`;
}

(3) カレンダーリソースを表示したシートの適切なセルに、次の関数を入力します(チェックボックスを除くカレンダーリソースが、A2からG25に表示されている場合)

=CALENDAR_URL(A2:G25)

できあがったURLをクリックすると、Googleカレンダーが表示され、選択した会議室を追加するかどうか確認されます。

カレンダーに追加する会議室を選択
カレンダーに追加する会議室を選択

ウェブアプリに会議室の空き状況を表示する

次にご紹介するのは、Apps Scriptで会議室の空き状況を確認するウェブアプリを作る方法です。ウェブアプリの作り方を説明する公式ドキュメントはこちらです。

developers.google.com

(1) 先程のスクリプトエディターを開きます(上部メニュー 拡張機能 > Apps Script を選択)

(2) スクリプトエディターに以下のコードを記述します。

function doGet() {
  return HtmlService.createHtmlOutputFromFile('Index');
}

(3) スクリプトエディターで新しいHTMLファイルIndex.htmlを追加します。 左ペインの「ファイル」の右横にある「+」を押して、「HTML」を選びます。次に「Index」とだけ入力します(間違えてIndex.htmlと拡張子まで入力しないようにご注意ください)。そして、「Index.html」を選択して、次のコードを記述します。

ソースコード(クリックで展開)

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Calendar App</title>
  <script src="https://unpkg.com/react/umd/react.production.min.js" crossorigin></script>
  <script src="https://unpkg.com/react-dom/umd/react-dom.production.min.js" crossorigin></script>
  <script src="https://unpkg.com/babel-standalone/babel.min.js"></script>
  <style>
    body {
      font-family: Arial, sans-serif;
    }

    .calendar {
      display: grid;
      background-color: #FFFFFF;
      height: calc(100vh - 80px);
      overflow-y: scroll;
      margin-top: 10px;
    }

    .calendar-header {
      display: contents;
    }

    .calendar-header .time-slot::after {
      border-top: none;
    }

    .calendar-header .resource {
      background-color: #D3D3D3;
      padding: 10px;
      border: 1px solid #A8A8A8;
      text-align: center;
      position: sticky;
      top: 0;
      z-index: 1;
      color: #4D4D4D;
    }

    .calendar-body {
      display: contents;
    }

    .time-column {
      display: grid;
      grid-template-rows: repeat(24, 60px);
    }

    .time-slot {
      position: relative;
      border-right: 1px solid #A8A8A8;
    }

    .time-slot::after {
      content: "";
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      border-top: 1px solid #A8A8A8;
    }

    .time-column .time-slot::after {
      left: 44px;
    }

    .time-label {
      position: absolute;
      top: -8px;
      right: 8px;
      color: #A8A8A8;
    }

    .resource-column {
      display: grid;
      grid-template-rows: repeat(24, 60px);
      position: relative;
    }

    .event {
      position: absolute;
      background-color: #909090;
      color: #FFFFFF;
      border-radius: 4px;
      padding: 2px;
      box-sizing: border-box;
      width: calc(100% - 10px);
      cursor: pointer;
    }

    .event:hover .tooltip {
      display: block;
    }

    .tooltip {
      display: none;
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      bottom: 100%;
      background-color: rgba(0, 0, 0, 0.8);
      color: #fff;
      padding: 5px;
      border-radius: 4px;
      white-space: pre-wrap;
      z-index: 10;
    }

    .date-navigation {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 10px;
    }

    .overlay-spinner {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(255, 255, 255, 0.8);
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 9999;
    }

    .spinner {
      width: 30px;
      height: 30px;
      border: 4px solid rgba(0, 0, 0, 0.1);
      border-top: 4px solid #000;
      border-radius: 50%;
      animation: spin 1s linear infinite;
    }

    @keyframes spin {
      0% {
        transform: rotate(0deg);
      }

      100% {
        transform: rotate(360deg);
      }
    }

    .error-message {
      color: red;
      margin: 10px 0;
    }

    .reload-button {
      background: none;
      border: none;
      cursor: pointer;
      font-size: 1.2em;
    }

    .reload-button:focus {
      outline: none;
    }
  </style>
</head>

<body>
  <div id="root"></div>

  <script type="text/babel">
    const EVENT_HEIGHT_ADJUSTMENT = 2;

    const getTodayDate = () => {
      const date = new Date().toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' });
      const [year, month, day] = date.split('/');
      return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
    };

    const gsRun = (funcName, ...args) => {
      return new Promise((resolve, reject) => {
        google.script.run
          .withSuccessHandler(resolve)
          .withFailureHandler(reject)<a href="...args" target="_blank" rel="noopener noreferrer">funcName</a>;
      });
    };

    const DateNavigator = ({ selectedDate, onDateChange, onTodayClick, onPrevDayClick, onNextDayClick, onReloadClick }) => {
      return (
        <div className="date-navigation">
          <button onClick={onTodayClick}>今日</button>
          <button onClick={onPrevDayClick}>&lt;</button>
          <button onClick={onNextDayClick}>&gt;</button>
          <input
            type="date"
            value={selectedDate}
            onChange={e => onDateChange(e.target.value)}
          />
          <button className="reload-button" onClick={onReloadClick} title="再読み込み">
            &#x21bb;
          </button>
        </div>
      );
    };

    const ResourceFilter = ({ filters, selectedFilter, onFilterChange }) => {
      return (
        <div id="building-filters">
          {filters.map(filter => (
            <label key={filter}>
              <input
                type="radio"
                name="building-filter"
                value={filter}
                checked={selectedFilter === filter}
                onChange={e => onFilterChange(e.target.value)}
              />
              {filter}
            </label>
          ))}
        </div>
      );
    };

    const CalendarEvent = ({ event }) => {
      const { title, start, end, creator } = event;
      const tooltipContent = `会議名: ${title || '非表示'}\n開始: ${start}\n終了: ${end}\n作成者: ${creator || '非表示'}`;
      const [startHour, startMinute] = start.split(':').map(Number);
      const [endHour, endMinute] = end.split(':').map(Number);
      const startOffset = ((startHour * 60) + startMinute) * (60 / 60);
      const duration = ((endHour - startHour) * 60 + (endMinute - startMinute)) * (60 / 60) - EVENT_HEIGHT_ADJUSTMENT;

      return (
        <div
          className="event"
          style={{ top: `${startOffset}px`, height: `${duration}px` }}
        >
          {title && <strong>{title}</strong>}
          {title && <br />}
          {start} - {end}
          <div className="tooltip">{tooltipContent}</div>
        </div>
      );
    };

    const Calendar = ({ resources, events, initialScrollHour }) => {
      const gridTemplateColumns = `50px repeat(${resources.length}, 1fr)`;

      React.useEffect(() => {
        const scrollOffset = initialScrollHour * 60;
        document.querySelector('.calendar').scrollTop = scrollOffset;
      }, [initialScrollHour]);

      return (
        <div className="calendar" style={{ gridTemplateColumns }}>
          <div className="calendar-header">
            <div className="time-slot"></div>
            {resources.map(resource => (
              <div key={resource} className="resource">{resource}</div>
            ))}
          </div>
          <div className="calendar-body">
            <div className="time-column">
              {Array.from({ length: 24 }, (_, hour) => (
                <div key={hour} className="time-slot">
                  <div className="time-label">{String(hour).padStart(2, '0')}:00</div>
                </div>
              ))}
            </div>
            {resources.map(resource => (
              <div key={resource} className={`resource-column cv-${resource.toLowerCase().replace(' ', '-')}`}>
                {Array.from({ length: 24 }, (_, hour) => (
                  <div key={hour} className="time-slot"></div>
                ))}
                {events.filter(event => event.resourceName === resource).map(event => (
                  <CalendarEvent key={`${event.start}-${event.end}`} event={event} />
                ))}
              </div>
            ))}
          </div>
        </div>
      );
    };

    class App extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          selectedDate: getTodayDate(),
          filters: [],
          selectedFilter: '',
          resources: [],
          filteredResources: [],
          events: [],
          cachedData: {},
          loadingResources: false,
          loadingCalendarData: false,
          initialScrollHour: 8, // 初期スクロール時間
          errorMessage: null,
          isDataFetched: false
        };
      }

      componentDidMount() {
        this.fetchResources();
      }

      fetchResources() {
        this.setState({ loadingResources: true });
        gsRun('fetchRooms').then(resources => {
          const filters = [...new Set(resources.map(resource => resource.buildingId))];
          const initialFilter = filters[0] || '';
          this.setState({ filters, selectedFilter: initialFilter, resources, loadingResources: false }, () => {
            this.fetchCalendarData(initialFilter, this.state.selectedDate);
          });
        }).catch(error => {
          console.error('Error fetching resources:', error);
          this.setState({ loadingResources: false, errorMessage: 'リソースの取得に失敗しました。' });
        });
      }

      fetchCalendarData(buildingId, selectedDate, forceReload = false) {
        this.setState({ loadingCalendarData: true, errorMessage: null });
        const cacheKey = `${buildingId}-${selectedDate}`;
        if (!forceReload && this.state.cachedData[cacheKey]) {
          const { filteredResources, events } = this.state.cachedData[cacheKey];
          this.setState({ filteredResources, events, loadingCalendarData: false, isDataFetched: true });
        } else {
          const filteredResources = this.state.resources.filter(resource => resource.buildingId === buildingId);
          this.setState({ filteredResources: filteredResources.map(resource => resource.resourceName) });
          const eventsPromises = filteredResources.map(resource => gsRun('fetchRoomEvents', resource.resourceEmail, resource.resourceName, selectedDate));
          Promise.all(eventsPromises).then(eventsArray => {
            const events = eventsArray.flat();
            const newCachedData = { ...this.state.cachedData, [cacheKey]: { filteredResources: filteredResources.map(resource => resource.resourceName), events } };
            this.setState({ filteredResources: filteredResources.map(resource => resource.resourceName), events, cachedData: newCachedData, loadingCalendarData: false, isDataFetched: true });
          }).catch(error => {
            console.error('Error fetching calendar data:', error);
            this.setState({ loadingCalendarData: false, errorMessage: 'カレンダーデータの取得に失敗しました。' });
          });
        }
      }

      handleDateChange = (selectedDate) => {
        this.setState({ selectedDate }, () => {
          this.fetchCalendarData(this.state.selectedFilter, selectedDate);
        });
      };

      handleTodayClick = () => {
        const today = getTodayDate();
        this.setState({ selectedDate: today }, () => {
          this.fetchCalendarData(this.state.selectedFilter, today);
        });
      };

      handlePrevDayClick = () => {
        const prevDate = this.getDate(-1);
        this.setState({ selectedDate: prevDate }, () => {
          this.fetchCalendarData(this.state.selectedFilter, prevDate);
        });
      };

      handleNextDayClick = () => {
        const nextDate = this.getDate(1);
        this.setState({ selectedDate: nextDate }, () => {
          this.fetchCalendarData(this.state.selectedFilter, nextDate);
        });
      };

      handleFilterChange = (selectedFilter) => {
        this.setState({ selectedFilter }, () => {
          this.fetchCalendarData(selectedFilter, this.state.selectedDate);
        });
      };

      handleReloadClick = () => {
        this.fetchCalendarData(this.state.selectedFilter, this.state.selectedDate, true);
      };

      getDate(dayOffset) {
        const date = new Date(this.state.selectedDate);
        date.setDate(date.getDate() + dayOffset);
        return date.toISOString().split('T')[0];
      }

      render() {
        const { selectedDate, filters, selectedFilter, filteredResources, events, loadingResources, loadingCalendarData, initialScrollHour, errorMessage, isDataFetched } = this.state;
        
        return (
          <div>
            <DateNavigator
              selectedDate={selectedDate}
              onDateChange={this.handleDateChange}
              onTodayClick={this.handleTodayClick}
              onPrevDayClick={this.handlePrevDayClick}
              onNextDayClick={this.handleNextDayClick}
              onReloadClick={this.handleReloadClick}
            />
            <ResourceFilter filters={filters} selectedFilter={selectedFilter} onFilterChange={this.handleFilterChange} />
            {errorMessage && <div className="error-message">{errorMessage}</div>}
            {(loadingResources || loadingCalendarData) && (
              <div className="overlay-spinner">
                <div className="spinner"></div>
              </div>
            )}
            {isDataFetched && (
              <Calendar resources={filteredResources} events={events} initialScrollHour={initialScrollHour} />
            )}
          </div>
        );
      }
    }

    ReactDOM.render(<App />, document.getElementById('root'));
  </script>
</body>

</html>

(4) スクリプトエディターの右上「デプロイ」ボタンを押し、「新しいデプロイ」を選びます。 左ペイン「種類の選択」の右横にあるギアアイコンを押して、ウェブアプリを選択します。次の項目を入力して、画面右下の「デプロイ」ボタンを押します。

  • 次のユーザーとして実行
    • 自分
  • アクセスできるユーザー
    • <自分の組織>内の全員

(5) デプロイが完了するとウェブアプリのURLが表示されます。このURLは、スクリプトエディターの右上「デプロイ」>「デプロイを管理」でいつでも表示できます。

URLにアクセスすると、自分の組織に登録された「会議室」リソースの「ビルディング名」が右上に表示されます。表示する予定の日付を選択することもできます。

なお、新しいデプロイをするたびにURLが変更されます。同じURLのまま修正したウェブアプリを公開したい場合は、デプロイを管理を選び、えんぴつアイコンを押して、バージョンを新バージョンにしてデプロイしてください。

URLを変更せずにデプロイするにはえんぴつアイコンから新バージョンを選択
URLを変更せずにデプロイするにはえんぴつアイコンから新バージョンを選択

Apps Scriptを使った業務改善

この記事では、GoogleカレンダーとApps Scriptを活用して会議室の空き状況を一目で確認する方法を紹介しました。もしGoogle Workspaceをお使いで、会議室がカレンダーリソースとして登録されている組織であれば、すぐに試していただけます。皆さんのお仕事が少しでも楽になれば幸いです。

BIGLOBEではApps Scriptを活用して、エンジニア部門とビジネス部門が一緒になって業務改善に取り組んでいます。具体的な取り組みについて、次の記事で紹介しています。もしご興味があればぜひご覧ください。

style.biglobe.co.jp

※ Google、Googleカレンダー、Google WorkspaceはGoogle LLCの商標であり、このブログはGoogleによって承認されたり、Googleと提携したりするものではありません。

※ 記載している企業、団体、製品、サービス等の名称は各社またはその関連会社の商標または登録商標です。


  1. 会社によって会議室の管理に利用するデータ項目が異なる場合があります。