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