add: elo data model and static rankings page
All checks were successful
Deploy on push / deploy (push) Has been skipped

This commit is contained in:
2026-03-22 20:18:28 -07:00
parent 8f9a7eda2f
commit 26adbe617f
11 changed files with 1071 additions and 18 deletions

View File

@@ -7,19 +7,23 @@ The site is based on the HTML5 UP Lens template and currently ships as a plain s
## Repo Layout ## Repo Layout
- `templates/index.html`: source template for the main gallery page - `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 - `index.html`: generated static gallery page
- `rankings.html`: generated static rankings page
- `assets/`: site CSS, JavaScript, fonts, and audio - `assets/`: site CSS, JavaScript, fonts, and audio
- `images/fulls/`: full-size gallery images - `images/fulls/`: full-size gallery images
- `images/thumbs/`: gallery thumbnails - `images/thumbs/`: gallery thumbnails
- `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
- `scripts/build.js`: renders static pages from templates and data - `scripts/build.js`: renders static pages from templates and data
- `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/lib/elo.js`: validates and syncs Elo data against the meal list
- `package.json`: minimal Node build entrypoint - `package.json`: minimal Node build entrypoint
## Content Workflow ## 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: 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 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: 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 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 ## Image Conventions
- Full-size images and thumbnails share the same numeric ID - 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 ## 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. 2. General cleanup and history cleanup once the bigger structural changes are in place.

View File

@@ -3,6 +3,7 @@
width: 100px; width: 100px;
height: auto; height: auto;
} }
#giftwo { #giftwo {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
@@ -11,3 +12,24 @@
width: 100px; width: 100px;
height: auto; 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;
}

160
assets/css/rankings.css Normal file
View File

@@ -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;
}
}

204
data/elo.json Normal file
View File

@@ -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
}
]
}

View File

@@ -29,6 +29,11 @@
<img src="images/meow.gif" alt="meow" id="gifone"> <img src="images/meow.gif" alt="meow" id="gifone">
<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">
<a href="index.html" aria-current="page">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html">rankings</a>
</p>
</header> </header>
<!-- Thumbnail --> <!-- Thumbnail -->
@@ -131,7 +136,7 @@
<article> <article>
<a class="thumbnail" href="images/fulls/20.jpg"><img src="images/thumbs/20.jpg" alt="" /></a> <a class="thumbnail" href="images/fulls/20.jpg"><img src="images/thumbs/20.jpg" alt="" /></a>
<h2>sul and beans</h2> <h2>sul and beans</h2>
<p>sweet treat -> claire dropping the most insane piece of information ever -> hti the yap</p> <p>sweet treat -&gt; claire dropping the most insane piece of information ever -&gt; hti the yap</p>
</article> </article>
<article> <article>
<a class="thumbnail" href="images/fulls/21.jpg"><img src="images/thumbs/21.jpg" alt="" /></a> <a class="thumbnail" href="images/fulls/21.jpg"><img src="images/thumbs/21.jpg" alt="" /></a>

355
rankings.html Normal file
View File

