From e326ead8177ae540f1ff101430b126721bce6c4a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 21:03:55 +0000 Subject: [PATCH] feat: Allow configuring Oscar date per year Co-authored-by: mathieumoran1 --- public/checklist.html | 2 +- public/control.html | 32 ++++++ public/js/app_checklist.js | 32 +++++- public/js/app_control.js | 207 +++++++++++++++++++++++++++++++++++++ public/js/app_movies.js | 33 +++++- public/movies.html | 2 +- routes/settings.js | 136 ++++++++++++++++++++++++ 7 files changed, 440 insertions(+), 4 deletions(-) diff --git a/public/checklist.html b/public/checklist.html index 1a93020..717783a 100644 --- a/public/checklist.html +++ b/public/checklist.html @@ -79,7 +79,7 @@

Checklist

-

Temps avant les Oscars (15 mars —) -

+

Temps avant les Oscars (— —) -

diff --git a/public/control.html b/public/control.html index bccef94..5f97f51 100644 --- a/public/control.html +++ b/public/control.html @@ -111,6 +111,38 @@
+ +
+
+

Date des Oscars (par année)

+

+ Configure la date exacte de la cérémonie pour le compte à rebours sur Films & Checklist. + Si non défini, la date par défaut reste 15 mars (comportement actuel). +

