(() => { const ADMIN_KEY_STORAGE = 'doseAdminKey'; const PALETTE_SLOTS = 8; function clientTodayStr() { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } const state = { players: [], timeseries: { dates: [], series: [] }, adminKey: localStorage.getItem(ADMIN_KEY_STORAGE) || null, selectedDate: clientTodayStr(), chart: null, }; const $ = (sel) => document.querySelector(sel); const leaderboardBody = $('#leaderboard-body'); const leaderboardEmpty = $('#leaderboard-empty'); const chartEmpty = $('#chart-empty'); const totalsRow = $('#totals-row'); const totalsEmpty = $('#totals-empty'); const adminStatus = $('#admin-status'); const adminLoginBtn = $('#admin-login-btn'); const adminLogoutBtn = $('#admin-logout-btn'); const adminDialog = $('#admin-dialog'); const adminLoginForm = $('#admin-login-form'); const adminPasswordInput = $('#admin-password-input'); const adminLoginError = $('#admin-login-error'); const adminCancelBtn = $('#admin-cancel-btn'); const addPlayerForm = $('#add-player-form'); const playerNameInput = $('#player-name-input'); const addPlayerError = $('#add-player-error'); const doseDateInput = $('#dose-date-input'); const adminOnlyEls = document.querySelectorAll('.admin-only'); doseDateInput.max = clientTodayStr(); doseDateInput.value = state.selectedDate; function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } function colorForPlayer(playerId) { const index = (playerId - 1) % PALETTE_SLOTS; return cssVar(`--series-${index + 1}`); } function dashForPlayer(playerId) { const cycle = Math.floor((playerId - 1) / PALETTE_SLOTS); return cycle === 0 ? [] : [6, 3]; } function formatDateLabel(iso) { const [y, m, d] = iso.split('-').map(Number); const date = new Date(y, m - 1, d); return date.toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' }); } async function api(path, options = {}) { const headers = Object.assign({}, options.headers); if (options.body) headers['Content-Type'] = 'application/json'; if (options.auth && state.adminKey) headers['x-admin-key'] = state.adminKey; const res = await fetch(`/api${path}`, { ...options, headers }); let data = null; try { data = await res.json(); } catch (_) { /* no body */ } if (!res.ok) { const error = new Error(data?.error || `Request failed (${res.status})`); error.status = res.status; throw error; } return data; } async function loadAll() { const [players, timeseries] = await Promise.all([ api(`/players?date=${state.selectedDate}`), api('/timeseries'), ]); state.players = players; state.timeseries = timeseries; renderLeaderboard(); renderChart(); renderTotals(); } function setAdminMode(isAdmin) { adminStatus.classList.toggle('hidden', !isAdmin); adminLoginBtn.classList.toggle('hidden', isAdmin); adminLogoutBtn.classList.toggle('hidden', !isAdmin); adminOnlyEls.forEach((el) => el.classList.toggle('hidden', !isAdmin)); renderLeaderboard(); } function renderLeaderboard() { leaderboardBody.innerHTML = ''; leaderboardEmpty.classList.toggle('hidden', state.players.length > 0); state.players.forEach((player, index) => { const tr = document.createElement('tr'); const rankTd = document.createElement('td'); rankTd.className = 'col-rank'; rankTd.textContent = String(index + 1); const nameTd = document.createElement('td'); nameTd.className = 'col-name'; const nameWrap = document.createElement('span'); nameWrap.className = 'player-name'; const dot = document.createElement('span'); dot.className = 'color-dot'; dot.style.background = colorForPlayer(player.id); nameWrap.appendChild(dot); nameWrap.appendChild(document.createTextNode(player.name)); nameTd.appendChild(nameWrap); const totalTd = document.createElement('td'); totalTd.className = 'col-total'; totalTd.textContent = String(player.total); tr.append(rankTd, nameTd, totalTd); if (state.adminKey) { const actionsTd = document.createElement('td'); actionsTd.className = 'col-actions'; const controls = document.createElement('div'); controls.className = 'dose-controls'; const minusBtn = document.createElement('button'); minusBtn.className = 'btn btn-icon'; minusBtn.textContent = '−'; minusBtn.disabled = player.dateCount <= 0; minusBtn.title = 'Retirer une dose à cette date'; minusBtn.addEventListener('click', () => undoDose(player.id)); const dateCount = document.createElement('span'); dateCount.className = 'today-count'; dateCount.textContent = String(player.dateCount); const plusBtn = document.createElement('button'); plusBtn.className = 'btn btn-icon'; plusBtn.textContent = '+'; plusBtn.title = 'Ajouter une dose à cette date'; plusBtn.addEventListener('click', () => addDose(player.id)); controls.append(minusBtn, dateCount, plusBtn); actionsTd.appendChild(controls); tr.appendChild(actionsTd); const removeTd = document.createElement('td'); removeTd.className = 'col-remove'; const removeBtn = document.createElement('button'); removeBtn.className = 'btn btn-icon btn-remove'; removeBtn.textContent = '✕'; removeBtn.title = 'Supprimer ce joueur'; removeBtn.addEventListener('click', () => removePlayer(player.id, player.name)); removeTd.appendChild(removeBtn); tr.appendChild(removeTd); } leaderboardBody.appendChild(tr); }); } function renderTotals() { totalsRow.innerHTML = ''; totalsEmpty.classList.toggle('hidden', state.players.length > 0); state.players.forEach((player) => { const tile = document.createElement('div'); tile.className = 'stat-tile'; const dot = document.createElement('span'); dot.className = 'color-dot'; dot.style.background = colorForPlayer(player.id); const name = document.createElement('span'); name.className = 'stat-name'; name.textContent = player.name; const value = document.createElement('span'); value.className = 'stat-value'; value.textContent = String(player.total); tile.append(dot, name, value); totalsRow.appendChild(tile); }); } function renderChart() { const { dates, series } = state.timeseries; chartEmpty.classList.toggle('hidden', dates.length > 0); const canvas = $('#dose-chart'); canvas.classList.toggle('hidden', dates.length === 0); if (state.chart) { state.chart.destroy(); state.chart = null; } if (dates.length === 0) return; const textMuted = cssVar('--text-muted'); const textPrimary = cssVar('--text-primary'); const gridline = cssVar('--gridline'); const surface = cssVar('--surface-1'); const datasets = series.map((s) => { const color = colorForPlayer(s.playerId); return { label: s.name, data: s.counts, borderColor: color, backgroundColor: color, borderWidth: 2, borderDash: dashForPlayer(s.playerId), pointRadius: 4, pointHoverRadius: 5, pointBackgroundColor: color, pointBorderColor: surface, pointBorderWidth: 2, tension: 0, }; }); state.chart = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels: dates, datasets }, options: { responsive: true, interaction: { mode: 'index', intersect: false }, scales: { x: { grid: { display: false }, border: { color: cssVar('--baseline') }, ticks: { color: textMuted, callback: function (value) { const label = this.getLabelForValue(value); return formatDateLabel(label); }, }, }, y: { beginAtZero: true, grid: { color: gridline }, border: { display: false }, ticks: { color: textMuted, precision: 0 }, }, }, plugins: { legend: { display: datasets.length > 1, position: 'top', align: 'start', labels: { color: textPrimary, usePointStyle: true, pointStyle: 'circle', boxWidth: 8, boxHeight: 8, }, }, tooltip: { callbacks: { title: (items) => formatDateLabel(items[0].label), }, }, }, }, }); } async function addDose(playerId) { try { await api('/doses', { method: 'POST', auth: true, body: JSON.stringify({ playerId, date: state.selectedDate }), }); await loadAll(); } catch (err) { handleAdminError(err); } } async function undoDose(playerId) { try { await api(`/doses/latest?playerId=${playerId}&date=${state.selectedDate}`, { method: 'DELETE', auth: true, }); await loadAll(); } catch (err) { handleAdminError(err); } } async function removePlayer(playerId, name) { if (!confirm(`Supprimer ${name} et toutes ses doses ? Cette action est irréversible.`)) return; try { await api(`/players/${playerId}`, { method: 'DELETE', auth: true }); await loadAll(); } catch (err) { handleAdminError(err); } } function handleAdminError(err) { if (err.status === 401) { clearAdminKey(); alert('Votre session admin a expiré. Merci de vous reconnecter.'); } else { alert(err.message); } } function clearAdminKey() { state.adminKey = null; localStorage.removeItem(ADMIN_KEY_STORAGE); setAdminMode(false); } async function tryRestoreAdminSession() { if (!state.adminKey) return; try { const result = await api('/admin/verify', { method: 'POST', body: JSON.stringify({ password: state.adminKey }), }); if (result.ok) { setAdminMode(true); } else { clearAdminKey(); } } catch (_) { clearAdminKey(); } } addPlayerForm.addEventListener('submit', async (e) => { e.preventDefault(); addPlayerError.classList.add('hidden'); const name = playerNameInput.value.trim(); if (!name) return; try { await api('/players', { method: 'POST', auth: true, body: JSON.stringify({ name }) }); playerNameInput.value = ''; await loadAll(); } catch (err) { addPlayerError.textContent = err.message; addPlayerError.classList.remove('hidden'); } }); adminLoginBtn.addEventListener('click', () => { adminLoginError.classList.add('hidden'); adminPasswordInput.value = ''; adminDialog.showModal(); adminPasswordInput.focus(); }); adminCancelBtn.addEventListener('click', () => adminDialog.close()); adminLoginForm.addEventListener('submit', async (e) => { e.preventDefault(); const password = adminPasswordInput.value; try { const result = await api('/admin/verify', { method: 'POST', body: JSON.stringify({ password }), }); if (result.ok) { state.adminKey = password; localStorage.setItem(ADMIN_KEY_STORAGE, password); adminDialog.close(); setAdminMode(true); } else { adminLoginError.classList.remove('hidden'); } } catch (_) { adminLoginError.classList.remove('hidden'); } }); adminLogoutBtn.addEventListener('click', clearAdminKey); doseDateInput.addEventListener('change', () => { if (!doseDateInput.value) return; state.selectedDate = doseDateInput.value; loadAll(); }); (async function init() { await tryRestoreAdminSession(); await loadAll(); })(); })();