310 lines
15 KiB
Markdown
310 lines
15 KiB
Markdown
## Author's Note
|
||
|
||
"author" is a bit of a misnomer because yes, this was completely vibecoded - I don't have a lot of experience in that area, and wanted to see what I could do. It's also a small tool I dont care much about.
|
||
|
||
It was also a very interesting challenge considering the structure of the CMU Userweb system - obviously I can only host static files, so everything had to be in browser. except one thing.
|
||
|
||
I wanted the link to have a fancy embed from the new discord documentation that was released recently (see my cyoa-discord repository for another exploration of this), which would have to be updated daily.
|
||
|
||
To achieve this, I have a somewhat complex Gitea action that has to log into the CMU AFS using sftp, build the new version of the site with the updated date, put it in the www directory, then trigger a Userweb update through their website. Interestingly enough - linux.andrew.cmu.edu is only accessible on-premise. So, instead of going for a complicated VPN route, I have an actions runner on my always-on server on campus. This required some additional logistics - but nothing insane (I have yet to hook up ethernet...).
|
||
|
||
Anyway, all I really care about is if this can be a useful and easy to use tool for people who want to understand the best way to spend their meal dollars. For me, it makes it a lot easier to track throughout the year so I don't end up with extra (or lacking) meal blocks at the end of the semester.
|
||
|
||
# CMU Meal Estimator
|
||
|
||
A small single-page web app that estimates how many **meal blocks** and **FLEX dollars** a
|
||
Carnegie Mellon student should have consumed by a given date — and how many should remain — based
|
||
on their dining plan and time spent on campus.
|
||
|
||
The tool spreads a plan's totals evenly across the days you're expected to be on campus, then
|
||
reports three views of that budget: a **per-week** rate, an amount **consumed so far**, and an
|
||
estimated **remaining** balance, for both meal blocks and FLEX.
|
||
|
||
> Estimates are informational and based on even distribution across on-campus days. They are not
|
||
> official dining balances.
|
||
|
||
## Features
|
||
|
||
- Pick from all eight dining plans (Green, Blue, Red, Yellow, Tartan Flex, Scotty's Choice,
|
||
Whitfield's Favor, Piper Select).
|
||
- "As of" date picker that defaults to today and is clamped to the meal period.
|
||
- Checkboxes for configured breaks ("missing periods") you spent off campus.
|
||
- A numeric input for any additional days you don't want counted.
|
||
- Live-updating results — every input recomputes the estimate instantly.
|
||
- Everything is config-driven: plans, dates, and breaks live in one JSON file that is fetched at
|
||
runtime. Edit it on the server and changes appear on the next page load — no rebuild needed.
|
||
- Your plan, checked breaks, and extra-days value are remembered between visits (via
|
||
`localStorage`). The date always resets to today.
|
||
- Dark theme with restrained CMU Tartan-red accents.
|
||
- Ships as plain static files — no server-side code or backend.
|
||
|
||
## Getting started
|
||
|
||
Requires [Node.js](https://nodejs.org/) 18+.
|
||
|
||
```bash
|
||
npm install # install dependencies
|
||
npm run dev # start the dev server at http://localhost:5173
|
||
```
|
||
|
||
### Other scripts
|
||
|
||
```bash
|
||
npm run build # type-check, build to dist/, then inject the Discord embed
|
||
npm run embed # (re)inject the Discord embed into an existing dist/ build
|
||
npm run preview # serve the production build locally
|
||
npm test # run the estimation-engine tests
|
||
```
|
||
|
||
## Configuration
|
||
|
||
All tunable values live in [`public/config.json`](public/config.json). This file is **fetched at
|
||
runtime**, not compiled into the bundle — so after deployment you can edit the deployed
|
||
`config.json` directly and the changes take effect on the next page load, with no rebuild.
|
||
|
||
During development, edit `public/config.json` and reload the page.
|
||
|
||
```jsonc
|
||
{
|
||
"mealStartDate": "2026-08-24", // first day of the meal period (YYYY-MM-DD)
|
||
"mealEndDate": "2026-12-13", // last day of the meal period (YYYY-MM-DD)
|
||
|
||
"plans": [
|
||
{
|
||
"id": "green", // stable unique key
|
||
"displayName": "Green", // shown in the dropdown
|
||
"totalMealBlocks": 210, // total blocks for the whole meal period
|
||
"totalFlexDollars": 500 // total FLEX dollars for the whole meal period
|
||
}
|
||
// ...more plans
|
||
],
|
||
|
||
"missingPeriods": [
|
||
{
|
||
"id": "fall-break", // stable unique key
|
||
"displayName": "Fall Break", // shown next to the checkbox
|
||
"startDate": "2026-10-19", // inclusive
|
||
"endDate": "2026-10-20" // inclusive
|
||
}
|
||
// ...add as many as you like; each becomes a checkbox
|
||
]
|
||
}
|
||
```
|
||
|
||
Notes:
|
||
|
||
- **The shipped plan numbers are placeholders.** Replace `totalMealBlocks` and `totalFlexDollars`
|
||
with the real figures from your CMU dining agreement, and set the correct `mealStartDate` /
|
||
`mealEndDate` for your semester.
|
||
- Add or remove plans and missing periods freely. Every missing period automatically becomes a
|
||
checkbox in the UI.
|
||
- If the config is malformed (missing fields, bad dates, duplicate ids, negative totals), the app
|
||
shows a readable list of problems instead of rendering broken estimates.
|
||
|
||
## How the estimate works
|
||
|
||
1. **Total active days** = every calendar day in `[mealStartDate, mealEndDate]`, minus the days
|
||
covered by any break you check.
|
||
2. **Daily rate** = `plan total / total active days`, computed separately for blocks and FLEX.
|
||
3. **Active days consumed** = active days from the start up to your "as of" date, minus checked
|
||
breaks in that window, minus any additional excluded days.
|
||
4. **Consumed** = `daily rate × active days consumed`.
|
||
5. **Remaining** = `plan total − consumed`.
|
||
6. **Per week** = `daily rate × 7`.
|
||
|
||
Checking a break or adding excluded days shifts your expected consumption down, which is what you
|
||
want when you were away and not eating on campus.
|
||
|
||
## Deployment (static hosting)
|
||
|
||
The app is a purely client-side static site — no backend, no server-side rendering. To deploy:
|
||
|
||
```bash
|
||
npm run build # outputs everything to dist/
|
||
```
|
||
|
||
Then copy the **contents of `dist/`** to wherever you're serving from. The build produces:
|
||
|
||
```
|
||
dist/
|
||
index.html
|
||
config.json # the live, editable config
|
||
assets/ # hashed JS + CSS
|
||
```
|
||
|
||
### Subpath hosting
|
||
|
||
Asset paths in the build are **relative**, and `config.json` is loaded relative to the page's
|
||
own location. That means the app works unchanged whether it's served from a domain root or a
|
||
subpath — e.g. `https://www.cmu.edu/~you/meal-estimator/`. No base-path configuration or rebuild
|
||
is needed to move it between locations.
|
||
|
||
The one requirement is that the app is served as a directory with a trailing slash (e.g.
|
||
`.../meal-estimator/`). Virtually all static servers (Apache, nginx, and the like) redirect
|
||
`.../meal-estimator` to `.../meal-estimator/` automatically, so this is handled for you.
|
||
|
||
### Editing config after deployment
|
||
|
||
Because `config.json` sits alongside `index.html` in the deployed files and is fetched at
|
||
runtime, you can update plan numbers, dates, or breaks by editing the deployed
|
||
`config.json` directly. No rebuild or redeploy of the JS is required — just reload the page.
|
||
|
||
## Discord link embed
|
||
|
||
The page's `<head>` includes a Discord [Components V2](https://discord.com/developers/docs/components/reference)
|
||
link embed (`<script id="discord:component-embed" type="application/json">`). When the site URL is
|
||
pasted into Discord, its crawler reads that JSON and renders a rich card showing the site name, a
|
||
short blurb, the site icon, and the **Red plan's on-pace estimate for the current day** (blocks
|
||
left, FLEX left, suggested weekly pace).
|
||
|
||
Because Discord's crawler reads the static HTML and does not run the page's JavaScript, this data
|
||
is baked in at build time by [`scripts/inject-embed.mjs`](scripts/inject-embed.mjs). That script
|
||
uses the same estimation engine the app uses, so the embed and the app always agree. Two
|
||
consequences worth knowing:
|
||
|
||
- The embed is a **snapshot from the moment of the build** — it does not update on its own. The
|
||
nightly deploy (below) rebuilds it so it stays current.
|
||
- It cannot show per-user results, since those only exist after the JS runs in a real browser.
|
||
|
||
`SITE_ICON_URL` controls the absolute icon URL Discord fetches; the deploy workflow sets it from
|
||
`SITE_BASE_URL`.
|
||
|
||
## Automated nightly deployment (Gitea Actions)
|
||
|
||
[`.gitea/workflows/deploy.yml`](.gitea/workflows/deploy.yml) rebuilds the site every night
|
||
(refreshing the embed snapshot), uploads `dist/` to CMU AFS space over SFTP, and then triggers
|
||
CMU's publish CGI to make the files live. It also runs on pushes to `main` and can be triggered
|
||
manually from the Gitea Actions UI.
|
||
|
||
### Why an on-campus runner is required
|
||
|
||
`linux.andrew.cmu.edu` (the SFTP target) is only reachable from **inside CMU's network**. A
|
||
cloud/hosted Gitea runner sits on the public internet and cannot reach it, which is what causes
|
||
`lftp` to fail with `max-retries exceeded`. The workflow is therefore pinned to a self-hosted
|
||
runner via `runs-on: cmu-onsite`; only a runner registered with that label — running on a machine
|
||
inside CMU's network — will pick up the job.
|
||
|
||
### Setting up the `cmu-onsite` runner
|
||
|
||
Run [`act_runner`](https://gitea.com/gitea/act_runner) on a machine that stays on CMU's network.
|
||
Register it against this repo (or your Gitea instance) with the `cmu-onsite` label mapped to a
|
||
Node-capable Docker image, so the workflow's `apt-get`/Node steps work unchanged:
|
||
|
||
```bash
|
||
# On the on-campus machine (Docker installed and running):
|
||
act_runner register \
|
||
--instance https://your-gitea-host \
|
||
--token <REGISTRATION_TOKEN_FROM_GITEA> \
|
||
--name cmu-onsite-01 \
|
||
--labels 'cmu-onsite:docker://node:20-bookworm' \
|
||
--no-interactive
|
||
|
||
# Then run it (or install as a service):
|
||
act_runner daemon
|
||
```
|
||
|
||
Notes:
|
||
|
||
- The registration token comes from Gitea under **Settings → Actions → Runners → Create new
|
||
runner** (repo-level) or the equivalent org/site-level page.
|
||
- The `:docker://node:20-bookworm` suffix is what makes the label run jobs in a Debian container
|
||
with Node preinstalled. Without an image mapping the label would run directly on the host, and
|
||
the `sudo apt-get install lftp` step would need the tools present on the machine instead.
|
||
- Confirm the runner host can actually reach the target before relying on it:
|
||
`nc -vz linux.andrew.cmu.edu 22`. Some CMU subnets are firewalled off from the timeshare hosts.
|
||
- Keep the runner process up (systemd service, container restart policy, etc.) so the nightly
|
||
schedule fires.
|
||
|
||
### Required secrets
|
||
|
||
Set these in your Gitea repo under **Settings → Actions → Secrets**:
|
||
|
||
| Secret | Required | Description |
|
||
| ------------------ | -------- | --------------------------------------------------------------------------- |
|
||
| `SFTP_HOST` | yes | SFTP server hostname (e.g. `unix.andrew.cmu.edu`). |
|
||
| `SFTP_USERNAME` | yes | Your Andrew (AFS) username. Also used for the publish callback. |
|
||
| `SFTP_PASSWORD` | yes | Password for the SFTP account. |
|
||
| `SFTP_REMOTE_PATH` | yes | Absolute remote directory to upload into (e.g. `/afs/andrew.cmu.edu/usr/<user>/www/meal-estimator`). |
|
||
| `SITE_BASE_URL` | yes | Public base URL of the site (e.g. `https://www.andrew.cmu.edu/user/<user>`). Used to build the embed icon URL. No trailing slash. |
|
||
| `SFTP_PORT` | no | SFTP port; defaults to `22` if unset. |
|
||
|
||
### What the workflow does
|
||
|
||
1. Checks out the repo and installs dependencies with `npm ci`.
|
||
2. Runs `npm run build`, which type-checks, bundles, and injects the current day's Red-plan embed
|
||
(with `SITE_ICON_URL` derived from `SITE_BASE_URL`).
|
||
3. Uploads `dist/` to `SFTP_REMOTE_PATH` using `lftp mirror -R --delete` (the remote is mirrored
|
||
to match the build; files no longer present locally are removed).
|
||
4. Calls `https://www.andrew.cmu.edu/cgi-bin/publish?FLAG=0&NAME=<SFTP_USERNAME>` to publish. The
|
||
step fails loudly if the publish request returns an HTTP error.
|
||
|
||
### Scheduling note
|
||
|
||
The cron in the workflow is in **UTC**. It is set to `0 5 * * *`, which is midnight US Eastern
|
||
during EST. For EDT (daylight saving), use `0 4 * * *`. Adjust to taste, or keep both in mind when
|
||
the clocks change.
|
||
|
||
### Security notes
|
||
|
||
- The SFTP password is passed to `lftp` via an environment variable interpolated into the command;
|
||
keep it only in Gitea secrets, never in the repo.
|
||
- Host-key checking is disabled on the runner (via `StrictHostKeyChecking=no` in the SSH
|
||
connect-program) because CMU's host key is not pre-seeded there. The transfer is still encrypted,
|
||
but this does not verify the server's identity. If you want strict host-key verification, add the
|
||
server's key to the runner's `known_hosts` and remove that option.
|
||
- The upload step runs `lftp` with `debug 3` and forces password auth over an explicit `ssh`
|
||
connect-program. This makes connection failures visible in the job log and re-enables a few older
|
||
key-exchange / host-key algorithms that some CMU hosts still require.
|
||
|
||
## Data persistence
|
||
|
||
The selected plan, checked breaks, and additional-days value are saved to the browser's
|
||
`localStorage` and restored on the next visit. The "as of" date is intentionally **not**
|
||
persisted — it always defaults to today. Persistence fails silently if `localStorage` is
|
||
unavailable (e.g. private browsing), and stale entries (a plan or break no longer in the config)
|
||
are ignored on load.
|
||
|
||
## Project structure
|
||
|
||
```
|
||
index.html # app shell (includes the Discord embed placeholder)
|
||
public/
|
||
config.json # all tunable values (plans, dates, breaks); served + fetched at runtime
|
||
favicon.svg # site icon (fork + progress arc)
|
||
src/
|
||
types.ts # shared types
|
||
dates.ts # whole-day date helpers
|
||
configLoader.ts # validates + normalizes config
|
||
configFetch.ts # fetches config.json at runtime (subpath-aware)
|
||
engine.ts # pure estimation math (no DOM)
|
||
store.ts # tiny reactive state store
|
||
persistence.ts # localStorage load/save of selections
|
||
main.ts # builds the UI, re-renders on change
|
||
style.css # dark CMU-accented theme
|
||
engine.test.ts # engine unit tests
|
||
sanity.test.ts # checks against the shipped config
|
||
scripts/
|
||
inject-embed.mjs # build-time: injects today's Red-plan Discord embed into dist/
|
||
.gitea/workflows/
|
||
deploy.yml # nightly build + SFTP deploy + CMU publish
|
||
```
|
||
|
||
## Tech stack
|
||
|
||
- [Vite](https://vitejs.dev/) for dev server and bundling
|
||
- TypeScript (strict mode)
|
||
- [Vitest](https://vitest.dev/) for tests
|
||
- No UI framework — plain DOM with a small reactive store
|
||
|
||
## Tests
|
||
|
||
The estimation engine is a set of pure functions, kept separate from the DOM so it can be tested
|
||
directly:
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Covers day counting across boundaries, budget conservation, monotonicity when excluding days, the
|
||
zero-active-days (division-by-zero) edge case, and sanity checks against the shipped config.
|