diff --git a/README.md b/README.md index 67ee295..8e8b692 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ npm run build:thumbs:force `data/elo.json` stores the seed rating, Elo `kFactor`, and a win-loss record for each meal. The page build keeps this file aligned with `data/meals.json`, so new meals automatically appear in `rankings.html` with the default seed rating. +The interactive voting flow on `rankings.html` uses browser `localStorage` for persistence. +That means Elo votes persist across reloads on the same browser and device, but they do not sync automatically across devices. +Use the reset button on the rankings page if you want to clear the local vote history and go back to the seeded board. + ## Image Conventions - Full-size images and thumbnails share the same numeric ID @@ -94,5 +98,5 @@ The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the c ## Planned Features -1. A pairwise voting page that shows two food images at a time and updates Elo rankings based on the selected winner. +1. Optional shared sync or export/import for rankings if browser-local persistence becomes too limiting. 2. General cleanup and history cleanup once the bigger structural changes are in place. diff --git a/assets/css/rankings.css b/assets/css/rankings.css index fa262e0..5da9acc 100644 --- a/assets/css/rankings.css +++ b/assets/css/rankings.css @@ -42,6 +42,186 @@ body.rankings-page #giftwo { transform: none; } +#voting { + padding: 0 2.25rem 1.75rem 2.25rem; +} + +.voting-panel { + background: + linear-gradient(140deg, rgba(255, 255, 255, 0.97), rgba(240, 248, 247, 0.94)); + border: 1px solid rgba(16, 16, 16, 0.08); + border-radius: 1.5rem; + box-shadow: 0 1.75rem 3.5rem rgba(16, 16, 16, 0.08); + padding: 1.5rem; +} + +.voting-panel__intro h2, +.voting-panel__intro p { + margin: 0; +} + +.voting-panel__eyebrow { + color: #00a892; + font-size: 0.8rem; + letter-spacing: 0.12em; + margin-bottom: 0.75rem; + text-transform: uppercase; +} + +.voting-panel__intro h2 { + color: #333; + font-size: 2rem; +} + +.vote-status { + color: #666; + margin-top: 0.5rem; +} + +.voting-panel__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.25rem; +} + +.vote-message { + color: #333; + font-size: 0.95rem; + margin: 1rem 0 0 0; +} + +.duel-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 1.25rem; +} + +.duel-placeholder { + color: #666; + grid-column: 1 / -1; + margin: 0; +} + +.duel-card { + background: #ffffff; + border: 1px solid rgba(16, 16, 16, 0.08); + border-radius: 1.25rem; + box-shadow: 0 1rem 2.5rem rgba(16, 16, 16, 0.08); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.duel-card__button { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: block; + padding: 0; + text-align: left; + white-space: normal; + width: 100%; +} + +.duel-card__button:hover, +.duel-card__button:focus-visible { + color: inherit; +} + +.duel-card__button:focus-visible { + outline: 3px solid rgba(0, 211, 183, 0.55); + outline-offset: -3px; +} + +.duel-card__button:hover .duel-card__media img, +.duel-card__button:focus-visible .duel-card__media img { + transform: scale(1.02); +} + +.duel-card__media { + background: #eff3f8; + overflow: hidden; +} + +.duel-card__media img { + aspect-ratio: 3 / 4; + display: block; + object-fit: cover; + transition: transform 0.2s ease; + width: 100%; +} + +.duel-card__body { + display: flex; + flex-direction: column; + gap: 0.7rem; + padding: 1.25rem; +} + +.duel-card__label, +.duel-card__meta, +.duel-card__description, +.duel-card__body h3 { + margin: 0; +} + +.duel-card__label, +.duel-card__meta { + text-transform: uppercase; +} + +.duel-card__label { + color: #00a892; + font-size: 0.78rem; + letter-spacing: 0.12em; +} + +.duel-card__title { + color: #333; + font-size: 1.5rem; +} + +.duel-card__meta { + color: #666; + font-size: 0.78rem; + letter-spacing: 0.08em; +} + +.duel-card__description { + color: #777; +} + +.duel-card__cta { + color: #101010; + display: inline-flex; + font-size: 0.92rem; + font-weight: 700; + margin-top: 0.35rem; +} + +.duel-card__open { + align-self: flex-start; + border-bottom: 0; + color: #666; + font-size: 0.82rem; + letter-spacing: 0.08em; + margin: 0 1.25rem 1.25rem 1.25rem; + text-transform: uppercase; +} + +.duel-card__open:hover { + color: #00a892; +} + +.vote-hint { + color: #666; + font-size: 0.85rem; + margin: 1rem 0 0 0; +} + #rankings-summary { padding: 0 2.25rem 1.25rem 2.25rem; } @@ -138,6 +318,7 @@ body.rankings-page #giftwo { } #rankings-summary, + #voting, #rankings, body.rankings-page #header, body.rankings-page #footer { @@ -145,6 +326,14 @@ body.rankings-page #giftwo { padding-right: 1.25rem; } + .voting-panel { + padding: 1.25rem; + } + + .duel-grid { + grid-template-columns: 1fr; + } + .ranking-card { grid-template-columns: 1fr; } diff --git a/assets/js/rankings.js b/assets/js/rankings.js new file mode 100644 index 0000000..b331f7d --- /dev/null +++ b/assets/js/rankings.js @@ -0,0 +1,543 @@ +(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(); +})(); diff --git a/rankings.html b/rankings.html index d7e8c4b..c760a02 100644 --- a/rankings.html +++ b/rankings.html @@ -28,7 +28,7 @@ + +
+
+
+

