GAS実践 / メール自動送信・カレンダー・フォーム連携

第5回 GAS実践② ── メール自動送信・カレンダー・フォーム連携

第4回まではスプレッドシート1枚の中で完結する自動化でした。今回はいよいよ、複数のGoogleサービスを1本の業務フローにつなぎます。 題材は「セミナーの申込受付から、開催後のアンケート集計まで」。Googleフォームで申込を受け、 スプレッドシートに記録し、メールを自動送信し、Googleカレンダーに予定を作り、 カレンダーを見て前日リマインドを飛ばし、開催後はアンケートの回答が届くたびにダッシュボードが自動更新される ── ここまでを今回で完成させます。 今回の題材とコードはすべてこのページだけで完結しています。第2〜4回の成果物は使いませんので、 まっさらなスプレッドシートから始めてください。

🎯

到達目標

MailApp で宛先・件名・本文を指定してメールを送信でき、文面をテンプレート化して差し込み送信できる。CalendarApp で予定の作成・取得・ゲスト追加ができる。Googleフォームの回答をフォーム送信トリガーで受け取り、FormApp で集計できる。さらに時間主導型トリガーとカレンダーを組み合わせ、「明日開催の予定があれば参加者全員にリマインドを送る」自動運用を構築できる。加えて、アンケート回答が届くたびに集計し直してダッシュボードシートを自動更新する仕組みを作れる。

1今回のゴール ── 1本につながる業務フロー

第4回までに学んだのは、1枚のスプレッドシートの中でデータを読み書きする技術でした。 しかし実際の業務は、シート1枚では終わりません。申込を受け付け、相手に連絡し、予定を押さえ、忘れないよう念押しする ── こうした複数の道具をまたぐ流れこそが、いちばん手間がかかり、いちばん自動化の効果が出るところです。

今回は、次の8ステップをぜんぶGASでつなぎます。人が手を動かすのは「フォームに入力する」ところだけ ── 申込のときと、終了後のアンケートのとき ── になります。

① Googleフォーム 申込者が入力する ② 申込一覧シート 回答が自動で1行追加 ③ 受付確認メール MailApp で自動返信 送信トリガー 同じ申込データを使って ④ カレンダー登録 CalendarApp で予定作成 ⑤ 毎朝8:00に自動起動 時間主導型トリガー 「明日の予定」を確認 ⑥ 前日リマインド 参加者へ一括送信 翌朝読む 開催あり→送信 人が触るのはフォームだけ そして当日を迎える 🎤 セミナー開催 終了後にアンケートを配布 ⑦ アンケート回答 参加者が入力(人の入力) ⑧ ダッシュボード 1件届くたびに集計し直し シートを自動で書き換え 送信トリガー
図1:今回作る自動化の全体像。人が入力するのは①と⑦のフォームだけで、残りはすべてGASが自動で行います
i

今回はGmailの画面を一切使いません。GASからメールを送るのに使うのは MailApp という「送信専用の窓口」です。受信箱を開いたり、下書きを探したり、ラベルを付けたりする機能は持っていません。送ることだけができるぶん、扱いがシンプルで、必要な権限も狭くて済みます。宛先はGmail以外のメールアドレス(会社のメールなど)でも問題なく届きます。

今回使うサービスGASでの窓口今回やること
メール送信MailApp受付確認メール・前日リマインドの送信
GoogleカレンダーCalendarApp開催予定の作成・取得、参加者のゲスト追加
GoogleフォームFormApp回答データの取得・集計
スプレッドシートSpreadsheetApp申込一覧の読み書き、ダッシュボードの自動生成
自動実行の仕掛けScriptApp / トリガー画面フォーム送信時・毎朝8時の自動起動

2教材データを3分で用意する

まず、今回ずっと使う「申込一覧」シートを用意します。フォームは第6章で作りますが、 フォームがなくても第3〜5章は動かせるように、最初は手入力で作ってしまいましょう。 過去の回のファイルは使いません。新しいスプレッドシートを1つ作るところから始めてください。

1
新規ファイルを作る

Googleドライブで新しいスプレッドシートを作成し、ファイル名を 「セミナー運営」 にします。

2
シート名を変える

下のタブ名を 「申込一覧」 に変更します(半角スペースなどが混じらないよう注意)。

3
見出しと数件を入力

1行目に下表の見出しを、2行目以降にサンプル3件を入力します。

列の設計

A〜G列は「フォームから届く情報」、H・I列はGASが書き込む作業列です。見出しだけ先に入れておきます。

列見出し内容例
Aタイムスタンプフォームが自動で入れる(手入力時は空でOK)2026/08/03 10:12
B氏名申込者の名前佐藤 一郎
C所属部署名経営企画部
Dメールアドレス案内の宛先練習中は自分のアドレス
E参加希望日日程マスタと完全に同じ文字列2026/09/10(木)14:00-16:00
F参加方法会場 / オンライン会場
G質問・要望自由記述事前資料はありますか?
H案内メール送信日時GASが書き込む(重複送信の防止に使う)(空欄のまま)
Iカレンダー登録GASが書き込む(重複登録の防止に使う)(空欄のまま)

入力するサンプルデータ(3件)

B 氏名C 所属D メールアドレスE 参加希望日F 参加方法
佐藤 一郎経営企画部自分のアドレス2026/09/10(木)14:00-16:00会場
田中 花子人事部自分のアドレス2026/09/17(木)14:00-16:00オンライン
鈴木 健太生産技術部自分のアドレス2026/09/10(木)14:00-16:00オンライン
⚠

練習中は、D列のメールアドレスを「全部あなた自身のアドレス」にしてください。メール送信は取り消せません。テスト中に同僚や取引先へ誤送信してしまう事故が、この単元でいちばん多いミスです。動作が固まってから本物の宛先に差し替えましょう。なお、E列の参加希望日の文字列は、あとで書く「日程マスタ」のキーと1文字でも違うと動きません。全角カッコ( )やハイフンの種類までそのままコピーしてください。

i

スクリプトエディタの開き方(復習):スプレッドシートのメニューから「拡張機能」→「Apps Script」。開いたプロジェクトに名前(例:セミナー運営 自動化)を付けておくと、後でトリガー画面から探しやすくなります。今回のコードは、すべてこの コード.gs に書いていきます。

3メールを送る ── MailApp の基本

いよいよメール送信です。使うのは MailApp.sendEmail() ただ1つ。 宛先・件名・本文の3つを渡すだけで、GASがあなたのGoogleアカウントからメールを送ってくれます。

🟢 簡単な例 ── 自分宛に1通送ってみる

コード.gs ── ① いちばん簡単な送信
function sendTestMail() {
  const to = "jibun@example.co.jp"; // ← 自分のメールアドレスに書き換える

  MailApp.sendEmail(
    to,                              // 宛先
    "【テスト】GASからの送信テスト",   // 件名
    "GASからメールを送信しました。\n改行はバックスラッシュ n で入れます。" // 本文
  );
}

▷ 実行を押すと、初回だけ「承認が必要です」という画面が出ます。 これは「このスクリプトにメール送信を許可しますか?」という確認で、自分が書いたスクリプトを自分に許可する操作です。 手順は下のとおりです。

1
権限を確認

「権限を確認」→ 自分のGoogleアカウントを選択します。

2
詳細を開く

「このアプリは確認されていません」と出たら、「詳細」→「(プロジェクト名)に移動」。

3
許可する

要求内容を確認して「許可」。以降は聞かれません。

もう少し詳しく ── オプション付きで送る

引数を1つのオブジェクトにまとめる書き方にすると、差出人名やCC、HTML本文まで指定できます。 実務ではこちらの形をおすすめします(項目の順番を気にしなくてよく、あとから足しやすいため)。

コード.gs ── ② オプション指定つきの送信
function sendMailWithOptions() {
  MailApp.sendEmail({
    to:      "jibun@example.co.jp",
    subject: "【ご案内】業務効率化セミナーのお知らせ",
    body:    "テキスト版の本文です。",          // 文字だけの本文
    htmlBody: "<p><b>HTML版</b>の本文です。</p>", // 装飾ありの本文(任意)
    name:    "研修事務局",                      // 差出人の表示名
    replyTo: "seminar-jimu@example.co.jp",     // 返信先アドレス
    cc:      ""                                // 必要ならCC(カンマ区切りで複数可)
  });
}
主なオプション働き
to宛先。カンマ区切りで複数指定できる
subject / body件名/本文(文字だけ)
htmlBodyHTMLの本文。指定するとそちらが優先して表示される
name差出人の表示名(「研修事務局」など)
replyTo返信先。問い合わせ窓口に集約したいときに使う
cc / bccCC/BCC。一斉配信ではBCCを検討する
attachments添付ファイル(ファイルの配列)

送信できる通数には上限がある

GASからのメール送信には1日あたりの上限があります(一般のGoogleアカウントで約100通、Google Workspaceで約1,500通が目安)。 一括送信を作る前に、残り何通送れるかを確認する癖をつけましょう。

コード.gs ── ③ 残りの送信可能数を調べる
function checkMailQuota() {
  const left = MailApp.getRemainingDailyQuota();
  Logger.log("本日あと " + left + " 通送れます");
}
⚠

ループの中で sendEmail を呼ぶときは、件数に注意。100行のシートに対してうっかりループを2回まわすと、それだけで上限に達します。まず3件だけで試す、宛先を自分に固定して試す ── この2つを徹底してください。上限に達すると、その日はもう送信できません(翌日にリセットされます)。

4文面をテンプレート化する(差し込み)

