leaderboard/public/app.js
2026-08-07 17:20:22 +02:00

405 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
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();
})();
})();