Merge pull request #35 from quoije/cursor/films-and-checklist-loading-4f51

Films and checklist loading
This commit was merged in pull request #35.
This commit is contained in:
Mathieu Moran
2025-12-21 16:40:55 -05:00
committed by GitHub
3 changed files with 245 additions and 78 deletions
+84 -29
View File
@@ -1,6 +1,7 @@
window.onload = async function () { window.onload = async function () {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
const movieTableBody = document.getElementById('movie-table-body'); const movieTableBody = document.getElementById('movie-table-body');
const watchedRatioEl = document.getElementById('watched-ratio');
function showTableLoading() { function showTableLoading() {
if (!movieTableBody) return; if (!movieTableBody) return;
@@ -18,6 +19,18 @@ window.onload = async function () {
`; `;
} }
function setInlineLoading(el, label = 'Chargement…') {
if (!el) return;
el.innerHTML = `
<span class="d-inline-flex align-items-center gap-2" aria-live="polite" aria-busy="true">
<span class="spinner-border spinner-border-sm text-secondary" role="status" aria-label="Chargement">
<span class="visually-hidden">Chargement...</span>
</span>
<span>${label}</span>
</span>
`;
}
function showTableError(message) { function showTableError(message) {
if (!movieTableBody) return; if (!movieTableBody) return;
movieTableBody.innerHTML = ` movieTableBody.innerHTML = `
@@ -135,29 +148,52 @@ window.onload = async function () {
return Number.isNaN(d.getTime()) ? null : d; return Number.isNaN(d.getTime()) ? null : d;
} }
async function fetchMoviesSummary(year) {
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch movies summary');
return await res.json();
}
const activeYear = await fetchActiveYear(); const activeYear = await fetchActiveYear();
document.title = `Pool Oscars ${activeYear} - Checklist`; document.title = `Pool Oscars ${activeYear} - Checklist`;
const oscarYearEl = document.getElementById('oscar-year'); const oscarYearEl = document.getElementById('oscar-year');
if (oscarYearEl) oscarYearEl.textContent = String(activeYear); if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear); // Render countdown immediately with a deterministic fallback (then refine from settings).
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
new Date(`March 15, ${activeYear} 12:00:00`);
const oscarDayMonthEl = document.getElementById('oscar-date-day-month'); const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
const timeLeftEl = document.getElementById('time-left');
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) { if (oscarDayMonthEl) {
try { try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) { } catch (_) {
oscarDayMonthEl.textContent = '15 mars'; oscarDayMonthEl.textContent = '15 mars';
} }
} }
const currentDate = new Date(); const currentDate = new Date();
const timeDifference = targetDate - currentDate; let timeDifference = fallbackDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
document.getElementById('time-left').textContent = `${daysLeft} jours`; if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
fallbackDate;
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) {}
}
const now = new Date();
timeDifference = targetDate - now;
daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
}).catch(() => {});
if (token) { if (token) {
try { try {
@@ -186,22 +222,35 @@ window.onload = async function () {
} }
showTableLoading(); showTableLoading();
setInlineLoading(watchedRatioEl);
const progressBarEl = document.getElementById('progress-bar');
if (progressBarEl) {
progressBarEl.classList.add('progress-bar-striped', 'progress-bar-animated');
}
let movies = []; let movies = [];
let watchedMovies = []; let watchedMovies = [];
let watchedMoviesInYear = []; let watchedMoviesInYear = [];
let movieImdbIds = new Set(); let movieImdbIds = new Set();
try { try {
// Fetch remaining data in parallel (Render latency is high). // Start summary fetch immediately so the header can update without waiting for the full list.
const [completionModalContent, moviesRes, watchedRes] = await Promise.all([ const summaryPromise = fetchMoviesSummary(activeYear)
.then((summary) => {
const totalCount = Number(summary?.totalMoviesCount) || 0;
const watchedCount = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0';
if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
updateProgressBar(watchedCount, totalCount, false);
return summary;
})
.catch(() => null);
// Fetch remaining data in parallel.
const [completionModalContent, moviesRes] = await Promise.all([
fetchCompletionModalContent(), fetchCompletionModalContent(),
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, { fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
method: 'GET', method: 'GET',
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}),
fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
}) })
]); ]);
@@ -218,8 +267,11 @@ window.onload = async function () {
// Sort movies alphabetically by title // Sort movies alphabetically by title
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' })); movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
if (watchedRes.ok) { const summary = await summaryPromise;
watchedMovies = await watchedRes.json(); watchedMovies = Array.isArray(summary?.watchedMovies) ? summary.watchedMovies : [];
if (progressBarEl) {
progressBarEl.classList.remove('progress-bar-striped', 'progress-bar-animated');
} }
} catch (e) { } catch (e) {
showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.'); showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.');
@@ -259,10 +311,12 @@ window.onload = async function () {
frame(); frame();
} }
function updateProgressBar(watchedCount, totalCount) { function updateProgressBar(watchedCount, totalCount, celebrate = true) {
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const percentage = (watchedCount / totalCount) * 100; if (!progressBar) return;
console.log(percentage); const safeTotal = Number(totalCount);
const safeWatched = Number(watchedCount);
const percentage = safeTotal > 0 && Number.isFinite(safeWatched) ? (safeWatched / safeTotal) * 100 : 0;
progressBar.style.width = `${percentage}%`; progressBar.style.width = `${percentage}%`;
if (percentage <= 50) { if (percentage <= 50) {
@@ -273,15 +327,16 @@ window.onload = async function () {
progressBar.setAttribute('aria-valuenow', Math.round(percentage)); progressBar.setAttribute('aria-valuenow', Math.round(percentage));
const videoModal = new bootstrap.Modal(document.getElementById("videoModal")); if (celebrate) {
const videoModal = new bootstrap.Modal(document.getElementById("videoModal"));
if (percentage === 100) { if (percentage === 100) {
videoModal.show(); videoModal.show();
launchConfetti(); launchConfetti();
const rewardVideo = document.getElementById("rewardVideo"); const rewardVideo = document.getElementById("rewardVideo");
// Only attempt autoplay if we actually have a visible <video>. // Only attempt autoplay if we actually have a visible <video>.
if (rewardVideo && !rewardVideo.classList.contains('d-none')) { if (rewardVideo && !rewardVideo.classList.contains('d-none')) {
rewardVideo.play().catch(() => {}); rewardVideo.play().catch(() => {});
}
} }
} }
} }
+93 -49
View File
@@ -10,6 +10,18 @@ window.onload = async function () {
`; `;
} }
function setInlineLoading(el, label = 'Chargement…') {
if (!el) return;
el.innerHTML = `
<span class="d-inline-flex align-items-center gap-2" aria-live="polite" aria-busy="true">
<span class="spinner-border spinner-border-sm text-secondary" role="status" aria-label="Chargement">
<span class="visually-hidden">Chargement...</span>
</span>
<span>${label}</span>
</span>
`;
}
function setLoadError(el, message) { function setLoadError(el, message) {
if (!el) return; if (!el) return;
el.innerHTML = ` el.innerHTML = `
@@ -49,28 +61,37 @@ window.onload = async function () {
return Number.isNaN(d.getTime()) ? null : d; return Number.isNaN(d.getTime()) ? null : d;
} }
async function fetchMoviesLastUpdated(year, token) { async function fetchMoviesSummary(year, token) {
const el = document.getElementById('movies-last-updated'); const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
if (!el) return; method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to fetch movies summary');
return await res.json();
}
try { function applySummaryToHeader(summary) {
const res = await fetch(`/api/movies/last-update?year=${encodeURIComponent(String(year))}`, { const lastUpdatedEl = document.getElementById('movies-last-updated');
method: 'GET', const ratioEl = document.getElementById('watched-ratio');
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json(); const lastUpdatedIso = summary?.lastUpdated;
const lastUpdatedIso = data?.lastUpdated; if (lastUpdatedEl) {
if (!lastUpdatedIso) return; if (lastUpdatedIso) {
const d = new Date(lastUpdatedIso);
const d = new Date(lastUpdatedIso); if (!Number.isNaN(d.getTime())) {
if (Number.isNaN(d.getTime())) return; lastUpdatedEl.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' });
} else {
el.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }); lastUpdatedEl.textContent = '—';
} catch (_) { }
// Keep the default placeholder (—) } else {
lastUpdatedEl.textContent = '—';
}
} }
const total = Number(summary?.totalMoviesCount) || 0;
const watched = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = total > 0 ? ((watched / total) * 100).toFixed(1) : '0.0';
if (ratioEl) ratioEl.innerText = `Vu: ${watched} / ${total} (${ratioPct}%)`;
} }
const activeYear = await fetchActiveYear(); const activeYear = await fetchActiveYear();
@@ -78,24 +99,41 @@ window.onload = async function () {
const oscarYearEl = document.getElementById('oscar-year'); const oscarYearEl = document.getElementById('oscar-year');
if (oscarYearEl) oscarYearEl.textContent = String(activeYear); if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear); // Render countdown immediately with a deterministic fallback (then refine from settings).
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
new Date(`March 15, ${activeYear} 12:00:00`);
const oscarDayMonthEl = document.getElementById('oscar-date-day-month'); const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
const timeLeftEl = document.getElementById('time-left');
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) { if (oscarDayMonthEl) {
try { try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) { } catch (_) {
oscarDayMonthEl.textContent = '15 mars'; oscarDayMonthEl.textContent = '15 mars';
} }
} }
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = fallbackDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
timeLeftEl.textContent = `${daysLeft} jours`;
}
const currentDate = new Date(); fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
const timeDifference = targetDate - currentDate; const targetDate =
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
document.getElementById('time-left').textContent = `${daysLeft} jours`; fallbackDate;
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) {}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
timeLeftEl.textContent = `${daysLeft} jours`;
}
}).catch(() => {});
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
if (token) { if (token) {
@@ -128,21 +166,33 @@ window.onload = async function () {
const moviesList = document.getElementById('movies-list'); const moviesList = document.getElementById('movies-list');
setLoading(moviesList); setLoading(moviesList);
// Populate "Dernière mise à jour des films" banner (per active year) // Make header stats feel responsive (these used to wait for the heavy movies payload).
await fetchMoviesLastUpdated(activeYear, token); setInlineLoading(document.getElementById('movies-last-updated'));
setInlineLoading(document.getElementById('watched-ratio'));
let watchedMoviesInYear = [];
try { try {
// Fetch movies + watched list in parallel (Render latency is high). // Start summary fetch immediately so header can update ASAP.
const [res, watchedRes] = await Promise.all([ const summaryPromise = fetchMoviesSummary(activeYear, token)
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, { .then((data) => {
method: 'GET', watchedMoviesInYear = Array.isArray(data?.watchedMovies) ? data.watchedMovies : [];
headers: { 'Authorization': `Bearer ${token}` }, applySummaryToHeader(data);
}), return data;
fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
}) })
]); .catch(() => {
// Leave the placeholders (—) if summary fails; movies list can still load.
const lastUpdatedEl = document.getElementById('movies-last-updated');
if (lastUpdatedEl) lastUpdatedEl.textContent = '—';
const ratioEl = document.getElementById('watched-ratio');
if (ratioEl) ratioEl.textContent = '—';
return null;
});
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) { if (!res.ok) {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
@@ -151,18 +201,12 @@ window.onload = async function () {
} }
const movies = await res.json(); const movies = await res.json();
// If summary is still in flight, wait for it before rendering watched banners.
await summaryPromise;
// Sort movies alphabetically by title (server also sorts, but keep client as a fallback) // Sort movies alphabetically by title (server also sorts, but keep client as a fallback)
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' })); movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
let watchedMovies = [];
if (watchedRes.ok) {
watchedMovies = await watchedRes.json();
}
const movieImdbIds = new Set(movies.map((m) => m.imdb_id).filter(Boolean));
const watchedMoviesInYear = watchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
const totalMoviesCount = movies.length; const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMoviesInYear.length; const watchedMoviesCount = watchedMoviesInYear.length;
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0'; const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
+68
View File
@@ -195,6 +195,74 @@ router.get("/last-update", async (req, res) => {
} }
}); });
// Get lightweight per-year summary for fast UI (counts, watched-in-year, last update)
router.get("/summary", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const year = parseOscarYear(req.query.year);
const filter = year ? { year } : {};
// Base (year-wide) data can be cached (not user-specific).
const cacheKey = `movies:summary-base:${year || "all"}`;
let base = cacheGet(cacheKey);
if (!base) {
const [idDocs, latestMovie] = await Promise.all([
Movie.find(filter).select("imdb_id").lean(),
Movie.findOne(filter)
.select("updatedAt createdAt")
.sort({ updatedAt: -1, createdAt: -1, _id: -1 })
.lean(),
]);
const imdbIds = idDocs.map((d) => d.imdb_id).filter(Boolean);
const imdbIdSet = new Set(imdbIds);
const lastUpdated =
latestMovie?.updatedAt ||
latestMovie?.createdAt ||
(typeof latestMovie?._id?.getTimestamp === "function" ? latestMovie._id.getTimestamp() : null);
base = {
year: year || null,
totalMoviesCount: imdbIds.length,
imdbIds,
// Store ISO string so it's JSON-safe + stable
lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : null,
};
// Avoid storing a Set in cache (non-serializable and not needed elsewhere)
cacheSet(cacheKey, base);
}
const user = await User.findById(decoded.id).select("watchedMovies").lean();
if (!user) return res.status(404).json({ message: "User not found" });
const imdbIdSet = new Set(base.imdbIds || []);
const watchedMoviesAll = Array.isArray(user.watchedMovies) ? user.watchedMovies : [];
const watchedMoviesInYear = watchedMoviesAll.filter((wm) => imdbIdSet.has(wm.imdb_id));
// Since this is user-specific, don't encourage shared caching.
res.set("Cache-Control", "private, max-age=5");
return res.status(200).json({
year: base.year,
totalMoviesCount: base.totalMoviesCount,
watchedMoviesCount: watchedMoviesInYear.length,
watchedMovies: watchedMoviesInYear,
lastUpdated: base.lastUpdated,
});
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: error.message });
}
});
// Get watched movies for the user // Get watched movies for the user
router.get("/watchedMovies", async (req, res) => { router.get("/watchedMovies", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1]; // Get token from header const token = req.headers.authorization?.split(" ")[1]; // Get token from header