Compare commits

...

5 Commits

Author SHA1 Message Date
ac52daa454 add: go back button in rankings
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-23 03:05:44 -07:00
dc1dce1120 add: docker compose code
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-23 02:50:30 -07:00
b1403da70d add: dockerfile and docker ignore rules
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-23 02:46:53 -07:00
a72e4d21d5 change: removed .html in url
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-23 02:40:49 -07:00
9f60ab3cca add: persistant storage of elo ranking
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-22 21:18:49 -07:00
12 changed files with 1142 additions and 107 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
.git
.runtime
node_modules

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.DS_Store
node_modules/
.runtime/

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV HOST=0.0.0.0
ENV PORT=80
EXPOSE 80
CMD ["npm", "start"]

View File

@@ -2,7 +2,7 @@
Static photo gallery for logging meals and food memories.
The site is based on the HTML5 UP Lens template and currently ships as a plain static site: HTML, CSS, JavaScript, and local image assets.
The site is based on the HTML5 UP Lens template. The front-end remains a mostly static HTML/CSS/JavaScript site, and the local Node server now also exposes a tiny rankings sync API for shared Elo persistence.
## Repo Layout
@@ -19,8 +19,9 @@ The site is based on the HTML5 UP Lens template and currently ships as a plain s
- `scripts/check.js`: validates data, image assets, and generated pages
- `scripts/generate-thumbnails.js`: regenerates thumbnails from the full-size images
- `scripts/ingest-meal.js`: ingests a new meal image and metadata in one command
- `scripts/serve.js`: serves the generated site locally with a small static file server
- `scripts/serve.js`: serves the generated site and the rankings sync API
- `scripts/lib/elo.js`: validates and syncs Elo data against the meal list
- `scripts/lib/rankings-state.js`: normalizes and persists the shared rankings state
- `package.json`: minimal Node build entrypoint
## Run Locally
@@ -45,6 +46,9 @@ npm run serve
Then open `http://127.0.0.1:4321`.
By default, rankings sync state is written to `.runtime/rankings-state.json`.
Override that path with `RANKINGS_STATE_PATH=/absolute/path/to/rankings-state.json`.
If you want a single command that builds and serves, run:
```sh
@@ -97,9 +101,58 @@ npm run build:thumbs:force
`data/elo.json` stores the seed rating, Elo `kFactor`, and a win-loss record for each meal.
The page build keeps this file aligned with `data/meals.json`, so new meals automatically appear in `rankings.html` with the default seed rating.
The interactive voting flow on `rankings.html` uses browser `localStorage` for persistence.
That means Elo votes persist across reloads on the same browser and device, but they do not sync automatically across devices.
Use the reset button on the rankings page if you want to clear the local vote history and go back to the seeded board.
The interactive voting flow on `rankings.html` now prefers the same-origin API exposed by `scripts/serve.js`:
- `GET /api/rankings`: load the shared rankings state
- `POST /api/rankings/vote`: apply one head-to-head result on the server
- `POST /api/rankings/reset`: reset the shared board back to the seeded state
The server persists the shared board to `.runtime/rankings-state.json` by default, or to `RANKINGS_STATE_PATH` if you set it.
That makes rankings persist across reloads, sessions, browsers, and devices as long as they are hitting the same deployed site.
If the API is unavailable, the page falls back to browser `localStorage`.
In that fallback mode, votes still persist across reloads in the same browser profile, but they do not sync across browsers or devices.
Use the reset button on the rankings page if you want to clear the current saved board and go back to the seeded state.
## Deployment Notes
For Docker/VPS deployment, mount a persistent volume and point `RANKINGS_STATE_PATH` at it so rankings survive container rebuilds and restarts.
Example:
```sh
RANKINGS_STATE_PATH=/data/rankings-state.json npm start
```
In a containerized setup, mount `/data` as a named volume or bind mount.
If you reverse-proxy the app through Caddy on the same domain, the rankings page will use the shared API automatically with no extra CORS setup.
The current server deployment lives one directory up from this repo in `~/docker/websites/docker-compose.yml` and uses this `gallery` service definition:
```yaml
services:
gallery:
build:
context: ./gallery-src
container_name: gallery
environment:
HOST: 0.0.0.0
PORT: 80
RANKINGS_STATE_PATH: /data/rankings-state.json
volumes:
- gallery-rankings:/data
restart: unless-stopped
networks:
- web
networks:
web:
external: true
name: web
volumes:
gallery-rankings:
```
## Image Conventions
@@ -131,7 +184,3 @@ For images that should crop away from the center, add optional thumbnail focus m
```
The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the center of the image.
## Planned Features
1. Optional shared sync or export/import for rankings if browser-local persistence becomes too limiting.