実務のメールは「宛名だけが違う、あとは同じ文面」がほとんどです。そこで、 文面のひな形(テンプレート)に {{氏名}} のような「差し込み口」を作っておき、データで置き換えるという作り方をします。 文面とロジックが分離するので、文面の修正を非エンジニアに任せられるのが最大の利点です。

テンプレート文(ひな形) {{氏名}} 様 開催日時: {{開催日時}} 会場  : {{会場}} 申込一覧の1行ぶんのデータ 氏名  :佐藤 一郎 開催日時:9月10日(木) 14:00 会場  :本社 3F 大会議室 fillTemplate() 完成した1通のメール 佐藤 一郎 様 開催日時:9月10日(木) 14:00 会場  :本社 3F 大会議室 研修事務局 seminar-jimu@example.co.jp
図2:ひな形と1行ぶんのデータを合流させて、1通のメールを組み立てる。人数ぶん繰り返せば一括送信になります

設定とテンプレートを、ファイルの先頭にまとめる

ここから先のコードは、この設定ブロックを土台にします。コード.gs のいちばん上に貼り付けてください (以降のコードは、この下に追記していきます)。

コード.gs ── ④ 設定・日程マスタ・文面テンプレート
// ===== 設定:ここだけ自分の環境に合わせて書き換える =====
const SHEET_NAME    = "申込一覧";
const SEMINAR_TITLE = "業務効率化セミナー";
const ORG_NAME      = "研修事務局";                  // 差出人の表示名
const CONTACT_MAIL  = "seminar-jimu@example.co.jp"; // 問い合わせ・返信先

// 列番号の早見表(A=1, B=2, …)。番号を直書きしないための工夫
const COL = {
  time: 1, name: 2, dept: 3, mail: 4, date: 5,
  style: 6, note: 7, sentAt: 8, eventId: 9
};

// 日程マスタ:フォームの選択肢(キー)と、実際の日時・場所(値)の対応表
const SCHEDULE = {
  "2026/09/10(木)14:00-16:00": {
    start: new Date(2026, 8, 10, 14, 0), // 月は 0 始まり(8 = 9月)
    end:   new Date(2026, 8, 10, 16, 0),
    place: "本社 3F 大会議室"
  },
  "2026/09/17(木)14:00-16:00": {
    start: new Date(2026, 8, 17, 14, 0),
    end:   new Date(2026, 8, 17, 16, 0),
    place: "オンライン(当日リンクをお送りします)"
  }
};

const WEEK = ["日", "月", "火", "水", "木", "金", "土"];

// ===== メール文面テンプレート(ここを直せば文面が変わる) =====
const INVITE_SUBJECT = "【受付完了】{{セミナー名}}のお申込みを受け付けました";
const INVITE_BODY =
  "{{氏名}} 様({{所属}})\n\n" +
  "平素より大変お世話になっております。{{差出人}}です。\n" +
  "下記のとおり、{{セミナー名}}のお申込みを受け付けました。\n\n" +
  "──────────────\n" +
  "■ 開催日時:{{開催日時}}\n" +
  "■ 参加方法:{{参加方法}}\n" +
  "■ 会場  :{{会場}}\n" +
  "──────────────\n\n" +
  "ご不明な点は本メールへご返信ください。\n\n" +
  "{{差出人}}\n{{問い合わせ先}}\n";

const REMIND_SUBJECT = "【明日開催】{{セミナー名}}のご案内";
const REMIND_BODY =
  "{{氏名}} 様\n\n" +
  "明日 {{開催日時}} より、{{セミナー名}}を開催いたします。\n\n" +
  "■ 参加方法:{{参加方法}}\n" +
  "■ 会場  :{{会場}}\n\n" +
  "お気をつけてお越しください。\n\n" +
  "{{差出人}}\n{{問い合わせ先}}\n";
⚠

日付の「月」は 0 から数えます。new Date(2026, 8, 10, 14, 0) は 2026年9月10日 14:00 です(8 が9月)。1月が 0、12月が 11。GASで日付を扱うときの定番の落とし穴なので、コメントを添えておくと安全です。日付を文字列のままカレンダーに渡すことはできません。必ず new Date(...) で日付オブジェクトにします。

差し込みを行う部品を作る

テンプレートの {{キー}} を、渡されたデータで片っ端から置き換える関数を作ります。 この関数1つで、どんな文面でも差し込みできるようになります。

コード.gs ── ⑤ 差し込み・日付整形の共通部品
// テンプレート内の {{キー}} を data の値に置き換える
function fillTemplate(template, data) {
  let text = template;
  Object.keys(data).forEach(function (key) {
    // split して join すると、同じキーが何回出てきても全部置き換わる
    text = text.split("{{" + key + "}}").join(data[key]);
  });
  return text;
}

// 日付を「9月10日(木) 14:00」の形にする
function formatJp(date) {
  const md = Utilities.formatDate(date, "Asia/Tokyo", "M月d日");
  const hm = Utilities.formatDate(date, "Asia/Tokyo", "HH:mm");
  return md + "(" + WEEK[date.getDay()] + ") " + hm;
}

// シートを取る(毎回書くのが面倒なので部品化)
function getSheet() {
  return SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
}

// 申込1行ぶんの配列から、差し込み用のデータを組み立てる
function buildMailData(row) {
  const choice = row[COL.date - 1];   // E列=参加希望日
  const info   = SCHEDULE[choice];        // 日程マスタから引く
  if (!info) return null;                // マスタに無い選択肢は対象外

  return {
    "氏名":       row[COL.name - 1],
    "所属":       row[COL.dept - 1],
    "セミナー名": SEMINAR_TITLE,
    "開催日時":   formatJp(info.start),
    "参加方法":   row[COL.style - 1],
    "会場":       info.place,
    "差出人":     ORG_NAME,
    "問い合わせ先": CONTACT_MAIL
  };
}
✓

「対応表としてのオブジェクト」は実務で頻出のテクニックです。SCHEDULE["2026/09/10(木)14:00-16:00"] のように、選択肢の文字列をキーにして、詳細情報を丸ごと取り出す。こうしておくと、日程が増えてもマスタに1件足すだけでコードは一切変わりません。「文字列を解析して日付に変換する」ような壊れやすい処理を避けられるのも大きな利点です。

AIに文面を書かせる

テンプレートの文面そのものは、生成AIの得意分野です。差し込み口の仕様まで指定して依頼するのがコツです。

✉️ ChatGPT へのプロンプト ── 案内メールの文面作成
あなたは社内研修事務局の担当者です。 セミナー申込の「受付完了メール」の文面を、日本のビジネスメールの作法に沿って作成してください。 【条件】 - 宛先は社内の他部署の方。丁寧だが、堅すぎず簡潔に(本文300字以内) - プレーンテキスト(HTMLタグは使わない)。1行は全角35文字程度で改行 - 次のプレースホルダをそのままの表記で本文に含めること: {{氏名}} {{所属}} {{セミナー名}} {{開催日時}} {{参加方法}} {{会場}} {{差出人}} {{問い合わせ先}} - 開催日時・参加方法・会場は、罫線で囲んだ箇条書きブロックにまとめる - 件名案も3つ提示してください 【出力形式】 1) 件名案(3つ) 2) 本文(プレースホルダ入りのまま)

5一覧から一括送信する(重複送信を防ぐ)

部品がそろったので、「申込一覧」の全員に受付確認メールを送る関数を作ります。 今回の成果物であるメール一括送信スクリプトの中核です。ポイントは重複送信の防止。 送信したら H列に送信日時を書き込み、次に実行したときはその行を飛ばすようにします。

コード.gs ── ⑥ 受付確認メールの一括送信
function sendAllInvitations() {
  const sheet = getSheet();
  const last  = sheet.getLastRow();
  if (last < 2) {                       // 見出しだけ=データ無し
    SpreadsheetApp.getUi().alert("申込データがありません。");
    return;
  }

  // A〜I列を一括で読む(第4回の getValues)
  const rows = sheet.getRange(2, 1, last - 1, 9).getValues();

  let sent = 0;
  let skipped = 0;

  rows.forEach(function (row, i) {
    const to = row[COL.mail - 1];

    // --- 送らない行は先に弾く(ガード) ---
    if (!to)                     { skipped++; return; } // アドレス空欄
    if (row[COL.sentAt - 1]) { skipped++; return; } // 送信済み

    const data = buildMailData(row);
    if (!data)                   { skipped++; return; } // 日程マスタに無い

    // --- 差し込んで送信 ---
    MailApp.sendEmail({
      to:      to,
      subject: fillTemplate(INVITE_SUBJECT, data),
      body:    fillTemplate(INVITE_BODY, data),
      name:    ORG_NAME,
      replyTo: CONTACT_MAIL
    });

    // --- 送った印をH列に残す(i は 0 始まり、データは2行目から → i + 2) ---
    sheet.getRange(i + 2, COL.sentAt).setValue(new Date());
    sent++;
  });

  SpreadsheetApp.getActiveSpreadsheet()
    .toast("送信 " + sent + " 件 / スキップ " + skipped + " 件", "📮 一括送信 完了", 5);
}

実行すると、3件のメールが届き、シートのH列に送信日時が入ります。 もう一度実行してみてください。「送信 0 件/スキップ 3 件」となり、二重送信されないことが確認できます。

✓

「送信済みフラグ」は業務自動化の必須装備です。自動化の事故でいちばん重いのが同じメールを何度も送ってしまうこと。相手の信頼を損ないますし、取り消せません。送ったらシートに記録し、記録があれば送らない ── このひと手間を、最初から組み込んでおきましょう。テストをやり直したいときは、H列を手で消すだけで再送できます。

i

