Films checklist page performance #33

Merged
quoije merged 1 commits from cursor/films-checklist-page-performance-ddde into 2026 2025-12-21 16:25:07 -05:00
7 changed files with 165 additions and 30 deletions
+2
View File
@@ -2,12 +2,14 @@ const express = require("express");
const mongoose = require("mongoose"); const mongoose = require("mongoose");
const dotenv = require("dotenv"); const dotenv = require("dotenv");
const cors = require("cors"); const cors = require("cors");
const compression = require("compression");
dotenv.config(); dotenv.config();
const app = express(); const app = express();
// Middleware // Middleware
app.use(cors()); app.use(cors());
app.use(compression());
app.use(express.json()); app.use(express.json());
// Connect to MongoDB // Connect to MongoDB
+7
View File
@@ -20,4 +20,11 @@ const movieSchema = new mongoose.Schema({
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }] watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
}, { timestamps: true }); }, { timestamps: true });
// Common read patterns:
// - list movies for a year, sorted by title
// - check "last update" per year (sort by updatedAt)
movieSchema.index({ year: 1, title: 1 });
movieSchema.index({ year: 1, updatedAt: -1 });
movieSchema.index({ year: 1, imdb_id: 1 });
module.exports = mongoose.model("Movie", movieSchema); module.exports = mongoose.model("Movie", movieSchema);
+49
View File
@@ -12,6 +12,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"compression": "^1.8.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.22.1", "express": "^4.22.1",
@@ -285,6 +286,45 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1368,6 +1408,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+1
View File
@@ -13,6 +13,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"compression": "^1.8.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.22.1", "express": "^4.22.1",
+16 -15
View File
@@ -110,10 +110,6 @@ 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);
// Load (public) "100% completion" modal content at runtime.
const completionModalContent = await fetchCompletionModalContent();
applyCompletionModalContent(completionModalContent);
const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear); const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear);
const targetDate = const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) || parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
@@ -159,27 +155,32 @@ window.onload = async function () {
window.location.href = '/'; window.location.href = '/';
} }
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, { // Fetch remaining data in parallel (Render latency is high).
method: 'GET', const [completionModalContent, moviesRes, watchedRes] = await Promise.all([
headers: { 'Authorization': `Bearer ${token}` } fetchCompletionModalContent(),
}); fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
})
]);
if (!res.ok) { applyCompletionModalContent(completionModalContent);
if (!moviesRes.ok) {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
window.location.href = '/'; window.location.href = '/';
return; return;
} }
const movies = await res.json(); const movies = await moviesRes.json();
// 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' }));
const watchedRes = await fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
let watchedMovies = []; let watchedMovies = [];
if (watchedRes.ok) { if (watchedRes.ok) {
watchedMovies = await watchedRes.json(); watchedMovies = await watchedRes.json();
+12 -10
View File
@@ -108,10 +108,17 @@ window.onload = async function () {
// Populate "Dernière mise à jour des films" banner (per active year) // Populate "Dernière mise à jour des films" banner (per active year)
await fetchMoviesLastUpdated(activeYear, token); await fetchMoviesLastUpdated(activeYear, token);
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, { // Fetch movies + watched list in parallel (Render latency is high).
method: 'GET', const [res, watchedRes] = await Promise.all([
headers: { 'Authorization': `Bearer ${token}` }, fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
}); method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
}),
fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
})
]);
if (!res.ok) { if (!res.ok) {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
@@ -121,14 +128,9 @@ window.onload = async function () {
const movies = await res.json(); const movies = await res.json();
// Sort movies alphabetically by title // 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' }));
const watchedRes = await fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
let watchedMovies = []; let watchedMovies = [];
if (watchedRes.ok) { if (watchedRes.ok) {
watchedMovies = await watchedRes.json(); watchedMovies = await watchedRes.json();
+78 -5
View File
@@ -9,6 +9,29 @@ const authenticate = require("../middleware/authMiddleware");
const router = express.Router(); const router = express.Router();
// Small in-memory cache to reduce repeated DB reads on slow networks (Render).
// This is safe because movie lists are identical for all users and change rarely.
const MOVIES_CACHE_TTL_MS = Number(process.env.MOVIES_CACHE_TTL_MS || "30000"); // 30s
const _cache = new Map(); // key -> { expiresAt:number, value:any }
function cacheGet(key) {
const hit = _cache.get(key);
if (!hit) return null;
if (Date.now() > hit.expiresAt) {
_cache.delete(key);
return null;
}
return hit.value;
}
function cacheSet(key, value) {
_cache.set(key, { expiresAt: Date.now() + MOVIES_CACHE_TTL_MS, value });
}
function cacheClear() {
_cache.clear();
}
function parseOscarYear(raw) { function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null; if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw); const n = Number(raw);
@@ -73,10 +96,20 @@ async function fetchMovieDetailsFromOmdb(imdb_id) {
// Get available Oscar years (distinct years from movies) // Get available Oscar years (distinct years from movies)
router.get("/years", async (req, res) => { router.get("/years", async (req, res) => {
try { try {
const cacheKey = "movies:years";
const cached = cacheGet(cacheKey);
if (cached) {
res.set("Cache-Control", "public, max-age=60");
return res.status(200).json(cached);
}
const years = await Movie.distinct("year"); const years = await Movie.distinct("year");
const cleaned = years const cleaned = years
.filter((y) => typeof y === "number" && Number.isFinite(y)) .filter((y) => typeof y === "number" && Number.isFinite(y))
.sort((a, b) => b - a); .sort((a, b) => b - a);
cacheSet(cacheKey, cleaned);
res.set("Cache-Control", "public, max-age=60");
res.status(200).json(cleaned); res.status(200).json(cleaned);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@@ -88,7 +121,31 @@ router.get("/", async (req, res) => {
try { try {
const year = parseOscarYear(req.query.year); const year = parseOscarYear(req.query.year);
const filter = year ? { year } : {}; const filter = year ? { year } : {};
const movies = await Movie.find(filter);
// Clients:
// - films page needs details (poster, description, player sources)
// - checklist page only needs imdb_id + title
const view = String(req.query.view || "").trim().toLowerCase();
const isChecklist = view === "checklist" || view === "compact";
const projection = isChecklist
? "imdb_id title"
: "imdb_id title description rating poster year category vod_link player_mode video_src embed_src updatedAt createdAt";
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
const cached = cacheGet(cacheKey);
if (cached) {
res.set("Cache-Control", "public, max-age=30");
return res.status(200).json(cached);
}
const movies = await Movie.find(filter)
.select(projection)
.sort({ title: 1 })
.lean();
cacheSet(cacheKey, movies);
res.set("Cache-Control", "public, max-age=30");
res.status(200).json(movies); res.status(200).json(movies);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@@ -106,7 +163,17 @@ router.get("/last-update", async (req, res) => {
const year = parseOscarYear(req.query.year); const year = parseOscarYear(req.query.year);
const filter = year ? { year } : {}; const filter = year ? { year } : {};
const latestMovie = await Movie.findOne(filter).sort({ updatedAt: -1, createdAt: -1, _id: -1 }); const cacheKey = `movies:last-update:${year || "all"}`;
const cached = cacheGet(cacheKey);
if (cached) {
res.set("Cache-Control", "public, max-age=30");
return res.status(200).json(cached);
}
const latestMovie = await Movie.findOne(filter)
.select("updatedAt createdAt")
.sort({ updatedAt: -1, createdAt: -1, _id: -1 })
.lean();
if (!latestMovie) { if (!latestMovie) {
return res.status(200).json({ lastUpdated: null }); return res.status(200).json({ lastUpdated: null });
} }
@@ -116,7 +183,10 @@ router.get("/last-update", async (req, res) => {
latestMovie.createdAt || latestMovie.createdAt ||
(typeof latestMovie._id?.getTimestamp === "function" ? latestMovie._id.getTimestamp() : null); (typeof latestMovie._id?.getTimestamp === "function" ? latestMovie._id.getTimestamp() : null);
return res.status(200).json({ lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : null }); const payload = { lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : null };
cacheSet(cacheKey, payload);
res.set("Cache-Control", "public, max-age=30");
return res.status(200).json(payload);
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) { if (error instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" }); return res.status(401).json({ message: "Invalid or expired token" });
@@ -132,7 +202,7 @@ router.get("/watchedMovies", async (req, res) => {
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); // Verify token const decoded = jwt.verify(token, process.env.JWT_SECRET); // Verify token
const user = await User.findById(decoded.id); // Get user by ID const user = await User.findById(decoded.id).select("watchedMovies").lean(); // Get user by ID
if (!user) return res.status(404).json({ message: "User not found" }); if (!user) return res.status(404).json({ message: "User not found" });
res.status(200).json(user.watchedMovies); // Return the list of watched movies res.status(200).json(user.watchedMovies); // Return the list of watched movies
@@ -242,6 +312,7 @@ router.post("/add", async (req, res) => {
}); });
await movie.save(); await movie.save();
cacheClear();
res.status(201).json({ message: "Movie added successfully!" }); res.status(201).json({ message: "Movie added successfully!" });
} catch (err) { } catch (err) {
@@ -279,6 +350,7 @@ router.delete("/delete", async (req, res) => {
const deletedImdbIds = moviesToDelete.map((m) => m.imdb_id).filter(Boolean); const deletedImdbIds = moviesToDelete.map((m) => m.imdb_id).filter(Boolean);
const result = await Movie.deleteMany(filter); const result = await Movie.deleteMany(filter);
cacheClear();
if (deletedImdbIds.length > 0) { if (deletedImdbIds.length > 0) {
await User.updateMany( await User.updateMany(
@@ -411,6 +483,7 @@ router.put("/:id", async (req, res) => {
} }
await movie.save(); await movie.save();
cacheClear();
return res.status(200).json(movie); return res.status(200).json(movie);
} catch (err) { } catch (err) {
if (err instanceof jwt.JsonWebTokenError) { if (err instanceof jwt.JsonWebTokenError) {
@@ -504,7 +577,7 @@ router.get("/:id", async (req, res) => {
return res.status(400).json({ message: "Invalid movie id" }); return res.status(400).json({ message: "Invalid movie id" });
} }
const movie = await Movie.findById(id); const movie = await Movie.findById(id).lean();
if (!movie) return res.status(404).json({ message: "Movie not found" }); if (!movie) return res.status(404).json({ message: "Movie not found" });
return res.status(200).json(movie); return res.status(200).json(movie);
} catch (err) { } catch (err) {