View File

@@ -3,6 +3,10 @@
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";
const REMOTE_RANKINGS_UNDO_URL = "/api/rankings/undo";
function $(id) {
return document.getElementById(id);
@@ -30,6 +34,10 @@
return [leftId, rightId].sort().join(":");
}
function isValidMealId(id) {
return typeof id === "string" && /^\d+$/.test(id);
}
function createDefaultEntry(id, defaultRating) {
return {
id,
@@ -72,7 +80,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 +149,155 @@
};
}
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, leftId, rightId) {
if (mode === "server" && typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_VOTE_URL, {
method: "POST",
body: JSON.stringify({
winnerId,
loserId,
pairKey,
leftId,
rightId,
}),
});
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,
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);
}
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;
@@ -148,6 +322,7 @@
voteCount: 0,
lastPairKey: null,
updatedAt: null,
undo: null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
@@ -162,7 +337,7 @@
};
}
function syncStoredState(seedData, storedState) {
function syncStateCore(seedData, storedState) {
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
return createSeedState(seedData);
}
@@ -194,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,
@@ -202,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;
@@ -252,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;
@@ -274,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)),
@@ -283,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;
@@ -333,9 +613,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 +650,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 {
@@ -400,6 +690,7 @@
voteStatus: $("vote-status"),
voteMessage: $("vote-message"),
skipPair: $("skip-pair"),
undoVote: $("undo-vote"),
resetRankings: $("reset-rankings"),
};
@@ -410,20 +701,40 @@
!elements.voteStatus ||
!elements.voteMessage ||
!elements.skipPair ||
!elements.undoVote ||
!elements.resetRankings
) {
return;
}
elements.voteMessage.textContent = "Loading saved rankings...";
const persistence = createPersistence();
let state = syncStoredState(seedData, persistence.load());
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;
persistence.save(state);
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)
@@ -448,6 +759,8 @@
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))
.join("");
@@ -459,15 +772,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 +789,132 @@
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.left.id,
currentPair.right.id
);
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) => {
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]");
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.undoVote.addEventListener("click", async () => {
await handleUndo();
});
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 +924,33 @@
return;
}
if (busy) {
return;
}
if (event.key.toLowerCase() === "z" && state.undo) {
event.preventDefault();
await handleUndo();
return;
}
if (!currentPair) {
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);
});
})();

View File

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

View File

@@ -30,9 +30,9 @@
<h1>for vham :3</h1>
<p id="haiku">Please enable javascript >.<</p>
<p class="page-links">
<a href="index.html" aria-current="page">gallery</a>
<a href="./" aria-current="page">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html">rankings</a>
<a href="rankings">rankings</a>
</p>
</header>

View File

@@ -30,9 +30,9 @@
<h1>food power rankings</h1>
<p>pick the better meal, one pair at a time, and the board updates live in this browser.</p>
<p class="page-links">
<a href="index.html">gallery</a>
<a href="./">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html" aria-current="page">rankings</a>
<a href="rankings" aria-current="page">rankings</a>
</p>
</header>
@@ -42,17 +42,18 @@
<div class="voting-panel__intro">
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
<h2>Pick the winner.</h2>
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to save votes in this browser.</p>
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to load saved rankings.</p>
</div>
<div class="voting-panel__actions">
<button class="button small" id="skip-pair" type="button">skip pair</button>
<button class="button small" id="reset-rankings" type="button">reset local votes</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 start head-to-head voting.</p>
<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>

