hzl-web 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1211 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>hzl dashboard</title>
7
+ <style>
8
+ :root {
9
+ --bg-primary: #1a1a1a;
10
+ --bg-secondary: #252525;
11
+ --bg-card: #2d2d2d;
12
+ --text-primary: #e5e5e5;
13
+ --text-secondary: #a3a3a3;
14
+ --text-muted: #737373;
15
+ --accent: #f59e0b;
16
+ --accent-dim: #b45309;
17
+ --border: #404040;
18
+ --status-backlog: #6b7280;
19
+ --status-blocked: #ef4444;
20
+ --status-ready: #22c55e;
21
+ --status-in-progress: #3b82f6;
22
+ --status-done: #6b7280;
23
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
24
+ }
25
+
26
+ * {
27
+ box-sizing: border-box;
28
+ margin: 0;
29
+ padding: 0;
30
+ }
31
+
32
+ body {
33
+ font-family: var(--font-mono);
34
+ font-size: 13px;
35
+ background: var(--bg-primary);
36
+ color: var(--text-primary);
37
+ line-height: 1.5;
38
+ min-height: 100vh;
39
+ }
40
+
41
+ /* Header */
42
+ .header {
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: space-between;
46
+ padding: 12px 16px;
47
+ background: var(--bg-secondary);
48
+ border-bottom: 1px solid var(--border);
49
+ position: sticky;
50
+ top: 0;
51
+ z-index: 100;
52
+ }
53
+
54
+ .header-left {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 8px;
58
+ }
59
+
60
+ .logo {
61
+ font-weight: 600;
62
+ font-size: 14px;
63
+ color: var(--accent);
64
+ }
65
+
66
+ .header-filters {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 12px;
70
+ }
71
+
72
+ .filter-group {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 6px;
76
+ }
77
+
78
+ .filter-label {
79
+ color: var(--text-muted);
80
+ font-size: 11px;
81
+ }
82
+
83
+ select {
84
+ font-family: var(--font-mono);
85
+ font-size: 12px;
86
+ background: var(--bg-primary);
87
+ color: var(--text-primary);
88
+ border: 1px solid var(--border);
89
+ padding: 4px 8px;
90
+ border-radius: 4px;
91
+ cursor: pointer;
92
+ }
93
+
94
+ select:focus {
95
+ outline: none;
96
+ border-color: var(--accent);
97
+ }
98
+
99
+ .header-right {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: 12px;
103
+ }
104
+
105
+ .connection-indicator {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 6px;
109
+ font-size: 11px;
110
+ color: var(--text-muted);
111
+ }
112
+
113
+ .connection-dot {
114
+ width: 8px;
115
+ height: 8px;
116
+ border-radius: 50%;
117
+ background: var(--accent);
118
+ }
119
+
120
+ .connection-dot.error {
121
+ background: var(--status-blocked);
122
+ }
123
+
124
+ .activity-btn {
125
+ font-family: var(--font-mono);
126
+ font-size: 12px;
127
+ background: var(--bg-primary);
128
+ color: var(--text-primary);
129
+ border: 1px solid var(--border);
130
+ padding: 6px 12px;
131
+ border-radius: 4px;
132
+ cursor: pointer;
133
+ }
134
+
135
+ .activity-btn:hover {
136
+ border-color: var(--accent);
137
+ }
138
+
139
+ /* Kanban Board */
140
+ .board {
141
+ display: flex;
142
+ gap: 12px;
143
+ padding: 16px;
144
+ overflow-x: auto;
145
+ min-height: calc(100vh - 53px);
146
+ }
147
+
148
+ .column {
149
+ flex: 0 0 280px;
150
+ background: var(--bg-secondary);
151
+ border-radius: 8px;
152
+ display: flex;
153
+ flex-direction: column;
154
+ max-height: calc(100vh - 85px);
155
+ }
156
+
157
+ .column-header {
158
+ padding: 12px;
159
+ border-bottom: 1px solid var(--border);
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: space-between;
163
+ }
164
+
165
+ .column-title {
166
+ font-weight: 600;
167
+ font-size: 12px;
168
+ text-transform: uppercase;
169
+ letter-spacing: 0.5px;
170
+ }
171
+
172
+ .column-count {
173
+ font-size: 11px;
174
+ color: var(--text-muted);
175
+ background: var(--bg-primary);
176
+ padding: 2px 8px;
177
+ border-radius: 10px;
178
+ }
179
+
180
+ .column-cards {
181
+ flex: 1;
182
+ overflow-y: auto;
183
+ padding: 8px;
184
+ display: flex;
185
+ flex-direction: column;
186
+ gap: 8px;
187
+ }
188
+
189
+ /* Task Cards */
190
+ .card {
191
+ background: var(--bg-card);
192
+ border: 1px solid var(--border);
193
+ border-radius: 6px;
194
+ padding: 10px 12px;
195
+ cursor: pointer;
196
+ transition: border-color 0.15s;
197
+ }
198
+
199
+ .card:hover {
200
+ border-color: var(--accent);
201
+ }
202
+
203
+ .card-id {
204
+ font-size: 10px;
205
+ color: var(--text-muted);
206
+ margin-bottom: 4px;
207
+ }
208
+
209
+ .card-title {
210
+ font-size: 13px;
211
+ color: var(--text-primary);
212
+ display: -webkit-box;
213
+ -webkit-line-clamp: 2;
214
+ -webkit-box-orient: vertical;
215
+ overflow: hidden;
216
+ margin-bottom: 8px;
217
+ }
218
+
219
+ .card-meta {
220
+ display: flex;
221
+ align-items: center;
222
+ justify-content: space-between;
223
+ font-size: 11px;
224
+ color: var(--text-muted);
225
+ }
226
+
227
+ .card-project {
228
+ background: var(--bg-primary);
229
+ padding: 2px 6px;
230
+ border-radius: 3px;
231
+ }
232
+
233
+ .card-agent {
234
+ color: var(--accent);
235
+ }
236
+
237
+ .card-blocked {
238
+ font-size: 10px;
239
+ color: var(--status-blocked);
240
+ margin-top: 6px;
241
+ }
242
+
243
+ .card-lease {
244
+ font-size: 10px;
245
+ color: var(--text-muted);
246
+ margin-top: 4px;
247
+ }
248
+
249
+ /* Empty State */
250
+ .empty-column {
251
+ text-align: center;
252
+ color: var(--text-muted);
253
+ padding: 24px 12px;
254
+ font-size: 12px;
255
+ }
256
+
257
+ /* Modal */
258
+ .modal-overlay {
259
+ position: fixed;
260
+ top: 0;
261
+ left: 0;
262
+ right: 0;
263
+ bottom: 0;
264
+ background: rgba(0, 0, 0, 0.7);
265
+ display: none;
266
+ align-items: center;
267
+ justify-content: center;
268
+ z-index: 200;
269
+ padding: 24px;
270
+ }
271
+
272
+ .modal-overlay.open {
273
+ display: flex;
274
+ }
275
+
276
+ .modal {
277
+ background: var(--bg-secondary);
278
+ border: 1px solid var(--border);
279
+ border-radius: 8px;
280
+ max-width: 600px;
281
+ width: 100%;
282
+ max-height: 80vh;
283
+ overflow: hidden;
284
+ display: flex;
285
+ flex-direction: column;
286
+ }
287
+
288
+ .modal-header {
289
+ padding: 16px;
290
+ border-bottom: 1px solid var(--border);
291
+ display: flex;
292
+ align-items: flex-start;
293
+ justify-content: space-between;
294
+ }
295
+
296
+ .modal-title {
297
+ font-size: 16px;
298
+ font-weight: 600;
299
+ }
300
+
301
+ .modal-close {
302
+ background: none;
303
+ border: none;
304
+ color: var(--text-muted);
305
+ font-size: 20px;
306
+ cursor: pointer;
307
+ padding: 0;
308
+ line-height: 1;
309
+ }
310
+
311
+ .modal-close:hover {
312
+ color: var(--text-primary);
313
+ }
314
+
315
+ .modal-body {
316
+ padding: 16px;
317
+ overflow-y: auto;
318
+ flex: 1;
319
+ }
320
+
321
+ .modal-section {
322
+ margin-bottom: 16px;
323
+ }
324
+
325
+ .modal-section:last-child {
326
+ margin-bottom: 0;
327
+ }
328
+
329
+ .modal-section-title {
330
+ font-size: 11px;
331
+ text-transform: uppercase;
332
+ color: var(--text-muted);
333
+ margin-bottom: 8px;
334
+ letter-spacing: 0.5px;
335
+ }
336
+
337
+ .modal-meta {
338
+ display: grid;
339
+ grid-template-columns: repeat(2, 1fr);
340
+ gap: 8px;
341
+ }
342
+
343
+ .modal-meta-item {
344
+ background: var(--bg-primary);
345
+ padding: 8px 10px;
346
+ border-radius: 4px;
347
+ }
348
+
349
+ .modal-meta-label {
350
+ font-size: 10px;
351
+ color: var(--text-muted);
352
+ margin-bottom: 2px;
353
+ }
354
+
355
+ .modal-meta-value {
356
+ font-size: 12px;
357
+ }
358
+
359
+ .modal-description {
360
+ background: var(--bg-primary);
361
+ padding: 12px;
362
+ border-radius: 4px;
363
+ white-space: pre-wrap;
364
+ font-size: 12px;
365
+ line-height: 1.6;
366
+ }
367
+
368
+ .modal-comments {
369
+ display: flex;
370
+ flex-direction: column;
371
+ gap: 8px;
372
+ }
373
+
374
+ .comment {
375
+ background: var(--bg-primary);
376
+ padding: 10px;
377
+ border-radius: 4px;
378
+ border-left: 2px solid var(--accent);
379
+ }
380
+
381
+ .comment-header {
382
+ display: flex;
383
+ align-items: center;
384
+ justify-content: space-between;
385
+ margin-bottom: 4px;
386
+ font-size: 11px;
387
+ color: var(--text-muted);
388
+ }
389
+
390
+ .comment-author {
391
+ color: var(--accent);
392
+ }
393
+
394
+ .comment-text {
395
+ font-size: 12px;
396
+ white-space: pre-wrap;
397
+ }
398
+
399
+ /* Activity Panel */
400
+ .activity-panel {
401
+ position: fixed;
402
+ top: 0;
403
+ right: -400px;
404
+ width: 400px;
405
+ height: 100vh;
406
+ background: var(--bg-secondary);
407
+ border-left: 1px solid var(--border);
408
+ z-index: 150;
409
+ transition: right 0.2s ease;
410
+ display: flex;
411
+ flex-direction: column;
412
+ }
413
+
414
+ .activity-panel.open {
415
+ right: 0;
416
+ }
417
+
418
+ .activity-header {
419
+ padding: 12px 16px;
420
+ border-bottom: 1px solid var(--border);
421
+ display: flex;
422
+ align-items: center;
423
+ justify-content: space-between;
424
+ }
425
+
426
+ .activity-title {
427
+ font-weight: 600;
428
+ font-size: 14px;
429
+ }
430
+
431
+ .activity-close {
432
+ background: none;
433
+ border: none;
434
+ color: var(--text-muted);
435
+ font-size: 18px;
436
+ cursor: pointer;
437
+ }
438
+
439
+ .activity-list {
440
+ flex: 1;
441
+ overflow-y: auto;
442
+ padding: 8px;
443
+ }
444
+
445
+ .activity-item {
446
+ padding: 10px;
447
+ border-bottom: 1px solid var(--border);
448
+ }
449
+
450
+ .activity-item:last-child {
451
+ border-bottom: none;
452
+ }
453
+
454
+ .activity-item-header {
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: space-between;
458
+ margin-bottom: 4px;
459
+ }
460
+
461
+ .activity-type {
462
+ font-size: 11px;
463
+ text-transform: uppercase;
464
+ letter-spacing: 0.5px;
465
+ padding: 2px 6px;
466
+ border-radius: 3px;
467
+ background: var(--bg-primary);
468
+ }
469
+
470
+ .activity-type.status_changed { color: var(--status-in-progress); }
471
+ .activity-type.task_created { color: var(--status-ready); }
472
+ .activity-type.comment_added { color: var(--accent); }
473
+ .activity-type.checkpoint_recorded { color: var(--text-secondary); }
474
+
475
+ .activity-time {
476
+ font-size: 10px;
477
+ color: var(--text-muted);
478
+ }
479
+
480
+ .activity-task {
481
+ font-size: 12px;
482
+ color: var(--text-primary);
483
+ margin-top: 4px;
484
+ }
485
+
486
+ .activity-detail {
487
+ font-size: 11px;
488
+ color: var(--text-muted);
489
+ margin-top: 2px;
490
+ }
491
+
492
+ /* Mobile Styles */
493
+ @media (max-width: 768px) {
494
+ .header {
495
+ flex-wrap: wrap;
496
+ gap: 8px;
497
+ }
498
+
499
+ .header-filters {
500
+ order: 3;
501
+ width: 100%;
502
+ flex-wrap: wrap;
503
+ }
504
+
505
+ .board {
506
+ display: none;
507
+ }
508
+
509
+ .mobile-tabs {
510
+ display: flex;
511
+ overflow-x: auto;
512
+ background: var(--bg-secondary);
513
+ border-bottom: 1px solid var(--border);
514
+ padding: 0 8px;
515
+ }
516
+
517
+ .mobile-tab {
518
+ flex: 0 0 auto;
519
+ padding: 12px 16px;
520
+ font-size: 12px;
521
+ color: var(--text-muted);
522
+ border-bottom: 2px solid transparent;
523
+ cursor: pointer;
524
+ white-space: nowrap;
525
+ }
526
+
527
+ .mobile-tab.active {
528
+ color: var(--accent);
529
+ border-bottom-color: var(--accent);
530
+ }
531
+
532
+ .mobile-tab-badge {
533
+ background: var(--bg-primary);
534
+ padding: 1px 6px;
535
+ border-radius: 8px;
536
+ font-size: 10px;
537
+ margin-left: 6px;
538
+ }
539
+
540
+ .mobile-cards {
541
+ display: none;
542
+ padding: 12px;
543
+ flex-direction: column;
544
+ gap: 8px;
545
+ }
546
+
547
+ .mobile-cards.active {
548
+ display: flex;
549
+ }
550
+
551
+ .activity-panel {
552
+ width: 100%;
553
+ right: -100%;
554
+ }
555
+ }
556
+
557
+ @media (min-width: 769px) {
558
+ .mobile-tabs,
559
+ .mobile-cards {
560
+ display: none !important;
561
+ }
562
+ }
563
+
564
+ /* Hamburger menu for mobile */
565
+ .hamburger {
566
+ display: none;
567
+ background: none;
568
+ border: none;
569
+ color: var(--text-primary);
570
+ font-size: 20px;
571
+ cursor: pointer;
572
+ }
573
+
574
+ @media (max-width: 768px) {
575
+ .hamburger {
576
+ display: block;
577
+ }
578
+ }
579
+ </style>
580
+ </head>
581
+ <body>
582
+ <header class="header">
583
+ <div class="header-left">
584
+ <button class="hamburger" id="hamburgerBtn">&#9776;</button>
585
+ <span class="logo">hzl dashboard</span>
586
+ </div>
587
+ <div class="header-filters">
588
+ <div class="filter-group">
589
+ <select id="dateFilter">
590
+ <option value="1d">Today</option>
591
+ <option value="3d" selected>Last 3 days</option>
592
+ <option value="7d">Last 7 days</option>
593
+ <option value="14d">Last 14 days</option>
594
+ <option value="30d">Last 30 days</option>
595
+ </select>
596
+ </div>
597
+ <div class="filter-group">
598
+ <select id="projectFilter">
599
+ <option value="">All projects</option>
600
+ </select>
601
+ </div>
602
+ <div class="filter-group">
603
+ <label class="filter-label">Refresh:</label>
604
+ <select id="refreshFilter">
605
+ <option value="1000">1s</option>
606
+ <option value="2000">2s</option>
607
+ <option value="5000" selected>5s</option>
608
+ <option value="10000">10s</option>
609
+ <option value="30000">30s</option>
610
+ </select>
611
+ </div>
612
+ </div>
613
+ <div class="header-right">
614
+ <div class="connection-indicator">
615
+ <div class="connection-dot" id="connectionDot"></div>
616
+ <span id="connectionText">Connecting...</span>
617
+ </div>
618
+ <button class="activity-btn" id="activityBtn">Activity</button>
619
+ </div>
620
+ </header>
621
+
622
+ <!-- Mobile Tabs -->
623
+ <div class="mobile-tabs" id="mobileTabs">
624
+ <div class="mobile-tab" data-status="backlog">Backlog <span class="mobile-tab-badge" id="badge-backlog">0</span></div>
625
+ <div class="mobile-tab" data-status="blocked">Blocked <span class="mobile-tab-badge" id="badge-blocked">0</span></div>
626
+ <div class="mobile-tab active" data-status="ready">Ready <span class="mobile-tab-badge" id="badge-ready">0</span></div>
627
+ <div class="mobile-tab" data-status="in_progress">In Progress <span class="mobile-tab-badge" id="badge-in_progress">0</span></div>
628
+ <div class="mobile-tab" data-status="done">Done <span class="mobile-tab-badge" id="badge-done">0</span></div>
629
+ </div>
630
+
631
+ <!-- Mobile Cards Container -->
632
+ <div id="mobileCardsContainer"></div>
633
+
634
+ <!-- Desktop Kanban Board -->
635
+ <main class="board" id="board">
636
+ <div class="column" data-status="backlog">
637
+ <div class="column-header">
638
+ <span class="column-title">Backlog</span>
639
+ <span class="column-count" id="count-backlog">0</span>
640
+ </div>
641
+ <div class="column-cards" id="cards-backlog"></div>
642
+ </div>
643
+ <div class="column" data-status="blocked">
644
+ <div class="column-header">
645
+ <span class="column-title">Blocked</span>
646
+ <span class="column-count" id="count-blocked">0</span>
647
+ </div>
648
+ <div class="column-cards" id="cards-blocked"></div>
649
+ </div>
650
+ <div class="column" data-status="ready">
651
+ <div class="column-header">
652
+ <span class="column-title">Ready</span>
653
+ <span class="column-count" id="count-ready">0</span>
654
+ </div>
655
+ <div class="column-cards" id="cards-ready"></div>
656
+ </div>
657
+ <div class="column" data-status="in_progress">
658
+ <div class="column-header">
659
+ <span class="column-title">In Progress</span>
660
+ <span class="column-count" id="count-in_progress">0</span>
661
+ </div>
662
+ <div class="column-cards" id="cards-in_progress"></div>
663
+ </div>
664
+ <div class="column" data-status="done">
665
+ <div class="column-header">
666
+ <span class="column-title">Done</span>
667
+ <span class="column-count" id="count-done">0</span>
668
+ </div>
669
+ <div class="column-cards" id="cards-done"></div>
670
+ </div>
671
+ </main>
672
+
673
+ <!-- Task Detail Modal -->
674
+ <div class="modal-overlay" id="modalOverlay">
675
+ <div class="modal">
676
+ <div class="modal-header">
677
+ <span class="modal-title" id="modalTitle">Task Details</span>
678
+ <button class="modal-close" id="modalClose">&times;</button>
679
+ </div>
680
+ <div class="modal-body" id="modalBody">
681
+ <!-- Populated by JavaScript -->
682
+ </div>
683
+ </div>
684
+ </div>
685
+
686
+ <!-- Activity Panel -->
687
+ <div class="activity-panel" id="activityPanel">
688
+ <div class="activity-header">
689
+ <span class="activity-title">Activity</span>
690
+ <button class="activity-close" id="activityClose">&times;</button>
691
+ </div>
692
+ <div class="activity-list" id="activityList">
693
+ <!-- Populated by JavaScript -->
694
+ </div>
695
+ </div>
696
+
697
+ <script>
698
+ // State
699
+ let tasks = [];
700
+ let events = [];
701
+ let lastEventId = 0;
702
+ let pollTimer = null;
703
+ let lastPollTime = null;
704
+ let selectedTask = null;
705
+ let activeTab = 'ready';
706
+
707
+ // DOM Elements
708
+ const dateFilter = document.getElementById('dateFilter');
709
+ const projectFilter = document.getElementById('projectFilter');
710
+ const refreshFilter = document.getElementById('refreshFilter');
711
+ const connectionDot = document.getElementById('connectionDot');
712
+ const connectionText = document.getElementById('connectionText');
713
+ const activityBtn = document.getElementById('activityBtn');
714
+ const activityPanel = document.getElementById('activityPanel');
715
+ const activityClose = document.getElementById('activityClose');
716
+ const activityList = document.getElementById('activityList');
717
+ const modalOverlay = document.getElementById('modalOverlay');
718
+ const modalClose = document.getElementById('modalClose');
719
+ const modalTitle = document.getElementById('modalTitle');
720
+ const modalBody = document.getElementById('modalBody');
721
+ const hamburgerBtn = document.getElementById('hamburgerBtn');
722
+ const mobileTabs = document.getElementById('mobileTabs');
723
+
724
+ // Load saved preferences
725
+ function loadPreferences() {
726
+ const saved = localStorage.getItem('hzl-dashboard-prefs');
727
+ if (saved) {
728
+ try {
729
+ const prefs = JSON.parse(saved);
730
+ if (prefs.dateFilter) dateFilter.value = prefs.dateFilter;
731
+ if (prefs.projectFilter) projectFilter.value = prefs.projectFilter;
732
+ if (prefs.refreshFilter) refreshFilter.value = prefs.refreshFilter;
733
+ } catch (e) {}
734
+ }
735
+ }
736
+
737
+ function savePreferences() {
738
+ const prefs = {
739
+ dateFilter: dateFilter.value,
740
+ projectFilter: projectFilter.value,
741
+ refreshFilter: refreshFilter.value,
742
+ };
743
+ localStorage.setItem('hzl-dashboard-prefs', JSON.stringify(prefs));
744
+ }
745
+
746
+ // API calls
747
+ async function fetchTasks() {
748
+ const since = dateFilter.value;
749
+ const project = projectFilter.value;
750
+ const url = `/api/tasks?since=${since}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
751
+ const res = await fetch(url);
752
+ if (!res.ok) throw new Error('Failed to fetch tasks');
753
+ const data = await res.json();
754
+ return data.tasks;
755
+ }
756
+
757
+ async function fetchEvents() {
758
+ const res = await fetch(`/api/events?since=${lastEventId}`);
759
+ if (!res.ok) throw new Error('Failed to fetch events');
760
+ const data = await res.json();
761
+ return data.events;
762
+ }
763
+
764
+ async function fetchTaskDetail(taskId) {
765
+ const [taskRes, commentsRes, checkpointsRes] = await Promise.all([
766
+ fetch(`/api/tasks/${taskId}`),
767
+ fetch(`/api/tasks/${taskId}/comments`),
768
+ fetch(`/api/tasks/${taskId}/checkpoints`),
769
+ ]);
770
+
771
+ if (!taskRes.ok) throw new Error('Task not found');
772
+
773
+ const taskData = await taskRes.json();
774
+ const commentsData = await commentsRes.json();
775
+ const checkpointsData = await checkpointsRes.json();
776
+
777
+ return {
778
+ task: taskData.task,
779
+ comments: commentsData.comments,
780
+ checkpoints: checkpointsData.checkpoints,
781
+ };
782
+ }
783
+
784
+ async function fetchStats() {
785
+ const res = await fetch('/api/stats');
786
+ if (!res.ok) throw new Error('Failed to fetch stats');
787
+ return await res.json();
788
+ }
789
+
790
+ // Render functions
791
+ function renderCard(task) {
792
+ const isBlocked = task.blocked_by && task.blocked_by.length > 0;
793
+ const status = isBlocked ? 'blocked' : task.status;
794
+
795
+ let extra = '';
796
+ if (isBlocked) {
797
+ extra = `<div class="card-blocked">Blocked by: ${task.blocked_by.map(id => id.slice(0, 8)).join(', ')}</div>`;
798
+ }
799
+ if (task.status === 'in_progress' && task.lease_until) {
800
+ const remaining = formatTimeRemaining(task.lease_until);
801
+ extra += `<div class="card-lease">${remaining}</div>`;
802
+ }
803
+
804
+ return `
805
+ <div class="card" data-task-id="${task.task_id}">
806
+ <div class="card-id">${task.task_id.slice(0, 8)}</div>
807
+ <div class="card-title">${escapeHtml(task.title)}</div>
808
+ <div class="card-meta">
809
+ <span class="card-project">${escapeHtml(task.project)}</span>
810
+ ${task.claimed_by_agent_id ? `<span class="card-agent">${task.claimed_by_agent_id.slice(0, 8)}</span>` : ''}
811
+ </div>
812
+ ${extra}
813
+ </div>
814
+ `;
815
+ }
816
+
817
+ // Group tasks into Kanban columns, treating ready tasks with unmet dependencies as blocked
818
+ function groupTasksByStatus(taskList) {
819
+ const columns = {
820
+ backlog: [],
821
+ blocked: [],
822
+ ready: [],
823
+ in_progress: [],
824
+ done: [],
825
+ };
826
+ for (const task of taskList) {
827
+ const isBlocked = task.blocked_by && task.blocked_by.length > 0;
828
+ const status = isBlocked && task.status === 'ready' ? 'blocked' : task.status;
829
+ if (columns[status]) {
830
+ columns[status].push(task);
831
+ }
832
+ }
833
+ return columns;
834
+ }
835
+
836
+ function renderBoard() {
837
+ const columns = groupTasksByStatus(tasks);
838
+
839
+ for (const [status, statusTasks] of Object.entries(columns)) {
840
+ const container = document.getElementById(`cards-${status}`);
841
+ const countEl = document.getElementById(`count-${status}`);
842
+ const badgeEl = document.getElementById(`badge-${status}`);
843
+
844
+ if (container) {
845
+ if (statusTasks.length === 0) {
846
+ container.innerHTML = '<div class="empty-column">No tasks</div>';
847
+ } else {
848
+ container.innerHTML = statusTasks.map(renderCard).join('');
849
+ }
850
+ }
851
+ if (countEl) countEl.textContent = statusTasks.length;
852
+ if (badgeEl) badgeEl.textContent = statusTasks.length;
853
+ }
854
+
855
+ // Render mobile cards for active tab
856
+ renderMobileCards();
857
+
858
+ // Add click handlers
859
+ document.querySelectorAll('.card').forEach(card => {
860
+ card.addEventListener('click', () => openTaskModal(card.dataset.taskId));
861
+ });
862
+ }
863
+
864
+ function renderMobileCards() {
865
+ const container = document.getElementById('mobileCardsContainer');
866
+ if (!container) return;
867
+
868
+ const columns = groupTasksByStatus(tasks);
869
+
870
+ container.innerHTML = Object.entries(columns).map(([status, statusTasks]) => `
871
+ <div class="mobile-cards ${status === activeTab ? 'active' : ''}" data-status="${status}">
872
+ ${statusTasks.length === 0
873
+ ? '<div class="empty-column">No tasks</div>'
874
+ : statusTasks.map(renderCard).join('')
875
+ }
876
+ </div>
877
+ `).join('');
878
+
879
+ container.querySelectorAll('.card').forEach(card => {
880
+ card.addEventListener('click', () => openTaskModal(card.dataset.taskId));
881
+ });
882
+ }
883
+
884
+ function renderActivity() {
885
+ if (events.length === 0) {
886
+ activityList.innerHTML = '<div class="empty-column">No recent activity</div>';
887
+ return;
888
+ }
889
+
890
+ activityList.innerHTML = events.map(event => {
891
+ let detail = '';
892
+ if (event.type === 'status_changed' && event.data) {
893
+ detail = `${event.data.from} → ${event.data.to}`;
894
+ }
895
+ if (event.agent_id) {
896
+ detail = detail ? `${detail} by ${event.agent_id.slice(0, 8)}` : `by ${event.agent_id.slice(0, 8)}`;
897
+ }
898
+
899
+ return `
900
+ <div class="activity-item">
901
+ <div class="activity-item-header">
902
+ <span class="activity-type ${event.type}">${formatEventType(event.type)}</span>
903
+ <span class="activity-time">${formatTime(event.timestamp)}</span>
904
+ </div>
905
+ <div class="activity-task">${escapeHtml(event.task_title || event.task_id.slice(0, 8))}</div>
906
+ ${detail ? `<div class="activity-detail">${escapeHtml(detail)}</div>` : ''}
907
+ </div>
908
+ `;
909
+ }).join('');
910
+ }
911
+
912
+ async function openTaskModal(taskId) {
913
+ try {
914
+ const data = await fetchTaskDetail(taskId);
915
+ selectedTask = data;
916
+
917
+ modalTitle.textContent = data.task.title;
918
+
919
+ let html = `
920
+ <div class="modal-section">
921
+ <div class="modal-meta">
922
+ <div class="modal-meta-item">
923
+ <div class="modal-meta-label">Status</div>
924
+ <div class="modal-meta-value">${data.task.status}</div>
925
+ </div>
926
+ <div class="modal-meta-item">
927
+ <div class="modal-meta-label">Project</div>
928
+ <div class="modal-meta-value">${escapeHtml(data.task.project)}</div>
929
+ </div>
930
+ <div class="modal-meta-item">
931
+ <div class="modal-meta-label">Priority</div>
932
+ <div class="modal-meta-value">${data.task.priority}</div>
933
+ </div>
934
+ <div class="modal-meta-item">
935
+ <div class="modal-meta-label">Created</div>
936
+ <div class="modal-meta-value">${formatTime(data.task.created_at)}</div>
937
+ </div>
938
+ </div>
939
+ </div>
940
+ `;
941
+
942
+ if (data.task.claimed_by_agent_id) {
943
+ html += `
944
+ <div class="modal-section">
945
+ <div class="modal-section-title">Claimed By</div>
946
+ <div class="modal-meta">
947
+ <div class="modal-meta-item">
948
+ <div class="modal-meta-label">Agent</div>
949
+ <div class="modal-meta-value">${data.task.claimed_by_agent_id}</div>
950
+ </div>
951
+ ${data.task.lease_until ? `
952
+ <div class="modal-meta-item">
953
+ <div class="modal-meta-label">Lease Until</div>
954
+ <div class="modal-meta-value">${formatTime(data.task.lease_until)}</div>
955
+ </div>
956
+ ` : ''}
957
+ </div>
958
+ </div>
959
+ `;
960
+ }
961
+
962
+ if (data.task.blocked_by && data.task.blocked_by.length > 0) {
963
+ html += `
964
+ <div class="modal-section">
965
+ <div class="modal-section-title">Blocked By</div>
966
+ <div class="modal-description">${data.task.blocked_by.join(', ')}</div>
967
+ </div>
968
+ `;
969
+ }
970
+
971
+ if (data.task.description) {
972
+ html += `
973
+ <div class="modal-section">
974
+ <div class="modal-section-title">Description</div>
975
+ <div class="modal-description">${escapeHtml(data.task.description)}</div>
976
+ </div>
977
+ `;
978
+ }
979
+
980
+ if (data.comments.length > 0) {
981
+ html += `
982
+ <div class="modal-section">
983
+ <div class="modal-section-title">Comments (${data.comments.length})</div>
984
+ <div class="modal-comments">
985
+ ${data.comments.map(c => `
986
+ <div class="comment">
987
+ <div class="comment-header">
988
+ <span class="comment-author">${escapeHtml(c.agent_id || c.author || 'Unknown')}</span>
989
+ <span>${formatTime(c.timestamp)}</span>
990
+ </div>
991
+ <div class="comment-text">${escapeHtml(c.text)}</div>
992
+ </div>
993
+ `).join('')}
994
+ </div>
995
+ </div>
996
+ `;
997
+ }
998
+
999
+ if (data.checkpoints.length > 0) {
1000
+ html += `
1001
+ <div class="modal-section">
1002
+ <div class="modal-section-title">Checkpoints (${data.checkpoints.length})</div>
1003
+ <div class="modal-comments">
1004
+ ${data.checkpoints.map(cp => `
1005
+ <div class="comment">
1006
+ <div class="comment-header">
1007
+ <span class="comment-author">${escapeHtml(cp.name)}</span>
1008
+ <span>${formatTime(cp.timestamp)}</span>
1009
+ </div>
1010
+ <div class="comment-text">${JSON.stringify(cp.data, null, 2)}</div>
1011
+ </div>
1012
+ `).join('')}
1013
+ </div>
1014
+ </div>
1015
+ `;
1016
+ }
1017
+
1018
+ modalBody.innerHTML = html;
1019
+ modalOverlay.classList.add('open');
1020
+ } catch (error) {
1021
+ console.error('Failed to load task:', error);
1022
+ alert('Failed to load task details');
1023
+ }
1024
+ }
1025
+
1026
+ function closeModal() {
1027
+ modalOverlay.classList.remove('open');
1028
+ selectedTask = null;
1029
+ }
1030
+
1031
+ // Polling
1032
+ async function poll() {
1033
+ try {
1034
+ const [newTasks, newEvents, stats] = await Promise.all([
1035
+ fetchTasks(),
1036
+ fetchEvents(),
1037
+ fetchStats(),
1038
+ ]);
1039
+
1040
+ tasks = newTasks;
1041
+
1042
+ if (newEvents.length > 0) {
1043
+ events = [...newEvents, ...events].slice(0, 50);
1044
+ lastEventId = newEvents[0].id;
1045
+ }
1046
+
1047
+ // Update project filter
1048
+ const currentProject = projectFilter.value;
1049
+ projectFilter.innerHTML = '<option value="">All projects</option>' +
1050
+ stats.projects.map(p => `<option value="${escapeHtml(p)}" ${p === currentProject ? 'selected' : ''}>${escapeHtml(p)}</option>`).join('');
1051
+
1052
+ renderBoard();
1053
+ renderActivity();
1054
+
1055
+ lastPollTime = Date.now();
1056
+ updateConnectionStatus(true);
1057
+ } catch (error) {
1058
+ console.error('Poll failed:', error);
1059
+ updateConnectionStatus(false);
1060
+ }
1061
+ }
1062
+
1063
+ function startPolling() {
1064
+ poll();
1065
+ const interval = parseInt(refreshFilter.value, 10);
1066
+ pollTimer = setInterval(poll, interval);
1067
+ }
1068
+
1069
+ function stopPolling() {
1070
+ if (pollTimer) {
1071
+ clearInterval(pollTimer);
1072
+ pollTimer = null;
1073
+ }
1074
+ }
1075
+
1076
+ function restartPolling() {
1077
+ stopPolling();
1078
+ startPolling();
1079
+ }
1080
+
1081
+ // Connection status
1082
+ function updateConnectionStatus(success) {
1083
+ if (success) {
1084
+ connectionDot.classList.remove('error');
1085
+ const ago = lastPollTime ? Math.round((Date.now() - lastPollTime) / 1000) : 0;
1086
+ connectionText.textContent = ago === 0 ? 'Just now' : `${ago}s ago`;
1087
+ } else {
1088
+ connectionDot.classList.add('error');
1089
+ connectionText.textContent = 'Error';
1090
+ }
1091
+ }
1092
+
1093
+ // Helpers
1094
+ function escapeHtml(str) {
1095
+ if (!str) return '';
1096
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1097
+ }
1098
+
1099
+ // Time constants in milliseconds
1100
+ const MS_PER_SECOND = 1000;
1101
+ const MS_PER_MINUTE = 60 * MS_PER_SECOND;
1102
+ const MS_PER_HOUR = 60 * MS_PER_MINUTE;
1103
+ const MS_PER_DAY = 24 * MS_PER_HOUR;
1104
+
1105
+ function formatTime(isoString) {
1106
+ if (!isoString) return '';
1107
+ const date = new Date(isoString);
1108
+ const now = new Date();
1109
+ const diff = now - date;
1110
+
1111
+ if (diff < MS_PER_MINUTE) return 'just now';
1112
+ if (diff < MS_PER_HOUR) return `${Math.floor(diff / MS_PER_MINUTE)}m ago`;
1113
+ if (diff < MS_PER_DAY) return `${Math.floor(diff / MS_PER_HOUR)}h ago`;
1114
+ return date.toLocaleDateString();
1115
+ }
1116
+
1117
+ function formatTimeRemaining(isoString) {
1118
+ if (!isoString) return '';
1119
+ const date = new Date(isoString);
1120
+ const now = new Date();
1121
+ const diff = date - now;
1122
+
1123
+ if (diff <= 0) return 'expired';
1124
+ if (diff < MS_PER_MINUTE) return `${Math.floor(diff / MS_PER_SECOND)}s left`;
1125
+ if (diff < MS_PER_HOUR) return `${Math.floor(diff / MS_PER_MINUTE)}m left`;
1126
+ return `${Math.floor(diff / MS_PER_HOUR)}h left`;
1127
+ }
1128
+
1129
+ function formatEventType(type) {
1130
+ return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1131
+ }
1132
+
1133
+ // Event listeners
1134
+ dateFilter.addEventListener('change', () => {
1135
+ savePreferences();
1136
+ poll();
1137
+ });
1138
+
1139
+ projectFilter.addEventListener('change', () => {
1140
+ savePreferences();
1141
+ poll();
1142
+ });
1143
+
1144
+ refreshFilter.addEventListener('change', () => {
1145
+ savePreferences();
1146
+ restartPolling();
1147
+ });
1148
+
1149
+ activityBtn.addEventListener('click', () => {
1150
+ activityPanel.classList.add('open');
1151
+ });
1152
+
1153
+ activityClose.addEventListener('click', () => {
1154
+ activityPanel.classList.remove('open');
1155
+ });
1156
+
1157
+ modalClose.addEventListener('click', closeModal);
1158
+ modalOverlay.addEventListener('click', (e) => {
1159
+ if (e.target === modalOverlay) closeModal();
1160
+ });
1161
+
1162
+ document.addEventListener('keydown', (e) => {
1163
+ if (e.key === 'Escape') {
1164
+ closeModal();
1165
+ activityPanel.classList.remove('open');
1166
+ }
1167
+ });
1168
+
1169
+ // Visibility API
1170
+ document.addEventListener('visibilitychange', () => {
1171
+ if (document.hidden) {
1172
+ stopPolling();
1173
+ } else {
1174
+ startPolling();
1175
+ }
1176
+ });
1177
+
1178
+ // Mobile tabs
1179
+ mobileTabs.addEventListener('click', (e) => {
1180
+ const tab = e.target.closest('.mobile-tab');
1181
+ if (!tab) return;
1182
+
1183
+ activeTab = tab.dataset.status;
1184
+
1185
+ document.querySelectorAll('.mobile-tab').forEach(t => t.classList.remove('active'));
1186
+ tab.classList.add('active');
1187
+
1188
+ document.querySelectorAll('.mobile-cards').forEach(c => c.classList.remove('active'));
1189
+ document.querySelector(`.mobile-cards[data-status="${activeTab}"]`)?.classList.add('active');
1190
+ });
1191
+
1192
+ // Hamburger menu (toggle filters on mobile)
1193
+ hamburgerBtn.addEventListener('click', () => {
1194
+ const filters = document.querySelector('.header-filters');
1195
+ filters.style.display = filters.style.display === 'none' ? 'flex' : 'none';
1196
+ });
1197
+
1198
+ // Initialize
1199
+ loadPreferences();
1200
+ startPolling();
1201
+
1202
+ // Update connection time every second
1203
+ setInterval(() => {
1204
+ if (lastPollTime) {
1205
+ const ago = Math.round((Date.now() - lastPollTime) / 1000);
1206
+ connectionText.textContent = ago === 0 ? 'Just now' : `${ago}s ago`;
1207
+ }
1208
+ }, 1000);
1209
+ </script>
1210
+ </body>
1211
+ </html>