教室予約システムに Google
カレンダー同期を組み込んだ

教室を運営の予約管理は、意外と大変です。
利用者には空き状況をわかりやすく見せたい。
一方で教室では、授業や休講の予定を Google カレンダーで管理している。
この二つが、食い違わないように気を配る必要があるからです。

今回は、既存の予約表示システムに Google カレンダーからの自動同期機能を追加しました。
この記事では、その実装過程と設計判断を共有します。

関連記事

1. 手動からGoogleカレンダーから取得へ

最初に作ったのは、週ごとの空き状況を × で表示するシンプルなシステムでした。
平日の4時限分を表形式で見せ、管理画面からチェックボックスで空き状況を編集できる仕組みです。

データは JSON ファイルに保存され、週ごとに以下のような構造で管理していました。

[
  "2025-02-17" => [  // 週の月曜日の日付
    "is_draft" => false,
    "schedule" => [
      "mon" => [0, 1, 0, 0],  // 0=空き、1=不可
      "tue" => [0, 0, 1, 0],
      // ...
    ]
  ]
]Code language: JSON / JSON with Comments (json)

公開ページは誰でも見られますが、編集は管理画面でパスワード認証が必要です。
この時点では、教室の予定が入るたびに管理画面を開いて手動でチェックを入れる運用でした。

1.1. Google カレンダーを読み取る必要性

教室側は既に Google カレンダーで授業や休講の予定を管理しています。
ここに「授業」や「休講」といった情報が集約されているわけです。
これを予約システムに反映できれば、二重入力の手間が省けます。

ただし、予約システムは「この時間帯は予約不可」という情報だけを扱います。
Google カレンダーから読み取った予定を × として追加し、既存の × に戻す処理はしない。
この「加算のみ」の設計が重要なポイントでした。

2. iCalendar 形式での取得

Google カレンダーには、iCalendar (iCal) 形式で予定を出力する機能があります。
これは .ics という拡張子で知られる標準フォーマットで、カレンダーアプリ間でデータをやり取りする際によく使われます。

Google カレンダーの設定画面から「非公開 URL」を取得すると、以下のような URL が得られます。

https://calendar.google.com/calendar/ical/.../basic.icsCode language: JavaScript (javascript)

この URL にアクセスすると、テキスト形式で予定データが返ってきます。

BEGIN:VEVENT
DTSTART:20250217T013000Z
DTEND:20250217T023000Z
SUMMARY:授業
END:VEVENTCode language: CSS (css)

DTSTART が開始日時、DTEND が終了日時、SUMMARY がタイトルです。
この情報を解析すれば、予約不可の時間帯を特定できます。

2.1. iCalendar データの解析

iCalendar は行ベースのフォーマットで、BEGIN:VEVENT から END:VEVENT までが一つの予定を表します。
ただし、長い行は次の行に折り返されることがあり、その場合は行頭にスペースかタブが入ります。

まず、この折り返しを展開する処理が必要でした。

function parseIcsEvents($ics)
{
    $lines = preg_split("/\r\n|\n|\r/", $ics);
    $unfolded = [];
    foreach ($lines as $line) {
        if ($line === '') {
            continue;
        }
        if (!empty($unfolded) && (strpos($line, ' ') === 0 || strpos($line, "\t") === 0)) {
            $unfolded[count($unfolded) - 1] .= ltrim($line);
            continue;
        }
        $unfolded[] = $line;
    }
    // ...
}Code language: PHP (php)

展開後、各行を 名前:値 の形式でパースします。
さらに、セミコロンで区切られたパラメータ(DTSTART;TZID=Asia/Tokyo:20250217T103000 のような形式)も扱えるようにしました。

2.2. 日時の扱い