View File

@@ -0,0 +1,380 @@
const fs = require("fs");
const path = require("path");
const { loadEloData } = require("./elo");
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,
rating: defaultRating,
wins: 0,
losses: 0,
};
}
function cloneEntry(entry) {
return {
id: entry.id,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
};
}
function roundRating(rating) {
return Math.round(rating * 1000) / 1000;
}
function expectedScore(rating, opponentRating) {
return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
}
function isValidStoredEntry(entry) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) {
return false;
}
if (typeof entry.rating !== "number" || !Number.isFinite(entry.rating) || entry.rating <= 0) {
return false;
}
return ["wins", "losses"].every(
(field) => Number.isInteger(entry[field]) && entry[field] >= 0
);
}
function createSeedState(seedData) {
return {
version: STATE_VERSION,
voteCount: 0,
lastPairKey: null,
updatedAt: null,
undo: null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries: seedData.meals.map((meal) => {
const seedEntry =
seedData.elo.entries.find((entry) => entry.id === meal.id) ||
createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
}),
},
};
}
function syncStateCore(seedData, storedState) {
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
return createSeedState(seedData);
}
const storedEntries = Array.isArray(storedState.elo?.entries)
? storedState.elo.entries.filter(isValidStoredEntry)
: [];
const storedEntryById = new Map(storedEntries.map((entry) => [entry.id, entry]));
const seedEntryById = new Map(seedData.elo.entries.map((entry) => [entry.id, entry]));
const entries = seedData.meals.map((meal) => {
const storedEntry = storedEntryById.get(meal.id);
if (storedEntry) {
return cloneEntry(storedEntry);
}
const seedEntry =
seedEntryById.get(meal.id) || createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
});
const derivedVoteCount = entries.reduce((sum, entry) => sum + entry.wins, 0);
return {
version: STATE_VERSION,
voteCount:
Number.isInteger(storedState.voteCount) && storedState.voteCount >= derivedVoteCount
? storedState.voteCount
: 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,
entries,
},
};
}
function createPairKey(leftId, rightId) {
return [leftId, rightId].sort().join(":");
}
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");
}
const winnerExpected = expectedScore(winner.rating, loser.rating);
const loserExpected = expectedScore(loser.rating, winner.rating);
winner.rating = roundRating(winner.rating + state.elo.kFactor * (1 - winnerExpected));
loser.rating = roundRating(loser.rating + state.elo.kFactor * (0 - loserExpected));
winner.wins += 1;
loser.losses += 1;
return {
...state,
voteCount: state.voteCount + 1,
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)),
},
};
}
function undoVote(seedData, state) {
if (!state.undo) {
return state;
}
return restoreUndoSnapshot(seedData, state.undo.snapshot);
}
function resolveStatePath(statePath) {
return statePath ? path.resolve(statePath) : defaultStatePath;
}
function loadSeedData() {
return {
meals: loadMeals(),
elo: loadEloData(),
};
}
function readPersistedState(statePath) {
if (!fs.existsSync(statePath)) {
return null;
}
return JSON.parse(fs.readFileSync(statePath, "utf8"));
}
function writePersistedState(statePath, state) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
const nextState = `${JSON.stringify(state, null, 2)}\n`;
const tempPath = `${statePath}.tmp`;
fs.writeFileSync(tempPath, nextState);
fs.renameSync(tempPath, statePath);
}
function loadRankingsState(options = {}) {
const statePath = resolveStatePath(options.statePath);
const seedData = loadSeedData();
const storedState = readPersistedState(statePath);
return syncStoredState(seedData, storedState);
}
function saveRankingsState(state, options = {}) {
const statePath = resolveStatePath(options.statePath);
const seedData = loadSeedData();
const nextState = syncStoredState(seedData, state);
writePersistedState(statePath, nextState);
return nextState;
}
function recordVote(vote, options = {}) {
if (!vote || typeof vote !== "object" || Array.isArray(vote)) {
throw new Error("Vote payload must be an object");
}
const { winnerId, loserId, pairKey, leftId, rightId } = vote;
if (!isValidMealId(winnerId)) {
throw new Error("Vote payload is missing a valid winnerId");
}
if (!isValidMealId(loserId)) {
throw new Error("Vote payload is missing a valid loserId");
}
if (winnerId === loserId) {
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,
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);
return nextState;
}
function resetRankingsState(options = {}) {
const statePath = resolveStatePath(options.statePath);
const seedData = loadSeedData();
const nextState = createSeedState(seedData);
writePersistedState(statePath, nextState);
return nextState;
}
module.exports = {
createSeedState,
defaultStatePath,
loadRankingsState,
recordVote,
resetRankingsState,
saveRankingsState,
syncStoredState,
undoLastRankingsVote,
};

