7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自分のお店のお客さんにとって使いやすい予約システムを自作してみた

Last updated at Posted at 2025-11-30

既存サービスはたくさんあるけど今ひとつマッチしない予約システム

地域の工作室「もくもくはりねずみ」の店長をやっている私市です。

一日1000円で木工工具からデジファブまでいろいろ使えて

ときどきワークショップイベントも開催される施設です。

イベントや機材予約など予約管理をすることがとても多く

状況に応じて色々な予約ツールを渡り歩いてきました。

うちのお客さまは子どもからシニアまで幅広く、「デジタル慣れ」してない方も多いです。

SNSや各種SaaSを普段から使い、「サインアップの経験」を十分につんでいる人にとって、メールアドレス、X、Googleアカウントでサインアップできることは「便利」なことですよね。

しかし、普段からあまりWebサービスを使わない人にとってはパスワード管理ができてなかったりしてアカウント作成そのものが「離脱ポイント」になり得ます。

これをなんとかできないものかーとずっと悩んでいたのですが、
アドベントカレンダーの機会にひとつ、プロトタイプとして作成してみることにしました。

これだけはゆずれない要件の一覧

  • 利用者がサインアップ無しでつかえる
  • 利用者はGoogleフォームから入力できる
  • 予約枠は 空いている時間のみ 選択できる
  • 送信された予約は Googleカレンダーへ自動登録
  • カレンダーのイベント名は 「氏名 + 固定の予定タイトル」
  • 入力内容は 説明欄に自動反映
  • 予約ログはスプレッドシートに保存
  • Discordへ自動通知
  • 直近4週間のみ予約可能
  • 土日13-17時 / 月火水17-21時のみ予約可(木金は受け付けない)
  • 予約できない日はカレンダー側で予定を入れれば自動除外

つくる

完成イメージ

予約が入るとカレンダーにちゃんと登録されて
スクリーンショット 2025-11-28 18.46.03.png

Discordに通知される

スクリーンショット 2025-11-28 18.45.49.png

システムの流れはこんな感じ

ユーザー
↓ Googleフォーム
↓ 回答送信
GAS (フォーム送信トリガー)
├ カレンダー重複確認
├ 予約イベント作成 (title + description)
├ Discord通知
├ スプレッドシートへログ書き込み
└ updateFormAvailableSlots()
      └ フォームの「希望枠」選択肢を空き枠のみで再生成

Googleカレンダーの作成

今回は、LaserPeckerというレーザー加工機の体験予約に特化したものを作りたいので専用のカレンダーを作成します。

「カレンダーID」の値が必要なので後ほど使えるようにひかえておきます。

スクリーンショット 2025-11-28 17.37.45.png

フォームの入力項目

質問タイトル タイプ
お名前 短文
希望枠 プルダウン(GASで動的更新)
参加人数 数値 or 短文
興味のある機器 チェックボックス
やりたいこと・質問 段落

🕒 予約制約

曜日 予約可
土日 13:00–17:00
月火水 17:00–21:00
木金 予約不可
その他 方法
直近4週間のみ GAS側で制御
使えない日 カレンダーに予定を入れると自動ブロック
埋まり枠除外 getEvents() で確認
予約直後に枠更新 onFormSubmit 内で updateFormAvailableSlots() を呼ぶ

⚙ トリガー設定

関数名 種類 ソース
onFormSubmit フォーム送信時 フォーム
updateFormAvailableSlots 時間主導型 毎時 or 毎日

GASのコード全文

コードはChatGPTとやりとりして完成させました。楽ちんでした。

コードを見る

// ===== 設定値 =====
const CALENDAR_ID = 'YOUR_CALENDAR_ID'; // GoogleカレンダーID
const TIMEZONE = 'Asia/Tokyo';
const FORM_ID = 'YOUR_FORM_ID'; // GoogleフォームID
const DAYS_AHEAD = 28; // 直近4週間
const DISCORD_WEBHOOK_URL = 'YOUR_DISCORD_WEBHOOK_URL';

