見出し画像

#71「チームのリアルタイム投票アプリ」— Chart.jsで棒グラフ、ポーリングで集計をリアルタイム更新

佐賀県唐津市、合同会社パーチのやすけんです。

GAS 100本ノック、今回は #71「チームのリアルタイム投票アプリを作ろう」 に挑戦しました。

チームのランチ場所やイベント日程を多数決で決めるとき、わざわざフォームを作るほどでもない...そんな場面で使えるシンプルな投票アプリをGASで作りました。スプレッドシートに投票を記録してChart.jsで棒グラフ表示、重複投票防止と締め切り機能も付けています。


今回のお題

達成条件

  • 選択肢に投票でき、結果がChart.jsの棒グラフでリアルタイムに更新される

  • 締め切り機能付き

ヒント

  • 投票テーマ・選択肢はシートで管理

  • 5秒ごとにポーリングで最新集計を取得

  • Chart.js CDNで棒グラフ描画

  • 重複投票防止: メールアドレスで管理


完成形はこんなアプリ

  • 青紫グラデーションのクリーンなデザイン

  • 投票テーマ・選択肢はスプレッドシートで管理(シートを編集すれば次の投票にすぐ使える)

  • メールアドレス入力 + ラジオボタンで投票

  • Chart.js 棒グラフで集計をリアルタイム表示

  • 「今すぐ更新」ボタン + 15秒カウントダウンで自動更新

  • 「投票を締め切る」ボタンで受付終了


ポーリングの実装について正直な話

お題には「5秒ごとにポーリング」とありましたが、GASの実態に合わせて 15秒間隔 に変更しました。

理由はGAS Webアプリの実行コスト。`google.script.run` でサーバー関数を呼び出すと、スプレッドシートのAPIアクセスを含めて 1〜3秒 程度かかります。5秒間隔だと前のリクエストが終わる前に次が発火してしまうケースがあり、UXが崩れます。

かといって、完全に手動更新だけにすると「リアルタイム感」がなくなります。そこで

  • 自動更新: 15秒カウントダウン + `setInterval` で自動更新

  • 手動更新: 「今すぐ更新」ボタンでいつでも最新を取得

この組み合わせで「遅延はあるが自動で追従する」体験を実現しています。


ステップ1: データ設計(3シート構造)

スプレッドシートに3枚のシートを使います。

投票テーマシート(投票テーマ)

  • 列A: テーマID

  • 列B: テーマ名

  • 列C: 締め切り日時

  • 列D: ステータス(open / closed)

選択肢シート(選択肢)

  • 列A: テーマID

  • 列B: 選択肢ID

  • 列C: 選択肢名

投票シート(投票)

  • 列A: テーマID

  • 列B: 選択肢ID

  • 列C: メールアドレス

  • 列D: 投票日時

テーマIDで3シートを紐付けることで、投票テーマを追加するだけで次回の投票に使えます。


ステップ2: 重複投票防止

function vote(themeId, choiceId, email) {
  if (!email || !themeId || !choiceId) return { error: '入力内容が不足しています。' };

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) return { error: 'メールアドレスの形式が正しくありません。' };

  // (中略: 締め切りチェック)

  // 重複投票チェック(大文字小文字を無視)
  const votesSheet = ss.getSheetByName(VOTES_SHEET);
  if (votesSheet.getLastRow() > 1) {
    const votes = votesSheet.getRange(2, 1, votesSheet.getLastRow() - 1, 3).getValues();
    const alreadyVoted = votes.some(r =>
      String(r[0]) === themeId && String(r[2]).toLowerCase() === email.toLowerCase()
    );
    if (alreadyVoted) return { error: 'このメールアドレスはすでに投票済みです。' };
  }

  votesSheet.appendRow([themeId, choiceId, email.toLowerCase(), new Date()]);
  return { success: true };
}

重複チェックは投票シートをスキャンしてメールアドレスの一致を確認するだけです。大文字小文字の違いを無視するため、保存時も `email.toLowerCase()` で統一しています。

本格的なシステムなら認証を使うべきですが、チーム内で使うツールなら「メールアドレス一致」で十分です。


ステップ3: 集計してChart.jsに渡す

