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_URL = "/api/rankings";
|
||||||
const REMOTE_RANKINGS_VOTE_URL = "/api/rankings/vote";
|
const REMOTE_RANKINGS_VOTE_URL = "/api/rankings/vote";
|
||||||
const REMOTE_RANKINGS_RESET_URL = "/api/rankings/reset";
|
const REMOTE_RANKINGS_RESET_URL = "/api/rankings/reset";
|
||||||
|
const REMOTE_RANKINGS_UNDO_URL = "/api/rankings/undo";
|
||||||
|
|
||||||
function $(id) {
|
function $(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
@@ -33,6 +34,10 @@
|
|||||||
return [leftId, rightId].sort().join(":");
|
return [leftId, rightId].sort().join(":");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidMealId(id) {
|
||||||
|
return typeof id === "string" && /^\d+$/.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
function createDefaultEntry(id, defaultRating) {
|
function createDefaultEntry(id, defaultRating) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -189,7 +194,7 @@
|
|||||||
|
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
async submitVote(seedData, state, winnerId, loserId, pairKey) {
|
async submitVote(seedData, state, winnerId, loserId, pairKey, leftId, rightId) {
|
||||||
if (mode === "server" && typeof fetch === "function") {
|
if (mode === "server" && typeof fetch === "function") {
|
||||||
try {
|
try {
|
||||||
const remoteState = await requestJson(REMOTE_RANKINGS_VOTE_URL, {
|
const remoteState = await requestJson(REMOTE_RANKINGS_VOTE_URL, {
|
||||||
@@ -198,6 +203,8 @@
|
|||||||
winnerId,
|
winnerId,
|
||||||
loserId,
|
loserId,
|
||||||
pairKey,
|
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") {
|
if (mode === "local") {
|
||||||
local.save(nextState);
|
local.save(nextState);
|
||||||
@@ -281,6 +322,7 @@
|
|||||||
voteCount: 0,
|
voteCount: 0,
|
||||||
lastPairKey: null,
|
lastPairKey: null,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
|
undo: null,
|
||||||
elo: {
|
elo: {
|
||||||
defaultRating: seedData.elo.defaultRating,
|
defaultRating: seedData.elo.defaultRating,
|
||||||
kFactor: seedData.elo.kFactor,
|
kFactor: seedData.elo.kFactor,
|
||||||
@@ -295,7 +337,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncStoredState(seedData, storedState) {
|
function syncStateCore(seedData, storedState) {
|
||||||
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
|
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
|
||||||
return createSeedState(seedData);
|
return createSeedState(seedData);
|
||||||
}
|
}
|
||||||
@@ -327,6 +369,7 @@
|
|||||||
: derivedVoteCount,
|
: derivedVoteCount,
|
||||||
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
|
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
|
||||||
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
|
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
|
||||||
|
undo: null,
|
||||||
elo: {
|
elo: {
|
||||||
defaultRating: seedData.elo.defaultRating,
|
defaultRating: seedData.elo.defaultRating,
|
||||||
kFactor: seedData.elo.kFactor,
|
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) {
|
function compareRankedMeals(left, right) {
|
||||||
const leftMatches = left.wins + left.losses;
|
const leftMatches = left.wins + left.losses;
|
||||||
const rightMatches = right.wins + right.losses;
|
const rightMatches = right.wins + right.losses;
|
||||||
@@ -385,12 +511,17 @@
|
|||||||
return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
|
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(
|
const entryById = new Map(
|
||||||
state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])
|
state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])
|
||||||
);
|
);
|
||||||
const winner = entryById.get(winnerId);
|
const winner = entryById.get(winnerId);
|
||||||
const loser = entryById.get(loserId);
|
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) {
|
if (!winner || !loser) {
|
||||||
return state;
|
return state;
|
||||||
@@ -407,8 +538,16 @@
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
voteCount: state.voteCount + 1,
|
voteCount: state.voteCount + 1,
|
||||||
lastPairKey: pairKey,
|
lastPairKey: resolvedPairKey,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
undo: {
|
||||||
|
pairKey: createPairKey(undoPair.leftId, undoPair.rightId),
|
||||||
|
leftId: undoPair.leftId,
|
||||||
|
rightId: undoPair.rightId,
|
||||||
|
winnerId,
|
||||||
|
loserId,
|
||||||
|
snapshot: createUndoSnapshot(state),
|
||||||
|
},
|
||||||
elo: {
|
elo: {
|
||||||
...state.elo,
|
...state.elo,
|
||||||
entries: seedData.meals.map((meal) => entryById.get(meal.id)),
|
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) {
|
function choosePair(rankedMeals, avoidedPairKeys) {
|
||||||
if (rankedMeals.length < 2) {
|
if (rankedMeals.length < 2) {
|
||||||
return null;
|
return null;
|
||||||
@@ -543,6 +690,7 @@
|
|||||||
voteStatus: $("vote-status"),
|
voteStatus: $("vote-status"),
|
||||||
voteMessage: $("vote-message"),
|
voteMessage: $("vote-message"),
|
||||||
skipPair: $("skip-pair"),
|
skipPair: $("skip-pair"),
|
||||||
|
undoVote: $("undo-vote"),
|
||||||
resetRankings: $("reset-rankings"),
|
resetRankings: $("reset-rankings"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -553,6 +701,7 @@
|
|||||||
!elements.voteStatus ||
|
!elements.voteStatus ||
|
||||||
!elements.voteMessage ||
|
!elements.voteMessage ||
|
||||||
!elements.skipPair ||
|
!elements.skipPair ||
|
||||||
|
!elements.undoVote ||
|
||||||
!elements.resetRankings
|
!elements.resetRankings
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -561,15 +710,31 @@
|
|||||||
elements.voteMessage.textContent = "Loading saved rankings...";
|
elements.voteMessage.textContent = "Loading saved rankings...";
|
||||||
|
|
||||||
const persistence = createPersistence();
|
const persistence = createPersistence();
|
||||||
|
const mealById = new Map(seedData.meals.map((meal) => [meal.id, meal]));
|
||||||
let state = await persistence.load(seedData);
|
let state = await persistence.load(seedData);
|
||||||
let currentPair = null;
|
let currentPair = null;
|
||||||
let currentPairKey = null;
|
let currentPairKey = null;
|
||||||
|
let pendingPairIds = null;
|
||||||
let lastMessage = "Choose the better meal to start ranking.";
|
let lastMessage = "Choose the better meal to start ranking.";
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
|
||||||
await persistence.save(state);
|
await persistence.save(state);
|
||||||
|
|
||||||
function queueNextPair(rankedMeals) {
|
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]);
|
currentPair = choosePair(rankedMeals, [currentPairKey, state.lastPairKey]);
|
||||||
currentPairKey = currentPair
|
currentPairKey = currentPair
|
||||||
? createPairKey(currentPair.left.id, currentPair.right.id)
|
? createPairKey(currentPair.left.id, currentPair.right.id)
|
||||||
@@ -594,6 +759,7 @@
|
|||||||
elements.voteStatus.textContent = getStatusText(persistence);
|
elements.voteStatus.textContent = getStatusText(persistence);
|
||||||
elements.voteMessage.textContent = lastMessage;
|
elements.voteMessage.textContent = lastMessage;
|
||||||
elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence);
|
elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence);
|
||||||
|
elements.undoVote.disabled = busy || !state.undo;
|
||||||
elements.resetRankings.disabled = busy;
|
elements.resetRankings.disabled = busy;
|
||||||
elements.rankings.innerHTML = rankedMeals
|
elements.rankings.innerHTML = rankedMeals
|
||||||
.map((meal, index) => renderRankingCard(meal, index + 1))
|
.map((meal, index) => renderRankingCard(meal, index + 1))
|
||||||
@@ -629,7 +795,15 @@
|
|||||||
render(`Saving ${winnerTitle} over ${loserTitle}...`);
|
render(`Saving ${winnerTitle} over ${loserTitle}...`);
|
||||||
|
|
||||||
try {
|
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;
|
currentPair = null;
|
||||||
|
|
||||||
const notice = persistence.consumeNotice();
|
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) => {
|
elements.duelCards.addEventListener("click", async (event) => {
|
||||||
const button = event.target.closest("[data-meal-id]");
|
const button = event.target.closest("[data-meal-id]");
|
||||||
|
|
||||||
@@ -667,6 +877,10 @@
|
|||||||
render("Skipped that pair.");
|
render("Skipped that pair.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
elements.undoVote.addEventListener("click", async () => {
|
||||||
|
await handleUndo();
|
||||||
|
});
|
||||||
|
|
||||||
elements.resetRankings.addEventListener("click", async () => {
|
elements.resetRankings.addEventListener("click", async () => {
|
||||||
if (busy) {
|
if (busy) {
|
||||||
return;
|
return;
|
||||||
@@ -710,7 +924,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentPair || busy) {
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key.toLowerCase() === "z" && state.undo) {
|
||||||
|
event.preventDefault();
|
||||||
|
await handleUndo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentPair) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676921.0168,
|
||||||
"size": 1052830,
|
"size": 1052830,
|
||||||
"focus": {
|
"focus": {
|
||||||
"x": 0.35,
|
"x": 0.35,
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676923.4688,
|
||||||
"size": 835360,
|
"size": 835360,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676926.1216,
|
||||||
"size": 1034158,
|
"size": 1034158,
|
||||||
"focus": {
|
"focus": {
|
||||||
"x": 0.5,
|
"x": 0.5,
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676928.9744,
|
||||||
"size": 1090215,
|
"size": 1090215,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676931.93,
|
||||||
"size": 1122236,
|
"size": 1122236,
|
||||||
"focus": {
|
"focus": {
|
||||||
"x": 0.5,
|
"x": 0.5,
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770444049000,
|
"mtimeMs": 1774257676932.7878,
|
||||||
"size": 676787,
|
"size": 676787,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676935.0764,
|
||||||
"size": 872024,
|
"size": 872024,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676936.637,
|
||||||
"size": 618276,
|
"size": 618276,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500127000,
|
"mtimeMs": 1774257676938.8577,
|
||||||
"size": 1349804,
|
"size": 1349804,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676940.0383,
|
||||||
"size": 1071870,
|
"size": 1071870,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676941.5466,
|
||||||
"size": 764329,
|
"size": 764329,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676943.8735,
|
||||||
"size": 1172905,
|
"size": 1172905,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676945.2588,
|
||||||
"size": 1099540,
|
"size": 1099540,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676946.2732,
|
||||||
"size": 1052362,
|
"size": 1052362,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676947.5552,
|
||||||
"size": 1227608,
|
"size": 1227608,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500127000,
|
"mtimeMs": 1774257676949.5437,
|
||||||
"size": 840466,
|
"size": 840466,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676951.916,
|
||||||
"size": 1136990,
|
"size": 1136990,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500127000,
|
"mtimeMs": 1774257676954.4558,
|
||||||
"size": 1261294,
|
"size": 1261294,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676956.7886,
|
||||||
"size": 1119498,
|
"size": 1119498,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676958.691,
|
||||||
"size": 868085,
|
"size": 868085,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676961.0393,
|
||||||
"size": 1057896,
|
"size": 1057896,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676963.4336,
|
||||||
"size": 1088795,
|
"size": 1088795,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676965.364,
|
||||||
"size": 852307,
|
"size": 852307,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676967.8005,
|
||||||
"size": 1149955,
|
"size": 1149955,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676970.2761,
|
||||||
"size": 1242099,
|
"size": 1242099,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676971.9075,
|
||||||
"size": 1414024,
|
"size": 1414024,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676973.2812,
|
||||||
"size": 1022877,
|
"size": 1022877,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676974.2112,
|
||||||
"size": 1018868,
|
"size": 1018868,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -265,7 +265,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676975.1504,
|
||||||
"size": 1233602,
|
"size": 1233602,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676976.6533,
|
||||||
"size": 739786,
|
"size": 739786,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1771126436000,
|
"mtimeMs": 1774257676977.1958,
|
||||||
"size": 1069693,
|
"size": 1069693,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1771226059000,
|
"mtimeMs": 1774257676979.063,
|
||||||
"size": 995282,
|
"size": 995282,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1771226144000,
|
"mtimeMs": 1774257676980.472,
|
||||||
"size": 729224,
|
"size": 729224,
|
||||||
"focus": null
|
"focus": null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="voting-panel__actions">
|
<div class="voting-panel__actions">
|
||||||
<button class="button small" id="skip-pair" type="button">skip pair</button>
|
<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>
|
<button class="button small" id="reset-rankings" type="button">reset saved rankings</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to load head-to-head voting.</p>
|
<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">
|
<div class="duel-grid" id="duel-cards">
|
||||||
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const { loadMeals, repoRoot } = require("./meals");
|
|||||||
const STATE_VERSION = 1;
|
const STATE_VERSION = 1;
|
||||||
const defaultStatePath = path.join(repoRoot, ".runtime", "rankings-state.json");
|
const defaultStatePath = path.join(repoRoot, ".runtime", "rankings-state.json");
|
||||||
|
|
||||||
|
function isValidMealId(id) {
|
||||||
|
return typeof id === "string" && /^\d+$/.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
function createDefaultEntry(id, defaultRating) {
|
function createDefaultEntry(id, defaultRating) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -57,6 +61,7 @@ function createSeedState(seedData) {
|
|||||||
voteCount: 0,
|
voteCount: 0,
|
||||||
lastPairKey: null,
|
lastPairKey: null,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
|
undo: null,
|
||||||
elo: {
|
elo: {
|
||||||
defaultRating: seedData.elo.defaultRating,
|
defaultRating: seedData.elo.defaultRating,
|
||||||
kFactor: seedData.elo.kFactor,
|
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)) {
|
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
|
||||||
return createSeedState(seedData);
|
return createSeedState(seedData);
|
||||||
}
|
}
|
||||||
@@ -103,6 +108,7 @@ function syncStoredState(seedData, storedState) {
|
|||||||
: derivedVoteCount,
|
: derivedVoteCount,
|
||||||
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
|
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
|
||||||
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
|
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
|
||||||
|
undo: null,
|
||||||
elo: {
|
elo: {
|
||||||
defaultRating: seedData.elo.defaultRating,
|
defaultRating: seedData.elo.defaultRating,
|
||||||
kFactor: seedData.elo.kFactor,
|
kFactor: seedData.elo.kFactor,
|
||||||
@@ -115,10 +121,99 @@ function createPairKey(leftId, rightId) {
|
|||||||
return [leftId, rightId].sort().join(":");
|
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 entryById = new Map(state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)]));
|
||||||
const winner = entryById.get(winnerId);
|
const winner = entryById.get(winnerId);
|
||||||
const loser = entryById.get(loserId);
|
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) {
|
if (!winner || !loser) {
|
||||||
throw new Error("Vote referenced an unknown meal id");
|
throw new Error("Vote referenced an unknown meal id");
|
||||||
@@ -135,8 +230,16 @@ function applyVote(seedData, state, winnerId, loserId, pairKey) {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
voteCount: state.voteCount + 1,
|
voteCount: state.voteCount + 1,
|
||||||
lastPairKey: pairKey || createPairKey(winnerId, loserId),
|
lastPairKey: resolvedPairKey,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
undo: {
|
||||||
|
pairKey: createPairKey(undoPair.leftId, undoPair.rightId),
|
||||||
|
leftId: undoPair.leftId,
|
||||||
|
rightId: undoPair.rightId,
|
||||||
|
winnerId,
|
||||||
|
loserId,
|
||||||
|
snapshot: createUndoSnapshot(state),
|
||||||
|
},
|
||||||
elo: {
|
elo: {
|
||||||
...state.elo,
|
...state.elo,
|
||||||
entries: seedData.meals.map((meal) => entryById.get(meal.id)),
|
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) {
|
function resolveStatePath(statePath) {
|
||||||
return statePath ? path.resolve(statePath) : defaultStatePath;
|
return statePath ? path.resolve(statePath) : defaultStatePath;
|
||||||
}
|
}
|
||||||
@@ -196,13 +307,13 @@ function recordVote(vote, options = {}) {
|
|||||||
throw new Error("Vote payload must be an object");
|
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");
|
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");
|
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");
|
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 statePath = resolveStatePath(options.statePath);
|
||||||
const seedData = loadSeedData();
|
const seedData = loadSeedData();
|
||||||
const storedState = syncStoredState(seedData, readPersistedState(statePath));
|
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);
|
writePersistedState(statePath, nextState);
|
||||||
|
|
||||||
@@ -238,4 +376,5 @@ module.exports = {
|
|||||||
resetRankingsState,
|
resetRankingsState,
|
||||||
saveRankingsState,
|
saveRankingsState,
|
||||||
syncStoredState,
|
syncStoredState,
|
||||||
|
undoLastRankingsVote,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const {
|
|||||||
loadRankingsState,
|
loadRankingsState,
|
||||||
recordVote,
|
recordVote,
|
||||||
resetRankingsState,
|
resetRankingsState,
|
||||||
|
undoLastRankingsVote,
|
||||||
} = require("./lib/rankings-state");
|
} = require("./lib/rankings-state");
|
||||||
|
|
||||||
const DEFAULT_HOST = "127.0.0.1";
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
@@ -195,7 +196,17 @@ async function handleApiRequest(request, response, pathname, options) {
|
|||||||
return true;
|
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");
|
sendText(response, 405, "Method not allowed");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="voting-panel__actions">
|
<div class="voting-panel__actions">
|
||||||
<button class="button small" id="skip-pair" type="button">skip pair</button>
|
<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>
|
<button class="button small" id="reset-rankings" type="button">reset saved rankings</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to load head-to-head voting.</p>
|
<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">
|
<div class="duel-grid" id="duel-cards">
|
||||||
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user