Files
gallery/assets/js/rankings.js
Ryan Chou 9f60ab3cca
All checks were successful
Deploy on push / deploy (push) Has been skipped
add: persistant storage of elo ranking
2026-03-22 21:18:49 -07:00

733 lines
20 KiB
JavaScript

(function () {
const STORAGE_KEY = "gallery.rankings.v1";
const STORAGE_TEST_KEY = `${STORAGE_KEY}.probe`;
const STATE_VERSION = 1;
const CLOSE_MATCH_COUNT = 6;
const REMOTE_RANKINGS_URL = "/api/rankings";
const REMOTE_RANKINGS_VOTE_URL = "/api/rankings/vote";
const REMOTE_RANKINGS_RESET_URL = "/api/rankings/reset";
function $(id) {
return document.getElementById(id);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatRating(rating) {
return new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
}).format(rating);
}
function pluralize(count, singular, plural) {
return count === 1 ? singular : plural;
}
function createPairKey(leftId, rightId) {
return [leftId, rightId].sort().join(":");
}
function createDefaultEntry(id, defaultRating) {
return {
id,
rating: defaultRating,
wins: 0,
losses: 0,
};
}
function cloneEntry(entry) {
return {
id: entry.id,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
};
}
function roundRating(rating) {
return Math.round(rating * 1000) / 1000;
}
function pickRandom(items) {
return items[Math.floor(Math.random() * items.length)];
}
function parseSeedData() {
const seedElement = $("rankings-seed-data");
if (!seedElement) {
throw new Error("Missing rankings seed data");
}
const seedData = JSON.parse(seedElement.textContent);
if (!seedData || !Array.isArray(seedData.meals) || !seedData.elo) {
throw new Error("Invalid rankings seed data");
}
return seedData;
}
async function requestJson(url, options) {
const response = await fetch(url, {
cache: "no-store",
headers: {
Accept: "application/json",
...(options && options.body ? { "Content-Type": "application/json" } : {}),
},
...options,
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
}
function createLocalPersistence() {
let available = false;
try {
localStorage.setItem(STORAGE_TEST_KEY, "1");
localStorage.removeItem(STORAGE_TEST_KEY);
available = true;
} catch (error) {
available = false;
}
return {
get available() {
return available;
},
load() {
if (!available) {
return null;
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
},
save(state) {
if (!available) {
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
available = false;
}
},
clear() {
if (!available) {
return;
}
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
available = false;
}
},
};
}
function createPersistence() {
const local = createLocalPersistence();
let mode = "memory";
let pendingNotice = null;
function setMode(nextMode, nextNotice) {
if (mode !== nextMode && nextNotice) {
pendingNotice = nextNotice;
}
mode = nextMode;
}
function getFallbackMode() {
return local.available ? "local" : "memory";
}
return {
get mode() {
return mode;
},
async load(seedData) {
if (typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_URL, { method: "GET" });
setMode("server");
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(getFallbackMode());
}
} else {
setMode(getFallbackMode());
}
return syncStoredState(seedData, local.load());
},
async save(state) {
if (mode === "local") {
local.save(state);
}
return state;
},
async submitVote(seedData, state, winnerId, loserId, pairKey) {
if (mode === "server" && typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_VOTE_URL, {
method: "POST",
body: JSON.stringify({
winnerId,
loserId,
pairKey,
}),
});
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(
getFallbackMode(),
local.available
? "Server sync failed, so votes are now saved only in this browser."
: "Server sync failed, so votes will reset when you reload."
);
}
}
const nextState = applyVote(seedData, state, winnerId, loserId, pairKey);
if (mode === "local") {
local.save(nextState);
}
return nextState;
},
async reset(seedData) {
if (mode === "server" && typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_RESET_URL, {
method: "POST",
});
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(
getFallbackMode(),
local.available
? "Server sync failed, so resets now apply only in this browser."
: "Server sync failed, so resets last only until you reload."
);
}
}
const nextState = createSeedState(seedData);
if (mode === "local") {
local.clear();
local.save(nextState);
}
return nextState;
},
consumeNotice() {
const notice = pendingNotice;
pendingNotice = null;
return notice;
},
};
}
function isValidStoredEntry(entry) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) {
return false;
}
if (typeof entry.rating !== "number" || !Number.isFinite(entry.rating) || entry.rating <= 0) {
return false;
}
return ["wins", "losses"].every(
(field) => Number.isInteger(entry[field]) && entry[field] >= 0
);
}
function createSeedState(seedData) {
return {
version: STATE_VERSION,
voteCount: 0,
lastPairKey: null,
updatedAt: null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries: seedData.meals.map((meal) => {
const seedEntry =
seedData.elo.entries.find((entry) => entry.id === meal.id) ||
createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
}),
},
};
}
function syncStoredState(seedData, storedState) {
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
return createSeedState(seedData);
}
const storedEntries = Array.isArray(storedState.elo?.entries)
? storedState.elo.entries.filter(isValidStoredEntry)
: [];
const storedEntryById = new Map(storedEntries.map((entry) => [entry.id, entry]));
const seedEntryById = new Map(seedData.elo.entries.map((entry) => [entry.id, entry]));
const entries = seedData.meals.map((meal) => {
const storedEntry = storedEntryById.get(meal.id);
if (storedEntry) {
return cloneEntry(storedEntry);
}
const seedEntry =
seedEntryById.get(meal.id) || createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
});
const derivedVoteCount = entries.reduce((sum, entry) => sum + entry.wins, 0);
return {
version: STATE_VERSION,
voteCount:
Number.isInteger(storedState.voteCount) && storedState.voteCount >= derivedVoteCount
? storedState.voteCount
: derivedVoteCount,
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries,
},
};
}
function compareRankedMeals(left, right) {
const leftMatches = left.wins + left.losses;
const rightMatches = right.wins + right.losses;
if (right.rating !== left.rating) {
return right.rating - left.rating;
}
if (rightMatches !== leftMatches) {
return rightMatches - leftMatches;
}
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);
return {
...meal,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
matches: entry.wins + entry.losses,
};
})
.sort(compareRankedMeals);
}
function getRankingMeta(rankedMeal) {
const ratingText = `Elo ${formatRating(rankedMeal.rating)}`;
if (rankedMeal.matches === 0) {
return `${ratingText} | no votes yet`;
}
return `${ratingText} | ${rankedMeal.wins}-${rankedMeal.losses} record across ${
rankedMeal.matches
} ${pluralize(rankedMeal.matches, "match", "matches")}`;
}
function expectedScore(rating, opponentRating) {
return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
}
function applyVote(seedData, state, winnerId, loserId, pairKey) {
const entryById = new Map(
state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])
);
const winner = entryById.get(winnerId);
const loser = entryById.get(loserId);
if (!winner || !loser) {
return state;
}
const winnerExpected = expectedScore(winner.rating, loser.rating);
const loserExpected = expectedScore(loser.rating, winner.rating);
winner.rating = roundRating(winner.rating + state.elo.kFactor * (1 - winnerExpected));
loser.rating = roundRating(loser.rating + state.elo.kFactor * (0 - loserExpected));
winner.wins += 1;
loser.losses += 1;
return {
...state,
voteCount: state.voteCount + 1,
lastPairKey: pairKey,
updatedAt: new Date().toISOString(),
elo: {
...state.elo,
entries: seedData.meals.map((meal) => entryById.get(meal.id)),
},
};
}
function choosePair(rankedMeals, avoidedPairKeys) {
if (rankedMeals.length < 2) {
return null;
}
const avoided = new Set(avoidedPairKeys.filter(Boolean));
const rankedOrder = new Map(rankedMeals.map((meal, index) => [meal.id, index]));
for (let attempt = 0; attempt < 20; attempt += 1) {
const baseMeal = rankedMeals[Math.floor(Math.random() * rankedMeals.length)];
const candidates = rankedMeals
.filter((meal) => meal.id !== baseMeal.id)
.sort((left, right) => {
const ratingGap = Math.abs(left.rating - baseMeal.rating) - Math.abs(right.rating - baseMeal.rating);
if (ratingGap !== 0) {
return ratingGap;
}
return (
Math.abs(rankedOrder.get(left.id) - rankedOrder.get(baseMeal.id)) -
Math.abs(rankedOrder.get(right.id) - rankedOrder.get(baseMeal.id))
);
});
const closeCandidates = candidates.slice(0, Math.min(CLOSE_MATCH_COUNT, candidates.length));
const filteredCandidates = closeCandidates.filter(
(meal) => !avoided.has(createPairKey(baseMeal.id, meal.id))
);
const candidatePool =
filteredCandidates.length > 0
? filteredCandidates
: candidates.filter((meal) => !avoided.has(createPairKey(baseMeal.id, meal.id)));
const fallbackPool = candidatePool.length > 0 ? candidatePool : candidates;
const opponent = pickRandom(
fallbackPool.slice(0, Math.min(CLOSE_MATCH_COUNT, fallbackPool.length))
);
if (opponent) {
return Math.random() < 0.5
? { left: baseMeal, right: opponent }
: { left: opponent, right: baseMeal };
}
}
return {
left: rankedMeals[0],
right: rankedMeals[1],
};
}
function renderDuelCard(meal, sideLabel, disabled) {
const disabledAttributes = disabled ? ' disabled aria-disabled="true"' : "";
return `<article class="duel-card">
<button class="duel-card__button" type="button" data-meal-id="${meal.id}"${disabledAttributes}>
<div class="duel-card__media">
<img src="images/fulls/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} photo`)}" />
</div>
<div class="duel-card__body">
<p class="duel-card__label">${escapeHtml(sideLabel)}</p>
<h3 class="duel-card__title">${escapeHtml(meal.title)}</h3>
<p class="duel-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
<p class="duel-card__description">${escapeHtml(meal.description)}</p>
<span class="duel-card__cta">Choose this meal</span>
</div>
</button>
<a class="duel-card__open" href="images/fulls/${meal.id}.jpg" target="_blank" rel="noreferrer">open full image</a>
</article>`;
}
function renderRankingCard(meal, placement) {
return `<article class="ranking-card">
<p class="ranking-card__placement">#${placement}</p>
<a class="ranking-card__thumbnail" href="images/fulls/${meal.id}.jpg">
<img src="images/thumbs/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} thumbnail`)}" loading="lazy" />
</a>
<div class="ranking-card__body">
<h2>${escapeHtml(meal.title)}</h2>
<p class="ranking-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
<p>${escapeHtml(meal.description)}</p>
</div>
</article>`;
}
function getSummaryText(seedData, state, persistence) {
const voteText = `${state.voteCount} ${pluralize(state.voteCount, "vote", "votes")}`;
if (persistence.mode === "server") {
return `${seedData.meals.length} meals ranked. ${voteText} saved on the server.`;
}
if (persistence.mode === "local") {
return `${seedData.meals.length} meals ranked. ${voteText} saved only in this browser.`;
}
return `${seedData.meals.length} meals ranked. ${voteText} active for this session only.`;
}
function getStatusText(persistence) {
if (persistence.mode === "server") {
return "Votes are saved on the server and shared across browsers.";
}
if (persistence.mode === "local") {
return "Server sync is unavailable, so votes are saved only in this browser.";
}
return "Browser storage is unavailable, so votes reset when you reload.";
}
async function init() {
let seedData;
try {
seedData = parseSeedData();
} catch (error) {
console.error(error);
return;
}
const elements = {
duelCards: $("duel-cards"),
rankings: $("rankings"),
rankingSummary: document.querySelector("#rankings-summary .ranking-summary"),
voteStatus: $("vote-status"),
voteMessage: $("vote-message"),
skipPair: $("skip-pair"),
resetRankings: $("reset-rankings"),
};
if (
!elements.duelCards ||
!elements.rankings ||
!elements.rankingSummary ||
!elements.voteStatus ||
!elements.voteMessage ||
!elements.skipPair ||
!elements.resetRankings
) {
return;
}
elements.voteMessage.textContent = "Loading saved rankings...";
const persistence = createPersistence();
let state = await persistence.load(seedData);
let currentPair = null;
let currentPairKey = null;
let lastMessage = "Choose the better meal to start ranking.";
let busy = false;
await persistence.save(state);
function queueNextPair(rankedMeals) {
currentPair = choosePair(rankedMeals, [currentPairKey, state.lastPairKey]);
currentPairKey = currentPair
? createPairKey(currentPair.left.id, currentPair.right.id)
: null;
}
function render(message) {
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
if (message) {
lastMessage = message;
}
if (
!currentPair ||
!rankedMeals.some((meal) => meal.id === currentPair.left.id) ||
!rankedMeals.some((meal) => meal.id === currentPair.right.id)
) {
queueNextPair(rankedMeals);
}
elements.voteStatus.textContent = getStatusText(persistence);
elements.voteMessage.textContent = lastMessage;
elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence);
elements.resetRankings.disabled = busy;
elements.rankings.innerHTML = rankedMeals
.map((meal, index) => renderRankingCard(meal, index + 1))
.join("");
if (!currentPair) {
elements.duelCards.innerHTML =
'<p class="duel-placeholder">Add at least two meals before starting head-to-head voting.</p>';
elements.skipPair.disabled = true;
return;
}
elements.skipPair.disabled = busy;
elements.duelCards.innerHTML = [
renderDuelCard(currentPair.left, "Left Pick", busy),
renderDuelCard(currentPair.right, "Right Pick", busy),
].join("");
}
async function handleVote(winnerId) {
if (!currentPair || busy) {
return;
}
const loserId = currentPair.left.id === winnerId ? currentPair.right.id : currentPair.left.id;
const winnerTitle =
currentPair.left.id === winnerId ? currentPair.left.title : currentPair.right.title;
const loserTitle =
currentPair.left.id === winnerId ? currentPair.right.title : currentPair.left.title;
let nextMessage = `Picked ${winnerTitle} over ${loserTitle}.`;
busy = true;
render(`Saving ${winnerTitle} over ${loserTitle}...`);
try {
state = await persistence.submitVote(seedData, state, winnerId, loserId, currentPairKey);
currentPair = null;
const notice = persistence.consumeNotice();
if (notice) {
nextMessage = `${nextMessage} ${notice}`;
}
} catch (error) {
console.error(error);
nextMessage = "Failed to save that vote.";
} finally {
busy = false;
render(nextMessage);
}
}
elements.duelCards.addEventListener("click", async (event) => {
const button = event.target.closest("[data-meal-id]");
if (!button) {
return;
}
await handleVote(button.getAttribute("data-meal-id"));
});
elements.skipPair.addEventListener("click", () => {
if (busy) {
return;
}
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
queueNextPair(rankedMeals);
render("Skipped that pair.");
});
elements.resetRankings.addEventListener("click", async () => {
if (busy) {
return;
}
if (!window.confirm("Reset the saved rankings back to the seeded board?")) {
return;
}
let nextMessage = "Saved rankings cleared. Back to the seeded board.";
busy = true;
render("Resetting saved rankings...");
try {
state = await persistence.reset(seedData);
currentPair = null;
currentPairKey = null;
const notice = persistence.consumeNotice();
if (notice) {
nextMessage = `${nextMessage} ${notice}`;
}
} catch (error) {
console.error(error);
nextMessage = "Failed to reset rankings.";
} finally {
busy = false;
render(nextMessage);
}
});
document.addEventListener("keydown", async (event) => {
const target = event.target;
if (
target &&
["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(target.tagName)
) {
return;
}
if (!currentPair || busy) {
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
await handleVote(currentPair.left.id);
} else if (event.key === "ArrowRight") {
event.preventDefault();
await handleVote(currentPair.right.id);
}
});
render();
}
init().catch((error) => {
console.error(error);
});
})();