function getResults(themeId) {
  // 選択肢ごとのカウントを初期化
  const counts = {};
  choices.forEach(c => { counts[String(c[1])] = 0; });

  // 投票を集計
  const votes = votesSheet.getRange(2, 1, votesSheet.getLastRow() - 1, 2).getValues();
  votes
    .filter(r => String(r[0]) === themeId)
    .forEach(r => {
      const cid = String(r[1]);
      if (counts[cid] !== undefined) counts[cid]++;
    });

  return {
    labels: choices.map(c => String(c[2])),
    data: choices.map(c => counts[String(c[1])]),
    total: data.reduce((s, v) => s + v, 0)
  };
}

集計ロジックはシンプルです。選択肢ごとのカウンターを先に初期化し、投票シートをスキャンしてカウントアップするだけ。Chart.jsが期待する `labels`(選択肢名の配列)と `data`(票数の配列)をそのまま返すので、クライアント側での変換も不要です。


ステップ4: Chart.jsで棒グラフを描画

chart = new Chart(ctx, {
  type: 'bar',
  data: {
    labels: res.labels,
    datasets: [{
      label: '投票数',
      data: res.data,
      backgroundColor: bgColors,
      borderColor: colors,
      borderWidth: 2,
      borderRadius: 8,       // 角丸バー
      borderSkipped: false,
    }]
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: { display: false }
    },
    scales: {
      y: {
        beginAtZero: true,
        ticks: { stepSize: 1, precision: 0 }  // 整数のみ表示
      }
    },
    animation: { duration: 600, easing: 'easeInOutQuart' }
  }
});

チャートが存在する場合は `chart.update()` で差分更新、初回だけ `new Chart()` で生成します。この切り替えをしないと、更新のたびにcanvasが増殖します。

`ticks: { precision: 0 }` で y軸を整数のみにするのがポイントです。票数なので「1.5票」は表示したくないですね。


ステップ5: 15秒カウントダウン + 自動ポーリング

function startCountdown() {
  document.getElementById('countdownArea').style.display = 'flex';
  countdownVal = REFRESH_INTERVAL; // 15秒

  clearInterval(countdownTimer);
  countdownTimer = setInterval(() => {
    countdownVal--;
    document.getElementById('countdownVal').textContent = countdownVal;
    if (countdownVal <= 0) {
      countdownVal = REFRESH_INTERVAL;
      loadResults();
    }
  }, 1000);
}

`setInterval` で1秒ごとにカウントダウン表示を更新し、0になったら `loadResults()` を呼びます。カウントダウンを画面に表示することで「もうすぐ更新される」ことがユーザーに伝わり、「本当に動いているのか?」という不安を減らせます。

手動更新ボタンを押したときはカウンターをリセットして即座に集計を取得します。

締め切り後は `clearInterval(countdownTimer)` で止めます。`setInterval` を止めないとバックグラウンドで不要なリクエストが走り続けるので必须です。


ステップ6: 締め切り機能

function closeVote(themeId) {
  const themesSheet = getSpreadsheet().getSheetByName(THEMES_SHEET);
  const themes = themesSheet.getRange(2, 1, themesSheet.getLastRow() - 1, 4).getValues();
  for (let i = 0; i < themes.length; i++) {
    if (String(themes[i][0]) === themeId) {
      themesSheet.getRange(i + 2, 4).setValue('closed');  // D列のステータスをclosedに
      return { success: true };
    }
  }
}

締め切りは「ステータス列を `closed` に書き換えるだけ」のシンプルな実装です。`getActiveVote()` は `status === 'open'` のテーマしか返さないので、`closed` にすれば自動的に投票フォームが非表示になります。

綺麗なUIで表示できました

正直にいうと、このチャートで表示できる機能は他にも応用したいくらい楽しい機能でした。見た目がいいので、社内のちょっとしたアプリ制作にもってこいです。


コード全体

code.gs(サーバーサイド)

const THEMES_SHEET = '投票テーマ';
const CHOICES_SHEET = '選択肢';
const VOTES_SHEET = '投票';

function getSpreadsheet() {
  const props = PropertiesService.getScriptProperties();
  let ssId = props.getProperty('SPREADSHEET_ID');
  if (ssId) {
    try { return SpreadsheetApp.openById(ssId); } catch (e) {}
  }
  const ss = SpreadsheetApp.create('チーム投票アプリ - データ');
  props.setProperty('SPREADSHEET_ID', ss.getId());
  return ss;
}

