diff --git a/CHARTER.md b/CHARTER.md
index fe852b8..63fa7db 100644
--- a/CHARTER.md
+++ b/CHARTER.md
@@ -100,9 +100,34 @@ Provide a unified life management and action dashboard that ingests data from 15
| Technical Lead | @chittyos-infrastructure |
| Contact | chittycommand@chitty.cc |
+## Three Aspects (TY VY RY)
+
+Source: `chittycanon://gov/governance#three-aspects`
+
+| Aspect | Abbrev | Question | ChittyCommand Answer |
+|--------|--------|----------|--------------------|
+| **Identity** | TY | What IS it? | Unified life management dashboard — ingests financial, legal, and administrative data from 15+ sources, scores urgency, recommends and executes actions |
+| **Connectivity** | VY | How does it ACT? | Cron-scheduled syncs (Plaid, Mercury, court dockets, utilities); bridge API to ChittyScrape, ChittyLedger, ChittyFinance; MCP server for Claude-driven queries; action execution via API, email, or browser automation |
+| **Authority** | RY | Where does it SIT? | Tier 5 Application — consumer of upstream data, not source of truth; delegates scraping to ChittyScrape, identity to ChittyID, financials to ChittyFinance |
+
+## Document Triad
+
+This charter is part of a synchronized documentation triad. Changes to shared fields must propagate.
+
+| Field | Canonical Source | Also In |
+|-------|-----------------|---------|
+| Canonical URI | CHARTER.md (Classification) | CHITTY.md (blockquote) |
+| Tier | CHARTER.md (Classification) | CHITTY.md (blockquote) |
+| Domain | CHARTER.md (Classification) | CHITTY.md (blockquote), CLAUDE.md (header) |
+| Endpoints | CHARTER.md (API Contract) | CHITTY.md (Endpoints table), CLAUDE.md (API section) |
+| Dependencies | CHARTER.md (Dependencies) | CHITTY.md (Dependencies table), CLAUDE.md (Architecture) |
+| Certification badge | CHITTY.md (Certification) | CHARTER.md frontmatter `status` |
+
+**Related docs**: [CHITTY.md](CHITTY.md) (badge/one-pager) | [CLAUDE.md](CLAUDE.md) (developer guide)
+
## 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
@@ -110,4 +135,4 @@ Provide a unified life management and action dashboard that ingests data from 15
- [x] CHITTY.md present
---
-*Charter Version: 1.0.0 | Last Updated: 2026-02-24*
+*Charter Version: 1.0.0 | Last Updated: 2026-02-23*
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`
diff --git a/docs/plans/2026-02-23-ui-overhaul-design.md b/docs/plans/2026-02-23-ui-overhaul-design.md
new file mode 100644
index 0000000..d966b86
--- /dev/null
+++ b/docs/plans/2026-02-23-ui-overhaul-design.md
@@ -0,0 +1,118 @@
+# ChittyCommand UI Overhaul — "Command Console"
+
+**Date:** 2026-02-23
+**Status:** Approved
+**Canonical URI:** `chittycanon://core/services/chittycommand#ui-overhaul`
+
+## Design Direction
+
+Hybrid of Bloomberg Terminal (information density, real-time data flow) and Apple Finance (clean cards, quality typography, refined aesthetics). Designed ADHD/neurospicy-first.
+
+## Visual Language
+
+### Color Scheme: Dark Chrome + Light Cards
+- **Shell** (sidebar, nav, status bar): Dark background (`#1a1a2e` → `#16213e` range)
+- **Card surfaces**: White/near-white (`#ffffff` / `#fafafa`)
+- **Urgency accents**: Red (`#ef4444`), Amber (`#f59e0b`), Green (`#22c55e`)
+- **Text**: Dark on light cards, light on dark chrome
+- **Muted state**: Low-urgency cards use lighter text (`#9ca3af`), no border accent
+
+### Typography
+- **Display/headings**: Outfit (geometric sans-serif) — clean, modern, highly legible
+- **Body/data**: Outfit at smaller weights — consistent family, no font-switching fatigue
+- **Monospace (numbers/amounts)**: JetBrains Mono or Tabular Outfit — aligned columns for financial data
+
+### Spatial Rules
+- Cards have generous internal padding but tight grid gaps — dense layout, breathable cards
+- Consistent card anatomy: title → key metric → status indicator → primary action
+- Left-border color accent for urgency (4px solid)
+- Rounded corners (8-12px) on all cards — Apple softness
+
+## Layout Architecture
+
+### Widget-Based Grid
+- CSS Grid dashboard with named areas
+- Draggable, resizable panels (saved to user preferences in KV)
+- Auto-personalizes: highest-urgency widgets float to top-left
+- Responsive: collapses to single-column on mobile
+
+### Sections
+1. **Top status bar** — cash position, next due date, sync freshness dots (green/amber/red), Focus Mode toggle
+2. **Sidebar** (dark) — navigation, account summary, quick filters
+3. **Main grid** — widget cards:
+ - Obligations (bills due, sorted by urgency)
+ - Active disputes (progress bars, status, next action)
+ - Cashflow chart (30-day projection)
+ - Recommendations (AI-scored, one CTA each)
+ - Recent transactions (last 10, grouped by account)
+ - Upcoming deadlines (calendar-style, next 14 days)
+ - Sync status (per-source freshness, last run time)
+
+## ADHD/Neurospicy-First Principles
+
+### 1. Focus Mode (default: ON)
+- Landing view shows only top 3 most urgent items
+- Each item: what it is, why it's urgent, one action button
+- "See everything" toggle expands to full dense dashboard
+- Reduces cognitive load on every visit — no overwhelm on load
+
+### 2. Visual Hierarchy Does the Thinking
+- Urgency scoring auto-sorts all widgets and items within widgets
+- Most important = biggest, brightest, top-left position
+- No scanning or deciding "what should I look at first"
+
+### 3. One Clear Action Per Card
+- Single primary CTA button per card (pay, respond, review)
+- Secondary actions behind a "..." menu
+- No decision paralysis from multiple equal-weight options
+
+### 4. Color-Coded Urgency Borders
+- Red (4px left border): overdue or due within 48hrs
+- Amber: due within 7 days or needs attention
+- Green: on track, no action needed
+- Peripheral vision catches urgency without reading
+
+### 5. Progress Indicators Everywhere
+- Disputes: progress bar (filed → response → resolution)
+- Monthly bills: "3 of 7 paid" with filled dots
+- Sync cycles: completion indicators per source
+- Dopamine-friendly — visible forward motion
+
+### 6. Chunked Sections with Clear Labels
+- Bold section headers, clear card boundaries
+- No wall-of-data — every group is visually separated
+- Consistent card anatomy reduces cognitive parsing
+
+### 7. Muted Non-Urgent Items
+- Low-urgency cards: lighter text, no accent border, smaller font
+- High-urgency: full color, accent border, larger metric
+- Attention goes where it's needed without effort
+
+### 8. Persistent Widget Layout
+- Drag to reorder, collapse/expand — arrangement saved
+- Same layout every visit — no re-orienting
+- Layout stored in Cloudflare KV per user
+
+## Subtle Data Indicators (Not Animations)
+
+- **Freshness dots**: small colored circles (green=fresh, amber=stale, red=failed) next to each data source
+- **Delta arrows**: small up/down arrows on amounts showing change from last sync
+- **Muted timestamps**: "2h ago" in light gray under each card
+- No pulse effects, no ticker strips, no glow — data confidence without noise
+
+## Tech Stack
+
+- **Framework**: React (existing) + Shadcn UI components
+- **Styling**: Tailwind CSS with CSS custom properties for theming
+- **Grid**: CSS Grid with `react-grid-layout` for drag/resize
+- **Charts**: Recharts (lightweight, React-native)
+- **State**: React context + Cloudflare KV for layout persistence
+- **Build**: Vite → Cloudflare Pages
+
+## Not Included
+
+- Ticker strips / scrolling tapes
+- Scan-line or CRT effects
+- Heavy animations or glow effects
+- Sound effects or haptics
+- Multiple theme options (single cohesive theme)
diff --git a/docs/plans/2026-02-23-ui-overhaul-plan.md b/docs/plans/2026-02-23-ui-overhaul-plan.md
new file mode 100644
index 0000000..6d6240f
--- /dev/null
+++ b/docs/plans/2026-02-23-ui-overhaul-plan.md
@@ -0,0 +1,1457 @@
+# ChittyCommand UI Overhaul — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Transform ChittyCommand from a dark-theme developer dashboard into an ADHD-friendly "Command Console" with dark chrome shell, light card surfaces, Focus Mode, urgency-based hierarchy, and widget-based layout.
+
+**Architecture:** Incremental replacement of existing UI. Theme foundation first, then shared components, then Layout shell, then Focus Mode, then page-by-page overhaul. Each task produces a working state — no broken intermediate builds.
+
+**Tech Stack:** React 18, Tailwind CSS 3, Vite 5, react-grid-layout (new), recharts (new), Outfit + JetBrains Mono fonts (new), lucide-react (existing)
+
+**Design Doc:** `docs/plans/2026-02-23-ui-overhaul-design.md`
+
+---
+
+### Task 1: Install Dependencies & Font Setup
+
+**Files:**
+- Modify: `ui/package.json`
+- Modify: `ui/index.html`
+- Modify: `ui/src/index.css`
+
+**Step 1: Install new packages**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npm install react-grid-layout recharts`
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npm install -D @types/react-grid-layout`
+
+**Step 2: Add Google Fonts to index.html**
+
+In `ui/index.html`, add to `
`:
+
+```html
+
+
+
+```
+
+**Step 3: Update index.css with new base styles**
+
+Replace `ui/src/index.css` with:
+
+```css
+@tailwind base;
+@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: '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;
+}
+```
+
+**Step 4: Verify build**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite build`
+Expected: Build succeeds with no errors.
+
+**Step 5: Commit**
+
+```bash
+git add ui/package.json ui/package-lock.json ui/index.html ui/src/index.css
+git commit -m "feat(ui): add Outfit/JetBrains Mono fonts, recharts, react-grid-layout"
+```
+
+---
+
+### Task 2: Tailwind Theme Config
+
+**Files:**
+- Modify: `ui/tailwind.config.js`
+
+**Step 1: Replace tailwind.config.js**
+
+```js
+/** @type {import('tailwindcss').Config} */
+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',
+ 100: '#dbe4ff',
+ 500: '#4c6ef5',
+ 600: '#3b5bdb',
+ 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',
+ },
+ },
+ },
+ plugins: [],
+};
+```
+
+**Step 2: Verify build**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite build`
+Expected: Build succeeds.
+
+**Step 3: Commit**
+
+```bash
+git add ui/tailwind.config.js
+git commit -m "feat(ui): extend Tailwind theme with Command Console color system"
+```
+
+---
+
+### Task 3: Shared UI Components
+
+**Files:**
+- Create: `ui/src/components/ui/Card.tsx`
+- Create: `ui/src/components/ui/UrgencyBorder.tsx`
+- Create: `ui/src/components/ui/FreshnessDot.tsx`
+- Create: `ui/src/components/ui/ProgressDots.tsx`
+- Create: `ui/src/components/ui/MetricCard.tsx`
+- Create: `ui/src/components/ui/ActionButton.tsx`
+
+**Step 1: Create Card component**
+
+`ui/src/components/ui/Card.tsx`:
+
+```tsx
+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}
+
+ );
+}
+```
+
+**Step 2: Create UrgencyBorder helper**
+
+`ui/src/components/ui/UrgencyBorder.tsx`:
+
+```tsx
+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';
+}
+```
+
+**Step 3: Create FreshnessDot**
+
+`ui/src/components/ui/FreshnessDot.tsx`:
+
+```tsx
+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';
+}
+```
+
+**Step 4: Create ProgressDots**
+
+`ui/src/components/ui/ProgressDots.tsx`:
+
+```tsx
+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}
+
+
+ );
+}
+```
+
+**Step 5: Create MetricCard**
+
+`ui/src/components/ui/MetricCard.tsx`:
+
+```tsx
+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' &&
▼}
+
+
+ );
+}
+```
+
+**Step 6: Create ActionButton**
+
+`ui/src/components/ui/ActionButton.tsx`:
+
+```tsx
+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 (
+
+ );
+}
+```
+
+**Step 7: Verify build**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite build`
+Expected: Build succeeds (components not yet used, but must compile).
+
+**Step 8: Commit**
+
+```bash
+git add ui/src/components/ui/
+git commit -m "feat(ui): add shared Card, MetricCard, urgency, freshness, progress components"
+```
+
+---
+
+### Task 4: New Layout — Sidebar + Status Bar
+
+**Files:**
+- Modify: `ui/src/components/Layout.tsx`
+- Create: `ui/src/components/Sidebar.tsx`
+- Create: `ui/src/components/StatusBar.tsx`
+- Create: `ui/src/lib/focus-mode.tsx`
+
+**Step 1: Create FocusMode context**
+
+`ui/src/lib/focus-mode.tsx`:
+
+```tsx
+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);
+}
+```
+
+**Step 2: Create Sidebar**
+
+`ui/src/components/Sidebar.tsx`:
+
+```tsx
+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 (
+
+ );
+}
+```
+
+**Step 3: Create StatusBar**
+
+`ui/src/components/StatusBar.tsx`:
+
+```tsx
+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 (
+
+ );
+}
+```
+
+**Step 4: Rewrite Layout.tsx**
+
+Replace `ui/src/components/Layout.tsx`:
+
+```tsx
+import { Outlet } from 'react-router-dom';
+import { Sidebar } from './Sidebar';
+import { StatusBar } from './StatusBar';
+
+export function Layout() {
+ return (
+
+ );
+}
+```
+
+**Step 5: Wrap app with FocusModeProvider**
+
+In `ui/src/main.tsx`, wrap `` with ``:
+
+```tsx
+import { FocusModeProvider } from './lib/focus-mode';
+
+// Inside render:
+
+
+
+ {/* ... routes ... */}
+
+
+
+```
+
+**Step 6: Verify build and visual**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite build`
+Expected: Build succeeds. When running dev server, sidebar + status bar are visible. Pages still render but with old card colors (mixed state is OK — we'll fix pages next).
+
+**Step 7: Commit**
+
+```bash
+git add ui/src/components/Layout.tsx ui/src/components/Sidebar.tsx ui/src/components/StatusBar.tsx ui/src/lib/focus-mode.tsx ui/src/main.tsx
+git commit -m "feat(ui): new sidebar + status bar layout with Focus Mode context"
+```
+
+---
+
+### Task 5: Dashboard Overhaul — Focus Mode + Full Mode
+
+This is the biggest task. The Dashboard page gets completely rewritten with Focus Mode (default ON showing top 3 items) and Full Mode (dense widget grid).
+
+**Files:**
+- Modify: `ui/src/pages/Dashboard.tsx`
+- Create: `ui/src/components/dashboard/FocusView.tsx`
+- Create: `ui/src/components/dashboard/FullView.tsx`
+- Create: `ui/src/components/dashboard/ObligationsWidget.tsx`
+- Create: `ui/src/components/dashboard/DisputesWidget.tsx`
+- Create: `ui/src/components/dashboard/DeadlinesWidget.tsx`
+- Create: `ui/src/components/dashboard/RecommendationsWidget.tsx`
+
+**Step 1: Create FocusView**
+
+`ui/src/components/dashboard/FocusView.tsx`:
+
+```tsx
+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;
+
+ // Gather all items with urgency scores, pick top 3
+ 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, // P1 = 100, P2 = 80, etc.
+ 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,
+ },
+ });
+ });
+
+ // Sort by urgency descending, take top 3
+ 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}
+ )}
+
+
+ ))}
+
+ );
+}
+```
+
+**Step 2: Create ObligationsWidget**
+
+`ui/src/components/dashboard/ObligationsWidget.tsx`:
+
+```tsx
+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' && (
+
+ )}
+
+
+
+ );
+ })}
+
+ );
+}
+```
+
+**Step 3: Create DisputesWidget**
+
+`ui/src/components/dashboard/DisputesWidget.tsx`:
+
+```tsx
+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}
+ )}
+
+
+
+ ))}
+
+ );
+}
+```
+
+**Step 4: Create DeadlinesWidget**
+
+`ui/src/components/dashboard/DeadlinesWidget.tsx`:
+
+```tsx
+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)}
+
+
+
+ );
+ })}
+
+ );
+}
+```
+
+**Step 5: Create RecommendationsWidget**
+
+`ui/src/components/dashboard/RecommendationsWidget.tsx`:
+
+```tsx
+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"
+ />
+ )}
+
+
+ ))}
+
+ );
+}
+```
+
+**Step 6: Create FullView**
+
+`ui/src/components/dashboard/FullView.tsx`:
+
+```tsx
+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 (
+
+ {/* Summary Metrics */}
+
+
+
+
+ 0 ? 'text-urgency-red' : 'text-urgency-green'}
+ />
+
+
+ {/* Two-column widget grid */}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+**Step 7: Rewrite Dashboard.tsx**
+
+Replace `ui/src/pages/Dashboard.tsx`:
+
+```tsx
+import { useEffect, useState, useCallback } from 'react';
+import { api, type DashboardData, type Obligation, type Recommendation } from '../lib/api';
+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));
+ }, []);
+
+ useEffect(() => { reload(); }, [reload]);
+
+ const handlePayNow = async (ob: Obligation) => {
+ if (payingId) return;
+ setPayingId(ob.id);
+ try {
+ await api.markPaid(ob.id);
+ reload();
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : 'Payment failed');
+ } finally {
+ setPayingId(null);
+ }
+ };
+
+ const handleExecute = async (rec: Recommendation) => {
+ if (executingId) return;
+ setExecutingId(rec.id);
+ try {
+ await api.actOnRecommendation(rec.id, { action_taken: rec.action_type || 'executed' });
+ reload();
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : 'Execution failed');
+ } finally {
+ setExecutingId(null);
+ }
+ };
+
+ if (error && !data) {
+ return (
+
+
Failed to load dashboard
+
{error}
+
+ );
+ }
+
+ if (!data) {
+ return Loading...
;
+ }
+
+ const viewProps = { data, onPayNow: handlePayNow, onExecute: handleExecute, payingId, executingId };
+
+ return focusMode ? : ;
+}
+```
+
+**Step 8: Verify build**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite build`
+Expected: Build succeeds.
+
+**Step 9: Commit**
+
+```bash
+git add ui/src/pages/Dashboard.tsx ui/src/components/dashboard/
+git commit -m "feat(ui): dashboard overhaul with Focus Mode + full widget grid"
+```
+
+---
+
+### Task 6: Bills Page Redesign
+
+**Files:**
+- Modify: `ui/src/pages/Bills.tsx`
+
+**Step 1: Rewrite Bills.tsx**
+
+Replace with card-based layout using new components. Key changes:
+- Replace dark table with Card components per obligation
+- Urgency left-border on each card
+- Muted low-urgency items
+- Keep filter buttons but restyle for light cards on dark chrome
+
+```tsx
+import { useEffect, useState } from 'react';
+import { api, type Obligation } from '../lib/api';
+import { Card } from '../components/ui/Card';
+import { ActionButton } from '../components/ui/ActionButton';
+import { urgencyLevel } from '../components/ui/UrgencyBorder';
+import { formatCurrency, formatDate, daysUntil } from '../lib/utils';
+import { 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 = {};
+ if (filter) params.status = filter;
+ api.getObligations(params).then(setObligations).catch((e) => setError(e.message));
+ }, [filter]);
+
+ const handleMarkPaid = async (id: string) => {
+ 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}
;
+
+ const filters = ['', 'pending', 'overdue', 'paid'];
+
+ return (
+
+
+
Bills & Obligations
+
+ {filters.map((f) => (
+
+ ))}
+
+
+
+
+ {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)}`
+ }>
+ )}
+
+
+
+
{formatCurrency(ob.amount_due)}
+
+ {ob.status}
+
+ {ob.status !== 'paid' && (
+
handleMarkPaid(ob.id)}
+ loading={payingId === ob.id}
+ />
+ )}
+
+
+
+ );
+ })}
+ {obligations.length === 0 && (
+
No obligations found
+ )}
+
+
+ );
+}
+```
+
+**Step 2: Verify build**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite build`
+Expected: Build succeeds.
+
+**Step 3: Commit**
+
+```bash
+git add ui/src/pages/Bills.tsx
+git commit -m "feat(ui): redesign Bills page with urgency cards and ADHD-friendly layout"
+```
+
+---
+
+### Task 7: Disputes Page Redesign
+
+**Files:**
+- Modify: `ui/src/pages/Disputes.tsx`
+
+**Step 1: Rewrite Disputes.tsx with progress bars and one-action-per-card**
+
+Key changes:
+- Card component with urgency borders based on priority
+- Progress bar showing dispute stage
+- Single primary CTA per card
+- Correspondence/documents behind expandable panel
+- Use new Card, ProgressDots, ActionButton components
+
+Keep the existing expand/collapse logic for correspondence and documents panels but restyle with light card surfaces.
+
+**Step 2: Verify build**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite build`
+
+**Step 3: Commit**
+
+```bash
+git add ui/src/pages/Disputes.tsx
+git commit -m "feat(ui): redesign Disputes page with progress bars and one-action-per-card"
+```
+
+---
+
+### Task 8: Accounts Page Redesign
+
+**Files:**
+- Modify: `ui/src/pages/Accounts.tsx`
+
+**Step 1: Rewrite with new Card components**
+
+Key changes:
+- Group headers use chrome-text styling
+- Account cards use Card component with light surfaces
+- Credit utilization bar uses Tailwind classes matching new palette
+- Balance text uses font-mono with urgency colors
+
+**Step 2: Verify build and commit**
+
+```bash
+git add ui/src/pages/Accounts.tsx
+git commit -m "feat(ui): redesign Accounts page with light cards and new color system"
+```
+
+---
+
+### Task 9: CashFlow Page Redesign
+
+**Files:**
+- Modify: `ui/src/pages/CashFlow.tsx`
+
+**Step 1: Replace custom bar chart with Recharts AreaChart**
+
+Key changes:
+- Use `recharts` `` with fill gradient for cash flow projection
+- Keep scenario panel but restyle with Card/MetricCard components
+- Outflows table uses light card surface
+- Restyle all buttons with ActionButton
+
+**Step 2: Verify build and commit**
+
+```bash
+git add ui/src/pages/CashFlow.tsx
+git commit -m "feat(ui): redesign CashFlow page with Recharts and new card system"
+```
+
+---
+
+### Task 10: Remaining Pages — Settings, Login, Recommendations, Legal, Upload
+
+**Files:**
+- Modify: `ui/src/pages/Settings.tsx`
+- Modify: `ui/src/pages/Login.tsx`
+- Modify: `ui/src/pages/Recommendations.tsx`
+- Modify: `ui/src/pages/Legal.tsx`
+- Modify: `ui/src/pages/Upload.tsx`
+
+**Step 1: Settings page**
+
+Restyle all tables and panels with light card surfaces. Service cards use Card component. Sync status table uses Card-based rows.
+
+**Step 2: Login page**
+
+Dark background stays (login is outside Layout). Restyle form card with rounded-card, update input fields to use chrome colors, add Outfit font to title.
+
+**Step 3: Recommendations page**
+
+Use Card components with urgency borders. One CTA per card. Priority badges use pill style on light backgrounds.
+
+**Step 4: Legal page**
+
+Apply Card components with urgencyFromDays borders. Same pattern as DeadlinesWidget.
+
+**Step 5: Upload page**
+
+Restyle upload area with Card + dashed border on light surface.
+
+**Step 6: Verify full build**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite build`
+Expected: Clean build, no TypeScript errors.
+
+**Step 7: Commit**
+
+```bash
+git add ui/src/pages/Settings.tsx ui/src/pages/Login.tsx ui/src/pages/Recommendations.tsx ui/src/pages/Legal.tsx ui/src/pages/Upload.tsx
+git commit -m "feat(ui): redesign Settings, Login, Recommendations, Legal, Upload pages"
+```
+
+---
+
+### Task 11: StatusBar Live Data + Polish
+
+**Files:**
+- Modify: `ui/src/components/Layout.tsx`
+- Modify: `ui/src/components/StatusBar.tsx`
+
+**Step 1: Pipe dashboard summary data into StatusBar**
+
+The Layout component needs to fetch summary data and pass it to StatusBar. Add a lightweight API call in Layout that fetches `/api/dashboard` summary (cash position, next due date) and passes to StatusBar props.
+
+**Step 2: Add freshness dots to StatusBar**
+
+Add sync status freshness dots to the status bar — small colored dots per data source showing last sync freshness.
+
+**Step 3: Verify and commit**
+
+```bash
+git add ui/src/components/Layout.tsx ui/src/components/StatusBar.tsx
+git commit -m "feat(ui): wire live data into StatusBar with freshness indicators"
+```
+
+---
+
+### Task 12: Final Visual Polish & Build Verification
+
+**Files:**
+- Various minor tweaks across all pages
+
+**Step 1: Run full build**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite build`
+Expected: Clean build.
+
+**Step 2: Run dev server and visually verify each page**
+
+Run: `cd /Users/nb/Desktop/Projects/github.com/CHITTYOS/chittycommand/ui && npx vite --host`
+
+Verify:
+- [ ] Login page renders with new styling
+- [ ] Dashboard Focus Mode shows top 3 urgent items
+- [ ] Focus Mode toggle switches to full view
+- [ ] Full dashboard shows 4 metric cards + widget grid
+- [ ] Bills page shows urgency-bordered cards
+- [ ] Disputes page shows progress dots
+- [ ] Accounts page shows grouped cards
+- [ ] CashFlow page shows Recharts chart
+- [ ] Settings page shows light card tables
+- [ ] Sidebar navigation works for all routes
+- [ ] Status bar shows cash position
+
+**Step 3: Fix any visual issues found**
+
+Address spacing, color, or typography inconsistencies.
+
+**Step 4: Final commit**
+
+```bash
+git add -A
+git commit -m "feat(ui): Command Console visual polish and build verification"
+```
+
+---
+
+### Dependency Graph
+
+```
+Task 1 (fonts/deps) ──┐
+ ├── Task 3 (shared components)
+Task 2 (tailwind) ────┘ │
+ ├── Task 4 (layout + focus mode)
+ │ │
+ │ ├── Task 5 (dashboard)
+ │ ├── Task 6 (bills)
+ │ ├── Task 7 (disputes)
+ │ ├── Task 8 (accounts)
+ │ ├── Task 9 (cashflow)
+ │ ├── Task 10 (remaining pages)
+ │ └── Task 11 (statusbar data)
+ │ │
+ │ └── Task 12 (polish)
+```
+
+Tasks 5-10 can be parallelized (independent pages). Tasks 1-4 are sequential.
diff --git a/package-lock.json b/package-lock.json
index 5f39216..e24bf8f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@cloudflare/workers-types": "^4.20240512.0",
"drizzle-kit": "^0.24.0",
"typescript": "^5.5.0",
+ "vitest": "^4.0.18",
"wrangler": "^4.60.0"
}
},
@@ -1614,6 +1615,356 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@sindresorhus/is": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
@@ -1634,6 +1985,38 @@
"dev": true,
"license": "CC0-1.0"
},
+ "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==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "25.2.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz",
@@ -1654,21 +2037,152 @@
"pg-types": "^4.0.1"
}
},
- "node_modules/blake3-wasm": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
- "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "node_modules/@vitest/expect": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
},
- "node_modules/buffer-from": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "node_modules/@vitest/mocker": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
- "license": "MIT"
- },
- "node_modules/cookie": {
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.18",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.18",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
@@ -1692,553 +2206,1410 @@
"ms": "^2.1.3"
},
"engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/drizzle-kit": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.24.2.tgz",
+ "integrity": "sha512-nXOaTSFiuIaTMhS8WJC2d4EBeIcN9OSt2A2cyFbQYBAZbi7lRsVGJNqDpEwPqYfJz38yxbY/UtbvBBahBfnExQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@drizzle-team/brocli": "^0.10.1",
+ "@esbuild-kit/esm-loader": "^2.5.5",
+ "esbuild": "^0.19.7",
+ "esbuild-register": "^3.5.0"
+ },
+ "bin": {
+ "drizzle-kit": "bin.cjs"
+ }
+ },
+ "node_modules/drizzle-orm": {
+ "version": "0.33.0",
+ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.33.0.tgz",
+ "integrity": "sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@aws-sdk/client-rds-data": ">=3",
+ "@cloudflare/workers-types": ">=3",
+ "@electric-sql/pglite": ">=0.1.1",
+ "@libsql/client": "*",
+ "@neondatabase/serverless": ">=0.1",
+ "@op-engineering/op-sqlite": ">=2",
+ "@opentelemetry/api": "^1.4.1",
+ "@planetscale/database": ">=1",
+ "@prisma/client": "*",
+ "@tidbcloud/serverless": "*",
+ "@types/better-sqlite3": "*",
+ "@types/pg": "*",
+ "@types/react": ">=18",
+ "@types/sql.js": "*",
+ "@vercel/postgres": ">=0.8.0",
+ "@xata.io/client": "*",
+ "better-sqlite3": ">=7",
+ "bun-types": "*",
+ "expo-sqlite": ">=13.2.0",
+ "knex": "*",
+ "kysely": "*",
+ "mysql2": ">=2",
+ "pg": ">=8",
+ "postgres": ">=3",
+ "react": ">=18",
+ "sql.js": ">=1",
+ "sqlite3": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/client-rds-data": {
+ "optional": true
+ },
+ "@cloudflare/workers-types": {
+ "optional": true
+ },
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "@libsql/client": {
+ "optional": true
+ },
+ "@neondatabase/serverless": {
+ "optional": true
+ },
+ "@op-engineering/op-sqlite": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@planetscale/database": {
+ "optional": true
+ },
+ "@prisma/client": {
+ "optional": true
+ },
+ "@tidbcloud/serverless": {
+ "optional": true
+ },
+ "@types/better-sqlite3": {
+ "optional": true
+ },
+ "@types/pg": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ },
+ "@types/sql.js": {
+ "optional": true
+ },
+ "@vercel/postgres": {
+ "optional": true
+ },
+ "@xata.io/client": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "bun-types": {
+ "optional": true
+ },
+ "expo-sqlite": {
+ "optional": true
+ },
+ "knex": {
+ "optional": true
+ },
+ "kysely": {
+ "optional": true
+ },
+ "mysql2": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ },
+ "postgres": {
+ "optional": true
+ },
+ "prisma": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "sql.js": {
+ "optional": true
+ },
+ "sqlite3": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/error-stack-parser-es": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
+ "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.19.12",
+ "@esbuild/android-arm": "0.19.12",
+ "@esbuild/android-arm64": "0.19.12",
+ "@esbuild/android-x64": "0.19.12",
+ "@esbuild/darwin-arm64": "0.19.12",
+ "@esbuild/darwin-x64": "0.19.12",
+ "@esbuild/freebsd-arm64": "0.19.12",
+ "@esbuild/freebsd-x64": "0.19.12",
+ "@esbuild/linux-arm": "0.19.12",
+ "@esbuild/linux-arm64": "0.19.12",
+ "@esbuild/linux-ia32": "0.19.12",
+ "@esbuild/linux-loong64": "0.19.12",
+ "@esbuild/linux-mips64el": "0.19.12",
+ "@esbuild/linux-ppc64": "0.19.12",
+ "@esbuild/linux-riscv64": "0.19.12",
+ "@esbuild/linux-s390x": "0.19.12",
+ "@esbuild/linux-x64": "0.19.12",
+ "@esbuild/netbsd-x64": "0.19.12",
+ "@esbuild/openbsd-x64": "0.19.12",
+ "@esbuild/sunos-x64": "0.19.12",
+ "@esbuild/win32-arm64": "0.19.12",
+ "@esbuild/win32-ia32": "0.19.12",
+ "@esbuild/win32-x64": "0.19.12"
+ }
+ },
+ "node_modules/esbuild-register": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
+ "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4"
+ },
+ "peerDependencies": {
+ "esbuild": ">=0.12 <1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "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/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/get-tsconfig": {
+ "version": "4.13.6",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
+ "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.11.9",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
+ "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/miniflare": {
+ "version": "4.20260219.0",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260219.0.tgz",
+ "integrity": "sha512-EIb5wXbWUnnC60XU2aiFOPNd4fgTXzECkwRSOXZ1vdcY9WZaEE9rVf+h+Apw+WkOHRkp3Dr9/ZhQ5y1R+9iZ4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "^0.34.5",
+ "undici": "7.18.2",
+ "workerd": "1.20260219.0",
+ "ws": "8.18.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "bin": {
+ "miniflare": "bootstrap.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "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.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "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/obuf": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
+ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
+ "license": "MIT"
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-numeric": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
+ "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
+ "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz",
+ "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "pg-numeric": "1.0.2",
+ "postgres-array": "~3.0.1",
+ "postgres-bytea": "~3.0.0",
+ "postgres-date": "~2.1.0",
+ "postgres-interval": "^3.0.0",
+ "postgres-range": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "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.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "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/postgres-array": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
+ "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
+ "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
+ "license": "MIT",
+ "dependencies": {
+ "obuf": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
+ "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
+ "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/postgres-range": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
+ "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
+ "license": "MIT"
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "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/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "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/undici": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
+ "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
}
},
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.24",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
"dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3"
}
},
- "node_modules/drizzle-kit": {
- "version": "0.24.2",
- "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.24.2.tgz",
- "integrity": "sha512-nXOaTSFiuIaTMhS8WJC2d4EBeIcN9OSt2A2cyFbQYBAZbi7lRsVGJNqDpEwPqYfJz38yxbY/UtbvBBahBfnExQ==",
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@drizzle-team/brocli": "^0.10.1",
- "@esbuild-kit/esm-loader": "^2.5.5",
- "esbuild": "^0.19.7",
- "esbuild-register": "^3.5.0"
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
},
"bin": {
- "drizzle-kit": "bin.cjs"
- }
- },
- "node_modules/drizzle-orm": {
- "version": "0.33.0",
- "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.33.0.tgz",
- "integrity": "sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA==",
- "license": "Apache-2.0",
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
"peerDependencies": {
- "@aws-sdk/client-rds-data": ">=3",
- "@cloudflare/workers-types": ">=3",
- "@electric-sql/pglite": ">=0.1.1",
- "@libsql/client": "*",
- "@neondatabase/serverless": ">=0.1",
- "@op-engineering/op-sqlite": ">=2",
- "@opentelemetry/api": "^1.4.1",
- "@planetscale/database": ">=1",
- "@prisma/client": "*",
- "@tidbcloud/serverless": "*",
- "@types/better-sqlite3": "*",
- "@types/pg": "*",
- "@types/react": ">=18",
- "@types/sql.js": "*",
- "@vercel/postgres": ">=0.8.0",
- "@xata.io/client": "*",
- "better-sqlite3": ">=7",
- "bun-types": "*",
- "expo-sqlite": ">=13.2.0",
- "knex": "*",
- "kysely": "*",
- "mysql2": ">=2",
- "pg": ">=8",
- "postgres": ">=3",
- "react": ">=18",
- "sql.js": ">=1",
- "sqlite3": ">=5"
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
- "@aws-sdk/client-rds-data": {
- "optional": true
- },
- "@cloudflare/workers-types": {
- "optional": true
- },
- "@electric-sql/pglite": {
- "optional": true
- },
- "@libsql/client": {
- "optional": true
- },
- "@neondatabase/serverless": {
- "optional": true
- },
- "@op-engineering/op-sqlite": {
- "optional": true
- },
- "@opentelemetry/api": {
- "optional": true
- },
- "@planetscale/database": {
- "optional": true
- },
- "@prisma/client": {
- "optional": true
- },
- "@tidbcloud/serverless": {
- "optional": true
- },
- "@types/better-sqlite3": {
- "optional": true
- },
- "@types/pg": {
- "optional": true
- },
- "@types/react": {
- "optional": true
- },
- "@types/sql.js": {
- "optional": true
- },
- "@vercel/postgres": {
- "optional": true
- },
- "@xata.io/client": {
- "optional": true
- },
- "better-sqlite3": {
- "optional": true
- },
- "bun-types": {
+ "@types/node": {
"optional": true
},
- "expo-sqlite": {
+ "jiti": {
"optional": true
},
- "knex": {
+ "less": {
"optional": true
},
- "kysely": {
+ "lightningcss": {
"optional": true
},
- "mysql2": {
+ "sass": {
"optional": true
},
- "pg": {
+ "sass-embedded": {
"optional": true
},
- "postgres": {
+ "stylus": {
"optional": true
},
- "prisma": {
+ "sugarss": {
"optional": true
},
- "react": {
+ "terser": {
"optional": true
},
- "sql.js": {
+ "tsx": {
"optional": true
},
- "sqlite3": {
+ "yaml": {
"optional": true
}
}
},
- "node_modules/error-stack-parser-es": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
- "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
"dev": true,
"license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/antfu"
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/esbuild": {
- "version": "0.19.12",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
- "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
+ "node_modules/vite/node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
"dev": true,
- "hasInstallScript": true,
"license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
+ "optional": true,
+ "os": [
+ "android"
+ ],
"engines": {
- "node": ">=12"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.19.12",
- "@esbuild/android-arm": "0.19.12",
- "@esbuild/android-arm64": "0.19.12",
- "@esbuild/android-x64": "0.19.12",
- "@esbuild/darwin-arm64": "0.19.12",
- "@esbuild/darwin-x64": "0.19.12",
- "@esbuild/freebsd-arm64": "0.19.12",
- "@esbuild/freebsd-x64": "0.19.12",
- "@esbuild/linux-arm": "0.19.12",
- "@esbuild/linux-arm64": "0.19.12",
- "@esbuild/linux-ia32": "0.19.12",
- "@esbuild/linux-loong64": "0.19.12",
- "@esbuild/linux-mips64el": "0.19.12",
- "@esbuild/linux-ppc64": "0.19.12",
- "@esbuild/linux-riscv64": "0.19.12",
- "@esbuild/linux-s390x": "0.19.12",
- "@esbuild/linux-x64": "0.19.12",
- "@esbuild/netbsd-x64": "0.19.12",
- "@esbuild/openbsd-x64": "0.19.12",
- "@esbuild/sunos-x64": "0.19.12",
- "@esbuild/win32-arm64": "0.19.12",
- "@esbuild/win32-ia32": "0.19.12",
- "@esbuild/win32-x64": "0.19.12"
+ "node": ">=18"
}
},
- "node_modules/esbuild-register": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
- "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
+ "node_modules/vite/node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "debug": "^4.3.4"
- },
- "peerDependencies": {
- "esbuild": ">=0.12 <1"
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "node_modules/vite/node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ "node": ">=18"
}
},
- "node_modules/get-tsconfig": {
- "version": "4.13.6",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
- "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
+ "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "resolve-pkg-maps": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/hono": {
- "version": "4.11.9",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
- "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
+ "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
"engines": {
- "node": ">=16.9.0"
+ "node": ">=18"
}
},
- "node_modules/kleur": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
- "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "node_modules/vite/node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
"dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=6"
+ "node": ">=18"
}
},
- "node_modules/miniflare": {
- "version": "4.20260219.0",
- "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260219.0.tgz",
- "integrity": "sha512-EIb5wXbWUnnC60XU2aiFOPNd4fgTXzECkwRSOXZ1vdcY9WZaEE9rVf+h+Apw+WkOHRkp3Dr9/ZhQ5y1R+9iZ4Q==",
+ "node_modules/vite/node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "@cspotcode/source-map-support": "0.8.1",
- "sharp": "^0.34.5",
- "undici": "7.18.2",
- "workerd": "1.20260219.0",
- "ws": "8.18.0",
- "youch": "4.1.0-beta.10"
- },
- "bin": {
- "miniflare": "bootstrap.js"
- },
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=18.0.0"
+ "node": ">=18"
}
},
- "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/obuf": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
- "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
- "license": "MIT"
- },
- "node_modules/path-to-regexp": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
- "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/pathe": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
- "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "node_modules/vite/node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
"dev": true,
- "license": "MIT"
- },
- "node_modules/pg-int8": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
- "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
- "license": "ISC",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=4.0.0"
+ "node": ">=18"
}
},
- "node_modules/pg-numeric": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
- "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
- "license": "ISC",
+ "node_modules/vite/node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=4"
+ "node": ">=18"
}
},
- "node_modules/pg-protocol": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
- "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
- "license": "MIT"
- },
- "node_modules/pg-types": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz",
- "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==",
+ "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
"license": "MIT",
- "dependencies": {
- "pg-int8": "1.0.1",
- "pg-numeric": "1.0.2",
- "postgres-array": "~3.0.1",
- "postgres-bytea": "~3.0.0",
- "postgres-date": "~2.1.0",
- "postgres-interval": "^3.0.0",
- "postgres-range": "^1.1.1"
- },
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=10"
+ "node": ">=18"
}
},
- "node_modules/postgres-array": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
- "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
+ "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
- "node_modules/postgres-bytea": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
- "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
+ "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
"license": "MIT",
- "dependencies": {
- "obuf": "~1.1.2"
- },
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">= 6"
+ "node": ">=18"
}
},
- "node_modules/postgres-date": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
- "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
+ "node_modules/vite/node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
- "node_modules/postgres-interval": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
- "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
+ "node_modules/vite/node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
- "node_modules/postgres-range": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
- "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
- "license": "MIT"
- },
- "node_modules/resolve-pkg-maps": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
- "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
"license": "MIT",
- "funding": {
- "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
"engines": {
- "node": ">=10"
+ "node": ">=18"
}
},
- "node_modules/sharp": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "node_modules/vite/node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@img/colour": "^1.0.0",
- "detect-libc": "^2.1.2",
- "semver": "^7.7.3"
- },
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.5",
- "@img/sharp-darwin-x64": "0.34.5",
- "@img/sharp-libvips-darwin-arm64": "1.2.4",
- "@img/sharp-libvips-darwin-x64": "1.2.4",
- "@img/sharp-libvips-linux-arm": "1.2.4",
- "@img/sharp-libvips-linux-arm64": "1.2.4",
- "@img/sharp-libvips-linux-ppc64": "1.2.4",
- "@img/sharp-libvips-linux-riscv64": "1.2.4",
- "@img/sharp-libvips-linux-s390x": "1.2.4",
- "@img/sharp-libvips-linux-x64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
- "@img/sharp-linux-arm": "0.34.5",
- "@img/sharp-linux-arm64": "0.34.5",
- "@img/sharp-linux-ppc64": "0.34.5",
- "@img/sharp-linux-riscv64": "0.34.5",
- "@img/sharp-linux-s390x": "0.34.5",
- "@img/sharp-linux-x64": "0.34.5",
- "@img/sharp-linuxmusl-arm64": "0.34.5",
- "@img/sharp-linuxmusl-x64": "0.34.5",
- "@img/sharp-wasm32": "0.34.5",
- "@img/sharp-win32-arm64": "0.34.5",
- "@img/sharp-win32-ia32": "0.34.5",
- "@img/sharp-win32-x64": "0.34.5"
+ "node": ">=18"
}
},
- "node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "node_modules/vite/node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "license": "BSD-3-Clause",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
"engines": {
- "node": ">=0.10.0"
+ "node": ">=18"
}
},
- "node_modules/source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "node_modules/vite/node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/supports-color": {
- "version": "10.2.2",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
- "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "node_modules/vite/node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
"engines": {
"node": ">=18"
- },
- "funding": {
- "url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "license": "0BSD",
- "optional": true
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "node_modules/vite/node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
- "license": "Apache-2.0",
+ "hasInstallScript": true,
+ "license": "MIT",
"bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
+ "esbuild": "bin/esbuild"
},
"engines": {
- "node": ">=14.17"
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
}
},
- "node_modules/undici": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
- "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
+ "node_modules/vitest": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.0.18",
+ "@vitest/mocker": "4.0.18",
+ "@vitest/pretty-format": "4.0.18",
+ "@vitest/runner": "4.0.18",
+ "@vitest/snapshot": "4.0.18",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
"engines": {
- "node": ">=20.18.1"
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/browser-preview": "4.0.18",
+ "@vitest/browser-webdriverio": "4.0.18",
+ "@vitest/ui": "4.0.18",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
}
},
- "node_modules/undici-types": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
- "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
- "license": "MIT"
- },
- "node_modules/unenv": {
- "version": "2.0.0-rc.24",
- "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
- "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "pathe": "^2.0.3"
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
}
},
"node_modules/workerd": {
diff --git a/package.json b/package.json
index d7bc6dc..415bc97 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@cloudflare/workers-types": "^4.20240512.0",
"drizzle-kit": "^0.24.0",
"typescript": "^5.5.0",
+ "vitest": "^4.0.18",
"wrangler": "^4.60.0"
}
}
diff --git a/src/lib/urgency.test.ts b/src/lib/urgency.test.ts
new file mode 100644
index 0000000..6c07f6d
--- /dev/null
+++ b/src/lib/urgency.test.ts
@@ -0,0 +1,373 @@
+import { describe, it, expect } from 'vitest';
+import { computeUrgencyScore, urgencyLevel } from './urgency';
+
+// Helper to create a YYYY-MM-DD date string N days from now (UTC)
+function daysFromNow(n: number): string {
+ const d = new Date();
+ d.setUTCDate(d.getUTCDate() + n);
+ return d.toISOString().slice(0, 10);
+}
+
+const base = {
+ category: 'utility',
+ status: 'pending',
+ auto_pay: false,
+ late_fee: null as number | null,
+ grace_period_days: 0,
+};
+
+describe('computeUrgencyScore', () => {
+ // ── Time pressure boundaries ────────────────────────────
+
+ describe('time pressure', () => {
+ it('scores 0 time pressure for due > 14 days out', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(30) });
+ // category weight only (utility = 15)
+ expect(score).toBe(15);
+ });
+
+ it('scores 10 for due in exactly 14 days', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(14) });
+ expect(score).toBe(10 + 15); // time + utility
+ });
+
+ it('scores 20 for due in exactly 7 days', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(7) });
+ expect(score).toBe(20 + 15);
+ });
+
+ it('scores 30 for due in exactly 3 days', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(3) });
+ expect(score).toBe(30 + 15);
+ });
+
+ it('scores 35 for due today', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(0) });
+ expect(score).toBe(35 + 15);
+ });
+
+ it('scores 40 for 1 day overdue', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(-1) });
+ expect(score).toBe(40 + 15);
+ });
+
+ it('scores 45 for 8 days overdue', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(-8) });
+ expect(score).toBe(45 + 15);
+ });
+
+ it('scores 50 for 31+ days overdue', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(-31) });
+ expect(score).toBe(50 + 15);
+ });
+
+ it('scores 10 for due in 8 days (between 7 and 14)', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(8) });
+ expect(score).toBe(10 + 15);
+ });
+
+ it('scores 20 for due in 4 days (between 3 and 7)', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(4) });
+ expect(score).toBe(20 + 15);
+ });
+
+ it('scores 30 for due in 1 day (between 0 and 3)', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(1) });
+ expect(score).toBe(30 + 15);
+ });
+ });
+
+ // ── Category weights ────────────────────────────────────
+
+ describe('category weights', () => {
+ const futureDue = daysFromNow(30); // 0 time pressure
+
+ it('legal = 30', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'legal' })).toBe(30);
+ });
+
+ it('mortgage = 25', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'mortgage' })).toBe(25);
+ });
+
+ it('property_tax = 20', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'property_tax' })).toBe(20);
+ });
+
+ it('federal_tax = 20', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'federal_tax' })).toBe(20);
+ });
+
+ it('utility = 15', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'utility' })).toBe(15);
+ });
+
+ it('insurance = 15', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'insurance' })).toBe(15);
+ });
+
+ it('hoa = 12', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'hoa' })).toBe(12);
+ });
+
+ it('credit_card = 10', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'credit_card' })).toBe(10);
+ });
+
+ it('loan = 10', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'loan' })).toBe(10);
+ });
+
+ it('subscription = 5', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'subscription' })).toBe(5);
+ });
+
+ it('unknown category defaults to 5', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: 'random_thing' })).toBe(5);
+ });
+
+ it('empty string category defaults to 5', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, category: '' })).toBe(5);
+ });
+ });
+
+ // ── Late fee tiers ──────────────────────────────────────
+
+ describe('late fees', () => {
+ const futureDue = daysFromNow(30);
+
+ it('no late fee adds 0', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, late_fee: null })).toBe(15);
+ });
+
+ it('late fee of 0 adds 0', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, late_fee: 0 })).toBe(15);
+ });
+
+ it('late fee of $10 adds 5', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, late_fee: 10 })).toBe(15 + 5);
+ });
+
+ it('late fee of $25 adds 5 (boundary, not > 25)', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, late_fee: 25 })).toBe(15 + 5);
+ });
+
+ it('late fee of $26 adds 10', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, late_fee: 26 })).toBe(15 + 10);
+ });
+
+ it('late fee of $50 adds 10 (boundary, not > 50)', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, late_fee: 50 })).toBe(15 + 10);
+ });
+
+ it('late fee of $51 adds 15', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, late_fee: 51 })).toBe(15 + 15);
+ });
+
+ it('late fee of $200 adds 15', () => {
+ expect(computeUrgencyScore({ ...base, due_date: futureDue, late_fee: 200 })).toBe(15 + 15);
+ });
+ });
+
+ // ── Status modifiers ────────────────────────────────────
+
+ describe('status modifiers', () => {
+ const futureDue = daysFromNow(30);
+
+ it('paid status reduces by 50 (clamped to 0)', () => {
+ const score = computeUrgencyScore({ ...base, due_date: futureDue, status: 'paid' });
+ // 0 time + 15 category - 50 paid = -35 → clamped to 0
+ expect(score).toBe(0);
+ });
+
+ it('disputed status reduces by 10', () => {
+ const score = computeUrgencyScore({ ...base, due_date: futureDue, status: 'disputed' });
+ expect(score).toBe(15 - 10); // 5
+ });
+
+ it('deferred status reduces by 15', () => {
+ const score = computeUrgencyScore({ ...base, due_date: futureDue, status: 'deferred' });
+ expect(score).toBe(15 - 15); // 0
+ });
+
+ it('overdue status has no additional modifier (time pressure handles it)', () => {
+ // Overdue is about the date, not a status modifier
+ const score = computeUrgencyScore({ ...base, due_date: futureDue, status: 'overdue' });
+ expect(score).toBe(15); // just category weight
+ });
+ });
+
+ // ── Auto-pay modifier ───────────────────────────────────
+
+ describe('auto_pay', () => {
+ const futureDue = daysFromNow(30);
+
+ it('auto_pay reduces by 25', () => {
+ const score = computeUrgencyScore({ ...base, due_date: futureDue, auto_pay: true });
+ // 0 time + 15 category - 25 auto_pay = -10 → clamped to 0
+ expect(score).toBe(0);
+ });
+
+ it('auto_pay on a due-today obligation still lowers score', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(0), auto_pay: true });
+ // 35 time + 15 category - 25 auto_pay = 25
+ expect(score).toBe(25);
+ });
+ });
+
+ // ── Grace period ────────────────────────────────────────
+
+ describe('grace period', () => {
+ it('grace period shifts effective due date for time pressure', () => {
+ // Due 1 day ago, but 5 day grace period → effectively 4 days out
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(-1),
+ grace_period_days: 5,
+ });
+ // With grace: effective = 4 days out → 20 time pressure + 15 category = 35
+ expect(score).toBe(20 + 15);
+ });
+
+ it('grace period of 0 has no effect', () => {
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(-1),
+ grace_period_days: 0,
+ });
+ // 1 day overdue → 40 time + 15 category = 55
+ expect(score).toBe(40 + 15);
+ });
+
+ it('grace period does not reduce overdue severity when fully expired', () => {
+ // 10 days overdue, 3 day grace → effectively 7 days overdue
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(-10),
+ grace_period_days: 3,
+ });
+ // Effective = 7 days overdue → daysUntilDue = -7 → < -7 is false, < 0 is true → 40
+ expect(score).toBe(40 + 15);
+ });
+ });
+
+ // ── Combined modifiers ──────────────────────────────────
+
+ describe('combined modifiers', () => {
+ it('auto_pay + paid stacks (both reduce)', () => {
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(0),
+ auto_pay: true,
+ status: 'paid',
+ });
+ // 35 time + 15 category - 25 auto_pay - 50 paid = -25 → clamped to 0
+ expect(score).toBe(0);
+ });
+
+ it('auto_pay + disputed stacks', () => {
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(0),
+ auto_pay: true,
+ status: 'disputed',
+ });
+ // 35 + 15 - 25 - 10 = 15
+ expect(score).toBe(15);
+ });
+
+ it('max possible score: severely overdue legal with high late fee', () => {
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(-60),
+ category: 'legal',
+ late_fee: 100,
+ });
+ // 50 time + 30 legal + 15 late fee = 95
+ expect(score).toBe(95);
+ });
+
+ it('score is clamped to 100 even with theoretical overflow', () => {
+ // This tests the upper clamp — construct a high score
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(-60),
+ category: 'legal',
+ late_fee: 100,
+ });
+ expect(score).toBeLessThanOrEqual(100);
+ });
+
+ it('score is clamped to 0 even with heavy reductions', () => {
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(30),
+ category: 'subscription',
+ auto_pay: true,
+ status: 'paid',
+ });
+ // 0 + 5 - 25 - 50 = -70 → clamped to 0
+ expect(score).toBe(0);
+ });
+ });
+
+ // ── Invalid / edge case inputs ──────────────────────────
+
+ describe('invalid inputs', () => {
+ it('invalid date string returns 0 (fails gracefully)', () => {
+ const score = computeUrgencyScore({ ...base, due_date: 'not-a-date' });
+ // Should handle NaN gracefully, not crash
+ expect(score).toBeGreaterThanOrEqual(0);
+ expect(score).toBeLessThanOrEqual(100);
+ });
+
+ it('empty date string returns category weight only (no time pressure)', () => {
+ const score = computeUrgencyScore({ ...base, due_date: '' });
+ expect(score).toBeGreaterThanOrEqual(0);
+ expect(score).toBeLessThanOrEqual(100);
+ });
+
+ it('NaN late_fee is treated as no late fee', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(30), late_fee: NaN });
+ expect(score).toBe(15); // just category weight
+ });
+
+ it('negative late_fee is treated as no late fee', () => {
+ const score = computeUrgencyScore({ ...base, due_date: daysFromNow(30), late_fee: -10 });
+ expect(score).toBe(15);
+ });
+
+ it('negative grace_period_days does not break scoring', () => {
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(3),
+ grace_period_days: -5,
+ });
+ // Should not make things worse — treat as 0
+ expect(score).toBeGreaterThanOrEqual(0);
+ });
+
+ it('NaN grace_period_days is treated as 0', () => {
+ const score = computeUrgencyScore({
+ ...base,
+ due_date: daysFromNow(-1),
+ grace_period_days: NaN,
+ });
+ // 1 day overdue (40 time + 15 category = 55)
+ expect(score).toBe(55);
+ });
+ });
+});
+
+// ── urgencyLevel ──────────────────────────────────────────
+
+describe('urgencyLevel', () => {
+ it('returns critical for 70', () => expect(urgencyLevel(70)).toBe('critical'));
+ it('returns critical for 100', () => expect(urgencyLevel(100)).toBe('critical'));
+ it('returns high for 50', () => expect(urgencyLevel(50)).toBe('high'));
+ it('returns high for 69', () => expect(urgencyLevel(69)).toBe('high'));
+ it('returns medium for 30', () => expect(urgencyLevel(30)).toBe('medium'));
+ it('returns medium for 49', () => expect(urgencyLevel(49)).toBe('medium'));
+ it('returns low for 29', () => expect(urgencyLevel(29)).toBe('low'));
+ it('returns low for 0', () => expect(urgencyLevel(0)).toBe('low'));
+});
diff --git a/src/lib/urgency.ts b/src/lib/urgency.ts
index 184695a..9710d14 100644
--- a/src/lib/urgency.ts
+++ b/src/lib/urgency.ts
@@ -11,18 +11,31 @@ export function computeUrgencyScore(obligation: {
grace_period_days: number;
}): number {
let score = 0;
+
+ // Parse dates as date-only (midnight UTC) to avoid time-of-day drift
+ // Cloudflare Workers run in UTC, so this is consistent across environments
const now = new Date();
- const due = new Date(obligation.due_date);
- const daysUntilDue = Math.floor((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
+ const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
+ const due = new Date(obligation.due_date + 'T00:00:00Z');
+
+ // Guard against invalid dates
+ if (isNaN(due.getTime())) {
+ // Can't compute time pressure — fall through to category weight only
+ } else {
+ // Apply grace period: shift effective due date forward
+ const graceDays = Math.max(0, obligation.grace_period_days || 0);
+ const effectiveDueMs = due.getTime() + graceDays * 86400000;
+ const daysUntilDue = Math.floor((effectiveDueMs - today.getTime()) / 86400000);
- // Time pressure
- if (daysUntilDue < -30) score += 50; // severely overdue
- else if (daysUntilDue < -7) score += 45; // overdue > 1 week
- else if (daysUntilDue < 0) score += 40; // overdue
- else if (daysUntilDue === 0) score += 35; // due today
- else if (daysUntilDue <= 3) score += 30; // due in 3 days
- else if (daysUntilDue <= 7) score += 20; // due in a week
- else if (daysUntilDue <= 14) score += 10; // due in 2 weeks
+ // Time pressure
+ if (daysUntilDue < -30) score += 50; // severely overdue
+ else if (daysUntilDue < -7) score += 45; // overdue > 1 week
+ else if (daysUntilDue < 0) score += 40; // overdue
+ else if (daysUntilDue === 0) score += 35; // due today
+ else if (daysUntilDue <= 3) score += 30; // due in 3 days
+ else if (daysUntilDue <= 7) score += 20; // due in a week
+ else if (daysUntilDue <= 14) score += 10; // due in 2 weeks
+ }
// Consequence severity by category
const categoryWeights: Record = {
@@ -39,17 +52,19 @@ export function computeUrgencyScore(obligation: {
};
score += categoryWeights[obligation.category] || 5;
- // Late fee increases urgency
- if (obligation.late_fee && obligation.late_fee > 50) score += 15;
- else if (obligation.late_fee && obligation.late_fee > 25) score += 10;
- else if (obligation.late_fee && obligation.late_fee > 0) score += 5;
+ // Late fee increases urgency (guard against NaN/negative)
+ const lateFee = obligation.late_fee != null && isFinite(obligation.late_fee) ? obligation.late_fee : 0;
+ if (lateFee > 50) score += 15;
+ else if (lateFee > 25) score += 10;
+ else if (lateFee > 0) score += 5;
// Auto-pay reduces urgency (it's handled)
if (obligation.auto_pay) score -= 25;
- // Already paid or disputed reduces urgency
+ // Status modifiers
if (obligation.status === 'paid') score -= 50;
if (obligation.status === 'disputed') score -= 10;
+ if (obligation.status === 'deferred') score -= 15;
return Math.min(100, Math.max(0, score));
}
diff --git a/src/lib/validators.ts b/src/lib/validators.ts
index e067bfc..5f7c2b8 100644
--- a/src/lib/validators.ts
+++ b/src/lib/validators.ts
@@ -112,6 +112,24 @@ export const actOnRecommendationSchema = z.object({
action_taken: z.string().min(1).max(1000).optional(),
});
+// ── Cash Flow ────────────────────────────────────────────────
+
+export const cashflowScenarioSchema = z.object({
+ defer_obligation_ids: z.array(z.string().uuid()).min(1, 'At least one obligation ID required'),
+});
+
+// ── Auth ─────────────────────────────────────────────────────
+
+export const loginSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(1),
+});
+
+export const registerSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(8, 'Password must be at least 8 characters'),
+});
+
// ── Bridge: Ledger ───────────────────────────────────────────
export const recordActionSchema = z.object({
@@ -120,8 +138,52 @@ export const recordActionSchema = z.object({
notes: z.string().max(2000).optional(),
});
+// ── Bridge: Books ────────────────────────────────────────────
+
+export const recordBookTransactionSchema = z.object({
+ type: z.enum(['income', 'expense']),
+ description: z.string().min(1).max(1000),
+ amount: z.number().positive(),
+});
+
+// ── Bridge: Assets ───────────────────────────────────────────
+
+export const submitEvidenceSchema = z.object({
+ evidenceType: z.string().min(1).max(255),
+ data: z.record(z.string(), z.unknown()),
+ metadata: z.record(z.string(), z.unknown()).optional(),
+});
+
+// ── Bridge: Scrape ───────────────────────────────────────────
+
+export const courtDocketScrapeSchema = z.object({
+ caseNumber: z.string().max(50).optional(),
+});
+
// ── Bridge: Plaid ────────────────────────────────────────────
export const exchangeTokenSchema = z.object({
public_token: z.string().min(1),
});
+
+// ── Query Param Schemas ──────────────────────────────────────
+
+const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD');
+
+export const obligationQuerySchema = z.object({
+ status: z.enum(['pending', 'overdue', 'paid', 'deferred', 'disputed']).optional(),
+ category: z.string().max(100).optional(),
+});
+
+export const obligationCalendarQuerySchema = z.object({
+ start: dateString.optional(),
+ end: dateString.optional(),
+});
+
+export const disputeQuerySchema = z.object({
+ status: z.string().max(50).optional(),
+});
+
+export const recommendationQuerySchema = z.object({
+ status: z.string().max(50).optional(),
+});
diff --git a/src/routes/auth.ts b/src/routes/auth.ts
index f26e070..8bcf0cb 100644
--- a/src/routes/auth.ts
+++ b/src/routes/auth.ts
@@ -1,6 +1,7 @@
import { Hono } from 'hono';
import type { Env } from '../index';
import type { AuthVariables } from '../middleware/auth';
+import { loginSchema, registerSchema } from '../lib/validators';
/**
* Auth routes — local auth with KV-stored credentials, falling back to ChittyAuth proxy.
@@ -21,15 +22,10 @@ authRoutes.get('/login', (c) => {
* Returns: { token: string, user_id: string, scopes: string[] }
*/
authRoutes.post('/login', async (c) => {
- const body = await c.req.json().catch(() => null);
- if (!body || typeof body !== 'object') {
- return c.json({ error: 'Invalid request body' }, 400);
- }
-
- const { email, password } = body as { email?: string; password?: string };
- if (!email || !password) {
- return c.json({ error: 'Email and password are required' }, 400);
- }
+ const raw = await c.req.json().catch(() => null);
+ const parsed = loginSchema.safeParse(raw);
+ if (!parsed.success) return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400);
+ const { email, password } = parsed.data;
// ── Local auth: check KV-stored credentials ──
const storedHash = await c.env.COMMAND_KV.get(`auth:user:${email.toLowerCase()}`);
@@ -76,18 +72,10 @@ authRoutes.post('/login', async (c) => {
* Body: { email: string, password: string }
*/
authRoutes.post('/register', async (c) => {
- const body = await c.req.json().catch(() => null);
- if (!body || typeof body !== 'object') {
- return c.json({ error: 'Invalid request body' }, 400);
- }
-
- const { email, password } = body as { email?: string; password?: string };
- if (!email || !password) {
- return c.json({ error: 'Email and password are required' }, 400);
- }
- if (password.length < 8) {
- return c.json({ error: 'Password must be at least 8 characters' }, 400);
- }
+ const raw = await c.req.json().catch(() => null);
+ const parsed = registerSchema.safeParse(raw);
+ if (!parsed.success) return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400);
+ const { email, password } = parsed.data;
const key = `auth:user:${email.toLowerCase()}`;
const existing = await c.env.COMMAND_KV.get(key);
diff --git a/src/routes/bridge.ts b/src/routes/bridge.ts
index 13facd2..bf6e711 100644
--- a/src/routes/bridge.ts
+++ b/src/routes/bridge.ts
@@ -3,7 +3,7 @@ import type { Env } from '../index';
import type { AuthVariables } from '../middleware/auth';
import { getDb } from '../lib/db';
import { ledgerClient, financeClient, plaidClient, mercuryClient, connectClient, booksClient, assetsClient, scrapeClient } from '../lib/integrations';
-import { recordActionSchema, exchangeTokenSchema } from '../lib/validators';
+import { recordActionSchema, exchangeTokenSchema, recordBookTransactionSchema, submitEvidenceSchema, courtDocketScrapeSchema } from '../lib/validators';
export const bridgeRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
@@ -545,10 +545,9 @@ bridgeRoutes.post('/books/record-transaction', async (c) => {
const books = booksClient(c.env);
if (!books) return c.json({ error: 'ChittyBooks not configured' }, 503);
- const body = await c.req.json() as { type: 'income' | 'expense'; description: string; amount: number };
- if (!body.type || !body.description || !body.amount) {
- return c.json({ error: 'type, description, and amount are required' }, 400);
- }
+ const parsed = recordBookTransactionSchema.safeParse(await c.req.json());
+ if (!parsed.success) return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400);
+ const body = parsed.data;
const result = await books.recordTransaction(body);
if (!result) return c.json({ error: 'Failed to record transaction in ChittyBooks' }, 502);
@@ -621,10 +620,9 @@ bridgeRoutes.post('/assets/submit-evidence', async (c) => {
const assets = assetsClient(c.env);
if (!assets) return c.json({ error: 'ChittyAssets not configured' }, 503);
- const body = await c.req.json() as { evidenceType: string; data: Record; metadata?: Record };
- if (!body.evidenceType || !body.data) {
- return c.json({ error: 'evidenceType and data are required' }, 400);
- }
+ const parsed = submitEvidenceSchema.safeParse(await c.req.json());
+ if (!parsed.success) return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400);
+ const body = parsed.data;
const result = await assets.submitEvidence({
evidenceType: body.evidenceType,
@@ -653,8 +651,9 @@ bridgeRoutes.post('/scrape/court-docket', async (c) => {
const token = await c.env.COMMAND_KV.get('scrape:service_token');
if (!token) return c.json({ error: 'Scrape service token not configured' }, 503);
- const { caseNumber } = await c.req.json() as { caseNumber?: string };
- const targetCase = caseNumber || '2024D007847';
+ const parsed = courtDocketScrapeSchema.safeParse(await c.req.json());
+ if (!parsed.success) return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400);
+ const targetCase = parsed.data.caseNumber || '2024D007847';
const result = await scrape.scrapeCourtDocket(targetCase, token);
diff --git a/src/routes/cashflow.ts b/src/routes/cashflow.ts
index 4faf049..013b6b5 100644
--- a/src/routes/cashflow.ts
+++ b/src/routes/cashflow.ts
@@ -2,6 +2,7 @@ import { Hono } from 'hono';
import type { Env } from '../index';
import { getDb } from '../lib/db';
import { generateProjections } from '../lib/projections';
+import { cashflowScenarioSchema } from '../lib/validators';
export const cashflowRoutes = new Hono<{ Bindings: Env }>();
@@ -26,8 +27,9 @@ cashflowRoutes.post('/generate', async (c) => {
// Scenario: "what if I defer obligation X?"
cashflowRoutes.post('/scenario', async (c) => {
- const { defer_obligation_ids } = await c.req.json() as { defer_obligation_ids: string[] };
- if (!defer_obligation_ids?.length) return c.json({ error: 'defer_obligation_ids required' }, 400);
+ const parsed = cashflowScenarioSchema.safeParse(await c.req.json());
+ if (!parsed.success) return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400);
+ const { defer_obligation_ids } = parsed.data;
const sql = getDb(c.env);
diff --git a/src/routes/disputes.ts b/src/routes/disputes.ts
index ab2093c..29bba0d 100644
--- a/src/routes/disputes.ts
+++ b/src/routes/disputes.ts
@@ -2,14 +2,16 @@ import { Hono } from 'hono';
import type { Env } from '../index';
import { getDb } from '../lib/db';
import { ledgerClient } from '../lib/integrations';
-import { createDisputeSchema, updateDisputeSchema, createCorrespondenceSchema } from '../lib/validators';
+import { createDisputeSchema, updateDisputeSchema, createCorrespondenceSchema, disputeQuerySchema } from '../lib/validators';
export const disputeRoutes = new Hono<{ Bindings: Env }>();
// List disputes
disputeRoutes.get('/', async (c) => {
const sql = getDb(c.env);
- const status = c.req.query('status') || 'open';
+ const qResult = disputeQuerySchema.safeParse({ status: c.req.query('status') });
+ if (!qResult.success) return c.json({ error: 'Invalid query params', issues: qResult.error.issues }, 400);
+ const status = qResult.data.status || 'open';
const disputes = await sql`
SELECT * FROM cc_disputes WHERE status = ${status} ORDER BY priority ASC, created_at DESC
`;
diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts
index fd5118c..06755c0 100644
--- a/src/routes/mcp.ts
+++ b/src/routes/mcp.ts
@@ -108,8 +108,8 @@ mcpRoutes.post('/', async (c) => {
return c.json({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
case 'tools/call': {
- const toolName = params?.name;
- const args = params?.arguments || {};
+ const toolName = params?.name as string;
+ const args = (params?.arguments || {}) as Record;
try {
const sql = getDb(c.env);
const result = await executeTool(sql, toolName, args);
diff --git a/src/routes/obligations.ts b/src/routes/obligations.ts
index e30cce8..50441ba 100644
--- a/src/routes/obligations.ts
+++ b/src/routes/obligations.ts
@@ -3,15 +3,17 @@ import type { Env } from '../index';
import { getDb } from '../lib/db';
import { computeUrgencyScore } from '../lib/urgency';
import { chargeClient } from '../lib/integrations';
-import { createObligationSchema, updateObligationSchema } from '../lib/validators';
+import { createObligationSchema, updateObligationSchema, obligationQuerySchema, obligationCalendarQuerySchema } from '../lib/validators';
export const obligationRoutes = new Hono<{ Bindings: Env }>();
// List obligations with filtering
obligationRoutes.get('/', async (c) => {
const sql = getDb(c.env);
- const status = c.req.query('status');
- const category = c.req.query('category');
+ const qResult = obligationQuerySchema.safeParse({ status: c.req.query('status'), category: c.req.query('category') });
+ if (!qResult.success) return c.json({ error: 'Invalid query params', issues: qResult.error.issues }, 400);
+ const status = qResult.data.status || null;
+ const category = qResult.data.category || null;
let obligations;
if (status && category) {
@@ -29,8 +31,10 @@ obligationRoutes.get('/', async (c) => {
// Calendar view: obligations grouped by due date
obligationRoutes.get('/calendar', async (c) => {
const sql = getDb(c.env);
- const start = c.req.query('start') || new Date().toISOString().slice(0, 10);
- const end = c.req.query('end') || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
+ const calResult = obligationCalendarQuerySchema.safeParse({ start: c.req.query('start'), end: c.req.query('end') });
+ if (!calResult.success) return c.json({ error: 'Invalid query params', issues: calResult.error.issues }, 400);
+ const start = calResult.data.start || new Date().toISOString().slice(0, 10);
+ const end = calResult.data.end || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
const obligations = await sql`
SELECT id, payee, category, amount_due, due_date, status, urgency_score, auto_pay
@@ -150,11 +154,13 @@ obligationRoutes.post('/recalculate-urgency', async (c) => {
category: ob.category,
status: ob.status,
auto_pay: ob.auto_pay,
- late_fee: ob.late_fee ? parseFloat(ob.late_fee) : null,
+ late_fee: ob.late_fee ? (isFinite(parseFloat(ob.late_fee)) ? parseFloat(ob.late_fee) : null) : null,
grace_period_days: ob.grace_period_days || 0,
});
- const dueDate = new Date(ob.due_date);
- const newStatus = dueDate < new Date() && ob.status === 'pending' ? 'overdue' : ob.status;
+ const dueDate = new Date(ob.due_date + 'T00:00:00Z');
+ const now = new Date();
+ const todayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
+ const newStatus = dueDate < todayUtc && ob.status === 'pending' ? 'overdue' : ob.status;
updates.push({ id: ob.id, score, status: newStatus });
}
diff --git a/src/routes/recommendations.ts b/src/routes/recommendations.ts
index ae3ef0a..bd49392 100644
--- a/src/routes/recommendations.ts
+++ b/src/routes/recommendations.ts
@@ -1,14 +1,16 @@
import { Hono } from 'hono';
import type { Env } from '../index';
import { getDb } from '../lib/db';
-import { actOnRecommendationSchema } from '../lib/validators';
+import { actOnRecommendationSchema, recommendationQuerySchema } from '../lib/validators';
import { runTriage } from '../lib/triage';
export const recommendationRoutes = new Hono<{ Bindings: Env }>();
recommendationRoutes.get('/', async (c) => {
const sql = getDb(c.env);
- const status = c.req.query('status') || 'active';
+ const qResult = recommendationQuerySchema.safeParse({ status: c.req.query('status') });
+ if (!qResult.success) return c.json({ error: 'Invalid query params', issues: qResult.error.issues }, 400);
+ const status = qResult.data.status || 'active';
const recs = await sql`
SELECT r.*, o.payee as obligation_payee, o.amount_due, o.due_date,
d.title as dispute_title, d.counterparty
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/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..75399b3
--- /dev/null
+++ b/ui/src/components/StatusBar.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useState } from 'react';
+import { useFocusMode } from '../lib/focus-mode';
+import { api, type DashboardData, type SyncStatus } from '../lib/api';
+import { FreshnessDot, freshnessFromDate } from './ui/FreshnessDot';
+import { Eye, EyeOff } from 'lucide-react';
+
+export function StatusBar() {
+ const { focusMode, toggleFocusMode } = useFocusMode();
+ const [data, setData] = useState
(null);
+ const [syncs, setSyncs] = useState([]);
+
+ useEffect(() => {
+ api.getDashboard().then(setData).catch((e) => console.error('[StatusBar] dashboard load failed:', e));
+ api.getSyncStatus().then(setSyncs).catch((e) => console.error('[StatusBar] sync status load failed:', e));
+ }, []);
+
+ const cashPosition = data?.summary?.total_cash;
+ const overdueCount = data?.obligations?.overdue_count;
+ const dueThisWeek = data?.obligations?.due_this_week;
+
+ // Pick the most recent sync per source for freshness display
+ const sourceFreshness = syncs.reduce>((acc, s) => {
+ const current = acc[s.source];
+ if (!current || (s.completed_at && s.completed_at > current)) {
+ acc[s.source] = s.completed_at;
+ }
+ return acc;
+ }, {});
+
+ return (
+
+
+ {cashPosition && (
+
+ Cash
+
+ ${Number(cashPosition).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
+
+
+ )}
+ {overdueCount && Number(overdueCount) > 0 && (
+
+ Overdue
+ {overdueCount}
+
+ )}
+ {dueThisWeek && Number(dueThisWeek) > 0 && (
+
+ Due This Week
+ {dueThisWeek}
+
+ )}
+
+ {/* Freshness dots */}
+ {Object.keys(sourceFreshness).length > 0 && (
+
+ {Object.entries(sourceFreshness).map(([source, lastSync]) => (
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
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..1e63de1
--- /dev/null
+++ b/ui/src/components/dashboard/FocusView.tsx
@@ -0,0 +1,137 @@
+import { useNavigate } from 'react-router-dom';
+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 {
+ id: string;
+ 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 navigate = useNavigate();
+ const { obligations, disputes, deadlines, recommendations } = data;
+
+ const items: FocusItem[] = [];
+
+ obligations.urgent.forEach((ob) => {
+ items.push({
+ id: `ob-${ob.id}`,
+ 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({
+ id: `disp-${d.id}`,
+ 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: () => navigate('/disputes'),
+ loading: false,
+ },
+ });
+ });
+
+ deadlines.forEach((dl) => {
+ const days = daysUntil(dl.deadline_date);
+ items.push({
+ id: `dl-${dl.id}`,
+ 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: () => navigate('/legal'),
+ loading: false,
+ },
+ });
+ });
+
+ recommendations.slice(0, 3).forEach((rec) => {
+ items.push({
+ id: `rec-${rec.id}`,
+ 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) : navigate('/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) => (
+
+
+
{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/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..1be60e1
--- /dev/null
+++ b/ui/src/components/ui/ProgressDots.tsx
@@ -0,0 +1,29 @@
+import { cn } from '../../lib/utils';
+
+interface ProgressDotsProps {
+ completed: number;
+ total: number;
+ className?: string;
+}
+
+export function ProgressDots({ completed, total, className }: ProgressDotsProps) {
+ const safeTotal = Math.max(1, Math.floor(total));
+ const safeCompleted = Math.max(0, Math.min(Math.floor(completed), safeTotal));
+
+ return (
+
+ {Array.from({ length: safeTotal }, (_, i) => (
+
+ ))}
+
+ {safeCompleted}/{safeTotal}
+
+
+ );
+}
diff --git a/ui/src/components/ui/UrgencyBorder.ts b/ui/src/components/ui/UrgencyBorder.ts
new file mode 100644
index 0000000..92acded
--- /dev/null
+++ b/ui/src/components/ui/UrgencyBorder.ts
@@ -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';
+}
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;
}
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(
-
-
- } />
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
+
+
+
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
,
);
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..7d2fc60 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,38 @@ 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)));
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : 'Failed to mark as paid';
+ console.error(`[Bills] markPaid failed for ${id}:`, msg, e);
+ setError(msg);
+ } 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 +57,59 @@ export function Bills() {
-
-
-
-
- | Payee |
- Category |
- Amount |
- Due Date |
- Status |
- Urgency |
- Actions |
-
-
-
- {obligations.map((ob) => {
- const days = ob.due_date ? daysUntil(ob.due_date) : null;
- return (
-
- | {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..b7c5cd7 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
-
- | Date |
- Obligations |
- Outflow |
- Balance After |
- Confidence |
+
+ | Date |
+ Outflow |
+ Balance After |
- {entries.filter((e) => e.outflow > 0).map((entry, i) => (
-
- | {formatDate(entry.date)} |
- {entry.obligations.join(', ') || '-'} |
- -{formatCurrency(entry.outflow)} |
-
+ {chartData.filter((e) => e.outflow > 0).slice(0, 15).map((entry) => (
+ |
+ | {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 && (
-
-
-
+
);
}
-
-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/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)}`}
-
-
-
handlePayNow(obligations.urgent[0])}
- disabled={payingId === obligations.urgent[0].id}
- className="px-4 py-2 bg-chitty-600 text-white rounded font-medium hover:bg-chitty-700 transition-colors disabled:opacity-50"
- >
- {payingId === obligations.urgent[0].id ? 'Paying...' : 'Pay Now'}
-
-
-
- )}
-
-
- {/* 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 && (
-
handleExecute(rec)}
- disabled={executingId === rec.id}
- className="mt-2 px-3 py-1 text-xs bg-chitty-600 text-white rounded hover:bg-chitty-700 disabled:opacity-50"
- >
- {executingId === rec.id ? 'Running...' : 'Execute'}
-
- )}
-
- ))}
- {recommendations.length === 0 && (
-
No recommendations yet — AI triage will generate these
- )}
-
-
-
-
- );
-}
-
-function SummaryCard({ label, value, color }: { label: string; value: string; color: string }) {
- return (
-
- );
+ return focusMode ?
:
;
}
diff --git a/ui/src/pages/Disputes.tsx b/ui/src/pages/Disputes.tsx
index 07bd3c1..15f1ed1 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,140 @@ 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 [panelError, setPanelError] = useState(null);
+
+ const togglePanel = async (disputeId: string, panel: 'correspondence' | 'documents') => {
+ if (expandedId === disputeId && activePanel === panel) {
+ setExpandedId(null);
+ setActivePanel(null);
+ setPanelError(null);
+ return;
+ }
+ setExpandedId(disputeId);
+ setActivePanel(panel);
+ setPanelError(null);
try {
const detail = await api.getDispute(disputeId);
- setDocumentList(detail.documents || []);
- } catch { setDocumentList([]); }
+ if (panel === 'correspondence') setCorrespondenceList(detail.correspondence || []);
+ else setDocumentList(detail.documents || []);
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : 'Failed to load';
+ console.error(`[Disputes] ${panel} load failed for ${disputeId}:`, msg, e);
+ setPanelError(`Unable to load ${panel}: ${msg}`);
+ 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)}
)}
)}
-
-
openCorrespondence(d.id)}
- className="px-3 py-1.5 text-sm bg-chitty-600 text-white rounded hover:bg-chitty-700"
- >
- Add Correspondence
-
-
openDocuments(d.id)}
- className="px-3 py-1.5 text-sm bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
- >
- View Documents
-
-
setStatusFor(statusFor === d.id ? null : d.id)}
- className="px-3 py-1.5 text-sm bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
- >
- Update Status
-
+
+
togglePanel(d.id, 'correspondence')}
+ />
+ togglePanel(d.id, 'documents')}
+ />
{/* Correspondence Panel */}
- {correspondenceFor === d.id && (
-
-
-
Correspondence
- setCorrespondenceFor(null)} className="text-gray-500 hover:text-white text-xs">Close
-
+ {expandedId === d.id && activePanel === 'correspondence' && (
+
+
Correspondence
+ {panelError && (
+
{panelError}
+ )}
{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
)}
)}
{/* Documents Panel */}
- {documentsFor === d.id && (
-
-
-
Documents
- setDocumentsFor(null)} className="text-gray-500 hover:text-white text-xs">Close
-
+ {expandedId === d.id && activePanel === 'documents' && (
+
+
Documents
+ {panelError && (
+
{panelError}
+ )}
{documentList.length > 0 ? (
{documentList.map((doc) => (
-
+
-
{doc.filename || 'Unnamed'}
-
{doc.doc_type} — {formatDate(doc.created_at)}
+
{doc.filename || 'Unnamed'}
+
{doc.doc_type} — {formatDate(doc.created_at)}
))}
) : (
-
No documents linked — upload from the Upload page
+
No documents linked — upload from the Upload page
)}
)}
-
- {/* Update Status Panel */}
- {statusFor === d.id && (
-
-
-
Update Status
- setStatusFor(null)} className="text-gray-500 hover:text-white text-xs">Close
-
-
Current: {d.status}
-
Status updates are managed via the API. Use the dispute detail endpoint or the AI recommendations to progress this dispute.
-
- )}
-
+
))}
{disputes.length === 0 && (
-
No active disputes
+
No active disputes
)}
);
diff --git a/ui/src/pages/Legal.tsx b/ui/src/pages/Legal.tsx
index 18a6217..ebbb3e4 100644
--- a/ui/src/pages/Legal.tsx
+++ b/ui/src/pages/Legal.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { api, type LegalDeadline } from '../lib/api';
import { formatDate, daysUntil } from '../lib/utils';
+import { Card } from '../components/ui/Card';
export function Legal() {
const [deadlines, setDeadlines] = useState
([]);
@@ -10,51 +11,59 @@ export function Legal() {
api.getLegalDeadlines().then(setDeadlines).catch((e) => setError(e.message));
}, []);
- if (error) return {error}
;
+ if (error) return {error}
;
+
+ const urgencyFromDays = (days: number): 'red' | 'amber' | 'green' | null => {
+ if (days < 0) return 'red';
+ if (days <= 7) return 'amber';
+ if (days <= 30) return 'green';
+ return null;
+ };
+
+ const countdownColor = (days: number): string => {
+ if (days < 0) return 'text-urgency-red';
+ if (days <= 7) return 'text-urgency-amber';
+ if (days <= 30) return 'text-urgency-amber';
+ return 'text-card-muted';
+ };
return (
-
Legal Deadlines
+
Legal Deadlines
-
+
{deadlines.map((dl) => {
const days = daysUntil(dl.deadline_date);
- const isUrgent = days <= 7;
const isPast = days < 0;
return (
-
+
-
+
{dl.deadline_type}
- {dl.case_ref}
+ {dl.case_ref}
-
{dl.title}
+
{dl.title}
-
+
{isPast ? `${Math.abs(days)}d PAST` : days === 0 ? 'TODAY' : `${days}d`}
-
{formatDate(dl.deadline_date)}
+
{formatDate(dl.deadline_date)}
-
+
);
})}
{deadlines.length === 0 && (
-
No upcoming legal deadlines
+
+ No upcoming legal deadlines
+
)}
);
diff --git a/ui/src/pages/Login.tsx b/ui/src/pages/Login.tsx
index 9029fa0..3299f88 100644
--- a/ui/src/pages/Login.tsx
+++ b/ui/src/pages/Login.tsx
@@ -28,21 +28,21 @@ export function Login() {
};
return (
-
+
-
-
ChittyCommand
-
Sign in to your command center
+
+
ChittyCommand
+
Sign in to your command center
{error && (
-