leaderboard/public/app.js

406 lines
12 KiB
JavaScript
Raw Normal View History

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