This commit is contained in:
2026-03-05 22:21:50 +02:00
parent 7029eb29d4
commit 817bbfb44c
12528 changed files with 21319 additions and 124 deletions

View File

@@ -1,4 +1,5 @@
const USERNAME_KEY = 'scrumPoker.username';
const PRESETS_KEY = 'scrumPoker.deckPresets.v1';
const SCALE_PRESETS = {
fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'],
@@ -6,6 +7,12 @@ const SCALE_PRESETS = {
'powers-of-two': ['1', '2', '4', '8', '16', '32', '?'],
};
const SPECIAL_CARD_ORDER = {
'?': 1,
'∞': 2,
'☕': 3,
};
const themeToggleBtn = document.getElementById('theme-toggle');
const roomConfigForm = document.getElementById('room-config-form');
const statusLine = document.getElementById('config-status');
@@ -16,12 +23,25 @@ const previewMaxPeople = document.getElementById('preview-max-people');
const previewCards = document.getElementById('preview-cards');
const customCardInput = document.getElementById('custom-card');
const addCardButton = document.getElementById('add-card');
const autoSortButton = document.getElementById('auto-sort');
const usernameInput = document.getElementById('username');
const savePresetButton = document.getElementById('save-preset');
const pickerToggleButton = document.getElementById('preset-picker-toggle');
const shareDeckButton = document.getElementById('share-deck');
const presetPicker = document.getElementById('preset-picker');
const presetList = document.getElementById('preset-list');
const importToggleButton = document.getElementById('import-toggle');
const importPane = document.getElementById('import-pane');
const importInput = document.getElementById('import-b64');
const importApplyButton = document.getElementById('import-apply');
let isDarkMode = false;
let nextCardID = 1;
let currentCards = [];
let draggingCardID = '';
let draggedElement = null;
let deckPresets = loadPresets();
const savedUsername = localStorage.getItem(USERNAME_KEY);
if (savedUsername && !usernameInput.value) {
@@ -40,6 +60,36 @@ themeToggleBtn.addEventListener('click', () => {
themeToggleBtn.textContent = 'Dark Mode';
});
function parseNumericCard(value) {
if (!/^-?\d+(\.\d+)?$/.test(value)) {
return null;
}
return Number(value);
}
function cardSortComparator(a, b) {
const aNum = parseNumericCard(a.value);
const bNum = parseNumericCard(b.value);
if (aNum !== null && bNum !== null) {
return aNum - bNum;
}
if (aNum !== null) {
return -1;
}
if (bNum !== null) {
return 1;
}
const aSpecial = SPECIAL_CARD_ORDER[a.value] || 99;
const bSpecial = SPECIAL_CARD_ORDER[b.value] || 99;
if (aSpecial !== bSpecial) {
return aSpecial - bSpecial;
}
return a.value.localeCompare(b.value);
}
function createCard(value) {
return { id: String(nextCardID++), value: value.toString() };
}
@@ -91,21 +141,28 @@ function removeCard(cardID) {
}, { once: true });
}
function reorderCard(fromID, toID) {
if (fromID === toID) {
return;
}
function syncCardsFromDOM() {
const order = Array.from(previewCards.querySelectorAll('.preview-card')).map((el) => el.dataset.cardId);
const map = new Map(currentCards.map((card) => [card.id, card]));
currentCards = order.map((id) => map.get(id)).filter(Boolean);
}
const fromIndex = currentCards.findIndex((card) => card.id === fromID);
const toIndex = currentCards.findIndex((card) => card.id === toID);
if (fromIndex < 0 || toIndex < 0) {
return;
}
function moveDraggedCardNearCursor(clientX, clientY) {
const cards = Array.from(previewCards.querySelectorAll('.preview-card:not(.dragging)'));
const target = cards.find((cardEl) => {
const rect = cardEl.getBoundingClientRect();
return clientX < rect.left + rect.width / 2 && clientY < rect.bottom;
});
const previousPositions = captureCardPositions();
const [moved] = currentCards.splice(fromIndex, 1);
currentCards.splice(toIndex, 0, moved);
renderCards(previousPositions);
if (!target) {
previewCards.appendChild(draggedElement);
} else {
previewCards.insertBefore(draggedElement, target);
}
syncCardsFromDOM();
animateReflow(previousPositions);
}
function buildCardElement(card) {
@@ -127,24 +184,14 @@ function buildCardElement(card) {
cardEl.addEventListener('dragstart', () => {
draggingCardID = card.id;
cardEl.classList.add('dragging');
draggedElement = cardEl;
cardEl.classList.add('dragging', 'wiggle');
});
cardEl.addEventListener('dragend', () => {
draggingCardID = '';
cardEl.classList.remove('dragging');
});
cardEl.addEventListener('dragover', (event) => {
event.preventDefault();
});
cardEl.addEventListener('drop', (event) => {
event.preventDefault();
if (!draggingCardID) {
return;
}
reorderCard(draggingCardID, card.id);
draggedElement = null;
cardEl.classList.remove('dragging', 'wiggle');
});
cardEl.appendChild(removeBtn);
@@ -160,10 +207,18 @@ function renderCards(previousPositions = new Map()) {
animateReflow(previousPositions);
}
function resetCardsForScale() {
function setDeck(values, message) {
const previousPositions = captureCardPositions();
currentCards = getCardsForScale(scaleSelect.value);
currentCards = values.map((value) => createCard(value));
renderCards(previousPositions);
if (message) {
statusLine.textContent = message;
}
}
function resetCardsForScale() {
const values = (SCALE_PRESETS[scaleSelect.value] || SCALE_PRESETS.fibonacci).slice();
setDeck(values);
}
function updatePreviewMeta() {
@@ -171,6 +226,139 @@ function updatePreviewMeta() {
previewMaxPeople.textContent = `Max: ${maxPeopleInput.value || 0}`;
}
function encodeDeckBase64(values) {
const json = JSON.stringify(values);
const bytes = new TextEncoder().encode(json);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
function decodeDeckBase64(input) {
const binary = atob(input.trim());
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
const json = new TextDecoder().decode(bytes);
const parsed = JSON.parse(json);
if (!Array.isArray(parsed)) {
throw new Error('Invalid payload');
}
const clean = parsed
.map((value) => value.toString().trim().slice(0, 8))
.filter((value) => value !== '');
if (clean.length === 0) {
throw new Error('No valid cards');
}
return clean;
}
function loadPresets() {
try {
const raw = localStorage.getItem(PRESETS_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter((item) => item && typeof item.name === 'string' && Array.isArray(item.cards))
.map((item) => ({
name: item.name.slice(0, 40),
cards: item.cards.map((card) => card.toString().slice(0, 8)).filter(Boolean),
}))
.filter((item) => item.cards.length > 0);
} catch (_err) {
return [];
}
}
function persistPresets() {
localStorage.setItem(PRESETS_KEY, JSON.stringify(deckPresets));
}
function applyPreset(cards, sourceLabel) {
setDeck(cards, `${sourceLabel} preset applied.`);
}
function renderPresetList() {
presetList.innerHTML = '';
if (deckPresets.length === 0) {
const empty = document.createElement('div');
empty.className = 'preset-item';
empty.textContent = 'No presets saved yet.';
presetList.appendChild(empty);
return;
}
deckPresets.forEach((preset, index) => {
const wrapper = document.createElement('div');
wrapper.className = 'preset-item';
const left = document.createElement('div');
const title = document.createElement('div');
title.textContent = preset.name;
const meta = document.createElement('div');
meta.className = 'preset-meta';
meta.textContent = preset.cards.join(', ');
left.appendChild(title);
left.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'preset-actions';
const useBtn = document.createElement('button');
useBtn.type = 'button';
useBtn.className = 'btn';
useBtn.textContent = 'Use';
useBtn.addEventListener('click', () => {
applyPreset(preset.cards, preset.name);
hidePresetPicker();
});
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn';
deleteBtn.textContent = 'Del';
deleteBtn.addEventListener('click', () => {
deckPresets.splice(index, 1);
persistPresets();
renderPresetList();
statusLine.textContent = 'Preset deleted.';
});
actions.appendChild(useBtn);
actions.appendChild(deleteBtn);
wrapper.appendChild(left);
wrapper.appendChild(actions);
presetList.appendChild(wrapper);
});
}
function showPresetPicker() {
presetPicker.classList.remove('hidden');
pickerToggleButton.setAttribute('aria-expanded', 'true');
renderPresetList();
}
function hidePresetPicker() {
presetPicker.classList.add('hidden');
pickerToggleButton.setAttribute('aria-expanded', 'false');
}
previewCards.addEventListener('dragover', (event) => {
if (!draggingCardID || !draggedElement) {
return;
}
event.preventDefault();
moveDraggedCardNearCursor(event.clientX, event.clientY);
});
addCardButton.addEventListener('click', () => {
const value = customCardInput.value.trim();
if (!value) {
@@ -184,6 +372,112 @@ addCardButton.addEventListener('click', () => {
customCardInput.focus();
});
autoSortButton.addEventListener('click', () => {
const previousPositions = captureCardPositions();
currentCards = [...currentCards].sort(cardSortComparator);
renderCards(previousPositions);
statusLine.textContent = 'Deck auto-sorted.';
});
savePresetButton.addEventListener('click', () => {
const cards = currentCards.map((card) => card.value);
if (cards.length === 0) {
statusLine.textContent = 'Cannot save an empty deck.';
return;
}
const defaultName = `Preset ${deckPresets.length + 1}`;
const name = window.prompt('Preset name:', defaultName);
if (!name) {
return;
}
const cleanName = name.trim().slice(0, 40);
if (!cleanName) {
statusLine.textContent = 'Preset name is required.';
return;
}
const existingIndex = deckPresets.findIndex((preset) => preset.name === cleanName);
const newPreset = { name: cleanName, cards };
if (existingIndex >= 0) {
deckPresets[existingIndex] = newPreset;
} else {
deckPresets.unshift(newPreset);
}
deckPresets = deckPresets.slice(0, 20);
persistPresets();
renderPresetList();
statusLine.textContent = `Preset "${cleanName}" saved.`;
});
pickerToggleButton.addEventListener('click', () => {
if (presetPicker.classList.contains('hidden')) {
showPresetPicker();
return;
}
hidePresetPicker();
});
shareDeckButton.addEventListener('click', async () => {
const cards = currentCards.map((card) => card.value);
if (cards.length === 0) {
statusLine.textContent = 'Cannot share an empty deck.';
return;
}
const encoded = encodeDeckBase64(cards);
try {
await navigator.clipboard.writeText(encoded);
statusLine.textContent = 'Shared deck copied as base64.';
} catch (_err) {
window.prompt('Copy this base64 deck string:', encoded);
statusLine.textContent = 'Clipboard unavailable. Base64 shown for manual copy.';
}
});
importToggleButton.addEventListener('click', () => {
importPane.classList.toggle('hidden');
if (!importPane.classList.contains('hidden')) {
importInput.focus();
}
});
importApplyButton.addEventListener('click', () => {
const raw = importInput.value.trim();
if (!raw) {
statusLine.textContent = 'Paste a base64 deck first.';
return;
}
try {
const decodedCards = decodeDeckBase64(raw);
applyPreset(decodedCards, 'Imported');
importInput.value = '';
importPane.classList.add('hidden');
hidePresetPicker();
} catch (_err) {
statusLine.textContent = 'Invalid base64 deck input.';
}
});
document.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
if (presetPicker.classList.contains('hidden')) {
return;
}
if (presetPicker.contains(target) || pickerToggleButton.contains(target)) {
return;
}
hidePresetPicker();
});
customCardInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -243,7 +537,7 @@ roomConfigForm.addEventListener('submit', async (event) => {
const target = `/room/${encodeURIComponent(data.roomId)}?participantId=${encodeURIComponent(data.creatorParticipantId)}&adminToken=${encodeURIComponent(data.adminToken)}`;
window.location.assign(target);
} catch (err) {
} catch (_err) {
statusLine.textContent = 'Network error while creating room.';
}
});
@@ -258,3 +552,4 @@ roomConfigForm.addEventListener('reset', () => {
updatePreviewMeta();
resetCardsForScale();
renderPresetList();

View File

@@ -4,10 +4,15 @@ const roomID = document.body.dataset.roomId;
const params = new URLSearchParams(window.location.search);
const themeToggleBtn = document.getElementById('theme-toggle');
const roomSkeleton = document.getElementById('room-skeleton');
const roomGrid = document.getElementById('room-grid');
const roomTitle = document.getElementById('room-title');
const revealModeLabel = document.getElementById('reveal-mode-label');
const roundStateLabel = document.getElementById('round-state-label');
const votingBoard = document.getElementById('voting-board');
const summaryBody = document.getElementById('summary-body');
const summaryAverage = document.getElementById('summary-average');
const summaryRecommended = document.getElementById('summary-recommended');
const participantList = document.getElementById('participant-list');
const adminControls = document.getElementById('admin-controls');
const revealBtn = document.getElementById('reveal-btn');
@@ -28,7 +33,10 @@ let isDarkMode = false;
let participantID = params.get('participantId') || '';
let adminToken = params.get('adminToken') || '';
let eventSource = null;
let latestState = null;
const savedUsername = localStorage.getItem(USERNAME_KEY) || '';
joinUsernameInput.value = savedUsername;
joinAdminTokenInput.value = adminToken;
themeToggleBtn.addEventListener('click', () => {
isDarkMode = !isDarkMode;
@@ -42,10 +50,6 @@ themeToggleBtn.addEventListener('click', () => {
themeToggleBtn.textContent = 'Dark Mode';
});
const savedUsername = localStorage.getItem(USERNAME_KEY) || '';
joinUsernameInput.value = savedUsername;
joinAdminTokenInput.value = adminToken;
function setJoinError(message) {
if (!message) {
joinError.classList.add('hidden');
@@ -64,14 +68,23 @@ function updateURL() {
} else {
next.searchParams.delete('participantId');
}
if (adminToken) {
next.searchParams.set('adminToken', adminToken);
} else {
next.searchParams.delete('adminToken');
}
window.history.replaceState({}, '', next.toString());
}
function activateRoomView() {
document.body.classList.remove('prejoin');
roomSkeleton.classList.add('hidden');
roomGrid.classList.remove('hidden');
joinPanel.classList.add('hidden');
}
async function joinRoom({ username, role, password, participantIdOverride }) {
const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/join`, {
method: 'POST',
@@ -91,18 +104,14 @@ async function joinRoom({ username, role, password, participantIdOverride }) {
}
participantID = data.participantId;
if (data.isAdmin && !adminToken) {
adminToken = joinAdminTokenInput.value.trim();
}
localStorage.setItem(USERNAME_KEY, data.username);
updateURL();
joinPanel.classList.add('hidden');
setJoinError('');
}
function renderParticipants(participants, isRevealed) {
participantList.innerHTML = '';
participants.forEach((participant) => {
const item = document.createElement('li');
item.className = 'participant-item';
@@ -134,12 +143,93 @@ function renderParticipants(participants, isRevealed) {
});
}
function parseNumericVote(value) {
if (!/^-?\d+(\.\d+)?$/.test(value)) {
return null;
}
return Number(value);
}
function calculateSummary(state) {
const rows = new Map();
const numericVotes = [];
state.participants.forEach((participant) => {
if (participant.role !== 'participant' || !participant.hasVoted || !participant.voteValue) {
return;
}
if (!rows.has(participant.voteValue)) {
rows.set(participant.voteValue, []);
}
rows.get(participant.voteValue).push(participant.username);
const numeric = parseNumericVote(participant.voteValue);
if (numeric !== null) {
numericVotes.push(numeric);
}
});
let average = null;
if (numericVotes.length > 0) {
average = numericVotes.reduce((acc, value) => acc + value, 0) / numericVotes.length;
}
const deckNumeric = state.cards
.map(parseNumericVote)
.filter((value) => value !== null)
.sort((a, b) => a - b);
let recommended = null;
if (average !== null && deckNumeric.length > 0) {
recommended = deckNumeric.find((value) => value >= average) ?? deckNumeric[deckNumeric.length - 1];
}
return { rows, average, recommended };
}
function renderSummary(state) {
if (!state.revealed) {
summaryBody.innerHTML = '<tr><td colspan="2">Results hidden until reveal.</td></tr>';
summaryAverage.textContent = 'Average: -';
summaryRecommended.textContent = 'Recommended: -';
return;
}
const { rows, average, recommended } = calculateSummary(state);
summaryBody.innerHTML = '';
if (rows.size === 0) {
summaryBody.innerHTML = '<tr><td colspan="2">No votes submitted.</td></tr>';
} else {
state.cards.forEach((cardValue) => {
const users = rows.get(cardValue);
if (!users || users.length === 0) {
return;
}
const row = document.createElement('tr');
const left = document.createElement('td');
const right = document.createElement('td');
left.textContent = cardValue;
right.textContent = users.join(', ');
row.appendChild(left);
row.appendChild(right);
summaryBody.appendChild(row);
});
}
summaryAverage.textContent = average === null ? 'Average: -' : `Average: ${average.toFixed(2)}`;
summaryRecommended.textContent = recommended === null ? 'Recommended: -' : `Recommended: ${recommended}`;
}
function renderCards(cards, participants, isRevealed) {
const self = participants.find((participant) => participant.id === participantID);
const canVote = self && self.role === 'participant';
const selfVote = self ? self.voteValue : '';
votingBoard.innerHTML = '';
cards.forEach((value) => {
const card = document.createElement('button');
card.type = 'button';
@@ -151,19 +241,25 @@ function renderCards(cards, participants, isRevealed) {
}
card.disabled = !canVote;
card.addEventListener('click', () => castVote(value));
card.addEventListener('click', () => {
card.classList.remove('impact');
void card.offsetWidth;
card.classList.add('impact');
castVote(value);
});
votingBoard.appendChild(card);
});
}
function renderState(state) {
latestState = state;
roomTitle.textContent = `${state.roomName} (${state.roomId})`;
revealModeLabel.textContent = `Reveal mode: ${state.revealMode}`;
roundStateLabel.textContent = state.revealed ? 'Cards revealed' : 'Cards hidden';
renderParticipants(state.participants, state.revealed);
renderCards(state.cards, state.participants, state.revealed);
renderSummary(state);
participantLinkInput.value = `${window.location.origin}${state.links.participantLink}`;
adminLinkInput.value = state.links.adminLink ? `${window.location.origin}${state.links.adminLink}` : '';
@@ -189,6 +285,7 @@ function connectSSE() {
try {
const payload = JSON.parse(event.data);
renderState(payload);
activateRoomView();
} catch (_err) {
roomStatus.textContent = 'Failed to parse room update.';
}
@@ -260,9 +357,14 @@ joinForm.addEventListener('submit', async (event) => {
username,
role: joinRoleInput.value,
password: joinPasswordInput.value,
participantIdOverride: participantID,
});
connectSSE();
} catch (err) {
if (participantID) {
participantID = '';
updateURL();
}
setJoinError(err.message);
}
});
@@ -275,32 +377,3 @@ window.addEventListener('pagehide', () => {
const payload = JSON.stringify({ participantId: participantID });
navigator.sendBeacon(`/api/rooms/${encodeURIComponent(roomID)}/leave`, new Blob([payload], { type: 'application/json' }));
});
async function bootstrap() {
if (!participantID) {
joinPanel.classList.remove('hidden');
return;
}
if (!savedUsername) {
joinPanel.classList.remove('hidden');
return;
}
try {
await joinRoom({
username: savedUsername,
role: 'participant',
password: '',
participantIdOverride: participantID,
});
connectSSE();
} catch (_err) {
participantID = '';
updateURL();
joinPanel.classList.remove('hidden');
setJoinError('Please join this room to continue.');
}
}
bootstrap();