iCalendar の日時には複数のパターンがあります。

  • 20250217 のような日付のみ(終日予定)
  • 20250217T103000 のような日時(タイムゾーン指定なし)
  • 20250217T013000Z のような UTC 日時(末尾の Z
  • TZID=Asia/Tokyo パラメータ付きの日時

これらを正規化して、PHP の DateTime オブジェクトに変換する必要がありました。

function parseIcsDateTimeValue($value, $tzid = null)
{
    if (substr($value, -1) === 'Z') {
        return new DateTime($value, new DateTimeZone('UTC'));
    }

    if ($tzid) {
        return DateTime::createFromFormat('Ymd\THis', $value, new DateTimeZone($tzid));
    }
    return DateTime::createFromFormat('Ymd\THis', $value, new DateTimeZone('Asia/Tokyo'));
}Code language: PHP (php)

終日予定の場合は開始日と終了日を日付として扱い、時刻を持つ予定は分単位まで正確に扱います。

2.3. タイトルによる予定の分類

どの予定を予約不可に反映するかは、タイトルで判定しています。
教室側がカレンダーに入力する際のルールを決めておき、それに合致するものだけを処理する形です。

判定ルールは以下の通りです。

  1. タイトルに「授業」を含む
  2. または ? で始まる
  3. 「終日休講」「終日休校」「祝日休校」を含む終日予定
  4. 「午前休講」「午前休校」を含む予定
  5. 「午後休講」「午後休校」を含む予定

通常の授業は開始時刻と終了時刻を見て、重なる時限を不可にします。
休講系の予定は、該当する時限すべてを一括で不可にする仕組みです。

function isLessonSummary($summary)
{
    if (mb_strpos($summary, '授業') !== false) {
        return true;
    }

    $trimmed = preg_replace('/^\s+/u', '', $summary);
    if ($trimmed === null || $trimmed === '') {
        return false;
    }

    $first = mb_substr($trimmed, 0, 1);
    return in_array($first, ['●', '◯', '○', '〇', '⚫', '⚪', '・', '※', '?', '?'], true);
}Code language: PHP (php)

このルールに合わない予定は無視されます。
Google カレンダーには授業以外の予定も入っているため、すべてを拾うわけにはいきません。

2.4. 時限との重なり判定

予約システムでは、4つの時限を固定で扱っています。

$timeSlots = [
    ['label' => '①10:15', 'sub' => '-10:55'],
    ['label' => '②11:30', 'sub' => '-12:10'],
    ['label' => '③13:15', 'sub' => '-13:55'],
    ['label' => '④14:30', 'sub' => '-15:10'],
];Code language: PHP (php)

カレンダーの予定時刻と各時限の開始・終了時刻を比較し、少しでも重なっていれば不可にします。

foreach ($timeSlots as $slotIndex => $slot) {
    $slotStartTime = extractTimeString($slot['label']);
    $slotEndTime = extractTimeString($slot['sub']);

    $slotStart = new DateTime($dateKey . ' ' . $slotStartTime, $displayTz);
    $slotEnd = new DateTime($dateKey . ' ' . $slotEndTime, $displayTz);

    if ($eventStart < $slotEnd && $eventEnd > $slotStart) {
        // 重なりあり
        mergeBlockedSlots($blocked, $dateKey, [$slotIndex]);
    }
}Code language: PHP (php)

時刻の重なり判定は、始点と終点の大小関係で行います。
予定の開始が時限の終了より前で、かつ予定の終了が時限の開始より後であれば、重なっていると判断できます。

2.5. 加算のみの同期

この同期処理は、× を増やすだけで に戻す操作はしません。

foreach ($blockedByDate as $dateKey => $slotIndexes) {
    // ...
    foreach ($slotIndexes as $slotIndex) {
        if (($data[$weekKey]['schedule'][$dayKey][$slotIndex] ?? 0) !== 1) {
            $data[$weekKey]['schedule'][$dayKey][$slotIndex] = 1;
            $updatedCount++;
        }
    }
}Code language: PHP (php)

0 から 1 への変更だけを行い、すでに 1 の場合は何もしません。
これは、手動で設定した不可状態を誤って上書きしないための設計です。

Google カレンダーから予定が消えたとしても、予約システム側の × は残ります。
解除が必要な場合は、管理画面から手動で行う運用です。
この非対称性により、二重予約のリスクを下げています。

3. セキュリティ対策の追加

同期機能を追加するにあたり、いくつかのセキュリティ対策も強化しました。

管理画面のログインには、試行回数制限を設けています。
同一 IP アドレスから短時間に複数回失敗すると、一定時間ロックがかかる仕組みです。

function secCanAttemptLogin($clientIp, $maxAttempts = 5, $windowSeconds = 600, $lockSeconds = 900)
{
    $now = time();
    $store = secReadRateLimitStore();
    $entry = $store[$clientIp] ?? ['failures' => [], 'locked_until' => 0];

    // 古い失敗記録を削除
    $entry['failures'] = array_values(array_filter(
        $entry['failures'],
        static function ($timestamp) use ($now, $windowSeconds) {
            return is_int($timestamp) && $timestamp > ($now - $windowSeconds);
        }
    ));

    // ロック中かチェック
    if (($entry['locked_until'] ?? 0) > $now) {
        return [
            'allowed' => false,
            'wait_seconds' => $entry['locked_until'] - $now,
        ];
    }

    // 試行回数超過でロック
    if (count($entry['failures']) >= $maxAttempts) {
        $entry['locked_until'] = $now + $lockSeconds;
        $store[$clientIp] = $entry;
        secWriteRateLimitStore($store);
        return [
            'allowed' => false,
            'wait_seconds' => $lockSeconds,
        ];
    }

    return ['allowed' => true, 'wait_seconds' => 0];
}Code language: PHP (php)

失敗履歴は JSON ファイルに保存しています。
ファイル名にはシステム固有の名前を付け、他のアプリケーションと衝突しないようにしました。

ログイン成功時には、セッション ID を再生成しています。

function secRotateSessionOnLogin()
{
    session_regenerate_id(true);
}Code language: JavaScript (javascript)

これにより、セッション固定攻撃を防ぎます。

CSRF トークンも実装しました。
重要な操作(データ保存など)には、トークンの検証を必須にしています。

function secGetCsrfToken()
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function secValidateCsrfToken($token)
{
    if (!is_string($token) || $token === '') {
        return false;
    }
    if (empty($_SESSION['csrf_token'])) {
        return false;
    }
    return hash_equals($_SESSION['csrf_token'], $token);
}Code language: PHP (php)

hash_equals を使うことで、タイミング攻撃を防いでいます。

3.1. 同期結果の確認

同期処理は、変更した枠の数と更新した週の数を返します。

$ php sync_google_calendar.php
Updated slots: 12
Updated weeks: 3

これにより、実際に反映された内容を確認できます。
Google カレンダーに新しい予定が追加されていれば、次回の同期で自動的に × が増えるはずです。

変更がなければ、両方とも 0 になります。

$ php sync_google_calendar.php
Updated slots: 0
Updated weeks: 0

3.2. CLI 実行の仕組み

同期処理は、Web 画面から直接実行するのではなく、コマンドラインから実行する形にしました。

// sync_google_calendar.php
require_once __DIR__ . '/lib/bootstrap.php';
appRequireCliOnly();  // CLI以外は拒否

require_once __DIR__ . '/config.php';
require_once __DIR__ . '/lib/google_calendar_sync.php';

try {
    $result = runGoogleCalendarAdditiveSync(GOOGLE_CALENDAR_ICAL_URL ?? '', $dayKeys, $timeSlots);
    echo "Updated slots: {$result['updated_slots']}\n";
    echo "Updated weeks: {$result['updated_weeks']}\n";
} catch (RuntimeException $e) {
    fwrite(STDERR, $e->getMessage() . "\n");
    exit(1);
}Code language: PHP (php)

appRequireCliOnly() は、実行環境が CLI かどうかを判定します。

function appRequireCliOnly()
{
    if (PHP_SAPI !== 'cli') {
        http_response_code(403);
        echo "Forbidden\n";
        exit(1);
    }
}Code language: PHP (php)

Web から直接実行されると、403 エラーを返して終了します。
これにより、外部からの不正実行を防いでいます。

実行は以下のようにターミナルから行います。

php sync_google_calendar.phpCode language: CSS (css)

cron で定期実行するのも簡単です。

0 * * * * cd /path/to/project && php sync_google_calendar.php

3.3. 設定の外部化

Google カレンダーの iCal URL や管理パスワードは、公開ディレクトリの外に置いた設定ファイルで管理しています。

// config.local.php(公開ディレクトリの1階層上)
<?php
define('ADMIN_PASSWORD', '実際のパスワード');
define('GOOGLE_CALENDAR_ICAL_URL', 'https://calendar.google.com/calendar/ical/.../basic.ics');Code language: PHP (php)

このファイルは .gitignore に登録し、バージョン管理から除外します。
環境変数でパスを上書きすることも可能にしました。

function appConfigLocalPath($baseDir)
{
    $fromEnv = getenv('CHIILABO_CONFIG_LOCAL_PATH');
    if (is_string($fromEnv) && $fromEnv !== '') {
        return $fromEnv;
    }

    return dirname($baseDir) . '/config.local.php';
}Code language: PHP (php)

開発環境とサーバー環境で設定を分けたい場合に便利です。

4. 運用の流れ

実際の運用では、以下のような流れになります。

  1. 教室側が Google カレンダーに授業や休講の予定を入力
  2. 同期スクリプトを実行(手動または cron)
  3. 必要に応じて管理画面で微調整
  4. 利用者は公開ページで空き状況を確認し、LINE で連絡

Google カレンダーを一次情報源として扱い、予約システムはそれを反映する形です。
既存の業務フローを大きく変えずに、自動化の恩恵を得られる設計にしました。

4.1. 制約

この実装には、いくつかの制約があります。

タイトルの命名ルールに依存しているため、ルール外のタイトルは拾えません。
教室側がルールを守る必要があります。

また、加算のみの同期なので、Google カレンダーから予定を削除しても、予約システム側の × は残ります。
解除は手動で行う運用です。

さらに、反映範囲は今週から3週後までの4週間に限定しています。
それより先の予定は同期されません。

完璧な自動化よりも、理解しやすく管理しやすいシステムを優先した結果です。