From 3d4d75fdfb1fd42e3050d5f3b35e05918118d7e3 Mon Sep 17 00:00:00 2001 From: lockeshor Date: Thu, 17 Sep 2026 01:19:19 -0400 Subject: [PATCH] functional --- .gitignore | 4 + .kiro/specs/cmu-meal-estimator/.config.kiro | 1 + .kiro/specs/cmu-meal-estimator/design.md | 357 +++++ README.md | 133 ++ index.html | 18 + package-lock.json | 1455 +++++++++++++++++++ package.json | 17 + src/config.json | 23 + src/configLoader.ts | 203 +++ src/dates.ts | 84 ++ src/engine.test.ts | 98 ++ src/engine.ts | 93 ++ src/main.ts | 280 ++++ src/sanity.test.ts | 59 + src/store.ts | 52 + src/style.css | 301 ++++ src/types.ts | 51 + tsconfig.json | 20 + vite.config.ts | 8 + 19 files changed, 3257 insertions(+) create mode 100644 .gitignore create mode 100644 .kiro/specs/cmu-meal-estimator/.config.kiro create mode 100644 .kiro/specs/cmu-meal-estimator/design.md create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/config.json create mode 100644 src/configLoader.ts create mode 100644 src/dates.ts create mode 100644 src/engine.test.ts create mode 100644 src/engine.ts create mode 100644 src/main.ts create mode 100644 src/sanity.test.ts create mode 100644 src/store.ts create mode 100644 src/style.css create mode 100644 src/types.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4d699a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.local +.DS_Store diff --git a/.kiro/specs/cmu-meal-estimator/.config.kiro b/.kiro/specs/cmu-meal-estimator/.config.kiro new file mode 100644 index 0000000..385b2e0 --- /dev/null +++ b/.kiro/specs/cmu-meal-estimator/.config.kiro @@ -0,0 +1 @@ +{"specId": "307b52f5-d095-4937-bdb7-2eafc89e008d", "workflowType": "design-first", "specType": "feature"} diff --git a/.kiro/specs/cmu-meal-estimator/design.md b/.kiro/specs/cmu-meal-estimator/design.md new file mode 100644 index 0000000..aaff728 --- /dev/null +++ b/.kiro/specs/cmu-meal-estimator/design.md @@ -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,
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,
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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4675a1 --- /dev/null +++ b/README.md @@ -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. diff --git a/index.html b/index.html new file mode 100644 index 0000000..22437e1 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + CMU Meal Estimator + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f1c9048 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1455 @@ +{ + "name": "cmu-meal-estimator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cmu-meal-estimator", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.6.3", + "vite": "^5.4.10", + "vitest": "^2.1.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7695240 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/config.json b/src/config.json new file mode 100644 index 0000000..0a32254 --- /dev/null +++ b/src/config.json @@ -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" } + ] +} diff --git a/src/configLoader.ts b/src/configLoader.ts new file mode 100644 index 0000000..7bd7936 --- /dev/null +++ b/src/configLoader.ts @@ -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; + + 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(); + + 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; + + 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(); + + 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; + + 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; +} diff --git a/src/dates.ts b/src/dates.ts new file mode 100644 index 0000000..351a6c9 --- /dev/null +++ b/src/dates.ts @@ -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); +} diff --git a/src/engine.test.ts b/src/engine.test.ts new file mode 100644 index 0000000..9a1c71c --- /dev/null +++ b/src/engine.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/engine.ts b/src/engine.ts new file mode 100644 index 0000000..665a04e --- /dev/null +++ b/src/engine.ts @@ -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): 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): 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)); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..172efdd --- /dev/null +++ b/src/main.ts @@ -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("#results")!; + const daysNote = appEl.querySelector("#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; +} diff --git a/src/sanity.test.ts b/src/sanity.test.ts new file mode 100644 index 0000000..1c6b300 --- /dev/null +++ b/src/sanity.test.ts @@ -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); + }); +}); diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..8148ac0 --- /dev/null +++ b/src/store.ts @@ -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(); + + 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): 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(); + } +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..09ae077 --- /dev/null +++ b/src/style.css @@ -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; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6bf9f76 --- /dev/null +++ b/src/types.ts @@ -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; + 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[] }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..50e85ed --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f524ede --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "./", + build: { + outDir: "dist", + }, +});