Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 23 additions & 0 deletions css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------- */
Expand Down
77 changes: 74 additions & 3 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -76,6 +78,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')
: [],
Comment thread
K-Cully marked this conversation as resolved.
};
}
}
Expand Down Expand Up @@ -118,7 +124,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 };
}
Expand Down Expand Up @@ -179,7 +185,19 @@ 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 || [];
const eligible = topic.entries.filter(e => !e.userId || !pickedIds.includes(e.userId));

/* If no eligible entries remain, fall back to full list (round will reset in recordPick) */
const pool = eligible.length > 0 ? eligible : topic.entries;

return pool[Math.floor(Math.random() * pool.length)];
}

function recordPick(topicName, entry) {
Expand All @@ -191,9 +209,43 @@ 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 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();
}

/* ---- 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 ?? []; }
Expand All @@ -206,6 +258,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; },
};
Expand Down Expand Up @@ -346,6 +399,10 @@ function renderMainContent() {
<button class="btn btn-accent" id="btn-pick" ${entries.length === 0 ? 'disabled' : ''}>
🐾 Pick!
</button>
<label class="fair-mode-toggle" title="Fair mode ensures each user is picked before any user is repeated">
<input type="checkbox" id="fair-mode-checkbox" ${App.isFairMode(topic) ? 'checked' : ''} />
<span class="fair-mode-label">Fair mode</span>
</label>
</div>
</div>

Expand Down Expand Up @@ -397,6 +454,11 @@ function renderMainContent() {

/* Wire up pick button */
$('btn-pick').addEventListener('click', () => runPicker(topic));

/* Wire up fair mode toggle */
$('fair-mode-checkbox').addEventListener('change', e => {
Comment thread
K-Cully marked this conversation as resolved.
App.setFairMode(topic, e.target.checked);
});
}

function renderEntriesHtml(topic, entries) {
Expand Down Expand Up @@ -462,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')
Expand All @@ -485,7 +551,11 @@ 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 = `
Expand Down Expand Up @@ -524,6 +594,7 @@ function runPicker(topic) {
}

pickBtn.disabled = false;
if (fairModeCheckbox) fairModeCheckbox.disabled = false;
}
}, SPIN_INTERVAL_MS);
}
Expand Down