なぜ forEach の中で setValue しているのか。第4回では「書き込みは setValues で一括に」と学びました。ここで1行ずつ書いているのは、途中でエラーが起きても「そこまで送った分」の記録が確実に残るようにするためです。メール送信のように取り消せない処理では、速度よりも「記録の確実さ」を優先します。目的によって使い分けるのがプロの判断です。

6Googleフォームを作り、回答を受け取る

ここまでは手入力のデータで動かしてきました。今度は入口をフォームに置き換えます。 Googleフォームは、回答をスプレッドシートに自動で1行ずつ追記してくれます。 つまり「申込一覧に行が増える」ところまでは、コードを1行も書かずに実現できるということです。

1
フォームを作る

Googleドライブで「Googleフォーム」を新規作成。タイトルは「業務効率化セミナー 参加申込」。

2
質問を5つ追加

下表のとおりに質問文をそのまま設定します(この文字列がコードのキーになります)。

3
回答先を紐づける

「回答」タブ →「スプレッドシートにリンク」→既存の「セミナー運営」を選択。

質問文(そのまま設定)形式選択肢・設定
氏名記述式必須にする
所属記述式必須にする
メールアドレス記述式必須/「回答の検証」でメールアドレスを指定
参加希望日プルダウン2026/09/10(木)14:00-16:00 / 2026/09/17(木)14:00-16:00
参加方法ラジオボタン会場 / オンライン
質問・要望段落任意
⚠

リンク直後にやるべき3つの後始末。①フォームを紐づけると「フォームの回答 1」という新しいシートが作られます。タブ名を「申込一覧」に変更してください(名前を変えてもフォームとの連携は切れません)。②練習用に手入力した古い「申込一覧」シートは、列構成が同じなら削除して構いません。③新しい回答シートのH1に「案内メール送信日時」、I1に「カレンダー登録」と見出しを入れておきます。フォームが作る列はG列までなので、この2列は自分で用意する必要があります。

i

フォームの選択肢と日程マスタは「同じ文字列」で揃える。プルダウンの選択肢 2026/09/10(木)14:00-16:00 は、コード④の SCHEDULE のキーと完全一致している必要があります。カッコが全角か半角か、ハイフンの前後に空白があるか ── 見た目が同じでも1文字違えば SCHEDULE[choice] は undefined になり、その行はスキップされます。フォーム側からコピーして貼り付けるのが確実です。

設定が終わったら、プレビュー(👁 アイコン)からフォームに1件回答してみてください。 「申込一覧」シートに1行増えれば成功です。ここまではコードを書かずに実現できています。

7フォーム送信トリガーで「即・自動返信」

次は、回答が届いた瞬間に受付確認メールを自動で返す仕組みです。 鍵になるのがトリガー。第4回では onOpen や onEdit という 「特別な名前を付けるだけで動く」タイプを扱いましたが、メール送信やカレンダー操作は、そのタイプでは動きません。

シンプルトリガー onOpen(e) / onEdit(e) ✓ 決まった名前で書くだけで動く ✓ 登録も承認も不要 ✗ メールを送れない ✗ カレンダーを操作できない ✗ ui.alert を出せない 権限が必要な操作は いっさいできない インストール型トリガー フォーム送信時 / 時間主導型 ✓ メールを送れる ✓ カレンダーを操作できる ✓ 外部APIも呼べる △ トリガー画面での登録が必要 △ 初回に承認(許可)が必要 今回の自動化は すべてこちらを使う
図3:メールを送りたいならインストール型。「勝手に動くけれど権限は弱い」シンプルトリガーとの違いを押さえましょう

フォーム送信時に呼ばれる関数を書く

関数には引数 e が渡され、「今どの行が追加されたか」「何が入力されたか」が入っています。 今回は e.range.getRow() で追加された行番号だけを取り、あとは第5章と同じ部品を使い回すのが賢いやり方です。

コード.gs ── ⑦ フォーム送信で即・自動返信
// フォームが送信されたときに呼ばれる(トリガー登録が必要)
function onFormSubmit(e) {
  const sheet = getSheet();

  // 追加された行番号。手動テスト時(e が無い)は最終行を使う
  const row = (e && e.range) ? e.range.getRow() : sheet.getLastRow();
  if (row < 2) return;

  // その1行をA〜I列まで読み取る
  const values = sheet.getRange(row, 1, 1, 9).getValues()[0];

  const to   = values[COL.mail - 1];
  const data = buildMailData(values);
  if (!to || !data) return;
  if (values[COL.sentAt - 1]) return; // すでに送信済みなら何もしない

  MailApp.sendEmail({
    to:      to,
    subject: fillTemplate(INVITE_SUBJECT, data),
    body:    fillTemplate(INVITE_BODY, data),
    name:    ORG_NAME,
    replyTo: CONTACT_MAIL
  });

  sheet.getRange(row, COL.sentAt).setValue(new Date());
}

トリガーを登録する

1
トリガー画面を開く

スクリプトエディタ左側の⏰(トリガー)アイコンをクリック。

2
トリガーを追加

右下の「+ トリガーを追加」ボタンを押します。

3
4項目を設定

関数=onFormSubmit/ソース=スプレッドシートから/種類=フォーム送信時。

保存すると、再び承認(許可)を求められます。許可したら、フォームのプレビューから実際に1件申し込んでみてください。 数秒〜十数秒で受付確認メールが届き、シートのH列に送信日時が入れば完成です。

⚠

onFormSubmit を「▷ 実行」で手動テストすると失敗します。引数 e はトリガー経由で呼ばれたときだけ渡されるため、手動実行では undefined になり、「e.range を読めません」というエラーが出ます。上のコードでは (e && e.range) ? … : 最終行 というガードを入れて、手動実行時は「最終行を処理する」ようにしてあります。この書き方を覚えておくと、トリガー系の関数のテストが一気に楽になります。

参考:e の中身を覗いてみる

行番号ではなく「質問文をキーにして回答を取り出す」こともできます。列の位置に依存しないので、 フォームの質問順を入れ替えても壊れないのが利点です。

e の中身意味
e.range追加された行の範囲(.getRow() で行番号)
e.values回答を列の順番で並べた配列(e.values[1] が氏名)
e.namedValues質問文をキーにしたオブジェクト。値は配列で入る
e.triggerUidどのトリガーから呼ばれたかのID
コード.gs ── ⑧ namedValues で回答を取り出す(参考)
function logAnswers(e) {
  // 値は配列で入るので、末尾に [0] が必要
  const name   = e.namedValues["氏名"][0];
  const mail   = e.namedValues["メールアドレス"][0];
  const choice = e.namedValues["参加希望日"][0];

  Logger.log(name + " / " + mail + " / " + choice);
}
i

先に一言だけ:フォームが2つになると、この関数が両方で呼ばれます。「フォーム送信時」トリガーはスプレッドシート単位で設定されるため、同じファイルに2つ目のフォーム(今回なら第11章のアンケート)を紐づけると、どちらの回答でも同じ関数が起動します。いまは申込フォーム1つだけなので問題ありませんが、第11章で「どちらから来たかを見分けて振り分ける」書き方に作り替えます。1つの入口・複数の処理という形になる、と頭の隅に置いておいてください。

8FormApp で回答データを直接集計する

スプレッドシート経由ではなく、フォーム本体から回答を直接読むこともできます。 FormApp を使うと、回答シートを作っていないフォームや、 「誰がいつ回答したか」といったフォーム固有の情報まで扱えます。

やりたいこと書き方
フォームを開く(編集URLから)FormApp.openByUrl("…/edit")
全回答を取得form.getResponses()
1回答の中身を取得response.getItemResponses()
質問文/回答値item.getItem().getTitle() / item.getResponse()
回答日時response.getTimestamp()
フォームの公開URLform.getPublishedUrl()
コード.gs ── ⑨ 参加方法ごとの人数を集計する
function countByStyle() {
  // ↓ フォームの「編集画面」のURL(末尾が /edit)に書き換える
  const form = FormApp.openByUrl("https://docs.google.com/forms/d/xxxxxxxx/edit");
  const responses = form.getResponses();

  const count = { "会場": 0, "オンライン": 0 };

  responses.forEach(function (res) {
    res.getItemResponses().forEach(function (item) {
      if (item.getItem().getTitle() !== "参加方法") return;
      const answer = item.getResponse();
      if (count[answer] !== undefined) count[answer]++;
    });
  });

  Logger.log("回答総数:" + responses.length);
  Logger.log("会場:" + count["会場"] + " 名 / オンライン:" + count["オンライン"] + " 名");
}
⚠

openByUrl に渡すのは「編集用URL」です。回答者に配る …/viewform のURLでは開けません。ブラウザでフォームを編集モードで開いたときのアドレス(末尾が /edit)をコピーしてください。FormApp.openById("フォームID") でも同じことができます。

i

スプレッドシート経由と FormApp、どちらを使うべきか。日常の集計や一括送信はスプレッドシート経由(第5章の方式)が圧倒的に扱いやすく、速いです。FormApp の出番は、フォームの質問文や選択肢そのものをコードから読み書きしたいとき、回答シートを介さずに集計したいときです。迷ったらスプレッドシート経由、と覚えておいて構いません。

9カレンダーに予定を登録する(CalendarApp)

申込を受けたら、次は開催予定をカレンダーに押さえる番です。 CalendarApp を使うと、予定の作成・検索・ゲスト追加・削除がコードからできます。 そして今回いちばん大事なのは ── カレンダーは「予定を入れる場所」であると同時に、「あとで自動処理を判断するための材料」になるという点です(第10章で効いてきます)。

