add: persistant storage of elo ranking
All checks were successful
Deploy on push / deploy (push) Has been skipped

This commit is contained in:
2026-03-22 21:18:49 -07:00
parent 614a3d1eff
commit 9f60ab3cca
7 changed files with 643 additions and 62 deletions

View File

@@ -3,6 +3,9 @@
const STORAGE_TEST_KEY = `${STORAGE_KEY}.probe`;
const STATE_VERSION = 1;
const CLOSE_MATCH_COUNT = 6;
const REMOTE_RANKINGS_URL = "/api/rankings";
const REMOTE_RANKINGS_VOTE_URL = "/api/rankings/vote";
const REMOTE_RANKINGS_RESET_URL = "/api/rankings/reset";
function $(id) {
return document.getElementById(id);
@@ -72,7 +75,24 @@
return seedData;
}
function createPersistence() {
async function requestJson(url, options) {
const response = await fetch(url, {
cache: "no-store",
headers: {
Accept: "application/json",
...(options && options.body ? { "Content-Type": "application/json" } : {}),
},
...options,
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
}
function createLocalPersistence() {
let available = false;
try {
@@ -124,6 +144,119 @@
};
}
function createPersistence() {
const local = createLocalPersistence();
let mode = "memory";
let pendingNotice = null;
function setMode(nextMode, nextNotice) {
if (mode !== nextMode && nextNotice) {
pendingNotice = nextNotice;
}
mode = nextMode;
}
function getFallbackMode() {
return local.available ? "local" : "memory";
}
return {
get mode() {
return mode;
},
async load(seedData) {
if (typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_URL, { method: "GET" });
setMode("server");
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(getFallbackMode());
}
} else {
setMode(getFallbackMode());
}
return syncStoredState(seedData, local.load());
},
async save(state) {
if (mode === "local") {
local.save(state);
}
return state;
},
async submitVote(seedData, state, winnerId, loserId, pairKey) {
if (mode === "server" && typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_VOTE_URL, {
method: "POST",
body: JSON.stringify({
winnerId,
loserId,
pairKey,
}),
});
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(
getFallbackMode(),
local.available
? "Server sync failed, so votes are now saved only in this browser."
: "Server sync failed, so votes will reset when you reload."
);
}
}
const nextState = applyVote(seedData, state, winnerId, loserId, pairKey);
if (mode === "local") {
local.save(nextState);
}
return nextState;
},
async reset(seedData) {
if (mode === "server" && typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_RESET_URL, {
method: "POST",
});
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(
getFallbackMode(),
local.available
? "Server sync failed, so resets now apply only in this browser."
: "Server sync failed, so resets last only until you reload."
);
}
}
const nextState = createSeedState(seedData);
if (mode === "local") {
local.clear();
local.save(nextState);
}
return nextState;
},
consumeNotice() {
const notice = pendingNotice;
pendingNotice = null;
return notice;
},
};
}
function isValidStoredEntry(entry) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
@@ -333,9 +466,11 @@
};
}
function renderDuelCard(meal, sideLabel) {
function renderDuelCard(meal, sideLabel, disabled) {
const disabledAttributes = disabled ? ' disabled aria-disabled="true"' : "";
return `<article class="duel-card">
<button class="duel-card__button" type="button" data-meal-id="${meal.id}">
<button class="duel-card__button" type="button" data-meal-id="${meal.id}"${disabledAttributes}>
<div class="duel-card__media">
<img src="images/fulls/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} photo`)}" />
</div>
@@ -368,22 +503,30 @@
function getSummaryText(seedData, state, persistence) {
const voteText = `${state.voteCount} ${pluralize(state.voteCount, "vote", "votes")}`;
if (persistence.available) {
return `${seedData.meals.length} meals ranked. ${voteText} saved in this browser.`;
if (persistence.mode === "server") {
return `${seedData.meals.length} meals ranked. ${voteText} saved on the server.`;
}
if (persistence.mode === "local") {
return `${seedData.meals.length} meals ranked. ${voteText} saved only in this browser.`;
}
return `${seedData.meals.length} meals ranked. ${voteText} active for this session only.`;
}
function getStatusText(persistence) {
if (persistence.available) {
return "Votes are saved in this browser on this device.";
if (persistence.mode === "server") {
return "Votes are saved on the server and shared across browsers.";
}
if (persistence.mode === "local") {
return "Server sync is unavailable, so votes are saved only in this browser.";
}
return "Browser storage is unavailable, so votes reset when you reload.";
}
function init() {
async function init() {
let seedData;
try {
@@ -415,13 +558,16 @@
return;
}
elements.voteMessage.textContent = "Loading saved rankings...";
const persistence = createPersistence();
let state = syncStoredState(seedData, persistence.load());
let state = await persistence.load(seedData);
let currentPair = null;
let currentPairKey = null;
let lastMessage = "Choose the better meal to start ranking.";
let busy = false;
persistence.save(state);
await persistence.save(state);
function queueNextPair(rankedMeals) {
currentPair = choosePair(rankedMeals, [currentPairKey, state.lastPairKey]);
@@ -448,6 +594,7 @@
elements.voteStatus.textContent = getStatusText(persistence);
elements.voteMessage.textContent = lastMessage;
elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence);
elements.resetRankings.disabled = busy;
elements.rankings.innerHTML = rankedMeals
.map((meal, index) => renderRankingCard(meal, index + 1))
.join("");
@@ -459,15 +606,15 @@
return;
}
elements.skipPair.disabled = false;
elements.skipPair.disabled = busy;
elements.duelCards.innerHTML = [
renderDuelCard(currentPair.left, "Left Pick"),
renderDuelCard(currentPair.right, "Right Pick"),
renderDuelCard(currentPair.left, "Left Pick", busy),
renderDuelCard(currentPair.right, "Right Pick", busy),
].join("");
}
function handleVote(winnerId) {
if (!currentPair) {
async function handleVote(winnerId) {
if (!currentPair || busy) {
return;
}
@@ -476,44 +623,84 @@
currentPair.left.id === winnerId ? currentPair.left.title : currentPair.right.title;
const loserTitle =
currentPair.left.id === winnerId ? currentPair.right.title : currentPair.left.title;
let nextMessage = `Picked ${winnerTitle} over ${loserTitle}.`;
state = applyVote(seedData, state, winnerId, loserId, currentPairKey);
persistence.save(state);
currentPair = null;
render(`Picked ${winnerTitle} over ${loserTitle}.`);
busy = true;
render(`Saving ${winnerTitle} over ${loserTitle}...`);
try {
state = await persistence.submitVote(seedData, state, winnerId, loserId, currentPairKey);
currentPair = null;
const notice = persistence.consumeNotice();
if (notice) {
nextMessage = `${nextMessage} ${notice}`;
}
} catch (error) {
console.error(error);
nextMessage = "Failed to save that vote.";
} finally {
busy = false;
render(nextMessage);
}
}
elements.duelCards.addEventListener("click", (event) => {
elements.duelCards.addEventListener("click", async (event) => {
const button = event.target.closest("[data-meal-id]");
if (!button) {
return;
}
handleVote(button.getAttribute("data-meal-id"));
await handleVote(button.getAttribute("data-meal-id"));
});
elements.skipPair.addEventListener("click", () => {
if (busy) {
return;
}
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
queueNextPair(rankedMeals);
render("Skipped that pair.");
});
elements.resetRankings.addEventListener("click", () => {
if (!window.confirm("Reset the local Elo votes saved in this browser?")) {
elements.resetRankings.addEventListener("click", async () => {
if (busy) {
return;
}
state = createSeedState(seedData);
persistence.clear();
persistence.save(state);
currentPair = null;
currentPairKey = null;
render("Local votes cleared. Back to the seeded board.");
if (!window.confirm("Reset the saved rankings back to the seeded board?")) {
return;
}
let nextMessage = "Saved rankings cleared. Back to the seeded board.";
busy = true;
render("Resetting saved rankings...");
try {
state = await persistence.reset(seedData);
currentPair = null;
currentPairKey = null;
const notice = persistence.consumeNotice();
if (notice) {
nextMessage = `${nextMessage} ${notice}`;
}
} catch (error) {
console.error(error);
nextMessage = "Failed to reset rankings.";
} finally {
busy = false;
render(nextMessage);
}
});
document.addEventListener("keydown", (event) => {
document.addEventListener("keydown", async (event) => {
const target = event.target;
if (
@@ -523,21 +710,23 @@
return;
}
if (!currentPair) {
if (!currentPair || busy) {
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
handleVote(currentPair.left.id);
await handleVote(currentPair.left.id);
} else if (event.key === "ArrowRight") {
event.preventDefault();
handleVote(currentPair.right.id);
await handleVote(currentPair.right.id);
}
});
render();
}
init();
init().catch((error) => {
console.error(error);
});
})();