add: persistant storage of elo ranking
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:
@@ -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);
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user