add: elo data model and static rankings page
All checks were successful
Deploy on push / deploy (push) Has been skipped

This commit is contained in:
2026-03-22 20:18:28 -07:00
parent 8f9a7eda2f
commit 26adbe617f
11 changed files with 1071 additions and 18 deletions

View File

@@ -1,18 +1,25 @@
const fs = require("fs");
const path = require("path");
const { getRankedMeals, syncEloWithMeals } = require("./lib/elo");
const { loadMeals, repoRoot } = require("./lib/meals");
const indexTemplatePath = path.join(repoRoot, "templates", "index.html");
const indexOutputPath = path.join(repoRoot, "index.html");
const rankingsTemplatePath = path.join(repoRoot, "templates", "rankings.html");
const rankingsOutputPath = path.join(repoRoot, "rankings.html");
const ratingFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
});
function detectEol(text) {
return text.includes("\r\n") ? "\r\n" : "\n";
}
function escapeHtml(value) {
return value
return String(value)
.replace(/&/g, "&")
.replace(/>/g, ">")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;");
}
@@ -37,6 +44,53 @@ function renderGallery(meals, eol) {
return meals.map((meal) => renderGalleryItem(meal, eol)).join(eol);
}
function formatRating(rating) {
return ratingFormatter.format(rating);
}
function renderRankingSummary(meals, eloData) {
const mealLabel = meals.length === 1 ? "meal" : "meals";
return `\t\t\t\t<p class="ranking-summary">${meals.length} ${mealLabel} seeded at Elo ${formatRating(
eloData.defaultRating
)} until head-to-head voting starts.</p>`;
}
function renderRankingMeta(rankedMeal) {
const ratingText = `Elo ${formatRating(rankedMeal.rating)}`;
if (rankedMeal.matches === 0) {
return `${ratingText} | no votes yet`;
}
const matchLabel = rankedMeal.matches === 1 ? "match" : "matches";
return `${ratingText} | ${rankedMeal.wins}-${rankedMeal.losses} record across ${
rankedMeal.matches
} ${matchLabel}`;
}
function renderRankingItem(rankedMeal, placement, eol) {
return [
'\t\t\t\t<article class="ranking-card">',
`\t\t\t\t\t<p class="ranking-card__placement">#${placement}</p>`,
`\t\t\t\t\t<a class="ranking-card__thumbnail" href="images/fulls/${rankedMeal.id}.jpg"><img src="images/thumbs/${rankedMeal.id}.jpg" alt="${escapeHtml(
`${rankedMeal.title} thumbnail`
)}" /></a>`,
'\t\t\t\t\t<div class="ranking-card__body">',
`\t\t\t\t\t\t<h2>${escapeHtml(rankedMeal.title)}</h2>`,
`\t\t\t\t\t\t<p class="ranking-card__meta">${escapeHtml(renderRankingMeta(rankedMeal))}</p>`,
`\t\t\t\t\t\t<p>${escapeHtml(rankedMeal.description)}</p>`,
"\t\t\t\t\t</div>",
"\t\t\t\t</article>",
].join(eol);
}
function renderRankings(rankedMeals, eol) {
return rankedMeals
.map((rankedMeal, index) => renderRankingItem(rankedMeal, index + 1, eol))
.join(eol);
}
function replaceBlock(template, token, replacement) {
const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m");
@@ -47,20 +101,39 @@ function replaceBlock(template, token, replacement) {
return template.replace(pattern, () => replacement);
}
function buildIndex() {
function buildIndex(meals = loadMeals()) {
const template = fs.readFileSync(indexTemplatePath, "utf8");
const eol = detectEol(template);
const meals = loadMeals();
return replaceBlock(template, "gallery_items", renderGallery(meals, eol));
}
function buildRankings(
meals = loadMeals(),
eloData = syncEloWithMeals(meals)
) {
const template = fs.readFileSync(rankingsTemplatePath, "utf8");
const eol = detectEol(template);
const rankedMeals = getRankedMeals(meals, eloData);
const withSummary = replaceBlock(
template,
"ranking_summary",
renderRankingSummary(meals, eloData)
);
return replaceBlock(withSummary, "ranking_items", renderRankings(rankedMeals, eol));
}
function writeFile(filePath, contents) {
fs.writeFileSync(filePath, contents);
}
function main() {
writeFile(indexOutputPath, buildIndex());
const meals = loadMeals();
const eloData = syncEloWithMeals(meals);
writeFile(indexOutputPath, buildIndex(meals));
writeFile(rankingsOutputPath, buildRankings(meals, eloData));
}
function buildPages() {
@@ -74,4 +147,5 @@ if (require.main === module) {
module.exports = {
buildPages,
buildIndex,
buildRankings,
};

View File

@@ -4,6 +4,7 @@ const sharp = require("sharp");
const { buildPages } = require("./build");
const { buildThumbnails } = require("./generate-thumbnails");
const { eloPath } = require("./lib/elo");
const {
getNextMealId,
loadMeals,
@@ -15,6 +16,7 @@ const {
const fullsDir = path.join(repoRoot, "images", "fulls");
const thumbsDir = path.join(repoRoot, "images", "thumbs");
const indexPath = path.join(repoRoot, "index.html");
const rankingsPath = path.join(repoRoot, "rankings.html");
const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json");
const FULL_IMAGE_QUALITY = 90;
@@ -123,28 +125,45 @@ async function writeFullImage(sourcePath, destinationPath) {
.toFile(destinationPath);
}
function readOptionalFile(filePath) {
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
}
async function restoreOptionalFile(filePath, previousContents) {
if (previousContents === null) {
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
}
return;
}
if (previousContents !== undefined) {
fs.writeFileSync(filePath, previousContents);
}
}
async function rollback({
createdFullPath,
createdThumbPath,
previousElo,
previousIndex,
previousManifest,
previousMeals,
previousRankings,
}) {
if (previousMeals !== undefined) {
fs.writeFileSync(mealsPath, previousMeals);
}
await restoreOptionalFile(eloPath, previousElo);
if (previousIndex !== undefined) {
fs.writeFileSync(indexPath, previousIndex);
}
if (previousManifest === null) {
if (fs.existsSync(manifestPath)) {
await fs.promises.unlink(manifestPath);
}
} else if (previousManifest !== undefined) {
fs.writeFileSync(manifestPath, previousManifest);
}
await restoreOptionalFile(rankingsPath, previousRankings);
await restoreOptionalFile(manifestPath, previousManifest);
if (createdThumbPath && fs.existsSync(createdThumbPath)) {
await fs.promises.unlink(createdThumbPath);
@@ -169,10 +188,10 @@ async function ingestMeal(options) {
}
const previousMeals = fs.readFileSync(mealsPath, "utf8");
const previousElo = readOptionalFile(eloPath);
const previousIndex = fs.readFileSync(indexPath, "utf8");
const previousManifest = fs.existsSync(manifestPath)
? fs.readFileSync(manifestPath, "utf8")
: null;
const previousRankings = readOptionalFile(rankingsPath);
const previousManifest = readOptionalFile(manifestPath);
await fs.promises.mkdir(fullsDir, { recursive: true });
@@ -185,9 +204,11 @@ async function ingestMeal(options) {
await rollback({
createdFullPath: fullPath,
createdThumbPath: thumbPath,
previousElo,
previousIndex,
previousManifest,
previousMeals,
previousRankings,
});
throw error;
}

139
scripts/lib/elo.js Normal file
View File

@@ -0,0 +1,139 @@
const fs = require("fs");
const path = require("path");
const { repoRoot } = require("./meals");
const eloPath = path.join(repoRoot, "data", "elo.json");
function validateEloData(eloData) {
if (!eloData || typeof eloData !== "object" || Array.isArray(eloData)) {
throw new Error("data/elo.json must contain an object");
}
for (const field of ["defaultRating", "kFactor"]) {
if (
typeof eloData[field] !== "number" ||
!Number.isFinite(eloData[field]) ||
eloData[field] <= 0
) {
throw new Error(`data/elo.json "${field}" must be a positive number`);
}
}
if (!Array.isArray(eloData.entries)) {
throw new Error('data/elo.json "entries" must be an array');
}
const ids = new Set();
for (const [index, entry] of eloData.entries.entries()) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`Elo entry ${index} must be an object`);
}
if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) {
throw new Error(`Elo entry ${index} has an invalid id`);
}
if (ids.has(entry.id)) {
throw new Error(`Duplicate Elo entry id "${entry.id}" found in data/elo.json`);
}
ids.add(entry.id);
if (
typeof entry.rating !== "number" ||
!Number.isFinite(entry.rating) ||
entry.rating <= 0
) {
throw new Error(`Elo entry ${index} must have a positive numeric rating`);
}
for (const field of ["wins", "losses"]) {
if (!Number.isInteger(entry[field]) || entry[field] < 0) {
throw new Error(`Elo entry ${index} field "${field}" must be a non-negative integer`);
}
}
}
}
function loadEloData() {
const eloData = JSON.parse(fs.readFileSync(eloPath, "utf8"));
validateEloData(eloData);
return eloData;
}
function saveEloData(eloData) {
validateEloData(eloData);
fs.writeFileSync(eloPath, `${JSON.stringify(eloData, null, 2)}\n`);
}
function createDefaultEntry(id, defaultRating) {
return {
id,
rating: defaultRating,
wins: 0,
losses: 0,
};
}
function syncEloWithMeals(meals) {
const eloData = loadEloData();
const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry]));
const syncedData = {
...eloData,
entries: meals.map((meal) => {
const existingEntry = entryById.get(meal.id);
return existingEntry
? { ...existingEntry }
: createDefaultEntry(meal.id, eloData.defaultRating);
}),
};
if (JSON.stringify(syncedData) !== JSON.stringify(eloData)) {
saveEloData(syncedData);
}
return syncedData;
}
function compareRankedMeals(left, right) {
if (right.rating !== left.rating) {
return right.rating - left.rating;
}
if (right.matches !== left.matches) {
return right.matches - left.matches;
}
return Number.parseInt(left.id, 10) - Number.parseInt(right.id, 10);
}
function getRankedMeals(meals, eloData) {
const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry]));
return meals
.map((meal) => {
const entry = entryById.get(meal.id) || createDefaultEntry(meal.id, eloData.defaultRating);
const matches = entry.wins + entry.losses;
return {
...meal,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
matches,
};
})
.sort(compareRankedMeals);
}
module.exports = {
eloPath,
getRankedMeals,
loadEloData,
saveEloData,
syncEloWithMeals,
};