add: go back button in rankings
All checks were successful
Deploy on push / deploy (push) Has been skipped

This commit is contained in:
2026-03-23 03:05:44 -07:00
parent dc1dce1120
commit ac52daa454
6 changed files with 426 additions and 50 deletions

View File

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