月額0円！GoogleスプレッドシートとGASで作る店舗向けリアルタイム空席表示システム

# 小さな店舗向け空席情報リアルタイム表示システム構築ガイド

このガイドでは、GoogleスプレッドシートとGoogle Apps Script (GAS) を利用して、小さな店舗の空席情報をリアルタイムで表示するシステムを構築する手順を説明します。

システム概要
https://ewxcnocq.manus.space/

完成イメージ
左：管理者画面 右：ユーザ画面
![スクリーンショット 2025-05-25 204101.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3887376/c9e20c8d-e8d9-4d3c-81e8-b330fe8f057b.png)


## ステップ0：準備するもの

*   **Googleアカウント：** Gmailアドレスなど、Googleのサービスを利用できるアカウント。お持ちでなければ無料で作成できます。
*   **インターネット環境：** 設定作業や運用に必要です。
*   **パソコン：** スクリプトやHTMLファイルの編集、設定作業を行います。
*   **スマートフォンまたはタブレット（店舗スタッフ用）：** 完成したシステムで空席情報を更新するために使います。
*   **テキストエディタ：** HTMLファイルを編集するために使います。（Windowsのメモ帳、macOSのテキストエディットでも可能ですが、VSCodeなどの無料の高機能エディタがおすすめです）
*   **基本的な知識（あれば尚可）：**
    *   簡単なHTMLのタグ（例：`<p>`, `<div>`, `<button>`）
    *   コピー＆ペーストの操作
    *   ファイルやフォルダの基本的な操作

## ステップ1：Googleスプレッドシートの作成

空席情報を記録・管理するためのデータベースとして機能します。

1.  Googleドライブ (`drive.google.com`) を開きます。
2.  「**+ 新規**」ボタンをクリックし、「**Googleスプレッドシート**」を選択して新しいスプレッドシートを作成します。
3.  スプレッドシートに名前を付けます（例：「空席管理」）。
4.  シート名を変更します。 デフォルトで「シート1」となっているシートのタブを右クリックし、「**名前を変更**」で `空席管理` に変更します。（これは後述のGASコード内の `SHEET_NAME` と一致させるためです）
5.  以下の情報をセルに入力します（これはGASの `initializeSheet()` 関数を実行すると自動で設定もできますが、手動で設定しても構いません）。
    *   `A1`セル： `現在の空席数` (例: `8` ←初期値として総席数)
    *   `B1`セル： `総席数` (例: `8`)
    *   `C1`セル： `最終更新日時` (例: `2023/10/27 10:00:00` ←初回は手入力するか、GAS実行時に自動入力されます)
6.  スプレッドシートIDを控えておきます。 ブラウザのアドレスバーに表示されるURLのうち、`https://docs.google.com/spreadsheets/d/` の後から `/edit` の前までの長い文字列がIDです。
    例： `https://docs.google.com/spreadsheets/d/【ここがスプレッドシートID】/edit#gid=0`

## ステップ2：Google Apps Script (GAS) の作成と設定

スプレッドシートのデータを読み書きし、Webページに情報を提供するバックエンドのプログラムを作成します。

1.  作成したスプレッドシートを開いた状態で、上部メニューの「**拡張機能**」>「**Apps Script**」を選択します。新しいタブでスクリプトエディタが開きます。
2.  スクリプトエディタに最初から書かれている `function myFunction() { ... }` などのコードを全て削除します。
3.  記事の一番下に添付してあるコードを全てコピーし、スクリプトエディタに貼り付けます。
4.  コードを編集します：
    *   `var SPREADSHEET_ID = 'YOUR_SPREADSHEET_ID';` の `YOUR_SPREADSHEET_ID` の部分を、ステップ1-6で控えた実際のスプレッドシートIDに書き換えます。
    *   `var SHEET_NAME = '空席管理';` の `空席管理` の部分が、ステップ1-4で設定したシート名と一致しているか確認します。
    *   `var TOTAL_SEATS = 8;` の `8` の部分を、あなたのコーヒー店の総席数に書き換えます。
