functional
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "307b52f5-d095-4937-bdb7-2eafc89e008d", "workflowType": "design-first", "specType": "feature"}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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>
|
||||
Generated
+1455
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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[] };
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user