- ${cat.nominees.map(nominee => {
- const isSelected = myPicks[cat.categoryNumber] === nominee.name;
+ container.innerHTML = `
+
+
+
+
+ | # |
+ Catégorie |
+ Choix |
+ Nommés |
+
+
+
+ ${categories.map(cat => {
+ const selectedNominee = myPicks[cat.categoryNumber];
return `
-
+
+ | ${cat.categoryNumber} |
+ ${cat.categoryName} |
+
+ ${selectedNominee ? `${selectedNominee}` : '—'}
+ |
+
+
+ ${cat.nominees.map(nominee => {
+ const isSelected = selectedNominee === nominee.name;
+ return `
+
+ ${nominee.name}
+
+ `;
+ }).join('')}
+
+ |
+
`;
}).join('')}
-
-
+
+
- `).join('');
+ `;
// Add click handlers
container.querySelectorAll('.nominee-option').forEach(el => {
@@ -186,15 +201,17 @@
const category = parseInt(this.dataset.category);
const nominee = this.dataset.nominee;
- // Remove selection from other options in same category
- container.querySelectorAll(`[data-category="${category}"]`).forEach(opt => {
- opt.classList.remove('selected');
- });
+ // Toggle selection
+ if (myPicks[category] === nominee) {
+ // Deselect if clicking the same nominee
+ delete myPicks[category];
+ } else {
+ // Select this nominee
+ myPicks[category] = nominee;
+ }
- // Select this option
- this.classList.add('selected');
- myPicks[category] = nominee;
- updateProgress();
+ // Re-render to update the display
+ renderCategories();
});
});
@@ -250,144 +267,12 @@
}
}
- // All picks functionality (admin only)
- let allPicks = [];
- let allPicksCategories = [];
-
- async function fetchAllPicks() {
- try {
- // Use active year if not set
- if (!currentYear) {
- currentYear = await fetchActiveYear();
- }
-
- const res = await fetch(`/api/picks/all?year=${currentYear}`, {
- headers: { 'Authorization': `Bearer ${token}` }
- });
-
- if (!res.ok) {
- if (res.status === 401) {
- localStorage.removeItem('auth_token');
- window.location.href = '/';
- return;
- }
- if (res.status === 403) {
- showAlert('Vous n\'avez pas les privilèges administrateur', 'warning');
- return;
- }
- throw new Error('Failed to fetch picks');
- }
-
- const data = await res.json();
- allPicks = data.picks || [];
- allPicksCategories = data.categories || [];
- currentYear = data.year || currentYear;
-
- const yearEl = document.getElementById('all-picks-year');
- if (yearEl) yearEl.textContent = currentYear;
-
- const countEl = document.getElementById('users-count');
- if (countEl) countEl.textContent = allPicks.length;
-
- renderAllPicks();
- } catch (err) {
- console.error('Error fetching all picks:', err);
- showAlert('Erreur lors du chargement des choix: ' + err.message, 'danger');
- }
- }
-
- function renderAllPicks() {
- const container = document.getElementById('all-picks-container');
- if (!container) return;
-
- if (allPicks.length === 0) {
- container.innerHTML = `
-
- Aucun utilisateur n'a encore soumis ses choix pour l'année ${currentYear}.
-
- `;
- return;
- }
-
- // Create a map of category info
- const categoryMap = new Map();
- allPicksCategories.forEach(cat => {
- categoryMap.set(cat.categoryNumber, cat);
- });
-
- container.innerHTML = allPicks.map(pick => {
- const userName = pick.userId?.name || 'Utilisateur inconnu';
- const userEmail = pick.userId?.email || '';
- const submittedDate = pick.submittedAt ? new Date(pick.submittedAt).toLocaleString('fr-FR') : '—';
-
- // Create a map of picks by category
- const picksMap = new Map();
- pick.picks.forEach(p => {
- picksMap.set(p.categoryNumber, p.selectedNominee);
- });
-
- // Build picks list
- const picksList = allPicksCategories.map(cat => {
- const selected = picksMap.get(cat.categoryNumber);
- if (!selected) return null;
-
- // Check if it's a winner (if winners are set)
- const nominee = cat.nominees.find(n => n.name === selected);
- const isWinner = nominee?.isWinner || false;
-
- return `
-
-
-
- ${cat.categoryNumber}. ${cat.categoryName}
- ${selected}
-
- ${isWinner ? '
Gagnant' : ''}
-
-
- `;
- }).filter(Boolean).join('');
-
- return `
-
-
-
- ${picksList || '
Aucun choix enregistré
'}
-
-
- `;
- }).join('');
- }
-
// Event listeners
document.getElementById('submit-picks')?.addEventListener('click', submitPicks);
document.getElementById('load-my-picks')?.addEventListener('click', () => {
loadMyPicks();
showAlert('Choix rechargés', 'info');
});
- document.getElementById('refresh-all-picks')?.addEventListener('click', () => {
- fetchAllPicks();
- showAlert('Choix rafraîchis', 'info');
- });
-
- // Load all picks when admin tab is shown
- const allPicksTab = document.getElementById('tab-all-picks');
- if (allPicksTab) {
- allPicksTab.addEventListener('shown.bs.tab', () => {
- fetchAllPicks();
- });
- }
// Winners & Scores functionality (admin only)
let winnersCategories = [];
@@ -437,28 +322,42 @@
const progressEl = document.getElementById('winners-progress');
if (progressEl) progressEl.textContent = `${winnersCount} / ${winnersCategories.length}`;
- container.innerHTML = winnersCategories.map(cat => {
- const winner = cat.nominees.find(n => n.isWinner);
- return `
-
-
-
-
-
-
-
- ${winner ? `
Gagnant actuel: ${winner.name}
` : ''}
-
-
- `;
- }).join('');
+ container.innerHTML = `
+
+
+
+
+ | # |
+ Catégorie |
+ Sélection |
+ Gagnant |
+
+
+
+ ${winnersCategories.map(cat => {
+ const winner = cat.nominees.find(n => n.isWinner);
+ return `
+
+ | ${cat.categoryNumber} |
+ ${cat.categoryName} |
+
+
+ |
+
+ ${winner ? `${winner.name}` : '—'}
+ |
+
+ `;
+ }).join('')}
+
+
+
+ `;
// Add event listeners for winner selection
container.querySelectorAll('.winner-select').forEach(select => {
@@ -466,34 +365,35 @@
const categoryId = this.dataset.categoryId;
const winnerName = this.value;
- if (!winnerName) return;
-
try {
+ // If empty value, clear the winner (set to null/empty)
const res = await fetch(`/api/categories/${categoryId}/winner`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
- body: JSON.stringify({ winnerName })
+ body: JSON.stringify({ winnerName: winnerName || null })
});
if (!res.ok) {
const error = await res.json();
- throw new Error(error.message || 'Failed to mark winner');
+ throw new Error(error.message || 'Failed to update winner');
}
- showAlert('Gagnant marqué avec succès', 'success');
+ showAlert(winnerName ? 'Gagnant marqué avec succès' : 'Gagnant supprimé', 'success');
await fetchWinnersCategories();
+ // Auto-refresh scores after marking/clearing a winner (silent mode)
+ await calculateScores(true);
} catch (err) {
- console.error('Error marking winner:', err);
+ console.error('Error updating winner:', err);
showAlert('Erreur: ' + err.message, 'danger');
}
});
});
}
- async function calculateScores() {
+ async function calculateScores(silent = false) {
try {
if (!currentYear) {
currentYear = await fetchActiveYear();
@@ -516,15 +416,29 @@
if (!res.ok) {
const error = await res.json();
+ // If no winners marked, handle gracefully (especially for auto-refresh)
+ if (error.message && error.message.includes('No winners marked')) {
+ if (!silent) {
+ // Only show as info/warning, not error
+ showAlert('Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.', 'info');
+ }
+ // Still try to fetch existing scores
+ await fetchScores();
+ return;
+ }
throw new Error(error.message || 'Failed to calculate scores');
}
const data = await res.json();
- showAlert(`Scores calculés avec succès! ${data.scores.length} utilisateur(s) évalué(s).`, 'success');
+ if (!silent) {
+ showAlert(`Scores calculés avec succès! ${data.scores.length} utilisateur(s) évalué(s).`, 'success');
+ }
await fetchScores();
} catch (err) {
console.error('Error calculating scores:', err);
- showAlert('Erreur: ' + err.message, 'danger');
+ if (!silent) {
+ showAlert('Erreur: ' + err.message, 'danger');
+ }
} finally {
const btn = document.getElementById('calculate-scores');
if (btn) {
@@ -540,21 +454,29 @@
currentYear = await fetchActiveYear();
}
+ // Update scores year display
+ const scoresYearEl = document.getElementById('scores-year');
+ if (scoresYearEl) {
+ scoresYearEl.textContent = currentYear;
+ }
+
const res = await fetch(`/api/picks/scores?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
- if (res.status === 403) {
- return; // Not admin, skip
- }
- throw new Error('Failed to fetch scores');
+ const errorData = await res.json().catch(() => ({ message: 'Failed to fetch scores' }));
+ throw new Error(errorData.message || 'Failed to fetch scores');
}
const data = await res.json();
renderScores(data.scores || [], data.totalCategories || 0);
} catch (err) {
console.error('Error fetching scores:', err);
+ const container = document.getElementById('scores-container');
+ if (container) {
+ container.innerHTML = '
Erreur lors du chargement des scores.
';
+ }
}
}
@@ -569,15 +491,15 @@
container.innerHTML = `
-
-
+
+
- | # |
+ # |
Utilisateur |
- Score |
- Total |
- % |
- Actions |
+ Score |
+ Total |
+ % |
+ Actions |
@@ -588,13 +510,13 @@
return `
| ${rank}${medal ? ' ' + medal : ''} |
- ${score.userName}${score.userEmail ? ` (${score.userEmail})` : ''} |
+ ${score.userName} |
${score.score} |
- ${totalCategories} |
+ ${totalCategories} |
${percentage}% |
|
@@ -723,19 +645,53 @@
showAlert('Données rafraîchies', 'info');
});
+ // Load scores when scores tab is shown
+ const scoresTab = document.getElementById('tab-scores');
+ if (scoresTab) {
+ scoresTab.addEventListener('shown.bs.tab', async () => {
+ await fetchScores();
+ });
+ }
+
// Load winners when admin tab is shown
const winnersTab = document.getElementById('tab-winners');
if (winnersTab) {
- winnersTab.addEventListener('shown.bs.tab', () => {
- fetchWinnersCategories();
+ winnersTab.addEventListener('shown.bs.tab', async () => {
+ await fetchWinnersCategories();
+ // Auto-refresh scores when entering the winners tab (silent mode)
+ await calculateScores(true);
});
}
+ // Refresh scores button
+ document.getElementById('refresh-scores')?.addEventListener('click', async () => {
+ await fetchScores();
+ showAlert('Scores rafraîchis', 'info');
+ });
+
// Initial load
(async () => {
currentYear = await fetchActiveYear();
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
await fetchCategories();
+
+ // Auto-refresh scores on page load if scores tab is active
+ const scoresTab = document.getElementById('tab-scores');
+ const scoresPane = document.getElementById('pane-scores');
+ if (scoresTab && scoresPane && (scoresTab.classList.contains('active') || scoresPane.classList.contains('active'))) {
+ await fetchScores();
+ }
+
+ // Auto-refresh winners on page load if admin and winners tab is active
+ if (decoded.admin) {
+ const winnersTab = document.getElementById('tab-winners');
+ const winnersPane = document.getElementById('pane-winners');
+ // Check if winners tab is active (either by default or if it becomes active)
+ if (winnersTab && winnersPane && (winnersTab.classList.contains('active') || winnersPane.classList.contains('active'))) {
+ await fetchWinnersCategories();
+ await calculateScores(true); // Silent mode for auto-refresh
+ }
+ }
})();
})();
diff --git a/public/js/app_user-stat.js b/public/js/app_user-stat.js
index 7ce213c..44a9a27 100644
--- a/public/js/app_user-stat.js
+++ b/public/js/app_user-stat.js
@@ -649,6 +649,24 @@ window.addEventListener('DOMContentLoaded', async function () {
pageLoader.setProgress(70);
const stats = await statsRes.json();
const userTableBody = document.getElementById('user-table-body');
+
+ // Check visibility config for Bon picks column
+ let showBonPicksColumn = true;
+ try {
+ const visibilityRes = await fetch('/api/settings/visibility-config', { cache: 'no-store' });
+ if (visibilityRes.ok) {
+ const visibilityConfig = await visibilityRes.json();
+ showBonPicksColumn = visibilityConfig.showBonPicksColumn !== false;
+ }
+ } catch (err) {
+ console.error('Error loading visibility config:', err);
+ }
+
+ // Hide/show Bon picks header
+ const bonPicksHeader = document.getElementById('bon-picks-header');
+ if (bonPicksHeader) {
+ bonPicksHeader.style.display = showBonPicksColumn ? '' : 'none';
+ }
// Sort by total points (descending), then by watched ratio
stats.sort((a, b) => {
@@ -686,6 +704,9 @@ window.addEventListener('DOMContentLoaded', async function () {
const picksTd = document.createElement('td');
const pickPointsConfig = userStat.pointsConfig || { pointsPerCorrectPick: 1 };
picksTd.innerHTML = `${userStat.correctPicks || 0} (${userStat.pickPoints || 0} pts)`;
+ if (!showBonPicksColumn) {
+ picksTd.style.display = 'none';
+ }
const pointsTd = document.createElement('td');
pointsTd.className = 'text-center';
diff --git a/public/movies.html b/public/movies.html
index 29853d0..a266569 100644
--- a/public/movies.html
+++ b/public/movies.html
@@ -59,7 +59,7 @@
Films
Checklist
Statistiques
- Mes choix
+ Mes picks
Admin
diff --git a/public/picks.html b/public/picks.html
index 3aa4a75..cb0eb2c 100644
--- a/public/picks.html
+++ b/public/picks.html
@@ -3,7 +3,7 @@
- Pool Oscars - Mes choix
+ Pool Oscars - Mes picks
@@ -26,22 +26,21 @@
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
- .category-card {
- margin-bottom: 1.5rem;
- }
.nominee-option {
cursor: pointer;
- transition: background-color 0.2s;
+ transition: all 0.2s;
}
.nominee-option:hover {
- background-color: #f8f9fa;
+ opacity: 0.8;
+ transform: scale(1.05);
}
- .nominee-option.selected {
- background-color: #0d6efd;
- color: white;
+ .table-sm td {
+ padding: 0.5rem;
+ vertical-align: middle;
}
- .nominee-option.selected:hover {
- background-color: #0b5ed7;
+ .table-sm th {
+ padding: 0.5rem;
+ font-size: 0.875rem;
}
.user-picks-card {
margin-bottom: 1.5rem;
@@ -76,7 +75,7 @@
Films
Checklist
Statistiques
- Mes choix
+ Mes picks
Admin
@@ -88,7 +87,7 @@
-
Mes choix Oscars
+
Mes picks Oscars
@@ -96,18 +95,17 @@
-
-
-
-
- Gagnants & Scores
+ Gagnants
Admin
@@ -116,93 +114,53 @@
-
-
-
-
-
Année: —
-
Sélectionne tes choix pour chaque catégorie
-
-
- Recharger mes choix
- Enregistrer mes choix
-
-
-
-
- Progression: 0 / 0 catégories complétées
-
-
+
+
+ Année: —
+ Progression: 0 / 0
+
+
+ Recharger
+ Enregistrer
-
Chargement des catégories…
+
Chargement des catégories…
-
-
-
-
-
-
-
Année: —
-
Vue d'ensemble de tous les choix des utilisateurs
-
-
- Rafraîchir
-
-
-
-
- 0 utilisateur(s) ont soumis leurs choix
-
-
+
+
+
+
+ Année: —
+
+
+ Rafraîchir
-
-
Chargement des choix…
+
+
Les scores seront calculés après avoir marqué les gagnants.
-
+
-
-
-
-
-
Année: —
-
Marquez les gagnants et calculez les scores
-
-
- Calculer les scores
- Rafraîchir
-
-
-
-
- 0 / 0 catégories avec gagnants marqués
-
-
+
+
+ Année: —
+ 0 / 0 gagnants marqués
+
+
+ Calculer
+ Rafraîchir
-
Chargement des catégories…
-
-
-
-
-
-
-
-
Les scores seront calculés après avoir marqué les gagnants.
-
-
+
Chargement des catégories…
diff --git a/public/player.html b/public/player.html
index af025a0..03a8087 100644
--- a/public/player.html
+++ b/public/player.html
@@ -127,7 +127,7 @@
Films
Checklist
Statistiques
-
Mes choix
+
Mes picks
Admin
@@ -112,7 +112,7 @@
# |
Nom |
Films |
-
Bonnes réponses |
+
Points |
Ratio |
diff --git a/public/view-picks.html b/public/view-picks.html
index b21cb41..dc8b213 100644
--- a/public/view-picks.html
+++ b/public/view-picks.html
@@ -59,7 +59,7 @@
Films
Checklist
Statistiques
-
Mes choix
+
Mes picks
Admin
Voir Tous les Choix
diff --git a/routes/picks.js b/routes/picks.js
index 0c58e8d..8b71bd9 100644
--- a/routes/picks.js
+++ b/routes/picks.js
@@ -248,13 +248,9 @@ router.post("/calculate-scores", verifyToken, async (req, res) => {
}
});
-// Get scores for all users (admin only)
+// Get scores for all users (available to all authenticated users)
router.get("/scores", verifyToken, async (req, res) => {
try {
- if (!isAdmin(req.user)) {
- return res.status(403).json({ message: "You do not have admin privileges" });
- }
-
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
const picks = await OscarPick.find({ year })
diff --git a/routes/settings.js b/routes/settings.js
index 30ef9e5..e7b7024 100644
--- a/routes/settings.js
+++ b/routes/settings.js
@@ -15,6 +15,7 @@ const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
const APP_VERSION_KEY = "app_version_control";
const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui";
const POINTS_CONFIG_KEY = "points_config";
+const VISIBILITY_CONFIG_KEY = "visibility_config";
const crypto = require("crypto");
@@ -36,6 +37,11 @@ const DEFAULT_POINTS_CONFIG = Object.freeze({
pointsPerCorrectPick: 1,
});
+const DEFAULT_VISIBILITY_CONFIG = Object.freeze({
+ showPicksButton: true,
+ showBonPicksColumn: true,
+});
+
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
@@ -762,6 +768,13 @@ function normalizePointsConfig(rawValue) {
return { pointsPerMovie, pointsPerCorrectPick };
}
+function normalizeVisibilityConfig(rawValue) {
+ const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
+ const showPicksButton = typeof v.showPicksButton === "boolean" ? v.showPicksButton : DEFAULT_VISIBILITY_CONFIG.showPicksButton;
+ const showBonPicksColumn = typeof v.showBonPicksColumn === "boolean" ? v.showBonPicksColumn : DEFAULT_VISIBILITY_CONFIG.showBonPicksColumn;
+ return { showPicksButton, showBonPicksColumn };
+}
+
// Public: get points configuration
router.get("/points-config", async (req, res) => {
try {
@@ -800,5 +813,43 @@ router.put("/points-config", async (req, res) => {
}
});
+// Public: get visibility configuration
+router.get("/visibility-config", async (req, res) => {
+ try {
+ const existing = await Setting.findOne({ key: VISIBILITY_CONFIG_KEY }).select("value");
+ const normalized = normalizeVisibilityConfig(existing?.value);
+ return res.status(200).json(normalized);
+ } catch (err) {
+ return res.status(200).json(DEFAULT_VISIBILITY_CONFIG);
+ }
+});
+
+// Admin-only: set visibility configuration
+router.put("/visibility-config", 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 normalized = normalizeVisibilityConfig(req.body);
+ const updated = await Setting.findOneAndUpdate(
+ { key: VISIBILITY_CONFIG_KEY },
+ { $set: { value: normalized } },
+ { upsert: true, new: true }
+ ).select("value");
+
+ return res.status(200).json(normalizeVisibilityConfig(updated?.value));
+ } 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;
diff --git a/routes/users.js b/routes/users.js
index 0ac3da0..b2d733b 100644
--- a/routes/users.js
+++ b/routes/users.js
@@ -7,6 +7,7 @@ const Movie = require("../models/Movie");
const Setting = require("../models/Setting");
const PlaybackProgress = require("../models/PlaybackProgress");
const OscarPick = require("../models/OscarPick");
+const OscarCategory = require("../models/OscarCategory");
const axios = require("axios");
const router = express.Router();
@@ -153,16 +154,38 @@ router.get("/stats", verifyToken, async (req, res) => {
}
: { pointsPerMovie: 1, pointsPerCorrectPick: 1 };
- // Get all picks for this year with scores
+ // Get all categories with winners marked for this year
+ const categories = await OscarCategory.find({ year }).lean();
+ const categoryWinners = new Map();
+ categories.forEach(cat => {
+ const winner = cat.nominees.find(n => n.isWinner);
+ if (winner) {
+ categoryWinners.set(cat.categoryNumber, winner.name);
+ }
+ });
+
+ // Get all picks for this year and calculate correct picks on-the-fly
let picksByUserId = new Map();
try {
const allPicks = await OscarPick.find({ year })
- .select("userId score")
+ .select("userId picks")
.lean();
allPicks.forEach(pick => {
const userId = String(pick.userId);
- picksByUserId.set(userId, pick.score !== null && pick.score !== undefined ? pick.score : 0);
+ let correctCount = 0;
+
+ // Calculate correct picks based on current winners
+ if (Array.isArray(pick.picks)) {
+ pick.picks.forEach(p => {
+ const correctWinner = categoryWinners.get(p.categoryNumber);
+ if (correctWinner && p.selectedNominee === correctWinner) {
+ correctCount++;
+ }
+ });
+ }
+
+ picksByUserId.set(userId, correctCount);
});
} catch (err) {
console.error("Error fetching picks for stats:", err);