feat: Add winners tab and 100% completion stats

Adds a new section to the admin control panel for managing yearly winners.
Also includes a new section on the user statistics page to display users
who have completed 100% of movies for each year.

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-21 20:38:01 +00:00
co-authored by mathieumoran1
parent 5a2b40357e
commit 9a7063b9dc
6 changed files with 666 additions and 6 deletions
+265
View File
@@ -62,6 +62,16 @@ window.onload = async function () {
const selectAllBox = document.getElementById('select-all-movies');
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 editMovieModal = editMovieModalEl ? new bootstrap.Modal(editMovieModalEl) : null;
const editMovieModalTitleEl = document.getElementById('editMovieModalTitle');
@@ -83,6 +93,10 @@ window.onload = async function () {
let moviesById = new Map();
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({
title: 'Félicitations! very nice 🎉🎉🎉',
@@ -460,6 +474,168 @@ window.onload = async function () {
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() {
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
@@ -1001,6 +1177,91 @@ window.onload = async function () {
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) {
e.preventDefault();
@@ -1135,6 +1396,10 @@ window.onload = async function () {
activeYearInput.value = desired;
}
}
// Winners year dropdown (optional tab)
setWinnerYearOptions(cleanedYears);
applyWinnerFormFromCache();
} catch (_) {
// ignore
}
+125 -6
View File
@@ -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) {
try {
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 statsRes = await fetch(statsUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
const [statsRes, winners, completions] = await Promise.all([
fetch(statsUrl, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
}),
fetchWinners(),
fetchCompletions(),
]);
if (!statsRes.ok) {
throw new Error('Failed to fetch user stats');
@@ -68,6 +181,9 @@ window.onload = async function () {
const table = document.querySelector('.user-table');
const spinner = document.getElementById('loading-spinner');
renderWinners(winners);
renderCompletions(completions);
// Ensure watchedRatio is treated as a number and sort in descending order
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
@@ -117,6 +233,9 @@ window.onload = async function () {
});
} catch (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