From 26adbe617fbd8b7cb159ed23f7a42626b3ecc363 Mon Sep 17 00:00:00 2001 From: Ryan Chou Date: Sun, 22 Mar 2026 20:18:28 -0700 Subject: [PATCH] add: elo data model and static rankings page --- README.md | 15 +- assets/css/nyaa.css | 22 +++ assets/css/rankings.css | 160 ++++++++++++++++++ data/elo.json | 204 +++++++++++++++++++++++ index.html | 7 +- rankings.html | 355 ++++++++++++++++++++++++++++++++++++++++ scripts/build.js | 82 +++++++++- scripts/ingest-meal.js | 41 +++-- scripts/lib/elo.js | 139 ++++++++++++++++ templates/index.html | 5 + templates/rankings.html | 59 +++++++ 11 files changed, 1071 insertions(+), 18 deletions(-) create mode 100644 assets/css/rankings.css create mode 100644 data/elo.json create mode 100644 rankings.html create mode 100644 scripts/lib/elo.js create mode 100644 templates/rankings.html diff --git a/README.md b/README.md index d64a125..67ee295 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,23 @@ The site is based on the HTML5 UP Lens template and currently ships as a plain s ## Repo Layout - `templates/index.html`: source template for the main gallery page +- `templates/rankings.html`: source template for the rankings page - `index.html`: generated static gallery page +- `rankings.html`: generated static rankings page - `assets/`: site CSS, JavaScript, fonts, and audio - `images/fulls/`: full-size gallery images - `images/thumbs/`: gallery thumbnails - `data/meals.json`: source of truth for gallery entries +- `data/elo.json`: Elo ratings, record totals, and ranking settings - `scripts/build.js`: renders static pages from templates and data - `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/lib/elo.js`: validates and syncs Elo data against the meal list - `package.json`: minimal Node build entrypoint ## Content Workflow -Gallery entries live in `data/meals.json`, and `index.html` is generated from `templates/index.html`. +Gallery entries live in `data/meals.json`, and the build generates both `index.html` and `rankings.html` from the template and data files. After editing content or templates, rebuild the site with: @@ -27,7 +31,7 @@ After editing content or templates, rebuild the site with: npm run build ``` -The build currently renders the main page without changing the existing Lens gallery structure, so the current client-side viewer code continues to work. +The gallery build keeps the existing Lens thumbnail markup intact, so the current client-side viewer code continues to work. To ingest a new meal image and update the site in one command, run: @@ -52,6 +56,11 @@ To force a full thumbnail rebuild, run: npm run build:thumbs:force ``` +## Rankings Data + +`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. + ## Image Conventions - Full-size images and thumbnails share the same numeric ID @@ -85,5 +94,5 @@ The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the c ## Planned Features -1. An Elo-style ranking page that shows two food images at a time and updates rankings automatically based on the selected winner. +1. A pairwise voting page that shows two food images at a time and updates Elo rankings based on the selected winner. 2. General cleanup and history cleanup once the bigger structural changes are in place. diff --git a/assets/css/nyaa.css b/assets/css/nyaa.css index 8802db9..dbd7783 100755 --- a/assets/css/nyaa.css +++ b/assets/css/nyaa.css @@ -3,6 +3,7 @@ width: 100px; height: auto; } + #giftwo { position: fixed; bottom: 0; @@ -11,3 +12,24 @@ width: 100px; height: auto; } + +.page-links { + font-size: 0.8rem; + letter-spacing: 0.1em; + margin-top: 1rem; + text-transform: uppercase; +} + +.page-links a { + border-bottom: 0; +} + +.page-links a[aria-current="page"] { + color: #00d3b7; +} + +.page-links__separator { + color: #d0d0d0; + display: inline-block; + margin: 0 0.5rem; +} diff --git a/assets/css/rankings.css b/assets/css/rankings.css new file mode 100644 index 0000000..fa262e0 --- /dev/null +++ b/assets/css/rankings.css @@ -0,0 +1,160 @@ +html.rankings-html, +body.rankings-page { + background: + radial-gradient(circle at top, rgba(0, 211, 183, 0.18), transparent 28rem), + linear-gradient(180deg, #f5f7fb 0%, #ffffff 100%); + overflow-x: hidden; + overflow-y: auto; +} + +body.rankings-page { + color: #7a7a7a; +} + +body.rankings-page #main { + height: auto; + left: auto; + margin: 2rem auto; + max-width: 72rem; + overflow: visible; + position: relative; + text-align: left; + width: min(72rem, calc(100% - 3rem)); +} + +body.rankings-page #header, +body.rankings-page #footer { + text-align: left; +} + +body.rankings-page #header { + padding-bottom: 1.25rem; +} + +body.rankings-page #gifone { + display: block; + margin-bottom: 1rem; +} + +body.rankings-page #giftwo { + left: auto; + position: static; + transform: none; +} + +#rankings-summary { + padding: 0 2.25rem 1.25rem 2.25rem; +} + +.ranking-summary { + color: #666; + font-size: 0.95rem; + letter-spacing: 0.04em; + margin: 0; + text-transform: uppercase; +} + +#rankings { + display: grid; + gap: 1.25rem; + padding: 0 2.25rem 2.25rem 2.25rem; +} + +.ranking-card { + background: #f9fbfd; + border: 1px solid rgba(16, 16, 16, 0.08); + border-radius: 1rem; + box-shadow: 0 1.5rem 3rem rgba(16, 16, 16, 0.08); + display: grid; + gap: 1.25rem; + grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr); + overflow: hidden; + position: relative; +} + +.ranking-card__placement { + background: #101010; + border-radius: 999px; + color: #ffffff; + font-size: 0.8rem; + left: 1rem; + letter-spacing: 0.08em; + margin: 0; + padding: 0.45rem 0.8rem; + position: absolute; + text-transform: uppercase; + top: 1rem; + z-index: 1; +} + +.ranking-card__thumbnail { + border-bottom: 0; + display: block; +} + +.ranking-card__thumbnail img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.ranking-card__body { + display: flex; + flex-direction: column; + gap: 0.85rem; + justify-content: center; + min-width: 0; + padding: 1.5rem 1.5rem 1.5rem 0; +} + +.ranking-card__body h2, +.ranking-card__body p { + margin: 0; +} + +.ranking-card__body h2 { + color: #333; + font-size: 1.5rem; +} + +.ranking-card__meta { + color: #00a892; + font-size: 0.8rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +@media screen and (max-width: 980px) { + body.rankings-page #main { + background: rgba(255, 255, 255, 0.96); + } +} + +@media screen and (max-width: 736px) { + body.rankings-page #main { + margin: 0.75rem auto; + width: calc(100% - 1.5rem); + } + + #rankings-summary, + #rankings, + body.rankings-page #header, + body.rankings-page #footer { + padding-left: 1.25rem; + padding-right: 1.25rem; + } + + .ranking-card { + grid-template-columns: 1fr; + } + + .ranking-card__placement { + left: auto; + right: 1rem; + } + + .ranking-card__body { + padding: 0 1.25rem 1.25rem 1.25rem; + } +} diff --git a/data/elo.json b/data/elo.json new file mode 100644 index 0000000..833cdfb --- /dev/null +++ b/data/elo.json @@ -0,0 +1,204 @@ +{ + "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 + } + ] +} diff --git a/index.html b/index.html index 2557b15..06b05b6 100755 --- a/index.html +++ b/index.html @@ -29,6 +29,11 @@ meow