5.  スクリプトに名前を付けます（例：「空席確認API」）。フロッピーディスクのアイコン💾をクリックして保存します。
6.  **デプロイ（ウェブアプリケーションとして公開）：**
    1.  スクリプトエディタ右上の「**デプロイ**」ボタンをクリックし、「**新しいデプロイ**」を選択します。
    2.  「**種類の選択**」の歯車アイコン⚙️をクリックし、「**ウェブアプリ**」を選択します。
    3.  「**説明**」は任意で入力します（例：「空席確認システム v1」）。
    4.  「**次のユーザーとして実行**」： 「**自分（あなたのGoogleアカウント）**」を選択します。
    5.  「**アクセスできるユーザー**」：
        *   顧客向けの情報取得 (`doGet`) のため、ここは「**全員**」を選択します。 これにより、誰でもURLにアクセスして空席情報を閲覧できるようになります。
        *   （もしスタッフ用更新機能も同じGASスクリプトの`doGet`/`doPost`で認証なしで処理する場合、セキュリティリスクを理解した上で設定してください。理想はスタッフ用は別途認証を設けることです。）
    6.  「**デプロイ**」ボタンをクリックします。
    7.  初回デプロイ時には「承認が必要です」という画面が表示されるので、「**アクセスを承認**」をクリックし、自分のGoogleアカウントを選択し、確認画面で「**許可**」をクリックします。（「Google で確認されていません」という警告が出ることがありますが、「**詳細**」を開き、「**（プロジェクト名）に移動（安全ではありません）**」をクリックして進めてください。）
    8.  デプロイが完了すると、「**ウェブアプリのURL**」が表示されます。このURLをコピーして控えておきます。これが顧客向けページやスタッフ向けページから呼び出すAPIのURLになります。

## ステップ3：顧客向け表示ページの作成 (HTML)

お客様が空席情報を確認するためのWebページを作成します。

1.  お使いのパソコンで、テキストエディタを開きます。
2.  新規ファイルを作成し、記事の一番下に添付してあるHTMLコードを全てコピーして貼り付けます。
3.  コードを編集します：
    *   `const GAS_URL = 'YOUR_GAS_WEB_APP_URL';` の `YOUR_GAS_WEB_APP_URL` の部分を、ステップ2-6で控えた実際のGASのウェブアプリURLに書き換えます。
    *   `<title>Coffee Shop 空席情報</title>` や `<div class="shop-name">My Coffee Shop</div>` の部分を、あなたの店の名前に変更します。
4.  ファイルを `index.html` という名前で保存します。（文字コードはUTF-8で保存してください）
5.  保存した `index.html` ファイルをダブルクリックしてブラウザで開いてみてください。GASのURLが正しければ、「現在の空席数: X席 / Y席」のように表示されるはずです。（まだホスティングしていないので、ローカルでの表示です）

## ステップ4：スタッフ用操作ページの作成 (HTML)

店舗スタッフが空席情報を更新するためのWebページを作成します。

1.  テキストエディタで、もう一つ新規ファイルを作成します。
2.  記事の一番下に添付してあるHTMLコードを全てコピーして貼り付けます。
3.  コードを編集します：
    *   `const ADMIN_GAS_URL = 'YOUR_GAS_WEB_APP_URL_FOR_ADMIN';` の `YOUR_GAS_WEB_APP_URL_FOR_ADMIN` の部分を、ステップ2-6で控えた実際のGASのウェブアプリURLに書き換えます。（基本的には顧客向けと同じURLを使いますが、GAS側で認証を分ける場合は別のURLになることもあります。今回は同じURLを想定しています。）
    *   `const TOTAL_SEATS_FROM_JS_OR_GAS = 8;` の `8` の部分を、あなたのコーヒー店の総席数に書き換えます。（これはGASの `TOTAL_SEATS` と一致させてください）
4.  ファイルを `admin.html` という名前で保存します。（文字コードはUTF-8）
5.  この `admin.html` もブラウザで開いてみてください。ボタンを押すとスプレッドシートの値が更新され、`index.html` 側の表示も変わるか確認します。

**注意：** このスタッフ用ページは誰でもアクセスできる状態だと問題なので、実際の運用ではパスワード保護をかけるか、Googleアカウントでログインした人だけが使えるようにするなどのセキュリティ対策が必要です。GAS側で認証を実装するか、ホスティングサービス側でアクセス制限をかけるなどの方法があります。簡易的には、URLを知っている人だけがアクセスできる、という運用も考えられますが、リスクはあります。

## ステップ5：Webページの公開 (ホスティング)

作成した `index.html` (顧客向けページ) をインターネット上で誰でも見られるようにします。`admin.html` (スタッフ用ページ) は、セキュリティを考慮し、限定的な公開方法を選ぶか、ローカルネットワークやパスワード保護された環境で利用することを検討してください。

ここでは主に `index.html` の公開方法について説明します。

*   **選択肢1：Googleサイト**
    *   Googleドライブから「**+ 新規**」>「**その他**」>「**Googleサイト**」で新しいサイトを作成。
    *   「**埋め込む**」機能を使って、`index.html` の内容をHTMLコードとして貼り付けるか、HTMLファイルをドライブにアップロードしてそれを埋め込むことができます。
    *   比較的簡単に公開できます。

