256 lines
11 KiB
Markdown
256 lines
11 KiB
Markdown
# 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.
|
||
|
||
### 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 (`sftp:auto-confirm yes`) 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 and
|
||
remove that setting.
|
||
|
||
## 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.
|