Yearly completion and winner stats #29
@@ -80,6 +80,11 @@
|
|||||||
Modal 100%
|
Modal 100%
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="admin-winners-tab" data-bs-toggle="tab" data-bs-target="#admin-winners" type="button" role="tab" aria-controls="admin-winners" aria-selected="false">
|
||||||
|
Gagnants
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false">
|
<button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false">
|
||||||
Backup / Restore
|
Backup / Restore
|
||||||
@@ -387,6 +392,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="admin-winners" role="tabpanel" aria-labelledby="admin-winners-tab">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-xxl-8">
|
||||||
|
<div class="card p-3">
|
||||||
|
<h4 class="mb-1">Définir le gagnant par année</h4>
|
||||||
|
<div class="text-muted mb-3">
|
||||||
|
Choisis une année, un utilisateur, et des points (optionnel). Laisse le champ points vide si tu ne veux pas l’afficher.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="winner_year" class="form-label">Année</label>
|
||||||
|
<select id="winner_year" class="form-select"></select>
|
||||||
|
<div class="form-text">Basé sur les années présentes dans les films.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label for="winner_user" class="form-label">Utilisateur</label>
|
||||||
|
<select id="winner_user" class="form-select">
|
||||||
|
<option value="">—</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Si tu choisis “—”, le gagnant est supprimé pour l’année.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="winner_points" class="form-label">Points (optionnel)</label>
|
||||||
|
<input id="winner_points" type="number" class="form-control" placeholder="ex: 42" min="0" step="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap justify-content-end gap-2 mt-3">
|
||||||
|
<button type="button" id="winner_reload" class="btn btn-outline-primary">Recharger</button>
|
||||||
|
<button type="button" id="winner_clear" class="btn btn-outline-danger">Supprimer</button>
|
||||||
|
<button type="button" id="winner_save" class="btn btn-warning">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="text-muted small mb-1">Gagnant actuel</div>
|
||||||
|
<div id="winner_current" class="border rounded p-2 bg-light">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ window.onload = async function () {
|
|||||||
const selectAllBox = document.getElementById('select-all-movies');
|
const selectAllBox = document.getElementById('select-all-movies');
|
||||||
const selectedCountEl = document.getElementById('selected-count');
|
const selectedCountEl = document.getElementById('selected-count');
|
||||||
|
|
||||||
|
// Winners tab elements
|
||||||
|
const adminWinnersTabBtn = document.getElementById('admin-winners-tab');
|
||||||
|
const winnerYearSelect = document.getElementById('winner_year');
|
||||||
|
const winnerUserSelect = document.getElementById('winner_user');
|
||||||
|
const winnerPointsEl = document.getElementById('winner_points');
|
||||||
|
const winnerReloadBtn = document.getElementById('winner_reload');
|
||||||
|
const winnerClearBtn = document.getElementById('winner_clear');
|
||||||
|
const winnerSaveBtn = document.getElementById('winner_save');
|
||||||
|
const winnerCurrentEl = document.getElementById('winner_current');
|
||||||
|
|
||||||
const editMovieModalEl = document.getElementById('editMovieModal');
|
const editMovieModalEl = document.getElementById('editMovieModal');
|
||||||
const editMovieModal = editMovieModalEl ? new bootstrap.Modal(editMovieModalEl) : null;
|
const editMovieModal = editMovieModalEl ? new bootstrap.Modal(editMovieModalEl) : null;
|
||||||
const editMovieModalTitleEl = document.getElementById('editMovieModalTitle');
|
const editMovieModalTitleEl = document.getElementById('editMovieModalTitle');
|
||||||
@@ -83,6 +93,10 @@ window.onload = async function () {
|
|||||||
|
|
||||||
let moviesById = new Map();
|
let moviesById = new Map();
|
||||||
let activeYear = null;
|
let activeYear = null;
|
||||||
|
let winnersLoadedOnce = false;
|
||||||
|
let winnerUsersLoadedOnce = false;
|
||||||
|
let winnersByYear = new Map(); // year -> { year, userId, name, points }
|
||||||
|
let winnerUsers = []; // admin list: {id, name, email, ...}
|
||||||
|
|
||||||
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
||||||
title: 'Félicitations! very nice 🎉🎉🎉',
|
title: 'Félicitations! very nice 🎉🎉🎉',
|
||||||
@@ -460,6 +474,168 @@ window.onload = async function () {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchWinners() {
|
||||||
|
const res = await fetch('/api/settings/winners', { method: 'GET' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return Array.isArray(data?.winners) ? data.winners : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWinner(year, userId, points) {
|
||||||
|
const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId: userId ? String(userId) : '',
|
||||||
|
points: points === undefined ? null : points,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWinnerUsersOptions(users) {
|
||||||
|
if (!winnerUserSelect) return;
|
||||||
|
const list = Array.isArray(users) ? users : [];
|
||||||
|
const previous = String(winnerUserSelect.value || '');
|
||||||
|
winnerUserSelect.innerHTML = '';
|
||||||
|
|
||||||
|
const emptyOpt = document.createElement('option');
|
||||||
|
emptyOpt.value = '';
|
||||||
|
emptyOpt.textContent = '—';
|
||||||
|
winnerUserSelect.appendChild(emptyOpt);
|
||||||
|
|
||||||
|
list.forEach((u) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(u?.id || '');
|
||||||
|
const label = u?.name ? u.name : u?.email ? u.email : '(sans nom)';
|
||||||
|
opt.textContent = label;
|
||||||
|
winnerUserSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stillExists = list.map((u) => String(u?.id || '')).includes(previous);
|
||||||
|
winnerUserSelect.value = stillExists ? previous : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWinnerYearOptions(cleanedYears) {
|
||||||
|
if (!winnerYearSelect) return;
|
||||||
|
const years = Array.isArray(cleanedYears) ? cleanedYears : [];
|
||||||
|
const previous = String(winnerYearSelect.value || '');
|
||||||
|
winnerYearSelect.innerHTML = '';
|
||||||
|
|
||||||
|
if (!years.length) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = '';
|
||||||
|
opt.textContent = 'Aucune année (ajoute un film)';
|
||||||
|
opt.disabled = true;
|
||||||
|
opt.selected = true;
|
||||||
|
winnerYearSelect.appendChild(opt);
|
||||||
|
winnerYearSelect.disabled = true;
|
||||||
|
if (winnerSaveBtn) winnerSaveBtn.disabled = true;
|
||||||
|
if (winnerClearBtn) winnerClearBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
years.forEach((y) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(y);
|
||||||
|
opt.textContent = String(y);
|
||||||
|
winnerYearSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
winnerYearSelect.disabled = false;
|
||||||
|
if (winnerSaveBtn) winnerSaveBtn.disabled = false;
|
||||||
|
if (winnerClearBtn) winnerClearBtn.disabled = false;
|
||||||
|
|
||||||
|
const stillExists = years.map(String).includes(previous);
|
||||||
|
const desired = stillExists
|
||||||
|
? previous
|
||||||
|
: (activeYear && years.map(String).includes(String(activeYear)))
|
||||||
|
? String(activeYear)
|
||||||
|
: String(years[0]);
|
||||||
|
winnerYearSelect.value = desired;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWinnerForYear(year) {
|
||||||
|
const y = parseYear(year);
|
||||||
|
if (!y) return null;
|
||||||
|
return winnersByYear.get(String(y)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWinnerCurrent() {
|
||||||
|
if (!winnerCurrentEl || !winnerYearSelect) return;
|
||||||
|
const y = parseYear(winnerYearSelect.value);
|
||||||
|
if (!y) {
|
||||||
|
winnerCurrentEl.textContent = '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = getWinnerForYear(y);
|
||||||
|
if (!w) {
|
||||||
|
winnerCurrentEl.textContent = `${y}: (aucun gagnant)`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const points = w.points === null || w.points === undefined ? null : Number(w.points);
|
||||||
|
const pointsLabel = points === null || Number.isNaN(points) ? '' : ` — ${points} pts`;
|
||||||
|
winnerCurrentEl.textContent = `${y}: ${w.name || '(utilisateur supprimé)'}${pointsLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWinnerFormFromCache() {
|
||||||
|
if (!winnerYearSelect) return;
|
||||||
|
const y = parseYear(winnerYearSelect.value);
|
||||||
|
if (!y) return;
|
||||||
|
const w = getWinnerForYear(y);
|
||||||
|
if (winnerUserSelect) {
|
||||||
|
winnerUserSelect.value = w?.userId ? String(w.userId) : '';
|
||||||
|
}
|
||||||
|
if (winnerPointsEl) {
|
||||||
|
const pts = w?.points;
|
||||||
|
winnerPointsEl.value = pts === null || pts === undefined ? '' : String(pts);
|
||||||
|
}
|
||||||
|
renderWinnerCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWinners(options = {}) {
|
||||||
|
const force = !!options.force;
|
||||||
|
if (winnersLoadedOnce && !force) return;
|
||||||
|
const list = await fetchWinners();
|
||||||
|
winnersLoadedOnce = true;
|
||||||
|
winnersByYear = new Map(
|
||||||
|
list
|
||||||
|
.filter((w) => parseYear(w?.year))
|
||||||
|
.map((w) => [
|
||||||
|
String(w.year),
|
||||||
|
{
|
||||||
|
year: Number(w.year),
|
||||||
|
userId: String(w.userId || ''),
|
||||||
|
name: w.name || null,
|
||||||
|
points: w.points ?? null,
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
applyWinnerFormFromCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWinnerUsers(options = {}) {
|
||||||
|
const force = !!options.force;
|
||||||
|
if (winnerUsersLoadedOnce && !force) return;
|
||||||
|
const users = await fetchAdminUsers();
|
||||||
|
winnerUsersLoadedOnce = true;
|
||||||
|
winnerUsers = Array.isArray(users) ? users : [];
|
||||||
|
winnerUsers.sort((a, b) => (a?.name || '').localeCompare((b?.name || ''), 'fr', { sensitivity: 'base' }));
|
||||||
|
buildWinnerUsersOptions(winnerUsers);
|
||||||
|
applyWinnerFormFromCache();
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchActiveYear() {
|
async function fetchActiveYear() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/year', { method: 'GET' });
|
const res = await fetch('/api/settings/year', { method: 'GET' });
|
||||||
@@ -1001,6 +1177,91 @@ window.onload = async function () {
|
|||||||
dbRestoreRunBtn.addEventListener('click', runRestore);
|
dbRestoreRunBtn.addEventListener('click', runRestore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Winners tab wiring
|
||||||
|
async function initWinnersTab(options = {}) {
|
||||||
|
const force = !!options.force;
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
loadWinners({ force }),
|
||||||
|
loadWinnerUsers({ force }),
|
||||||
|
]);
|
||||||
|
renderWinnerCurrent();
|
||||||
|
} catch (err) {
|
||||||
|
showResponse('danger', err.message || 'Erreur réseau');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminWinnersTabBtn) {
|
||||||
|
adminWinnersTabBtn.addEventListener('shown.bs.tab', () => initWinnersTab({ force: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winnerYearSelect) {
|
||||||
|
winnerYearSelect.addEventListener('change', () => applyWinnerFormFromCache());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winnerReloadBtn) {
|
||||||
|
winnerReloadBtn.addEventListener('click', async () => {
|
||||||
|
winnerReloadBtn.disabled = true;
|
||||||
|
const old = winnerReloadBtn.textContent;
|
||||||
|
winnerReloadBtn.textContent = 'Chargement...';
|
||||||
|
try {
|
||||||
|
await initWinnersTab({ force: true });
|
||||||
|
showResponse('success', 'Gagnants rechargés.');
|
||||||
|
} finally {
|
||||||
|
winnerReloadBtn.disabled = false;
|
||||||
|
winnerReloadBtn.textContent = old;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winnerClearBtn) {
|
||||||
|
winnerClearBtn.addEventListener('click', async () => {
|
||||||
|
if (!winnerYearSelect) return;
|
||||||
|
const y = parseYear(winnerYearSelect.value);
|
||||||
|
if (!y) return;
|
||||||
|
const ok = window.confirm(`Supprimer le gagnant pour ${y} ?`);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
if (winnerUserSelect) winnerUserSelect.value = '';
|
||||||
|
if (winnerPointsEl) winnerPointsEl.value = '';
|
||||||
|
await saveWinner(y, '', null);
|
||||||
|
await loadWinners({ force: true });
|
||||||
|
showResponse('success', `Gagnant supprimé pour ${y}.`);
|
||||||
|
} catch (err) {
|
||||||
|
showResponse('danger', err.message || 'Erreur réseau');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winnerSaveBtn) {
|
||||||
|
winnerSaveBtn.addEventListener('click', async () => {
|
||||||
|
if (!winnerYearSelect) return;
|
||||||
|
const y = parseYear(winnerYearSelect.value);
|
||||||
|
if (!y) {
|
||||||
|
showResponse('warning', 'Année invalide.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = String(winnerUserSelect?.value || '').trim();
|
||||||
|
const pointsRaw = String(winnerPointsEl?.value || '').trim();
|
||||||
|
const points = pointsRaw === '' ? null : Number(pointsRaw);
|
||||||
|
const pointsToSend = pointsRaw === '' || Number.isNaN(points) ? null : Math.round(points);
|
||||||
|
|
||||||
|
winnerSaveBtn.disabled = true;
|
||||||
|
const old = winnerSaveBtn.textContent;
|
||||||
|
winnerSaveBtn.textContent = 'Enregistrement...';
|
||||||
|
try {
|
||||||
|
await saveWinner(y, userId, pointsToSend);
|
||||||
|
await loadWinners({ force: true });
|
||||||
|
showResponse('success', `Gagnant enregistré pour ${y}.`);
|
||||||
|
} catch (err) {
|
||||||
|
showResponse('danger', err.message || 'Erreur réseau');
|
||||||
|
} finally {
|
||||||
|
winnerSaveBtn.disabled = false;
|
||||||
|
winnerSaveBtn.textContent = old;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', async function (e) {
|
form.addEventListener('submit', async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -1135,6 +1396,10 @@ window.onload = async function () {
|
|||||||
activeYearInput.value = desired;
|
activeYearInput.value = desired;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Winners year dropdown (optional tab)
|
||||||
|
setWinnerYearOptions(cleanedYears);
|
||||||
|
applyWinnerFormFromCache();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-6
@@ -13,6 +13,117 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchWinners() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/winners', { method: 'GET' });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch winners');
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return Array.isArray(data?.winners) ? data.winners : [];
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCompletions() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/completions', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch completions');
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return data && typeof data === 'object' ? data : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWinners(winners) {
|
||||||
|
const winnersList = document.getElementById('winners-list');
|
||||||
|
if (!winnersList) return;
|
||||||
|
const list = Array.isArray(winners) ? winners : [];
|
||||||
|
if (!list.length) {
|
||||||
|
winnersList.innerHTML = '<div class="list-group-item text-muted">Aucun gagnant défini.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure sorted by year desc
|
||||||
|
list.sort((a, b) => Number(b?.year || 0) - Number(a?.year || 0));
|
||||||
|
winnersList.innerHTML = '';
|
||||||
|
|
||||||
|
list.forEach((w) => {
|
||||||
|
const year = Number(w?.year);
|
||||||
|
const name = w?.name || '(utilisateur supprimé)';
|
||||||
|
const points = w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points);
|
||||||
|
const pointsLabel = points === null || Number.isNaN(points) ? '' : ` — ${points} pts`;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div><strong>${year || '—'}</strong> : ${name}${pointsLabel}</div>
|
||||||
|
`;
|
||||||
|
winnersList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCompletions(completions) {
|
||||||
|
const root = document.getElementById('completers-by-year');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
if (!completions || typeof completions !== 'object') {
|
||||||
|
root.innerHTML = '<div class="text-muted">Impossible de charger les finisseurs 100%.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const years = Array.isArray(completions?.years) ? completions.years : [];
|
||||||
|
const totals = completions?.totals && typeof completions.totals === 'object' ? completions.totals : {};
|
||||||
|
const byYear = completions?.completersByYear && typeof completions.completersByYear === 'object' ? completions.completersByYear : {};
|
||||||
|
|
||||||
|
if (!years.length) {
|
||||||
|
root.innerHTML = '<div class="text-muted">Aucune année trouvée.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a simple accordion
|
||||||
|
const accordionId = 'completersAccordion';
|
||||||
|
root.innerHTML = `<div class="accordion" id="${accordionId}"></div>`;
|
||||||
|
const acc = root.querySelector('.accordion');
|
||||||
|
|
||||||
|
years.forEach((y, idx) => {
|
||||||
|
const yearStr = String(y);
|
||||||
|
const total = Number(totals?.[yearStr] ?? 0);
|
||||||
|
const completers = Array.isArray(byYear?.[yearStr]) ? byYear[yearStr] : [];
|
||||||
|
const count = completers.length;
|
||||||
|
|
||||||
|
const collapseId = `completers-${yearStr}`;
|
||||||
|
const headingId = `heading-${yearStr}`;
|
||||||
|
const show = idx === 0 ? 'show' : '';
|
||||||
|
const collapsed = idx === 0 ? '' : 'collapsed';
|
||||||
|
const expanded = idx === 0 ? 'true' : 'false';
|
||||||
|
|
||||||
|
const bodyHtml = count
|
||||||
|
? `<ul class="list-group list-group-flush">
|
||||||
|
${completers.map((u) => `<li class="list-group-item">${u?.name || '(sans nom)'}</li>`).join('')}
|
||||||
|
</ul>`
|
||||||
|
: `<div class="text-muted">Aucun utilisateur à 100%.</div>`;
|
||||||
|
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'accordion-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<h2 class="accordion-header" id="${headingId}">
|
||||||
|
<button class="accordion-button ${collapsed}" type="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="${expanded}" aria-controls="${collapseId}">
|
||||||
|
${yearStr} — ${count} finisseur(s) (total films: ${total})
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="${collapseId}" class="accordion-collapse collapse ${show}" aria-labelledby="${headingId}" data-bs-parent="#${accordionId}">
|
||||||
|
<div class="accordion-body">
|
||||||
|
${bodyHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
acc.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT
|
const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT
|
||||||
@@ -52,12 +163,14 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats';
|
const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats';
|
||||||
const statsRes = await fetch(statsUrl, {
|
const [statsRes, winners, completions] = await Promise.all([
|
||||||
method: 'GET',
|
fetch(statsUrl, {
|
||||||
headers: {
|
method: 'GET',
|
||||||
'Authorization': `Bearer ${token}`
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
}
|
}),
|
||||||
});
|
fetchWinners(),
|
||||||
|
fetchCompletions(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!statsRes.ok) {
|
if (!statsRes.ok) {
|
||||||
throw new Error('Failed to fetch user stats');
|
throw new Error('Failed to fetch user stats');
|
||||||
@@ -68,6 +181,9 @@ window.onload = async function () {
|
|||||||
const table = document.querySelector('.user-table');
|
const table = document.querySelector('.user-table');
|
||||||
const spinner = document.getElementById('loading-spinner');
|
const spinner = document.getElementById('loading-spinner');
|
||||||
|
|
||||||
|
renderWinners(winners);
|
||||||
|
renderCompletions(completions);
|
||||||
|
|
||||||
// Ensure watchedRatio is treated as a number and sort in descending order
|
// Ensure watchedRatio is treated as a number and sort in descending order
|
||||||
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
|
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
|
||||||
|
|
||||||
@@ -117,6 +233,9 @@ window.onload = async function () {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading user statistics:', error);
|
console.error('Error loading user statistics:', error);
|
||||||
|
// Still attempt to render these sections even if stats fail (best effort)
|
||||||
|
try { renderWinners(await fetchWinners()); } catch (_) {}
|
||||||
|
try { renderCompletions(await fetchCompletions()); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log-off functionality
|
// Log-off functionality
|
||||||
|
|||||||
@@ -62,6 +62,24 @@
|
|||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<h2>Statistiques des utilisateurs</h2>
|
<h2>Statistiques des utilisateurs</h2>
|
||||||
|
|
||||||
|
<!-- Winners section -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4 class="mb-2">Gagnants</h4>
|
||||||
|
<div class="text-muted mb-2">Définis dans l’admin (optionnel).</div>
|
||||||
|
<div id="winners-list" class="list-group mb-4">
|
||||||
|
<div class="list-group-item text-muted">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 100% completers section -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4 class="mb-2">Utilisateurs ayant terminé 100% (par année)</h4>
|
||||||
|
<div class="text-muted mb-2">Completion = 100% pour l’année (tous les films de l’année vus).</div>
|
||||||
|
<div id="completers-by-year" class="mb-4">
|
||||||
|
<div class="text-muted">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Spinner shown while loading -->
|
<!-- Spinner shown while loading -->
|
||||||
<div id="loading-spinner" class="spinner-border text-primary" role="status">
|
<div id="loading-spinner" class="spinner-border text-primary" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
const Setting = require("../models/Setting");
|
const Setting = require("../models/Setting");
|
||||||
const Movie = require("../models/Movie");
|
const Movie = require("../models/Movie");
|
||||||
|
const User = require("../models/User");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const ACTIVE_YEAR_KEY = "active_oscar_year";
|
const ACTIVE_YEAR_KEY = "active_oscar_year";
|
||||||
const COMPLETION_MODAL_KEY = "completion_modal_content";
|
const COMPLETION_MODAL_KEY = "completion_modal_content";
|
||||||
|
const WINNERS_BY_YEAR_KEY = "winners_by_year";
|
||||||
|
|
||||||
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
||||||
title: "Félicitations! very nice 🎉🎉🎉",
|
title: "Félicitations! very nice 🎉🎉🎉",
|
||||||
@@ -24,6 +27,31 @@ function parseOscarYear(raw) {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalPoints(raw) {
|
||||||
|
if (raw === undefined) return null;
|
||||||
|
if (raw === null) return null;
|
||||||
|
if (raw === "") return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
// Points can be any finite number, but we store a rounded int for consistency.
|
||||||
|
return Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWinnersByYear(rawValue) {
|
||||||
|
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
const year = parseOscarYear(k);
|
||||||
|
if (!year) continue;
|
||||||
|
const entry = v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
||||||
|
const userId = typeof entry.userId === "string" ? entry.userId.trim() : "";
|
||||||
|
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) continue;
|
||||||
|
const points = parseOptionalPoints(entry.points);
|
||||||
|
out[String(year)] = { userId, points };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeHtml(raw) {
|
function sanitizeHtml(raw) {
|
||||||
// Basic, defensive sanitization (admin-controlled input, rendered for all users).
|
// Basic, defensive sanitization (admin-controlled input, rendered for all users).
|
||||||
// We remove script tags and inline event handlers. This is not a complete HTML sanitizer.
|
// We remove script tags and inline event handlers. This is not a complete HTML sanitizer.
|
||||||
@@ -153,5 +181,108 @@ router.put("/completion-modal", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public: get winners by year (optionally with points)
|
||||||
|
router.get("/winners", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
|
||||||
|
const normalized = normalizeWinnersByYear(existing?.value);
|
||||||
|
|
||||||
|
const entries = Object.entries(normalized)
|
||||||
|
.map(([yearStr, v]) => ({
|
||||||
|
year: Number(yearStr),
|
||||||
|
userId: v.userId,
|
||||||
|
points: v.points ?? null,
|
||||||
|
}))
|
||||||
|
.filter((e) => parseOscarYear(e.year))
|
||||||
|
.sort((a, b) => b.year - a.year);
|
||||||
|
|
||||||
|
const userIds = entries.map((e) => e.userId).filter(Boolean);
|
||||||
|
const users = userIds.length
|
||||||
|
? await User.find({ _id: { $in: userIds } }).select("name").lean()
|
||||||
|
: [];
|
||||||
|
const nameById = new Map(users.map((u) => [String(u._id), u.name]));
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
winners: entries.map((e) => ({
|
||||||
|
year: e.year,
|
||||||
|
userId: e.userId,
|
||||||
|
name: nameById.get(String(e.userId)) || null,
|
||||||
|
points: e.points ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin-only: set/clear winner for a given year. Points optional.
|
||||||
|
// Body: { userId: "<mongoId>" | "" | null, points?: number|null }
|
||||||
|
router.put("/winners/:year", async (req, res) => {
|
||||||
|
const token = req.headers.authorization?.split(" ")[1];
|
||||||
|
if (!token) return res.status(401).json({ message: "Authentication token is required" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
if (!decoded.admin) {
|
||||||
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = parseOscarYear(req.params?.year);
|
||||||
|
if (!year) {
|
||||||
|
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUserId = req.body?.userId;
|
||||||
|
const userId = typeof rawUserId === "string" ? rawUserId.trim() : rawUserId === null ? "" : "";
|
||||||
|
const points = parseOptionalPoints(req.body?.points);
|
||||||
|
|
||||||
|
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
|
||||||
|
const winners = normalizeWinnersByYear(existing?.value);
|
||||||
|
|
||||||
|
// Clear winner for year
|
||||||
|
if (!userId) {
|
||||||
|
delete winners[String(year)];
|
||||||
|
await Setting.findOneAndUpdate(
|
||||||
|
{ key: WINNERS_BY_YEAR_KEY },
|
||||||
|
{ $set: { value: winners } },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
);
|
||||||
|
return res.status(200).json({ year, winner: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(userId)) {
|
||||||
|
return res.status(400).json({ message: "userId invalide" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnerUser = await User.findById(userId).select("name").lean();
|
||||||
|
if (!winnerUser) {
|
||||||
|
return res.status(404).json({ message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
winners[String(year)] = { userId: String(userId), points };
|
||||||
|
|
||||||
|
await Setting.findOneAndUpdate(
|
||||||
|
{ key: WINNERS_BY_YEAR_KEY },
|
||||||
|
{ $set: { value: winners } },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
year,
|
||||||
|
winner: {
|
||||||
|
year,
|
||||||
|
userId: String(userId),
|
||||||
|
name: winnerUser.name || null,
|
||||||
|
points: points ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof jwt.JsonWebTokenError) {
|
||||||
|
return res.status(401).json({ message: "Invalid or expired token" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,85 @@ router.get("/stats", verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get users who have 100% completion per year (across all years in DB)
|
||||||
|
// Returns: { years: number[], totals: { [year]: number }, completersByYear: { [year]: {name, watchedCount, totalMoviesCount}[] } }
|
||||||
|
router.get("/completions", verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const yearsRaw = await Movie.distinct("year");
|
||||||
|
const years = (Array.isArray(yearsRaw) ? yearsRaw : [])
|
||||||
|
.map((y) => Number(y))
|
||||||
|
.filter((y) => parseOscarYear(y))
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
|
||||||
|
if (years.length === 0) {
|
||||||
|
return res.status(200).json({ years: [], totals: {}, completersByYear: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch imdb_id + year for all movies that have a valid year
|
||||||
|
const movies = await Movie.find({ year: { $in: years } }).select("imdb_id year").lean();
|
||||||
|
|
||||||
|
const imdbIdsByYear = new Map();
|
||||||
|
const totals = {};
|
||||||
|
|
||||||
|
movies.forEach((m) => {
|
||||||
|
const year = Number(m?.year);
|
||||||
|
if (!parseOscarYear(year)) return;
|
||||||
|
const imdb = typeof m?.imdb_id === "string" ? m.imdb_id : "";
|
||||||
|
if (!imdb) return;
|
||||||
|
if (!imdbIdsByYear.has(year)) imdbIdsByYear.set(year, new Set());
|
||||||
|
imdbIdsByYear.get(year).add(imdb);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const y of years) {
|
||||||
|
const set = imdbIdsByYear.get(y);
|
||||||
|
totals[String(y)] = set ? set.size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await User.find().select("name watchedMovies.imdb_id").lean();
|
||||||
|
|
||||||
|
const completersByYear = {};
|
||||||
|
for (const y of years) {
|
||||||
|
const total = totals[String(y)] || 0;
|
||||||
|
if (total <= 0) {
|
||||||
|
completersByYear[String(y)] = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const yearSet = imdbIdsByYear.get(y) || new Set();
|
||||||
|
|
||||||
|
const completers = [];
|
||||||
|
users.forEach((u) => {
|
||||||
|
const watched = Array.isArray(u?.watchedMovies) ? u.watchedMovies : [];
|
||||||
|
const watchedSet = new Set(
|
||||||
|
watched
|
||||||
|
.map((wm) => (typeof wm?.imdb_id === "string" ? wm.imdb_id : ""))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const imdb of watchedSet) {
|
||||||
|
if (yearSet.has(imdb)) count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === total) {
|
||||||
|
completers.push({
|
||||||
|
name: u?.name || "(sans nom)",
|
||||||
|
watchedCount: count,
|
||||||
|
totalMoviesCount: total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
completers.sort((a, b) => String(a.name).localeCompare(String(b.name), "fr", { sensitivity: "base" }));
|
||||||
|
completersByYear[String(y)] = completers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ years, totals, completersByYear });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur dans l'extraction des completers:", err);
|
||||||
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Register User
|
// Register User
|
||||||
router.post("/register", async (req, res) => {
|
router.post("/register", async (req, res) => {
|
||||||
const { name, email, password, verifoof } = req.body;
|
const { name, email, password, verifoof } = req.body;
|
||||||
|
|||||||
Reference in New Issue
Block a user