*   **選択肢2：GitHub Pages**
    *   GitHubアカウントが必要。リポジトリを作成し、そこに `index.html` をアップロードすると、無料でWebページとして公開できます。
    *   少しGitの知識が必要ですが、非常に強力で人気のある方法です。

*   **選択肢3：Firebase Hosting**
    *   Googleのサービス。無料枠があり、高機能なホスティングが可能です。
    *   Firebase CLIのインストールなど、初期設定が少し必要です。

*   **選択肢4：その他の無料ホスティングサービス**
    *   Netlify, Vercel, Cloudflare Pages など、静的サイト向けの優れた無料ホスティングサービスがたくさんあります。

**基本的な流れ（例：GitHub Pagesの場合）：**

1.  GitHubアカウントを作成。
2.  新しいリポジトリを作成（例: `my-cafe-occupancy`）。
3.  作成した `index.html` をリポジトリにアップロード。
4.  リポジトリの「**Settings**」>「**Pages**」で、Sourceを「**Deploy from a branch**」にし、Branchを「**main**」（または**master**）、「**/(root)**」フォルダを選択してSave。
5.  しばらくすると、`https://[あなたのGitHubユーザー名].github.io/[リポジトリ名]/` のようなURLでページが公開されます。

**(任意) 独自ドメインの設定：**
各ホスティングサービスで、自分で取得したドメイン（例： `mycoffeeshop.com`）を割り当てることも可能です。ドメイン取得には年間1,000円～程度の費用がかかります。

## ステップ6：テストと調整

1.  公開された顧客向けページ (`index.html`) がスマートフォンやPCで正しく表示されるか確認します。
2.  スタッフ用ページ (`admin.html` またはそれに代わる操作方法) で空席数を変更し、顧客向けページにリアルタイム（または設定した更新間隔）で反映されるかテストします。
3.  表示の崩れや、動作がおかしい部分があれば、HTMLやGASのコードを修正し、再度アップロード（またはGASの場合は新しいバージョンをデプロイ）します。
4.  顧客向けページの更新頻度 (`setInterval(fetchSeatInfo, 30000);` の `30000` の部分。ミリ秒単位なので30秒) が適切か検討し、必要なら調整します。

## ステップ7：運用開始と周知

システムが問題なく動作することを確認したら、運用を開始します。

*   お客様に空席確認ページのURLを周知します。
    *   店頭にQRコードを掲示する。
    *   お店のウェブサイトやSNSアカウントでリンクを共有する。
    *   Googleマイビジネスの店舗情報にリンクを追加する。


### デザインや文字の修正
デザインや文字はChatGPTにソースコードをそのままコピペし「かわいいデザインにして」「〇〇という文章にして」など聞けば修正してくれます。
修正してもらったソースコードを反映させてください。
https://chatgpt.com/

## Google Apps Script (Code.gs)
```
var SPREADSHEET_ID = 'YOUR_SPREADSHEET_ID'; // ★★★ ここにあなたのGoogleスプレッドシートのIDを貼り付けてください ★★★
var SHEET_NAME = '空席管理'; // ★★★ スプレッドシートのシート名に合わせてください（デフォルトは「空席管理」） ★★★
var TOTAL_SEATS = 8; // ★★★ あなたのお店の総席数に合わせてください ★★★

function doGet(e) {
  try {
    var sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
    var currentSeats = sheet.getRange('A1').getValue();
    var lastUpdated = sheet.getRange('C1').getValue();
    
    var formattedLastUpdated = '';
    if (lastUpdated instanceof Date) {
      formattedLastUpdated = Utilities.formatDate(lastUpdated, Session.getScriptTimeZone(), 'MM/dd HH:mm');
    }

    var result = {
      availableSeats: currentSeats,
      totalSeats: TOTAL_SEATS,
      lastUpdated: formattedLastUpdated
    };
    
    if (e.parameter.callback) {
      return ContentService.createTextOutput(e.parameter.callback + '(' + JSON.stringify(result) + ')')
        .setMimeType(ContentService.MimeType.JAVASCRIPT);
    } else {
      return ContentService.createTextOutput(JSON.stringify(result))
        .setMimeType(ContentService.MimeType.JSON);
    }
  } catch (error) {
    return ContentService.createTextOutput(JSON.stringify({ error: error.toString() }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

function doPost(e) {
  return handleUpdateRequest(e);
}

function handleUpdateRequest(e) {
  try {
    var action = e.parameter.action;
    var sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
    var currentSeatsCell = sheet.getRange('A1');
    var currentSeats = parseInt(currentSeatsCell.getValue()) || 0;

    if (action === 'increment') {
      if (currentSeats < TOTAL_SEATS) {
        currentSeats++;
      }
    } else if (action === 'decrement') {
      if (currentSeats > 0) {
        currentSeats--;
      }
    } else {
      return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: 'Invalid action' }))
        .setMimeType(ContentService.MimeType.JSON);
    }

    currentSeatsCell.setValue(currentSeats);
    sheet.getRange('C1').setValue(new Date());

    return ContentService.createTextOutput(JSON.stringify({ status: 'success', availableSeats: currentSeats, totalSeats: TOTAL_SEATS })) // totalSeatsも返すように修正
      .setMimeType(ContentService.MimeType.JSON);
      
  } catch (error) {
    return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: error.toString() }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

function initializeSheet() {
  var sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
  sheet.getRange('A1').setValue(TOTAL_SEATS);
  sheet.getRange('B1').setValue(TOTAL_SEATS);
  sheet.getRange('C1').setValue(new Date());
}
```

