(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, ">") .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 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 `
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"), 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 = '

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 = 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); }); })();