for vham :3

Please enable javascript >.<

+ @@ -131,7 +136,7 @@

sul and beans

-

sweet treat -> claire dropping the most insane piece of information ever -> hti the yap

+

sweet treat -> claire dropping the most insane piece of information ever -> hti the yap

diff --git a/rankings.html b/rankings.html new file mode 100644 index 0000000..d7e8c4b --- /dev/null +++ b/rankings.html @@ -0,0 +1,355 @@ + + + + + food rankings + + + + + + + + + + + + + + + +
+ + + + + +
+

33 meals seeded at Elo 1,000 until head-to-head voting starts.

+
+ + +
+
+

#1

+ sf on $10 thumbnail +
+

sf on $10

+

Elo 1,000 | no votes yet

+

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

+
+
+
+

#2

+ honey butter chicken thumbnail +
+

honey butter chicken

+

Elo 1,000 | no votes yet

+

the first thing you cooked for me ! so yum 10/10

+
+
+
+

#3

+ aloha fresh thumbnail +
+

aloha fresh

+

Elo 1,000 | no votes yet

+

we fucking love this place 10/10 i love poke i should have never quit pokehouse

+
+
+
+

#4

+ mad yolks thumbnail +
+

mad yolks

+

Elo 1,000 | no votes yet

+

for our santa cruz hang! u in my city now. so so good but lwk so so tax 9/10

