-
Définir le gagnant par année
+
Définir les gagnants par année (égalité)
- Choisis une année, un utilisateur, et des points (optionnel). Laisse le champ points vide si tu ne veux pas l’afficher.
+ Tu peux ajouter plusieurs gagnants pour la même année (ex: égalité au même nombre de points).
+ Choisis un utilisateur + des points (optionnel), puis clique Ajouter .
@@ -413,7 +414,7 @@
—
-
Si tu choisis “—”, le gagnant est supprimé pour l’année.
+
Pour retirer un gagnant, utilise la liste “Gagnants actuels” ci-dessous.
Points (optionnel)
@@ -423,12 +424,12 @@
Recharger
- Supprimer
- Enregistrer
+ Tout supprimer
+ Ajouter
-
Gagnant actuel
+
Gagnants actuels
—
diff --git a/public/js/app_control.js b/public/js/app_control.js
index b732f58..8ba6e91 100644
--- a/public/js/app_control.js
+++ b/public/js/app_control.js
@@ -95,7 +95,7 @@ window.onload = async function () {
let activeYear = null;
let winnersLoadedOnce = false;
let winnerUsersLoadedOnce = false;
- let winnersByYear = new Map(); // year -> { year, userId, name, points }
+ let winnersByYear = new Map(); // year -> [{ year, userId, name, points }]
let winnerUsers = []; // admin list: {id, name, email, ...}
const DEFAULT_COMPLETION_MODAL = Object.freeze({
@@ -484,7 +484,7 @@ window.onload = async function () {
return Array.isArray(data?.winners) ? data.winners : [];
}
- async function saveWinner(year, userId, points) {
+ async function addWinner(year, userId, points) {
const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}`, {
method: 'PUT',
headers: {
@@ -504,6 +504,19 @@ window.onload = async function () {
return data;
}
+ async function removeWinner(year, userId) {
+ const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}/${encodeURIComponent(String(userId))}`, {
+ method: 'DELETE',
+ headers: { 'Authorization': `Bearer ${token}` },
+ });
+ 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 : [];
@@ -579,27 +592,81 @@ window.onload = async function () {
winnerCurrentEl.textContent = '—';
return;
}
- const w = getWinnerForYear(y);
- if (!w) {
+ const list = getWinnerForYear(y);
+ const winners = Array.isArray(list) ? list : [];
+ if (!winners.length) {
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}`;
+
+ // Sort for stable display: points desc (null last), then name
+ const sorted = winners.slice().sort((a, b) => {
+ const ap = a?.points === null || a?.points === undefined ? null : Number(a.points);
+ const bp = b?.points === null || b?.points === undefined ? null : Number(b.points);
+ if (ap === null && bp !== null) return 1;
+ if (ap !== null && bp === null) return -1;
+ if (ap !== null && bp !== null && ap !== bp) return bp - ap;
+ return String(a?.name || '').localeCompare(String(b?.name || ''), 'fr', { sensitivity: 'base' });
+ });
+
+ const rows = sorted.map((w) => {
+ const name = w?.name || '(utilisateur supprimé)';
+ const pts = w?.points === null || w?.points === undefined ? null : Number(w.points);
+ const ptsLabel = pts === null || Number.isNaN(pts) ? '' : `
— ${pts} pts `;
+ const uid = String(w?.userId || '');
+ return `
+
+
${name} ${ptsLabel}
+
Retirer
+
+ `;
+ }).join('');
+
+ winnerCurrentEl.innerHTML = `
+
${y}
+ ${rows}
+ `;
+
+ winnerCurrentEl.querySelectorAll('button[data-winner-remove-year][data-winner-remove-user]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const yearRaw = btn.getAttribute('data-winner-remove-year');
+ const userRaw = btn.getAttribute('data-winner-remove-user');
+ const yy = parseYear(yearRaw);
+ const uid = String(userRaw || '').trim();
+ if (!yy || !uid) return;
+ btn.disabled = true;
+ const old = btn.textContent;
+ btn.textContent = '...';
+ try {
+ await removeWinner(yy, uid);
+ await loadWinners({ force: true });
+ showResponse('success', `Gagnant retiré pour ${yy}.`);
+ } catch (err) {
+ showResponse('danger', err.message || 'Erreur réseau');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = old;
+ }
+ });
+ });
}
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) : '';
+ winnerUserSelect.value = '';
}
if (winnerPointsEl) {
- const pts = w?.points;
- winnerPointsEl.value = pts === null || pts === undefined ? '' : String(pts);
+ // Convenience: if all winners share the same non-null points, pre-fill it.
+ const list = getWinnerForYear(y);
+ const winners = Array.isArray(list) ? list : [];
+ const ptsVals = winners
+ .map((w) => (w?.points === null || w?.points === undefined) ? null : Number(w.points))
+ .filter((n) => Number.isFinite(n));
+ const unique = Array.from(new Set(ptsVals));
+ winnerPointsEl.value = unique.length === 1 ? String(unique[0]) : '';
}
renderWinnerCurrent();
}
@@ -609,19 +676,21 @@ window.onload = async function () {
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,
- }
- ])
- );
+ const grouped = new Map();
+ (Array.isArray(list) ? list : [])
+ .filter((w) => parseYear(w?.year))
+ .forEach((w) => {
+ const key = String(w.year);
+ const arr = grouped.get(key) || [];
+ arr.push({
+ year: Number(w.year),
+ userId: String(w.userId || ''),
+ name: w.name || null,
+ points: w.points ?? null,
+ });
+ grouped.set(key, arr);
+ });
+ winnersByYear = grouped;
applyWinnerFormFromCache();
}
@@ -1219,12 +1288,12 @@ window.onload = async function () {
if (!winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value);
if (!y) return;
- const ok = window.confirm(`Supprimer le gagnant pour ${y} ?`);
+ const ok = window.confirm(`Supprimer tous les gagnants pour ${y} ?`);
if (!ok) return;
try {
if (winnerUserSelect) winnerUserSelect.value = '';
if (winnerPointsEl) winnerPointsEl.value = '';
- await saveWinner(y, '', null);
+ await addWinner(y, '', null);
await loadWinners({ force: true });
showResponse('success', `Gagnant supprimé pour ${y}.`);
} catch (err) {
@@ -1242,6 +1311,10 @@ window.onload = async function () {
return;
}
const userId = String(winnerUserSelect?.value || '').trim();
+ if (!userId) {
+ showResponse('warning', 'Choisis un utilisateur.');
+ return;
+ }
const pointsRaw = String(winnerPointsEl?.value || '').trim();
const points = pointsRaw === '' ? null : Number(pointsRaw);
const pointsToSend = pointsRaw === '' || Number.isNaN(points) ? null : Math.round(points);
@@ -1250,9 +1323,9 @@ window.onload = async function () {
const old = winnerSaveBtn.textContent;
winnerSaveBtn.textContent = 'Enregistrement...';
try {
- await saveWinner(y, userId, pointsToSend);
+ await addWinner(y, userId, pointsToSend);
await loadWinners({ force: true });
- showResponse('success', `Gagnant enregistré pour ${y}.`);
+ showResponse('success', `Gagnant ajouté pour ${y}.`);
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
diff --git a/public/js/app_user-stat.js b/public/js/app_user-stat.js
index b7891af..1ae3d69 100644
--- a/public/js/app_user-stat.js
+++ b/public/js/app_user-stat.js
@@ -49,19 +49,51 @@ window.onload = async function () {
return;
}
- // Ensure sorted by year desc
- list.sort((a, b) => Number(b?.year || 0) - Number(a?.year || 0));
+ // Group by year (supports ties)
+ const byYear = new Map();
+ list.forEach((w) => {
+ const y = Number(w?.year || 0);
+ if (!Number.isInteger(y) || y < 1900 || y > 3000) return;
+ const arr = byYear.get(String(y)) || [];
+ arr.push({
+ year: y,
+ name: w?.name || '(utilisateur supprimé)',
+ points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points),
+ });
+ byYear.set(String(y), arr);
+ });
+
+ const years = Array.from(byYear.keys()).map(Number).sort((a, b) => b - a);
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`;
+ years.forEach((year) => {
+ const winnersForYear = byYear.get(String(year)) || [];
+ const sorted = winnersForYear.slice().sort((a, b) => {
+ const ap = a.points === null || Number.isNaN(a.points) ? null : a.points;
+ const bp = b.points === null || Number.isNaN(b.points) ? null : b.points;
+ if (ap === null && bp !== null) return 1;
+ if (ap !== null && bp === null) return -1;
+ if (ap !== null && bp !== null && ap !== bp) return bp - ap;
+ return String(a.name || '').localeCompare(String(b.name || ''), 'fr', { sensitivity: 'base' });
+ });
+
const item = document.createElement('div');
- item.className = 'list-group-item d-flex justify-content-between align-items-center';
+ item.className = 'list-group-item';
+
+ const winnersHtml = sorted
+ .map((w) => {
+ const pts = w.points === null || Number.isNaN(w.points) ? null : w.points;
+ const ptsLabel = pts === null ? '' : `
— ${pts} pts `;
+ return `
${w.name} ${ptsLabel}
`;
+ })
+ .join('');
+
item.innerHTML = `
-
${year || '—'} : ${name}${pointsLabel}
+
+
${year}
+
${sorted.length}
+
+
${winnersHtml}
`;
winnersList.appendChild(item);
});
diff --git a/routes/settings.js b/routes/settings.js
index 30359eb..c011e59 100644
--- a/routes/settings.js
+++ b/routes/settings.js
@@ -38,17 +38,33 @@ function parseOptionalPoints(raw) {
}
function normalizeWinnersByYear(rawValue) {
+ // Stored format:
+ // - New: { "2026": [ { userId, points? }, { userId, points? } ] }
+ // - Legacy: { "2026": { userId, points? } }
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 };
+
+ const list = Array.isArray(v) ? v : v && typeof v === "object" ? [v] : [];
+ const dedup = new Map(); // userId -> {userId, points}
+
+ for (const rawEntry of list) {
+ const entry = rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {};
+ const userId = typeof entry.userId === "string" ? entry.userId.trim() : "";
+ if (!userId || !mongoose.Types.ObjectId.isValid(userId)) continue;
+ const points = parseOptionalPoints(entry.points);
+ dedup.set(String(userId), { userId: String(userId), points });
+ }
+
+ const normalizedList = Array.from(dedup.values());
+ if (normalizedList.length) {
+ out[String(year)] = normalizedList;
+ }
}
+
return out;
}
@@ -188,11 +204,13 @@ router.get("/winners", async (req, res) => {
const normalized = normalizeWinnersByYear(existing?.value);
const entries = Object.entries(normalized)
- .map(([yearStr, v]) => ({
- year: Number(yearStr),
- userId: v.userId,
- points: v.points ?? null,
- }))
+ .flatMap(([yearStr, list]) =>
+ (Array.isArray(list) ? list : []).map((v) => ({
+ year: Number(yearStr),
+ userId: v.userId,
+ points: v.points ?? null,
+ }))
+ )
.filter((e) => parseOscarYear(e.year))
.sort((a, b) => b.year - a.year);
@@ -215,8 +233,10 @@ router.get("/winners", async (req, res) => {
}
});
-// Admin-only: set/clear winner for a given year. Points optional.
-// Body: { userId: "
" | "" | null, points?: number|null }
+// Admin-only: add/upsert/clear winners for a given year. Points optional.
+// - Clear all for year: { userId: "" } (legacy behavior)
+// - Add/upsert winner for year: { userId: "", points?: number|null }
+// - Replace full list for year: { winners: [{ userId, points? }, ...] }
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" });
@@ -232,14 +252,31 @@ router.put("/winners/:year", async (req, res) => {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
+ const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
+ const winners = normalizeWinnersByYear(existing?.value);
+
+ // Replace list mode: { winners: [...] }
+ if (Array.isArray(req.body?.winners)) {
+ const replacement = normalizeWinnersByYear({ [String(year)]: req.body.winners })[String(year)] || [];
+ if (!replacement.length) {
+ delete winners[String(year)];
+ } else {
+ winners[String(year)] = replacement;
+ }
+ await Setting.findOneAndUpdate(
+ { key: WINNERS_BY_YEAR_KEY },
+ { $set: { value: winners } },
+ { upsert: true, new: true }
+ );
+ return res.status(200).json({ year, winners: winners[String(year)] || [] });
+ }
+
+ // Add/upsert/clear mode (legacy-compatible): { userId, points? }
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
+ // Clear all for year
if (!userId) {
delete winners[String(year)];
await Setting.findOneAndUpdate(
@@ -247,7 +284,7 @@ router.put("/winners/:year", async (req, res) => {
{ $set: { value: winners } },
{ upsert: true, new: true }
);
- return res.status(200).json({ year, winner: null });
+ return res.status(200).json({ year, winners: [] });
}
if (!mongoose.Types.ObjectId.isValid(userId)) {
@@ -255,11 +292,13 @@ router.put("/winners/:year", async (req, res) => {
}
const winnerUser = await User.findById(userId).select("name").lean();
- if (!winnerUser) {
- return res.status(404).json({ message: "User not found" });
- }
+ if (!winnerUser) return res.status(404).json({ message: "User not found" });
- winners[String(year)] = { userId: String(userId), points };
+ const key = String(year);
+ const current = Array.isArray(winners[key]) ? winners[key] : [];
+ const next = current.filter((w) => String(w?.userId || "") !== String(userId));
+ next.push({ userId: String(userId), points });
+ winners[key] = next;
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
@@ -269,12 +308,8 @@ router.put("/winners/:year", async (req, res) => {
return res.status(200).json({
year,
- winner: {
- year,
- userId: String(userId),
- name: winnerUser.name || null,
- points: points ?? null,
- },
+ winner: { year, userId: String(userId), name: winnerUser.name || null, points: points ?? null },
+ winners: winners[String(year)] || [],
});
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
@@ -284,5 +319,47 @@ router.put("/winners/:year", async (req, res) => {
}
});
+// Admin-only: remove a specific winner for a given year.
+router.delete("/winners/:year/:userId", 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 userId = typeof req.params?.userId === "string" ? req.params.userId.trim() : "";
+ if (!mongoose.Types.ObjectId.isValid(userId)) {
+ return res.status(400).json({ message: "userId invalide" });
+ }
+
+ const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
+ const winners = normalizeWinnersByYear(existing?.value);
+ const key = String(year);
+ const current = Array.isArray(winners[key]) ? winners[key] : [];
+ const next = current.filter((w) => String(w?.userId || "") !== String(userId));
+ if (next.length) winners[key] = next;
+ else delete winners[key];
+
+ await Setting.findOneAndUpdate(
+ { key: WINNERS_BY_YEAR_KEY },
+ { $set: { value: winners } },
+ { upsert: true, new: true }
+ );
+
+ return res.status(200).json({ year, winners: winners[key] || [] });
+ } 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;