add: go back button in rankings
All checks were successful
Deploy on push / deploy (push) Has been skipped
All checks were successful
Deploy on push / deploy (push) Has been skipped
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user