よく使うメソッド働き
CalendarApp.getDefaultCalendar()自分のメインカレンダーを取得
cal.createEvent(件名, 開始, 終了, オプション)時刻つきの予定を作成
cal.createAllDayEvent(件名, 日付)終日の予定を作成
cal.getEventsForDay(日付)その日の予定を配列で取得
event.getTitle() / getStartTime()予定の件名/開始日時
event.addGuest(メールアドレス)予定にゲストを追加(招待が届く)
event.addPopupReminder(分)開始◯分前に通知を出す
event.deleteEvent()予定を削除

🟢 簡単な例 ── 予定を1件作ってみる

コード.gs ── ⑩ カレンダーに予定を1件作る
function createOneEvent() {
  const cal = CalendarApp.getDefaultCalendar();

  const event = cal.createEvent(
    "業務効率化セミナー",
    new Date(2026, 8, 10, 14, 0),  // 2026/9/10 14:00
    new Date(2026, 8, 10, 16, 0),  // 2026/9/10 16:00
    {
      description: "社内向けセミナー。お問い合わせは研修事務局まで。",
      location:    "本社 3F 大会議室"
    }
  );

  event.addPopupReminder(60); // 開始60分前に通知
  Logger.log("作成しました: " + event.getId());
}

実行後、Googleカレンダーの2026年9月10日を開いて、予定が入っていることを確認してください。

🔵 実務的な例 ── 開催予定を用意し、申込者をゲストに追加する

実務では、「開催予定はセミナーごとに1件」「参加者はそこにゲストとして足していく」のが自然な形です。 そこで2つの関数を作ります ── ①日程マスタを見てまだ無ければ予定を作る、②申込一覧を見てまだ追加していない人をゲストに足す。 どちらも「すでにあるか」を確認してから実行するので、何度実行しても重複しません。

コード.gs ── ⑪ 開催予定の作成 と ゲスト追加
// 指定日にある「セミナー」の予定を1件だけ探す(無ければ null)
function findSeminarEvent(date) {
  const events = CalendarApp.getDefaultCalendar().getEventsForDay(date);
  for (let i = 0; i < events.length; i++) {
    if (events[i].getTitle().indexOf(SEMINAR_TITLE) !== -1) {
      return events[i];
    }
  }
  return null;
}

// ① 日程マスタを見て、まだ予定が無い日程だけ作る
function setupSeminarEvents() {
  const cal = CalendarApp.getDefaultCalendar();
  let made = 0;

  Object.keys(SCHEDULE).forEach(function (key) {
    const info = SCHEDULE[key];
    if (findSeminarEvent(info.start)) return; // すでにあるので作らない

    const ev = cal.createEvent(SEMINAR_TITLE, info.start, info.end, {
      description: "お問い合わせ:" + CONTACT_MAIL,
      location:    info.place
    });
    ev.addPopupReminder(60);
    made++;
  });

  SpreadsheetApp.getActiveSpreadsheet()
    .toast("開催予定を " + made + " 件作成しました", "📅 完了", 4);
}

// ② 申込者を、その日程の予定にゲストとして追加する
function addGuestsToEvents() {
  const sheet = getSheet();
  const last  = sheet.getLastRow();
  if (last < 2) return;

  const rows = sheet.getRange(2, 1, last - 1, 9).getValues();
  let added = 0;

  rows.forEach(function (row, i) {
    if (row[COL.eventId - 1]) return;   // 追加済み

    const to   = row[COL.mail - 1];
    const info = SCHEDULE[row[COL.date - 1]];
    if (!to || !info) return;

    const ev = findSeminarEvent(info.start);
    if (!ev) return;                        // 先に setupSeminarEvents を実行

    ev.addGuest(to);
    sheet.getRange(i + 2, COL.eventId).setValue("追加済み");
    added++;
  });

  SpreadsheetApp.getActiveSpreadsheet()
    .toast("ゲストを " + added + " 名追加しました", "👥 完了", 4);
}
⚠

ゲスト追加は、相手に招待メールが届きます。addGuest() はカレンダーの招待通知を送ります。練習段階ではD列を自分のアドレスにしたまま試してください。運用に乗せる際は、「招待を送ってよい相手か」「案内メールと二重に届かないか」を事前に決めておきましょう。招待を出さずに参加者を管理したい場合は、ゲスト追加せず、予定の description(説明欄)に参加者名を追記する方法もあります。

i

なぜ「予定を探してから作る」のか。カレンダーには同じ内容の予定を何件でも作れてしまいます。setupSeminarEvents を2回実行したら予定が2つ ── これが実務では地味に厄介です。そこで getEventsForDay() でその日の予定を調べ、同名のものが無いときだけ作るようにしています。メールのH列フラグと同じ「重複防止」の発想です。

10カレンダーを起点に「前日リマインド」を自動送信

いよいよ今回の山場です。毎朝8時にGASが勝手に起動し、カレンダーを見て「明日セミナーがある」と判断したら、 その日程の参加者だけにリマインドメールを送る ── 人が何もしなくても回り続ける仕組みを作ります。

毎朝 8:00 時間主導型トリガー カレンダーに 明日の予定がある? getEventsForDay() はい その日程の参加者だけに リマインドを一括送信 いいえ 何もしないで終了 (メールは1通も送らない) 判断材料はカレンダー。 予定を消せば送信も止まる
図4:カレンダーが「送るか送らないか」を決める設計。開催が中止になったら予定を消すだけでリマインドも止まります
コード.gs ── ⑫ 明日開催なら参加者へリマインド
function sendReminderForTomorrow() {
  // --- ① 「明日」の日付を作る ---
  const tomorrow = new Date();
  tomorrow.setDate(tomorrow.getDate() + 1); // 月末でも自動で翌月に繰り上がる

  // --- ② カレンダーに明日の開催予定があるか? ---
  const ev = findSeminarEvent(tomorrow);
  if (!ev) {
    Logger.log("明日は開催予定がありません。何もしません。");
    return;                       // ← ここで終了。メールは1通も送らない
  }

  // --- ③ 明日の日付を「yyyy/MM/dd」の文字列にしておく(比較用) ---
  const target = Utilities.formatDate(ev.getStartTime(), "Asia/Tokyo", "yyyy/MM/dd");

  // --- ④ 申込一覧から、その日程の人だけに送る ---
  const sheet = getSheet();
  const last  = sheet.getLastRow();
  if (last < 2) return;

  const rows = sheet.getRange(2, 1, last - 1, 9).getValues();
  let sent = 0;

  rows.forEach(function (row) {
    const to   = row[COL.mail - 1];
    const info = SCHEDULE[row[COL.date - 1]];
    if (!to || !info) return;

    // 申込日程が「明日」でなければ対象外
    const day = Utilities.formatDate(info.start, "Asia/Tokyo", "yyyy/MM/dd");
    if (day !== target) return;

    const data = buildMailData(row);
    MailApp.sendEmail({
      to:      to,
      subject: fillTemplate(REMIND_SUBJECT, data),
      body:    fillTemplate(REMIND_BODY, data),
      name:    ORG_NAME,
      replyTo: CONTACT_MAIL
    });
    sent++;
  });

  Logger.log("リマインドを " + sent + " 件送信しました。");
}
✓

「カレンダーに判断させる」のがこの設計の肝です。スクリプトの中に開催日を書き込んでしまうと、日程が変わるたびにコードを直すことになります。カレンダーの予定を「唯一の正」にしておけば、日程変更も中止もカレンダーを操作するだけで自動処理に反映されます。しかもカレンダーはプログラムを知らない人でも編集できる ── これが業務システムとしての強さです。

時間主導型トリガーを登録する

画面から登録する方法と、コードで登録する方法の2つがあります。

1
トリガー画面へ

⏰アイコン →「+ トリガーを追加」。

2
関数と種類を選ぶ

関数=sendReminderForTomorrow/種類=時間主導型。

3
時間帯を選ぶ

日付ベースのタイマー→午前8時〜9時を選んで保存。

コード.gs ── ⑬ トリガーをコードから登録する
function createDailyTrigger() {
  // 同じ関数の古いトリガーを先に消す(実行のたびに増殖するのを防ぐ)
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === "sendReminderForTomorrow") {
      ScriptApp.deleteTrigger(t);
    }
  });

  ScriptApp.newTrigger("sendReminderForTomorrow")
    .timeBased()
    .everyDays(1)
    .atHour(8)      // 8時台に実行(分単位の指定はできない)
    .create();

  SpreadsheetApp.getActiveSpreadsheet()
    .toast("毎朝8時台のトリガーを登録しました", "⏰ 完了", 5);
}
⚠

時間主導型トリガーの実行時刻は「◯時ちょうど」ではありません。atHour(8) は「8:00〜9:00のどこか」で実行される、という意味です(Googleが負荷を見て決めます)。分単位の厳密な時刻指定はできないので、時刻の正確さに依存しない設計にしておきましょう。また、createDailyTrigger を削除処理なしで何度も実行すると、トリガーが増えて同じメールが何通も届きます。上のコードのように「消してから作る」が鉄則です。

★

明日まで待たずにテストする方法。①カレンダーに明日の日付で「業務効率化セミナー」という予定を手で作る、②日程マスタ SCHEDULE に明日の日付の行を1つ足し、申込一覧のE列をその文字列にする、③sendReminderForTomorrow を手動で ▷ 実行。これでリマインドが飛ぶはずです。自動化を作ったら「トリガーを待たずに手で試せる状態」を必ず用意しておきましょう。

11開催後アンケート → ダッシュボードを自動更新