## index.html (顧客向け表示ページ)
```
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Coffee Shop 空席情報</title> <!-- ★★★ あなたのお店の名前に合わせてください ★★★ -->
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
            text-align: center;
            background-color: #f5f5dc;
            color: #5d4037;
            margin: 0;
            padding-top: 30px;
            padding-bottom: 30px;
        }
        .container {
            max-width: 350px;
            margin: 0 auto;
            background-color: #fffaf0;
            padding: 20px;
            border-radius: 12px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
        }
        .shop-name {
            font-size: 1.8em;
            margin-bottom: 20px;
            font-weight: bold;
        }
        .icon {
            font-size: 3.5em;
            margin-bottom: 15px;
        }
        .seats-info {
            font-size: 1.3em;
            margin-bottom: 8px;
        }
        .seats-count {
            font-size: 2.8em;
            font-weight: bold;
            margin-bottom: 20px;
            color: #d32f2f; /* 満席時のデフォルトカラー */
        }
        .seats-count.available {
            color: #388e3c; /* 空席あり時のカラー */
        }
        .last-updated {
            font-size: 0.9em;
            color: #757575;
        }

        @media (max-width: 400px) {
            .shop-name {
                font-size: 1.6em;
            }
            .icon {
                font-size: 3em;
            }
            .seats-info {
                font-size: 1.2em;
            }
            .seats-count {
                font-size: 2.5em;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="shop-name">My Coffee Shop</div> <!-- ★★★ あなたのお店の名前に合わせてください ★★★ -->
        <div class="icon">☕</div>
        <div class="seats-info">現在の空席数:</div>
        <div class="seats-count" id="seats-display">読み込み中...</div>
        <div class="last-updated" id="last-updated-display"></div>
    </div>

    <script>
        const GAS_URL = 'YOUR_GAS_WEB_APP_URL'; // ★★★ ここにGASをデプロイした際のウェブアプリURLを貼り付けてください ★★★

        function fetchSeatInfo() {
            const scriptId = 'jsonp-fetch-' + Date.now();
            const script = document.createElement('script');
            script.id = scriptId;
            script.src = `${GAS_URL}?callback=updateSeatInfo&t=${Date.now()}`;
            
            const cleanup = () => {
                const oldScript = document.getElementById(scriptId);
                if (oldScript && oldScript.parentNode) {
                    oldScript.parentNode.removeChild(oldScript);
                }
            };

            script.onload = cleanup;
            script.onerror = () => {
                 document.getElementById('seats-display').textContent = '情報取得エラー';
                 console.error('Error loading GAS script for seat info.');
                 cleanup();
            };
            
            document.body.appendChild(script);
        }
        
        window.updateSeatInfo = function(data) {
            const seatsDisplay = document.getElementById('seats-display');
            const lastUpdatedDisplay = document.getElementById('last-updated-display');

            if (data.error) {
                seatsDisplay.textContent = '情報取得エラー';
                lastUpdatedDisplay.textContent = '';
                console.error('Error from GAS:', data.error);
                return;
            }
            
            if (data.availableSeats !== undefined && data.totalSeats !== undefined) {
                seatsDisplay.textContent = `${data.availableSeats} 席 / ${data.totalSeats} 席`;
                if (parseInt(data.availableSeats) > 0) {
                    seatsDisplay.classList.add('available');
                    seatsDisplay.classList.remove('unavailable'); 
                } else {
                    seatsDisplay.classList.remove('available');
                    seatsDisplay.classList.add('unavailable'); 
                }

            } else {
                seatsDisplay.textContent = 'データ形式エラー';
            }

            if (data.lastUpdated) {
                lastUpdatedDisplay.textContent = `最終更新: ${data.lastUpdated}`;
            } else {
                lastUpdatedDisplay.textContent = '';
            }
        }

        fetchSeatInfo();
        setInterval(fetchSeatInfo, 30000);
    </script>
</body>
</html>
```