+
+
+
+

#5

+ sizzling lunch thumbnail +
+

sizzling lunch

+

Elo 1,000 | no votes yet

+

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

+
+
+
+

#6

+ braised pork belly thumbnail +
+

braised pork belly

+

Elo 1,000 | no votes yet

+

omfg this is my favorite thing uve made 100/10

+
+
+
+

#7

+ sushi w/ claire! thumbnail +
+

sushi w/ claire!

+

Elo 1,000 | no votes yet

+

and then we played bananagrams. sushi 8/10 thanks for paying mommy

+
+
+
+

#8

+ myungrang hot dog thumbnail +
+

myungrang hot dog

+

Elo 1,000 | no votes yet

+

main street tino nothing special 7/10

+
+
+
+

#9

+ liangs village thumbnail +
+

liangs village

+

Elo 1,000 | no votes yet

+

my peoples food. 9/10

+
+
+
+

#10

+ cabonara thumbnail +
+

cabonara

+

Elo 1,000 | no votes yet

+

insane safeway hang 9/10

+
+
+
+

#11

+ heytea thumbnail +
+

heytea

+

Elo 1,000 | no votes yet

+

this fuckass blue drink

+
+
+
+

#12

+ sparcos thumbnail +
+

sparcos

+

Elo 1,000 | no votes yet

+

one of many.. 100/10

+
+
+
+

#13

+ noahs bagels thumbnail +
+

noahs bagels

+

Elo 1,000 | no votes yet

+

this is the plaza where i used to go to all the time before school 9/10

+
+
+
+

#14

+ homeroom thumbnail +
+

homeroom

+

Elo 1,000 | no votes yet

+

mac and cheese was gas. 10/10. you know its my fav comfort food. free the girl crying in the corner tho

+
+
+
+

#15

+ sparcos x2 thumbnail +
+

sparcos x2

+

Elo 1,000 | no votes yet

+

spartan tacos

+
+
+
+

#16

+ sparcos x3 thumbnail +
+

sparcos x3

+

Elo 1,000 | no votes yet

+

okay damn no way we got this b2b

+
+
+
+

#17

+ aloha fresh thumbnail +
+

aloha fresh

+

Elo 1,000 | no votes yet

+

this is lowkey the spot poke always hits so fucking good

+
+
+
+

#18

+ house of bagels thumbnail +
+

house of bagels

+

Elo 1,000 | no votes yet

+

hobags ughgmmfmfm im such a fucking ho for hobags 100/10

+
+
+
+

#19

+ toro sushi thumbnail +
+

toro sushi

+

Elo 1,000 | no votes yet

+

carmel by the sea! we love sushi but tax 8/10

+
+
+
+

#20

+ sul and beans thumbnail +
+

sul and beans

+

Elo 1,000 | no votes yet

+

sweet treat -> claire dropping the most insane piece of information ever -> hti the yap

+
+
+
+

#21

+ highland hand pulled noodles thumbnail +
+

highland hand pulled noodles

+

Elo 1,000 | no votes yet

+

so good and soooo filling 10/10. also my peoples food.

+
+
+
+

#22

+ bloom thumbnail +
+

bloom

+

Elo 1,000 | no votes yet

+

even when its rich white people breakfast im getting salmon nox 10/10

+
+
+
+

#23

+ happy donuts thumbnail +
+

happy donuts

+

Elo 1,000 | no votes yet

+

1k cal meal 0 protein 10/10

+
+
+
+

#24

+ marugame thumbnail +
+

marugame

+

Elo 1,000 | no votes yet

+

i dont want to talk about this. 9/10

+
+
+
+

#25

+ siam station! thumbnail +
+

siam station!

+

Elo 1,000 | no votes yet

+