セミナーは開催して終わりではありません。満足度はどうだったか、どのテーマが求められているか ── その振り返りこそが次回の企画につながります。ところが現実には、アンケートを集めたはいいが、集計が面倒で結局グラフにしないまま終わる というのが、いちばんよくある形ではないでしょうか。

そこで作るのが「回答が1件届くたびに、ダッシュボードシートが勝手に書き換わる」仕組みです。 担当者は集計作業を一切しません。ダッシュボードを開けば、その瞬間の最新集計が必ず載っている状態をGASで作ります。

アンケート フォーム 1件回答が届く 「アンケート回答」 シート 全行を読み直す 集計しなおす ・平均スコア ・満足度の分布 ・日程別の平均 ・要望テーマ集計 ダッシュ ボード 書き換え 回答が届くたびに、この流れを最初からやり直す = 差分を計算しないから、集計ミスも二重カウントも起きない
図5:毎回ぜんぶ作り直すのがコツ。「前回からの差分を足す」設計にすると、数え間違いや重複が必ず起きます

ステップ1:アンケートフォームを作る

2つ目のGoogleフォームを作り、回答先は同じ「セミナー運営」スプレッドシートにリンクします。 作られたシートのタブ名は「アンケート回答」に変更してください。

列質問文(そのまま設定)形式選択肢・設定
Aタイムスタンプ自動フォームが入れる
B参加した日程プルダウン申込フォームと同じ2つの選択肢
C総合満足度均等目盛(線形尺度)1〜5(1=不満/5=満足)
D内容の分かりやすさ均等目盛1〜5
E業務への活用イメージ均等目盛1〜5
F今後扱ってほしいテーマプルダウンデータ集計の自動化/資料作成の自動化/メール・連絡の自動化/外部サービス連携
Gご意見・ご感想段落任意
i

アンケートの配布も自動化できます。今回は手動で配る前提ですが、第10章の sendReminderForTomorrow を応用して、「昨日開催の予定があれば、参加者にアンケートURLを送る」 関数(お礼メール)を作れば、配布まで完全に自動化できます。フォームの公開URLは form.getPublishedUrl()(第8章)で取得できます。宿題の発展課題として用意しました。

ステップ2:フォームが2つになると起きること

ここが今回いちばん重要な実務ポイントです。1つのスプレッドシートに2つのフォームを紐づけると、 「フォーム送信時」トリガーはどちらのフォームが送信されても同じ関数を呼びます。 つまり第7章で作った onFormSubmit は、アンケートが回答されたときにも起動してしまうのです。 このままでは、アンケート回答者に「お申込みを受け付けました」というメールが誤送信されかねません。

解決策は「入口を1つにして、中で振り分ける」こと。 e.range.getSheet().getName() でどのシートに行が追加されたかを調べれば、 どちらのフォームから来たのかが判別できます。

コード.gs ── ⑭ 2つのフォームを1つの入口で振り分ける
// アンケート側の設定(設定ブロックに追記する)
const SURVEY_SHEET = "アンケート回答";
const DASH_SHEET   = "ダッシュボード";
const SUR = { time: 1, date: 2, score: 3, clear: 4, useful: 5, theme: 6, comment: 7 };

// ★ トリガーにはこの関数を登録する(onFormSubmit の代わり)
function onAnyFormSubmit(e) {
  if (!e || !e.range) return;

  // 「どのシートに行が追加されたか」で、どちらのフォームか判別する
  const name = e.range.getSheet().getName();

  if (name === SHEET_NAME) {          // 申込フォーム → 受付確認メール
    handleApplication(e);
  } else if (name === SURVEY_SHEET) { // アンケート → ダッシュボード更新
    updateDashboardSafely();
  }
}
⚠

トリガーの登録先を onFormSubmit から onAnyFormSubmit に変更してください。⏰トリガー画面で、既存のフォーム送信時トリガーをいったん削除し、実行する関数を onAnyFormSubmit にして登録し直します。第7章で作った onFormSubmit は、関数名を handleApplication に変更して、振り分け先の部品として使い回します(中身は変えません)。トリガーが呼ぶのは1つ、実際の処理は複数 ── この形にしておくと、3つ目・4つ目のフォームが増えても else if を足すだけで済みます。

ステップ3:ダッシュボードを組み立てる

いよいよ本体です。やることは「全行を読む → 集計する → シートに書き出す」だけ。 第4回で学んだ「読む→加工する→書く」の型そのものですが、集計の中身が実務レベルになります。

