Compare commits
2 Commits
26adbe617f
...
614a3d1eff
| Author | SHA1 | Date | |
|---|---|---|---|
| 614a3d1eff | |||
| b3a8368bab |
43
README.md
43
README.md
@@ -16,11 +16,47 @@ The site is based on the HTML5 UP Lens template and currently ships as a plain s
|
|||||||
- `data/meals.json`: source of truth for gallery entries
|
- `data/meals.json`: source of truth for gallery entries
|
||||||
- `data/elo.json`: Elo ratings, record totals, and ranking settings
|
- `data/elo.json`: Elo ratings, record totals, and ranking settings
|
||||||
- `scripts/build.js`: renders static pages from templates and data
|
- `scripts/build.js`: renders static pages from templates and data
|
||||||
|
- `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/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
|
||||||
- `package.json`: minimal Node build entrypoint
|
- `package.json`: minimal Node build entrypoint
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the site and validate the generated output:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Serve it locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://127.0.0.1:4321`.
|
||||||
|
|
||||||
|
If you want a single command that builds and serves, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
To validate the repo state without rebuilding thumbnails or pages, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
## Content Workflow
|
## Content Workflow
|
||||||
|
|
||||||
Gallery entries live in `data/meals.json`, and the build generates both `index.html` and `rankings.html` from the template and data files.
|
Gallery entries live in `data/meals.json`, and the build generates both `index.html` and `rankings.html` from the template and data files.
|
||||||
@@ -61,6 +97,10 @@ 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.
|
||||||
|
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.
|
||||||
|
|
||||||
## Image Conventions
|
## Image Conventions
|
||||||
|
|
||||||
- Full-size images and thumbnails share the same numeric ID
|
- Full-size images and thumbnails share the same numeric ID
|
||||||
@@ -94,5 +134,4 @@ The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the c
|
|||||||
|
|
||||||
## Planned Features
|
## Planned Features
|
||||||
|
|
||||||
1. A pairwise voting page that shows two food images at a time and updates Elo rankings based on the selected winner.
|
1. Optional shared sync or export/import for rankings if browser-local persistence becomes too limiting.
|
||||||
2. General cleanup and history cleanup once the bigger structural changes are in place.
|
|
||||||
|
|||||||
@@ -42,6 +42,186 @@ body.rankings-page #giftwo {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#voting {
|
||||||
|
padding: 0 2.25rem 1.75rem 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voting-panel {
|
||||||
|
background:
|
||||||
|
linear-gradient(140deg, rgba(255, 255, 255, 0.97), rgba(240, 248, 247, 0.94));
|
||||||
|
border: 1px solid rgba(16, 16, 16, 0.08);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
box-shadow: 0 1.75rem 3.5rem rgba(16, 16, 16, 0.08);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voting-panel__intro h2,
|
||||||
|
.voting-panel__intro p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voting-panel__eyebrow {
|
||||||
|
color: #00a892;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voting-panel__intro h2 {
|
||||||
|
color: #333;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-status {
|
||||||
|
color: #666;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voting-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-message {
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-placeholder {
|
||||||
|
color: #666;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(16, 16, 16, 0.08);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
box-shadow: 0 1rem 2.5rem rgba(16, 16, 16, 0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__button:hover,
|
||||||
|
.duel-card__button:focus-visible {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__button:focus-visible {
|
||||||
|
outline: 3px solid rgba(0, 211, 183, 0.55);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__button:hover .duel-card__media img,
|
||||||
|
.duel-card__button:focus-visible .duel-card__media img {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__media {
|
||||||
|
background: #eff3f8;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__media img {
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__label,
|
||||||
|
.duel-card__meta,
|
||||||
|
.duel-card__description,
|
||||||
|
.duel-card__body h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__label,
|
||||||
|
.duel-card__meta {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__label {
|
||||||
|
color: #00a892;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__title {
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__meta {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__description {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__cta {
|
||||||
|
color: #101010;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__open {
|
||||||
|
align-self: flex-start;
|
||||||
|
border-bottom: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0 1.25rem 1.25rem 1.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-card__open:hover {
|
||||||
|
color: #00a892;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
#rankings-summary {
|
#rankings-summary {
|
||||||
padding: 0 2.25rem 1.25rem 2.25rem;
|
padding: 0 2.25rem 1.25rem 2.25rem;
|
||||||
}
|
}
|
||||||
@@ -138,6 +318,7 @@ body.rankings-page #giftwo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#rankings-summary,
|
#rankings-summary,
|
||||||
|
#voting,
|
||||||
#rankings,
|
#rankings,
|
||||||
body.rankings-page #header,
|
body.rankings-page #header,
|
||||||
body.rankings-page #footer {
|
body.rankings-page #footer {
|
||||||
@@ -145,6 +326,14 @@ body.rankings-page #giftwo {
|
|||||||
padding-right: 1.25rem;
|
padding-right: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voting-panel {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.ranking-card {
|
.ranking-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
543
assets/js/rankings.js
Normal file
543
assets/js/rankings.js
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
(function () {
|
||||||
|
const STORAGE_KEY = "gallery.rankings.v1";
|
||||||
|
const STORAGE_TEST_KEY = `${STORAGE_KEY}.probe`;
|
||||||
|
const STATE_VERSION = 1;
|
||||||
|
const CLOSE_MATCH_COUNT = 6;
|
||||||
|
|
||||||
|
function $(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRating(rating) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize(count, singular, plural) {
|
||||||
|
return count === 1 ? singular : plural;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPairKey(leftId, rightId) {
|
||||||
|
return [leftId, rightId].sort().join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 pickRandom(items) {
|
||||||
|
return items[Math.floor(Math.random() * items.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeedData() {
|
||||||
|
const seedElement = $("rankings-seed-data");
|
||||||
|
|
||||||
|
if (!seedElement) {
|
||||||
|
throw new Error("Missing rankings seed data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedData = JSON.parse(seedElement.textContent);
|
||||||
|
|
||||||
|
if (!seedData || !Array.isArray(seedData.meals) || !seedData.elo) {
|
||||||
|
throw new Error("Invalid rankings seed data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return seedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersistence() {
|
||||||
|
let available = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_TEST_KEY, "1");
|
||||||
|
localStorage.removeItem(STORAGE_TEST_KEY);
|
||||||
|
available = true;
|
||||||
|
} catch (error) {
|
||||||
|
available = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get available() {
|
||||||
|
return available;
|
||||||
|
},
|
||||||
|
load() {
|
||||||
|
if (!available) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
save(state) {
|
||||||
|
if (!available) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
} catch (error) {
|
||||||
|
available = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
if (!available) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
available = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 compareRankedMeals(left, right) {
|
||||||
|
const leftMatches = left.wins + left.losses;
|
||||||
|
const rightMatches = right.wins + right.losses;
|
||||||
|
|
||||||
|
if (right.rating !== left.rating) {
|
||||||
|
return right.rating - left.rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightMatches !== leftMatches) {
|
||||||
|
return rightMatches - leftMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.parseInt(left.id, 10) - Number.parseInt(right.id, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankedMeals(meals, eloData) {
|
||||||
|
const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry]));
|
||||||
|
|
||||||
|
return meals
|
||||||
|
.map((meal) => {
|
||||||
|
const entry =
|
||||||
|
entryById.get(meal.id) || createDefaultEntry(meal.id, eloData.defaultRating);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...meal,
|
||||||
|
rating: entry.rating,
|
||||||
|
wins: entry.wins,
|
||||||
|
losses: entry.losses,
|
||||||
|
matches: entry.wins + entry.losses,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(compareRankedMeals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankingMeta(rankedMeal) {
|
||||||
|
const ratingText = `Elo ${formatRating(rankedMeal.rating)}`;
|
||||||
|
|
||||||
|
if (rankedMeal.matches === 0) {
|
||||||
|
return `${ratingText} | no votes yet`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${ratingText} | ${rankedMeal.wins}-${rankedMeal.losses} record across ${
|
||||||
|
rankedMeal.matches
|
||||||
|
} ${pluralize(rankedMeal.matches, "match", "matches")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedScore(rating, opponentRating) {
|
||||||
|
return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
elo: {
|
||||||
|
...state.elo,
|
||||||
|
entries: seedData.meals.map((meal) => entryById.get(meal.id)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePair(rankedMeals, avoidedPairKeys) {
|
||||||
|
if (rankedMeals.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const avoided = new Set(avoidedPairKeys.filter(Boolean));
|
||||||
|
const rankedOrder = new Map(rankedMeals.map((meal, index) => [meal.id, index]));
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
const baseMeal = rankedMeals[Math.floor(Math.random() * rankedMeals.length)];
|
||||||
|
const candidates = rankedMeals
|
||||||
|
.filter((meal) => meal.id !== baseMeal.id)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const ratingGap = Math.abs(left.rating - baseMeal.rating) - Math.abs(right.rating - baseMeal.rating);
|
||||||
|
|
||||||
|
if (ratingGap !== 0) {
|
||||||
|
return ratingGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.abs(rankedOrder.get(left.id) - rankedOrder.get(baseMeal.id)) -
|
||||||
|
Math.abs(rankedOrder.get(right.id) - rankedOrder.get(baseMeal.id))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const closeCandidates = candidates.slice(0, Math.min(CLOSE_MATCH_COUNT, candidates.length));
|
||||||
|
const filteredCandidates = closeCandidates.filter(
|
||||||
|
(meal) => !avoided.has(createPairKey(baseMeal.id, meal.id))
|
||||||
|
);
|
||||||
|
const candidatePool =
|
||||||
|
filteredCandidates.length > 0
|
||||||
|
? filteredCandidates
|
||||||
|
: candidates.filter((meal) => !avoided.has(createPairKey(baseMeal.id, meal.id)));
|
||||||
|
const fallbackPool = candidatePool.length > 0 ? candidatePool : candidates;
|
||||||
|
const opponent = pickRandom(
|
||||||
|
fallbackPool.slice(0, Math.min(CLOSE_MATCH_COUNT, fallbackPool.length))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opponent) {
|
||||||
|
return Math.random() < 0.5
|
||||||
|
? { left: baseMeal, right: opponent }
|
||||||
|
: { left: opponent, right: baseMeal };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: rankedMeals[0],
|
||||||
|
right: rankedMeals[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDuelCard(meal, sideLabel) {
|
||||||
|
return `<article class="duel-card">
|
||||||
|
<button class="duel-card__button" type="button" data-meal-id="${meal.id}">
|
||||||
|
<div class="duel-card__media">
|
||||||
|
<img src="images/fulls/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} photo`)}" />
|
||||||
|
</div>
|
||||||
|
<div class="duel-card__body">
|
||||||
|
<p class="duel-card__label">${escapeHtml(sideLabel)}</p>
|
||||||
|
<h3 class="duel-card__title">${escapeHtml(meal.title)}</h3>
|
||||||
|
<p class="duel-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
|
||||||
|
<p class="duel-card__description">${escapeHtml(meal.description)}</p>
|
||||||
|
<span class="duel-card__cta">Choose this meal</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<a class="duel-card__open" href="images/fulls/${meal.id}.jpg" target="_blank" rel="noreferrer">open full image</a>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRankingCard(meal, placement) {
|
||||||
|
return `<article class="ranking-card">
|
||||||
|
<p class="ranking-card__placement">#${placement}</p>
|
||||||
|
<a class="ranking-card__thumbnail" href="images/fulls/${meal.id}.jpg">
|
||||||
|
<img src="images/thumbs/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} thumbnail`)}" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
<div class="ranking-card__body">
|
||||||
|
<h2>${escapeHtml(meal.title)}</h2>
|
||||||
|
<p class="ranking-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
|
||||||
|
<p>${escapeHtml(meal.description)}</p>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummaryText(seedData, state, persistence) {
|
||||||
|
const voteText = `${state.voteCount} ${pluralize(state.voteCount, "vote", "votes")}`;
|
||||||
|
|
||||||
|
if (persistence.available) {
|
||||||
|
return `${seedData.meals.length} meals ranked. ${voteText} saved in this browser.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${seedData.meals.length} meals ranked. ${voteText} active for this session only.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(persistence) {
|
||||||
|
if (persistence.available) {
|
||||||
|
return "Votes are saved in this browser on this device.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Browser storage is unavailable, so votes reset when you reload.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
let seedData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
seedData = parseSeedData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
duelCards: $("duel-cards"),
|
||||||
|
rankings: $("rankings"),
|
||||||
|
rankingSummary: document.querySelector("#rankings-summary .ranking-summary"),
|
||||||
|
voteStatus: $("vote-status"),
|
||||||
|
voteMessage: $("vote-message"),
|
||||||
|
skipPair: $("skip-pair"),
|
||||||
|
resetRankings: $("reset-rankings"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!elements.duelCards ||
|
||||||
|
!elements.rankings ||
|
||||||
|
!elements.rankingSummary ||
|
||||||
|
!elements.voteStatus ||
|
||||||
|
!elements.voteMessage ||
|
||||||
|
!elements.skipPair ||
|
||||||
|
!elements.resetRankings
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistence = createPersistence();
|
||||||
|
let state = syncStoredState(seedData, persistence.load());
|
||||||
|
let currentPair = null;
|
||||||
|
let currentPairKey = null;
|
||||||
|
let lastMessage = "Choose the better meal to start ranking.";
|
||||||
|
|
||||||
|
persistence.save(state);
|
||||||
|
|
||||||
|
function queueNextPair(rankedMeals) {
|
||||||
|
currentPair = choosePair(rankedMeals, [currentPairKey, state.lastPairKey]);
|
||||||
|
currentPairKey = currentPair
|
||||||
|
? createPairKey(currentPair.left.id, currentPair.right.id)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(message) {
|
||||||
|
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
lastMessage = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!currentPair ||
|
||||||
|
!rankedMeals.some((meal) => meal.id === currentPair.left.id) ||
|
||||||
|
!rankedMeals.some((meal) => meal.id === currentPair.right.id)
|
||||||
|
) {
|
||||||
|
queueNextPair(rankedMeals);
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.voteStatus.textContent = getStatusText(persistence);
|
||||||
|
elements.voteMessage.textContent = lastMessage;
|
||||||
|
elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence);
|
||||||
|
elements.rankings.innerHTML = rankedMeals
|
||||||
|
.map((meal, index) => renderRankingCard(meal, index + 1))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
if (!currentPair) {
|
||||||
|
elements.duelCards.innerHTML =
|
||||||
|
'<p class="duel-placeholder">Add at least two meals before starting head-to-head voting.</p>';
|
||||||
|
elements.skipPair.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.skipPair.disabled = false;
|
||||||
|
elements.duelCards.innerHTML = [
|
||||||
|
renderDuelCard(currentPair.left, "Left Pick"),
|
||||||
|
renderDuelCard(currentPair.right, "Right Pick"),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVote(winnerId) {
|
||||||
|
if (!currentPair) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loserId = currentPair.left.id === winnerId ? currentPair.right.id : currentPair.left.id;
|
||||||
|
const winnerTitle =
|
||||||
|
currentPair.left.id === winnerId ? currentPair.left.title : currentPair.right.title;
|
||||||
|
const loserTitle =
|
||||||
|
currentPair.left.id === winnerId ? currentPair.right.title : currentPair.left.title;
|
||||||
|
|
||||||
|
state = applyVote(seedData, state, winnerId, loserId, currentPairKey);
|
||||||
|
persistence.save(state);
|
||||||
|
currentPair = null;
|
||||||
|
render(`Picked ${winnerTitle} over ${loserTitle}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.duelCards.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-meal-id]");
|
||||||
|
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleVote(button.getAttribute("data-meal-id"));
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.skipPair.addEventListener("click", () => {
|
||||||
|
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
|
||||||
|
|
||||||
|
queueNextPair(rankedMeals);
|
||||||
|
render("Skipped that pair.");
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.resetRankings.addEventListener("click", () => {
|
||||||
|
if (!window.confirm("Reset the local Elo votes saved in this browser?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = createSeedState(seedData);
|
||||||
|
persistence.clear();
|
||||||
|
persistence.save(state);
|
||||||
|
currentPair = null;
|
||||||
|
currentPairKey = null;
|
||||||
|
render("Local votes cleared. Back to the seeded board.");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(target.tagName)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentPair) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleVote(currentPair.left.id);
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleVote(currentPair.right.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
"name": "gallery",
|
"name": "gallery",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:thumbs && npm run build:pages",
|
"build": "npm run build:thumbs && npm run build:pages && npm run check",
|
||||||
"ingest": "node scripts/ingest-meal.js",
|
"ingest": "node scripts/ingest-meal.js",
|
||||||
|
"check": "node scripts/check.js",
|
||||||
"build:pages": "node scripts/build.js",
|
"build:pages": "node scripts/build.js",
|
||||||
"build:thumbs": "node scripts/generate-thumbnails.js",
|
"build:thumbs": "node scripts/generate-thumbnails.js",
|
||||||
"build:thumbs:force": "node scripts/generate-thumbnails.js --force"
|
"build:thumbs:force": "node scripts/generate-thumbnails.js --force",
|
||||||
|
"serve": "node scripts/serve.js",
|
||||||
|
"start": "npm run build && npm run serve"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
|
|||||||
421
rankings.html
421
rankings.html
@@ -28,7 +28,7 @@
|
|||||||
<header id="header">
|
<header id="header">
|
||||||
<img src="images/meow.gif" alt="meow" id="gifone">
|
<img src="images/meow.gif" alt="meow" id="gifone">
|
||||||
<h1>food power rankings</h1>
|
<h1>food power rankings</h1>
|
||||||
<p>static Elo seeds for every meal before the head-to-head voting page exists.</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="index.html">gallery</a>
|
||||||
<span class="page-links__separator">/</span>
|
<span class="page-links__separator">/</span>
|
||||||
@@ -36,9 +36,29 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Voting -->
|
||||||
|
<section id="voting">
|
||||||
|
<div class="voting-panel">
|
||||||
|
<div class="voting-panel__intro">
|
||||||
|
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
|
||||||
|
<h2>Pick the winner.</h2>
|
||||||
|
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to save votes in this browser.</p>
|
||||||
|
</div>
|
||||||
|
<div class="voting-panel__actions">
|
||||||
|
<button class="button small" id="skip-pair" type="button">skip pair</button>
|
||||||
|
<button class="button small" id="reset-rankings" type="button">reset local votes</button>
|
||||||
|
</div>
|
||||||
|
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to start head-to-head voting.</p>
|
||||||
|
<div class="duel-grid" id="duel-cards">
|
||||||
|
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
||||||
|
</div>
|
||||||
|
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Rankings Summary -->
|
<!-- Rankings Summary -->
|
||||||
<section id="rankings-summary">
|
<section id="rankings-summary">
|
||||||
<p class="ranking-summary">33 meals seeded at Elo 1,000 until head-to-head voting starts.</p>
|
<p class="ranking-summary">33 meals seeded at Elo 1,000. Enable JavaScript to vote and reorder them.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Rankings -->
|
<!-- Rankings -->
|
||||||
@@ -351,5 +371,402 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<script id="rankings-seed-data" type="application/json">
|
||||||
|
{
|
||||||
|
"meals": [
|
||||||
|
{
|
||||||
|
"id": "01",
|
||||||
|
"thumbnail": {
|
||||||
|
"focus": {
|
||||||
|
"x": 0.35,
|
||||||
|
"y": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": "left center",
|
||||||
|
"title": "sf on $10",
|
||||||
|
"description": "this was so not real i can't believe technically u paid for our first meal back. calmluh 3 years after. first hang !!!! pork buns were yummy 7/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02",
|
||||||
|
"title": "honey butter chicken",
|
||||||
|
"description": "the first thing you cooked for me ! so yum 10/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03",
|
||||||
|
"thumbnail": {
|
||||||
|
"focus": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.35
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": "top center",
|
||||||
|
"title": "aloha fresh",
|
||||||
|
"description": "we fucking love this place 10/10 i love poke i should have never quit pokehouse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04",
|
||||||
|
"title": "mad yolks",
|
||||||
|
"description": "for our santa cruz hang! u in my city now. so so good but lwk so so tax 9/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05",
|
||||||
|
"thumbnail": {
|
||||||
|
"focus": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.35
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": "top center",
|
||||||
|
"title": "sizzling lunch",
|
||||||
|
"description": "better than pepper lunch. server was being a little bitchy but i would be too if i was the only one working the front. 8/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06",
|
||||||
|
"title": "braised pork belly",
|
||||||
|
"description": "omfg this is my favorite thing uve made 100/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "07",
|
||||||
|
"title": "sushi w/ claire!",
|
||||||
|
"description": "and then we played bananagrams. sushi 8/10 thanks for paying mommy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "08",
|
||||||
|
"title": "myungrang hot dog",
|
||||||
|
"description": "main street tino nothing special 7/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "09",
|
||||||
|
"title": "liangs village",
|
||||||
|
"description": "my peoples food. 9/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "10",
|
||||||
|
"title": "cabonara",
|
||||||
|
"description": "insane safeway hang 9/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "11",
|
||||||
|
"title": "heytea",
|
||||||
|
"description": "this fuckass blue drink"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "12",
|
||||||
|
"title": "sparcos",
|
||||||
|
"description": "one of many.. 100/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "13",
|
||||||
|
"title": "noahs bagels",
|
||||||
|
"description": "this is the plaza where i used to go to all the time before school 9/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14",
|
||||||
|
"title": "homeroom",
|
||||||
|
"description": "mac and cheese was gas. 10/10. you know its my fav comfort food. free the girl crying in the corner tho"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "15",
|
||||||
|
"title": "sparcos x2",
|
||||||
|
"description": "spartan tacos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "16",
|
||||||
|
"title": "sparcos x3",
|
||||||
|
"description": "okay damn no way we got this b2b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "17",
|
||||||
|
"title": "aloha fresh",
|
||||||
|
"description": "this is lowkey the spot poke always hits so fucking good"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "18",
|
||||||
|
"title": "house of bagels",
|
||||||
|
"description": "hobags ughgmmfmfm im such a fucking ho for hobags 100/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19",
|
||||||
|
"title": "toro sushi",
|
||||||
|
"description": "carmel by the sea! we love sushi but tax 8/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "20",
|
||||||
|
"title": "sul and beans",
|
||||||
|
"description": "sweet treat -> claire dropping the most insane piece of information ever -> hti the yap"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "21",
|
||||||
|
"title": "highland hand pulled noodles",
|
||||||
|
"description": "so good and soooo filling 10/10. also my peoples food."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "22",
|
||||||
|
"title": "bloom",
|
||||||
|
"description": "even when its rich white people breakfast im getting salmon nox 10/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "23",
|
||||||
|
"title": "happy donuts",
|
||||||
|
"description": "1k cal meal 0 protein 10/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "24",
|
||||||
|
"title": "marugame",
|
||||||
|
"description": "i dont want to talk about this. 9/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "25",
|
||||||
|
"title": "siam station!",
|
||||||
|
"description": "i can't believe u didnt eat ur leftovers. 10/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "26",
|
||||||
|
"title": "muukata 6395",
|
||||||
|
"description": "for my birthday!! i love eating meat and i love you so perfect combination 1000/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "27",
|
||||||
|
"title": "bambu",
|
||||||
|
"description": "why was the store so nice. i wonder about the 4 sisters"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "28",
|
||||||
|
"title": "porridge at julias",
|
||||||
|
"description": "her boyfriend is so not real hes so stupid and funny. porridge was gas too i love eating free at julias 10/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "29",
|
||||||
|
"title": "sparcos x4",
|
||||||
|
"description": "spartan tacos. i was moody lol."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "30",
|
||||||
|
"title": "wonton udon",
|
||||||
|
"description": "i helped wrap the wontons w/ u !!! super fun and super yummy 10/10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "31",
|
||||||
|
"title": "steak dinna for vday",
|
||||||
|
"description": "marry me? yes. 100/10 best valentines day ever"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "32",
|
||||||
|
"title": "poke house",
|
||||||
|
"description": "poke house 3 years later 9/10 but +1 point bc its basically free"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "33",
|
||||||
|
"title": "hey tea",
|
||||||
|
"description": "mochi yinje black milk tea ts was actually so buss 10/10 only boba i've ever wanted to get again myself"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elo": {
|
||||||
|
"defaultRating": 1000,
|
||||||
|
"kFactor": 32,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "01",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "02",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "07",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "08",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "09",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "10",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "11",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "12",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "13",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "15",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "16",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "17",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "18",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "20",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "21",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "22",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "23",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "24",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "25",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "26",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "27",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "28",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "29",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "30",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "31",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "32",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "33",
|
||||||
|
"rating": 1000,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="assets/js/rankings.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const { getRankedMeals, syncEloWithMeals } = require("./lib/elo");
|
const { getRankedMeals, syncEloWithMeals } = require("./lib/elo");
|
||||||
const { loadMeals, repoRoot } = require("./lib/meals");
|
const { loadMeals, repoRoot, validateMealAssets } = require("./lib/meals");
|
||||||
|
|
||||||
const indexTemplatePath = path.join(repoRoot, "templates", "index.html");
|
const indexTemplatePath = path.join(repoRoot, "templates", "index.html");
|
||||||
const indexOutputPath = path.join(repoRoot, "index.html");
|
const indexOutputPath = path.join(repoRoot, "index.html");
|
||||||
@@ -24,6 +24,20 @@ function escapeHtml(value) {
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeJsonForHtml(value) {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
.replace(/</g, "\\u003c")
|
||||||
|
.replace(/\u2028/g, "\\u2028")
|
||||||
|
.replace(/\u2029/g, "\\u2029");
|
||||||
|
}
|
||||||
|
|
||||||
|
function indentBlock(text, indent) {
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `${indent}${line}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function renderGalleryItem(meal, eol) {
|
function renderGalleryItem(meal, eol) {
|
||||||
const attrs = [`class="thumbnail"`, `href="images/fulls/${meal.id}.jpg"`];
|
const attrs = [`class="thumbnail"`, `href="images/fulls/${meal.id}.jpg"`];
|
||||||
|
|
||||||
@@ -53,7 +67,7 @@ function renderRankingSummary(meals, eloData) {
|
|||||||
|
|
||||||
return `\t\t\t\t<p class="ranking-summary">${meals.length} ${mealLabel} seeded at Elo ${formatRating(
|
return `\t\t\t\t<p class="ranking-summary">${meals.length} ${mealLabel} seeded at Elo ${formatRating(
|
||||||
eloData.defaultRating
|
eloData.defaultRating
|
||||||
)} until head-to-head voting starts.</p>`;
|
)}. Enable JavaScript to vote and reorder them.</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRankingMeta(rankedMeal) {
|
function renderRankingMeta(rankedMeal) {
|
||||||
@@ -91,6 +105,16 @@ function renderRankings(rankedMeals, eol) {
|
|||||||
.join(eol);
|
.join(eol);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRankingsSeedData(meals, eloData) {
|
||||||
|
return indentBlock(
|
||||||
|
serializeJsonForHtml({
|
||||||
|
meals,
|
||||||
|
elo: eloData,
|
||||||
|
}),
|
||||||
|
"\t\t\t"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function replaceBlock(template, token, replacement) {
|
function replaceBlock(template, token, replacement) {
|
||||||
const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m");
|
const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m");
|
||||||
|
|
||||||
@@ -102,6 +126,7 @@ function replaceBlock(template, token, replacement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildIndex(meals = loadMeals()) {
|
function buildIndex(meals = loadMeals()) {
|
||||||
|
validateMealAssets(meals);
|
||||||
const template = fs.readFileSync(indexTemplatePath, "utf8");
|
const template = fs.readFileSync(indexTemplatePath, "utf8");
|
||||||
const eol = detectEol(template);
|
const eol = detectEol(template);
|
||||||
|
|
||||||
@@ -112,6 +137,7 @@ function buildRankings(
|
|||||||
meals = loadMeals(),
|
meals = loadMeals(),
|
||||||
eloData = syncEloWithMeals(meals)
|
eloData = syncEloWithMeals(meals)
|
||||||
) {
|
) {
|
||||||
|
validateMealAssets(meals);
|
||||||
const template = fs.readFileSync(rankingsTemplatePath, "utf8");
|
const template = fs.readFileSync(rankingsTemplatePath, "utf8");
|
||||||
const eol = detectEol(template);
|
const eol = detectEol(template);
|
||||||
const rankedMeals = getRankedMeals(meals, eloData);
|
const rankedMeals = getRankedMeals(meals, eloData);
|
||||||
@@ -120,8 +146,13 @@ function buildRankings(
|
|||||||
"ranking_summary",
|
"ranking_summary",
|
||||||
renderRankingSummary(meals, eloData)
|
renderRankingSummary(meals, eloData)
|
||||||
);
|
);
|
||||||
|
const withSeedData = replaceBlock(
|
||||||
|
withSummary,
|
||||||
|
"rankings_seed_data",
|
||||||
|
renderRankingsSeedData(meals, eloData)
|
||||||
|
);
|
||||||
|
|
||||||
return replaceBlock(withSummary, "ranking_items", renderRankings(rankedMeals, eol));
|
return replaceBlock(withSeedData, "ranking_items", renderRankings(rankedMeals, eol));
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeFile(filePath, contents) {
|
function writeFile(filePath, contents) {
|
||||||
|
|||||||
151
scripts/check.js
Normal file
151
scripts/check.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const { getEloAlignmentReport, loadEloData } = require("./lib/elo");
|
||||||
|
const {
|
||||||
|
fullsDir,
|
||||||
|
loadMeals,
|
||||||
|
repoRoot,
|
||||||
|
thumbsDir,
|
||||||
|
validateMealAssets,
|
||||||
|
} = require("./lib/meals");
|
||||||
|
|
||||||
|
const indexPath = path.join(repoRoot, "index.html");
|
||||||
|
const rankingsPath = path.join(repoRoot, "rankings.html");
|
||||||
|
|
||||||
|
function listJpgIds(directoryPath) {
|
||||||
|
if (!fs.existsSync(directoryPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readdirSync(directoryPath, { withFileTypes: true })
|
||||||
|
.filter(
|
||||||
|
(entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === ".jpg"
|
||||||
|
)
|
||||||
|
.map((entry) => path.basename(entry.name, ".jpg"))
|
||||||
|
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnexpectedIds(directoryPath, expectedIds) {
|
||||||
|
return listJpgIds(directoryPath).filter((id) => !expectedIds.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMatches(text, pattern) {
|
||||||
|
return (text.match(pattern) || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRankingsSeedData(rankingsHtml) {
|
||||||
|
const match = rankingsHtml.match(
|
||||||
|
/<script id="rankings-seed-data" type="application\/json">([\s\S]*?)<\/script>/
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Generated rankings.html is missing embedded rankings seed data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGeneratedPages(meals, eloData) {
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
throw new Error("Generated index.html is missing; run npm run build");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(rankingsPath)) {
|
||||||
|
throw new Error("Generated rankings.html is missing; run npm run build");
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexHtml = fs.readFileSync(indexPath, "utf8");
|
||||||
|
const rankingsHtml = fs.readFileSync(rankingsPath, "utf8");
|
||||||
|
const galleryArticleCount = countMatches(indexHtml, /<article>/g);
|
||||||
|
const rankingCardCount = countMatches(rankingsHtml, /class="ranking-card"/g);
|
||||||
|
|
||||||
|
if (galleryArticleCount !== meals.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Generated index.html is out of sync: expected ${meals.length} gallery entries, found ${galleryArticleCount}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rankingCardCount !== meals.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Generated rankings.html is out of sync: expected ${meals.length} ranking cards, found ${rankingCardCount}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rankingsHtml.includes('src="assets/js/rankings.js"')) {
|
||||||
|
throw new Error("Generated rankings.html is missing the interactive rankings script");
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedData = parseRankingsSeedData(rankingsHtml);
|
||||||
|
|
||||||
|
if (!Array.isArray(seedData.meals) || seedData.meals.length !== meals.length) {
|
||||||
|
throw new Error("Generated rankings.html has stale embedded meal seed data");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!seedData.elo ||
|
||||||
|
!Array.isArray(seedData.elo.entries) ||
|
||||||
|
seedData.elo.entries.length !== eloData.entries.length
|
||||||
|
) {
|
||||||
|
throw new Error("Generated rankings.html has stale embedded Elo seed data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const meals = loadMeals();
|
||||||
|
const eloData = loadEloData();
|
||||||
|
const expectedIds = new Set(meals.map((meal) => meal.id));
|
||||||
|
const alignment = getEloAlignmentReport(meals, eloData);
|
||||||
|
|
||||||
|
validateMealAssets(meals);
|
||||||
|
|
||||||
|
if (alignment.missingEntryIds.length > 0 || alignment.unexpectedEntryIds.length > 0) {
|
||||||
|
const messages = [];
|
||||||
|
|
||||||
|
if (alignment.missingEntryIds.length > 0) {
|
||||||
|
messages.push(
|
||||||
|
`Missing Elo entries for meal ids: ${alignment.missingEntryIds.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alignment.unexpectedEntryIds.length > 0) {
|
||||||
|
messages.push(
|
||||||
|
`Unexpected Elo entries with no meal: ${alignment.unexpectedEntryIds.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(messages.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unexpectedFulls = getUnexpectedIds(fullsDir, expectedIds);
|
||||||
|
|
||||||
|
if (unexpectedFulls.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected full-size image files with no meal entry: ${unexpectedFulls.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unexpectedThumbs = getUnexpectedIds(thumbsDir, expectedIds);
|
||||||
|
|
||||||
|
if (unexpectedThumbs.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected thumbnail files with no meal entry: ${unexpectedThumbs.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateGeneratedPages(meals, eloData);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Validation passed: ${meals.length} meals, ${eloData.entries.length} Elo entries, generated pages and image assets are in sync.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,20 @@ function syncEloWithMeals(meals) {
|
|||||||
return syncedData;
|
return syncedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEloAlignmentReport(meals, eloData) {
|
||||||
|
const mealIds = new Set(meals.map((meal) => meal.id));
|
||||||
|
const eloIds = new Set(eloData.entries.map((entry) => entry.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
missingEntryIds: meals
|
||||||
|
.map((meal) => meal.id)
|
||||||
|
.filter((mealId) => !eloIds.has(mealId)),
|
||||||
|
unexpectedEntryIds: eloData.entries
|
||||||
|
.map((entry) => entry.id)
|
||||||
|
.filter((entryId) => !mealIds.has(entryId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function compareRankedMeals(left, right) {
|
function compareRankedMeals(left, right) {
|
||||||
if (right.rating !== left.rating) {
|
if (right.rating !== left.rating) {
|
||||||
return right.rating - left.rating;
|
return right.rating - left.rating;
|
||||||
@@ -132,6 +146,7 @@ function getRankedMeals(meals, eloData) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
eloPath,
|
eloPath,
|
||||||
|
getEloAlignmentReport,
|
||||||
getRankedMeals,
|
getRankedMeals,
|
||||||
loadEloData,
|
loadEloData,
|
||||||
saveEloData,
|
saveEloData,
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ const path = require("path");
|
|||||||
|
|
||||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||||
const mealsPath = path.join(repoRoot, "data", "meals.json");
|
const mealsPath = path.join(repoRoot, "data", "meals.json");
|
||||||
|
const fullsDir = path.join(repoRoot, "images", "fulls");
|
||||||
|
const thumbsDir = path.join(repoRoot, "images", "thumbs");
|
||||||
|
|
||||||
|
function isNonEmptyString(value) {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function validateThumbnail(meal, index) {
|
function validateThumbnail(meal, index) {
|
||||||
if (meal.thumbnail === undefined) {
|
if (meal.thumbnail === undefined) {
|
||||||
@@ -55,7 +61,7 @@ function validateMeals(meals) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const field of ["id", "title", "description"]) {
|
for (const field of ["id", "title", "description"]) {
|
||||||
if (typeof meal[field] !== "string" || meal[field].length === 0) {
|
if (!isNonEmptyString(meal[field])) {
|
||||||
throw new Error(`Meal ${index} is missing required string field "${field}"`);
|
throw new Error(`Meal ${index} is missing required string field "${field}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,8 +70,8 @@ function validateMeals(meals) {
|
|||||||
throw new Error(`Meal ${index} has a non-numeric id "${meal.id}"`);
|
throw new Error(`Meal ${index} has a non-numeric id "${meal.id}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meal.position !== undefined && typeof meal.position !== "string") {
|
if (meal.position !== undefined && !isNonEmptyString(meal.position)) {
|
||||||
throw new Error(`Meal ${index} has a non-string "position" value`);
|
throw new Error(`Meal ${index} has an invalid "position" value`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ids.has(meal.id)) {
|
if (ids.has(meal.id)) {
|
||||||
@@ -91,6 +97,49 @@ function saveMeals(meals) {
|
|||||||
fs.writeFileSync(mealsPath, `${JSON.stringify(meals, null, 2)}\n`);
|
fs.writeFileSync(mealsPath, `${JSON.stringify(meals, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMealImagePaths(mealOrId) {
|
||||||
|
const mealId =
|
||||||
|
typeof mealOrId === "string" ? mealOrId : mealOrId && typeof mealOrId.id === "string" ? mealOrId.id : null;
|
||||||
|
|
||||||
|
if (!mealId) {
|
||||||
|
throw new Error("Expected a meal object or meal id string");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullPath: path.join(fullsDir, `${mealId}.jpg`),
|
||||||
|
thumbPath: path.join(thumbsDir, `${mealId}.jpg`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMealAssets(meals, options = {}) {
|
||||||
|
const settings = {
|
||||||
|
requireFull: true,
|
||||||
|
requireThumb: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
const missingAssets = [];
|
||||||
|
|
||||||
|
for (const meal of meals) {
|
||||||
|
const { fullPath, thumbPath } = getMealImagePaths(meal);
|
||||||
|
|
||||||
|
if (settings.requireFull && !fs.existsSync(fullPath)) {
|
||||||
|
missingAssets.push(
|
||||||
|
`Meal ${meal.id} is missing full-size image: ${path.relative(repoRoot, fullPath)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.requireThumb && !fs.existsSync(thumbPath)) {
|
||||||
|
missingAssets.push(
|
||||||
|
`Meal ${meal.id} is missing thumbnail image: ${path.relative(repoRoot, thumbPath)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingAssets.length > 0) {
|
||||||
|
throw new Error(`Missing image assets:\n${missingAssets.join("\n")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getNextMealId(meals) {
|
function getNextMealId(meals) {
|
||||||
if (meals.length === 0) {
|
if (meals.length === 0) {
|
||||||
return "01";
|
return "01";
|
||||||
@@ -108,9 +157,13 @@ function getNextMealId(meals) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
fullsDir,
|
||||||
getNextMealId,
|
getNextMealId,
|
||||||
|
getMealImagePaths,
|
||||||
loadMeals,
|
loadMeals,
|
||||||
mealsPath,
|
mealsPath,
|
||||||
repoRoot,
|
repoRoot,
|
||||||
saveMeals,
|
saveMeals,
|
||||||
|
thumbsDir,
|
||||||
|
validateMealAssets,
|
||||||
};
|
};
|
||||||
|
|||||||
131
scripts/serve.js
Normal file
131
scripts/serve.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const { repoRoot } = require("./lib/meals");
|
||||||
|
|
||||||
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
|
const DEFAULT_PORT = 4321;
|
||||||
|
const MIME_TYPES = {
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".map": "application/json; charset=utf-8",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
host: process.env.HOST || DEFAULT_HOST,
|
||||||
|
port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
const value = argv[index + 1];
|
||||||
|
|
||||||
|
if (arg === "--host" && value) {
|
||||||
|
options.host = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--port" && value) {
|
||||||
|
options.port = Number.parseInt(value, 10);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(options.port) || options.port <= 0 || options.port > 65535) {
|
||||||
|
throw new Error("Port must be an integer between 1 and 65535");
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
||||||
|
const absolutePath = path.resolve(repoRoot, `.${requestedPath}`);
|
||||||
|
const withinRepo =
|
||||||
|
absolutePath === repoRoot || absolutePath.startsWith(`${repoRoot}${path.sep}`);
|
||||||
|
|
||||||
|
if (!withinRepo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isDirectory()) {
|
||||||
|
return path.join(absolutePath, "index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(response, filePath) {
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
response.writeHead(200, {
|
||||||
|
"Content-Type": getContentType(filePath),
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.pipe(response);
|
||||||
|
stream.on("error", () => {
|
||||||
|
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
response.end("Failed to read file");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServer() {
|
||||||
|
return http.createServer((request, response) => {
|
||||||
|
const filePath = resolveRequestPath(request.url || "/");
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
response.end("Forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||||
|
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
response.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendFile(response, filePath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const server = createServer();
|
||||||
|
|
||||||
|
server.listen(options.port, options.host, () => {
|
||||||
|
console.log(`Serving ${repoRoot} at http://${options.host}:${options.port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<header id="header">
|
<header id="header">
|
||||||
<img src="images/meow.gif" alt="meow" id="gifone">
|
<img src="images/meow.gif" alt="meow" id="gifone">
|
||||||
<h1>food power rankings</h1>
|
<h1>food power rankings</h1>
|
||||||
<p>static Elo seeds for every meal before the head-to-head voting page exists.</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="index.html">gallery</a>
|
||||||
<span class="page-links__separator">/</span>
|
<span class="page-links__separator">/</span>
|
||||||
@@ -36,6 +36,26 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Voting -->
|
||||||
|
<section id="voting">
|
||||||
|
<div class="voting-panel">
|
||||||
|
<div class="voting-panel__intro">
|
||||||
|
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
|
||||||
|
<h2>Pick the winner.</h2>
|
||||||
|
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to save votes in this browser.</p>
|
||||||
|
</div>
|
||||||
|
<div class="voting-panel__actions">
|
||||||
|
<button class="button small" id="skip-pair" type="button">skip pair</button>
|
||||||
|
<button class="button small" id="reset-rankings" type="button">reset local votes</button>
|
||||||
|
</div>
|
||||||
|
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to start head-to-head voting.</p>
|
||||||
|
<div class="duel-grid" id="duel-cards">
|
||||||
|
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
|
||||||
|
</div>
|
||||||
|
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Rankings Summary -->
|
<!-- Rankings Summary -->
|
||||||
<section id="rankings-summary">
|
<section id="rankings-summary">
|
||||||
{{ranking_summary}}
|
{{ranking_summary}}
|
||||||
@@ -55,5 +75,9 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<script id="rankings-seed-data" type="application/json">
|
||||||
|
{{rankings_seed_data}}
|
||||||
|
</script>
|
||||||
|
<script src="assets/js/rankings.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user