// ===== フォームの「希望枠」選択肢を、空き枠のみで更新 =====
function updateFormAvailableSlots() {
  const form = FormApp.openById(FORM_ID);
  const cal = CalendarApp.getCalendarById(CALENDAR_ID);
  const now = new Date();

  const labels = [];

  for (let i = 0; i < DAYS_AHEAD; i++) {
    const base = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i);
    const dow = base.getDay(); // 0:日〜6:土

    let slotStartHour = null;
    let slotEndHour = null;

    // 土日 13:00-17:00
    if (dow === 0 || dow === 6) {
      slotStartHour = 13;
      slotEndHour = 17;
    }
    // 月火水 17:00-21:00
    else if (dow === 1 || dow === 2 || dow === 3) {
      slotStartHour = 17;
      slotEndHour = 21;
    } else {
      continue; // 木金は予約なし
    }

    for (let h = slotStartHour; h < slotEndHour; h++) {
      const start = new Date(base.getFullYear(), base.getMonth(), base.getDate(), h, 0);
      const end   = new Date(start.getTime() + 60 * 60 * 1000);

      const events = cal.getEvents(start, end);
      if (events.length > 0) continue;

      const label =
        Utilities.formatDate(start, TIMEZONE, 'yyyy/MM/dd(E) HH:mm') +
        '' +
        Utilities.formatDate(end, TIMEZONE, 'HH:mm');

      labels.push(label);
    }
  }

  const items = form.getItems();
  const slotItem = items.filter(item => item.getTitle && item.getTitle() === '希望枠')[0];
  if (!slotItem) throw new Error('希望枠が見つかりません');

  slotItem.asListItem().setChoiceValues(labels);
}

// ===== フォーム送信時処理 =====
function onFormSubmit(e) {
  if (!e) return;

  let namedValues = e.namedValues;
  if (!namedValues && e.response) namedValues = buildNamedValuesFromFormResponse(e.response);

  const name     = getAnswer(namedValues, 'お名前');
  const persons  = getAnswer(namedValues, '参加人数');
  const devices  = getAnswer(namedValues, '興味のある機器');
  const question = getAnswer(namedValues, 'やりたいこと・質問');
  const slotLabel = getAnswer(namedValues, '希望枠');

  const { start, end } = parseSlotLabel(slotLabel);

  const cal = CalendarApp.getCalendarById(CALENDAR_ID);
  const events = cal.getEvents(start, end);

  let result, eventId = '';

  if (events.length > 0) {
    result = 'NG:埋まり';
  } else {
    const title = name + ' LaserPecker使用';
    const description = [
      '【お名前】 ' + name,
      '【希望枠】 ' + slotLabel,
      '【参加人数】 ' + persons,
      '【興味のある機器】 ' + devices,
      '',
      '【やりたいこと・質問】',
      question
    ].join('\n');

    const event = cal.createEvent(title, start, end, { description });
    eventId = event.getId();
    result = 'OK';

    updateFormAvailableSlots();

    postToDiscord({
      name, persons, devices, question, label: slotLabel
    });
  }

  // ---- 回答スプレッドシートへ書き込み ----
  const form = FormApp.openById(FORM_ID);
  const ss = SpreadsheetApp.openById(form.getDestinationId());
  const sheet = ss.getSheets()[0];
  const row = sheet.getLastRow();
  sheet.getRange(row, 10).setValue(result);
  sheet.getRange(row, 11).setValue(eventId);
}

// ===== FormResponse → namedValues に変換 =====
function buildNamedValuesFromFormResponse(response) {
  const map = {};
  response.getItemResponses().forEach(r => {
    const title = r.getItem().getTitle();
    const resp = r.getResponse();
    map[title] = [Array.isArray(resp) ? resp.join(', ') : resp];
  });
  return map;
}

// ===== "2025/11/30(日) 13:00〜14:00" → Date =====
function parseSlotLabel(label) {
  const [datePart, timePart] = label.split(' ');
  const dateOnly = datePart.split('(')[0];
  const [startStr, endStr] = timePart.split('');

  const [y, m, d] = dateOnly.split('/').map(Number);
  const [sh, sm] = startStr.split(':').map(Number);
  const [eh, em] = endStr.split(':').map(Number);

  return {
    start: new Date(y, m - 1, d, sh, sm),
    end:   new Date(y, m - 1, d, eh, em)
  };
}

// ===== 値取り出しヘルパー =====
function getAnswer(namedValues, title) {
  const v = namedValues[title];
  return (!v || v.length === 0) ? '' : v[0];
}

// ===== Discord通知 =====
function postToDiscord(info) {
  const content = [
    '📅 **LaserPecker新規予約**',
    '',
    '■お名前: ' + info.name,
    '■希望枠: ' + info.label,
    '■人数: ' + info.persons,
    '■興味機器: ' + info.devices,
    '',
    info.question
  ].join('\n');

  UrlFetchApp.fetch(DISCORD_WEBHOOK_URL, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ content }),
    muteHttpExceptions: true
  });
}


これで実際に運用をしつつ、今後やりたいことも盛り込んでいきたいと思います。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?