diff --git a/assets/js/rankings.js b/assets/js/rankings.js index 58fb24f..3d3f50f 100644 --- a/assets/js/rankings.js +++ b/assets/js/rankings.js @@ -6,6 +6,7 @@ const REMOTE_RANKINGS_URL = "/api/rankings"; const REMOTE_RANKINGS_VOTE_URL = "/api/rankings/vote"; const REMOTE_RANKINGS_RESET_URL = "/api/rankings/reset"; + const REMOTE_RANKINGS_UNDO_URL = "/api/rankings/undo"; function $(id) { return document.getElementById(id); @@ -33,6 +34,10 @@ return [leftId, rightId].sort().join(":"); } + function isValidMealId(id) { + return typeof id === "string" && /^\d+$/.test(id); + } + function createDefaultEntry(id, defaultRating) { return { id, @@ -189,7 +194,7 @@ return state; }, - async submitVote(seedData, state, winnerId, loserId, pairKey) { + async submitVote(seedData, state, winnerId, loserId, pairKey, leftId, rightId) { if (mode === "server" && typeof fetch === "function") { try { const remoteState = await requestJson(REMOTE_RANKINGS_VOTE_URL, { @@ -198,6 +203,8 @@ winnerId, loserId, pairKey, + leftId, + rightId, }), }); @@ -212,7 +219,41 @@ } } - const nextState = applyVote(seedData, state, winnerId, loserId, pairKey); + const nextState = applyVote( + seedData, + state, + winnerId, + loserId, + pairKey, + leftId, + rightId + ); + + if (mode === "local") { + local.save(nextState); + } + + return nextState; + }, + async undo(seedData, state) { + if (mode === "server" && typeof fetch === "function") { + try { + const remoteState = await requestJson(REMOTE_RANKINGS_UNDO_URL, { + method: "POST", + }); + + return syncStoredState(seedData, remoteState); + } catch (error) { + setMode( + getFallbackMode(), + local.available + ? "Server sync failed, so go back now applies only in this browser." + : "Server sync failed, so go back lasts only until you reload." + ); + } + } + + const nextState = undoLastVote(seedData, state); if (mode === "local") { local.save(nextState); @@ -281,6 +322,7 @@ voteCount: 0, lastPairKey: null, updatedAt: null, + undo: null, elo: { defaultRating: seedData.elo.defaultRating, kFactor: seedData.elo.kFactor, @@ -295,7 +337,7 @@ }; } - function syncStoredState(seedData, storedState) { + function syncStateCore(seedData, storedState) { if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) { return createSeedState(seedData); } @@ -327,6 +369,7 @@ : derivedVoteCount, lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null, updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null, + undo: null, elo: { defaultRating: seedData.elo.defaultRating, kFactor: seedData.elo.kFactor, @@ -335,6 +378,89 @@ }; } + function createUndoSnapshot(state) { + return { + voteCount: state.voteCount, + lastPairKey: state.lastPairKey, + updatedAt: state.updatedAt, + elo: { + defaultRating: state.elo.defaultRating, + kFactor: state.elo.kFactor, + entries: state.elo.entries.map(cloneEntry), + }, + }; + } + + function resolveUndoPairIds(winnerId, loserId, leftId, rightId) { + const validPair = + isValidMealId(leftId) && + isValidMealId(rightId) && + leftId !== rightId && + [leftId, rightId].includes(winnerId) && + [leftId, rightId].includes(loserId); + + if (validPair) { + return { leftId, rightId }; + } + + return { leftId: winnerId, rightId: loserId }; + } + + function syncUndo(seedData, storedUndo) { + if (!storedUndo || typeof storedUndo !== "object" || Array.isArray(storedUndo)) { + return null; + } + + const { leftId, rightId, winnerId, loserId, snapshot } = storedUndo; + + if ( + !isValidMealId(leftId) || + !isValidMealId(rightId) || + !isValidMealId(winnerId) || + !isValidMealId(loserId) || + leftId === rightId || + winnerId === loserId + ) { + return null; + } + + const pairIds = new Set([leftId, rightId]); + const mealIds = new Set(seedData.meals.map((meal) => meal.id)); + + if ( + !pairIds.has(winnerId) || + !pairIds.has(loserId) || + ![leftId, rightId, winnerId, loserId].every((id) => mealIds.has(id)) + ) { + return null; + } + + return { + pairKey: createPairKey(leftId, rightId), + leftId, + rightId, + winnerId, + loserId, + snapshot: createUndoSnapshot(syncStateCore(seedData, snapshot)), + }; + } + + function syncStoredState(seedData, storedState) { + const nextState = syncStateCore(seedData, storedState); + + return { + ...nextState, + undo: syncUndo(seedData, storedState?.undo), + }; + } + + function restoreUndoSnapshot(seedData, snapshot) { + return { + ...syncStateCore(seedData, snapshot), + undo: null, + }; + } + function compareRankedMeals(left, right) { const leftMatches = left.wins + left.losses; const rightMatches = right.wins + right.losses; @@ -385,12 +511,17 @@ return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400)); } - function applyVote(seedData, state, winnerId, loserId, pairKey) { + function applyVote(seedData, state, winnerId, loserId, pairKey, leftId, rightId) { const entryById = new Map( state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)]) ); const winner = entryById.get(winnerId); const loser = entryById.get(loserId); + const undoPair = resolveUndoPairIds(winnerId, loserId, leftId, rightId); + const resolvedPairKey = + typeof pairKey === "string" && pairKey.length > 0 + ? pairKey + : createPairKey(undoPair.leftId, undoPair.rightId); if (!winner || !loser) { return state; @@ -407,8 +538,16 @@ return { ...state, voteCount: state.voteCount + 1, - lastPairKey: pairKey, + lastPairKey: resolvedPairKey, updatedAt: new Date().toISOString(), + undo: { + pairKey: createPairKey(undoPair.leftId, undoPair.rightId), + leftId: undoPair.leftId, + rightId: undoPair.rightId, + winnerId, + loserId, + snapshot: createUndoSnapshot(state), + }, elo: { ...state.elo, entries: seedData.meals.map((meal) => entryById.get(meal.id)), @@ -416,6 +555,14 @@ }; } + function undoLastVote(seedData, state) { + if (!state.undo) { + return state; + } + + return restoreUndoSnapshot(seedData, state.undo.snapshot); + } + function choosePair(rankedMeals, avoidedPairKeys) { if (rankedMeals.length < 2) { return null; @@ -543,6 +690,7 @@ voteStatus: $("vote-status"), voteMessage: $("vote-message"), skipPair: $("skip-pair"), + undoVote: $("undo-vote"), resetRankings: $("reset-rankings"), }; @@ -553,6 +701,7 @@ !elements.voteStatus || !elements.voteMessage || !elements.skipPair || + !elements.undoVote || !elements.resetRankings ) { return; @@ -561,15 +710,31 @@ elements.voteMessage.textContent = "Loading saved rankings..."; const persistence = createPersistence(); + const mealById = new Map(seedData.meals.map((meal) => [meal.id, meal])); let state = await persistence.load(seedData); let currentPair = null; let currentPairKey = null; + let pendingPairIds = null; let lastMessage = "Choose the better meal to start ranking."; let busy = false; await persistence.save(state); function queueNextPair(rankedMeals) { + if (pendingPairIds) { + const rankedMealById = new Map(rankedMeals.map((meal) => [meal.id, meal])); + const left = rankedMealById.get(pendingPairIds.leftId); + const right = rankedMealById.get(pendingPairIds.rightId); + + pendingPairIds = null; + + if (left && right && left.id !== right.id) { + currentPair = { left, right }; + currentPairKey = createPairKey(left.id, right.id); + return; + } + } + currentPair = choosePair(rankedMeals, [currentPairKey, state.lastPairKey]); currentPairKey = currentPair ? createPairKey(currentPair.left.id, currentPair.right.id) @@ -594,6 +759,7 @@ elements.voteStatus.textContent = getStatusText(persistence); elements.voteMessage.textContent = lastMessage; elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence); + elements.undoVote.disabled = busy || !state.undo; elements.resetRankings.disabled = busy; elements.rankings.innerHTML = rankedMeals .map((meal, index) => renderRankingCard(meal, index + 1)) @@ -629,7 +795,15 @@ render(`Saving ${winnerTitle} over ${loserTitle}...`); try { - state = await persistence.submitVote(seedData, state, winnerId, loserId, currentPairKey); + state = await persistence.submitVote( + seedData, + state, + winnerId, + loserId, + currentPairKey, + currentPair.left.id, + currentPair.right.id + ); currentPair = null; const notice = persistence.consumeNotice(); @@ -646,6 +820,42 @@ } } + async function handleUndo() { + if (!state.undo || busy) { + return; + } + + const undoInfo = state.undo; + const winnerTitle = mealById.get(undoInfo.winnerId)?.title || "that meal"; + const loserTitle = mealById.get(undoInfo.loserId)?.title || "the other meal"; + let nextMessage = `Went back before ${winnerTitle} over ${loserTitle}. Pick again.`; + + busy = true; + render(`Going back before ${winnerTitle} over ${loserTitle}...`); + + try { + state = await persistence.undo(seedData, state); + currentPair = null; + currentPairKey = null; + pendingPairIds = { + leftId: undoInfo.leftId, + rightId: undoInfo.rightId, + }; + + const notice = persistence.consumeNotice(); + + if (notice) { + nextMessage = `${nextMessage} ${notice}`; + } + } catch (error) { + console.error(error); + nextMessage = "Failed to go back to the previous vote."; + } finally { + busy = false; + render(nextMessage); + } + } + elements.duelCards.addEventListener("click", async (event) => { const button = event.target.closest("[data-meal-id]"); @@ -667,6 +877,10 @@ render("Skipped that pair."); }); + elements.undoVote.addEventListener("click", async () => { + await handleUndo(); + }); + elements.resetRankings.addEventListener("click", async () => { if (busy) { return; @@ -710,7 +924,17 @@ return; } - if (!currentPair || busy) { + if (busy) { + return; + } + + if (event.key.toLowerCase() === "z" && state.undo) { + event.preventDefault(); + await handleUndo(); + return; + } + + if (!currentPair) { return; } diff --git a/images/thumbs/.thumbs-manifest.json b/images/thumbs/.thumbs-manifest.json index e68a568..e252b11 100644 --- a/images/thumbs/.thumbs-manifest.json +++ b/images/thumbs/.thumbs-manifest.json @@ -4,7 +4,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500129000, + "mtimeMs": 1774257676921.0168, "size": 1052830, "focus": { "x": 0.35, @@ -16,7 +16,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676923.4688, "size": 835360, "focus": null }, @@ -25,7 +25,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676926.1216, "size": 1034158, "focus": { "x": 0.5, @@ -37,7 +37,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676928.9744, "size": 1090215, "focus": null }, @@ -46,7 +46,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676931.93, "size": 1122236, "focus": { "x": 0.5, @@ -58,7 +58,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770444049000, + "mtimeMs": 1774257676932.7878, "size": 676787, "focus": null }, @@ -67,7 +67,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676935.0764, "size": 872024, "focus": null }, @@ -76,7 +76,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676936.637, "size": 618276, "focus": null }, @@ -85,7 +85,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500127000, + "mtimeMs": 1774257676938.8577, "size": 1349804, "focus": null }, @@ -94,7 +94,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676940.0383, "size": 1071870, "focus": null }, @@ -103,7 +103,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676941.5466, "size": 764329, "focus": null }, @@ -112,7 +112,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676943.8735, "size": 1172905, "focus": null }, @@ -121,7 +121,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500129000, + "mtimeMs": 1774257676945.2588, "size": 1099540, "focus": null }, @@ -130,7 +130,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676946.2732, "size": 1052362, "focus": null }, @@ -139,7 +139,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676947.5552, "size": 1227608, "focus": null }, @@ -148,7 +148,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500127000, + "mtimeMs": 1774257676949.5437, "size": 840466, "focus": null }, @@ -157,7 +157,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676951.916, "size": 1136990, "focus": null }, @@ -166,7 +166,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500127000, + "mtimeMs": 1774257676954.4558, "size": 1261294, "focus": null }, @@ -175,7 +175,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676956.7886, "size": 1119498, "focus": null }, @@ -184,7 +184,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676958.691, "size": 868085, "focus": null }, @@ -193,7 +193,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676961.0393, "size": 1057896, "focus": null }, @@ -202,7 +202,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676963.4336, "size": 1088795, "focus": null }, @@ -211,7 +211,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500129000, + "mtimeMs": 1774257676965.364, "size": 852307, "focus": null }, @@ -220,7 +220,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500129000, + "mtimeMs": 1774257676967.8005, "size": 1149955, "focus": null }, @@ -229,7 +229,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500129000, + "mtimeMs": 1774257676970.2761, "size": 1242099, "focus": null }, @@ -238,7 +238,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676971.9075, "size": 1414024, "focus": null }, @@ -247,7 +247,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676973.2812, "size": 1022877, "focus": null }, @@ -256,7 +256,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500129000, + "mtimeMs": 1774257676974.2112, "size": 1018868, "focus": null }, @@ -265,7 +265,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500128000, + "mtimeMs": 1774257676975.1504, "size": 1233602, "focus": null }, @@ -274,7 +274,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1770500129000, + "mtimeMs": 1774257676976.6533, "size": 739786, "focus": null }, @@ -283,7 +283,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1771126436000, + "mtimeMs": 1774257676977.1958, "size": 1069693, "focus": null }, @@ -292,7 +292,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1771226059000, + "mtimeMs": 1774257676979.063, "size": 995282, "focus": null }, @@ -301,7 +301,7 @@ "width": 240, "height": 320, "quality": 82, - "mtimeMs": 1771226144000, + "mtimeMs": 1774257676980.472, "size": 729224, "focus": null } diff --git a/rankings.html b/rankings.html index 501e067..f0ba9ff 100644 --- a/rankings.html +++ b/rankings.html @@ -46,13 +46,14 @@
+