@@ -0,0 +1,355 @@
<!DOCTYPE HTML>
<!--
Lens by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
-->
<html class="rankings-html">
<head>
<title>food rankings</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="assets/css/main.css" />
<link rel="stylesheet" href="assets/css/nyaa.css" />
<link rel="stylesheet" href="assets/css/rankings.css" />
<noscript><link rel="stylesheet" href="assets/css/noscript.css" /></noscript>
<link rel="apple-touch-icon" sizes="180x180" href="../favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../favicon/favicon-16x16.png">
<link rel="manifest" href="../favicon/site.webmanifest">
</head>
<body class="rankings-page">
<!-- Main -->
<div id="main">
<!-- Header -->
<header id="header">
<img src="images/meow.gif" alt="meow" id="gifone">
<h1>food power rankings</h1>
<p>static Elo seeds for every meal before the head-to-head voting page exists.</p>
<p class="page-links">
<a href="index.html">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html" aria-current="page">rankings</a>
</p>
</header>
<!-- Rankings Summary -->
<section id="rankings-summary">
<p class="ranking-summary">33 meals seeded at Elo 1,000 until head-to-head voting starts.</p>
</section>
<!-- Rankings -->
<section id="rankings">
<article class="ranking-card">
<p class="ranking-card__placement">#1</p>
<a class="ranking-card__thumbnail" href="images/fulls/01.jpg"><img src="images/thumbs/01.jpg" alt="sf on $10 thumbnail" /></a>
<div class="ranking-card__body">
<h2>sf on $10</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>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</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#2</p>
<a class="ranking-card__thumbnail" href="images/fulls/02.jpg"><img src="images/thumbs/02.jpg" alt="honey butter chicken thumbnail" /></a>
<div class="ranking-card__body">
<h2>honey butter chicken</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>the first thing you cooked for me ! so yum 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#3</p>
<a class="ranking-card__thumbnail" href="images/fulls/03.jpg"><img src="images/thumbs/03.jpg" alt="aloha fresh thumbnail" /></a>
<div class="ranking-card__body">
<h2>aloha fresh</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>we fucking love this place 10/10 i love poke i should have never quit pokehouse</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#4</p>
<a class="ranking-card__thumbnail" href="images/fulls/04.jpg"><img src="images/thumbs/04.jpg" alt="mad yolks thumbnail" /></a>
<div class="ranking-card__body">
<h2>mad yolks</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>for our santa cruz hang! u in my city now. so so good but lwk so so tax 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#5</p>
<a class="ranking-card__thumbnail" href="images/fulls/05.jpg"><img src="images/thumbs/05.jpg" alt="sizzling lunch thumbnail" /></a>
<div class="ranking-card__body">
<h2>sizzling lunch</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>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</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#6</p>
<a class="ranking-card__thumbnail" href="images/fulls/06.jpg"><img src="images/thumbs/06.jpg" alt="braised pork belly thumbnail" /></a>
<div class="ranking-card__body">
<h2>braised pork belly</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>omfg this is my favorite thing uve made 100/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#7</p>
<a class="ranking-card__thumbnail" href="images/fulls/07.jpg"><img src="images/thumbs/07.jpg" alt="sushi w/ claire! thumbnail" /></a>
<div class="ranking-card__body">
<h2>sushi w/ claire!</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>and then we played bananagrams. sushi 8/10 thanks for paying mommy</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#8</p>
<a class="ranking-card__thumbnail" href="images/fulls/08.jpg"><img src="images/thumbs/08.jpg" alt="myungrang hot dog thumbnail" /></a>
<div class="ranking-card__body">
<h2>myungrang hot dog</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>main street tino nothing special 7/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#9</p>
<a class="ranking-card__thumbnail" href="images/fulls/09.jpg"><img src="images/thumbs/09.jpg" alt="liangs village thumbnail" /></a>
<div class="ranking-card__body">
<h2>liangs village</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>my peoples food. 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#10</p>
<a class="ranking-card__thumbnail" href="images/fulls/10.jpg"><img src="images/thumbs/10.jpg" alt="cabonara thumbnail" /></a>
<div class="ranking-card__body">
<h2>cabonara</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>insane safeway hang 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#11</p>
<a class="ranking-card__thumbnail" href="images/fulls/11.jpg"><img src="images/thumbs/11.jpg" alt="heytea thumbnail" /></a>
<div class="ranking-card__body">
<h2>heytea</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>this fuckass blue drink</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#12</p>
<a class="ranking-card__thumbnail" href="images/fulls/12.jpg"><img src="images/thumbs/12.jpg" alt="sparcos thumbnail" /></a>
<div class="ranking-card__body">
<h2>sparcos</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>one of many.. 100/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#13</p>
<a class="ranking-card__thumbnail" href="images/fulls/13.jpg"><img src="images/thumbs/13.jpg" alt="noahs bagels thumbnail" /></a>
<div class="ranking-card__body">
<h2>noahs bagels</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>this is the plaza where i used to go to all the time before school 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#14</p>
<a class="ranking-card__thumbnail" href="images/fulls/14.jpg"><img src="images/thumbs/14.jpg" alt="homeroom thumbnail" /></a>
<div class="ranking-card__body">
<h2>homeroom</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>mac and cheese was gas. 10/10. you know its my fav comfort food. free the girl crying in the corner tho</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#15</p>
<a class="ranking-card__thumbnail" href="images/fulls/15.jpg"><img src="images/thumbs/15.jpg" alt="sparcos x2 thumbnail" /></a>
<div class="ranking-card__body">
<h2>sparcos x2</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>spartan tacos</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#16</p>
<a class="ranking-card__thumbnail" href="images/fulls/16.jpg"><img src="images/thumbs/16.jpg" alt="sparcos x3 thumbnail" /></a>
<div class="ranking-card__body">
<h2>sparcos x3</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>okay damn no way we got this b2b</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#17</p>
<a class="ranking-card__thumbnail" href="images/fulls/17.jpg"><img src="images/thumbs/17.jpg" alt="aloha fresh thumbnail" /></a>
<div class="ranking-card__body">
<h2>aloha fresh</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>this is lowkey the spot poke always hits so fucking good</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#18</p>
<a class="ranking-card__thumbnail" href="images/fulls/18.jpg"><img src="images/thumbs/18.jpg" alt="house of bagels thumbnail" /></a>
<div class="ranking-card__body">
<h2>house of bagels</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>hobags ughgmmfmfm im such a fucking ho for hobags 100/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#19</p>
<a class="ranking-card__thumbnail" href="images/fulls/19.jpg"><img src="images/thumbs/19.jpg" alt="toro sushi thumbnail" /></a>
<div class="ranking-card__body">
<h2>toro sushi</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>carmel by the sea! we love sushi but tax 8/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#20</p>
<a class="ranking-card__thumbnail" href="images/fulls/20.jpg"><img src="images/thumbs/20.jpg" alt="sul and beans thumbnail" /></a>
<div class="ranking-card__body">
<h2>sul and beans</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>sweet treat -&gt; claire dropping the most insane piece of information ever -&gt; hti the yap</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#21</p>
<a class="ranking-card__thumbnail" href="images/fulls/21.jpg"><img src="images/thumbs/21.jpg" alt="highland hand pulled noodles thumbnail" /></a>
<div class="ranking-card__body">
<h2>highland hand pulled noodles</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>so good and soooo filling 10/10. also my peoples food.</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#22</p>
<a class="ranking-card__thumbnail" href="images/fulls/22.jpg"><img src="images/thumbs/22.jpg" alt="bloom thumbnail" /></a>
<div class="ranking-card__body">
<h2>bloom</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>even when its rich white people breakfast im getting salmon nox 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#23</p>
<a class="ranking-card__thumbnail" href="images/fulls/23.jpg"><img src="images/thumbs/23.jpg" alt="happy donuts thumbnail" /></a>
<div class="ranking-card__body">
<h2>happy donuts</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>1k cal meal 0 protein 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#24</p>
<a class="ranking-card__thumbnail" href="images/fulls/24.jpg"><img src="images/thumbs/24.jpg" alt="marugame thumbnail" /></a>
<div class="ranking-card__body">
<h2>marugame</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>i dont want to talk about this. 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#25</p>
<a class="ranking-card__thumbnail" href="images/fulls/25.jpg"><img src="images/thumbs/25.jpg" alt="siam station! thumbnail" /></a>
<div class="ranking-card__body">
<h2>siam station!</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>i can't believe u didnt eat ur leftovers. 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#26</p>
<a class="ranking-card__thumbnail" href="images/fulls/26.jpg"><img src="images/thumbs/26.jpg" alt="muukata 6395 thumbnail" /></a>
<div class="ranking-card__body">
<h2>muukata 6395</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>for my birthday!! i love eating meat and i love you so perfect combination 1000/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#27</p>
<a class="ranking-card__thumbnail" href="images/fulls/27.jpg"><img src="images/thumbs/27.jpg" alt="bambu thumbnail" /></a>
<div class="ranking-card__body">
<h2>bambu</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>why was the store so nice. i wonder about the 4 sisters</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#28</p>
<a class="ranking-card__thumbnail" href="images/fulls/28.jpg"><img src="images/thumbs/28.jpg" alt="porridge at julias thumbnail" /></a>
<div class="ranking-card__body">
<h2>porridge at julias</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>her boyfriend is so not real hes so stupid and funny. porridge was gas too i love eating free at julias 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#29</p>
<a class="ranking-card__thumbnail" href="images/fulls/29.jpg"><img src="images/thumbs/29.jpg" alt="sparcos x4 thumbnail" /></a>
<div class="ranking-card__body">
<h2>sparcos x4</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>spartan tacos. i was moody lol.</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#30</p>
<a class="ranking-card__thumbnail" href="images/fulls/30.jpg"><img src="images/thumbs/30.jpg" alt="wonton udon thumbnail" /></a>
<div class="ranking-card__body">
<h2>wonton udon</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>i helped wrap the wontons w/ u !!! super fun and super yummy 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#31</p>
<a class="ranking-card__thumbnail" href="images/fulls/31.jpg"><img src="images/thumbs/31.jpg" alt="steak dinna for vday thumbnail" /></a>
<div class="ranking-card__body">
<h2>steak dinna for vday</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>marry me? yes. 100/10 best valentines day ever</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#32</p>
<a class="ranking-card__thumbnail" href="images/fulls/32.jpg"><img src="images/thumbs/32.jpg" alt="poke house thumbnail" /></a>
<div class="ranking-card__body">
<h2>poke house</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>poke house 3 years later 9/10 but +1 point bc its basically free</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#33</p>
<a class="ranking-card__thumbnail" href="images/fulls/33.jpg"><img src="images/thumbs/33.jpg" alt="hey tea thumbnail" /></a>
<div class="ranking-card__body">
<h2>hey tea</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>mochi yinje black milk tea ts was actually so buss 10/10 only boba i've ever wanted to get again myself</p>
</div>
</article>
</section>
<!-- Footer -->
<footer id="footer">
<ul class="copyright">
<li>&copy; Ryan Chou. 2026.</li>
</ul>
<img src="images/nyaa.gif" alt="nyaa" id="giftwo">
</footer>
</div>
</body>
</html>

