From 5f1b8607fd0e663ad01533353271f0412c118438 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 19:22:51 +0000 Subject: [PATCH 1/4] Initial plan From 87ef26f5d581e382324941cd806ab1c61f9a57dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 19:27:15 +0000 Subject: [PATCH 2/4] feat: add fair mode (pseudo-random) to picker Adds a per-topic "Fair mode" toggle that ensures each user is picked before any user is repeated. When enabled, entries attributed to already-picked users are excluded from the candidate pool until every user with entries in the topic has been selected, then the round resets. - New fairMode and fairModePickedUserIds fields in topic data - pickRandom respects fair mode filtering - recordPick tracks picked users and resets when round completes - UI checkbox toggle in picker card - State persisted in localStorage - DECISIONS.md updated with rationale Agent-Logs-Url: https://github.com/K-Cully/random-picker/sessions/f269db2d-b428-46c1-a8a3-2b4f925e4ec9 Co-authored-by: K-Cully <2370032+K-Cully@users.noreply.github.com> --- DECISIONS.md | 15 ++++++++++++ css/styles.css | 23 ++++++++++++++++++ js/app.js | 65 ++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index a84989e..7d78665 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -103,3 +103,18 @@ _Date: 2026-05-06_ **Replaced stacked sidebar sections with a tab-based interface (Topics / Users)** Rationale: The previous layout stacked the Topics and Users panels vertically within a fixed-width sidebar. When both panels had content the Users section would overlap or compress the Topics list, making the UI confusing. A tab interface shows one panel at a time, eliminates the overlap issue, and gives each panel the full vertical space of the sidebar. Tabs use ARIA `role="tablist"` / `role="tab"` / `role="tabpanel"` for accessibility. + +## Fair Mode (Pseudo-Random Selection) + +_Date: 2026-05-07_ + +**Added per-topic "Fair mode" toggle that ensures each user is selected before any user is repeated.** + +Rationale: Pure random selection can lead to unfair distribution in small groups — one user may be picked repeatedly while others are skipped. Fair mode addresses this by tracking which users have already been picked in the current round. Entries attributed to already-picked users are excluded from the candidate pool until every user with entries in the topic has been selected. Once all users have been picked, the round resets automatically. + +Implementation details: +- Each topic stores `fairMode` (boolean) and `fairModePickedUserIds` (array of user IDs already picked this round). +- Entries with no user attribution (`userId: null`) are always eligible regardless of fair mode state. +- The round resets when all distinct user IDs present in the topic's entries have been picked. +- Toggling fair mode off clears the tracking array. +- State is persisted in localStorage alongside existing topic data. diff --git a/css/styles.css b/css/styles.css index 1d41194..556b93f 100644 --- a/css/styles.css +++ b/css/styles.css @@ -769,6 +769,29 @@ body { display: flex; gap: 10px; justify-content: center; + align-items: center; +} + +/* ---------- Fair Mode Toggle ---------- */ +.fair-mode-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 0.85rem; + color: var(--clr-text-muted); + user-select: none; +} + +.fair-mode-toggle input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--clr-primary); + cursor: pointer; +} + +.fair-mode-label { + white-space: nowrap; } /* ---------- Spin Animation ---------- */ diff --git a/js/app.js b/js/app.js index 7c32e2e..1bd28cf 100644 --- a/js/app.js +++ b/js/app.js @@ -76,6 +76,10 @@ const Storage = { typeof p.text === 'string' && typeof p.timestamp === 'number') : [], + fairMode: !!value.fairMode, + fairModePickedUserIds: Array.isArray(value.fairModePickedUserIds) + ? value.fairModePickedUserIds.filter(id => typeof id === 'string') + : [], }; } } @@ -118,7 +122,7 @@ const App = (() => { t => t.toLowerCase() === key ); if (existing) return { ok: false, msg: `"${existing}" already exists.` }; - state.topics[trimmed] = { entries: [], picks: [] }; + state.topics[trimmed] = { entries: [], picks: [], fairMode: false, fairModePickedUserIds: [] }; persist(); return { ok: true, topic: trimmed }; } @@ -179,7 +183,22 @@ const App = (() => { function pickRandom(topicName) { const topic = state.topics[topicName]; if (!topic || topic.entries.length === 0) return null; - return topic.entries[Math.floor(Math.random() * topic.entries.length)]; + + if (!topic.fairMode) { + return topic.entries[Math.floor(Math.random() * topic.entries.length)]; + } + + /* Fair mode: exclude entries whose userId is already picked this round */ + const pickedIds = topic.fairModePickedUserIds || []; + let eligible = topic.entries.filter(e => !e.userId || !pickedIds.includes(e.userId)); + + /* If no eligible entries remain (all users picked), reset the round */ + if (eligible.length === 0) { + topic.fairModePickedUserIds = []; + eligible = topic.entries; + } + + return eligible[Math.floor(Math.random() * eligible.length)]; } function recordPick(topicName, entry) { @@ -191,9 +210,41 @@ const App = (() => { if (topic.picks.length > MAX_PICKS_PER_TOPIC) { topic.picks.splice(0, topic.picks.length - MAX_PICKS_PER_TOPIC); } + + /* Fair mode: track the user who was just picked */ + if (topic.fairMode && entry.userId) { + if (!topic.fairModePickedUserIds) topic.fairModePickedUserIds = []; + if (!topic.fairModePickedUserIds.includes(entry.userId)) { + topic.fairModePickedUserIds.push(entry.userId); + } + /* Check if all users with entries have been picked – if so, reset */ + const userIdsInTopic = [...new Set( + topic.entries.map(e => e.userId).filter(Boolean) + )]; + if (userIdsInTopic.length > 0 && userIdsInTopic.every(id => topic.fairModePickedUserIds.includes(id))) { + topic.fairModePickedUserIds = []; + } + } + persist(); } + /* ---- fair mode ---- */ + function setFairMode(topicName, enabled) { + const topic = state.topics[topicName]; + if (!topic) return; + topic.fairMode = !!enabled; + if (!enabled) { + topic.fairModePickedUserIds = []; + } + persist(); + } + + function isFairMode(topicName) { + const topic = state.topics[topicName]; + return topic ? !!topic.fairMode : false; + } + /* ---- getters ---- */ function getActiveTopic() { return activeTopic; } function getEntries(topicName) { return state.topics[topicName]?.entries ?? []; } @@ -206,6 +257,7 @@ const App = (() => { addEntry, removeEntry, pickRandom, recordPick, addUser, removeUser, getUsers, getUserById, getActiveTopic, getEntries, getPickHistory, + setFairMode, isFairMode, get spinInterval() { return spinInterval; }, set spinInterval(v) { spinInterval = v; }, }; @@ -346,6 +398,10 @@ function renderMainContent() { + @@ -397,6 +453,11 @@ function renderMainContent() { /* Wire up pick button */ $('btn-pick').addEventListener('click', () => runPicker(topic)); + + /* Wire up fair mode toggle */ + $('fair-mode-checkbox').addEventListener('change', e => { + App.setFairMode(topic, e.target.checked); + }); } function renderEntriesHtml(topic, entries) { From c8a8edbe6ad3b88ce0babaa4330d95613fa5dce4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 20:04:53 +0000 Subject: [PATCH 3/4] fix: address PR review feedback for fair mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fairMode/fairModePickedUserIds defaults to v1→v2 migration - Remove side effect from pickRandom(), keep it pure - Move round reset logic into recordPick() before tracking - Disable fair mode checkbox during spin animation Agent-Logs-Url: https://github.com/K-Cully/random-picker/sessions/15435e48-9aea-49b2-90a0-4e373a1e41f9 Co-authored-by: K-Cully <2370032+K-Cully@users.noreply.github.com> --- js/app.js | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/js/app.js b/js/app.js index 1bd28cf..a57f836 100644 --- a/js/app.js +++ b/js/app.js @@ -64,6 +64,8 @@ const Storage = { .filter(t => typeof t === 'string') .map(t => ({ text: t, userId: null })), picks: [], + fairMode: false, + fairModePickedUserIds: [], }; } else if (value && typeof value === 'object') { /* v2: ensure both arrays exist and contain valid elements */ @@ -190,15 +192,12 @@ const App = (() => { /* Fair mode: exclude entries whose userId is already picked this round */ const pickedIds = topic.fairModePickedUserIds || []; - let eligible = topic.entries.filter(e => !e.userId || !pickedIds.includes(e.userId)); + const eligible = topic.entries.filter(e => !e.userId || !pickedIds.includes(e.userId)); - /* If no eligible entries remain (all users picked), reset the round */ - if (eligible.length === 0) { - topic.fairModePickedUserIds = []; - eligible = topic.entries; - } + /* If no eligible entries remain, fall back to full list (round will reset in recordPick) */ + const pool = eligible.length > 0 ? eligible : topic.entries; - return eligible[Math.floor(Math.random() * eligible.length)]; + return pool[Math.floor(Math.random() * pool.length)]; } function recordPick(topicName, entry) { @@ -214,16 +213,18 @@ const App = (() => { /* Fair mode: track the user who was just picked */ if (topic.fairMode && entry.userId) { if (!topic.fairModePickedUserIds) topic.fairModePickedUserIds = []; - if (!topic.fairModePickedUserIds.includes(entry.userId)) { - topic.fairModePickedUserIds.push(entry.userId); - } - /* Check if all users with entries have been picked – if so, reset */ + + /* If all users were already picked, reset the round before tracking */ const userIdsInTopic = [...new Set( topic.entries.map(e => e.userId).filter(Boolean) )]; if (userIdsInTopic.length > 0 && userIdsInTopic.every(id => topic.fairModePickedUserIds.includes(id))) { topic.fairModePickedUserIds = []; } + + if (!topic.fairModePickedUserIds.includes(entry.userId)) { + topic.fairModePickedUserIds.push(entry.userId); + } } persist(); @@ -523,6 +524,10 @@ function runPicker(topic) { pickBtn.disabled = true; + /* disable fair mode toggle during spin */ + const fairModeCheckbox = $('fair-mode-checkbox'); + if (fairModeCheckbox) fairModeCheckbox.disabled = true; + /* clear previous highlights */ document.querySelectorAll('.entry-item.highlighted').forEach(el => el.classList.remove('highlighted') @@ -546,7 +551,7 @@ function runPicker(topic) { /* final pick */ const winner = App.pickRandom(topic); - if (!winner) { pickBtn.disabled = false; return; } + if (!winner) { pickBtn.disabled = false; if (fairModeCheckbox) fairModeCheckbox.disabled = false; return; } const user = winner.userId ? App.getUserById(winner.userId) : null; resultEl.innerHTML = ` @@ -585,6 +590,7 @@ function runPicker(topic) { } pickBtn.disabled = false; + if (fairModeCheckbox) fairModeCheckbox.disabled = false; } }, SPIN_INTERVAL_MS); } From c502a3768312b29e620a1814785746036efcd459 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 20:06:07 +0000 Subject: [PATCH 4/4] style: split multi-statement guard clause for readability Agent-Logs-Url: https://github.com/K-Cully/random-picker/sessions/15435e48-9aea-49b2-90a0-4e373a1e41f9 Co-authored-by: K-Cully <2370032+K-Cully@users.noreply.github.com> --- js/app.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index a57f836..084e306 100644 --- a/js/app.js +++ b/js/app.js @@ -551,7 +551,11 @@ function runPicker(topic) { /* final pick */ const winner = App.pickRandom(topic); - if (!winner) { pickBtn.disabled = false; if (fairModeCheckbox) fairModeCheckbox.disabled = false; return; } + if (!winner) { + pickBtn.disabled = false; + if (fairModeCheckbox) fairModeCheckbox.disabled = false; + return; + } const user = winner.userId ? App.getUserById(winner.userId) : null; resultEl.innerHTML = `