make static
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
@@ -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";
|
||||
}
|
||||
+62
-17
@@ -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) {
|
||||
void main();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
renderLoading();
|
||||
const result = await fetchConfig();
|
||||
if (!result.ok) {
|
||||
renderConfigError(result.errors);
|
||||
} else {
|
||||
} 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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -9,6 +9,7 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"types": ["vite/client", "node"],
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
|
||||
Reference in New Issue
Block a user