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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user