ダッシュボードに載せる指標意味・使い道
回答数母数。少ないうちは平均値を鵜呑みにしない判断材料になる
3項目の平均スコア満足度/分かりやすさ/活用イメージ。どこが弱いかが一目でわかる
好意的回答率(4点以上の割合)平均値だけでは見えない「実際に満足した人の割合」
満足度の分布★1〜5の件数。平均3.0が「全員ふつう」なのか「絶賛と酷評が半々」なのかを区別できる
日程別の平均会場回とオンライン回など、回による差を検出する
要望テーマの票数次回の企画に直結する。多い順に並べる
最新コメント数字に出ない生の声。直近5件だけ載せる
コード.gs ── ⑮ アンケートを集計してダッシュボードを作り直す
function updateDashboard() {
  const ss  = SpreadsheetApp.getActiveSpreadsheet();
  const src = ss.getSheetByName(SURVEY_SHEET);
  if (!src) return;

  const last = src.getLastRow();
  if (last < 2) return;                    // まだ回答ゼロ

  // ===== ① 全回答を一括で読む =====
  const rows = src.getRange(2, 1, last - 1, 7).getValues();
  const total = rows.length;

  // ===== ② 集計する =====
  let sumScore = 0, sumClear = 0, sumUseful = 0;
  const dist     = [0, 0, 0, 0, 0];  // ★1〜★5の件数
  const byDate   = {};                 // 日程別 { 件数, 合計 }
  const byTheme  = {};                 // テーマ別 票数
  const comments = [];

  rows.forEach(function (r) {
    const score = Number(r[SUR.score - 1]);
    sumScore  += score;
    sumClear  += Number(r[SUR.clear - 1]);
    sumUseful += Number(r[SUR.useful - 1]);

    if (score >= 1 && score <= 5) dist[score - 1]++;

    const when = r[SUR.date - 1];
    if (when) {
      if (!byDate[when]) byDate[when] = { count: 0, sum: 0 };
      byDate[when].count++;
      byDate[when].sum += score;
    }

    const theme = r[SUR.theme - 1];
    if (theme) byTheme[theme] = (byTheme[theme] || 0) + 1;

    const c = r[SUR.comment - 1];
    if (c) comments.push(String(c));
  });

  const avg = function (sum) { return Math.round((sum / total) * 10) / 10; };
  const goodRate = Math.round(((dist[3] + dist[4]) / total) * 100);

  // ===== ③ 表(二次元配列)を組み立てる =====
  // A列=項目名 / B列=表示用(数値や文字の棒グラフ) / C列=グラフ用の数値
  const t = [];
  t.push(["📊 " + SEMINAR_TITLE + " アンケート集計", "", ""]);
  t.push(["最終更新", Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm"), ""]);
  t.push(["", "", ""]);
  t.push(["回答数", total + " 件", total]);
  t.push(["総合満足度(5点満点)", avg(sumScore),  ""]);
  t.push(["内容の分かりやすさ",     avg(sumClear),  ""]);
  t.push(["業務への活用イメージ",   avg(sumUseful), ""]);
  t.push(["好意的回答率(4点以上)", goodRate + " %", goodRate]);

  t.push(["", "", ""]);
  t.push(["■ 満足度の分布", "バー", "件数"]); // ← C列がグラフの元データ
  for (let s = 5; s >= 1; s--) {
    const n = dist[s - 1];
    t.push(["★" + s, "█".repeat(n), n]); // 文字で棒グラフ+実数
  }

  t.push(["", "", ""]);
  t.push(["■ 日程別の平均満足度", "平均", "回答数"]);
  Object.keys(byDate).forEach(function (k) {
    const d = byDate[k];
    t.push([k, Math.round((d.sum / d.count) * 10) / 10, d.count]);
  });

  t.push(["", "", ""]);
  t.push(["■ 今後扱ってほしいテーマ(多い順)", "バー", "票数"]);
  Object.keys(byTheme)
    .sort(function (a, b) { return byTheme[b] - byTheme[a]; })
    .forEach(function (k) {
      t.push([k, "█".repeat(byTheme[k]), byTheme[k]]);
    });

  t.push(["", "", ""]);
  t.push(["■ 最新のご意見(新しい順に5件)", "", ""]);
  comments.slice(-5).reverse().forEach(function (c) {
    t.push(["・", c, ""]);
  });

  // ===== ④ ダッシュボードシートに書き出す =====
  let dash = ss.getSheetByName(DASH_SHEET);
  if (!dash) dash = ss.insertSheet(DASH_SHEET);

  dash.clearContents();                        // 値だけ消す(書式とグラフは残る)
  dash.getRange(1, 1, t.length, 3).setValues(t); // 一括書き込み

  // ===== ⑤ 見た目を整える =====
  dash.getRange(1, 1, 1, 3)
    .setFontWeight("bold").setFontSize(13)
    .setBackground("#1e293b").setFontColor("#ffffff");
  dash.getRange(5, 2, 3, 1).setNumberFormat("0.0"); // 平均は 4 → 4.0 と表示
  dash.setColumnWidth(1, 260);
  dash.setColumnWidth(2, 380);
  dash.setColumnWidth(3, 80);
  dash.setFrozenRows(2);
}
✓

「毎回ぜんぶ作り直す」という設計判断。「新しい1件を、前回の集計結果に足す」ほうが速そうに見えます。しかし実際には、回答が修正されたら? テスト送信を消したら? トリガーが2回動いたら? ── 差分方式は必ずズレます。全件を読み直して丸ごと作り直せば、何回実行しても結果は同じ(この性質を冪等(べきとう)と呼びます)。数百件程度なら一瞬で終わるので、速さより正しさを取るのが定石です。

ステップ4:同時に回答が来ても壊れないようにする

セミナー終了直後は、参加者が一斉にアンケートを送信します。同じタイミングで updateDashboard が何本も走ると、書き込みがぶつかって表が崩れることがあります。 そこで「先に始めた1本だけを通す」鍵をかけます。

コード.gs ── ⑯ 同時実行を防ぐラッパー
function updateDashboardSafely() {
  const lock = LockService.getScriptLock();

  // 10秒待っても鍵が取れなければ、今回はあきらめる
  if (!lock.tryLock(10000)) return;

  try {
    updateDashboard();
  } finally {
    lock.releaseLock();  // 成功しても失敗しても必ず鍵を返す
  }
}
i

「あきらめてよい」のは、作り直し方式だからです。鍵が取れずスキップした回答も、次の誰かの回答のときに全件読み直しの中で必ず反映されます。差分方式ならスキップ=永久に欠落ですが、作り直し方式なら一時的に1件遅れるだけ。設計の選択が、例外処理の楽さにまで効いてくる好例です。手動で確実に最新化したいときは、メニューから updateDashboard を直接呼べるようにしておきます(第12章の完成版に入れてあります)。

★

本物のグラフも自動更新にできます。そのためにC列に「グラフ用の数値」だけを別で書き出してあります(B列の █ は文字なのでグラフには使えません)。「■ 満足度の分布」の★5〜★1のA列とC列を選択し、スプレッドシートの標準機能からグラフを1度だけ挿入してください。上のコードは clearContents()(=値だけを消す)を使っているので、グラフは消えず、値が書き換わるたびにグラフも自動で追従します。「見せる列」と「計算に使う列」を分けるのは、ダッシュボード設計の基本テクニックです。

⚠

アンケートは「回答者を特定しない」設計が基本です。今回のフォームは氏名・メールアドレスを聞いていません。率直な評価を集めるためであると同時に、個人が特定できる評価データを溜め込まないという配慮でもあります。誰が何点を付けたかを記録する場合は、収集目的の明示と保管ルールの確認を必ず行ってください。フォーム設定の「メールアドレスを収集する」をオンにすると回答者が特定できてしまう点にも注意しましょう。

12統合実践 ── 完成版コード全文

ここまでの部品をすべて1本にまとめ、独自メニューと申込状況サマリーを足した完成版です。 これ1つを コード.gs に貼り付ければ、今回のシステム全体が動きます (章ごとに書き足してきた方は、いったん全部消してからこれを貼るのが確実です)。

1
貼って保存

下のコードを全文コピーし、コード.gs に貼り付けて保存します。

2
シートを再読み込み

スプレッドシートをブラウザで更新すると、上部に「📮 セミナー運営」メニューが出ます。

3
①→②→③の順に実行

メニューから順に実行し、最後にトリガーを2つ(フォーム送信時 → onAnyFormSubmit/毎朝8時 → リマインド)登録します。

コード.gs ── ⑭ 完成版(これ1つで動きます)
/* =========================================================
   セミナー運営 自動化スクリプト(第5回 完成版)
   申込 → 受付確認メール → カレンダー登録 → 前日リマインド
        → 開催後アンケート → ダッシュボード自動更新
   ========================================================= */

// ===== 1. 設定 =====
const SHEET_NAME    = "申込一覧";
const SURVEY_SHEET  = "アンケート回答";
const DASH_SHEET    = "ダッシュボード";
const SEMINAR_TITLE = "業務効率化セミナー";
const ORG_NAME      = "研修事務局";
const CONTACT_MAIL  = "seminar-jimu@example.co.jp";

// 申込一覧の列番号
const COL = {
  time: 1, name: 2, dept: 3, mail: 4, date: 5,
  style: 6, note: 7, sentAt: 8, eventId: 9
};

// アンケート回答の列番号
const SUR = {
  time: 1, date: 2, score: 3, clear: 4,
  useful: 5, theme: 6, comment: 7
};

const SCHEDULE = {
  "2026/09/10(木)14:00-16:00": {
    start: new Date(2026, 8, 10, 14, 0),
    end:   new Date(2026, 8, 10, 16, 0),
    place: "本社 3F 大会議室"
  },
  "2026/09/17(木)14:00-16:00": {
    start: new Date(2026, 8, 17, 14, 0),
    end:   new Date(2026, 8, 17, 16, 0),
    place: "オンライン(当日リンクをお送りします)"
  }
};

const WEEK = ["日", "月", "火", "水", "木", "金", "土"];

// ===== 2. メール文面テンプレート =====
const INVITE_SUBJECT = "【受付完了】{{セミナー名}}のお申込みを受け付けました";
const INVITE_BODY =
  "{{氏名}} 様({{所属}})\n\n" +
  "平素より大変お世話になっております。{{差出人}}です。\n" +
  "下記のとおり、{{セミナー名}}のお申込みを受け付けました。\n\n" +
  "──────────────\n" +
  "■ 開催日時:{{開催日時}}\n" +
  "■ 参加方法:{{参加方法}}\n" +
  "■ 会場  :{{会場}}\n" +
  "──────────────\n\n" +
  "ご不明な点は本メールへご返信ください。\n\n" +
  "{{差出人}}\n{{問い合わせ先}}\n";

const REMIND_SUBJECT = "【明日開催】{{セミナー名}}のご案内";
const REMIND_BODY =
  "{{氏名}} 様\n\n" +
  "明日 {{開催日時}} より、{{セミナー名}}を開催いたします。\n\n" +
  "■ 参加方法:{{参加方法}}\n" +
  "■ 会場  :{{会場}}\n\n" +
  "お気をつけてお越しください。\n\n" +
  "{{差出人}}\n{{問い合わせ先}}\n";

// ===== 3. 共通部品 =====
function fillTemplate(template, data) {
  let text = template;
  Object.keys(data).forEach(function (key) {
    text = text.split("{{" + key + "}}").join(data[key]);
  });
  return text;
}

function formatJp(date) {
  const md = Utilities.formatDate(date, "Asia/Tokyo", "M月d日");
  const hm = Utilities.formatDate(date, "Asia/Tokyo", "HH:mm");
  return md + "(" + WEEK[date.getDay()] + ") " + hm;
}

function getSheet() {
  return SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
}

function buildMailData(row) {
  const info = SCHEDULE[row[COL.date - 1]];
  if (!info) return null;
  return {
    "氏名":         row[COL.name - 1],
    "所属":         row[COL.dept - 1],
    "セミナー名":   SEMINAR_TITLE,
    "開催日時":     formatJp(info.start),
    "参加方法":     row[COL.style - 1],
    "会場":         info.place,
    "差出人":       ORG_NAME,
    "問い合わせ先": CONTACT_MAIL
  };
}

function sendInvitation(to, data) {
  MailApp.sendEmail({
    to: to,
    subject: fillTemplate(INVITE_SUBJECT, data),
    body:    fillTemplate(INVITE_BODY, data),
    name: ORG_NAME, replyTo: CONTACT_MAIL
  });
}

// ===== 4. 受付確認メールの一括送信 =====
function sendAllInvitations() {
  const sheet = getSheet();
  const last  = sheet.getLastRow();
  if (last < 2) { SpreadsheetApp.getUi().alert("申込データがありません。"); return; }

  const rows = sheet.getRange(2, 1, last - 1, 9).getValues();
  let sent = 0, skipped = 0;

  rows.forEach(function (row, i) {
    const to = row[COL.mail - 1];
    if (!to || row[COL.sentAt - 1]) { skipped++; return; }

    const data = buildMailData(row);
    if (!data) { skipped++; return; }

    sendInvitation(to, data);
    sheet.getRange(i + 2, COL.sentAt).setValue(new Date());
    sent++;
  });

  SpreadsheetApp.getActiveSpreadsheet()
    .toast("送信 " + sent + " 件 / スキップ " + skipped + " 件", "📮 完了", 5);
}

// ===== 5. フォーム送信時の振り分け(トリガー:フォーム送信時) =====
// ★ トリガーにはこの関数だけを登録する
function onAnyFormSubmit(e) {
  if (!e || !e.range) return;
  const name = e.range.getSheet().getName();

  if (name === SHEET_NAME)         handleApplication(e);   // 申込フォーム
  else if (name === SURVEY_SHEET) updateDashboardSafely(); // アンケート
}

// 申込フォームが送信されたとき:受付確認メールを返す
function handleApplication(e) {
  const sheet = getSheet();
  const row = (e && e.range) ? e.range.getRow() : sheet.getLastRow();
  if (row < 2) return;

  const values = sheet.getRange(row, 1, 1, 9).getValues()[0];
  const to   = values[COL.mail - 1];
  const data = buildMailData(values);
  if (!to || !data || values[COL.sentAt - 1]) return;

  sendInvitation(to, data);
  sheet.getRange(row, COL.sentAt).setValue(new Date());
}

// ===== 6. カレンダー =====
function findSeminarEvent(date) {
  const events = CalendarApp.getDefaultCalendar().getEventsForDay(date);
  for (let i = 0; i < events.length; i++) {
    if (events[i].getTitle().indexOf(SEMINAR_TITLE) !== -1) return events[i];
  }
  return null;
}

function setupSeminarEvents() {
  const cal = CalendarApp.getDefaultCalendar();
  let made = 0;
  Object.keys(SCHEDULE).forEach(function (key) {
    const info = SCHEDULE[key];
    if (findSeminarEvent(info.start)) return;
    cal.createEvent(SEMINAR_TITLE, info.start, info.end, {
      description: "お問い合わせ:" + CONTACT_MAIL,
      location: info.place
    }).addPopupReminder(60);
    made++;
  });
  SpreadsheetApp.getActiveSpreadsheet().toast("開催予定を " + made + " 件作成", "📅 完了", 4);
}

function addGuestsToEvents() {
  const sheet = getSheet();
  const last  = sheet.getLastRow();
  if (last < 2) return;

  const rows = sheet.getRange(2, 1, last - 1, 9).getValues();
  let added = 0;

  rows.forEach(function (row, i) {
    if (row[COL.eventId - 1]) return;
    const to   = row[COL.mail - 1];
    const info = SCHEDULE[row[COL.date - 1]];
    if (!to || !info) return;

    const ev = findSeminarEvent(info.start);
    if (!ev) return;

    ev.addGuest(to);
    sheet.getRange(i + 2, COL.eventId).setValue("追加済み");
    added++;
  });
  SpreadsheetApp.getActiveSpreadsheet().toast("ゲストを " + added + " 名追加", "👥 完了", 4);
}

// ===== 7. 前日リマインド(トリガー:時間主導型 毎朝8時) =====
function sendReminderForTomorrow() {
  const tomorrow = new Date();
  tomorrow.setDate(tomorrow.getDate() + 1);

  const ev = findSeminarEvent(tomorrow);
  if (!ev) { Logger.log("明日は開催予定なし"); return; }

  const target = Utilities.formatDate(ev.getStartTime(), "Asia/Tokyo", "yyyy/MM/dd");
  const sheet  = getSheet();
  const last   = sheet.getLastRow();
  if (last < 2) return;

  const rows = sheet.getRange(2, 1, last - 1, 9).getValues();
  let sent = 0;

  rows.forEach(function (row) {
    const to   = row[COL.mail - 1];
    const info = SCHEDULE[row[COL.date - 1]];
    if (!to || !info) return;
    if (Utilities.formatDate(info.start, "Asia/Tokyo", "yyyy/MM/dd") !== target) return;

    const data = buildMailData(row);
    MailApp.sendEmail({
      to: to,
      subject: fillTemplate(REMIND_SUBJECT, data),
      body:    fillTemplate(REMIND_BODY, data),
      name: ORG_NAME, replyTo: CONTACT_MAIL
    });
    sent++;
  });
  Logger.log("リマインド送信: " + sent + " 件");
}

function createDailyTrigger() {
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === "sendReminderForTomorrow") ScriptApp.deleteTrigger(t);
  });
  ScriptApp.newTrigger("sendReminderForTomorrow").timeBased().everyDays(1).atHour(8).create();
  SpreadsheetApp.getActiveSpreadsheet().toast("毎朝8時台のトリガーを登録しました", "⏰ 完了", 5);
}

