From 6f4167e7abf6cb8d68920e6f9740ce00ea0ffbf7 Mon Sep 17 00:00:00 2001 From: Christopher Mayor Date: Tue, 28 Apr 2026 21:40:54 -0700 Subject: [PATCH] [#5] Add results leaderboard tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 6th tab (πŸ† Results) shows ranked results across all categories - Medal icons πŸ₯‡πŸ₯ˆπŸ₯‰ for top 3 per category - Percentage bars with category colors - Accessible without voter name via ?view=results URL - Shareable link: no modal, shows live results --- public/index.html | 157 +++++++++++++++++++++++++++++++++++++++++++--- server.js | 3 +- 2 files changed, 152 insertions(+), 8 deletions(-) diff --git a/public/index.html b/public/index.html index 92ad49c..7633ff2 100644 --- a/public/index.html +++ b/public/index.html @@ -400,6 +400,86 @@ opacity: 0.7; } + /* ── Results Leaderboard ───────────────────────────────── */ + .results-header { + text-align: center; + margin-bottom: 16px; + } + .results-header h2 { font-size: 1.1rem; color: var(--accent); margin-bottom: 4px; } + .results-header p { font-size: 0.75rem; color: var(--text-muted); } + .results-category { + margin-bottom: 20px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + } + .results-category-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 10px; + font-size: 0.8rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + } + .results-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 0; + border-bottom: 1px solid var(--border); + } + .results-row:last-child { border-bottom: none; } + .results-rank { + font-size: 0.85rem; + font-weight: 700; + width: 28px; + text-align: center; + flex-shrink: 0; + } + .results-rank.gold { color: #ffd700; } + .results-rank.silver { color: #c0c0c0; } + .results-rank.bronze { color: #cd7f32; } + .results-name { + flex: 1; + font-size: 0.82rem; + color: var(--text); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .results-name.winner { + color: var(--accent); + font-weight: 700; + } + .results-votes { + font-size: 0.75rem; + color: var(--text-muted); + flex-shrink: 0; + } + .results-bar-bg { + flex: 2; + height: 4px; + background: var(--surface2); + border-radius: 2px; + overflow: hidden; + } + .results-bar-fill { + height: 100%; + border-radius: 2px; + transition: width 0.6s cubic-bezier(0.4,0,0.2,1); + } + .results-share { + text-align: center; + margin-top: 16px; + font-size: 0.72rem; + color: var(--text-muted); + } + /* ── Toast ──────────────────────────────────────────────── */ .toast { position: fixed; @@ -629,17 +709,32 @@ // ── Init ─────────────────────────────────────────────────── function init() { + // Check for ?view=results URL param β€” skip name modal, go to results + const params = new URLSearchParams(location.search); + const viewResults = params.get('view') === 'results'; + if (state.voterName) { applyVoterName(state.voterName); } + + // If view=results, skip name modal and go to results tab + if (viewResults) { + activeTab = 'results'; + document.getElementById('nameModal').classList.add('hidden'); + } + connectWS(); renderTabs(); - document.getElementById('voterNameInput').addEventListener('keydown', e => { - if (e.key === 'Enter') submitName(); - }); - document.getElementById('voterNameInput').focus(); + render(); - // Number keys 1-5 to switch tabs + if (!viewResults) { + document.getElementById('voterNameInput').addEventListener('keydown', e => { + if (e.key === 'Enter') submitName(); + }); + document.getElementById('voterNameInput').focus(); + } + + // Number keys 1-6 to switch tabs document.addEventListener('keydown', e => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; const num = parseInt(e.key); @@ -749,7 +844,7 @@ onkeydown="handleTabKey(event, '${cat.id}')"> ${cat.emoji} ${cat.name} - ${state.options.filter(o => o.categoryId === cat.id).length} + ${cat.id === 'results' ? '' : state.options.filter(o => o.categoryId === cat.id).length} `).join(''); bar.setAttribute('role', 'tablist'); @@ -782,7 +877,54 @@ // ── Render options ──────────────────────────────────────── function render() { const list = document.getElementById('optionsList'); - const opts = state.options.filter(o => o.categoryId === activeTab); + + // ── Results tab ────────────────────────────────────────── + if (activeTab === 'results') { + const votingCats = state.categories.filter(c => c.id !== 'results'); + const totalVotes = state.options.reduce((sum, o) => sum + o.votes.length, 0); + const statusText = state.pollsOpen + ? `🏈 ${state.totalVoters} voter${state.totalVoters !== 1 ? 's' : ''} Β· ${totalVotes} total votes Β· Polls OPEN` + : `πŸ† ${state.totalVoters} voter${state.totalVoters !== 1 ? 's' : ''} Β· ${totalVotes} total votes Β· Polls CLOSED`; + + list.innerHTML = ` +
+

πŸ† Final Results

+

${statusText}

+
+ ${votingCats.map(cat => { + const catOpts = state.options.filter(o => o.categoryId === cat.id && o.approved); + const sorted = [...catOpts].sort((a, b) => b.votes.length - a.votes.length); + const maxVotes = sorted[0]?.votes.length || 1; + if (sorted.length === 0) return ''; + return ` +
+
${cat.emoji} ${cat.name}
+ ${sorted.map((opt, i) => { + const pct = maxVotes > 0 ? (opt.votes.length / maxVotes * 100) : 0; + const rank = i + 1; + const medal = rank === 1 ? 'πŸ₯‡' : rank === 2 ? 'πŸ₯ˆ' : rank === 3 ? 'πŸ₯‰' : rank; + const medalClass = rank === 1 ? 'gold' : rank === 2 ? 'silver' : rank === 3 ? 'bronze' : ''; + const winner = rank === 1 ? 'winner' : ''; + const barColor = cat.id === 'hotel' ? 'var(--hotel)' : cat.id === 'golf' ? 'var(--golf)' : cat.id === 'nightlife' ? 'var(--nightlife)' : cat.id === 'excursion' ? 'var(--excursion)' : 'var(--itinerary)'; + return ` +
+
${medal}
+
${opt.name}
+
${opt.votes.length}
+
+
+
+
`; + }).join('')} +
`; + }).join('')} +
Share this URL to show live results without voting
+ `; + return; + } + + // ── Regular voting tabs ───────────────────────────────── + const opts = state.options.filter(o => o.categoryId === activeTab && o.approved); document.getElementById('totalVotersCount').textContent = state.totalVoters ? `πŸ‘₯ ${state.totalVoters} voter${state.totalVoters !== 1 ? 's' : ''}` : ''; @@ -824,6 +966,7 @@ // ── Voting ──────────────────────────────────────────────── function toggleVote(optionId) { + if (activeTab === 'results') return; // no voting on results tab if (!state.voterName) { document.getElementById('nameModal').classList.remove('hidden'); document.getElementById('voterNameInput').focus(); diff --git a/server.js b/server.js index 490ca94..548e90b 100644 --- a/server.js +++ b/server.js @@ -49,7 +49,8 @@ function buildSeedData() { { id: 'golf', name: 'Golf', emoji: 'β›³' }, { id: 'nightlife', name: 'Nightlife', emoji: '🎧' }, { id: 'excursion', name: 'Excursions', emoji: '🚀' }, - { id: 'itinerary', name: 'Full Itineraries', emoji: 'πŸ—ΊοΈ' }, + { id: 'itinerary', name: 'Itineraries', emoji: 'πŸ—ΊοΈ' }, + { id: 'results', name: 'Results', emoji: 'πŸ†' }, ], options: [ // Hotels