add: go back button in rankings
All checks were successful
Deploy on push / deploy (push) Has been skipped

This commit is contained in:
2026-03-23 03:05:44 -07:00
parent dc1dce1120
commit ac52daa454
6 changed files with 426 additions and 50 deletions

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -46,13 +46,14 @@
</div>
<div class="voting-panel__actions">
<button class="button small" id="skip-pair" type="button">skip pair</button>
<button class="button small" id="undo-vote" type="button">go back</button>
<button class="button small" id="reset-rankings" type="button">reset saved rankings</button>
</div>
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to load head-to-head voting.</p>
<div class="duel-grid" id="duel-cards">
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
</div>
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster.</p>
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster, or press Z to go back.</p>
</div>
</section>

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -46,13 +46,14 @@
</div>
<div class="voting-panel__actions">
<button class="button small" id="skip-pair" type="button">skip pair</button>
<button class="button small" id="undo-vote" type="button">go back</button>
<button class="button small" id="reset-rankings" type="button">reset saved rankings</button>
</div>
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to load head-to-head voting.</p>
<div class="duel-grid" id="duel-cards">
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
</div>
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster.</p>
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster, or press Z to go back.</p>
</div>
</section>