(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"; const REMOTE_RANKINGS_UNDO_URL = "/api/rankings/undo"; function $(id) { return document.getElementById(id); } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } 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 isValidMealId(id) { return typeof id === "string" && /^\d+$/.test(id); } 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, leftId, rightId) { if (mode === "server" && typeof fetch === "function") { try { const remoteState = await requestJson(REMOTE_RANKINGS_VOTE_URL, { method: "POST", body: JSON.stringify({ winnerId, loserId, pairKey, leftId, rightId, }), }); 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, leftId, rightId ); if (mode === "local") { local.save(nextState); } return nextState; }, async undo(seedData, state) { if (mode === "server" && typeof fetch === "function") { try { const remoteState = await requestJson(REMOTE_RANKINGS_UNDO_URL, { method: "POST", }); return syncStoredState(seedData, remoteState); } catch (error) { setMode( getFallbackMode(), local.available ? "Server sync failed, so go back now applies only in this browser." : "Server sync failed, so go back lasts only until you reload." ); } } const nextState = undoLastVote(seedData, state); 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, undo: 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 syncStateCore(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, undo: null, elo: { defaultRating: seedData.elo.defaultRating, kFactor: seedData.elo.kFactor, entries, }, }; } function createUndoSnapshot(state) { return { voteCount: state.voteCount, lastPairKey: state.lastPairKey, updatedAt: state.updatedAt, elo: { defaultRating: state.elo.defaultRating, kFactor: state.elo.kFactor, entries: state.elo.entries.map(cloneEntry), }, }; } function resolveUndoPairIds(winnerId, loserId, leftId, rightId) { const validPair = isValidMealId(leftId) && isValidMealId(rightId) && leftId !== rightId && [leftId, rightId].includes(winnerId) && [leftId, rightId].includes(loserId); if (validPair) { return { leftId, rightId }; } return { leftId: winnerId, rightId: loserId }; } function syncUndo(seedData, storedUndo) { if (!storedUndo || typeof storedUndo !== "object" || Array.isArray(storedUndo)) { return null; } const { leftId, rightId, winnerId, loserId, snapshot } = storedUndo; if ( !isValidMealId(leftId) || !isValidMealId(rightId) || !isValidMealId(winnerId) || !isValidMealId(loserId) || leftId === rightId || winnerId === loserId ) { return null; } const pairIds = new Set([leftId, rightId]); const mealIds = new Set(seedData.meals.map((meal) => meal.id)); if ( !pairIds.has(winnerId) || !pairIds.has(loserId) || ![leftId, rightId, winnerId, loserId].every((id) => mealIds.has(id)) ) { return null; } return { pairKey: createPairKey(leftId, rightId), leftId, rightId, winnerId, loserId, snapshot: createUndoSnapshot(syncStateCore(seedData, snapshot)), }; } function syncStoredState(seedData, storedState) { const nextState = syncStateCore(seedData, storedState); return { ...nextState, undo: syncUndo(seedData, storedState?.undo), }; } function restoreUndoSnapshot(seedData, snapshot) { return { ...syncStateCore(seedData, snapshot), undo: null, }; } 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, leftId, rightId) { const entryById = new Map( state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)]) ); const winner = entryById.get(winnerId); const loser = entryById.get(loserId); const undoPair = resolveUndoPairIds(winnerId, loserId, leftId, rightId); const resolvedPairKey = typeof pairKey === "string" && pairKey.length > 0 ? pairKey : createPairKey(undoPair.leftId, undoPair.rightId); 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: resolvedPairKey, updatedAt: new Date().toISOString(), undo: { pairKey: createPairKey(undoPair.leftId, undoPair.rightId), leftId: undoPair.leftId, rightId: undoPair.rightId, winnerId, loserId, snapshot: createUndoSnapshot(state), }, elo: { ...state.elo, entries: seedData.meals.map((meal) => entryById.get(meal.id)), }, }; } function undoLastVote(seedData, state) { if (!state.undo) { return state; } return restoreUndoSnapshot(seedData, state.undo.snapshot); } 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 `
open full image
`; } function renderRankingCard(meal, placement) { return `

#${placement}

${escapeHtml(`${meal.title} thumbnail`)}

${escapeHtml(meal.title)}

${escapeHtml(getRankingMeta(meal))}

${escapeHtml(meal.description)}

`; } 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"), undoVote: $("undo-vote"), resetRankings: $("reset-rankings"), }; if ( !elements.duelCards || !elements.rankings || !elements.rankingSummary || !elements.voteStatus || !elements.voteMessage || !elements.skipPair || !elements.undoVote || !elements.resetRankings ) { return; } elements.voteMessage.textContent = "Loading saved rankings..."; const persistence = createPersistence(); const mealById = new Map(seedData.meals.map((meal) => [meal.id, meal])); let state = await persistence.load(seedData); let currentPair = null; let currentPairKey = null; let pendingPairIds = null; let lastMessage = "Choose the better meal to start ranking."; let busy = false; await persistence.save(state); function queueNextPair(rankedMeals) { if (pendingPairIds) { const rankedMealById = new Map(rankedMeals.map((meal) => [meal.id, meal])); const left = rankedMealById.get(pendingPairIds.leftId); const right = rankedMealById.get(pendingPairIds.rightId); pendingPairIds = null; if (left && right && left.id !== right.id) { currentPair = { left, right }; currentPairKey = createPairKey(left.id, right.id); return; } } 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.undoVote.disabled = busy || !state.undo; elements.resetRankings.disabled = busy; elements.rankings.innerHTML = rankedMeals .map((meal, index) => renderRankingCard(meal, index + 1)) .join(""); if (!currentPair) { elements.duelCards.innerHTML = '

Add at least two meals before starting head-to-head voting.

'; 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.left.id, currentPair.right.id ); 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); } } async function handleUndo() { if (!state.undo || busy) { return; } const undoInfo = state.undo; const winnerTitle = mealById.get(undoInfo.winnerId)?.title || "that meal"; const loserTitle = mealById.get(undoInfo.loserId)?.title || "the other meal"; let nextMessage = `Went back before ${winnerTitle} over ${loserTitle}. Pick again.`; busy = true; render(`Going back before ${winnerTitle} over ${loserTitle}...`); try { state = await persistence.undo(seedData, state); currentPair = null; currentPairKey = null; pendingPairIds = { leftId: undoInfo.leftId, rightId: undoInfo.rightId, }; const notice = persistence.consumeNotice(); if (notice) { nextMessage = `${nextMessage} ${notice}`; } } catch (error) { console.error(error); nextMessage = "Failed to go back to the previous 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.undoVote.addEventListener("click", async () => { await handleUndo(); }); 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 (busy) { return; } if (event.key.toLowerCase() === "z" && state.undo) { event.preventDefault(); await handleUndo(); return; } if (!currentPair) { 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); }); })();