diff --git a/README.md b/README.md index f4675a1..be2ccce 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index f1c9048..0e6a27d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7695240..8cdddbd 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/config.json b/public/config.json similarity index 79% rename from src/config.json rename to public/config.json index 0a32254..20fe33b 100644 --- a/src/config.json +++ b/public/config.json @@ -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", diff --git a/src/configFetch.ts b/src/configFetch.ts new file mode 100644 index 0000000..19f0ca5 --- /dev/null +++ b/src/configFetch.ts @@ -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 { + 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"; +} diff --git a/src/main.ts b/src/main.ts index 172efdd..2b03c24 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { + 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("#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"); diff --git a/src/persistence.ts b/src/persistence.ts new file mode 100644 index 0000000..7e42513 --- /dev/null +++ b/src/persistence.ts @@ -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; + + 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. + } +} diff --git a/src/sanity.test.ts b/src/sanity.test.ts index 1c6b300..65d1860 100644 --- a/src/sanity.test.ts +++ b/src/sanity.test.ts @@ -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); diff --git a/src/style.css b/src/style.css index 09ae077..9bab462 100644 --- a/src/style.css +++ b/src/style.css @@ -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; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json index 50e85ed..9be21e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, + "types": ["vite/client", "node"], "noEmit": true, "strict": true, "noUnusedLocals": true,