Enable JavaScript to load head-to-head voting.

Enable JavaScript to compare meals here.

-

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

+

Tip: use the left and right arrow keys to vote faster, or press Z to go back.

diff --git a/scripts/lib/rankings-state.js b/scripts/lib/rankings-state.js index aacd775..fe418c4 100644 --- a/scripts/lib/rankings-state.js +++ b/scripts/lib/rankings-state.js @@ -7,6 +7,10 @@ const { loadMeals, repoRoot } = require("./meals"); const STATE_VERSION = 1; const defaultStatePath = path.join(repoRoot, ".runtime", "rankings-state.json"); +function isValidMealId(id) { + return typeof id === "string" && /^\d+$/.test(id); +} + function createDefaultEntry(id, defaultRating) { return { id, @@ -57,6 +61,7 @@ function createSeedState(seedData) { voteCount: 0, lastPairKey: null, updatedAt: null, + undo: null, elo: { defaultRating: seedData.elo.defaultRating, kFactor: seedData.elo.kFactor, @@ -71,7 +76,7 @@ function createSeedState(seedData) { }; } -function syncStoredState(seedData, storedState) { +function syncStateCore(seedData, storedState) { if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) { return createSeedState(seedData); } @@ -103,6 +108,7 @@ function syncStoredState(seedData, storedState) { : derivedVoteCount, lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null, updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null, + undo: null, elo: { defaultRating: seedData.elo.defaultRating, kFactor: seedData.elo.kFactor, @@ -115,10 +121,99 @@ function createPairKey(leftId, rightId) { return [leftId, rightId].sort().join(":"); } -function applyVote(seedData, state, winnerId, loserId, pairKey) { +function createUndoSnapshot(state) { + return { + voteCount: state.voteCount, + lastPairKey: state.lastPairKey, + updatedAt: state.updatedAt, + elo: { + defaultRating: state.elo.defaultRating, + kFactor: state.elo.kFactor, + entries: state.elo.entries.map(cloneEntry), + }, + }; +} + +function resolveUndoPairIds(winnerId, loserId, leftId, rightId) { + const validPair = + isValidMealId(leftId) && + isValidMealId(rightId) && + leftId !== rightId && + [leftId, rightId].includes(winnerId) && + [leftId, rightId].includes(loserId); + + if (validPair) { + return { leftId, rightId }; + } + + return { leftId: winnerId, rightId: loserId }; +} + +function syncUndo(seedData, storedUndo) { + if (!storedUndo || typeof storedUndo !== "object" || Array.isArray(storedUndo)) { + return null; + } + + const { leftId, rightId, winnerId, loserId, snapshot } = storedUndo; + + if ( + !isValidMealId(leftId) || + !isValidMealId(rightId) || + !isValidMealId(winnerId) || + !isValidMealId(loserId) || + leftId === rightId || + winnerId === loserId + ) { + return null; + } + + const pairIds = new Set([leftId, rightId]); + const mealIds = new Set(seedData.meals.map((meal) => meal.id)); + + if ( + !pairIds.has(winnerId) || + !pairIds.has(loserId) || + ![leftId, rightId, winnerId, loserId].every((id) => mealIds.has(id)) + ) { + return null; + } + + return { + pairKey: createPairKey(leftId, rightId), + leftId, + rightId, + winnerId, + loserId, + snapshot: createUndoSnapshot(syncStateCore(seedData, snapshot)), + }; +} + +function syncStoredState(seedData, storedState) { + const nextState = syncStateCore(seedData, storedState); + + return { + ...nextState, + undo: syncUndo(seedData, storedState?.undo), + }; +} + +function restoreUndoSnapshot(seedData, snapshot) { + return { + ...syncStateCore(seedData, snapshot), + undo: null, + }; +} + +function applyVote(seedData, state, vote) { + const { winnerId, loserId, pairKey, leftId, rightId } = vote; const entryById = new Map(state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])); const winner = entryById.get(winnerId); const loser = entryById.get(loserId); + const undoPair = resolveUndoPairIds(winnerId, loserId, leftId, rightId); + const resolvedPairKey = + typeof pairKey === "string" && pairKey.length > 0 + ? pairKey + : createPairKey(undoPair.leftId, undoPair.rightId); if (!winner || !loser) { throw new Error("Vote referenced an unknown meal id"); @@ -135,8 +230,16 @@ function applyVote(seedData, state, winnerId, loserId, pairKey) { return { ...state, voteCount: state.voteCount + 1, - lastPairKey: pairKey || createPairKey(winnerId, loserId), + lastPairKey: resolvedPairKey, updatedAt: new Date().toISOString(), + undo: { + pairKey: createPairKey(undoPair.leftId, undoPair.rightId), + leftId: undoPair.leftId, + rightId: undoPair.rightId, + winnerId, + loserId, + snapshot: createUndoSnapshot(state), + }, elo: { ...state.elo, entries: seedData.meals.map((meal) => entryById.get(meal.id)), @@ -144,6 +247,14 @@ function applyVote(seedData, state, winnerId, loserId, pairKey) { }; } +function undoVote(seedData, state) { + if (!state.undo) { + return state; + } + + return restoreUndoSnapshot(seedData, state.undo.snapshot); +} + function resolveStatePath(statePath) { return statePath ? path.resolve(statePath) : defaultStatePath; } @@ -196,13 +307,13 @@ function recordVote(vote, options = {}) { throw new Error("Vote payload must be an object"); } - const { winnerId, loserId, pairKey } = vote; + const { winnerId, loserId, pairKey, leftId, rightId } = vote; - if (typeof winnerId !== "string" || !/^\d+$/.test(winnerId)) { + if (!isValidMealId(winnerId)) { throw new Error("Vote payload is missing a valid winnerId"); } - if (typeof loserId !== "string" || !/^\d+$/.test(loserId)) { + if (!isValidMealId(loserId)) { throw new Error("Vote payload is missing a valid loserId"); } @@ -210,10 +321,37 @@ function recordVote(vote, options = {}) { throw new Error("winnerId and loserId must be different"); } + if (leftId !== undefined || rightId !== undefined) { + if (!isValidMealId(leftId) || !isValidMealId(rightId) || leftId === rightId) { + throw new Error("Vote payload is missing a valid left/right pair"); + } + + if (![leftId, rightId].includes(winnerId) || ![leftId, rightId].includes(loserId)) { + throw new Error("Vote payload leftId/rightId must match winnerId/loserId"); + } + } + const statePath = resolveStatePath(options.statePath); const seedData = loadSeedData(); const storedState = syncStoredState(seedData, readPersistedState(statePath)); - const nextState = applyVote(seedData, storedState, winnerId, loserId, pairKey); + const nextState = applyVote(seedData, storedState, { + winnerId, + loserId, + pairKey, + leftId, + rightId, + }); + + writePersistedState(statePath, nextState); + + return nextState; +} + +function undoLastRankingsVote(options = {}) { + const statePath = resolveStatePath(options.statePath); + const seedData = loadSeedData(); + const storedState = syncStoredState(seedData, readPersistedState(statePath)); + const nextState = undoVote(seedData, storedState); writePersistedState(statePath, nextState); @@ -238,4 +376,5 @@ module.exports = { resetRankingsState, saveRankingsState, syncStoredState, + undoLastRankingsVote, }; diff --git a/scripts/serve.js b/scripts/serve.js index 18cd2b2..34c2b9d 100644 --- a/scripts/serve.js +++ b/scripts/serve.js @@ -8,6 +8,7 @@ const { loadRankingsState, recordVote, resetRankingsState, + undoLastRankingsVote, } = require("./lib/rankings-state"); const DEFAULT_HOST = "127.0.0.1"; @@ -195,7 +196,17 @@ async function handleApiRequest(request, response, pathname, options) { return true; } - if (pathname === "/api/rankings" || pathname === "/api/rankings/vote" || pathname === "/api/rankings/reset") { + if (pathname === "/api/rankings/undo" && request.method === "POST") { + sendJson(response, 200, undoLastRankingsVote({ statePath: options.rankingsStatePath })); + return true; + } + + if ( + pathname === "/api/rankings" || + pathname === "/api/rankings/vote" || + pathname === "/api/rankings/reset" || + pathname === "/api/rankings/undo" + ) { sendText(response, 405, "Method not allowed"); return true; } diff --git a/templates/rankings.html b/templates/rankings.html index bce32de..3a5e05b 100644 --- a/templates/rankings.html +++ b/templates/rankings.html @@ -46,13 +46,14 @@
+

Enable JavaScript to load head-to-head voting.

Enable JavaScript to compare meals here.

-

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

+

Tip: use the left and right arrow keys to vote faster, or press Z to go back.