add: elo data model and static rankings page
All checks were successful
Deploy on push / deploy (push) Has been skipped
All checks were successful
Deploy on push / deploy (push) Has been skipped
This commit is contained in:
15
README.md
15
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
|
## 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.
|
||||||
|
|||||||
@@ -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
160
assets/css/rankings.css
Normal 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
204
data/elo.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 -> claire dropping the most insane piece of information ever -> 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
355
rankings.html
Normal 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 -> claire dropping the most insane piece of information ever -> 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>© Ryan Chou. 2026.</li>
|
||||||
|
</ul>
|
||||||
|
<img src="images/nyaa.gif" alt="nyaa" id="giftwo">
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
|
.replace(/>/g, ">")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
139
scripts/lib/elo.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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
59
templates/rankings.html
Normal 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>© Ryan Chou. 2026.</li>
|
||||||
|
</ul>
|
||||||
|
<img src="images/nyaa.gif" alt="nyaa" id="giftwo">
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user