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
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
*.local
.DS_Store
@@ -0,0 +1 @@
{"specId": "307b52f5-d095-4937-bdb7-2eafc89e008d", "workflowType": "design-first", "specType": "feature"}
+357
View File
@@ -0,0 +1,357 @@
# Design Document: CMU Meal Estimator
## Overview
The CMU Meal Estimator is a single-page website that tells a Carnegie Mellon student how many
meal blocks and FLEX dollars they *should* have consumed by a given date, and how many should
remain. The tool spreads a plan's total meal blocks and FLEX dollars evenly across the active
days of the semester (the days a student is expected to be on campus), then reports three views
of that budget: a per-week rate, an estimated amount consumed up to a target date, and an
estimated remaining balance.
Everything that varies year to year or student to student lives in a single configuration file:
the eight dining plans (each with its total meal blocks and FLEX dollars), the semester meal
start date, and an extensible list of named "missing periods" (breaks or trips like Fall Break)
where a student would not be on campus. The UI reads this config reactively, so adding a ninth
plan or a new break requires only a config edit — no code changes. The user selects a plan,
adjusts a date (defaulting to today), toggles checkboxes for the missing periods that apply to
them, and optionally enters extra excluded days. The estimates recompute live.
The interface uses a dark theme with restrained Carnegie Mellon accents (Tartan red as the
primary accent), prioritizing clear typography and legible numbers over decorative gradients or
heavy visual effects.
## Architecture
The application is a client-side single-page app with no backend. Configuration is loaded once
at startup, validated, and held in application state. All estimation is pure computation derived
from `config + user selections`, so the UI is a straightforward function of state.
```mermaid
graph TD
Config[config file: plans, start date, missing periods] --> Loader[Config Loader & Validator]
Loader --> Store[App State Store]
UI[UI Controls: date picker, plan selector,<br/>missing-period checkboxes, extra-days input] --> Store
Store --> Engine[Estimation Engine pure functions]
Engine --> Results[Results View: per-week / consumed / remaining]
Store -->|reactive plan + period list| UI
Results --> UI
```
Layering:
- **Config Loader & Validator** — reads the config file, checks shape and value constraints,
produces a normalized, typed config object or a list of validation errors.
- **App State Store** — holds the loaded config plus the current user selections (selected plan,
target date, set of active missing-period ids, extra excluded days). Emits changes so the UI
and engine stay in sync.
- **Estimation Engine** — pure functions that take `(config, selections)` and return the
computed estimates. No side effects, no I/O; this is the testable core.
- **UI layer** — renders controls from config (plans and missing periods are data-driven) and
renders results from the engine output.
## Sequence Diagram: Recompute on User Input
```mermaid
sequenceDiagram
participant U as User
participant UI as UI Controls
participant S as State Store
participant E as Estimation Engine
participant R as Results View
Note over UI: On load, defaults date to today
U->>UI: Select plan / change date / toggle break / set extra days
UI->>S: updateSelection(change)
S->>E: estimate(config, selections)
E->>E: compute active days, daily rate,<br/>consumed, remaining
E-->>S: EstimationResult
S->>R: render(result)
R-->>U: Per-week, consumed, remaining meals & FLEX
```
## Components and Interfaces
### Component 1: Config Loader & Validator
**Purpose**: Load the configuration file and guarantee downstream code receives a valid,
normalized config or a clear set of errors.
**Interface**:
```pascal
INTERFACE ConfigLoader
// Loads and validates the raw config source into a normalized Config.
// Returns either a valid Config or a list of human-readable errors.
FUNCTION loadConfig(rawSource): ConfigResult
// Pure validation over an already-parsed structure.
FUNCTION validate(parsed): ListOfValidationError
END INTERFACE
```
**Responsibilities**:
- Parse the config source and confirm required fields exist.
- Enforce value constraints (see Data Models validation rules).
- Normalize dates to a single internal representation.
- Ensure every plan and missing period has a unique id.
### Component 2: App State Store
**Purpose**: Single source of truth for config + user selections, and the reactive bridge
between UI and engine.
**Interface**:
```pascal
INTERFACE StateStore
FUNCTION getConfig(): Config
FUNCTION getSelections(): Selections
// Mutators for each user-adjustable input.
PROCEDURE setSelectedPlan(planId)
PROCEDURE setTargetDate(date)
PROCEDURE setMissingPeriodActive(periodId, isActive)
PROCEDURE setExtraExcludedDays(count)
// Subscribe to any selection/config change (drives re-render + recompute).
PROCEDURE onChange(listener)
END INTERFACE
```
**Responsibilities**:
- Initialize `targetDate` to the current date on startup.
- Hold the set of active missing-period ids and the extra excluded-days count.
- Notify subscribers when anything relevant changes.
### Component 3: Estimation Engine
**Purpose**: Compute the meal-block and FLEX-dollar estimates. Pure and fully testable.
**Interface**:
```pascal
INTERFACE EstimationEngine
// Master entry point: everything the results view needs.
FUNCTION estimate(config, selections): EstimationResult
// Count of days a student is expected to be on campus across the
// full meal period, after removing active missing periods.
FUNCTION totalActiveDays(config, activePeriodIds): Integer
// Active days from meal start date up to (and including) targetDate,
// minus active missing periods within that window, minus extraExcludedDays.
FUNCTION activeDaysConsumed(config, selections): Integer
END INTERFACE
```
**Responsibilities**:
- Determine the meal period end date (from config, or derived — see Open Questions).
- Compute total active days and the daily rate for blocks and FLEX.
- Compute consumed vs. remaining for both blocks and FLEX.
- Compute the per-week rate (daily rate x 7).
### Component 4: UI Layer
**Purpose**: Render data-driven controls and the results view; forward user actions to the store.
**Responsibilities**:
- Render a plan selector populated from `config.plans`.
- Render a date picker defaulting to today, adjustable by the user.
- Render one checkbox per entry in `config.missingPeriods` (reactive — any number supported).
- Render a numeric input for extra excluded days.
- Render three result groups (per-week, consumed, remaining), each showing meals and FLEX.
- Apply the dark CMU-accented theme.
## Data Models
### Model 1: Config (root)
```pascal
STRUCTURE Config
mealStartDate: Date // first day of the meal period
mealEndDate: Date // last day of the meal period (optional; see Open Questions)
plans: ListOf(Plan) // the eight (or more) dining plans
missingPeriods: ListOf(MissingPeriod) // reactive; zero or more
END STRUCTURE
```
**Validation Rules**:
- `mealStartDate` must be a valid date.
- If `mealEndDate` is present, it must be strictly after `mealStartDate`.
- `plans` must be non-empty; each `Plan.id` unique.
- `missingPeriods` may be empty; each `MissingPeriod.id` unique.
### Model 2: Plan
```pascal
STRUCTURE Plan
id: String // stable key, e.g. "green"
displayName: String // e.g. "Green"
totalMealBlocks: Integer // total blocks for the meal period
totalFlexDollars: Number // total FLEX dollars for the meal period
END STRUCTURE
```
**Validation Rules**:
- `id` and `displayName` non-empty.
- `totalMealBlocks` >= 0.
- `totalFlexDollars` >= 0.
The eight expected plans: green, blue, red, yellow, tartan flex, scotty's choice,
whitfield's favor, piper select.
### Model 3: MissingPeriod
```pascal
STRUCTURE MissingPeriod
id: String // stable key, e.g. "fall-break"
displayName: String // e.g. "Fall Break"
startDate: Date // inclusive
endDate: Date // inclusive
END STRUCTURE
```
**Validation Rules**:
- `id` and `displayName` non-empty.
- `startDate` <= `endDate`.
- Period should fall within `[mealStartDate, mealEndDate]` (warn if outside).
### Model 4: Selections (user state)
```pascal
STRUCTURE Selections
selectedPlanId: String // which plan is active
targetDate: Date // "as of" date; defaults to today
activeMissingPeriodIds: SetOf(String) // checkboxes the user ticked
extraExcludedDays: Integer // manual additional days not to count, >= 0
END STRUCTURE
```
**Validation Rules**:
- `selectedPlanId` must match a `Plan.id` in config.
- `targetDate` clamped to `[mealStartDate, mealEndDate]` for consumed calculations.
- `extraExcludedDays` >= 0.
### Model 5: EstimationResult (engine output)
```pascal
STRUCTURE EstimationResult
perWeekMealBlocks: Number
perWeekFlexDollars: Number
consumedMealBlocks: Number
consumedFlexDollars: Number
remainingMealBlocks: Number
remainingFlexDollars: Number
totalActiveDays: Integer
activeDaysConsumed: Integer
END STRUCTURE
```
## Estimation Approach (high-level)
The engine models the plan as an evenly distributed budget over the days the student is
expected to be on campus:
1. **Total active days** = every calendar day in `[mealStartDate, mealEndDate]`, minus the days
covered by any *active* missing period the user checked.
2. **Daily rate** = `plan.total / totalActiveDays`, computed separately for meal blocks and FLEX.
3. **Active days consumed** = active days in `[mealStartDate, targetDate]`, minus active missing
periods within that window, minus `extraExcludedDays`.
4. **Consumed** = `dailyRate x activeDaysConsumed`.
5. **Remaining** = `plan.total - consumed`.
6. **Per-week** = `dailyRate x 7`.
This keeps the logic pure and easy to verify, and makes the missing-period checkboxes and the
extra-days input behave intuitively: excluding time you were away shifts your expected
consumption down.
## Correctness Properties
### Property 1: Budget Conservation
For any config and selections, `consumedMealBlocks + remainingMealBlocks == plan.totalMealBlocks`
when `targetDate == mealEndDate` and no extra days are excluded (budget fully accounted for).
### Property 2: Consumed Bounds
`consumed` values are always in `[0, plan.total]` after clamping.
### Property 3: Remaining Bounds
`remaining` values are always in `[0, plan.total]` after clamping.
### Property 4: Monotonic Exclusions
Increasing `extraExcludedDays` or activating additional missing periods never increases
`consumed`.
### Property 5: Active Days Ordering
`totalActiveDays >= activeDaysConsumed >= 0`.
### Property 6: Per-Week Rate
Per-week rate is non-negative and equals `dailyRate x 7`.
## Error Handling
### Error Scenario 1: Invalid or unparseable config
**Condition**: Config file is missing required fields, malformed, or violates validation rules.
**Response**: Show a clear, human-readable error listing each problem; do not render estimates.
**Recovery**: User fixes the config and reloads.
### Error Scenario 2: No plan selected / unknown plan id
**Condition**: `selectedPlanId` does not match any plan.
**Response**: Default to the first plan in config, or prompt the user to select one.
**Recovery**: User selects a valid plan.
### Error Scenario 3: Zero active days (division by zero)
**Condition**: Active missing periods cover the entire meal window.
**Response**: Report a daily rate of 0 and surface a message explaining that all days are
excluded; avoid dividing by zero.
**Recovery**: User unchecks periods or adjusts config.
### Error Scenario 4: Target date outside the meal window
**Condition**: `targetDate` is before `mealStartDate` or after `mealEndDate`.
**Response**: Clamp for the consumed calculation (0 consumed before start, full window at/after
end) and optionally note the clamp in the UI.
**Recovery**: User adjusts the date.
## Testing Strategy
### Unit Testing Approach
- Config validation: valid config passes; each violated rule produces the expected error.
- Date math: active-day counts across windows, including boundaries and overlapping periods.
- Engine: consumed/remaining/per-week for representative plans and dates.
### Property-Based Testing Approach
Verify the Correctness Properties above hold across randomized configs, plans, dates, missing-
period selections, and extra-day counts (e.g., conservation of budget at end date; monotonicity
of consumed with respect to excluded days).
**Property Test Library**: fast-check (if implemented in JS/TS) or the ecosystem-appropriate
equivalent chosen at implementation time.
### Integration Testing Approach
- Reactivity: adding a missing period in config renders a new checkbox; toggling it changes
results. Changing the date updates consumed/remaining live.
## Security Considerations
Client-side only, no authentication, no persisted personal data, no backend to attack. Config is
trusted local data. Estimates are informational, not authoritative billing figures.
## Dependencies
- A front-end rendering approach with reactive state (framework choice deferred to the tasks
phase).
- A date-handling utility for accurate day counting across ranges.
- A property-based testing library for verifying correctness properties.
- The configuration file itself (plans, meal start date, missing periods).
## Open Questions (to resolve during requirements)
- **Meal end date**: taken directly from config, or derived (e.g., start date + fixed semester
length)? Design assumes it is configurable, defaulting to a derived value if absent.
- **Config format/location**: JSON vs. another format, and how it is delivered to the client.
- **Rounding**: whether meal blocks are shown as whole numbers (floor/round) or fractional, and
how FLEX dollars are rounded (to cents).
- **Default plan figures**: exact block/FLEX totals per plan for the current year; these are
config values the user will confirm.
+133
View File
@@ -0,0 +1,133 @@
# CMU Meal Estimator
A small single-page web app that estimates how many **meal blocks** and **FLEX dollars** a
Carnegie Mellon student should have consumed by a given date — and how many should remain — based
on their dining plan and time spent on campus.
The tool spreads a plan's totals evenly across the days you're expected to be on campus, then
reports three views of that budget: a **per-week** rate, an amount **consumed so far**, and an
estimated **remaining** balance, for both meal blocks and FLEX.
> Estimates are informational and based on even distribution across on-campus days. They are not
> official dining balances.
## Features
- Pick from all eight dining plans (Green, Blue, Red, Yellow, Tartan Flex, Scotty's Choice,
Whitfield's Favor, Piper Select).
- "As of" date picker that defaults to today and is clamped to the meal period.
- 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.
- Dark theme with restrained CMU Tartan-red accents.
## Getting started
Requires [Node.js](https://nodejs.org/) 18+.
```bash
npm install # install dependencies
npm run dev # start the dev server at http://localhost:5173
```
### Other scripts
```bash
npm run build # type-check and produce a production build in dist/
npm run preview # serve the production build locally
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.
```jsonc
{
"mealStartDate": "2026-08-24", // first day of the meal period (YYYY-MM-DD)
"mealEndDate": "2026-12-13", // last day of the meal period (YYYY-MM-DD)
"plans": [
{
"id": "green", // stable unique key
"displayName": "Green", // shown in the dropdown
"totalMealBlocks": 210, // total blocks for the whole meal period
"totalFlexDollars": 500 // total FLEX dollars for the whole meal period
}
// ...more plans
],
"missingPeriods": [
{
"id": "fall-break", // stable unique key
"displayName": "Fall Break", // shown next to the checkbox
"startDate": "2026-10-19", // inclusive
"endDate": "2026-10-20" // inclusive
}
// ...add as many as you like; each becomes a checkbox
]
}
```
Notes:
- **The shipped plan numbers are placeholders.** Replace `totalMealBlocks` and `totalFlexDollars`
with the real figures from your CMU dining agreement, and set the correct `mealStartDate` /
`mealEndDate` for your semester.
- Add or remove plans and missing periods freely. Every missing period automatically becomes a
checkbox in the UI.
- If the config is malformed (missing fields, bad dates, duplicate ids, negative totals), the app
shows a readable list of problems instead of rendering broken estimates.
## How the estimate works
1. **Total active days** = every calendar day in `[mealStartDate, mealEndDate]`, minus the days
covered by any break you check.
2. **Daily rate** = `plan total / total active days`, computed separately for blocks and FLEX.
3. **Active days consumed** = active days from the start up to your "as of" date, minus checked
breaks in that window, minus any additional excluded days.
4. **Consumed** = `daily rate × active days consumed`.
5. **Remaining** = `plan total − consumed`.
6. **Per week** = `daily rate × 7`.
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.
## Project structure
```
index.html # app shell
src/
config.json # all tunable values (plans, dates, breaks)
types.ts # shared types
dates.ts # whole-day date helpers
configLoader.ts # validates + normalizes config
engine.ts # pure estimation math (no DOM)
store.ts # tiny reactive state store
main.ts # builds the UI, re-renders on change
style.css # dark CMU-accented theme
engine.test.ts # engine unit tests
sanity.test.ts # checks against the shipped config
```
## Tech stack
- [Vite](https://vitejs.dev/) for dev server and bundling
- TypeScript (strict mode)
- [Vitest](https://vitest.dev/) for tests
- No UI framework — plain DOM with a small reactive store
## Tests
The estimation engine is a set of pure functions, kept separate from the DOM so it can be tested
directly:
```bash
npm test
```
Covers day counting across boundaries, budget conservation, monotonicity when excluding days, the
zero-active-days (division-by-zero) edge case, and sanity checks against the shipped config.
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CMU Meal Estimator</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1455
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "cmu-meal-estimator",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4"
}
}
+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[] };
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src", "vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
export default defineConfig({
base: "./",
build: {
outDir: "dist",
},
});