i can't believe u didnt eat ur leftovers. 10/10

+
+
+
+

#26

+ muukata 6395 thumbnail +
+

muukata 6395

+

Elo 1,000 | no votes yet

+

for my birthday!! i love eating meat and i love you so perfect combination 1000/10

+
+
+
+

#27

+ bambu thumbnail +
+

bambu

+

Elo 1,000 | no votes yet

+

why was the store so nice. i wonder about the 4 sisters

+
+
+
+

#28

+ porridge at julias thumbnail +
+

porridge at julias

+

Elo 1,000 | no votes yet

+

her boyfriend is so not real hes so stupid and funny. porridge was gas too i love eating free at julias 10/10

+
+
+
+

#29

+ sparcos x4 thumbnail +
+

sparcos x4

+

Elo 1,000 | no votes yet

+

spartan tacos. i was moody lol.

+
+
+
+

#30

+ wonton udon thumbnail +
+

wonton udon

+

Elo 1,000 | no votes yet

+

i helped wrap the wontons w/ u !!! super fun and super yummy 10/10

+
+
+
+

#31

+ steak dinna for vday thumbnail +
+

steak dinna for vday

+

Elo 1,000 | no votes yet

+

marry me? yes. 100/10 best valentines day ever

+
+
+
+

#32

+ poke house thumbnail +
+

poke house

+

Elo 1,000 | no votes yet

+

poke house 3 years later 9/10 but +1 point bc its basically free

+
+
+
+

#33

+ hey tea thumbnail +
+

hey tea

+

Elo 1,000 | no votes yet

+

mochi yinje black milk tea ts was actually so buss 10/10 only boba i've ever wanted to get again myself