View File

@@ -1,18 +1,25 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { getRankedMeals, syncEloWithMeals } = require("./lib/elo");
const { loadMeals, repoRoot } = require("./lib/meals"); const { loadMeals, repoRoot } = 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");
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) { function detectEol(text) {
return text.includes("\r\n") ? "\r\n" : "\n"; return text.includes("\r\n") ? "\r\n" : "\n";
} }
function escapeHtml(value) { function escapeHtml(value) {
return value return String(value)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/>/g, "&gt;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
@@ -37,6 +44,53 @@ function renderGallery(meals, eol) {
return meals.map((meal) => renderGalleryItem(meal, eol)).join(eol); return meals.map((meal) => 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<p class="ranking-summary">${meals.length} ${mealLabel} seeded at Elo ${formatRating(
eloData.defaultRating
)} until head-to-head voting starts.</p>`;
}
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<article class="ranking-card">',
`\t\t\t\t\t<p class="ranking-card__placement">#${placement}</p>`,
`\t\t\t\t\t<a class="ranking-card__thumbnail" href="images/fulls/${rankedMeal.id}.jpg"><img src="images/thumbs/${rankedMeal.id}.jpg" alt="${escapeHtml(
`${rankedMeal.title} thumbnail`
)}" /></a>`,
'\t\t\t\t\t<div class="ranking-card__body">',
`\t\t\t\t\t\t<h2>${escapeHtml(rankedMeal.title)}</h2>`,
`\t\t\t\t\t\t<p class="ranking-card__meta">${escapeHtml(renderRankingMeta(rankedMeal))}</p>`,
`\t\t\t\t\t\t<p>${escapeHtml(rankedMeal.description)}</p>`,
"\t\t\t\t\t</div>",
"\t\t\t\t</article>",
].join(eol);
}
function renderRankings(rankedMeals, eol) {
return rankedMeals
.map((rankedMeal, index) => renderRankingItem(rankedMeal, index + 1, eol))
.join(eol);
}
function replaceBlock(template, token, replacement) { function replaceBlock(template, token, replacement) {
const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m"); const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m");
@@ -47,20 +101,39 @@ function replaceBlock(template, token, replacement) {
return template.replace(pattern, () => replacement); return template.replace(pattern, () => replacement);
} }
function buildIndex() { function buildIndex(meals = loadMeals()) {
const template = fs.readFileSync(indexTemplatePath, "utf8"); const template = fs.readFileSync(indexTemplatePath, "utf8");
const eol = detectEol(template); const eol = detectEol(template);
const meals = loadMeals();
return replaceBlock(template, "gallery_items", renderGallery(meals, eol)); 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) { function writeFile(filePath, contents) {
fs.writeFileSync(filePath, contents); fs.writeFileSync(filePath, contents);
} }
function main() { function main() {
writeFile(indexOutputPath, buildIndex()); const meals = loadMeals();
const eloData = syncEloWithMeals(meals);
writeFile(indexOutputPath, buildIndex(meals));
writeFile(rankingsOutputPath, buildRankings(meals, eloData));
} }
function buildPages() { function buildPages() {
@@ -74,4 +147,5 @@ if (require.main === module) {
module.exports = { module.exports = {
buildPages, buildPages,
buildIndex, buildIndex,
buildRankings,
}; };

View File

@@ -4,6 +4,7 @@ const sharp = require("sharp");
const { buildPages } = require("./build"); const { buildPages } = require("./build");
const { buildThumbnails } = require("./generate-thumbnails"); const { buildThumbnails } = require("./generate-thumbnails");
const { eloPath } = require("./lib/elo");
const { const {
getNextMealId, getNextMealId,
loadMeals, loadMeals,
@@ -15,6 +16,7 @@ const {
const fullsDir = path.join(repoRoot, "images", "fulls"); const fullsDir = path.join(repoRoot, "images", "fulls");
const thumbsDir = path.join(repoRoot, "images", "thumbs"); const thumbsDir = path.join(repoRoot, "images", "thumbs");
const indexPath = path.join(repoRoot, "index.html"); const indexPath = path.join(repoRoot, "index.html");
const rankingsPath = path.join(repoRoot, "rankings.html");
const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json"); const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json");
const FULL_IMAGE_QUALITY = 90; const FULL_IMAGE_QUALITY = 90;
@@ -123,28 +125,45 @@ async function writeFullImage(sourcePath, destinationPath) {
.toFile(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({ async function rollback({
createdFullPath, createdFullPath,
createdThumbPath, createdThumbPath,
previousElo,
previousIndex, previousIndex,
previousManifest, previousManifest,
previousMeals, previousMeals,
previousRankings,
}) { }) {
if (previousMeals !== undefined) { if (previousMeals !== undefined) {
fs.writeFileSync(mealsPath, previousMeals); fs.writeFileSync(mealsPath, previousMeals);
} }
await restoreOptionalFile(eloPath, previousElo);
if (previousIndex !== undefined) { if (previousIndex !== undefined) {
fs.writeFileSync(indexPath, previousIndex); fs.writeFileSync(indexPath, previousIndex);
} }
if (previousManifest === null) { await restoreOptionalFile(rankingsPath, previousRankings);
if (fs.existsSync(manifestPath)) { await restoreOptionalFile(manifestPath, previousManifest);
await fs.promises.unlink(manifestPath);
}
} else if (previousManifest !== undefined) {
fs.writeFileSync(manifestPath, previousManifest);
}
if (createdThumbPath && fs.existsSync(createdThumbPath)) { if (createdThumbPath && fs.existsSync(createdThumbPath)) {
await fs.promises.unlink(createdThumbPath); await fs.promises.unlink(createdThumbPath);
@@ -169,10 +188,10 @@ async function ingestMeal(options) {
} }
const previousMeals = fs.readFileSync(mealsPath, "utf8"); const previousMeals = fs.readFileSync(mealsPath, "utf8");
const previousElo = readOptionalFile(eloPath);
const previousIndex = fs.readFileSync(indexPath, "utf8"); const previousIndex = fs.readFileSync(indexPath, "utf8");
const previousManifest = fs.existsSync(manifestPath) const previousRankings = readOptionalFile(rankingsPath);
? fs.readFileSync(manifestPath, "utf8") const previousManifest = readOptionalFile(manifestPath);
: null;
await fs.promises.mkdir(fullsDir, { recursive: true }); await fs.promises.mkdir(fullsDir, { recursive: true });
@@ -185,9 +204,11 @@ async function ingestMeal(options) {
await rollback({ await rollback({
createdFullPath: fullPath, createdFullPath: fullPath,
createdThumbPath: thumbPath, createdThumbPath: thumbPath,
previousElo,
previousIndex, previousIndex,
previousManifest, previousManifest,
previousMeals, previousMeals,
previousRankings,
}); });
throw error; throw error;
} }

139
scripts/lib/elo.js Normal file
View File

@@ -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,
};

View File

@@ -29,6 +29,11 @@
<img src="images/meow.gif" alt="meow" id="gifone"> <img src="images/meow.gif" alt="meow" id="gifone">
<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">
<a href="index.html" aria-current="page">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html">rankings</a>
</p>
</header> </header>
<!-- Thumbnail --> <!-- Thumbnail -->

59
templates/rankings.html Normal file
View File

@@ -0,0 +1,59 @@
<!DOCTYPE HTML>
<!--
Lens by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
-->
<html class="rankings-html">
<head>
<title>food rankings</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="assets/css/main.css" />
<link rel="stylesheet" href="assets/css/nyaa.css" />
<link rel="stylesheet" href="assets/css/rankings.css" />
<noscript><link rel="stylesheet" href="assets/css/noscript.css" /></noscript>
<link rel="apple-touch-icon" sizes="180x180" href="../favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../favicon/favicon-16x16.png">
<link rel="manifest" href="../favicon/site.webmanifest">
</head>
<body class="rankings-page">
<!-- Main -->
<div id="main">
<!-- Header -->
<header id="header">
<img src="images/meow.gif" alt="meow" id="gifone">
<h1>food power rankings</h1>
<p>static Elo seeds for every meal before the head-to-head voting page exists.</p>
<p class="page-links">
<a href="index.html">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html" aria-current="page">rankings</a>
</p>
</header>
<!-- Rankings Summary -->
<section id="rankings-summary">
{{ranking_summary}}
</section>
<!-- Rankings -->
<section id="rankings">
{{ranking_items}}
</section>
<!-- Footer -->
<footer id="footer">
<ul class="copyright">
<li>&copy; Ryan Chou. 2026.</li>
</ul>
<img src="images/nyaa.gif" alt="nyaa" id="giftwo">
</footer>
</div>
</body>
</html>