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 ` #${placement} ${escapeHtml(meal.description)}
+
+ ${escapeHtml(meal.title)}
+
+
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 @@
static Elo seeds for every meal before the head-to-head voting page exists.
+pick the better meal, one pair at a time, and the board updates live in this browser.
gallery / @@ -36,9 +36,29 @@
Head-To-Head Voting
+Enable JavaScript to save votes in this browser.
+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.