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

1
.gitignore vendored
View File

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

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,31 @@ 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.
## Image Conventions
@@ -131,7 +157,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,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);
});
})();

View File

@@ -42,13 +42,13 @@
<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="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>

View File

@@ -0,0 +1,241 @@
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 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,
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 syncStoredState(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,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries,
},
};
}
function createPairKey(leftId, rightId) {
return [leftId, rightId].sort().join(":");
}
function applyVote(seedData, state, winnerId, loserId, pairKey) {
const entryById = new Map(state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)]));
const winner = entryById.get(winnerId);
const loser = entryById.get(loserId);
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: pairKey || createPairKey(winnerId, loserId),
updatedAt: new Date().toISOString(),
elo: {
...state.elo,
entries: seedData.meals.map((meal) => entryById.get(meal.id)),
},
};
}
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 } = vote;
if (typeof winnerId !== "string" || !/^\d+$/.test(winnerId)) {
throw new Error("Vote payload is missing a valid winnerId");
}
if (typeof loserId !== "string" || !/^\d+$/.test(loserId)) {
throw new Error("Vote payload is missing a valid loserId");
}
if (winnerId === loserId) {
throw new Error("winnerId and loserId must be different");
}
const statePath = resolveStatePath(options.statePath);
const seedData = loadSeedData();
const storedState = syncStoredState(seedData, readPersistedState(statePath));
const nextState = applyVote(seedData, storedState, winnerId, loserId, pairKey);
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,
};

View File

@@ -3,9 +3,16 @@ const http = require("http");
const path = require("path");
const { repoRoot } = require("./lib/meals");
const {
defaultStatePath,
loadRankingsState,
recordVote,
resetRankingsState,
} = 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 +36,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 +52,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 +72,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 =
@@ -77,6 +97,56 @@ function resolveRequestPath(requestUrl) {
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 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 +157,81 @@ 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" || pathname === "/api/rankings/vote" || pathname === "/api/rankings/reset") {
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;
try {
pathname = decodeURIComponent(new URL(request.url || "/", "http://localhost").pathname);
} catch (error) {
sendText(response, 400, "Bad request");
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 +241,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

@@ -42,13 +42,13 @@
<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="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>