// ===== 8. アンケート集計ダッシュボード =====
function updateDashboardSafely() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(10000)) return; // 同時実行中なら今回はスキップ
  try { updateDashboard(); } finally { lock.releaseLock(); }
}

function updateDashboard() {
  const ss  = SpreadsheetApp.getActiveSpreadsheet();
  const src = ss.getSheetByName(SURVEY_SHEET);
  if (!src) return;
  const last = src.getLastRow();
  if (last < 2) return;

  const rows  = src.getRange(2, 1, last - 1, 7).getValues();
  const total = rows.length;

  let sumScore = 0, sumClear = 0, sumUseful = 0;
  const dist = [0, 0, 0, 0, 0], byDate = {}, byTheme = {}, comments = [];

  rows.forEach(function (r) {
    const score = Number(r[SUR.score - 1]);
    sumScore  += score;
    sumClear  += Number(r[SUR.clear - 1]);
    sumUseful += Number(r[SUR.useful - 1]);
    if (score >= 1 && score <= 5) dist[score - 1]++;

    const when = r[SUR.date - 1];
    if (when) {
      if (!byDate[when]) byDate[when] = { count: 0, sum: 0 };
      byDate[when].count++;
      byDate[when].sum += score;
    }
    const theme = r[SUR.theme - 1];
    if (theme) byTheme[theme] = (byTheme[theme] || 0) + 1;
    const c = r[SUR.comment - 1];
    if (c) comments.push(String(c));
  });

  const avg = function (s) { return Math.round((s / total) * 10) / 10; };
  const goodRate = Math.round(((dist[3] + dist[4]) / total) * 100);

  const t = [];
  // A列=項目名 / B列=表示用 / C列=グラフ用の数値
  t.push(["📊 " + SEMINAR_TITLE + " アンケート集計", "", ""]);
  t.push(["最終更新", Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm"), ""]);
  t.push(["", "", ""]);
  t.push(["回答数", total + " 件", total]);
  t.push(["総合満足度(5点満点)", avg(sumScore),  ""]);
  t.push(["内容の分かりやすさ",     avg(sumClear),  ""]);
  t.push(["業務への活用イメージ",   avg(sumUseful), ""]);
  t.push(["好意的回答率(4点以上)", goodRate + " %", goodRate]);

  t.push(["", "", ""]);
  t.push(["■ 満足度の分布", "バー", "件数"]);
  for (let s = 5; s >= 1; s--) {
    t.push(["★" + s, "█".repeat(dist[s - 1]), dist[s - 1]]);
  }

  t.push(["", "", ""]);
  t.push(["■ 日程別の平均満足度", "平均", "回答数"]);
  Object.keys(byDate).forEach(function (k) {
    const d = byDate[k];
    t.push([k, Math.round((d.sum / d.count) * 10) / 10, d.count]);
  });

  t.push(["", "", ""]);
  t.push(["■ 今後扱ってほしいテーマ(多い順)", "バー", "票数"]);
  Object.keys(byTheme)
    .sort(function (a, b) { return byTheme[b] - byTheme[a]; })
    .forEach(function (k) {
      t.push([k, "█".repeat(byTheme[k]), byTheme[k]]);
    });

  t.push(["", "", ""]);
  t.push(["■ 最新のご意見(新しい順に5件)", "", ""]);
  comments.slice(-5).reverse().forEach(function (c) { t.push(["・", c, ""]); });

  let dash = ss.getSheetByName(DASH_SHEET);
  if (!dash) dash = ss.insertSheet(DASH_SHEET);
  dash.clearContents();
  dash.getRange(1, 1, t.length, 3).setValues(t);
  dash.getRange(1, 1, 1, 3)
    .setFontWeight("bold").setFontSize(13)
    .setBackground("#1e293b").setFontColor("#ffffff");
  dash.getRange(5, 2, 3, 1).setNumberFormat("0.0");
  dash.setColumnWidth(1, 260);
  dash.setColumnWidth(2, 380);
  dash.setColumnWidth(3, 80);
  dash.setFrozenRows(2);
}

// ===== 9. 申込状況サマリー =====
function showSummary() {
  const ui = SpreadsheetApp.getUi();
  const sheet = getSheet();
  const last = sheet.getLastRow();
  if (last < 2) { ui.alert("申込データがありません。"); return; }

  const rows = sheet.getRange(2, 1, last - 1, 9).getValues();
  const count = {};
  Object.keys(SCHEDULE).forEach(function (k) {
    count[k] = { "会場": 0, "オンライン": 0 };
  });

  rows.forEach(function (row) {
    const key   = row[COL.date - 1];
    const style = row[COL.style - 1];
    if (count[key] && count[key][style] !== undefined) count[key][style]++;
  });

  let msg = "";
  Object.keys(count).forEach(function (k) {
    msg += k + "\n 会場 " + count[k]["会場"] + " 名 / オンライン " + count[k]["オンライン"] + " 名\n\n";
  });
  msg += "本日あと " + MailApp.getRemainingDailyQuota() + " 通メールを送れます。";

  ui.alert("📊 申込状況", msg, ui.ButtonSet.OK);
}

// ===== 10. 独自メニュー(ファイルを開くと自動で作られる) =====
function onOpen() {
  SpreadsheetApp.getUi().createMenu("📮 セミナー運営")
    .addItem("① 受付確認メールを一括送信", "sendAllInvitations")
    .addItem("② 開催予定をカレンダーに作成", "setupSeminarEvents")
    .addItem("③ 申込者をゲストに追加", "addGuestsToEvents")
    .addSeparator()
    .addItem("④ 明日のリマインドを今すぐ送る", "sendReminderForTomorrow")
    .addItem("⑤ 毎朝8時のトリガーを登録", "createDailyTrigger")
    .addSeparator()
    .addItem("📈 ダッシュボードを今すぐ更新", "updateDashboard")
    .addItem("📊 申込状況サマリー", "showSummary")
    .addToUi();
}
✓

これで「人が手を動かすのはフォーム入力だけ」の運用が完成しました。注目してほしいのは、同じ部品が何度も使い回されている点です。fillTemplate は受付確認にもリマインドにも、buildMailData は一括送信にもフォーム自動返信にも、findSeminarEvent は予定作成にもリマインド判定にも、updateDashboard はトリガーからもメニューからも呼ばれています。小さな部品に切り出して名前を付ける ── 第3回で学んだ「関数化」が、こういう規模になって効いてきます。

i

トリガーは2つだけ登録します。①フォーム送信時 → onAnyFormSubmit(申込・アンケートの両方のフォームがこの1つで処理されます。第7章の手順で登録し、関数名だけ変えてください)/②時間主導型・毎朝8時 → sendReminderForTomorrow(メニュー⑤を押せばコードから登録されます)。第7章で onFormSubmit のトリガーをすでに作っている場合は、それを削除してから登録し直してください(残しておくと二重に処理されます)。登録済みのトリガーは⏰画面で一覧・削除できます。動作確認が終わるまでは、宛先を自分のアドレスにしたままにしておきましょう。

13つまずきポイントと対処

複数サービスとトリガーが絡む今回は、つまずき方にも「型」があります。症状から原因を引ける形でまとめました。

① 「承認が必要です」で先に進めない

初回実行時、「このアプリは Google で確認されていません」という警告が出ます。これはあなた自身が書いたスクリプトに対する一般的な警告です。 画面左下の「詳細」→「(プロジェクト名)に移動(安全ではないページ)」→「許可」と進んでください。 なお、この画面はポップアップで出るため、ブラウザのポップアップブロックが有効だと表示されないことがあります。

② フォーム送信トリガーが動かない/エラーになる

症状原因と対処
手動実行で「e のプロパティを読めません」トリガー以外から呼ぶと e は空。第7章のガード付きの書き方にする
フォームを送っても何も起きないトリガーを登録していない。⏰画面で「フォーム送信時」を登録する
トリガーはあるのに動かないイベントのソースが「スプレッドシートから」になっているか確認
実行はされたがメールが来ない⏰画面の「実行数」タブでエラー内容を確認(失敗はここに残る)

③ 日付まわりがずれる

月は0始まり(new Date(2026, 8, 10) は9月10日)。そしてタイムゾーンにも注意です。 スクリプトエディタの「⚙ プロジェクトの設定」で、タイムゾーンが「(GMT+09:00) 日本標準時 – 東京」になっているか確認してください。 ここが海外時刻だと、カレンダーの予定が数時間ずれて作られます。

④ SCHEDULE[choice] が undefined になる

E列の文字列と日程マスタのキーが1文字でも違うと、その行は静かにスキップされます(エラーにならないので気づきにくい)。 疑わしいときは、ログに出して目で突き合わせましょう。

コード.gs ── キーの不一致を可視化する
function checkScheduleKeys() {
  const sheet = getSheet();
  const last = sheet.getLastRow();
  const rows = sheet.getRange(2, COL.date, last - 1, 1).getValues();

  rows.forEach(function (r, i) {
    const ok = SCHEDULE[r[0]] ? "OK " : "NG ";
    Logger.log(ok + (i + 2) + "行目: [" + r[0] + "]"); // [] で前後の空白も見える
  });
  Logger.log("マスタのキー: " + Object.keys(SCHEDULE).join(" / "));
}

⑤ メールが届かない

迷惑メールフォルダを必ず確認してください(自動送信メールは振り分けられがちです)。 それでも無ければ、D列のアドレスの綴り、H列に既に日時が入っていないか(送信済み扱いでスキップされている)、 1日の送信上限(MailApp.getRemainingDailyQuota() で確認)の順に切り分けます。

⑥ 同じメールが何通も届く

原因のほぼすべてはトリガーの二重登録です。⏰画面を開き、同じ関数のトリガーが複数ないか確認して、余分を削除してください。 createDailyTrigger のように「消してから作る」書き方にしておくと、この事故は起きません。

⑦ getSheetByName が null になる

フォームを紐づけると「フォームの回答 1」という名前のシートが作られます。設定 SHEET_NAME は "申込一覧" なので、 タブ名を変更するか、SHEET_NAME の方を実際のタブ名に合わせてください。全角・半角スペースの混入にも注意。

⑧ カレンダーに予定が二重に作られる

createEvent は何度でも作れてしまいます。第9章のように getEventsForDay() で先に探し、 無いときだけ作るようにしてください。すでに重複してしまった予定は、カレンダー画面から手で削除するのが早いです。

⑨ アンケートに回答した人に「お申込みを受け付けました」が届く

第11章の振り分けができていないサインです。原因は次のどちらかです。 ① トリガーの実行関数が onAnyFormSubmit ではなく古い onFormSubmit のままになっている。 ② 古いトリガーを削除せずに新しいトリガーを足したため、2つとも生きている。 ⏰トリガー画面を開き、フォーム送信時のトリガーが1つだけ・関数が onAnyFormSubmit になっているか確認してください。

⑩ アンケートに回答してもダッシュボードが更新されない

切り分けの順番はこうです。まずメニューから「📈 ダッシュボードを今すぐ更新」を手動実行してみてください。

手動実行の結果絞り込める原因
手動なら更新される集計ロジックは正常。トリガー側の問題(未登録/関数名違い/振り分けの条件ミス)
手動でも何も起きないSURVEY_SHEET のシート名が実際のタブ名と違う(「フォームの回答 2」のまま等)
手動でエラーになる「実行数」画面でエラー文を確認。列数・列位置のずれが多い

⑪ 平均点が NaN と表示される

NaN(Not a Number)は「数値として計算できなかった」印です。 アンケートの評価項目を「均等目盛」ではなく「記述式」で作ってしまうと、回答が文字列になり Number() で数値化できません。フォームの質問形式を見直すか、次のように数値でない回答を除外するガードを入れます。

コード.gs ── 数値でない回答を弾く
const score = Number(r[SUR.score - 1]);
if (isNaN(score)) return;   // 数値でなければこの行は集計しない
🔧 ChatGPT へのプロンプト ── トリガー系のデバッグ依頼
あなたはGoogle Apps Script(GAS)の専門家です。 初学者が書いた以下のコードが期待どおりに動きません。原因を特定し、 修正後のコード全文と「なぜ間違っていたか」を初学者にもわかる言葉で説明してください。 【やりたいこと】 スプレッドシート「申込一覧」にフォーム回答が追加されたら、 D列のメールアドレス宛に受付確認メールを自動送信したい。 【実行環境】 - スプレッドシートに紐づいたGASプロジェクト - トリガー:イベントのソース「スプレッドシートから」/種類「フォーム送信時」で登録済み - メール送信は MailApp を使用(Gmailの画面は使わない前提) 【症状】 (ここに具体的に書く。例:フォームを送信しても何も起きない/  実行数の画面に「TypeError: Cannot read properties of undefined」と出る) 【実行ログ・エラー文】 (ここにそのまま貼り付ける) 【いま動かしているコード】 (ここにコード全文を貼り付ける)
★

トリガーのデバッグは「実行数」画面から。自動で動く関数は、実行中の画面を見ていられません。スクリプトエディタ左の「実行数」(時計の隣のリストアイコン)を開くと、いつ・どの関数が・成功したか失敗したか、そして Logger.log() の中身がすべて残っています。自動化を作ったら、まずここを見る ── これがトリガー時代のデバッグの基本動作です。

HW

第5回の宿題

次回までに、以下の5つに取り組んでください。コードはAIに書かせてOKです。所要時間:90分程度
1

差し込みメールを自分宛に送る

第12章の完成版を貼り付け、申込一覧に3件以上(宛先はすべて自分のアドレス)を用意して、メニュー「① 受付確認メールを一括送信」を実行してください。もう一度実行して「送信 0 件」になること=重複送信が防げていることまで確認できたら合格です。

2

フォームを作り、自動返信を動かす

第6章の表のとおりにGoogleフォームを作成し、回答先を「セミナー運営」に紐づけます。フォーム送信時トリガーを登録し、実際にフォームから申し込んで受付確認メールが自動で届くことを確認してください。

3

カレンダー起点の前日リマインドを動かす

日程マスタ SCHEDULE に「明日の日付」の日程を1つ追加し、申込一覧のE列をその選択肢に変更。メニュー「② 開催予定をカレンダーに作成」→「④ 明日のリマインドを今すぐ送る」の順に実行し、リマインドが届くことを確認してください。さらに、カレンダーからその予定を削除して再実行し、今度は1通も送られないことも確かめましょう。

4

アンケート → ダッシュボードの自動更新を動かす

第11章の手順で2つ目のフォーム(アンケート)を作り、同じスプレッドシートに紐づけて「アンケート回答」シートにします。トリガーの実行関数を onAnyFormSubmit に登録し直したうえで、アンケートに3件以上回答してください。回答するたびに「ダッシュボード」シートの回答数・平均点・分布・最終更新時刻が変わることを確認しましょう。余力があれば、満足度の分布のセルを参照してグラフを1つ挿入し、回答追加後もグラフが自動で追従することまで確かめてください。

5

AIに「機能追加」を依頼する

次のいずれかの機能を、ChatGPTに追加依頼してみてください。①参加方法(会場/オンライン)で本文を出し分ける、②キャンセル用フォームを作り、回答があったら該当行に「キャンセル」と記録して以降の送信対象から外す、③申込が入るたびに、事務局宛に「◯◯様が申込(残席◯名)」と通知する、④開催翌朝に「お礼+アンケートURL」を参加者へ自動送信する(第10章のカレンダー判定を「昨日」に変えれば作れます)。うまく動かないときは、第13章のデバッグ用プロンプトを使ってください。

⚠

宿題を進めるときのお願い:メールの宛先は最後まで自分のアドレスにしてください。実在する同僚・取引先のアドレスは使わないこと。カレンダーのゲスト追加も、招待通知が飛ぶため自分のアドレスのみで試してください。

📩

提出方法:①作成したスプレッドシートのURL、②2つのフォーム(申込・アンケート)の公開URL、③最終的に動いたGASコード(コード.gs 全文)、④ダッシュボードシートのスクリーンショットの4点を、研修事務局へメールでご提出ください。提出期限:次回研修の前日17:00まで。