(function() {
'use strict';
function createPageLoader(options = {}) {
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || t('picks.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
`;
} else {
// Keep screen reader label up to date.
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function setSubtitle(_) {
// Intentionally no-op (we only show the bar now).
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
// Scope the offset to this overlay instance (no global CSS var).
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
// we still need to compute the navbar offset. Otherwise it defaults to 0px
// and the blur covers the header.
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail(_) {
// Don't block the UI on errors: let the page render its own error message.
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, setSubtitle, done, fail };
}
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
return;
}
function decodeJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch {
return null;
}
}
const decoded = decodeJwt(token);
if (!decoded) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
// Check if admin and show admin tabs
if (decoded.admin) {
const adminTabWinners = document.getElementById('tab-winners');
if (adminTabWinners) adminTabWinners.classList.remove('d-none');
}
// Logout
const logOffBtn = document.getElementById('log-off');
if (logOffBtn) {
logOffBtn.addEventListener('click', () => {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
}
let categories = [];
let myPicks = {};
let currentYear = null;
let isSaving = false; // Prevent multiple simultaneous saves
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year');
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
return Number(data.year) || new Date().getFullYear();
} catch (err) {
console.error('Error fetching active year:', err);
return new Date().getFullYear();
}
}
function showAlert(message, type = 'info') {
const container = document.getElementById('alert-container');
if (!container) return;
container.innerHTML = `
${message}
`;
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 5000);
}
async function fetchCategories(pageLoader) {
try {
// Use active year if not set
if (!currentYear) {
currentYear = await fetchActiveYear();
document.title = t('picks.pageTitleWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
}
if (pageLoader) pageLoader.setProgress(18);
const res = await fetch(`/api/picks/categories?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
throw new Error('Failed to fetch categories');
}
if (pageLoader) pageLoader.setProgress(40);
const data = await res.json();
categories = data.categories || [];
currentYear = data.year || currentYear;
document.getElementById('current-year').textContent = currentYear;
if (categories.length === 0) {
const noCats = t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear });
const adminHint = t('picks.adminMustImportCategories', 'An administrator must first import the categories.');
document.getElementById('categories-container').innerHTML = `
${noCats}
${adminHint}
`;
if (pageLoader) pageLoader.done();
return;
}
if (pageLoader) pageLoader.setProgress(60);
renderCategories();
await loadMyPicks(pageLoader);
if (pageLoader) pageLoader.setProgress(85);
} catch (err) {
console.error('Error fetching categories:', err);
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
if (pageLoader) pageLoader.fail();
}
}
async function loadMyPicks(pageLoader) {
try {
if (pageLoader) pageLoader.setProgress(70);
const res = await fetch(`/api/picks/my-picks?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok && res.status !== 404) {
throw new Error('Failed to load picks');
}
if (pageLoader) pageLoader.setProgress(80);
const data = await res.json();
if (data.pick && data.pick.picks) {
myPicks = {};
// Create a map of existing category numbers
const existingCategoryNumbers = new Set(categories.map(c => c.categoryNumber));
// Only load picks for categories that still exist
data.pick.picks.forEach(p => {
if (existingCategoryNumbers.has(p.categoryNumber)) {
myPicks[p.categoryNumber] = p.selectedNominee;
}
});
renderCategories();
updateProgress();
}
} catch (err) {
console.error('Error loading picks:', err);
}
}
function renderCategories() {
const container = document.getElementById('categories-container');
if (!container) return;
if (categories.length === 0) {
container.innerHTML = `${t('picks.noCategoriesAvailable', 'No categories available')}
`;
return;
}
container.innerHTML = `
#
${t('picks.category', 'Category')}
${t('picks.choice', 'Choice')}
${t('picks.nominees', 'Nominees')}
${categories.map(cat => {
const selectedNominee = myPicks[cat.categoryNumber];
return `
${cat.categoryNumber}
${cat.categoryName}
${selectedNominee ? `
${selectedNominee}
` : `${t('picks.noChoice', 'No choice')} `}
${cat.nominees.map(nominee => {
const isSelected = selectedNominee === nominee.name;
return `
${isSelected ? '✓ ' : ''}
${nominee.name}
`;
}).join('')}
`;
}).join('')}
`;
// Add click handlers
container.querySelectorAll('.nominee-option').forEach(el => {
el.addEventListener('click', function() {
const category = parseInt(this.dataset.category);
const nominee = this.dataset.nominee;
// Toggle selection
if (myPicks[category] === nominee) {
// Deselect if clicking the same nominee
delete myPicks[category];
} else {
// Select this nominee
myPicks[category] = nominee;
}
// Re-render to update the display
renderCategories();
// Auto-save after selection change
autoSavePicks();
});
});
updateProgress();
}
async function autoSavePicks() {
// Clear any existing timeout to reset the debounce
if (autoSavePicks.timeout) {
clearTimeout(autoSavePicks.timeout);
}
// Debounce: wait for user to stop clicking before saving
autoSavePicks.timeout = setTimeout(async () => {
// Skip if already saving (will retry after current save completes)
if (isSaving) {
// Retry after a short delay
setTimeout(() => autoSavePicks(), 200);
return;
}
isSaving = true;
try {
await submitPicks(true); // Pass true for silent mode (no alert)
} catch (err) {
console.error('Auto-save failed:', err);
// Show error alert only on failure
showAlert(t('picks.errorAutoSaving', 'Error auto-saving: {error}', { error: err.message }), 'danger');
} finally {
isSaving = false;
}
}, 250); // Reduced to 250ms for better responsiveness
}
function updateProgress() {
const total = categories.length;
const completed = Object.keys(myPicks).length;
document.getElementById('picks-progress').textContent = `${completed} / ${total}`;
}
async function submitPicks(silent = false) {
const picks = Object.keys(myPicks).map(catNum => ({
categoryNumber: parseInt(catNum),
selectedNominee: myPicks[catNum]
}));
// Allow empty picks to be saved (to clear all selections)
// Only show warning in non-silent mode if user explicitly tries to submit with no picks
if (picks.length === 0 && !silent) {
// Don't block - allow saving empty picks to clear selections
}
// Skip confirmation dialog in silent mode (auto-save)
if (!silent && picks.length < categories.length) {
if (!confirm(t('picks.incompleteSelection', `You have selected only ${picks.length} out of ${categories.length} categories. Do you want to save anyway?`, { selected: picks.length, total: categories.length }))) {
return;
}
}
try {
const res = await fetch('/api/picks/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
year: currentYear,
picks
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to submit picks');
}
const data = await res.json();
if (!silent) {
showAlert(t('picks.successSaved', 'Your picks have been saved successfully!'), 'success');
}
} catch (err) {
console.error('Error submitting picks:', err);
showAlert(t('picks.errorSubmitting', 'Error submitting picks: {error}', { error: err.message }), 'danger');
}
}
// Event listeners
document.getElementById('load-my-picks')?.addEventListener('click', () => {
loadMyPicks();
showAlert(t('picks.reloaded', 'Picks reloaded'), 'info');
});
// Winners & Scores functionality (admin only)
let winnersCategories = [];
async function fetchWinnersCategories() {
try {
if (!currentYear) {
currentYear = await fetchActiveYear();
}
const res = await fetch(`/api/picks/categories?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
throw new Error('Failed to fetch categories');
}
const data = await res.json();
winnersCategories = data.categories || [];
currentYear = data.year || currentYear;
const yearEl = document.getElementById('winners-year');
if (yearEl) yearEl.textContent = currentYear;
renderWinnersCategories();
await fetchScores();
} catch (err) {
console.error('Error fetching winners categories:', err);
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
}
}
function renderWinnersCategories() {
const container = document.getElementById('winners-container');
if (!container) return;
if (winnersCategories.length === 0) {
container.innerHTML = `${t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear })}
`;
return;
}
const winnersCount = winnersCategories.filter(cat =>
cat.nominees.some(n => n.isWinner)
).length;
const progressEl = document.getElementById('winners-progress');
if (progressEl) progressEl.textContent = `${winnersCount} / ${winnersCategories.length}`;
container.innerHTML = `
#
${t('picks.categoryHeader', 'Category')}
${t('picks.selectionHeader', 'Selection')}
${t('picks.winnerHeader', 'Winner')}
${winnersCategories.map(cat => {
const winner = cat.nominees.find(n => n.isWinner);
return `
${cat.categoryNumber}
${cat.categoryName}
—
${cat.nominees.map(nominee => `
${nominee.name}
`).join('')}
${winner ? `${winner.name} ` : '— '}
`;
}).join('')}
`;
// Add event listeners for winner selection
container.querySelectorAll('.winner-select').forEach(select => {
select.addEventListener('change', async function() {
const categoryId = this.dataset.categoryId;
const winnerName = this.value;
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: winnerName || null })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to update winner');
}
showAlert(
winnerName
? t('picks.winnerMarked', 'Winner marked successfully')
: t('picks.winnerDeleted', 'Winner deleted'),
'success'
);
await fetchWinnersCategories();
// Auto-refresh scores after marking/clearing a winner (silent mode)
await calculateScores(true);
} catch (err) {
console.error('Error updating winner:', err);
showAlert(t('picks.errorUpdatingWinner', 'Error: {error}', { error: err.message }), 'danger');
}
});
});
}
async function calculateScores(silent = false) {
try {
if (!currentYear) {
currentYear = await fetchActiveYear();
}
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = true;
btn.textContent = t('picks.calculatingScores', 'Calculating scores…');
}
const res = await fetch('/api/picks/calculate-scores', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ year: currentYear })
});
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(t('picks.noWinnersMarked', 'No winners marked. Scores will be calculated once winners are defined.'), 'info');
}
// Still try to fetch existing scores
await fetchScores();
return;
}
throw new Error(error.message || 'Failed to calculate scores');
}
const data = await res.json();
if (!silent) {
showAlert(
t('picks.scoresCalculated', 'Scores calculated successfully! {count} user(s) evaluated.', { count: data.scores.length }),
'success'
);
}
await fetchScores();
} catch (err) {
console.error('Error calculating scores:', err);
if (!silent) {
showAlert(t('picks.errorCalculatingScores', 'Error: {error}', { error: err.message }), 'danger');
}
} finally {
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = false;
btn.textContent = t('picks.calculateScores', 'Calculate scores');
}
}
}
async function fetchScores() {
try {
if (!currentYear) {
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) {
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 = `${t('picks.errorLoadingScores', 'Error loading scores.')}
`;
}
}
}
function renderScores(scores, totalCategories) {
const container = document.getElementById('scores-container');
if (!container) return;
// Filter out scores for removed users (Unknown users or null userId)
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 = `${t('picks.noScoresAvailable', 'No scores available. Users must first submit their picks.')}
`;
return;
}
container.innerHTML = `
#
${t('picks.userHeader', 'User')}
Score
Total
Actions
${validScores.map((score, index) => {
// Use the pre-calculated actualScore (always use it, even if 0)
const actualScore = score.actualScore !== undefined ? score.actualScore : (score.score || 0);
const rank = index + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
return `
${rank}${medal ? ' ' + medal : ''}
${score.userName}
${actualScore}
${totalCategories}
${t('picks.detailsButton', 'Details')}
`;
}).join('')}
`;
// Store valid scores data globally for modal access
window.scoresData = validScores;
// Add event listeners for view picks buttons
container.querySelectorAll('.view-picks-btn').forEach(btn => {
btn.addEventListener('click', function() {
const scoreIndex = parseInt(this.dataset.scoreIndex);
const score = validScores[scoreIndex];
showUserPicksModal(score);
});
});
}
function showUserPicksModal(score) {
const modal = new bootstrap.Modal(document.getElementById('userPicksModal'));
const modalTitle = document.getElementById('userPicksModalLabel');
const modalContent = document.getElementById('user-picks-modal-content');
modalTitle.textContent = t('picks.userChoicesTitle', 'Picks for {user}', { user: score.userName });
if (!score.pickDetails || score.pickDetails.length === 0) {
modalContent.innerHTML = `${t('picks.noDetailsAvailable', 'No details available.')}
`;
modal.show();
return;
}
// 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 = `
${t('picks.summaryTitle', 'Summary')}
${correctPicks.length} ${t('picks.correctCountLabel', 'correct')} |
${incorrectPicks.length} ${t('picks.incorrectCountLabel', 'incorrect')} |
${score.pickDetails.length} ${t('picks.totalCountLabel', 'total')}
${actualScore} / ${totalCategories}
${totalCategories > 0 ? Math.round((actualScore / totalCategories) * 100) : 0}%
${correctPicks.length > 0 ? `
✓
${t('picks.correctSectionTitle', 'Correct ({count})', { count: correctPicks.length })}
${correctPicks.map(pick => `
${pick.categoryNumber}. ${pick.categoryName}
✓ ${pick.selectedNominee}
`).join('')}
` : ''}
${incorrectPicks.length > 0 ? `
✗
${t('picks.incorrectSectionTitle', 'Incorrect ({count})', { count: incorrectPicks.length })}
${incorrectPicks.map(pick => `
${pick.categoryNumber}. ${pick.categoryName}
✗ ${t('picks.choiceLabel', 'Choice')}: ${pick.selectedNominee}
${pick.correctWinner ? `
✓ ${t('picks.winnerLabel', 'Winner')}: ${pick.correctWinner}
` : `
${t('picks.noWinnerMarkedShort', 'No winner marked')}
`}
`).join('')}
` : ''}
${correctPicks.length === 0 && incorrectPicks.length === 0 ? `
${t('picks.noChoicesToShow', 'No picks to show.')}
` : ''}
`;
modal.show();
}
// Event listeners for winners tab
document.getElementById('refresh-winners')?.addEventListener('click', () => {
fetchWinnersCategories();
showAlert(t('picks.dataRefreshed', 'Data refreshed'), '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', 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(t('picks.scoresRefreshed', 'Scores refreshed'), 'info');
});
// Initial load
window.addEventListener('DOMContentLoaded', async function() {
const pageLoader = createPageLoader({
title: t('picks.loadingTitle', 'Loading picks'),
subtitle: t('picks.loadingSubtitle', 'Fetching data…')
});
try {
currentYear = await fetchActiveYear();
document.title = t('picks.pageTitleChoicesWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
pageLoader.setProgress(12);
await fetchCategories(pageLoader);
// 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
}
}
pageLoader.done();
} catch (err) {
console.error('Error during initial load:', err);
pageLoader.fail();
}
});
})();