+ +
+
+ + +
Basé sur les années présentes dans les films.
+
+
+ + +
Format: YYYY-MM-DD.
+
+
+ + +
+
+ +
+
Dates configurées
+
—
+
+
+
diff --git a/public/js/app_checklist.js b/public/js/app_checklist.js index 8bc2daa..5c00fe9 100644 --- a/public/js/app_checklist.js +++ b/public/js/app_checklist.js @@ -88,6 +88,23 @@ window.onload = async function () { } } + async function fetchOscarEffectiveDate(year) { + try { + const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' }); + if (!res.ok) throw new Error('Failed to fetch oscar date'); + const data = await res.json(); + const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null; + return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`; + } catch (_) { + return `${year}-03-15`; + } + } + + function parseLocalNoonFromIsoDate(dateStr) { + const d = new Date(`${dateStr}T12:00:00`); + return Number.isNaN(d.getTime()) ? null : d; + } + const activeYear = await fetchActiveYear(); document.title = `Pool Oscars ${activeYear} - Checklist`; const oscarYearEl = document.getElementById('oscar-year'); @@ -97,7 +114,20 @@ window.onload = async function () { const completionModalContent = await fetchCompletionModalContent(); applyCompletionModalContent(completionModalContent); - const targetDate = new Date(`March 15, ${activeYear}`); + const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear); + const targetDate = + parseLocalNoonFromIsoDate(oscarEffectiveDate) || + new Date(`March 15, ${activeYear} 12:00:00`); + + const oscarDayMonthEl = document.getElementById('oscar-date-day-month'); + if (oscarDayMonthEl) { + try { + oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); + } catch (_) { + oscarDayMonthEl.textContent = '15 mars'; + } + } + const currentDate = new Date(); const timeDifference = targetDate - currentDate; const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); diff --git a/public/js/app_control.js b/public/js/app_control.js index 228b8b2..1591658 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -41,6 +41,13 @@ window.onload = async function () { const activeYearInput = document.getElementById('active_oscar_year'); const saveActiveYearBtn = document.getElementById('save-active-year'); + // Oscar date per year (settings tab) + const oscarDateYearSelect = document.getElementById('oscar_date_year'); + const oscarDateValueInput = document.getElementById('oscar_date_value'); + const saveOscarDateBtn = document.getElementById('save-oscar-date'); + const clearOscarDateBtn = document.getElementById('clear-oscar-date'); + const oscarDateCurrentEl = document.getElementById('oscar-date-current'); + // Modal 100% elements const completionModalTitleEl = document.getElementById('completion_modal_title'); const completionModalBodyTextEl = document.getElementById('completion_modal_body_text'); @@ -97,6 +104,8 @@ window.onload = async function () { let winnerUsersLoadedOnce = false; let winnersByYear = new Map(); // year -> [{ year, userId, name, points }] let winnerUsers = []; // admin list: {id, name, email, ...} + let oscarDatesLoadedOnce = false; + let oscarDatesByYear = {}; // { "2026": "2026-03-15" } const DEFAULT_COMPLETION_MODAL = Object.freeze({ title: 'Félicitations! very nice 🎉🎉🎉', @@ -474,6 +483,81 @@ window.onload = async function () { return n; } + function isIsoDateString(value) { + return /^\d{4}-\d{2}-\d{2}$/.test(String(value || '').trim()); + } + + async function fetchOscarDates() { + const res = await fetch('/api/settings/oscar-dates', { 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 data?.dates && typeof data.dates === 'object' ? data.dates : {}; + } + + async function ensureOscarDatesLoaded(options = {}) { + const force = !!options.force; + if (oscarDatesLoadedOnce && !force) return; + try { + oscarDatesByYear = await fetchOscarDates(); + oscarDatesLoadedOnce = true; + } catch (_) { + oscarDatesByYear = {}; + oscarDatesLoadedOnce = true; + } + } + + async function setOscarDateForYear(year, dateStrOrEmpty) { + const res = await fetch(`/api/settings/oscar-date/${encodeURIComponent(String(year))}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ date: dateStrOrEmpty }), + }); + + 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 renderOscarDatesCurrent(cleanedYears) { + if (!oscarDateCurrentEl) return; + const years = Array.isArray(cleanedYears) ? cleanedYears : []; + if (!years.length) { + oscarDateCurrentEl.textContent = '—'; + return; + } + + const lines = years.map((y) => { + const key = String(y); + const val = oscarDatesByYear && typeof oscarDatesByYear === 'object' ? oscarDatesByYear[key] : null; + const date = (typeof val === 'string' && isIsoDateString(val)) ? val : '— (défaut: 03-15)'; + return `${key}: ${date}`; + }); + + oscarDateCurrentEl.textContent = lines.join('\n'); + oscarDateCurrentEl.style.whiteSpace = 'pre'; + } + + function applyOscarDateSelection() { + if (!oscarDateYearSelect || !oscarDateValueInput) return; + const y = parseYear(oscarDateYearSelect.value); + if (!y) { + oscarDateValueInput.value = ''; + return; + } + const key = String(y); + const val = oscarDatesByYear && typeof oscarDatesByYear === 'object' ? oscarDatesByYear[key] : null; + oscarDateValueInput.value = (typeof val === 'string' && isIsoDateString(val)) ? val : ''; + } + async function fetchWinners() { const res = await fetch('/api/settings/winners', { method: 'GET' }); const data = await res.json().catch(() => ({})); @@ -803,6 +887,84 @@ window.onload = async function () { } } + // Oscar date (settings tab) wiring + if (oscarDateYearSelect) { + oscarDateYearSelect.addEventListener('change', () => { + applyOscarDateSelection(); + }); + } + + if (saveOscarDateBtn && oscarDateYearSelect && oscarDateValueInput) { + saveOscarDateBtn.addEventListener('click', async () => { + const y = parseYear(oscarDateYearSelect.value); + if (!y) { + showResponse('warning', 'Année invalide. Exemple attendu: 2026'); + return; + } + + const dateValue = String(oscarDateValueInput.value || '').trim(); + if (dateValue && !isIsoDateString(dateValue)) { + showResponse('warning', 'Date invalide. Format attendu: YYYY-MM-DD'); + return; + } + + saveOscarDateBtn.disabled = true; + if (clearOscarDateBtn) clearOscarDateBtn.disabled = true; + const oldText = saveOscarDateBtn.textContent; + saveOscarDateBtn.textContent = 'Enregistrement...'; + + try { + const result = await setOscarDateForYear(y, dateValue); + if (result?.date) { + oscarDatesByYear[String(y)] = String(result.date); + showResponse('success', `Date Oscars enregistrée pour ${y}: ${result.date}`); + } else { + delete oscarDatesByYear[String(y)]; + showResponse('success', `Date Oscars effacée pour ${y} (retour au défaut: 15 mars).`); + } + + await refreshYears(); + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } finally { + saveOscarDateBtn.disabled = false; + if (clearOscarDateBtn) clearOscarDateBtn.disabled = false; + saveOscarDateBtn.textContent = oldText; + applyOscarDateSelection(); + } + }); + } + + if (clearOscarDateBtn && oscarDateYearSelect && oscarDateValueInput) { + clearOscarDateBtn.addEventListener('click', async () => { + const y = parseYear(oscarDateYearSelect.value); + if (!y) { + showResponse('warning', 'Année invalide. Exemple attendu: 2026'); + return; + } + + clearOscarDateBtn.disabled = true; + if (saveOscarDateBtn) saveOscarDateBtn.disabled = true; + const oldText = clearOscarDateBtn.textContent; + clearOscarDateBtn.textContent = 'Effacement...'; + + try { + oscarDateValueInput.value = ''; + await setOscarDateForYear(y, ''); + delete oscarDatesByYear[String(y)]; + showResponse('success', `Date Oscars effacée pour ${y} (retour au défaut: 15 mars).`); + await refreshYears(); + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } finally { + clearOscarDateBtn.disabled = false; + if (saveOscarDateBtn) saveOscarDateBtn.disabled = false; + clearOscarDateBtn.textContent = oldText; + applyOscarDateSelection(); + } + }); + } + if (deleteUserVerifyInputEl && confirmDeleteUserBtn) { deleteUserVerifyInputEl.addEventListener('input', () => { const expected = String(currentDeleteTarget?.email || '').trim(); @@ -1402,6 +1564,9 @@ window.onload = async function () { .filter((y) => Number.isInteger(y) && y >= 1900 && y <= 3000) .sort((a, b) => b - a); + // Ensure Oscar dates cache is loaded (for settings UI). + await ensureOscarDatesLoaded(); + // Rebuild manage dropdown from scratch (so removed years disappear) const previousSelection = manageYearSelect.value; manageYearSelect.innerHTML = ''; @@ -1471,6 +1636,48 @@ window.onload = async function () { } } + // Oscar date year dropdown (settings tab) + if (oscarDateYearSelect && oscarDateValueInput && saveOscarDateBtn && clearOscarDateBtn) { + const previousOscarYearSelection = String(oscarDateYearSelect.value || ''); + oscarDateYearSelect.innerHTML = ''; + + if (cleanedYears.length === 0) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'Aucune année (ajoute un film)'; + opt.disabled = true; + opt.selected = true; + oscarDateYearSelect.appendChild(opt); + oscarDateYearSelect.disabled = true; + oscarDateValueInput.disabled = true; + saveOscarDateBtn.disabled = true; + clearOscarDateBtn.disabled = true; + } else { + cleanedYears.forEach((y) => { + const opt = document.createElement('option'); + opt.value = String(y); + opt.textContent = String(y); + oscarDateYearSelect.appendChild(opt); + }); + + const desired = + (previousOscarYearSelection && cleanedYears.map(String).includes(previousOscarYearSelection)) + ? previousOscarYearSelection + : (activeYear && cleanedYears.map(String).includes(String(activeYear))) + ? String(activeYear) + : String(cleanedYears[0]); + + oscarDateYearSelect.disabled = false; + oscarDateValueInput.disabled = false; + saveOscarDateBtn.disabled = false; + clearOscarDateBtn.disabled = false; + oscarDateYearSelect.value = desired; + } + } + + applyOscarDateSelection(); + renderOscarDatesCurrent(cleanedYears); + // Winners year dropdown (optional tab) setWinnerYearOptions(cleanedYears); applyWinnerFormFromCache(); diff --git a/public/js/app_movies.js b/public/js/app_movies.js index 7b3aaa9..654d598 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -11,6 +11,24 @@ window.onload = async function () { } } + async function fetchOscarEffectiveDate(year) { + try { + const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' }); + if (!res.ok) throw new Error('Failed to fetch oscar date'); + const data = await res.json(); + const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null; + return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`; + } catch (_) { + return `${year}-03-15`; + } + } + + function parseLocalNoonFromIsoDate(dateStr) { + // Use local noon to avoid DST/midnight edge cases for "days left" math. + const d = new Date(`${dateStr}T12:00:00`); + return Number.isNaN(d.getTime()) ? null : d; + } + async function fetchMoviesLastUpdated(year, token) { const el = document.getElementById('movies-last-updated'); if (!el) return; @@ -40,7 +58,20 @@ window.onload = async function () { const oscarYearEl = document.getElementById('oscar-year'); if (oscarYearEl) oscarYearEl.textContent = String(activeYear); - const targetDate = new Date(`March 15, ${activeYear}`); + const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear); + const targetDate = + parseLocalNoonFromIsoDate(oscarEffectiveDate) || + new Date(`March 15, ${activeYear} 12:00:00`); + + const oscarDayMonthEl = document.getElementById('oscar-date-day-month'); + if (oscarDayMonthEl) { + try { + oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); + } catch (_) { + oscarDayMonthEl.textContent = '15 mars'; + } + } + const currentDate = new Date(); const timeDifference = targetDate - currentDate; const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); diff --git a/public/movies.html b/public/movies.html index b6a30a9..1c109e0 100644 --- a/public/movies.html +++ b/public/movies.html @@ -106,7 +106,7 @@ -

Temps avant les Oscars (15 mars —) -

+

Temps avant les Oscars (— —) -

diff --git a/routes/settings.js b/routes/settings.js index c011e59..4575aed 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -11,6 +11,7 @@ const router = express.Router(); const ACTIVE_YEAR_KEY = "active_oscar_year"; const COMPLETION_MODAL_KEY = "completion_modal_content"; const WINNERS_BY_YEAR_KEY = "winners_by_year"; +const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year"; const DEFAULT_COMPLETION_MODAL = Object.freeze({ title: "Félicitations! very nice 🎉🎉🎉", @@ -37,6 +38,52 @@ function parseOptionalPoints(raw) { return Math.round(n); } +function parseIsoDateString(raw) { + // Expected format: YYYY-MM-DD + if (raw === undefined || raw === null) return null; + const s = String(raw).trim(); + if (!s) return null; + + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!m) return null; + + const year = Number(m[1]); + const month = Number(m[2]); + const day = Number(m[3]); + + if (!Number.isInteger(year) || year < 1900 || year > 3000) return null; + if (!Number.isInteger(month) || month < 1 || month > 12) return null; + if (!Number.isInteger(day) || day < 1 || day > 31) return null; + + // Validate calendar date (handles month/day overflow like Feb 30). + const d = new Date(Date.UTC(year, month - 1, day)); + if ( + d.getUTCFullYear() !== year || + d.getUTCMonth() !== month - 1 || + d.getUTCDate() !== day + ) { + return null; + } + + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +function normalizeOscarDatesByYear(rawValue) { + // Stored format: { "2026": "2026-03-15" } + 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 date = parseIsoDateString(v); + if (!date) continue; + out[String(year)] = date; + } + + return out; +} + function normalizeWinnersByYear(rawValue) { // Stored format: // - New: { "2026": [ { userId, points? }, { userId, points? } ] } @@ -127,6 +174,95 @@ router.get("/year", async (req, res) => { } }); +// Public: get Oscar date for a given year (with fallback). +// - If no custom date is configured, fallback is March 15 of that year (legacy behavior). +router.get("/oscar-date", async (req, res) => { + try { + const year = parseOscarYear(req.query?.year); + if (!year) { + return res.status(400).json({ message: "Année invalide (ex: 2026)" }); + } + + const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value"); + const dates = normalizeOscarDatesByYear(existing?.value); + const configured = dates[String(year)] || null; + const effectiveDate = configured || `${String(year)}-03-15`; + + return res.status(200).json({ + year, + date: configured, // configured value (null if unset) + effectiveDate, // always present + }); + } catch (err) { + // Even if DB is down, keep the legacy fallback. + const year = parseOscarYear(req.query?.year); + if (year) { + return res.status(200).json({ year, date: null, effectiveDate: `${String(year)}-03-15` }); + } + return res.status(500).json({ error: err.message }); + } +}); + +// Public: get all configured Oscar dates. +router.get("/oscar-dates", async (req, res) => { + try { + const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value"); + const dates = normalizeOscarDatesByYear(existing?.value); + return res.status(200).json({ dates }); + } catch (err) { + return res.status(200).json({ dates: {} }); + } +}); + +// Admin-only: set/clear Oscar date for a given year. +// Body: { date: "YYYY-MM-DD" } or { date: "" } to clear. +router.put("/oscar-date/:year", 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 rawDate = req.body?.date; + const date = parseIsoDateString(rawDate); + const wantsClear = rawDate === "" || rawDate === null || rawDate === undefined || (typeof rawDate === "string" && rawDate.trim() === ""); + + if (!wantsClear && !date) { + return res.status(400).json({ message: "Date invalide (format attendu: YYYY-MM-DD)" }); + } + + const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value"); + const dates = normalizeOscarDatesByYear(existing?.value); + + if (wantsClear) { + delete dates[String(year)]; + } else { + dates[String(year)] = date; + } + + await Setting.findOneAndUpdate( + { key: OSCAR_DATES_BY_YEAR_KEY }, + { $set: { value: dates } }, + { upsert: true, new: true } + ); + + return res.status(200).json({ year, date: dates[String(year)] || null }); + } 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 }); + } +}); + // Admin-only: set current active Oscar year. router.put("/year", async (req, res) => { const token = req.headers.authorization?.split(" ")[1]; -- 2.54.0