add: pairwise elo voting workflow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
543
assets/js/rankings.js
Normal file
543
assets/js/rankings.js
Normal file
@@ -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, ">")
|
||||
.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 `<article class="duel-card">
|
||||
<button class="duel-card__button" type="button" data-meal-id="${meal.id}">
|
||||
<div class="duel-card__media">
|
||||
<img src="images/fulls/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} photo`)}" />
|
||||
</div>
|
||||
<div class="duel-card__body">
|
||||
<p class="duel-card__label">${escapeHtml(sideLabel)}</p>
|
||||
<h3 class="duel-card__title">${escapeHtml(meal.title)}</h3>
|
||||
<p class="duel-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
|
||||
<p class="duel-card__description">${escapeHtml(meal.description)}</p>
|
||||
<span class="duel-card__cta">Choose this meal</span>
|
||||
</div>
|
||||
</button>
|
||||
<a class="duel-card__open" href="images/fulls/${meal.id}.jpg" target="_blank" rel="noreferrer">open full image</a>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderRankingCard(meal, placement) {
|
||||
return `<article class="ranking-card">
|
||||
<p class="ranking-card__placement">#${placement}</p>
|
||||
<a class="ranking-card__thumbnail" href="images/fulls/${meal.id}.jpg">
|
||||
<img src="images/thumbs/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} thumbnail`)}" loading="lazy" />
|
||||
</a>
|
||||
<div class="ranking-card__body">
|
||||
<h2>${escapeHtml(meal.title)}</h2>
|
||||
<p class="ranking-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
|
||||
<p>${escapeHtml(meal.description)}</p>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
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 =
|
||||
'<p class="duel-placeholder">Add at least two meals before starting head-to-head voting.</p>';
|
||||
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();
|
||||
})();
|
||||
Reference in New Issue
Block a user