## admin.html (スタッフ用操作ページ)
```
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>空席管理</title>
    <style>
        body {
            font-family: sans-serif;
            text-align: center;
            padding: 20px;
            margin: 0;
        }
        h1 {
            font-size: 2em;
            margin-bottom: 20px;
        }
        button {
            font-size: 1.5em;
            padding: 15px 30px;
            margin: 10px;
            cursor: pointer;
            border: none;
            border-radius: 8px;
            color: white;
            min-width: 200px;
            box-sizing: border-box;
        }
        .increment { background-color: #4CAF50; }
        .decrement { background-color: #f44336; }
        
        #current-seats-admin {
            font-size: 1.8em;
            font-weight: bold;
            margin: 20px 0;
        }
        #message {
            margin-top: 20px;
            color: green;
            min-height: 1.2em;
        }

        @media (max-width: 600px) {
            body {
                padding: 15px;
            }
            h1 {
                font-size: 1.6em;
            }
            button {
                display: block;
                width: 90%;
                margin: 15px auto;
                padding: 15px 10px;
                font-size: 1.2em;
                min-width: unset;
            }
            #current-seats-admin {
                font-size: 1.5em;
            }
        }
    </style>
</head>
<body>
    <h1>空席管理</h1>
    <div id="current-seats-admin">現在の空席: 取得中...</div>
    
    <button class="increment" onclick="updateSeats('increment')">+ (席が空いた)</button>
    <button class="decrement" onclick="updateSeats('decrement')">- (席が埋まった)</button>

    <div id="message"></div>

    <script>
        const ADMIN_GAS_URL = 'YOUR_GAS_WEB_APP_URL'; // ★★★ ここにGASをデプロイした際のウェブアプリURLを貼り付けてください ★★★
        let currentTotalSeats = 8; // ★★★ 初期値としてお店の総席数を設定（GASから取得できれば上書きされます） ★★★

        function getCurrentSeats() {
            fetch(`${ADMIN_GAS_URL}`)
                .then(response => response.json())
                .then(data => {
                    if (data.error) {
                        console.error('Error from GAS:', data.error);
                        document.getElementById('current-seats-admin').textContent = '現在の空席: 情報取得エラー';
                        return;
                    }
                    if (data.availableSeats !== undefined && data.totalSeats !== undefined) {
                        currentTotalSeats = data.totalSeats;
                        document.getElementById('current-seats-admin').textContent = `現在の空席: ${data.availableSeats} / ${currentTotalSeats} 席`;
                    } else {
                        document.getElementById('current-seats-admin').textContent = '現在の空席: データ形式エラー';
                    }
                })
                .catch(error => {
                    console.error('Error fetching current seats:', error);
                    document.getElementById('current-seats-admin').textContent = '現在の空席: 取得失敗';
                });
        }
        
        function updateSeats(action) {
            document.getElementById('message').textContent = '更新中...';
            fetch(ADMIN_GAS_URL, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: `action=${action}`
            })
            .then(response => response.json())
            .then(data => {
                if (data.status === 'success' && data.availableSeats !== undefined && data.totalSeats !== undefined) {
                    document.getElementById('message').textContent = '更新しました！';
                    currentTotalSeats = data.totalSeats; // 更新時にも総席数を更新
                    document.getElementById('current-seats-admin').textContent = `現在の空席: ${data.availableSeats} / ${currentTotalSeats} 席`;
                } else {
                    document.getElementById('message').textContent = '更新失敗: ' + (data.message || '不明なエラー');
                    getCurrentSeats(); 
                }
                setTimeout(() => { document.getElementById('message').textContent = ''; }, 3000);
            })
            .catch(error => {
                console.error('Error updating seats:', error);
                document.getElementById('message').textContent = '更新エラーが発生しました。';
                setTimeout(() => { document.getElementById('message').textContent = ''; }, 3000);
                getCurrentSeats();
            });
        }
        
        document.addEventListener('DOMContentLoaded', getCurrentSeats);
    </script>
</body>
</html>
```