functional

This commit is contained in:
2026-09-17 01:19:19 -04:00
commit 3d4d75fdfb
19 changed files with 3257 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
{
"$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" }
]
}
+203
View File
@@ -0,0 +1,203 @@
import type { ConfigResult, MissingPeriod, Plan } from "./types";
import { daysBetween, parseISODate } from "./dates";
/**
* Validate and normalize a raw (parsed JSON) config object into a typed Config,
* or return a list of human-readable errors. Pure: no I/O.
*/
export function loadConfig(raw: unknown): ConfigResult {
const errors: string[] = [];
if (typeof raw !== "object" || raw === null) {
return { ok: false, errors: ["Config must be a JSON object."] };
}
const obj = raw as Record<string, unknown>;
const mealStartDate = requireDate(obj.mealStartDate, "mealStartDate", errors);
const mealEndDate = requireDate(obj.mealEndDate, "mealEndDate", errors);
if (mealStartDate && mealEndDate && daysBetween(mealStartDate, mealEndDate) <= 0) {
errors.push("mealEndDate must be strictly after mealStartDate.");
}
const plans = parsePlans(obj.plans, errors);
const missingPeriods = parseMissingPeriods(
obj.missingPeriods,
mealStartDate,
mealEndDate,
errors
);
if (errors.length > 0 || !mealStartDate || !mealEndDate) {
return { ok: false, errors };
}
return {
ok: true,
config: { mealStartDate, mealEndDate, plans, missingPeriods },
};
}
function requireDate(
value: unknown,
field: string,
errors: string[]
): Date | null {
if (typeof value !== "string") {
errors.push(`${field} is required and must be a "YYYY-MM-DD" string.`);
return null;
}
const parsed = parseISODate(value);
if (!parsed) {
errors.push(`${field} "${value}" is not a valid YYYY-MM-DD date.`);
return null;
}
return parsed;
}
function parsePlans(value: unknown, errors: string[]): Plan[] {
if (!Array.isArray(value) || value.length === 0) {
errors.push("plans must be a non-empty array.");
return [];
}
const plans: Plan[] = [];
const seenIds = new Set<string>();
value.forEach((entry, i) => {
const label = `plans[${i}]`;
if (typeof entry !== "object" || entry === null) {
errors.push(`${label} must be an object.`);
return;
}
const p = entry as Record<string, unknown>;
const id = requireNonEmptyString(p.id, `${label}.id`, errors);
const displayName = requireNonEmptyString(
p.displayName,
`${label}.displayName`,
errors
);
const totalMealBlocks = requireNonNegativeNumber(
p.totalMealBlocks,
`${label}.totalMealBlocks`,
errors
);
const totalFlexDollars = requireNonNegativeNumber(
p.totalFlexDollars,
`${label}.totalFlexDollars`,
errors
);
if (id !== null) {
if (seenIds.has(id)) {
errors.push(`Duplicate plan id "${id}".`);
}
seenIds.add(id);
}
if (
id !== null &&
displayName !== null &&
totalMealBlocks !== null &&
totalFlexDollars !== null
) {
plans.push({ id, displayName, totalMealBlocks, totalFlexDollars });
}
});
return plans;
}
function parseMissingPeriods(
value: unknown,
mealStartDate: Date | null,
mealEndDate: Date | null,
errors: string[]
): MissingPeriod[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
errors.push("missingPeriods must be an array (or omitted).");
return [];
}
const periods: MissingPeriod[] = [];
const seenIds = new Set<string>();
value.forEach((entry, i) => {
const label = `missingPeriods[${i}]`;
if (typeof entry !== "object" || entry === null) {
errors.push(`${label} must be an object.`);
return;
}
const m = entry as Record<string, unknown>;
const id = requireNonEmptyString(m.id, `${label}.id`, errors);
const displayName = requireNonEmptyString(
m.displayName,
`${label}.displayName`,
errors
);
const startDate = requireDate(m.startDate, `${label}.startDate`, errors);
const endDate = requireDate(m.endDate, `${label}.endDate`, errors);
if (startDate && endDate && daysBetween(startDate, endDate) < 0) {
errors.push(`${label}: startDate must be on or before endDate.`);
}
if (id !== null) {
if (seenIds.has(id)) {
errors.push(`Duplicate missing period id "${id}".`);
}
seenIds.add(id);
}
// Warn (non-fatal) if the period falls outside the meal window.
if (startDate && endDate && mealStartDate && mealEndDate) {
const outside =
daysBetween(mealStartDate, endDate) < 0 ||
daysBetween(startDate, mealEndDate) < 0;
if (outside) {
console.warn(
`${label} "${displayName ?? id}" falls outside the meal period; it will have no effect.`
);
}
}
if (
id !== null &&
displayName !== null &&
startDate !== null &&
endDate !== null &&
daysBetween(startDate, endDate) >= 0
) {
periods.push({ id, displayName, startDate, endDate });
}
});
return periods;
}
function requireNonEmptyString(
value: unknown,
field: string,
errors: string[]
): string | null {
if (typeof value !== "string" || value.trim() === "") {
errors.push(`${field} is required and must be a non-empty string.`);
return null;
}
return value;
}
function requireNonNegativeNumber(
value: unknown,
field: string,
errors: string[]
): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
errors.push(`${field} is required and must be a number >= 0.`);
return null;
}
return value;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Date helpers that work in whole calendar days at local midnight, so day
* counting is not affected by time-of-day or DST shifts.
*/
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Strip time; return a new Date at local midnight. */
export function atMidnight(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
/** Parse an ISO YYYY-MM-DD string as a local-midnight Date. Returns null if invalid. */
export function parseISODate(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
if (!match) return null;
const [, y, m, d] = match;
const year = Number(y);
const month = Number(m);
const day = Number(d);
const date = new Date(year, month - 1, day);
// Reject overflow (e.g. 2026-02-30 rolling into March).
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
return date;
}
/** Format a Date as ISO YYYY-MM-DD (local). */
export function toISODate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** Whole days from a to b (b - a). Negative if b is before a. */
export function daysBetween(a: Date, b: Date): number {
const diff = atMidnight(b).getTime() - atMidnight(a).getTime();
return Math.round(diff / MS_PER_DAY);
}
/**
* Inclusive day count of [start, end]. Zero if end < start.
* e.g. same day => 1.
*/
export function inclusiveDayCount(start: Date, end: Date): number {
const n = daysBetween(start, end) + 1;
return n > 0 ? n : 0;
}
/** Return the later of two dates. */
export function maxDate(a: Date, b: Date): Date {
return a.getTime() >= b.getTime() ? a : b;
}
/** Return the earlier of two dates. */
export function minDate(a: Date, b: Date): Date {
return a.getTime() <= b.getTime() ? a : b;
}
/** Clamp d into [lo, hi]. */
export function clampDate(d: Date, lo: Date, hi: Date): Date {
return maxDate(lo, minDate(d, hi));
}
/**
* Count days of the inclusive window [winStart, winEnd] that are covered by the
* inclusive period [perStart, perEnd] — i.e. the size of the overlap.
*/
export function overlapDays(
winStart: Date,
winEnd: Date,
perStart: Date,
perEnd: Date
): number {
const start = maxDate(winStart, perStart);
const end = minDate(winEnd, perEnd);
return inclusiveDayCount(start, end);
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { estimate, totalActiveDays, activeDaysConsumed } from "./engine";
import type { Config, Selections } from "./types";
import { parseISODate } from "./dates";
function d(iso: string): Date {
const parsed = parseISODate(iso);
if (!parsed) throw new Error(`bad test date ${iso}`);
return parsed;
}
// A simple 100-day window (Jan 1 .. Apr 10, 2026 is 100 inclusive days).
const config: Config = {
mealStartDate: d("2026-01-01"),
mealEndDate: d("2026-04-10"),
plans: [
{ id: "green", displayName: "Green", totalMealBlocks: 200, totalFlexDollars: 500 },
],
missingPeriods: [
{ id: "break", displayName: "Break", startDate: d("2026-02-01"), endDate: d("2026-02-10") }, // 10 days
],
};
function sel(overrides: Partial<Selections> = {}): Selections {
return {
selectedPlanId: "green",
targetDate: d("2026-04-10"),
activeMissingPeriodIds: new Set(),
extraExcludedDays: 0,
...overrides,
};
}
describe("totalActiveDays", () => {
it("counts the full inclusive window with no missing periods", () => {
expect(totalActiveDays(config, new Set())).toBe(100);
});
it("subtracts an active missing period", () => {
expect(totalActiveDays(config, new Set(["break"]))).toBe(90);
});
});
describe("activeDaysConsumed", () => {
it("is the full window at the end date", () => {
expect(activeDaysConsumed(config, sel())).toBe(100);
});
it("clamps a target date before the start to zero", () => {
expect(activeDaysConsumed(config, sel({ targetDate: d("2025-12-01") }))).toBe(0);
});
it("subtracts extra excluded days", () => {
expect(activeDaysConsumed(config, sel({ extraExcludedDays: 5 }))).toBe(95);
});
});
describe("estimate", () => {
it("conserves the budget at the end date with no exclusions", () => {
const r = estimate(config, sel());
expect(r.consumedMealBlocks).toBeCloseTo(200);
expect(r.remainingMealBlocks).toBeCloseTo(0);
expect(r.consumedFlexDollars).toBeCloseTo(500);
expect(r.remainingFlexDollars).toBeCloseTo(0);
});
it("consumed + remaining always equals the plan total", () => {
const r = estimate(config, sel({ targetDate: d("2026-02-15"), activeMissingPeriodIds: new Set(["break"]) }));
expect(r.consumedMealBlocks + r.remainingMealBlocks).toBeCloseTo(200);
expect(r.consumedFlexDollars + r.remainingFlexDollars).toBeCloseTo(500);
});
it("computes a per-week rate of dailyRate * 7", () => {
const r = estimate(config, sel());
// 200 blocks / 100 days = 2/day => 14/week
expect(r.perWeekMealBlocks).toBeCloseTo(14);
expect(r.perWeekFlexDollars).toBeCloseTo(35); // 500/100*7
});
it("never lets excluding days increase consumption (monotonicity)", () => {
const base = estimate(config, sel({ targetDate: d("2026-03-01") }));
const more = estimate(config, sel({ targetDate: d("2026-03-01"), extraExcludedDays: 10 }));
expect(more.consumedMealBlocks).toBeLessThanOrEqual(base.consumedMealBlocks);
});
it("handles zero active days without dividing by zero", () => {
const allExcluded: Config = {
...config,
missingPeriods: [
{ id: "all", displayName: "All", startDate: d("2026-01-01"), endDate: d("2026-04-10") },
],
};
const r = estimate(allExcluded, sel({ activeMissingPeriodIds: new Set(["all"]) }));
expect(r.perWeekMealBlocks).toBe(0);
expect(r.consumedMealBlocks).toBe(0);
expect(Number.isFinite(r.remainingMealBlocks)).toBe(true);
});
});
+93
View File
@@ -0,0 +1,93 @@
import type { Config, EstimationResult, MissingPeriod, Plan, Selections } from "./types";
import { clampDate, daysBetween, inclusiveDayCount, overlapDays } from "./dates";
/** The active missing periods for a set of ids, in config order. */
function activePeriods(config: Config, ids: Set<string>): MissingPeriod[] {
return config.missingPeriods.filter((p) => ids.has(p.id));
}
/**
* Days a student is expected to be on campus across the full meal window,
* after removing days covered by the active missing periods.
* Overlapping/adjacent periods are handled by summing per-period overlap with
* the window; periods in the config are assumed non-overlapping (typical for
* distinct breaks). Result is clamped to >= 0.
*/
export function totalActiveDays(config: Config, activeIds: Set<string>): number {
const { mealStartDate, mealEndDate } = config;
const windowDays = inclusiveDayCount(mealStartDate, mealEndDate);
let excluded = 0;
for (const p of activePeriods(config, activeIds)) {
excluded += overlapDays(mealStartDate, mealEndDate, p.startDate, p.endDate);
}
return Math.max(0, windowDays - excluded);
}
/**
* Active days from meal start up to (and including) the target date, minus
* active missing periods within that window, minus extra excluded days.
* The target date is clamped into the meal window first. Result clamped >= 0.
*/
export function activeDaysConsumed(config: Config, selections: Selections): number {
const { mealStartDate, mealEndDate } = config;
// Before the meal period even begins, nothing is consumed yet.
if (daysBetween(mealStartDate, selections.targetDate) < 0) {
return 0;
}
const target = clampDate(selections.targetDate, mealStartDate, mealEndDate);
const windowDays = inclusiveDayCount(mealStartDate, target);
let excluded = 0;
for (const p of activePeriods(config, selections.activeMissingPeriodIds)) {
excluded += overlapDays(mealStartDate, target, p.startDate, p.endDate);
}
const consumed = windowDays - excluded - Math.max(0, selections.extraExcludedDays);
return Math.max(0, consumed);
}
function findPlan(config: Config, planId: string): Plan | undefined {
return config.plans.find((p) => p.id === planId);
}
/**
* Master estimate. Distributes a plan's totals evenly across total active days,
* then reports per-week rate, consumed-by-target, and remaining for both meal
* blocks and FLEX dollars. All output values are clamped to sane bounds.
*/
export function estimate(config: Config, selections: Selections): EstimationResult {
const plan = findPlan(config, selections.selectedPlanId) ?? config.plans[0];
const total = totalActiveDays(config, selections.activeMissingPeriodIds);
const consumedDays = Math.min(
activeDaysConsumed(config, selections),
total
);
// Guard against division by zero when every day is excluded.
const blockRate = total > 0 ? plan.totalMealBlocks / total : 0;
const flexRate = total > 0 ? plan.totalFlexDollars / total : 0;
const consumedMealBlocks = clamp(blockRate * consumedDays, 0, plan.totalMealBlocks);
const consumedFlexDollars = clamp(flexRate * consumedDays, 0, plan.totalFlexDollars);
return {
perWeekMealBlocks: blockRate * 7,
perWeekFlexDollars: flexRate * 7,
consumedMealBlocks,
consumedFlexDollars,
remainingMealBlocks: plan.totalMealBlocks - consumedMealBlocks,
remainingFlexDollars: plan.totalFlexDollars - consumedFlexDollars,
totalActiveDays: total,
activeDaysConsumed: consumedDays,
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
+280
View File
@@ -0,0 +1,280 @@
import "./style.css";
import rawConfig from "./config.json";
import { loadConfig } from "./configLoader";
import { estimate } from "./engine";
import { Store } from "./store";
import { toISODate, parseISODate, atMidnight } from "./dates";
import type { Config, EstimationResult } from "./types";
const appEl = document.getElementById("app")!;
const result = loadConfig(rawConfig);
if (!result.ok) {
renderConfigError(result.errors);
} else {
bootstrap(result.config);
}
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,
});
appEl.innerHTML = "";
appEl.appendChild(buildLayout(store, config));
const resultsMount = appEl.querySelector<HTMLElement>("#results")!;
const daysNote = appEl.querySelector<HTMLElement>("#days-note")!;
const rerender = () => {
const r = estimate(config, store.getSelections());
resultsMount.innerHTML = "";
resultsMount.appendChild(renderResults(r));
daysNote.textContent = `${fmtDays(r.activeDaysConsumed)} of ${fmtDays(
r.totalActiveDays
)} on-campus days counted.`;
};
store.onChange(rerender);
rerender();
}
/* ---------- layout ---------- */
function buildLayout(store: Store, config: Config): HTMLElement {
const wrap = el("div", "wrap");
// Header
const header = el("header", "header");
const title = el("h1", "title");
title.append(spanTartan("CMU"), document.createTextNode(" Meal Estimator"));
const subtitle = el("p", "subtitle");
subtitle.textContent =
"Estimate the meal blocks and FLEX dollars you should have used by a given date.";
header.append(title, subtitle);
// Two-column body
const grid = el("div", "grid");
grid.append(buildControls(store, config), buildResultsPanel());
const footer = el("footer", "footer");
footer.textContent =
"Estimates spread each plan evenly across on-campus days. Informational only, not official balances.";
wrap.append(header, grid, footer);
return wrap;
}
function buildControls(store: Store, config: Config): HTMLElement {
const panel = el("section", "panel controls");
panel.append(sectionTitle("Your plan"));
// Plan selector
const planField = field("Meal plan");
const select = document.createElement("select");
select.className = "select";
select.id = "plan-select";
for (const plan of config.plans) {
const opt = document.createElement("option");
opt.value = plan.id;
opt.textContent = `${plan.displayName} — ${plan.totalMealBlocks} blocks / $${plan.totalFlexDollars} FLEX`;
select.appendChild(opt);
}
select.value = store.getSelections().selectedPlanId;
select.addEventListener("change", () => store.setSelectedPlan(select.value));
planField.appendChild(select);
panel.appendChild(planField);
// Date picker
const dateField = field("As of date");
const date = document.createElement("input");
date.type = "date";
date.className = "input";
date.id = "target-date";
date.min = toISODate(config.mealStartDate);
date.max = toISODate(config.mealEndDate);
date.value = toISODate(store.getSelections().targetDate);
date.addEventListener("change", () => {
const parsed = parseISODate(date.value);
if (parsed) store.setTargetDate(parsed);
});
dateField.appendChild(date);
const range = el("p", "hint");
range.textContent = `Meal period: ${toISODate(config.mealStartDate)} → ${toISODate(
config.mealEndDate
)}`;
dateField.appendChild(range);
panel.appendChild(dateField);
// Missing periods
panel.append(sectionTitle("Time away"));
if (config.missingPeriods.length === 0) {
const none = el("p", "hint");
none.textContent = "No breaks configured.";
panel.appendChild(none);
} else {
const list = el("div", "checkbox-list");
for (const period of config.missingPeriods) {
const row = el("label", "checkbox-row");
const cb = document.createElement("input");
cb.type = "checkbox";
cb.value = period.id;
cb.addEventListener("change", () =>
store.setMissingPeriodActive(period.id, cb.checked)
);
const text = el("span", "checkbox-text");
const name = el("span", "checkbox-name");
name.textContent = period.displayName;
const dates = el("span", "checkbox-dates");
dates.textContent = `${toISODate(period.startDate)} → ${toISODate(
period.endDate
)}`;
text.append(name, dates);
row.append(cb, text);
list.appendChild(row);
}
panel.appendChild(list);
const hint = el("p", "hint");
hint.textContent = "Check any breaks you spent off campus.";
panel.appendChild(hint);
}
// Extra excluded days
const extraField = field("Additional days to exclude");
const extra = document.createElement("input");
extra.type = "number";
extra.min = "0";
extra.step = "1";
extra.value = "0";
extra.className = "input";
extra.id = "extra-days";
extra.addEventListener("input", () =>
store.setExtraExcludedDays(Number(extra.value))
);
extraField.appendChild(extra);
const extraHint = el("p", "hint");
extraHint.textContent = "Other days you weren't eating on campus.";
extraField.appendChild(extraHint);
panel.appendChild(extraField);
return panel;
}
function buildResultsPanel(): HTMLElement {
const panel = el("section", "panel results-panel");
panel.append(sectionTitle("Estimate"));
const note = el("p", "days-note");
note.id = "days-note";
panel.appendChild(note);
const mount = el("div", "results");
mount.id = "results";
panel.appendChild(mount);
return panel;
}
/* ---------- results rendering ---------- */
function renderResults(r: EstimationResult): HTMLElement {
const container = el("div", "result-groups");
container.append(
resultGroup("Per week", r.perWeekMealBlocks, r.perWeekFlexDollars),
resultGroup("Consumed so far", r.consumedMealBlocks, r.consumedFlexDollars),
resultGroup("Remaining", r.remainingMealBlocks, r.remainingFlexDollars)
);
return container;
}
function resultGroup(label: string, blocks: number, flex: number): HTMLElement {
const group = el("div", "result-group");
const h = el("h3", "result-label");
h.textContent = label;
group.appendChild(h);
const row = el("div", "result-row");
row.append(
metric(fmtBlocks(blocks), "meal blocks"),
metric(fmtMoney(flex), "FLEX dollars")
);
group.appendChild(row);
return group;
}
function metric(value: string, unit: string): HTMLElement {
const m = el("div", "metric");
const v = el("span", "metric-value");
v.textContent = value;
const u = el("span", "metric-unit");
u.textContent = unit;
m.append(v, u);
return m;
}
/* ---------- config error ---------- */
function renderConfigError(errors: string[]): void {
const box = el("div", "config-error");
const h = el("h2", "");
h.textContent = "Configuration problem";
const p = el("p", "");
p.textContent = "The config file could not be loaded. Fix these and reload:";
const ul = document.createElement("ul");
for (const e of errors) {
const li = document.createElement("li");
li.textContent = e;
ul.appendChild(li);
}
box.append(h, p, ul);
appEl.innerHTML = "";
appEl.appendChild(box);
}
/* ---------- formatting + DOM helpers ---------- */
function fmtBlocks(n: number): string {
return (Math.round(n * 10) / 10).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
}
function fmtMoney(n: number): string {
return `$${n.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
function fmtDays(n: number): string {
return `${n} ${n === 1 ? "day" : "days"}`;
}
function el(tag: string, className: string): HTMLElement {
const e = document.createElement(tag);
if (className) e.className = className;
return e;
}
function sectionTitle(text: string): HTMLElement {
const h = el("h2", "section-title");
h.textContent = text;
return h;
}
function field(label: string): HTMLElement {
const f = el("div", "field");
const l = el("label", "field-label");
l.textContent = label;
f.appendChild(l);
return f;
}
function spanTartan(text: string): HTMLElement {
const s = el("span", "tartan");
s.textContent = text;
return s;
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import rawConfig from "./config.json";
import { loadConfig } from "./configLoader";
import { estimate } from "./engine";
import { parseISODate } from "./dates";
describe("real config sanity", () => {
const result = loadConfig(rawConfig);
it("loads the shipped config without errors", () => {
expect(result.ok).toBe(true);
});
it("produces sensible Green-plan numbers at the halfway point", () => {
if (!result.ok) throw new Error("config failed to load");
const config = result.config;
// Roughly mid-semester target.
const r = estimate(config, {
selectedPlanId: "green",
targetDate: parseISODate("2026-10-18")!,
activeMissingPeriodIds: new Set(),
extraExcludedDays: 0,
});
// Green = 210 blocks / 500 flex over ~112 days (Aug 24 -> Dec 13).
expect(r.totalActiveDays).toBe(112);
// per-week block rate ~ 210/112*7 = 13.125
expect(r.perWeekMealBlocks).toBeCloseTo(13.125, 2);
// consumed + remaining conserves the total
expect(r.consumedMealBlocks + r.remainingMealBlocks).toBeCloseTo(210);
expect(r.consumedFlexDollars + r.remainingFlexDollars).toBeCloseTo(500);
// partway through, consumption is strictly between 0 and total
expect(r.consumedMealBlocks).toBeGreaterThan(0);
expect(r.consumedMealBlocks).toBeLessThan(210);
});
it("checking a break reduces total active days", () => {
if (!result.ok) throw new Error("config failed to load");
const config = result.config;
const base = estimate(config, {
selectedPlanId: "green",
targetDate: parseISODate("2026-12-13")!,
activeMissingPeriodIds: new Set(),
extraExcludedDays: 0,
});
const withBreak = estimate(config, {
selectedPlanId: "green",
targetDate: parseISODate("2026-12-13")!,
activeMissingPeriodIds: new Set(["thanksgiving"]),
extraExcludedDays: 0,
});
expect(withBreak.totalActiveDays).toBeLessThan(base.totalActiveDays);
// Per-week rate rises because the same total spreads over fewer days.
expect(withBreak.perWeekMealBlocks).toBeGreaterThan(base.perWeekMealBlocks);
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { Config, Selections } from "./types";
type Listener = () => void;
/**
* Minimal reactive store: holds the immutable config plus mutable user
* selections, and notifies subscribers on any change.
*/
export class Store {
private listeners = new Set<Listener>();
constructor(
public readonly config: Config,
private selections: Selections
) {}
getSelections(): Selections {
return this.selections;
}
onChange(listener: Listener): void {
this.listeners.add(listener);
}
private update(patch: Partial<Selections>): void {
this.selections = { ...this.selections, ...patch };
this.emit();
}
setSelectedPlan(planId: string): void {
this.update({ selectedPlanId: planId });
}
setTargetDate(date: Date): void {
this.update({ targetDate: date });
}
setMissingPeriodActive(periodId: string, isActive: boolean): void {
const next = new Set(this.selections.activeMissingPeriodIds);
if (isActive) next.add(periodId);
else next.delete(periodId);
this.update({ activeMissingPeriodIds: next });
}
setExtraExcludedDays(count: number): void {
this.update({ extraExcludedDays: Math.max(0, Math.floor(count) || 0) });
}
private emit(): void {
for (const l of this.listeners) l();
}
}
+301
View File
@@ -0,0 +1,301 @@
:root {
/* Dark surfaces */
--bg: #0e0f12;
--surface: #16181d;
--surface-2: #1d2027;
--border: #2a2e37;
--border-strong: #3a3f4b;
/* Text */
--text: #e8eaed;
--text-dim: #9aa0aa;
--text-faint: #6b7280;
/* CMU Tartan accent */
--tartan: #c8102e;
--tartan-soft: #e0384f;
--radius: 10px;
--radius-sm: 7px;
--gap: 20px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 940px;
margin: 0 auto;
padding: 48px 24px 40px;
}
/* Header */
.header {
margin-bottom: 32px;
border-bottom: 1px solid var(--border);
padding-bottom: 24px;
}
.title {
font-size: 30px;
font-weight: 700;
margin: 0 0 6px;
letter-spacing: -0.02em;
}
.title .tartan {
color: var(--tartan);
}
.subtitle {
margin: 0;
color: var(--text-dim);
font-size: 15px;
}
/* Grid */
.grid {
display: grid;
grid-template-columns: 5fr 6fr;
gap: var(--gap);
align-items: start;
}
@media (max-width: 760px) {
.grid {
grid-template-columns: 1fr;
}
}
/* Panels */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 22px;
}
.section-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
margin: 0 0 14px;
}
.controls .section-title:not(:first-child) {
margin-top: 26px;
padding-top: 22px;
border-top: 1px solid var(--border);
}
/* Fields */
.field {
margin-bottom: 16px;
}
.field:last-child {
margin-bottom: 0;
}
.field-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-dim);
margin-bottom: 7px;
}
.select,
.input {
width: 100%;
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.12s ease;
}
.select:focus,
.input:focus {
outline: none;
border-color: var(--tartan);
}
/* Native date/number controls readable on dark */
.input[type="date"],
.input[type="number"] {
color-scheme: dark;
}
.hint {
margin: 7px 0 0;
font-size: 12px;
color: var(--text-faint);
}
/* Checkboxes */
.checkbox-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
cursor: pointer;
transition: border-color 0.12s ease;
}
.checkbox-row:hover {
border-color: var(--border-strong);
}
.checkbox-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--tartan);
cursor: pointer;
flex-shrink: 0;
}
.checkbox-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.checkbox-name {
font-size: 14px;
font-weight: 500;
}
.checkbox-dates {
font-size: 12px;
color: var(--text-faint);
font-family: "IBM Plex Mono", ui-monospace, monospace;
}
/* Results */
.results-panel {
position: sticky;
top: 24px;
}
.days-note {
margin: -4px 0 18px;
font-size: 13px;
color: var(--text-dim);
font-family: "IBM Plex Mono", ui-monospace, monospace;
}
.result-groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-group {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 16px 18px;
}
.result-group:first-child {
border-left: 3px solid var(--tartan);
}
.result-label {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
}
.result-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric-value {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.metric-unit {
font-size: 12px;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Footer */
.footer {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-faint);
}
/* Config error */
.config-error {
max-width: 640px;
margin: 64px auto;
background: var(--surface);
border: 1px solid var(--tartan);
border-radius: var(--radius);
padding: 24px 28px;
}
.config-error h2 {
margin: 0 0 8px;
color: var(--tartan-soft);
}
.config-error ul {
margin: 12px 0 0;
padding-left: 20px;
color: var(--text-dim);
font-size: 14px;
}
.config-error li {
margin-bottom: 6px;
}
+51
View File
@@ -0,0 +1,51 @@
/** A single dining plan and its full-period totals. */
export interface Plan {
id: string;
displayName: string;
totalMealBlocks: number;
totalFlexDollars: number;
}
/**
* A named span the student was not on campus (e.g. Fall Break).
* Both dates are inclusive.
*/
export interface MissingPeriod {
id: string;
displayName: string;
startDate: Date;
endDate: Date;
}
/** Normalized, validated configuration. */
export interface Config {
mealStartDate: Date;
mealEndDate: Date;
plans: Plan[];
missingPeriods: MissingPeriod[];
}
/** Current user-adjustable inputs. */
export interface Selections {
selectedPlanId: string;
targetDate: Date;
activeMissingPeriodIds: Set<string>;
extraExcludedDays: number;
}
/** Output of the estimation engine. */
export interface EstimationResult {
perWeekMealBlocks: number;
perWeekFlexDollars: number;
consumedMealBlocks: number;
consumedFlexDollars: number;
remainingMealBlocks: number;
remainingFlexDollars: number;
totalActiveDays: number;
activeDaysConsumed: number;
}
/** Result of loading + validating the raw config. */
export type ConfigResult =
| { ok: true; config: Config }
| { ok: false; errors: string[] };