-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
584 lines (547 loc) · 90.3 KB
/
index.html
File metadata and controls
584 lines (547 loc) · 90.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SparkyBot Command Center</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
surface: { 50: '#1a1a2e', 100: '#16213e', 200: '#0f3460', 300: '#252547' },
amber: { 400: '#f6b93b', 500: '#e58e26', 600: '#d4a017' },
slate: { 750: '#293548' }
},
fontFamily: {
mono: ['JetBrains Mono', 'monospace'],
sans: ['DM Sans', 'sans-serif'],
}
}
}
}
</script>
<style>
* { box-sizing: border-box; }
body { background: #0a0a1a; color: #e2e8f0; font-family: 'DM Sans', sans-serif; margin: 0; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
.kanban-column { min-height: calc(100vh - 220px); }
.card-drag { opacity: 0.5; }
.drop-target { border: 2px dashed #f6b93b !important; background: rgba(246, 185, 59, 0.05) !important; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
@keyframes slideIn { from { opacity: 0; transform: translateX(-16px); } to { opacity: 1; transform: translateX(0); } }
.animate-fade { animation: fadeIn 0.3s ease-out; }
.animate-slide { animation: slideIn 0.25s ease-out; }
.animate-pulse-slow { animation: pulse 2s infinite; }
.priority-1 { border-left: 3px solid #ef4444; }
.priority-2 { border-left: 3px solid #f97316; }
.priority-3 { border-left: 3px solid #eab308; }
.priority-4 { border-left: 3px solid #3b82f6; }
.priority-5 { border-left: 3px solid #6b7280; }
.glass { background: rgba(22, 33, 62, 0.6); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.06); }
.glass-hover:hover { background: rgba(22, 33, 62, 0.8); border-color: rgba(255,255,255,0.12); }
.btn-amber { background: linear-gradient(135deg, #f6b93b, #e58e26); color: #0a0a1a; font-weight: 600; }
.btn-amber:hover { background: linear-gradient(135deg, #f9c74f, #f6b93b); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(246,185,59,0.3); }
.modal-overlay { background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); }
input, textarea, select { background: #1a1a2e; border: 1px solid rgba(255,255,255,0.1); color: #e2e8f0; border-radius: 6px; padding: 10px 14px; font-size: 14px; font-family: 'DM Sans', sans-serif; transition: border-color 0.2s; }
input:focus, textarea:focus, select:focus { outline: none; border-color: #f6b93b; box-shadow: 0 0 0 3px rgba(246,185,59,0.15); }
select option { background: #1a1a2e; }
.sparkline { fill: none; stroke: #f6b93b; stroke-width: 2; }
.sparkline-area { fill: url(#sparkGrad); opacity: 0.3; }
.bubble-user { background: rgba(246, 185, 59, 0.15); border: 1px solid rgba(246, 185, 59, 0.2); border-radius: 16px 16px 4px 16px; }
.bubble-bot { background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.15); border-radius: 16px 16px 16px 4px; }
.tab-active { border-bottom: 2px solid #f6b93b; color: #f6b93b; }
.tab-inactive { border-bottom: 2px solid transparent; color: #94a3b8; }
.tab-inactive:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.1); }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useRef, useMemo } = React;
// ============================================
// Configuration
// ============================================
const GOOGLE_CLIENT_ID = '33924084936-p812or3vaa78vl4fhoovpvimov45d6tq.apps.googleusercontent.com';
const API_BASE = 'https://sparkybot-scheduler.sparkybot.workers.dev/dashboard';
// ============================================
// API Client (replaces Supabase)
// ============================================
let _idToken = localStorage.getItem('gid_token') || '';
function setToken(token) {
_idToken = token;
if (token) localStorage.setItem('gid_token', token);
else localStorage.removeItem('gid_token');
}
async function api(path, options = {}) {
const resp = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_idToken}`,
...(options.headers || {}),
},
body: options.body ? (typeof options.body === 'string' ? options.body : JSON.stringify(options.body)) : undefined,
});
if (resp.status === 401) {
setToken('');
window.location.reload();
throw new Error('Session expired');
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: resp.statusText }));
throw new Error(err.error || resp.statusText);
}
return resp.json();
}
// ============================================
// Constants
// ============================================
const STATUSES = ['backlog', 'todo', 'in_progress', 'review', 'done'];
const STATUS_CONFIG = {
backlog: { label: 'Backlog', emoji: '📦', color: '#6b7280', bg: 'rgba(107,114,128,0.1)' },
todo: { label: 'To Do', emoji: '📋', color: '#eab308', bg: 'rgba(234,179,8,0.1)' },
in_progress: { label: 'In Progress', emoji: '🔨', color: '#3b82f6', bg: 'rgba(59,130,246,0.1)' },
review: { label: 'Review', emoji: '🔍', color: '#a855f7', bg: 'rgba(168,85,247,0.1)' },
done: { label: 'Done', emoji: '✅', color: '#22c55e', bg: 'rgba(34,197,94,0.1)' },
};
const PRIORITY_CONFIG = {
1: { label: 'Critical', color: '#ef4444', icon: '🔴' },
2: { label: 'High', color: '#f97316', icon: '🟠' },
3: { label: 'Medium', color: '#eab308', icon: '🟡' },
4: { label: 'Low', color: '#3b82f6', icon: '🔵' },
5: { label: 'Minimal', color: '#6b7280', icon: '⚪' },
};
const ASSIGNEE_PRESETS = {
'claude': { label: '🤖 Claude', color: '#a855f7' },
'sparky': { label: '⚡ Sparky', color: '#f6b93b' },
'both': { label: '🤝 Both', color: '#3b82f6' },
};
function getAssigneeDisplay(a) { if (!a) return null; return ASSIGNEE_PRESETS[a] || { label: `👤 ${a}`, color: '#94a3b8' }; }
function timeAgo(date) { const now = new Date(); const d = new Date(date); const s = Math.floor((now - d) / 1000); if (s < 60) return 'just now'; const m = Math.floor(s / 60); if (m < 60) return m + 'm ago'; const h = Math.floor(m / 60); if (h < 24) return h + 'h ago'; const days = Math.floor(h / 24); if (days < 7) return days + 'd ago'; return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
// ============================================
// Login Screen (Google Identity Services)
// ============================================
function LoginScreen({ onLogin }) {
const [error, setError] = useState('');
const btnRef = useRef(null);
useEffect(() => { if (!window.google?.accounts?.id) { const interval = setInterval(() => { if (window.google?.accounts?.id) { clearInterval(interval); initGSI(); } }, 200); return () => clearInterval(interval); } initGSI(); }, []);
function initGSI() { try { google.accounts.id.initialize({ client_id: GOOGLE_CLIENT_ID, callback: handleCredentialResponse, auto_select: true }); if (btnRef.current) { google.accounts.id.renderButton(btnRef.current, { theme: 'filled_black', size: 'large', width: 320, text: 'signin_with' }); } } catch (e) { setError('Failed to initialize Google Sign-In: ' + e.message); } }
function handleCredentialResponse(response) { if (response.credential) { setToken(response.credential); onLogin(response.credential); } else { setError('Sign-in failed. Please try again.'); } }
return (<div className="min-h-screen flex items-center justify-center p-8"><div className="glass rounded-2xl p-10 max-w-md w-full animate-fade"><div className="text-center mb-8"><div className="text-5xl mb-3">⚡</div><h1 className="text-2xl font-bold font-mono tracking-tight">SparkyBot Command Center</h1><p className="text-sm text-gray-400 mt-2">Sign in to access your dashboard</p></div>{error && <div className="text-red-400 text-sm bg-red-400/10 rounded-lg p-3 mb-5">{error}</div>}<div className="flex justify-center" ref={btnRef}></div><p className="text-xs text-gray-500 mt-6 text-center">Access is restricted to authorized accounts only.</p></div></div>);
}
// ============================================
// Toast
// ============================================
function Toast({ message, type, onDismiss }) { useEffect(() => { const t = setTimeout(onDismiss, 3000); return () => clearTimeout(t); }, []); const colors = { success: 'bg-green-500/20 text-green-400 border-green-500/30', error: 'bg-red-500/20 text-red-400 border-red-500/30', info: 'bg-blue-500/20 text-blue-400 border-blue-500/30' }; return <div className={`fixed bottom-6 right-6 z-50 ${colors[type]} border rounded-lg px-4 py-3 text-sm font-mono animate-fade shadow-xl`}>{message}</div>; }
// ============================================
// Stats Bar
// ============================================
function StatsBar({ stats }) { const items = [ { label: 'Active', value: stats.active_tasks ?? 0, color: '#f6b93b' }, { label: 'In Progress', value: stats.in_progress ?? 0, color: '#3b82f6' }, { label: 'Done (7d)', value: stats.done_7d ?? 0, color: '#22c55e' }, { label: 'Memories', value: stats.total_memories ?? 0, color: '#a855f7' }, { label: 'Projects', value: stats.projects ?? 0, color: '#8b5cf6' } ]; return (<div className="flex gap-4 overflow-x-auto pb-2">{items.map(s => (<div key={s.label} className="glass rounded-lg px-4 py-3 min-w-[120px]"><div className="text-2xl font-bold font-mono" style={{ color: s.color }}>{s.value}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-0.5">{s.label}</div></div>))}</div>); }
// ============================================
// Kanban Card
// ============================================
function KanbanCard({ card, onEdit, onDelete }) { const [dragging, setDragging] = useState(false); const handleDragStart = (e) => { setDragging(true); e.dataTransfer.setData('text/plain', JSON.stringify({ id: card.id, status: card.status })); e.dataTransfer.effectAllowed = 'move'; }; const assignee = getAssigneeDisplay(card.assigned_to); return (<div draggable onDragStart={handleDragStart} onDragEnd={() => setDragging(false)} className={`glass glass-hover rounded-lg p-4 mb-3 cursor-grab active:cursor-grabbing transition-all animate-slide priority-${card.priority} ${dragging ? 'card-drag' : ''}`}><div className="flex items-start justify-between gap-2 mb-2"><h3 className="text-sm font-semibold leading-snug flex-1">{card.is_anomaly ? <span className="text-amber-400 mr-1">⚠️</span> : null}{card.title}</h3></div><div className="flex items-center gap-2 flex-wrap"><span className="text-xs px-2 py-0.5 rounded-full font-mono" style={{ background: `${PRIORITY_CONFIG[card.priority]?.color}20`, color: PRIORITY_CONFIG[card.priority]?.color }}>{PRIORITY_CONFIG[card.priority]?.icon} {PRIORITY_CONFIG[card.priority]?.label}</span>{card.project_name && <span className="text-xs text-gray-400 font-mono">{card.project_name}</span>}{assignee && <span className="text-xs px-2 py-0.5 rounded-full font-mono" style={{ background: `${assignee.color}20`, color: assignee.color }}>{assignee.label}</span>}</div>{card.github_issue_url && <a href={card.github_issue_url} target="_blank" rel="noopener" className="text-xs text-blue-400 hover:text-blue-300 mt-2 inline-flex items-center gap-1 font-mono">🔗 #{card.github_issue_id}</a>}<div className="flex items-center justify-between mt-3 pt-3 border-t border-white/5"><code className="text-[10px] text-gray-500 font-mono">{card.id?.substring(0, 8)}</code><div className="flex gap-1"><button onClick={() => onDelete(card)} className="text-xs text-gray-500 hover:text-red-400 transition-colors px-1.5 py-0.5 rounded hover:bg-red-400/10">🗑️</button><button onClick={() => onEdit(card)} className="text-xs text-gray-500 hover:text-amber-400 transition-colors px-1.5 py-0.5 rounded hover:bg-amber-400/10">✏️</button></div></div></div>); }
// ============================================
// Kanban Column
// ============================================
function KanbanColumn({ status, cards, onDrop, onEdit, onDelete }) { const [dragOver, setDragOver] = useState(false); const config = STATUS_CONFIG[status]; const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOver(true); }; const handleDrop = (e) => { e.preventDefault(); setDragOver(false); try { const data = JSON.parse(e.dataTransfer.getData('text/plain')); if (data.status !== status) onDrop(data.id, status); } catch {} }; return (<div className={`kanban-column rounded-xl p-3 transition-all ${dragOver ? 'drop-target' : ''}`} style={{ background: config.bg, minWidth: '250px', flex: '1 1 250px' }} onDragOver={handleDragOver} onDragLeave={() => setDragOver(false)} onDrop={handleDrop}><div className="flex items-center justify-between mb-4 px-1"><div className="flex items-center gap-2"><span className="text-lg">{config.emoji}</span><h2 className="text-sm font-bold font-mono uppercase tracking-wider" style={{ color: config.color }}>{config.label}</h2></div><span className="text-xs font-mono px-2 py-0.5 rounded-full" style={{ background: `${config.color}20`, color: config.color }}>{cards.length}</span></div><div className="space-y-0">{cards.map(c => <KanbanCard key={c.id} card={c} onEdit={onEdit} onDelete={onDelete} />)}{cards.length === 0 && <div className="text-center py-8 text-gray-600 text-xs font-mono">Drop cards here</div>}</div></div>); }
// ============================================
// Card Modal
// ============================================
function CardModal({ card, projects, onSave, onClose }) { const [title, setTitle] = useState(card?.title || ''); const [description, setDescription] = useState(card?.description || ''); const [status, setStatus] = useState(card?.status || 'todo'); const [priority, setPriority] = useState(card?.priority || 3); const [projectId, setProjectId] = useState(card?.project_id || (projects[0]?.id || '')); const [assignee, setAssignee] = useState(card?.assigned_to || ''); const [saving, setSaving] = useState(false); const handleSave = async () => { if (!title.trim()) return; setSaving(true); await onSave({ id: card?.id, title: title.trim(), description: description.trim(), status, priority: parseInt(priority), project_id: projectId, assigned_to: assignee || null }); setSaving(false); onClose(); }; return (<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-4" onClick={onClose}><div className="glass rounded-2xl p-6 w-full max-w-lg animate-fade" onClick={e => e.stopPropagation()}><h2 className="text-lg font-bold font-mono mb-5">{card ? 'Edit Task' : 'New Task'}</h2><div className="space-y-4"><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Title</label><input value={title} onChange={e => setTitle(e.target.value)} placeholder="Task title..." className="w-full" autoFocus /></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Description</label><textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="Optional details..." className="w-full h-24 resize-none" /></div><div className="grid grid-cols-2 gap-3"><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Status</label><select value={status} onChange={e => setStatus(e.target.value)} className="w-full text-sm">{STATUSES.map(s => <option key={s} value={s}>{STATUS_CONFIG[s].emoji} {STATUS_CONFIG[s].label}</option>)}</select></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Priority</label><select value={priority} onChange={e => setPriority(e.target.value)} className="w-full text-sm">{[1,2,3,4,5].map(p => <option key={p} value={p}>{PRIORITY_CONFIG[p].icon} {PRIORITY_CONFIG[p].label}</option>)}</select></div></div><div className="grid grid-cols-2 gap-3"><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Project</label><select value={projectId} onChange={e => setProjectId(e.target.value)} className="w-full text-sm">{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}</select></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Assignee</label><select value={assignee} onChange={e => setAssignee(e.target.value)} className="w-full text-sm"><option value="">Unassigned</option>{Object.entries(ASSIGNEE_PRESETS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}</select></div></div></div><div className="flex gap-3 mt-6"><button onClick={onClose} className="flex-1 py-2.5 rounded-lg text-sm font-mono text-gray-400 hover:text-white hover:bg-white/5 transition-all">Cancel</button><button onClick={handleSave} disabled={saving || !title.trim()} className="flex-1 btn-amber py-2.5 rounded-lg text-sm font-mono transition-all disabled:opacity-50">{saving ? 'Saving...' : card ? 'Update' : 'Create'}</button></div></div></div>); }
// ============================================
// Portfolio Tab
// ============================================
function PortfolioTab() { const [snapshots, setSnapshots] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { api('/portfolio').then(d => { setSnapshots(d.results || []); setLoading(false); }).catch(() => setLoading(false)); }, []); if (loading) return <div className="text-center py-12 text-gray-500 font-mono text-sm animate-pulse-slow">Loading portfolio...</div>; if (snapshots.length === 0) return <div className="glass rounded-xl p-8 text-center animate-fade"><div className="text-3xl mb-3">📊</div><p className="text-gray-400 font-mono text-sm">No portfolio snapshots yet.</p></div>; const latest = snapshots[0]; const holdings = typeof latest.holdings === 'string' ? JSON.parse(latest.holdings) : (latest.holdings || []); const history = [...snapshots].reverse(); const maxVal = Math.max(...history.map(s => Number(s.total_value) || 0)); const minVal = Math.min(...history.map(s => Number(s.total_value) || 0)); const range = maxVal - minVal || 1; return (<div className="space-y-6 animate-fade"><div className="grid grid-cols-1 sm:grid-cols-3 gap-4"><div className="glass rounded-xl p-5"><div className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-1">Total Value</div><div className="text-2xl font-bold font-mono text-amber-400">{'$' + Number(latest.total_value || 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}</div><div className="text-xs font-mono mt-1" style={{ color: latest.daily_change_pct >= 0 ? '#22c55e' : '#ef4444' }}>{latest.daily_change_pct >= 0 ? '▲' : '▼'} {'$' + Math.abs(Number(latest.daily_change || 0)).toLocaleString('en-US', { minimumFractionDigits: 2 })} ({latest.daily_change_pct >= 0 ? '+' : ''}{latest.daily_change_pct}%)</div></div><div className="glass rounded-xl p-5"><div className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-1">Holdings</div><div className="text-2xl font-bold font-mono text-blue-400">{Array.isArray(holdings) ? holdings.length : '—'}</div><div className="text-xs text-gray-500 mt-1">positions tracked</div></div><div className="glass rounded-xl p-5"><div className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-1">History</div><div className="text-2xl font-bold font-mono text-purple-400">{snapshots.length}</div><div className="text-xs text-gray-500 mt-1">daily snapshots</div></div></div>{history.length > 1 && (<div className="glass rounded-xl p-5"><div className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-4">Portfolio Value — Last {history.length} Days</div><div className="relative h-48"><svg viewBox={'0 0 ' + (history.length * 30) + ' 200'} className="w-full h-full" preserveAspectRatio="none"><defs><linearGradient id="sparkGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="#f6b93b" stopOpacity="0.3"/><stop offset="100%" stopColor="#f6b93b" stopOpacity="0"/></linearGradient></defs><path className="sparkline-area" d={history.map((s, i) => { const x = i * 30; const y = 190 - ((Number(s.total_value || 0) - minVal) / range) * 170; return (i === 0 ? 'M' : 'L') + x + ',' + y; }).join(' ') + ' L' + ((history.length - 1) * 30) + ',190 L0,190 Z'} /><path className="sparkline" d={history.map((s, i) => { const x = i * 30; const y = 190 - ((Number(s.total_value || 0) - minVal) / range) * 170; return (i === 0 ? 'M' : 'L') + x + ',' + y; }).join(' ')} /></svg></div></div>)}{Array.isArray(holdings) && holdings.length > 0 && (<div className="glass rounded-xl p-5"><div className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-4">Holdings — {new Date(latest.snapshot_date).toLocaleDateString()}</div><div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="text-xs font-mono text-gray-500 uppercase border-b border-white/5"><th className="text-left py-2 pr-4">Symbol</th><th className="text-right py-2 px-4">Shares</th><th className="text-right py-2 px-4">Price</th><th className="text-right py-2 px-4">Value</th><th className="text-right py-2 pl-4">Change</th></tr></thead><tbody>{holdings.map((h, i) => (<tr key={i} className="border-b border-white/5 hover:bg-white/5 transition-colors"><td className="py-2.5 pr-4 font-mono font-semibold text-amber-400">{h.symbol}</td><td className="py-2.5 px-4 text-right font-mono text-gray-300">{h.shares || h.quantity || '—'}</td><td className="py-2.5 px-4 text-right font-mono text-gray-300">{'$' + Number(h.price || h.currentPrice || 0).toFixed(2)}</td><td className="py-2.5 px-4 text-right font-mono text-gray-300">{'$' + Number(h.value || h.totalValue || 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}</td><td className="py-2.5 pl-4 text-right font-mono" style={{ color: Number(h.changePct || h.change_pct || 0) >= 0 ? '#22c55e' : '#ef4444' }}>{Number(h.changePct || h.change_pct || 0) >= 0 ? '+' : ''}{Number(h.changePct || h.change_pct || 0).toFixed(2)}%</td></tr>))}</tbody></table></div></div>)}</div>); }
// ============================================
// Conversations Tab
// ============================================
function ConversationsTab() { const [messages, setMessages] = useState([]); const [skills, setSkills] = useState([]); const [loading, setLoading] = useState(true); const [filterSkill, setFilterSkill] = useState('all'); const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(true); const PAGE = 50; const load = useCallback(async (off = 0, append = false) => { setLoading(true); const skillParam = filterSkill !== 'all' ? `&skill=${encodeURIComponent(filterSkill)}` : ''; const data = await api(`/conversations?limit=${PAGE}&offset=${off}${skillParam}`); if (append) setMessages(prev => [...prev, ...(data.results || [])]); else { setMessages(data.results || []); setSkills(data.skills || []); } setHasMore((data.results || []).length === PAGE); setLoading(false); }, [filterSkill]); useEffect(() => { setOffset(0); load(0); }, [load]); const pairs = useMemo(() => { const result = []; const filtered = messages; for (let i = 0; i < filtered.length; i++) { const msg = filtered[i]; if (msg.role === 'assistant' && i + 1 < filtered.length && filtered[i+1].role === 'user') { result.push({ user: filtered[i+1], bot: msg }); i++; } else { result.push(msg.role === 'user' ? { user: msg, bot: null } : { user: null, bot: msg }); } } return result.reverse(); }, [messages]); if (loading && messages.length === 0) return <div className="text-center py-12 text-gray-500 font-mono text-sm animate-pulse-slow">Loading conversations...</div>; return (<div className="animate-fade"><div className="flex items-center justify-between mb-4"><div className="text-xs font-mono text-gray-500">{messages.length} messages loaded{hasMore ? '+' : ''}</div><select value={filterSkill} onChange={e => setFilterSkill(e.target.value)} className="text-xs font-mono py-1.5 px-2 rounded-lg"><option value="all">All Skills</option>{skills.map(s => <option key={s} value={s}>{s}</option>)}</select></div>{hasMore && <div className="text-center mb-4"><button onClick={() => { const next = offset + PAGE; setOffset(next); load(next, true); }} className="text-xs font-mono text-amber-400 hover:text-amber-300 px-4 py-2 rounded-lg glass glass-hover transition-all">Load older messages...</button></div>}<div className="space-y-4 max-w-3xl mx-auto">{pairs.map((pair, i) => (<div key={i} className="space-y-2 animate-slide">{pair.user && <div className="flex justify-end"><div className="bubble-user px-4 py-3 max-w-[80%]"><div className="text-sm whitespace-pre-wrap">{pair.user.message}</div><div className="text-[10px] font-mono text-gray-500 mt-1.5">{timeAgo(pair.user.created_at)}</div></div></div>}{pair.bot && <div className="flex justify-start"><div className="bubble-bot px-4 py-3 max-w-[80%]"><div className="flex items-center gap-1.5 mb-1"><span className="text-xs">⚡</span><span className="text-[10px] font-mono text-blue-400 uppercase tracking-wider">Sparky</span>{pair.bot.skill_used && <span className="text-[10px] font-mono text-gray-600 ml-1">via {pair.bot.skill_used}</span>}</div><div className="text-sm whitespace-pre-wrap text-gray-300">{pair.bot.message && pair.bot.message.length > 500 ? pair.bot.message.substring(0, 500) + '...' : pair.bot.message}</div><div className="text-[10px] font-mono text-gray-600 mt-1.5">{timeAgo(pair.bot.created_at)}</div></div></div>}</div>))}</div>{messages.length === 0 && <div className="glass rounded-xl p-8 text-center"><div className="text-3xl mb-3">💬</div><p className="text-gray-400 font-mono text-sm">No conversations yet.</p></div>}</div>); }
// ============================================
// VIP Contacts Tab
// ============================================
function VIPModal({ contact, onSave, onClose }) { const [name, setName] = useState(contact?.name || ''); const [email, setEmail] = useState(contact?.email || ''); const [phone, setPhone] = useState(contact?.phone || ''); const [twitter, setTwitter] = useState(contact?.twitter_handle || ''); const [priority, setPriority] = useState(contact?.priority || 1); const [notes, setNotes] = useState(contact?.notes || ''); return (<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-4" onClick={onClose}><div className="glass rounded-2xl p-6 w-full max-w-md animate-fade" onClick={e => e.stopPropagation()}><h2 className="text-lg font-bold font-mono mb-5">{contact ? 'Edit VIP' : 'New VIP Contact'}</h2><div className="space-y-4"><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase">Name *</label><input value={name} onChange={e => setName(e.target.value)} className="w-full" autoFocus /></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase">Email</label><input value={email} onChange={e => setEmail(e.target.value)} className="w-full" /></div><div className="grid grid-cols-2 gap-3"><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase">Phone</label><input value={phone} onChange={e => setPhone(e.target.value)} className="w-full" /></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase">X / Twitter</label><input value={twitter} onChange={e => setTwitter(e.target.value)} className="w-full" /></div></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase">Priority</label><select value={priority} onChange={e => setPriority(parseInt(e.target.value))} className="w-full text-sm">{[1,2,3,4,5].map(p => <option key={p} value={p}>{'★'.repeat(p)} Priority {p}</option>)}</select></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase">Notes</label><textarea value={notes} onChange={e => setNotes(e.target.value)} className="w-full h-20 resize-none" /></div></div><div className="flex gap-3 mt-6"><button onClick={onClose} className="flex-1 py-2.5 rounded-lg text-sm font-mono text-gray-400 hover:text-white hover:bg-white/5">Cancel</button><button onClick={() => onSave({ id: contact?.id, name, email: email || null, phone: phone || null, twitter_handle: twitter || null, priority, notes: notes || null })} disabled={!name.trim()} className="flex-1 btn-amber py-2.5 rounded-lg text-sm font-mono disabled:opacity-50">{contact ? 'Update' : 'Add VIP'}</button></div></div></div>); }
function VIPContactsTab({ showToast }) { const [contacts, setContacts] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); const fetch_ = useCallback(async () => { const d = await api('/vip'); setContacts(d.results || []); setLoading(false); }, []); useEffect(() => { fetch_(); }, [fetch_]); const handleSave = async (c) => { if (c.id) await api(`/vip/${c.id}`, { method: 'PUT', body: c }); else await api('/vip', { method: 'POST', body: c }); setEditing(null); fetch_(); showToast(c.id ? 'VIP updated' : 'VIP added'); }; const handleDelete = async (id) => { if (!confirm('Delete this VIP?')) return; await api(`/vip/${id}`, { method: 'DELETE' }); fetch_(); showToast('VIP deleted'); }; if (loading) return <div className="text-center py-12 text-gray-500 font-mono text-sm animate-pulse-slow">Loading...</div>; return (<div className="animate-fade"><div className="flex items-center justify-between mb-4"><div className="text-xs font-mono text-gray-500">{contacts.length} VIP contact{contacts.length !== 1 ? 's' : ''}</div><button onClick={() => setEditing({})} className="btn-amber px-4 py-1.5 rounded-lg text-xs font-mono">+ Add VIP</button></div>{contacts.length === 0 ? <div className="glass rounded-xl p-8 text-center"><div className="text-3xl mb-3">👑</div><p className="text-gray-400 font-mono text-sm">No VIP contacts yet.</p></div> : (<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{contacts.map(c => (<div key={c.id} className="glass glass-hover rounded-xl p-4 animate-slide"><div className="flex items-start justify-between gap-2 mb-2"><div><h3 className="text-sm font-semibold">{c.name}</h3><div className="flex items-center gap-0.5 mt-0.5">{[1,2,3,4,5].map(i => <span key={i} className={'text-[10px] ' + (i <= c.priority ? 'text-amber-400' : 'text-gray-700')}>★</span>)}</div></div><div className="flex gap-1"><button onClick={() => setEditing(c)} className="text-xs text-gray-500 hover:text-amber-400 px-1.5 py-0.5 rounded hover:bg-amber-400/10">✏️</button><button onClick={() => handleDelete(c.id)} className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-red-400/10">🗑️</button></div></div><div className="space-y-1 text-xs font-mono text-gray-400">{c.email && <div>📧 {c.email}</div>}{c.phone && <div>📱 {c.phone}</div>}{c.twitter_handle && <div>𝕏 @{(c.twitter_handle || '').replace('@','')}</div>}{c.notes && <div className="text-gray-500 mt-2 italic text-[11px]">{c.notes}</div>}</div></div>))}</div>)}{editing !== null && <VIPModal contact={editing.id ? editing : null} onSave={handleSave} onClose={() => setEditing(null)} />}</div>); }
// ============================================
// Approvals Tab
// ============================================
const APPROVAL_STATUS_CONFIG = { pending: { color: '#f6b93b', bg: 'rgba(246,185,59,0.15)', label: '⏳ Pending' }, approved: { color: '#22c55e', bg: 'rgba(34,197,94,0.15)', label: '✅ Approved' }, rejected: { color: '#ef4444', bg: 'rgba(239,68,68,0.15)', label: '❌ Rejected' }, modified: { color: '#3b82f6', bg: 'rgba(59,130,246,0.15)', label: '✏️ Modified' }, expired: { color: '#6b7280', bg: 'rgba(107,114,128,0.15)', label: '⏰ Expired' } };
function ApprovalsTab({ showToast }) { const [approvals, setApprovals] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState('all'); const [days, setDays] = useState(14); const [hasMore, setHasMore] = useState(false); const [expanded, setExpanded] = useState(null); const [acting, setActing] = useState(null); const fetch_ = useCallback(async (d) => { const data = await api(`/approvals?days=${d}`); setApprovals(data.results || []); setHasMore(data.hasMore); setLoading(false); }, []); useEffect(() => { fetch_(days); }, [fetch_, days]); const handleAction = async (id, status) => { setActing(id); try { await api(`/approvals/${id}`, { method: 'PUT', body: { status, user_response: { action: status, source: 'dashboard' } } }); showToast(status === 'approved' ? 'Approved — action will execute via bot' : 'Rejected'); fetch_(days); } catch (e) { showToast(`Action failed: ${e.message}`, 'error'); } setActing(null); }; if (loading) return <div className="text-center py-12 text-gray-500 font-mono text-sm animate-pulse-slow">Loading...</div>; const filtered = filter === 'all' ? approvals : approvals.filter(a => a.status === filter); const pendingCount = approvals.filter(a => a.status === 'pending').length; return (<div className="space-y-4 animate-fade"><div className="flex items-center justify-between gap-2 flex-wrap"><div className="text-xs font-mono text-gray-500">{approvals.length} approvals · last {days} days {pendingCount > 0 && <span className="text-amber-400">({pendingCount} pending)</span>}</div><div className="flex gap-1">{['all', 'pending', 'approved', 'rejected'].map(f => (<button key={f} onClick={() => setFilter(f)} className={'px-3 py-1 rounded-lg text-xs font-mono transition-all ' + (filter === f ? 'bg-amber-500/20 text-amber-400' : 'text-gray-500 hover:text-gray-300 hover:bg-white/5')}>{f === 'all' ? `All (${approvals.length})` : f.charAt(0).toUpperCase() + f.slice(1)}</button>))}</div></div>{filtered.length === 0 ? <div className="glass rounded-xl p-8 text-center"><p className="text-gray-400 font-mono text-sm">{filter === 'pending' ? 'No pending approvals 🎉' : 'No approvals found'}</p></div> : (<div className="space-y-2">{filtered.map(a => { const cfg = APPROVAL_STATUS_CONFIG[a.status] || APPROVAL_STATUS_CONFIG.pending; const details = typeof a.action_details === 'string' ? JSON.parse(a.action_details) : (a.action_details || {}); const isPending = a.status === 'pending'; return (<div key={a.id} className={`glass rounded-xl p-4 transition-all ${isPending ? 'ring-1 ring-amber-400/20' : ''}`}><div className="flex items-start justify-between gap-3"><div className="flex-1 min-w-0"><div className="flex items-center gap-2 mb-1"><span className="text-sm font-mono font-semibold">{a.action_type}</span><span className="text-xs px-2 py-0.5 rounded-full font-mono" style={{ background: cfg.bg, color: cfg.color }}>{cfg.label}</span></div><p className="text-xs text-gray-400 font-mono truncate">{details.subject || details.to || JSON.stringify(details).substring(0, 80)}</p><div className="text-xs text-gray-600 font-mono mt-1">{timeAgo(a.created_at)}</div></div><div className="flex items-center gap-1.5 shrink-0"><button onClick={() => setExpanded(expanded === a.id ? null : a.id)} className="text-xs text-gray-500 hover:text-blue-400 px-2 py-1.5 rounded-lg hover:bg-blue-400/10">{expanded === a.id ? '▲' : '▼'}</button>{isPending && <><button onClick={() => handleAction(a.id, 'approved')} disabled={acting === a.id} className="text-xs font-mono px-3 py-1.5 rounded-lg bg-green-500/15 text-green-400 hover:bg-green-500/25 disabled:opacity-50">✅</button><button onClick={() => handleAction(a.id, 'rejected')} disabled={acting === a.id} className="text-xs font-mono px-3 py-1.5 rounded-lg bg-red-500/15 text-red-400 hover:bg-red-500/25 disabled:opacity-50">❌</button></>}</div></div>{expanded === a.id && <div className="mt-3 pt-3 border-t border-white/5 animate-fade"><pre className="text-xs font-mono text-gray-500 bg-black/30 rounded-lg p-3 whitespace-pre-wrap max-h-48 overflow-y-auto">{JSON.stringify(details, null, 2)}</pre></div>}</div>); })}</div>)}{hasMore && <div className="text-center pt-2"><button onClick={() => setDays(d => d + 14)} className="px-4 py-2 rounded-lg text-xs font-mono bg-white/5 text-gray-400 hover:bg-white/10">Load more...</button></div>}</div>); }
// ============================================
// Skills Tab
// ============================================
function SkillsTab({ showToast }) { const [skills, setSkills] = useState([]); const [loading, setLoading] = useState(true); const fetch_ = useCallback(async () => { const d = await api('/skills'); setSkills(d.results || []); setLoading(false); }, []); useEffect(() => { fetch_(); }, [fetch_]); const handleToggle = async (skill) => { const newEnabled = !skill.enabled; setSkills(prev => prev.map(s => s.id === skill.id ? { ...s, enabled: newEnabled ? 1 : 0 } : s)); try { await api(`/skills/${skill.id}`, { method: 'PUT', body: { enabled: newEnabled } }); showToast(`${skill.name} ${newEnabled ? 'enabled' : 'disabled'}`); } catch (e) { showToast(`Toggle failed: ${e.message}`, 'error'); fetch_(); } }; const handleDelete = async (skill) => { if (!confirm(`Delete "${skill.name}"?`)) return; await api(`/skills/${skill.id}`, { method: 'DELETE' }); setSkills(prev => prev.filter(s => s.id !== skill.id)); showToast(`${skill.name} deleted`); }; if (loading) return <div className="text-center py-12 text-gray-500 font-mono text-sm animate-pulse-slow">Loading...</div>; const enabledCount = skills.filter(s => s.enabled).length; return (<div className="animate-fade"><div className="flex items-center justify-between mb-4"><div className="text-xs font-mono text-gray-500">{skills.length} skill{skills.length !== 1 ? 's' : ''} ({enabledCount} active)</div></div>{skills.length === 0 ? <div className="glass rounded-xl p-8 text-center"><div className="text-3xl mb-3">📦</div><p className="text-gray-400 font-mono text-sm">No skills registered.</p></div> : (<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{skills.map(s => { const triggers = typeof s.trigger_patterns === 'string' ? JSON.parse(s.trigger_patterns || '[]') : (s.trigger_patterns || []); return (<div key={s.id} className={`glass rounded-xl p-4 animate-slide transition-all ${!s.enabled ? 'opacity-50' : ''}`}><div className="flex items-start justify-between gap-2 mb-2"><div className="min-w-0"><h3 className="text-sm font-semibold truncate">{s.name}</h3><code className="text-[10px] font-mono text-gray-600">{s.id}</code></div><button onClick={() => handleToggle(s)} className={`shrink-0 w-10 h-5 rounded-full transition-all relative ${s.enabled ? 'bg-green-500/30' : 'bg-gray-700'}`}><div className={`absolute top-0.5 w-4 h-4 rounded-full transition-all ${s.enabled ? 'left-5 bg-green-400' : 'left-0.5 bg-gray-500'}`} /></button></div><p className="text-xs text-gray-400 mb-2 line-clamp-2">{s.description}</p><div className="flex items-center gap-1.5 flex-wrap mb-2"><span className="text-[10px] px-2 py-0.5 rounded-full font-mono bg-white/5 text-gray-500">{s.autonomy_level}</span></div>{triggers.length > 0 && <div className="flex flex-wrap gap-1 mb-2">{triggers.slice(0, 3).map((t, i) => <span key={i} className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-gray-500">"{t}"</span>)}</div>}<div className="flex items-center justify-between mt-3 pt-3 border-t border-white/5"><span className="text-[10px] font-mono text-gray-600">{s.updated_at ? timeAgo(s.updated_at) : ''}</span><button onClick={() => handleDelete(s)} className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-red-400/10">🗑️</button></div></div>); })}</div>)}</div>); }
// ============================================
// Memory Tab
// ============================================
const MEMORY_CATEGORIES = ['preferences', 'projects', 'patterns', 'facts', 'contacts', 'routines'];
const MEMORY_CAT_CONFIG = { preferences: { icon: '⚙️', color: '#a855f7', label: 'Preferences' }, projects: { icon: '🔨', color: '#3b82f6', label: 'Projects' }, patterns: { icon: '🔄', color: '#f97316', label: 'Patterns' }, facts: { icon: '📋', color: '#22c55e', label: 'Facts' }, contacts: { icon: '👥', color: '#ec4899', label: 'Contacts' }, routines: { icon: '📅', color: '#eab308', label: 'Routines' } };
function ConfidenceBar({ value }) { const pct = Math.round((value || 0) * 100); const color = pct >= 80 ? '#22c55e' : pct >= 50 ? '#eab308' : '#ef4444'; return (<div className="flex items-center gap-2"><div className="flex-1 h-1.5 rounded-full bg-white/5 overflow-hidden"><div className="h-full rounded-full transition-all" style={{ width: pct + '%', background: color }} /></div><span className="text-[10px] font-mono shrink-0" style={{ color }}>{pct}%</span></div>); }
function MemoryModal({ memory, onSave, onClose }) { const [category, setCategory] = useState(memory?.category || 'facts'); const [key, setKey] = useState(memory?.key || ''); const [value, setValue] = useState(memory?.value || ''); const [confidence, setConfidence] = useState(memory?.confidence_score ?? 0.9); const [saving, setSaving] = useState(false); const handleSave = async () => { if (!key.trim() || !value.trim()) return; setSaving(true); await onSave({ id: memory?.id, category, key: key.trim(), value: value.trim(), confidence_score: parseFloat(confidence) }); setSaving(false); }; return (<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-4" onClick={onClose}><div className="glass rounded-2xl p-6 w-full max-w-md animate-fade" onClick={e => e.stopPropagation()}><h2 className="text-lg font-bold font-mono mb-5">{memory?.id ? 'Edit Memory' : 'Teach New Memory'}</h2><div className="space-y-4"><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Category</label><select value={category} onChange={e => setCategory(e.target.value)} className="w-full text-sm">{MEMORY_CATEGORIES.map(c => <option key={c} value={c}>{MEMORY_CAT_CONFIG[c].icon} {MEMORY_CAT_CONFIG[c].label}</option>)}</select></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Key</label><input value={key} onChange={e => setKey(e.target.value)} placeholder="e.g. favorite_language, timezone..." className="w-full" autoFocus /></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Value</label><textarea value={value} onChange={e => setValue(e.target.value)} placeholder="What should Sparky remember?" className="w-full h-24 resize-none" /></div><div><label className="block text-xs font-mono text-gray-400 mb-1.5 uppercase tracking-wider">Confidence ({Math.round(confidence * 100)}%)</label><input type="range" min="0.1" max="1.0" step="0.05" value={confidence} onChange={e => setConfidence(e.target.value)} className="w-full accent-amber-400" /></div></div><div className="flex gap-3 mt-6"><button onClick={onClose} className="flex-1 py-2.5 rounded-lg text-sm font-mono text-gray-400 hover:text-white hover:bg-white/5 transition-all">Cancel</button><button onClick={handleSave} disabled={saving || !key.trim() || !value.trim()} className="flex-1 btn-amber py-2.5 rounded-lg text-sm font-mono transition-all disabled:opacity-50">{saving ? 'Saving...' : memory?.id ? 'Update' : 'Teach'}</button></div></div></div>); }
function MemoryTab({ showToast }) { const [memories, setMemories] = useState([]); const [memStats, setMemStats] = useState(null); const [loading, setLoading] = useState(true); const [filterCat, setFilterCat] = useState('all'); const [search, setSearch] = useState(''); const [editing, setEditing] = useState(null); const [showStats, setShowStats] = useState(true); const fetchMemories = useCallback(async () => { const params = new URLSearchParams(); if (filterCat !== 'all') params.set('category', filterCat); if (search.trim()) params.set('q', search.trim()); params.set('limit', '200'); const [memData, statsData] = await Promise.all([ api('/memory?' + params.toString()), api('/memory/stats') ]); setMemories(memData.results || []); setMemStats(statsData); setLoading(false); }, [filterCat, search]); useEffect(() => { fetchMemories(); }, [fetchMemories]); const handleSave = async (mem) => { try { if (mem.id) { await api(`/memory/${mem.id}`, { method: 'PUT', body: mem }); showToast('Memory updated'); } else { await api('/memory', { method: 'POST', body: mem }); showToast('Memory taught'); } setEditing(null); fetchMemories(); } catch (e) { showToast('Save failed: ' + e.message, 'error'); } }; const handleDelete = async (id) => { if (!confirm('Delete this memory?')) return; try { await api(`/memory/${id}`, { method: 'DELETE' }); setMemories(prev => prev.filter(m => m.id !== id)); showToast('Memory forgotten'); } catch (e) { showToast('Delete failed: ' + e.message, 'error'); } }; const grouped = useMemo(() => { const g = {}; MEMORY_CATEGORIES.forEach(c => { g[c] = []; }); memories.forEach(m => { if (g[m.category]) g[m.category].push(m); }); return g; }, [memories]); if (loading) return <div className="text-center py-12 text-gray-500 font-mono text-sm animate-pulse-slow">Loading memories...</div>; return (<div className="space-y-5 animate-fade">{showStats && memStats && (<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"><div className="glass rounded-xl p-4"><div className="text-2xl font-bold font-mono text-amber-400">{memStats.total_memories || 0}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-0.5">Total Memories</div></div><div className="glass rounded-xl p-4"><div className="text-2xl font-bold font-mono text-green-400">{memStats.high_confidence || 0}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-0.5">High Confidence</div></div><div className="glass rounded-xl p-4"><div className="text-2xl font-bold font-mono text-yellow-400">{memStats.medium_confidence || 0}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-0.5">Medium</div></div><div className="glass rounded-xl p-4"><div className="text-2xl font-bold font-mono text-red-400">{memStats.low_confidence || 0}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-0.5">Low Confidence</div></div></div>)}{memStats && memStats.by_category && (<div className="glass rounded-xl p-4"><div className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-3">By Category</div><div className="flex flex-wrap gap-2">{(memStats.by_category || []).map(c => { const cfg = MEMORY_CAT_CONFIG[c.category] || {}; return (<span key={c.category} className="text-xs px-3 py-1.5 rounded-full font-mono flex items-center gap-1.5" style={{ background: (cfg.color || '#6b7280') + '15', color: cfg.color || '#6b7280' }}>{cfg.icon} {cfg.label || c.category} <strong>{c.count}</strong></span>); })}</div>{memStats.last_consolidation && <div className="text-[10px] font-mono text-gray-600 mt-3">Last consolidation: {timeAgo(memStats.last_consolidation.run_at)} — reviewed {memStats.last_consolidation.memories_reviewed}, decayed {memStats.last_consolidation.memories_decayed}, expired {memStats.last_consolidation.memories_expired}</div>}</div>)}<div className="flex items-center justify-between gap-3 flex-wrap"><div className="flex items-center gap-2 flex-1 min-w-0"><input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search memories..." className="text-xs font-mono py-1.5 px-3 rounded-lg flex-1 min-w-[150px] max-w-xs" /><select value={filterCat} onChange={e => setFilterCat(e.target.value)} className="text-xs font-mono py-1.5 px-2 rounded-lg"><option value="all">All Categories</option>{MEMORY_CATEGORIES.map(c => <option key={c} value={c}>{MEMORY_CAT_CONFIG[c].icon} {MEMORY_CAT_CONFIG[c].label}</option>)}</select><button onClick={() => setShowStats(s => !s)} className="text-xs font-mono text-gray-500 hover:text-gray-300 px-2 py-1.5 rounded-lg hover:bg-white/5">{showStats ? '▲ Stats' : '▼ Stats'}</button></div><button onClick={() => setEditing({})} className="btn-amber px-4 py-1.5 rounded-lg text-xs font-mono shrink-0">+ Teach Memory</button></div>{memories.length === 0 ? (<div className="glass rounded-xl p-8 text-center"><div className="text-3xl mb-3">🧠</div><p className="text-gray-400 font-mono text-sm">No memories yet. Teach Sparky something!</p></div>) : (filterCat === 'all' ? (<div className="space-y-5">{MEMORY_CATEGORIES.filter(c => grouped[c].length > 0).map(cat => { const cfg = MEMORY_CAT_CONFIG[cat]; return (<div key={cat}><div className="flex items-center gap-2 mb-3"><span className="text-sm">{cfg.icon}</span><h3 className="text-xs font-bold font-mono uppercase tracking-wider" style={{ color: cfg.color }}>{cfg.label}</h3><span className="text-[10px] font-mono text-gray-600">({grouped[cat].length})</span></div><div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">{grouped[cat].map(m => (<div key={m.id} className="glass glass-hover rounded-lg p-3 animate-slide"><div className="flex items-start justify-between gap-2 mb-1.5"><div className="min-w-0 flex-1"><div className="text-xs font-semibold truncate">{m.key.replace(/_/g, ' ')}</div><div className="text-xs text-gray-400 mt-0.5 line-clamp-2">{m.value}</div></div><div className="flex gap-0.5 shrink-0"><button onClick={() => setEditing(m)} className="text-xs text-gray-500 hover:text-amber-400 px-1 py-0.5 rounded hover:bg-amber-400/10">✏️</button><button onClick={() => handleDelete(m.id)} className="text-xs text-gray-500 hover:text-red-400 px-1 py-0.5 rounded hover:bg-red-400/10">🗑️</button></div></div><ConfidenceBar value={m.confidence_score} /><div className="flex items-center justify-between mt-1.5"><span className="text-[9px] font-mono text-gray-600">×{m.reinforcement_count || 1} reinforced</span><span className="text-[9px] font-mono text-gray-600">{m.updated_at ? timeAgo(m.updated_at) : ''}</span></div></div>))}</div></div>); })}</div>) : (<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">{memories.map(m => (<div key={m.id} className="glass glass-hover rounded-lg p-3 animate-slide"><div className="flex items-start justify-between gap-2 mb-1.5"><div className="min-w-0 flex-1"><div className="text-xs font-semibold truncate">{m.key.replace(/_/g, ' ')}</div><div className="text-xs text-gray-400 mt-0.5 line-clamp-2">{m.value}</div></div><div className="flex gap-0.5 shrink-0"><button onClick={() => setEditing(m)} className="text-xs text-gray-500 hover:text-amber-400 px-1 py-0.5 rounded hover:bg-amber-400/10">✏️</button><button onClick={() => handleDelete(m.id)} className="text-xs text-gray-500 hover:text-red-400 px-1 py-0.5 rounded hover:bg-red-400/10">🗑️</button></div></div><ConfidenceBar value={m.confidence_score} /><div className="flex items-center justify-between mt-1.5"><span className="text-[9px] font-mono text-gray-600">×{m.reinforcement_count || 1} reinforced</span><span className="text-[9px] font-mono text-gray-600">{m.updated_at ? timeAgo(m.updated_at) : ''}</span></div></div>))}</div>))}{editing !== null && <MemoryModal memory={editing?.id ? editing : null} onSave={handleSave} onClose={() => setEditing(null)} />}</div>); }
// ============================================
// Heartbeat Tab
// ============================================
const ALERT_LEVEL_CONFIG = { critical: { icon: '🚨', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', label: 'Critical' }, warning: { icon: '⚠️', color: '#f97316', bg: 'rgba(249,115,22,0.15)', label: 'Warning' }, info: { icon: 'ℹ️', color: '#3b82f6', bg: 'rgba(59,130,246,0.15)', label: 'Info' } };
const MONITOR_ICONS = { market_alert: '📈', task_deadline: '📋', anomaly_check: '⚠️', portfolio_summary: '💰', memory_health: '🧠' };
function HeartbeatTab({ showToast }) {
const [stats, setStats] = useState(null);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [logFilter, setLogFilter] = useState('all');
const [sentFilter, setSentFilter] = useState('all');
const [showLog, setShowLog] = useState(false);
const [testing, setTesting] = useState(false);
const [toggling, setToggling] = useState(null);
const [editingMonitor, setEditingMonitor] = useState(null);
const fetchData = useCallback(async () => {
try {
const [statsData, logData] = await Promise.all([
api('/heartbeat/stats'),
api('/heartbeat/log?limit=100')
]);
setStats(statsData);
setLogs(logData.results || []);
} catch (e) { console.error('Heartbeat fetch error:', e); }
setLoading(false);
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const handleToggle = async (monitor) => {
setToggling(monitor.id);
try {
await api(`/heartbeat/${monitor.id}`, { method: 'PUT', body: { enabled: !monitor.enabled } });
showToast(`${monitor.display_name} ${monitor.enabled ? 'disabled' : 'enabled'}`);
fetchData();
} catch (e) { showToast('Toggle failed: ' + e.message, 'error'); }
setToggling(null);
};
const handleTest = async () => {
setTesting(true);
try {
const result = await api('/heartbeat/test', { method: 'POST' });
showToast(`Heartbeat: ${result.checked} monitors checked, ${result.alerts_sent} alerts sent, ${result.suppressed} suppressed`);
fetchData();
} catch (e) { showToast('Test failed: ' + e.message, 'error'); }
setTesting(false);
};
const handleSaveThreshold = async (monitor, newConfig) => {
try {
await api(`/heartbeat/${monitor.id}`, { method: 'PUT', body: { threshold_config: newConfig } });
showToast('Thresholds updated');
setEditingMonitor(null);
fetchData();
} catch (e) { showToast('Save failed: ' + e.message, 'error'); }
};
const filteredLogs = useMemo(() => {
let f = logs;
if (logFilter !== 'all') f = f.filter(l => l.monitor_type === logFilter);
if (sentFilter === 'sent') f = f.filter(l => l.was_sent);
if (sentFilter === 'suppressed') f = f.filter(l => !l.was_sent);
return f;
}, [logs, logFilter, sentFilter]);
if (loading) return <div className="text-center py-12 text-gray-500 font-mono text-sm animate-pulse-slow">Loading heartbeat...</div>;
const monitors = stats?.monitors || [];
const today = stats?.today || {};
const recentAlerts = stats?.recent_alerts || [];
return (<div className="space-y-5 animate-fade">
{/* Status bar */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
<div className="glass rounded-xl p-4">
<div className="text-2xl font-bold font-mono text-amber-400">{monitors.filter(m => m.enabled).length}/{monitors.length}</div>
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-0.5">Active Monitors</div>
</div>
<div className="glass rounded-xl p-4">
<div className="text-2xl font-bold font-mono text-green-400">{today.sent_today || 0}</div>
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-0.5">Sent Today</div>
</div>
<div className="glass rounded-xl p-4">
<div className="text-2xl font-bold font-mono text-gray-400">{today.suppressed_today || 0}</div>
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-0.5">Suppressed</div>
</div>
<div className="glass rounded-xl p-4">
<div className="text-2xl font-bold font-mono" style={{ color: stats?.is_quiet_hours ? '#ef4444' : '#22c55e' }}>{stats?.current_hour_cst ?? '?'}:00</div>
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-0.5">{stats?.is_quiet_hours ? '🌙 Quiet Hours' : '☀️ Active'} CST</div>
</div>
<div className="glass rounded-xl p-4 flex items-center justify-center">
<button onClick={handleTest} disabled={testing} className="btn-amber px-4 py-2 rounded-lg text-xs font-mono transition-all disabled:opacity-50 w-full">{testing ? '⏳ Running...' : '▶ Test Now'}</button>
</div>
</div>
{/* Monitors */}
<div>
<div className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-3">Condition Monitors</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{monitors.map(m => {
const config = typeof m.threshold_config === 'string' ? JSON.parse(m.threshold_config || '{}') : (m.threshold_config || {});
const icon = MONITOR_ICONS[m.monitor_type] || '📡';
return (<div key={m.id} className={`glass rounded-xl p-4 transition-all ${!m.enabled ? 'opacity-50' : ''}`}>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0">
<h3 className="text-sm font-semibold flex items-center gap-1.5"><span>{icon}</span>{m.display_name}</h3>
<p className="text-[11px] text-gray-500 mt-0.5">{m.description}</p>
</div>
<button onClick={() => handleToggle(m)} disabled={toggling === m.id} className={`shrink-0 w-10 h-5 rounded-full transition-all relative ${m.enabled ? 'bg-green-500/30' : 'bg-gray-700'}`}>
<div className={`absolute top-0.5 w-4 h-4 rounded-full transition-all ${m.enabled ? 'left-5 bg-green-400' : 'left-0.5 bg-gray-500'}`} />
</button>
</div>
<div className="flex items-center gap-1.5 flex-wrap text-[10px] font-mono text-gray-500 mt-2">
<span className="px-2 py-0.5 rounded bg-white/5">⏱ {m.cooldown_minutes}m cooldown</span>
<span className="px-2 py-0.5 rounded bg-white/5">🔔 max {m.max_daily_alerts}/day</span>
<span className="px-2 py-0.5 rounded bg-white/5">🌙 {m.quiet_hours_start}-{m.quiet_hours_end}</span>
</div>
{Object.keys(config).length > 0 && (
<div className="mt-2 pt-2 border-t border-white/5">
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{Object.entries(config).slice(0, 3).map(([k, v]) => (
<span key={k} className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-amber-400/10 text-amber-400">{k}: {typeof v === 'object' ? JSON.stringify(v).substring(0, 30) : String(v)}</span>
))}
</div>
<button onClick={() => setEditingMonitor(m)} className="text-[10px] text-gray-500 hover:text-amber-400 px-1.5 py-0.5 rounded hover:bg-amber-400/10">⚙️</button>
</div>
</div>
)}
</div>);
})}
</div>
</div>
{/* Recent Sent Alerts */}
{recentAlerts.length > 0 && (<div>
<div className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-3">Recent Alerts (Sent)</div>
<div className="space-y-2">
{recentAlerts.slice(0, 5).map(a => {
const lvl = ALERT_LEVEL_CONFIG[a.alert_level] || ALERT_LEVEL_CONFIG.info;
return (<div key={a.id} className="glass rounded-lg p-3 animate-slide">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs px-2 py-0.5 rounded-full font-mono" style={{ background: lvl.bg, color: lvl.color }}>{lvl.icon} {lvl.label}</span>
<span className="text-[10px] font-mono text-gray-600">{MONITOR_ICONS[a.monitor_type] || '📡'} {a.monitor_type}</span>
</div>
<div className="text-xs font-semibold">{a.title}</div>
<div className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{a.message}</div>
</div>
<div className="text-[10px] font-mono text-gray-600 shrink-0">{timeAgo(a.created_at)}</div>
</div>
{a.triage_score != null && <div className="flex items-center gap-2 mt-1.5"><span className="text-[9px] font-mono text-gray-600">Triage: {(a.triage_score * 100).toFixed(0)}%</span>{a.triage_reasoning && <span className="text-[9px] font-mono text-gray-600 truncate">— {a.triage_reasoning}</span>}</div>}
</div>);
})}
</div>
</div>)}
{/* Log Toggle */}
<div>
<button onClick={() => setShowLog(s => !s)} className="text-xs font-mono text-gray-500 hover:text-gray-300 flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5 transition-all">
{showLog ? '▲' : '▼'} Full Alert Log ({logs.length} entries)
</button>
{showLog && (<div className="mt-3 space-y-3">
<div className="flex gap-2 flex-wrap">
<select value={logFilter} onChange={e => setLogFilter(e.target.value)} className="text-xs font-mono py-1.5 px-2 rounded-lg">
<option value="all">All Monitors</option>
{monitors.map(m => <option key={m.monitor_type} value={m.monitor_type}>{MONITOR_ICONS[m.monitor_type]} {m.display_name}</option>)}
</select>
<select value={sentFilter} onChange={e => setSentFilter(e.target.value)} className="text-xs font-mono py-1.5 px-2 rounded-lg">
<option value="all">All</option>
<option value="sent">✅ Sent</option>
<option value="suppressed">🚫 Suppressed</option>
</select>
</div>
<div className="max-h-96 overflow-y-auto space-y-1.5">
{filteredLogs.length === 0 ? <div className="text-xs text-gray-600 font-mono py-4 text-center">No log entries</div> : filteredLogs.map(l => {
const lvl = ALERT_LEVEL_CONFIG[l.alert_level] || ALERT_LEVEL_CONFIG.info;
return (<div key={l.id} className={`glass rounded-lg px-3 py-2 text-xs ${!l.was_sent ? 'opacity-60' : ''}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span style={{ color: lvl.color }}>{lvl.icon}</span>
<span className="font-mono truncate">{l.title}</span>
{l.was_sent ? <span className="text-green-400 shrink-0">✓ sent</span> : <span className="text-gray-500 shrink-0 text-[10px]">🚫 {l.suppression_reason}</span>}
</div>
<span className="text-[10px] font-mono text-gray-600 shrink-0">{timeAgo(l.created_at)}</span>
</div>
{l.triage_score != null && <div className="text-[9px] font-mono text-gray-600 mt-0.5">Triage: {(l.triage_score * 100).toFixed(0)}% — {l.triage_reasoning}</div>}
</div>);
})}
</div>
</div>)}
</div>
{/* Threshold Editor Modal */}
{editingMonitor && (<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-4" onClick={() => setEditingMonitor(null)}>
<div className="glass rounded-2xl p-6 w-full max-w-md animate-fade" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-bold font-mono mb-4">{editingMonitor.display_name} — Settings</h2>
<ThresholdEditor monitor={editingMonitor} onSave={handleSaveThreshold} onClose={() => setEditingMonitor(null)} />
</div>
</div>)}
</div>);
}
function ThresholdEditor({ monitor, onSave, onClose }) {
const config = typeof monitor.threshold_config === 'string' ? JSON.parse(monitor.threshold_config || '{}') : (monitor.threshold_config || {});
const [values, setValues] = useState(config);
const [saving, setSaving] = useState(false);
const updateField = (key, val) => setValues(prev => ({ ...prev, [key]: val }));
const handleSave = async () => { setSaving(true); await onSave(monitor, values); setSaving(false); };
return (<div className="space-y-4">
{Object.entries(values).map(([key, val]) => (<div key={key}>
<label className="block text-xs font-mono text-gray-400 mb-1 uppercase tracking-wider">{key.replace(/_/g, ' ')}</label>
{typeof val === 'boolean' ? (
<button onClick={() => updateField(key, !val)} className={`text-xs font-mono px-3 py-1.5 rounded-lg ${val ? 'bg-green-500/20 text-green-400' : 'bg-gray-700 text-gray-400'}`}>{val ? 'Enabled' : 'Disabled'}</button>
) : typeof val === 'number' ? (
<input type="number" value={val} onChange={e => updateField(key, parseFloat(e.target.value) || 0)} className="w-full text-sm" />
) : Array.isArray(val) ? (
<input value={val.join(', ')} onChange={e => updateField(key, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} className="w-full text-sm" placeholder="Comma-separated values" />
) : (
<input value={String(val)} onChange={e => updateField(key, e.target.value)} className="w-full text-sm" />
)}
</div>))}
<div className="flex gap-3 mt-4">
<button onClick={onClose} className="flex-1 py-2 rounded-lg text-sm font-mono text-gray-400 hover:text-white hover:bg-white/5">Cancel</button>
<button onClick={handleSave} disabled={saving} className="flex-1 btn-amber py-2 rounded-lg text-sm font-mono disabled:opacity-50">{saving ? 'Saving...' : 'Save'}</button>
</div>
</div>);
}
// ============================================
// Browser Tab (Phase 4)
// ============================================
const JOB_STATUS_CONFIG = { pending: { icon: '⏳', color: '#f6b93b', label: 'Pending' }, approved: { icon: '✅', color: '#22c55e', label: 'Approved' }, running: { icon: '⚙️', color: '#3b82f6', label: 'Running' }, completed: { icon: '✅', color: '#22c55e', label: 'Completed' }, failed: { icon: '❌', color: '#ef4444', label: 'Failed' }, rejected: { icon: '🚫', color: '#6b7280', label: 'Rejected' } };
const JOB_TYPE_CONFIG = { scrape: { icon: '🔍', label: 'Scrape' }, extract: { icon: '🤖', label: 'AI Extract' }, fill_form: { icon: '📝', label: 'Form Fill' }, screenshot: { icon: '📸', label: 'Screenshot' }, monitor: { icon: '👁️', label: 'Monitor' } };
function BrowserTab({ showToast }) {
const [subTab, setSubTab] = useState('jobs');
const [jobs, setJobs] = useState([]);
const [monitors, setMonitors] = useState([]);
const [allowlist, setAllowlist] = useState([]);
const [browserStats, setBrowserStats] = useState({});
const [loading, setLoading] = useState(true);
const [showAddMonitor, setShowAddMonitor] = useState(false);
const [showAddAllowlist, setShowAddAllowlist] = useState(false);
const [newMonitor, setNewMonitor] = useState({ name: '', url: '', prompt: '', monitor_type: 'custom', check_interval_minutes: 60 });
const [newAllowEntry, setNewAllowEntry] = useState({ pattern: '', job_type: 'scrape', description: '' });
const fetchBrowserData = useCallback(async () => { try { const [j, m, a, s] = await Promise.all([ api('/browser/jobs?limit=30'), api('/browser/monitors'), api('/browser/allowlist'), api('/browser/stats') ]); setJobs(j || []); setMonitors(m || []); setAllowlist(a || []); setBrowserStats(s || {}); } catch (e) { console.error('Browser fetch error:', e); } setLoading(false); }, []);
useEffect(() => { fetchBrowserData(); }, [fetchBrowserData]);
const toggleMonitor = async (monitor) => { try { await api(`/browser/monitors/${monitor.id}`, { method: 'PUT', body: { enabled: monitor.enabled ? 0 : 1 } }); showToast(`Monitor ${monitor.enabled ? 'disabled' : 'enabled'}`); fetchBrowserData(); } catch (e) { showToast(`Failed: ${e.message}`, 'error'); } };
const deleteMonitor = async (monitor) => { if (!confirm(`Delete monitor "${monitor.name}"?`)) return; try { await api(`/browser/monitors/${monitor.id}`, { method: 'DELETE' }); showToast('Monitor deleted'); fetchBrowserData(); } catch (e) { showToast(`Failed: ${e.message}`, 'error'); } };
const addMonitor = async () => { if (!newMonitor.name || !newMonitor.url || !newMonitor.prompt) { showToast('Name, URL, and prompt are required', 'error'); return; } try { await api('/browser/monitors', { method: 'POST', body: newMonitor }); showToast('Monitor created'); setShowAddMonitor(false); setNewMonitor({ name: '', url: '', prompt: '', monitor_type: 'custom', check_interval_minutes: 60 }); fetchBrowserData(); } catch (e) { showToast(`Failed: ${e.message}`, 'error'); } };
const deleteAllowEntry = async (entry) => { if (!confirm(`Delete allowlist pattern "${entry.pattern}"?`)) return; try { await api(`/browser/allowlist/${entry.id}`, { method: 'DELETE' }); showToast('Allowlist entry deleted'); fetchBrowserData(); } catch (e) { showToast(`Failed: ${e.message}`, 'error'); } };
const addAllowEntry = async () => { if (!newAllowEntry.pattern) { showToast('Pattern is required', 'error'); return; } try { await api('/browser/allowlist', { method: 'POST', body: newAllowEntry }); showToast('Allowlist entry added'); setShowAddAllowlist(false); setNewAllowEntry({ pattern: '', job_type: 'scrape', description: '' }); fetchBrowserData(); } catch (e) { showToast(`Failed: ${e.message}`, 'error'); } };
if (loading) return <div className="text-center py-12 text-gray-500 font-mono text-sm animate-pulse-slow">Loading browser data...</div>;
return (<div className="space-y-4 animate-fade">
{/* Stats Row */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
<div className="glass rounded-xl p-4 text-center"><div className="text-2xl font-bold font-mono text-amber-400">{browserStats.total_jobs || 0}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-1">Total Jobs</div></div>
<div className="glass rounded-xl p-4 text-center"><div className="text-2xl font-bold font-mono text-green-400">{browserStats.completed_jobs || 0}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-1">Completed</div></div>
<div className="glass rounded-xl p-4 text-center"><div className="text-2xl font-bold font-mono text-red-400">{browserStats.failed_jobs || 0}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-1">Failed</div></div>
<div className="glass rounded-xl p-4 text-center"><div className="text-2xl font-bold font-mono text-blue-400">{(browserStats.total_browser_hours || 0).toFixed(4)}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-1">Browser Hrs</div></div>
<div className="glass rounded-xl p-4 text-center"><div className="text-2xl font-bold font-mono text-purple-400">{browserStats.active_monitors || 0}</div><div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mt-1">Monitors</div></div>
</div>
{/* Sub-tabs */}
<div className="flex gap-1 border-b border-white/5 pb-0">
{[{ id: 'jobs', label: 'Jobs', icon: '📋' }, { id: 'monitors', label: 'Monitors', icon: '👁️' }, { id: 'allowlist', label: 'Allowlist', icon: '🔓' }].map(st => (
<button key={st.id} onClick={() => setSubTab(st.id)} className={'px-4 py-2 text-xs font-mono transition-all ' + (subTab === st.id ? 'tab-active' : 'tab-inactive')}><span className="mr-1">{st.icon}</span>{st.label}</button>
))}
</div>
{/* Jobs Sub-tab */}
{subTab === 'jobs' && (<div className="space-y-2">
{jobs.length === 0 ? <div className="glass rounded-lg p-6 text-center text-gray-500 font-mono text-sm">No browser jobs yet. Use SparkyBot to browse pages, scrape data, or extract info.</div> : jobs.map(job => {
const st = JOB_STATUS_CONFIG[job.status] || JOB_STATUS_CONFIG.pending;
const jt = JOB_TYPE_CONFIG[job.job_type] || { icon: '🌐', label: job.job_type };
return (<div key={job.id} className="glass rounded-lg p-3 animate-slide">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span title={jt.label}>{jt.icon}</span>
<span className="text-xs font-mono text-gray-300 truncate" title={job.url}>{job.url?.length > 60 ? job.url.substring(0, 60) + '...' : job.url}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full" style={{ color: st.color, background: st.color + '20' }}>{st.icon} {st.label}</span>
{job.execution_ms && <span className="text-[10px] font-mono text-gray-600">{job.execution_ms}ms</span>}
</div>
</div>
{job.prompt && <div className="text-[10px] font-mono text-gray-600 mt-1 truncate">Prompt: {job.prompt}</div>}
{job.error_message && <div className="text-[10px] font-mono text-red-400 mt-1 truncate">Error: {job.error_message}</div>}
<div className="text-[10px] font-mono text-gray-700 mt-1">{new Date(job.created_at + 'Z').toLocaleString()}</div>
</div>);
})}
</div>)}
{/* Monitors Sub-tab */}
{subTab === 'monitors' && (<div className="space-y-3">
<div className="flex justify-end"><button onClick={() => setShowAddMonitor(!showAddMonitor)} className="btn-amber px-3 py-1.5 rounded-lg text-xs font-mono">+ Add Monitor</button></div>
{showAddMonitor && (<div className="glass rounded-lg p-4 space-y-3 animate-slide">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input value={newMonitor.name} onChange={e => setNewMonitor(p => ({...p, name: e.target.value}))} placeholder="Monitor name" className="text-xs font-mono px-3 py-2 rounded-lg bg-surface-50 border border-white/10 focus:border-amber-400/50 outline-none" />
<input value={newMonitor.url} onChange={e => setNewMonitor(p => ({...p, url: e.target.value}))} placeholder="URL to monitor" className="text-xs font-mono px-3 py-2 rounded-lg bg-surface-50 border border-white/10 focus:border-amber-400/50 outline-none" />
</div>
<textarea value={newMonitor.prompt} onChange={e => setNewMonitor(p => ({...p, prompt: e.target.value}))} placeholder="What to extract (e.g., 'Get the current stock price and volume')" rows={2} className="w-full text-xs font-mono px-3 py-2 rounded-lg bg-surface-50 border border-white/10 focus:border-amber-400/50 outline-none" />
<div className="flex gap-3 items-center">
<select value={newMonitor.monitor_type} onChange={e => setNewMonitor(p => ({...p, monitor_type: e.target.value}))} className="text-xs font-mono px-2 py-1.5 rounded-lg">
<option value="custom">Custom</option><option value="price">Price</option><option value="content_change">Content Change</option><option value="availability">Availability</option>
</select>
<input type="number" value={newMonitor.check_interval_minutes} onChange={e => setNewMonitor(p => ({...p, check_interval_minutes: parseInt(e.target.value) || 60}))} min={30} className="text-xs font-mono px-2 py-1.5 rounded-lg bg-surface-50 border border-white/10 w-20" />
<span className="text-[10px] font-mono text-gray-500">min</span>
<div className="flex-1" />
<button onClick={addMonitor} className="btn-amber px-4 py-1.5 rounded-lg text-xs font-mono">Create</button>
<button onClick={() => setShowAddMonitor(false)} className="text-xs font-mono text-gray-500 hover:text-gray-300">Cancel</button>
</div>
</div>)}
{monitors.length === 0 ? <div className="glass rounded-lg p-6 text-center text-gray-500 font-mono text-sm">No page monitors. Click + Add Monitor to start tracking pages.</div> : monitors.map(m => (
<div key={m.id} className={`glass rounded-lg p-3 animate-slide ${m.enabled ? '' : 'opacity-50'}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span>👁️</span>
<span className="text-sm font-mono font-semibold text-gray-200">{m.name}</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400">{m.monitor_type}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => toggleMonitor(m)} className={`text-[10px] font-mono px-2 py-0.5 rounded-full ${m.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-500'}`}>{m.enabled ? '● Active' : '○ Paused'}</button>
<button onClick={() => deleteMonitor(m)} className="text-gray-600 hover:text-red-400 transition-colors" title="Delete">🗑️</button>
</div>
</div>
<div className="text-[10px] font-mono text-gray-500 mt-1 truncate">{m.url}</div>
<div className="text-[10px] font-mono text-gray-600 mt-0.5">Every {m.check_interval_minutes}m{m.last_checked_at ? ` · Last: ${new Date(m.last_checked_at + 'Z').toLocaleString()}` : ' · Never checked'}{m.last_change_detected_at ? ` · ⚡ Change: ${new Date(m.last_change_detected_at + 'Z').toLocaleString()}` : ''}</div>
{m.prompt && <div className="text-[10px] font-mono text-gray-700 mt-0.5 truncate">Prompt: {m.prompt}</div>}
</div>
))}
</div>)}
{/* Allowlist Sub-tab */}
{subTab === 'allowlist' && (<div className="space-y-3">
<div className="flex justify-end"><button onClick={() => setShowAddAllowlist(!showAddAllowlist)} className="btn-amber px-3 py-1.5 rounded-lg text-xs font-mono">+ Add Pattern</button></div>
{showAddAllowlist && (<div className="glass rounded-lg p-4 space-y-3 animate-slide">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input value={newAllowEntry.pattern} onChange={e => setNewAllowEntry(p => ({...p, pattern: e.target.value}))} placeholder="URL pattern (e.g., *.example.com/*)" className="text-xs font-mono px-3 py-2 rounded-lg bg-surface-50 border border-white/10 focus:border-amber-400/50 outline-none" />
<select value={newAllowEntry.job_type} onChange={e => setNewAllowEntry(p => ({...p, job_type: e.target.value}))} className="text-xs font-mono px-2 py-1.5 rounded-lg">
<option value="scrape">Scrape</option><option value="extract">Extract</option><option value="screenshot">Screenshot</option><option value="fill_form">Form Fill</option>
</select>
<input value={newAllowEntry.description} onChange={e => setNewAllowEntry(p => ({...p, description: e.target.value}))} placeholder="Description" className="text-xs font-mono px-3 py-2 rounded-lg bg-surface-50 border border-white/10 focus:border-amber-400/50 outline-none" />
</div>
<div className="flex gap-3 items-center justify-end">
<button onClick={addAllowEntry} className="btn-amber px-4 py-1.5 rounded-lg text-xs font-mono">Add</button>
<button onClick={() => setShowAddAllowlist(false)} className="text-xs font-mono text-gray-500 hover:text-gray-300">Cancel</button>
</div>
</div>)}
{allowlist.length === 0 ? <div className="glass rounded-lg p-6 text-center text-gray-500 font-mono text-sm">No allowlist entries.</div> : (<div className="glass rounded-lg overflow-hidden"><table className="w-full"><thead><tr className="text-[10px] font-mono text-gray-500 uppercase tracking-wider border-b border-white/5"><th className="text-left px-3 py-2">Pattern</th><th className="text-left px-3 py-2">Type</th><th className="text-left px-3 py-2">Description</th><th className="px-3 py-2 w-8"></th></tr></thead><tbody>{allowlist.map(e => (<tr key={e.id} className="border-b border-white/5 hover:bg-white/[0.02]"><td className="text-xs font-mono text-amber-400 px-3 py-2">{e.pattern}</td><td className="text-xs font-mono text-gray-400 px-3 py-2">{e.job_type}</td><td className="text-xs font-mono text-gray-500 px-3 py-2">{e.description || '—'}</td><td className="px-3 py-2"><button onClick={() => deleteAllowEntry(e)} className="text-gray-600 hover:text-red-400 text-xs">✕</button></td></tr>))}</tbody></table></div>)}
</div>)}
</div>);
}
// ============================================
// Anomaly Panel
// ============================================
function AnomalyPanel({ anomalies }) { if (anomalies.length === 0) return <div className="glass rounded-xl p-4 animate-fade"><div className="flex items-center gap-2 mb-2"><span className="text-lg">✅</span><h3 className="text-sm font-bold font-mono">System Health</h3></div><p className="text-xs text-green-400 font-mono">All systems healthy — no open anomalies</p></div>; return (<div className="glass rounded-xl p-4 animate-fade border-l-2 border-amber-400"><div className="flex items-center gap-2 mb-3"><span className="text-lg animate-pulse-slow">⚠️</span><h3 className="text-sm font-bold font-mono text-amber-400">{anomalies.length} Open Anomal{anomalies.length === 1 ? 'y' : 'ies'}</h3></div><div className="space-y-1.5">{anomalies.slice(0, 5).map(a => <div key={a.id} className="text-xs text-gray-400 truncate">{PRIORITY_CONFIG[a.priority]?.icon} {a.title.substring(0, 60)}</div>)}</div></div>); }
// ============================================
// Main Dashboard
// ============================================
function Dashboard({ onSignOut }) { const [cards, setCards] = useState([]); const [projects, setProjects] = useState([]); const [stats, setStats] = useState({}); const [loading, setLoading] = useState(true); const [filterProject, setFilterProject] = useState('all'); const [showDone, setShowDone] = useState(false); const [modalCard, setModalCard] = useState(undefined); const [toast, setToast] = useState(null); const [tab, setTab] = useState('board'); const showToast = (message, type = 'success') => setToast({ message, type }); const fetchData = useCallback(async () => { try { const [cardsData, projectsData, statsData] = await Promise.all([ api('/kanban/cards'), api('/projects'), api('/stats') ]); setCards(cardsData.results || []); setProjects(projectsData.results || []); setStats(statsData || {}); } catch (e) { console.error('Fetch error:', e); } setLoading(false); }, []); useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { const i = setInterval(fetchData, 30000); return () => clearInterval(i); }, [fetchData]); const handleDrop = async (cardId, newStatus) => { setCards(prev => prev.map(c => c.id === cardId ? { ...c, status: newStatus } : c)); try { await api(`/kanban/cards/${cardId}`, { method: 'PUT', body: { status: newStatus } }); showToast(`Moved to ${STATUS_CONFIG[newStatus].label}`); } catch (e) { showToast(`Move failed: ${e.message}`, 'error'); fetchData(); } }; const handleSave = async (cardData) => { try { if (cardData.id) { await api(`/kanban/cards/${cardData.id}`, { method: 'PUT', body: cardData }); showToast('Task updated'); } else { await api('/kanban/cards', { method: 'POST', body: cardData }); showToast('Task created'); } fetchData(); } catch (e) { showToast(`Save failed: ${e.message}`, 'error'); } }; const handleDelete = async (card) => { if (!confirm(`Delete "${card.title}"?`)) return; try { await api(`/kanban/cards/${card.id}`, { method: 'DELETE' }); setCards(prev => prev.filter(c => c.id !== card.id)); showToast('Task deleted'); } catch (e) { showToast(`Delete failed: ${e.message}`, 'error'); } }; let filteredCards = cards; if (filterProject !== 'all') filteredCards = cards.filter(c => c.project_id === filterProject); if (!showDone) filteredCards = filteredCards.filter(c => c.status !== 'done'); const anomalies = cards.filter(c => c.is_anomaly && c.status !== 'done'); const cardsByStatus = {}; STATUSES.forEach(s => { cardsByStatus[s] = filteredCards.filter(c => c.status === s); }); if (loading) return <div className="min-h-screen flex items-center justify-center"><div className="text-center animate-fade"><div className="text-4xl mb-3 animate-pulse-slow">⚡</div><div className="text-sm font-mono text-gray-400">Loading command center...</div></div></div>; return (<div className="min-h-screen"><header className="sticky top-0 z-40 glass border-b border-white/5"><div className="max-w-[1800px] mx-auto px-4 sm:px-6 py-3"><div className="flex items-center justify-between flex-wrap gap-3"><div className="flex items-center gap-3"><span className="text-2xl">⚡</span><div><h1 className="text-base font-bold font-mono tracking-tight">SparkyBot</h1><span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Command Center</span></div></div><div className="flex items-center gap-3 flex-wrap">{tab === 'board' && <><select value={filterProject} onChange={e => setFilterProject(e.target.value)} className="text-xs font-mono py-1.5 px-2 rounded-lg"><option value="all">All Projects</option>{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}</select><label className="flex items-center gap-1.5 text-xs font-mono text-gray-400 cursor-pointer select-none"><input type="checkbox" checked={showDone} onChange={e => setShowDone(e.target.checked)} className="rounded border-gray-600 bg-transparent text-amber-400 focus:ring-amber-400/30" />Done</label><button onClick={() => setModalCard(null)} className="btn-amber px-4 py-1.5 rounded-lg text-xs font-mono transition-all">+ New Task</button></>}<button onClick={fetchData} className="text-gray-500 hover:text-amber-400 transition-colors p-1.5" title="Refresh"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg></button><button onClick={onSignOut} className="text-gray-600 hover:text-red-400 transition-colors p-1.5" title="Sign Out"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></button></div></div></div><div className="max-w-[1800px] mx-auto px-4 sm:px-6"><div className="flex gap-0 overflow-x-auto border-t border-white/5">{[{ id: 'board', label: 'Board', icon: '📋' }, { id: 'portfolio', label: 'Portfolio', icon: '📊' }, { id: 'conversations', label: 'Chat Log', icon: '💬' }, { id: 'vip', label: 'VIP Contacts', icon: '👑' }, { id: 'approvals', label: 'Approvals', icon: '🔐', badge: stats.pending_approvals || null }, { id: 'skills', label: 'Skills', icon: '📦' }, { id: 'memory', label: 'Memory', icon: '🧠', badge: stats.total_memories || null }, { id: 'heartbeat', label: 'Heartbeat', icon: '💓' }, { id: 'browser', label: 'Browser', icon: '🌐' }, { id: 'anomalies', label: 'Anomalies', icon: '⚠️', badge: (stats.anomalies || null) }].map(t => (<button key={t.id} onClick={() => setTab(t.id)} className={'px-4 py-2.5 text-xs font-mono transition-all whitespace-nowrap relative ' + (tab === t.id ? 'tab-active' : 'tab-inactive')}><span className="mr-1.5">{t.icon}</span>{t.label}{t.badge > 0 && <span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center">{t.badge}</span>}</button>))}</div></div></header><div className="max-w-[1800px] mx-auto px-4 sm:px-6 pt-5 pb-3"><StatsBar stats={stats} /></div><main className="max-w-[1800px] mx-auto px-4 sm:px-6 pb-8">{tab === 'board' && <div className="flex gap-4 overflow-x-auto pt-3 pb-4">{STATUSES.filter(s => showDone || s !== 'done').map(status => <KanbanColumn key={status} status={status} cards={cardsByStatus[status] || []} onDrop={handleDrop} onEdit={setModalCard} onDelete={handleDelete} />)}</div>}{tab === 'portfolio' && <div className="pt-4"><PortfolioTab /></div>}{tab === 'conversations' && <div className="pt-4"><ConversationsTab /></div>}{tab === 'vip' && <div className="pt-4"><VIPContactsTab showToast={showToast} /></div>}{tab === 'approvals' && <div className="pt-4"><ApprovalsTab showToast={showToast} /></div>}{tab === 'skills' && <div className="pt-4"><SkillsTab showToast={showToast} /></div>}{tab === 'memory' && <div className="pt-4"><MemoryTab showToast={showToast} /></div>}{tab === 'heartbeat' && <div className="pt-4"><HeartbeatTab showToast={showToast} /></div>}{tab === 'browser' && <div className="pt-4"><BrowserTab showToast={showToast} /></div>}{tab === 'anomalies' && <div className="max-w-2xl pt-4 space-y-4"><AnomalyPanel anomalies={anomalies} />{anomalies.map(a => <div key={a.id} className={'glass rounded-lg p-4 animate-slide priority-' + a.priority}><h3 className="text-sm font-semibold">{a.title}</h3>{a.anomaly_details && <div className="text-xs text-gray-500 font-mono mt-1.5">Error: {(typeof a.anomaly_details === 'string' ? JSON.parse(a.anomaly_details) : a.anomaly_details)?.errorMessage?.substring(0, 100)}</div>}</div>)}</div>}</main>{modalCard !== undefined && <CardModal card={modalCard} projects={projects} onSave={handleSave} onClose={() => setModalCard(undefined)} />}{toast && <Toast {...toast} onDismiss={() => setToast(null)} />}</div>); }
// ============================================
// App Root
// ============================================
function App() { const [authenticated, setAuthenticated] = useState(!!_idToken); const [checking, setChecking] = useState(!!_idToken); useEffect(() => { if (!_idToken) { setChecking(false); return; } api('/stats').then(() => { setAuthenticated(true); setChecking(false); }).catch(() => { setToken(''); setAuthenticated(false); setChecking(false); }); }, []); const handleLogin = (token) => { setToken(token); setAuthenticated(true); }; const handleSignOut = () => { setToken(''); setAuthenticated(false); if (window.google?.accounts?.id) google.accounts.id.disableAutoSelect(); }; if (checking) return <div className="min-h-screen flex items-center justify-center"><div className="text-center animate-fade"><div className="text-4xl mb-3">⚡</div><p className="text-gray-400 font-mono text-sm">Loading...</p></div></div>; if (!authenticated) return <LoginScreen onLogin={handleLogin} />; return <Dashboard onSignOut={handleSignOut} />; }
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>