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