make static

This commit is contained in:
2026-09-17 01:29:20 -04:00
parent 3d4d75fdfb
commit 7a0101bcae
11 changed files with 280 additions and 26 deletions
+56 -5
View File
@@ -19,9 +19,12 @@ estimated **remaining** balance, for both meal blocks and FLEX.
- 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. No code changes
needed to adjust them.
- 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
@@ -42,8 +45,11 @@ npm test # run the estimation-engine tests
## Configuration
All tunable values live in [`src/config.json`](src/config.json). Edit that file, then reload the
page — the UI updates reactively.
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
{
@@ -96,17 +102,62 @@ Notes:
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.
## 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
public/
config.json # all tunable values (plans, dates, breaks); served + fetched at runtime
src/
config.json # all tunable values (plans, dates, breaks)
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
+18
View File
@@ -8,6 +8,7 @@
"name": "cmu-meal-estimator",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^20.19.43",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4"
@@ -785,6 +786,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
@@ -1285,6 +1296,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+1
View File
@@ -10,6 +10,7 @@
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^20.19.43",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$comment": "Edit this file to configure the estimator. Dates are ISO YYYY-MM-DD. totalMealBlocks and totalFlexDollars are the amounts for the whole meal period. Add/remove plans and missingPeriods freely; the UI updates reactively. Set exact figures from your CMU dining agreement.",
"$comment": "Edit this file to configure the estimator. It is fetched at runtime, so changes take effect on reload without rebuilding. Dates are ISO YYYY-MM-DD. totalMealBlocks and totalFlexDollars are the amounts for the whole meal period. Add/remove plans and missingPeriods freely; the UI updates reactively. Set exact figures from your CMU dining agreement.",
"mealStartDate": "2026-08-24",
"mealEndDate": "2026-12-13",
+66
View File
@@ -0,0 +1,66 @@
import { loadConfig } from "./configLoader";
import type { ConfigResult } from "./types";
/**
* Fetch and validate config.json at runtime. The URL is resolved against the
* app's configured base (import.meta.env.BASE_URL), so it works whether the app
* is served from a domain root or a subpath.
*/
export async function fetchConfig(): Promise<ConfigResult> {
const url = configUrl();
let response: Response;
try {
response = await fetch(url.toString(), { cache: "no-cache" });
} catch (err) {
return {
ok: false,
errors: [`Could not fetch config.json (${describe(err)}).`],
};
}
if (!response.ok) {
return {
ok: false,
errors: [
`Could not load config.json (HTTP ${response.status} ${response.statusText}).`,
],
};
}
let raw: unknown;
try {
raw = await response.json();
} catch {
return {
ok: false,
errors: ["config.json is not valid JSON."],
};
}
return loadConfig(raw);
}
/**
* Resolve the config.json URL for the deployed location. Uses Vite's configured
* base when it is an absolute path (e.g. "/meal-estimator/"); otherwise falls
* back to the directory of the current document so a relative "./" base works
* whether or not the URL has a trailing slash.
*/
function configUrl(): string {
const base = import.meta.env.BASE_URL;
if (base.startsWith("/")) {
// Absolute base path: config sits directly under it.
return new URL(`${base}config.json`, window.location.origin).toString();
}
// Relative base: resolve against the current document's directory.
const path = window.location.pathname;
const dir = path.endsWith("/") ? path : path.slice(0, path.lastIndexOf("/") + 1);
return new URL(`${dir}config.json`, window.location.origin).toString();
}
function describe(err: unknown): string {
return err instanceof Error ? err.message : "network error";
}
+64 -19
View File
@@ -1,29 +1,57 @@
import "./style.css";
import rawConfig from "./config.json";
import { loadConfig } from "./configLoader";
import { fetchConfig } from "./configFetch";
import { estimate } from "./engine";
import { Store } from "./store";
import { toISODate, parseISODate, atMidnight } from "./dates";
import type { Config, EstimationResult } from "./types";
import { loadPersisted, savePersisted } from "./persistence";
import type { Config, EstimationResult, Selections } from "./types";
const appEl = document.getElementById("app")!;
const result = loadConfig(rawConfig);
if (!result.ok) {
renderConfigError(result.errors);
} else {
bootstrap(result.config);
void main();
async function main(): Promise<void> {
renderLoading();
const result = await fetchConfig();
if (!result.ok) {
renderConfigError(result.errors);
} else {
bootstrap(result.config);
}
}
/**
* Build the initial selections: defaults, overlaid with any persisted values
* that are still valid for this config. The target date is never persisted —
* it always starts at today.
*/
function initialSelections(config: Config): Selections {
const defaults: Selections = {
selectedPlanId: config.plans[0].id,
targetDate: atMidnight(new Date()),
activeMissingPeriodIds: new Set(),
extraExcludedDays: 0,
};
const persisted = loadPersisted();
if (!persisted) return defaults;
// Only honor a persisted plan / periods that still exist in the config.
const planExists = config.plans.some((p) => p.id === persisted.selectedPlanId);
const validPeriodIds = new Set(config.missingPeriods.map((m) => m.id));
return {
...defaults,
selectedPlanId: planExists ? persisted.selectedPlanId : defaults.selectedPlanId,
activeMissingPeriodIds: new Set(
persisted.activeMissingPeriodIds.filter((id) => validPeriodIds.has(id))
),
extraExcludedDays: persisted.extraExcludedDays,
};
}
function bootstrap(config: Config): void {
const today = atMidnight(new Date());
const store = new Store(config, {
selectedPlanId: config.plans[0].id,
targetDate: today,
activeMissingPeriodIds: new Set(),
extraExcludedDays: 0,
});
const store = new Store(config, initialSelections(config));
appEl.innerHTML = "";
appEl.appendChild(buildLayout(store, config));
@@ -32,7 +60,16 @@ function bootstrap(config: Config): void {
const daysNote = appEl.querySelector<HTMLElement>("#days-note")!;
const rerender = () => {
const r = estimate(config, store.getSelections());
const sel = store.getSelections();
// Persist everything except the date.
savePersisted({
selectedPlanId: sel.selectedPlanId,
activeMissingPeriodIds: [...sel.activeMissingPeriodIds],
extraExcludedDays: sel.extraExcludedDays,
});
const r = estimate(config, sel);
resultsMount.innerHTML = "";
resultsMount.appendChild(renderResults(r));
daysNote.textContent = `${fmtDays(r.activeDaysConsumed)} of ${fmtDays(
@@ -124,6 +161,7 @@ function buildControls(store: Store, config: Config): HTMLElement {
const cb = document.createElement("input");
cb.type = "checkbox";
cb.value = period.id;
cb.checked = store.getSelections().activeMissingPeriodIds.has(period.id);
cb.addEventListener("change", () =>
store.setMissingPeriodActive(period.id, cb.checked)
);
@@ -150,7 +188,7 @@ function buildControls(store: Store, config: Config): HTMLElement {
extra.type = "number";
extra.min = "0";
extra.step = "1";
extra.value = "0";
extra.value = String(store.getSelections().extraExcludedDays);
extra.className = "input";
extra.id = "extra-days";
extra.addEventListener("input", () =>
@@ -214,7 +252,14 @@ function metric(value: string, unit: string): HTMLElement {
return m;
}
/* ---------- config error ---------- */
/* ---------- loading + config error ---------- */
function renderLoading(): void {
const box = el("div", "loading");
box.textContent = "Loading…";
appEl.innerHTML = "";
appEl.appendChild(box);
}
function renderConfigError(errors: string[]): void {
const box = el("div", "config-error");
+56
View File
@@ -0,0 +1,56 @@
/**
* Persists the user's selections across visits via localStorage.
* Deliberately excludes the target date — that always defaults to today unless
* the user changes it during a session.
*/
const STORAGE_KEY = "cmu-meal-estimator:selections:v1";
export interface PersistedSelections {
selectedPlanId: string;
activeMissingPeriodIds: string[];
extraExcludedDays: number;
}
/** Load persisted selections, or null if none/invalid/unavailable. */
export function loadPersisted(): PersistedSelections | null {
let raw: string | null;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
// localStorage can throw (private mode, disabled). Fail soft.
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null) return null;
const o = parsed as Record<string, unknown>;
const selectedPlanId = typeof o.selectedPlanId === "string" ? o.selectedPlanId : null;
const idsRaw = Array.isArray(o.activeMissingPeriodIds) ? o.activeMissingPeriodIds : [];
const activeMissingPeriodIds = idsRaw.filter(
(x): x is string => typeof x === "string"
);
const extraExcludedDays =
typeof o.extraExcludedDays === "number" && Number.isFinite(o.extraExcludedDays)
? Math.max(0, Math.floor(o.extraExcludedDays))
: 0;
if (selectedPlanId === null) return null;
return { selectedPlanId, activeMissingPeriodIds, extraExcludedDays };
} catch {
return null;
}
}
/** Persist the given selections. Fails soft if storage is unavailable. */
export function savePersisted(sel: PersistedSelections): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sel));
} catch {
// Ignore — persistence is a convenience, not a requirement.
}
}
+6 -1
View File
@@ -1,9 +1,14 @@
import { describe, it, expect } from "vitest";
import rawConfig from "./config.json";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { loadConfig } from "./configLoader";
import { estimate } from "./engine";
import { parseISODate } from "./dates";
// Read the actual shipped config that gets served at runtime.
const configPath = fileURLToPath(new URL("../public/config.json", import.meta.url));
const rawConfig = JSON.parse(readFileSync(configPath, "utf-8"));
describe("real config sanity", () => {
const result = loadConfig(rawConfig);
+10
View File
@@ -299,3 +299,13 @@ body {
.config-error li {
margin-bottom: 6px;
}
/* Loading */
.loading {
max-width: 640px;
margin: 96px auto;
text-align: center;
color: var(--text-dim);
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 15px;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+1
View File
@@ -9,6 +9,7 @@
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["vite/client", "node"],
"noEmit": true,
"strict": true,
"noUnusedLocals": true,