Compare commits
5 Commits
614a3d1eff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ac52daa454 | |||
| dc1dce1120 | |||
| b1403da70d | |||
| a72e4d21d5 | |||
| 9f60ab3cca |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
|
.git
|
||||||
|
.runtime
|
||||||
|
node_modules
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.runtime/
|
||||||
|
|||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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"]
|
||||||
67
README.md
67
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Static photo gallery for logging meals and food memories.
|
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
|
## 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/check.js`: validates data, image assets, and generated pages
|
||||||
- `scripts/generate-thumbnails.js`: regenerates thumbnails from the full-size images
|
- `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/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/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
|
- `package.json`: minimal Node build entrypoint
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
@@ -45,6 +46,9 @@ npm run serve
|
|||||||
|
|
||||||
Then open `http://127.0.0.1:4321`.
|
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:
|
If you want a single command that builds and serves, run:
|
||||||
|
|
||||||
```sh
|
```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.
|
`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 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.
|
The interactive voting flow on `rankings.html` now prefers the same-origin API exposed by `scripts/serve.js`:
|
||||||
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.
|
- `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
|
## 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.
|
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.
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
const STORAGE_TEST_KEY = `${STORAGE_KEY}.probe`;
|
const STORAGE_TEST_KEY = `${STORAGE_KEY}.probe`;
|
||||||
const STATE_VERSION = 1;
|
const STATE_VERSION = 1;
|
||||||
const CLOSE_MATCH_COUNT = 6;
|
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) {
|
function $(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
@@ -30,6 +34,10 @@
|
|||||||
return [leftId, rightId].sort().join(":");
|
return [leftId, rightId].sort().join(":");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidMealId(id) {
|
||||||
|
return typeof id === "string" && /^\d+$/.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
function createDefaultEntry(id, defaultRating) {
|
function createDefaultEntry(id, defaultRating) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -72,7 +80,24 @@
|
|||||||
return seedData;
|
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;
|
let available = false;
|
||||||
|
|
||||||
try {
|
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) {
|
function isValidStoredEntry(entry) {
|
||||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -148,6 +322,7 @@
|
|||||||
voteCount: 0,
|
voteCount: 0,
|
||||||
lastPairKey: null,
|
lastPairKey: null,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
|
undo: null,
|
||||||
elo: {
|
elo: {
|
||||||
defaultRating: seedData.elo.defaultRating,
|
defaultRating: seedData.elo.defaultRating,
|
||||||
kFactor: seedData.elo.kFactor,
|
kFactor: seedData.elo.kFactor,
|
||||||
@@ -162,7 +337,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncStoredState(seedData, storedState) {
|
function syncStateCore(seedData, storedState) {
|
||||||
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
|
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
|
||||||
return createSeedState(seedData);
|
return createSeedState(seedData);
|
||||||
}
|
}
|
||||||
@@ -194,6 +369,7 @@
|
|||||||
: derivedVoteCount,
|
: derivedVoteCount,
|
||||||
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
|
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
|
||||||
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
|
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
|
||||||
|
undo: null,
|
||||||
elo: {
|
elo: {
|
||||||
defaultRating: seedData.elo.defaultRating,
|
defaultRating: seedData.elo.defaultRating,
|
||||||
kFactor: seedData.elo.kFactor,
|
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) {
|
function compareRankedMeals(left, right) {
|
||||||
const leftMatches = left.wins + left.losses;
|
const leftMatches = left.wins + left.losses;
|
||||||
const rightMatches = right.wins + right.losses;
|
const rightMatches = right.wins + right.losses;
|
||||||
@@ -252,12 +511,17 @@
|
|||||||
return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
|
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(
|
const entryById = new Map(
|
||||||
state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])
|
state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])
|
||||||
);
|
);
|
||||||
const winner = entryById.get(winnerId);
|
const winner = entryById.get(winnerId);
|
||||||
const loser = entryById.get(loserId);
|
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) {
|
if (!winner || !loser) {
|
||||||
return state;
|
return state;
|
||||||
@@ -274,8 +538,16 @@
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
voteCount: state.voteCount + 1,
|
voteCount: state.voteCount + 1,
|
||||||
lastPairKey: pairKey,
|
lastPairKey: resolvedPairKey,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
undo: {
|
||||||
|
pairKey: createPairKey(undoPair.leftId, undoPair.rightId),
|
||||||
|
leftId: undoPair.leftId,
|
||||||
|
rightId: undoPair.rightId,
|
||||||
|
winnerId,
|
||||||
|
loserId,
|
||||||
|
snapshot: createUndoSnapshot(state),
|
||||||
|
},
|
||||||
elo: {
|
elo: {
|
||||||
...state.elo,
|
...state.elo,
|
||||||
entries: seedData.meals.map((meal) => entryById.get(meal.id)),
|
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) {
|
function choosePair(rankedMeals, avoidedPairKeys) {
|
||||||
if (rankedMeals.length < 2) {
|
if (rankedMeals.length < 2) {
|
||||||
return null;
|
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">
|
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">
|
<div class="duel-card__media">
|
||||||
<img src="images/fulls/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} photo`)}" />
|
<img src="images/fulls/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} photo`)}" />
|
||||||
</div>
|
</div>
|
||||||
@@ -368,22 +650,30 @@
|
|||||||
function getSummaryText(seedData, state, persistence) {
|
function getSummaryText(seedData, state, persistence) {
|
||||||
const voteText = `${state.voteCount} ${pluralize(state.voteCount, "vote", "votes")}`;
|
const voteText = `${state.voteCount} ${pluralize(state.voteCount, "vote", "votes")}`;
|
||||||
|
|
||||||
if (persistence.available) {
|
if (persistence.mode === "server") {
|
||||||
return `${seedData.meals.length} meals ranked. ${voteText} saved in this browser.`;
|
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.`;
|
return `${seedData.meals.length} meals ranked. ${voteText} active for this session only.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusText(persistence) {
|
function getStatusText(persistence) {
|
||||||
if (persistence.available) {
|
if (persistence.mode === "server") {
|
||||||
return "Votes are saved in this browser on this device.";
|
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.";
|
return "Browser storage is unavailable, so votes reset when you reload.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
async function init() {
|
||||||
let seedData;
|
let seedData;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -400,6 +690,7 @@
|
|||||||
voteStatus: $("vote-status"),
|
voteStatus: $("vote-status"),
|
||||||
voteMessage: $("vote-message"),
|
voteMessage: $("vote-message"),
|
||||||
skipPair: $("skip-pair"),
|
skipPair: $("skip-pair"),
|
||||||
|
undoVote: $("undo-vote"),
|
||||||
resetRankings: $("reset-rankings"),
|
resetRankings: $("reset-rankings"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -410,20 +701,40 @@
|
|||||||
!elements.voteStatus ||
|
!elements.voteStatus ||
|
||||||
!elements.voteMessage ||
|
!elements.voteMessage ||
|
||||||
!elements.skipPair ||
|
!elements.skipPair ||
|
||||||
|
!elements.undoVote ||
|
||||||
!elements.resetRankings
|
!elements.resetRankings
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elements.voteMessage.textContent = "Loading saved rankings...";
|
||||||
|
|
||||||
const persistence = createPersistence();
|
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 currentPair = null;
|
||||||
let currentPairKey = null;
|
let currentPairKey = null;
|
||||||
|
let pendingPairIds = null;
|
||||||
let lastMessage = "Choose the better meal to start ranking.";
|
let lastMessage = "Choose the better meal to start ranking.";
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
persistence.save(state);
|
await persistence.save(state);
|
||||||
|
|
||||||
function queueNextPair(rankedMeals) {
|
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]);
|
currentPair = choosePair(rankedMeals, [currentPairKey, state.lastPairKey]);
|
||||||
currentPairKey = currentPair
|
currentPairKey = currentPair
|
||||||
? createPairKey(currentPair.left.id, currentPair.right.id)
|
? createPairKey(currentPair.left.id, currentPair.right.id)
|
||||||
@@ -448,6 +759,8 @@
|
|||||||
elements.voteStatus.textContent = getStatusText(persistence);
|
elements.voteStatus.textContent = getStatusText(persistence);
|
||||||
elements.voteMessage.textContent = lastMessage;
|
elements.voteMessage.textContent = lastMessage;
|
||||||
elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence);
|
elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence);
|
||||||
|
elements.undoVote.disabled = busy || !state.undo;
|
||||||
|
elements.resetRankings.disabled = busy;
|
||||||
elements.rankings.innerHTML = rankedMeals
|
elements.rankings.innerHTML = rankedMeals
|
||||||
.map((meal, index) => renderRankingCard(meal, index + 1))
|
.map((meal, index) => renderRankingCard(meal, index + 1))
|
||||||
.join("");
|
.join("");
|
||||||
@@ -459,15 +772,15 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.skipPair.disabled = false;
|
elements.skipPair.disabled = busy;
|
||||||
elements.duelCards.innerHTML = [
|
elements.duelCards.innerHTML = [
|
||||||
renderDuelCard(currentPair.left, "Left Pick"),
|
renderDuelCard(currentPair.left, "Left Pick", busy),
|
||||||
renderDuelCard(currentPair.right, "Right Pick"),
|
renderDuelCard(currentPair.right, "Right Pick", busy),
|
||||||
].join("");
|
].join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVote(winnerId) {
|
async function handleVote(winnerId) {
|
||||||
if (!currentPair) {
|
if (!currentPair || busy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,44 +789,132 @@
|
|||||||
currentPair.left.id === winnerId ? currentPair.left.title : currentPair.right.title;
|
currentPair.left.id === winnerId ? currentPair.left.title : currentPair.right.title;
|
||||||
const loserTitle =
|
const loserTitle =
|
||||||
currentPair.left.id === winnerId ? currentPair.right.title : currentPair.left.title;
|
currentPair.left.id === winnerId ? currentPair.right.title : currentPair.left.title;
|
||||||
|
let nextMessage = `Picked ${winnerTitle} over ${loserTitle}.`;
|
||||||
|
|
||||||
state = applyVote(seedData, state, winnerId, loserId, currentPairKey);
|
busy = true;
|
||||||
persistence.save(state);
|
render(`Saving ${winnerTitle} over ${loserTitle}...`);
|
||||||
currentPair = null;
|
|
||||||
render(`Picked ${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]");
|
const button = event.target.closest("[data-meal-id]");
|
||||||
|
|
||||||
if (!button) {
|
if (!button) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleVote(button.getAttribute("data-meal-id"));
|
await handleVote(button.getAttribute("data-meal-id"));
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.skipPair.addEventListener("click", () => {
|
elements.skipPair.addEventListener("click", () => {
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
|
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
|
||||||
|
|
||||||
queueNextPair(rankedMeals);
|
queueNextPair(rankedMeals);
|
||||||
render("Skipped that pair.");
|
render("Skipped that pair.");
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.resetRankings.addEventListener("click", () => {
|
elements.undoVote.addEventListener("click", async () => {
|
||||||
if (!window.confirm("Reset the local Elo votes saved in this browser?")) {
|
await handleUndo();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.resetRankings.addEventListener("click", async () => {
|
||||||
|
if (busy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = createSeedState(seedData);
|
if (!window.confirm("Reset the saved rankings back to the seeded board?")) {
|
||||||
persistence.clear();
|
return;
|
||||||
persistence.save(state);
|
}
|
||||||
currentPair = null;
|
|
||||||
currentPairKey = null;
|
let nextMessage = "Saved rankings cleared. Back to the seeded board.";
|
||||||
render("Local votes 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;
|
const target = event.target;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -523,21 +924,33 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key.toLowerCase() === "z" && state.undo) {
|
||||||
|
event.preventDefault();
|
||||||
|
await handleUndo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentPair) {
|
if (!currentPair) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "ArrowLeft") {
|
if (event.key === "ArrowLeft") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleVote(currentPair.left.id);
|
await handleVote(currentPair.left.id);
|
||||||
} else if (event.key === "ArrowRight") {
|
} else if (event.key === "ArrowRight") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleVote(currentPair.right.id);
|
await handleVote(currentPair.right.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676921.0168,
|
||||||
"size": 1052830,
|
"size": 1052830,
|
||||||
"focus": {
|
"focus": {
|
||||||
"x": 0.35,
|
"x": 0.35,
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676923.4688,
|
||||||
"size": 835360,
|
"size": 835360,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676926.1216,
|
||||||
"size": 1034158,
|
"size": 1034158,
|
||||||
"focus": {
|
"focus": {
|
||||||
"x": 0.5,
|
"x": 0.5,
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676928.9744,
|
||||||
"size": 1090215,
|
"size": 1090215,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676931.93,
|
||||||
"size": 1122236,
|
"size": 1122236,
|
||||||
"focus": {
|
"focus": {
|
||||||
"x": 0.5,
|
"x": 0.5,
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770444049000,
|
"mtimeMs": 1774257676932.7878,
|
||||||
"size": 676787,
|
"size": 676787,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676935.0764,
|
||||||
"size": 872024,
|
"size": 872024,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676936.637,
|
||||||
"size": 618276,
|
"size": 618276,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500127000,
|
"mtimeMs": 1774257676938.8577,
|
||||||
"size": 1349804,
|
"size": 1349804,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676940.0383,
|
||||||
"size": 1071870,
|
"size": 1071870,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676941.5466,
|
||||||
"size": 764329,
|
"size": 764329,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676943.8735,
|
||||||
"size": 1172905,
|
"size": 1172905,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676945.2588,
|
||||||
"size": 1099540,
|
"size": 1099540,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676946.2732,
|
||||||
"size": 1052362,
|
"size": 1052362,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676947.5552,
|
||||||
"size": 1227608,
|
"size": 1227608,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500127000,
|
"mtimeMs": 1774257676949.5437,
|
||||||
"size": 840466,
|
"size": 840466,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676951.916,
|
||||||
"size": 1136990,
|
"size": 1136990,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500127000,
|
"mtimeMs": 1774257676954.4558,
|
||||||
"size": 1261294,
|
"size": 1261294,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676956.7886,
|
||||||
"size": 1119498,
|
"size": 1119498,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676958.691,
|
||||||
"size": 868085,
|
"size": 868085,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676961.0393,
|
||||||
"size": 1057896,
|
"size": 1057896,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676963.4336,
|
||||||
"size": 1088795,
|
"size": 1088795,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676965.364,
|
||||||
"size": 852307,
|
"size": 852307,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676967.8005,
|
||||||
"size": 1149955,
|
"size": 1149955,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676970.2761,
|
||||||
"size": 1242099,
|
"size": 1242099,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676971.9075,
|
||||||
"size": 1414024,
|
"size": 1414024,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676973.2812,
|
||||||
"size": 1022877,
|
"size": 1022877,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676974.2112,
|
||||||
"size": 1018868,
|
"size": 1018868,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -265,7 +265,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500128000,
|
"mtimeMs": 1774257676975.1504,
|
||||||
"size": 1233602,
|
"size": 1233602,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1770500129000,
|
"mtimeMs": 1774257676976.6533,
|
||||||
"size": 739786,
|
"size": 739786,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1771126436000,
|
"mtimeMs": 1774257676977.1958,
|
||||||
"size": 1069693,
|
"size": 1069693,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1771226059000,
|
"mtimeMs": 1774257676979.063,
|
||||||
"size": 995282,
|
"size": 995282,
|
||||||
"focus": null
|
"focus": null
|
||||||
},
|
},
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
"width": 240,
|
"width": 240,
|
||||||
"height": 320,
|
"height": 320,
|
||||||
"quality": 82,
|
"quality": 82,
|
||||||
"mtimeMs": 1771226144000,
|
"mtimeMs": 1774257676980.472,
|
||||||
"size": 729224,
|
"size": 729224,
|
||||||
"focus": null
|
"focus": null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
<h1>for vham :3</h1>
|
<h1>for vham :3</h1>
|
||||||
<p id="haiku">Please enable javascript >.<</p>
|
<p id="haiku">Please enable javascript >.<</p>
|
||||||
<p class="page-links">
|
<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>
|
<span class="page-links__separator">/</span>
|
||||||
<a href="rankings.html">rankings</a>
|
<a href="rankings">rankings</a>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
<h1>food power rankings</h1>
|
<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>pick the better meal, one pair at a time, and the board updates live in this browser.</p>
|
||||||
<p class="page-links">
|
<p class="page-links">
|
||||||
<a href="index.html">gallery</a>
|
<a href="./">gallery</a>
|
||||||
<span class="page-links__separator">/</span>
|
<span class="page-links__separator">/</span>
|
||||||
<a href="rankings.html" aria-current="page">rankings</a>
|
<a href="rankings" aria-current="page">rankings</a>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -42,17 +42,18 @@
|
|||||||
<div class="voting-panel__intro">
|
<div class="voting-panel__intro">
|
||||||
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
|
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
|
||||||
<h2>Pick the winner.</h2>
|
<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>
|
||||||
<div class="voting-panel__actions">
|
<div class="voting-panel__actions">
|
||||||
<button class="button small" id="skip-pair" type="button">skip pair</button>
|
<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>
|
</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">
|
<div class="duel-grid" id="duel-cards">
|
||||||
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
380
scripts/lib/rankings-state.js
Normal file
380
scripts/lib/rankings-state.js
Normal 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,
|
||||||
|
};
|
||||||
197
scripts/serve.js
197
scripts/serve.js
@@ -3,9 +3,17 @@ const http = require("http");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const { repoRoot } = require("./lib/meals");
|
const { repoRoot } = require("./lib/meals");
|
||||||
|
const {
|
||||||
|
defaultStatePath,
|
||||||
|
loadRankingsState,
|
||||||
|
recordVote,
|
||||||
|
resetRankingsState,
|
||||||
|
undoLastRankingsVote,
|
||||||
|
} = require("./lib/rankings-state");
|
||||||
|
|
||||||
const DEFAULT_HOST = "127.0.0.1";
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
const DEFAULT_PORT = 4321;
|
const DEFAULT_PORT = 4321;
|
||||||
|
const MAX_REQUEST_BODY_BYTES = 16 * 1024;
|
||||||
const MIME_TYPES = {
|
const MIME_TYPES = {
|
||||||
".css": "text/css; charset=utf-8",
|
".css": "text/css; charset=utf-8",
|
||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
@@ -29,6 +37,7 @@ function parseArgs(argv) {
|
|||||||
const options = {
|
const options = {
|
||||||
host: process.env.HOST || DEFAULT_HOST,
|
host: process.env.HOST || DEFAULT_HOST,
|
||||||
port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10),
|
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) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
@@ -44,6 +53,12 @@ function parseArgs(argv) {
|
|||||||
if (arg === "--port" && value) {
|
if (arg === "--port" && value) {
|
||||||
options.port = Number.parseInt(value, 10);
|
options.port = Number.parseInt(value, 10);
|
||||||
index += 1;
|
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";
|
return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequestPath(requestUrl) {
|
function hasHiddenSegment(pathname) {
|
||||||
const url = new URL(requestUrl, "http://localhost");
|
return pathname.split("/").some((segment) => segment.startsWith(".") && segment.length > 1);
|
||||||
const pathname = decodeURIComponent(url.pathname);
|
}
|
||||||
|
|
||||||
|
function resolveRequestPath(pathname) {
|
||||||
|
if (hasHiddenSegment(pathname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
||||||
const absolutePath = path.resolve(repoRoot, `.${requestedPath}`);
|
const absolutePath = path.resolve(repoRoot, `.${requestedPath}`);
|
||||||
const withinRepo =
|
const withinRepo =
|
||||||
@@ -74,9 +95,76 @@ function resolveRequestPath(requestUrl) {
|
|||||||
return path.join(absolutePath, "index.html");
|
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;
|
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) {
|
function sendFile(response, filePath) {
|
||||||
const stream = fs.createReadStream(filePath);
|
const stream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
@@ -87,24 +175,106 @@ function sendFile(response, filePath) {
|
|||||||
|
|
||||||
stream.pipe(response);
|
stream.pipe(response);
|
||||||
stream.on("error", () => {
|
stream.on("error", () => {
|
||||||
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
sendText(response, 500, "Failed to read file");
|
||||||
response.end("Failed to read file");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServer() {
|
async function handleApiRequest(request, response, pathname, options) {
|
||||||
return http.createServer((request, response) => {
|
if (pathname === "/api/rankings" && request.method === "GET") {
|
||||||
const filePath = resolveRequestPath(request.url || "/");
|
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) {
|
if (!filePath) {
|
||||||
response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
sendText(response, 403, "Forbidden");
|
||||||
response.end("Forbidden");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||||
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
sendText(response, 404, "Not found");
|
||||||
response.end("Not found");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +284,11 @@ function createServer() {
|
|||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const options = parseArgs(process.argv.slice(2));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
const server = createServer();
|
const server = createServer(options);
|
||||||
|
|
||||||
server.listen(options.port, options.host, () => {
|
server.listen(options.port, options.host, () => {
|
||||||
console.log(`Serving ${repoRoot} at http://${options.host}:${options.port}`);
|
console.log(`Serving ${repoRoot} at http://${options.host}:${options.port}`);
|
||||||
|
console.log(`Rankings state path: ${options.rankingsStatePath}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
<h1>for vham :3</h1>
|
<h1>for vham :3</h1>
|
||||||
<p id="haiku">Please enable javascript >.<</p>
|
<p id="haiku">Please enable javascript >.<</p>
|
||||||
<p class="page-links">
|
<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>
|
<span class="page-links__separator">/</span>
|
||||||
<a href="rankings.html">rankings</a>
|
<a href="rankings">rankings</a>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
<h1>food power rankings</h1>
|
<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>pick the better meal, one pair at a time, and the board updates live in this browser.</p>
|
||||||
<p class="page-links">
|
<p class="page-links">
|
||||||
<a href="index.html">gallery</a>
|
<a href="./">gallery</a>
|
||||||
<span class="page-links__separator">/</span>
|
<span class="page-links__separator">/</span>
|
||||||
<a href="rankings.html" aria-current="page">rankings</a>
|
<a href="rankings" aria-current="page">rankings</a>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -42,17 +42,18 @@
|
|||||||
<div class="voting-panel__intro">
|
<div class="voting-panel__intro">
|
||||||
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
|
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
|
||||||
<h2>Pick the winner.</h2>
|
<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>
|
||||||
<div class="voting-panel__actions">
|
<div class="voting-panel__actions">
|
||||||
<button class="button small" id="skip-pair" type="button">skip pair</button>
|
<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>
|
</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">
|
<div class="duel-grid" id="duel-cards">
|
||||||
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user