make static
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"$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.",
|
||||
|
||||
"mealStartDate": "2026-08-24",
|
||||
"mealEndDate": "2026-12-13",
|
||||
|
||||
"plans": [
|
||||
{ "id": "green", "displayName": "Green", "totalMealBlocks": 210, "totalFlexDollars": 500 },
|
||||
{ "id": "blue", "displayName": "Blue", "totalMealBlocks": 285, "totalFlexDollars": 450 },
|
||||
{ "id": "red", "displayName": "Red", "totalMealBlocks": 175, "totalFlexDollars": 625 },
|
||||
{ "id": "yellow", "displayName": "Yellow", "totalMealBlocks": 140, "totalFlexDollars": 725 },
|
||||
{ "id": "tartan-flex", "displayName": "Tartan Flex", "totalMealBlocks": 105, "totalFlexDollars": 900 },
|
||||
{ "id": "scottys-choice", "displayName": "Scotty's Choice", "totalMealBlocks": 245, "totalFlexDollars": 400 },
|
||||
{ "id": "whitfields-favor", "displayName": "Whitfield's Favor", "totalMealBlocks": 300, "totalFlexDollars": 350 },
|
||||
{ "id": "piper-select", "displayName": "Piper Select", "totalMealBlocks": 70, "totalFlexDollars": 1100 }
|
||||
],
|
||||
|
||||
"missingPeriods": [
|
||||
{ "id": "labor-day", "displayName": "Labor Day", "startDate": "2026-09-07", "endDate": "2026-09-07" },
|
||||
{ "id": "fall-break", "displayName": "Fall Break", "startDate": "2026-10-19", "endDate": "2026-10-20" },
|
||||
{ "id": "thanksgiving", "displayName": "Thanksgiving Break", "startDate": "2026-11-25", "endDate": "2026-11-29" }
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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" />
|
||||
Reference in New Issue
Block a user