Head-To-Head Voting

+

Pick the winner.

+

Enable JavaScript to save votes in this browser.

+
+
+ + +
+

Enable JavaScript to start head-to-head voting.

+
+

Enable JavaScript to compare meals here.

+
+

Tip: use the left and right arrow keys to vote faster.

+
+
+
-

33 meals seeded at Elo 1,000 until head-to-head voting starts.

+

33 meals seeded at Elo 1,000. Enable JavaScript to vote and reorder them.

@@ -351,5 +371,402 @@ + + diff --git a/scripts/build.js b/scripts/build.js index ad4959c..7b378de 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -24,6 +24,20 @@ function escapeHtml(value) { .replace(/"/g, """); } +function serializeJsonForHtml(value) { + return JSON.stringify(value, null, 2) + .replace(/ `${indent}${line}`) + .join("\n"); +} + function renderGalleryItem(meal, eol) { const attrs = [`class="thumbnail"`, `href="images/fulls/${meal.id}.jpg"`]; @@ -53,7 +67,7 @@ function renderRankingSummary(meals, eloData) { return `\t\t\t\t

${meals.length} ${mealLabel} seeded at Elo ${formatRating( eloData.defaultRating - )} until head-to-head voting starts.

`; + )}. Enable JavaScript to vote and reorder them.

`; } function renderRankingMeta(rankedMeal) { @@ -91,6 +105,16 @@ function renderRankings(rankedMeals, eol) { .join(eol); } +function renderRankingsSeedData(meals, eloData) { + return indentBlock( + serializeJsonForHtml({ + meals, + elo: eloData, + }), + "\t\t\t" + ); +} + function replaceBlock(template, token, replacement) { const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m"); @@ -120,8 +144,13 @@ function buildRankings( "ranking_summary", renderRankingSummary(meals, eloData) ); + const withSeedData = replaceBlock( + withSummary, + "rankings_seed_data", + renderRankingsSeedData(meals, eloData) + ); - return replaceBlock(withSummary, "ranking_items", renderRankings(rankedMeals, eol)); + return replaceBlock(withSeedData, "ranking_items", renderRankings(rankedMeals, eol)); } function writeFile(filePath, contents) { diff --git a/templates/rankings.html b/templates/rankings.html index 9c756d8..87949a7 100644 --- a/templates/rankings.html +++ b/templates/rankings.html @@ -28,7 +28,7 @@ + +
+
+
+

Head-To-Head Voting

+

Pick the winner.

+

Enable JavaScript to save votes in this browser.

+
+
+ + +
+

Enable JavaScript to start head-to-head voting.

+
+

Enable JavaScript to compare meals here.

+
+

Tip: use the left and right arrow keys to vote faster.

+
+
+
{{ranking_summary}} @@ -55,5 +75,9 @@ + +