/** * Best Team Effort Grand Prix — live audience voting with a toy-car race dashboard. * * SETUP (about 5 minutes) * 1. Create a new Google Sheet → Extensions → Apps Script. * 2. Paste this file into Code.gs. Add two HTML files named exactly "Dashboard" and "Vote" * (File → + → HTML) and paste Dashboard.html and Vote.html into them. * 3. Run setup() once (approve permissions). It creates the Config, Teams and Votes tabs. * 4. Edit the Teams tab (team names + optional hex colours) and the Admin PIN in Config. * 5. Deploy → New deployment → Web app. Execute as: Me. Who has access: Anyone. Deploy. * 6. Open the web app URL on the projector = live dashboard. The QR on the dashboard * opens ?page=vote on phones. Press Start voting (no PIN needed). */ const TAB = { CONFIG: 'Config', TEAMS: 'Teams', VOTES: 'Votes' }; const PALETTE = ['#ff7a3d', '#45b4ff', '#2ec4a6', '#f25ca2', '#a78bfa', '#f2d64b', '#ef5350', '#d9e1ec']; function doGet(e) { const page = (e && e.parameter && e.parameter.page) || 'dashboard'; const isVote = page === 'vote'; return HtmlService.createHtmlOutputFromFile(isVote ? 'Vote' : 'Dashboard') .setTitle(isVote ? 'Vote for Best Team Effort' : 'Best Team Effort Grand Prix') .addMetaTag('viewport', 'width=device-width, initial-scale=1, viewport-fit=cover') .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); } function setup() { const ss = SpreadsheetApp.getActive(); const c = ss.getSheetByName(TAB.CONFIG) || ss.insertSheet(TAB.CONFIG); if (c.getLastRow() === 0) { c.getRange(1, 1, 6, 2).setValues([ ['Key', 'Value'], ['Event title', 'Best Team Effort Grand Prix'], ['Admin PIN', '2468'], ['Voting open', 'NO'], ['Finish line votes', 5], // votes needed to reach the chequered flag (the track rescales if a team passes it) ['Max votes', 22] // voting closes automatically when this many votes are in ]); } const t = ss.getSheetByName(TAB.TEAMS) || ss.insertSheet(TAB.TEAMS); if (t.getLastRow() === 0) { t.getRange(1, 1, 6, 2).setValues([ ['Team', 'Colour (optional hex)'], ['Team 1', '#ff7a3d'], ['Team 2', '#45b4ff'], ['Team 3', '#2ec4a6'], ['Team 4', '#f25ca2'], ['Team 5', '#a78bfa'] ]); } const v = ss.getSheetByName(TAB.VOTES) || ss.insertSheet(TAB.VOTES); if (v.getLastRow() === 0) v.getRange(1, 1, 1, 3).setValues([['Timestamp', 'Team', 'Device']]); } /* ---------- helpers ---------- */ function cfg_() { const rows = SpreadsheetApp.getActive().getSheetByName(TAB.CONFIG).getDataRange().getValues(); const out = {}; rows.slice(1).forEach(r => { if (r[0]) out[String(r[0]).trim()] = r[1]; }); return out; } function setCfg_(key, val) { const sh = SpreadsheetApp.getActive().getSheetByName(TAB.CONFIG); const rows = sh.getDataRange().getValues(); for (let i = 1; i < rows.length; i++) if (String(rows[i][0]).trim() === key) { sh.getRange(i + 1, 2).setValue(val); return; } sh.appendRow([key, val]); } function isOpen_(c) { return ['YES', 'TRUE', 'OPEN'].indexOf(String(c['Voting open']).toUpperCase()) > -1; } function teams_() { const rows = SpreadsheetApp.getActive().getSheetByName(TAB.TEAMS).getDataRange().getValues().slice(1); return rows.filter(r => String(r[0]).trim()) .map((r, i) => ({ name: String(r[0]).trim(), color: /^#[0-9a-f]{6}$/i.test(String(r[1]).trim()) ? String(r[1]).trim() : PALETTE[i % PALETTE.length] })); } function checkPin_(pin) { if (String(pin).trim() !== String(cfg_()['Admin PIN']).trim()) throw new Error('Wrong PIN. Check the Admin PIN in the Config tab.'); } /* ---------- called from the pages ---------- */ function getConfig() { const c = cfg_(); return { title: c['Event title'] || 'Best Team Effort Grand Prix', teams: teams_(), open: isOpen_(c), target: Number(c['Finish line votes']) || 20, voteUrl: ScriptApp.getService().getUrl() + '?page=vote' }; } function getResults() { const rows = SpreadsheetApp.getActive().getSheetByName(TAB.VOTES).getDataRange().getValues().slice(1).filter(r => r[1] !== ''); const counts = {}; teams_().forEach(t => counts[t.name] = 0); rows.forEach(r => { if (r[1] in counts) counts[r[1]]++; }); const recent = rows.slice(-8).reverse().map(r => ({ team: r[1], t: new Date(r[0]).getTime() })); return { counts: counts, total: rows.length, open: isOpen_(cfg_()), recent: recent }; } function submitVote(team, device) { const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const c = cfg_(); if (!isOpen_(c)) return { ok: false, reason: 'closed' }; if (!teams_().some(t => t.name === team)) return { ok: false, reason: 'team' }; const sh = SpreadsheetApp.getActive().getSheetByName(TAB.VOTES); const rows = sh.getDataRange().getValues().slice(1).filter(r => r[1] !== ''); const prior = rows.find(r => String(r[2]) === String(device)); if (prior) return { ok: false, reason: 'already', team: prior[1] }; const max = Number(c['Max votes']) || 0; // 0 or blank = no limit if (max && rows.length >= max) { setCfg_('Voting open', 'NO'); return { ok: false, reason: 'closed' }; } sh.appendRow([new Date(), team, String(device)]); if (max && rows.length + 1 >= max) setCfg_('Voting open', 'NO'); // last vote in → close automatically return { ok: true }; } finally { lock.releaseLock(); } } function setVoting(open) { setCfg_('Voting open', open ? 'YES' : 'NO'); return { open: !!open }; } function resetVotes() { const sh = SpreadsheetApp.getActive().getSheetByName(TAB.VOTES); if (sh.getLastRow() > 1) sh.getRange(2, 1, sh.getLastRow() - 1, 3).clearContent(); setCfg_('Voting open', 'NO'); return true; }