function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('チームリアルタイム投票')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

function knock071Setup() {
  const ss = getSpreadsheet();
  // 投票テーマシート
  let themesSheet = ss.getSheetByName(THEMES_SHEET);
  if (!themesSheet) themesSheet = ss.insertSheet(THEMES_SHEET);
  themesSheet.clearContents();
  themesSheet.getRange('A1:D1').setValues([['テーマID', 'テーマ名', '締め切り', 'ステータス']]);
  const deadline = new Date();
  deadline.setDate(deadline.getDate() + 7);
  themesSheet.getRange(2, 1, 1, 4).setValues([
    ['theme001', '次回チームランチの場所はどこがいい?', deadline, 'open']
  ]);
  // 選択肢シート
  let choicesSheet = ss.getSheetByName(CHOICES_SHEET);
  if (!choicesSheet) choicesSheet = ss.insertSheet(CHOICES_SHEET);
  choicesSheet.clearContents();
  choicesSheet.getRange('A1:C1').setValues([['テーマID', '選択肢ID', '選択肢名']]);
  choicesSheet.getRange(2, 1, 4, 3).setValues([
    ['theme001', 'c001', '和食'],
    ['theme001', 'c002', '洋食'],
    ['theme001', 'c003', 'イタリアン'],
    ['theme001', 'c004', '中華'],
  ]);
  // 投票シート
  let votesSheet = ss.getSheetByName(VOTES_SHEET);
  if (!votesSheet) votesSheet = ss.insertSheet(VOTES_SHEET);
  votesSheet.clearContents();
  votesSheet.getRange('A1:D1').setValues([['テーマID', '選択肢ID', 'メールアドレス', '投票日時']]);
  return 'セットアップ完了';
}

function getActiveVote() {
  const ss = getSpreadsheet();
  const themesSheet = ss.getSheetByName(THEMES_SHEET);
  if (!themesSheet || themesSheet.getLastRow() <= 1) return { error: 'knock071Setup() を実行してください。' };
  const themes = themesSheet.getRange(2, 1, themesSheet.getLastRow() - 1, 4).getValues();
  const now = new Date();
  let activeTheme = null;
  for (const row of themes) {
    if (String(row[3]) === 'open' && row[2] && new Date(row[2]) > now) {
      activeTheme = {
        id: String(row[0]), name: String(row[1]),
        deadline: Utilities.formatDate(new Date(row[2]), 'Asia/Tokyo', 'yyyy-MM-dd HH:mm'),
        status: 'open'
      };
      break;
    }
  }
  if (!activeTheme) {
    for (let i = themes.length - 1; i >= 0; i--) {
      if (themes[i][0]) {
        return { status: 'closed', theme: { id: String(themes[i][0]), name: String(themes[i][1]) }, choices: [] };
      }
    }
    return { status: 'none' };
  }
  const choicesSheet = ss.getSheetByName(CHOICES_SHEET);
  const allChoices = choicesSheet.getRange(2, 1, choicesSheet.getLastRow() - 1, 3).getValues();
  const choices = allChoices.filter(r => String(r[0]) === activeTheme.id)
    .map(r => ({ id: String(r[1]), name: String(r[2]) }));
  return { status: 'open', theme: activeTheme, choices };
}

function vote(themeId, choiceId, email) {
  if (!email || !themeId || !choiceId) return { error: '入力内容が不足しています。' };
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) return { error: 'メールアドレスの形式が正しくありません。' };
  const ss = getSpreadsheet();
  const themesSheet = ss.getSheetByName(THEMES_SHEET);
  const themes = themesSheet.getRange(2, 1, themesSheet.getLastRow() - 1, 4).getValues();
  const theme = themes.find(r => String(r[0]) === themeId);
  if (!theme) return { error: '投票テーマが見つかりません。' };
  if (String(theme[3]) !== 'open') return { error: 'この投票は締め切られています。' };
  if (theme[2] && new Date(theme[2]) < new Date()) return { error: '投票の締め切り日時を過ぎています。' };
  const votesSheet = ss.getSheetByName(VOTES_SHEET);
  if (votesSheet.getLastRow() > 1) {
    const votes = votesSheet.getRange(2, 1, votesSheet.getLastRow() - 1, 3).getValues();
    if (votes.some(r => String(r[0]) === themeId && String(r[2]).toLowerCase() === email.toLowerCase())) {
      return { error: 'このメールアドレスはすでに投票済みです。' };
    }
  }
  votesSheet.appendRow([themeId, choiceId, email.toLowerCase(), new Date()]);
  return { success: true };
}

