406 lines
12 KiB
JavaScript
406 lines
12 KiB
JavaScript
|
|
(() => {
|
|||
|
|
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();
|
|||
|
|
})();
|
|||
|
|
})();
|