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 = `