From efcffb5f50148ad4d38efa7290bf31eef669cb80 Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:23:48 -0600 Subject: [PATCH 01/14] docs: improve CLAUDE.md accuracy and update CHARTER.md CLAUDE.md: - Fix architecture: single worker, not 4 aspirational ones - Clarify Wave Accounting comes via ChittyFinance, not direct - Add auth middleware flow documentation - Add cron schedule with CT times - Document Hyperdrive binding for DB - Fix CORS origins to match actual config - Add key files: auth.ts, cron.ts, integrations.ts, bridge.ts, mcp.ts - Add secret management commands - Remove stale Active Disputes section (operational, not dev guidance) CHARTER.md: - Update compliance checklist: service now registered Co-Authored-By: Claude Opus 4.6 --- CHARTER.md | 2 +- CLAUDE.md | 76 +++++++++++++++++++++++++++++++++--------------------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/CHARTER.md b/CHARTER.md index fe852b8..2edeff8 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -102,7 +102,7 @@ Provide a unified life management and action dashboard that ingests data from 15 ## Compliance -- [ ] Service registered in ChittyRegistry +- [x] Service registered in ChittyRegister (03-1-USA-3846-T-2602-0-57, pending_cert) - [x] Health endpoint operational at /health - [x] Status endpoint operational at /api/v1/status - [x] CLAUDE.md development guide present diff --git a/CLAUDE.md b/CLAUDE.md index 98599af..821d041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,68 +5,86 @@ ChittyCommand is a unified life management and action dashboard for the ChittyOS ecosystem. It ingests data from 15+ financial, legal, and administrative sources, scores urgency with AI, recommends actions, and executes them via APIs, email, or browser automation. **Repo:** `CHITTYOS/chittycommand` -**Deploy:** Cloudflare Workers + Pages at `command.chitty.cc` -**Stack:** Hono TypeScript, React + Shadcn UI, Neon PostgreSQL, Cloudflare R2 +**Deploy:** Cloudflare Workers at `command.chitty.cc` +**Stack:** Hono TypeScript, React + Tailwind, Neon PostgreSQL (via Hyperdrive), Cloudflare R2/KV +**Canonical URI:** `chittycanon://core/services/chittycommand` | Tier 5 ## Common Commands ```bash npm run dev # Start Hono dev server (wrangler dev) npm run deploy # Deploy to Cloudflare Workers -npm run ui:dev # Start React frontend dev server +npm run ui:dev # Start React frontend dev server (localhost:5173) npm run ui:build # Build frontend for Pages npm run db:generate # Generate Drizzle migrations npm run db:migrate # Run Drizzle migrations ``` -## Architecture +Secrets are managed via wrangler (never hardcode): +```bash +wrangler secret put PLAID_CLIENT_ID +wrangler secret put PLAID_SECRET +wrangler secret put DATABASE_URL +``` -### Workers +## Architecture -| Worker | Domain | Role | -|--------|--------|------| -| command-api | command.chitty.cc | Core API, CRUD, action execution | -| command-ingest | Cron-triggered | Data ingestion from all sources | -| command-ai | Internal | AI triage, urgency scoring, recommendations | -| command-ui | app.command.chitty.cc | React SPA (Cloudflare Pages) | +Single Cloudflare Worker (`chittycommand`) serving API + cron. Frontend is a separate React SPA at `app.command.chitty.cc` (Cloudflare Pages). ### Data Sources -**API Sources (auto-sync):** Mercury, Wave, Stripe, TurboTenant, ChittyRental/ChittyFinance +**Via ChittyFinance (auto-sync):** Mercury, Wave Accounting, Stripe, Plaid +**Direct API:** ChittyBooks, ChittyAssets, ChittyCharge, ChittyLedger +**Via ChittyScrape (bridge):** Mr. Cooper mortgage, Cook County property tax, Court docket **Email Parse:** ComEd, Peoples Gas, Xfinity, Citi, Home Depot, Lowe's -**Scraper:** Mr. Cooper, Cook County property tax, Court docket **Manual:** IRS quarterly, HOA fees, Personal loans **Historical Only:** DoorLoop (sunset, data archived) +### Auth Flow + +Three auth layers in `src/middleware/auth.ts`: +1. **`authMiddleware`** (`/api/*`) — KV token lookup, then ChittyAuth fallback +2. **`bridgeAuthMiddleware`** (`/api/bridge/*`) — Service token OR user token +3. **`mcpAuthMiddleware`** (`/mcp/*`) — Shared service token from KV (bypassed in dev) + +### Cron Schedule + +Defined in `wrangler.toml`, dispatched via `src/lib/cron.ts`: +- `0 12 * * *` — Daily 6 AM CT: Plaid + ChittyFinance sync +- `0 13 * * *` — Daily 7 AM CT: Court docket check +- `0 14 * * 1` — Weekly Mon 8 AM CT: Utility scrapers +- `0 15 1 * *` — Monthly 1st 9 AM CT: Mortgage, property tax + ### Database -Neon PostgreSQL with `cc_` prefixed tables. Schema in `src/db/schema.ts`, SQL migrations in `migrations/`. +Neon PostgreSQL via Hyperdrive binding. All tables prefixed `cc_`. Schema in `src/db/schema.ts`, SQL migrations in `migrations/` (0001-0007). ### Action Execution Three modes: -1. **API** — Mercury transfers, Stripe payments, TurboTenant/ChittyRental +1. **API** — Mercury transfers, Stripe payments via bridge routes 2. **Claude in Chrome** — Browser automation for portals without APIs 3. **Email** — Dispute letters, follow-ups via Cloudflare Email Workers -## Active Disputes - -1. **Xfinity** — Pricing/credit dispute (priority 2) -2. **Commodore Green Briar Landmark Condo Association** — HOA dispute (priority 3) -3. **Fox Rental** — $14K+ reclaim (priority 1) - ## Key Files -- `src/index.ts` — Hono API entry point -- `src/db/schema.ts` — Drizzle schema for all tables +- `src/index.ts` — Hono entry point, route mounting, health/status endpoints +- `src/middleware/auth.ts` — Auth middleware (user, bridge, MCP) +- `src/lib/cron.ts` — Cron sync orchestrator (all data sources) +- `src/lib/integrations.ts` — Service clients (Mercury, Plaid, ChittyScrape, etc.) - `src/lib/urgency.ts` — Deterministic urgency scoring engine -- `src/routes/` — API route handlers -- `migrations/` — SQL migration files -- `ui/` — React frontend +- `src/lib/validators.ts` — Zod schemas for request validation +- `src/routes/bridge.ts` — Inter-service bridge (scrape, ledger, finance, Plaid) +- `src/routes/mcp.ts` — MCP server for Claude integration +- `src/routes/dashboard.ts` — Dashboard summary with urgency scoring +- `src/db/schema.ts` — Drizzle schema for all cc_* tables +- `migrations/` — SQL migration files (0001-0007) +- `ui/` — React frontend (Vite + Tailwind) ## Security -- Credentials via 1Password (`op run`) -- No hardcoded secrets +- Credentials via 1Password (`op run`) — never expose in terminal output +- Secrets via `wrangler secret put` — never in `[vars]` - R2 for document storage (zero egress) -- CORS restricted to `app.command.chitty.cc` and localhost +- CORS restricted to `app.command.chitty.cc`, `command.mychitty.com`, `chittycommand-ui.pages.dev`, `localhost:5173` +- Service tokens stored in KV: `bridge:service_token`, `mcp:service_token`, `scrape:service_token` From 50f3278bc07bf6bb91aa15d17bea244bc43d4b5c Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:26:03 -0600 Subject: [PATCH 02/14] feat(ui): add Outfit/JetBrains Mono fonts, recharts, react-grid-layout Install react-grid-layout and recharts for the dashboard layout and charting. Add Outfit (display) and JetBrains Mono (monospace) Google Fonts. Replace index.css with design token CSS variables for the new dark-chrome/light-card visual system, urgency colors, and brand palette. Co-Authored-By: Claude Opus 4.6 --- ui/index.html | 3 + ui/package-lock.json | 492 ++++++++++++++++++++++++++++++++++++++++++- ui/package.json | 7 +- ui/src/index.css | 48 ++++- 4 files changed, 541 insertions(+), 9 deletions(-) diff --git a/ui/index.html b/ui/index.html index 07782e4..4035aac 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,6 +4,9 @@ ChittyCommand + + +
diff --git a/ui/package-lock.json b/ui/package-lock.json index 0e705f6..e2eeeb0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,12 +12,15 @@ "lucide-react": "^0.400.0", "react": "^18.3.0", "react-dom": "^18.3.0", + "react-grid-layout": "^2.2.2", "react-router-dom": "^6.23.0", + "recharts": "^3.7.0", "tailwind-merge": "^2.3.0" }, "devDependencies": { "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "@types/react-grid-layout": "^1.3.6", "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", @@ -800,6 +803,42 @@ "node": ">= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1166,6 +1205,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1211,6 +1262,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1222,14 +1336,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": 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, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1246,6 +1360,22 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz", + "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1514,9 +1644,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1535,6 +1786,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1556,6 +1813,16 @@ "dev": true, "license": "ISC" }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1605,6 +1872,18 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1733,6 +2012,25 @@ "node": ">= 0.4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1971,7 +2269,6 @@ "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" @@ -2197,6 +2494,23 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/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==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2243,6 +2557,68 @@ "react": "^18.3.1" } }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz", + "integrity": "sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2253,6 +2629,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-resizable": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz", + "integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-router": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", @@ -2308,6 +2698,63 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2545,6 +2992,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2658,6 +3111,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2665,6 +3127,28 @@ "dev": true, "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/ui/package.json b/ui/package.json index 460a687..0d8c553 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,16 +9,19 @@ "preview": "vite preview" }, "dependencies": { + "clsx": "^2.1.0", + "lucide-react": "^0.400.0", "react": "^18.3.0", "react-dom": "^18.3.0", + "react-grid-layout": "^2.2.2", "react-router-dom": "^6.23.0", - "lucide-react": "^0.400.0", - "clsx": "^2.1.0", + "recharts": "^3.7.0", "tailwind-merge": "^2.3.0" }, "devDependencies": { "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "@types/react-grid-layout": "^1.3.6", "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", diff --git a/ui/src/index.css b/ui/src/index.css index 997cf72..54e4f18 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -2,9 +2,51 @@ @tailwind components; @tailwind utilities; +:root { + /* Chrome (dark shell) */ + --chrome-bg: #1a1a2e; + --chrome-surface: #16213e; + --chrome-border: #2a2a4a; + --chrome-text: #e2e8f0; + --chrome-text-muted: #94a3b8; + + /* Cards (light surfaces) */ + --card-bg: #ffffff; + --card-bg-hover: #f8fafc; + --card-border: #e2e8f0; + --card-text: #1e293b; + --card-text-muted: #64748b; + + /* Urgency */ + --urgency-red: #ef4444; + --urgency-amber: #f59e0b; + --urgency-green: #22c55e; + --urgency-red-bg: #fef2f2; + --urgency-amber-bg: #fffbeb; + --urgency-green-bg: #f0fdf4; + + /* Brand */ + --chitty-500: #4c6ef5; + --chitty-600: #3b5bdb; + --chitty-700: #364fc7; +} + body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; - background-color: #0f1117; - color: #e4e5e7; + font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif; + background-color: var(--chrome-bg); + color: var(--chrome-text); + -webkit-font-smoothing: antialiased; +} + +/* Monospace for financial numbers */ +.font-mono { + font-family: 'JetBrains Mono', ui-monospace, monospace; +} + +/* react-grid-layout overrides */ +.react-grid-item.react-grid-placeholder { + background: var(--chitty-500) !important; + opacity: 0.15 !important; + border-radius: 12px !important; } From f606b0c4f4ce56b91063e9205570d48528e96ce8 Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:27:11 -0600 Subject: [PATCH 03/14] feat(ui): extend Tailwind theme with Command Console color system Co-Authored-By: Claude Opus 4.6 --- ui/tailwind.config.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js index 4ef88a8..c6af262 100644 --- a/ui/tailwind.config.js +++ b/ui/tailwind.config.js @@ -3,6 +3,10 @@ export default { content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], theme: { extend: { + fontFamily: { + sans: ['Outfit', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'], + mono: ['JetBrains Mono', 'ui-monospace', 'monospace'], + }, colors: { chitty: { 50: '#f0f4ff', @@ -12,6 +16,28 @@ export default { 700: '#364fc7', 900: '#1b2559', }, + chrome: { + bg: '#1a1a2e', + surface: '#16213e', + border: '#2a2a4a', + text: '#e2e8f0', + muted: '#94a3b8', + }, + card: { + bg: '#ffffff', + hover: '#f8fafc', + border: '#e2e8f0', + text: '#1e293b', + muted: '#64748b', + }, + urgency: { + red: '#ef4444', + amber: '#f59e0b', + green: '#22c55e', + }, + }, + borderRadius: { + card: '12px', }, }, }, From fe07d5a8f4eaf06ee7217fa6f4a7903a6809e270 Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:27:55 -0600 Subject: [PATCH 04/14] feat(ui): add shared Card, MetricCard, urgency, freshness, progress components Co-Authored-By: Claude Opus 4.6 --- ui/src/components/ui/ActionButton.tsx | 29 ++++++++++++++++++++++ ui/src/components/ui/Card.tsx | 34 ++++++++++++++++++++++++++ ui/src/components/ui/FreshnessDot.tsx | 26 ++++++++++++++++++++ ui/src/components/ui/MetricCard.tsx | 22 +++++++++++++++++ ui/src/components/ui/ProgressDots.tsx | 26 ++++++++++++++++++++ ui/src/components/ui/UrgencyBorder.tsx | 12 +++++++++ 6 files changed, 149 insertions(+) create mode 100644 ui/src/components/ui/ActionButton.tsx create mode 100644 ui/src/components/ui/Card.tsx create mode 100644 ui/src/components/ui/FreshnessDot.tsx create mode 100644 ui/src/components/ui/MetricCard.tsx create mode 100644 ui/src/components/ui/ProgressDots.tsx create mode 100644 ui/src/components/ui/UrgencyBorder.tsx diff --git a/ui/src/components/ui/ActionButton.tsx b/ui/src/components/ui/ActionButton.tsx new file mode 100644 index 0000000..f188257 --- /dev/null +++ b/ui/src/components/ui/ActionButton.tsx @@ -0,0 +1,29 @@ +import { cn } from '../../lib/utils'; + +interface ActionButtonProps { + label: string; + onClick: () => void; + variant?: 'primary' | 'secondary' | 'danger'; + loading?: boolean; + disabled?: boolean; + className?: string; +} + +export function ActionButton({ label, onClick, variant = 'primary', loading, disabled, className }: ActionButtonProps) { + const base = 'px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50'; + const variants = { + primary: 'bg-chitty-600 text-white hover:bg-chitty-700', + secondary: 'bg-card-border text-card-text hover:bg-gray-200', + danger: 'bg-urgency-red text-white hover:bg-red-600', + }; + + return ( + + ); +} diff --git a/ui/src/components/ui/Card.tsx b/ui/src/components/ui/Card.tsx new file mode 100644 index 0000000..02276ff --- /dev/null +++ b/ui/src/components/ui/Card.tsx @@ -0,0 +1,34 @@ +import { cn } from '../../lib/utils'; + +interface CardProps { + children: React.ReactNode; + className?: string; + urgency?: 'red' | 'amber' | 'green' | null; + muted?: boolean; + onClick?: () => void; +} + +export function Card({ children, className, urgency, muted, onClick }: CardProps) { + const borderColor = urgency === 'red' + ? 'border-l-urgency-red' + : urgency === 'amber' + ? 'border-l-urgency-amber' + : urgency === 'green' + ? 'border-l-urgency-green' + : 'border-l-transparent'; + + return ( +
+ {children} +
+ ); +} diff --git a/ui/src/components/ui/FreshnessDot.tsx b/ui/src/components/ui/FreshnessDot.tsx new file mode 100644 index 0000000..7571437 --- /dev/null +++ b/ui/src/components/ui/FreshnessDot.tsx @@ -0,0 +1,26 @@ +import { cn } from '../../lib/utils'; + +interface FreshnessDotProps { + status: 'fresh' | 'stale' | 'failed' | 'unknown'; + className?: string; +} + +export function FreshnessDot({ status, className }: FreshnessDotProps) { + const color = status === 'fresh' + ? 'bg-urgency-green' + : status === 'stale' + ? 'bg-urgency-amber' + : status === 'failed' + ? 'bg-urgency-red' + : 'bg-chrome-muted'; + + return ; +} + +export function freshnessFromDate(dateStr: string | null): 'fresh' | 'stale' | 'failed' | 'unknown' { + if (!dateStr) return 'unknown'; + const hours = (Date.now() - new Date(dateStr).getTime()) / (1000 * 60 * 60); + if (hours < 24) return 'fresh'; + if (hours < 72) return 'stale'; + return 'failed'; +} diff --git a/ui/src/components/ui/MetricCard.tsx b/ui/src/components/ui/MetricCard.tsx new file mode 100644 index 0000000..018434d --- /dev/null +++ b/ui/src/components/ui/MetricCard.tsx @@ -0,0 +1,22 @@ +import { cn } from '../../lib/utils'; + +interface MetricCardProps { + label: string; + value: string; + trend?: 'up' | 'down' | null; + className?: string; + valueClassName?: string; +} + +export function MetricCard({ label, value, trend, className, valueClassName }: MetricCardProps) { + return ( +
+

{label}

+
+

{value}

+ {trend === 'up' && } + {trend === 'down' && } +
+
+ ); +} diff --git a/ui/src/components/ui/ProgressDots.tsx b/ui/src/components/ui/ProgressDots.tsx new file mode 100644 index 0000000..ebbced9 --- /dev/null +++ b/ui/src/components/ui/ProgressDots.tsx @@ -0,0 +1,26 @@ +import { cn } from '../../lib/utils'; + +interface ProgressDotsProps { + completed: number; + total: number; + className?: string; +} + +export function ProgressDots({ completed, total, className }: ProgressDotsProps) { + return ( +
+ {Array.from({ length: total }, (_, i) => ( + + ))} + + {completed}/{total} + +
+ ); +} diff --git a/ui/src/components/ui/UrgencyBorder.tsx b/ui/src/components/ui/UrgencyBorder.tsx new file mode 100644 index 0000000..92acded --- /dev/null +++ b/ui/src/components/ui/UrgencyBorder.tsx @@ -0,0 +1,12 @@ +export function urgencyLevel(score: number | null): 'red' | 'amber' | 'green' | null { + if (score === null || score === undefined) return null; + if (score >= 70) return 'red'; + if (score >= 40) return 'amber'; + return 'green'; +} + +export function urgencyFromDays(days: number): 'red' | 'amber' | 'green' { + if (days <= 2) return 'red'; + if (days <= 7) return 'amber'; + return 'green'; +} From 26955e885e84ef3770ad17186d8ea7eb310170f4 Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:30:53 -0600 Subject: [PATCH 05/14] feat(ui): new sidebar + status bar layout with Focus Mode context Co-Authored-By: Claude Opus 4.6 --- ui/src/components/Layout.tsx | 71 +++++---------------------------- ui/src/components/Sidebar.tsx | 65 ++++++++++++++++++++++++++++++ ui/src/components/StatusBar.tsx | 39 ++++++++++++++++++ ui/src/lib/focus-mode.tsx | 36 +++++++++++++++++ ui/src/main.tsx | 35 ++++++++-------- 5 files changed, 170 insertions(+), 76 deletions(-) create mode 100644 ui/src/components/Sidebar.tsx create mode 100644 ui/src/components/StatusBar.tsx create mode 100644 ui/src/lib/focus-mode.tsx diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 9275847..0401a9b 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,66 +1,17 @@ -import { Outlet, NavLink } from 'react-router-dom'; -import { cn } from '../lib/utils'; -import { logout, getUser } from '../lib/auth'; - -const navItems = [ - { path: '/', label: 'Dashboard' }, - { path: '/bills', label: 'Bills' }, - { path: '/disputes', label: 'Disputes' }, - { path: '/accounts', label: 'Accounts' }, - { path: '/legal', label: 'Legal' }, - { path: '/recommendations', label: 'AI Recs' }, - { path: '/cashflow', label: 'Cash Flow' }, - { path: '/upload', label: 'Upload' }, - { path: '/settings', label: 'Settings' }, -]; +import { Outlet } from 'react-router-dom'; +import { Sidebar } from './Sidebar'; +import { StatusBar } from './StatusBar'; export function Layout() { - const user = getUser(); - return ( -
-
-
-

- ChittyCommand -

-
- -
- {user && ( - {user.user_id} - )} - -
-
-
-
-
- -
+
+ +
+ +
+ +
+
); } diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx new file mode 100644 index 0000000..daca729 --- /dev/null +++ b/ui/src/components/Sidebar.tsx @@ -0,0 +1,65 @@ +import { NavLink } from 'react-router-dom'; +import { cn } from '../lib/utils'; +import { + LayoutDashboard, Receipt, ShieldAlert, Wallet, Scale, + Lightbulb, TrendingUp, Upload, Settings, LogOut, +} from 'lucide-react'; +import { logout, getUser } from '../lib/auth'; + +const navItems = [ + { path: '/', label: 'Dashboard', icon: LayoutDashboard }, + { path: '/bills', label: 'Bills', icon: Receipt }, + { path: '/disputes', label: 'Disputes', icon: ShieldAlert }, + { path: '/accounts', label: 'Accounts', icon: Wallet }, + { path: '/legal', label: 'Legal', icon: Scale }, + { path: '/recommendations', label: 'AI Recs', icon: Lightbulb }, + { path: '/cashflow', label: 'Cash Flow', icon: TrendingUp }, + { path: '/upload', label: 'Upload', icon: Upload }, + { path: '/settings', label: 'Settings', icon: Settings }, +]; + +export function Sidebar() { + const user = getUser(); + + return ( + + ); +} diff --git a/ui/src/components/StatusBar.tsx b/ui/src/components/StatusBar.tsx new file mode 100644 index 0000000..c34647e --- /dev/null +++ b/ui/src/components/StatusBar.tsx @@ -0,0 +1,39 @@ +import { useFocusMode } from '../lib/focus-mode'; +import { Eye, EyeOff } from 'lucide-react'; + +interface StatusBarProps { + cashPosition?: string; + nextDue?: string; +} + +export function StatusBar({ cashPosition, nextDue }: StatusBarProps) { + const { focusMode, toggleFocusMode } = useFocusMode(); + + return ( +
+
+ {cashPosition && ( +
+ Cash + {cashPosition} +
+ )} + {nextDue && ( +
+ Next Due + {nextDue} +
+ )} +
+ + +
+ ); +} diff --git a/ui/src/lib/focus-mode.tsx b/ui/src/lib/focus-mode.tsx new file mode 100644 index 0000000..d7477e9 --- /dev/null +++ b/ui/src/lib/focus-mode.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; + +interface FocusModeContextType { + focusMode: boolean; + toggleFocusMode: () => void; +} + +const FocusModeContext = createContext({ + focusMode: true, + toggleFocusMode: () => {}, +}); + +export function FocusModeProvider({ children }: { children: ReactNode }) { + const [focusMode, setFocusMode] = useState(() => { + const saved = localStorage.getItem('chittycommand_focus_mode'); + return saved !== null ? saved === 'true' : true; // default ON + }); + + const toggleFocusMode = useCallback(() => { + setFocusMode((prev) => { + const next = !prev; + localStorage.setItem('chittycommand_focus_mode', String(next)); + return next; + }); + }, []); + + return ( + + {children} + + ); +} + +export function useFocusMode() { + return useContext(FocusModeContext); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index e9f8719..e5560b0 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -13,6 +13,7 @@ import { Recommendations } from './pages/Recommendations'; import { Settings } from './pages/Settings'; import { Login } from './pages/Login'; import { isAuthenticated } from './lib/auth'; +import { FocusModeProvider } from './lib/focus-mode'; import './index.css'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -24,21 +25,23 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { ReactDOM.createRoot(document.getElementById('root')!).render( - - - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + , ); From bfd0c4e4ad1fc1b595e0b90f516d1ba124c7b098 Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:39:26 -0600 Subject: [PATCH 06/14] feat(ui): redesign Dashboard with Focus/Full views and widget components Add FocusView (top 3 urgent items), FullView (metric cards + widget grid), ObligationsWidget, DisputesWidget, DeadlinesWidget, RecommendationsWidget. Dashboard now toggles between views based on Focus Mode context. Co-Authored-By: Claude Opus 4.6 --- .../components/dashboard/DeadlinesWidget.tsx | 37 ++++ .../components/dashboard/DisputesWidget.tsx | 51 +++++ ui/src/components/dashboard/FocusView.tsx | 130 +++++++++++++ ui/src/components/dashboard/FullView.tsx | 44 +++++ .../dashboard/ObligationsWidget.tsx | 56 ++++++ .../dashboard/RecommendationsWidget.tsx | 40 ++++ ui/src/pages/Dashboard.tsx | 180 +----------------- 7 files changed, 368 insertions(+), 170 deletions(-) create mode 100644 ui/src/components/dashboard/DeadlinesWidget.tsx create mode 100644 ui/src/components/dashboard/DisputesWidget.tsx create mode 100644 ui/src/components/dashboard/FocusView.tsx create mode 100644 ui/src/components/dashboard/FullView.tsx create mode 100644 ui/src/components/dashboard/ObligationsWidget.tsx create mode 100644 ui/src/components/dashboard/RecommendationsWidget.tsx diff --git a/ui/src/components/dashboard/DeadlinesWidget.tsx b/ui/src/components/dashboard/DeadlinesWidget.tsx new file mode 100644 index 0000000..89752ec --- /dev/null +++ b/ui/src/components/dashboard/DeadlinesWidget.tsx @@ -0,0 +1,37 @@ +import { Card } from '../ui/Card'; +import { urgencyFromDays } from '../ui/UrgencyBorder'; +import type { LegalDeadline } from '../../lib/api'; +import { formatDate, daysUntil } from '../../lib/utils'; + +interface Props { + deadlines: LegalDeadline[]; +} + +export function DeadlinesWidget({ deadlines }: Props) { + if (deadlines.length === 0) return null; + + return ( +
+

Legal Deadlines

+ {deadlines.map((dl) => { + const days = daysUntil(dl.deadline_date); + return ( + +
+
+

{dl.title}

+

{dl.case_ref}

+
+
+

+ {days > 0 ? `${days}d` : days === 0 ? 'TODAY' : `${Math.abs(days)}d ago`} +

+

{formatDate(dl.deadline_date)}

+
+
+
+ ); + })} +
+ ); +} diff --git a/ui/src/components/dashboard/DisputesWidget.tsx b/ui/src/components/dashboard/DisputesWidget.tsx new file mode 100644 index 0000000..2820cea --- /dev/null +++ b/ui/src/components/dashboard/DisputesWidget.tsx @@ -0,0 +1,51 @@ +import { Card } from '../ui/Card'; +import { ProgressDots } from '../ui/ProgressDots'; +import type { Dispute } from '../../lib/api'; +import { formatCurrency } from '../../lib/utils'; + +const DISPUTE_STAGES = ['filed', 'response_pending', 'in_review', 'resolved']; + +function disputeStageIndex(status: string): number { + const idx = DISPUTE_STAGES.indexOf(status); + return idx >= 0 ? idx : 0; +} + +interface Props { + disputes: Dispute[]; +} + +export function DisputesWidget({ disputes }: Props) { + if (disputes.length === 0) { + return ( +
+

Active Disputes

+

No active disputes

+
+ ); + } + + return ( +
+

Active Disputes

+ {disputes.map((d) => ( + +
+
+

{d.title}

+

vs {d.counterparty}

+ +
+
+ {d.amount_at_stake && ( +

{formatCurrency(d.amount_at_stake)}

+ )} + {d.next_action && ( + {d.next_action} + )} +
+
+
+ ))} +
+ ); +} diff --git a/ui/src/components/dashboard/FocusView.tsx b/ui/src/components/dashboard/FocusView.tsx new file mode 100644 index 0000000..30ed001 --- /dev/null +++ b/ui/src/components/dashboard/FocusView.tsx @@ -0,0 +1,130 @@ +import { Card } from '../ui/Card'; +import { ActionButton } from '../ui/ActionButton'; +import { urgencyLevel } from '../ui/UrgencyBorder'; +import type { DashboardData, Obligation, Recommendation } from '../../lib/api'; +import { formatCurrency, formatDate, daysUntil } from '../../lib/utils'; + +interface FocusViewProps { + data: DashboardData; + onPayNow: (ob: Obligation) => void; + onExecute: (rec: Recommendation) => void; + payingId: string | null; + executingId: string | null; +} + +interface FocusItem { + type: 'obligation' | 'dispute' | 'deadline' | 'recommendation'; + urgency: number; + title: string; + subtitle: string; + metric: string; + action: { label: string; onClick: () => void; loading: boolean }; +} + +export function FocusView({ data, onPayNow, onExecute, payingId, executingId }: FocusViewProps) { + const { obligations, disputes, deadlines, recommendations } = data; + + const items: FocusItem[] = []; + + obligations.urgent.forEach((ob) => { + items.push({ + type: 'obligation', + urgency: ob.urgency_score ?? 0, + title: ob.payee, + subtitle: ob.status === 'overdue' + ? `OVERDUE ${Math.abs(daysUntil(ob.due_date))} days` + : `Due ${formatDate(ob.due_date)}`, + metric: formatCurrency(ob.amount_due), + action: { + label: 'Pay Now', + onClick: () => onPayNow(ob), + loading: payingId === ob.id, + }, + }); + }); + + disputes.forEach((d) => { + items.push({ + type: 'dispute', + urgency: (6 - d.priority) * 20, + title: d.title, + subtitle: `vs ${d.counterparty}`, + metric: d.amount_at_stake ? formatCurrency(d.amount_at_stake) : '', + action: { + label: d.next_action ? 'Take Action' : 'View', + onClick: () => { window.location.href = '/disputes'; }, + loading: false, + }, + }); + }); + + deadlines.forEach((dl) => { + const days = daysUntil(dl.deadline_date); + items.push({ + type: 'deadline', + urgency: dl.urgency_score ?? (days <= 7 ? 80 : 30), + title: dl.title, + subtitle: dl.case_ref, + metric: days > 0 ? `${days}d left` : days === 0 ? 'TODAY' : `${Math.abs(days)}d ago`, + action: { + label: 'View', + onClick: () => { window.location.href = '/legal'; }, + loading: false, + }, + }); + }); + + recommendations.slice(0, 3).forEach((rec) => { + items.push({ + type: 'recommendation', + urgency: (6 - rec.priority) * 15, + title: rec.title, + subtitle: rec.reasoning, + metric: '', + action: { + label: rec.action_type ? 'Execute' : 'View', + onClick: () => rec.action_type ? onExecute(rec) : (window.location.href = '/recommendations'), + loading: executingId === rec.id, + }, + }); + }); + + const top3 = items.sort((a, b) => b.urgency - a.urgency).slice(0, 3); + + if (top3.length === 0) { + return ( +
+
+

All clear

+

Nothing urgent right now.

+
+
+ ); + } + + return ( +
+

Needs your attention

+ {top3.map((item, i) => ( + +
+

{item.title}

+

{item.subtitle}

+
+ {item.metric && ( +

{item.metric}

+ )} + +
+ ))} +
+ ); +} diff --git a/ui/src/components/dashboard/FullView.tsx b/ui/src/components/dashboard/FullView.tsx new file mode 100644 index 0000000..2791d39 --- /dev/null +++ b/ui/src/components/dashboard/FullView.tsx @@ -0,0 +1,44 @@ +import { MetricCard } from '../ui/MetricCard'; +import { ObligationsWidget } from './ObligationsWidget'; +import { DisputesWidget } from './DisputesWidget'; +import { DeadlinesWidget } from './DeadlinesWidget'; +import { RecommendationsWidget } from './RecommendationsWidget'; +import type { DashboardData, Obligation, Recommendation } from '../../lib/api'; +import { formatCurrency } from '../../lib/utils'; + +interface FullViewProps { + data: DashboardData; + onPayNow: (ob: Obligation) => void; + onExecute: (rec: Recommendation) => void; + payingId: string | null; + executingId: string | null; +} + +export function FullView({ data, onPayNow, onExecute, payingId, executingId }: FullViewProps) { + const { summary, obligations, disputes, deadlines, recommendations } = data; + + return ( +
+
+ + + + 0 ? 'text-urgency-red' : 'text-urgency-green'} + /> +
+ +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/ui/src/components/dashboard/ObligationsWidget.tsx b/ui/src/components/dashboard/ObligationsWidget.tsx new file mode 100644 index 0000000..bacf3a1 --- /dev/null +++ b/ui/src/components/dashboard/ObligationsWidget.tsx @@ -0,0 +1,56 @@ +import { Card } from '../ui/Card'; +import { urgencyLevel } from '../ui/UrgencyBorder'; +import type { Obligation } from '../../lib/api'; +import { formatCurrency, formatDate, daysUntil } from '../../lib/utils'; + +interface Props { + obligations: Obligation[]; + onPayNow: (ob: Obligation) => void; + payingId: string | null; +} + +export function ObligationsWidget({ obligations, onPayNow, payingId }: Props) { + if (obligations.length === 0) { + return ( +
+

Upcoming Bills

+

No pending obligations

+
+ ); + } + + return ( +
+

Upcoming Bills

+ {obligations.map((ob) => { + const days = daysUntil(ob.due_date); + return ( + +
+
+

{ob.payee}

+

+ {ob.category} — {ob.status === 'overdue' + ? `${Math.abs(days)}d overdue` + : `Due ${formatDate(ob.due_date)}`} +

+
+
+

{formatCurrency(ob.amount_due)}

+ {ob.status !== 'paid' && ( + + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/ui/src/components/dashboard/RecommendationsWidget.tsx b/ui/src/components/dashboard/RecommendationsWidget.tsx new file mode 100644 index 0000000..e023b4e --- /dev/null +++ b/ui/src/components/dashboard/RecommendationsWidget.tsx @@ -0,0 +1,40 @@ +import { Card } from '../ui/Card'; +import { ActionButton } from '../ui/ActionButton'; +import type { Recommendation } from '../../lib/api'; + +interface Props { + recommendations: Recommendation[]; + onExecute: (rec: Recommendation) => void; + executingId: string | null; +} + +export function RecommendationsWidget({ recommendations, onExecute, executingId }: Props) { + if (recommendations.length === 0) return null; + + return ( +
+

AI Recommendations

+ {recommendations.map((rec) => ( + +
+
+
+ {rec.rec_type} +
+

{rec.title}

+

{rec.reasoning}

+
+ {rec.action_type && ( + onExecute(rec)} + loading={executingId === rec.id} + className="shrink-0" + /> + )} +
+
+ ))} +
+ ); +} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index d293171..9ab55b4 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -1,12 +1,15 @@ import { useEffect, useState, useCallback } from 'react'; import { api, type DashboardData, type Obligation, type Recommendation } from '../lib/api'; -import { formatCurrency, formatDate, daysUntil, urgencyColor, urgencyBg, statusBadgeColor } from '../lib/utils'; +import { useFocusMode } from '../lib/focus-mode'; +import { FocusView } from '../components/dashboard/FocusView'; +import { FullView } from '../components/dashboard/FullView'; export function Dashboard() { const [data, setData] = useState(null); const [error, setError] = useState(null); const [payingId, setPayingId] = useState(null); const [executingId, setExecutingId] = useState(null); + const { focusMode } = useFocusMode(); const reload = useCallback(() => { api.getDashboard().then(setData).catch((e) => setError(e.message)); @@ -40,183 +43,20 @@ export function Dashboard() { } }; - if (error) { + if (error && !data) { return (
-

Failed to load dashboard

-

{error}

-

Make sure the API is running: npm run dev

+

Failed to load dashboard

+

{error}

); } if (!data) { - return
Loading...
; + return
Loading...
; } - const { summary, obligations, disputes, deadlines, recommendations } = data; + const viewProps = { data, onPayNow: handlePayNow, onExecute: handleExecute, payingId, executingId }; - return ( -
- {/* Summary Cards */} -
- - - - 0 ? 'text-red-400' : 'text-green-400'} /> -
- - {/* Urgency Banner */} - {obligations.urgent.length > 0 && obligations.urgent[0].urgency_score && obligations.urgent[0].urgency_score >= 50 && ( -
-
-
- - MOST URGENT - -

- {obligations.urgent[0].payee} — {formatCurrency(obligations.urgent[0].amount_due)} -

-

- {obligations.urgent[0].status === 'overdue' - ? `OVERDUE ${Math.abs(daysUntil(obligations.urgent[0].due_date))} days` - : `Due ${formatDate(obligations.urgent[0].due_date)}`} -

-
- -
-
- )} - -
- {/* Urgent Obligations */} -
-

Upcoming Bills

-
- {obligations.urgent.map((ob) => ( -
-
-
= 70 ? 'bg-red-500' : ob.urgency_score && ob.urgency_score >= 50 ? 'bg-orange-500' : ob.urgency_score && ob.urgency_score >= 30 ? 'bg-yellow-500' : 'bg-green-500'}`} /> -
-

{ob.payee}

-

{ob.category} {ob.due_date ? `- Due ${formatDate(ob.due_date)}` : ''}

-
-
-
-

{formatCurrency(ob.amount_due)}

- - {ob.status} - -
-
- ))} - {obligations.urgent.length === 0 && ( -

No pending obligations

- )} -
-
- - {/* Active Disputes */} -
-

Active Disputes

-
- {disputes.map((d) => ( -
-
-
-

{d.title}

-

vs {d.counterparty}

-
- {d.amount_at_stake && ( - {formatCurrency(d.amount_at_stake)} - )} -
- {d.next_action && ( -

Next: {d.next_action}

- )} -
- ))} - {disputes.length === 0 && ( -

No active disputes

- )} -
-
-
- -
- {/* Legal Deadlines */} -
-

Legal Deadlines

-
- {deadlines.map((dl) => { - const days = daysUntil(dl.deadline_date); - return ( -
-
-

{dl.title}

-

{dl.case_ref}

-
-
-

- {days > 0 ? `${days}d` : days === 0 ? 'TODAY' : `${Math.abs(days)}d ago`} -

-

{formatDate(dl.deadline_date)}

-
-
- ); - })} - {deadlines.length === 0 && ( -

No upcoming deadlines

- )} -
-
- - {/* AI Recommendations */} -
-

AI Recommendations

-
- {recommendations.map((rec) => ( -
-
-
- {rec.rec_type} - {rec.title} -
- #{rec.priority} -
-

{rec.reasoning}

- {rec.action_type && ( - - )} -
- ))} - {recommendations.length === 0 && ( -

No recommendations yet — AI triage will generate these

- )} -
-
-
-
- ); -} - -function SummaryCard({ label, value, color }: { label: string; value: string; color: string }) { - return ( -
-

{label}

-

{value}

-
- ); + return focusMode ? : ; } From cf6da3d4fa8354f51acd85f2a1373b16ccfcfe65 Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:39:38 -0600 Subject: [PATCH 07/14] feat(ui): redesign Bills, Disputes, Accounts, CashFlow pages Bills: card list with urgency borders, loading states. Disputes: ProgressDots for lifecycle, priority badges, expandable panels. Accounts: grouped by type with credit utilization bars. CashFlow: Recharts area chart, scenario panel, MetricCard summaries. Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Accounts.tsx | 35 +++--- ui/src/pages/Bills.tsx | 137 ++++++++++---------- ui/src/pages/CashFlow.tsx | 255 ++++++++++++++------------------------ ui/src/pages/Disputes.tsx | 199 ++++++++++++++--------------- 4 files changed, 278 insertions(+), 348 deletions(-) diff --git a/ui/src/pages/Accounts.tsx b/ui/src/pages/Accounts.tsx index 71ce83f..dfeb52e 100644 --- a/ui/src/pages/Accounts.tsx +++ b/ui/src/pages/Accounts.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { api, type Account } from '../lib/api'; +import { Card } from '../components/ui/Card'; import { formatCurrency } from '../lib/utils'; export function Accounts() { @@ -10,7 +11,7 @@ export function Accounts() { api.getAccounts().then(setAccounts).catch((e) => setError(e.message)); }, []); - if (error) return

{error}

; + if (error) return

{error}

; const grouped = accounts.reduce>((acc, a) => { const type = a.account_type; @@ -28,52 +29,50 @@ export function Accounts() { loan: 'Loans', }; + const isDebtType = (type: string) => ['credit_card', 'store_credit', 'mortgage', 'loan'].includes(type); + return (
-

Accounts

+

Accounts

{Object.entries(grouped).map(([type, accts]) => (
-

+

{typeLabels[type] || type}

{accts.map((a) => ( -
+
-
-

{a.account_name}

-

{a.institution}

+
+

{a.account_name}

+

{a.institution}

-

+

{formatCurrency(a.current_balance)}

{a.credit_limit && ( -
-
+
+
-

+

{formatCurrency(a.current_balance)} / {formatCurrency(a.credit_limit)}

)} -
+ ))}
))} {accounts.length === 0 && ( -

No accounts configured

+

No accounts configured

)}
); diff --git a/ui/src/pages/Bills.tsx b/ui/src/pages/Bills.tsx index 7d3105e..9fcd3e7 100644 --- a/ui/src/pages/Bills.tsx +++ b/ui/src/pages/Bills.tsx @@ -1,11 +1,15 @@ import { useEffect, useState } from 'react'; import { api, type Obligation } from '../lib/api'; -import { formatCurrency, formatDate, daysUntil, urgencyColor, statusBadgeColor } from '../lib/utils'; +import { Card } from '../components/ui/Card'; +import { ActionButton } from '../components/ui/ActionButton'; +import { urgencyLevel } from '../components/ui/UrgencyBorder'; +import { formatCurrency, formatDate, daysUntil, cn } from '../lib/utils'; export function Bills() { const [obligations, setObligations] = useState([]); const [filter, setFilter] = useState(''); const [error, setError] = useState(null); + const [payingId, setPayingId] = useState(null); useEffect(() => { const params: Record = {}; @@ -14,22 +18,34 @@ export function Bills() { }, [filter]); const handleMarkPaid = async (id: string) => { - await api.markPaid(id); - setObligations((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'paid', urgency_score: 0 } : o))); + setPayingId(id); + try { + await api.markPaid(id); + setObligations((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'paid', urgency_score: 0 } : o))); + } finally { + setPayingId(null); + } }; - if (error) return

{error}

; + if (error) return

{error}

; + + const filters = ['', 'pending', 'overdue', 'paid']; return (
-

Bills & Obligations

-
- {['', 'pending', 'overdue', 'paid'].map((f) => ( +

Bills & Obligations

+
+ {filters.map((f) => ( @@ -37,64 +53,59 @@ export function Bills() {
-
- - - - - - - - - - - - - - {obligations.map((ob) => { - const days = ob.due_date ? daysUntil(ob.due_date) : null; - return ( - - - - - - - - - - ); - })} - -
PayeeCategoryAmountDue DateStatusUrgencyActions
{ob.payee}{ob.category}{formatCurrency(ob.amount_due)} - {ob.due_date ? ( - - {formatDate(ob.due_date)} - {days !== null && ({days > 0 ? `${days}d` : days === 0 ? 'today' : `${Math.abs(days)}d late`})} +
+ {obligations.map((ob) => { + const days = ob.due_date ? daysUntil(ob.due_date) : null; + return ( + +
+
+

{ob.payee}

+

+ {ob.category} + {ob.due_date && ( + + {' — '} + {days !== null && days < 0 + ? {Math.abs(days)}d late + : days === 0 + ? Due today + : `Due ${formatDate(ob.due_date)}` + } - ) : ( - - )} -

- - {ob.status} - - - - {ob.urgency_score ?? '—'} - - - {ob.status !== 'paid' && ( - )} -
+

+
+
+

{formatCurrency(ob.amount_due)}

+ + {ob.status} + + {ob.status !== 'paid' && ( + handleMarkPaid(ob.id)} + loading={payingId === ob.id} + /> + )} +
+
+ + ); + })} {obligations.length === 0 && ( -

No obligations found

+

No obligations found

)}
diff --git a/ui/src/pages/CashFlow.tsx b/ui/src/pages/CashFlow.tsx index 56cdc98..b565792 100644 --- a/ui/src/pages/CashFlow.tsx +++ b/ui/src/pages/CashFlow.tsx @@ -1,6 +1,10 @@ import { useEffect, useState } from 'react'; import { api, type CashflowProjection, type ProjectionResult, type ScenarioResult, type Obligation } from '../lib/api'; +import { Card } from '../components/ui/Card'; +import { MetricCard } from '../components/ui/MetricCard'; +import { ActionButton } from '../components/ui/ActionButton'; import { formatCurrency, formatDate } from '../lib/utils'; +import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; export function CashFlow() { const [projections, setProjections] = useState([]); @@ -36,8 +40,8 @@ export function CashFlow() { setSummary(result); const proj = await api.getCashflowProjections(); setProjections(proj); - } catch (e: any) { - setError(e.message); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Generation failed'); } finally { setGenerating(false); } @@ -58,177 +62,142 @@ export function CashFlow() { try { const result = await api.runCashflowScenario(Array.from(deferIds)); setScenario(result); - } catch (e: any) { - setError(e.message); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Scenario failed'); } }; - if (error && projections.length === 0) { - return ( -
-

Failed to load cash flow data

-

{error}

- -
- ); - } - - // Parse projections into chart-friendly format - const entries = projections.map((p) => ({ - date: p.projection_date, + // Chart data + const chartData = projections.map((p) => ({ + date: new Date(p.projection_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), balance: parseFloat(p.projected_balance), inflow: parseFloat(p.projected_inflow), outflow: parseFloat(p.projected_outflow), - obligations: parseOblArray(p.obligations), - confidence: parseFloat(p.confidence), })); - const balances = entries.map((e) => e.balance); - const minBalance = balances.length ? Math.min(...balances) : 0; - const maxBalance = balances.length ? Math.max(...balances) : 1; - const range = maxBalance - minBalance || 1; - - // Upcoming obligations for scenario panel const upcoming = obligations .filter((o) => o.status === 'pending' || o.status === 'overdue') .sort((a, b) => a.due_date.localeCompare(b.due_date)) .slice(0, 20); + if (error && projections.length === 0) { + return ( +
+

Failed to load cash flow data

+

{error}

+ +
+ ); + } + return (
-

Cash Flow Forecast

-
- {summary && ( -
-
Start: {formatCurrency(summary.starting_balance)}
-
End: {formatCurrency(summary.ending_balance)}
-
Low: {formatCurrency(summary.lowest_balance)}
-
- )} - -
+

Cash Flow Forecast

+
- {loading ? ( -
Loading projections...
- ) : entries.length === 0 ? ( -
-

No projections yet.

- + {summary && ( +
+ + +
+ )} + + {loading ? ( +
Loading projections...
+ ) : chartData.length === 0 ? ( + +

No projections yet.

+ +
) : ( <> - {/* Bar chart */} -
-
- {entries.map((entry, i) => { - const height = ((entry.balance - minBalance) / range) * 100; - const isNegative = entry.balance < 0; - const hasOutflow = entry.outflow > 0; - const opacity = entry.confidence >= 0.8 ? '' : entry.confidence >= 0.6 ? 'opacity-80' : 'opacity-60'; - return ( -
-
-
-

{formatDate(entry.date)}

-

{formatCurrency(entry.balance)}

- {entry.inflow > 0 &&

+{formatCurrency(entry.inflow)} in

} - {entry.outflow > 0 &&

-{formatCurrency(entry.outflow)} out

} - {entry.obligations.map((o, j) => ( -

{o}

- ))} -

{Math.round(entry.confidence * 100)}% confidence

-
-
- ); - })} -
-
- Today - 30 days - 60 days - 90 days -
-
+ {/* Recharts Area Chart */} + + + + + + + + + + + `$${(v / 1000).toFixed(0)}k`} /> + [value != null ? formatCurrency(value) : '', '']} + /> + + + + + {/* Outflows table */} -
-

Projected Outflows

+ +

Projected Outflows

- - - - - - + + + + - {entries.filter((e) => e.outflow > 0).map((entry, i) => ( - - - - - + + + - ))}
DateObligationsOutflowBalance AfterConfidence
DateOutflowBalance After
{formatDate(entry.date)}{entry.obligations.join(', ') || '-'}-{formatCurrency(entry.outflow)} + {chartData.filter((e) => e.outflow > 0).slice(0, 15).map((entry, i) => ( +
{entry.date}-{formatCurrency(entry.outflow)} {formatCurrency(entry.balance)} {Math.round(entry.confidence * 100)}%
-
+ )} - {/* Scenario: "What if I defer...?" */} -
-

Scenario: What If I Defer?

-

Select obligations to defer and see the impact on your cash position.

+ {/* Scenario Panel */} + +

Scenario: What If I Defer?

+

Select obligations to defer and see the impact on your cash position.

{upcoming.map((ob) => ( -
- + /> {deferIds.size > 0 && ( - )} @@ -236,49 +205,13 @@ export function CashFlow() { {scenario && (
-
-
Without Deferral
-
- {formatCurrency(scenario.original_balance)} -
-
-
-
With Deferral
-
- {formatCurrency(scenario.projected_balance)} -
-
-
-
Savings
-
{formatCurrency(scenario.savings_from_deferral)}
-
-
-
Items Deferred
-
{scenario.deferred_items.length}
-
+ + + +
)} -
- -
-

Legend

-
-
Normal
-
Payment due
-
Negative balance
-
Low confidence (<70%)
-
-
+
); } - -function parseOblArray(val: string): string[] { - if (!val) return []; - try { - const parsed = JSON.parse(val); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} diff --git a/ui/src/pages/Disputes.tsx b/ui/src/pages/Disputes.tsx index 07bd3c1..95e6bb6 100644 --- a/ui/src/pages/Disputes.tsx +++ b/ui/src/pages/Disputes.tsx @@ -1,15 +1,24 @@ import { useEffect, useState, useCallback } from 'react'; import { api, type Dispute, type Correspondence } from '../lib/api'; +import { Card } from '../components/ui/Card'; +import { ActionButton } from '../components/ui/ActionButton'; +import { ProgressDots } from '../components/ui/ProgressDots'; import { formatCurrency, formatDate } from '../lib/utils'; +const DISPUTE_STAGES = ['filed', 'response_pending', 'in_review', 'resolved']; + +function disputeStageIndex(status: string): number { + const idx = DISPUTE_STAGES.indexOf(status); + return idx >= 0 ? idx : 0; +} + export function Disputes() { const [disputes, setDisputes] = useState([]); const [error, setError] = useState(null); - const [correspondenceFor, setCorrespondenceFor] = useState(null); - const [documentsFor, setDocumentsFor] = useState(null); - const [statusFor, setStatusFor] = useState(null); + const [expandedId, setExpandedId] = useState(null); const [correspondenceList, setCorrespondenceList] = useState([]); const [documentList, setDocumentList] = useState<{ id: string; filename: string | null; doc_type: string; created_at: string }[]>([]); + const [activePanel, setActivePanel] = useState<'correspondence' | 'documents' | null>(null); const [newCorrespondence, setNewCorrespondence] = useState({ direction: 'outbound', channel: 'email', subject: '', content: '' }); const [saving, setSaving] = useState(false); @@ -19,135 +28,130 @@ export function Disputes() { useEffect(() => { reload(); }, [reload]); - const openCorrespondence = async (disputeId: string) => { - setCorrespondenceFor(disputeId); - try { - const detail = await api.getDispute(disputeId); - setCorrespondenceList(detail.correspondence || []); - } catch { setCorrespondenceList([]); } - }; - - const openDocuments = async (disputeId: string) => { - setDocumentsFor(disputeId); + const togglePanel = async (disputeId: string, panel: 'correspondence' | 'documents') => { + if (expandedId === disputeId && activePanel === panel) { + setExpandedId(null); + setActivePanel(null); + return; + } + setExpandedId(disputeId); + setActivePanel(panel); try { const detail = await api.getDispute(disputeId); - setDocumentList(detail.documents || []); - } catch { setDocumentList([]); } + if (panel === 'correspondence') setCorrespondenceList(detail.correspondence || []); + else setDocumentList(detail.documents || []); + } catch { + if (panel === 'correspondence') setCorrespondenceList([]); + else setDocumentList([]); + } }; const submitCorrespondence = async () => { - if (!correspondenceFor || !newCorrespondence.subject.trim()) return; + if (!expandedId || !newCorrespondence.subject.trim()) return; setSaving(true); try { - await api.addCorrespondence(correspondenceFor, newCorrespondence); + await api.addCorrespondence(expandedId, newCorrespondence); setNewCorrespondence({ direction: 'outbound', channel: 'email', subject: '', content: '' }); - const detail = await api.getDispute(correspondenceFor); + const detail = await api.getDispute(expandedId); setCorrespondenceList(detail.correspondence || []); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to add correspondence'); - } finally { setSaving(false); } + } finally { + setSaving(false); + } }; - if (error) return

{error}

; + if (error) return

{error}

; - const priorityColor = (p: number) => { - if (p <= 1) return 'bg-red-600'; - if (p <= 3) return 'bg-orange-600'; - return 'bg-yellow-600'; + const priorityUrgency = (p: number): 'red' | 'amber' | 'green' => { + if (p <= 1) return 'red'; + if (p <= 3) return 'amber'; + return 'green'; }; return ( -
-

Active Disputes

+
+

Active Disputes

-
+
{disputes.map((d) => ( -
-
-
-
- + +
+
+
+ P{d.priority} - + {d.dispute_type}
-

{d.title}

-

vs {d.counterparty}

+

{d.title}

+

vs {d.counterparty}

{d.amount_at_stake && ( -
-

At Stake

-

{formatCurrency(d.amount_at_stake)}

+
+

At Stake

+

{formatCurrency(d.amount_at_stake)}

)}
+ + {d.description && ( -

{d.description}

+

{d.description}

)} {d.next_action && ( -
-

Next Action

-

{d.next_action}

+
+

Next Action

+

{d.next_action}

{d.next_action_date && ( -

By {formatDate(d.next_action_date)}

+

By {formatDate(d.next_action_date)}

)}
)} -
- - - +
+ togglePanel(d.id, 'correspondence')} + /> + togglePanel(d.id, 'documents')} + />
{/* Correspondence Panel */} - {correspondenceFor === d.id && ( -
-
-

Correspondence

- -
+ {expandedId === d.id && activePanel === 'correspondence' && ( +
+

Correspondence

{correspondenceList.length > 0 ? (
{correspondenceList.map((c) => ( -
-
+
+
{c.direction} via {c.channel} {formatDate(c.sent_at)}
- {c.subject &&

{c.subject}

} - {c.content &&

{c.content}

} + {c.subject &&

{c.subject}

} + {c.content &&

{c.content}

}
))}
) : ( -

No correspondence yet

+

No correspondence yet

)}
setNewCorrespondence({ ...newCorrespondence, channel: e.target.value })} - className="bg-[#161822] border border-gray-700 rounded px-2 py-1 text-xs text-white" + className="bg-card-bg border border-card-border rounded-lg px-2 py-1 text-xs text-card-text" > @@ -168,66 +172,49 @@ export function Disputes() { placeholder="Subject" value={newCorrespondence.subject} onChange={(e) => setNewCorrespondence({ ...newCorrespondence, subject: e.target.value })} - className="w-full bg-[#161822] border border-gray-700 rounded px-3 py-1.5 text-sm text-white" + className="w-full bg-card-bg border border-card-border rounded-lg px-3 py-1.5 text-sm text-card-text" />