diff --git a/.gitignore b/.gitignore index 646ac51..bd9e405 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store node_modules/ +.runtime/ diff --git a/README.md b/README.md index 4b93ab9..468b260 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/js/rankings.js b/assets/js/rankings.js index b331f7d..58fb24f 100644 --- a/assets/js/rankings.js +++ b/assets/js/rankings.js @@ -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 `
- - + -

Enable JavaScript to start head-to-head voting.

+

Enable JavaScript to load head-to-head voting.

Enable JavaScript to compare meals here.

diff --git a/scripts/lib/rankings-state.js b/scripts/lib/rankings-state.js new file mode 100644 index 0000000..aacd775 --- /dev/null +++ b/scripts/lib/rankings-state.js @@ -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, +}; diff --git a/scripts/serve.js b/scripts/serve.js index c930926..6468831 100644 --- a/scripts/serve.js +++ b/scripts/serve.js @@ -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}`); }); } diff --git a/templates/rankings.html b/templates/rankings.html index 87949a7..6a5c32b 100644 --- a/templates/rankings.html +++ b/templates/rankings.html @@ -42,13 +42,13 @@

Head-To-Head Voting

Pick the winner.

-

Enable JavaScript to save votes in this browser.

+

Enable JavaScript to load saved rankings.

- +
-

Enable JavaScript to start head-to-head voting.

+

Enable JavaScript to load head-to-head voting.

Enable JavaScript to compare meals here.