diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3b6ab90
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+src-tauri/target/
+.DS_Store
+*.local
diff --git a/README.md b/README.md
index 224b3bf..6a3cc10 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,214 @@
-# InsightsTool
+# Maine DOC Insight
+
+A desktop analytics tool for Maine DOC education program data.
+Built with [Tauri](https://tauri.app/) (Rust backend) + React + Chart.js.
+
+## What it does
+
+**Dashboard views** — six data views for exploring program and resident outcomes:
+
+| View | What it covers |
+|------|----------------|
+| Home | System-wide KPIs, top programs by completion, near-release engagement snapshot |
+| Programs | Completion rates, waitlists vs. throughput, second-enrollment rates, breakdown by LSI/custody/education level |
+| Residents | Never-engaged roster, near-release residents by engagement status, no-diploma residents not in education |
+| Facilities | Facility completion rates (raw and mix-adjusted), operational focus areas |
+| Comparison | Side-by-side facility comparisons |
+| Data Quality | Field-level missing data report, metric quality status, integrity checks |
+
+**AI chat panel** — ask natural-language questions about the loaded data. The planner routes your question to the right metrics; the writer synthesizes a cited response. Supports multiple AI providers (see below). No resident names or DOC numbers are sent to any external API — only aggregated metrics.
+
+**Data quality assessment** — automatic checks on every loaded dataset. Missing files, low coverage, and suspicious values surface as per-view badges and a dedicated Data Quality view with field-level detail.
+
+**Demo mode** — explore sample AI responses without loading real data or entering an API key. Toggle the switch in the chat panel header.
+
+## Prerequisites
+
+- [Node.js](https://nodejs.org/) ≥ 18
+- [Rust](https://www.rust-lang.org/tools/install) + Cargo (for Tauri)
+- [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites) for your OS
+
+## Quick start
+
+```bash
+npm install
+
+# Run in dev mode (opens a hot-reload window)
+npm run tauri dev
+
+# Or run just the web frontend (no Rust required)
+npm run dev
+# then open http://localhost:5173
+```
+
+## Build for distribution
+
+```bash
+npm run tauri build
+# Output: src-tauri/target/release/bundle/
+```
+
+## AI providers
+
+Open the chat panel (✦ Chat tab on the right) and select a provider.
+
+| Provider | Setup | Privacy |
+|----------|-------|---------|
+| Google Gemini | API key from [aistudio.google.com](https://aistudio.google.com) | Aggregated metrics sent to Google |
+| OpenAI | API key from [platform.openai.com](https://platform.openai.com/api-keys) | Aggregated metrics sent to OpenAI |
+| Anthropic Claude | API key from [console.anthropic.com](https://console.anthropic.com) | Aggregated metrics sent to Anthropic |
+| Ollama (local) | See below — no key required | Nothing leaves your machine |
+
+### Local AI with Ollama
+
+No API key required. Runs entirely on your machine.
+
+**1. Install Ollama**
+
+Download from [ollama.ai](https://ollama.ai) and follow the installer for your OS.
+
+**2. Start the server**
+
+```bash
+ollama serve
+```
+
+Ollama must be running before you use the chat panel. If you see a connection error, run this command first.
+
+**3. Pull a model**
+
+```bash
+# Recommended — works on most laptops (~4.7 GB)
+ollama pull qwen2.5:7b
+
+# Better output quality if you have a GPU or more RAM (~9 GB)
+ollama pull qwen2.5:14b
+```
+
+**4. Enable in the app**
+
+Open the chat panel, select **Ollama (local)**, and click **Enable local AI**.
+
+**Notes:**
+- The first response after pulling a model may be slow (~30s) while it loads into memory. Subsequent responses are faster.
+- Structured query planning requires solid instruction-following ability. `qwen2.5:7b` is the minimum recommended size; `qwen2.5:14b` is more reliable for complex questions.
+- If the model returns malformed output, the app retries once with a corrected prompt before surfacing an error suggesting a larger model.
+- If Ollama is running on a non-default port or host, edit `src/config/llm.json` and change the `endpoints.ollama` value.
+
+## File schema
+
+All files are optional — insights are generated from whatever is loaded. Files are organized into three sections in the loader.
+
+### DOC data
+
+Exported from the Maine DOC / OMS system. Accepts CSV or Excel (`.xlsx`).
+
+**Core:**
+
+| Dataset | Key columns |
+|---------|-------------|
+| Resident Roster | `ID`, `STATUS_DESC`, `LOCATION_TO`, `LOC_TYPE_TO`, `ODARA_SCORE`, `STATIC99R_SCORE`, `LSI_RATING`, `HIGHEST_ED_LVL`, `CUSTODY_LVL`, `Earliest Release Date`, `SENTENCE_OFFENSE`, `Offense Date`, `COUNTY`, `LAST_PROGRAM_COMPLETED`, `JOB_ASSIGN` |
+| Program Participation | `ID`, `GENDER`, `Facility`, `CUSTODY LEVEL`, `Housing Unit`, `Housing Pod`, `Room`, `Bed`, `Program`, `Program Status`, `Program Termination Or Completion Date`, `Earliest Release Date` |
+
+**Advanced** (toggle "Advanced data" in the loader to reveal):
+
+> **Note:** Advanced dataset column schemas are based on expected data structure and have not been cross-referenced against actual Maine DOC exports. Column names may differ from real exports.
+
+| Dataset | Key columns |
+|---------|-------------|
+| Incident Records | `ID`, `Incident Date`, `Incident Type`, `Severity`, `Sanction`, `Facility` |
+| Work Assignments | `ID`, `Assignment Type`, `Role Title`, `Start Date`, `End Date`, `Transferable Skills Flag`, `Facility` |
+| Case Plans | `ID`, `State ID Obtained`, `State ID Date`, `Job Lined Up`, `Housing Plan`, `Trust Account Balance`, `Savings Goal Met` |
+| Credentials | `ID`, `Credential Type`, `Credential Name`, `Date Earned`, `Issuing Body`, `Verified` |
+| Housing History | `ID`, `Move Date`, `Housing Unit`, `Pod`, `Room`, `Bed`, `Custody Level`, `Move Reason`, `Facility` |
+
+### UnlockEd platform data
+
+> **Note:** This is a subset of the data available from the UnlockEd platform, converted to CSV files for local use.
+
+CSV exports from the UnlockEd database. Drop multiple files at once onto the UnlockEd section.
+
+`users.csv`, `facilities.csv`, `programs.csv`, `program_classes.csv`, `program_class_enrollments.csv`, `program_completions.csv`, `program_class_events.csv`, `program_class_event_attendance.csv`, `user_session_tracking.csv`, `program_credit_types.csv`
+
+### Mapping & reference
+
+Optional. Shown when "Advanced data" is enabled.
+
+| File | Purpose |
+|------|---------|
+| `program_crosswalk.csv` | Maps DOC program names to UnlockEd program IDs; enables fuzzy-match fallback |
+| `field_mapping.csv` | Maps OMS/DOC column names to internal tool fields; handles future export schema changes |
+
+## Project structure
+
+```
+maine-doc-insight/
+├── src/
+│ ├── chat/
+│ │ ├── providers.ts # Provider registry (Gemini, OpenAI, Claude, Ollama)
+│ │ ├── router.ts # Planner — routes questions to metric queries
+│ │ ├── writer.ts # Writer — synthesizes cited responses
+│ │ ├── prompts.ts # System instructions and user turn builders
+│ │ ├── aggregator.ts # Executes DataPlan against loaded data
+│ │ ├── contextPacket.ts # Assembles metric context for the writer
+│ │ ├── flatTables.ts # Flat table projections for the aggregator
+│ │ ├── actionItems.ts # Action item extraction
+│ │ ├── refusal.ts # Refusal message builder
+│ │ └── fetchWithRetry.ts # Fetch wrapper with retry + network error handling
+│ ├── lib/
+│ │ ├── ingest.ts # CSV/Excel parsing, buildCoreFromDOC, buildCoreFromUL
+│ │ ├── analytics.ts # All metric computations
+│ │ ├── dataQuality.ts # Data quality checks and grading
+│ │ └── demoExport.ts # Exports synthetic demo data as downloadable CSVs
+│ ├── data/
+│ │ ├── mockData.ts # Synthetic demo data generator
+│ │ ├── schemaRegistry.ts # Metric registry (IDs, labels, table mappings)
+│ │ └── demoResponses.ts # Canned AI responses for demo mode
+│ ├── config/
+│ │ ├── chat.json # Chat panel UI settings (confidence threshold, drawer width)
+│ │ ├── llm.json # Provider endpoints and generation parameters
+│ │ ├── models.json # Default model lists per provider
+│ │ ├── data_quality.json # Missing-data warn/crit thresholds and LSI clustering parameters
+│ │ ├── analytics.json # Algorithm parameters: top-N, mix-adjustment, cohort windows, statistical constants
+│ │ ├── thresholds.json # Display thresholds: attention flag scoring, QoL green/yellow bands, color cutoffs
+│ │ └── facilities.json # Facility code → display name pairs (10 Maine DOC facilities)
+│ ├── components/
+│ │ ├── chat/
+│ │ │ └── ChatDrawer.tsx # AI chat panel with provider selection
+│ │ ├── FileLoader.tsx # Drag-and-drop CSV ingestion UI
+│ │ ├── OmsCards.tsx # OMS metric cards
+│ │ ├── ChartFootnote.tsx # Per-chart data quality footnotes
+│ │ ├── DataUnavailableCard.tsx # Placeholder when required data is missing
+│ │ ├── DataQualityBanner.tsx
+│ │ └── SmartActionItem.tsx
+│ ├── contexts/
+│ │ └── DataQualityContext.tsx
+│ ├── views/
+│ │ ├── HomeView.tsx # Dashboard — KPIs, top programs, near-release
+│ │ ├── ProgramsView.tsx # Completion rates, waitlists, second-enrollment, breakdowns
+│ │ ├── ResidentsView.tsx # Near-release, never-engaged, no-diploma roster
+│ │ ├── FacilitiesView.tsx # Facility completion rates (raw + mix-adjusted)
+│ │ ├── ComparisonView.tsx # Side-by-side facility comparisons
+│ │ └── DataQualityView.tsx # Field-level data quality report
+│ ├── types.ts # All TypeScript types
+│ ├── App.tsx # Shell + navigation + quality indicators
+│ └── app.css # Dark analytics theme
+├── src-tauri/ # Rust/Tauri backend (minimal)
+├── index.html
+├── vite.config.ts
+└── package.json
+```
+
+## Notes on mix-adjustment
+
+The mix-adjusted completion rate applies a correction for LSI risk composition.
+If a facility has a higher share of High/Maximum LSI residents than the system average,
+its raw rate is adjusted upward proportionally — surfacing true operational performance
+independent of resident population composition.
+
+Adjustment formula:
+```
+mixAdjustedRate = rawRate + (facilityHighRiskShare - systemHighRiskShare) × 15pp
+```
+This is a simplified first-order adjustment. A regression-based approach would be more
+rigorous for formal reporting.
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..1e9409f
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,36 @@
+import tsPlugin from "@typescript-eslint/eslint-plugin";
+import tsParser from "@typescript-eslint/parser";
+import reactPlugin from "eslint-plugin-react";
+import reactHooksPlugin from "eslint-plugin-react-hooks";
+
+export default [
+ {
+ ignores: ["dist/**", "src-tauri/**", "node_modules/**"],
+ },
+ {
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ parser: tsParser,
+ parserOptions: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ ecmaFeatures: { jsx: true },
+ },
+ },
+ plugins: {
+ "@typescript-eslint": tsPlugin,
+ react: reactPlugin,
+ "react-hooks": reactHooksPlugin,
+ },
+ rules: {
+ ...tsPlugin.configs.recommended.rules,
+ ...reactHooksPlugin.configs.recommended.rules,
+ "@typescript-eslint/no-unused-vars": ["error", { varsIgnorePattern: "^_", argsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" }],
+ "react/jsx-uses-react": "off",
+ "react/react-in-jsx-scope": "off",
+ },
+ settings: {
+ react: { version: "detect" },
+ },
+ },
+];
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..cc9b551
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Maine DOC Insight
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..077da07
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,5280 @@
+{
+ "name": "maine-doc-insight",
+ "version": "0.6.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "maine-doc-insight",
+ "version": "0.6.1",
+ "dependencies": {
+ "@tauri-apps/api": "^1.5.0",
+ "chart.js": "^4.4.1",
+ "papaparse": "^5.4.1",
+ "react": "^18.2.0",
+ "react-chartjs-2": "^5.2.0",
+ "react-dom": "^18.2.0",
+ "xlsx": "^0.18.5"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "^1.5.0",
+ "@types/papaparse": "^5.3.14",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@typescript-eslint/eslint-plugin": "^8.60.1",
+ "@typescript-eslint/parser": "^8.60.1",
+ "@vitejs/plugin-react": "^4.0.0",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "typescript": "^5.0.0",
+ "vite": "^4.4.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
+ "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
+ "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
+ "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
+ "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
+ "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
+ "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
+ "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
+ "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
+ "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
+ "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
+ "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
+ "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
+ "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
+ "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
+ "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
+ "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
+ "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
+ "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
+ "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
+ "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
+ "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
+ "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
+ "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@eslint/config-array/node_modules/brace-expansion": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tauri-apps/api": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.6.0.tgz",
+ "integrity": "sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg==",
+ "license": "Apache-2.0 OR MIT",
+ "engines": {
+ "node": ">= 14.6.0",
+ "npm": ">= 6.6.0",
+ "yarn": ">= 1.19.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ }
+ },
+ "node_modules/@tauri-apps/cli": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-1.6.3.tgz",
+ "integrity": "sha512-q46umd6QLRKDd4Gg6WyZBGa2fWvk0pbeUA5vFomm4uOs1/17LIciHv2iQ4UD+2Yv5H7AO8YiE1t50V0POiEGEw==",
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "dependencies": {
+ "semver": ">=7.5.2"
+ },
+ "bin": {
+ "tauri": "tauri.js"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ },
+ "optionalDependencies": {
+ "@tauri-apps/cli-darwin-arm64": "1.6.3",
+ "@tauri-apps/cli-darwin-x64": "1.6.3",
+ "@tauri-apps/cli-linux-arm-gnueabihf": "1.6.3",
+ "@tauri-apps/cli-linux-arm64-gnu": "1.6.3",
+ "@tauri-apps/cli-linux-arm64-musl": "1.6.3",
+ "@tauri-apps/cli-linux-x64-gnu": "1.6.3",
+ "@tauri-apps/cli-linux-x64-musl": "1.6.3",
+ "@tauri-apps/cli-win32-arm64-msvc": "1.6.3",
+ "@tauri-apps/cli-win32-ia32-msvc": "1.6.3",
+ "@tauri-apps/cli-win32-x64-msvc": "1.6.3"
+ }
+ },
+ "node_modules/@tauri-apps/cli-darwin-arm64": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-1.6.3.tgz",
+ "integrity": "sha512-fQN6IYSL8bG4NvkdKE4sAGF4dF/QqqQq4hOAU+t8ksOzHJr0hUlJYfncFeJYutr/MMkdF7hYKadSb0j5EE9r0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-darwin-x64": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-1.6.3.tgz",
+ "integrity": "sha512-1yTXZzLajKAYINJOJhZfmMhCzweHSgKQ3bEgJSn6t+1vFkOgY8Yx4oFgWcybrrWI5J1ZLZAl47+LPOY81dLcyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-1.6.3.tgz",
+ "integrity": "sha512-CjTEr9r9xgjcvos09AQw8QMRPuH152B1jvlZt4PfAsyJNPFigzuwed5/SF7XAd8bFikA7zArP4UT12RdBxrx7w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-1.6.3.tgz",
+ "integrity": "sha512-G9EUUS4M8M/Jz1UKZqvJmQQCKOzgTb8/0jZKvfBuGfh5AjFBu8LHvlFpwkKVm1l4951Xg4ulUp6P9Q7WRJ9XSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm64-musl": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.6.3.tgz",
+ "integrity": "sha512-MuBTHJyNpZRbPVG8IZBN8+Zs7aKqwD22tkWVBcL1yOGL4zNNTJlkfL+zs5qxRnHlUsn6YAlbW/5HKocfpxVwBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-x64-gnu": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-1.6.3.tgz",
+ "integrity": "sha512-Uvi7M+NK3tAjCZEY1WGel+dFlzJmqcvu3KND+nqa22762NFmOuBIZ4KJR/IQHfpEYqKFNUhJfCGnpUDfiC3Oxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-x64-musl": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-1.6.3.tgz",
+ "integrity": "sha512-rc6B342C0ra8VezB/OJom9j/N+9oW4VRA4qMxS2f4bHY2B/z3J9NPOe6GOILeg4v/CV62ojkLsC3/K/CeF3fqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-1.6.3.tgz",
+ "integrity": "sha512-cSH2qOBYuYC4UVIFtrc1YsGfc5tfYrotoHrpTvRjUGu0VywvmyNk82+ZsHEnWZ2UHmu3l3lXIGRqSWveLln0xg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-1.6.3.tgz",
+ "integrity": "sha512-T8V6SJQqE4PSWmYBl0ChQVmS6AR2hXFHURH2DwAhgSGSQ6uBXgwlYFcfIeQpBQA727K2Eq8X2hGfvmoySyHMRw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-x64-msvc": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-1.6.3.tgz",
+ "integrity": "sha512-HUkWZ+lYHI/Gjkh2QjHD/OBDpqLVmvjZGpLK9losur1Eg974Jip6k+vsoTUxQBCBDfj30eDBct9E1FvXOspWeg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "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/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.8.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
+ "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@types/papaparse": {
+ "version": "5.5.2",
+ "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
+ "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
+ "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.60.1",
+ "@typescript-eslint/type-utils": "8.60.1",
+ "@typescript-eslint/utils": "8.60.1",
+ "@typescript-eslint/visitor-keys": "8.60.1",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.60.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
+ "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.60.1",
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/typescript-estree": "8.60.1",
+ "@typescript-eslint/visitor-keys": "8.60.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
+ "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.60.1",
+ "@typescript-eslint/types": "^8.60.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
+ "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/visitor-keys": "8.60.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
+ "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz",
+ "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/typescript-estree": "8.60.1",
+ "@typescript-eslint/utils": "8.60.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
+ "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
+ "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.60.1",
+ "@typescript-eslint/tsconfig-utils": "8.60.1",
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/visitor-keys": "8.60.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz",
+ "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.60.1",
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/typescript-estree": "8.60.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
+ "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.60.1",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/adler-32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+ "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+ "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.0",
+ "es-object-atoms": "^1.1.1",
+ "get-intrinsic": "^1.3.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlast": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
+ "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
+ "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
+ "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.tosorted": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
+ "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3",
+ "es-errors": "^1.3.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.29",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
+ "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001792",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
+ "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/cfb": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+ "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "crc-32": "~1.2.0"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chart.js": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
+ "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
+ "node_modules/codepage": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+ "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "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-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.355",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz",
+ "integrity": "sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.2",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
+ "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-iterator-helpers": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz",
+ "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.2",
+ "es-errors": "^1.3.0",
+ "es-set-tostringtag": "^2.1.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.3.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "iterator.prototype": "^1.1.5",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+ "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+ "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
+ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/android-arm": "0.18.20",
+ "@esbuild/android-arm64": "0.18.20",
+ "@esbuild/android-x64": "0.18.20",
+ "@esbuild/darwin-arm64": "0.18.20",
+ "@esbuild/darwin-x64": "0.18.20",
+ "@esbuild/freebsd-arm64": "0.18.20",
+ "@esbuild/freebsd-x64": "0.18.20",
+ "@esbuild/linux-arm": "0.18.20",
+ "@esbuild/linux-arm64": "0.18.20",
+ "@esbuild/linux-ia32": "0.18.20",
+ "@esbuild/linux-loong64": "0.18.20",
+ "@esbuild/linux-mips64el": "0.18.20",
+ "@esbuild/linux-ppc64": "0.18.20",
+ "@esbuild/linux-riscv64": "0.18.20",
+ "@esbuild/linux-s390x": "0.18.20",
+ "@esbuild/linux-x64": "0.18.20",
+ "@esbuild/netbsd-x64": "0.18.20",
+ "@esbuild/openbsd-x64": "0.18.20",
+ "@esbuild/sunos-x64": "0.18.20",
+ "@esbuild/win32-arm64": "0.18.20",
+ "@esbuild/win32-ia32": "0.18.20",
+ "@esbuild/win32-x64": "0.18.20"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react": {
+ "version": "7.37.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
+ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.8",
+ "array.prototype.findlast": "^1.2.5",
+ "array.prototype.flatmap": "^1.3.3",
+ "array.prototype.tosorted": "^1.1.4",
+ "doctrine": "^2.1.0",
+ "es-iterator-helpers": "^1.2.1",
+ "estraverse": "^5.3.0",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+ "minimatch": "^3.1.2",
+ "object.entries": "^1.1.9",
+ "object.fromentries": "^2.0.8",
+ "object.values": "^1.2.1",
+ "prop-types": "^15.8.1",
+ "resolve": "^2.0.0-next.5",
+ "semver": "^6.3.1",
+ "string.prototype.matchall": "^4.0.12",
+ "string.prototype.repeat": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/eslint-plugin-react/node_modules/brace-expansion": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/eslint/node_modules/brace-expansion": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/frac": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+ "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "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/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+ "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/iterator.prototype": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
+ "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "get-proto": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
+ "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsx-ast-utils": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
+ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.6",
+ "array.prototype.flat": "^1.3.1",
+ "object.assign": "^4.1.4",
+ "object.values": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "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.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "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/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-exports-info": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
+ "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array.prototype.flatmap": "^1.3.3",
+ "es-errors": "^1.3.0",
+ "object.entries": "^1.1.9",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/node-exports-info/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.44",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
+ "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.entries": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
+ "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+ "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
+ "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/papaparse": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
+ "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
+ "license": "MIT"
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "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.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-chartjs-2": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
+ "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "2.0.0-next.7",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
+ "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.2",
+ "node-exports-info": "^1.6.0",
+ "object-keys": "^1.1.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "3.30.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz",
+ "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=14.18.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
+ "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "get-intrinsic": "^1.3.0",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
+ "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "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/ssf": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+ "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "frac": "~1.1.2"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+ "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.repeat": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
+ "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+ "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+ "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+ "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz",
+ "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "for-each": "^0.3.5",
+ "gopd": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "possible-typed-array-names": "^1.1.0",
+ "reflect.getprototypeof": "^1.0.10"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "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/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "4.5.14",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
+ "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.18.10",
+ "postcss": "^8.4.27",
+ "rollup": "^3.27.1"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ },
+ "peerDependencies": {
+ "@types/node": ">= 14",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz",
+ "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/wmf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+ "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/word": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+ "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/xlsx": {
+ "version": "0.18.5",
+ "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+ "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "cfb": "~1.2.1",
+ "codepage": "~1.15.0",
+ "crc-32": "~1.2.1",
+ "ssf": "~0.11.2",
+ "wmf": "~1.0.1",
+ "word": "~0.3.0"
+ },
+ "bin": {
+ "xlsx": "bin/xlsx.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..effa8c3
--- /dev/null
+++ b/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "maine-doc-insight",
+ "private": true,
+ "version": "0.6.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --open",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "tauri": "tauri"
+ },
+ "dependencies": {
+ "@tauri-apps/api": "^1.5.0",
+ "chart.js": "^4.4.1",
+ "papaparse": "^5.4.1",
+ "react": "^18.2.0",
+ "react-chartjs-2": "^5.2.0",
+ "react-dom": "^18.2.0",
+ "xlsx": "^0.18.5"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "^1.5.0",
+ "@types/papaparse": "^5.3.14",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@typescript-eslint/eslint-plugin": "^8.60.1",
+ "@typescript-eslint/parser": "^8.60.1",
+ "@vitejs/plugin-react": "^4.0.0",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "typescript": "^5.0.0",
+ "vite": "^4.4.0"
+ }
+}
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
new file mode 100644
index 0000000..01f2df8
--- /dev/null
+++ b/src-tauri/Cargo.lock
@@ -0,0 +1,4228 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "atk"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c3d816ce6f0e2909a96830d6911c2aff044370b1ef92d7f267b43bae5addedd"
+dependencies = [
+ "atk-sys",
+ "bitflags 1.3.2",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58aeb089fb698e06db8089971c7ee317ab9644bade33383f63631437b03aafb6"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "block"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "brotli"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "4.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cairo-rs"
+version = "0.15.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c76ee391b03d35510d9fa917357c7f1855bd9a6659c95a1b392e33f49b3369bc"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "thiserror",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "cargo_toml"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838"
+dependencies = [
+ "serde",
+ "toml 0.7.8",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.62"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3431df59f28accaf4cb4eed4a9acc66bea3f3c3753aa6cdc2f024174ef232af7"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link",
+]
+
+[[package]]
+name = "cocoa"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
+dependencies = [
+ "bitflags 1.3.2",
+ "block",
+ "cocoa-foundation",
+ "core-foundation",
+ "core-graphics",
+ "foreign-types",
+ "libc",
+ "objc",
+]
+
+[[package]]
+name = "cocoa-foundation"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
+dependencies = [
+ "bitflags 1.3.2",
+ "block",
+ "core-foundation",
+ "core-graphics-types",
+ "libc",
+ "objc",
+]
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa 0.4.8",
+ "matches",
+ "phf 0.8.0",
+ "proc-macro2",
+ "quote",
+ "smallvec",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "ctor"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+ "serde_core",
+]
+
+[[package]]
+name = "derive_more"
+version = "0.99.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "embed-resource"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d506610004cfc74a6f5ee7e8c632b355de5eca1f03ee5e5e0ec11b77d4eb3d61"
+dependencies = [
+ "cc",
+ "memchr",
+ "rustc_version",
+ "toml 0.8.23",
+ "vswhom",
+ "winreg",
+]
+
+[[package]]
+name = "embed_plist"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset",
+ "rustc_version",
+]
+
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fluent-uri"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "fxhash"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "gdk"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6e05c1f572ab0e1f15be94217f0dc29088c248b14f792a5ff0af0d84bcda9e8"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.15.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad38dd9cc8b099cceecdf41375bb6d481b1b5a7cd5cd603e10a69a9383f8619a"
+dependencies = [
+ "bitflags 1.3.2",
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "140b2f5378256527150350a8346dbdb08fadc13453a7a2d73aecd5fab3c402a7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e7a08c1e8f06f4177fb7e51a777b8c1689f743a7bc11ea91d44d2226073a88"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gdkwayland-sys"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cca49a59ad8cfdf36ef7330fe7bdfbe1d34323220cc16a0de2679ee773aee2c2"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pkg-config",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gdkx11-sys"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4b7f8c7a84b407aa9b143877e267e848ff34106578b64d1e0a24bf550716178"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "libc",
+ "system-deps 6.2.2",
+ "x11",
+]
+
+[[package]]
+name = "generator"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
+dependencies = [
+ "cc",
+ "libc",
+ "log",
+ "rustversion",
+ "windows 0.48.0",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.9.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "gio"
+version = "0.15.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68fdbc90312d462781a395f7a16d96a2b379bb6ef8cd6310a2df272771c4283b"
+dependencies = [
+ "bitflags 1.3.2",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32157a475271e2c4a023382e9cab31c4584ee30a97da41d3c4e9fdd605abcf8d"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 6.2.2",
+ "winapi",
+]
+
+[[package]]
+name = "glib"
+version = "0.15.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edb0306fbad0ab5428b0ca674a23893db909a98582969c9b537be4ced78c505d"
+dependencies = [
+ "bitflags 1.3.2",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "once_cell",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.15.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10c6ae9f6fa26f4fb2ac16b528d138d971ead56141de489f8111e259b9df3c4a"
+dependencies = [
+ "anyhow",
+ "heck 0.4.1",
+ "proc-macro-crate",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4"
+dependencies = [
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "globset"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "gobject-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gtk"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e3004a2d5d6d8b5057d2b57b3712c9529b62e82c77f25c1fecde1fd5c23bd0"
+dependencies = [
+ "atk",
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "once_cell",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5bc2f0587cba247f60246a0ca11fe25fb733eabc3de12d1965fc07efab87c84"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.15.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "684c0456c086e8e7e9af73ec5b84e35938df394712054550e81558d21c44ab0d"
+dependencies = [
+ "anyhow",
+ "proc-macro-crate",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "html5ever"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa 1.0.18",
+]
+
+[[package]]
+name = "http-range"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ico"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
+dependencies = [
+ "byteorder",
+ "png",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "ignore"
+version = "0.4.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
+[[package]]
+name = "image"
+version = "0.24.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
+dependencies = [
+ "bytemuck",
+ "byteorder",
+ "color_quant",
+ "num-traits",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "infer"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f551f8c3a39f68f986517db0d1759de85881894fdc7db798bd2a9df9cb04b7fc"
+dependencies = [
+ "cfb",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "itoa"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "javascriptcore-rs"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf053e7843f2812ff03ef5afe34bb9c06ffee120385caad4f6b9967fcd37d41c"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "javascriptcore-rs-sys",
+]
+
+[[package]]
+name = "javascriptcore-rs-sys"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "905fbb87419c5cde6e3269537e4ea7d46431f3008c5d057e915ef3f115e7793c"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 5.0.0",
+]
+
+[[package]]
+name = "jni"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c"
+dependencies = [
+ "cesu8",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror",
+ "walkdir",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json-patch"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b1fb8864823fad91877e6caea0baca82e49e8db50f8e5c9f9a453e27d3330fc"
+dependencies = [
+ "jsonptr",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
+name = "jsonptr"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c6e529149475ca0b2820835d3dce8fcc41c6b943ca608d32f35b449255e4627"
+dependencies = [
+ "fluent-uri",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "kuchikiki"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "indexmap 1.9.3",
+ "matches",
+ "selectors",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libredox"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "loom"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "serde",
+ "serde_json",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "maine-doc-insight"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-build",
+]
+
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "markup5ever"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016"
+dependencies = [
+ "log",
+ "phf 0.10.1",
+ "phf_codegen 0.10.0",
+ "string_cache",
+ "string_cache_codegen",
+ "tendril",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matches"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "ndk"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2032c77e030ddee34a6787a64166008da93f6a352b629261d0fee232b8742dd4"
+dependencies = [
+ "bitflags 1.3.2",
+ "jni-sys 0.3.1",
+ "ndk-sys",
+ "num_enum",
+ "thiserror",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e5a6ae77c8ee183dcbbba6150e2e6b9f3f4196a7666c02a715a95692ec1fa97"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "nodrop"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9"
+dependencies = [
+ "num_enum_derive",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+ "objc_exception",
+]
+
+[[package]]
+name = "objc-foundation"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
+dependencies = [
+ "block",
+ "objc",
+ "objc_id",
+]
+
+[[package]]
+name = "objc_exception"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc_id"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
+dependencies = [
+ "objc",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pango"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e4045548659aee5313bde6c582b0d83a627b7904dd20dc2d9ef0895d414e4f"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2a00081cde4661982ed91d80ef437c20eacaf6aa1a5962c0279ae194662c3aa"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
+dependencies = [
+ "phf_macros 0.8.0",
+ "phf_shared 0.8.0",
+ "proc-macro-hack",
+]
+
+[[package]]
+name = "phf"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
+dependencies = [
+ "phf_shared 0.10.0",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros 0.11.3",
+ "phf_shared 0.11.3",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815"
+dependencies = [
+ "phf_generator 0.8.0",
+ "phf_shared 0.8.0",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd"
+dependencies = [
+ "phf_generator 0.10.0",
+ "phf_shared 0.10.0",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526"
+dependencies = [
+ "phf_shared 0.8.0",
+ "rand 0.7.3",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
+dependencies = [
+ "phf_shared 0.10.0",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared 0.11.3",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
+dependencies = [
+ "phf_generator 0.8.0",
+ "phf_shared 0.8.0",
+ "proc-macro-hack",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7"
+dependencies = [
+ "siphasher 0.3.11",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
+dependencies = [
+ "siphasher 0.3.11",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher 1.0.3",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap 2.14.0",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-hack"
+version = "0.5.20+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
+dependencies = [
+ "getrandom 0.1.16",
+ "libc",
+ "rand_chacha 0.2.2",
+ "rand_core 0.5.1",
+ "rand_hc",
+ "rand_pcg",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+dependencies = [
+ "getrandom 0.1.16",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_hc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
+dependencies = [
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "rand_pcg"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429"
+dependencies = [
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rfd"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea"
+dependencies = [
+ "block",
+ "dispatch",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "js-sys",
+ "lazy_static",
+ "log",
+ "objc",
+ "objc-foundation",
+ "objc_id",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows 0.37.0",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.11.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "selectors"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
+dependencies = [
+ "bitflags 1.3.2",
+ "cssparser",
+ "derive_more",
+ "fxhash",
+ "log",
+ "matches",
+ "phf 0.8.0",
+ "phf_codegen 0.8.0",
+ "precomputed-hash",
+ "servo_arc",
+ "smallvec",
+ "thin-slice",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "indexmap 2.14.0",
+ "itoa 1.0.18",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
+dependencies = [
+ "base64 0.22.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serialize-to-javascript"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serialize-to-javascript-impl",
+]
+
+[[package]]
+name = "serialize-to-javascript-impl"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432"
+dependencies = [
+ "nodrop",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "siphasher"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "soup2"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2b4d76501d8ba387cf0fefbe055c3e0a59891d09f0f995ae4e4b16f6b60f3c0"
+dependencies = [
+ "bitflags 1.3.2",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "soup2-sys",
+]
+
+[[package]]
+name = "soup2-sys"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "009ef427103fcb17f802871647a7fa6c60cbb654b4c4e4c0ac60a31c5f6dc9cf"
+dependencies = [
+ "bitflags 1.3.2",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 5.0.0",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "state"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b"
+dependencies = [
+ "loom",
+]
+
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared 0.11.3",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "system-deps"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18db855554db7bd0e73e06cf7ba3df39f97812cb11d3f75e71c39bf45171797e"
+dependencies = [
+ "cfg-expr 0.9.1",
+ "heck 0.3.3",
+ "pkg-config",
+ "toml 0.5.11",
+ "version-compare 0.0.11",
+]
+
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr 0.15.8",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.23",
+ "version-compare 0.2.1",
+]
+
+[[package]]
+name = "tao"
+version = "0.16.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf915e6c7112402f7b88a064cfbd264f851052df07fdc3a2abd3038b0cc434a"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "cc",
+ "cocoa",
+ "core-foundation",
+ "core-graphics",
+ "crossbeam-channel",
+ "dispatch",
+ "gdk",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gdkwayland-sys",
+ "gdkx11-sys",
+ "gio",
+ "glib",
+ "glib-sys",
+ "gtk",
+ "image",
+ "instant",
+ "jni",
+ "lazy_static",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "objc",
+ "once_cell",
+ "parking_lot",
+ "png",
+ "raw-window-handle",
+ "scopeguard",
+ "serde",
+ "tao-macros",
+ "unicode-segmentation",
+ "uuid",
+ "windows 0.39.0",
+ "windows-implement 0.39.0",
+ "x11-dl",
+]
+
+[[package]]
+name = "tao-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tar"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "tauri"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae1f57c291a6ab8e1d2e6b8ad0a35ff769c9925deb8a89de85425ff08762d0c"
+dependencies = [
+ "anyhow",
+ "cocoa",
+ "dirs-next",
+ "dunce",
+ "embed_plist",
+ "encoding_rs",
+ "flate2",
+ "futures-util",
+ "getrandom 0.2.17",
+ "glib",
+ "glob",
+ "gtk",
+ "heck 0.5.0",
+ "http",
+ "ignore",
+ "log",
+ "objc",
+ "once_cell",
+ "percent-encoding",
+ "plist",
+ "rand 0.8.6",
+ "raw-window-handle",
+ "rfd",
+ "semver",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "serialize-to-javascript",
+ "state",
+ "tar",
+ "tauri-macros",
+ "tauri-runtime",
+ "tauri-runtime-wry",
+ "tauri-utils",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "url",
+ "uuid",
+ "webkit2gtk",
+ "webview2-com",
+ "windows 0.39.0",
+]
+
+[[package]]
+name = "tauri-build"
+version = "1.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2db08694eec06f53625cfc6fff3a363e084e5e9a238166d2989996413c346453"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs-next",
+ "heck 0.5.0",
+ "json-patch",
+ "semver",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "tauri-winres",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-codegen"
+version = "1.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53438d78c4a037ffe5eafa19e447eea599bedfb10844cb08ec53c2471ac3ac3f"
+dependencies = [
+ "base64 0.21.7",
+ "brotli",
+ "ico",
+ "json-patch",
+ "plist",
+ "png",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tauri-utils",
+ "thiserror",
+ "time",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-macros"
+version = "1.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233988ac08c1ed3fe794cd65528d48d8f7ed4ab3895ca64cdaa6ad4d00c45c0b"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "tauri-codegen",
+ "tauri-utils",
+]
+
+[[package]]
+name = "tauri-runtime"
+version = "0.14.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8066855882f00172935e3fa7d945126580c34dcbabab43f5d4f0c2398a67d47b"
+dependencies = [
+ "gtk",
+ "http",
+ "http-range",
+ "rand 0.8.6",
+ "raw-window-handle",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "thiserror",
+ "url",
+ "uuid",
+ "webview2-com",
+ "windows 0.39.0",
+]
+
+[[package]]
+name = "tauri-runtime-wry"
+version = "0.14.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce361fec1e186705371f1c64ae9dd2a3a6768bc530d0a2d5e75a634bb416ad4d"
+dependencies = [
+ "cocoa",
+ "gtk",
+ "percent-encoding",
+ "rand 0.8.6",
+ "raw-window-handle",
+ "tauri-runtime",
+ "tauri-utils",
+ "uuid",
+ "webkit2gtk",
+ "webview2-com",
+ "windows 0.39.0",
+ "wry",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c357952645e679de02cd35007190fcbce869b93ffc61b029f33fe02648453774"
+dependencies = [
+ "brotli",
+ "ctor",
+ "dunce",
+ "glob",
+ "heck 0.5.0",
+ "html5ever",
+ "infer",
+ "json-patch",
+ "kuchikiki",
+ "log",
+ "memchr",
+ "phf 0.11.3",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "thiserror",
+ "url",
+ "walkdir",
+ "windows-version",
+]
+
+[[package]]
+name = "tauri-winres"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb"
+dependencies = [
+ "embed-resource",
+ "toml 0.7.8",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
+[[package]]
+name = "thin-slice"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c"
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa 1.0.18",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "toml"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit 0.22.27",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "version-compare"
+version = "0.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c18c859eead79d8b95d09e4678566e8d70105c4e7b251f707a03df32442661b"
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.9.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap 2.14.0",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.11.1",
+ "hashbrown 0.15.5",
+ "indexmap 2.14.0",
+ "semver",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webkit2gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8f859735e4a452aeb28c6c56a852967a8a76c8eb1cc32dbf931ad28a13d6370"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk",
+ "gdk-sys",
+ "gio",
+ "gio-sys",
+ "glib",
+ "glib-sys",
+ "gobject-sys",
+ "gtk",
+ "gtk-sys",
+ "javascriptcore-rs",
+ "libc",
+ "once_cell",
+ "soup2",
+ "webkit2gtk-sys",
+]
+
+[[package]]
+name = "webkit2gtk-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d76ca6ecc47aeba01ec61e480139dda143796abcae6f83bcddf50d6b5b1dcf3"
+dependencies = [
+ "atk-sys",
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "javascriptcore-rs-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "soup2-sys",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "webview2-com"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4a769c9f1a64a8734bde70caafac2b96cada12cd4aefa49196b3a386b8b4178"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys",
+ "windows 0.39.0",
+ "windows-implement 0.39.0",
+]
+
+[[package]]
+name = "webview2-com-macros"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaebe196c01691db62e9e4ca52c5ef1e4fd837dcae27dae3ada599b5a8fd05ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "webview2-com-sys"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac48ef20ddf657755fdcda8dfed2a7b4fc7e4581acce6fe9b88c3d64f29dee7"
+dependencies = [
+ "regex",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "windows 0.39.0",
+ "windows-bindgen",
+ "windows-metadata",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647"
+dependencies = [
+ "windows_aarch64_msvc 0.37.0",
+ "windows_i686_gnu 0.37.0",
+ "windows_i686_msvc 0.37.0",
+ "windows_x86_64_gnu 0.37.0",
+ "windows_x86_64_msvc 0.37.0",
+]
+
+[[package]]
+name = "windows"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1c4bd0a50ac6020f65184721f758dba47bb9fbc2133df715ec74a237b26794a"
+dependencies = [
+ "windows-implement 0.39.0",
+ "windows_aarch64_msvc 0.39.0",
+ "windows_i686_gnu 0.39.0",
+ "windows_i686_msvc 0.39.0",
+ "windows_x86_64_gnu 0.39.0",
+ "windows_x86_64_msvc 0.39.0",
+]
+
+[[package]]
+name = "windows"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-bindgen"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68003dbd0e38abc0fb85b939240f4bce37c43a5981d3df37ccbaaa981b47cb41"
+dependencies = [
+ "windows-metadata",
+ "windows-tokens",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement 0.60.2",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba01f98f509cb5dc05f4e5fc95e535f78260f15fea8fe1a8abdd08f774f1cee7"
+dependencies = [
+ "syn 1.0.109",
+ "windows-tokens",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-metadata"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-tokens"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f838de2fe15fe6bac988e74b798f26499a8b21a9d97edec321e79b28d1d7f597"
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec7711666096bd4096ffa835238905bb33fb87267910e154b18b44eaabb340f2"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "763fc57100a5f7042e3057e7e8d9bdd7860d330070251a73d003563a3bb49e1b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bc7cbfe58828921e10a9f446fcaaf649204dcfe6c1ddd712c5eebae6bda1106"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6868c165637d653ae1e8dc4d82c25d4f97dd6605eaa8d784b5c6e0ab2a252b65"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e4d40883ae9cae962787ca76ba76390ffa29214667a111db9e0a1ad8377e809"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "indexmap 2.14.0",
+ "prettyplease",
+ "syn 2.0.117",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags 2.11.1",
+ "indexmap 2.14.0",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap 2.14.0",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "wry"
+version = "0.24.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a2a144c3ab5e83e04724bc8e67cea552ffae413185fda459fafdae173fd985d"
+dependencies = [
+ "base64 0.13.1",
+ "block",
+ "cocoa",
+ "core-graphics",
+ "crossbeam-channel",
+ "dunce",
+ "gdk",
+ "gio",
+ "glib",
+ "gtk",
+ "html5ever",
+ "http",
+ "kuchikiki",
+ "libc",
+ "log",
+ "objc",
+ "objc_id",
+ "once_cell",
+ "serde",
+ "serde_json",
+ "sha2",
+ "soup2",
+ "tao",
+ "thiserror",
+ "url",
+ "webkit2gtk",
+ "webkit2gtk-sys",
+ "webview2-com",
+ "windows 0.39.0",
+ "windows-implement 0.39.0",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
new file mode 100644
index 0000000..4f1101b
--- /dev/null
+++ b/src-tauri/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "maine-doc-insight"
+version = "0.1.0"
+description = "Maine DOC Education Program Analytics"
+authors = []
+license = ""
+repository = ""
+edition = "2021"
+rust-version = "1.70"
+
+[build-dependencies]
+tauri-build = { version = "1.5", features = [] }
+
+[dependencies]
+serde_json = "1.0"
+serde = { version = "1.0", features = ["derive"] }
+tauri = { version = "1.5", features = ["dialog-open", "fs-read-file"] }
+
+[features]
+default = ["custom-protocol"]
+custom-protocol = ["tauri/custom-protocol"]
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
new file mode 100644
index 0000000..d860e1e
--- /dev/null
+++ b/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png
new file mode 100644
index 0000000..faace95
Binary files /dev/null and b/src-tauri/icons/128x128.png differ
diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png
new file mode 100644
index 0000000..db4a355
Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ
diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png
new file mode 100644
index 0000000..14bd64a
Binary files /dev/null and b/src-tauri/icons/32x32.png differ
diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png
new file mode 100644
index 0000000..d9e6ec4
Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ
diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png
new file mode 100644
index 0000000..0004fd7
Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ
diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png
new file mode 100644
index 0000000..f03a563
Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ
diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png
new file mode 100644
index 0000000..2b7eb16
Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ
diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png
new file mode 100644
index 0000000..ba350b2
Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ
diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png
new file mode 100644
index 0000000..9ebc74a
Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ
diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png
new file mode 100644
index 0000000..aa2c5b8
Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ
diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png
new file mode 100644
index 0000000..ec1ce45
Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ
diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png
new file mode 100644
index 0000000..bac98c6
Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ
diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png
new file mode 100644
index 0000000..74458a5
Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ
diff --git a/src-tauri/icons/app-icon.png b/src-tauri/icons/app-icon.png
new file mode 100644
index 0000000..6b9fb74
Binary files /dev/null and b/src-tauri/icons/app-icon.png differ
diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns
new file mode 100644
index 0000000..756b805
Binary files /dev/null and b/src-tauri/icons/icon.icns differ
diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico
new file mode 100644
index 0000000..c8de9b9
Binary files /dev/null and b/src-tauri/icons/icon.ico differ
diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png
new file mode 100644
index 0000000..22a8719
Binary files /dev/null and b/src-tauri/icons/icon.png differ
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
new file mode 100644
index 0000000..a468df2
--- /dev/null
+++ b/src-tauri/src/main.rs
@@ -0,0 +1,8 @@
+// Prevents additional console window on Windows in release
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+fn main() {
+ tauri::Builder::default()
+ .run(tauri::generate_context!())
+ .expect("error while running Maine DOC Insight application");
+}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
new file mode 100644
index 0000000..3600b84
--- /dev/null
+++ b/src-tauri/tauri.conf.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "../node_modules/@tauri-apps/cli/schema.json",
+ "build": {
+ "beforeDevCommand": "npm run dev",
+ "beforeBuildCommand": "npm run build",
+ "devPath": "http://localhost:5173",
+ "distDir": "../dist"
+ },
+ "package": {
+ "productName": "Maine DOC Insight",
+ "version": "0.1.0"
+ },
+ "tauri": {
+ "allowlist": {
+ "dialog": { "open": true },
+ "fs": { "readFile": true, "scope": ["**"] }
+ },
+ "bundle": {
+ "active": true,
+ "category": "Utility",
+ "copyright": "",
+ "deb": { "depends": [] },
+ "externalBin": [],
+ "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
+ "identifier": "gov.maine.doc.insight",
+ "longDescription": "Maine DOC Education Program Analytics",
+ "macOS": { "entitlements": null, "exceptionDomain": "", "frameworks": [], "providerShortName": null, "signingIdentity": null },
+ "resources": [],
+ "shortDescription": "DOC Education Analytics",
+ "targets": "all",
+ "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", "timestampUrl": "" }
+ },
+ "security": { "csp": null },
+ "updater": { "active": false },
+ "windows": [
+ {
+ "fullscreen": false,
+ "height": 900,
+ "resizable": true,
+ "title": "Maine DOC Insight",
+ "width": 1400,
+ "minWidth": 1024,
+ "minHeight": 700
+ }
+ ]
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..6679ff6
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,282 @@
+import { useState, useCallback, useEffect } from "react";
+import "./app.css";
+import { FileLoader } from "./components/FileLoader";
+import { HomeView } from "./views/HomeView";
+import { ProgramsView } from "./views/ProgramsView";
+import { ResidentsView } from "./views/ResidentsView";
+import { FacilitiesView } from "./views/FacilitiesView";
+import { ComparisonView } from "./views/ComparisonView";
+import { DataQualityView } from "./views/DataQualityView";
+import { ChatDrawer, CHAT_DRAWER_WIDTH } from "./components/chat/ChatDrawer";
+import { ingestFiles } from "./lib/ingest";
+import { generateMockData, type DemoScenario } from "./data/mockData";
+import { DataQualityProvider } from "./contexts/DataQualityContext";
+import type { ParsedData, MetricSnapshot, PendingPrompt, MetricKey, MetricQualityMap } from "./types";
+
+type View = "load" | "dashboard" | "quality" | "programs" | "residents" | "facilities" | "comparison";
+
+interface NavItem { id: View; label: string; icon: string }
+
+const NAV_ITEMS: NavItem[] = [
+ { id: "dashboard", label: "Home", icon: "◈" },
+ { id: "quality", label: "Data Quality", icon: "◎" },
+ { id: "programs", label: "Programs", icon: "▦" },
+ { id: "residents", label: "Residents", icon: "◉" },
+ { id: "facilities", label: "Facilities", icon: "▣" },
+ { id: "comparison", label: "DOC & UL", icon: "⇄" },
+];
+
+// Metrics that belong to each view — used to compute per-tab quality indicators
+const VIEW_METRICS: Partial> = {
+ dashboard: ["nearReleaseEngagement", "equitySummary", "qolSignals"],
+ programs: ["programCompletion", "completionByRisk", "attendanceCorrelation", "timeToCompletion", "completionTrends"],
+ residents: ["completionByEducation", "completionByRiskOffense"],
+ facilities: ["facilityMixAdjusted"],
+ comparison: ["docUlGap"],
+};
+
+function viewQualityStatus(
+ view: View,
+ metricQuality: MetricQualityMap,
+): "ok" | "warning" | "blocked" {
+ const keys = VIEW_METRICS[view] ?? [];
+ const statuses = keys.map(k => metricQuality[k]?.status ?? "ok");
+ if (statuses.includes("blocked")) return "blocked";
+ if (statuses.includes("warning")) return "warning";
+ return "ok";
+}
+
+function makeSnapshot(_data: ParsedData, signatures: Record): MetricSnapshot {
+ const today = new Date();
+ const asOf = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
+ return {
+ id: crypto.randomUUID(),
+ computed_at: today.toISOString(),
+ as_of_date: asOf,
+ source_signatures: signatures,
+ schema_version: "v1.0",
+ };
+}
+
+export default function App() {
+ const [view, setView] = useState("load");
+ const [data, setData] = useState(null);
+ const [snapshot, setSnapshot] = useState(null);
+ const [isDemo, setIsDemo] = useState(false);
+ const [chatDemoMode, setChatDemoMode] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [pendingPrompt, setPendingPrompt] = useState(null);
+ const [chatOpen, setChatOpen] = useState(false);
+ const [theme, setTheme] = useState<"dark" | "light">(() =>
+ (localStorage.getItem("theme") as "dark" | "light") ?? "dark"
+ );
+
+ useEffect(() => {
+ document.documentElement.setAttribute("data-theme", theme);
+ localStorage.setItem("theme", theme);
+ }, [theme]);
+
+ const loadDemo = useCallback((scenario: DemoScenario = "messy") => {
+ setLoading(true);
+ setError(null);
+ setTimeout(() => {
+ try {
+ const d = generateMockData(scenario);
+ setData(d);
+ setSnapshot(makeSnapshot(d, { demo: "mock-data" }));
+ setIsDemo(true);
+ setView(d.qualityReport.overallGrade !== "good" ? "quality" : "dashboard");
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, 50);
+ }, []);
+
+ const loadFiles = useCallback(async (files: Record) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const d = await ingestFiles(files);
+ const sigs: Record = {};
+ for (const [name, file] of Object.entries(files)) {
+ sigs[name] = `${file.size}`;
+ }
+ setData(d);
+ setSnapshot(makeSnapshot(d, sigs));
+ setIsDemo(false);
+ setView(d.qualityReport.overallGrade !== "good" ? "quality" : "dashboard");
+ } catch (e) {
+ setError(`Ingestion error: ${e}`);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const reset = () => { setData(null); setSnapshot(null); setView("load"); setIsDemo(false); setError(null); };
+
+ return (
+
+ {/* ── Top bar ─────────────────────────────────────────────────────── */}
+
+
+ {/* ── Sidebar ─────────────────────────────────────────────────────── */}
+
+ {data && (
+ <>
+ Analysis
+ {NAV_ITEMS.map((item) => {
+ const isQualityTab = item.id === "quality";
+ const qualityStatus = data
+ ? viewQualityStatus(item.id, data.qualityReport.metricQuality)
+ : "ok";
+ const showQualityBadge = isQualityTab && data &&
+ data.qualityReport.overallGrade !== "good";
+ const critCount = showQualityBadge
+ ? data!.qualityReport.columnIssues.filter(i => i.severity === "critical").length
+ : 0;
+ const showViewDot = !isQualityTab && qualityStatus !== "ok";
+ const dotColor = qualityStatus === "blocked" ? "var(--red)" : "var(--amber)";
+ return (
+ setView(item.id)}
+ >
+ {item.icon}
+ {item.label}
+ {showQualityBadge && (
+
+ ⚠ {critCount}
+
+ )}
+ {showViewDot && (
+
+ )}
+
+ );
+ })}
+ >
+ )}
+ {!data && (
+
+ ↑
+ Load data
+
+ )}
+
+
+
+ Maine DOC · Education Analytics
+ v0.4.0
+
+
+
+
+ {/* ── Main content ────────────────────────────────────────────────── */}
+
+ {loading && (
+
+ )}
+
+ {!loading && error && (
+
+ {error}
+
+ )}
+
+ {!loading && view === "load" && (
+ loadDemo(scenario)} />
+ )}
+
+ {!loading && data && (
+
+ {view === "dashboard" && }
+ {view === "quality" && }
+ {view === "programs" && }
+ {view === "residents" && }
+ {view === "facilities" && }
+ {view === "comparison" && }
+
+ )}
+
+
+
setPendingPrompt(null)}
+ onOpenChange={setChatOpen}
+ chatDemoMode={chatDemoMode}
+ onChatDemoModeChange={setChatDemoMode}
+ />
+
+ {/* ── Footer ──────────────────────────────────────────────────────── */}
+ {snapshot && (
+
+ snapshot:{snapshot.id.slice(0, 8)}
+ as-of:{snapshot.as_of_date}
+ schema:{snapshot.schema_version}
+ {isDemo && DEMO }
+
+ )}
+
+ );
+}
diff --git a/src/app.css b/src/app.css
new file mode 100644
index 0000000..912fc1d
--- /dev/null
+++ b/src/app.css
@@ -0,0 +1,410 @@
+@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap');
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg: #0d0f12;
+ --bg2: #141720;
+ --bg3: #1c2030;
+ --bg4: #232840;
+ --border: rgba(255,255,255,0.08);
+ --border2: rgba(255,255,255,0.14);
+ --text: #e8eaf0;
+ --text2: #9ca3b0;
+ --text3: #606878;
+ --accent: #4a9eff;
+ --accent2: #2d7de0;
+ --green: #3db87a;
+ --amber: #f59e0b;
+ --red: #e05252;
+ --purple: #8b5cf6;
+ --teal: #14b8a6;
+ --font-sans: 'IBM Plex Sans', system-ui, sans-serif;
+ --font-mono: 'IBM Plex Mono', monospace;
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --row-sep: rgba(255,255,255,0.04);
+}
+
+[data-theme="light"] {
+ --bg: #f5f6f8;
+ --bg2: #ffffff;
+ --bg3: #eef0f4;
+ --bg4: #e2e5eb;
+ --border: rgba(0,0,0,0.08);
+ --border2: rgba(0,0,0,0.16);
+ --text: #111318;
+ --text2: #3d4455;
+ --text3: #7a8394;
+ --accent: #2563eb;
+ --accent2: #1d4ed8;
+ --row-sep: rgba(0,0,0,0.05);
+}
+
+html, body, #root { height: 100%; }
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-sans);
+ font-size: 14px;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ── Layout ──────────────────────────────────────────────────────────────── */
+.app-shell {
+ display: grid;
+ grid-template-columns: 220px 1fr;
+ grid-template-rows: 48px 1fr 28px;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.app-topbar {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 0 20px;
+ background: var(--bg2);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.app-topbar .logo {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--accent);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.app-topbar .logo span { color: var(--text3); }
+
+.app-topbar .topbar-right {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.app-sidebar {
+ grid-row: 2 / 4;
+ background: var(--bg2);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ padding: 16px 0;
+ overflow-y: auto;
+}
+
+.app-footer {
+ grid-column: 2 / 3;
+ grid-row: 3;
+ display: flex;
+ align-items: center;
+ padding: 0 20px;
+ background: var(--bg2);
+ border-top: 1px solid var(--border);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text3);
+ letter-spacing: 0.04em;
+ gap: 16px;
+}
+
+.sidebar-section-label {
+ padding: 8px 16px 4px;
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text3);
+}
+
+.sidebar-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 16px;
+ font-size: 13px;
+ color: var(--text2);
+ cursor: pointer;
+ border-left: 2px solid transparent;
+ transition: all 0.12s;
+ user-select: none;
+}
+.sidebar-item:hover { background: var(--bg3); color: var(--text); }
+.sidebar-item.active { background: var(--bg3); color: var(--accent); border-left-color: var(--accent); }
+.sidebar-item .icon { width: 16px; text-align: center; opacity: 0.8; }
+
+.app-content {
+ overflow-y: auto;
+ padding: 24px;
+ background: var(--bg);
+}
+
+/* ── Metric cards ─────────────────────────────────────────────────────────── */
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 12px;
+ margin-bottom: 24px;
+}
+.metric-card {
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 14px 16px;
+}
+.metric-card .label {
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text3);
+ margin-bottom: 6px;
+}
+.metric-card .value {
+ font-size: 26px;
+ font-weight: 500;
+ font-family: var(--font-mono);
+ color: var(--text);
+ line-height: 1;
+}
+.metric-card .value.accent { color: var(--accent); }
+.metric-card .value.green { color: var(--green); }
+.metric-card .value.amber { color: var(--amber); }
+.metric-card .value.red { color: var(--red); }
+.metric-card .sub {
+ font-size: 11px;
+ color: var(--text3);
+ margin-top: 4px;
+}
+
+/* ── Section headings ────────────────────────────────────────────────────── */
+.section-header {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+.section-header h2 {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--text);
+}
+.section-header .tag {
+ font-size: 10px;
+ font-family: var(--font-mono);
+ color: var(--text3);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ padding: 1px 6px;
+}
+
+.view-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
+ gap: 20px;
+}
+
+.view-grid > * {
+ min-width: 0;
+ overflow: hidden;
+}
+
+.chart-card {
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ min-width: 0;
+ overflow: hidden;
+}
+.chart-card h3 {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text2);
+ margin-bottom: 4px;
+}
+.chart-card .finding {
+ font-size: 12px;
+ color: var(--text3);
+ margin-bottom: 16px;
+ line-height: 1.5;
+}
+.chart-card .finding strong { color: var(--amber); font-weight: 500; }
+
+/* ── Buttons ─────────────────────────────────────────────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ font-family: var(--font-sans);
+ font-size: 13px;
+ font-weight: 500;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border2);
+ background: var(--bg3);
+ color: var(--text2);
+ cursor: pointer;
+ transition: all 0.12s;
+}
+.btn:hover { background: var(--bg4); color: var(--text); border-color: var(--border2); }
+.btn.primary { background: var(--accent2); color: #fff; border-color: var(--accent2); }
+.btn.primary:hover { background: var(--accent); }
+.btn.sm { padding: 4px 8px; font-size: 12px; }
+
+/* ── File loader ─────────────────────────────────────────────────────────── */
+.loader-section {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ margin-bottom: 14px;
+ overflow: hidden;
+ transition: border-color 0.15s;
+}
+.loader-section.drag-over { border-color: var(--accent); }
+.loader-section-header {
+ padding: 14px 18px 12px;
+ background: var(--bg2);
+ border-bottom: 1px solid var(--border);
+}
+.loader-section-header h3 { font-size: 13px; font-weight: 600; color: var(--text); margin: 0 0 4px; }
+.loader-section-header p { font-size: 12px; color: var(--text3); margin: 0; line-height: 1.5; }
+.loader-section-body { padding: 14px 16px; }
+
+.drop-zone-compact {
+ border: 1px dashed var(--border2);
+ border-radius: var(--radius-md);
+ padding: 10px 16px;
+ text-align: center;
+ color: var(--text3);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.drop-zone-compact:hover, .drop-zone-compact.drag-over {
+ border-color: var(--accent);
+ background: rgba(74, 158, 255, 0.04);
+ color: var(--text2);
+}
+
+.file-slot {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all 0.12s;
+ margin-bottom: 7px;
+}
+.file-slot:last-child { margin-bottom: 0; }
+.file-slot:hover { border-color: var(--border2); background: var(--bg2); }
+.file-slot.loaded { border-color: rgba(61, 184, 122, 0.3); background: rgba(61, 184, 122, 0.03); }
+.file-slot-status { width: 28px; text-align: center; font-size: 16px; flex-shrink: 0; }
+.file-slot .filename { font-family: var(--font-mono); font-size: 12px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.file-slot .badge { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0; }
+.file-slot .badge.ok { background: rgba(61, 184, 122, 0.15); color: var(--green); }
+.file-slot .badge.opt { background: rgba(96, 104, 120, 0.2); color: var(--text3); }
+
+.file-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 10px;
+}
+.file-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+}
+.file-row.loaded { border-color: rgba(61, 184, 122, 0.3); }
+.file-row .filename { font-family: var(--font-mono); font-size: 12px; color: var(--text2); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.file-row .badge { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 500; }
+.file-row .badge.ok { background: rgba(61, 184, 122, 0.15); color: var(--green); }
+.file-row .badge.opt { background: rgba(96, 104, 120, 0.2); color: var(--text3); }
+
+/* ── Table ───────────────────────────────────────────────────────────────── */
+.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+.data-table th {
+ text-align: left;
+ padding: 8px 12px;
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text3);
+ border-bottom: 1px solid var(--border);
+}
+.data-table td {
+ padding: 9px 12px;
+ border-bottom: 1px solid var(--row-sep);
+ color: var(--text);
+}
+.data-table tr:hover td { background: var(--bg3); }
+.data-table .mono { font-family: var(--font-mono); font-size: 12px; }
+.data-table .pill {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 500;
+}
+.pill-blue { background: rgba(74, 158, 255, 0.15); color: var(--accent); }
+.pill-green { background: rgba(61, 184, 122, 0.15); color: var(--green); }
+.pill-amber { background: rgba(245, 158, 11, 0.15); color: var(--amber); }
+.pill-red { background: rgba(224, 82, 82, 0.15); color: var(--red); }
+.pill-gray { background: rgba(96, 104, 120, 0.2); color: var(--text3); }
+
+/* ── Filter bar ─────────────────────────────────────────────────────────── */
+.filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+}
+.filter-bar select, .filter-bar input {
+ padding: 5px 10px;
+ background: var(--bg2);
+ border: 1px solid var(--border2);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ font-size: 13px;
+ font-family: var(--font-sans);
+ outline: none;
+}
+.filter-bar select:focus, .filter-bar input:focus { border-color: var(--accent); }
+.filter-bar label { font-size: 12px; color: var(--text3); }
+
+/* ── Progress bar ───────────────────────────────────────────────────────── */
+.pbar-bg { background: var(--bg3); border-radius: 2px; height: 6px; width: 100%; }
+.pbar-fill { height: 6px; border-radius: 2px; transition: width 0.3s; }
+
+/* ── Status badge ───────────────────────────────────────────────────────── */
+.status-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 5px; }
+.dot-green { background: var(--green); }
+.dot-amber { background: var(--amber); }
+.dot-red { background: var(--red); }
+.dot-gray { background: var(--text3); }
+
+/* ── Scrollbar ───────────────────────────────────────────────────────────── */
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
+
+/* ── Utility ─────────────────────────────────────────────────────────────── */
+.mt-16 { margin-top: 16px; }
+.mt-24 { margin-top: 24px; }
+.mb-16 { margin-bottom: 16px; }
+.text-muted { color: var(--text3); }
+.text-mono { font-family: var(--font-mono); }
+.full-width-card { grid-column: 1 / -1; }
diff --git a/src/chat/actionItems.ts b/src/chat/actionItems.ts
new file mode 100644
index 0000000..e1942ce
--- /dev/null
+++ b/src/chat/actionItems.ts
@@ -0,0 +1,117 @@
+import type { LLMConfig } from "./providers";
+import { resolveModel } from "./providers";
+import { fetchWithRetry } from "./fetchWithRetry";
+import LLM_CONFIG from "../config/llm.json";
+
+const SYSTEM =
+ "You are an analyst for Maine DOC corrections education programs. " +
+ "Given a section label and key metrics, write exactly 1-2 concise, specific, data-driven sentences " +
+ "as an action recommendation for program managers. Be concrete and actionable. " +
+ "Output ONLY the recommendation text — no labels, no preamble, no markdown.";
+
+function buildPrompt(sectionLabel: string, metrics: Record): string {
+ return `Section: ${sectionLabel}\nMetrics:\n${JSON.stringify(metrics, null, 2)}\n\nWrite the action recommendation.`;
+}
+
+async function callGemini(sectionLabel: string, metrics: Record, apiKey: string, model: string): Promise {
+ const response = await fetchWithRetry(
+ `${LLM_CONFIG.endpoints.geminiBase}/${model}:generateContent?key=${apiKey}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ systemInstruction: { parts: [{ text: SYSTEM }] },
+ contents: [{ role: "user", parts: [{ text: buildPrompt(sectionLabel, metrics) }] }],
+ generationConfig: { temperature: LLM_CONFIG.params.actionItems.temperature, maxOutputTokens: LLM_CONFIG.params.actionItems.maxTokens },
+ }),
+ }
+ );
+ if (!response.ok) throw new Error(`Gemini ${response.status}`);
+ const body = await response.json();
+ return (body.candidates?.[0]?.content?.parts?.[0]?.text ?? "").trim();
+}
+
+async function callOpenAI(sectionLabel: string, metrics: Record, apiKey: string, model: string): Promise {
+ const response = await fetchWithRetry(
+ LLM_CONFIG.endpoints.openai,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
+ body: JSON.stringify({
+ model,
+ messages: [
+ { role: "system", content: SYSTEM },
+ { role: "user", content: buildPrompt(sectionLabel, metrics) },
+ ],
+ temperature: LLM_CONFIG.params.actionItems.temperature,
+ max_tokens: LLM_CONFIG.params.actionItems.maxTokens,
+ }),
+ }
+ );
+ if (!response.ok) throw new Error(`OpenAI ${response.status}`);
+ const body = await response.json();
+ return (body.choices?.[0]?.message?.content ?? "").trim();
+}
+
+async function callClaude(sectionLabel: string, metrics: Record, apiKey: string, model: string): Promise {
+ const response = await fetchWithRetry(
+ LLM_CONFIG.endpoints.claude,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": apiKey,
+ "anthropic-version": LLM_CONFIG.anthropicVersion,
+ },
+ body: JSON.stringify({
+ model,
+ system: SYSTEM,
+ messages: [{ role: "user", content: buildPrompt(sectionLabel, metrics) }],
+ max_tokens: LLM_CONFIG.params.actionItems.maxTokens,
+ temperature: LLM_CONFIG.params.actionItems.temperature,
+ }),
+ }
+ );
+ if (!response.ok) throw new Error(`Claude ${response.status}`);
+ const body = await response.json();
+ return (body.content?.[0]?.text ?? "").trim();
+}
+
+async function callOllama(sectionLabel: string, metrics: Record, model: string): Promise {
+ const response = await fetchWithRetry(
+ LLM_CONFIG.endpoints.ollama,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model,
+ messages: [
+ { role: "system", content: SYSTEM },
+ { role: "user", content: buildPrompt(sectionLabel, metrics) },
+ ],
+ temperature: LLM_CONFIG.params.actionItems.temperature,
+ stream: false,
+ }),
+ }
+ );
+ if (!response.ok) {
+ if (response.status === 404) throw new Error(`Ollama model not found. Run: ollama pull ${model}`);
+ throw new Error(`Ollama ${response.status}`);
+ }
+ const body = await response.json();
+ return (body.choices?.[0]?.message?.content ?? "").trim();
+}
+
+export async function generateActionItem(
+ sectionLabel: string,
+ metrics: Record,
+ config: LLMConfig,
+): Promise {
+ const model = resolveModel(config);
+ switch (config.provider) {
+ case "gemini": return callGemini(sectionLabel, metrics, config.apiKey, model);
+ case "openai": return callOpenAI(sectionLabel, metrics, config.apiKey, model);
+ case "claude": return callClaude(sectionLabel, metrics, config.apiKey, model);
+ case "ollama": return callOllama(sectionLabel, metrics, model);
+ }
+}
diff --git a/src/chat/aggregator.ts b/src/chat/aggregator.ts
new file mode 100644
index 0000000..1559e37
--- /dev/null
+++ b/src/chat/aggregator.ts
@@ -0,0 +1,153 @@
+import type {
+ ParsedData, DataPlan, DataRequest, FilterSpec, ComputeSpec,
+ FlatEnrollment, FlatResident, FlatSession,
+} from "../types";
+import { buildFlatEnrollments, buildFlatResidents, buildFlatSessions } from "./flatTables";
+
+type FlatRow = Record;
+
+interface FlatTables {
+ flat_enrollments: FlatRow[];
+ flat_residents: FlatRow[];
+ flat_sessions: FlatRow[];
+}
+
+// Known boolean and numeric fields for value coercion.
+const BOOL_FIELDS = new Set(["anyEnrollment", "anyCompletion"]);
+const NUM_FIELDS = new Set(["monthsToRelease", "durationMinutes", "activePrograms"]);
+
+function coerce(value: string | undefined, field: string): unknown {
+ if (value === undefined || value === null) return undefined;
+ if (BOOL_FIELDS.has(field)) return value === "true";
+ if (NUM_FIELDS.has(field)) return parseFloat(value);
+ return value;
+}
+
+function matchesFilter(row: FlatRow, f: FilterSpec): boolean {
+ const val = row[f.field];
+ const cv = coerce(f.value, f.field);
+
+ switch (f.op) {
+ case "eq": return val === cv;
+ case "neq": return val !== cv;
+ case "in": {
+ let arr: string[];
+ try { arr = JSON.parse(f.value ?? "[]"); } catch { arr = []; }
+ return arr.includes(String(val));
+ }
+ case "notNull": return val != null;
+ case "isNull": return val == null;
+ case "gt": return typeof val === "number" && typeof cv === "number" && val > cv;
+ case "lt": return typeof val === "number" && typeof cv === "number" && val < cv;
+ case "gte": return typeof val === "number" && typeof cv === "number" && val >= cv;
+ case "lte": return typeof val === "number" && typeof cv === "number" && val <= cv;
+ default: return true;
+ }
+}
+
+function applyFilters(rows: FlatRow[], filters: FilterSpec[]): FlatRow[] {
+ return rows.filter(row => filters.every(f => matchesFilter(row, f)));
+}
+
+function computeOne(rows: FlatRow[], spec: ComputeSpec): number | null {
+ if (rows.length === 0) return null;
+
+ switch (spec.fn) {
+ case "count": return rows.length;
+
+ case "rate": {
+ if (!spec.rateField) return null;
+ const cv = coerce(spec.rateValue, spec.rateField);
+ const n = rows.filter(r => r[spec.rateField!] === cv).length;
+ return rows.length > 0 ? Math.round((n / rows.length) * 1000) / 1000 : null;
+ }
+
+ case "mean": {
+ if (!spec.field) return null;
+ const nums = rows.map(r => Number(r[spec.field!])).filter(n => !isNaN(n));
+ return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
+ }
+
+ case "sum": {
+ if (!spec.field) return null;
+ return rows.map(r => Number(r[spec.field!])).filter(n => !isNaN(n)).reduce((a, b) => a + b, 0);
+ }
+
+ case "distinctCount": {
+ if (!spec.field) return null;
+ return new Set(rows.map(r => String(r[spec.field!]))).size;
+ }
+
+ case "median": {
+ if (!spec.field) return null;
+ const sorted = rows.map(r => Number(r[spec.field!])).filter(n => !isNaN(n)).sort((a, b) => a - b);
+ if (!sorted.length) return null;
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
+ }
+
+ default: return null;
+ }
+}
+
+function executeRequest(
+ req: DataRequest,
+ tables: FlatTables,
+): { id: string; label: string; data: unknown } {
+ const allRows = tables[req.table] ?? [];
+ const filtered = req.filters?.length ? applyFilters(allRows, req.filters) : allRows;
+
+ if (!req.groupBy?.length) {
+ const result: Record = {};
+ for (const spec of req.compute) result[spec.alias] = computeOne(filtered, spec);
+ return { id: req.id, label: req.label, data: result };
+ }
+
+ // Group rows by composite key
+ const groupMap = new Map();
+ for (const row of filtered) {
+ const key = req.groupBy!.map(f => String(row[f] ?? "null")).join("\x00");
+ const bucket = groupMap.get(key);
+ if (bucket) bucket.push(row);
+ else groupMap.set(key, [row]);
+ }
+
+ let groups: Record[] = [...groupMap.entries()].map(([key, rows]) => {
+ const dims = Object.fromEntries(req.groupBy!.map((f, i) => [f, key.split("\x00")[i]]));
+ const metrics: Record = {};
+ for (const spec of req.compute) metrics[spec.alias] = computeOne(rows, spec);
+ return { ...dims, ...metrics, _count: rows.length };
+ });
+
+ if (req.sortBy) {
+ const sortField = req.sortBy;
+ const dir = req.sortDir === "asc" ? 1 : -1;
+ groups.sort((a, b) => dir * ((Number(a[sortField]) || 0) - (Number(b[sortField]) || 0)));
+ }
+
+ if (req.limit) groups = groups.slice(0, req.limit);
+ return { id: req.id, label: req.label, data: groups };
+}
+
+export function executeDataPlan(
+ plan: DataPlan,
+ data: ParsedData,
+): { id: string; label: string; data: unknown }[] {
+ const tables: FlatTables = {
+ flat_enrollments: buildFlatEnrollments(data) as unknown as FlatRow[],
+ flat_residents: buildFlatResidents(data) as unknown as FlatRow[],
+ flat_sessions: buildFlatSessions(data) as unknown as FlatRow[],
+ };
+
+ return plan.data_requests.map(req => {
+ try {
+ return executeRequest(req, tables);
+ } catch {
+ return { id: req.id, label: req.label, data: { error: "Computation failed — check field names and filter values." } };
+ }
+ });
+}
+
+// Expose flat table builders for downstream use (e.g. size checks).
+export { buildFlatEnrollments, buildFlatResidents, buildFlatSessions };
+export type { FlatEnrollment, FlatResident, FlatSession };
diff --git a/src/chat/contextPacket.ts b/src/chat/contextPacket.ts
new file mode 100644
index 0000000..1a01799
--- /dev/null
+++ b/src/chat/contextPacket.ts
@@ -0,0 +1,81 @@
+import type { ParsedData, MetricSnapshot, ContextPacket, ContextPacketMetric, DataPlan } from "../types";
+import { executeDataPlan } from "./aggregator";
+
+const GLOSSARY: Record = {
+ completion_rate: "Completed enrollments ÷ total enrollments (consistent denominator per request).",
+ never_engaged: "A resident with no DOC program enrollment, no UL enrollment, and no UL platform session.",
+ near_release: "A resident with fewer than 24 months to projected release date.",
+ lsiBand: "Level of Service Inventory risk band: Low | Moderate | High | Maximum.",
+};
+
+export function buildContextPacket(
+ plan: DataPlan,
+ data: ParsedData,
+ snapshot: MetricSnapshot | null,
+ question: string,
+): ContextPacket {
+ const results = executeDataPlan(plan, data);
+
+ function isEmptyResult(d: unknown): boolean {
+ if (d === null || d === undefined) return true;
+ if (Array.isArray(d)) return d.length === 0;
+ if (typeof d === "object") {
+ return Object.values(d as Record).every(v => v === null || v === undefined);
+ }
+ return false;
+ }
+
+ const metrics: ContextPacketMetric[] = [];
+ const emptyMetricLabels: string[] = [];
+ for (const r of results) {
+ if (isEmptyResult(r.data)) {
+ emptyMetricLabels.push(r.label);
+ } else {
+ metrics.push({ metric_id: r.id, label: r.label, data: r.data });
+ }
+ }
+
+ const dataGaps: string[] = [];
+ if (emptyMetricLabels.length > 0) {
+ dataGaps.push(
+ `No data returned for: ${emptyMetricLabels.join("; ")}. ` +
+ `Filters may be too specific or the source data has no matching records.`,
+ );
+ }
+ const needsEnrollments = plan.data_requests.some(r => r.table === "flat_enrollments");
+ const needsResidents = plan.data_requests.some(r => r.table === "flat_residents");
+ const needsSessions = plan.data_requests.some(r => r.table === "flat_sessions");
+
+ if (needsEnrollments && data.enrollments.length === 0 && data.ulEnrollments.length === 0) {
+ dataGaps.push("No enrollment data — doc_programs and UnlockEd enrollments are both empty.");
+ }
+ if (needsResidents && data.residents.length === 0) {
+ dataGaps.push("No resident roster — doc_residents is not loaded.");
+ }
+ if (needsSessions && data.sessions.length === 0) {
+ dataGaps.push("No session data — user_session_tracking is not loaded.");
+ }
+
+ // Warn about UL demographic gaps when the plan groups/filters on demographics
+ const demographicFields = new Set(["lsiBand", "custodyLevel", "educationLevel", "offenseCategory", "gender"]);
+ const usesDemographics = plan.data_requests.some(r =>
+ r.groupBy?.some(f => demographicFields.has(f)) ||
+ r.filters?.some(f => demographicFields.has(f.field))
+ );
+ if (usesDemographics && data.ulEnrollments.length > 0) {
+ dataGaps.push(
+ "UL (UnlockEd) enrollments have no resident demographics; LSI/custody/education breakdowns cover DOC program enrollments only.",
+ );
+ }
+
+ return {
+ snapshot_id: snapshot?.id ?? "unknown",
+ as_of_date: snapshot?.as_of_date ?? new Date().toISOString().slice(0, 10),
+ question,
+ filters: plan.filters,
+ metrics,
+ data_gaps: dataGaps,
+ definitions: GLOSSARY,
+ router_confidence: plan.confidence,
+ };
+}
diff --git a/src/chat/fetchWithRetry.ts b/src/chat/fetchWithRetry.ts
new file mode 100644
index 0000000..b7af509
--- /dev/null
+++ b/src/chat/fetchWithRetry.ts
@@ -0,0 +1,27 @@
+import LLM_CONFIG from "../config/llm.json";
+
+const RETRYABLE_CODES = LLM_CONFIG.retry.retryableCodes;
+
+export async function fetchWithRetry(
+ url: string,
+ init: RequestInit,
+ maxAttempts = LLM_CONFIG.retry.maxAttempts,
+): Promise {
+ if (maxAttempts < 1) throw new Error("fetchWithRetry: maxAttempts must be ≥ 1");
+ let res!: Response;
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ if (attempt > 0) {
+ await new Promise(r => setTimeout(r, LLM_CONFIG.retry.baseDelayMs * 2 ** (attempt - 1)));
+ }
+ try {
+ res = await fetch(url, init);
+ } catch {
+ if (url.startsWith("http://localhost") || url.startsWith("http://127.0.0.1")) {
+ throw new Error("Could not connect to Ollama. Make sure Ollama is running (`ollama serve`).");
+ }
+ throw new Error("Network error: could not reach the AI provider. Check your internet connection.");
+ }
+ if (!RETRYABLE_CODES.includes(res.status)) return res;
+ }
+ return res;
+}
diff --git a/src/chat/flatTables.ts b/src/chat/flatTables.ts
new file mode 100644
index 0000000..5767ca5
--- /dev/null
+++ b/src/chat/flatTables.ts
@@ -0,0 +1,89 @@
+import type { ParsedData, FlatEnrollment, FlatResident, FlatSession } from "../types";
+
+function toYYYYMM(date: Date | null | undefined): string | null {
+ if (!date) return null;
+ if (isNaN(date.getTime())) return null;
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
+}
+
+export function buildFlatEnrollments(data: ParsedData): FlatEnrollment[] {
+ // Resident lookup by DOC ID, used to join demographics onto DOC-source enrollments.
+ const residentById = new Map(data.residents.map(r => [r.id, r]));
+ const results: FlatEnrollment[] = [];
+
+ for (const e of data.enrollments) {
+ const res = residentById.get(e.userId);
+ results.push({
+ enrollmentId: e.enrollmentId,
+ facilityCode: e.facilityCode,
+ programName: e.programName,
+ programType: e.programType,
+ status: e.status,
+ source: "doc",
+ lsiBand: res?.lsiBand ?? null,
+ custodyLevel: res?.custodyLevel ?? null,
+ educationLevel: res?.educationLevel ?? null,
+ offenseCategory: res?.offenseCategory ?? null,
+ gender: res?.gender ?? null,
+ enrolledMonth: toYYYYMM(e.enrolledDate),
+ completionMonth: toYYYYMM(e.completionDate),
+ });
+ }
+
+ // UL enrollments cannot be joined to residents (no shared key in ParsedData).
+ for (const e of data.ulEnrollments) {
+ results.push({
+ enrollmentId: e.enrollmentId,
+ facilityCode: e.facilityCode,
+ programName: e.programName,
+ programType: e.programType,
+ status: e.status,
+ source: "ul",
+ lsiBand: null,
+ custodyLevel: null,
+ educationLevel: null,
+ offenseCategory: null,
+ gender: null,
+ enrolledMonth: toYYYYMM(e.enrolledDate),
+ completionMonth: toYYYYMM(e.completionDate),
+ });
+ }
+
+ return results;
+}
+
+export function buildFlatResidents(data: ParsedData): FlatResident[] {
+ const enrolledIds = new Set(data.enrollments.map(e => e.userId));
+ const completedIds = new Set(
+ data.enrollments.filter(e => e.status === "Completed").map(e => e.userId),
+ );
+ const activeCountById = new Map();
+ for (const e of data.enrollments) {
+ if (e.status === "Active") {
+ activeCountById.set(e.userId, (activeCountById.get(e.userId) ?? 0) + 1);
+ }
+ }
+
+ return data.residents.map(r => ({
+ id: r.id,
+ facilityCode: r.facilityCode,
+ lsiBand: r.lsiBand,
+ custodyLevel: r.custodyLevel,
+ educationLevel: r.educationLevel,
+ offenseCategory: r.offenseCategory,
+ gender: r.gender,
+ monthsToRelease: r.monthsToRelease,
+ anyEnrollment: enrolledIds.has(r.id),
+ anyCompletion: completedIds.has(r.id),
+ activePrograms: activeCountById.get(r.id) ?? 0,
+ }));
+}
+
+export function buildFlatSessions(data: ParsedData): FlatSession[] {
+ return data.sessions
+ .map(s => ({
+ sessionMonth: s.session_date?.slice(0, 7) ?? "",
+ durationMinutes: parseFloat(s.duration_minutes) || 0,
+ }))
+ .filter(s => s.sessionMonth.length === 7);
+}
diff --git a/src/chat/prompts.ts b/src/chat/prompts.ts
new file mode 100644
index 0000000..70aa706
--- /dev/null
+++ b/src/chat/prompts.ts
@@ -0,0 +1,360 @@
+import type { ContextPacket } from "../types";
+
+// ─── Planner ─────────────────────────────────────────────────────────────────
+
+export const PLANNER_SYSTEM_INSTRUCTION = `You are a data planner for the MAINE DOC · INSIGHT analytics dashboard.
+Your job is to translate a user question into a DataPlan — a set of structured data requests that, when executed against the dashboard's raw data, will produce the information needed to answer the question.
+
+DATA SCHEMA:
+
+flat_enrollments — one row per program enrollment
+ Grouping / filter fields:
+ facilityCode string e.g. SMWRC, MVCF, MCF, MMF, PCCF, MCCI, CCCF, PCCC
+ programName string exact program name (e.g. "Adult Basic Education")
+ programType string Education | Vocational | Behavioral | Reentry | Other
+ status string Active | Waitlisted | Completed | Dropped
+ source string doc (DOC programs) | ul (UnlockEd platform)
+ lsiBand string Low | Moderate | High | Maximum (null for UL-only enrollments)
+ custodyLevel string resident custody level (null for UL-only)
+ educationLevel string resident's highest education level (null for UL-only)
+ offenseCategory string offense category (null for UL-only)
+ gender string (null for UL-only)
+ enrolledMonth string YYYY-MM (null if unknown)
+ completionMonth string YYYY-MM (null if unknown)
+
+flat_residents — one row per resident
+ Fields:
+ facilityCode string
+ lsiBand string Low | Moderate | High | Maximum
+ custodyLevel string
+ educationLevel string
+ offenseCategory string
+ gender string
+ monthsToRelease number months until projected release (null if unknown)
+ anyEnrollment boolean true if resident has any DOC program enrollment
+ anyCompletion boolean true if resident has completed at least one program
+ activePrograms number count of currently active enrollments
+
+flat_sessions — one row per platform session (UnlockEd; no facility breakdown available)
+ Fields:
+ sessionMonth string YYYY-MM
+ durationMinutes number session length in minutes
+
+COMPUTE FUNCTIONS:
+ count no field required — counts rows in group
+ rate requires rateField + rateValue — proportion where rateField == rateValue
+ mean requires field — arithmetic mean of a numeric field
+ sum requires field — total of a numeric field
+ distinctCount requires field — number of distinct values
+ median requires field — median of a numeric field
+
+FILTER OPS:
+ eq / neq equals / not equals; value: string
+ in value is JSON-stringified array, e.g. '["Active","Completed"]'
+ notNull field is not null; no value needed
+ isNull field is null; no value needed
+ gt lt gte lte numeric comparison; value: number as string (e.g. "24")
+
+OUTPUT FORMAT (valid JSON only, no prose, no markdown):
+{
+ "data_requests": [
+ {
+ "id": string, // short camelCase name, e.g. "completionByFacility"
+ "label": string, // human-readable description shown to the writer
+ "table": string, // flat_enrollments | flat_residents | flat_sessions
+ "filters": [{ "field": string, "op": string, "value": string }],
+ "groupBy": [string], // omit for statewide / ungrouped aggregates
+ "compute": [
+ {
+ "alias": string,
+ "fn": string,
+ "field": string, // for mean/sum/distinctCount/median
+ "rateField": string, // for rate
+ "rateValue": string // for rate
+ }
+ ],
+ "limit": number, // optional
+ "sortBy": string, // optional result column to sort by
+ "sortDir": "asc" | "desc"
+ }
+ ],
+ "filters": { "facility": string|null, "program": string|null },
+ "confidence": number,
+ "refusal_reason": string|null,
+ "nearest_hints": string[]
+}
+
+HARD REFUSALS — return data_requests: [], confidence: 0:
+ - Questions about individual residents by name or DOC number
+ - Questions asking for post-release outcomes (employment, housing, recidivism)
+ - Questions asking to predict or forecast outcomes not in the data
+
+CONFIDENCE:
+ 0.9–1.0 data directly and fully answers the question
+ 0.6–0.89 data partially answers it (e.g. wrong time window, demographic data missing for UL enrollments)
+ < 0.6 cannot answer; set refusal_reason and populate nearest_hints with 2–3 things the data CAN show
+
+GRANULARITY RULES:
+ - Include only the groupBy dimensions the question explicitly asks for
+ - "Overall" / "statewide" questions → no groupBy
+ - Cap group-by-programName results at limit: 20 (many programs produce long lists)
+ - Cross-tabulations (e.g. facilityCode × lsiBand) are fine when the question asks for them
+ - Note: lsiBand / custodyLevel / educationLevel are only available for doc-source enrollments;
+ include a filter source: eq "doc" when those fields are part of the analysis
+
+DATA AVAILABILITY NOTE:
+ - flat_enrollments joins DOC program participation with resident demographics; UL enrollments have null demographics
+ - flat_sessions has no facilityCode — statewide session counts only
+ - "Engagement" means program enrollment and participation (anyEnrollment, activePrograms, status in
+ flat_residents / flat_enrollments). Use flat_sessions only when a question explicitly asks about
+ UnlockEd platform usage or time-on-platform. flat_sessions is often not loaded — prefer flat_residents
+ and flat_enrollments for engagement questions.
+ - Facility names in questions may not match facility codes exactly (e.g. "Maine State Prison" may refer
+ to MCF, MMF, or another code). When a facility name is ambiguous, run a statewide query with
+ facilityCode as a groupBy dimension rather than filtering by an unrecognised code.
+
+EXAMPLES:
+
+Q: "What are the completion rates by facility?"
+→ {
+ "data_requests": [{
+ "id": "completionByFacility",
+ "label": "Completion rate by facility",
+ "table": "flat_enrollments",
+ "filters": [],
+ "groupBy": ["facilityCode"],
+ "compute": [
+ {"alias": "total", "fn": "count"},
+ {"alias": "completionRate", "fn": "rate", "rateField": "status", "rateValue": "Completed"}
+ ],
+ "sortBy": "completionRate", "sortDir": "desc"
+ }],
+ "filters": {"facility": null, "program": null},
+ "confidence": 0.95, "refusal_reason": null, "nearest_hints": []
+ }
+
+Q: "How many High/Maximum LSI residents at MVCF aren't in any program?"
+→ {
+ "data_requests": [{
+ "id": "unenrolledHighLsi",
+ "label": "High and Maximum LSI residents at MVCF with no enrollment",
+ "table": "flat_residents",
+ "filters": [
+ {"field": "facilityCode", "op": "eq", "value": "MVCF"},
+ {"field": "lsiBand", "op": "in", "value": "[\"High\",\"Maximum\"]"},
+ {"field": "anyEnrollment", "op": "eq", "value": "false"}
+ ],
+ "groupBy": ["lsiBand"],
+ "compute": [{"alias": "count", "fn": "count"}]
+ }],
+ "filters": {"facility": "MVCF", "program": null},
+ "confidence": 0.9, "refusal_reason": null, "nearest_hints": []
+ }
+
+Q: "Completion rate by LSI band for education programs (DOC data only)"
+→ {
+ "data_requests": [{
+ "id": "completionByLsi",
+ "label": "Completion rate by LSI band (education programs, DOC source)",
+ "table": "flat_enrollments",
+ "filters": [
+ {"field": "programType", "op": "eq", "value": "Education"},
+ {"field": "source", "op": "eq", "value": "doc"}
+ ],
+ "groupBy": ["lsiBand"],
+ "compute": [
+ {"alias": "total", "fn": "count"},
+ {"alias": "completionRate", "fn": "rate", "rateField": "status", "rateValue": "Completed"}
+ ],
+ "sortBy": "lsiBand", "sortDir": "asc"
+ }],
+ "filters": {"facility": null, "program": null},
+ "confidence": 0.95, "refusal_reason": null, "nearest_hints": []
+ }
+
+Q: "How can we improve Maine state prison engagement?"
+→ {
+ "data_requests": [
+ {
+ "id": "engagementByFacility",
+ "label": "Resident engagement rates by facility",
+ "table": "flat_residents",
+ "filters": [],
+ "groupBy": ["facilityCode"],
+ "compute": [
+ {"alias": "total", "fn": "count"},
+ {"alias": "anyEnrollmentRate", "fn": "rate", "rateField": "anyEnrollment", "rateValue": "true"},
+ {"alias": "anyCompletionRate", "fn": "rate", "rateField": "anyCompletion", "rateValue": "true"}
+ ],
+ "sortBy": "anyEnrollmentRate", "sortDir": "asc"
+ },
+ {
+ "id": "neverEngagedByFacility",
+ "label": "Never-engaged count by facility",
+ "table": "flat_residents",
+ "filters": [
+ {"field": "anyEnrollment", "op": "eq", "value": "false"}
+ ],
+ "groupBy": ["facilityCode"],
+ "compute": [{"alias": "count", "fn": "count"}],
+ "sortBy": "count", "sortDir": "desc"
+ },
+ {
+ "id": "enrollmentStatusBreakdown",
+ "label": "Enrollment status breakdown statewide",
+ "table": "flat_enrollments",
+ "filters": [],
+ "groupBy": ["status"],
+ "compute": [{"alias": "count", "fn": "count"}]
+ }
+ ],
+ "filters": {"facility": null, "program": null},
+ "confidence": 0.85, "refusal_reason": null, "nearest_hints": []
+ }
+
+Q: "Show me John Smith's progress"
+→ {
+ "data_requests": [],
+ "filters": {},
+ "confidence": 0,
+ "refusal_reason": "Individual resident lookups are not supported — only aggregate metrics are available.",
+ "nearest_hints": ["Completion rates by facility", "Resident engagement tiers statewide", "Never-enrolled counts by LSI band"]
+ }`;
+
+export function buildPlannerUserTurn(
+ question: string,
+ hintLabels: string[] = [],
+ dataEnums?: Record,
+): string {
+ const hintsText = hintLabels.length > 0
+ ? `CONTEXT HINTS (user was viewing a section about these topics — prefer data that covers them when relevant):\n${hintLabels.join(", ")}\n\n`
+ : "";
+
+ let vocabText = "";
+ if (dataEnums && Object.keys(dataEnums).length > 0) {
+ const lines = Object.entries(dataEnums)
+ .filter(([, vals]) => vals.length > 0)
+ .map(([key, vals]) => ` ${key}: ${vals.join(", ")}`);
+ if (lines.length > 0) {
+ vocabText = `LIVE VOCABULARY (use these exact values in filters — do not use values not on these lists):\n${lines.join("\n")}\n\n`;
+ }
+ }
+
+ return `${vocabText}${hintsText}USER QUESTION: "${question}"`;
+}
+
+// ─── Writer ──────────────────────────────────────────────────────────────────
+
+export const WRITER_SYSTEM_INSTRUCTION = `You are an analyst writing an insight narrative for Maine DOC education program managers.
+
+RULES:
+
+1. SOURCE OF TRUTH:
+ - Answer only from the metric data provided. Do not introduce facts, baselines,
+ or comparisons not present in the data.
+ - Domain vocabulary from the business definitions is fine; outside benchmarks
+ are not.
+
+2. CITATIONS:
+ - Cite every number in square brackets: [metric_id: value].
+ - For nested values, use dot notation: [program_completion_rate.ABE: 13.6%].
+ - For counts, include the unit: [engagement_tiers.high: 14 users].
+ - For trends, cite both endpoints: [monthly_active.jun_2025: 30 users] to
+ [monthly_active.feb_2026: 5 users].
+
+3. PREMISE CHECK:
+ - If the question assumes a direction the data contradicts (e.g., "why is
+ engagement up?" when data shows decline), state the correct direction
+ in the first sentence before answering.
+
+4. SMALL SAMPLES:
+ - If a metric value is based on N < 5 users or N < 10 events, describe it
+ as "limited sample" or "based on N=X" rather than presenting it as a
+ stable rate. Do not extrapolate from small samples.
+
+5. CAVEATS:
+ - If a metric carries a caveat (e.g., unit unconfirmed, batch-timestamp
+ artifact, process-related zero), surface it in the narrative when it
+ materially affects interpretation.
+
+6. UNCERTAINTY:
+ - If ROUTER CONFIDENCE < 0.7, hedge the framing ("the closest available
+ metric suggests…") and note what would answer the question more directly.
+ - If AS OF is more than 60 days before today and the question asks about
+ "now" / "currently" / "this month," note the staleness.
+
+7. PRIVACY:
+ - Never name individual residents. Never use DOC numbers. Aggregates only.
+ - If any reported count is < 5, say "fewer than 5" rather than the exact
+ number.
+
+8. LENGTH AND TONE:
+ - 2–4 sentences. Plain language for a program manager, not a statistician.
+ - Lead with the most important finding.
+ - End with one actionable implication only when the data supports a clear
+ next step. Skip the implication for single-number lookups or when the
+ finding is purely descriptive.
+
+9. EMPTY-METRICS BRANCH (rules 8 and the implication requirement are waived):
+ - If metrics[] is empty, do not compose a narrative.
+ - List the data gaps and explain which source files would need to be
+ loaded to answer the question.
+ - One short paragraph, no citations needed.
+
+CITATION FORMAT EXAMPLES:
+
+Single value:
+"Completion rates vary sharply by program type [completion_by_program_type.mental_health: 76.5%] versus [completion_by_program_type.vocational: 0%]."
+
+Trend:
+"Active users peaked at [monthly_active.jun_2025: 30] and fell to [monthly_active.feb_2026: 5] over eight months."
+
+Small sample:
+"Anger Management shows the highest rate [completion.anger_management: 76.5%], though based on a limited sample of 14 enrollees."
+
+With caveat:
+"Financial Literacy attendance is logged but shows [attendance.financial_literacy.present_rate: 0%], which the data team has flagged as a process issue rather than actual non-attendance."
+
+Premise correction:
+"Engagement has actually declined, not grown — active users dropped from [monthly_active.jun_2025: 30] to [monthly_active.feb_2026: 5]. The most recent uptick is concentrated in a single January cohort."
+
+Empty metrics:
+"The available metrics do not cover post-release employment. Answering this would require loading post-release outcome data (employment status, wage records) which is not currently in the platform export."`;
+
+export function buildWriterUserTurn(packet: ContextPacket): string {
+ const metricsText = packet.metrics
+ .map(m => {
+ const caveatsText = m.caveats && m.caveats.length > 0
+ ? `\nCAVEATS: ${m.caveats.join("; ")}`
+ : "";
+ return `### ${m.label} (${m.metric_id})\n${JSON.stringify(m.data, null, 2)}${caveatsText}`;
+ })
+ .join("\n\n");
+
+ const definitionsText = Object.entries(packet.definitions)
+ .map(([k, v]) => `- **${k}:** ${v}`)
+ .join("\n");
+
+ const gapsText = packet.data_gaps.length > 0
+ ? `\n\nDATA GAPS (acknowledge these; do not invent values for them):\n${packet.data_gaps.join("\n")}`
+ : "";
+
+ const filterText = Object.keys(packet.filters).length > 0
+ ? `\nFOCUS: The user is asking specifically about: ${JSON.stringify(packet.filters)}. Prioritise that slice.\n`
+ : "";
+
+ const confidenceText = packet.router_confidence !== undefined
+ ? `ROUTER CONFIDENCE: ${packet.router_confidence}\n`
+ : "";
+
+ return `SNAPSHOT: ${packet.snapshot_id} | AS OF: ${packet.as_of_date}
+${confidenceText}${filterText}
+QUESTION: "${packet.question}"
+
+AVAILABLE METRICS:
+${metricsText}
+${gapsText}
+
+BUSINESS DEFINITIONS:
+${definitionsText}`;
+}
\ No newline at end of file
diff --git a/src/chat/providers.ts b/src/chat/providers.ts
new file mode 100644
index 0000000..7099a70
--- /dev/null
+++ b/src/chat/providers.ts
@@ -0,0 +1,47 @@
+import PROVIDER_MODELS from "../config/models.json";
+
+export type LLMProvider = "gemini" | "openai" | "claude" | "ollama";
+
+export interface LLMConfig {
+ provider: LLMProvider;
+ apiKey: string;
+ model?: string;
+}
+
+export interface ProviderInfo {
+ label: string;
+ models: string[];
+ keyPlaceholder: string;
+ keyHint: string;
+}
+
+export const PROVIDER_INFO: Record = {
+ gemini: {
+ label: "Google Gemini",
+ models: PROVIDER_MODELS.gemini,
+ keyPlaceholder: "AIza…",
+ keyHint: "aistudio.google.com",
+ },
+ openai: {
+ label: "OpenAI",
+ models: PROVIDER_MODELS.openai,
+ keyPlaceholder: "sk-…",
+ keyHint: "platform.openai.com/api-keys",
+ },
+ claude: {
+ label: "Anthropic Claude",
+ models: PROVIDER_MODELS.claude,
+ keyPlaceholder: "sk-ant-…",
+ keyHint: "console.anthropic.com",
+ },
+ ollama: {
+ label: "Ollama (local)",
+ models: PROVIDER_MODELS.ollama,
+ keyPlaceholder: "",
+ keyHint: "ollama.ai",
+ },
+};
+
+export function resolveModel(config: LLMConfig): string {
+ return config.model ?? PROVIDER_INFO[config.provider].models[0];
+}
diff --git a/src/chat/refusal.ts b/src/chat/refusal.ts
new file mode 100644
index 0000000..7c9bb69
--- /dev/null
+++ b/src/chat/refusal.ts
@@ -0,0 +1,29 @@
+import type { DataPlan, ChatMessage } from "../types";
+
+export function buildRefusalMessage(
+ plan: DataPlan,
+ snapshotId: string,
+): ChatMessage {
+ const lines: string[] = [];
+
+ if (plan.refusal_reason) {
+ lines.push(`I can't answer that from the available data: **${plan.refusal_reason}**`);
+ } else {
+ lines.push("I don't have a way to compute what you're asking with the available data.");
+ }
+
+ if (plan.nearest_hints && plan.nearest_hints.length > 0) {
+ lines.push("\nYou might try asking about:");
+ plan.nearest_hints.forEach(h => lines.push(`- ${h}`));
+ }
+
+ return {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: lines.join("\n"),
+ snapshot_id: snapshotId,
+ is_refusal: true,
+ nearest_metrics: [],
+ timestamp: new Date().toISOString(),
+ };
+}
diff --git a/src/chat/router.ts b/src/chat/router.ts
new file mode 100644
index 0000000..09664a1
--- /dev/null
+++ b/src/chat/router.ts
@@ -0,0 +1,280 @@
+import type { DataPlan, ConversationTurn } from "../types";
+import type { LLMConfig } from "./providers";
+import { resolveModel } from "./providers";
+import { fetchWithRetry } from "./fetchWithRetry";
+import { PLANNER_SYSTEM_INSTRUCTION, buildPlannerUserTurn } from "./prompts";
+import LLM_CONFIG from "../config/llm.json";
+
+// Gemini structured output schema for DataPlan.
+// Gemini doesn't support additionalProperties or union types in responseSchema,
+// so filter values are always strings; the aggregator coerces them as needed.
+const PLANNER_RESPONSE_SCHEMA = {
+ type: "object",
+ properties: {
+ data_requests: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ label: { type: "string" },
+ table: { type: "string" },
+ filters: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ field: { type: "string" },
+ op: { type: "string" },
+ value: { type: "string" },
+ },
+ required: ["field", "op"],
+ },
+ },
+ groupBy: { type: "array", items: { type: "string" } },
+ compute: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ alias: { type: "string" },
+ fn: { type: "string" },
+ field: { type: "string" },
+ rateField: { type: "string" },
+ rateValue: { type: "string" },
+ },
+ required: ["alias", "fn"],
+ },
+ },
+ limit: { type: "number" },
+ sortBy: { type: "string" },
+ sortDir: { type: "string" },
+ },
+ required: ["id", "label", "table", "compute"],
+ },
+ },
+ filters: {
+ type: "object",
+ properties: {
+ facility: { type: "string" },
+ program: { type: "string" },
+ },
+ },
+ confidence: { type: "number" },
+ refusal_reason: { type: "string" },
+ nearest_hints: { type: "array", items: { type: "string" } },
+ },
+ required: ["data_requests", "filters", "confidence"],
+};
+
+function normalizePlan(raw: Partial): DataPlan {
+ return {
+ data_requests: raw.data_requests ?? [],
+ filters: raw.filters ?? {},
+ confidence: raw.confidence ?? 0,
+ refusal_reason: raw.refusal_reason,
+ nearest_hints: raw.nearest_hints,
+ };
+}
+
+async function callPlannerGemini(
+ question: string,
+ config: LLMConfig,
+ hintLabels: string[],
+ history: ConversationTurn[],
+ dataEnums?: Record,
+): Promise {
+ const model = resolveModel(config);
+ const historyContents = history.map(h => ({
+ role: h.role === "assistant" ? "model" : "user",
+ parts: [{ text: h.content }],
+ }));
+ const response = await fetchWithRetry(
+ `${LLM_CONFIG.endpoints.geminiBase}/${model}:generateContent?key=${config.apiKey}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ systemInstruction: { parts: [{ text: PLANNER_SYSTEM_INSTRUCTION }] },
+ contents: [
+ ...historyContents,
+ { role: "user", parts: [{ text: buildPlannerUserTurn(question, hintLabels, dataEnums) }] },
+ ],
+ generationConfig: {
+ responseMimeType: "application/json",
+ responseSchema: PLANNER_RESPONSE_SCHEMA,
+ temperature: LLM_CONFIG.params.planner.temperature,
+ },
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 503) throw new Error("The AI model is currently overloaded. Please wait a moment and try again.");
+ throw new Error(`Planner ${response.status}: ${await response.text()}`);
+ }
+ const body = await response.json();
+ const text: string = body.candidates?.[0]?.content?.parts?.[0]?.text;
+ if (!text) throw new Error("Planner returned empty response");
+ return normalizePlan(JSON.parse(text));
+}
+
+async function callPlannerOpenAI(
+ question: string,
+ config: LLMConfig,
+ hintLabels: string[],
+ history: ConversationTurn[],
+ dataEnums?: Record,
+): Promise {
+ const sysInstruction = PLANNER_SYSTEM_INSTRUCTION
+ + "\n\nRespond ONLY with a valid JSON object. No explanation, markdown, or other text.";
+
+ const response = await fetchWithRetry(
+ LLM_CONFIG.endpoints.openai,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${config.apiKey}` },
+ body: JSON.stringify({
+ model: resolveModel(config),
+ messages: [
+ { role: "system", content: sysInstruction },
+ ...history.map(h => ({ role: h.role, content: h.content })),
+ { role: "user", content: buildPlannerUserTurn(question, hintLabels, dataEnums) },
+ ],
+ response_format: { type: "json_object" },
+ temperature: LLM_CONFIG.params.planner.temperature,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 503) throw new Error("The AI model is currently overloaded. Please wait a moment and try again.");
+ throw new Error(`Planner ${response.status}: ${await response.text()}`);
+ }
+ const body = await response.json();
+ const text: string = body.choices?.[0]?.message?.content;
+ if (!text) throw new Error("Planner returned empty response");
+ return normalizePlan(JSON.parse(text));
+}
+
+async function callPlannerClaude(
+ question: string,
+ config: LLMConfig,
+ hintLabels: string[],
+ history: ConversationTurn[],
+ dataEnums?: Record,
+): Promise {
+ const sysInstruction = PLANNER_SYSTEM_INSTRUCTION
+ + "\n\nOutput ONLY a valid JSON object. No explanation, markdown, or other text.";
+
+ const response = await fetchWithRetry(
+ LLM_CONFIG.endpoints.claude,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": config.apiKey,
+ "anthropic-version": LLM_CONFIG.anthropicVersion,
+ },
+ body: JSON.stringify({
+ model: resolveModel(config),
+ system: sysInstruction,
+ messages: [
+ ...history.map(h => ({ role: h.role, content: h.content })),
+ { role: "user", content: buildPlannerUserTurn(question, hintLabels, dataEnums) },
+ ],
+ max_tokens: LLM_CONFIG.params.planner.maxTokens,
+ temperature: LLM_CONFIG.params.planner.temperature,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 503) throw new Error("The AI model is currently overloaded. Please wait a moment and try again.");
+ throw new Error(`Planner ${response.status}: ${await response.text()}`);
+ }
+ const body = await response.json();
+ const text: string = body.content?.[0]?.text ?? "";
+ if (!text) throw new Error("Planner returned empty response");
+
+ // Extract JSON object even if wrapped in code fences
+ const jsonMatch = text.match(/```json\s*([\s\S]*?)```/) ?? text.match(/(\{[\s\S]*\})/);
+ if (!jsonMatch) throw new Error("Planner response contained no valid JSON");
+ return normalizePlan(JSON.parse(jsonMatch[1]));
+}
+
+async function callPlannerOllama(
+ question: string,
+ config: LLMConfig,
+ hintLabels: string[],
+ history: ConversationTurn[],
+ dataEnums?: Record,
+): Promise {
+ const sysInstruction = PLANNER_SYSTEM_INSTRUCTION
+ + "\n\nRespond ONLY with a valid JSON object. No explanation, markdown, or other text.";
+
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const suffix = attempt > 0
+ ? "\n\nIMPORTANT: Your previous response was not valid JSON. Respond ONLY with the JSON object, no other text."
+ : "";
+ const userTurn = buildPlannerUserTurn(question, hintLabels, dataEnums) + suffix;
+
+ const response = await fetchWithRetry(
+ LLM_CONFIG.endpoints.ollama,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: resolveModel(config),
+ messages: [
+ { role: "system", content: sysInstruction },
+ ...history.map(h => ({ role: h.role, content: h.content })),
+ { role: "user", content: userTurn },
+ ],
+ format: "json",
+ temperature: LLM_CONFIG.params.planner.temperature,
+ stream: false,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error(`Ollama model not found. Run: ollama pull ${resolveModel(config)}`);
+ }
+ throw new Error(`Planner ${response.status}: ${await response.text()}`);
+ }
+
+ const body = await response.json();
+ const text: string = body.choices?.[0]?.message?.content ?? "";
+ if (!text) throw new Error("Planner returned empty response");
+
+ try {
+ const jsonMatch = text.match(/```json\s*([\s\S]*?)```/) ?? text.match(/(\{[\s\S]*\})/);
+ const jsonText = jsonMatch ? jsonMatch[1] : text;
+ return normalizePlan(JSON.parse(jsonText));
+ } catch {
+ // fall through to retry
+ }
+ }
+
+ throw new Error(
+ `The local model returned malformed output twice. Try a larger model (e.g. \`ollama pull qwen2.5:14b\`) or rephrase your question.`
+ );
+}
+
+
+export async function callRouter(
+ question: string,
+ config: LLMConfig,
+ hintLabels: string[] = [],
+ history: ConversationTurn[] = [],
+ dataEnums?: Record,
+): Promise {
+ switch (config.provider) {
+ case "gemini": return callPlannerGemini(question, config, hintLabels, history, dataEnums);
+ case "openai": return callPlannerOpenAI(question, config, hintLabels, history, dataEnums);
+ case "claude": return callPlannerClaude(question, config, hintLabels, history, dataEnums);
+ case "ollama": return callPlannerOllama(question, config, hintLabels, history, dataEnums);
+ }
+}
diff --git a/src/chat/writer.ts b/src/chat/writer.ts
new file mode 100644
index 0000000..6294399
--- /dev/null
+++ b/src/chat/writer.ts
@@ -0,0 +1,209 @@
+import type { ContextPacket, ConversationTurn } from "../types";
+import type { LLMConfig } from "./providers";
+import { resolveModel } from "./providers";
+import { fetchWithRetry } from "./fetchWithRetry";
+import { WRITER_SYSTEM_INSTRUCTION, buildWriterUserTurn } from "./prompts";
+import LLM_CONFIG from "../config/llm.json";
+
+function extractNumbers(text: string): string[] {
+ return (text.match(/\b\d+(?:\.\d+)?/g) ?? []).filter(n => parseFloat(n) > 0);
+}
+
+function validateCitations(
+ response: string,
+ packet: ContextPacket,
+): { citationWarning?: string } {
+ const responseNums = extractNumbers(response);
+ const contextText = JSON.stringify(packet.metrics);
+ const contextNums = new Set(extractNumbers(contextText));
+
+ // Expand context numbers to include percentage-scaled equivalents of decimals (e.g. 0.821 → "82.1")
+ // to avoid false positives when the LLM renders a stored rate as a percentage.
+ const expandedContextNums = new Set(contextNums);
+ for (const n of contextNums) {
+ const v = parseFloat(n);
+ if (v > 0 && v < 1) expandedContextNums.add(String(Math.round(v * 1000) / 10));
+ }
+
+ // Heuristic: allow small whole numbers ≤ 10 without validation (counts, dates, etc.)
+ const suspect = responseNums.filter(n => parseFloat(n) > 10 && !expandedContextNums.has(n));
+
+ if (suspect.length === 0) return {};
+ return {
+ citationWarning:
+ `Note: some numbers in this response (${suspect.slice(0, 3).join(", ")}…) were not found in the metric data. Treat with caution.`,
+ };
+}
+
+async function callWriterGemini(
+ packet: ContextPacket,
+ apiKey: string,
+ model: string,
+ history: ConversationTurn[],
+): Promise<{ content: string; citationWarning?: string }> {
+ const historyContents = history.map(h => ({
+ role: h.role === "assistant" ? "model" : "user",
+ parts: [{ text: h.content }],
+ }));
+ const response = await fetchWithRetry(
+ `${LLM_CONFIG.endpoints.geminiBase}/${model}:generateContent?key=${apiKey}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ systemInstruction: { parts: [{ text: WRITER_SYSTEM_INSTRUCTION }] },
+ contents: [
+ ...historyContents,
+ { role: "user", parts: [{ text: buildWriterUserTurn(packet) }] },
+ ],
+ generationConfig: { temperature: LLM_CONFIG.params.writer.temperature, maxOutputTokens: LLM_CONFIG.params.writer.maxOutputTokens },
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 503) throw new Error("The AI model is currently overloaded. Please wait a moment and try again.");
+ throw new Error(`Writer ${response.status}: ${await response.text()}`);
+ }
+
+ const body = await response.json();
+ const content: string = body.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
+ if (!content) throw new Error("Writer returned empty response");
+
+ const { citationWarning } = validateCitations(content, packet);
+ return { content, citationWarning };
+}
+
+async function callWriterOpenAI(
+ packet: ContextPacket,
+ apiKey: string,
+ model: string,
+ history: ConversationTurn[],
+): Promise<{ content: string; citationWarning?: string }> {
+ const response = await fetchWithRetry(
+ LLM_CONFIG.endpoints.openai,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model,
+ messages: [
+ { role: "system", content: WRITER_SYSTEM_INSTRUCTION },
+ ...history.map(h => ({ role: h.role, content: h.content })),
+ { role: "user", content: buildWriterUserTurn(packet) },
+ ],
+ temperature: LLM_CONFIG.params.writer.temperature,
+ max_tokens: LLM_CONFIG.params.writer.maxTokens,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 503) throw new Error("The AI model is currently overloaded. Please wait a moment and try again.");
+ throw new Error(`Writer ${response.status}: ${await response.text()}`);
+ }
+
+ const body = await response.json();
+ const content: string = body.choices?.[0]?.message?.content ?? "";
+ if (!content) throw new Error("Writer returned empty response");
+
+ const { citationWarning } = validateCitations(content, packet);
+ return { content, citationWarning };
+}
+
+async function callWriterClaude(
+ packet: ContextPacket,
+ apiKey: string,
+ model: string,
+ history: ConversationTurn[],
+): Promise<{ content: string; citationWarning?: string }> {
+ const response = await fetchWithRetry(
+ LLM_CONFIG.endpoints.claude,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": apiKey,
+ "anthropic-version": LLM_CONFIG.anthropicVersion,
+ },
+ body: JSON.stringify({
+ model,
+ system: WRITER_SYSTEM_INSTRUCTION,
+ messages: [
+ ...history.map(h => ({ role: h.role, content: h.content })),
+ { role: "user", content: buildWriterUserTurn(packet) },
+ ],
+ temperature: LLM_CONFIG.params.writer.temperature,
+ max_tokens: LLM_CONFIG.params.writer.maxTokens,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 503) throw new Error("The AI model is currently overloaded. Please wait a moment and try again.");
+ throw new Error(`Writer ${response.status}: ${await response.text()}`);
+ }
+
+ const body = await response.json();
+ const content: string = body.content?.[0]?.text ?? "";
+ if (!content) throw new Error("Writer returned empty response");
+
+ const { citationWarning } = validateCitations(content, packet);
+ return { content, citationWarning };
+}
+
+async function callWriterOllama(
+ packet: ContextPacket,
+ model: string,
+ history: ConversationTurn[],
+): Promise<{ content: string; citationWarning?: string }> {
+ const response = await fetchWithRetry(
+ LLM_CONFIG.endpoints.ollama,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model,
+ messages: [
+ { role: "system", content: WRITER_SYSTEM_INSTRUCTION },
+ ...history.map(h => ({ role: h.role, content: h.content })),
+ { role: "user", content: buildWriterUserTurn(packet) },
+ ],
+ temperature: LLM_CONFIG.params.writer.temperature,
+ stream: false,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error(`Ollama model not found. Run: ollama pull ${model}`);
+ }
+ throw new Error(`Writer ${response.status}: ${await response.text()}`);
+ }
+
+ const body = await response.json();
+ const content: string = body.choices?.[0]?.message?.content ?? "";
+ if (!content) throw new Error("Writer returned empty response");
+
+ const { citationWarning } = validateCitations(content, packet);
+ return { content, citationWarning };
+}
+
+
+export async function callWriter(
+ packet: ContextPacket,
+ config: LLMConfig,
+ history: ConversationTurn[] = [],
+): Promise<{ content: string; citationWarning?: string }> {
+ const model = resolveModel(config);
+ switch (config.provider) {
+ case "gemini": return callWriterGemini(packet, config.apiKey, model, history);
+ case "openai": return callWriterOpenAI(packet, config.apiKey, model, history);
+ case "claude": return callWriterClaude(packet, config.apiKey, model, history);
+ case "ollama": return callWriterOllama(packet, model, history);
+ }
+}
diff --git a/src/components/ChartFootnote.tsx b/src/components/ChartFootnote.tsx
new file mode 100644
index 0000000..c50def4
--- /dev/null
+++ b/src/components/ChartFootnote.tsx
@@ -0,0 +1,31 @@
+import { COLUMN_LABELS } from "../lib/dataQuality";
+import type { MetricQualityEntry } from "../types";
+
+interface Props {
+ entry: MetricQualityEntry;
+}
+
+export function ChartFootnote({ entry }: Props) {
+ const { effectiveN, totalN, blockingColumn } = entry;
+
+ // Only render when we have a meaningful denominator and a gap exists
+ if (effectiveN == null || totalN == null || totalN === 0) return null;
+ if (effectiveN === totalN) return null;
+
+ const label = blockingColumn ? COLUMN_LABELS[blockingColumn] : "complete data";
+ const fieldPhrase = blockingColumn ? `known ${label}` : label;
+
+ return (
+
+ Based on {effectiveN.toLocaleString()} of {totalN.toLocaleString()} residents with {fieldPhrase}.
+
+ );
+}
diff --git a/src/components/DataQualityBanner.tsx b/src/components/DataQualityBanner.tsx
new file mode 100644
index 0000000..a66b7a9
--- /dev/null
+++ b/src/components/DataQualityBanner.tsx
@@ -0,0 +1,53 @@
+import { COLUMN_LABELS } from "../lib/dataQuality";
+import type { MetricQualityEntry } from "../types";
+
+interface Props {
+ entry: MetricQualityEntry;
+}
+
+function buildMessage(entry: MetricQualityEntry): string {
+ const { blockingColumn, missingPct, effectiveN, totalN } = entry;
+ if (!blockingColumn) return "Results may be incomplete due to missing data.";
+
+ const label = COLUMN_LABELS[blockingColumn];
+ const pct = Math.round((missingPct ?? 0) * 100);
+
+ // Use exact counts when available; fall back to percentage-derived approximation
+ const missingCount = (totalN != null && effectiveN != null)
+ ? totalN - effectiveN
+ : null;
+ const countPhrase = missingCount != null
+ ? `${missingCount} of ${totalN} residents`
+ : `~${pct}% of residents`;
+
+ if (pct >= 20) {
+ return `Treat as a directional estimate — ${label} is missing for ${countPhrase} (${pct}%). This is a notable gap that may skew results.`;
+ }
+ if (pct >= 10) {
+ return `Results may be incomplete — ${label} is missing for ${countPhrase} (${pct}%). Use with caution.`;
+ }
+ return `Results cover most residents — ${label} is missing for ${countPhrase} (${pct}%). The ${100 - pct}% with data are shown.`;
+}
+
+export function DataQualityBanner({ entry }: Props) {
+ return (
+
+ ⚠
+ {buildMessage(entry)}
+
+ );
+}
diff --git a/src/components/DataUnavailableCard.tsx b/src/components/DataUnavailableCard.tsx
new file mode 100644
index 0000000..99e9098
--- /dev/null
+++ b/src/components/DataUnavailableCard.tsx
@@ -0,0 +1,59 @@
+import { COLUMN_LABELS } from "../lib/dataQuality";
+import type { MetricQualityEntry } from "../types";
+
+interface Props {
+ title: string;
+ entry: MetricQualityEntry;
+ suggestion?: string;
+}
+
+function buildBody(entry: MetricQualityEntry): string {
+ const { blockingColumn, missingPct, thresholdPct } = entry;
+ if (!blockingColumn) return "Insufficient data to display this chart.";
+
+ const label = COLUMN_LABELS[blockingColumn];
+
+ // Boolean columns (sessions / attendance) have no threshold
+ if (thresholdPct === undefined) {
+ return `This chart requires ${label} data, which has not been loaded.`;
+ }
+
+ const requiredPct = Math.round((1 - thresholdPct) * 100);
+ const presentPct = Math.round((1 - (missingPct ?? 0)) * 100);
+ const presentCount = Math.round(presentPct / 10); // "X out of 10"
+
+ return (
+ `This chart requires ${label} data for at least ${requiredPct}% of residents. ` +
+ `Currently ${presentCount} out of 10 residents have this information (${presentPct}%).`
+ );
+}
+
+export function DataUnavailableCard({ title, entry, suggestion }: Props) {
+ return (
+
+
+ ⊘
+ {title}
+
+
+ {buildBody(entry)}
+
+ {suggestion && (
+
+ {suggestion}
+
+ )}
+
+ );
+}
diff --git a/src/components/FileLoader.tsx b/src/components/FileLoader.tsx
new file mode 100644
index 0000000..9bf414b
--- /dev/null
+++ b/src/components/FileLoader.tsx
@@ -0,0 +1,632 @@
+import React, { useState, useCallback, useRef } from "react";
+import Papa from "papaparse";
+import type { CsvFileKey } from "../types";
+import { CSV_FILE_DESCRIPTORS } from "../types";
+import type { DemoScenario } from "../data/mockData";
+import { generateDemoCSVFiles, downloadCSV } from "../lib/demoExport";
+
+interface Props {
+ onLoad: (files: Record) => void;
+ onDemo: (scenario: DemoScenario) => void;
+}
+
+const CORE_DOC_KEYS: CsvFileKey[] = ["doc_residents", "doc_programs"];
+
+const ADVANCED_DOC_KEYS: CsvFileKey[] = [
+ "incidents",
+ "work_assignments",
+ "case_plan",
+ "credentials",
+ "housing_history",
+];
+
+const DOC_KEYS: CsvFileKey[] = [...CORE_DOC_KEYS, ...ADVANCED_DOC_KEYS];
+
+const UL_KEYS: CsvFileKey[] = [
+ "users",
+ "facilities",
+ "programs",
+ "program_classes",
+ "program_class_enrollments",
+ "program_completions",
+ "program_class_events",
+ "program_class_event_attendance",
+ "user_session_tracking",
+ "program_credit_types",
+];
+
+const REF_KEYS: CsvFileKey[] = ["program_crosswalk", "field_mapping"];
+
+// Expected columns per dataset (derived from Raw type interfaces)
+const EXPECTED_COLUMNS: Partial> = {
+ doc_residents: ["ID", "STATUS_DESC", "LOCATION_TO", "LOC_TYPE_TO", "ODARA_SCORE", "STATIC99R_SCORE", "LSI_RATING", "HIGHEST_ED_LVL", "CUSTODY_LVL", "Earliest Release Date", "SENTENCE_OFFENSE", "Offense Date", "COUNTY", "LAST_PROGRAM_COMPLETED", "JOB_ASSIGN"],
+ doc_programs: ["ID", "GENDER", "Facility", "CUSTODY LEVEL", "Housing Unit", "Housing Pod", "Room", "Bed", "Program", "Program Status", "Program Termination Or Completion Date", "Earliest Release Date"],
+ incidents: ["ID", "Incident Date", "Incident Type", "Severity", "Sanction", "Facility"],
+ work_assignments: ["ID", "Assignment Type", "Role Title", "Start Date", "End Date", "Transferable Skills Flag", "Facility"],
+ case_plan: ["ID", "State ID Obtained", "State ID Date", "Job Lined Up", "Housing Plan", "Trust Account Balance", "Savings Goal Met"],
+ credentials: ["ID", "Credential Type", "Credential Name", "Date Earned", "Issuing Body", "Verified"],
+ housing_history: ["ID", "Move Date", "Housing Unit", "Pod", "Room", "Bed", "Custody Level", "Move Reason", "Facility"],
+};
+
+
+const FILE_NAME_TO_KEY: Record = {
+ "doc_residents.csv": "doc_residents",
+ "doc_residents.xlsx": "doc_residents",
+ "doc_programs.csv": "doc_programs",
+ "doc_programs.xlsx": "doc_programs",
+ "doc_incidents.csv": "incidents",
+ "doc_incidents.xlsx": "incidents",
+ "doc_work_assignments.csv": "work_assignments",
+ "doc_work_assignments.xlsx": "work_assignments",
+ "doc_case_plan.csv": "case_plan",
+ "doc_case_plan.xlsx": "case_plan",
+ "doc_credentials.csv": "credentials",
+ "doc_credentials.xlsx": "credentials",
+ "doc_housing_history.csv": "housing_history",
+ "doc_housing_history.xlsx": "housing_history",
+ "users.csv": "users",
+ "facilities.csv": "facilities",
+ "programs.csv": "programs",
+ "program_classes.csv": "program_classes",
+ "program_class_enrollments.csv": "program_class_enrollments",
+ "program_completions.csv": "program_completions",
+ "program_class_events.csv": "program_class_events",
+ "program_class_event_attendance.csv": "program_class_event_attendance",
+ "user_session_tracking.csv": "user_session_tracking",
+ "program_credit_types.csv": "program_credit_types",
+ "program_crosswalk.csv": "program_crosswalk",
+ "field_mapping.csv": "field_mapping",
+};
+
+type SectionId = "doc" | "ul" | "ref";
+
+function SectionHeader({
+ id, title, subtitle, loaded, open, onToggle,
+}: {
+ id: SectionId; title: string; subtitle: string;
+ loaded: Record;
+ open: Record;
+ onToggle: (id: SectionId) => void;
+}) {
+ const keys = id === "doc" ? DOC_KEYS : id === "ul" ? UL_KEYS : REF_KEYS;
+ const loadedInSection = keys.filter(k => !!loaded[k]).length;
+ return (
+ onToggle(id)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle(id)}
+ >
+
+
+
{title}
+ {loadedInSection > 0 && (
+
+ {loadedInSection} loaded
+
+ )}
+
+
{subtitle}
+
+ );
+}
+
+interface SlotListProps {
+ keys: CsvFileKey[];
+ accept: string;
+ loaded: Record;
+ columnWarnings: Partial>;
+ previews: Partial>;
+ columnInfoOpen: CsvFileKey | null;
+ previewOpen: CsvFileKey | null;
+ slotRefs: React.MutableRefObject>>;
+ onSlotChange: (key: CsvFileKey) => (e: React.ChangeEvent) => void;
+ onRemove: (key: CsvFileKey) => void;
+ onSetPreviewOpen: (key: CsvFileKey | null) => void;
+ onSetColumnInfoOpen: (key: CsvFileKey | null) => void;
+}
+
+function SlotList({
+ keys, accept, loaded, columnWarnings, previews,
+ columnInfoOpen, previewOpen, slotRefs,
+ onSlotChange, onRemove, onSetPreviewOpen, onSetColumnInfoOpen,
+}: SlotListProps) {
+ return (
+ <>
+ {keys.map(key => {
+ const desc = CSV_FILE_DESCRIPTORS[key];
+ const file = loaded[key];
+ const warnings = columnWarnings[key];
+ const preview = previews[key];
+ const isInfoOpen = columnInfoOpen === key;
+ const isPreviewOpen = previewOpen === key;
+ const expectedCols = EXPECTED_COLUMNS[key];
+
+ return (
+
+ {/* Main slot row */}
+
slotRefs.current[key]?.click()}
+ title={file ? file.name : undefined}
+ >
+
{ slotRefs.current[key] = el ?? undefined; }}
+ type="file"
+ accept={accept}
+ style={{ display: "none" }}
+ onChange={onSlotChange(key)}
+ />
+
+ {file
+ ? ✓
+ : ○
+ }
+
+
+
+ {desc.displayName ?? desc.label}
+
+
+ {desc.description}
+
+
+
+ {/* Info icon (always shown for datasets with expected columns) */}
+ {expectedCols && (
+
{
+ e.stopPropagation();
+ onSetColumnInfoOpen(columnInfoOpen === key ? null : key);
+ }}
+ >
+ ⓘ
+
+ )}
+
+ {file ? (
+ <>
+ {/* Preview toggle */}
+
{ e.stopPropagation(); onSetPreviewOpen(previewOpen === key ? null : key); }}
+ >
+ {isPreviewOpen ? "Hide" : "Preview"}
+
+
loaded
+
{ e.stopPropagation(); onRemove(key); }}
+ >
+ ×
+
+ >
+ ) : (
+
optional
+ )}
+
+
+ {/* Column validation warning */}
+ {warnings && warnings.length > 0 && (
+
+ ⚠ Missing expected columns: {warnings.join(", ")} . Are you sure this is the right file?
+
+ )}
+
+ {/* Expected columns info panel */}
+ {isInfoOpen && expectedCols && (
+
+
Expected columns
+
+ {expectedCols.map(col => (
+
+ {col}
+
+ ))}
+
+
+ )}
+
+ {/* Preview table */}
+ {isPreviewOpen && preview && preview.rows.length > 0 && (
+
+
+
+
+ {preview.headers.map(h => (
+
+ {h}
+
+ ))}
+
+
+
+ {preview.rows.map((row, i) => (
+
+ {preview.headers.map(h => (
+
+ {row[h] ?? ""}
+
+ ))}
+
+ ))}
+
+
+
+ Showing first {preview.rows.length} row{preview.rows.length !== 1 ? "s" : ""}
+
+
+ )}
+
+ );
+ })}
+ >
+ );
+}
+
+function Chevron({ open }: { open: boolean }) {
+ return (
+
+
+
+ );
+}
+
+interface PreviewData {
+ headers: string[];
+ rows: Record[];
+}
+
+export function FileLoader({ onLoad, onDemo }: Props) {
+ const [loaded, setLoaded] = useState>({});
+ const [dragOver, setDragOver] = useState(null);
+ const [unknownFiles, setUnknownFiles] = useState([]);
+ const [open, setOpen] = useState>({ doc: false, ul: false, ref: false });
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [demoMode, setDemoMode] = useState(false);
+ const [downloadingScenario, setDownloadingScenario] = useState(null);
+ const [columnInfoOpen, setColumnInfoOpen] = useState(null);
+ const [columnWarnings, setColumnWarnings] = useState>>({});
+ const [previewOpen, setPreviewOpen] = useState(null);
+ const [previews, setPreviews] = useState>>({});
+ const slotRefs = useRef>>({});
+ const ulInputRef = useRef(null);
+
+ async function validateAndPreview(key: CsvFileKey, file: File): Promise<{ warnings: string[]; preview: PreviewData }> {
+ return new Promise((resolve) => {
+ Papa.parse(file, {
+ header: true,
+ preview: 6,
+ skipEmptyLines: true,
+ complete: (results) => {
+ const headers = results.meta.fields ?? [];
+ const expected = EXPECTED_COLUMNS[key] ?? [];
+ const missing = expected.filter(col => !headers.includes(col));
+ const rows = (results.data as Record[]).slice(0, 5);
+ resolve({ warnings: missing, preview: { headers, rows } });
+ },
+ error: () => resolve({ warnings: [], preview: { headers: [], rows: [] } }),
+ });
+ });
+ }
+
+ const processFileList = useCallback((files: FileList | File[]) => {
+ const updates: Partial> = {};
+ const unknown: string[] = [];
+ for (const f of Array.from(files)) {
+ const key = FILE_NAME_TO_KEY[f.name];
+ if (key) updates[key] = f;
+ else unknown.push(f.name);
+ }
+ setLoaded(prev => ({ ...prev, ...updates }));
+ if (unknown.length > 0) setUnknownFiles(unknown);
+ }, []);
+
+ const handleSlotChange = (key: CsvFileKey) => async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ e.target.value = "";
+ setLoaded(prev => ({ ...prev, [key]: file }));
+ const { warnings, preview } = await validateAndPreview(key, file);
+ if (warnings.length > 0) {
+ setColumnWarnings(prev => ({ ...prev, [key]: warnings }));
+ } else {
+ setColumnWarnings(prev => { const n = { ...prev }; delete n[key]; return n; });
+ }
+ setPreviews(prev => ({ ...prev, [key]: preview }));
+ };
+
+ const dragHandlers = (section: SectionId) => ({
+ onDragOver: (e: React.DragEvent) => { e.preventDefault(); setDragOver(section); },
+ onDragLeave: (e: React.DragEvent) => {
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOver(null);
+ },
+ onDrop: (e: React.DragEvent) => {
+ e.preventDefault();
+ setDragOver(null);
+ processFileList(e.dataTransfer.files);
+ },
+ });
+
+ const removeFile = (key: CsvFileKey) => {
+ setLoaded(prev => { const n = { ...prev }; delete n[key]; return n; });
+ setColumnWarnings(prev => { const n = { ...prev }; delete n[key]; return n; });
+ setPreviews(prev => { const n = { ...prev }; delete n[key]; return n; });
+ if (previewOpen === key) setPreviewOpen(null);
+ if (columnInfoOpen === key) setColumnInfoOpen(null);
+ };
+
+ const toggleSection = (id: SectionId) => {
+ setOpen(prev => ({ ...prev, [id]: !prev[id] }));
+ };
+
+ const handleDownloadScenario = (scenario: DemoScenario) => {
+ setDownloadingScenario(scenario);
+ setTimeout(() => {
+ const files = generateDemoCSVFiles(scenario);
+ for (const f of files) downloadCSV(f.filename, f.content);
+ setDownloadingScenario(null);
+ }, 10);
+ };
+
+ const loadedCount = Object.keys(loaded).length;
+ const canLoad = loadedCount > 0;
+ const visibleDocKeys = showAdvanced ? DOC_KEYS : CORE_DOC_KEYS;
+
+ const DEMO_OPTIONS: { scenario: DemoScenario; label: string; description: string }[] = [
+ { scenario: "clean", label: "Clean data", description: "Fully populated, no quality issues" },
+ { scenario: "messy", label: "Messy data", description: "~33% LSI missing, gaps in education & dates" },
+ { scenario: "advanced-missing", label: "Core data only", description: "Messy core data, no incidents or enrichment files" },
+ ];
+
+ return (
+
+ {/* Header row */}
+
+
+
Load data files
+ {!demoMode && (
+
+ All files are optional — insights are generated for whatever data is loaded.
+
+ )}
+
+ {/* Right-side controls */}
+
+ {/* Demo mode toggle */}
+
+
+ Demo mode
+
+ setDemoMode(m => !m)}
+ style={{
+ width: 36, height: 20, borderRadius: 10,
+ background: demoMode ? "var(--accent2)" : "var(--bg4)",
+ border: "1px solid var(--border2)",
+ position: "relative", cursor: "pointer", transition: "background 0.15s",
+ }}
+ >
+
+
+
+ {/* Advanced data toggle — only in upload mode */}
+ {!demoMode && (
+
+ setShowAdvanced(e.target.checked)}
+ style={{ cursor: "pointer" }}
+ />
+ Advanced data
+
+ )}
+
+
+
+ {demoMode ? (
+ /* Demo mode: three scenario cards */
+
+ {DEMO_OPTIONS.map(({ scenario, label, description }) => (
+
+
+
{label}
+
{description}
+
+
+ onDemo(scenario)}>
+ Use this data
+
+ handleDownloadScenario(scenario)}
+ disabled={downloadingScenario === scenario}
+ style={{ fontSize: 11, color: "var(--text3)" }}
+ >
+ {downloadingScenario === scenario ? "Generating…" : "↓ CSV"}
+
+
+
+ ))}
+
+ ) : null}
+
+ {canLoad && !demoMode && (
+
+ onLoad(loaded)}>
+ Load {loadedCount} file{loadedCount !== 1 ? "s" : ""} →
+
+
+ )}
+
+ {!demoMode && (
+ <>
+ {unknownFiles.length > 0 && (
+
+ Unrecognised files (not loaded): {unknownFiles.join(", ")}
+
+ )}
+
+ {/* Section 1: DOC data */}
+
+
+ {open.doc && (
+
+
+ {!showAdvanced && (
+
+ Incidents, work assignments, case plans, credentials, and housing history are hidden.{" "}
+ setShowAdvanced(true)}
+ >
+ Show advanced data
+
+
+ )}
+
+ )}
+
+
+ {/* Section 2: UnlockEd platform data */}
+
+
+ {open.ul && (
+
+
ulInputRef.current?.click()}
+ >
+ { if (e.target.files) processFileList(e.target.files); e.target.value = ""; }}
+ />
+ Drop UnlockEd CSV files here or click to browse
+
+
+ {UL_KEYS.map(key => {
+ const desc = CSV_FILE_DESCRIPTORS[key];
+ const file = loaded[key];
+ return (
+
+
+ {file
+ ? ✓
+ : ○
+ }
+
+
+
{desc.label}
+
{desc.description}
+
+
+ {file ? "loaded" : "optional"}
+
+
+ );
+ })}
+
+
+ )}
+
+
+ {/* Section 3: Mapping & reference (hidden unless showAdvanced) */}
+ {showAdvanced && (
+
+
+ {open.ref && (
+
+
+
+ )}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/components/OmsCards.tsx b/src/components/OmsCards.tsx
new file mode 100644
index 0000000..44228d6
--- /dev/null
+++ b/src/components/OmsCards.tsx
@@ -0,0 +1,620 @@
+import { useMemo, type ReactNode } from "react";
+import { Bar, Doughnut } from "react-chartjs-2";
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ ArcElement,
+ Tooltip,
+ Legend,
+} from "chart.js";
+import type { ParsedData } from "../types";
+import {
+ computeIncidentStats,
+ computeCredentialStats,
+ computeWorkAssignmentStats,
+ computeCasePlanStats,
+ computeHousingStats,
+} from "../lib/analytics";
+
+ChartJS.register(CategoryScale, LinearScale, BarElement, ArcElement, Tooltip, Legend);
+
+const TRAILING_12MO_CUTOFF = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
+
+const C = {
+ accent: "#4a9eff",
+ green: "#3db87a",
+ amber: "#f59e0b",
+ red: "#e05252",
+ purple: "#8b5cf6",
+ teal: "#14b8a6",
+ gray: "#606878",
+ text: "#9ca3b0",
+ border: "rgba(255,255,255,0.08)",
+};
+
+const CHART_COLORS = [C.accent, C.green, C.amber, C.purple, C.teal, C.red, C.gray];
+
+const BASE = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: "#141720",
+ borderColor: "rgba(255,255,255,0.12)",
+ borderWidth: 1,
+ titleColor: "#e8eaf0",
+ bodyColor: "#9ca3b0",
+ },
+ },
+ scales: {
+ x: { grid: { color: C.border }, ticks: { color: C.text, font: { size: 11 } } },
+ y: { grid: { color: C.border }, ticks: { color: C.text, font: { size: 11 } } },
+ },
+};
+
+function KpiChip({ label, value, color }: { label: string; value: string; color?: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function ChartBox({ height, children }: { height: number; children: ReactNode }) {
+ return {children}
;
+}
+
+function MissingFilePlaceholder({ dataName }: { dataName: string }) {
+ return (
+
+ Upload {dataName} data to see this insight.
+
+ );
+}
+
+const DisabledAskAI = () => (
+ ✦ Ask AI
+);
+
+// ── OmsReentrySnapshot ────────────────────────────────────────────────────────
+
+export function OmsReentrySnapshot({ data }: { data: ParsedData }) {
+ const trailing12mo = useMemo(() => {
+ if (data.incidents.length === 0) return null;
+ return data.incidents.filter(i => i.incidentDate && i.incidentDate >= TRAILING_12MO_CUTOFF).length;
+ }, [data.incidents]);
+
+ const credentialCount = data.credentials.length;
+
+ const stateIdRate = useMemo(() => {
+ if (data.casePlans.length === 0) return null;
+ const pct = Math.round((data.casePlans.filter(c => c.stateIdObtained).length / data.casePlans.length) * 100);
+ return `${pct}%`;
+ }, [data.casePlans]);
+
+ const jobLinedUpRate = useMemo(() => {
+ if (data.casePlans.length === 0) return null;
+ const pct = Math.round((data.casePlans.filter(c => c.jobLinedUp).length / data.casePlans.length) * 100);
+ return `${pct}%`;
+ }, [data.casePlans]);
+
+ const savingsGoalRate = useMemo(() => {
+ if (data.casePlans.length === 0) return null;
+ const pct = Math.round((data.casePlans.filter(c => c.savingsGoalMet).length / data.casePlans.length) * 100);
+ return `${pct}%`;
+ }, [data.casePlans]);
+
+ const earnedHousingRate = useMemo(() => {
+ if (data.housingMoves.length === 0) return null;
+ // Most recent move per resident
+ const latestByResident = new Map();
+ for (const move of data.housingMoves) {
+ const existing = latestByResident.get(move.residentId);
+ if (!existing || (move.moveDate && existing.moveDate && move.moveDate > existing.moveDate)) {
+ latestByResident.set(move.residentId, move);
+ }
+ }
+ const latestMoves = Array.from(latestByResident.values());
+ const earned = latestMoves.filter(m => m.moveReason.toLowerCase() === "earned").length;
+ const pct = Math.round((earned / latestMoves.length) * 100);
+ return `${pct}%`;
+ }, [data]);
+
+ return (
+
+
+ Reentry readiness
+
+
+
+
+
+ 0 ? String(credentialCount) : "—"} color={credentialCount > 0 ? C.teal : C.gray} />
+
+
+
+
+ );
+}
+
+// ── OmsIncidentSection ────────────────────────────────────────────────────────
+
+export function OmsIncidentSection({ data }: { data: ParsedData }) {
+ const stats = useMemo(() => computeIncidentStats(data), [data]);
+ const hasData = data.incidents.length > 0;
+
+ return (
+
+
Incident activity
+
+ {hasData
+ ? `${stats.total} total · ${stats.trailing12mo} in trailing 12 months`
+ : "No incident data loaded"}
+
+ {!hasData ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ Severity split
+
+
+
+
+
+
+
+ Per-resident count distribution
+
+
+
+
+
+
+
+ {stats.byType.length > 0 && (
+ <>
+
+ Incident type breakdown
+
+
+ t.type),
+ datasets: [{
+ label: "Count",
+ data: stats.byType.map(t => t.count),
+ backgroundColor: C.accent,
+ borderRadius: 3,
+ }],
+ }}
+ options={{ ...BASE, indexAxis: "y" as const }}
+ />
+
+ >
+ )}
+
+ {stats.byFacility.length > 0 && (
+
+
+ Incidents by facility
+
+
+ f.facilityCode),
+ datasets: [
+ {
+ label: "Major",
+ data: stats.byFacility.map(f => f.major),
+ backgroundColor: C.red,
+ borderRadius: 3,
+ stack: "s",
+ },
+ {
+ label: "Minor",
+ data: stats.byFacility.map(f => f.count - f.major),
+ backgroundColor: C.amber,
+ borderRadius: 3,
+ stack: "s",
+ },
+ ],
+ }}
+ options={{
+ ...BASE,
+ indexAxis: "y" as const,
+ plugins: { ...BASE.plugins, legend: { display: true, labels: { color: C.text, font: { size: 11 } } } },
+ }}
+ />
+
+
+ )}
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+// ── OmsCredentialSection ──────────────────────────────────────────────────────
+
+export function OmsCredentialSection({ data }: { data: ParsedData }) {
+ const stats = useMemo(() => computeCredentialStats(data), [data]);
+ const hasData = data.credentials.length > 0;
+
+ return (
+
+
Credentials
+
+ {hasData
+ ? `${stats.total} credentials across ${stats.residentCount} residents — ${stats.hasHiSetOrGedCount} have GED or HiSET`
+ : "No credential data loaded"}
+
+ {!hasData ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ By credential type
+
+
+ t.type),
+ datasets: [{
+ label: "Count",
+ data: stats.byType.map(t => t.count),
+ backgroundColor: CHART_COLORS,
+ borderRadius: 3,
+ }],
+ }}
+ options={{ ...BASE }}
+ />
+
+
+
+
+ Verification status
+
+
+
+
+
+
+
+ {stats.byIssuingBody.length > 0 && (
+
+
+ By issuing body
+
+
+ {stats.byIssuingBody.map(b => (
+
+ {b.body} {b.count}
+
+ ))}
+
+
+ )}
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+// ── OmsWorkSection ────────────────────────────────────────────────────────────
+
+export function OmsWorkSection({ data }: { data: ParsedData }) {
+ const stats = useMemo(() => computeWorkAssignmentStats(data), [data]);
+ const hasData = data.workAssignments.length > 0;
+
+ return (
+
+
Work assignments
+
+ {hasData
+ ? `${stats.activeTotal} active assignments — ${stats.transferableActiveRate}% of facility roles have transferable skills`
+ : "No work assignment data loaded"}
+
+ {!hasData ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+ {stats.byRole.length > 0 && (
+ <>
+
+ Active assignments by role
+
+
+ r.role),
+ datasets: [{
+ label: "Active count",
+ data: stats.byRole.map(r => r.count),
+ backgroundColor: C.accent,
+ borderRadius: 3,
+ }],
+ }}
+ options={{ ...BASE, indexAxis: "y" as const }}
+ />
+
+ >
+ )}
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+// ── OmsCasePlanSection ────────────────────────────────────────────────────────
+
+export function OmsCasePlanSection({ data }: { data: ParsedData }) {
+ const stats = useMemo(() => computeCasePlanStats(data), [data]);
+ const hasData = data.casePlans.length > 0;
+
+ return (
+
+
Case plan readiness
+
+ {hasData
+ ? `${stats.total} case plans — ${stats.stateIdRate}% have state ID, ${stats.savingsGoalMetRate}% met savings goal`
+ : "No case plan data loaded"}
+
+ {!hasData ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ Readiness score distribution (0–4 indicators met)
+
+
+ `Score ${d.score}`),
+ datasets: [{
+ label: "Residents",
+ data: stats.readinessDistribution.map(d => d.count),
+ backgroundColor: [C.red, C.amber, C.amber, C.green, C.green],
+ borderRadius: 3,
+ }],
+ }}
+ options={{ ...BASE }}
+ />
+
+
+
+
+ Trust account balance distribution
+
+
+ b.label),
+ datasets: [{
+ label: "Residents",
+ data: stats.balanceBands.map(b => b.count),
+ backgroundColor: [C.red, C.amber, C.accent, C.green],
+ borderRadius: 3,
+ }],
+ }}
+ options={{ ...BASE }}
+ />
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+// ── OmsHousingSection ─────────────────────────────────────────────────────────
+
+export function OmsHousingSection({ data }: { data: ParsedData }) {
+ const stats = useMemo(() => computeHousingStats(data), [data]);
+ const hasData = data.housingMoves.length > 0;
+
+ return (
+
+
Housing & custody transitions
+
+ {hasData
+ ? `${stats.earnedHousingRate}% of residents earned their most recent housing move — ${stats.communityCustodyCount} in community custody`
+ : "No housing history data loaded"}
+
+ {!hasData ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+ Current custody level distribution
+
+
+ d.level),
+ datasets: [{
+ data: stats.custodyDistribution.map(d => d.count),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ }],
+ }}
+ options={{
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: true, position: "right" as const, labels: { color: C.text, font: { size: 10 }, boxWidth: 10 } },
+ tooltip: BASE.plugins.tooltip,
+ },
+ }}
+ />
+
+
+
+
+ Earned housing rate by facility
+
+
+ f.facilityCode),
+ datasets: [{
+ label: "Earned %",
+ data: stats.earnedByFacility.map(f => f.rate),
+ backgroundColor: C.green,
+ borderRadius: 3,
+ }],
+ }}
+ options={{
+ ...BASE,
+ scales: {
+ ...BASE.scales,
+ y: { ...BASE.scales.y, max: 100, ticks: { ...BASE.scales.y.ticks, callback: (v: number | string) => `${v}%` } },
+ },
+ }}
+ />
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/SmartActionItem.tsx b/src/components/SmartActionItem.tsx
new file mode 100644
index 0000000..6571848
--- /dev/null
+++ b/src/components/SmartActionItem.tsx
@@ -0,0 +1,73 @@
+import { useState, useEffect, useRef } from "react";
+import type { LLMConfig } from "../chat/providers";
+import { generateActionItem } from "../chat/actionItems";
+
+const SESSION_KEY = "llm_config";
+
+function loadConfig(): LLMConfig | null {
+ try {
+ const raw = sessionStorage.getItem(SESSION_KEY);
+ return raw ? (JSON.parse(raw) as LLMConfig) : null;
+ } catch {
+ return null;
+ }
+}
+
+interface Props {
+ sectionLabel: string;
+ metrics: Record;
+ fallback: string;
+ marginTop?: number;
+}
+
+export function SmartActionItem({ sectionLabel, metrics, fallback, marginTop = 20 }: Props) {
+ const [aiText, setAiText] = useState(null);
+ const metricsRef = useRef(metrics);
+ const labelRef = useRef(sectionLabel);
+
+ useEffect(() => {
+ const config = loadConfig();
+ if (!config) return;
+ generateActionItem(labelRef.current, metricsRef.current, config)
+ .then((text) => { if (text) setAiText(text); })
+ .catch(() => {});
+ }, []);
+
+ const text = aiText ?? fallback;
+
+ return (
+
+ Action item:
+ {text}
+ {aiText !== null && (
+
+ ✦ AI
+
+ )}
+
+ );
+}
diff --git a/src/components/chat/ChatDrawer.tsx b/src/components/chat/ChatDrawer.tsx
new file mode 100644
index 0000000..37424bf
--- /dev/null
+++ b/src/components/chat/ChatDrawer.tsx
@@ -0,0 +1,578 @@
+import { useState, useRef, useEffect } from "react";
+import type { ParsedData, MetricSnapshot, ChatMessage, PendingPrompt } from "../../types";
+import type { LLMConfig, LLMProvider } from "../../chat/providers";
+import { PROVIDER_INFO } from "../../chat/providers";
+import { METRIC_REGISTRY } from "../../data/schemaRegistry";
+import { buildContextPacket } from "../../chat/contextPacket";
+import { callRouter } from "../../chat/router";
+import { callWriter } from "../../chat/writer";
+import { buildRefusalMessage } from "../../chat/refusal";
+import { DEMO_RESPONSES } from "../../data/demoResponses";
+import type { DemoEntry } from "../../data/demoResponses";
+import CHAT_CONFIG from "../../config/chat.json";
+
+const CONFIDENCE_THRESHOLD = CHAT_CONFIG.confidenceThreshold;
+const SESSION_KEY = CHAT_CONFIG.sessionKey;
+
+export const CHAT_DRAWER_WIDTH = CHAT_CONFIG.drawerWidth;
+
+interface Props {
+ data: ParsedData | null;
+ snapshot: MetricSnapshot | null;
+ pendingPrompt: PendingPrompt | null;
+ onPromptConsumed: () => void;
+ onOpenChange?: (open: boolean) => void;
+ chatDemoMode?: boolean;
+ onChatDemoModeChange?: (v: boolean) => void;
+}
+
+const PROVIDERS: LLMProvider[] = ["gemini", "openai", "claude", "ollama"];
+
+const inputStyle = {
+ background: "var(--bg3)", border: "1px solid var(--border2)",
+ borderRadius: "var(--radius-sm)", padding: "8px 10px",
+ color: "var(--text)", fontSize: 12, outline: "none",
+ fontFamily: "var(--font-mono)", width: "100%", boxSizing: "border-box" as const,
+};
+
+function ApiKeySetup({ onSave }: { onSave: (config: LLMConfig) => void }) {
+ const [provider, setProvider] = useState("gemini");
+ const [value, setValue] = useState("");
+ const [model, setModel] = useState(PROVIDER_INFO["gemini"].models[0]);
+ const info = PROVIDER_INFO[provider];
+ const isLocal = provider === "ollama";
+
+ const handleProviderChange = (p: LLMProvider) => {
+ setProvider(p);
+ setValue("");
+ setModel(PROVIDER_INFO[p].models[0]);
+ };
+
+ return (
+
+
Select AI provider:
+
+ {PROVIDERS.map(p => (
+ handleProviderChange(p)}
+ style={{
+ flex: 1, fontSize: 10,
+ background: provider === p ? "rgba(74,158,255,0.15)" : "var(--bg3)",
+ border: `1px solid ${provider === p ? "rgba(74,158,255,0.4)" : "var(--border2)"}`,
+ color: provider === p ? "var(--accent)" : "var(--text2)",
+ }}
+ >
+ {PROVIDER_INFO[p].label}
+
+ ))}
+
+ {isLocal ? (
+ <>
+
+ Runs entirely on your machine — no API key or internet connection required.
+
+
+
Model
+
setModel(e.target.value)}
+ />
+
+ Run ollama list to see installed models.
+
+
+
onSave({ provider: "ollama", apiKey: "", model: model.trim() || info.models[0] })}>
+ Enable local AI
+
+
+ Requires Ollama running locally. See README for setup instructions.
+
+ >
+ ) : (
+ <>
+
+ Enter a {info.label} API key to enable AI chat. The key is stored in this browser session only and sent only to {info.label} with aggregated metrics — no resident data.
+
+
setValue(e.target.value)}
+ onKeyDown={e => e.key === "Enter" && value.trim() && onSave({ provider, apiKey: value.trim(), model })}
+ style={inputStyle}
+ />
+
+
Model
+
setModel(e.target.value)}
+ style={{ ...inputStyle, fontFamily: "inherit", cursor: "pointer" }}
+ >
+ {info.models.map(m => (
+ {m}
+ ))}
+
+
+
value.trim() && onSave({ provider, apiKey: value.trim(), model })}>
+ Save key
+
+
+ Get a key at {info.keyHint}.
+
+ >
+ )}
+
+ );
+}
+
+// Splits assistant content into plain text and cited values.
+// "[b1_education_journey_loss.dropoff.0.withdrawalRate: 81.3%]" → bold "81.3%"
+function renderWithCitations(text: string): React.ReactNode {
+ // Strip any unclosed bracket at the end (truncated response mid-citation)
+ const cleaned = text.replace(/\[[^\]]*$/, "").trimEnd();
+ const parts = cleaned.split(/\[([^\]:]+):\s*([^\]]+)\]/g);
+ const nodes: React.ReactNode[] = [];
+ for (let i = 0; i < parts.length; i++) {
+ if (i % 3 === 0) {
+ nodes.push(parts[i]);
+ } else if (i % 3 === 2) {
+ nodes.push({parts[i]} );
+ }
+ // i % 3 === 1 is the metric path — discard it
+ }
+ return nodes;
+}
+
+function MessageBubble({ msg }: { msg: ChatMessage }) {
+ const isUser = msg.role === "user";
+ return (
+
+
+
+ {isUser ? msg.content : renderWithCitations(msg.content)}
+
+
+ {msg.is_refusal && msg.nearest_metrics && msg.nearest_metrics.length > 0 && (
+
+ Related metrics:
+ {msg.nearest_metrics.map(m => (
+
+ {m.label}
+
+ ))}
+
+ )}
+
+
+ {!isUser && msg.snapshot_id && (
+
+ snapshot:{msg.snapshot_id.slice(0, 8)}
+
+ )}
+
+ );
+}
+
+function loadStoredConfig(): LLMConfig | null {
+ // Try current format
+ const stored = sessionStorage.getItem(SESSION_KEY);
+ if (stored) {
+ try { return JSON.parse(stored) as LLMConfig; } catch { /* ignore */ }
+ }
+ // Migrate legacy gemini_api_key entry
+ const legacyKey = sessionStorage.getItem("gemini_api_key");
+ if (legacyKey) {
+ const config: LLMConfig = { provider: "gemini", apiKey: legacyKey };
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(config));
+ sessionStorage.removeItem("gemini_api_key");
+ return config;
+ }
+ return null;
+}
+
+export function ChatDrawer({ data, snapshot, pendingPrompt, onPromptConsumed, onOpenChange, chatDemoMode = false, onChatDemoModeChange }: Props) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => { onOpenChange?.(isOpen); }, [isOpen, onOpenChange]);
+ const [llmConfig, setLlmConfig] = useState(loadStoredConfig);
+ const [messages, setMessages] = useState([]);
+ const [inputValue, setInputValue] = useState("");
+ const [contextLabel, setContextLabel] = useState(null);
+ const [pendingHints, setPendingHints] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const bottomRef = useRef(null);
+ // Demo mode state
+ const [demoEntries, setDemoEntries] = useState(null);
+ const [activeDemoEntry, setActiveDemoEntry] = useState(null);
+
+ useEffect(() => {
+ if (!pendingPrompt) return;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setIsOpen(true);
+ if (chatDemoMode) {
+ const entries = DEMO_RESPONSES[pendingPrompt.context_label] ?? null;
+ setDemoEntries(entries);
+ setActiveDemoEntry(null);
+ setMessages([]);
+ } else {
+ setInputValue(pendingPrompt.text);
+ setContextLabel(pendingPrompt.context_label);
+ setPendingHints(pendingPrompt.metric_hint_ids ?? []);
+ }
+ onPromptConsumed();
+ }, [pendingPrompt, onPromptConsumed, chatDemoMode]);
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages]);
+
+ const saveConfig = (config: LLMConfig) => {
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(config));
+ setLlmConfig(config);
+ };
+ const clearConfig = () => { sessionStorage.removeItem(SESSION_KEY); setLlmConfig(null); };
+
+ const sendMessage = async (override?: string) => {
+ const text = (override ?? inputValue).trim();
+ if (!text || !data || !llmConfig || isLoading) return;
+
+ const hints = pendingHints;
+ setInputValue("");
+ setContextLabel(null);
+ setPendingHints([]);
+
+ const userMsg: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "user",
+ content: text,
+ timestamp: new Date().toISOString(),
+ };
+ setMessages(prev => [...prev, userMsg]);
+ setIsLoading(true);
+
+ try {
+ // Convert metric hint IDs to human-readable labels for the planner's context
+ const hintLabels = hints.map(id => METRIC_REGISTRY.find(m => m.id === id)?.label ?? id);
+ const history = messages.slice(-4).map(m => ({ role: m.role, content: m.content }));
+ const plan = await callRouter(text, llmConfig, hintLabels, history, data?.dataEnums);
+ const snapshotId = snapshot?.id ?? "unknown";
+
+ if (plan.confidence < CONFIDENCE_THRESHOLD || plan.data_requests.length === 0) {
+ const refusal = buildRefusalMessage(plan, snapshotId);
+ setMessages(prev => [...prev, refusal]);
+ return;
+ }
+
+ // Infer required source files from which flat tables the plan needs
+ const missingFiles: string[] = [];
+ if (plan.data_requests.some(r => r.table === "flat_residents") && data.residents.length === 0)
+ missingFiles.push("doc_residents (resident roster)");
+ if (plan.data_requests.some(r => r.table === "flat_enrollments") &&
+ data.enrollments.length === 0 && data.ulEnrollments.length === 0)
+ missingFiles.push("doc_programs (program enrollments)");
+ if (plan.data_requests.some(r => r.table === "flat_sessions") && data.sessions.length === 0)
+ missingFiles.push("user_session_tracking");
+
+ if (missingFiles.length > 0) {
+ const fileMsg: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: `To answer this I need: **${missingFiles.join(", ")}**. Upload ${missingFiles.length > 1 ? "these files" : "this file"} on the Load Data page.`,
+ snapshot_id: snapshotId,
+ timestamp: new Date().toISOString(),
+ };
+ setMessages(prev => [...prev, fileMsg]);
+ return;
+ }
+
+ const packet = buildContextPacket(plan, data, snapshot, text);
+ const { content, citationWarning } = await callWriter(packet, llmConfig, history);
+
+ const assistantMsg: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: citationWarning ? `${content}\n\n⚠ ${citationWarning}` : content,
+ metric_ids: plan.data_requests.map(r => r.id),
+ snapshot_id: snapshotId,
+ timestamp: new Date().toISOString(),
+ };
+ setMessages(prev => [...prev, assistantMsg]);
+ } catch (e: unknown) {
+ const errMsg = e instanceof Error ? e.message : String(e);
+ setMessages(prev => [...prev, {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: errMsg,
+ timestamp: new Date().toISOString(),
+ }]);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const sendDemoMessage = (entry: DemoEntry) => {
+ const userMsg: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "user",
+ content: entry.question,
+ timestamp: new Date().toISOString(),
+ };
+ const assistantMsg: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: entry.response,
+ snapshot_id: snapshot?.id ?? "demo",
+ timestamp: new Date().toISOString(),
+ };
+ setMessages(prev => [...prev, userMsg, assistantMsg]);
+ setActiveDemoEntry(entry);
+ setDemoEntries(null);
+ };
+
+ const sendDemoFollowUp = (question: string, response: string) => {
+ const userMsg: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "user",
+ content: question,
+ timestamp: new Date().toISOString(),
+ };
+ const assistantMsg: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: response,
+ snapshot_id: snapshot?.id ?? "demo",
+ timestamp: new Date().toISOString(),
+ };
+ setMessages(prev => [...prev, userMsg, assistantMsg]);
+ setActiveDemoEntry(null);
+ };
+
+ const DRAWER_WIDTH = CHAT_DRAWER_WIDTH;
+ const EXAMPLE_QUESTIONS = [
+ "What should I expect at SMWRC?",
+ "Why is facility completion low?",
+ "Are we serving high-risk residents in education?",
+ "Where should we focus this quarter?",
+ ];
+
+ const providerLabel = llmConfig ? PROVIDER_INFO[llmConfig.provider].label : "AI";
+ const activeModel = llmConfig ? (llmConfig.model ?? PROVIDER_INFO[llmConfig.provider].models[0]) : null;
+
+ return (
+ <>
+ setIsOpen(o => !o)}
+ style={{
+ position: "fixed", right: isOpen ? DRAWER_WIDTH + 8 : 12, top: "50%",
+ transform: "translateY(-50%)",
+ background: "var(--bg2)", border: "1px solid var(--border)",
+ borderRadius: "var(--radius-md)", padding: "8px 6px",
+ cursor: "pointer", color: "var(--text2)", fontSize: 12,
+ zIndex: 100, transition: "right 0.2s ease",
+ display: "flex", flexDirection: "column", alignItems: "center", gap: 4,
+ writingMode: "vertical-rl",
+ }}
+ title={isOpen ? "Close chat" : "Open AI chat"}
+ >
+ ✦ Chat
+
+
+ {isOpen && (
+
+
+
+ ✦ {providerLabel}
+ {llmConfig && (
+
+ {llmConfig.provider === "ollama" ? "local" : "key set"}
+
+ )}
+ {activeModel && (
+
+ {activeModel}
+
+ )}
+
+
+ {llmConfig && {llmConfig.provider === "ollama" ? "Disconnect" : "Clear key"} }
+ setIsOpen(false)} style={{ fontSize: 10 }}>✕
+
+
+
+ {/* Demo mode toggle */}
+
+
+
+ Demo mode
+
+ {chatDemoMode && (
+
+ ON
+
+ )}
+
+
{
+ const next = !chatDemoMode;
+ onChatDemoModeChange?.(next);
+ if (!next) { setDemoEntries(null); setActiveDemoEntry(null); }
+ }}
+ style={{
+ width: 36, height: 20, borderRadius: 10,
+ background: chatDemoMode ? "var(--amber)" : "var(--bg3)",
+ border: `1px solid ${chatDemoMode ? "var(--amber)" : "var(--border2)"}`,
+ cursor: "pointer", position: "relative", transition: "background 0.15s",
+ padding: 0,
+ }}
+ title={chatDemoMode ? "Turn demo mode off" : "Turn demo mode on"}
+ >
+
+
+
+
+ {!chatDemoMode && llmConfig?.provider === "ollama" && (
+
+ Running locally — no data leaves your machine.
+
+ )}
+ {!chatDemoMode && llmConfig?.provider !== "ollama" && (
+
+ Aggregated metrics only — no resident names or DOC numbers are shared with the API.
+
+ )}
+ {chatDemoMode && (
+
+ Demo active — click "Ask AI" on Facilities or Engagement cards to explore sample insights.
+
+ )}
+
+
+ {!llmConfig && !chatDemoMode ? (
+
+ ) : (
+ <>
+ {/* Demo: starter questions for a card */}
+ {chatDemoMode && demoEntries && messages.length === 0 && (
+
+
Sample questions for this card:
+ {demoEntries.map(entry => (
+
sendDemoMessage(entry)}
+ style={{ display: "block", marginBottom: 6, textAlign: "left", fontSize: 11, width: "100%", whiteSpace: "normal", height: "auto", lineHeight: 1.5 }}>
+ {entry.question}
+
+ ))}
+
+ )}
+
+ {/* Normal empty state */}
+ {!chatDemoMode && messages.length === 0 && (
+
+
Try asking:
+ {EXAMPLE_QUESTIONS.map(q => (
+
sendMessage(q)}
+ style={{ display: "block", marginBottom: 6, textAlign: "left", fontSize: 11, width: "100%" }}>
+ {q}
+
+ ))}
+
+ )}
+
+ {/* Demo: no card selected yet */}
+ {chatDemoMode && !demoEntries && messages.length === 0 && (
+
+
Click "Ask AI" on the
+
Facilities needing attention
+
or
+
Engagement overview
+
card to start.
+
+ )}
+
+ {messages.map(msg =>
)}
+
+ {/* Demo: follow-up questions after a response */}
+ {chatDemoMode && activeDemoEntry && activeDemoEntry.followUps.length > 0 && (
+
+
Follow-up questions:
+ {activeDemoEntry.followUps.map(fu => (
+
sendDemoFollowUp(fu.question, fu.response)}
+ style={{ display: "block", marginBottom: 6, textAlign: "left", fontSize: 11, width: "100%", whiteSpace: "normal", height: "auto", lineHeight: 1.5 }}>
+ {fu.question}
+
+ ))}
+
+ )}
+
+ {isLoading && (
+
+ ✦ Thinking…
+
+ )}
+
+ >
+ )}
+
+
+ {llmConfig && !chatDemoMode && (
+
+ {contextLabel && (
+
+ Context:
+
+ {contextLabel}
+
+ { setContextLabel(null); setPendingHints([]); }}
+ style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text3)", fontSize: 11 }}>
+ ✕
+
+
+ )}
+
+
+
+ Enter to send · Shift+Enter for new line
+
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/src/config/analytics.json b/src/config/analytics.json
new file mode 100644
index 0000000..879296b
--- /dev/null
+++ b/src/config/analytics.json
@@ -0,0 +1,41 @@
+{
+ "topPrograms": {
+ "minEnrollees": 10,
+ "topN": 10
+ },
+ "mixAdjustment": {
+ "lsiBandMinEnrollees": 5,
+ "validBandsRequired": 3
+ },
+ "nearReleaseMonths": 24,
+ "sessionInactivityDays": 30,
+ "attendanceMinEnrollees": 3,
+ "timeToCompletion": {
+ "batchGroupSize": 5,
+ "minSampleSize": 2,
+ "coverageFlagRatio": 0.5
+ },
+ "programPerformanceMatrix": {
+ "minEnrolledPerCell": 3,
+ "underperformDelta": -10,
+ "minUnderperformingPrograms": 2
+ },
+ "cohortRetention": {
+ "recentMonthsSlice": 6,
+ "offsetWindow": 6,
+ "minFacilityCohortSize": 3
+ },
+ "wilsonCI": {
+ "zScore": 1.96
+ },
+ "lookbackPeriods": {
+ "trailing12MonthsDays": 365.25,
+ "prior24MonthsDays": 730.5
+ },
+ "infWaitlistSentinel": 99,
+ "trustBalanceBands": {
+ "low": 50,
+ "mid": 100,
+ "high": 250
+ }
+}
diff --git a/src/config/chat.json b/src/config/chat.json
new file mode 100644
index 0000000..0daf502
--- /dev/null
+++ b/src/config/chat.json
@@ -0,0 +1,5 @@
+{
+ "confidenceThreshold": 0.6,
+ "sessionKey": "llm_config",
+ "drawerWidth": 360
+}
diff --git a/src/config/data_quality.json b/src/config/data_quality.json
new file mode 100644
index 0000000..4e54347
--- /dev/null
+++ b/src/config/data_quality.json
@@ -0,0 +1,30 @@
+{
+ "warnThreshold": {
+ "lsiBand": 0.05,
+ "custodyLevel": 0.05,
+ "educationLevel": 0.05,
+ "offenseCategory": 0.05,
+ "projectedReleaseDate": 0.05,
+ "completionDates": 0.10,
+ "sessions": null,
+ "attendance": null
+ },
+ "critThreshold": {
+ "lsiBand": 0.30,
+ "custodyLevel": 0.40,
+ "educationLevel": 0.30,
+ "offenseCategory": 0.30,
+ "projectedReleaseDate": 0.40,
+ "completionDates": 0.50,
+ "sessions": null,
+ "attendance": null
+ },
+ "lsiClustering": {
+ "minFacilitySize": 5,
+ "clusterMultiplier": 2,
+ "populationMultiplier": 1.5
+ },
+ "statusSkewThreshold": 0.85,
+ "lowParticipationThreshold": 0.15,
+ "minEnrollmentsForSkewCheck": 10
+}
diff --git a/src/config/facilities.json b/src/config/facilities.json
new file mode 100644
index 0000000..4a6943b
--- /dev/null
+++ b/src/config/facilities.json
@@ -0,0 +1,12 @@
+[
+ { "code": "MVCF", "name": "Mountain View Correctional Facility" },
+ { "code": "BCF", "name": "Bolduc Correctional Facility" },
+ { "code": "MCC", "name": "Maine Correctional Center" },
+ { "code": "MSP", "name": "Maine State Prison" },
+ { "code": "DCF", "name": "Downeast Correctional Facility" },
+ { "code": "CCF", "name": "Charleston Correctional Facility" },
+ { "code": "SMWRC", "name": "Southern Maine Women's Reentry Center" },
+ { "code": "SMRC", "name": "Southern Maine Reentry Center" },
+ { "code": "LCYDC", "name": "Long Creek Youth Development Center" },
+ { "code": "MVYDC", "name": "Mountain View Youth Development Center" }
+]
diff --git a/src/config/llm.json b/src/config/llm.json
new file mode 100644
index 0000000..b51b1f3
--- /dev/null
+++ b/src/config/llm.json
@@ -0,0 +1,30 @@
+{
+ "_comment": "LLM provider endpoints and generation parameters. Change ollama endpoint here for non-default installations.",
+ "endpoints": {
+ "geminiBase": "https://generativelanguage.googleapis.com/v1beta/models",
+ "openai": "https://api.openai.com/v1/chat/completions",
+ "claude": "https://api.anthropic.com/v1/messages",
+ "ollama": "http://localhost:11434/v1/chat/completions"
+ },
+ "anthropicVersion": "2023-06-01",
+ "params": {
+ "planner": {
+ "temperature": 0,
+ "maxTokens": 2048
+ },
+ "writer": {
+ "temperature": 0.2,
+ "maxTokens": 4096,
+ "maxOutputTokens": 2048
+ },
+ "actionItems": {
+ "temperature": 0.3,
+ "maxTokens": 1000
+ }
+ },
+ "retry": {
+ "maxAttempts": 5,
+ "retryableCodes": [429, 500, 503],
+ "baseDelayMs": 1000
+ }
+}
diff --git a/src/config/models.json b/src/config/models.json
new file mode 100644
index 0000000..8e76d6f
--- /dev/null
+++ b/src/config/models.json
@@ -0,0 +1,22 @@
+{
+ "_comment": "First entry in each array is the default model. For ollama, the first entry pre-fills the model input; users can type any installed model name.",
+ "gemini": [
+ "gemini-2.5-flash",
+ "gemini-2.5-pro"
+ ],
+ "openai": [
+ "gpt-4o",
+ "gpt-4o-mini",
+ "o4-mini"
+ ],
+ "claude": [
+ "claude-sonnet-4-6",
+ "claude-opus-4-8",
+ "claude-haiku-4-5-20251001"
+ ],
+ "ollama": [
+ "qwen2.5:7b",
+ "qwen2.5:14b",
+ "llama3.2:3b"
+ ]
+}
diff --git a/src/config/thresholds.json b/src/config/thresholds.json
new file mode 100644
index 0000000..8753ab9
--- /dev/null
+++ b/src/config/thresholds.json
@@ -0,0 +1,60 @@
+{
+ "attentionFlags": {
+ "neverEngagedRateThreshold": 30,
+ "mixAdjDeltaThreshold": -2,
+ "equityGapThreshold": 10,
+ "trendDownConsecutiveMonths": 4,
+ "scoreWeights": {
+ "neverEngagedRatePerPp": 0.5,
+ "nearReleaseUnengagedPerResident": 3,
+ "infWaitlist": 15,
+ "mixAdjUnderperform": 20,
+ "equityGap": 25,
+ "trendDown": 20,
+ "programMatrixUnderperform": 15
+ }
+ },
+ "qol": {
+ "neverEngagedRate": {
+ "greenThreshold": 70,
+ "yellowThreshold": 50,
+ "majorityThreshold": 50,
+ "significantThreshold": 30
+ },
+ "infWaitlists": {
+ "yellowMaxCount": 2
+ },
+ "maxWaitMonths": {
+ "yellowThreshold": 6
+ },
+ "retention90": {
+ "greenThreshold": 60,
+ "yellowThreshold": 40
+ },
+ "secondProgramRate": {
+ "greenThreshold": 40,
+ "yellowThreshold": 20
+ },
+ "eduCompletionRate": {
+ "greenThreshold": 40,
+ "yellowThreshold": 25
+ }
+ },
+ "display": {
+ "dropoutTiming": {
+ "earlyDays": 30,
+ "lateDays": 90
+ },
+ "withdrawalRateRed": 30,
+ "neverEngagedRed": 40,
+ "lsiGapWarn": 5,
+ "lsiGapRed": 10,
+ "programCompletionGreen": 50,
+ "programCompletionAccent": 35,
+ "programRateGreen": 50,
+ "deltaColorGreen": 5,
+ "facilityCompletionGreen": 40,
+ "facilityPctGreen": 60,
+ "facilityPctAccent": 30
+ }
+}
diff --git a/src/contexts/DataQualityContext.tsx b/src/contexts/DataQualityContext.tsx
new file mode 100644
index 0000000..d525ba3
--- /dev/null
+++ b/src/contexts/DataQualityContext.tsx
@@ -0,0 +1,11 @@
+import { createContext, useContext } from "react";
+import type { MetricKey, MetricQualityEntry, MetricQualityMap } from "../types";
+
+const DataQualityContext = createContext(null);
+
+export const DataQualityProvider = DataQualityContext.Provider;
+
+export function useMetricQuality(metricKey: MetricKey): MetricQualityEntry {
+ const map = useContext(DataQualityContext);
+ return map?.[metricKey] ?? { status: "ok" };
+}
diff --git a/src/data/demoResponses.ts b/src/data/demoResponses.ts
new file mode 100644
index 0000000..9a73b9a
--- /dev/null
+++ b/src/data/demoResponses.ts
@@ -0,0 +1,219 @@
+// Pregenerated demo responses for B2 (Engagement Overview) and B4 (Facilities Needing Attention)
+// Each entry has a question, a data-informed response, and optional follow-up Q&As.
+
+export interface DemoFollowUp {
+ question: string;
+ response: string;
+}
+
+export interface DemoEntry {
+ question: string;
+ response: string;
+ followUps: DemoFollowUp[];
+}
+
+export const DEMO_RESPONSES: Record = {
+ // ── B4: Facilities needing attention ──────────────────────────────────────
+ "Operational attention": [
+ {
+ question: "What should we focus on operationally this quarter?",
+ response: `Three facilities stand out and need attention this quarter.
+
+**SMWRC** is the highest-priority concern: [score: 0.81]. Of its 48 residents, **19 are within 24 months of release** and have zero program activity — that's a [near_release_unengaged: 40%] rate. Every month without engagement narrows the reentry window.
+
+**BCF** carries the heaviest never-engaged load in absolute terms: [never_engaged_count: 47] residents, representing [never_engaged_pct: 38%] of its population. Its waitlist pressure score is also elevated, with [waitlist_depth: 23] residents queued but unable to start.
+
+**MCC** shows a different pattern — its [completion_rate: 28%] is the lowest statewide. Residents are enrolling but not finishing, which suggests a program-fit or scheduling issue rather than an outreach gap.
+
+Recommended sequence: start with SMWRC's near-release roster this month, run a BCF outreach push in parallel, and schedule an MCC program review for mid-quarter.`,
+ followUps: [
+ {
+ question: "Which facility has the most urgent near-release situation?",
+ response: `**SMWRC** is the most urgent by a significant margin.
+
+[near_release_count: 19] residents there are within 24 months of their projected release date and have **no program enrollment on record**. At current throughput, [projected_unengaged_at_release: 12] of them will likely exit without any documented program participation.
+
+The next closest is BCF with [bcf_near_release_unengaged: 9] residents in the same situation, but SMWRC's rate — [smwrc_near_release_rate: 40%] of all near-release residents — is more than double the system average of [system_avg: 18%].
+
+A targeted pull of the SMWRC near-release roster, sorted by months-to-release, would be the fastest way to prioritize individual outreach. The [high_max_lsi_near_release: 7] High/Maximum-LSI residents in that group should be at the top of the list.`,
+ },
+ {
+ question: "What quick wins can we pursue this month?",
+ response: `Two actions can move the needle before month-end with minimal coordination overhead.
+
+**1 — Activate SMWRC's existing waitlist.** [smwrc_waitlisted: 11] residents are already queued for programs that have open seats. No new outreach needed — this is a scheduling and intake handoff. Moving them from waitlisted to active would immediately reduce the near-release unengaged count.
+
+**2 — Re-engage BCF's "dropped" cohort.** [bcf_dropped_last_90: 14] residents dropped from programs in the last 90 days. Re-enrollment rates for this group tend to be higher than cold outreach because the barrier is usually scheduling or a life event, not disinterest. A brief check-in call or coordinator visit could recover several of these within the month.
+
+Longer-horizon item: MCC's completion issue will require a program review conversation, but that shouldn't delay the first two actions.`,
+ },
+ ],
+ },
+ {
+ question: "Which facilities have the most residents near release with no enrollment?",
+ response: `Across all facilities, [total_near_release_unengaged: 34] residents are within 24 months of release and have zero active or completed program history.
+
+Breaking it down:
+
+| Facility | Near-release, unengaged | % of near-release pop |
+|----------|------------------------|-----------------------|
+| SMWRC | [smwrc: 19] | [smwrc_pct: 40%] |
+| BCF | [bcf: 9] | [bcf_pct: 22%] |
+| DCF | [dcf: 4] | [dcf_pct: 14%] |
+| MSP | [msp: 2] | [msp_pct: 8%] |
+
+SMWRC and BCF together account for [combined_pct: 82%] of the system's near-release gap. Both have available program capacity — the barrier is enrollment intake, not seat availability.
+
+CCF and MVCF are notably clean: [ccf_near_release_unengaged: 0] and [mvcf_near_release_unengaged: 1] respectively, which suggests their intake processes are working and could serve as a model.`,
+ followUps: [
+ {
+ question: "What's preventing enrollment at SMWRC and BCF?",
+ response: `The data points to two different root causes at each facility.
+
+**At SMWRC**, the issue appears to be **waitlist management**. [smwrc_waitlisted: 11] residents are formally queued — they've expressed interest — but haven't transitioned to active status. The median wait time there is [smwrc_median_wait: 4.2 months], the longest in the system. The bottleneck is likely intake scheduling or coordinator capacity, not resident motivation.
+
+**At BCF**, the pattern looks more like **cold non-engagement**: [bcf_never_contacted_pct: 61%] of the never-engaged population there has no waitlist record either, meaning they haven't been reached yet rather than waiting in a queue. This is an outreach gap, not a throughput gap.
+
+The fix at SMWRC is operational (process the waitlist). The fix at BCF is programmatic (expand initial outreach touchpoints). Both are solvable, but they require different approaches from coordinators.`,
+ },
+ ],
+ },
+ {
+ question: "How does the composite attention score work, and are the right facilities flagged?",
+ response: `The composite score weights three signals:
+
+- **Never-engaged rate ×0.5** — the largest share, because persistent non-engagement is the strongest predictor of unmet need
+- **Near-release unengaged ×0.3** — time-sensitive cases where the reentry window is closing
+- **Waitlist pressure ×0.2** — demand that can't be met, a leading indicator of future disengagement
+
+Current scores: SMWRC [0.81], BCF [0.74], MCC [0.61]. The next closest facility is DCF at [dcf_score: 0.44], a meaningful gap that suggests the flagging threshold is reasonably calibrated.
+
+One limitation worth noting: the score doesn't yet account for **program type fit**. A facility may have high enrollment but only in low-intensity programs, leaving high-LSI residents in mismatched placements. That pattern shows up separately in the Equity section but isn't currently baked into the attention score. Incorporating it would likely surface MSP as a higher concern than it currently appears.`,
+ followUps: [
+ {
+ question: "Should MSP be flagged too?",
+ response: `MSP sits just below the flagging threshold at [msp_score: 0.44], but there's a case for a closer look.
+
+Its never-engaged rate of [msp_ne_rate: 31%] is below the flag cutoff on its own. However, MSP has the highest concentration of **High and Maximum LSI residents** in the system — [msp_high_max_pct: 52%] of its population. When you cross that against program enrollment, [msp_high_lsi_unengaged: 18] High/Max residents have no program record.
+
+For equity and recidivism-reduction purposes, those 18 residents represent higher-stakes non-engagement than the same number at a lower-LSI facility. The composite score, as weighted, doesn't fully capture that.
+
+A reasonable next step: flag MSP for a **LSI-stratified review** rather than a full operational intervention — understand which programs they're accessing and whether program intensity matches need before treating it as an outreach problem.`,
+ },
+ ],
+ },
+ ],
+
+ // ── B2: Engagement overview ────────────────────────────────────────────────
+ "Engagement overview": [
+ {
+ question: "What is driving the current engagement distribution, and where should we focus?",
+ response: `The system-wide engagement picture breaks down into four groups:
+
+- **Active** — [active_count: 187] residents ([active_pct: 38%]) currently enrolled in at least one program
+- **Waitlisted** — [waitlisted_count: 94] residents ([waitlisted_pct: 19%]) in queue, not yet active
+- **Completed, not re-enrolled** — [completed_not_active: 68] ([completed_pct: 14%]) finished a program but haven't continued
+- **Never engaged** — [never_engaged: 147] residents ([ne_pct: 29%]) with no program history at all
+
+The [never_engaged: 147] residents are the primary concern — that's [ne_pct: 29%] of the population with no touchpoint. Of those, [high_lsi_ne: 53] are High or Maximum LSI, where educational engagement has the strongest evidence for recidivism reduction.
+
+The waitlist group is a near-term opportunity: [waitlisted: 94] residents have already self-selected for programs. Converting them to active is largely a scheduling and capacity problem, not a motivation problem.
+
+Focus sequence: (1) clear the waitlist bottleneck at SMWRC and BCF, (2) target never-engaged High/Max LSI residents for direct outreach, (3) re-engage the completed-not-re-enrolled group through continuation pathways.`,
+ followUps: [
+ {
+ question: "Which facilities have the highest never-engaged rates?",
+ response: `Never-engaged rates vary significantly across facilities:
+
+| Facility | Never engaged | % of pop | High/Max LSI among NE |
+|----------|--------------|----------|-----------------------|
+| SMWRC | [smwrc_ne: 21] | [smwrc_ne_pct: 44%] | [smwrc_hm: 9] |
+| BCF | [bcf_ne: 47] | [bcf_ne_pct: 38%] | [bcf_hm: 18] |
+| MCC | [mcc_ne: 28] | [mcc_ne_pct: 35%] | [mcc_hm: 14] |
+| MSP | [msp_ne: 19] | [msp_ne_pct: 31%] | [msp_hm: 10] |
+| DCF | [dcf_ne: 17] | [dcf_ne_pct: 26%] | [dcf_hm: 6] |
+| CCF | [ccf_ne: 10] | [ccf_ne_pct: 18%] | [ccf_hm: 3] |
+| MVCF | [mvcf_ne: 5] | [mvcf_ne_pct: 12%] | [mvcf_hm: 2] |
+
+CCF and MVCF stand out as high performers — under 20% never-engaged. Their intake and outreach practices are worth documenting and sharing system-wide.
+
+SMWRC's rate of [smwrc_ne_pct: 44%] is most alarming given its population size, but BCF's absolute count of [bcf_ne: 47] makes it the largest single intervention target.`,
+ },
+ {
+ question: "How does this compare to last quarter?",
+ response: `The cohort retention data gives us a proxy for trend, though a direct quarter-over-quarter comparison requires the previous snapshot.
+
+What the current data shows: the **6-month enrollment trend** is declining. Monthly new enrollments peaked at [peak_month_enrollments: 34] in month 4 of the tracked window and have dropped to [current_month_enrollments: 21] in the most recent month — a [enrollment_decline_pct: 38%] fall.
+
+The never-engaged cohort has been **growing as a share of population**: residents who joined the system in the last 90 days have a never-engaged rate of [new_cohort_ne: 47%], compared to [legacy_cohort_ne: 24%] for residents who have been in the system over a year. This suggests intake outreach is not keeping pace with new arrivals.
+
+The positive signal: completion rates among *active* participants are holding at [completion_rate: 64%], meaning the programs themselves are performing — the gap is in getting residents into them in the first place.`,
+ },
+ ],
+ },
+ {
+ question: "How can we reduce the never-engaged population?",
+ response: `Reducing the [ne_count: 147] never-engaged residents requires addressing two distinct groups within that population.
+
+**Group 1 — Aware but not enrolled ([ne_with_waitlist_history: 38] residents):** These residents have previously interacted with program intake — they showed up on a waitlist or attended an orientation — but never activated. For this group, the intervention is removing the friction: reduce wait times, offer more enrollment windows, or match them to shorter-duration programs to build initial engagement.
+
+**Group 2 — Fully cold ([ne_no_contact: 109] residents):** These have no record of program interaction at all. This is primarily an outreach and awareness problem. Facilities that have reduced their never-engaged rate most effectively (CCF, MVCF) share a common pattern: peer-led program introductions during intake week, rather than relying on self-referral.
+
+The highest-leverage action is targeting the [high_lsi_cold_ne: 53] High/Max LSI residents in Group 2. The evidence base for education reducing recidivism is strongest for this population, and they're likely to face the most reentry barriers.
+
+A phased approach: warm outreach to Group 1 this month (faster wins), structured intake-week programming for new arrivals going forward, and a direct coordinator push to Group 2's High LSI subset.`,
+ followUps: [
+ {
+ question: "What programs have the highest engagement rates?",
+ response: `Looking at completion rate among enrolled residents (a proxy for sustained engagement):
+
+| Program | Enrolled | Completion rate | Avg duration |
+|---------|----------|-----------------|--------------|
+| HiSET Prep | [hiset_enrolled: 62] | [hiset_completion: 71%] | [hiset_duration: 4.2 mo] |
+| Anger Management | [am_enrolled: 48] | [am_completion: 68%] | [am_duration: 2.1 mo] |
+| SAFE | [safe_enrolled: 41] | [safe_completion: 65%] | [safe_duration: 3.0 mo] |
+| Food Preserving | [fp_enrolled: 29] | [fp_completion: 74%] | [fp_duration: 1.8 mo] |
+| College-Associate | [college_enrolled: 23] | [college_completion: 52%] | [college_duration: 8.4 mo] |
+| SUD Treatment | [sud_enrolled: 37] | [sud_completion: 44%] | [sud_duration: 5.6 mo] |
+
+**HiSET Prep** and **Food Preserving** have the best completion rates. Notably, Food Preserving also has the shortest duration — it may serve as an effective gateway program for residents who haven't engaged before, lowering the initial commitment barrier.
+
+**SUD Treatment's** lower completion rate ([sud_completion: 44%]) is worth investigating separately — it may reflect the nature of the population rather than program quality, and completion metrics may not be the right lens for that program type.`,
+ },
+ ],
+ },
+ {
+ question: "Are high-risk residents getting equitable access to programs?",
+ response: `The equity picture shows a meaningful access gap for the highest-need residents.
+
+High and Maximum LSI residents make up [high_max_share_of_pop: 45%] of the total population, but only [high_max_share_of_enrolled: 31%] of active enrollments. The inverse holds for Low LSI residents: [low_lsi_pop_share: 22%] of the population but [low_lsi_enrolled_share: 38%] of enrollments.
+
+In concrete terms: if enrollment were proportional to LSI representation, there would be [high_max_enrollment_gap: 43] more High/Max LSI residents in programs today.
+
+The gap is most pronounced at **MSP** and **BCF**, where High/Max LSI residents are significantly underrepresented in enrollments relative to their share of the facility population.
+
+There are two likely drivers: (1) program scheduling may favor custody levels that create logistical friction for higher-security residents, and (2) some programs may have informal eligibility criteria that screen out High LSI residents before formal enrollment.
+
+This is an important lens to apply to any capacity expansion decisions — adding seats that only Low LSI residents can access won't close the equity gap.`,
+ followUps: [
+ {
+ question: "Which programs are most accessible to High/Max LSI residents?",
+ response: `Looking at LSI composition of enrolled participants by program:
+
+**Most accessible (High/Max LSI well-represented):**
+- Anger Management: [am_high_max_pct: 58%] of enrollees are High/Max LSI — above population average
+- SAFE: [safe_high_max_pct: 51%] High/Max representation, consistent with population
+- SUD Treatment: [sud_high_max_pct: 49%] — also proportionate
+
+**Least accessible:**
+- College-Associate: [college_high_max_pct: 18%] High/Max — significantly underrepresented
+- HiSET Prep: [hiset_high_max_pct: 27%] — below population average despite high overall completion rates
+
+The cognitive/behavioral programs are reaching High/Max LSI residents at or above parity. The academic programs are not. This may reflect eligibility criteria, scheduling constraints tied to security level, or self-selection.
+
+Given the evidence base for education (vs. behavioral programs alone) on long-term recidivism outcomes, closing the access gap in HiSET Prep specifically — which also has the highest completion rate — would have outsized impact.`,
+ },
+ ],
+ },
+ ],
+};
diff --git a/src/data/mockData.ts b/src/data/mockData.ts
new file mode 100644
index 0000000..2b4d04e
--- /dev/null
+++ b/src/data/mockData.ts
@@ -0,0 +1,697 @@
+/**
+ * Generates realistic synthetic data that matches the Maine DOC CSV schemas.
+ * Used when the user hasn't loaded their own CSVs yet (demo / development mode).
+ * Numbers and distributions are calibrated to match the sample outputs in the PDF.
+ */
+
+import type {
+ RawProgramClass, RawCompletion, RawProgramClassEvent,
+ RawEventAttendance, RawUserSessionTracking,
+ ParsedData, Facility, Program, Resident, EnrollmentRecord, ProgramCrosswalkEntry,
+ IncidentRecord, WorkAssignmentRecord, CasePlanRecord, CredentialRecord, HousingMoveRecord,
+} from "../types";
+import { computeDataQuality } from "../lib/dataQuality";
+
+// ── Static lookups ────────────────────────────────────────────────────────────
+
+const FACILITIES = [
+ { facility_id: "f1", facility_code: "BCF", facility_name: "Bolduc Correctional Facility", region: "South" },
+ { facility_id: "f2", facility_code: "CCF", facility_name: "Charleston Correctional Facility", region: "Central" },
+ { facility_id: "f3", facility_code: "DCF", facility_name: "Downeast Correctional Facility", region: "East" },
+ { facility_id: "f4", facility_code: "MCC", facility_name: "Maine Correctional Center", region: "South" },
+ { facility_id: "f5", facility_code: "MSP", facility_name: "Maine State Prison", region: "South" },
+ { facility_id: "f6", facility_code: "MVCF", facility_name: "Mountain View Correctional Facility", region: "Central" },
+ { facility_id: "f7", facility_code: "SMWRC", facility_name: "Southern Maine Women's Reentry Center", region: "South" },
+ { facility_id: "f8", facility_code: "SMRC", facility_name: "Southern Maine Reentry Center", region: "South" },
+] as const;
+
+const PROGRAMS = [
+ { program_id: "p1", program_name: "edu HiSET Prep Social Studies", program_type: "Education", category: "Academic" },
+ { program_id: "p2", program_name: "edu College- Associate's Degree", program_type: "Education", category: "Academic" },
+ { program_id: "p3", program_name: "edu College Course(s) Non-Matriculated", program_type: "Education", category: "Academic" },
+ { program_id: "p4", program_name: "Anger Management", program_type: "Cognitive-Behavioral", category: "Behavior" },
+ { program_id: "p5", program_name: "Food Preserving Class", program_type: "Vocational", category: "Vocational" },
+ { program_id: "p6", program_name: "SAFE", program_type: "Cognitive-Behavioral", category: "Behavior" },
+ { program_id: "p7", program_name: "Master Gardener", program_type: "Vocational", category: "Vocational" },
+ { program_id: "p8", program_name: "Challenge Program - Rational Thinking", program_type: "Cognitive-Behavioral", category: "Behavior" },
+ { program_id: "p9", program_name: "CBI-IPV", program_type: "Cognitive-Behavioral", category: "Behavior" },
+ { program_id: "p10", program_name: "SUD Treatment Tier 2", program_type: "SUD", category: "Treatment" },
+ { program_id: "p11", program_name: "Discontinued-Helping Men Recover", program_type: "SUD", category: "Treatment" },
+ { program_id: "p12", program_name: "Urban Horticulture", program_type: "Vocational", category: "Vocational" },
+ { program_id: "p13", program_name: "Thinking for a Change", program_type: "Cognitive-Behavioral", category: "Behavior" },
+ { program_id: "p14", program_name: "WCCC Cert. Prod. Tech. Certification", program_type: "Vocational", category: "Vocational" },
+ { program_id: "p15", program_name: "R&R2", program_type: "Cognitive-Behavioral", category: "Behavior" },
+ { program_id: "p16", program_name: "Nonviolent Communication", program_type: "Cognitive-Behavioral", category: "Behavior" },
+ { program_id: "p17", program_name: "Leading The Way", program_type: "Other", category: "Other" },
+ { program_id: "p18", program_name: "Problem Sexual Behavior Tx Building a Balanced Lif", program_type: "Cognitive-Behavioral", category: "Behavior" },
+] as const;
+
+const EDUCATION_PROGRAM_IDS = ["p1", "p2", "p3"];
+
+// Program completion rate targets from PDF sample
+const PROGRAM_TARGETS: Record = {
+ p1: 0.14, p2: 0.30, p3: 0.22,
+ p4: 0.60, p5: 0.67, p6: 0.56,
+ p7: 0.48, p8: 0.42, p9: 0.38,
+ p10: 0.36, p11: 0.32, p12: 0.31,
+ p13: 0.31, p14: 0.28,
+ p15: 0.45, p16: 0.52, p17: 0.38, p18: 0.34,
+};
+
+function rng(seed: number) {
+ let s = seed;
+ return () => { s = (s * 1664525 + 1013904223) & 0xffffffff; return (s >>> 0) / 0xffffffff; };
+}
+
+function randInt(r: () => number, min: number, max: number) {
+ return Math.floor(r() * (max - min + 1)) + min;
+}
+
+function weightedPick(r: () => number, items: readonly T[], weights: number[]): T {
+ const total = weights.reduce((a, b) => a + b, 0);
+ let v = r() * total;
+ for (let i = 0; i < items.length; i++) { v -= weights[i]; if (v <= 0) return items[i]; }
+ return items[items.length - 1];
+}
+
+function dateOffset(base: Date, days: number): string {
+ const d = new Date(base);
+ d.setDate(d.getDate() + days);
+ return d.toISOString().slice(0, 10);
+}
+
+function monthKey(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
+}
+
+// ── Generator ────────────────────────────────────────────────────────────────
+
+export type DemoScenario = "messy" | "clean" | "advanced-missing";
+
+export function generateMockData(scenario: DemoScenario = "messy"): ParsedData {
+ const r = rng(42);
+ const now = new Date("2025-05-14");
+ const isClean = scenario === "clean";
+ const isMessy = scenario === "messy";
+
+ // Facilities / Programs
+ const facilities: Facility[] = FACILITIES.map((f) => ({ id: f.facility_id, code: f.facility_code, name: f.facility_name, region: f.region }));
+ const programs: Program[] = PROGRAMS.map((p) => ({ id: p.program_id, name: p.program_name, type: p.program_type, category: p.category }));
+
+ // ── Residents (300 total, ~7 facilities ≈ 43 per facility) ────────────────
+ // "messy": Unknown entries drive data quality warnings/criticals
+ // lsiBand ~33% → critical (>30% threshold)
+ // educationLevel ~12%, offenseCategory ~8%, custodyLevel ~8% → warning (>5% threshold)
+ // "clean": no Unknown entries, all fields populated
+ const lsiBands = ["Low", "Moderate", "High", "Maximum", "Unknown"] as const;
+ // messy base weights give ~16% unknown at most facilities; MSP (fi=4) uses its own override below
+ const lsiWeights = isClean ? [0.25, 0.35, 0.28, 0.12, 0.0] : [0.27, 0.40, 0.32, 0.17, 0.22];
+ const custodyLevels = ["Minimum", "Medium", "Close", "Community", "Administrative", "Unclassified", "Unknown"];
+ const custodyW = isClean ? [0.22, 0.46, 0.16, 0.09, 0.04, 0.03, 0.0] : [0.18, 0.40, 0.14, 0.08, 0.06, 0.14, 0.09];
+ const eduLevels = ["No HS diploma", "HS/GED", "Some college+", "Unknown"];
+ const eduWeights = isClean ? [0.40, 0.38, 0.22, 0.0] : [0.36, 0.36, 0.20, 0.13];
+ const residentTypes = ["Incarcerated", "County Jail Hold", "SCCP"] as const;
+ const offenseCategories = ["Person", "Property", "Drug", "Sex/Registry", "Other", "Unknown"] as const;
+ const offenseWeights = isClean ? [0.30, 0.24, 0.27, 0.12, 0.07, 0.0] : [0.28, 0.22, 0.25, 0.12, 0.13, 0.09];
+
+ const residents: Resident[] = [];
+ const facDistribution = [40, 45, 38, 48, 52, 52, 25, 20]; // ~320 total; last entry = SMRC
+
+ for (let fi = 0; fi < FACILITIES.length; fi++) {
+ const fac = FACILITIES[fi];
+ const n = facDistribution[fi];
+ for (let i = 0; i < n; i++) {
+ const uid = `u${residents.length + 1}`;
+ // MSP in messy scenario: ~50% LSI unknown (clustering flag target)
+ const lsiW = (isMessy && fi === 4) ? [0.15, 0.25, 0.20, 0.10, 0.70] : lsiWeights;
+ const lsi = weightedPick(r, lsiBands, lsiW);
+ const custody = weightedPick(r, custodyLevels, custodyW);
+ const edu = weightedPick(r, eduLevels, eduWeights);
+ // SCCP (community supervision) is only valid at reentry centers (SMWRC=6, SMRC=7)
+ const isReentryFac = fi === 6 || fi === 7;
+ const resType = isReentryFac
+ ? weightedPick(r, residentTypes, [0.55, 0.10, 0.35])
+ : weightedPick(r, residentTypes.slice(0, 2) as ["Incarcerated", "County Jail Hold"], [0.81, 0.19]);
+ const offense = weightedPick(r, offenseCategories, offenseWeights);
+ const admDaysAgo = randInt(r, 30, 1800);
+ const admDate = dateOffset(now, -admDaysAgo);
+ const monthsToRelease = r() < 0.08 ? randInt(r, 1, 24) / 1
+ : r() < 0.5 ? randInt(r, 24, 72)
+ : randInt(r, 0, 36) + 60;
+ const releaseDate = dateOffset(now, Math.round(monthsToRelease * 30.44));
+ const age = randInt(r, 19, 62);
+ const gender = fi === 6 ? "Female" : r() < 0.07 ? "Female" : "Male";
+
+ residents.push({
+ id: uid, facilityId: fac.facility_id, facilityCode: fac.facility_code,
+ lsiBand: lsi, custodyLevel: custody, educationLevel: edu,
+ residentType: resType,
+ projectedReleaseDate: r() < 0.10 ? null : new Date(releaseDate),
+ admissionDate: new Date(admDate),
+ age, gender,
+ monthsToRelease,
+ offenseCategory: offense,
+ });
+ }
+ }
+
+ // ── Classes (ensure ≥2 classes per program per facility for Q16 density) ───
+ const classes: RawProgramClass[] = [];
+ let classCounter = 1;
+ const classesByFacilityProgram = new Map(); // "facId::progId" → classIds
+
+ for (const prog of PROGRAMS) {
+ for (const fac of FACILITIES) {
+ if (r() < 0.25) continue; // ~75% of facility-program combos have classes (was 65% chance skip)
+ const numClasses = randInt(r, 2, 3); // at least 2 classes per pair for enrollment density
+ for (let ci = 0; ci < numClasses; ci++) {
+ const cid = `c${classCounter++}`;
+ const startDaysAgo = randInt(r, 30, 400);
+ const durationDays = randInt(r, 60, 300);
+ classes.push({
+ class_id: cid,
+ program_id: prog.program_id,
+ facility_id: fac.facility_id,
+ capacity: String(randInt(r, 8, 25)),
+ status: startDaysAgo < durationDays ? "Active" : "Completed",
+ start_date: dateOffset(now, -startDaysAgo),
+ end_date: dateOffset(now, durationDays - startDaysAgo),
+ });
+ const key = `${fac.facility_id}::${prog.program_id}`;
+ const arr = classesByFacilityProgram.get(key) ?? [];
+ arr.push(cid);
+ classesByFacilityProgram.set(key, arr);
+ }
+ }
+ }
+
+ // ── Enrollments (~40% of residents have zero enrollment — "never engaged") ──
+ const enrollments: EnrollmentRecord[] = [];
+ let enrollCounter = 1;
+
+ const classesByFacility = new Map();
+ for (const c of classes) {
+ const list = classesByFacility.get(c.facility_id) ?? [];
+ list.push(c);
+ classesByFacility.set(c.facility_id, list);
+ }
+
+ const programMap = new Map();
+ for (const p of PROGRAMS) programMap.set(p.program_id, p);
+
+ for (const res of residents) {
+ if (r() < 0.40) continue; // ~40% never engaged
+
+ const facClasses = classesByFacility.get(res.facilityId) ?? [];
+ if (facClasses.length === 0) continue;
+
+ const numEnrollments = r() < 0.35 ? 2 : 1;
+ const used = new Set();
+
+ for (let ei = 0; ei < numEnrollments; ei++) {
+ const cls = facClasses[randInt(r, 0, facClasses.length - 1)];
+ if (used.has(cls.class_id)) continue;
+ used.add(cls.class_id);
+
+ const prog = programMap.get(cls.program_id)!;
+ const targetRate = PROGRAM_TARGETS[cls.program_id] ?? 0.35;
+ const lsiMult = res.lsiBand === "Low" ? 1.15 : res.lsiBand === "Moderate" ? 1.0 : res.lsiBand === "High" ? 0.88 : 0.72;
+ const completionProb = Math.min(targetRate * lsiMult, 0.95);
+
+ let status: string;
+ const roll = r();
+ if (roll < completionProb) status = "Completed";
+ else if (roll < completionProb + 0.25) status = "Active";
+ else if (roll < completionProb + 0.35) status = "Waitlisted";
+ else status = "Dropped";
+
+ const enrollDaysAgo = randInt(r, 30, 365);
+ const completionDaysAgo = status === "Completed" ? randInt(r, 1, enrollDaysAgo - 1) : undefined;
+
+ enrollments.push({
+ enrollmentId: `e${enrollCounter++}`,
+ userId: res.id,
+ classId: cls.class_id,
+ programId: cls.program_id,
+ facilityId: cls.facility_id,
+ facilityCode: res.facilityCode,
+ status,
+ enrolledDate: new Date(dateOffset(now, -enrollDaysAgo)),
+ completionDate: completionDaysAgo && (isClean || r() > 0.15) ? new Date(dateOffset(now, -completionDaysAgo)) : null,
+ programName: prog.program_name,
+ programType: prog.program_type,
+ source: "doc" as const,
+ });
+ }
+ }
+
+ // ── Q16 matrix seeding: ensure ≥3 enrollments per facility-program pair ──────
+ // For pairs that exist in classes but have <3 enrollments, top them up.
+ const pairEnrollCount = new Map();
+ for (const e of enrollments) {
+ const cls = classes.find(c => c.class_id === e.classId);
+ if (!cls) continue;
+ const key = `${cls.facility_id}::${cls.program_id}`;
+ pairEnrollCount.set(key, (pairEnrollCount.get(key) ?? 0) + 1);
+ }
+
+ const residentsByFacility = new Map();
+ for (const res of residents) {
+ const list = residentsByFacility.get(res.facilityId) ?? [];
+ list.push(res);
+ residentsByFacility.set(res.facilityId, list);
+ }
+
+ for (const [key, classIds] of classesByFacilityProgram) {
+ const current = pairEnrollCount.get(key) ?? 0;
+ if (current >= 3) continue;
+ const [facId, progId] = key.split("::");
+ const facResidents = residentsByFacility.get(facId) ?? [];
+ if (facResidents.length === 0) continue;
+ const prog = programMap.get(progId);
+ if (!prog) continue;
+ const cls = classes.find(c => c.class_id === classIds[0]);
+ if (!cls) continue;
+
+ const needed = 3 - current;
+ let added = 0;
+ for (const res of facResidents) {
+ if (added >= needed) break;
+ // skip residents already enrolled in this class
+ if (enrollments.some(e => e.userId === res.id && e.classId === cls.class_id)) continue;
+ const targetRate = PROGRAM_TARGETS[progId] ?? 0.35;
+ const status = r() < targetRate ? "Completed" : "Active";
+ const enrollDaysAgo = randInt(r, 30, 300);
+ const completionDaysAgo = status === "Completed" ? randInt(r, 1, enrollDaysAgo - 1) : undefined;
+
+ enrollments.push({
+ enrollmentId: `e${enrollCounter++}`,
+ userId: res.id,
+ classId: cls.class_id,
+ programId: progId,
+ facilityId: facId,
+ facilityCode: res.facilityCode,
+ status,
+ enrolledDate: new Date(dateOffset(now, -enrollDaysAgo)),
+ completionDate: completionDaysAgo && (isClean || r() > 0.15) ? new Date(dateOffset(now, -completionDaysAgo)) : null,
+ programName: prog.program_name,
+ programType: prog.program_type,
+ source: "doc" as const,
+ });
+ added++;
+ }
+ }
+
+ // ── Q17 cohort seeding: inject education enrollments across last 6 months ────
+ // Ensures Q17 cohort retention has meaningful data per facility.
+ const educationPrograms = PROGRAMS.filter(p => EDUCATION_PROGRAM_IDS.includes(p.program_id));
+
+ for (let monthOffset = 0; monthOffset < 6; monthOffset++) {
+ // Cohort month: monthOffset months ago
+ const cohortDate = new Date(now);
+ cohortDate.setMonth(cohortDate.getMonth() - monthOffset);
+ cohortDate.setDate(randInt(r, 1, 20)); // random day within that month
+ const cohortDaysAgo = Math.round((now.getTime() - cohortDate.getTime()) / 86400000);
+
+ for (const fac of FACILITIES) {
+ const facResidents = residentsByFacility.get(fac.facility_id) ?? [];
+ if (facResidents.length === 0) continue;
+
+ // Pick an education program that has a class at this facility
+ const eduProg = weightedPick(r, educationPrograms, educationPrograms.map(() => 1));
+ const facClassIds = classesByFacilityProgram.get(`${fac.facility_id}::${eduProg.program_id}`);
+ if (!facClassIds || facClassIds.length === 0) continue;
+ const cls = classes.find(c => c.class_id === facClassIds[0]);
+ if (!cls) continue;
+
+ const cohortSize = randInt(r, 5, 9);
+ let seeded = 0;
+
+ for (const res of facResidents) {
+ if (seeded >= cohortSize) break;
+ // Don't double-seed same resident in same class
+ if (enrollments.some(e => e.userId === res.id && e.classId === cls.class_id)) continue;
+
+ // Determine retention: residents enrolled monthOffset months ago
+ // For offsets 0-2: good retention; 3-5: slightly lower
+ const retentionProb = monthOffset < 3 ? 0.72 : 0.55;
+ const status = r() < retentionProb ? "Active" : r() < 0.4 ? "Completed" : "Dropped";
+ const completionDaysAgo = status === "Completed" ? randInt(r, 1, Math.max(cohortDaysAgo - 1, 1)) : undefined;
+
+ enrollments.push({
+ enrollmentId: `e${enrollCounter++}`,
+ userId: res.id,
+ classId: cls.class_id,
+ programId: eduProg.program_id,
+ facilityId: fac.facility_id,
+ facilityCode: fac.facility_code,
+ status,
+ enrolledDate: new Date(cohortDate),
+ completionDate: completionDaysAgo && (isClean || r() > 0.15) ? new Date(dateOffset(now, -completionDaysAgo)) : null,
+ programName: eduProg.program_name,
+ programType: eduProg.program_type,
+ source: "doc" as const,
+ });
+ seeded++;
+ }
+ }
+ }
+
+ // ── Completions ───────────────────────────────────────────────────────────
+ const completions: RawCompletion[] = enrollments
+ .filter((e) => e.status === "Completed")
+ .map((e, i) => ({
+ completion_id: `comp${i + 1}`,
+ user_id: e.userId,
+ program_id: e.programId,
+ class_id: e.classId,
+ completion_date: e.completionDate?.toISOString().slice(0, 10) ?? "",
+ credit_type_id: "ct1",
+ }));
+
+ // ── Events & Attendance ───────────────────────────────────────────────────
+ const events: RawProgramClassEvent[] = [];
+ const attendance: RawEventAttendance[] = [];
+ let evtCounter = 1, attCounter = 1;
+
+ for (const cls of classes) {
+ if (cls.status !== "Active") continue;
+ const numEvents = randInt(r, 1, 3);
+ for (let ei = 0; ei < numEvents; ei++) {
+ const eid = `ev${evtCounter++}`;
+ events.push({ event_id: eid, class_id: cls.class_id, event_date: dateOffset(now, -randInt(r, 1, 45)), event_type: "Session" });
+ for (const enr of enrollments) {
+ if (enr.classId === cls.class_id && enr.status === "Active") {
+ const attended = r() < 0.75;
+ attendance.push({ attendance_id: `att${attCounter++}`, event_id: eid, user_id: enr.userId, attended: String(attended) });
+ }
+ }
+ }
+ }
+
+ // ── UL deployments: first 3 facility codes are "UL-deployed" ─────────────
+ const ulFacilityCodes: Set = new Set(FACILITIES.slice(0, 3).map(f => f.facility_code));
+
+ // ── UL enrollments: copy ~80% of enrollments in UL facilities ─────────────
+ const ulEnrollments: EnrollmentRecord[] = [];
+ let ulEnrollCounter = 1;
+ // Batch artifact: pick completed enrollments in first UL facility, set same timestamp
+ const batchTimestamp = new Date("2025-11-15T14:32:07Z");
+ const firstUlFacCode = FACILITIES[0].facility_code;
+ let batchCount = 0;
+
+ for (const enr of enrollments) {
+ if (!ulFacilityCodes.has(enr.facilityCode)) continue;
+ if (r() > 0.80) continue; // keep ~80%
+ const isBatchable = batchCount < 10 && enr.status === "Completed" && enr.facilityCode === firstUlFacCode;
+ const completionDate = isBatchable ? batchTimestamp : enr.completionDate;
+ if (isBatchable) batchCount++;
+
+ ulEnrollments.push({
+ ...enr,
+ enrollmentId: `ul_e${ulEnrollCounter++}`,
+ source: "ul" as const,
+ completionDate,
+ });
+ }
+
+ // ── Completion-before-enrollment date errors (messy only) ────────────────
+ if (isMessy) {
+ const completedDOC = enrollments.filter(
+ e => e.status === "Completed" && e.completionDate && e.enrolledDate
+ );
+ const targetFlips = Math.max(1, Math.round(completedDOC.length * 0.025));
+ let flipped = 0;
+ for (const enr of completedDOC) {
+ if (flipped >= targetFlips) break;
+ if (r() < 0.4) {
+ // Set completion date a few days BEFORE enrollment date (data entry error)
+ enr.completionDate = new Date(enr.enrolledDate!.getTime() - randInt(r, 1, 30) * 86_400_000);
+ flipped++;
+ }
+ }
+ }
+
+ // ── Session tracking ─────────────────────────────────────────────────────
+ const sessions: RawUserSessionTracking[] = [];
+ let sessCounter = 1;
+ for (const enr of enrollments) {
+ if (enr.status !== "Active") continue;
+ const isUlFacility = ulFacilityCodes.has(enr.facilityCode);
+ // ~65% of active enrollees overall get sessions
+ if (r() >= 0.65) continue;
+ // For UL facilities: ~20% get only old sessions (>30 days ago) for Q8
+ const isInactive = isUlFacility && r() < 0.20;
+ const numSess = randInt(r, 1, 5);
+ for (let si = 0; si < numSess; si++) {
+ const daysAgo = isInactive
+ ? randInt(r, 31, 90) // old sessions only — triggers Q8
+ : randInt(r, 1, 30); // recent sessions
+ sessions.push({
+ session_id: `s${sessCounter++}`,
+ user_id: enr.userId,
+ session_date: dateOffset(now, -daysAgo),
+ platform: "UL",
+ duration_minutes: String(randInt(r, 10, 90)),
+ });
+ }
+ }
+
+ const crosswalk: ProgramCrosswalkEntry[] = [];
+
+ // ── Incidents ────────────────────────────────────────────────────────────────
+
+ const MAJOR_INC = [
+ { type: "Assault on staff", sanction: "Disciplinary segregation 15d" },
+ { type: "Assault on resident", sanction: "Disciplinary segregation 10d" },
+ { type: "Fighting", sanction: "Loss of privileges 30d" },
+ { type: "Threatening behavior", sanction: "Loss of privileges 21d" },
+ { type: "Drug/alcohol positive", sanction: "Disciplinary segregation 14d" },
+ { type: "Tampering with security device", sanction: "Disciplinary segregation 10d" },
+ { type: "Possession of contraband - drug", sanction: "Disciplinary segregation 7d" },
+ { type: "Possession of contraband - phone", sanction: "Disciplinary segregation 5d" },
+ ] as const;
+
+ const MINOR_INC = [
+ { type: "Possession of contraband - tobacco", sanction: "Loss of privileges 14d" },
+ { type: "Disorderly conduct", sanction: "Verbal warning" },
+ { type: "Refusing assignment", sanction: "Loss of privileges 7d" },
+ { type: "Unauthorized area", sanction: "Verbal warning" },
+ { type: "Disobeying a direct order", sanction: "Loss of privileges 7d" },
+ { type: "Theft", sanction: "Restitution + loss of privileges 14d" },
+ ] as const;
+
+ const incidents: IncidentRecord[] = [];
+ for (const res of residents) {
+ if (r() > 0.50) continue;
+ const n = r() < 0.50 ? 1 : r() < 0.72 ? 2 : r() < 0.88 ? 3 : randInt(r, 4, 6);
+ for (let i = 0; i < n; i++) {
+ const major = r() < 0.55;
+ const pool = major ? MAJOR_INC : MINOR_INC;
+ const pick = pool[randInt(r, 0, pool.length - 1)];
+ incidents.push({
+ residentId: res.id,
+ incidentDate: new Date(dateOffset(now, -randInt(r, 1, 700))),
+ incidentType: pick.type,
+ severity: major ? "Major" : "Minor",
+ sanction: pick.sanction,
+ facilityCode: res.facilityCode,
+ });
+ }
+ }
+
+ // ── Work assignments ─────────────────────────────────────────────────────────
+
+ const FACILITY_ROLES = [
+ { title: "Library aide", transferable: true },
+ { title: "Unit porter", transferable: false },
+ { title: "Laundry", transferable: false },
+ { title: "Kitchen worker", transferable: true },
+ { title: "Maintenance helper", transferable: true },
+ { title: "Recreation aide", transferable: false },
+ { title: "Grounds crew", transferable: true },
+ { title: "Industries - woodshop", transferable: true },
+ { title: "Industries - print shop", transferable: true },
+ { title: "Industries - upholstery", transferable: true },
+ { title: "Barber/Cosmetology assistant", transferable: true },
+ { title: "Education tutor", transferable: true },
+ ] as const;
+
+ const EXTERNAL_JOBS = [
+ "Work release - food service",
+ "Work release - construction",
+ "Work release - manufacturing",
+ "Community work crew",
+ ] as const;
+
+ const workAssignments: WorkAssignmentRecord[] = [];
+ for (const res of residents) {
+ if (r() > 0.70) continue;
+ const role = FACILITY_ROLES[randInt(r, 0, FACILITY_ROLES.length - 1)];
+ const startDaysAgo = randInt(r, 30, 500);
+ const isActive = r() < 0.82;
+ workAssignments.push({
+ residentId: res.id,
+ assignmentType: "Facility Role",
+ roleTitle: role.title,
+ startDate: new Date(dateOffset(now, -startDaysAgo)),
+ endDate: isActive ? null : new Date(dateOffset(now, -randInt(r, 1, startDaysAgo - 1))),
+ isActive,
+ transferableSkills: role.transferable,
+ facilityCode: res.facilityCode,
+ });
+ if (r() < 0.14) {
+ const jobTitle = EXTERNAL_JOBS[randInt(r, 0, EXTERNAL_JOBS.length - 1)];
+ const extStart = randInt(r, 30, 300);
+ const extActive = r() < 0.68;
+ workAssignments.push({
+ residentId: res.id,
+ assignmentType: "External Job",
+ roleTitle: jobTitle,
+ startDate: new Date(dateOffset(now, -extStart)),
+ endDate: extActive ? null : new Date(dateOffset(now, -randInt(r, 1, extStart - 1))),
+ isActive: extActive,
+ transferableSkills: true,
+ facilityCode: res.facilityCode,
+ });
+ }
+ }
+
+ // Thread active job assignment onto each resident (mirrors demoExport logic)
+ const activeJobMap = new Map();
+ for (const w of workAssignments) {
+ if (w.isActive && !activeJobMap.has(w.residentId)) {
+ activeJobMap.set(w.residentId, w.roleTitle);
+ }
+ }
+ for (const res of residents) {
+ const job = activeJobMap.get(res.id);
+ if (job) res.jobAssign = job;
+ }
+
+ // ── Case plans (one per resident) ────────────────────────────────────────────
+
+ const casePlans: CasePlanRecord[] = residents.map((res) => {
+ const stateId = r() < 0.20;
+ const job = r() < 0.06;
+ const housing = r() < 0.12;
+ const balance = Math.round((r() * 545 + 5) * 100) / 100;
+ const savings = r() < (balance >= 250 ? 0.40 : 0.06);
+ return {
+ residentId: res.id,
+ stateIdObtained: stateId,
+ stateIdDate: stateId ? new Date(dateOffset(now, -randInt(r, 30, 500))) : null,
+ jobLinedUp: job,
+ housingPlan: housing,
+ trustBalance: balance,
+ savingsGoalMet: savings,
+ };
+ });
+
+ // ── Credentials ──────────────────────────────────────────────────────────────
+
+ const VOC_CREDS = [
+ "OSHA-10",
+ "Forklift Operator",
+ "Microsoft Office Specialist",
+ "ServSafe Food Handler",
+ "NCCER Core Construction",
+ "Master Gardener Certificate",
+ ] as const;
+
+ const credentials: CredentialRecord[] = [];
+ for (const res of residents) {
+ if (r() > 0.58) continue;
+ // Education credential — aligned with resident education level
+ if (res.educationLevel === "HS/GED" || r() < 0.28) {
+ const isGed = r() < 0.55;
+ credentials.push({
+ residentId: res.id,
+ credentialType: isGed ? "GED" : "HiSET",
+ credentialName: isGed ? "GED Certificate" : "HiSET Certificate",
+ dateEarned: new Date(dateOffset(now, -randInt(r, 100, 3000))),
+ issuingBody: "Maine DOE",
+ verified: r() < 0.97,
+ });
+ }
+ if (r() < 0.42) {
+ credentials.push({
+ residentId: res.id,
+ credentialType: "Vocational",
+ credentialName: VOC_CREDS[randInt(r, 0, VOC_CREDS.length - 1)],
+ dateEarned: new Date(dateOffset(now, -randInt(r, 30, 1000))),
+ issuingBody: "Program Provider",
+ verified: r() < 0.88,
+ });
+ }
+ if (r() < 0.08) {
+ credentials.push({
+ residentId: res.id,
+ credentialType: "Degree",
+ credentialName: "Associate Degree - General Studies",
+ dateEarned: new Date(dateOffset(now, -randInt(r, 100, 2000))),
+ issuingBody: "Community College",
+ verified: r() < 0.98,
+ });
+ }
+ }
+
+ // ── Housing history ──────────────────────────────────────────────────────────
+
+ const INTERIM_UNITS = [
+ "Receiving", "Restrictive Housing", "General Population",
+ "Unit A", "Unit B", "Unit C", "SMU",
+ ] as const;
+
+ const MOVE_REASONS = [
+ "Administrative", "Program placement", "Disciplinary", "Custody review",
+ ] as const;
+
+ const housingMoves: HousingMoveRecord[] = [];
+ for (const res of residents) {
+ const numMoves = randInt(r, 1, 4);
+ const admissionDaysAgo = randInt(r, 200, 900);
+ for (let mi = 0; mi < numMoves; mi++) {
+ const isFirst = mi === 0;
+ const isLast = mi === numMoves - 1;
+ const moveDaysAgo = isFirst
+ ? admissionDaysAgo
+ : admissionDaysAgo - Math.round((admissionDaysAgo / numMoves) * mi) - randInt(r, 0, 20);
+ const reason = isFirst ? "Intake"
+ : isLast && r() < 0.22 ? "Earned"
+ : MOVE_REASONS[randInt(r, 0, MOVE_REASONS.length - 1)];
+ const custodyLevel = isLast
+ ? res.custodyLevel
+ : (["Minimum", "Medium", "Close", "Unclassified"] as const)[randInt(r, 0, 3)];
+ const housingUnit = isFirst ? "General Population"
+ : reason === "Earned" || custodyLevel === "Community" ? "Community"
+ : INTERIM_UNITS[randInt(r, 0, INTERIM_UNITS.length - 1)];
+ housingMoves.push({
+ residentId: res.id,
+ moveDate: new Date(dateOffset(now, -moveDaysAgo)),
+ housingUnit,
+ custodyLevel,
+ moveReason: reason,
+ facilityCode: res.facilityCode,
+ });
+ }
+ }
+
+ const includeAdvanced = scenario !== "advanced-missing";
+ const partial = {
+ facilities, programs, residents, enrollments, ulEnrollments, completions,
+ events, attendance, sessions, classes, crosswalk,
+ incidents: includeAdvanced ? incidents : [],
+ workAssignments: includeAdvanced ? workAssignments : [],
+ casePlans: includeAdvanced ? casePlans : [],
+ credentials: includeAdvanced ? credentials : [],
+ housingMoves: includeAdvanced ? housingMoves : [],
+ dataEnums: {},
+ };
+ // messy: surface junk rows + duplicate-roster integrity checks
+ const meta = isMessy ? { junkRowCount: 4, duplicateResidentCount: 6 } : undefined;
+ return { ...partial, qualityReport: computeDataQuality(partial, meta) };
+}
+
+// suppress unused warning — referenced in analytics
+void monthKey;
diff --git a/src/data/schemaRegistry.ts b/src/data/schemaRegistry.ts
new file mode 100644
index 0000000..cffd64f
--- /dev/null
+++ b/src/data/schemaRegistry.ts
@@ -0,0 +1,309 @@
+// Schema registry skeleton — metric IDs tagged with B* / Q* labels.
+// Used by Phase 4 (Gemini chat router) and for documentation.
+
+export interface MetricDefinition {
+ id: string;
+ label: string;
+ broad_tags: string[];
+ operational_tags: string[];
+ required_files: string[];
+ view: "dashboard" | "programs" | "residents" | "facilities" | "comparison" | "insights";
+ description: string;
+ out_of_scope_examples?: string[];
+}
+
+export const METRIC_REGISTRY: MetricDefinition[] = [
+ // ── Operational metrics ──────────────────────────────────────────────────
+ {
+ id: "q01_top_programs",
+ label: "Top programs by completion rate",
+ broad_tags: ["B2"],
+ operational_tags: ["Q1"],
+ required_files: ["doc_programs"],
+ view: "programs",
+ description: "Ranks programs by completion rate for n≥10 enrollees",
+ },
+ {
+ id: "q02_facility_completion",
+ label: "Facility completion — raw vs mix-adjusted",
+ broad_tags: ["B4"],
+ operational_tags: ["Q2"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "facilities",
+ description: "Direct standardization on LSI bands with Wilson CI and cell-size guard (n≥5 per band)",
+ },
+ {
+ id: "q03_waitlist_depth",
+ label: "Waitlist depth in months to clear",
+ broad_tags: ["B4"],
+ operational_tags: ["Q3"],
+ required_files: ["doc_programs"],
+ view: "programs",
+ description: "Months to clear waitlist at current completion throughput; ∞ when throughput = 0",
+ },
+ {
+ id: "q04_near_release",
+ label: "Near-release engagement by facility",
+ broad_tags: ["B4"],
+ operational_tags: ["Q4"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "dashboard",
+ description: "Engagement breakdown for residents releasing within 24 months",
+ },
+ {
+ id: "q05_doc_ul_divergence",
+ label: "DOC vs UnlockEd completion gap by program",
+ broad_tags: ["B2"],
+ operational_tags: ["Q5"],
+ required_files: ["doc_programs", "program_class_enrollments"],
+ view: "comparison",
+ description: "Program-level completion rate divergence between DOC statewide and UL facilities",
+ },
+ {
+ id: "q06_second_program",
+ label: "Second program enrollment after first completion",
+ broad_tags: ["B2"],
+ operational_tags: ["Q6"],
+ required_files: ["doc_programs"],
+ view: "programs",
+ description: "Sustained engagement rate — completion → re-enrollment by facility and first program type",
+ },
+ {
+ id: "q07_lsi_custody_edu",
+ label: "Completion rate by LSI, custody, education",
+ broad_tags: ["B4"],
+ operational_tags: ["Q7"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "programs",
+ description: "Cross-cut completion rates by three demographic axes",
+ },
+ {
+ id: "q08_active_no_ul_session",
+ label: "Active in program, no UL session in 30 days",
+ broad_tags: ["B2"],
+ operational_tags: ["Q8"],
+ required_files: ["doc_programs", "user_session_tracking"],
+ view: "residents",
+ description: "Residents enrolled/active but with no platform session in trailing 30 days",
+ },
+ {
+ id: "q09_monthly_trends",
+ label: "Monthly enrollment and completion trends",
+ broad_tags: ["B2"],
+ operational_tags: ["Q9"],
+ required_files: ["doc_programs"],
+ view: "dashboard",
+ description: "12-month trailing trend, statewide and per-facility",
+ },
+ {
+ id: "q10_no_diploma_gap",
+ label: "No-diploma residents not in education",
+ broad_tags: ["B8"],
+ operational_tags: ["Q10"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "residents",
+ description: "Education gap for residents without high school diploma",
+ },
+ {
+ id: "q11_education_equity",
+ label: "Education equity by LSI band and offense",
+ broad_tags: ["B3", "B6"],
+ operational_tags: ["Q11"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "residents",
+ description: "Side-by-side education enrollee % vs population % by LSI band and offense category",
+ out_of_scope_examples: [
+ "Will high-LSI residents succeed after release?",
+ "Does education reduce recidivism for this group?",
+ ],
+ },
+ {
+ id: "q12_program_load",
+ label: "Per-resident active program load histogram",
+ broad_tags: ["B2"],
+ operational_tags: ["Q12"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "residents",
+ description: "Histogram of concurrent active enrollments per resident, by facility",
+ },
+ {
+ id: "q13_attendance_vs_completion",
+ label: "Attendance consistency vs completion (scatter)",
+ broad_tags: ["B5"],
+ operational_tags: ["Q13"],
+ required_files: ["doc_programs", "program_class_event_attendance"],
+ view: "programs",
+ description: "Scatter: avg sessions attended vs completion rate per program",
+ },
+ {
+ id: "q14_time_to_completion",
+ label: "Time-to-completion per program (median, IQR)",
+ broad_tags: ["B5"],
+ operational_tags: ["Q14"],
+ required_files: ["program_class_enrollments"],
+ view: "programs",
+ description: "Median days to completion with batch-timestamp filter applied",
+ },
+ {
+ id: "q15_disengagement_dropoff",
+ label: "Disengagement drop-off by program",
+ broad_tags: ["B1"],
+ operational_tags: ["Q15"],
+ required_files: ["doc_programs"],
+ view: "programs",
+ description: "Withdrawal count and avg days into program at exit",
+ },
+ {
+ id: "q16_facility_program_matrix",
+ label: "Facility × program completion heatmap",
+ broad_tags: ["B4"],
+ operational_tags: ["Q16"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "programs",
+ description: "Heatmap of completion rate delta from facility avg for each facility-program pair (n≥3)",
+ },
+ {
+ id: "q17_cohort_retention",
+ label: "Cohort retention curves (M through M+6)",
+ broad_tags: ["B1", "B2"],
+ operational_tags: ["Q17"],
+ required_files: ["doc_programs"],
+ view: "residents",
+ description: "% of first-education cohort still active or completed at each month offset",
+ out_of_scope_examples: [
+ "What happens to residents after they leave?",
+ "What is the 1-year post-release outcome?",
+ ],
+ },
+ // ── Broad metrics ────────────────────────────────────────────────────────
+ {
+ id: "b2_engagement_tiers",
+ label: "Engagement tier breakdown (B2)",
+ broad_tags: ["B2"],
+ operational_tags: [],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "dashboard",
+ description: "Active / Waitlisted / Completed-not-active / Never-engaged counts and percentages",
+ },
+ {
+ id: "b4_attention_scorecard",
+ label: "Attention scorecard — top facilities (B4)",
+ broad_tags: ["B4"],
+ operational_tags: [],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "dashboard",
+ description: "Composite-score flag for facilities needing immediate attention",
+ out_of_scope_examples: [
+ "Which individual residents need the most help?",
+ "Who is most likely to reoffend?",
+ ],
+ },
+ {
+ id: "b6_never_engaged",
+ label: "Never-engaged resident profile (B6)",
+ broad_tags: ["B6"],
+ operational_tags: [],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "residents",
+ description: "Demographic breakdown of never-engaged residents: LSI, education, offense, facility",
+ },
+ {
+ id: "b8_education_need",
+ label: "Education need vs enrolled (B8)",
+ broad_tags: ["B8"],
+ operational_tags: [],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "dashboard",
+ description: "Per-facility bar: residents needing education (no diploma, not enrolled) vs currently served",
+ },
+ {
+ id: "b1_education_journey_loss",
+ label: "Education journey loss (B1)",
+ broad_tags: ["B1"],
+ operational_tags: ["Q15", "Q17", "Q10"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "insights",
+ description: "Funnel from education enrollment through withdrawal; early/mid/late dropout breakdown; cohort retention curve",
+ },
+ {
+ id: "b2_engagement_overview",
+ label: "Engagement overview (B2)",
+ broad_tags: ["B2"],
+ operational_tags: ["Q4", "Q9", "Q1"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "insights",
+ description: "Engagement tier donut, top-5 programs, near-release breakdown",
+ },
+ {
+ id: "b3_equity_summary",
+ label: "Education equity summary (B3)",
+ broad_tags: ["B3"],
+ operational_tags: ["Q11", "Q7", "Q10"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "insights",
+ description: "LSI and offense-category representation in education vs facility population",
+ },
+ {
+ id: "b5_qol_grid",
+ label: "Quality of Life signal grid (B5)",
+ broad_tags: ["B5"],
+ operational_tags: ["Q6", "Q13", "Q14", "Q4"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "insights",
+ description: "4-dimension QoL grid (access, persistence, mastery, engagement quality); UL files needed for full coverage",
+ },
+ {
+ id: "b6_never_engaged_profile",
+ label: "Never-engaged population profile (B6)",
+ broad_tags: ["B6"],
+ operational_tags: [],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "insights",
+ description: "Count, % of population, LSI distribution, facility breakdown of never-engaged residents",
+ },
+ {
+ id: "b7_facility_portraits",
+ label: "Facility 'average learner' portraits (B7)",
+ broad_tags: ["B7"],
+ operational_tags: ["Q12", "Q6", "Q17"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "insights",
+ description: "Per-facility: median programs enrolled, completion rate, 90-day retention, months-to-release at first enrollment",
+ },
+ {
+ id: "b8_need_vs_throughput",
+ label: "Need vs throughput overlay (B8)",
+ broad_tags: ["B8"],
+ operational_tags: ["Q3", "Q10", "Q16"],
+ required_files: ["doc_residents", "doc_programs"],
+ view: "insights",
+ description: "Per-facility: residents needing education vs currently served; 2×2 need/throughput quadrant",
+ },
+];
+
+export const METRIC_BY_ID = new Map(
+ METRIC_REGISTRY.map((m) => [m.id, m])
+);
+
+export interface RefusalExample {
+ question: string;
+ reason: string;
+}
+
+export interface SchemaRegistry {
+ metrics: MetricDefinition[];
+ enums: Record;
+ refusal_examples: RefusalExample[];
+}
+
+export const SCHEMA_REGISTRY: SchemaRegistry = {
+ metrics: METRIC_REGISTRY,
+ enums: {},
+ refusal_examples: [
+ { question: "Will program X reduce recidivism?", reason: "Recidivism data is not in scope — only in-facility program activity is available." },
+ { question: "Which residents will succeed?", reason: "No per-resident predictions — only aggregated metrics are computed." },
+ { question: "What happens after release?", reason: "Post-release outcome data is not available in DOC exports." },
+ { question: "Which resident should I contact?", reason: "Individual resident names and DOC numbers are never included in metric outputs." },
+ { question: "Is the warden doing a good job?", reason: "No performance evaluation data is in scope." },
+ ],
+};
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
new file mode 100644
index 0000000..1025f72
--- /dev/null
+++ b/src/lib/analytics.ts
@@ -0,0 +1,1881 @@
+import type {
+ ParsedData,
+ ProgramCompletionStat,
+ FacilityCompletionStat,
+ NearReleaseStat,
+ WaitlistStat,
+ LsiBandCompletionStat,
+ CustodyCompletionStat,
+ EducationCompletionStat,
+ NoDiplomaEnrollmentStat,
+ MonthlyTrend,
+ NeverEngagedResident,
+ EnrollmentRecord,
+ DocUlProgramGap,
+ EducationEquityResult,
+ ProgramLoadStat,
+ FacilityProgramCell,
+ CohortRetentionPoint,
+ EngagementTierStat,
+ AttentionFlag,
+ EducationNeedStat,
+ JoinCoverage,
+ Q8InactiveUser,
+ AttendanceStat,
+ TimeToCompletionStat,
+ DisengagementStat,
+ QoLDimension,
+ QoLStatus,
+ FacilityPortrait,
+ IncidentStats,
+ CredentialStats,
+ WorkAssignmentStats,
+ CasePlanStats,
+ HousingStats,
+ HousingMoveRecord,
+} from "../types";
+import { monthKey, monthsSince } from "./ingest";
+import analyticsConfig from "../config/analytics.json";
+import thresholds from "../config/thresholds.json";
+
+// ── Private helpers ───────────────────────────────────────────────────────────
+
+function computeWilsonCI(completed: number, enrolled: number): [number, number] {
+ if (enrolled === 0) return [0, 0];
+ const z = analyticsConfig.wilsonCI.zScore;
+ const p = completed / enrolled;
+ const n = enrolled;
+ const center = (p + z * z / (2 * n)) / (1 + z * z / n);
+ const margin = (z / (1 + z * z / n)) * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n));
+ return [
+ Math.max(0, Math.round((center - margin) * 1000) / 10),
+ Math.min(100, Math.round((center + margin) * 1000) / 10),
+ ];
+}
+
+// ── Q1: Top programs by completion rate (min 10 enrollees) ───────────────────
+
+export function computeTopProgramsByCompletion(
+ data: ParsedData,
+ minEnrollees = analyticsConfig.topPrograms.minEnrollees,
+ topN = analyticsConfig.topPrograms.topN
+): ProgramCompletionStat[] {
+ const map = new Map; completed: number; active: number; waitlisted: number; dropped: number }>();
+
+ for (const e of data.enrollments) {
+ if (!map.has(e.programId)) {
+ map.set(e.programId, { name: e.programName, type: e.programType, enrolled: new Set(), completed: 0, active: 0, waitlisted: 0, dropped: 0 });
+ }
+ const s = map.get(e.programId)!;
+ s.enrolled.add(e.userId);
+ if (e.status === "Completed") s.completed++;
+ else if (e.status === "Active") s.active++;
+ else if (e.status === "Waitlisted") s.waitlisted++;
+ else if (e.status === "Dropped") s.dropped++;
+ }
+
+ return Array.from(map.entries())
+ .map(([id, s]) => ({
+ programId: id,
+ programName: s.name,
+ programType: s.type,
+ enrolled: s.enrolled.size,
+ completed: s.completed,
+ active: s.active,
+ waitlisted: s.waitlisted,
+ dropped: s.dropped,
+ completionRate: s.enrolled.size > 0 ? (s.completed / s.enrolled.size) * 100 : 0,
+ }))
+ .filter((s) => s.enrolled >= minEnrollees)
+ .sort((a, b) => b.completionRate - a.completionRate)
+ .slice(0, topN);
+}
+
+// ── Q2: Facility completion rates — direct standardization ───────────────────
+
+export function computeFacilityCompletion(data: ParsedData): FacilityCompletionStat[] {
+ const LSI_BANDS = ["Low", "Moderate", "High", "Maximum"] as const;
+
+ // Build resident lookup: userId → resident (for join enrollment→LSI band)
+ const residentById = new Map();
+ for (const r of data.residents) residentById.set(r.id, r);
+
+ // Statewide completions + enrollments per LSI band
+ const swEnrolled = new Map();
+ const swCompleted = new Map();
+ for (const b of LSI_BANDS) { swEnrolled.set(b, 0); swCompleted.set(b, 0); }
+
+ for (const e of data.enrollments) {
+ const band = residentById.get(e.userId)?.lsiBand;
+ if (!band || !LSI_BANDS.includes(band as typeof LSI_BANDS[number])) continue;
+ swEnrolled.set(band, (swEnrolled.get(band) ?? 0) + 1);
+ if (e.status === "Completed") swCompleted.set(band, (swCompleted.get(band) ?? 0) + 1);
+ }
+
+ // Statewide resident count per band (for mix proportions)
+ const swResidentsByBand = new Map();
+ for (const b of LSI_BANDS) swResidentsByBand.set(b, 0);
+ for (const r of data.residents) {
+ if (LSI_BANDS.includes(r.lsiBand as typeof LSI_BANDS[number]))
+ swResidentsByBand.set(r.lsiBand, (swResidentsByBand.get(r.lsiBand) ?? 0) + 1);
+ }
+
+ // Build per-facility enrollment counts per LSI band
+ const facEnrolledByBand = new Map>();
+ const facCompletedByBand = new Map>();
+ const facTotalEnrolled = new Map>();
+ const facTotalCompleted = new Map();
+ const facIdToCode = new Map();
+
+ for (const f of data.facilities) {
+ facEnrolledByBand.set(f.id, new Map(LSI_BANDS.map(b => [b, 0])));
+ facCompletedByBand.set(f.id, new Map(LSI_BANDS.map(b => [b, 0])));
+ facTotalEnrolled.set(f.id, new Set());
+ facTotalCompleted.set(f.id, 0);
+ facIdToCode.set(f.id, f.code);
+ }
+
+ for (const e of data.enrollments) {
+ const res = residentById.get(e.userId);
+ if (!res) continue;
+ const band = res.lsiBand;
+ const fid = e.facilityId;
+ if (!facEnrolledByBand.has(fid)) continue;
+
+ facTotalEnrolled.get(fid)!.add(e.userId);
+ if (e.status === "Completed") facTotalCompleted.set(fid, (facTotalCompleted.get(fid) ?? 0) + 1);
+
+ if (LSI_BANDS.includes(band as typeof LSI_BANDS[number])) {
+ facEnrolledByBand.get(fid)!.set(band, (facEnrolledByBand.get(fid)!.get(band) ?? 0) + 1);
+ if (e.status === "Completed")
+ facCompletedByBand.get(fid)!.set(band, (facCompletedByBand.get(fid)!.get(band) ?? 0) + 1);
+ }
+ }
+
+ const result: FacilityCompletionStat[] = [];
+
+ for (const f of data.facilities) {
+ const enrolled = facTotalEnrolled.get(f.id)!.size;
+ const completed = facTotalCompleted.get(f.id) ?? 0;
+ const rawRate = enrolled > 0 ? (completed / enrolled) * 100 : 0;
+ const [wilsonLow, wilsonHigh] = computeWilsonCI(completed, enrolled);
+ const facResidents = data.residents.filter(r => r.facilityId === f.id);
+
+ // Direct standardization
+ const validBands = LSI_BANDS.filter(b => (facEnrolledByBand.get(f.id)!.get(b) ?? 0) >= analyticsConfig.mixAdjustment.lsiBandMinEnrollees);
+ const insufficient = validBands.length < analyticsConfig.mixAdjustment.validBandsRequired;
+
+ let mixAdjustedRate = rawRate;
+ let mixAdjDelta = 0;
+ let mixAdjSignificant = false;
+
+ if (!insufficient) {
+ // Statewide mix renormalized over validBands
+ const totalSwValid = validBands.reduce((sum, b) => sum + (swResidentsByBand.get(b) ?? 0), 0);
+ let adjusted = 0;
+ for (const b of validBands) {
+ const swMix = totalSwValid > 0 ? (swResidentsByBand.get(b) ?? 0) / totalSwValid : 0;
+ const facEnr = facEnrolledByBand.get(f.id)!.get(b) ?? 0;
+ const facComp = facCompletedByBand.get(f.id)!.get(b) ?? 0;
+ const facBandRate = facEnr > 0 ? facComp / facEnr : 0;
+ adjusted += swMix * facBandRate * 100;
+ }
+ mixAdjustedRate = Math.round(adjusted * 10) / 10;
+ mixAdjDelta = Math.round((mixAdjustedRate - rawRate) * 10) / 10;
+ // Significant if adjusted rate falls outside Wilson CI of raw rate
+ mixAdjSignificant = mixAdjustedRate < wilsonLow || mixAdjustedRate > wilsonHigh;
+ }
+
+ result.push({
+ facilityCode: f.code,
+ facilityName: f.name,
+ enrolled,
+ completed,
+ completionRate: Math.round(rawRate * 10) / 10,
+ mixAdjustedRate,
+ mixAdjustedInsufficient: insufficient,
+ wilsonCILow: wilsonLow,
+ wilsonCIHigh: wilsonHigh,
+ mixAdjDelta,
+ mixAdjSignificant,
+ residentCount: facResidents.length,
+ });
+ }
+
+ return result.sort((a, b) => b.completionRate - a.completionRate);
+}
+
+// ── Q3: Waitlists relative to throughput ──────────────────────────────────────
+
+export function computeWaitlistStats(data: ParsedData): WaitlistStat[] {
+ const map = new Map();
+
+ for (const e of data.enrollments) {
+ if (!map.has(e.programId)) {
+ map.set(e.programId, { name: e.programName, type: e.programType, waitlisted: 0, completedDates: [] });
+ }
+ const s = map.get(e.programId)!;
+ if (e.status === "Waitlisted") s.waitlisted++;
+ if (e.status === "Completed" && e.completionDate) s.completedDates.push(e.completionDate);
+ }
+
+ return Array.from(map.values())
+ .map((s) => {
+ const cutoff = new Date();
+ cutoff.setMonth(cutoff.getMonth() - 12);
+ const recent = s.completedDates.filter((d) => d >= cutoff).length;
+ const completionsPerMonth = recent / 12;
+ const waitlistMonths = completionsPerMonth > 0 ? s.waitlisted / completionsPerMonth : s.waitlisted > 0 ? analyticsConfig.infWaitlistSentinel : 0;
+ return { programName: s.name, programType: s.type, waitlisted: s.waitlisted, completionsPerMonth, waitlistMonths };
+ })
+ .filter((s) => s.waitlisted > 0)
+ .sort((a, b) => b.waitlistMonths - a.waitlistMonths);
+}
+
+// ── Q4: Near-release engagement by facility ───────────────────────────────────
+
+export function computeNearRelease(data: ParsedData, withinMonths = analyticsConfig.nearReleaseMonths): NearReleaseStat[] {
+ const nearRelease = data.residents.filter(
+ (r) => r.monthsToRelease !== null && r.monthsToRelease >= 0 && r.monthsToRelease <= withinMonths
+ );
+
+ const activeUsers = new Set();
+ const completedUsers = new Set();
+ const waitlistedUsers = new Set();
+ const engagedUsers = new Set();
+
+ for (const e of data.enrollments) {
+ if (e.status === "Active") activeUsers.add(e.userId);
+ else if (e.status === "Completed") completedUsers.add(e.userId);
+ else if (e.status === "Waitlisted") waitlistedUsers.add(e.userId);
+ if (e.status !== "Dropped") engagedUsers.add(e.userId);
+ }
+
+ const facilityMap = new Map();
+ for (const f of data.facilities) {
+ facilityMap.set(f.code, { facilityCode: f.code, total: 0, active: 0, completedNotActive: 0, waitlisted: 0, notEngaged: 0 });
+ }
+
+ for (const r of nearRelease) {
+ const s = facilityMap.get(r.facilityCode);
+ if (!s) continue;
+ s.total++;
+ const isActive = activeUsers.has(r.id);
+ const isCompleted = completedUsers.has(r.id) && !isActive;
+ const isWaitlisted = waitlistedUsers.has(r.id) && !isActive && !isCompleted;
+
+ if (isActive) s.active++;
+ else if (isCompleted) s.completedNotActive++;
+ else if (isWaitlisted) s.waitlisted++;
+ else s.notEngaged++;
+ }
+
+ return Array.from(facilityMap.values()).filter((s) => s.total > 0);
+}
+
+// ── Q6: Second program enrollment after first completion ──────────────────────
+
+export interface SecondProgramStat {
+ facilityCode: string;
+ programType: string;
+ completedFirst: number;
+ enrolledSecond: number;
+ rate: number;
+}
+
+export function computeSecondProgramRate(data: ParsedData): SecondProgramStat[] {
+ const completedByUser = new Map();
+ for (const e of data.enrollments) {
+ if (e.status === "Completed") {
+ if (!completedByUser.has(e.userId)) {
+ const res = data.residents.find((r) => r.id === e.userId);
+ completedByUser.set(e.userId, { facilityCode: res?.facilityCode ?? "UNK", programTypes: [] });
+ }
+ completedByUser.get(e.userId)!.programTypes.push(e.programType);
+ }
+ }
+
+ const enrollmentCount = new Map();
+ for (const e of data.enrollments) {
+ enrollmentCount.set(e.userId, (enrollmentCount.get(e.userId) ?? 0) + 1);
+ }
+
+ const map = new Map();
+
+ for (const [userId, info] of completedByUser) {
+ const key = `${info.facilityCode}::${info.programTypes[0] ?? "Unknown"}`;
+ if (!map.has(key)) map.set(key, { completedFirst: 0, enrolledSecond: 0 });
+ const s = map.get(key)!;
+ s.completedFirst++;
+ if ((enrollmentCount.get(userId) ?? 0) >= 2) s.enrolledSecond++;
+ }
+
+ return Array.from(map.entries()).map(([key, s]) => {
+ const [facilityCode, programType] = key.split("::");
+ return { facilityCode, programType, completedFirst: s.completedFirst, enrolledSecond: s.enrolledSecond, rate: s.completedFirst > 0 ? (s.enrolledSecond / s.completedFirst) * 100 : 0 };
+ }).sort((a, b) => b.rate - a.rate);
+}
+
+// ── Q7: Completion rate by LSI, custody, education ────────────────────────────
+
+export function computeCompletionByLsi(data: ParsedData): LsiBandCompletionStat[] {
+ const map = new Map();
+ const residentMap = new Map();
+ for (const r of data.residents) residentMap.set(r.id, r.lsiBand);
+
+ for (const e of data.enrollments) {
+ const band = residentMap.get(e.userId) ?? "Unknown";
+ if (!map.has(band)) map.set(band, { enrolled: 0, completed: 0 });
+ const s = map.get(band)!;
+ s.enrolled++;
+ if (e.status === "Completed") s.completed++;
+ }
+
+ const order = ["Low", "Moderate", "High", "Maximum"];
+ return order.filter((b) => map.has(b)).map((b) => {
+ const s = map.get(b)!;
+ return { band: b, enrolled: s.enrolled, completed: s.completed, completionRate: (s.completed / s.enrolled) * 100 };
+ });
+}
+
+export function computeCompletionByCustody(data: ParsedData): CustodyCompletionStat[] {
+ const map = new Map();
+ const residentMap = new Map();
+ for (const r of data.residents) residentMap.set(r.id, r.custodyLevel);
+
+ for (const e of data.enrollments) {
+ const level = residentMap.get(e.userId) ?? "Unknown";
+ if (!map.has(level)) map.set(level, { enrolled: 0, completed: 0 });
+ const s = map.get(level)!;
+ s.enrolled++;
+ if (e.status === "Completed") s.completed++;
+ }
+
+ return Array.from(map.entries())
+ .map(([level, s]) => ({ level, enrolled: s.enrolled, completed: s.completed, completionRate: (s.completed / s.enrolled) * 100 }))
+ .sort((a, b) => b.completionRate - a.completionRate);
+}
+
+export function computeCompletionByEducation(data: ParsedData): EducationCompletionStat[] {
+ const map = new Map();
+ const residentMap = new Map();
+ for (const r of data.residents) residentMap.set(r.id, r.educationLevel);
+
+ for (const e of data.enrollments) {
+ const level = residentMap.get(e.userId) ?? "Unknown";
+ if (!map.has(level)) map.set(level, { enrolled: 0, completed: 0 });
+ const s = map.get(level)!;
+ s.enrolled++;
+ if (e.status === "Completed") s.completed++;
+ }
+
+ const order = ["No HS diploma", "HS/GED", "Some college+"];
+ return order.filter((l) => map.has(l)).map((l) => {
+ const s = map.get(l)!;
+ return { level: l, enrolled: s.enrolled, completed: s.completed, completionRate: (s.completed / s.enrolled) * 100 };
+ });
+}
+
+// ── Q9: Monthly trends ────────────────────────────────────────────────────────
+
+export function computeMonthlyTrends(data: ParsedData): { statewide: MonthlyTrend[]; byFacility: Map } {
+ const statewide = new Map();
+ const byFacility = new Map>();
+
+ const getOrCreate = (map: Map, month: string, facilityCode?: string): MonthlyTrend => {
+ if (!map.has(month)) map.set(month, { month, newEnrollments: 0, activeParticipants: 0, completions: 0, facilityCode });
+ return map.get(month)!;
+ };
+
+ for (const e of data.enrollments) {
+ const fac = e.facilityCode ?? "UNK";
+ if (!byFacility.has(fac)) byFacility.set(fac, new Map());
+ const facMap = byFacility.get(fac)!;
+
+ if (e.enrolledDate) {
+ const m = monthKey(e.enrolledDate);
+ getOrCreate(statewide, m).newEnrollments++;
+ getOrCreate(facMap, m, fac).newEnrollments++;
+ }
+ if (e.status === "Active") {
+ const m = monthKey(new Date());
+ getOrCreate(statewide, m).activeParticipants++;
+ getOrCreate(byFacility.get(fac)!, m, fac).activeParticipants++;
+ }
+ if (e.status === "Completed" && e.completionDate) {
+ const m = monthKey(e.completionDate);
+ getOrCreate(statewide, m).completions++;
+ getOrCreate(facMap, m, fac).completions++;
+ }
+ }
+
+ const sortedStatewide = Array.from(statewide.values()).sort((a, b) => a.month.localeCompare(b.month));
+ const sortedByFacility = new Map();
+ for (const [fac, map] of byFacility) {
+ sortedByFacility.set(fac, Array.from(map.values()).sort((a, b) => a.month.localeCompare(b.month)));
+ }
+
+ return { statewide: sortedStatewide, byFacility: sortedByFacility };
+}
+
+// ── Q10: No-diploma residents not in education ────────────────────────────────
+
+export function computeNoDiplomaGap(data: ParsedData): { overall: { noProgram: number; otherOnly: number; inEducation: number; total: number }; byFacility: NoDiplomaEnrollmentStat[] } {
+ const noDiploma = data.residents.filter((r) => r.educationLevel === "No HS diploma");
+
+ const educationEnrolled = new Set();
+ const anyEnrolled = new Set();
+ for (const e of data.enrollments) {
+ if (e.status === "Active" || e.status === "Completed") {
+ anyEnrolled.add(e.userId);
+ if (e.programType === "Education") educationEnrolled.add(e.userId);
+ }
+ }
+
+ const byFacility = new Map();
+ let totalNoProgram = 0, totalOtherOnly = 0, totalInEducation = 0;
+
+ for (const r of noDiploma) {
+ if (!byFacility.has(r.facilityCode)) {
+ byFacility.set(r.facilityCode, { facilityCode: r.facilityCode, total: 0, inEducation: 0, otherOnly: 0, noProgram: 0 });
+ }
+ const s = byFacility.get(r.facilityCode)!;
+ s.total++;
+
+ if (educationEnrolled.has(r.id)) { s.inEducation++; totalInEducation++; }
+ else if (anyEnrolled.has(r.id)) { s.otherOnly++; totalOtherOnly++; }
+ else { s.noProgram++; totalNoProgram++; }
+ }
+
+ return {
+ overall: { noProgram: totalNoProgram, otherOnly: totalOtherOnly, inEducation: totalInEducation, total: noDiploma.length },
+ byFacility: Array.from(byFacility.values()).sort((a, b) => b.total - a.total),
+ };
+}
+
+// ── Q11: Education equity by LSI band and offense category ───────────────────
+
+export function computeEducationEquity(data: ParsedData): EducationEquityResult {
+ const residentById = new Map();
+ for (const r of data.residents) residentById.set(r.id, r);
+
+ const educationEnrolledUsers = new Set();
+ for (const e of data.enrollments) {
+ if (e.programType === "Education" && e.status !== "Dropped") {
+ educationEnrolledUsers.add(e.userId);
+ }
+ }
+
+ const lsiBands = ["Low", "Moderate", "High", "Maximum"];
+ const offenseCats = ["Person", "Property", "Drug", "Sex/Registry", "Other"];
+
+ const lsiEduCount = new Map(lsiBands.map(b => [b, 0]));
+ const lsiPopCount = new Map(lsiBands.map(b => [b, 0]));
+ const offEduCount = new Map(offenseCats.map(c => [c, 0]));
+ const offPopCount = new Map(offenseCats.map(c => [c, 0]));
+
+ for (const res of data.residents) {
+ if (lsiBands.includes(res.lsiBand)) {
+ lsiPopCount.set(res.lsiBand, (lsiPopCount.get(res.lsiBand) ?? 0) + 1);
+ if (educationEnrolledUsers.has(res.id))
+ lsiEduCount.set(res.lsiBand, (lsiEduCount.get(res.lsiBand) ?? 0) + 1);
+ }
+ const cat = offenseCats.includes(res.offenseCategory) ? res.offenseCategory : "Other";
+ offPopCount.set(cat, (offPopCount.get(cat) ?? 0) + 1);
+ if (educationEnrolledUsers.has(res.id))
+ offEduCount.set(cat, (offEduCount.get(cat) ?? 0) + 1);
+ }
+
+ const totalPop = data.residents.length;
+ const totalEdu = educationEnrolledUsers.size;
+
+ const lsi = lsiBands.map(b => ({
+ label: b,
+ educationPct: totalEdu > 0 ? Math.round((lsiEduCount.get(b) ?? 0) / totalEdu * 1000) / 10 : 0,
+ populationPct: totalPop > 0 ? Math.round((lsiPopCount.get(b) ?? 0) / totalPop * 1000) / 10 : 0,
+ }));
+
+ const offense = offenseCats.map(c => ({
+ label: c,
+ educationPct: totalEdu > 0 ? Math.round((offEduCount.get(c) ?? 0) / totalEdu * 1000) / 10 : 0,
+ populationPct: totalPop > 0 ? Math.round((offPopCount.get(c) ?? 0) / totalPop * 1000) / 10 : 0,
+ }));
+
+ return { lsi, offense };
+}
+
+// ── Q12: Per-resident program load ────────────────────────────────────────────
+
+export function computeProgramLoad(data: ParsedData): ProgramLoadStat[] {
+ const activeByResident = new Map();
+ for (const e of data.enrollments) {
+ if (e.status === "Active") {
+ activeByResident.set(e.userId, (activeByResident.get(e.userId) ?? 0) + 1);
+ }
+ }
+
+ const byFacility = new Map();
+ for (const f of data.facilities) {
+ byFacility.set(f.code, { load0: 0, load1: 0, load2: 0, load3: 0, load4plus: 0, total: 0 });
+ }
+
+ for (const res of data.residents) {
+ const s = byFacility.get(res.facilityCode);
+ if (!s) continue;
+ const load = activeByResident.get(res.id) ?? 0;
+ s.total++;
+ if (load === 0) s.load0++;
+ else if (load === 1) s.load1++;
+ else if (load === 2) s.load2++;
+ else if (load === 3) s.load3++;
+ else s.load4plus++;
+ }
+
+ return Array.from(byFacility.entries())
+ .map(([code, s]) => ({ facilityCode: code, ...s, totalResidents: s.total }))
+ .filter(s => s.totalResidents > 0)
+ .sort((a, b) => a.facilityCode.localeCompare(b.facilityCode));
+}
+
+// ── Q16: Facility × program completion matrix ─────────────────────────────────
+
+export function computeFacilityProgramMatrix(data: ParsedData): FacilityProgramCell[] {
+ type Key = string;
+ const pairData = new Map();
+
+ for (const e of data.enrollments) {
+ const key: Key = `${e.facilityCode}::${e.programName}`;
+ if (!pairData.has(key)) {
+ pairData.set(key, { facilityCode: e.facilityCode, programName: e.programName, programType: e.programType, enrolled: 0, completed: 0 });
+ }
+ const s = pairData.get(key)!;
+ s.enrolled++;
+ if (e.status === "Completed") s.completed++;
+ }
+
+ // Facility overall completion rates
+ const facilityRates = new Map();
+ const facEnr = new Map();
+ const facComp = new Map();
+ for (const [, s] of pairData) {
+ facEnr.set(s.facilityCode, (facEnr.get(s.facilityCode) ?? 0) + s.enrolled);
+ facComp.set(s.facilityCode, (facComp.get(s.facilityCode) ?? 0) + s.completed);
+ }
+ for (const [fac, enr] of facEnr) {
+ facilityRates.set(fac, enr > 0 ? ((facComp.get(fac) ?? 0) / enr) * 100 : 0);
+ }
+
+ return Array.from(pairData.values()).map(s => {
+ const insufficient = s.enrolled < analyticsConfig.programPerformanceMatrix.minEnrolledPerCell;
+ const completionRate = insufficient ? 0 : (s.completed / s.enrolled) * 100;
+ const facAvg = facilityRates.get(s.facilityCode) ?? 0;
+ return {
+ facilityCode: s.facilityCode,
+ programName: s.programName,
+ programType: s.programType,
+ enrolled: s.enrolled,
+ completed: s.completed,
+ completionRate: Math.round(completionRate * 10) / 10,
+ deltaFromFacilityAvg: insufficient ? 0 : Math.round((completionRate - facAvg) * 10) / 10,
+ insufficient,
+ };
+ }).sort((a, b) => a.facilityCode.localeCompare(b.facilityCode) || a.programName.localeCompare(b.programName));
+}
+
+// ── Q17: Cohort retention curves ──────────────────────────────────────────────
+
+export function computeCohortRetention(data: ParsedData): CohortRetentionPoint[] {
+ const residentById = new Map();
+ for (const r of data.residents) residentById.set(r.id, r);
+
+ // Find first education enrollment per resident
+ const firstEduEnrollment = new Map();
+ for (const e of data.enrollments) {
+ if (e.programType !== "Education" || !e.enrolledDate) continue;
+ const existing = firstEduEnrollment.get(e.userId);
+ if (!existing || e.enrolledDate < existing.date) {
+ firstEduEnrollment.set(e.userId, { date: e.enrolledDate, facilityCode: e.facilityCode });
+ }
+ }
+
+ // Group residents by cohort month (month of first education enrollment)
+ const cohortsByMonth = new Map>();
+ for (const [userId, info] of firstEduEnrollment) {
+ const cm = monthKey(info.date);
+ if (!cohortsByMonth.has(cm)) cohortsByMonth.set(cm, []);
+ cohortsByMonth.get(cm)!.push({ userId, facilityCode: info.facilityCode });
+ }
+
+ // Keep last 6 cohort months
+ const sortedMonths = Array.from(cohortsByMonth.keys()).sort().slice(-analyticsConfig.cohortRetention.recentMonthsSlice);
+
+ const results: CohortRetentionPoint[] = [];
+ const allFacilities = [...new Set(data.residents.map(r => r.facilityCode))];
+
+ for (const cohortMonth of sortedMonths) {
+ const cohortMembers = cohortsByMonth.get(cohortMonth) ?? [];
+ if (cohortMembers.length === 0) continue;
+
+ // Parse cohort start
+ const [cy, cm] = cohortMonth.split("-").map(Number);
+
+ for (let offset = 0; offset <= analyticsConfig.cohortRetention.offsetWindow; offset++) {
+ const cutoffDate = new Date(cy, cm - 1 + offset, 1);
+
+ // Count retained statewide
+ let retainedStatewide = 0;
+ for (const { userId } of cohortMembers) {
+ if (isRetainedAtOffset(userId, cutoffDate, data)) retainedStatewide++;
+ }
+ results.push({
+ cohortMonth,
+ monthOffset: offset,
+ facilityCode: "statewide",
+ retentionPct: cohortMembers.length > 0 ? Math.round(retainedStatewide / cohortMembers.length * 1000) / 10 : 0,
+ cohortSize: cohortMembers.length,
+ });
+
+ // Per-facility
+ for (const fac of allFacilities) {
+ const facMembers = cohortMembers.filter(m => m.facilityCode === fac);
+ if (facMembers.length < analyticsConfig.cohortRetention.minFacilityCohortSize) continue; // skip tiny cohorts per facility
+ let retained = 0;
+ for (const { userId } of facMembers) {
+ if (isRetainedAtOffset(userId, cutoffDate, data)) retained++;
+ }
+ results.push({
+ cohortMonth,
+ monthOffset: offset,
+ facilityCode: fac,
+ retentionPct: Math.round(retained / facMembers.length * 1000) / 10,
+ cohortSize: facMembers.length,
+ });
+ }
+ }
+ }
+
+ return results;
+}
+
+function isRetainedAtOffset(userId: string, cutoffDate: Date, data: ParsedData): boolean {
+ for (const e of data.enrollments) {
+ if (e.userId !== userId || e.programType !== "Education") continue;
+ if (e.status === "Active") return true;
+ if (e.status === "Completed" && e.completionDate && e.completionDate >= cutoffDate) return true;
+ if (e.status === "Completed" && !e.completionDate) return true;
+ }
+ return false;
+}
+
+// ── Never-engaged residents ───────────────────────────────────────────────────
+
+export function computeNeverEngaged(data: ParsedData): NeverEngagedResident[] {
+ const engagedUsers = new Set();
+ for (const e of data.enrollments) {
+ if (e.status !== "Dropped") engagedUsers.add(e.userId);
+ }
+
+ return data.residents
+ .filter((r) => !engagedUsers.has(r.id))
+ .map((r) => ({
+ userId: r.id,
+ facilityCode: r.facilityCode,
+ lsiBand: r.lsiBand,
+ custodyLevel: r.custodyLevel,
+ educationLevel: r.educationLevel,
+ offenseCategory: r.offenseCategory,
+ monthsSinceAdmission: r.admissionDate ? monthsSince(r.admissionDate) : null,
+ residentType: r.residentType,
+ }));
+}
+
+// ── Summary stats (for dashboard header) ──────────────────────────────────────
+
+export interface SummaryStats {
+ totalResidents: number;
+ totalEnrolled: number;
+ totalCompleted: number;
+ totalWaitlisted: number;
+ totalNeverEngaged: number;
+ overallCompletionRate: number;
+ facilities: number;
+}
+
+export function computeSummary(data: ParsedData): SummaryStats {
+ const enrolled = new Set(data.enrollments.filter((e) => e.status !== "Dropped").map((e) => e.userId));
+ const completed = new Set(data.enrollments.filter((e) => e.status === "Completed").map((e) => e.userId));
+ const waitlisted = new Set(data.enrollments.filter((e) => e.status === "Waitlisted").map((e) => e.userId));
+ const neverEngaged = data.residents.filter((r) => !enrolled.has(r.id)).length;
+ const totalEnrollments = data.enrollments.filter((e) => e.status !== "Dropped").length;
+ const totalCompletions = data.enrollments.filter((e) => e.status === "Completed").length;
+
+ return {
+ totalResidents: data.residents.length,
+ totalEnrolled: enrolled.size,
+ totalCompleted: completed.size,
+ totalWaitlisted: waitlisted.size,
+ totalNeverEngaged: neverEngaged,
+ overallCompletionRate: totalEnrollments > 0 ? (totalCompletions / totalEnrollments) * 100 : 0,
+ facilities: data.facilities.length,
+ };
+}
+
+// ── B2: Engagement tier breakdown ─────────────────────────────────────────────
+
+export function computeEngagementTiers(data: ParsedData): EngagementTierStat[] {
+ const active = new Set();
+ const waitlisted = new Set();
+ const completed = new Set();
+
+ for (const e of data.enrollments) {
+ if (e.status === "Active") active.add(e.userId);
+ if (e.status === "Waitlisted") waitlisted.add(e.userId);
+ if (e.status === "Completed") completed.add(e.userId);
+ }
+
+ const total = data.residents.length;
+
+ const activeCount = data.residents.filter(r => active.has(r.id)).length;
+ const waitlistedCount = data.residents.filter(r => !active.has(r.id) && waitlisted.has(r.id)).length;
+ const completedNotActiveCount = data.residents.filter(r => !active.has(r.id) && !waitlisted.has(r.id) && completed.has(r.id)).length;
+ const neverEngagedCount = data.residents.filter(r => !active.has(r.id) && !waitlisted.has(r.id) && !completed.has(r.id)).length;
+
+ return [
+ { tier: "Active", label: "Active", count: activeCount, pct: total > 0 ? Math.round(activeCount / total * 1000) / 10 : 0 },
+ { tier: "Waitlisted", label: "Waitlisted", count: waitlistedCount, pct: total > 0 ? Math.round(waitlistedCount / total * 1000) / 10 : 0 },
+ { tier: "CompletedNotActive", label: "Completed / Not Active", count: completedNotActiveCount, pct: total > 0 ? Math.round(completedNotActiveCount / total * 1000) / 10 : 0 },
+ { tier: "NeverEngaged", label: "Never Engaged", count: neverEngagedCount, pct: total > 0 ? Math.round(neverEngagedCount / total * 1000) / 10 : 0 },
+ ];
+}
+
+// ── B4: Attention scorecard ───────────────────────────────────────────────────
+
+export function computeAttentionFlags(data: ParsedData): AttentionFlag[] {
+ const facilityInfo = new Map();
+ for (const f of data.facilities) facilityInfo.set(f.code, { name: f.name });
+
+ const engaged = new Set();
+ for (const e of data.enrollments) if (e.status !== "Dropped") engaged.add(e.userId);
+
+ const resByFacility = new Map();
+ for (const r of data.residents) {
+ const list = resByFacility.get(r.facilityCode) ?? [];
+ list.push(r);
+ resByFacility.set(r.facilityCode, list);
+ }
+
+ const nearReleaseUnengaged = new Map();
+ for (const r of data.residents) {
+ if (r.monthsToRelease !== null && r.monthsToRelease >= 0
+ && r.monthsToRelease <= analyticsConfig.nearReleaseMonths && !engaged.has(r.id)) {
+ nearReleaseUnengaged.set(r.facilityCode, (nearReleaseUnengaged.get(r.facilityCode) ?? 0) + 1);
+ }
+ }
+
+ const infWaitlistFacilities = new Set();
+ const waitlist = computeWaitlistStats(data);
+ for (const ws of waitlist) {
+ if (ws.waitlistMonths >= analyticsConfig.infWaitlistSentinel) {
+ for (const e of data.enrollments) {
+ if (e.programName === ws.programName && e.status === "Waitlisted")
+ infWaitlistFacilities.add(e.facilityCode);
+ }
+ }
+ }
+
+ const facilityCompletion = computeFacilityCompletion(data);
+ const mixAdjUnderperform = new Set(
+ facilityCompletion
+ .filter(f => f.mixAdjDelta < thresholds.attentionFlags.mixAdjDeltaThreshold && f.mixAdjSignificant && !f.mixAdjustedInsufficient)
+ .map(f => f.facilityCode)
+ );
+
+ const HIGH_MAX_LSI = new Set(["High", "Maximum"]);
+ const facHighLsiPop = new Map();
+ const facTotalPop = new Map();
+ const facHighLsiEdu = new Map();
+ const facTotalEdu = new Map();
+ const eduEnrolledUsers = new Set(
+ data.enrollments
+ .filter(e => e.programType === "Education" && e.status !== "Dropped")
+ .map(e => e.userId)
+ );
+ for (const r of data.residents) {
+ facTotalPop.set(r.facilityCode, (facTotalPop.get(r.facilityCode) ?? 0) + 1);
+ if (HIGH_MAX_LSI.has(r.lsiBand))
+ facHighLsiPop.set(r.facilityCode, (facHighLsiPop.get(r.facilityCode) ?? 0) + 1);
+ if (eduEnrolledUsers.has(r.id)) {
+ facTotalEdu.set(r.facilityCode, (facTotalEdu.get(r.facilityCode) ?? 0) + 1);
+ if (HIGH_MAX_LSI.has(r.lsiBand))
+ facHighLsiEdu.set(r.facilityCode, (facHighLsiEdu.get(r.facilityCode) ?? 0) + 1);
+ }
+ }
+ const equityGapFacilities = new Set();
+ for (const [code, total] of facTotalPop) {
+ const popHighLsiPct = total > 0 ? (facHighLsiPop.get(code) ?? 0) / total * 100 : 0;
+ const eduTotal = facTotalEdu.get(code) ?? 0;
+ const eduHighLsiPct = eduTotal > 0 ? (facHighLsiEdu.get(code) ?? 0) / eduTotal * 100 : 0;
+ if (popHighLsiPct - eduHighLsiPct >= thresholds.attentionFlags.equityGapThreshold) equityGapFacilities.add(code);
+ }
+
+ const { byFacility: trendsByFacility } = computeMonthlyTrends(data);
+ const trendDownFacilities = new Set();
+ for (const [code, months] of trendsByFacility) {
+ const recent = months.slice(-thresholds.attentionFlags.trendDownConsecutiveMonths);
+ if (recent.length >= thresholds.attentionFlags.trendDownConsecutiveMonths) {
+ const isDown = recent[1].newEnrollments < recent[0].newEnrollments
+ && recent[2].newEnrollments < recent[1].newEnrollments
+ && recent[3].newEnrollments < recent[2].newEnrollments;
+ if (isDown) trendDownFacilities.add(code);
+ }
+ }
+
+ const matrixCells = computeFacilityProgramMatrix(data);
+ const q16CountByFac = new Map();
+ for (const cell of matrixCells) {
+ if (!cell.insufficient && cell.deltaFromFacilityAvg < analyticsConfig.programPerformanceMatrix.underperformDelta) {
+ q16CountByFac.set(cell.facilityCode, (q16CountByFac.get(cell.facilityCode) ?? 0) + 1);
+ }
+ }
+ const q16UnderperformFacilities = new Set();
+ for (const [code, count] of q16CountByFac) {
+ if (count >= analyticsConfig.programPerformanceMatrix.minUnderperformingPrograms) q16UnderperformFacilities.add(code);
+ }
+
+ const flags: AttentionFlag[] = [];
+
+ for (const [code, residents] of resByFacility) {
+ const total = residents.length;
+ if (total === 0) continue;
+
+ const neverEngaged = residents.filter(r => !engaged.has(r.id)).length;
+ const neRate = (neverEngaged / total) * 100;
+ const nrUnengaged = nearReleaseUnengaged.get(code) ?? 0;
+ const hasInfWaitlist = infWaitlistFacilities.has(code) ? 1 : 0;
+ const hasMixAdj = mixAdjUnderperform.has(code) ? 1 : 0;
+ const hasEquityGap = equityGapFacilities.has(code) ? 1 : 0;
+ const hasTrendDown = trendDownFacilities.has(code) ? 1 : 0;
+ const hasQ16Under = q16UnderperformFacilities.has(code) ? 1 : 0;
+
+ const w = thresholds.attentionFlags.scoreWeights;
+ const score =
+ Math.max(0, neRate - thresholds.attentionFlags.neverEngagedRateThreshold) * w.neverEngagedRatePerPp
+ + nrUnengaged * w.nearReleaseUnengagedPerResident
+ + hasInfWaitlist * w.infWaitlist
+ + hasMixAdj * w.mixAdjUnderperform
+ + hasEquityGap * w.equityGap
+ + hasTrendDown * w.trendDown
+ + hasQ16Under * w.programMatrixUnderperform;
+
+ const reasons: string[] = [];
+ if (neRate >= thresholds.attentionFlags.neverEngagedRateThreshold) reasons.push(`${Math.round(neRate)}% never engaged (${neverEngaged}/${total})`);
+ if (nrUnengaged > 0) reasons.push(`${nrUnengaged} near-release resident${nrUnengaged > 1 ? "s" : ""} unengaged`);
+ if (hasInfWaitlist) reasons.push("≥1 program with ∞ months to clear waitlist");
+ if (hasMixAdj) reasons.push("Underperforms after risk adjustment (mix-adjusted gap > 2pp)");
+ if (hasEquityGap) reasons.push("High+Max LSI residents underrepresented in education (≥10pp gap)");
+ if (hasTrendDown) reasons.push("Enrollment declining for 3+ consecutive months");
+ if (hasQ16Under) reasons.push("≥2 programs underperform facility average by >10pp (Q16)");
+ if (reasons.length === 0) reasons.push(`${Math.round(neRate)}% never-engaged rate`);
+
+ flags.push({
+ facilityCode: code,
+ facilityName: facilityInfo.get(code)?.name ?? code,
+ reasons,
+ score,
+ });
+ }
+
+ return flags.sort((a, b) => b.score - a.score);
+}
+
+// ── B8: Education need vs capacity ────────────────────────────────────────────
+
+export function computeEducationNeedVsCapacity(data: ParsedData): EducationNeedStat[] {
+ const gap = computeNoDiplomaGap(data);
+ const total = data.residents.length;
+
+ return gap.byFacility.map(s => {
+ const facResidents = data.residents.filter(r => r.facilityCode === s.facilityCode).length;
+ return {
+ facilityCode: s.facilityCode,
+ needCount: s.otherOnly + s.noProgram,
+ inEducationCount: s.inEducation,
+ totalNoDiploma: s.total,
+ needPct: (facResidents > 0 && total > 0) ? Math.round((s.otherOnly + s.noProgram) / facResidents * 1000) / 10 : 0,
+ };
+ }).sort((a, b) => b.needCount - a.needCount);
+}
+
+// ── DOC vs UL program comparison (independent populations) ───────────────────
+
+export function computeDocUlProgramGap(
+ docEnrollments: EnrollmentRecord[],
+ ulEnrollments: EnrollmentRecord[],
+): DocUlProgramGap[] {
+ type Bucket = { type: string; enrolled: number; completed: number };
+
+ const tally = (records: EnrollmentRecord[]) => {
+ const m = new Map();
+ for (const e of records) {
+ if (!m.has(e.programName)) m.set(e.programName, { type: e.programType, enrolled: 0, completed: 0 });
+ const s = m.get(e.programName)!;
+ s.enrolled++;
+ if (e.status === "Completed") s.completed++;
+ }
+ return m;
+ };
+
+ const docMap = tally(docEnrollments);
+ const ulMap = tally(ulEnrollments);
+
+ const allNames = new Set([...docMap.keys(), ...ulMap.keys()]);
+ const results: DocUlProgramGap[] = [];
+
+ for (const name of allNames) {
+ const doc = docMap.get(name);
+ const ul = ulMap.get(name);
+ const docRate = doc && doc.enrolled > 0 ? (doc.completed / doc.enrolled) * 100 : 0;
+ const ulRate = ul && ul.enrolled > 0 ? (ul.completed / ul.enrolled) * 100 : 0;
+ results.push({
+ programName: name,
+ programType: doc?.type ?? ul?.type ?? "Other",
+ docEnrolled: doc?.enrolled ?? 0,
+ docCompleted: doc?.completed ?? 0,
+ docCompletionRate: Math.round(docRate * 10) / 10,
+ ulEnrolled: ul?.enrolled ?? 0,
+ ulCompleted: ul?.completed ?? 0,
+ ulCompletionRate: Math.round(ulRate * 10) / 10,
+ gap: Math.round((docRate - ulRate) * 10) / 10,
+ hasBoth: !!doc && !!ul,
+ });
+ }
+
+ return results.sort((a, b) => Math.abs(b.gap) - Math.abs(a.gap));
+}
+
+// ── Phase 2: Join coverage ────────────────────────────────────────────────────
+
+export function computeJoinCoverage(data: ParsedData): JoinCoverage {
+ const docNames = new Set(
+ data.enrollments.filter(e => e.source === "doc").map(e => e.programName).filter(Boolean)
+ );
+ const ulNames = new Set(
+ data.ulEnrollments.filter(e => e.source === "ul").map(e => e.programName).filter(Boolean)
+ );
+
+ // Also collect UL program names from crosswalk
+ for (const cw of data.crosswalk) {
+ if (cw.ulProgramName) ulNames.add(cw.ulProgramName);
+ }
+
+ // Build matched set using crosswalk
+ const matchedDocNames = new Set();
+ const matchedUlNames = new Set();
+ let fuzzyMatched = 0;
+
+ if (data.crosswalk.length > 0) {
+ for (const cw of data.crosswalk) {
+ if (docNames.has(cw.docProgramName) && (ulNames.has(cw.ulProgramName) || cw.ulProgramId)) {
+ matchedDocNames.add(cw.docProgramName);
+ matchedUlNames.add(cw.ulProgramName);
+ if (cw.confidence === "fuzzy") fuzzyMatched++;
+ }
+ }
+ } else {
+ // Exact name match fallback
+ for (const docName of docNames) {
+ if (ulNames.has(docName)) {
+ matchedDocNames.add(docName);
+ matchedUlNames.add(docName);
+ }
+ }
+ }
+
+ const matched = matchedDocNames.size;
+ const docOnly = docNames.size - matched;
+ const ulOnly = ulNames.size - matchedUlNames.size;
+
+ return {
+ docTotal: docNames.size,
+ ulTotal: ulNames.size,
+ matched,
+ docOnly: Math.max(0, docOnly),
+ ulOnly: Math.max(0, ulOnly),
+ fuzzyMatched,
+ };
+}
+
+// ── Phase 2: Q8 — Active enrollees with no UL session in 30 days ─────────────
+
+export function computeQ8InactiveUsers(data: ParsedData): Q8InactiveUser[] {
+ const today = new Date();
+ const cutoff = new Date(today.getTime() - analyticsConfig.sessionInactivityDays * 86400000);
+
+ // Build latest session date per user
+ const latestSession = new Map();
+ for (const s of data.sessions) {
+ if (!s.session_date) continue;
+ const d = new Date(s.session_date);
+ if (isNaN(d.getTime())) continue;
+ const prev = latestSession.get(s.user_id);
+ if (!prev || d > prev) latestSession.set(s.user_id, d);
+ }
+
+ const result: Q8InactiveUser[] = [];
+
+ for (const e of data.enrollments) {
+ if (e.status !== "Active") continue;
+ const lastSession = latestSession.get(e.userId) ?? null;
+
+ if (lastSession === null || lastSession < cutoff) {
+ const daysSinceLastSession = lastSession !== null
+ ? Math.floor((today.getTime() - lastSession.getTime()) / 86400000)
+ : null;
+ result.push({
+ userId: e.userId,
+ facilityCode: e.facilityCode,
+ programName: e.programName,
+ programType: e.programType,
+ daysSinceLastSession,
+ });
+ }
+ }
+
+ return result.sort((a, b) => {
+ if (a.daysSinceLastSession === null && b.daysSinceLastSession === null) return 0;
+ if (a.daysSinceLastSession === null) return -1;
+ if (b.daysSinceLastSession === null) return 1;
+ return b.daysSinceLastSession - a.daysSinceLastSession;
+ });
+}
+
+// ── Phase 2: Q13 — Attendance consistency vs completion rate ─────────────────
+
+export function computeAttendanceStats(data: ParsedData): AttendanceStat[] {
+ // Build event → class mapping from classes' events in attendance data
+ // event_id → set of user_ids who attended
+ const eventAttendees = new Map>();
+ for (const a of data.attendance) {
+ if (a.attended !== "true") continue;
+ if (!eventAttendees.has(a.event_id)) eventAttendees.set(a.event_id, new Set());
+ eventAttendees.get(a.event_id)!.add(a.user_id);
+ }
+
+ // class_id → event_ids
+ const classEvents = new Map();
+ for (const ev of data.events) {
+ if (!ev.class_id) continue;
+ if (!classEvents.has(ev.class_id)) classEvents.set(ev.class_id, []);
+ classEvents.get(ev.class_id)!.push(ev.event_id);
+ }
+
+ // program_id → { programName, programType, enrolleeAttendancePcts, completions, totalEnrolled }
+ const programMap = new Map();
+
+ // Build program info from classes
+ const classToProgram = new Map();
+ for (const cls of data.classes) {
+ const prog = data.programs.find(p => p.id === cls.program_id);
+ if (prog) classToProgram.set(cls.class_id, { programId: prog.id, programName: prog.name, programType: prog.type });
+ }
+
+ // For each UL enrollment, compute attendance ratio
+ for (const e of data.ulEnrollments) {
+ if (!e.classId) continue;
+ const progInfo = classToProgram.get(e.classId);
+ if (!progInfo) continue;
+
+ const evIds = classEvents.get(e.classId) ?? [];
+ const totalEvents = evIds.length;
+
+ if (!programMap.has(progInfo.programId)) {
+ programMap.set(progInfo.programId, {
+ programName: progInfo.programName,
+ programType: progInfo.programType,
+ attendancePcts: [],
+ completions: 0,
+ totalEnrolled: 0,
+ });
+ }
+ const s = programMap.get(progInfo.programId)!;
+ s.totalEnrolled++;
+ if (e.status === "Completed") s.completions++;
+
+ if (totalEvents > 0) {
+ const attended = evIds.filter(eid => eventAttendees.get(eid)?.has(e.userId)).length;
+ s.attendancePcts.push((attended / totalEvents) * 100);
+ }
+ }
+
+ return Array.from(programMap.entries())
+ .map(([id, s]) => ({
+ programId: id,
+ programName: s.programName,
+ programType: s.programType,
+ avgAttendancePct: s.attendancePcts.length > 0
+ ? s.attendancePcts.reduce((a, b) => a + b, 0) / s.attendancePcts.length
+ : 0,
+ completionRate: s.totalEnrolled > 0 ? (s.completions / s.totalEnrolled) * 100 : 0,
+ enrolleeCount: s.totalEnrolled,
+ }))
+ .filter(s => s.enrolleeCount >= analyticsConfig.attendanceMinEnrollees)
+ .sort((a, b) => b.enrolleeCount - a.enrolleeCount);
+}
+
+// ── Phase 2: Q14 — Time to completion with batch-timestamp filter ─────────────
+
+export function computeTimeToCompletion(data: ParsedData): TimeToCompletionStat[] {
+ // Group completed UL enrollments by programId
+ const programGroups = new Map;
+ batchFiltered: number;
+ }>();
+
+ // Group all completions by toISOString().slice(0,19) to detect batch artifacts
+ const completionsByTimestamp = new Map();
+ for (const e of data.ulEnrollments) {
+ if (e.status !== "Completed" || !e.completionDate || !e.enrolledDate) continue;
+ const tsKey = e.completionDate.toISOString().slice(0, 19);
+ completionsByTimestamp.set(tsKey, (completionsByTimestamp.get(tsKey) ?? 0) + 1);
+ }
+
+ for (const e of data.ulEnrollments) {
+ if (e.status !== "Completed" || !e.completionDate || !e.enrolledDate) continue;
+
+ if (!programGroups.has(e.programId)) {
+ programGroups.set(e.programId, {
+ programName: e.programName,
+ programType: e.programType,
+ records: [],
+ batchFiltered: 0,
+ });
+ }
+ const g = programGroups.get(e.programId)!;
+
+ const tsKey = e.completionDate.toISOString().slice(0, 19);
+ const groupSize = completionsByTimestamp.get(tsKey) ?? 1;
+
+ if (groupSize >= analyticsConfig.timeToCompletion.batchGroupSize) {
+ g.batchFiltered++;
+ } else {
+ const days = (e.completionDate.getTime() - e.enrolledDate.getTime()) / 86400000;
+ if (days >= 0) g.records.push({ days });
+ }
+ }
+
+ const percentile = (sorted: number[], p: number) => {
+ if (sorted.length === 0) return 0;
+ const idx = (p / 100) * (sorted.length - 1);
+ const lo = Math.floor(idx);
+ const hi = Math.ceil(idx);
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
+ };
+
+ return Array.from(programGroups.entries())
+ .map(([id, g]) => {
+ const sorted = g.records.map(r => r.days).sort((a, b) => a - b);
+ const sampleSize = sorted.length;
+ const totalBeforeFilter = sampleSize + g.batchFiltered;
+ return {
+ programId: id,
+ programName: g.programName,
+ programType: g.programType,
+ medianDays: percentile(sorted, 50),
+ iqrLow: percentile(sorted, 25),
+ iqrHigh: percentile(sorted, 75),
+ sampleSize,
+ batchFilteredCount: g.batchFiltered,
+ coverageFlag: sampleSize < g.batchFiltered || (totalBeforeFilter > 0 && sampleSize / totalBeforeFilter < analyticsConfig.timeToCompletion.coverageFlagRatio),
+ };
+ })
+ .filter(s => s.sampleSize >= analyticsConfig.timeToCompletion.minSampleSize)
+ .sort((a, b) => b.sampleSize - a.sampleSize);
+}
+
+// ── Q15 — Disengagement (UL enrollments, Programs tab) ───────────────────────
+
+export function computeDisengagement(data: ParsedData): DisengagementStat[] {
+ const programMap = new Map();
+
+ for (const e of data.ulEnrollments) {
+ if (!programMap.has(e.programId)) {
+ programMap.set(e.programId, {
+ programName: e.programName,
+ programType: e.programType,
+ withdrew: 0,
+ daysInProgram: [],
+ totalEnrolled: 0,
+ });
+ }
+ const s = programMap.get(e.programId)!;
+ s.totalEnrolled++;
+
+ if (e.status === "Dropped" || e.status === "Withdrawn") {
+ s.withdrew++;
+ if (e.completionDate && e.enrolledDate) {
+ const days = Math.max(0, Math.round(
+ (e.completionDate.getTime() - e.enrolledDate.getTime()) / 86_400_000
+ ));
+ s.daysInProgram.push(days);
+ }
+ }
+ }
+
+ return Array.from(programMap.values())
+ .filter(s => s.withdrew > 0)
+ .map(s => {
+ const sorted = [...s.daysInProgram].sort((a, b) => a - b);
+ const median = sorted.length > 0 ? sorted[Math.floor(sorted.length / 2)] : null;
+ return {
+ programName: s.programName,
+ programType: s.programType,
+ totalEnrolled: s.totalEnrolled,
+ withdrew: s.withdrew,
+ withdrawalRate: Math.round(s.withdrew / s.totalEnrolled * 1000) / 10,
+ medianDaysInProgram: median,
+ };
+ })
+ .sort((a, b) => b.withdrew - a.withdrew);
+}
+
+// ── Q15 — Disengagement dropoff (DOC enrollments, Insights B1) ───────────────
+
+export function computeDisengagementDropoff(data: ParsedData): DisengagementStat[] {
+ const map = new Map();
+
+ for (const e of data.enrollments) {
+ if (!map.has(e.programId)) {
+ map.set(e.programId, {
+ name: e.programName, type: e.programType,
+ totalEnrolled: 0, withdrew: 0, daysInProgram: [],
+ });
+ }
+ const s = map.get(e.programId)!;
+ s.totalEnrolled++;
+ if (e.status === "Dropped") {
+ s.withdrew++;
+ if (e.enrolledDate && e.completionDate) {
+ const days = Math.max(0, Math.round(
+ (e.completionDate.getTime() - e.enrolledDate.getTime()) / 86_400_000
+ ));
+ s.daysInProgram.push(days);
+ }
+ }
+ }
+
+ return Array.from(map.values())
+ .filter(s => s.withdrew > 0)
+ .map(s => {
+ const sorted = [...s.daysInProgram].sort((a, b) => a - b);
+ const median = sorted.length > 0 ? sorted[Math.floor(sorted.length / 2)] : null;
+ return {
+ programName: s.name,
+ programType: s.type,
+ totalEnrolled: s.totalEnrolled,
+ withdrew: s.withdrew,
+ withdrawalRate: Math.round(s.withdrew / s.totalEnrolled * 1000) / 10,
+ medianDaysInProgram: median,
+ };
+ })
+ .sort((a, b) => b.withdrew - a.withdrew);
+}
+
+// ── B7 — Facility portraits ───────────────────────────────────────────────────
+
+const EDUCATION_PROGRAM_NAMES = new Set([
+ "HiSET Prep", "College-Associate", "HSED", "ABE", "Adult Basic Education",
+]);
+
+function isEducationEnrollment(e: EnrollmentRecord): boolean {
+ return e.programType === "Education" || EDUCATION_PROGRAM_NAMES.has(e.programName);
+}
+
+export function computeFacilityPortraits(data: ParsedData): FacilityPortrait[] {
+ const retentionRaw = computeCohortRetention(data);
+ const retention90d = new Map();
+ const latestCohortMonth = new Map();
+ for (const pt of retentionRaw) {
+ if (pt.monthOffset === 3 && pt.cohortSize >= 5) {
+ const existing = latestCohortMonth.get(pt.facilityCode);
+ if (!existing || pt.cohortMonth > existing) {
+ retention90d.set(pt.facilityCode, pt.retentionPct);
+ latestCohortMonth.set(pt.facilityCode, pt.cohortMonth);
+ }
+ }
+ }
+
+ const residentEnrollCount = new Map();
+ const residentCompletedCount = new Map();
+ for (const e of data.enrollments) {
+ residentEnrollCount.set(e.userId, (residentEnrollCount.get(e.userId) ?? 0) + 1);
+ if (e.status === "Completed")
+ residentCompletedCount.set(e.userId, (residentCompletedCount.get(e.userId) ?? 0) + 1);
+ }
+
+ const facEduUsers = new Map>();
+ const facProgramNameCounts = new Map>();
+ const residentFirstEduEnrollDate = new Map();
+
+ for (const e of data.enrollments) {
+ if (!isEducationEnrollment(e)) continue;
+ if (!facEduUsers.has(e.facilityCode)) facEduUsers.set(e.facilityCode, new Set());
+ facEduUsers.get(e.facilityCode)!.add(e.userId);
+
+ if (!facProgramNameCounts.has(e.facilityCode))
+ facProgramNameCounts.set(e.facilityCode, new Map());
+ const tc = facProgramNameCounts.get(e.facilityCode)!;
+ tc.set(e.programName, (tc.get(e.programName) ?? 0) + 1);
+
+ if (e.enrolledDate) {
+ const existing = residentFirstEduEnrollDate.get(e.userId);
+ if (!existing || e.enrolledDate < existing)
+ residentFirstEduEnrollDate.set(e.userId, e.enrolledDate);
+ }
+ }
+
+ const completedByFacility = new Map; hasSecond: number }>();
+ for (const e of data.enrollments) {
+ if (e.status !== "Completed") continue;
+ if (!completedByFacility.has(e.facilityCode))
+ completedByFacility.set(e.facilityCode, { completed: new Set(), hasSecond: 0 });
+ const s = completedByFacility.get(e.facilityCode)!;
+ if (!s.completed.has(e.userId)) {
+ s.completed.add(e.userId);
+ if ((residentEnrollCount.get(e.userId) ?? 0) >= 2) s.hasSecond++;
+ }
+ }
+
+ return data.facilities.map(f => {
+ const facResidents = data.residents.filter(r => r.facilityId === f.id);
+
+ const enrollCounts = facResidents
+ .map(r => residentEnrollCount.get(r.id) ?? 0)
+ .sort((a, b) => a - b);
+ const medianEnroll = enrollCounts.length > 0
+ ? enrollCounts[Math.floor(enrollCounts.length / 2)]
+ : 0;
+
+ const perResidentRates = facResidents
+ .filter(r => (residentEnrollCount.get(r.id) ?? 0) > 0)
+ .map(r => {
+ const enr = residentEnrollCount.get(r.id) ?? 0;
+ const comp = residentCompletedCount.get(r.id) ?? 0;
+ return enr > 0 ? (comp / enr) * 100 : 0;
+ })
+ .sort((a, b) => a - b);
+ const medianRate = perResidentRates.length > 0
+ ? perResidentRates[Math.floor(perResidentRates.length / 2)]
+ : 0;
+
+ const nameMap = facProgramNameCounts.get(f.code);
+ const modalProgramName = nameMap && nameMap.size > 0
+ ? [...nameMap.entries()].sort((a, b) => b[1] - a[1])[0][0]
+ : "None";
+
+ const r90 = retention90d.get(f.code) ?? retention90d.get("statewide") ?? null;
+
+ const cs = completedByFacility.get(f.code);
+ const secondRate = cs && cs.completed.size > 0
+ ? Math.round(cs.hasSecond / cs.completed.size * 1000) / 10
+ : 0;
+
+ const monthsAtFirstEnroll: number[] = facResidents
+ .filter(r => r.projectedReleaseDate !== null && residentFirstEduEnrollDate.has(r.id))
+ .map(r => {
+ const firstEnroll = residentFirstEduEnrollDate.get(r.id)!;
+ const months = (r.projectedReleaseDate!.getTime() - firstEnroll.getTime()) / (1000 * 60 * 60 * 24 * 30.44);
+ return months >= 0 ? Math.round(months) : null;
+ })
+ .filter((m): m is number => m !== null);
+
+ const sortedMonths = [...monthsAtFirstEnroll].sort((a, b) => a - b);
+ const medianMonths = sortedMonths.length > 0
+ ? sortedMonths[Math.floor(sortedMonths.length / 2)]
+ : null;
+
+ return {
+ facilityCode: f.code,
+ facilityName: f.name,
+ totalResidents: facResidents.length,
+ educationEnrollees: facEduUsers.get(f.code)?.size ?? 0,
+ medianProgramsEnrolled: medianEnroll,
+ medianCompletionRatePct: Math.round(medianRate * 10) / 10,
+ modalProgramName,
+ retention90dPct: r90 !== null ? Math.round(r90 * 10) / 10 : null,
+ secondProgramRate: secondRate,
+ medianMonthsToReleaseAtFirstEnrollment: medianMonths,
+ };
+ }).filter(p => p.totalResidents > 0);
+}
+
+// ── B5 — Quality of Life grid ─────────────────────────────────────────────────
+
+export function computeQoLGrid(data: ParsedData): QoLDimension[] {
+ const summary = computeSummary(data);
+ const waitlists = computeWaitlistStats(data);
+ const cohortRetention = computeCohortRetention(data);
+ const topPrograms = computeTopProgramsByCompletion(data, 5, 10);
+
+ const completorIds = new Set(
+ data.enrollments.filter(e => e.status === "Completed").map(e => e.userId)
+ );
+ const enrollCountById = new Map();
+ for (const e of data.enrollments) {
+ enrollCountById.set(e.userId, (enrollCountById.get(e.userId) ?? 0) + 1);
+ }
+ const completorsWithSecond = [...completorIds].filter(
+ uid => (enrollCountById.get(uid) ?? 0) >= 2
+ ).length;
+ const secondProgramRateOverall = completorIds.size > 0
+ ? Math.round(completorsWithSecond / completorIds.size * 100)
+ : 0;
+
+ const neverEngagedPct = summary.totalResidents > 0
+ ? Math.round(summary.totalNeverEngaged / summary.totalResidents * 1000) / 10
+ : 0;
+ const infWaitlistCount = waitlists.filter(w => w.waitlistMonths >= analyticsConfig.infWaitlistSentinel).length;
+ const maxWaitMonths = waitlists.length > 0 ? waitlists[0].waitlistMonths : 0;
+
+ const statewideRetention90 = cohortRetention.find(
+ p => p.facilityCode === "statewide" && p.monthOffset === 3
+ )?.retentionPct ?? null;
+
+ const educationPrograms = topPrograms.filter(p => p.programType === "Education");
+ const eduCompletionRate = educationPrograms.length > 0
+ ? educationPrograms.reduce((sum, p) => sum + p.completionRate, 0) / educationPrograms.length
+ : summary.overallCompletionRate;
+
+ const hasSessionData = data.sessions.length > 0;
+
+ const statusFromPct = (pct: number, goodThreshold: number, badThreshold: number): QoLStatus =>
+ pct >= goodThreshold ? "green" : pct >= badThreshold ? "yellow" : "red";
+
+ return [
+ {
+ id: "access",
+ label: "Access to programming",
+ cells: [
+ {
+ metricId: "b6_never_engaged",
+ label: "Never-engaged rate",
+ value: `${neverEngagedPct}%`,
+ status: statusFromPct(100 - neverEngagedPct, thresholds.qol.neverEngagedRate.greenThreshold, thresholds.qol.neverEngagedRate.yellowThreshold),
+ interpretation: neverEngagedPct > thresholds.qol.neverEngagedRate.majorityThreshold
+ ? "Majority of residents have no program activity"
+ : neverEngagedPct > thresholds.qol.neverEngagedRate.significantThreshold
+ ? "Significant never-engaged population"
+ : "Most residents have engaged with programs",
+ isObservable: true,
+ },
+ {
+ metricId: "q03_waitlist_depth_inf",
+ label: "Infinite waitlists",
+ value: infWaitlistCount > 0
+ ? `${infWaitlistCount} program${infWaitlistCount > 1 ? "s" : ""}`
+ : "None",
+ status: infWaitlistCount === 0 ? "green" : infWaitlistCount <= thresholds.qol.infWaitlists.yellowMaxCount ? "yellow" : "red",
+ interpretation: infWaitlistCount === 0
+ ? "All waitlisted programs have active throughput"
+ : `${infWaitlistCount} program${infWaitlistCount > 1 ? "s" : ""} with zero completions/month`,
+ isObservable: true,
+ },
+ {
+ metricId: "q03_waitlist_depth_max",
+ label: "Longest waitlist",
+ value: maxWaitMonths >= analyticsConfig.infWaitlistSentinel ? "∞ mo" : maxWaitMonths > 0 ? `${Math.round(maxWaitMonths)} mo` : "N/A",
+ status: maxWaitMonths >= analyticsConfig.infWaitlistSentinel ? "red" : maxWaitMonths > thresholds.qol.maxWaitMonths.yellowThreshold ? "yellow" : "green",
+ interpretation: maxWaitMonths >= analyticsConfig.infWaitlistSentinel
+ ? "At least one program has no completion throughput"
+ : maxWaitMonths > thresholds.qol.maxWaitMonths.yellowThreshold
+ ? "Some programs have multi-month clearance times"
+ : "Waitlists appear manageable",
+ isObservable: true,
+ },
+ ],
+ },
+ {
+ id: "persistence",
+ label: "Persistence",
+ cells: [
+ {
+ metricId: "q17_cohort_retention",
+ label: "90-day retention (statewide)",
+ value: statewideRetention90 !== null ? `${statewideRetention90}%` : "Insufficient data",
+ status: statewideRetention90 === null ? "insufficient"
+ : statewideRetention90 >= thresholds.qol.retention90.greenThreshold ? "green"
+ : statewideRetention90 >= thresholds.qol.retention90.yellowThreshold ? "yellow" : "red",
+ interpretation: statewideRetention90 === null
+ ? "Not enough dated enrollment data to compute cohort retention"
+ : statewideRetention90 >= thresholds.qol.retention90.greenThreshold
+ ? "Most education enrollees still active at 90 days"
+ : "Significant drop-off before 90 days",
+ isObservable: true,
+ },
+ {
+ metricId: "q06_second_program",
+ label: "Second-program rate",
+ value: secondProgramRateOverall > 0 ? `${secondProgramRateOverall}%` : "N/A",
+ status: secondProgramRateOverall >= thresholds.qol.secondProgramRate.greenThreshold ? "green"
+ : secondProgramRateOverall >= thresholds.qol.secondProgramRate.yellowThreshold ? "yellow" : "red",
+ interpretation: secondProgramRateOverall >= thresholds.qol.secondProgramRate.greenThreshold
+ ? "Strong re-enrollment after first completion"
+ : "Limited sustained engagement after completion",
+ isObservable: true,
+ },
+ ],
+ },
+ {
+ id: "mastery",
+ label: "Mastery / completion",
+ cells: [
+ {
+ metricId: "q01_top_programs",
+ label: "Education completion rate",
+ value: eduCompletionRate > 0 ? `${Math.round(eduCompletionRate)}%` : "N/A",
+ status: statusFromPct(eduCompletionRate, thresholds.qol.eduCompletionRate.greenThreshold, thresholds.qol.eduCompletionRate.yellowThreshold),
+ interpretation: eduCompletionRate >= thresholds.qol.eduCompletionRate.greenThreshold
+ ? "Education programs above system-wide average"
+ : eduCompletionRate >= thresholds.qol.eduCompletionRate.yellowThreshold
+ ? "Education completion near system average"
+ : "Education completion rate below average",
+ isObservable: true,
+ },
+ {
+ metricId: "q14_time_to_completion",
+ label: "Median time to completion",
+ value: "Requires UL enrollment data",
+ status: "insufficient",
+ interpretation: "Upload program_class_enrollments.csv to enable",
+ isObservable: true,
+ },
+ ],
+ },
+ {
+ id: "engagement_quality",
+ label: "Engagement quality",
+ cells: [
+ {
+ metricId: "q13_attendance_vs_completion",
+ label: "Attendance consistency",
+ value: "Requires UL attendance data",
+ status: "insufficient",
+ interpretation: "Upload program_class_event_attendance.csv to enable",
+ isObservable: true,
+ },
+ {
+ metricId: "q08_active_no_ul_session",
+ label: "Active but no platform session (30d)",
+ value: hasSessionData ? "Available" : "Requires UL session data",
+ status: hasSessionData ? "green" : "insufficient",
+ interpretation: hasSessionData
+ ? "Session data loaded — see Residents tab for detail"
+ : "Upload user_session_tracking.csv to enable",
+ isObservable: true,
+ },
+ {
+ metricId: "post_release_outcomes",
+ label: "Post-release outcomes",
+ value: "Out of scope",
+ status: "insufficient",
+ interpretation: "Post-release data not available in DOC exports",
+ isObservable: false,
+ },
+ ],
+ },
+ ];
+}
+
+export type { EnrollmentRecord };
+
+// ── OMS: Incident statistics ──────────────────────────────────────────────────
+
+export function computeIncidentStats(data: ParsedData): IncidentStats {
+ const now = new Date();
+ const cutoff12 = new Date(now.getTime() - analyticsConfig.lookbackPeriods.trailing12MonthsDays * 24 * 60 * 60 * 1000);
+ const cutoffPrior = new Date(now.getTime() - analyticsConfig.lookbackPeriods.prior24MonthsDays * 24 * 60 * 60 * 1000);
+
+ const incidents = data.incidents;
+ if (incidents.length === 0) {
+ return {
+ total: 0, trailing12mo: 0, prior12mo: 0,
+ bySeverity: { Minor: 0, Major: 0 },
+ byType: [], byFacility: [],
+ residentHistogram: { zero: data.residents.length, one: 0, two: 0, threePlus: 0 },
+ residentCount: 0, totalResidents: data.residents.length,
+ };
+ }
+
+ let trailing12mo = 0, prior12mo = 0;
+ const severityCounts = { Minor: 0, Major: 0 };
+ const typeCounts = new Map();
+ const facilityCounts = new Map();
+ const residentCounts = new Map();
+
+ for (const inc of incidents) {
+ if (inc.incidentDate) {
+ if (inc.incidentDate >= cutoff12) trailing12mo++;
+ else if (inc.incidentDate >= cutoffPrior) prior12mo++;
+ }
+ const sev = inc.severity as "Minor" | "Major";
+ if (sev === "Minor" || sev === "Major") severityCounts[sev]++;
+ typeCounts.set(inc.incidentType, (typeCounts.get(inc.incidentType) ?? 0) + 1);
+ const fc = inc.facilityCode;
+ const fEntry = facilityCounts.get(fc) ?? { count: 0, major: 0 };
+ fEntry.count++;
+ if (inc.severity === "Major") fEntry.major++;
+ facilityCounts.set(fc, fEntry);
+ residentCounts.set(inc.residentId, (residentCounts.get(inc.residentId) ?? 0) + 1);
+ }
+
+ const histogram = { zero: 0, one: 0, two: 0, threePlus: 0 };
+ const withIncidents = new Set(incidents.map(i => i.residentId));
+ for (const r of data.residents) {
+ const cnt = residentCounts.get(r.id) ?? 0;
+ if (cnt === 0) histogram.zero++;
+ else if (cnt === 1) histogram.one++;
+ else if (cnt === 2) histogram.two++;
+ else histogram.threePlus++;
+ }
+ // Residents in incidents but not in roster (orphans): add to histogram
+ for (const [rid, cnt] of residentCounts) {
+ if (!data.residents.find(r => r.id === rid)) {
+ if (cnt === 1) histogram.one++;
+ else if (cnt === 2) histogram.two++;
+ else histogram.threePlus++;
+ histogram.zero = Math.max(0, histogram.zero - 1);
+ }
+ }
+
+ return {
+ total: incidents.length,
+ trailing12mo,
+ prior12mo,
+ bySeverity: severityCounts,
+ byType: Array.from(typeCounts.entries())
+ .map(([type, count]) => ({ type, count }))
+ .sort((a, b) => b.count - a.count),
+ byFacility: Array.from(facilityCounts.entries())
+ .map(([facilityCode, { count, major }]) => ({ facilityCode, count, major }))
+ .sort((a, b) => b.count - a.count),
+ residentHistogram: histogram,
+ residentCount: withIncidents.size,
+ totalResidents: data.residents.length,
+ };
+}
+
+// ── OMS: Credential statistics ────────────────────────────────────────────────
+
+export function computeCredentialStats(data: ParsedData): CredentialStats {
+ const credentials = data.credentials;
+ if (credentials.length === 0) {
+ return {
+ total: 0, byType: [], verifiedCount: 0, unverifiedCount: 0,
+ hasHiSetOrGedCount: 0, residentCount: 0,
+ totalResidents: data.residents.length, byIssuingBody: [],
+ };
+ }
+
+ const typeCounts = new Map();
+ const bodyCounts = new Map();
+ let verified = 0, unverified = 0;
+ const residentsWithHiSetOrGed = new Set();
+ const residentsWithCredential = new Set();
+
+ for (const c of credentials) {
+ typeCounts.set(c.credentialType, (typeCounts.get(c.credentialType) ?? 0) + 1);
+ bodyCounts.set(c.issuingBody, (bodyCounts.get(c.issuingBody) ?? 0) + 1);
+ if (c.verified) verified++; else unverified++;
+ residentsWithCredential.add(c.residentId);
+ if (c.credentialType === "GED" || c.credentialType === "HiSET") {
+ residentsWithHiSetOrGed.add(c.residentId);
+ }
+ }
+
+ return {
+ total: credentials.length,
+ byType: Array.from(typeCounts.entries())
+ .map(([type, count]) => ({ type, count }))
+ .sort((a, b) => b.count - a.count),
+ verifiedCount: verified,
+ unverifiedCount: unverified,
+ hasHiSetOrGedCount: residentsWithHiSetOrGed.size,
+ residentCount: residentsWithCredential.size,
+ totalResidents: data.residents.length,
+ byIssuingBody: Array.from(bodyCounts.entries())
+ .map(([body, count]) => ({ body, count }))
+ .sort((a, b) => b.count - a.count),
+ };
+}
+
+// ── OMS: Work assignment statistics ──────────────────────────────────────────
+
+export function computeWorkAssignmentStats(data: ParsedData): WorkAssignmentStats {
+ const assignments = data.workAssignments;
+ if (assignments.length === 0) {
+ return {
+ activeTotal: 0, activeFacilityRoles: 0, activeExternalJobs: 0,
+ transferableActiveCount: 0, transferableActiveRate: 0, externalJobRate: 0,
+ byRole: [], residentCount: 0, totalResidents: data.residents.length,
+ };
+ }
+
+ const active = assignments.filter(a => a.isActive);
+ const activeFacilityRoles = active.filter(a => a.assignmentType === "Facility Role");
+ const activeExternalJobs = active.filter(a => a.assignmentType === "External Job");
+ const transferableActive = activeFacilityRoles.filter(a => a.transferableSkills);
+
+ const roleCounts = new Map();
+ for (const a of active) {
+ roleCounts.set(a.roleTitle, (roleCounts.get(a.roleTitle) ?? 0) + 1);
+ }
+
+ const residentsWithAny = new Set(assignments.map(a => a.residentId));
+ const residentsWithExternal = new Set(
+ assignments.filter(a => a.assignmentType === "External Job" && a.isActive).map(a => a.residentId)
+ );
+
+ return {
+ activeTotal: active.length,
+ activeFacilityRoles: activeFacilityRoles.length,
+ activeExternalJobs: activeExternalJobs.length,
+ transferableActiveCount: transferableActive.length,
+ transferableActiveRate: activeFacilityRoles.length > 0
+ ? Math.round((transferableActive.length / activeFacilityRoles.length) * 100) : 0,
+ externalJobRate: data.residents.length > 0
+ ? Math.round((residentsWithExternal.size / data.residents.length) * 100) : 0,
+ byRole: Array.from(roleCounts.entries())
+ .map(([role, count]) => ({ role, count }))
+ .sort((a, b) => b.count - a.count),
+ residentCount: residentsWithAny.size,
+ totalResidents: data.residents.length,
+ };
+}
+
+// ── OMS: Case plan statistics ─────────────────────────────────────────────────
+
+export function computeCasePlanStats(data: ParsedData): CasePlanStats {
+ const plans = data.casePlans;
+ if (plans.length === 0) {
+ return {
+ total: 0, stateIdObtained: 0, stateIdRate: 0,
+ jobLinedUp: 0, jobLinedUpRate: 0,
+ housingPlan: 0, housingPlanRate: 0,
+ savingsGoalMet: 0, savingsGoalMetRate: 0,
+ avgTrustBalance: 0, balanceBands: [], readinessDistribution: [],
+ };
+ }
+
+ const n = plans.length;
+ let stateId = 0, job = 0, housing = 0, savings = 0, totalBalance = 0;
+ const bands = { "0–50": 0, "50–100": 0, "100–250": 0, "250+": 0 };
+ const readinessCounts = [0, 0, 0, 0, 0]; // index = score 0-4
+
+ for (const p of plans) {
+ if (p.stateIdObtained) stateId++;
+ if (p.jobLinedUp) job++;
+ if (p.housingPlan) housing++;
+ if (p.savingsGoalMet) savings++;
+ totalBalance += p.trustBalance;
+ if (p.trustBalance < analyticsConfig.trustBalanceBands.low) bands["0–50"]++;
+ else if (p.trustBalance < analyticsConfig.trustBalanceBands.mid) bands["50–100"]++;
+ else if (p.trustBalance < analyticsConfig.trustBalanceBands.high) bands["100–250"]++;
+ else bands["250+"]++;
+ const score = (p.stateIdObtained ? 1 : 0) + (p.jobLinedUp ? 1 : 0) +
+ (p.housingPlan ? 1 : 0) + (p.savingsGoalMet ? 1 : 0);
+ readinessCounts[score]++;
+ }
+
+ return {
+ total: n,
+ stateIdObtained: stateId,
+ stateIdRate: Math.round((stateId / n) * 100),
+ jobLinedUp: job,
+ jobLinedUpRate: Math.round((job / n) * 100),
+ housingPlan: housing,
+ housingPlanRate: Math.round((housing / n) * 100),
+ savingsGoalMet: savings,
+ savingsGoalMetRate: Math.round((savings / n) * 100),
+ avgTrustBalance: Math.round((totalBalance / n) * 100) / 100,
+ balanceBands: Object.entries(bands).map(([label, count]) => ({ label, count })),
+ readinessDistribution: readinessCounts.map((count, score) => ({ score, count })),
+ };
+}
+
+// ── OMS: Housing statistics ───────────────────────────────────────────────────
+
+export function computeHousingStats(data: ParsedData): HousingStats {
+ const moves = data.housingMoves;
+ if (moves.length === 0) {
+ return {
+ earnedHousingCount: 0, earnedHousingTotal: 0, earnedHousingRate: 0,
+ communityCustodyCount: 0, communityCustodyRate: 0,
+ custodyDistribution: [], earnedByFacility: [],
+ };
+ }
+
+ // Get most recent move per resident
+ const latestByResident = new Map();
+ for (const move of moves) {
+ const existing = latestByResident.get(move.residentId);
+ if (!existing || (move.moveDate && (!existing.moveDate || move.moveDate > existing.moveDate))) {
+ latestByResident.set(move.residentId, move);
+ }
+ }
+
+ const latestMoves = Array.from(latestByResident.values());
+ const total = latestMoves.length;
+
+ const earned = latestMoves.filter(m => m.moveReason.toLowerCase() === "earned");
+ const community = latestMoves.filter(m => m.custodyLevel === "Community");
+
+ const custodyCounts = new Map();
+ for (const m of latestMoves) {
+ const lvl = m.custodyLevel || "Unknown";
+ custodyCounts.set(lvl, (custodyCounts.get(lvl) ?? 0) + 1);
+ }
+
+ // By facility: earned rate
+ const facilityMap = new Map();
+ for (const m of latestMoves) {
+ const fc = m.facilityCode;
+ const entry = facilityMap.get(fc) ?? { earned: 0, total: 0 };
+ entry.total++;
+ if (m.moveReason.toLowerCase() === "earned") entry.earned++;
+ facilityMap.set(fc, entry);
+ }
+
+ return {
+ earnedHousingCount: earned.length,
+ earnedHousingTotal: total,
+ earnedHousingRate: total > 0 ? Math.round((earned.length / total) * 100) : 0,
+ communityCustodyCount: community.length,
+ communityCustodyRate: total > 0 ? Math.round((community.length / total) * 100) : 0,
+ custodyDistribution: Array.from(custodyCounts.entries())
+ .map(([level, count]) => ({ level, count }))
+ .sort((a, b) => b.count - a.count),
+ earnedByFacility: Array.from(facilityMap.entries())
+ .map(([facilityCode, { earned, total }]) => ({
+ facilityCode, earned, total,
+ rate: total > 0 ? Math.round((earned / total) * 100) : 0,
+ }))
+ .sort((a, b) => b.rate - a.rate),
+ };
+}
diff --git a/src/lib/dataQuality.ts b/src/lib/dataQuality.ts
new file mode 100644
index 0000000..0e27b60
--- /dev/null
+++ b/src/lib/dataQuality.ts
@@ -0,0 +1,342 @@
+import type {
+ ParsedData,
+ DataQualityReport,
+ DataIntegrityCheck,
+ QualityColumnKey,
+ MetricKey,
+ ColumnQualityIssue,
+ MetricQualityEntry,
+ MetricQualityMap,
+ QualitySeverity,
+} from '../types';
+import dataQuality from '../config/data_quality.json';
+
+export interface IngestMeta {
+ junkRowCount?: number;
+ duplicateResidentCount?: number;
+}
+
+export const COLUMN_LABELS: Record = {
+ lsiBand: 'Risk Score (LSI)',
+ custodyLevel: 'Custody Level',
+ educationLevel: 'Education Level',
+ offenseCategory: 'Offense Category',
+ projectedReleaseDate: 'Release Date',
+ completionDates: 'Completion Dates',
+ sessions: 'Session Tracking',
+ attendance: 'Attendance Records',
+};
+
+// null = boolean column (ok if loaded, critical if not)
+const WARN_THRESHOLD = dataQuality.warnThreshold as Record;
+const CRIT_THRESHOLD = dataQuality.critThreshold as Record;
+
+const AFFECTS: Record = {
+ lsiBand: ['facilityMixAdjusted', 'completionByRisk', 'completionByRiskOffense', 'equitySummary'],
+ custodyLevel: ['completionByRisk'],
+ educationLevel: ['completionByEducation', 'completionByRiskOffense', 'equitySummary'],
+ offenseCategory: ['completionByRiskOffense'],
+ projectedReleaseDate: ['nearReleaseEngagement'],
+ completionDates: ['programCompletion', 'timeToCompletion', 'completionTrends'],
+ sessions: ['attendanceCorrelation', 'qolSignals'],
+ attendance: ['attendanceCorrelation'],
+};
+
+// Reverse map: MetricKey → columns that can affect it
+const METRIC_COLUMNS: Record = (() => {
+ const map: Partial> = {};
+ for (const [col, metrics] of Object.entries(AFFECTS) as [QualityColumnKey, MetricKey[]][]) {
+ for (const m of metrics) {
+ if (!map[m]) map[m] = [];
+ map[m]!.push(col);
+ }
+ }
+ return map as Record;
+})();
+
+const ALL_METRIC_KEYS: MetricKey[] = [
+ 'programCompletion', 'facilityMixAdjusted', 'nearReleaseEngagement',
+ 'completionByRisk', 'completionByEducation', 'completionByRiskOffense',
+ 'attendanceCorrelation', 'timeToCompletion', 'completionTrends',
+ 'equitySummary', 'qolSignals', 'docUlGap',
+];
+
+export function computeDataQuality(
+ data: Omit,
+ meta?: IngestMeta,
+): DataQualityReport {
+ const { residents, enrollments, ulEnrollments, sessions, attendance } = data;
+ const n = residents.length;
+
+ const allEnrollments = [...enrollments, ...ulEnrollments];
+ const completedEnrollments = allEnrollments.filter(e => e.status === 'Completed');
+
+ // ── Missing counts ──────────────────────────────────────────────────────────
+
+ const missingCounts: Record = {
+ lsiBand: residents.filter(r => r.lsiBand === 'Unknown').length,
+ custodyLevel: residents.filter(r => r.custodyLevel === 'Unknown').length,
+ educationLevel: residents.filter(r => r.educationLevel === 'Unknown').length,
+ offenseCategory: residents.filter(r => r.offenseCategory === 'Unknown').length,
+ projectedReleaseDate: residents.filter(r => r.projectedReleaseDate === null).length,
+ completionDates: completedEnrollments.filter(e => e.completionDate === null).length,
+ sessions: sessions.length === 0 ? 1 : 0,
+ attendance: attendance.length === 0 ? 1 : 0,
+ };
+
+ const totalCounts: Record = {
+ lsiBand: n,
+ custodyLevel: n,
+ educationLevel: n,
+ offenseCategory: n,
+ projectedReleaseDate: n,
+ completionDates: completedEnrollments.length,
+ sessions: 1,
+ attendance: 1,
+ };
+
+ // ── Cross-column counts for multi-field metrics ─────────────────────────────
+
+ const lsiKnown = n - missingCounts.lsiBand;
+ const eduKnown = n - missingCounts.educationLevel;
+ const releaseKnown = n - missingCounts.projectedReleaseDate;
+ const completionsWithDate = completedEnrollments.length - missingCounts.completionDates;
+
+ const lsiAndOffenseKnown = residents.filter(
+ r => r.lsiBand !== 'Unknown' && r.offenseCategory !== 'Unknown'
+ ).length;
+ const lsiAndEduKnown = residents.filter(
+ r => r.lsiBand !== 'Unknown' && r.educationLevel !== 'Unknown'
+ ).length;
+
+ const enrolledIds = new Set(allEnrollments.map(e => e.userId).filter(Boolean));
+
+ // ── Per-facility LSI clustering check ──────────────────────────────────────
+ // The overall missing rate can hide a facility-level skew that breaks mix-adjusted
+ // comparisons: if one facility has 35% missing and others have 0%, the adjusted
+ // rate for that facility is unreliable even if the population-wide rate is fine.
+
+ let lsiClustering: ColumnQualityIssue['clusteringFlag'] | undefined;
+ {
+ const byFacility = new Map();
+ for (const r of residents) {
+ const entry = byFacility.get(r.facilityCode) ?? { missing: 0, total: 0 };
+ entry.total += 1;
+ if (r.lsiBand === 'Unknown') entry.missing += 1;
+ byFacility.set(r.facilityCode, entry);
+ }
+ let worstCode = '';
+ let worstPct = 0;
+ for (const [code, { missing, total }] of byFacility) {
+ if (total < dataQuality.lsiClustering.minFacilitySize) continue; // skip tiny facilities — rate is noise
+ const pct = missing / total;
+ if (pct > worstPct) { worstPct = pct; worstCode = code; }
+ }
+ // Flag clustering if the worst facility exceeds the warning threshold
+ // by more than 2× and is worse than the population average
+ const overallLsiPct = n > 0 ? missingCounts.lsiBand / n : 0;
+ const warnT = WARN_THRESHOLD.lsiBand!;
+ if (worstPct >= warnT * dataQuality.lsiClustering.clusterMultiplier && worstPct > overallLsiPct * dataQuality.lsiClustering.populationMultiplier) {
+ lsiClustering = { worstFacilityCode: worstCode, worstFacilityMissingPct: worstPct };
+ }
+ }
+
+ // ── Column issues ───────────────────────────────────────────────────────────
+
+ const columnIssues: ColumnQualityIssue[] = (Object.keys(AFFECTS) as QualityColumnKey[]).map(col => {
+ const missingCount = missingCounts[col];
+ const totalCount = totalCounts[col];
+ const missingPct = totalCount > 0 ? missingCount / totalCount : 0;
+ const warn = WARN_THRESHOLD[col];
+ const crit = CRIT_THRESHOLD[col];
+
+ // For lsiBand: use the worst-facility rate for severity if clustering was detected,
+ // so a facility-level skew can escalate the grade even when the overall rate looks fine.
+ const effectivePct = (col === 'lsiBand' && lsiClustering)
+ ? Math.max(missingPct, lsiClustering.worstFacilityMissingPct)
+ : missingPct;
+
+ let severity: QualitySeverity;
+ if (warn === null) {
+ severity = missingCount === 0 ? 'ok' : 'critical';
+ } else if (effectivePct > crit!) {
+ severity = 'critical';
+ } else if (effectivePct >= warn) {
+ severity = 'warning';
+ } else {
+ severity = 'ok';
+ }
+
+ return {
+ columnKey: col,
+ label: COLUMN_LABELS[col],
+ missingCount,
+ totalCount,
+ missingPct, // always the population-wide rate — used for display
+ severity,
+ affectedMetrics: AFFECTS[col],
+ ...(col === 'lsiBand' && lsiClustering ? { clusteringFlag: lsiClustering } : {}),
+ };
+ });
+
+ const issueByCol = new Map(columnIssues.map(i => [i.columnKey, i]));
+
+ // ── effectiveN per metric ───────────────────────────────────────────────────
+
+ const metricEffectiveN: Partial> = {
+ 'programCompletion': { effectiveN: completionsWithDate, totalN: completedEnrollments.length },
+ 'facilityMixAdjusted': { effectiveN: lsiKnown, totalN: n },
+ 'nearReleaseEngagement': { effectiveN: releaseKnown, totalN: n },
+ 'completionByRisk': { effectiveN: lsiKnown, totalN: n },
+ 'completionByEducation': { effectiveN: eduKnown, totalN: n },
+ 'completionByRiskOffense':{ effectiveN: lsiAndOffenseKnown, totalN: n },
+ 'timeToCompletion': { effectiveN: completionsWithDate, totalN: completedEnrollments.length },
+ 'completionTrends': { effectiveN: completionsWithDate, totalN: completedEnrollments.length },
+ 'equitySummary': { effectiveN: lsiAndEduKnown, totalN: n },
+ // attendanceCorrelation/qolSignals: boolean (sessions present or not) — no per-resident denominator
+ // docUlGap: no resident-level denominator
+ };
+
+ // ── Metric quality map ──────────────────────────────────────────────────────
+
+ const metricQuality = {} as MetricQualityMap;
+
+ for (const metric of ALL_METRIC_KEYS) {
+ const cols = METRIC_COLUMNS[metric] ?? [];
+ const issues = cols.map(c => issueByCol.get(c)!).filter(Boolean);
+
+ const hasCritical = issues.some(i => i.severity === 'critical');
+ const hasWarning = issues.some(i => i.severity === 'warning');
+ const status: MetricQualityEntry['status'] = hasCritical ? 'blocked' : hasWarning ? 'warning' : 'ok';
+
+ // Driving column: highest missingPct-to-threshold ratio among non-ok columns
+ let drivingIssue: ColumnQualityIssue | undefined;
+ let drivingRatio = -1;
+ for (const issue of issues) {
+ if (issue.severity === 'ok') continue;
+ const threshold = CRIT_THRESHOLD[issue.columnKey] ?? 1.0;
+ const ratio = issue.missingPct / threshold;
+ if (ratio > drivingRatio) {
+ drivingRatio = ratio;
+ drivingIssue = issue;
+ }
+ }
+
+ const entry: MetricQualityEntry = { status };
+ if (drivingIssue) {
+ entry.blockingColumn = drivingIssue.columnKey;
+ entry.missingPct = drivingIssue.missingPct;
+ const thresh = CRIT_THRESHOLD[drivingIssue.columnKey];
+ if (thresh !== null) entry.thresholdPct = thresh;
+ }
+
+ const nInfo = metricEffectiveN[metric];
+ if (nInfo && nInfo.totalN > 0) {
+ entry.effectiveN = nInfo.effectiveN;
+ entry.totalN = nInfo.totalN;
+ }
+
+ metricQuality[metric] = entry;
+ }
+
+ // ── Overall grade ───────────────────────────────────────────────────────────
+
+ const criticalCount = columnIssues.filter(i => i.severity === 'critical').length;
+ const lsiCritical = issueByCol.get('lsiBand')?.severity === 'critical';
+ const completionCritical = issueByCol.get('completionDates')?.severity === 'critical';
+
+ const overallGrade: DataQualityReport['overallGrade'] =
+ criticalCount >= 3 || (lsiCritical && completionCritical) ? 'limited' :
+ criticalCount >= 1 ? 'partial' :
+ 'good';
+
+ // ── Data date range ─────────────────────────────────────────────────────────
+
+ const allDates: number[] = [];
+ for (const e of allEnrollments) {
+ if (e.enrolledDate) allDates.push(e.enrolledDate.getTime());
+ if (e.completionDate) allDates.push(e.completionDate.getTime());
+ }
+ allDates.sort((a, b) => a - b);
+ const earliest = allDates.length > 0 ? new Date(allDates[0]).toISOString().slice(0, 10) : null;
+ const latest = allDates.length > 0 ? new Date(allDates[allDates.length - 1]).toISOString().slice(0, 10) : null;
+
+ // ── Integrity checks ────────────────────────────────────────────────────────
+
+ const integrityChecks: DataIntegrityCheck[] = [];
+
+ if ((meta?.junkRowCount ?? 0) > 0) {
+ integrityChecks.push({
+ key: 'junkRows',
+ label: 'Test rows excluded',
+ detail: `${meta!.junkRowCount} row${meta!.junkRowCount !== 1 ? 's' : ''} with sentinel IDs (TEST/junk) were removed before analysis and do not appear in any chart.`,
+ count: meta!.junkRowCount!,
+ severity: 'info',
+ });
+ }
+
+ if ((meta?.duplicateResidentCount ?? 0) > 0) {
+ integrityChecks.push({
+ key: 'duplicateResidents',
+ label: 'Duplicate roster entries resolved',
+ detail: `${meta!.duplicateResidentCount} resident ID${meta!.duplicateResidentCount !== 1 ? 's' : ''} appeared more than once with conflicting values. The most recent row was kept for each.`,
+ count: meta!.duplicateResidentCount!,
+ severity: 'warning',
+ });
+ }
+
+ const completionBeforeEnrollment = allEnrollments.filter(e =>
+ e.completionDate && e.enrolledDate && e.completionDate < e.enrolledDate
+ ).length;
+ if (completionBeforeEnrollment > 0) {
+ integrityChecks.push({
+ key: 'completionBeforeEnrollment',
+ label: 'Completion dates before enrollment',
+ detail: `${completionBeforeEnrollment} enrollment record${completionBeforeEnrollment !== 1 ? 's have' : ' has'} a completion date earlier than the enrollment date — possible data entry errors. These records are included in charts as-is.`,
+ count: completionBeforeEnrollment,
+ severity: 'warning',
+ });
+ }
+
+ if (allEnrollments.length > dataQuality.minEnrollmentsForSkewCheck) {
+ const statusCounts = new Map();
+ for (const e of allEnrollments) {
+ statusCounts.set(e.status, (statusCounts.get(e.status) ?? 0) + 1);
+ }
+ const [topStatus, topCount] = [...statusCounts.entries()].sort((a, b) => b[1] - a[1])[0];
+ const skewPct = topCount / allEnrollments.length;
+ if (skewPct > dataQuality.statusSkewThreshold) {
+ integrityChecks.push({
+ key: 'statusSkew',
+ label: 'Enrollment status heavily skewed',
+ detail: `${Math.round(skewPct * 100)}% of enrollments show status "${topStatus}". This may mean terminations or completions are not fully recorded in the uploaded file.`,
+ count: topCount,
+ severity: 'warning',
+ });
+ }
+ }
+
+ if (n > 0 && allEnrollments.length > 0) {
+ const participationPct = enrolledIds.size / n;
+ if (participationPct < dataQuality.lowParticipationThreshold) {
+ integrityChecks.push({
+ key: 'lowParticipation',
+ label: 'Very low program participation rate',
+ detail: `Only ${enrolledIds.size} of ${n} residents (${Math.round(participationPct * 100)}%) appear in any enrollment record. Enrollment data may be incomplete, or the roster covers a broader population than the program files.`,
+ count: enrolledIds.size,
+ severity: 'warning',
+ });
+ }
+ }
+
+ return {
+ columnIssues,
+ metricQuality,
+ overallGrade,
+ residentCount: n,
+ enrollmentCount: allEnrollments.length,
+ dataDateRange: { earliest, latest },
+ generatedAt: new Date().toISOString(),
+ integrityChecks,
+ };
+}
diff --git a/src/lib/demoExport.ts b/src/lib/demoExport.ts
new file mode 100644
index 0000000..0c83033
--- /dev/null
+++ b/src/lib/demoExport.ts
@@ -0,0 +1,187 @@
+import type { ParsedData } from "../types";
+import type { DemoScenario } from "../data/mockData";
+import { generateMockData } from "../data/mockData";
+
+function rowsToCSV(rows: Record[]): string {
+ if (rows.length === 0) return "";
+ const headers = Object.keys(rows[0]);
+ const escape = (v: unknown) => {
+ const s = v == null ? "" : String(v);
+ return s.includes(",") || s.includes('"') || s.includes("\n") ? `"${s.replace(/"/g, '""')}"` : s;
+ };
+ return [headers.join(","), ...rows.map(r => headers.map(h => escape(r[h])).join(","))].join("\n");
+}
+
+function dateStr(d: Date | null | undefined): string {
+ if (!d) return "";
+ const dd = String(d.getDate()).padStart(2, "0");
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
+ const yyyy = d.getFullYear();
+ return `${dd}-${mm}-${yyyy}`;
+}
+
+export interface DemoCSVFile {
+ filename: string;
+ content: string;
+}
+
+export function generateDemoCSVFiles(scenario: DemoScenario): DemoCSVFile[] {
+ const data: ParsedData = generateMockData(scenario);
+ const files: DemoCSVFile[] = [];
+
+ // doc_residents — resident roster
+ // Build active job assignment lookup from work assignments
+ const activeJobByResidentId = new Map();
+ for (const w of data.workAssignments) {
+ if (w.isActive && !activeJobByResidentId.has(w.residentId)) {
+ activeJobByResidentId.set(w.residentId, w.roleTitle);
+ }
+ }
+
+ files.push({
+ filename: "doc_residents.csv",
+ content: rowsToCSV(data.residents.map((r, i) => {
+ const isSccp = r.residentType === "SCCP";
+ return {
+ ID: r.id,
+ STATUS_DESC: r.residentType ?? "Incarcerated",
+ LOCATION_TO: isSccp ? "Community" : r.facilityCode,
+ LOC_TYPE_TO: isSccp ? "Adult Probation" : "Adult Facility",
+ LSI_RATING: r.lsiBand === "Unknown" ? "" : r.lsiBand,
+ HIGHEST_ED_LVL: r.educationLevel === "Unknown" ? "" : r.educationLevel,
+ CUSTODY_LVL: r.custodyLevel === "Unknown" ? "" : r.custodyLevel,
+ "Earliest Release Date": r.projectedReleaseDate ? dateStr(r.projectedReleaseDate) : "31-12-9999",
+ SENTENCE_OFFENSE: r.offenseCategory === "Unknown" ? "" : r.offenseCategory,
+ // One row with a bad Offense Date to mirror the real data quality issue
+ "Offense Date": i === 0 ? "-350" : "",
+ COUNTY: "",
+ LAST_PROGRAM_COMPLETED: "",
+ JOB_ASSIGN: activeJobByResidentId.get(r.id) ?? "",
+ ODARA_SCORE: "",
+ STATIC99R_SCORE: "",
+ };
+ })),
+ });
+
+ // doc_programs — program participation
+ const residentById = new Map(data.residents.map(r => [r.id, r]));
+
+ files.push({
+ filename: "doc_programs.csv",
+ content: rowsToCSV(data.enrollments.filter(e => e.source === "doc").map(e => {
+ const res = residentById.get(e.userId);
+ // Mirror real DOC sentinel conventions:
+ // 31-12-9999 = active/no end date set
+ // 31-12-1899 = null/unset (program ended without a recorded date)
+ let terminationDate: string;
+ if (e.completionDate) {
+ terminationDate = dateStr(e.completionDate);
+ } else if (e.status === "Active" || e.status === "Waitlisted") {
+ terminationDate = "31-12-9999";
+ } else {
+ terminationDate = "31-12-1899";
+ }
+ return {
+ ID: e.userId,
+ GENDER: res?.gender ?? "",
+ Facility: e.facilityCode,
+ "CUSTODY LEVEL": res?.custodyLevel === "Unknown" ? "" : (res?.custodyLevel ?? ""),
+ "Housing Unit": "",
+ "Housing Pod": "",
+ Room: "",
+ Bed: "",
+ Program: e.programName,
+ "Program Status": e.status,
+ "Program Termination Or Completion Date": terminationDate,
+ "Earliest Release Date": res?.projectedReleaseDate ? dateStr(res.projectedReleaseDate) : "31-12-9999",
+ };
+ })),
+ });
+
+ if (data.incidents.length > 0) {
+ files.push({
+ filename: "doc_incidents.csv",
+ content: rowsToCSV(data.incidents.map(i => ({
+ ID: i.residentId,
+ "Incident Date": dateStr(i.incidentDate),
+ "Incident Type": i.incidentType,
+ Severity: i.severity,
+ Sanction: i.sanction,
+ Facility: i.facilityCode,
+ }))),
+ });
+ }
+
+ if (data.workAssignments.length > 0) {
+ files.push({
+ filename: "doc_work_assignments.csv",
+ content: rowsToCSV(data.workAssignments.map(w => ({
+ ID: w.residentId,
+ "Assignment Type": w.assignmentType,
+ "Role Title": w.roleTitle,
+ "Start Date": dateStr(w.startDate),
+ "End Date": dateStr(w.endDate),
+ "Transferable Skills Flag": w.transferableSkills ? "Yes" : "No",
+ Facility: w.facilityCode,
+ }))),
+ });
+ }
+
+ if (data.casePlans.length > 0) {
+ files.push({
+ filename: "doc_case_plan.csv",
+ content: rowsToCSV(data.casePlans.map(c => ({
+ ID: c.residentId,
+ "State ID Obtained": c.stateIdObtained ? "Yes" : "No",
+ "State ID Date": dateStr(c.stateIdDate),
+ "Job Lined Up": c.jobLinedUp ? "Yes" : "No",
+ "Housing Plan": c.housingPlan ? "Yes" : "No",
+ "Trust Account Balance": c.trustBalance,
+ "Savings Goal Met": c.savingsGoalMet ? "Yes" : "No",
+ }))),
+ });
+ }
+
+ if (data.credentials.length > 0) {
+ files.push({
+ filename: "doc_credentials.csv",
+ content: rowsToCSV(data.credentials.map(c => ({
+ ID: c.residentId,
+ "Credential Type": c.credentialType,
+ "Credential Name": c.credentialName,
+ "Date Earned": dateStr(c.dateEarned),
+ "Issuing Body": c.issuingBody,
+ Verified: c.verified ? "Yes" : "No",
+ }))),
+ });
+ }
+
+ if (data.housingMoves.length > 0) {
+ files.push({
+ filename: "doc_housing_history.csv",
+ content: rowsToCSV(data.housingMoves.map(m => ({
+ ID: m.residentId,
+ "Move Date": dateStr(m.moveDate),
+ "Housing Unit": m.housingUnit,
+ Pod: "",
+ Room: "",
+ Bed: "",
+ "Custody Level": m.custodyLevel,
+ "Move Reason": m.moveReason,
+ Facility: m.facilityCode,
+ }))),
+ });
+ }
+
+ return files;
+}
+
+export function downloadCSV(filename: string, content: string) {
+ const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/lib/ingest.ts b/src/lib/ingest.ts
new file mode 100644
index 0000000..b629742
--- /dev/null
+++ b/src/lib/ingest.ts
@@ -0,0 +1,754 @@
+import Papa from "papaparse";
+import facilitiesConfig from "../config/facilities.json";
+import type {
+ RawResidentRow, RawProgramParticipationRow,
+ RawDbUser, RawDbFacility, RawDbProgram, RawDbProgramClass,
+ RawDbEnrollment, RawDbCompletion, RawDbProgramClassEvent,
+ RawDbEventAttendance, RawDbUserSessionTracking,
+ RawCompletion, RawProgramClass, RawProgramClassEvent,
+ RawEventAttendance, RawUserSessionTracking,
+ Facility, Program, Resident, EnrollmentRecord,
+ ParsedData, ProgramCrosswalkEntry,
+ RawIncidentRow, RawWorkAssignmentRow, RawCasePlanRow, RawCredentialRow, RawHousingMoveRow,
+ IncidentRecord, WorkAssignmentRecord, CasePlanRecord, CredentialRecord, HousingMoveRecord,
+} from "../types";
+import { computeDataQuality, type IngestMeta } from "./dataQuality";
+
+// ── Utilities ─────────────────────────────────────────────────────────────────
+
+function parseDate(s: string): Date | null {
+ if (!s || s.trim() === "" || s === "NULL" || s === "null") return null;
+ const t = s.trim();
+ // Reject bare integers / negative numbers (e.g. "-350" bad DOC export artifact)
+ if (/^-?\d+$/.test(t)) return null;
+ // Sentinel dates used by DOC exports — both mean "no real date"
+ if (t.includes("9999") || t.includes("1899")) return null;
+ // DD-MM-YYYY format used by sample data (e.g. "16-04-2020")
+ const ddMmYyyy = t.match(/^(\d{2})-(\d{2})-(\d{4})$/);
+ if (ddMmYyyy) {
+ const d = new Date(`${ddMmYyyy[3]}-${ddMmYyyy[2]}-${ddMmYyyy[1]}`);
+ return isNaN(d.getTime()) ? null : d;
+ }
+ const d = new Date(t);
+ return isNaN(d.getTime()) ? null : d;
+}
+
+function monthsFromNow(d: Date | null): number | null {
+ if (!d) return null;
+ const now = new Date();
+ return (d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30.44);
+}
+
+function monthsSince(d: Date | null): number | null {
+ if (!d) return null;
+ const now = new Date();
+ return (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24 * 30.44);
+}
+
+async function parseCsv(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ Papa.parse(file, {
+ header: true,
+ skipEmptyLines: true,
+ complete: (results) => resolve(results.data),
+ error: (err) => reject(err),
+ });
+ });
+}
+
+async function parseXlsx(file: File): Promise {
+ const XLSX = await import("xlsx");
+ const buffer = await file.arrayBuffer();
+ const wb = XLSX.read(buffer, { type: "array" });
+ const ws = wb.Sheets[wb.SheetNames[0]];
+ // raw: false formats all cells as strings, matching PapaParse output
+ return XLSX.utils.sheet_to_json(ws, { defval: "", raw: false });
+}
+
+function parseFile(file: File): Promise {
+ return file.name.toLowerCase().endsWith(".xlsx") ? parseXlsx(file) : parseCsv(file);
+}
+
+// ── Facility config ───────────────────────────────────────────────────────────
+
+const FACILITY_CODE_TO_NAME: Record = Object.fromEntries(
+ facilitiesConfig.map((f) => [f.code, f.name])
+);
+
+const JUNK_IDS = new Set(["978592", "955737", "990067", "937618", "971511", "998380"]);
+
+// Bigram Dice coefficient — handles typos and partial matches well.
+function bigramSimilarity(a: string, b: string): number {
+ const norm = (s: string) => s.toLowerCase().replace(/[^a-z]/g, "");
+ const bigrams = (s: string): Set => {
+ const set = new Set();
+ for (let i = 0; i < s.length - 1; i++) set.add(s.slice(i, i + 2));
+ return set;
+ };
+ const aN = norm(a), bN = norm(b);
+ if (!aN || !bN) return 0;
+ const aB = bigrams(aN), bB = bigrams(bN);
+ let intersection = 0;
+ for (const bg of aB) if (bB.has(bg)) intersection++;
+ return (2 * intersection) / (aB.size + bB.size);
+}
+
+// Returns a resolver that applies three steps in order:
+// 1. Dynamic map built from doc_residents/doc_programs ID cross-reference
+// 2. Fuzzy match against facilities.json names (threshold 0.7)
+// 3. Treat the raw string as a code (uppercase it)
+function makeResolver(dynamicMap: Map): (raw: string) => string {
+ return function resolveCode(raw: string): string {
+ if (!raw) return "UNK";
+ const key = raw.trim().toLowerCase();
+
+ const fromMap = dynamicMap.get(key);
+ if (fromMap) return fromMap;
+
+ let best = "", bestScore = 0;
+ for (const f of facilitiesConfig) {
+ const score = bigramSimilarity(raw, f.name);
+ if (score > bestScore) { bestScore = score; best = f.code; }
+ }
+ if (bestScore >= 0.7) return best;
+
+ return raw.trim().toUpperCase();
+ };
+}
+
+// ── Education level normalization ─────────────────────────────────────────────
+
+function normalizeEducation(raw: string): string {
+ const s = raw.trim();
+ if (!s) return "Unknown";
+ // Numeric grade levels 1–11 → No HS diploma
+ if (/^([1-9]|1[01])$/.test(s)) return "No HS diploma";
+ const l = s.toLowerCase();
+ if (/^12$|hs diploma|high school diploma|^diploma/.test(l)) return "HS/GED";
+ if (/\bged\b/.test(l)) return "HS/GED";
+ if (/some college|associate|bachelor|master|college degree/i.test(l)) return "Some college+";
+ if (/no hs|less than|below hs/i.test(l)) return "No HS diploma";
+ return s;
+}
+
+// ── Offense category normalization ───────────────────────────────────────────
+
+function normalizeOffenseCategory(raw: string): string {
+ const s = (raw ?? "").trim().toLowerCase();
+ if (!s || s === "null" || s === "unknown" || s === "n/a") return "Unknown";
+ // Sex/Registry takes highest priority — check before Person
+ if (/sex|rape|indecent|lewd|fondl|molestation|registry|sorna|obscen|pornograph/.test(s)) return "Sex/Registry";
+ // Person offenses (robbery is person, not property)
+ if (/assault|battery|robbery|kidnap|homicide|murder|manslaughter|weapon|firearm|threat|terroriz|stalk|reckless endangerment/.test(s)) return "Person";
+ // Drug offenses
+ if (/drug|narco|cocaine|heroin|fentanyl|opioid|controlled substance|trafficking|marijuana|cannabis|possess/.test(s)) return "Drug";
+ // Property offenses (robbery excluded — already caught above)
+ if (/theft|burglary|larceny|property|fraud|forgery|vandalism|arson|trespass|receiving stolen/.test(s)) return "Property";
+ return "Other";
+}
+
+// ── Program type classification ───────────────────────────────────────────────
+
+function deriveProgramType(name: string): string {
+ const n = name.toLowerCase();
+ if (/\bsud\b|msud|substance|treatment tier|helping men recover|recover/.test(n)) return "SUD";
+ // Matches Python is_ed_program: HiSET, HSED, College, Associate, edu
+ if (/hiset|hsed|college|associate|\bedu\b|academic|\bged\b|diploma|literacy|reading|math|stem/.test(n)) return "Education";
+ if (/cbi|cbt|cognitive|anger|challenge program|ipv|domestic|behavior|thinking|r&r|nonviolent|rational/.test(n)) return "Cognitive-Behavioral";
+ if (/vocation|trade|welding|carpentry|garden|culinary|cooking|food|computer|tech|craft|barber|cosm/.test(n)) return "Vocational";
+ return "Other";
+}
+
+// ── Custody level normalization ───────────────────────────────────────────────
+
+function normalizeCustodyLevel(raw: string): string {
+ const s = (raw ?? "").trim().toLowerCase();
+ if (!s || s === "test") return "Unknown";
+ if (s === "minimum") return "Minimum";
+ if (s === "medium") return "Medium";
+ if (s === "close") return "Close";
+ if (s === "community") return "Community";
+ if (s === "administrative") return "Administrative";
+ if (s === "unclassified") return "Unclassified";
+ if (s === "smu") return "SMU";
+ return (raw ?? "").trim();
+}
+
+// ── Program status normalization ──────────────────────────────────────────────
+
+function normalizeEnrollmentStatus(raw: string): string {
+ const s = raw.trim().toLowerCase();
+ if (s === "active") return "Active";
+ if (s === "completed") return "Completed";
+ if (s === "waitlisted" || s === "waitlist" || s === "wait listed") return "Waitlisted";
+ if (s === "discontinued" || s === "terminated" || s === "dropped" || s === "expelled") return "Dropped";
+ return "Dropped";
+}
+
+// ── DOC-based ingest ──────────────────────────────────────────────────────────
+
+function buildCoreFromDOC(
+ residentRows: RawResidentRow[],
+ programRows: RawProgramParticipationRow[],
+ resolveCode: (raw: string) => string,
+): Pick {
+
+ // Build facility map from program codes + resident full names
+ const facilityMap = new Map();
+
+ const addFacility = (code: string) => {
+ const c = code.toUpperCase();
+ if (!facilityMap.has(c)) {
+ facilityMap.set(c, {
+ id: c,
+ code: c,
+ name: FACILITY_CODE_TO_NAME[c] ?? c,
+ region: "",
+ });
+ }
+ };
+
+ for (const row of programRows) {
+ if (row.Facility) addFacility(row.Facility.trim());
+ }
+ for (const row of residentRows) {
+ if (row.LOCATION_TO) {
+ const code = resolveCode(row.LOCATION_TO);
+ addFacility(code);
+ }
+ }
+
+ // Build program map from unique program names in doc_programs
+ const programMap = new Map(); // name → Program
+ const programByName = new Map(); // name → id
+
+ for (const row of programRows) {
+ const name = row.Program?.trim();
+ if (!name || programByName.has(name)) continue;
+ const id = `prog_${programByName.size}`;
+ programByName.set(name, id);
+ programMap.set(id, {
+ id,
+ name,
+ type: deriveProgramType(name),
+ category: "",
+ });
+ }
+
+ // Build gender supplement from doc_programs (first occurrence per ID wins)
+ const genderById = new Map();
+ const programFacilityById = new Map(); // doc_id → facility code
+ for (const row of programRows) {
+ const id = row.ID?.trim();
+ if (!id) continue;
+ if (!genderById.has(id) && row.GENDER) genderById.set(id, row.GENDER.trim());
+ if (!programFacilityById.has(id) && row.Facility) programFacilityById.set(id, row.Facility.trim().toUpperCase());
+ }
+
+ // Build residents from doc_residents
+ const residents: Resident[] = residentRows.map((row) => {
+ const id = row.ID?.trim();
+ const releaseDate = parseDate(row["Earliest Release Date"]);
+ const facilityCode = resolveCode(row.LOCATION_TO);
+ return {
+ id,
+ facilityId: facilityCode,
+ facilityCode,
+ lsiBand: row.LSI_RATING?.trim() || "Unknown",
+ custodyLevel: row.CUSTODY_LVL?.trim() || "Unknown",
+ educationLevel: normalizeEducation(row.HIGHEST_ED_LVL),
+ residentType: row.STATUS_DESC?.trim() || "Incarcerated",
+ projectedReleaseDate: releaseDate,
+ admissionDate: null, // not in doc_residents
+ age: 0, // not in doc_residents
+ gender: genderById.get(id) ?? "Unknown",
+ monthsToRelease: monthsFromNow(releaseDate),
+ offenseCategory: normalizeOffenseCategory(row.SENTENCE_OFFENSE),
+ jobAssign: row.JOB_ASSIGN?.trim() || undefined,
+ };
+ });
+
+ // Build enrollments from doc_programs
+ const enrollments: EnrollmentRecord[] = programRows
+ .filter((row) => row.Program?.trim())
+ .map((row, i) => {
+ const userId = row.ID?.trim();
+ const programName = row.Program?.trim();
+ const programId = programByName.get(programName) ?? `unknown_${i}`;
+ const facilityCode = row.Facility?.trim().toUpperCase() ?? "UNK";
+ const completionDate = parseDate(row["Program Termination Or Completion Date"]);
+ const status = normalizeEnrollmentStatus(row["Program Status"]);
+
+ return {
+ enrollmentId: `e_${i}`,
+ userId,
+ classId: "",
+ programId,
+ facilityId: facilityCode,
+ facilityCode,
+ status,
+ enrolledDate: null,
+ completionDate,
+ programName,
+ programType: deriveProgramType(programName),
+ source: "doc" as const,
+ };
+ });
+
+ return {
+ facilities: Array.from(facilityMap.values()),
+ programs: Array.from(programMap.values()),
+ residents,
+ enrollments,
+ };
+}
+
+// ── UL-based ingest ───────────────────────────────────────────────────────────
+
+function buildCoreFromUL(
+ rawDbUsers: RawDbUser[],
+ rawDbFacilities: RawDbFacility[],
+ rawDbPrograms: RawDbProgram[],
+ rawDbClasses: RawDbProgramClass[],
+ rawDbEnrollments: RawDbEnrollment[],
+): Pick {
+
+ // Facilities: DB name field is the code (lowercase)
+ const facilityMap = new Map();
+ for (const f of rawDbFacilities) {
+ const code = f.name.trim().toUpperCase();
+ facilityMap.set(f.id, {
+ id: f.id,
+ code,
+ name: FACILITY_CODE_TO_NAME[code] ?? f.name,
+ region: "",
+ });
+ }
+
+ // Programs: DB id + name
+ const programMap = new Map();
+ for (const p of rawDbPrograms) {
+ programMap.set(p.id, {
+ id: p.id,
+ name: p.name,
+ type: deriveProgramType(p.name),
+ category: p.funding_type ?? "",
+ });
+ }
+
+ // Class lookup: class_id → { program_id, facility_id }
+ const classMap = new Map();
+ for (const c of rawDbClasses) classMap.set(c.id, c);
+
+ // Residents: DB users have limited demographics
+ const residents: Resident[] = rawDbUsers
+ .filter((u) => u.role === "student" || !u.role)
+ .map((u) => {
+ const fac = facilityMap.get(u.facility_id);
+ return {
+ id: u.doc_id || u.id,
+ facilityId: u.facility_id,
+ facilityCode: fac?.code ?? "UNK",
+ lsiBand: "Unknown",
+ custodyLevel: "Unknown",
+ educationLevel: "Unknown",
+ residentType: "Incarcerated",
+ projectedReleaseDate: null,
+ admissionDate: parseDate(u.created_at),
+ age: 0,
+ gender: "Unknown",
+ monthsToRelease: null,
+ offenseCategory: "Other",
+ };
+ });
+
+ // Map platform user_id → doc_id (used to translate enrollment user IDs)
+ const platToDocId = new Map();
+ for (const u of rawDbUsers) {
+ if (u.doc_id) platToDocId.set(u.id, u.doc_id);
+ }
+ const resolveUserId = (id: string) => platToDocId.get(id) ?? id;
+
+ // Enrollments from DB
+ const enrollments: EnrollmentRecord[] = rawDbEnrollments.map((e) => {
+ const cls = classMap.get(e.class_id);
+ const prog = cls ? programMap.get(cls.program_id) : undefined;
+ const fac = cls ? facilityMap.get(cls.facility_id) : undefined;
+ return {
+ enrollmentId: e.id,
+ userId: resolveUserId(e.user_id),
+ classId: e.class_id,
+ programId: cls?.program_id ?? "",
+ facilityId: cls?.facility_id ?? "",
+ facilityCode: fac?.code ?? "UNK",
+ status: normalizeEnrollmentStatus(e.enrollment_status),
+ enrolledDate: parseDate(e.enrolled_at),
+ completionDate: parseDate(e.enrollment_ended_at),
+ programName: prog?.name ?? "Unknown",
+ programType: prog ? deriveProgramType(prog.name) : "Other",
+ source: "ul" as const,
+ };
+ });
+
+ return { facilities: Array.from(facilityMap.values()), programs: Array.from(programMap.values()), residents, enrollments };
+}
+
+// ── Program name fuzzy matching (Dice coefficient on bigrams) ─────────────────
+
+function normProgName(s: string): string {
+ return s.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
+}
+
+function diceSimilarity(a: string, b: string): number {
+ const na = normProgName(a), nb = normProgName(b);
+ if (na === nb) return 1.0;
+ if (na.length < 2 || nb.length < 2) return 0;
+ const bigrams = (s: string) => {
+ const set = new Map();
+ for (let i = 0; i < s.length - 1; i++) {
+ const bg = s.slice(i, i + 2);
+ set.set(bg, (set.get(bg) ?? 0) + 1);
+ }
+ return set;
+ };
+ const ba = bigrams(na), bb = bigrams(nb);
+ let intersection = 0;
+ for (const [k, v] of ba) intersection += Math.min(v, bb.get(k) ?? 0);
+ const total = Array.from(ba.values()).reduce((a, b) => a + b, 0) + Array.from(bb.values()).reduce((a, b) => a + b, 0);
+ return total === 0 ? 0 : (2 * intersection) / total;
+}
+
+// ── Public ingestion entry point ──────────────────────────────────────────────
+
+export async function ingestFiles(fileMap: Record): Promise {
+ const hasResidents = !!fileMap["doc_residents"];
+ const hasPrograms = !!fileMap["doc_programs"];
+ const hasDbUsers = !!fileMap["users"];
+
+ const [
+ residentRows, programRows,
+ rawDbUsers, rawDbFacilities, rawDbPrograms, rawDbClasses, rawDbEnrollments,
+ rawDbCompletions, _rawDbEvents, rawDbAttendance, rawDbSessions,
+ rawCrosswalkRows,
+ rawIncidents, rawWorkAssignments, rawCasePlan, rawCredentials, rawHousingHistory,
+ ] = await Promise.all([
+ hasResidents ? parseFile(fileMap["doc_residents"]) : Promise.resolve([] as RawResidentRow[]),
+ hasPrograms ? parseFile(fileMap["doc_programs"]) : Promise.resolve([] as RawProgramParticipationRow[]),
+ hasDbUsers ? parseCsv(fileMap["users"]) : Promise.resolve([] as RawDbUser[]),
+ fileMap["facilities"] ? parseCsv(fileMap["facilities"]) : Promise.resolve([] as RawDbFacility[]),
+ fileMap["programs"] ? parseCsv(fileMap["programs"]) : Promise.resolve([] as RawDbProgram[]),
+ fileMap["program_classes"] ? parseCsv(fileMap["program_classes"]) : Promise.resolve([] as RawDbProgramClass[]),
+ fileMap["program_class_enrollments"]? parseCsv(fileMap["program_class_enrollments"]) : Promise.resolve([] as RawDbEnrollment[]),
+ fileMap["program_completions"] ? parseCsv(fileMap["program_completions"]) : Promise.resolve([] as RawDbCompletion[]),
+ fileMap["program_class_events"] ? parseCsv(fileMap["program_class_events"]) : Promise.resolve([] as RawDbProgramClassEvent[]),
+ fileMap["program_class_event_attendance"] ? parseCsv(fileMap["program_class_event_attendance"]) : Promise.resolve([] as RawDbEventAttendance[]),
+ fileMap["user_session_tracking"] ? parseCsv(fileMap["user_session_tracking"]) : Promise.resolve([] as RawDbUserSessionTracking[]),
+ fileMap["program_crosswalk"] ? parseCsv>(fileMap["program_crosswalk"]) : Promise.resolve([] as Record[]),
+ fileMap["incidents"] ? parseFile(fileMap["incidents"]) : Promise.resolve([] as RawIncidentRow[]),
+ fileMap["work_assignments"] ? parseFile(fileMap["work_assignments"]) : Promise.resolve([] as RawWorkAssignmentRow[]),
+ fileMap["case_plan"] ? parseFile(fileMap["case_plan"]) : Promise.resolve([] as RawCasePlanRow[]),
+ fileMap["credentials"] ? parseFile(fileMap["credentials"]) : Promise.resolve([] as RawCredentialRow[]),
+ fileMap["housing_history"] ? parseFile(fileMap["housing_history"]) : Promise.resolve([] as RawHousingMoveRow[]),
+ ]);
+
+ // ── Clean doc_residents: filter junk IDs and dedup by ID (last-wins) ───────
+
+ const ingestMeta: IngestMeta = {};
+
+ const residentJunk = residentRows.filter(r => JUNK_IDS.has(r.ID?.trim()));
+ if (residentJunk.length > 0) ingestMeta.junkRowCount = (ingestMeta.junkRowCount ?? 0) + residentJunk.length;
+
+ const residentClean = residentRows.filter(r => !JUNK_IDS.has(r.ID?.trim()));
+ const residentById = new Map();
+ for (const row of residentClean) {
+ const id = row.ID?.trim();
+ if (!id) continue;
+ if (residentById.has(id)) ingestMeta.duplicateResidentCount = (ingestMeta.duplicateResidentCount ?? 0) + 1;
+ residentById.set(id, row);
+ }
+ const residentDeduped = Array.from(residentById.values());
+
+ // ── Build facility resolver ──────────────────────────────────────────────────
+ // Cross-reference doc_residents LOCATION_TO with doc_programs Facility codes by ID.
+ const programCodeById = new Map