From 7d8077774b2930e147169bdcf7e22637906f8680 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sat, 27 Dec 2025 23:38:23 -0500 Subject: [PATCH] Add unsaved changes tracking with banner and button state management in picks tab --- public/js/app_picks.js | 182 +++++++++++++++++++++++++++++++++++++---- public/picks.html | 56 ++++++++++++- 2 files changed, 221 insertions(+), 17 deletions(-) diff --git a/public/js/app_picks.js b/public/js/app_picks.js index 50fdb38..4b6fd51 100644 --- a/public/js/app_picks.js +++ b/public/js/app_picks.js @@ -39,7 +39,9 @@ let categories = []; let myPicks = {}; + let savedPicks = {}; // Track saved state to detect changes let currentYear = null; + let hasUnsavedChanges = false; async function fetchActiveYear() { try { @@ -131,11 +133,20 @@ const data = await res.json(); if (data.pick && data.pick.picks) { myPicks = {}; + savedPicks = {}; // Reset saved state data.pick.picks.forEach(p => { myPicks[p.categoryNumber] = p.selectedNominee; + savedPicks[p.categoryNumber] = p.selectedNominee; // Store saved state }); + hasUnsavedChanges = false; renderCategories(); updateProgress(); + updateUnsavedChangesIndicator(); + } else { + // No picks saved yet + savedPicks = {}; + hasUnsavedChanges = false; + updateUnsavedChangesIndicator(); } } catch (err) { console.error('Error loading picks:', err); @@ -153,12 +164,12 @@ container.innerHTML = `
- +
- + @@ -166,22 +177,26 @@ ${categories.map(cat => { const selectedNominee = myPicks[cat.categoryNumber]; return ` - - - + + + ${validScores.map((score, index) => { - const percentage = totalCategories > 0 ? Math.round((score.score / totalCategories) * 100) : 0; + // Use the pre-calculated actualScore (always use it, even if 0) + const actualScore = score.actualScore !== undefined ? score.actualScore : (score.score || 0); + const percentage = totalCategories > 0 ? Math.round((actualScore / totalCategories) * 100) : 0; const rank = index + 1; const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ''; return ` - +
# CatégorieChoixChoix Nommés
${cat.categoryNumber}${cat.categoryName}
${cat.categoryNumber}${cat.categoryName} - ${selectedNominee ? `${selectedNominee}` : '—'} + ${selectedNominee ? ` + + ${selectedNominee} + + ` : 'Aucun choix'} -
+
${cat.nominees.map(nominee => { const isSelected = selectedNominee === nominee.name; return ` - - ${nominee.name} + ${isSelected ? '✓' : ''} + ${nominee.name} `; }).join('')} @@ -212,12 +227,73 @@ // Re-render to update the display renderCategories(); + checkForUnsavedChanges(); }); }); updateProgress(); } + function checkForUnsavedChanges() { + // Compare current picks with saved picks + const currentKeys = Object.keys(myPicks).sort(); + const savedKeys = Object.keys(savedPicks).sort(); + + // Check if counts differ + if (currentKeys.length !== savedKeys.length) { + hasUnsavedChanges = true; + updateUnsavedChangesIndicator(); + return; + } + + // Check if any values differ or keys are different + const allKeys = new Set([...currentKeys, ...savedKeys]); + for (const key of allKeys) { + if (myPicks[key] !== savedPicks[key]) { + hasUnsavedChanges = true; + updateUnsavedChangesIndicator(); + return; + } + } + + // No changes detected + hasUnsavedChanges = false; + updateUnsavedChangesIndicator(); + } + + function updateUnsavedChangesIndicator() { + const submitBtn = document.getElementById('submit-picks'); + const banner = document.getElementById('unsaved-changes-banner'); + + if (hasUnsavedChanges) { + // Enable button and change style + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.classList.add('btn-warning'); + submitBtn.classList.remove('btn-primary'); + } + // Show banner + if (banner) { + banner.classList.remove('d-none'); + if (!banner.classList.contains('show')) { + banner.classList.add('show'); + } + } + } else { + // Disable button and change style + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.classList.remove('btn-warning'); + submitBtn.classList.add('btn-primary'); + } + // Hide banner + if (banner) { + banner.classList.add('d-none'); + banner.classList.remove('show'); + } + } + } + function updateProgress() { const total = categories.length; const completed = Object.keys(myPicks).length; @@ -260,6 +336,10 @@ } const data = await res.json(); + // Update saved state after successful save + savedPicks = { ...myPicks }; + hasUnsavedChanges = false; + updateUnsavedChangesIndicator(); showAlert('Vos choix ont été enregistrés avec succès!', 'success'); } catch (err) { console.error('Error submitting picks:', err); @@ -485,12 +565,26 @@ if (!container) return; // Filter out scores for removed users (Unknown users or null userId) - const validScores = scores.filter(score => + let validScores = scores.filter(score => score.userName && score.userName !== 'Unknown' && score.userId ); + // Calculate actual scores and sort by them + validScores = validScores.map(score => { + // Always calculate actual score from pickDetails if available (more accurate than stored score) + let actualScore; + if (score.pickDetails && Array.isArray(score.pickDetails) && score.pickDetails.length > 0) { + // Calculate from pickDetails - this is the source of truth + actualScore = score.pickDetails.filter(p => p.isCorrect === true).length; + } else { + // Fallback to stored score only if pickDetails not available + actualScore = score.score || 0; + } + return { ...score, actualScore }; + }).sort((a, b) => b.actualScore - a.actualScore); + if (validScores.length === 0) { container.innerHTML = '
Aucun score disponible. Les utilisateurs doivent d\'abord soumettre leurs choix.
'; return; @@ -511,14 +605,16 @@
${rank}${medal ? ' ' + medal : ''} ${score.userName}${score.score}${actualScore} ${totalCategories} ${percentage}% @@ -563,6 +659,10 @@ // Separate correct and incorrect picks const correctPicks = score.pickDetails.filter(p => p.isCorrect); const incorrectPicks = score.pickDetails.filter(p => !p.isCorrect); + + // Calculate actual score from pickDetails (more accurate than stored score) + const actualScore = correctPicks.length; + const totalCategories = score.totalCategories || score.pickDetails.length; modalContent.innerHTML = `
@@ -576,8 +676,8 @@
-
${score.score} / ${score.totalCategories}
-
${Math.round((score.score / score.totalCategories) * 100)}%
+
${actualScore} / ${totalCategories}
+
${totalCategories > 0 ? Math.round((actualScore / totalCategories) * 100) : 0}%
@@ -655,6 +755,14 @@ // Load scores when scores tab is shown const scoresTab = document.getElementById('tab-scores'); if (scoresTab) { + scoresTab.addEventListener('show.bs.tab', (e) => { + if (hasUnsavedChanges) { + if (!confirm('Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir changer d\'onglet? Vos modifications seront perdues.')) { + e.preventDefault(); + return false; + } + } + }); scoresTab.addEventListener('shown.bs.tab', async () => { await fetchScores(); }); @@ -663,6 +771,14 @@ // Load winners when admin tab is shown const winnersTab = document.getElementById('tab-winners'); if (winnersTab) { + winnersTab.addEventListener('show.bs.tab', (e) => { + if (hasUnsavedChanges) { + if (!confirm('Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir changer d\'onglet? Vos modifications seront perdues.')) { + e.preventDefault(); + return false; + } + } + }); winnersTab.addEventListener('shown.bs.tab', async () => { await fetchWinnersCategories(); // Auto-refresh scores when entering the winners tab (silent mode) @@ -676,8 +792,44 @@ showAlert('Scores rafraîchis', 'info'); }); + // Warn before leaving page with unsaved changes + window.addEventListener('beforeunload', (e) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = 'Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page?'; + return e.returnValue; + } + }); + + // Intercept navigation links to warn about unsaved changes + document.addEventListener('click', (e) => { + // Check if clicking on a navigation link + const link = e.target.closest('a[href]'); + if (!link) return; + + // Skip if it's the same page or a hash link + const href = link.getAttribute('href'); + if (!href || href.startsWith('#') || href.startsWith('javascript:')) return; + + // Skip if it's the submit button or other action buttons + if (link.closest('#pane-my-picks') || link.id === 'submit-picks') return; + + // Check for unsaved changes + if (hasUnsavedChanges) { + e.preventDefault(); + const confirmed = confirm('Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page? Vos modifications seront perdues.'); + if (confirmed) { + hasUnsavedChanges = false; // Allow navigation + window.location.href = href; + } + } + }); + // Initial load (async () => { + // Initialize button state (disabled by default) + updateUnsavedChangesIndicator(); + currentYear = await fetchActiveYear(); document.title = `Pool Oscars (${currentYear}) - Mes choix`; await fetchCategories(); diff --git a/public/picks.html b/public/picks.html index f4a69e4..14c37e0 100644 --- a/public/picks.html +++ b/public/picks.html @@ -29,10 +29,57 @@ .nominee-option { cursor: pointer; transition: all 0.2s; + position: relative; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; } .nominee-option:hover { - opacity: 0.8; - transform: scale(1.05); + transform: translateY(-2px); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + .nominee-option.selected { + font-weight: 600; + box-shadow: 0 2px 6px rgba(13, 110, 253, 0.3); + } + .nominee-checkmark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.3); + font-size: 0.75rem; + font-weight: bold; + } + .nominee-option.selected .nominee-checkmark { + background-color: rgba(255, 255, 255, 0.9); + color: #0d6efd; + } + .picks-table { + border-collapse: separate; + border-spacing: 0; + } + .picks-table tbody tr { + transition: background-color 0.2s; + } + .picks-table tbody tr:hover { + background-color: rgba(13, 110, 253, 0.05); + } + .picks-table tbody tr.has-selection { + background-color: rgba(13, 110, 253, 0.08); + border-left: 3px solid #0d6efd; + } + .selected-choice-badge { + font-weight: 600; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; } .table-sm td { padding: 0.5rem; @@ -125,6 +172,11 @@ + +
Chargement des catégories…