(function () { const STORAGE_KEY = "gallery.rankings.v1"; const STORAGE_TEST_KEY = `${STORAGE_KEY}.probe`; const STATE_VERSION = 1; const CLOSE_MATCH_COUNT = 6; 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; } function createPersistence() { 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 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) { 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.available) { return `${seedData.meals.length} meals ranked. ${voteText} saved in this browser.`; } return `${seedData.meals.length} meals ranked. ${voteText} active for this session only.`; } function getStatusText(persistence) { if (persistence.available) { return "Votes are saved in this browser on this device."; } return "Browser storage is unavailable, so votes reset when you reload."; } 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; } const persistence = createPersistence(); let state = syncStoredState(seedData, persistence.load()); let currentPair = null; let currentPairKey = null; let lastMessage = "Choose the better meal to start ranking."; 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.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 = false; elements.duelCards.innerHTML = [ renderDuelCard(currentPair.left, "Left Pick"), renderDuelCard(currentPair.right, "Right Pick"), ].join(""); } function handleVote(winnerId) { if (!currentPair) { 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; state = applyVote(seedData, state, winnerId, loserId, currentPairKey); persistence.save(state); currentPair = null; render(`Picked ${winnerTitle} over ${loserTitle}.`); } elements.duelCards.addEventListener("click", (event) => { const button = event.target.closest("[data-meal-id]"); if (!button) { return; } handleVote(button.getAttribute("data-meal-id")); }); elements.skipPair.addEventListener("click", () => { const rankedMeals = getRankedMeals(seedData.meals, state.elo); queueNextPair(rankedMeals); render("Skipped that pair."); }); elements.resetRankings.addEventListener("click", () => { if (!window.confirm("Reset the local Elo votes saved in this browser?")) { return; } state = createSeedState(seedData); persistence.clear(); persistence.save(state); currentPair = null; currentPairKey = null; render("Local votes cleared. Back to the seeded board."); }); document.addEventListener("keydown", (event) => { const target = event.target; if ( target && ["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(target.tagName) ) { return; } if (!currentPair) { return; } if (event.key === "ArrowLeft") { event.preventDefault(); handleVote(currentPair.left.id); } else if (event.key === "ArrowRight") { event.preventDefault(); handleVote(currentPair.right.id); } }); render(); } init(); })();