View File

@@ -3,9 +3,17 @@ const http = require("http");
const path = require("path");
const { repoRoot } = require("./lib/meals");
const {
defaultStatePath,
loadRankingsState,
recordVote,
resetRankingsState,
undoLastRankingsVote,
} = require("./lib/rankings-state");
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 4321;
const MAX_REQUEST_BODY_BYTES = 16 * 1024;
const MIME_TYPES = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
@@ -29,6 +37,7 @@ function parseArgs(argv) {
const options = {
host: process.env.HOST || DEFAULT_HOST,
port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10),
rankingsStatePath: path.resolve(process.env.RANKINGS_STATE_PATH || defaultStatePath),
};
for (let index = 0; index < argv.length; index += 1) {
@@ -44,6 +53,12 @@ function parseArgs(argv) {
if (arg === "--port" && value) {
options.port = Number.parseInt(value, 10);
index += 1;
continue;
}
if (arg === "--rankings-state-path" && value) {
options.rankingsStatePath = path.resolve(value);
index += 1;
}
}
@@ -58,9 +73,15 @@ function getContentType(filePath) {
return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
}
function resolveRequestPath(requestUrl) {
const url = new URL(requestUrl, "http://localhost");
const pathname = decodeURIComponent(url.pathname);
function hasHiddenSegment(pathname) {
return pathname.split("/").some((segment) => segment.startsWith(".") && segment.length > 1);
}
function resolveRequestPath(pathname) {
if (hasHiddenSegment(pathname)) {
return null;
}
const requestedPath = pathname === "/" ? "/index.html" : pathname;
const absolutePath = path.resolve(repoRoot, `.${requestedPath}`);
const withinRepo =
@@ -74,9 +95,76 @@ function resolveRequestPath(requestUrl) {
return path.join(absolutePath, "index.html");
}
if (
path.extname(absolutePath) === "" &&
!fs.existsSync(absolutePath) &&
fs.existsSync(`${absolutePath}.html`) &&
fs.statSync(`${absolutePath}.html`).isFile()
) {
return `${absolutePath}.html`;
}
return absolutePath;
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
});
response.end(`${JSON.stringify(payload, null, 2)}\n`);
}
function sendText(response, statusCode, body) {
response.writeHead(statusCode, {
"Cache-Control": "no-store",
"Content-Type": "text/plain; charset=utf-8",
});
response.end(body);
}
function sendRedirect(response, location) {
response.writeHead(301, {
Location: location,
"Cache-Control": "no-store",
});
response.end();
}
function parseJsonBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalBytes = 0;
request.on("data", (chunk) => {
totalBytes += chunk.length;
if (totalBytes > MAX_REQUEST_BODY_BYTES) {
reject(new Error("Request body is too large"));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on("end", () => {
if (chunks.length === 0) {
resolve({});
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (error) {
reject(new Error("Request body must be valid JSON"));
}
});
request.on("error", reject);
});
}
function sendFile(response, filePath) {
const stream = fs.createReadStream(filePath);
@@ -87,24 +175,106 @@ function sendFile(response, filePath) {
stream.pipe(response);
stream.on("error", () => {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Failed to read file");
sendText(response, 500, "Failed to read file");
});
}
function createServer() {
return http.createServer((request, response) => {
const filePath = resolveRequestPath(request.url || "/");
async function handleApiRequest(request, response, pathname, options) {
if (pathname === "/api/rankings" && request.method === "GET") {
sendJson(response, 200, loadRankingsState({ statePath: options.rankingsStatePath }));
return true;
}
if (pathname === "/api/rankings/vote" && request.method === "POST") {
const body = await parseJsonBody(request);
sendJson(response, 200, recordVote(body, { statePath: options.rankingsStatePath }));
return true;
}
if (pathname === "/api/rankings/reset" && request.method === "POST") {
sendJson(response, 200, resetRankingsState({ statePath: options.rankingsStatePath }));
return true;
}
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;
}
if (pathname.startsWith("/api/")) {
sendText(response, 404, "Not found");
return true;
}
return false;
}
function createServer(options) {
return http.createServer(async (request, response) => {
let pathname;
let search;
try {
const requestUrl = new URL(request.url || "/", "http://localhost");
pathname = decodeURIComponent(requestUrl.pathname);
search = requestUrl.search;
} catch (error) {
sendText(response, 400, "Bad request");
return;
}
if (request.method === "GET" || request.method === "HEAD") {
if (pathname === "/index.html") {
sendRedirect(response, `/${search}`);
return;
}
if (pathname.endsWith(".html") && pathname !== "/index.html") {
sendRedirect(response, `${pathname.slice(0, -5)}${search}`);
return;
}
}
try {
if (await handleApiRequest(request, response, pathname, options)) {
return;
}
} catch (error) {
const statusCode =
error.message === "Request body must be valid JSON" ||
error.message === "Request body is too large" ||
error.message.startsWith("Vote payload") ||
error.message === "winnerId and loserId must be different"
? 400
: 500;
if (statusCode >= 500) {
console.error(error);
}
sendJson(response, statusCode, { error: error.message });
return;
}
const filePath = resolveRequestPath(pathname);
if (!filePath) {
response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Forbidden");
sendText(response, 403, "Forbidden");
return;
}
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
sendText(response, 404, "Not found");
return;
}
@@ -114,10 +284,11 @@ function createServer() {
function main() {
const options = parseArgs(process.argv.slice(2));
const server = createServer();
const server = createServer(options);
server.listen(options.port, options.host, () => {
console.log(`Serving ${repoRoot} at http://${options.host}:${options.port}`);
console.log(`Rankings state path: ${options.rankingsStatePath}`);
});
}

View File

@@ -30,9 +30,9 @@
<h1>for vham :3</h1>
<p id="haiku">Please enable javascript >.<</p>
<p class="page-links">
<a href="index.html" aria-current="page">gallery</a>
<a href="./" aria-current="page">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html">rankings</a>
<a href="rankings">rankings</a>
</p>
</header>

View File

@@ -30,9 +30,9 @@
<h1>food power rankings</h1>
<p>pick the better meal, one pair at a time, and the board updates live in this browser.</p>
<p class="page-links">
<a href="index.html">gallery</a>
<a href="./">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html" aria-current="page">rankings</a>
<a href="rankings" aria-current="page">rankings</a>
</p>
</header>
@@ -42,17 +42,18 @@
<div class="voting-panel__intro">
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
<h2>Pick the winner.</h2>
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to save votes in this browser.</p>
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to load saved rankings.</p>
</div>
<div class="voting-panel__actions">
<button class="button small" id="skip-pair" type="button">skip pair</button>
<button class="button small" id="reset-rankings" type="button">reset local votes</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 start head-to-head voting.</p>
<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>