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. - Checkboxes for configured breaks ("missing periods") you spent off campus.
- A numeric input for any additional days you don't want counted. - A numeric input for any additional days you don't want counted.
- Live-updating results — every input recomputes the estimate instantly. - 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 - Everything is config-driven: plans, dates, and breaks live in one JSON file that is fetched at
needed to adjust them. 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. - Dark theme with restrained CMU Tartan-red accents.
- Ships as plain static files — no server-side code or backend.
## Getting started ## Getting started
@@ -42,8 +45,11 @@ npm test # run the estimation-engine tests
## Configuration ## Configuration
All tunable values live in [`src/config.json`](src/config.json). Edit that file, then reload the All tunable values live in [`public/config.json`](public/config.json). This file is **fetched at
page — the UI updates reactively. 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 ```jsonc
{ {
@@ -96,17 +102,62 @@ Notes:
Checking a break or adding excluded days shifts your expected consumption down, which is what you 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. 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 ## Project structure
``` ```
index.html # app shell index.html # app shell
public/
config.json # all tunable values (plans, dates, breaks); served + fetched at runtime
src/ src/
config.json # all tunable values (plans, dates, breaks)
types.ts # shared types types.ts # shared types
dates.ts # whole-day date helpers dates.ts # whole-day date helpers
configLoader.ts # validates + normalizes config configLoader.ts # validates + normalizes config
configFetch.ts # fetches config.json at runtime (subpath-aware)
engine.ts # pure estimation math (no DOM) engine.ts # pure estimation math (no DOM)
store.ts # tiny reactive state store store.ts # tiny reactive state store
persistence.ts # localStorage load/save of selections
main.ts # builds the UI, re-renders on change main.ts # builds the UI, re-renders on change
style.css # dark CMU-accented theme style.css # dark CMU-accented theme
engine.test.ts # engine unit tests engine.test.ts # engine unit tests
+18
View File
@@ -8,6 +8,7 @@
"name": "cmu-meal-estimator", "name": "cmu-meal-estimator",
"version": "1.0.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"@types/node": "^20.19.43",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10", "vite": "^5.4.10",
"vitest": "^2.1.4" "vitest": "^2.1.4"
@@ -785,6 +786,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@vitest/expect": {
"version": "2.1.9", "version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
@@ -1285,6 +1296,13 @@
"node": ">=14.17" "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+1
View File
@@ -10,6 +10,7 @@
"test": "vitest run" "test": "vitest run"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.19.43",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10", "vite": "^5.4.10",
"vitest": "^2.1.4" "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", "mealStartDate": "2026-08-24",
"mealEndDate": "2026-12-13", "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";
}
+58 -13
View File
@@ -1,29 +1,57 @@
import "./style.css"; import "./style.css";
import rawConfig from "./config.json"; import { fetchConfig } from "./configFetch";
import { loadConfig } from "./configLoader";
import { estimate } from "./engine"; import { estimate } from "./engine";
import { Store } from "./store"; import { Store } from "./store";
import { toISODate, parseISODate, atMidnight } from "./dates"; 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 appEl = document.getElementById("app")!;
const result = loadConfig(rawConfig); void main();
async function main(): Promise<void> {
renderLoading();
const result = await fetchConfig();
if (!result.ok) { if (!result.ok) {
renderConfigError(result.errors); renderConfigError(result.errors);
} else { } else {
bootstrap(result.config); bootstrap(result.config);
} }
}
function bootstrap(config: Config): void { /**
const today = atMidnight(new Date()); * Build the initial selections: defaults, overlaid with any persisted values
* that are still valid for this config. The target date is never persisted —
const store = new Store(config, { * it always starts at today.
*/
function initialSelections(config: Config): Selections {
const defaults: Selections = {
selectedPlanId: config.plans[0].id, selectedPlanId: config.plans[0].id,
targetDate: today, targetDate: atMidnight(new Date()),
activeMissingPeriodIds: new Set(), activeMissingPeriodIds: new Set(),
extraExcludedDays: 0, 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 store = new Store(config, initialSelections(config));
appEl.innerHTML = ""; appEl.innerHTML = "";
appEl.appendChild(buildLayout(store, config)); appEl.appendChild(buildLayout(store, config));
@@ -32,7 +60,16 @@ function bootstrap(config: Config): void {
const daysNote = appEl.querySelector<HTMLElement>("#days-note")!; const daysNote = appEl.querySelector<HTMLElement>("#days-note")!;
const rerender = () => { 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.innerHTML = "";
resultsMount.appendChild(renderResults(r)); resultsMount.appendChild(renderResults(r));
daysNote.textContent = `${fmtDays(r.activeDaysConsumed)} of ${fmtDays( daysNote.textContent = `${fmtDays(r.activeDaysConsumed)} of ${fmtDays(
@@ -124,6 +161,7 @@ function buildControls(store: Store, config: Config): HTMLElement {
const cb = document.createElement("input"); const cb = document.createElement("input");
cb.type = "checkbox"; cb.type = "checkbox";
cb.value = period.id; cb.value = period.id;
cb.checked = store.getSelections().activeMissingPeriodIds.has(period.id);
cb.addEventListener("change", () => cb.addEventListener("change", () =>
store.setMissingPeriodActive(period.id, cb.checked) store.setMissingPeriodActive(period.id, cb.checked)
); );
@@ -150,7 +188,7 @@ function buildControls(store: Store, config: Config): HTMLElement {
extra.type = "number"; extra.type = "number";
extra.min = "0"; extra.min = "0";
extra.step = "1"; extra.step = "1";
extra.value = "0"; extra.value = String(store.getSelections().extraExcludedDays);
extra.className = "input"; extra.className = "input";
extra.id = "extra-days"; extra.id = "extra-days";
extra.addEventListener("input", () => extra.addEventListener("input", () =>
@@ -214,7 +252,14 @@ function metric(value: string, unit: string): HTMLElement {
return m; 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 { function renderConfigError(errors: string[]): void {
const box = el("div", "config-error"); 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 { 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 { loadConfig } from "./configLoader";
import { estimate } from "./engine"; import { estimate } from "./engine";
import { parseISODate } from "./dates"; 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", () => { describe("real config sanity", () => {
const result = loadConfig(rawConfig); const result = loadConfig(rawConfig);
+10
View File
@@ -299,3 +299,13 @@ body {
.config-error li { .config-error li {
margin-bottom: 6px; 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, "allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"types": ["vite/client", "node"],
"noEmit": true, "noEmit": true,
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,