+
+
+
+ + +
+ + nyaa +
+ +
+ + diff --git a/scripts/build.js b/scripts/build.js index 9a06b6d..ad4959c 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -1,18 +1,25 @@ const fs = require("fs"); const path = require("path"); +const { getRankedMeals, syncEloWithMeals } = require("./lib/elo"); const { loadMeals, repoRoot } = require("./lib/meals"); const indexTemplatePath = path.join(repoRoot, "templates", "index.html"); const indexOutputPath = path.join(repoRoot, "index.html"); +const rankingsTemplatePath = path.join(repoRoot, "templates", "rankings.html"); +const rankingsOutputPath = path.join(repoRoot, "rankings.html"); +const ratingFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, +}); function detectEol(text) { return text.includes("\r\n") ? "\r\n" : "\n"; } function escapeHtml(value) { - return value + return String(value) .replace(/&/g, "&") + .replace(/>/g, ">") .replace(/ renderGalleryItem(meal, eol)).join(eol); } +function formatRating(rating) { + return ratingFormatter.format(rating); +} + +function renderRankingSummary(meals, eloData) { + const mealLabel = meals.length === 1 ? "meal" : "meals"; + + return `\t\t\t\t

${meals.length} ${mealLabel} seeded at Elo ${formatRating( + eloData.defaultRating + )} until head-to-head voting starts.

`; +} + +function renderRankingMeta(rankedMeal) { + const ratingText = `Elo ${formatRating(rankedMeal.rating)}`; + + if (rankedMeal.matches === 0) { + return `${ratingText} | no votes yet`; + } + + const matchLabel = rankedMeal.matches === 1 ? "match" : "matches"; + return `${ratingText} | ${rankedMeal.wins}-${rankedMeal.losses} record across ${ + rankedMeal.matches + } ${matchLabel}`; +} + +function renderRankingItem(rankedMeal, placement, eol) { + return [ + '\t\t\t\t
', + `\t\t\t\t\t

#${placement}

`, + `\t\t\t\t\t${escapeHtml(
+      `${rankedMeal.title} thumbnail`
+    )}`, + '\t\t\t\t\t
', + `\t\t\t\t\t\t

${escapeHtml(rankedMeal.title)}

`, + `\t\t\t\t\t\t

${escapeHtml(renderRankingMeta(rankedMeal))}

`, + `\t\t\t\t\t\t

${escapeHtml(rankedMeal.description)}

`, + "\t\t\t\t\t
", + "\t\t\t\t
", + ].join(eol); +} + +function renderRankings(rankedMeals, eol) { + return rankedMeals + .map((rankedMeal, index) => renderRankingItem(rankedMeal, index + 1, eol)) + .join(eol); +} + function replaceBlock(template, token, replacement) { const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m"); @@ -47,20 +101,39 @@ function replaceBlock(template, token, replacement) { return template.replace(pattern, () => replacement); } -function buildIndex() { +function buildIndex(meals = loadMeals()) { const template = fs.readFileSync(indexTemplatePath, "utf8"); const eol = detectEol(template); - const meals = loadMeals(); return replaceBlock(template, "gallery_items", renderGallery(meals, eol)); } +function buildRankings( + meals = loadMeals(), + eloData = syncEloWithMeals(meals) +) { + const template = fs.readFileSync(rankingsTemplatePath, "utf8"); + const eol = detectEol(template); + const rankedMeals = getRankedMeals(meals, eloData); + const withSummary = replaceBlock( + template, + "ranking_summary", + renderRankingSummary(meals, eloData) + ); + + return replaceBlock(withSummary, "ranking_items", renderRankings(rankedMeals, eol)); +} + function writeFile(filePath, contents) { fs.writeFileSync(filePath, contents); } function main() { - writeFile(indexOutputPath, buildIndex()); + const meals = loadMeals(); + const eloData = syncEloWithMeals(meals); + + writeFile(indexOutputPath, buildIndex(meals)); + writeFile(rankingsOutputPath, buildRankings(meals, eloData)); } function buildPages() { @@ -74,4 +147,5 @@ if (require.main === module) { module.exports = { buildPages, buildIndex, + buildRankings, }; diff --git a/scripts/ingest-meal.js b/scripts/ingest-meal.js index baac156..8dc4912 100644 --- a/scripts/ingest-meal.js +++ b/scripts/ingest-meal.js @@ -4,6 +4,7 @@ const sharp = require("sharp"); const { buildPages } = require("./build"); const { buildThumbnails } = require("./generate-thumbnails"); +const { eloPath } = require("./lib/elo"); const { getNextMealId, loadMeals, @@ -15,6 +16,7 @@ const { const fullsDir = path.join(repoRoot, "images", "fulls"); const thumbsDir = path.join(repoRoot, "images", "thumbs"); const indexPath = path.join(repoRoot, "index.html"); +const rankingsPath = path.join(repoRoot, "rankings.html"); const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json"); const FULL_IMAGE_QUALITY = 90; @@ -123,28 +125,45 @@ async function writeFullImage(sourcePath, destinationPath) { .toFile(destinationPath); } +function readOptionalFile(filePath) { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null; +} + +async function restoreOptionalFile(filePath, previousContents) { + if (previousContents === null) { + if (fs.existsSync(filePath)) { + await fs.promises.unlink(filePath); + } + + return; + } + + if (previousContents !== undefined) { + fs.writeFileSync(filePath, previousContents); + } +} + async function rollback({ createdFullPath, createdThumbPath, + previousElo, previousIndex, previousManifest, previousMeals, + previousRankings, }) { if (previousMeals !== undefined) { fs.writeFileSync(mealsPath, previousMeals); } + await restoreOptionalFile(eloPath, previousElo); + if (previousIndex !== undefined) { fs.writeFileSync(indexPath, previousIndex); } - if (previousManifest === null) { - if (fs.existsSync(manifestPath)) { - await fs.promises.unlink(manifestPath); - } - } else if (previousManifest !== undefined) { - fs.writeFileSync(manifestPath, previousManifest); - } + await restoreOptionalFile(rankingsPath, previousRankings); + await restoreOptionalFile(manifestPath, previousManifest); if (createdThumbPath && fs.existsSync(createdThumbPath)) { await fs.promises.unlink(createdThumbPath); @@ -169,10 +188,10 @@ async function ingestMeal(options) { } const previousMeals = fs.readFileSync(mealsPath, "utf8"); + const previousElo = readOptionalFile(eloPath); const previousIndex = fs.readFileSync(indexPath, "utf8"); - const previousManifest = fs.existsSync(manifestPath) - ? fs.readFileSync(manifestPath, "utf8") - : null; + const previousRankings = readOptionalFile(rankingsPath); + const previousManifest = readOptionalFile(manifestPath); await fs.promises.mkdir(fullsDir, { recursive: true }); @@ -185,9 +204,11 @@ async function ingestMeal(options) { await rollback({ createdFullPath: fullPath, createdThumbPath: thumbPath, + previousElo, previousIndex, previousManifest, previousMeals, + previousRankings, }); throw error; } diff --git a/scripts/lib/elo.js b/scripts/lib/elo.js new file mode 100644 index 0000000..47ca9e0 --- /dev/null +++ b/scripts/lib/elo.js @@ -0,0 +1,139 @@ +const fs = require("fs"); +const path = require("path"); + +const { repoRoot } = require("./meals"); + +const eloPath = path.join(repoRoot, "data", "elo.json"); + +function validateEloData(eloData) { + if (!eloData || typeof eloData !== "object" || Array.isArray(eloData)) { + throw new Error("data/elo.json must contain an object"); + } + + for (const field of ["defaultRating", "kFactor"]) { + if ( + typeof eloData[field] !== "number" || + !Number.isFinite(eloData[field]) || + eloData[field] <= 0 + ) { + throw new Error(`data/elo.json "${field}" must be a positive number`); + } + } + + if (!Array.isArray(eloData.entries)) { + throw new Error('data/elo.json "entries" must be an array'); + } + + const ids = new Set(); + + for (const [index, entry] of eloData.entries.entries()) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`Elo entry ${index} must be an object`); + } + + if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) { + throw new Error(`Elo entry ${index} has an invalid id`); + } + + if (ids.has(entry.id)) { + throw new Error(`Duplicate Elo entry id "${entry.id}" found in data/elo.json`); + } + + ids.add(entry.id); + + if ( + typeof entry.rating !== "number" || + !Number.isFinite(entry.rating) || + entry.rating <= 0 + ) { + throw new Error(`Elo entry ${index} must have a positive numeric rating`); + } + + for (const field of ["wins", "losses"]) { + if (!Number.isInteger(entry[field]) || entry[field] < 0) { + throw new Error(`Elo entry ${index} field "${field}" must be a non-negative integer`); + } + } + } +} + +function loadEloData() { + const eloData = JSON.parse(fs.readFileSync(eloPath, "utf8")); + + validateEloData(eloData); + + return eloData; +} + +function saveEloData(eloData) { + validateEloData(eloData); + fs.writeFileSync(eloPath, `${JSON.stringify(eloData, null, 2)}\n`); +} + +function createDefaultEntry(id, defaultRating) { + return { + id, + rating: defaultRating, + wins: 0, + losses: 0, + }; +} + +function syncEloWithMeals(meals) { + const eloData = loadEloData(); + const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry])); + const syncedData = { + ...eloData, + entries: meals.map((meal) => { + const existingEntry = entryById.get(meal.id); + return existingEntry + ? { ...existingEntry } + : createDefaultEntry(meal.id, eloData.defaultRating); + }), + }; + + if (JSON.stringify(syncedData) !== JSON.stringify(eloData)) { + saveEloData(syncedData); + } + + return syncedData; +} + +function compareRankedMeals(left, right) { + if (right.rating !== left.rating) { + return right.rating - left.rating; + } + + if (right.matches !== left.matches) { + return right.matches - left.matches; + } + + 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); + const matches = entry.wins + entry.losses; + + return { + ...meal, + rating: entry.rating, + wins: entry.wins, + losses: entry.losses, + matches, + }; + }) + .sort(compareRankedMeals); +} + +module.exports = { + eloPath, + getRankedMeals, + loadEloData, + saveEloData, + syncEloWithMeals, +}; diff --git a/templates/index.html b/templates/index.html index c481725..3d5900c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -29,6 +29,11 @@ meow

for vham :3

Please enable javascript >.<

+ diff --git a/templates/rankings.html b/templates/rankings.html new file mode 100644 index 0000000..9c756d8 --- /dev/null +++ b/templates/rankings.html @@ -0,0 +1,59 @@ + + + + + food rankings + + + + + + + + + + + + + + + +
+ + + + + +
+ {{ranking_summary}} +
+ + +
+ {{ranking_items}} +
+ + + + +
+ +