From b04a664db7905dac63d02817c289618ebc124541 Mon Sep 17 00:00:00 2001 From: Nick Bianchi Date: Tue, 17 Mar 2026 20:30:14 +0000 Subject: [PATCH 1/2] feat: add Eisenhower Priority Matrix page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a tactical 2×2 priority matrix that aggregates obligations, disputes, tasks, and recommendations into four quadrants (Do First, Schedule, Delegate, Eliminate) based on importance and urgency scoring. Includes crosshair grid layout with animated center dot, responsive mobile stacking, and per-item source icons with due-date/amount metadata. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/Sidebar.tsx | 3 +- ui/src/index.css | 426 ++++++++++++++++++++++++++++++++++ ui/src/main.tsx | 2 + ui/src/pages/Priorities.tsx | 370 +++++++++++++++++++++++++++++ 4 files changed, 800 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/Priorities.tsx diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 92cbd56..28b95ee 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import { NavLink } from 'react-router-dom'; import { cn } from '../lib/utils'; import { LayoutDashboard, Zap, Receipt, ShieldAlert, Wallet, Scale, - Lightbulb, TrendingUp, Upload, Settings, LogOut, + Lightbulb, TrendingUp, Upload, Settings, LogOut, Crosshair, } from 'lucide-react'; import { logout, getUser } from '../lib/auth'; @@ -13,6 +13,7 @@ const navItems = [ { path: '/disputes', label: 'Disputes', icon: ShieldAlert }, { path: '/accounts', label: 'Accounts', icon: Wallet }, { path: '/legal', label: 'Legal', icon: Scale }, + { path: '/priorities', label: 'Priorities', icon: Crosshair }, { path: '/recommendations', label: 'AI Recs', icon: Lightbulb }, { path: '/cashflow', label: 'Cash Flow', icon: TrendingUp }, { path: '/upload', label: 'Upload', icon: Upload }, diff --git a/ui/src/index.css b/ui/src/index.css index 4ee8b40..df8bc9e 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -715,3 +715,429 @@ h1, h2, .font-display { padding: 1.25rem; } } + +/* ═══════════════════════════════════════════════════════════════ + EISENHOWER PRIORITY MATRIX + Tactical operations board — 2×2 quadrant grid with crosshair + ═══════════════════════════════════════════════════════════════ */ + +.eisenhower-root { + display: flex; + flex-direction: column; + gap: 1rem; + animation: fadeIn 0.3s ease-out; +} + +/* ── Header ───────────────────────────────────────────── */ + +.eisenhower-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.eisenhower-header-left { + display: flex; + align-items: center; + gap: 0.75rem; +} +.eisenhower-header-icon { + color: var(--chitty-400); + opacity: 0.8; +} +.eisenhower-title { + font-family: 'Syne', sans-serif; + font-size: 1.25rem; + font-weight: 700; + color: var(--chrome-text); + letter-spacing: -0.01em; +} +.eisenhower-subtitle { + font-size: 0.7rem; + color: var(--chrome-text-muted); + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 0.1rem; +} +.eisenhower-refresh { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + border: 1px solid var(--chrome-border); + background: transparent; + color: var(--chrome-text-muted); + cursor: pointer; + transition: all 0.2s; +} +.eisenhower-refresh:hover { + background: var(--chrome-border); + color: var(--chrome-text); +} + +/* ── Error ────────────────────────────────────────────── */ + +.eisenhower-error { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + background: rgba(244, 63, 94, 0.1); + border: 1px solid rgba(244, 63, 94, 0.25); + color: var(--urgency-red); + font-size: 0.8rem; +} + +/* ── Chips row ────────────────────────────────────────── */ + +.eisenhower-chips { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} +.eisenhower-chip { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.6rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; + border: 1px solid transparent; + transition: transform 0.15s; +} +.eisenhower-chip:hover { + transform: translateY(-1px); +} +.eisenhower-chip-label { + text-transform: uppercase; + letter-spacing: 0.05em; +} +.eisenhower-chip-count { + font-family: 'JetBrains Mono', monospace; + font-size: 0.65rem; + opacity: 0.7; + margin-left: 0.15rem; +} + +/* Quadrant colors */ +.eisenhower-q1 { --q-accent: #f43f5e; --q-bg: rgba(244, 63, 94, 0.08); --q-border: rgba(244, 63, 94, 0.2); --q-text: #fda4af; } +.eisenhower-q2 { --q-accent: #6366f1; --q-bg: rgba(99, 102, 241, 0.08); --q-border: rgba(99, 102, 241, 0.2); --q-text: #a5b4fc; } +.eisenhower-q3 { --q-accent: #f59e0b; --q-bg: rgba(245, 158, 11, 0.08); --q-border: rgba(245, 158, 11, 0.2); --q-text: #fcd34d; } +.eisenhower-q4 { --q-accent: #64748b; --q-bg: rgba(100, 116, 139, 0.06); --q-border: rgba(100, 116, 139, 0.15); --q-text: #94a3b8; } + +.eisenhower-chip.eisenhower-q1 { background: var(--q-bg); border-color: var(--q-border); color: var(--q-text); } +.eisenhower-chip.eisenhower-q2 { background: var(--q-bg); border-color: var(--q-border); color: var(--q-text); } +.eisenhower-chip.eisenhower-q3 { background: var(--q-bg); border-color: var(--q-border); color: var(--q-text); } +.eisenhower-chip.eisenhower-q4 { background: var(--q-bg); border-color: var(--q-border); color: var(--q-text); } + +/* ── Matrix grid ──────────────────────────────────────── */ + +.eisenhower-grid { + position: relative; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + gap: 0; + min-height: 60vh; +} + +/* Crosshair lines */ +.eisenhower-crosshair-h { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--chrome-border) 15%, var(--chitty-400) 50%, var(--chrome-border) 85%, transparent); + opacity: 0.5; + z-index: 2; + pointer-events: none; +} +.eisenhower-crosshair-v { + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 1px; + background: linear-gradient(180deg, transparent, var(--chrome-border) 15%, var(--chitty-400) 50%, var(--chrome-border) 85%, transparent); + opacity: 0.5; + z-index: 2; + pointer-events: none; +} +.eisenhower-crosshair-dot { + position: absolute; + top: 50%; + left: 50%; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--chitty-400); + transform: translate(-50%, -50%); + z-index: 3; + pointer-events: none; + box-shadow: 0 0 12px var(--glow-brand), 0 0 24px var(--glow-brand); + animation: eisenhower-pulse 3s ease-in-out infinite; +} + +@keyframes eisenhower-pulse { + 0%, 100% { box-shadow: 0 0 8px var(--glow-brand), 0 0 16px var(--glow-brand); opacity: 0.8; } + 50% { box-shadow: 0 0 16px var(--glow-brand), 0 0 32px var(--glow-brand); opacity: 1; } +} + +/* Axis labels */ +.eisenhower-axis-y { + position: absolute; + left: -2rem; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + z-index: 1; + pointer-events: none; +} +.eisenhower-axis-x { + position: absolute; + bottom: -1.5rem; + left: 0; + right: 0; + display: flex; + justify-content: space-around; + z-index: 1; + pointer-events: none; +} +.eisenhower-axis-label { + font-size: 0.55rem; + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--chrome-text-muted); + opacity: 0.5; +} +.eisenhower-axis-label--top { + writing-mode: vertical-lr; + transform: rotate(180deg); + margin-top: 1rem; +} +.eisenhower-axis-label--bottom { + writing-mode: vertical-lr; + transform: rotate(180deg); + margin-bottom: 1rem; +} + +/* ── Quadrant panel ───────────────────────────────────── */ + +.eisenhower-quadrant { + display: flex; + flex-direction: column; + min-height: 25vh; + background: var(--q-bg); + transition: background 0.2s; +} +.eisenhower-quadrant:hover { + background: color-mix(in srgb, var(--q-bg) 80%, var(--q-accent) 5%); +} + +.eisenhower-quadrant--tl { border-radius: 16px 0 0 0; border-right: none; border-bottom: none; } +.eisenhower-quadrant--tr { border-radius: 0 16px 0 0; border-left: none; border-bottom: none; } +.eisenhower-quadrant--bl { border-radius: 0 0 0 16px; border-right: none; border-top: none; } +.eisenhower-quadrant--br { border-radius: 0 0 16px 0; border-left: none; border-top: none; } + +.eisenhower-quadrant-header { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.75rem 0.85rem 0.4rem; + color: var(--q-accent); +} +.eisenhower-quadrant-label { + font-family: 'Syne', sans-serif; + font-weight: 700; + font-size: 0.8rem; + letter-spacing: 0.01em; +} +.eisenhower-quadrant-tactical { + font-family: 'JetBrains Mono', monospace; + font-size: 0.55rem; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.5; + margin-left: auto; +} +.eisenhower-quadrant-count { + font-family: 'JetBrains Mono', monospace; + font-size: 0.6rem; + background: var(--q-border); + color: var(--q-text); + padding: 0.1rem 0.4rem; + border-radius: 999px; + font-weight: 600; +} + +.eisenhower-quadrant-list { + flex: 1; + padding: 0.25rem 0.5rem 0.75rem; + overflow-y: auto; + max-height: 45vh; +} +.eisenhower-quadrant-list::-webkit-scrollbar { + width: 3px; +} +.eisenhower-quadrant-list::-webkit-scrollbar-thumb { + background: var(--q-border); + border-radius: 4px; +} + +.eisenhower-empty { + display: flex; + align-items: center; + justify-content: center; + height: 4rem; + font-size: 0.75rem; + color: var(--chrome-text-muted); + opacity: 0.4; + font-style: italic; +} + +/* ── Item row ─────────────────────────────────────────── */ + +.eisenhower-row { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.45rem 0.4rem; + border-radius: 0.5rem; + transition: background 0.15s; + animation: fadeInUp 0.3s ease-out both; +} +.eisenhower-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.eisenhower-row-icon { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.35rem; + background: var(--q-border); + color: var(--q-text); + margin-top: 0.05rem; +} + +.eisenhower-row-body { + flex: 1; + min-width: 0; +} +.eisenhower-row-title { + font-size: 0.78rem; + font-weight: 600; + color: var(--chrome-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} +.eisenhower-row-subtitle { + font-size: 0.65rem; + color: var(--chrome-text-muted); + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.eisenhower-row-meta { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.1rem; +} +.eisenhower-row-amount { + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + font-weight: 600; + color: var(--chrome-text); +} +.eisenhower-row-date { + font-family: 'JetBrains Mono', monospace; + font-size: 0.6rem; + color: var(--chrome-text-muted); + opacity: 0.6; +} +.eisenhower-row-date--overdue { + color: var(--urgency-red); + opacity: 1; + font-weight: 600; +} +.eisenhower-row-date--soon { + color: var(--urgency-amber); + opacity: 1; +} + +/* ── Loading state ────────────────────────────────────── */ + +.eisenhower-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + height: 50vh; + color: var(--chrome-text-muted); + font-size: 0.85rem; +} +.eisenhower-loading-icon { + color: var(--chitty-400); + animation: eisenhower-spin 2s linear infinite; +} +@keyframes eisenhower-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ── Mobile responsive ────────────────────────────────── */ + +@media (max-width: 768px) { + .eisenhower-grid { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto auto; + gap: 0.5rem; + min-height: auto; + } + .eisenhower-quadrant { + min-height: auto; + border-radius: 12px; + border: 1px solid var(--q-border); + } + .eisenhower-quadrant--tl, + .eisenhower-quadrant--tr, + .eisenhower-quadrant--bl, + .eisenhower-quadrant--br { + border-radius: 12px; + } + .eisenhower-crosshair-h, + .eisenhower-crosshair-v, + .eisenhower-crosshair-dot, + .eisenhower-axis-y, + .eisenhower-axis-x { + display: none; + } + .eisenhower-quadrant-list { + max-height: 30vh; + } + .eisenhower-quadrant-tactical { + display: none; + } +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 61f290c..9367604 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -12,6 +12,7 @@ import { CashFlow } from './pages/CashFlow'; import { Recommendations } from './pages/Recommendations'; import { Settings } from './pages/Settings'; import { ActionQueue } from './pages/ActionQueue'; +import { Priorities } from './pages/Priorities'; import { Login } from './pages/Login'; import { isAuthenticated } from './lib/auth'; import { FocusModeProvider } from './lib/focus-mode'; @@ -40,6 +41,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/pages/Priorities.tsx b/ui/src/pages/Priorities.tsx new file mode 100644 index 0000000..d5dfa24 --- /dev/null +++ b/ui/src/pages/Priorities.tsx @@ -0,0 +1,370 @@ +import { useEffect, useState, useMemo } from 'react'; +import { api, type Obligation, type Dispute, type Task, type Recommendation } from '../lib/api'; +import { formatCurrency, formatDate, daysUntil } from '../lib/utils'; +import { + Crosshair, Flame, CalendarClock, Users, Trash2, + Receipt, ShieldAlert, ListChecks, Lightbulb, + AlertTriangle, RefreshCw, +} from 'lucide-react'; + +// ── Quadrant types ─────────────────────────────────────────── + +type Quadrant = 'do_first' | 'schedule' | 'delegate' | 'eliminate'; +type SourceKind = 'obligation' | 'dispute' | 'task' | 'recommendation'; + +interface PriorityItem { + id: string; + kind: SourceKind; + title: string; + subtitle: string | null; + amount: string | null; + dueDate: string | null; + daysLeft: number | null; + urgencyScore: number | null; + priority: number; + status: string; + quadrant: Quadrant; +} + +// ── Classification logic ───────────────────────────────────── + +function classifyObligation(ob: Obligation): Quadrant { + const score = ob.urgency_score ?? 0; + const due = ob.due_date ? daysUntil(ob.due_date) : 999; + const amount = parseFloat(ob.amount_due ?? '0'); + const isImportant = amount >= 200 || ob.category === 'mortgage' || ob.category === 'legal' || ob.category === 'tax'; + const isUrgent = score >= 50 || due <= 7 || ob.status === 'overdue'; + if (isImportant && isUrgent) return 'do_first'; + if (isImportant) return 'schedule'; + if (isUrgent) return 'delegate'; + return 'eliminate'; +} + +function classifyDispute(d: Dispute): Quadrant { + const isImportant = d.priority <= 2 || parseFloat(d.amount_at_stake ?? '0') >= 1000; + const isUrgent = d.next_action_date ? daysUntil(d.next_action_date) <= 7 : d.status === 'open'; + if (isImportant && isUrgent) return 'do_first'; + if (isImportant) return 'schedule'; + if (isUrgent) return 'delegate'; + return 'eliminate'; +} + +function classifyTask(t: Task): Quadrant { + const isImportant = t.priority <= 2 || t.task_type === 'legal' || t.task_type === 'financial'; + const isUrgent = t.due_date ? daysUntil(t.due_date) <= 7 : t.priority <= 1; + if (isImportant && isUrgent) return 'do_first'; + if (isImportant) return 'schedule'; + if (isUrgent) return 'delegate'; + return 'eliminate'; +} + +function classifyRecommendation(r: Recommendation): Quadrant { + const isImportant = r.priority <= 2 || r.rec_type === 'legal' || r.rec_type === 'payment'; + const isUrgent = r.priority <= 1 || r.action_type === 'pay_now'; + if (isImportant && isUrgent) return 'do_first'; + if (isImportant) return 'schedule'; + if (isUrgent) return 'delegate'; + return 'eliminate'; +} + +// ── Quadrant metadata ──────────────────────────────────────── + +const QUADRANTS: Record = { + do_first: { + label: 'Do First', + tactical: 'CRITICAL — ACT NOW', + icon: Flame, + cssClass: 'eisenhower-q1', + }, + schedule: { + label: 'Schedule', + tactical: 'STRATEGIC — PLAN IT', + icon: CalendarClock, + cssClass: 'eisenhower-q2', + }, + delegate: { + label: 'Delegate', + tactical: 'TACTICAL — HAND OFF', + icon: Users, + cssClass: 'eisenhower-q3', + }, + eliminate: { + label: 'Eliminate', + tactical: 'LOW VALUE — DROP IT', + icon: Trash2, + cssClass: 'eisenhower-q4', + }, +}; + +const KIND_ICON: Record = { + obligation: Receipt, + dispute: ShieldAlert, + task: ListChecks, + recommendation: Lightbulb, +}; + +const KIND_LABEL: Record = { + obligation: 'Bill', + dispute: 'Dispute', + task: 'Task', + recommendation: 'Rec', +}; + +// ── Component ──────────────────────────────────────────────── + +export function Priorities() { + const [obligations, setObligations] = useState([]); + const [disputes, setDisputes] = useState([]); + const [tasks, setTasks] = useState([]); + const [recs, setRecs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const [obRes, dispRes, taskRes, recRes] = await Promise.all([ + api.getObligations().catch(() => [] as Obligation[]), + api.getDisputes().catch(() => [] as Dispute[]), + api.getTasks({ limit: 100 }).catch(() => ({ tasks: [] as Task[], total: 0, limit: 100, offset: 0 })), + api.getRecommendations().catch(() => [] as Recommendation[]), + ]); + setObligations(obRes.filter(o => o.status !== 'paid')); + setDisputes(dispRes.filter(d => d.status !== 'resolved' && d.status !== 'closed')); + setTasks(Array.isArray(taskRes) ? taskRes : taskRes.tasks?.filter((t: Task) => t.backend_status !== 'completed') ?? []); + setRecs(recRes); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, []); + + const items = useMemo(() => { + const all: PriorityItem[] = []; + + for (const ob of obligations) { + all.push({ + id: `ob-${ob.id}`, + kind: 'obligation', + title: ob.payee, + subtitle: ob.category + (ob.subcategory ? ` · ${ob.subcategory}` : ''), + amount: ob.amount_due, + dueDate: ob.due_date, + daysLeft: ob.due_date ? daysUntil(ob.due_date) : null, + urgencyScore: ob.urgency_score, + priority: ob.urgency_score ? (ob.urgency_score >= 70 ? 1 : ob.urgency_score >= 50 ? 2 : ob.urgency_score >= 30 ? 3 : 4) : 4, + status: ob.status, + quadrant: classifyObligation(ob), + }); + } + + for (const d of disputes) { + all.push({ + id: `disp-${d.id}`, + kind: 'dispute', + title: d.title, + subtitle: `${d.counterparty} · ${d.dispute_type}`, + amount: d.amount_at_stake, + dueDate: d.next_action_date, + daysLeft: d.next_action_date ? daysUntil(d.next_action_date) : null, + urgencyScore: null, + priority: d.priority, + status: d.status, + quadrant: classifyDispute(d), + }); + } + + for (const t of tasks) { + all.push({ + id: `task-${t.id}`, + kind: 'task', + title: t.title, + subtitle: t.task_type + (t.source ? ` · ${t.source}` : ''), + amount: null, + dueDate: t.due_date, + daysLeft: t.due_date ? daysUntil(t.due_date) : null, + urgencyScore: null, + priority: t.priority, + status: t.backend_status, + quadrant: classifyTask(t), + }); + } + + for (const r of recs) { + all.push({ + id: `rec-${r.id}`, + kind: 'recommendation', + title: r.title, + subtitle: r.rec_type + (r.obligation_payee ? ` · ${r.obligation_payee}` : '') + (r.dispute_title ? ` · ${r.dispute_title}` : ''), + amount: null, + dueDate: null, + daysLeft: null, + urgencyScore: null, + priority: r.priority, + status: 'active', + quadrant: classifyRecommendation(r), + }); + } + + return all.sort((a, b) => a.priority - b.priority); + }, [obligations, disputes, tasks, recs]); + + const grouped = useMemo(() => { + const result: Record = { + do_first: [], + schedule: [], + delegate: [], + eliminate: [], + }; + for (const item of items) { + result[item.quadrant].push(item); + } + return result; + }, [items]); + + if (loading) { + return ( +
+ +

Mapping priority matrix...

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

Priority Matrix

+

+ {items.length} items across {obligations.length} bills, {disputes.length} disputes, {tasks.length} tasks, {recs.length} recs +

+
+
+ +
+ + {error && ( +
+ + {error} +
+ )} + + {/* Quadrant count chips */} +
+ {(['do_first', 'schedule', 'delegate', 'eliminate'] as Quadrant[]).map((q) => { + const meta = QUADRANTS[q]; + const Icon = meta.icon; + return ( +
+ + {meta.label} + {grouped[q].length} +
+ ); + })} +
+ + {/* Matrix grid */} +
+ {/* Axis labels */} +
+ IMPORTANT + NOT IMPORTANT +
+
+ URGENT + NOT URGENT +
+ + {/* Crosshair center */} +
+
+
+ + {/* Q1 — Do First (top-left) */} + + {/* Q2 — Schedule (top-right) */} + + {/* Q3 — Delegate (bottom-left) */} + + {/* Q4 — Eliminate (bottom-right) */} + +
+
+ ); +} + +// ── Quadrant panel component ───────────────────────────────── + +function QuadrantPanel({ quadrant, items, position }: { quadrant: Quadrant; items: PriorityItem[]; position: string }) { + const meta = QUADRANTS[quadrant]; + const Icon = meta.icon; + + return ( +
+
+ + {meta.label} + {meta.tactical} + {items.length > 0 && ( + {items.length} + )} +
+
+ {items.length === 0 ? ( +
All clear
+ ) : ( + items.map((item, idx) => ( + + )) + )} +
+
+ ); +} + +// ── Individual priority item row ───────────────────────────── + +function PriorityRow({ item, index }: { item: PriorityItem; index: number }) { + const KindIcon = KIND_ICON[item.kind]; + const overdue = item.daysLeft !== null && item.daysLeft < 0; + const dueSoon = item.daysLeft !== null && item.daysLeft >= 0 && item.daysLeft <= 3; + + return ( +
+
+ +
+
+
{item.title}
+ {item.subtitle && ( +
{item.subtitle}
+ )} +
+
+ {item.amount && ( + {formatCurrency(item.amount)} + )} + {item.dueDate && ( + + {overdue ? `${Math.abs(item.daysLeft!)}d late` : item.daysLeft === 0 ? 'Today' : item.daysLeft !== null && item.daysLeft <= 14 ? `${item.daysLeft}d` : formatDate(item.dueDate)} + + )} +
+
+ ); +} From d52b41a084b52c0b79695ac1cd834360ed4789df Mon Sep 17 00:00:00 2001 From: Nick Bianchi Date: Wed, 18 Mar 2026 09:42:42 +0000 Subject: [PATCH 2/2] feat: add Agent Board kanban page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /agents page with a Kanban-style board mirroring the Notion Agent Task Board. Displays tasks grouped by status columns (Pending → Claimed → Running → Done/Failed/Blocked) with agent badges, task type labels, priority indicators, and due date tracking. Includes toggle for completed tasks visibility. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/Sidebar.tsx | 3 +- ui/src/index.css | 361 ++++++++++++++++++++++++++++++++++ ui/src/main.tsx | 2 + ui/src/pages/AgentBoard.tsx | 240 ++++++++++++++++++++++ 4 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/AgentBoard.tsx diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 28b95ee..fb1a15d 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import { NavLink } from 'react-router-dom'; import { cn } from '../lib/utils'; import { LayoutDashboard, Zap, Receipt, ShieldAlert, Wallet, Scale, - Lightbulb, TrendingUp, Upload, Settings, LogOut, Crosshair, + Lightbulb, TrendingUp, Upload, Settings, LogOut, Crosshair, Bot, } from 'lucide-react'; import { logout, getUser } from '../lib/auth'; @@ -14,6 +14,7 @@ const navItems = [ { path: '/accounts', label: 'Accounts', icon: Wallet }, { path: '/legal', label: 'Legal', icon: Scale }, { path: '/priorities', label: 'Priorities', icon: Crosshair }, + { path: '/agents', label: 'Agent Board', icon: Bot }, { path: '/recommendations', label: 'AI Recs', icon: Lightbulb }, { path: '/cashflow', label: 'Cash Flow', icon: TrendingUp }, { path: '/upload', label: 'Upload', icon: Upload }, diff --git a/ui/src/index.css b/ui/src/index.css index df8bc9e..64bc961 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1141,3 +1141,364 @@ h1, h2, .font-display { display: none; } } + +/* ═══════════════════════════════════════════════════════════════ + AGENT BOARD — Kanban pipeline for agent tasks + ═══════════════════════════════════════════════════════════════ */ + +:root { + --ab-pending: #f59e0b; + --ab-claimed: #6366f1; + --ab-running: #3b82f6; + --ab-completed: #10b981; + --ab-failed: #f43f5e; + --ab-blocked: #64748b; +} + +.ab-root { + display: flex; + flex-direction: column; + gap: 1rem; + animation: fadeIn 0.3s ease-out; +} + +/* ── Header ───────────────────────────────────────────── */ + +.ab-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; +} +.ab-header-left { + display: flex; + align-items: center; + gap: 0.75rem; +} +.ab-header-icon { + color: var(--chitty-400); + opacity: 0.8; +} +.ab-title { + font-family: 'Syne', sans-serif; + font-size: 1.25rem; + font-weight: 700; + color: var(--chrome-text); + letter-spacing: -0.01em; +} +.ab-subtitle { + font-size: 0.7rem; + color: var(--chrome-text-muted); + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 0.1rem; +} +.ab-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} +.ab-toggle { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.65rem; + border-radius: 999px; + border: 1px solid var(--chrome-border); + background: transparent; + color: var(--chrome-text-muted); + font-size: 0.7rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} +.ab-toggle:hover { + border-color: var(--ab-completed); + color: var(--chrome-text); +} +.ab-toggle--active { + border-color: var(--ab-completed); + background: rgba(16, 185, 129, 0.1); + color: #6ee7b7; +} +.ab-refresh { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + border: 1px solid var(--chrome-border); + background: transparent; + color: var(--chrome-text-muted); + cursor: pointer; + transition: all 0.2s; +} +.ab-refresh:hover { + background: var(--chrome-border); + color: var(--chrome-text); +} + +/* ── Error ────────────────────────────────────────────── */ + +.ab-error { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + background: rgba(244, 63, 94, 0.1); + border: 1px solid rgba(244, 63, 94, 0.25); + color: var(--urgency-red); + font-size: 0.8rem; +} + +/* ── Kanban board ─────────────────────────────────────── */ + +.ab-board { + display: grid; + grid-template-columns: repeat(var(--ab-cols, 5), 1fr); + gap: 0.5rem; + min-height: 55vh; +} + +/* ── Column ───────────────────────────────────────────── */ + +.ab-column { + display: flex; + flex-direction: column; + background: rgba(255, 255, 255, 0.02); + border-radius: 12px; + border: 1px solid var(--chrome-border); + overflow: hidden; + transition: border-color 0.2s; +} +.ab-column:hover { + border-color: color-mix(in srgb, var(--chrome-border) 60%, white 10%); +} + +.ab-column--pending { --col-accent: var(--ab-pending); } +.ab-column--claimed { --col-accent: var(--ab-claimed); } +.ab-column--running { --col-accent: var(--ab-running); } +.ab-column--completed { --col-accent: var(--ab-completed); } +.ab-column--failed { --col-accent: var(--ab-failed); } +.ab-column--blocked { --col-accent: var(--ab-blocked); } + +.ab-column-header { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.6rem 0.7rem; + border-bottom: 1px solid var(--chrome-border); + background: rgba(255, 255, 255, 0.015); +} +.ab-column-icon { + color: var(--col-accent); +} +.ab-column-label { + font-family: 'Syne', sans-serif; + font-weight: 700; + font-size: 0.75rem; + color: var(--chrome-text); +} +.ab-column-tactical { + font-family: 'JetBrains Mono', monospace; + font-size: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--col-accent); + opacity: 0.5; + margin-left: auto; +} +.ab-column-count { + font-family: 'JetBrains Mono', monospace; + font-size: 0.6rem; + background: color-mix(in srgb, var(--col-accent) 15%, transparent); + color: var(--col-accent); + padding: 0.1rem 0.4rem; + border-radius: 999px; + font-weight: 600; +} + +.ab-column-list { + flex: 1; + padding: 0.4rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.ab-column-list::-webkit-scrollbar { + width: 3px; +} +.ab-column-list::-webkit-scrollbar-thumb { + background: var(--chrome-border); + border-radius: 4px; +} + +.ab-empty { + display: flex; + align-items: center; + justify-content: center; + height: 4rem; + font-size: 0.7rem; + color: var(--chrome-text-muted); + opacity: 0.35; + font-style: italic; +} + +/* ── Task card ────────────────────────────────────────── */ + +.ab-card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + padding: 0.55rem 0.6rem; + transition: all 0.15s; + animation: fadeInUp 0.3s ease-out both; + cursor: default; +} +.ab-card:hover { + background: rgba(255, 255, 255, 0.07); + border-color: rgba(255, 255, 255, 0.1); + transform: translateY(-1px); +} + +.ab-card-top { + display: flex; + align-items: center; + gap: 0.3rem; + margin-bottom: 0.3rem; + flex-wrap: wrap; +} +.ab-card-type { + font-size: 0.55rem; + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--chrome-text-muted); + opacity: 0.7; + background: rgba(255, 255, 255, 0.05); + padding: 0.1rem 0.35rem; + border-radius: 3px; +} +.ab-card-agent { + font-size: 0.55rem; + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.04em; + padding: 0.1rem 0.35rem; + border-radius: 3px; + font-weight: 600; +} + +/* Agent color variants */ +.ab-agent--blue { background: rgba(59, 130, 246, 0.15); color: #93c5fd; } +.ab-agent--purple { background: rgba(139, 92, 246, 0.15); color: #c4b5fd; } +.ab-agent--green { background: rgba(16, 185, 129, 0.15); color: #6ee7b7; } +.ab-agent--yellow { background: rgba(234, 179, 8, 0.15); color: #fde68a; } +.ab-agent--orange { background: rgba(249, 115, 22, 0.15); color: #fdba74; } +.ab-agent--red { background: rgba(244, 63, 94, 0.15); color: #fda4af; } +.ab-agent--pink { background: rgba(236, 72, 153, 0.15); color: #f9a8d4; } +.ab-agent--gray { background: rgba(156, 163, 175, 0.15); color: #d1d5db; } +.ab-agent--brown { background: rgba(180, 130, 80, 0.15); color: #d4a574; } +.ab-agent--slate { background: rgba(100, 116, 139, 0.12); color: #94a3b8; } + +.ab-card-title { + font-size: 0.78rem; + font-weight: 600; + color: var(--chrome-text); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.ab-card-desc { + font-size: 0.65rem; + color: var(--chrome-text-muted); + opacity: 0.6; + margin-top: 0.2rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; +} + +.ab-card-footer { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.35rem; + flex-wrap: wrap; +} +.ab-card-priority { + font-family: 'JetBrains Mono', monospace; + font-size: 0.55rem; + font-weight: 700; + color: var(--col-accent); + opacity: 0.8; +} +.ab-card-date { + font-family: 'JetBrains Mono', monospace; + font-size: 0.55rem; + color: var(--chrome-text-muted); + opacity: 0.6; +} +.ab-card-date--overdue { + color: var(--urgency-red); + opacity: 1; + font-weight: 600; +} +.ab-card-verify { + display: flex; + align-items: center; + gap: 0.15rem; + font-family: 'JetBrains Mono', monospace; + font-size: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--chrome-text-muted); + opacity: 0.5; + margin-left: auto; +} + +/* ── Loading state ────────────────────────────────────── */ + +.ab-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + height: 50vh; + color: var(--chrome-text-muted); + font-size: 0.85rem; +} +.ab-loading-icon { + color: var(--chitty-400); + animation: eisenhower-spin 2s linear infinite; +} + +/* ── Mobile responsive ────────────────────────────────── */ + +@media (max-width: 1024px) { + .ab-board { + grid-template-columns: repeat(3, 1fr); + gap: 0.4rem; + } +} +@media (max-width: 768px) { + .ab-board { + grid-template-columns: 1fr; + gap: 0.5rem; + } + .ab-column { + max-height: 40vh; + } + .ab-column-tactical { + display: none; + } +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 9367604..3733e2e 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 { ActionQueue } from './pages/ActionQueue'; import { Priorities } from './pages/Priorities'; +import { AgentBoard } from './pages/AgentBoard'; import { Login } from './pages/Login'; import { isAuthenticated } from './lib/auth'; import { FocusModeProvider } from './lib/focus-mode'; @@ -42,6 +43,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/pages/AgentBoard.tsx b/ui/src/pages/AgentBoard.tsx new file mode 100644 index 0000000..b0ead1c --- /dev/null +++ b/ui/src/pages/AgentBoard.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState, useMemo, useCallback } from 'react'; +import { api, type Task } from '../lib/api'; +import { formatDate, daysUntil, cn } from '../lib/utils'; +import { + Bot, RefreshCw, Clock, Play, CheckCircle2, XCircle, Ban, + ChevronRight, ArrowRight, AlertTriangle, +} from 'lucide-react'; + +// ── Status columns matching Notion Agent Task Board ────────── + +type BoardStatus = 'pending' | 'claimed' | 'running' | 'completed' | 'failed' | 'blocked'; + +const COLUMNS: { status: BoardStatus; label: string; tactical: string; icon: typeof Clock; cssVar: string }[] = [ + { status: 'pending', label: 'Pending', tactical: 'QUEUE', icon: Clock, cssVar: '--ab-pending' }, + { status: 'claimed', label: 'Claimed', tactical: 'ASSIGNED', icon: ArrowRight, cssVar: '--ab-claimed' }, + { status: 'running', label: 'Running', tactical: 'ACTIVE', icon: Play, cssVar: '--ab-running' }, + { status: 'completed', label: 'Done', tactical: 'SHIPPED', icon: CheckCircle2, cssVar: '--ab-completed' }, + { status: 'failed', label: 'Failed', tactical: 'ERROR', icon: XCircle, cssVar: '--ab-failed' }, + { status: 'blocked', label: 'Blocked', tactical: 'WAITING', icon: Ban, cssVar: '--ab-blocked' }, +]; + +// Map backend_status values to board columns +function mapStatus(backendStatus: string): BoardStatus { + const s = backendStatus.toLowerCase(); + if (s === 'pending' || s === 'open' || s === 'new') return 'pending'; + if (s === 'claimed' || s === 'assigned') return 'claimed'; + if (s === 'running' || s === 'in_progress' || s === 'active') return 'running'; + if (s === 'completed' || s === 'done' || s === 'resolved' || s === 'verified') return 'completed'; + if (s === 'failed' || s === 'error') return 'failed'; + if (s === 'blocked' || s === 'waiting' || s === 'deferred') return 'blocked'; + return 'pending'; +} + +// Agent name badge colors +const AGENT_COLORS: Record = { + 'chittyagent-tasks': 'ab-agent--blue', + 'chittyagent-notion': 'ab-agent--purple', + 'chittyagent-ui': 'ab-agent--green', + 'chittyagent-notes': 'ab-agent--yellow', + 'chittyagent-ship': 'ab-agent--orange', + 'chittyagent-dispute': 'ab-agent--red', + 'chittyagent-imessage': 'ab-agent--pink', + 'chittyagent-canon': 'ab-agent--gray', + 'chittyagent-chatgpt': 'ab-agent--brown', + 'chittyagent-cleaner': 'ab-agent--slate', + 'chittyagent-cloudflare': 'ab-agent--blue', + 'chittyagent-finance': 'ab-agent--green', + 'chittyagent-helper': 'ab-agent--purple', + 'chittyagent-orchestrator': 'ab-agent--orange', + 'chittyagent-resolve': 'ab-agent--red', +}; + +// Task type badge labels +const TYPE_LABELS: Record = { + evidence_ingest: 'Evidence', + email_monitor: 'Email', + note_sync: 'Notes', + error_triage: 'Triage', + notion_sync: 'Notion', + deploy: 'Deploy', + cleanup: 'Cleanup', + general: 'General', + legal: 'Legal', + financial: 'Finance', + compliance: 'Compliance', + dispute: 'Dispute', +}; + +// ── Component ──────────────────────────────────────────────── + +export function AgentBoard() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCompleted, setShowCompleted] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await api.getTasks({ limit: 200 }); + setTasks(Array.isArray(res) ? res : res.tasks ?? []); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load tasks'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const grouped = useMemo(() => { + const result: Record = { + pending: [], claimed: [], running: [], completed: [], failed: [], blocked: [], + }; + for (const task of tasks) { + const status = mapStatus(task.backend_status); + if (status === 'completed' && !showCompleted) continue; + result[status].push(task); + } + // Sort each column by priority (lower = higher priority) + for (const col of Object.values(result)) { + col.sort((a, b) => a.priority - b.priority); + } + return result; + }, [tasks, showCompleted]); + + const activeCols = useMemo(() => { + if (showCompleted) return COLUMNS; + // Always show pending/claimed/running/failed/blocked; hide completed if empty and toggled off + return COLUMNS.filter(c => c.status !== 'completed' || grouped.completed.length > 0); + }, [showCompleted, grouped]); + + const totalByStatus = useMemo(() => { + const counts: Record = { pending: 0, claimed: 0, running: 0, completed: 0, failed: 0, blocked: 0 }; + for (const task of tasks) { + counts[mapStatus(task.backend_status)]++; + } + return counts; + }, [tasks]); + + if (loading) { + return ( +
+ +

Loading agent operations...

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

Agent Board

+

+ {tasks.length} tasks · {totalByStatus.running} running · {totalByStatus.pending} queued +

+
+
+
+ + +
+
+ + {error && ( +
+ + {error} +
+ )} + + {/* Kanban board */} +
+ {activeCols.map((col) => { + const Icon = col.icon; + const items = grouped[col.status]; + return ( +
+
+ + {col.label} + {col.tactical} + {items.length > 0 && ( + {items.length} + )} +
+
+ {items.length === 0 ? ( +
+ {col.status === 'failed' ? 'No failures' : col.status === 'blocked' ? 'Nothing blocked' : 'Empty'} +
+ ) : ( + items.map((task, idx) => ( + + )) + )} +
+
+ ); + })} +
+
+ ); +} + +// ── Task card ──────────────────────────────────────────────── + +function TaskCard({ task, index }: { task: Task; index: number }) { + const agent = task.assigned_to || task.source || ''; + const agentShort = agent.replace('chittyagent-', '').replace('notion-task-triager', 'triager'); + const agentClass = AGENT_COLORS[agent] || 'ab-agent--slate'; + const typeLabel = TYPE_LABELS[task.task_type] || task.task_type; + const hasDue = Boolean(task.due_date); + const days = hasDue ? daysUntil(task.due_date!) : null; + const overdue = days !== null && days < 0; + + return ( +
+
+ {typeLabel} + {agentShort && ( + {agentShort} + )} +
+
{task.title}
+ {task.description && ( +
{task.description}
+ )} +
+ + P{task.priority} + + {hasDue && ( + + {overdue ? `${Math.abs(days!)}d late` : days === 0 ? 'Today' : days !== null && days <= 14 ? `${days}d` : formatDate(task.due_date!)} + + )} + {task.verification_type && task.verification_type !== 'none' && ( + + + {task.verification_type} + + )} +
+
+ ); +}