function getResults(themeId) {
  const ss = getSpreadsheet();
  const choicesSheet = ss.getSheetByName(CHOICES_SHEET);
  if (!choicesSheet || choicesSheet.getLastRow() <= 1) return { labels: [], data: [], total: 0 };
  const choices = choicesSheet.getRange(2, 1, choicesSheet.getLastRow() - 1, 3).getValues()
    .filter(r => String(r[0]) === themeId);
  const counts = {};
  choices.forEach(c => { counts[String(c[1])] = 0; });
  const votesSheet = ss.getSheetByName(VOTES_SHEET);
  if (votesSheet && votesSheet.getLastRow() > 1) {
    votesSheet.getRange(2, 1, votesSheet.getLastRow() - 1, 2).getValues()
      .filter(r => String(r[0]) === themeId)
      .forEach(r => { if (counts[String(r[1])] !== undefined) counts[String(r[1])]++; });
  }
  const labels = choices.map(c => String(c[2]));
  const data = choices.map(c => counts[String(c[1])]);
  return { labels, data, total: data.reduce((s, v) => s + v, 0) };
}

function closeVote(themeId) {
  const ss = getSpreadsheet();
  const themesSheet = ss.getSheetByName(THEMES_SHEET);
  if (!themesSheet || themesSheet.getLastRow() <= 1) return { error: 'データがありません。' };
  const themes = themesSheet.getRange(2, 1, themesSheet.getLastRow() - 1, 4).getValues();
  for (let i = 0; i < themes.length; i++) {
    if (String(themes[i][0]) === themeId) {
      themesSheet.getRange(i + 2, 4).setValue('closed');
      return { success: true };
    }
  }
  return { error: 'テーマが見つかりません。' };
}

デプロイ手順

  1. GASエディタで `knock071Setup()` を手動実行 → スプレッドシートが自動作成される

  2. 「デプロイ」→「新しいデプロイ」→ 種類: ウェブアプリ

  3. 実行ユーザー: 自分 / アクセス: 全員(または会社内)

  4. デプロイ後URLをチームに共有

  5. 各自がメールアドレスを入力して投票!


学んだこと・ポイントまとめ

  • GASのポーリングは5秒では厳しい: `google.script.run` は1〜3秒かかるため、15秒間隔に変更。カウントダウン表示でユーザーに待ち時間を伝えると体験が良くなる

  • Chart.jsの更新は `chart.update()` で差分更新: 毎回 `new Chart()` するとcanvasが重複する。初回のみ生成し、以降は `.data` を書き換えて `.update()` を呼ぶ

  • メールアドレスは小文字で統一: `email.toLowerCase()` で保存し、チェックも同様に変換することで大文字小文字の違いによる重複見逃しを防ぐ

  • テーマIDで3シートを紐付ける: シートを追加するだけで次の投票に使いまわせる。ステータス列を `closed` に変えるだけで締め切れるシンプルな設計

  • setIntervalは必ずclearIntervalとセットで: 締め切り後や画面離脱時に止めないとバックグラウンドでリクエストが走り続ける


まとめ

今回は「チームリアルタイム投票アプリ」を通して、Chart.jsによる棒グラフ描画、GASに合わせたポーリング設計(15秒カウントダウン)、メールアドレスによる重複投票防止、締め切り機能を実装しました。

「5秒ポーリング」というお題でしたが、GASの実行コストを考えて15秒に変更したのが今回の学びです。技術的な制約をちゃんと理解して、ユーザー体験を損なわない設計に落とし込むのも大事なスキルだと実感しました。

テーマと選択肢をシートで管理しているので、次回の投票も内容を差し替えるだけで使い回せます。ちょっとした社内決め事に活用してみてください!

次回はいよいよ #72 に挑戦します。お楽しみに!


いいなと思ったら応援しよう!