hzl-web 2.2.0 → 2.4.0

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.
@@ -1,4473 +1,16 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <meta name="theme-color" content="#1a1a1a">
7
- <meta name="apple-mobile-web-app-capable" content="yes">
8
- <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
9
- <link rel="shortcut icon" href="/favicon.ico">
10
- <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
11
- <meta name="apple-mobile-web-app-title" content="HZL">
12
- <link rel="manifest" href="/site.webmanifest">
13
- <title>HZL</title>
14
- <style>
15
- :root {
16
- --bg-primary: #1a1a1a;
17
- --bg-secondary: #252525;
18
- --bg-card: #2d2d2d;
19
- --text-primary: #e5e5e5;
20
- --text-secondary: #a3a3a3;
21
- --text-muted: #737373;
22
- --accent: #f59e0b;
23
- --accent-dim: #b45309;
24
- --border: #404040;
25
- --status-backlog: #6b7280; /* gray - not yet prioritized */
26
- --status-blocked: #ef4444; /* red - stuck, needs help */
27
- --status-ready: #3b82f6; /* blue - available to claim */
28
- --status-in-progress: #f59e0b; /* orange - active work */
29
- --status-done: #22c55e; /* green - completed */
30
- --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
31
- }
32
-
33
- * {
34
- box-sizing: border-box;
35
- margin: 0;
36
- padding: 0;
37
- }
38
-
39
- body {
40
- font-family: var(--font-mono);
41
- font-size: 13px;
42
- background: var(--bg-primary);
43
- color: var(--text-primary);
44
- line-height: 1.5;
45
- min-height: 100vh;
46
- }
47
-
48
- /* Header */
49
- .header {
50
- display: flex;
51
- align-items: center;
52
- justify-content: flex-start;
53
- gap: 14px;
54
- padding: 12px 16px;
55
- background: var(--bg-secondary);
56
- border-bottom: 1px solid var(--border);
57
- position: sticky;
58
- top: 0;
59
- z-index: 100;
60
- }
61
-
62
- .header-left {
63
- display: flex;
64
- align-items: center;
65
- gap: 8px;
66
- flex-shrink: 0;
67
- }
68
-
69
- .logo {
70
- font-weight: 600;
71
- font-size: 14px;
72
- color: var(--accent);
73
- }
74
-
75
- .header-filters {
76
- display: flex;
77
- align-items: center;
78
- gap: 8px;
79
- flex: 1;
80
- min-width: 0;
81
- }
82
-
83
- .filter-group {
84
- display: flex;
85
- align-items: center;
86
- gap: 8px;
87
- position: relative;
88
- }
89
-
90
- .filter-label {
91
- color: var(--text-muted);
92
- font-size: 11px;
93
- }
94
-
95
- select {
96
- font-family: var(--font-mono);
97
- font-size: 12px;
98
- background: var(--bg-primary);
99
- color: var(--text-primary);
100
- border: 1px solid var(--border);
101
- padding: 0 44px 0 12px;
102
- border-radius: 6px;
103
- cursor: pointer;
104
- min-height: 42px;
105
- line-height: 1.2;
106
- }
107
-
108
- select:focus {
109
- outline: none;
110
- border-color: var(--accent);
111
- }
112
-
113
- .task-search-group {
114
- gap: 6px;
115
- }
116
-
117
- .task-search-input {
118
- width: 220px;
119
- font-family: var(--font-mono);
120
- font-size: 12px;
121
- background: var(--bg-primary);
122
- color: var(--text-primary);
123
- border: 1px solid var(--border);
124
- padding: 0 12px;
125
- border-radius: 6px;
126
- min-height: 42px;
127
- }
128
-
129
- .task-search-input:focus {
130
- outline: none;
131
- border-color: var(--accent);
132
- }
133
-
134
- .task-search-clear {
135
- border: 1px solid var(--border);
136
- border-radius: 4px;
137
- background: var(--bg-primary);
138
- color: var(--text-secondary);
139
- font-family: var(--font-mono);
140
- font-size: 12px;
141
- line-height: 1;
142
- padding: 4px 8px;
143
- cursor: pointer;
144
- }
145
-
146
- .task-search-clear:hover {
147
- border-color: var(--accent);
148
- color: var(--text-primary);
149
- }
150
-
151
- .task-search-clear[hidden] {
152
- display: none;
153
- }
154
-
155
- .task-search-meta {
156
- min-width: 0;
157
- width: 0;
158
- overflow: hidden;
159
- font-size: 11px;
160
- color: var(--text-muted);
161
- text-align: right;
162
- font-variant-numeric: tabular-nums;
163
- transition: width 120ms ease;
164
- }
165
-
166
- .task-search-group.active .task-search-meta {
167
- width: 56px;
168
- }
169
-
170
- .header-right {
171
- display: flex;
172
- align-items: center;
173
- gap: 12px;
174
- margin-left: auto;
175
- flex-shrink: 0;
176
- }
177
-
178
- .connection-indicator {
179
- display: flex;
180
- align-items: center;
181
- gap: 6px;
182
- font-size: 11px;
183
- color: var(--text-muted);
184
- min-width: 85px;
185
- justify-content: flex-end;
186
- }
187
-
188
- .connection-dot {
189
- width: 8px;
190
- height: 8px;
191
- border-radius: 50%;
192
- background: var(--text-muted);
193
- }
194
-
195
- .connection-dot.live {
196
- background: var(--status-done);
197
- }
198
-
199
- .connection-dot.error {
200
- background: var(--status-blocked);
201
- }
202
-
203
- .activity-btn {
204
- font-family: var(--font-mono);
205
- font-size: 12px;
206
- background: var(--bg-primary);
207
- color: var(--text-primary);
208
- border: 1px solid var(--border);
209
- min-height: 42px;
210
- padding: 0 14px;
211
- border-radius: 6px;
212
- cursor: pointer;
213
- }
214
-
215
- .activity-btn:hover {
216
- border-color: var(--accent);
217
- }
218
-
219
- .settings-shortcuts-btn {
220
- width: 100%;
221
- font-family: var(--font-mono);
222
- font-size: 12px;
223
- background: var(--bg-primary);
224
- color: var(--text-secondary);
225
- border: 1px solid var(--border);
226
- padding: 6px 8px;
227
- border-radius: 4px;
228
- cursor: pointer;
229
- text-align: left;
230
- white-space: nowrap;
231
- }
232
-
233
- .settings-shortcuts-btn:hover {
234
- color: var(--text-primary);
235
- border-color: var(--accent);
236
- }
237
-
238
- .settings-view-select {
239
- width: 100%;
240
- }
241
-
242
- .collapse-parents-actions {
243
- display: flex;
244
- gap: 6px;
245
- margin-top: 2px;
246
- }
247
-
248
- .collapse-parents-btn {
249
- flex: 1;
250
- font-family: var(--font-mono);
251
- font-size: 11px;
252
- background: var(--bg-primary);
253
- color: var(--text-secondary);
254
- border: 1px solid var(--border);
255
- padding: 5px 6px;
256
- border-radius: 4px;
257
- cursor: pointer;
258
- }
259
-
260
- .collapse-parents-btn:hover:not(:disabled) {
261
- color: var(--text-primary);
262
- border-color: var(--accent);
263
- }
264
-
265
- .collapse-parents-btn:disabled {
266
- opacity: 0.45;
267
- cursor: not-allowed;
268
- }
269
-
270
- .collapse-parents-meta {
271
- margin-top: 5px;
272
- font-size: 10px;
273
- color: var(--text-muted);
274
- }
275
-
276
- /* Column Visibility Dropdown */
277
- .columns-toggle {
278
- font-family: var(--font-mono);
279
- font-size: 12px;
280
- background: var(--bg-primary);
281
- color: var(--text-primary);
282
- border: 1px solid var(--border);
283
- padding: 4px 8px;
284
- border-radius: 4px;
285
- cursor: pointer;
286
- }
287
-
288
- .columns-toggle:hover {
289
- border-color: var(--accent);
290
- }
291
-
292
- .columns-dropdown {
293
- display: none;
294
- position: absolute;
295
- top: 100%;
296
- left: 0;
297
- margin-top: 4px;
298
- background: var(--bg-secondary);
299
- border: 1px solid var(--border);
300
- border-radius: 4px;
301
- padding: 8px;
302
- z-index: 100;
303
- min-width: 140px;
304
- }
305
-
306
- .columns-dropdown.open {
307
- display: block;
308
- }
309
-
310
- .column-checkbox {
311
- display: flex;
312
- align-items: center;
313
- gap: 8px;
314
- padding: 4px 0;
315
- cursor: pointer;
316
- font-size: 12px;
317
- color: var(--text-primary);
318
- }
319
-
320
- .column-checkbox:hover {
321
- color: var(--accent);
322
- }
323
-
324
- .column-checkbox input {
325
- accent-color: var(--accent);
326
- }
327
-
328
- /* Settings Dropdown */
329
- .settings-group {
330
- position: relative;
331
- }
332
-
333
- .settings-toggle {
334
- display: flex;
335
- align-items: center;
336
- justify-content: center;
337
- background: var(--bg-primary);
338
- color: var(--text-secondary);
339
- border: 1px solid var(--border);
340
- width: 42px;
341
- height: 42px;
342
- padding: 0;
343
- border-radius: 6px;
344
- cursor: pointer;
345
- }
346
-
347
- #dateFilter {
348
- min-width: 150px;
349
- }
350
-
351
- #projectFilter {
352
- min-width: 220px;
353
- }
354
-
355
- #assigneeFilter {
356
- min-width: 180px;
357
- }
358
-
359
- .settings-toggle:hover {
360
- border-color: var(--accent);
361
- color: var(--text-primary);
362
- }
363
-
364
- .settings-dropdown {
365
- display: none;
366
- position: absolute;
367
- top: 100%;
368
- right: 0;
369
- margin-top: 4px;
370
- background: var(--bg-secondary);
371
- border: 1px solid var(--border);
372
- border-radius: 6px;
373
- padding: 12px;
374
- z-index: 100;
375
- min-width: 180px;
376
- }
377
-
378
- .settings-dropdown.open {
379
- display: block;
380
- }
381
-
382
- .settings-section {
383
- margin-bottom: 12px;
384
- }
385
-
386
- .settings-section:last-child {
387
- margin-bottom: 0;
388
- }
389
-
390
- .settings-label {
391
- display: block;
392
- font-size: 11px;
393
- color: var(--text-muted);
394
- margin-bottom: 6px;
395
- text-transform: uppercase;
396
- letter-spacing: 0.5px;
397
- }
398
-
399
- .settings-section select {
400
- width: 100%;
401
- font-family: var(--font-mono);
402
- font-size: 12px;
403
- background: var(--bg-primary);
404
- color: var(--text-primary);
405
- border: 1px solid var(--border);
406
- padding: 4px 8px;
407
- border-radius: 4px;
408
- }
409
-
410
- .column-checkboxes {
411
- display: flex;
412
- flex-direction: column;
413
- }
414
-
415
- /* Kanban Board */
416
- .board {
417
- display: flex;
418
- gap: 12px;
419
- padding: 16px;
420
- overflow-x: auto;
421
- min-height: calc(100vh - 53px);
422
- }
423
-
424
- .column {
425
- flex: 1 1 220px;
426
- min-width: 180px;
427
- max-width: 320px;
428
- background: var(--bg-secondary);
429
- border-radius: 8px;
430
- display: flex;
431
- flex-direction: column;
432
- max-height: calc(100vh - 85px);
433
- }
434
-
435
- .column.hidden {
436
- display: none;
437
- }
438
-
439
- .column-header {
440
- padding: 12px;
441
- border-bottom: 1px solid var(--border);
442
- display: flex;
443
- align-items: center;
444
- justify-content: space-between;
445
- }
446
-
447
- .column-title {
448
- font-weight: 600;
449
- font-size: 12px;
450
- text-transform: uppercase;
451
- letter-spacing: 0.5px;
452
- }
453
-
454
- .column-count {
455
- font-size: 11px;
456
- color: var(--text-muted);
457
- background: var(--bg-primary);
458
- padding: 2px 8px;
459
- border-radius: 10px;
460
- }
461
-
462
- .column-cards {
463
- flex: 1;
464
- overflow-y: auto;
465
- padding: 8px;
466
- display: flex;
467
- flex-direction: column;
468
- gap: 8px;
469
- scrollbar-width: none;
470
- -ms-overflow-style: none;
471
- }
472
-
473
- .column-cards::-webkit-scrollbar {
474
- width: 0;
475
- height: 0;
476
- }
477
-
478
- .column-cards.is-scrolling,
479
- .column-cards:hover,
480
- .column-cards:focus-within {
481
- scrollbar-width: thin;
482
- scrollbar-color: var(--border) transparent;
483
- }
484
-
485
- .column-cards.is-scrolling::-webkit-scrollbar,
486
- .column-cards:hover::-webkit-scrollbar,
487
- .column-cards:focus-within::-webkit-scrollbar {
488
- width: 8px;
489
- height: 8px;
490
- }
491
-
492
- .column-cards.is-scrolling::-webkit-scrollbar-thumb,
493
- .column-cards:hover::-webkit-scrollbar-thumb,
494
- .column-cards:focus-within::-webkit-scrollbar-thumb {
495
- background: var(--border);
496
- border-radius: 999px;
497
- }
498
-
499
- .column-cards.is-scrolling::-webkit-scrollbar-track,
500
- .column-cards:hover::-webkit-scrollbar-track,
501
- .column-cards:focus-within::-webkit-scrollbar-track {
502
- background: transparent;
503
- }
504
-
505
- /* Task Cards */
506
- .card {
507
- background: var(--bg-card);
508
- border: 1px solid var(--border);
509
- border-radius: 6px;
510
- padding: 10px 12px;
511
- cursor: pointer;
512
- transition: border-color 0.15s;
513
- }
514
-
515
- .card:hover {
516
- border-color: var(--accent);
517
- }
518
-
519
- .card-header {
520
- display: flex;
521
- align-items: center;
522
- justify-content: space-between;
523
- gap: 8px;
524
- margin-bottom: 4px;
525
- }
526
-
527
- .card-header-left {
528
- display: flex;
529
- align-items: center;
530
- gap: 6px;
531
- flex: 1;
532
- min-width: 0;
533
- }
534
-
535
- .card-header-right {
536
- display: flex;
537
- flex-direction: column;
538
- align-items: flex-end;
539
- gap: 4px;
540
- min-width: 0;
541
- flex-shrink: 0;
542
- }
543
-
544
- .card-emoji {
545
- font-size: 12px;
546
- flex-shrink: 0;
547
- }
548
-
549
- .card-parent {
550
- box-shadow: 0 0 0 1px var(--family-color);
551
- }
552
-
553
- .card-id {
554
- font-size: 10px;
555
- color: var(--text-muted);
556
- }
557
-
558
- .card-title {
559
- font-size: 13px;
560
- color: var(--text-primary);
561
- display: -webkit-box;
562
- -webkit-line-clamp: 2;
563
- -webkit-box-orient: vertical;
564
- overflow: hidden;
565
- margin-bottom: 8px;
566
- }
567
-
568
- .card-meta {
569
- display: flex;
570
- align-items: center;
571
- justify-content: flex-start;
572
- gap: 8px;
573
- font-size: 11px;
574
- color: var(--text-muted);
575
- }
576
-
577
- .card-project {
578
- display: inline-block;
579
- max-width: 140px;
580
- overflow: hidden;
581
- text-overflow: ellipsis;
582
- white-space: nowrap;
583
- background: var(--bg-primary);
584
- padding: 2px 6px;
585
- border-radius: 3px;
586
- }
587
-
588
- .card-assignee {
589
- display: inline-block;
590
- max-width: 140px;
591
- overflow: hidden;
592
- text-overflow: ellipsis;
593
- white-space: nowrap;
594
- font-size: 11px;
595
- font-weight: 600;
596
- padding: 2px 8px;
597
- border-radius: 3px;
598
- background: var(--bg-primary);
599
- line-height: 1.2;
600
- }
601
-
602
- .card-assignee.assigned {
603
- color: var(--accent);
604
- }
605
-
606
- .card-assignee.unassigned {
607
- color: var(--text-muted);
608
- font-weight: 500;
609
- }
610
-
611
- .card-progress {
612
- color: var(--accent);
613
- background: rgba(245, 158, 11, 0.15);
614
- padding: 2px 6px;
615
- border-radius: 3px;
616
- font-size: 10px;
617
- flex-shrink: 0;
618
- }
619
-
620
- .card-progress.complete {
621
- color: var(--status-done);
622
- background: rgba(34, 197, 94, 0.15);
623
- }
624
-
625
- .card-subtask-count {
626
- font-size: 11px;
627
- color: var(--text-muted);
628
- margin-bottom: 6px;
629
- }
630
-
631
- .card-subtask-toggle {
632
- display: inline-flex;
633
- align-items: center;
634
- gap: 4px;
635
- border: 1px solid var(--border);
636
- border-radius: 4px;
637
- background: var(--bg-primary);
638
- color: var(--text-muted);
639
- font-family: var(--font-mono);
640
- font-size: 11px;
641
- padding: 2px 6px;
642
- margin-bottom: 6px;
643
- cursor: pointer;
644
- }
645
-
646
- .card-subtask-toggle:hover {
647
- color: var(--text-primary);
648
- border-color: var(--accent);
649
- }
650
-
651
- .card-blocked {
652
- font-size: 10px;
653
- color: var(--status-blocked);
654
- margin-top: 6px;
655
- }
656
-
657
- .card-lease {
658
- font-size: 10px;
659
- color: var(--text-muted);
660
- margin-top: 4px;
661
- }
662
-
663
- /* Empty State */
664
- .empty-column {
665
- text-align: center;
666
- color: var(--text-muted);
667
- padding: 24px 12px;
668
- font-size: 12px;
669
- }
670
-
671
- /* Modal */
672
- .modal-overlay {
673
- position: fixed;
674
- top: 0;
675
- left: 0;
676
- right: 0;
677
- bottom: 0;
678
- background: rgba(0, 0, 0, 0.7);
679
- display: none;
680
- align-items: center;
681
- justify-content: center;
682
- z-index: 200;
683
- padding: 24px;
684
- }
685
-
686
- .modal-overlay.open {
687
- display: flex;
688
- }
689
-
690
- .modal {
691
- background: var(--bg-secondary);
692
- border: 1px solid var(--border);
693
- border-radius: 8px;
694
- max-width: 800px;
695
- width: 100%;
696
- max-height: 80vh;
697
- overflow: hidden;
698
- display: flex;
699
- flex-direction: column;
700
- }
701
-
702
- .modal-header {
703
- padding: 16px;
704
- border-bottom: 1px solid var(--border);
705
- display: flex;
706
- align-items: flex-start;
707
- justify-content: space-between;
708
- }
709
-
710
- .modal-title-wrap {
711
- min-width: 0;
712
- display: flex;
713
- flex-direction: column;
714
- gap: 6px;
715
- }
716
-
717
- .modal-title {
718
- font-size: 16px;
719
- font-weight: 600;
720
- }
721
-
722
- .modal-task-id-row {
723
- display: flex;
724
- align-items: center;
725
- gap: 8px;
726
- flex-wrap: wrap;
727
- font-size: 11px;
728
- color: var(--text-muted);
729
- }
730
-
731
- .modal-task-id-value {
732
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
733
- font-size: 11px;
734
- color: var(--text-primary);
735
- background: var(--bg-primary);
736
- padding: 2px 8px;
737
- border-radius: 999px;
738
- max-width: 360px;
739
- overflow: hidden;
740
- text-overflow: ellipsis;
741
- white-space: nowrap;
742
- }
743
-
744
- .modal-task-id-copy {
745
- border: 1px solid var(--border);
746
- background: var(--bg-primary);
747
- color: var(--text-muted);
748
- border-radius: 4px;
749
- font-size: 11px;
750
- line-height: 1.3;
751
- padding: 2px 8px;
752
- cursor: pointer;
753
- transition: color 120ms ease, border-color 120ms ease;
754
- }
755
-
756
- .modal-task-id-copy:hover:not(:disabled) {
757
- color: var(--text-primary);
758
- }
759
-
760
- .modal-task-id-copy:disabled {
761
- opacity: 0.55;
762
- cursor: default;
763
- }
764
-
765
- .modal-task-id-copy.copied {
766
- color: var(--status-done);
767
- border-color: var(--status-done);
768
- }
769
-
770
- .modal-task-id-copy.failed {
771
- color: var(--status-blocked);
772
- border-color: var(--status-blocked);
773
- }
774
-
775
- .modal-close {
776
- background: none;
777
- border: none;
778
- color: var(--text-muted);
779
- font-size: 20px;
780
- cursor: pointer;
781
- padding: 0;
782
- line-height: 1;
783
- }
784
-
785
- .modal-close:hover {
786
- color: var(--text-primary);
787
- }
788
-
789
- .modal-body {
790
- padding: 16px;
791
- overflow-y: auto;
792
- flex: 1;
793
- }
794
-
795
- .modal-section {
796
- margin-bottom: 16px;
797
- }
798
-
799
- .modal-section:last-child {
800
- margin-bottom: 0;
801
- }
802
-
803
- .modal-section-title {
804
- font-size: 11px;
805
- text-transform: uppercase;
806
- color: var(--text-muted);
807
- margin-bottom: 8px;
808
- letter-spacing: 0.5px;
809
- }
810
-
811
- .modal-meta {
812
- display: grid;
813
- grid-template-columns: repeat(2, 1fr);
814
- gap: 8px;
815
- }
816
-
817
- .modal-meta-item {
818
- background: var(--bg-primary);
819
- padding: 8px 10px;
820
- border-radius: 4px;
821
- }
822
-
823
- .modal-meta-label {
824
- font-size: 10px;
825
- color: var(--text-muted);
826
- margin-bottom: 2px;
827
- }
828
-
829
- .modal-meta-value {
830
- font-size: 12px;
831
- }
832
-
833
- .modal-meta-fallback {
834
- color: var(--text-muted);
835
- }
836
-
837
- .modal-progress {
838
- color: var(--accent);
839
- background: rgba(245, 158, 11, 0.15);
840
- padding: 2px 8px;
841
- border-radius: 4px;
842
- }
843
-
844
- .modal-progress.complete {
845
- color: var(--status-done);
846
- background: rgba(34, 197, 94, 0.15);
847
- }
848
-
849
- .modal-description {
850
- background: var(--bg-primary);
851
- padding: 12px;
852
- border-radius: 4px;
853
- font-size: 12px;
854
- line-height: 1.6;
855
- }
856
-
857
- /* Markdown content styles */
858
- .modal-description h1,
859
- .modal-description h2,
860
- .modal-description h3,
861
- .modal-description h4,
862
- .modal-description h5,
863
- .modal-description h6 {
864
- margin: 16px 0 8px 0;
865
- font-weight: 600;
866
- line-height: 1.3;
867
- }
868
-
869
- .modal-description h1:first-child,
870
- .modal-description h2:first-child,
871
- .modal-description h3:first-child {
872
- margin-top: 0;
873
- }
874
-
875
- .modal-description h1 { font-size: 18px; }
876
- .modal-description h2 { font-size: 16px; }
877
- .modal-description h3 { font-size: 14px; }
878
- .modal-description h4,
879
- .modal-description h5,
880
- .modal-description h6 { font-size: 12px; }
881
-
882
- .modal-description p {
883
- margin: 8px 0;
884
- }
885
-
886
- .modal-description p:first-child {
887
- margin-top: 0;
888
- }
889
-
890
- .modal-description p:last-child {
891
- margin-bottom: 0;
892
- }
893
-
894
- .modal-description ul,
895
- .modal-description ol {
896
- margin: 8px 0;
897
- padding-left: 20px;
898
- }
899
-
900
- .modal-description li {
901
- margin: 4px 0;
902
- }
903
-
904
- .modal-description code {
905
- background: var(--bg-secondary);
906
- padding: 2px 6px;
907
- border-radius: 3px;
908
- font-family: var(--font-mono);
909
- font-size: 11px;
910
- }
911
-
912
- .modal-description pre {
913
- background: var(--bg-secondary);
914
- padding: 12px;
915
- border-radius: 4px;
916
- overflow-x: auto;
917
- margin: 12px 0;
918
- }
919
-
920
- .modal-description pre code {
921
- background: none;
922
- padding: 0;
923
- font-size: 11px;
924
- line-height: 1.5;
925
- }
926
-
927
- .modal-description blockquote {
928
- border-left: 3px solid var(--accent);
929
- margin: 12px 0;
930
- padding: 8px 12px;
931
- background: var(--bg-secondary);
932
- color: var(--text-secondary);
933
- }
934
-
935
- .modal-description blockquote p {
936
- margin: 0;
937
- }
938
-
939
- .modal-description a {
940
- color: var(--accent);
941
- text-decoration: none;
942
- }
943
-
944
- .modal-description a:hover {
945
- text-decoration: underline;
946
- }
947
-
948
- .modal-description hr {
949
- border: none;
950
- border-top: 1px solid var(--border);
951
- margin: 16px 0;
952
- }
953
-
954
- .modal-description table {
955
- border-collapse: collapse;
956
- width: 100%;
957
- margin: 12px 0;
958
- font-size: 11px;
959
- }
960
-
961
- .modal-description th,
962
- .modal-description td {
963
- border: 1px solid var(--border);
964
- padding: 6px 10px;
965
- text-align: left;
966
- }
967
-
968
- .modal-description th {
969
- background: var(--bg-secondary);
970
- font-weight: 600;
971
- }
972
-
973
- .modal-description img {
974
- max-width: 100%;
975
- height: auto;
976
- }
977
-
978
- .modal-comments {
979
- display: flex;
980
- flex-direction: column;
981
- gap: 8px;
982
- }
983
-
984
- .comment {
985
- background: var(--bg-primary);
986
- padding: 10px;
987
- border-radius: 4px;
988
- border-left: 2px solid var(--accent);
989
- }
990
-
991
- .comment-header {
992
- display: flex;
993
- align-items: center;
994
- justify-content: space-between;
995
- margin-bottom: 4px;
996
- font-size: 11px;
997
- color: var(--text-muted);
998
- }
999
-
1000
- .comment-author {
1001
- color: var(--accent);
1002
- }
1003
-
1004
- .comment-text {
1005
- font-size: 12px;
1006
- white-space: pre-wrap;
1007
- }
1008
-
1009
- .modal-checkpoint-list,
1010
- .modal-task-activity-list {
1011
- display: flex;
1012
- flex-direction: column;
1013
- gap: 10px;
1014
- }
1015
-
1016
- .modal-checkpoint-entry,
1017
- .modal-task-activity-entry {
1018
- background: var(--bg-primary);
1019
- border: 1px solid var(--border);
1020
- border-radius: 6px;
1021
- padding: 10px 12px;
1022
- }
1023
-
1024
- .modal-checkpoint-header,
1025
- .modal-task-activity-header {
1026
- display: flex;
1027
- align-items: flex-start;
1028
- justify-content: space-between;
1029
- gap: 10px;
1030
- margin-bottom: 6px;
1031
- }
1032
-
1033
- .modal-checkpoint-name,
1034
- .modal-task-activity-type {
1035
- font-size: 12px;
1036
- font-weight: 600;
1037
- color: var(--text-primary);
1038
- line-height: 1.3;
1039
- }
1040
-
1041
- .modal-entry-time {
1042
- color: var(--text-muted);
1043
- font-size: 11px;
1044
- white-space: nowrap;
1045
- line-height: 1.3;
1046
- margin-top: 1px;
1047
- }
1048
-
1049
- .modal-checkpoint-data {
1050
- margin: 0;
1051
- font-family: var(--font-mono);
1052
- font-size: 11px;
1053
- line-height: 1.45;
1054
- color: var(--text-secondary);
1055
- background: var(--bg-secondary);
1056
- border: 1px solid var(--border);
1057
- border-radius: 4px;
1058
- padding: 8px;
1059
- white-space: pre-wrap;
1060
- overflow-wrap: anywhere;
1061
- }
1062
-
1063
- .modal-task-activity-author {
1064
- color: var(--text-secondary);
1065
- font-size: 11px;
1066
- margin-bottom: 4px;
1067
- }
1068
-
1069
- .modal-task-activity-detail {
1070
- color: var(--text-primary);
1071
- font-size: 12px;
1072
- line-height: 1.45;
1073
- white-space: pre-wrap;
1074
- overflow-wrap: anywhere;
1075
- }
1076
-
1077
- .show-more-btn {
1078
- width: 100%;
1079
- padding: 8px;
1080
- background: var(--bg-primary);
1081
- border: 1px dashed var(--border);
1082
- border-radius: 4px;
1083
- color: var(--text-secondary);
1084
- font-family: var(--font-mono);
1085
- font-size: 12px;
1086
- cursor: pointer;
1087
- transition: border-color 0.15s, color 0.15s;
1088
- }
1089
-
1090
- .show-more-btn:hover {
1091
- border-color: var(--accent);
1092
- color: var(--accent);
1093
- }
1094
-
1095
- .modal-tabs {
1096
- display: flex;
1097
- gap: 0;
1098
- border-bottom: 1px solid var(--border);
1099
- margin-bottom: 12px;
1100
- }
1101
-
1102
- .modal-tab {
1103
- padding: 8px 16px;
1104
- background: none;
1105
- border: none;
1106
- color: var(--text-muted);
1107
- font-family: var(--font-mono);
1108
- font-size: 12px;
1109
- cursor: pointer;
1110
- border-bottom: 2px solid transparent;
1111
- margin-bottom: -1px;
1112
- }
1113
-
1114
- .modal-tab:hover {
1115
- color: var(--text-primary);
1116
- }
1117
-
1118
- .modal-tab.active {
1119
- color: var(--accent);
1120
- border-bottom-color: var(--accent);
1121
- }
1122
-
1123
- .modal-tab:disabled {
1124
- opacity: 0.4;
1125
- cursor: not-allowed;
1126
- }
1127
-
1128
- .modal-tab-count {
1129
- background: var(--bg-primary);
1130
- padding: 1px 6px;
1131
- border-radius: 8px;
1132
- font-size: 10px;
1133
- margin-left: 6px;
1134
- }
1135
-
1136
- .modal-tab-content {
1137
- display: none;
1138
- }
1139
-
1140
- .modal-tab-content.active {
1141
- display: block;
1142
- }
1143
-
1144
- .shortcuts-modal-overlay {
1145
- position: fixed;
1146
- inset: 0;
1147
- background: rgba(0, 0, 0, 0.65);
1148
- display: none;
1149
- align-items: center;
1150
- justify-content: center;
1151
- z-index: 300;
1152
- }
1153
-
1154
- .shortcuts-modal-overlay.open {
1155
- display: flex;
1156
- }
1157
-
1158
- .shortcuts-modal {
1159
- width: min(460px, calc(100vw - 24px));
1160
- background: var(--bg-secondary);
1161
- border: 1px solid var(--border);
1162
- border-radius: 8px;
1163
- box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
1164
- overflow: hidden;
1165
- }
1166
-
1167
- .shortcuts-header {
1168
- display: flex;
1169
- align-items: center;
1170
- justify-content: space-between;
1171
- padding: 12px 16px;
1172
- border-bottom: 1px solid var(--border);
1173
- }
1174
-
1175
- .shortcuts-title {
1176
- font-size: 14px;
1177
- font-weight: 600;
1178
- color: var(--text-primary);
1179
- }
1180
-
1181
- .shortcuts-close {
1182
- border: none;
1183
- background: transparent;
1184
- color: var(--text-muted);
1185
- font-size: 18px;
1186
- cursor: pointer;
1187
- line-height: 1;
1188
- }
1189
-
1190
- .shortcuts-close:hover {
1191
- color: var(--text-primary);
1192
- }
1193
-
1194
- .shortcuts-body {
1195
- padding: 14px 16px 16px;
1196
- }
1197
-
1198
- .shortcuts-list {
1199
- display: grid;
1200
- grid-template-columns: auto 1fr;
1201
- gap: 8px 12px;
1202
- align-items: center;
1203
- }
1204
-
1205
- .shortcut-key {
1206
- display: inline-block;
1207
- min-width: 34px;
1208
- padding: 1px 8px;
1209
- border: 1px solid var(--border);
1210
- border-radius: 4px;
1211
- background: var(--bg-primary);
1212
- color: var(--text-primary);
1213
- font-size: 11px;
1214
- text-align: center;
1215
- font-weight: 600;
1216
- }
1217
-
1218
- .shortcut-desc {
1219
- color: var(--text-secondary);
1220
- font-size: 12px;
1221
- }
1222
-
1223
- .shortcuts-note {
1224
- margin-top: 12px;
1225
- font-size: 11px;
1226
- color: var(--text-muted);
1227
- }
1228
-
1229
- /* Activity Panel */
1230
- .activity-panel {
1231
- position: fixed;
1232
- top: 0;
1233
- right: -400px;
1234
- width: 400px;
1235
- height: 100vh;
1236
- background: var(--bg-secondary);
1237
- border-left: 1px solid var(--border);
1238
- z-index: 150;
1239
- transition: right 0.2s ease;
1240
- display: flex;
1241
- flex-direction: column;
1242
- }
1243
-
1244
- .activity-panel.open {
1245
- right: 0;
1246
- }
1247
-
1248
- .activity-header {
1249
- padding: 12px 16px;
1250
- border-bottom: 1px solid var(--border);
1251
- display: flex;
1252
- align-items: center;
1253
- justify-content: space-between;
1254
- }
1255
-
1256
- .activity-title {
1257
- font-weight: 600;
1258
- font-size: 14px;
1259
- }
1260
-
1261
- .activity-close {
1262
- background: none;
1263
- border: none;
1264
- color: var(--text-muted);
1265
- font-size: 18px;
1266
- cursor: pointer;
1267
- }
1268
-
1269
- .activity-filters {
1270
- display: flex;
1271
- flex-direction: column;
1272
- gap: 8px;
1273
- padding: 10px 16px;
1274
- border-bottom: 1px solid var(--border);
1275
- }
1276
-
1277
- .activity-filters select,
1278
- .activity-filters input {
1279
- width: 100%;
1280
- box-sizing: border-box;
1281
- }
1282
-
1283
- .activity-filters input {
1284
- font-family: var(--font-mono);
1285
- font-size: 12px;
1286
- background: var(--bg-primary);
1287
- color: var(--text-primary);
1288
- border: 1px solid var(--border);
1289
- padding: 4px 8px;
1290
- border-radius: 4px;
1291
- }
1292
-
1293
- .activity-filters input:focus {
1294
- outline: none;
1295
- border-color: var(--accent);
1296
- }
1297
-
1298
- .activity-list {
1299
- flex: 1;
1300
- overflow-y: auto;
1301
- padding: 8px;
1302
- }
1303
-
1304
- .activity-item {
1305
- padding: 10px;
1306
- border-bottom: 1px solid var(--border);
1307
- cursor: pointer;
1308
- }
1309
-
1310
- .activity-item:last-child {
1311
- border-bottom: none;
1312
- }
1313
-
1314
- .activity-item-header {
1315
- display: flex;
1316
- align-items: center;
1317
- justify-content: space-between;
1318
- margin-bottom: 4px;
1319
- }
1320
-
1321
- .activity-type {
1322
- font-size: 11px;
1323
- text-transform: uppercase;
1324
- letter-spacing: 0.5px;
1325
- padding: 2px 6px;
1326
- border-radius: 3px;
1327
- background: var(--bg-primary);
1328
- }
1329
-
1330
- .activity-type.status_changed { color: var(--status-in-progress); }
1331
- .activity-type.task_created { color: var(--status-ready); }
1332
- .activity-type.comment_added { color: var(--accent); }
1333
- .activity-type.checkpoint_recorded { color: var(--text-secondary); }
1334
-
1335
- .activity-time {
1336
- font-size: 10px;
1337
- color: var(--text-muted);
1338
- }
1339
-
1340
- .activity-task {
1341
- font-size: 12px;
1342
- color: var(--text-primary);
1343
- margin-top: 4px;
1344
- }
1345
-
1346
- .activity-detail {
1347
- font-size: 11px;
1348
- color: var(--text-muted);
1349
- margin-top: 2px;
1350
- }
1351
-
1352
- /* Graph Container */
1353
- .graph-container {
1354
- flex: 1;
1355
- position: relative;
1356
- min-height: 400px;
1357
- background: var(--bg-primary);
1358
- }
1359
-
1360
- .graph-container.hidden {
1361
- display: none;
1362
- }
1363
-
1364
- .graph-loading {
1365
- position: absolute;
1366
- inset: 0;
1367
- display: flex;
1368
- flex-direction: column;
1369
- align-items: center;
1370
- justify-content: center;
1371
- gap: 12px;
1372
- color: var(--text-secondary);
1373
- }
1374
-
1375
- .graph-loading .spinner {
1376
- width: 24px;
1377
- height: 24px;
1378
- border: 2px solid var(--border);
1379
- border-top-color: var(--accent);
1380
- border-radius: 50%;
1381
- animation: spin 1s linear infinite;
1382
- }
1383
-
1384
- @keyframes spin {
1385
- to { transform: rotate(360deg); }
1386
- }
1387
-
1388
- /* Mobile Styles */
1389
- @media (max-width: 768px) {
1390
- .header {
1391
- flex-wrap: wrap;
1392
- row-gap: 10px;
1393
- align-items: center;
1394
- align-content: flex-start;
1395
- }
1396
-
1397
- .header-left {
1398
- order: 1;
1399
- flex: 0 0 auto;
1400
- min-height: 42px;
1401
- }
1402
-
1403
- .header-right {
1404
- order: 2;
1405
- flex: 0 0 auto;
1406
- margin-left: auto;
1407
- min-height: 42px;
1408
- gap: 8px;
1409
- }
1410
-
1411
- .header-filters {
1412
- order: 3;
1413
- flex: 0 0 100%;
1414
- width: 100%;
1415
- max-width: 100%;
1416
- min-width: 100%;
1417
- display: none;
1418
- flex-direction: column;
1419
- align-items: stretch;
1420
- gap: 8px;
1421
- border-top: 1px solid var(--border);
1422
- padding-top: 8px;
1423
- }
1424
-
1425
- .header-filters.open {
1426
- display: flex;
1427
- }
1428
-
1429
- .filter-group {
1430
- width: 100%;
1431
- max-width: 100%;
1432
- }
1433
-
1434
- .task-search-group {
1435
- width: 100%;
1436
- }
1437
-
1438
- .task-search-input {
1439
- flex: 1;
1440
- width: 100%;
1441
- }
1442
-
1443
- #dateFilter,
1444
- #projectFilter,
1445
- #assigneeFilter {
1446
- min-width: 0;
1447
- width: 100%;
1448
- }
1449
-
1450
- .task-search-meta {
1451
- display: none;
1452
- }
1453
-
1454
- .board {
1455
- display: none;
1456
- }
1457
-
1458
- .graph-container {
1459
- min-height: calc(100vh - 150px);
1460
- }
1461
-
1462
- .mobile-tabs {
1463
- display: flex;
1464
- overflow-x: auto;
1465
- background: var(--bg-secondary);
1466
- border-bottom: 1px solid var(--border);
1467
- padding: 0 8px;
1468
- }
1469
-
1470
- .mobile-tab {
1471
- flex: 0 0 auto;
1472
- padding: 12px 16px;
1473
- font-size: 12px;
1474
- color: var(--text-muted);
1475
- border-bottom: 2px solid transparent;
1476
- cursor: pointer;
1477
- white-space: nowrap;
1478
- }
1479
-
1480
- .mobile-tab.active {
1481
- color: var(--accent);
1482
- border-bottom-color: var(--accent);
1483
- }
1484
-
1485
- .mobile-tab-badge {
1486
- background: var(--bg-primary);
1487
- padding: 1px 6px;
1488
- border-radius: 8px;
1489
- font-size: 10px;
1490
- margin-left: 6px;
1491
- }
1492
-
1493
- .mobile-cards {
1494
- display: none;
1495
- padding: 12px;
1496
- flex-direction: column;
1497
- gap: 8px;
1498
- }
1499
-
1500
- .mobile-cards.active {
1501
- display: flex;
1502
- }
1503
-
1504
- .card-assignee {
1505
- max-width: 110px;
1506
- }
1507
-
1508
- .activity-panel {
1509
- width: 100%;
1510
- right: -100%;
1511
- }
1512
- }
1513
-
1514
- @media (min-width: 769px) {
1515
- .mobile-tabs,
1516
- .mobile-cards {
1517
- display: none !important;
1518
- }
1519
- }
1520
-
1521
- /* Hamburger menu for mobile */
1522
- .hamburger {
1523
- display: none;
1524
- background: none;
1525
- border: none;
1526
- color: var(--text-primary);
1527
- font-size: 20px;
1528
- cursor: pointer;
1529
- }
1530
-
1531
- @media (max-width: 768px) {
1532
- .hamburger {
1533
- display: block;
1534
- }
1535
- }
1536
-
1537
- /* Calendar View */
1538
- .calendar-container {
1539
- padding: 16px 24px;
1540
- max-width: 1200px;
1541
- margin: 0 auto;
1542
- }
1543
- .calendar-container.hidden { display: none; }
1544
- .calendar-header {
1545
- display: flex;
1546
- align-items: center;
1547
- gap: 12px;
1548
- margin-bottom: 16px;
1549
- }
1550
- .calendar-nav-btn {
1551
- font-family: var(--font-mono);
1552
- font-size: 14px;
1553
- background: var(--bg-card);
1554
- color: var(--text-primary);
1555
- border: 1px solid var(--border);
1556
- border-radius: 6px;
1557
- padding: 4px 10px;
1558
- cursor: pointer;
1559
- }
1560
- .calendar-nav-btn:hover { background: var(--bg-hover); }
1561
- .calendar-month-label {
1562
- font-size: 18px;
1563
- font-weight: 600;
1564
- color: var(--text-primary);
1565
- min-width: 200px;
1566
- text-align: center;
1567
- }
1568
- .calendar-grid {
1569
- display: grid;
1570
- grid-template-columns: repeat(7, 1fr);
1571
- gap: 1px;
1572
- background: var(--border);
1573
- border: 1px solid var(--border);
1574
- border-radius: 8px;
1575
- overflow: hidden;
1576
- }
1577
- .calendar-day-header {
1578
- background: var(--bg-card);
1579
- color: var(--text-muted);
1580
- font-size: 12px;
1581
- font-weight: 600;
1582
- text-align: center;
1583
- padding: 8px 4px;
1584
- text-transform: uppercase;
1585
- }
1586
- .calendar-day {
1587
- background: var(--bg-card);
1588
- min-height: 100px;
1589
- padding: 4px;
1590
- display: flex;
1591
- flex-direction: column;
1592
- }
1593
- .calendar-day.other-month {
1594
- opacity: 0.35;
1595
- }
1596
- .calendar-day.today {
1597
- border: 2px solid var(--accent);
1598
- border-radius: 2px;
1599
- }
1600
- .calendar-day-number {
1601
- font-size: 12px;
1602
- color: var(--text-secondary);
1603
- margin-bottom: 4px;
1604
- font-weight: 500;
1605
- }
1606
- .calendar-day.today .calendar-day-number {
1607
- color: var(--accent);
1608
- font-weight: 700;
1609
- }
1610
- .calendar-day-tasks {
1611
- display: flex;
1612
- flex-direction: column;
1613
- gap: 2px;
1614
- flex: 1;
1615
- }
1616
- .calendar-mini-card { border-left-color: var(--text-muted); }
1617
- .calendar-mini-card[data-status="backlog"] { border-left-color: var(--status-backlog); }
1618
- .calendar-mini-card[data-status="ready"] { border-left-color: var(--status-ready); }
1619
- .calendar-mini-card[data-status="in_progress"] { border-left-color: var(--status-in-progress); }
1620
- .calendar-mini-card[data-status="blocked"] { border-left-color: var(--status-blocked); }
1621
- .calendar-mini-card[data-status="done"] { border-left-color: var(--status-done); }
1622
- .calendar-mini-card {
1623
- background: var(--bg-primary);
1624
- border-left: 3px solid;
1625
- border-radius: 3px;
1626
- padding: 2px 4px;
1627
- cursor: pointer;
1628
- display: flex;
1629
- align-items: center;
1630
- gap: 4px;
1631
- overflow: hidden;
1632
- }
1633
- .calendar-mini-card:hover { background: var(--bg-hover); }
1634
- .calendar-mini-title {
1635
- font-size: 11px;
1636
- color: var(--text-primary);
1637
- white-space: nowrap;
1638
- overflow: hidden;
1639
- text-overflow: ellipsis;
1640
- flex: 1;
1641
- }
1642
- .calendar-mini-project {
1643
- font-size: 9px;
1644
- color: var(--text-muted);
1645
- background: var(--bg-card);
1646
- padding: 0 4px;
1647
- border-radius: 3px;
1648
- white-space: nowrap;
1649
- flex-shrink: 0;
1650
- }
1651
- .calendar-more-link {
1652
- font-size: 11px;
1653
- color: var(--accent);
1654
- cursor: pointer;
1655
- padding: 1px 4px;
1656
- }
1657
- .calendar-more-link:hover { text-decoration: underline; }
1658
- .calendar-popover {
1659
- position: absolute;
1660
- z-index: 100;
1661
- background: var(--bg-card);
1662
- border: 1px solid var(--border);
1663
- border-radius: 8px;
1664
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
1665
- padding: 8px;
1666
- min-width: 200px;
1667
- max-width: 280px;
1668
- max-height: 300px;
1669
- overflow-y: auto;
1670
- }
1671
- .calendar-popover-header {
1672
- font-size: 12px;
1673
- font-weight: 600;
1674
- color: var(--text-secondary);
1675
- margin-bottom: 6px;
1676
- padding-bottom: 4px;
1677
- border-bottom: 1px solid var(--border);
1678
- }
1679
- .calendar-popover .calendar-mini-card {
1680
- margin-bottom: 2px;
1681
- }
1682
- .calendar-empty-state {
1683
- text-align: center;
1684
- color: var(--text-muted);
1685
- padding: 32px 16px;
1686
- font-size: 14px;
1687
- }
1688
- </style>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>HZL Dashboard</title>
7
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
8
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
9
+ <link rel="manifest" href="/site.webmanifest" />
10
+ <script type="module" crossorigin src="/assets/index-DhxVdKMf.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-CdpHwHFG.css">
1689
12
  </head>
1690
13
  <body>
1691
- <header class="header">
1692
- <div class="header-left">
1693
- <button class="hamburger" id="hamburgerBtn">&#9776;</button>
1694
- <span class="logo">HZL</span>
1695
- </div>
1696
- <div class="header-filters">
1697
- <div class="filter-group">
1698
- <select id="dateFilter">
1699
- <option value="1d">Today</option>
1700
- <option value="3d" selected>Last 3 days</option>
1701
- <option value="7d">Last 7 days</option>
1702
- <option value="14d">Last 14 days</option>
1703
- <option value="30d">Last 30 days</option>
1704
- </select>
1705
- </div>
1706
- <div class="filter-group">
1707
- <select id="projectFilter">
1708
- <option value="">All projects</option>
1709
- </select>
1710
- </div>
1711
- <div class="filter-group">
1712
- <select id="assigneeFilter">
1713
- <option value="">Any Agent</option>
1714
- </select>
1715
- </div>
1716
- <div class="filter-group task-search-group" id="taskSearchGroup">
1717
- <input
1718
- type="search"
1719
- id="taskSearchInput"
1720
- class="task-search-input"
1721
- placeholder="Find task (/)"
1722
- aria-label="Search tasks"
1723
- >
1724
- <button type="button" class="task-search-clear" id="taskSearchClear" hidden>&times;</button>
1725
- <span class="task-search-meta" id="taskSearchMeta"></span>
1726
- </div>
1727
- <div class="filter-group settings-group">
1728
- <button class="settings-toggle" id="settingsToggle" title="Settings">
1729
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1730
- <path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
1731
- <path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
1732
- </svg>
1733
- </button>
1734
- <div class="settings-dropdown" id="settingsDropdown">
1735
- <div class="settings-section">
1736
- <label class="settings-label" for="viewFilter">View</label>
1737
- <select id="viewFilter" class="settings-view-select" aria-label="Select view">
1738
- <option value="kanban">Kanban</option>
1739
- <option value="calendar">Calendar</option>
1740
- <option value="graph">Graph</option>
1741
- </select>
1742
- </div>
1743
- <div class="settings-section">
1744
- <label class="settings-label">Refresh</label>
1745
- <select id="refreshFilter">
1746
- <option value="1000">1s</option>
1747
- <option value="2000">2s</option>
1748
- <option value="5000" selected>5s</option>
1749
- <option value="10000">10s</option>
1750
- <option value="30000">30s</option>
1751
- </select>
1752
- </div>
1753
- <div class="settings-section">
1754
- <label class="settings-label">Columns</label>
1755
- <div class="column-checkboxes">
1756
- <label class="column-checkbox">
1757
- <input type="checkbox" value="backlog" checked> Backlog
1758
- </label>
1759
- <label class="column-checkbox">
1760
- <input type="checkbox" value="ready" checked> Ready
1761
- </label>
1762
- <label class="column-checkbox">
1763
- <input type="checkbox" value="in_progress" checked> In Progress
1764
- </label>
1765
- <label class="column-checkbox">
1766
- <input type="checkbox" value="blocked" checked> Blocked
1767
- </label>
1768
- <label class="column-checkbox">
1769
- <input type="checkbox" value="done" checked> Done
1770
- </label>
1771
- </div>
1772
- </div>
1773
- <div class="settings-section">
1774
- <label class="column-checkbox">
1775
- <input type="checkbox" id="showSubtasks" checked> Show subtasks
1776
- </label>
1777
- </div>
1778
- <div class="settings-section">
1779
- <label class="settings-label">Parent View</label>
1780
- <div class="collapse-parents-actions">
1781
- <button type="button" class="collapse-parents-btn" id="collapseAllParentsBtn">Collapse all</button>
1782
- <button type="button" class="collapse-parents-btn" id="expandAllParentsBtn">Expand all</button>
1783
- </div>
1784
- <div class="collapse-parents-meta" id="collapseParentsMeta"></div>
1785
- </div>
1786
- <div class="settings-section">
1787
- <button
1788
- type="button"
1789
- class="settings-shortcuts-btn"
1790
- id="shortcutsBtn"
1791
- title="Keyboard shortcuts (?)"
1792
- aria-label="Keyboard shortcuts"
1793
- >
1794
- Shortcuts (?)
1795
- </button>
1796
- </div>
1797
- </div>
1798
- </div>
1799
- </div>
1800
- <div class="header-right">
1801
- <div class="connection-indicator">
1802
- <div class="connection-dot" id="connectionDot"></div>
1803
- <span id="connectionText">Connecting...</span>
1804
- </div>
1805
- <button class="activity-btn" id="activityBtn">Activity</button>
1806
- </div>
1807
- </header>
1808
-
1809
- <!-- Mobile Tabs -->
1810
- <div class="mobile-tabs" id="mobileTabs">
1811
- <div class="mobile-tab" data-status="backlog">Backlog <span class="mobile-tab-badge" id="badge-backlog">0</span></div>
1812
- <div class="mobile-tab active" data-status="ready">Ready <span class="mobile-tab-badge" id="badge-ready">0</span></div>
1813
- <div class="mobile-tab" data-status="in_progress">In Progress <span class="mobile-tab-badge" id="badge-in_progress">0</span></div>
1814
- <div class="mobile-tab" data-status="blocked">Blocked <span class="mobile-tab-badge" id="badge-blocked">0</span></div>
1815
- <div class="mobile-tab" data-status="done">Done <span class="mobile-tab-badge" id="badge-done">0</span></div>
1816
- </div>
1817
-
1818
- <!-- Mobile Cards Container -->
1819
- <div id="mobileCardsContainer"></div>
1820
-
1821
- <!-- Desktop Kanban Board -->
1822
- <main class="board" id="board">
1823
- <div class="column" data-status="backlog">
1824
- <div class="column-header">
1825
- <span class="column-title">Backlog</span>
1826
- <span class="column-count" id="count-backlog">0</span>
1827
- </div>
1828
- <div class="column-cards" id="cards-backlog"></div>
1829
- </div>
1830
- <div class="column" data-status="ready">
1831
- <div class="column-header">
1832
- <span class="column-title">Ready</span>
1833
- <span class="column-count" id="count-ready">0</span>
1834
- </div>
1835
- <div class="column-cards" id="cards-ready"></div>
1836
- </div>
1837
- <div class="column" data-status="in_progress">
1838
- <div class="column-header">
1839
- <span class="column-title">In Progress</span>
1840
- <span class="column-count" id="count-in_progress">0</span>
1841
- </div>
1842
- <div class="column-cards" id="cards-in_progress"></div>
1843
- </div>
1844
- <div class="column" data-status="blocked">
1845
- <div class="column-header">
1846
- <span class="column-title">Blocked</span>
1847
- <span class="column-count" id="count-blocked">0</span>
1848
- </div>
1849
- <div class="column-cards" id="cards-blocked"></div>
1850
- </div>
1851
- <div class="column" data-status="done">
1852
- <div class="column-header">
1853
- <span class="column-title">Done</span>
1854
- <span class="column-count" id="count-done">0</span>
1855
- </div>
1856
- <div class="column-cards" id="cards-done"></div>
1857
- </div>
1858
- </main>
1859
-
1860
- <!-- Calendar View Container -->
1861
- <div id="calendarContainer" class="calendar-container hidden"></div>
1862
-
1863
- <!-- Graph View Container -->
1864
- <div id="graphContainer" class="graph-container hidden">
1865
- <div class="graph-loading" id="graphLoading">
1866
- <div class="spinner"></div>
1867
- <span>Loading graph...</span>
1868
- </div>
1869
- </div>
1870
-
1871
- <!-- Task Detail Modal -->
1872
- <div class="modal-overlay" id="modalOverlay">
1873
- <div class="modal">
1874
- <div class="modal-header">
1875
- <div class="modal-title-wrap">
1876
- <span class="modal-title" id="modalTitle">Task Details</span>
1877
- <div class="modal-task-id-row">
1878
- <span>Task ID</span>
1879
- <span class="modal-task-id-value" id="modalTaskIdValue">-</span>
1880
- <button type="button" class="modal-task-id-copy" id="modalTaskIdCopy" disabled>Copy</button>
1881
- </div>
1882
- </div>
1883
- <button class="modal-close" id="modalClose">&times;</button>
1884
- </div>
1885
- <div class="modal-body" id="modalBody">
1886
- <!-- Populated by JavaScript -->
1887
- </div>
1888
- </div>
1889
- </div>
1890
-
1891
- <!-- Activity Panel -->
1892
- <div class="activity-panel" id="activityPanel">
1893
- <div class="activity-header">
1894
- <span class="activity-title">Activity</span>
1895
- <button class="activity-close" id="activityClose">&times;</button>
1896
- </div>
1897
- <div class="activity-filters">
1898
- <select id="activityAssigneeFilter">
1899
- <option value="">Any Agent</option>
1900
- </select>
1901
- <input
1902
- type="search"
1903
- id="activityKeywordFilter"
1904
- placeholder="Search title/description (3+ chars)"
1905
- >
1906
- </div>
1907
- <div class="activity-list" id="activityList">
1908
- <!-- Populated by JavaScript -->
1909
- </div>
1910
- </div>
1911
-
1912
- <div class="shortcuts-modal-overlay" id="shortcutsModalOverlay">
1913
- <div class="shortcuts-modal">
1914
- <div class="shortcuts-header">
1915
- <span class="shortcuts-title">Keyboard Shortcuts</span>
1916
- <button type="button" class="shortcuts-close" id="shortcutsClose">&times;</button>
1917
- </div>
1918
- <div class="shortcuts-body">
1919
- <div class="shortcuts-list">
1920
- <span class="shortcut-key">/</span><span class="shortcut-desc">Focus task search</span>
1921
- <span class="shortcut-key">a</span><span class="shortcut-desc">Toggle activity panel</span>
1922
- <span class="shortcut-key">?</span><span class="shortcut-desc">Open this shortcuts dialog</span>
1923
- <span class="shortcut-key">Esc</span><span class="shortcut-desc">Close open dialogs/panels</span>
1924
- </div>
1925
- <div class="shortcuts-note">Shortcuts are disabled while typing in inputs.</div>
1926
- </div>
1927
- </div>
1928
- </div>
1929
-
1930
- <script>
1931
- // State
1932
- let tasks = [];
1933
- let events = [];
1934
- let lastEventId = 0;
1935
- const SSE_ENDPOINT = '/api/events/stream';
1936
- const SSE_MIN_RECONNECT_MS = 1000;
1937
- const SSE_MAX_RECONNECT_MS = 30000;
1938
- const SSE_HEARTBEAT_MARKERS = new Set(['ping', 'pong', 'heartbeat', 'keepalive', 'keep-alive']);
1939
- let eventSource = null;
1940
- let reconnectTimer = null;
1941
- let reconnectAttempt = 0;
1942
- let reconnectAt = null;
1943
- let streamState = 'connecting'; // connecting | live | reconnecting | paused
1944
- let windowHasFocus = typeof document.hasFocus === 'function' ? document.hasFocus() : true;
1945
- let pendingPoll = false; // Queue refreshes while a poll() is in flight
1946
- let lastPollTime = null;
1947
- let lastPollError = false;
1948
- let selectedTask = null;
1949
- let showAllComments = false;
1950
- let showAllCheckpoints = false;
1951
- let showAllTaskActivity = false;
1952
- let activeModalTab = 'comments';
1953
- const COMMENT_DISPLAY_LIMIT = 15;
1954
- const CHECKPOINT_DISPLAY_LIMIT = 15;
1955
- const TASK_ACTIVITY_DISPLAY_LIMIT = 20;
1956
- let activeTab = 'ready';
1957
- let activeView = 'kanban';
1958
- let taskSearchQuery = '';
1959
- let collapsedParents = new Set();
1960
- let graphInstance = null;
1961
- let graphInitialized = false;
1962
- let nodeStatusMap = new Map();
1963
- let pendingModalRequestId = 0; // Track modal fetch requests to avoid race conditions
1964
- let isPolling = false; // Guard against concurrent poll() calls
1965
- let copyFeedbackTimer = null;
1966
- let calendarYear = new Date().getFullYear();
1967
- let calendarMonth = new Date().getMonth(); // 0-indexed
1968
- let initialTaskIdFromUrl = null;
1969
- let initialActivityPanelOpen = false;
1970
-
1971
- // DOM Elements
1972
- const dateFilter = document.getElementById('dateFilter');
1973
- const taskSearchInput = document.getElementById('taskSearchInput');
1974
- const taskSearchClear = document.getElementById('taskSearchClear');
1975
- const taskSearchMeta = document.getElementById('taskSearchMeta');
1976
- const taskSearchGroup = document.getElementById('taskSearchGroup');
1977
- const projectFilter = document.getElementById('projectFilter');
1978
- const assigneeFilter = document.getElementById('assigneeFilter');
1979
- const refreshFilter = document.getElementById('refreshFilter');
1980
- const connectionDot = document.getElementById('connectionDot');
1981
- const connectionText = document.getElementById('connectionText');
1982
- const shortcutsBtn = document.getElementById('shortcutsBtn');
1983
- const shortcutsModalOverlay = document.getElementById('shortcutsModalOverlay');
1984
- const shortcutsClose = document.getElementById('shortcutsClose');
1985
- const activityBtn = document.getElementById('activityBtn');
1986
- const activityPanel = document.getElementById('activityPanel');
1987
- const activityClose = document.getElementById('activityClose');
1988
- const activityAssigneeFilter = document.getElementById('activityAssigneeFilter');
1989
- const activityKeywordFilter = document.getElementById('activityKeywordFilter');
1990
- const activityList = document.getElementById('activityList');
1991
- const modalOverlay = document.getElementById('modalOverlay');
1992
- const modalClose = document.getElementById('modalClose');
1993
- const modalTitle = document.getElementById('modalTitle');
1994
- const modalTaskIdValue = document.getElementById('modalTaskIdValue');
1995
- const modalTaskIdCopy = document.getElementById('modalTaskIdCopy');
1996
- const modalBody = document.getElementById('modalBody');
1997
- const hamburgerBtn = document.getElementById('hamburgerBtn');
1998
- const mobileTabs = document.getElementById('mobileTabs');
1999
- const settingsToggle = document.getElementById('settingsToggle');
2000
- const settingsDropdown = document.getElementById('settingsDropdown');
2001
- const viewFilter = document.getElementById('viewFilter');
2002
- const showSubtasksCheckbox = document.getElementById('showSubtasks');
2003
- const collapseAllParentsBtn = document.getElementById('collapseAllParentsBtn');
2004
- const expandAllParentsBtn = document.getElementById('expandAllParentsBtn');
2005
- const collapseParentsMeta = document.getElementById('collapseParentsMeta');
2006
- const board = document.getElementById('board');
2007
- const calendarContainer = document.getElementById('calendarContainer');
2008
- const graphContainer = document.getElementById('graphContainer');
2009
- const graphLoading = document.getElementById('graphLoading');
2010
- let pendingProjectPreference = null;
2011
- let pendingAssigneePreference = null;
2012
- let pendingActivityAssigneePreference = null;
2013
- const columnScrollTimers = new WeakMap();
2014
-
2015
- // Column visibility
2016
- const COLUMNS = ['backlog', 'ready', 'in_progress', 'blocked', 'done'];
2017
-
2018
- // Emoji family system for parent/child task indicators
2019
- const FAMILY_EMOJIS = [
2020
- '🔷', '🔶', '🔴', '🟢', '🔵', '🟡', '🟣', '🟠',
2021
- '⬛', '⬜', '🔳', '🔲', '▪️', '▫️', '◾', '◽',
2022
- '💠', '🔹', '🔸', '♦️', '♠️', '♣️', '♥️', '🃏',
2023
- '⭐', '🌟', '✨', '💫', '🔆', '🔅', '☀️', '🌙',
2024
- '🎯', '🎪', '🎨', '🎭', '🎬', '🎮', '🎲', '🎸',
2025
- '🔑', '🔐', '🔒', '🔓', '🗝️', '⚡', '💡', '🔔'
2026
- ];
2027
-
2028
- // djb2 hash for deterministic emoji assignment
2029
- function getTaskEmoji(taskId) {
2030
- let hash = 5381;
2031
- for (let i = 0; i < taskId.length; i++) {
2032
- hash = ((hash << 5) + hash) ^ taskId.charCodeAt(i);
2033
- }
2034
- return FAMILY_EMOJIS[Math.abs(hash) % FAMILY_EMOJIS.length];
2035
- }
2036
-
2037
- function getTaskFamilyColor(taskId) {
2038
- let hash = 5381;
2039
- for (let i = 0; i < taskId.length; i++) {
2040
- hash = ((hash << 5) + hash) ^ taskId.charCodeAt(i);
2041
- }
2042
- const hue = Math.abs(hash) % 360;
2043
- return `hsl(${hue} 55% 55%)`;
2044
- }
2045
-
2046
- // Build emoji map with suffix numbers for children
2047
- function buildEmojiMap(taskList) {
2048
- // Build set of known task IDs for visible parent detection
2049
- const taskIds = new Set(taskList.map(t => t.task_id));
2050
-
2051
- // Group visible children by parent for suffix ordering
2052
- // Only children whose parent is visible get suffix numbers
2053
- const childrenByParent = new Map();
2054
-
2055
- for (const task of taskList) {
2056
- if (task.parent_id && taskIds.has(task.parent_id)) {
2057
- if (!childrenByParent.has(task.parent_id)) {
2058
- childrenByParent.set(task.parent_id, []);
2059
- }
2060
- childrenByParent.get(task.parent_id).push(task);
2061
- }
2062
- }
2063
-
2064
- // Sort children by task_id for stable suffix ordering
2065
- for (const children of childrenByParent.values()) {
2066
- children.sort((a, b) => a.task_id.localeCompare(b.task_id));
2067
- }
2068
-
2069
- // Build emoji assignments
2070
- const emojiMap = new Map(); // task_id -> { emoji, suffix }
2071
-
2072
- for (const task of taskList) {
2073
- if (task.parent_id) {
2074
- // Child task: always use parent's emoji for family consistency
2075
- // (even if parent is filtered out)
2076
- const emoji = getTaskEmoji(task.parent_id);
2077
- // Only show suffix if parent is visible (siblings can be compared)
2078
- const siblings = childrenByParent.get(task.parent_id) || [];
2079
- const suffix = siblings.length > 0 ? siblings.indexOf(task) + 1 : null;
2080
- emojiMap.set(task.task_id, { emoji, suffix: suffix || null });
2081
- } else {
2082
- // Parent or standalone task: use own emoji, no suffix
2083
- emojiMap.set(task.task_id, { emoji: getTaskEmoji(task.task_id), suffix: null });
2084
- }
2085
- }
2086
-
2087
- return emojiMap;
2088
- }
2089
-
2090
- function updateColumnVisibility() {
2091
- COLUMNS.forEach(status => {
2092
- const checkbox = settingsDropdown.querySelector(`input[value="${status}"]`);
2093
- const column = document.querySelector(`.column[data-status="${status}"]`);
2094
- if (column && checkbox) {
2095
- column.classList.toggle('hidden', !checkbox.checked);
2096
- }
2097
- });
2098
- }
2099
-
2100
- function normalizeTaskSearchQuery(value) {
2101
- if (typeof value !== 'string') return '';
2102
- return value.trim().replace(/\s+/g, ' ').slice(0, 120);
2103
- }
2104
-
2105
- function getTaskSearchQuery() {
2106
- return taskSearchQuery.toLowerCase();
2107
- }
2108
-
2109
- function taskMatchesSearch(task, query) {
2110
- if (!query) return true;
2111
- const terms = query.split(' ');
2112
- const haystack = [
2113
- task.task_id,
2114
- task.title,
2115
- task.project,
2116
- getAssigneeValue(task.assignee),
2117
- task.description,
2118
- Array.isArray(task.tags) ? task.tags.join(' ') : '',
2119
- Array.isArray(task.blocked_by) ? task.blocked_by.join(' ') : '',
2120
- ]
2121
- .filter(Boolean)
2122
- .join(' ')
2123
- .toLowerCase();
2124
- return terms.every((term) => haystack.includes(term));
2125
- }
2126
-
2127
- function getParentTaskIds(taskList = tasks) {
2128
- return taskList
2129
- .filter((task) => (task.subtask_total ?? 0) > 0)
2130
- .map((task) => task.task_id);
2131
- }
2132
-
2133
- function pruneCollapsedParents(taskList = tasks) {
2134
- const validParents = new Set(getParentTaskIds(taskList));
2135
- let changed = false;
2136
- for (const parentId of Array.from(collapsedParents)) {
2137
- if (!validParents.has(parentId)) {
2138
- collapsedParents.delete(parentId);
2139
- changed = true;
2140
- }
2141
- }
2142
- return changed;
2143
- }
2144
-
2145
- function updateCollapseControls() {
2146
- const parentIds = getParentTaskIds(tasks);
2147
- const collapsedCount = parentIds.filter((parentId) => collapsedParents.has(parentId)).length;
2148
- const showSubtasks = showSubtasksCheckbox.checked;
2149
-
2150
- collapseAllParentsBtn.disabled = !showSubtasks || parentIds.length === 0 || collapsedCount === parentIds.length;
2151
- expandAllParentsBtn.disabled = !showSubtasks || collapsedCount === 0;
2152
-
2153
- if (parentIds.length === 0) {
2154
- collapseParentsMeta.textContent = 'No parent tasks';
2155
- return;
2156
- }
2157
-
2158
- if (!showSubtasks) {
2159
- collapseParentsMeta.textContent = 'Enable "Show subtasks" to expand by parent';
2160
- return;
2161
- }
2162
-
2163
- collapseParentsMeta.textContent = `${collapsedCount}/${parentIds.length} collapsed`;
2164
- }
2165
-
2166
- function toggleParentCollapsed(parentId) {
2167
- if (!parentId) return;
2168
- if (collapsedParents.has(parentId)) {
2169
- collapsedParents.delete(parentId);
2170
- } else {
2171
- collapsedParents.add(parentId);
2172
- }
2173
- savePreferences();
2174
- renderBoard();
2175
- renderActivity();
2176
- }
2177
-
2178
- function collapseAllParents() {
2179
- if (!showSubtasksCheckbox.checked) return;
2180
- for (const parentId of getParentTaskIds(tasks)) {
2181
- collapsedParents.add(parentId);
2182
- }
2183
- savePreferences();
2184
- renderBoard();
2185
- renderActivity();
2186
- }
2187
-
2188
- function expandAllParents() {
2189
- for (const parentId of getParentTaskIds(tasks)) {
2190
- collapsedParents.delete(parentId);
2191
- }
2192
- savePreferences();
2193
- renderBoard();
2194
- renderActivity();
2195
- }
2196
-
2197
- function parseYearMonth(value) {
2198
- const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(value);
2199
- if (!match) return null;
2200
- return {
2201
- year: Number(match[1]),
2202
- month: Number(match[2]) - 1,
2203
- };
2204
- }
2205
-
2206
- function getActiveTaskId() {
2207
- const taskId = selectedTask?.task?.task_id;
2208
- return typeof taskId === 'string' && taskId ? taskId : null;
2209
- }
2210
-
2211
- function setShortcutsModalOpen(open) {
2212
- shortcutsModalOverlay.classList.toggle('open', open);
2213
- }
2214
-
2215
- function setActivityPanelOpen(open, options = {}) {
2216
- const { persist = true } = options;
2217
- activityPanel.classList.toggle('open', open);
2218
- if (persist) {
2219
- syncUrlState();
2220
- }
2221
- }
2222
-
2223
- function setActiveTab(status, options = {}) {
2224
- const { persist = true } = options;
2225
- if (!COLUMNS.includes(status)) return;
2226
- activeTab = status;
2227
- document.querySelectorAll('.mobile-tab').forEach((tab) => {
2228
- tab.classList.toggle('active', tab.dataset.status === activeTab);
2229
- });
2230
- document.querySelectorAll('.mobile-cards').forEach((cards) => {
2231
- cards.classList.toggle('active', cards.dataset.status === activeTab);
2232
- });
2233
- if (persist) {
2234
- savePreferences();
2235
- }
2236
- }
2237
-
2238
- function buildUrlStateParams() {
2239
- const params = new URLSearchParams();
2240
-
2241
- if (activeView !== 'kanban') {
2242
- params.set('view', activeView);
2243
- }
2244
-
2245
- if (activeView === 'calendar') {
2246
- const month = String(calendarMonth + 1).padStart(2, '0');
2247
- params.set('month', `${calendarYear}-${month}`);
2248
- } else if (dateFilter.value !== '3d') {
2249
- params.set('since', dateFilter.value);
2250
- }
2251
-
2252
- if (projectFilter.value) params.set('project', projectFilter.value);
2253
- if (assigneeFilter.value) params.set('assignee', assigneeFilter.value);
2254
- if (taskSearchQuery) params.set('q', taskSearchQuery);
2255
- if (!showSubtasksCheckbox.checked) params.set('subtasks', '0');
2256
- if (activeTab !== 'ready') params.set('tab', activeTab);
2257
- if (activityAssigneeFilter.value) params.set('activity_assignee', activityAssigneeFilter.value);
2258
- if (activityKeywordFilter.value.trim()) params.set('activity_q', activityKeywordFilter.value.trim());
2259
- if (activityPanel.classList.contains('open')) params.set('activity', '1');
2260
-
2261
- const taskId = getActiveTaskId();
2262
- if (taskId) params.set('task', taskId);
2263
-
2264
- return params;
2265
- }
2266
-
2267
- function syncUrlState() {
2268
- const params = buildUrlStateParams();
2269
- const query = params.toString();
2270
- const nextUrl = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash || ''}`;
2271
- history.replaceState(null, '', nextUrl);
2272
- }
2273
-
2274
- function applyUrlStateOverrides() {
2275
- const params = new URLSearchParams(window.location.search);
2276
- let preferredView = null;
2277
-
2278
- const since = params.get('since');
2279
- const validDateValues = new Set(Array.from(dateFilter.options).map((option) => option.value));
2280
- if (since && validDateValues.has(since)) {
2281
- dateFilter.value = since;
2282
- }
2283
-
2284
- const view = params.get('view');
2285
- if (view && (view === 'kanban' || view === 'calendar' || view === 'graph')) {
2286
- preferredView = view;
2287
- }
2288
-
2289
- const monthParam = params.get('month');
2290
- if (monthParam) {
2291
- const parsedMonth = parseYearMonth(monthParam);
2292
- if (parsedMonth) {
2293
- calendarYear = parsedMonth.year;
2294
- calendarMonth = parsedMonth.month;
2295
- if (!preferredView) preferredView = 'calendar';
2296
- }
2297
- }
2298
-
2299
- const project = params.get('project');
2300
- if (project !== null) {
2301
- pendingProjectPreference = project;
2302
- }
2303
-
2304
- const assignee = params.get('assignee');
2305
- if (assignee !== null) {
2306
- pendingAssigneePreference = assignee;
2307
- }
2308
-
2309
- const activityAssignee = params.get('activity_assignee');
2310
- if (activityAssignee !== null) {
2311
- pendingActivityAssigneePreference = activityAssignee;
2312
- }
2313
-
2314
- const activityKeyword = params.get('activity_q');
2315
- if (activityKeyword !== null) {
2316
- activityKeywordFilter.value = activityKeyword;
2317
- }
2318
-
2319
- const searchQuery = params.get('q');
2320
- if (searchQuery !== null) {
2321
- taskSearchQuery = normalizeTaskSearchQuery(searchQuery);
2322
- taskSearchInput.value = taskSearchQuery;
2323
- }
2324
-
2325
- const subtasks = params.get('subtasks');
2326
- if (subtasks === '0') showSubtasksCheckbox.checked = false;
2327
- if (subtasks === '1') showSubtasksCheckbox.checked = true;
2328
-
2329
- const tab = params.get('tab');
2330
- if (tab && COLUMNS.includes(tab)) {
2331
- activeTab = tab;
2332
- }
2333
-
2334
- const taskId = params.get('task');
2335
- if (taskId && taskId.trim()) {
2336
- initialTaskIdFromUrl = taskId.trim();
2337
- }
2338
-
2339
- initialActivityPanelOpen = params.get('activity') === '1';
2340
-
2341
- return { preferredView };
2342
- }
2343
-
2344
- // Load saved preferences
2345
- function loadPreferences() {
2346
- let preferredView = null;
2347
- const saved = localStorage.getItem('hzl-dashboard-prefs');
2348
- if (saved) {
2349
- try {
2350
- const prefs = JSON.parse(saved);
2351
- if (prefs.dateFilter) dateFilter.value = prefs.dateFilter;
2352
- if (typeof prefs.projectFilter === 'string') pendingProjectPreference = prefs.projectFilter;
2353
- if (typeof prefs.assigneeFilter === 'string') pendingAssigneePreference = prefs.assigneeFilter;
2354
- if (typeof prefs.activityAssigneeFilter === 'string') pendingActivityAssigneePreference = prefs.activityAssigneeFilter;
2355
- if (typeof prefs.activityKeywordFilter === 'string') activityKeywordFilter.value = prefs.activityKeywordFilter;
2356
- if (typeof prefs.taskSearch === 'string') {
2357
- taskSearchQuery = normalizeTaskSearchQuery(prefs.taskSearch);
2358
- taskSearchInput.value = taskSearchQuery;
2359
- }
2360
- if (prefs.refreshFilter) refreshFilter.value = prefs.refreshFilter;
2361
- if (Array.isArray(prefs.columnVisibility)) {
2362
- settingsDropdown.querySelectorAll('.column-checkboxes input[type="checkbox"]').forEach(cb => {
2363
- cb.checked = prefs.columnVisibility.includes(cb.value);
2364
- });
2365
- updateColumnVisibility();
2366
- }
2367
- if (prefs.showSubtasks !== undefined) {
2368
- showSubtasksCheckbox.checked = prefs.showSubtasks;
2369
- }
2370
- if (Array.isArray(prefs.collapsedParents)) {
2371
- collapsedParents = new Set(
2372
- prefs.collapsedParents.filter((value) => typeof value === 'string' && value.length > 0)
2373
- );
2374
- }
2375
- if (typeof prefs.activeTab === 'string' && COLUMNS.includes(prefs.activeTab)) {
2376
- activeTab = prefs.activeTab;
2377
- }
2378
- if (prefs.activeView && prefs.activeView !== 'kanban') {
2379
- preferredView = prefs.activeView;
2380
- }
2381
- } catch {}
2382
- }
2383
-
2384
- const urlOverrides = applyUrlStateOverrides();
2385
- if (urlOverrides.preferredView) {
2386
- preferredView = urlOverrides.preferredView;
2387
- }
2388
-
2389
- if (initialActivityPanelOpen) {
2390
- setActivityPanelOpen(true, { persist: false });
2391
- }
2392
-
2393
- setActiveTab(activeTab, { persist: false });
2394
- updateTaskSearchUi();
2395
- updateCollapseControls();
2396
-
2397
- if (preferredView && preferredView !== 'kanban') {
2398
- // Defer view switch until after ForceGraph may have loaded
2399
- setTimeout(() => setActiveView(preferredView), 100);
2400
- }
2401
- }
2402
-
2403
- function savePreferences() {
2404
- const prefs = {
2405
- dateFilter: dateFilter.value,
2406
- projectFilter: projectFilter.value,
2407
- assigneeFilter: assigneeFilter.value,
2408
- activityAssigneeFilter: activityAssigneeFilter.value,
2409
- activityKeywordFilter: activityKeywordFilter.value,
2410
- taskSearch: taskSearchQuery,
2411
- refreshFilter: refreshFilter.value,
2412
- columnVisibility: Array.from(
2413
- settingsDropdown.querySelectorAll('.column-checkboxes input[type="checkbox"]:checked')
2414
- ).map(cb => cb.value),
2415
- showSubtasks: showSubtasksCheckbox.checked,
2416
- collapsedParents: Array.from(collapsedParents),
2417
- activeView: activeView,
2418
- activeTab: activeTab,
2419
- };
2420
- localStorage.setItem('hzl-dashboard-prefs', JSON.stringify(prefs));
2421
- syncUrlState();
2422
- }
2423
-
2424
- // Graph View Functions
2425
- function handleGraphLibError() {
2426
- console.warn('[hzl] force-graph CDN failed to load');
2427
- const graphOption = viewFilter.querySelector('option[value="graph"]');
2428
- if (graphOption) {
2429
- graphOption.disabled = true;
2430
- graphOption.textContent = 'Graph (unavailable)';
2431
- }
2432
- if (activeView === 'graph') {
2433
- setActiveView('kanban');
2434
- }
2435
- }
2436
-
2437
- function getStatusColor(status, type) {
2438
- if (type === 'root') return '#f59e0b';
2439
- if (type === 'project') return '#e5e5e5';
2440
- const colors = {
2441
- backlog: '#6b7280', // gray - not yet prioritized
2442
- ready: '#3b82f6', // blue - available to claim
2443
- in_progress: '#f59e0b', // orange - active work
2444
- blocked: '#ef4444', // red - stuck, needs help
2445
- done: '#22c55e', // green - completed
2446
- };
2447
- return colors[status] ?? '#6b7280';
2448
- }
2449
-
2450
- function getNodeSize(node) {
2451
- if (node.type === 'root') return 20;
2452
- if (node.type === 'project') return 14;
2453
- const progress = node.progress ?? 0;
2454
- return 8 + (progress / 100) * 16;
2455
- }
2456
-
2457
- function transformTasksToGraph(taskList) {
2458
- const nodes = [{ id: 'root', type: 'root', name: 'HZL', ring: 0, angle: 0 }];
2459
- const links = [];
2460
- const projectList = [];
2461
- const projectAngles = new Map();
2462
- nodeStatusMap.clear();
2463
-
2464
- // First pass: collect unique projects
2465
- for (const task of taskList) {
2466
- if (task.project && !projectAngles.has(task.project)) {
2467
- projectList.push(task.project);
2468
- projectAngles.set(task.project, 0);
2469
- }
2470
- }
2471
-
2472
- // Assign angles to projects (evenly distributed)
2473
- projectList.forEach((proj, i) => {
2474
- const angle = (2 * Math.PI * i) / projectList.length;
2475
- projectAngles.set(proj, angle);
2476
- });
2477
-
2478
- // Second pass: create nodes
2479
- const addedProjects = new Set();
2480
- for (const task of taskList) {
2481
- if (!task.project || !task.task_id) {
2482
- console.warn('[hzl] Skipping task with missing required fields:', task);
2483
- continue;
2484
- }
2485
-
2486
- const projAngle = projectAngles.get(task.project);
2487
-
2488
- // Add project node if not seen
2489
- if (!addedProjects.has(task.project)) {
2490
- addedProjects.add(task.project);
2491
- const projectId = `project:${task.project}`;
2492
- nodes.push({ id: projectId, type: 'project', name: task.project, ring: 1, angle: projAngle });
2493
- links.push({ source: projectId, target: 'root', type: 'hierarchy' });
2494
- nodeStatusMap.set(projectId, null);
2495
- }
2496
-
2497
- // Add task node with same angle as its project (with small random offset)
2498
- const ring = task.parent_id ? 3 : 2;
2499
- const angleOffset = (Math.random() - 0.5) * 0.3; // small spread within project
2500
- nodes.push({
2501
- id: task.task_id,
2502
- type: task.parent_id ? 'subtask' : 'task',
2503
- name: task.title,
2504
- status: task.status,
2505
- progress: task.progress ?? 0,
2506
- ring,
2507
- angle: projAngle + angleOffset,
2508
- project: task.project,
2509
- });
2510
- nodeStatusMap.set(task.task_id, task.status);
2511
-
2512
- // Hierarchy link
2513
- const parent = task.parent_id || `project:${task.project}`;
2514
- links.push({ source: task.task_id, target: parent, type: 'hierarchy' });
2515
-
2516
- // Dependency links (for particles)
2517
- if (task.blocked_by) {
2518
- for (const blockerId of task.blocked_by) {
2519
- links.push({ source: blockerId, target: task.task_id, type: 'dependency' });
2520
- }
2521
- }
2522
- }
2523
-
2524
- return { nodes, links };
2525
- }
2526
-
2527
- function initGraph() {
2528
- if (graphInitialized) return;
2529
-
2530
- if (typeof ForceGraph === 'undefined') {
2531
- console.error('[hzl] ForceGraph not available - CDN may have failed');
2532
- handleGraphLibError();
2533
- return;
2534
- }
2535
-
2536
- // Hide loading spinner
2537
- graphLoading.style.display = 'none';
2538
-
2539
- const RING_RADII = { 0: 0, 1: 180, 2: 360, 3: 540 };
2540
- const baseRadius = window.innerWidth < 768 ? 0.6 : 1;
2541
-
2542
- // Pre-position nodes based on angle and ring
2543
- const graphData = transformTasksToGraph(tasks);
2544
- for (const node of graphData.nodes) {
2545
- const radius = (RING_RADII[node.ring] ?? 300) * baseRadius;
2546
- node.x = Math.cos(node.angle || 0) * radius;
2547
- node.y = Math.sin(node.angle || 0) * radius;
2548
- }
2549
-
2550
- graphInstance = ForceGraph()(graphContainer)
2551
- .graphData(graphData)
2552
- .backgroundColor('#1a1a1a')
2553
- .nodeLabel(n => n.name)
2554
- .nodeColor(n => getStatusColor(n.status, n.type))
2555
- .nodeVal(n => getNodeSize(n))
2556
- .linkColor(l => l.type === 'dependency' ? '#e57373' : '#40404080')
2557
- .linkWidth(l => l.type === 'dependency' ? 2 : 1)
2558
- .linkLabel(l => l.type === 'dependency' ? 'blocks' : '')
2559
- .linkDirectionalArrowLength(l => l.type === 'dependency' ? 6 : 0)
2560
- .linkDirectionalArrowRelPos(1)
2561
- .onNodeClick(n => {
2562
- if (n.type !== 'root' && n.type !== 'project') {
2563
- openTaskModal(n.id);
2564
- }
2565
- })
2566
- // Forces to keep nodes in their angular sectors
2567
- .d3Force('x', d3.forceX(d => {
2568
- const radius = (RING_RADII[d.ring] ?? 300) * baseRadius;
2569
- return Math.cos(d.angle || 0) * radius;
2570
- }).strength(0.3))
2571
- .d3Force('y', d3.forceY(d => {
2572
- const radius = (RING_RADII[d.ring] ?? 300) * baseRadius;
2573
- return Math.sin(d.angle || 0) * radius;
2574
- }).strength(0.3))
2575
- .d3Force('collision', d3.forceCollide(d => getNodeSize(d) + 15))
2576
- .d3Force('charge', d3.forceManyBody().strength(-50))
2577
- .d3Force('link', d3.forceLink().strength(0.1))
2578
- // Animated particles on dependency edges
2579
- .linkDirectionalParticles(l => l.type === 'dependency' ? 3 : 0)
2580
- .linkDirectionalParticleWidth(3)
2581
- .linkDirectionalParticleSpeed(0.005)
2582
- .linkDirectionalParticleColor(() => '#e57373')
2583
- // Custom node rendering for root glow
2584
- .nodeCanvasObject((node, ctx, globalScale) => {
2585
- if (node.type === 'root') {
2586
- // Pulsing glow effect
2587
- const pulse = Math.sin(Date.now() / 500) * 0.3 + 0.7;
2588
- ctx.beginPath();
2589
- ctx.arc(node.x, node.y, 25 * pulse, 0, 2 * Math.PI);
2590
- ctx.fillStyle = `rgba(245, 158, 11, ${0.3 * pulse})`;
2591
- ctx.fill();
2592
- }
2593
-
2594
- // Draw node
2595
- const size = getNodeSize(node);
2596
- ctx.beginPath();
2597
- ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
2598
- ctx.fillStyle = getStatusColor(node.status, node.type);
2599
- ctx.fill();
2600
-
2601
- // Label for root node
2602
- if (node.type === 'root') {
2603
- ctx.font = `bold ${11 / globalScale}px ui-monospace`;
2604
- ctx.fillStyle = '#1a1a1a';
2605
- ctx.textAlign = 'center';
2606
- ctx.textBaseline = 'middle';
2607
- ctx.fillText('HZL', node.x, node.y + 1);
2608
- }
2609
-
2610
- // Label for projects
2611
- if (node.type === 'project' && globalScale > 0.5) {
2612
- ctx.font = `${10 / globalScale}px ui-monospace`;
2613
- ctx.fillStyle = '#e5e5e5';
2614
- ctx.textAlign = 'center';
2615
- ctx.fillText(node.name, node.x, node.y + size + 12);
2616
- }
2617
- })
2618
- .nodePointerAreaPaint((node, color, ctx) => {
2619
- const size = getNodeSize(node);
2620
- ctx.beginPath();
2621
- ctx.arc(node.x, node.y, size + 4, 0, 2 * Math.PI);
2622
- ctx.fillStyle = color;
2623
- ctx.fill();
2624
- });
2625
-
2626
- graphInitialized = true;
2627
-
2628
- // Zoom to fit after layout settles
2629
- setTimeout(() => {
2630
- if (graphInstance) {
2631
- graphInstance.zoomToFit(400, 50);
2632
- }
2633
- }, 500);
2634
- }
2635
-
2636
- let lastGraphDataHash = '';
2637
-
2638
- function hashTasks(taskList) {
2639
- // Simple hash based on task ids, statuses, and dependencies
2640
- return taskList.map(t => `${t.task_id}:${t.status}:${t.progress}`).sort().join('|');
2641
- }
2642
-
2643
- function updateGraphData() {
2644
- // Skip updates when graph isn't visible to save CPU
2645
- if (!graphInstance || !graphInitialized || activeView !== 'graph') {
2646
- return;
2647
- }
2648
-
2649
- const newHash = hashTasks(tasks);
2650
- if (newHash !== lastGraphDataHash) {
2651
- lastGraphDataHash = newHash;
2652
-
2653
- // Get current node positions
2654
- const currentData = graphInstance.graphData();
2655
- const positionMap = new Map();
2656
- for (const node of currentData.nodes) {
2657
- if (node.x !== undefined && node.y !== undefined) {
2658
- positionMap.set(node.id, { x: node.x, y: node.y, vx: node.vx, vy: node.vy });
2659
- }
2660
- }
2661
-
2662
- // Create new graph data and preserve positions
2663
- const newData = transformTasksToGraph(tasks);
2664
- for (const node of newData.nodes) {
2665
- const pos = positionMap.get(node.id);
2666
- if (pos) {
2667
- node.x = pos.x;
2668
- node.y = pos.y;
2669
- node.vx = pos.vx;
2670
- node.vy = pos.vy;
2671
- }
2672
- }
2673
-
2674
- graphInstance.graphData(newData);
2675
- }
2676
- }
2677
-
2678
- // Debounced resize handling
2679
- let resizeTimeout;
2680
- window.addEventListener('resize', () => {
2681
- clearTimeout(resizeTimeout);
2682
- resizeTimeout = setTimeout(() => {
2683
- if (graphInstance && activeView === 'graph') {
2684
- graphInstance.width(graphContainer.clientWidth);
2685
- graphInstance.height(graphContainer.clientHeight);
2686
- }
2687
- }, 100);
2688
- });
2689
-
2690
- // API calls
2691
- async function fetchTasks() {
2692
- const project = projectFilter.value;
2693
- let url;
2694
- if (activeView === 'calendar') {
2695
- const mm = String(calendarMonth + 1).padStart(2, '0');
2696
- url = `/api/tasks?due_month=${calendarYear}-${mm}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
2697
- } else {
2698
- const since = dateFilter.value;
2699
- url = `/api/tasks?since=${since}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
2700
- }
2701
- const res = await fetch(url);
2702
- if (!res.ok) throw new Error('Failed to fetch tasks');
2703
- const data = await res.json();
2704
- return data.tasks;
2705
- }
2706
-
2707
- async function fetchEvents() {
2708
- const res = await fetch(`/api/events?since=${lastEventId}`);
2709
- if (!res.ok) throw new Error('Failed to fetch events');
2710
- const data = await res.json();
2711
- return data.events;
2712
- }
2713
-
2714
- async function fetchTaskDetail(taskId) {
2715
- const [taskRes, commentsRes, checkpointsRes, eventsRes] = await Promise.all([
2716
- fetch(`/api/tasks/${taskId}`),
2717
- fetch(`/api/tasks/${taskId}/comments`),
2718
- fetch(`/api/tasks/${taskId}/checkpoints`),
2719
- fetch(`/api/tasks/${taskId}/events`),
2720
- ]);
2721
-
2722
- if (!taskRes.ok) throw new Error('Task not found');
2723
- if (!commentsRes.ok || !checkpointsRes.ok || !eventsRes.ok) {
2724
- throw new Error('Failed to load task activity');
2725
- }
2726
-
2727
- const taskData = await taskRes.json();
2728
- const commentsData = await commentsRes.json();
2729
- const checkpointsData = await checkpointsRes.json();
2730
- const eventsData = await eventsRes.json();
2731
-
2732
- return {
2733
- task: taskData.task,
2734
- comments: commentsData.comments,
2735
- checkpoints: checkpointsData.checkpoints,
2736
- taskEvents: eventsData.events,
2737
- };
2738
- }
2739
-
2740
- async function fetchStats() {
2741
- const res = await fetch('/api/stats');
2742
- if (!res.ok) throw new Error('Failed to fetch stats');
2743
- return await res.json();
2744
- }
2745
-
2746
- // Render functions
2747
- function renderCard(task, emojiInfo, showSubtasks) {
2748
- const isBlocked = task.blocked_by && task.blocked_by.length > 0;
2749
- const status = isBlocked ? 'blocked' : task.status;
2750
-
2751
- const isParentTask = (task.subtask_total ?? 0) > 0;
2752
- const parentStyle = isParentTask
2753
- ? `style="--family-color: ${getTaskFamilyColor(task.task_id)}"`
2754
- : '';
2755
-
2756
- // Build emoji indicator
2757
- let emojiHtml = '';
2758
- if (emojiInfo) {
2759
- const { emoji, suffix } = emojiInfo;
2760
- emojiHtml = suffix
2761
- ? `<span class="card-emoji">${emoji}-${suffix}</span>`
2762
- : `<span class="card-emoji">${emoji}</span>`;
2763
- }
2764
-
2765
- // Build subtask count (for parents regardless of subtasks visibility)
2766
- let subtaskHtml = '';
2767
- const visibleCount = task.subtask_count ?? 0;
2768
- const totalCount = task.subtask_total ?? visibleCount;
2769
- if (totalCount > 0) {
2770
- const label = totalCount === 1 ? 'subtask' : 'subtasks';
2771
- const countLabel = visibleCount === totalCount
2772
- ? `${visibleCount} ${label}`
2773
- : `${visibleCount}/${totalCount} ${label}`;
2774
- if (showSubtasks) {
2775
- const isCollapsed = collapsedParents.has(task.task_id);
2776
- const symbol = isCollapsed ? '&#9654;' : '&#9660;';
2777
- subtaskHtml = `
2778
- <button
2779
- type="button"
2780
- class="card-subtask-toggle"
2781
- data-action="toggle-subtasks"
2782
- data-parent-id="${escapeHtml(task.task_id)}"
2783
- aria-expanded="${isCollapsed ? 'false' : 'true'}"
2784
- title="${isCollapsed ? 'Expand subtasks' : 'Collapse subtasks'}"
2785
- >${symbol} [${countLabel}]</button>
2786
- `;
2787
- } else {
2788
- subtaskHtml = `<div class="card-subtask-count">[${countLabel}]</div>`;
2789
- }
2790
- }
2791
-
2792
- // Build progress badge
2793
- let progressHtml = '';
2794
- if (task.progress !== null && task.progress !== undefined && task.progress > 0) {
2795
- const progressClass = task.progress >= 100 ? 'card-progress complete' : 'card-progress';
2796
- progressHtml = `<span class="${progressClass}">${task.progress}%</span>`;
2797
- }
2798
-
2799
- const assignee = getAssigneeValue(task.assignee);
2800
- const hasAssignee = assignee.length > 0;
2801
- const assigneeText = hasAssignee ? assignee : 'Unassigned';
2802
- const assigneeCardText = truncateCardLabel(assigneeText, 10);
2803
- const assigneeClass = hasAssignee ? 'card-assignee assigned' : 'card-assignee unassigned';
2804
- const assigneeHtml = `<span class="${assigneeClass}" title="${escapeHtml(assigneeText)}">${escapeHtml(assigneeCardText)}</span>`;
2805
- const projectHtml = `<span class="card-project" title="${escapeHtml(task.project)}">${escapeHtml(task.project)}</span>`;
2806
-
2807
- let extra = '';
2808
- if (isBlocked) {
2809
- extra = `<div class="card-blocked">Blocked by: ${task.blocked_by.map(id => escapeHtml(id.slice(0, 8))).join(', ')}</div>`;
2810
- }
2811
- if (task.status === 'in_progress' && task.lease_until) {
2812
- const remaining = formatTimeRemaining(task.lease_until);
2813
- extra += `<div class="card-lease">${remaining}</div>`;
2814
- }
2815
-
2816
- return `
2817
- <div class="card${isParentTask ? ' card-parent' : ''}" data-task-id="${task.task_id}" ${parentStyle}>
2818
- <div class="card-header">
2819
- <div class="card-header-left">
2820
- ${emojiHtml}
2821
- <span class="card-id">${task.task_id.slice(0, 8)}</span>
2822
- </div>
2823
- <div class="card-header-right">
2824
- ${projectHtml}
2825
- ${progressHtml}
2826
- </div>
2827
- </div>
2828
- <div class="card-title">${escapeHtml(task.title)}</div>
2829
- ${subtaskHtml}
2830
- <div class="card-meta">
2831
- ${assigneeHtml}
2832
- </div>
2833
- ${extra}
2834
- </div>
2835
- `;
2836
- }
2837
-
2838
- // Group tasks into Kanban columns, treating ready tasks with unmet dependencies as blocked
2839
- function getBoardStatus(task) {
2840
- const isBlocked = task.blocked_by && task.blocked_by.length > 0;
2841
- return isBlocked && task.status === 'ready' ? 'blocked' : task.status;
2842
- }
2843
-
2844
- function getVisibleBoardStatuses() {
2845
- return new Set(
2846
- Array.from(
2847
- settingsDropdown.querySelectorAll('.column-checkboxes input[type="checkbox"]:checked')
2848
- ).map(cb => cb.value)
2849
- );
2850
- }
2851
-
2852
- function updateTaskSearchUi() {
2853
- const hasQuery = taskSearchQuery.length > 0;
2854
- taskSearchGroup.classList.toggle('active', hasQuery);
2855
- taskSearchClear.hidden = !hasQuery;
2856
-
2857
- if (!hasQuery) {
2858
- taskSearchMeta.textContent = '';
2859
- return;
2860
- }
2861
-
2862
- const totalCandidates = getFilteredBoardTasks(tasks, { applySearchFilter: false }).length;
2863
- const matchedCount = getFilteredBoardTasks(tasks).length;
2864
- const label = totalCandidates === 1 ? 'task' : 'tasks';
2865
- taskSearchMeta.textContent = `${matchedCount}/${totalCandidates} ${label}`;
2866
- }
2867
-
2868
- function applyTaskSearch(value, options = {}) {
2869
- const { persist = true } = options;
2870
- const normalized = normalizeTaskSearchQuery(value);
2871
- taskSearchInput.value = normalized;
2872
- if (normalized === taskSearchQuery) return;
2873
-
2874
- taskSearchQuery = normalized;
2875
- updateTaskSearchUi();
2876
- updateAssigneeOptions();
2877
- updateActivityAssigneeOptions();
2878
- renderBoard();
2879
- renderActivity();
2880
-
2881
- if (persist) {
2882
- savePreferences();
2883
- }
2884
- }
2885
-
2886
- function getFilteredBoardTasks(taskList = tasks, options = {}) {
2887
- const {
2888
- onlyVisibleColumns = false,
2889
- applyAssigneeFilter = true,
2890
- applySearchFilter = true,
2891
- applyCollapsedParents = true,
2892
- } = options;
2893
- const showSubtasks = showSubtasksCheckbox.checked;
2894
-
2895
- let filtered = showSubtasks ? taskList : taskList.filter(task => !task.parent_id);
2896
-
2897
- if (onlyVisibleColumns) {
2898
- const visibleStatuses = getVisibleBoardStatuses();
2899
- filtered = filtered.filter(task => visibleStatuses.has(getBoardStatus(task)));
2900
- }
2901
-
2902
- if (applyAssigneeFilter && assigneeFilter.value) {
2903
- filtered = filtered.filter(task => getAssigneeValue(task.assignee) === assigneeFilter.value);
2904
- }
2905
-
2906
- if (applySearchFilter) {
2907
- const query = getTaskSearchQuery();
2908
- if (query) {
2909
- filtered = filtered.filter((task) => taskMatchesSearch(task, query));
2910
- }
2911
- }
2912
-
2913
- if (showSubtasks && applyCollapsedParents) {
2914
- // Search mode should always show matching subtasks, regardless of collapsed parents.
2915
- const query = getTaskSearchQuery();
2916
- if (!query) {
2917
- const visibleTaskIds = new Set(filtered.map((task) => task.task_id));
2918
- filtered = filtered.filter((task) => {
2919
- if (!task.parent_id) return true;
2920
- if (!visibleTaskIds.has(task.parent_id)) return true;
2921
- return !collapsedParents.has(task.parent_id);
2922
- });
2923
- }
2924
- }
2925
-
2926
- return filtered;
2927
- }
2928
-
2929
- function updateAssigneeOptions(preferredAssignee = null) {
2930
- const previousSelection = assigneeFilter.value;
2931
- const targetSelection = preferredAssignee ?? previousSelection;
2932
- const optionTasks = getFilteredBoardTasks(tasks, {
2933
- onlyVisibleColumns: true,
2934
- applyAssigneeFilter: false,
2935
- });
2936
-
2937
- const assigneeCounts = new Map();
2938
- for (const task of optionTasks) {
2939
- const assignee = getAssigneeValue(task.assignee);
2940
- if (!assignee) continue;
2941
- assigneeCounts.set(assignee, (assigneeCounts.get(assignee) ?? 0) + 1);
2942
- }
2943
-
2944
- const sortedAssignees = Array.from(assigneeCounts.entries())
2945
- .sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
2946
-
2947
- assigneeFilter.innerHTML = '';
2948
- const anyOption = document.createElement('option');
2949
- anyOption.value = '';
2950
- anyOption.textContent = 'Any Agent';
2951
- assigneeFilter.appendChild(anyOption);
2952
-
2953
- for (const [assignee, count] of sortedAssignees) {
2954
- const option = document.createElement('option');
2955
- option.value = assignee;
2956
- option.textContent = `${assignee} (${count})`;
2957
- assigneeFilter.appendChild(option);
2958
- }
2959
-
2960
- const nextSelection = targetSelection && assigneeCounts.has(targetSelection) ? targetSelection : '';
2961
- assigneeFilter.value = nextSelection;
2962
-
2963
- return {
2964
- changed: previousSelection !== nextSelection,
2965
- reset: targetSelection !== nextSelection,
2966
- };
2967
- }
2968
-
2969
- function getActivityKeywordFilter() {
2970
- const keyword = activityKeywordFilter.value.trim().toLowerCase();
2971
- return keyword.length >= 3 ? keyword : '';
2972
- }
2973
-
2974
- function eventMatchesActivityKeyword(event, keyword) {
2975
- if (!keyword) return true;
2976
- const title = typeof event.task_title === 'string' ? event.task_title.toLowerCase() : '';
2977
- const description = typeof event.task_description === 'string' ? event.task_description.toLowerCase() : '';
2978
- return title.includes(keyword) || description.includes(keyword);
2979
- }
2980
-
2981
- function getEventAssignee(event) {
2982
- const taskAssignee = getAssigneeValue(event.task_assignee);
2983
- if (taskAssignee) return taskAssignee;
2984
- return getAssigneeValue(event.data?.assignee);
2985
- }
2986
-
2987
- function getFilteredActivityEvents(options = {}) {
2988
- const { applyAssigneeFilter = true } = options;
2989
- const assignee = activityAssigneeFilter.value;
2990
- const keyword = getActivityKeywordFilter();
2991
- const visibleBoardTaskIds = new Set(
2992
- getFilteredBoardTasks(tasks, {
2993
- onlyVisibleColumns: true,
2994
- applyAssigneeFilter: false,
2995
- }).map((task) => task.task_id)
2996
- );
2997
-
2998
- return events.filter((event) => {
2999
- if (!visibleBoardTaskIds.has(event.task_id)) return false;
3000
- if (keyword && !eventMatchesActivityKeyword(event, keyword)) return false;
3001
- if (applyAssigneeFilter && assignee && getEventAssignee(event) !== assignee) return false;
3002
- return true;
3003
- });
3004
- }
3005
-
3006
- function updateActivityAssigneeOptions(preferredAssignee = null) {
3007
- const previousSelection = activityAssigneeFilter.value;
3008
- const targetSelection = preferredAssignee ?? previousSelection;
3009
- const optionEvents = getFilteredActivityEvents({ applyAssigneeFilter: false });
3010
- const assigneeTaskSets = new Map();
3011
-
3012
- for (const event of optionEvents) {
3013
- const assignee = getEventAssignee(event);
3014
- if (!assignee) continue;
3015
- if (!assigneeTaskSets.has(assignee)) {
3016
- assigneeTaskSets.set(assignee, new Set());
3017
- }
3018
- assigneeTaskSets.get(assignee).add(event.task_id);
3019
- }
3020
-
3021
- const sortedAssignees = Array.from(assigneeTaskSets.entries())
3022
- .sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
3023
-
3024
- activityAssigneeFilter.innerHTML = '';
3025
- const anyOption = document.createElement('option');
3026
- anyOption.value = '';
3027
- anyOption.textContent = 'Any Agent';
3028
- activityAssigneeFilter.appendChild(anyOption);
3029
-
3030
- for (const [assignee, taskIds] of sortedAssignees) {
3031
- const option = document.createElement('option');
3032
- option.value = assignee;
3033
- option.textContent = `${assignee} (${taskIds.size})`;
3034
- activityAssigneeFilter.appendChild(option);
3035
- }
3036
-
3037
- const nextSelection = targetSelection && assigneeTaskSets.has(targetSelection) ? targetSelection : '';
3038
- activityAssigneeFilter.value = nextSelection;
3039
-
3040
- return {
3041
- changed: previousSelection !== nextSelection,
3042
- reset: targetSelection !== nextSelection,
3043
- };
3044
- }
3045
-
3046
- function groupTasksByStatus(taskList) {
3047
- const columns = {
3048
- backlog: [],
3049
- blocked: [],
3050
- ready: [],
3051
- in_progress: [],
3052
- done: [],
3053
- };
3054
- for (const task of taskList) {
3055
- const status = getBoardStatus(task);
3056
- if (columns[status]) {
3057
- columns[status].push(task);
3058
- }
3059
- }
3060
- return columns;
3061
- }
3062
-
3063
- // Calendar state shared between renderCalendar and popover
3064
- let calendarTasksByDay = {};
3065
-
3066
- function renderMiniCard(task) {
3067
- return `<div class="calendar-mini-card" data-task-id="${task.task_id}" data-status="${task.status}">
3068
- <span class="calendar-mini-title">${escapeHtml(task.title)}</span>
3069
- <span class="calendar-mini-project">${escapeHtml(task.project)}</span>
3070
- </div>`;
3071
- }
3072
-
3073
- function renderCalendar() {
3074
- const now = new Date();
3075
- const isCurrentMonth = calendarYear === now.getFullYear() && calendarMonth === now.getMonth();
3076
- const todayDate = now.getDate();
3077
- const MAX_CARDS = 3;
3078
-
3079
- // Filter to current month in local timezone and group by day in single pass
3080
- // (Server already filters by project via ?project= param, no client-side filter needed)
3081
- calendarTasksByDay = {};
3082
- let monthTaskCount = 0;
3083
- for (const t of tasks) {
3084
- if (!t.due_at) continue;
3085
- const d = new Date(t.due_at);
3086
- if (d.getFullYear() !== calendarYear || d.getMonth() !== calendarMonth) continue;
3087
- const day = d.getDate();
3088
- if (!calendarTasksByDay[day]) calendarTasksByDay[day] = [];
3089
- calendarTasksByDay[day].push(t);
3090
- monthTaskCount++;
3091
- }
3092
-
3093
- // Month label
3094
- const monthLabel = new Date(calendarYear, calendarMonth, 1)
3095
- .toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
3096
-
3097
- // First day of month (0=Sun) and number of days
3098
- const firstDow = new Date(calendarYear, calendarMonth, 1).getDay();
3099
- const daysInMonth = new Date(calendarYear, calendarMonth + 1, 0).getDate();
3100
- const daysInPrevMonth = new Date(calendarYear, calendarMonth, 0).getDate();
3101
-
3102
- // Build header
3103
- let html = `
3104
- <div class="calendar-header">
3105
- <button class="calendar-nav-btn" id="calPrev">&larr;</button>
3106
- <div class="calendar-month-label">${escapeHtml(monthLabel)}</div>
3107
- <button class="calendar-nav-btn" id="calNext">&rarr;</button>
3108
- <button class="calendar-nav-btn" id="calToday">Today</button>
3109
- </div>
3110
- `;
3111
-
3112
- // Empty state message (grid still renders for visual context)
3113
- if (monthTaskCount === 0) {
3114
- html += `<div class="calendar-empty-state">No tasks with due dates in ${escapeHtml(monthLabel)}</div>`;
3115
- }
3116
-
3117
- html += `<div class="calendar-grid">`;
3118
-
3119
- // Day-of-week headers
3120
- const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
3121
- for (const d of dayNames) {
3122
- html += `<div class="calendar-day-header">${d}</div>`;
3123
- }
3124
-
3125
- // Leading days from previous month
3126
- for (let i = firstDow - 1; i >= 0; i--) {
3127
- const day = daysInPrevMonth - i;
3128
- html += `<div class="calendar-day other-month"><span class="calendar-day-number">${day}</span></div>`;
3129
- }
3130
-
3131
- // Current month days
3132
- for (let d = 1; d <= daysInMonth; d++) {
3133
- const isToday = isCurrentMonth && d === todayDate;
3134
- const dateStr = `${calendarYear}-${String(calendarMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
3135
- html += `<div class="calendar-day${isToday ? ' today' : ''}" data-date="${dateStr}">`;
3136
- html += `<span class="calendar-day-number">${d}</span>`;
3137
- html += `<div class="calendar-day-tasks">`;
3138
-
3139
- const dayTasks = calendarTasksByDay[d] || [];
3140
- const visible = dayTasks.slice(0, MAX_CARDS);
3141
- for (const t of visible) {
3142
- html += renderMiniCard(t);
3143
- }
3144
- if (dayTasks.length > MAX_CARDS) {
3145
- html += `<div class="calendar-more-link" data-date="${dateStr}">+${dayTasks.length - MAX_CARDS} more</div>`;
3146
- }
3147
-
3148
- html += `</div></div>`;
3149
- }
3150
-
3151
- // Trailing days to fill last week
3152
- const totalCells = firstDow + daysInMonth;
3153
- const trailingDays = (7 - (totalCells % 7)) % 7;
3154
- for (let d = 1; d <= trailingDays; d++) {
3155
- html += `<div class="calendar-day other-month"><span class="calendar-day-number">${d}</span></div>`;
3156
- }
3157
-
3158
- html += `</div>`;
3159
- calendarContainer.innerHTML = html;
3160
-
3161
- // Wire navigation (named functions to avoid re-adding anonymous listeners)
3162
- document.getElementById('calPrev').addEventListener('click', navPrev);
3163
- document.getElementById('calNext').addEventListener('click', navNext);
3164
- document.getElementById('calToday').addEventListener('click', navToday);
3165
- }
3166
-
3167
- function navPrev() {
3168
- calendarMonth--;
3169
- if (calendarMonth < 0) { calendarMonth = 11; calendarYear--; }
3170
- syncUrlState();
3171
- requestPoll();
3172
- }
3173
- function navNext() {
3174
- calendarMonth++;
3175
- if (calendarMonth > 11) { calendarMonth = 0; calendarYear++; }
3176
- syncUrlState();
3177
- requestPoll();
3178
- }
3179
- function navToday() {
3180
- const today = new Date();
3181
- calendarYear = today.getFullYear();
3182
- calendarMonth = today.getMonth();
3183
- syncUrlState();
3184
- requestPoll();
3185
- }
3186
-
3187
- // Calendar popover for "+N more" links
3188
- let activePopover = null;
3189
- let popoverOutsideListener = null;
3190
-
3191
- function dismissPopover() {
3192
- if (popoverOutsideListener) {
3193
- document.removeEventListener('click', popoverOutsideListener);
3194
- popoverOutsideListener = null;
3195
- }
3196
- if (activePopover) {
3197
- activePopover.remove();
3198
- activePopover = null;
3199
- }
3200
- }
3201
-
3202
- function showCalendarPopover(dateStr, anchorEl) {
3203
- dismissPopover();
3204
-
3205
- // Reuse grouped data from renderCalendar instead of re-filtering
3206
- const [y, m, d] = dateStr.split('-').map(Number);
3207
- const dayTasks = calendarTasksByDay[d] || [];
3208
-
3209
- if (dayTasks.length === 0) return;
3210
-
3211
- // Format date for header
3212
- const headerDate = new Date(y, m - 1, d)
3213
- .toLocaleDateString(undefined, { month: 'long', day: 'numeric' });
3214
-
3215
- const popover = document.createElement('div');
3216
- popover.className = 'calendar-popover';
3217
- popover.innerHTML = `<div class="calendar-popover-header">${escapeHtml(headerDate)}</div>`
3218
- + dayTasks.map(t => renderMiniCard(t)).join('');
3219
-
3220
- // Position below the anchor, clamped to viewport edges
3221
- const rect = anchorEl.getBoundingClientRect();
3222
- const popoverWidth = 280; // matches CSS max-width
3223
- const popoverMaxHeight = 300; // matches CSS max-height
3224
- const left = Math.min(rect.left, window.innerWidth - popoverWidth - 8);
3225
- // If popover would overflow bottom, position above the anchor instead
3226
- const spaceBelow = window.innerHeight - rect.bottom - 8;
3227
- const top = spaceBelow >= popoverMaxHeight
3228
- ? rect.bottom + 4
3229
- : Math.max(8, rect.top - popoverMaxHeight - 4);
3230
- popover.style.position = 'fixed';
3231
- popover.style.left = left + 'px';
3232
- popover.style.top = top + 'px';
3233
-
3234
- document.body.appendChild(popover);
3235
- activePopover = popover;
3236
-
3237
- // Dismiss on click-outside (next tick to avoid immediate dismiss)
3238
- setTimeout(() => {
3239
- popoverOutsideListener = function onClickOutside(e) {
3240
- if (activePopover && !activePopover.contains(e.target)) {
3241
- dismissPopover();
3242
- }
3243
- };
3244
- document.addEventListener('click', popoverOutsideListener);
3245
- }, 0);
3246
- }
3247
-
3248
- // Event delegation for calendar interactions
3249
- calendarContainer.addEventListener('click', (e) => {
3250
- // "+N more" link
3251
- const moreLink = e.target.closest('.calendar-more-link');
3252
- if (moreLink) {
3253
- e.stopPropagation();
3254
- showCalendarPopover(moreLink.dataset.date, moreLink);
3255
- return;
3256
- }
3257
-
3258
- // Mini card click → open task modal
3259
- const card = e.target.closest('.calendar-mini-card');
3260
- if (card) {
3261
- dismissPopover();
3262
- openTaskModal(card.dataset.taskId);
3263
- return;
3264
- }
3265
- });
3266
-
3267
- // Also handle clicks inside popover (which is appended to body, not calendarContainer)
3268
- document.addEventListener('click', (e) => {
3269
- if (!activePopover) return;
3270
- const card = e.target.closest('.calendar-popover .calendar-mini-card');
3271
- if (card) {
3272
- dismissPopover();
3273
- openTaskModal(card.dataset.taskId);
3274
- }
3275
- });
3276
-
3277
- function renderBoard() {
3278
- const showSubtasks = showSubtasksCheckbox.checked;
3279
- const emojiMap = buildEmojiMap(tasks);
3280
- const visibleTasks = getFilteredBoardTasks(tasks);
3281
- const emptyMessage = taskSearchQuery ? 'No matching tasks' : 'No tasks';
3282
-
3283
- const columns = groupTasksByStatus(visibleTasks);
3284
-
3285
- for (const [status, statusTasks] of Object.entries(columns)) {
3286
- const container = document.getElementById(`cards-${status}`);
3287
- const countEl = document.getElementById(`count-${status}`);
3288
- const badgeEl = document.getElementById(`badge-${status}`);
3289
-
3290
- if (container) {
3291
- if (statusTasks.length === 0) {
3292
- container.innerHTML = `<div class="empty-column">${emptyMessage}</div>`;
3293
- } else {
3294
- container.innerHTML = statusTasks.map(task => {
3295
- const emojiInfo = emojiMap.get(task.task_id);
3296
- return renderCard(task, emojiInfo, showSubtasks);
3297
- }).join('');
3298
- }
3299
- }
3300
- if (countEl) countEl.textContent = statusTasks.length;
3301
- if (badgeEl) badgeEl.textContent = statusTasks.length;
3302
- }
3303
-
3304
- // Render mobile cards for active tab
3305
- renderMobileCards(showSubtasks, emojiMap);
3306
- updateTaskSearchUi();
3307
- updateCollapseControls();
3308
-
3309
- // Card click handlers are set up via event delegation in init
3310
- }
3311
-
3312
- function bindColumnScrollIndicators() {
3313
- document.querySelectorAll('.column-cards').forEach((column) => {
3314
- column.addEventListener('scroll', () => {
3315
- column.classList.add('is-scrolling');
3316
- const existingTimer = columnScrollTimers.get(column);
3317
- if (existingTimer) {
3318
- clearTimeout(existingTimer);
3319
- }
3320
- const timerId = setTimeout(() => {
3321
- column.classList.remove('is-scrolling');
3322
- columnScrollTimers.delete(column);
3323
- }, 700);
3324
- columnScrollTimers.set(column, timerId);
3325
- }, { passive: true });
3326
- });
3327
- }
3328
-
3329
- function renderMobileCards(showSubtasks, emojiMap) {
3330
- const container = document.getElementById('mobileCardsContainer');
3331
- if (!container) return;
3332
-
3333
- const visibleTasks = getFilteredBoardTasks(tasks);
3334
- const emptyMessage = taskSearchQuery ? 'No matching tasks' : 'No tasks';
3335
-
3336
- const columns = groupTasksByStatus(visibleTasks);
3337
-
3338
- container.innerHTML = Object.entries(columns).map(([status, statusTasks]) => `
3339
- <div class="mobile-cards ${status === activeTab ? 'active' : ''}" data-status="${status}">
3340
- ${statusTasks.length === 0
3341
- ? `<div class="empty-column">${emptyMessage}</div>`
3342
- : statusTasks.map(task => {
3343
- const emojiInfo = emojiMap.get(task.task_id);
3344
- return renderCard(task, emojiInfo, showSubtasks);
3345
- }).join('')
3346
- }
3347
- </div>
3348
- `).join('');
3349
-
3350
- // Card click handlers are set up via event delegation in init
3351
- }
3352
-
3353
- function renderActivity() {
3354
- const filteredEvents = getFilteredActivityEvents();
3355
-
3356
- if (filteredEvents.length === 0) {
3357
- activityList.innerHTML = '<div class="empty-column">No recent activity</div>';
3358
- return;
3359
- }
3360
-
3361
- activityList.innerHTML = filteredEvents.map(event => {
3362
- const actor = getEventActor(event);
3363
- const actionDetail = formatEventDetail(event);
3364
- const detail = actionDetail ? `${actionDetail} by ${actor}` : `by ${actor}`;
3365
-
3366
- return `
3367
- <div class="activity-item" data-task-id="${escapeHtml(event.task_id || '')}">
3368
- <div class="activity-item-header">
3369
- <span class="activity-type ${event.type}">${formatEventType(event.type)}</span>
3370
- <span class="activity-time">${formatTime(event.timestamp)}</span>
3371
- </div>
3372
- <div class="activity-task">${escapeHtml(event.task_title || event.task_id.slice(0, 8))}</div>
3373
- ${detail ? `<div class="activity-detail">${escapeHtml(detail)}</div>` : ''}
3374
- </div>
3375
- `;
3376
- }).join('');
3377
- }
3378
-
3379
- async function openTaskModal(taskId, preserveShowAll = false) {
3380
- // Track this request to handle rapid clicks (race condition prevention)
3381
- const requestId = ++pendingModalRequestId;
3382
-
3383
- // Reset expansion state for new tasks
3384
- if (!preserveShowAll) {
3385
- showAllComments = false;
3386
- showAllCheckpoints = false;
3387
- showAllTaskActivity = false;
3388
- activeModalTab = 'comments';
3389
- }
3390
-
3391
- try {
3392
- const data = await fetchTaskDetail(taskId);
3393
-
3394
- // Discard stale response if a newer request was made
3395
- if (requestId !== pendingModalRequestId) return;
3396
-
3397
- selectedTask = data;
3398
- modalTitle.textContent = data.task.title;
3399
- const resolvedTaskId = data.task.task_id || taskId;
3400
- modalTaskIdValue.textContent = resolvedTaskId || '-';
3401
- modalTaskIdCopy.dataset.taskId = resolvedTaskId || '';
3402
- modalTaskIdCopy.disabled = !resolvedTaskId;
3403
- setTaskIdCopyFeedback('idle');
3404
-
3405
- const progressValue = data.task.progress ?? 0;
3406
- const progressClass = progressValue >= 100 ? 'modal-progress complete' : 'modal-progress';
3407
- const assignee = getAssigneeValue(data.task.assignee);
3408
- const hasAssignee = assignee.length > 0;
3409
- const assigneeValue = hasAssignee
3410
- ? escapeHtml(assignee)
3411
- : '<span class="modal-meta-fallback">Unassigned</span>';
3412
-
3413
- let html = `
3414
- <div class="modal-section">
3415
- <div class="modal-meta">
3416
- <div class="modal-meta-item">
3417
- <div class="modal-meta-label">Status</div>
3418
- <div class="modal-meta-value">${data.task.status}</div>
3419
- </div>
3420
- <div class="modal-meta-item">
3421
- <div class="modal-meta-label">Progress</div>
3422
- <div class="modal-meta-value"><span class="${progressClass}">${progressValue}%</span></div>
3423
- </div>
3424
- <div class="modal-meta-item">
3425
- <div class="modal-meta-label">Project</div>
3426
- <div class="modal-meta-value">${escapeHtml(data.task.project)}</div>
3427
- </div>
3428
- <div class="modal-meta-item">
3429
- <div class="modal-meta-label">Assignee</div>
3430
- <div class="modal-meta-value">${assigneeValue}</div>
3431
- </div>
3432
- <div class="modal-meta-item">
3433
- <div class="modal-meta-label">Priority</div>
3434
- <div class="modal-meta-value">${data.task.priority}</div>
3435
- </div>
3436
- <div class="modal-meta-item">
3437
- <div class="modal-meta-label">Created</div>
3438
- <div class="modal-meta-value">${formatTime(data.task.created_at)}</div>
3439
- </div>
3440
- ${data.task.lease_until ? `
3441
- <div class="modal-meta-item">
3442
- <div class="modal-meta-label">Lease Until</div>
3443
- <div class="modal-meta-value">${formatTime(data.task.lease_until)}</div>
3444
- </div>
3445
- ` : ''}
3446
- ${data.task.due_at ? `
3447
- <div class="modal-meta-item">
3448
- <div class="modal-meta-label">Due Date</div>
3449
- <div class="modal-meta-value">${new Date(data.task.due_at).toLocaleDateString()}</div>
3450
- </div>
3451
- ` : ''}
3452
- </div>
3453
- </div>
3454
- `;
3455
-
3456
- if (data.task.blocked_by && data.task.blocked_by.length > 0) {
3457
- html += `
3458
- <div class="modal-section">
3459
- <div class="modal-section-title">Blocked By</div>
3460
- <div class="modal-description">${data.task.blocked_by.join(', ')}</div>
3461
- </div>
3462
- `;
3463
- }
3464
-
3465
- if (data.task.description) {
3466
- html += `
3467
- <div class="modal-section">
3468
- <div class="modal-section-title">Description</div>
3469
- <div class="modal-description">${renderMarkdown(data.task.description)}</div>
3470
- </div>
3471
- `;
3472
- }
3473
-
3474
- // Tabbed interface for comments, checkpoints, and per-task activity
3475
- if (data.comments.length > 0 || data.checkpoints.length > 0 || data.taskEvents.length > 0) {
3476
- const tabAvailability = {
3477
- comments: data.comments.length > 0,
3478
- checkpoints: data.checkpoints.length > 0,
3479
- activity: data.taskEvents.length > 0,
3480
- };
3481
- if (!tabAvailability[activeModalTab]) {
3482
- activeModalTab = ['comments', 'checkpoints', 'activity'].find(t => tabAvailability[t]) || 'activity';
3483
- }
3484
-
3485
- const hasMoreComments = data.comments.length > COMMENT_DISPLAY_LIMIT && !showAllComments;
3486
- const visibleComments = hasMoreComments
3487
- ? data.comments.slice(-COMMENT_DISPLAY_LIMIT)
3488
- : data.comments;
3489
- const hiddenCommentCount = Math.max(0, data.comments.length - COMMENT_DISPLAY_LIMIT);
3490
-
3491
- const hasMoreCheckpoints = data.checkpoints.length > CHECKPOINT_DISPLAY_LIMIT && !showAllCheckpoints;
3492
- const visibleCheckpoints = hasMoreCheckpoints
3493
- ? data.checkpoints.slice(-CHECKPOINT_DISPLAY_LIMIT)
3494
- : data.checkpoints;
3495
- const hiddenCheckpointCount = Math.max(0, data.checkpoints.length - CHECKPOINT_DISPLAY_LIMIT);
3496
-
3497
- const hasMoreTaskActivity = data.taskEvents.length > TASK_ACTIVITY_DISPLAY_LIMIT && !showAllTaskActivity;
3498
- const visibleTaskActivity = hasMoreTaskActivity
3499
- ? data.taskEvents.slice(-TASK_ACTIVITY_DISPLAY_LIMIT)
3500
- : data.taskEvents;
3501
- const displayTaskActivity = [...visibleTaskActivity].reverse();
3502
- const hiddenTaskActivityCount = Math.max(0, data.taskEvents.length - TASK_ACTIVITY_DISPLAY_LIMIT);
3503
-
3504
- html += `
3505
- <div class="modal-section">
3506
- <div class="modal-tabs">
3507
- <button class="modal-tab ${activeModalTab === 'comments' ? 'active' : ''}" data-tab="comments" ${data.comments.length === 0 ? 'disabled' : ''}>
3508
- Comments<span class="modal-tab-count">${data.comments.length}</span>
3509
- </button>
3510
- <button class="modal-tab ${activeModalTab === 'checkpoints' ? 'active' : ''}" data-tab="checkpoints" ${data.checkpoints.length === 0 ? 'disabled' : ''}>
3511
- Checkpoints<span class="modal-tab-count">${data.checkpoints.length}</span>
3512
- </button>
3513
- <button class="modal-tab ${activeModalTab === 'activity' ? 'active' : ''}" data-tab="activity" ${data.taskEvents.length === 0 ? 'disabled' : ''}>
3514
- Activity<span class="modal-tab-count">${data.taskEvents.length}</span>
3515
- </button>
3516
- </div>
3517
-
3518
- <div class="modal-tab-content ${activeModalTab === 'comments' ? 'active' : ''}" data-tab-content="comments">
3519
- ${data.comments.length === 0 ? '<div class="empty-column">No comments</div>' : `
3520
- <div class="modal-comments">
3521
- ${hasMoreComments ? `
3522
- <button class="show-more-btn" id="showMoreComments">
3523
- Show ${hiddenCommentCount} earlier comment${hiddenCommentCount === 1 ? '' : 's'}
3524
- </button>
3525
- ` : ''}
3526
- ${visibleComments.map(c => `
3527
- <div class="comment">
3528
- <div class="comment-header">
3529
- <span class="comment-author">${escapeHtml(c.agent_id || c.author || 'Unknown')}</span>
3530
- <span>${formatTime(c.timestamp)}</span>
3531
- </div>
3532
- <div class="comment-text">${escapeHtml(c.text)}</div>
3533
- </div>
3534
- `).join('')}
3535
- </div>
3536
- `}
3537
- </div>
3538
-
3539
- <div class="modal-tab-content ${activeModalTab === 'checkpoints' ? 'active' : ''}" data-tab-content="checkpoints">
3540
- ${data.checkpoints.length === 0 ? '<div class="empty-column">No checkpoints</div>' : `
3541
- <div class="modal-checkpoint-list">
3542
- ${hasMoreCheckpoints ? `
3543
- <button class="show-more-btn" id="showMoreCheckpoints">
3544
- Show ${hiddenCheckpointCount} earlier checkpoint${hiddenCheckpointCount === 1 ? '' : 's'}
3545
- </button>
3546
- ` : ''}
3547
- ${visibleCheckpoints.map(cp => {
3548
- const hasData = cp.data && Object.keys(cp.data).length > 0;
3549
- return `
3550
- <div class="modal-checkpoint-entry">
3551
- <div class="modal-checkpoint-header">
3552
- <span class="modal-checkpoint-name">${escapeHtml(cp.name)}</span>
3553
- <span class="modal-entry-time">${formatTime(cp.timestamp)}</span>
3554
- </div>
3555
- ${hasData ? `<pre class="modal-checkpoint-data">${escapeHtml(JSON.stringify(cp.data, null, 2))}</pre>` : ''}
3556
- </div>
3557
- `;
3558
- }).join('')}
3559
- </div>
3560
- `}
3561
- </div>
3562
-
3563
- <div class="modal-tab-content ${activeModalTab === 'activity' ? 'active' : ''}" data-tab-content="activity">
3564
- ${data.taskEvents.length === 0 ? '<div class="empty-column">No activity</div>' : `
3565
- <div class="modal-task-activity-list">
3566
- ${hasMoreTaskActivity ? `
3567
- <button class="show-more-btn" id="showMoreTaskActivity">
3568
- Show ${hiddenTaskActivityCount} earlier event${hiddenTaskActivityCount === 1 ? '' : 's'}
3569
- </button>
3570
- ` : ''}
3571
- ${displayTaskActivity.map(event => {
3572
- const actor = getEventActor(event);
3573
- const hasActor = actor !== 'system';
3574
- const detail = formatEventDetail(event);
3575
- return `
3576
- <div class="modal-task-activity-entry">
3577
- <div class="modal-task-activity-header">
3578
- <span class="modal-task-activity-type">${escapeHtml(formatEventType(event.type))}</span>
3579
- <span class="modal-entry-time">${formatTime(event.timestamp)}</span>
3580
- </div>
3581
- ${hasActor ? `<div class="modal-task-activity-author">By ${escapeHtml(actor)}</div>` : ''}
3582
- ${detail ? `<div class="modal-task-activity-detail">${escapeHtml(detail)}</div>` : ''}
3583
- </div>
3584
- `;
3585
- }).join('')}
3586
- </div>
3587
- `}
3588
- </div>
3589
- </div>
3590
- `;
3591
- }
3592
-
3593
- modalBody.innerHTML = html;
3594
- modalOverlay.classList.add('open');
3595
- syncUrlState();
3596
-
3597
- // Attach tab switching handlers (DOM-only, no re-fetch)
3598
- modalBody.querySelectorAll('.modal-tab').forEach(tab => {
3599
- tab.addEventListener('click', () => {
3600
- if (tab.hasAttribute('disabled')) return;
3601
- const targetTab = tab.dataset.tab;
3602
-
3603
- // Update tab active states
3604
- modalBody.querySelectorAll('.modal-tab').forEach(t =>
3605
- t.classList.toggle('active', t.dataset.tab === targetTab));
3606
-
3607
- // Update content visibility
3608
- modalBody.querySelectorAll('.modal-tab-content').forEach(c =>
3609
- c.classList.toggle('active', c.dataset.tabContent === targetTab));
3610
-
3611
- // Update global state for re-opens
3612
- activeModalTab = targetTab;
3613
- });
3614
- });
3615
-
3616
- // Attach "show more comments" handler if present
3617
- const showMoreCommentsBtn = document.getElementById('showMoreComments');
3618
- if (showMoreCommentsBtn) {
3619
- showMoreCommentsBtn.addEventListener('click', () => {
3620
- showAllComments = true;
3621
- openTaskModal(taskId, true);
3622
- });
3623
- }
3624
-
3625
- // Attach "show more checkpoints" handler if present
3626
- const showMoreCheckpointsBtn = document.getElementById('showMoreCheckpoints');
3627
- if (showMoreCheckpointsBtn) {
3628
- showMoreCheckpointsBtn.addEventListener('click', () => {
3629
- showAllCheckpoints = true;
3630
- openTaskModal(taskId, true);
3631
- });
3632
- }
3633
-
3634
- const showMoreTaskActivityBtn = document.getElementById('showMoreTaskActivity');
3635
- if (showMoreTaskActivityBtn) {
3636
- showMoreTaskActivityBtn.addEventListener('click', () => {
3637
- showAllTaskActivity = true;
3638
- openTaskModal(taskId, true);
3639
- });
3640
- }
3641
- } catch (error) {
3642
- console.error('Failed to load task:', error);
3643
- alert('Failed to load task details');
3644
- }
3645
- }
3646
-
3647
- function closeModal() {
3648
- const wasOpen = modalOverlay.classList.contains('open');
3649
- modalOverlay.classList.remove('open');
3650
- selectedTask = null;
3651
- modalTaskIdValue.textContent = '-';
3652
- modalTaskIdCopy.dataset.taskId = '';
3653
- modalTaskIdCopy.disabled = true;
3654
- setTaskIdCopyFeedback('idle');
3655
- if (wasOpen) {
3656
- syncUrlState();
3657
- }
3658
- }
3659
-
3660
- // Live updates + refresh
3661
- function getConfiguredRefreshMs() {
3662
- const interval = parseInt(refreshFilter.value, 10);
3663
- return Number.isFinite(interval) ? Math.max(SSE_MIN_RECONNECT_MS, interval) : 5000;
3664
- }
3665
-
3666
- function shouldRunLiveUpdates() {
3667
- return !document.hidden && windowHasFocus;
3668
- }
3669
-
3670
- function requestPoll() {
3671
- if (isPolling) {
3672
- pendingPoll = true;
3673
- return;
3674
- }
3675
- void poll();
3676
- }
3677
-
3678
- async function poll() {
3679
- if (isPolling) {
3680
- pendingPoll = true;
3681
- return;
3682
- }
3683
- isPolling = true;
3684
-
3685
- do {
3686
- pendingPoll = false;
3687
- try {
3688
- const [newTasks, newEvents, stats] = await Promise.all([
3689
- fetchTasks(),
3690
- fetchEvents(),
3691
- fetchStats(),
3692
- ]);
3693
-
3694
- tasks = newTasks;
3695
- const collapsedParentsPruned = pruneCollapsedParents(tasks);
3696
- const selectionUpdate = updateAssigneeOptions(pendingAssigneePreference);
3697
- pendingAssigneePreference = null;
3698
- const activitySelectionUpdate = updateActivityAssigneeOptions(pendingActivityAssigneePreference);
3699
- pendingActivityAssigneePreference = null;
3700
- if (selectionUpdate.reset || activitySelectionUpdate.reset || collapsedParentsPruned) {
3701
- savePreferences();
3702
- }
3703
-
3704
- if (newEvents.length > 0) {
3705
- events = [...newEvents, ...events].slice(0, 50);
3706
- lastEventId = newEvents[0].id;
3707
- }
3708
-
3709
- // Update project filter
3710
- const currentProject = pendingProjectPreference ?? projectFilter.value;
3711
- projectFilter.innerHTML = '<option value="">All projects</option>' +
3712
- stats.projects.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
3713
- const hasProject = currentProject && stats.projects.includes(currentProject);
3714
- projectFilter.value = hasProject ? currentProject : '';
3715
- pendingProjectPreference = null;
3716
-
3717
- if (activeView === 'calendar') {
3718
- renderCalendar();
3719
- } else {
3720
- renderBoard();
3721
- updateGraphData();
3722
- }
3723
- renderActivity();
3724
- updateTaskSearchUi();
3725
- syncUrlState();
3726
-
3727
- if (initialTaskIdFromUrl) {
3728
- const taskToOpen = initialTaskIdFromUrl;
3729
- initialTaskIdFromUrl = null;
3730
- void openTaskModal(taskToOpen);
3731
- }
3732
-
3733
- lastPollTime = Date.now();
3734
- lastPollError = false;
3735
- } catch (error) {
3736
- console.error('Poll failed:', error);
3737
- lastPollError = true;
3738
- } finally {
3739
- updateConnectionStatus();
3740
- }
3741
- } while (pendingPoll);
3742
-
3743
- isPolling = false;
3744
- }
3745
-
3746
- function getSseUrl() {
3747
- if (lastEventId <= 0) return SSE_ENDPOINT;
3748
- const params = new URLSearchParams({ since: String(lastEventId) });
3749
- return `${SSE_ENDPOINT}?${params.toString()}`;
3750
- }
3751
-
3752
- function clearReconnectTimer() {
3753
- if (reconnectTimer) {
3754
- clearTimeout(reconnectTimer);
3755
- reconnectTimer = null;
3756
- }
3757
- reconnectAt = null;
3758
- }
3759
-
3760
- function disconnectEventStream() {
3761
- if (!eventSource) return;
3762
- eventSource.onopen = null;
3763
- eventSource.onmessage = null;
3764
- eventSource.onerror = null;
3765
- eventSource.close();
3766
- eventSource = null;
3767
- }
3768
-
3769
- function scheduleReconnect() {
3770
- if (!shouldRunLiveUpdates()) {
3771
- pauseLiveUpdates();
3772
- return;
3773
- }
3774
-
3775
- clearReconnectTimer();
3776
- const base = Math.max(SSE_MIN_RECONNECT_MS, Math.min(getConfiguredRefreshMs(), 10000));
3777
- const expDelay = Math.min(SSE_MAX_RECONNECT_MS, base * (2 ** reconnectAttempt));
3778
- const jitter = Math.floor(Math.random() * Math.min(1000, Math.round(expDelay * 0.2)));
3779
- const delay = Math.min(SSE_MAX_RECONNECT_MS, expDelay + jitter);
3780
- reconnectAttempt = Math.min(reconnectAttempt + 1, 12);
3781
- reconnectAt = Date.now() + delay;
3782
- streamState = 'reconnecting';
3783
- updateConnectionStatus();
3784
-
3785
- reconnectTimer = setTimeout(() => {
3786
- reconnectTimer = null;
3787
- reconnectAt = null;
3788
- connectEventStream();
3789
- }, delay);
3790
- }
3791
-
3792
- function parseSsePayload(rawData) {
3793
- if (typeof rawData !== 'string') return rawData;
3794
- const trimmed = rawData.trim();
3795
- if (!trimmed) return null;
3796
-
3797
- try {
3798
- return JSON.parse(trimmed);
3799
- } catch {
3800
- return trimmed;
3801
- }
3802
- }
3803
-
3804
- function shouldRefreshFromSseEvent(streamEvent) {
3805
- const type = String(streamEvent?.type || '').toLowerCase();
3806
- if (type === 'updates_available' || type === 'update' || type === 'updates') {
3807
- return true;
3808
- }
3809
- if (SSE_HEARTBEAT_MARKERS.has(type)) {
3810
- return false;
3811
- }
3812
-
3813
- const payload = parseSsePayload(streamEvent?.data);
3814
- if (payload == null) return false;
3815
-
3816
- if (typeof payload === 'boolean') return payload;
3817
- if (typeof payload === 'number') return payload > 0;
3818
-
3819
- if (typeof payload === 'string') {
3820
- const normalized = payload.toLowerCase();
3821
- if (SSE_HEARTBEAT_MARKERS.has(normalized)) return false;
3822
- if (normalized === 'updates_available' || normalized === 'update' || normalized === 'updates') {
3823
- return true;
3824
- }
3825
- return true;
3826
- }
3827
-
3828
- if (Array.isArray(payload)) return payload.length > 0;
3829
-
3830
- if (typeof payload === 'object') {
3831
- const kind = typeof payload.type === 'string' ? payload.type.toLowerCase() : '';
3832
- if (kind && SSE_HEARTBEAT_MARKERS.has(kind)) return false;
3833
- if (kind === 'updates_available' || kind === 'update' || kind === 'updates') return true;
3834
- if (Object.keys(payload).length === 0) return false;
3835
- if (
3836
- payload.updates_available === true ||
3837
- payload.updatesAvailable === true ||
3838
- payload.has_updates === true ||
3839
- payload.hasUpdates === true ||
3840
- payload.changed === true
3841
- ) {
3842
- return true;
3843
- }
3844
- if (Array.isArray(payload.events)) return payload.events.length > 0;
3845
- if (typeof payload.event_count === 'number') return payload.event_count > 0;
3846
- if (typeof payload.eventCount === 'number') return payload.eventCount > 0;
3847
- }
3848
-
3849
- return true;
3850
- }
3851
-
3852
- function handleSseSignal(streamEvent) {
3853
- const streamEventId = parseInt(streamEvent?.lastEventId || '', 10);
3854
- if (Number.isFinite(streamEventId) && streamEventId > lastEventId) {
3855
- lastEventId = streamEventId;
3856
- }
3857
-
3858
- if (shouldRefreshFromSseEvent(streamEvent)) {
3859
- requestPoll();
3860
- }
3861
- }
3862
-
3863
- function connectEventStream() {
3864
- if (!shouldRunLiveUpdates()) {
3865
- pauseLiveUpdates();
3866
- return;
3867
- }
3868
-
3869
- if (typeof EventSource === 'undefined') {
3870
- streamState = 'reconnecting';
3871
- reconnectAt = Date.now() + getConfiguredRefreshMs();
3872
- updateConnectionStatus();
3873
- scheduleReconnect();
3874
- return;
3875
- }
3876
-
3877
- clearReconnectTimer();
3878
- disconnectEventStream();
3879
- streamState = 'connecting';
3880
- updateConnectionStatus();
3881
-
3882
- const source = new EventSource(getSseUrl());
3883
- eventSource = source;
3884
-
3885
- const onSignal = (streamEvent) => {
3886
- if (eventSource !== source) return;
3887
- handleSseSignal(streamEvent);
3888
- };
3889
-
3890
- source.onopen = () => {
3891
- if (eventSource !== source) return;
3892
- reconnectAttempt = 0;
3893
- reconnectAt = null;
3894
- streamState = 'live';
3895
- updateConnectionStatus();
3896
- requestPoll();
3897
- };
3898
-
3899
- source.onmessage = onSignal;
3900
- source.addEventListener('updates_available', onSignal);
3901
- source.addEventListener('update', onSignal);
3902
- source.addEventListener('updates', onSignal);
3903
-
3904
- source.onerror = () => {
3905
- if (eventSource !== source) return;
3906
- disconnectEventStream();
3907
- scheduleReconnect();
3908
- };
3909
- }
3910
-
3911
- function pauseLiveUpdates() {
3912
- clearReconnectTimer();
3913
- disconnectEventStream();
3914
- reconnectAt = null;
3915
- streamState = 'paused';
3916
- updateConnectionStatus();
3917
- }
3918
-
3919
- function resumeLiveUpdates() {
3920
- if (!shouldRunLiveUpdates()) {
3921
- pauseLiveUpdates();
3922
- return;
3923
- }
3924
- reconnectAttempt = 0;
3925
- reconnectAt = null;
3926
- connectEventStream();
3927
- requestPoll();
3928
- }
3929
-
3930
- function setTaskIdCopyFeedback(state) {
3931
- if (copyFeedbackTimer) {
3932
- clearTimeout(copyFeedbackTimer);
3933
- copyFeedbackTimer = null;
3934
- }
3935
-
3936
- modalTaskIdCopy.classList.remove('copied', 'failed');
3937
- modalTaskIdCopy.textContent = 'Copy';
3938
-
3939
- if (state === 'copied') {
3940
- modalTaskIdCopy.classList.add('copied');
3941
- modalTaskIdCopy.textContent = 'Copied';
3942
- } else if (state === 'failed') {
3943
- modalTaskIdCopy.classList.add('failed');
3944
- modalTaskIdCopy.textContent = 'Copy failed';
3945
- } else {
3946
- return;
3947
- }
3948
-
3949
- copyFeedbackTimer = setTimeout(() => {
3950
- modalTaskIdCopy.classList.remove('copied', 'failed');
3951
- modalTaskIdCopy.textContent = 'Copy';
3952
- copyFeedbackTimer = null;
3953
- }, 1500);
3954
- }
3955
-
3956
- async function copyTextToClipboard(text) {
3957
- if (!text) return false;
3958
-
3959
- if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
3960
- try {
3961
- await navigator.clipboard.writeText(text);
3962
- return true;
3963
- } catch (error) {
3964
- // Fall through to execCommand fallback for local/dev environments.
3965
- }
3966
- }
3967
-
3968
- const textarea = document.createElement('textarea');
3969
- textarea.value = text;
3970
- textarea.setAttribute('readonly', '');
3971
- textarea.style.position = 'fixed';
3972
- textarea.style.left = '-9999px';
3973
- textarea.style.top = '-9999px';
3974
- textarea.style.opacity = '0';
3975
- document.body.appendChild(textarea);
3976
- textarea.select();
3977
- textarea.setSelectionRange(0, textarea.value.length);
3978
-
3979
- let copied = false;
3980
- try {
3981
- copied = document.execCommand('copy');
3982
- } catch (error) {
3983
- copied = false;
3984
- }
3985
-
3986
- document.body.removeChild(textarea);
3987
- return copied;
3988
- }
3989
-
3990
- // Connection status
3991
- function updateConnectionStatus() {
3992
- connectionDot.classList.remove('live', 'error');
3993
-
3994
- if (streamState === 'paused') {
3995
- connectionDot.classList.add('error');
3996
- connectionText.textContent = 'Paused';
3997
- return;
3998
- }
3999
-
4000
- if (streamState === 'connecting') {
4001
- connectionText.textContent = 'Connecting...';
4002
- return;
4003
- }
4004
-
4005
- if (streamState === 'reconnecting') {
4006
- connectionDot.classList.add('error');
4007
- const msRemaining = reconnectAt ? Math.max(0, reconnectAt - Date.now()) : 0;
4008
- const seconds = Math.max(1, Math.ceil(msRemaining / 1000));
4009
- connectionText.textContent = `Reconnecting ${seconds}s`;
4010
- return;
4011
- }
4012
-
4013
- if (lastPollError) {
4014
- connectionDot.classList.add('error');
4015
- connectionText.textContent = 'Sync error';
4016
- return;
4017
- }
4018
-
4019
- connectionDot.classList.add('live');
4020
- connectionText.textContent = 'Live';
4021
- }
4022
-
4023
- // Helpers
4024
- function escapeHtml(str) {
4025
- if (!str) return '';
4026
- return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
4027
- }
4028
-
4029
- function getAssigneeValue(value) {
4030
- if (typeof value !== 'string') return '';
4031
- return value.trim().length > 0 ? value : '';
4032
- }
4033
-
4034
- function truncateCardLabel(value, maxChars = 10) {
4035
- if (typeof value !== 'string' || maxChars <= 0) return '';
4036
- const graphemes = Array.from(value);
4037
- if (graphemes.length <= maxChars) return value;
4038
- return `${graphemes.slice(0, maxChars).join('')}...`;
4039
- }
4040
-
4041
- // Render markdown to sanitized HTML
4042
- function renderMarkdown(str) {
4043
- if (!str) return '';
4044
- // Fall back to plain text if libraries aren't loaded
4045
- if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
4046
- return escapeHtml(str);
4047
- }
4048
- try {
4049
- const html = marked.parse(str);
4050
- return DOMPurify.sanitize(html);
4051
- } catch (e) {
4052
- console.error('Markdown parse error:', e);
4053
- return escapeHtml(str);
4054
- }
4055
- }
4056
-
4057
- // Time constants in milliseconds
4058
- const MS_PER_SECOND = 1000;
4059
- const MS_PER_MINUTE = 60 * MS_PER_SECOND;
4060
- const MS_PER_HOUR = 60 * MS_PER_MINUTE;
4061
- const MS_PER_DAY = 24 * MS_PER_HOUR;
4062
-
4063
- function formatTime(isoString) {
4064
- if (!isoString) return '';
4065
- const date = new Date(isoString);
4066
- const now = new Date();
4067
- const diff = now - date;
4068
-
4069
- if (diff < MS_PER_MINUTE) return 'just now';
4070
- if (diff < MS_PER_HOUR) return `${Math.floor(diff / MS_PER_MINUTE)}m ago`;
4071
- if (diff < MS_PER_DAY) return `${Math.floor(diff / MS_PER_HOUR)}h ago`;
4072
- return date.toLocaleDateString();
4073
- }
4074
-
4075
- function formatTimeRemaining(isoString) {
4076
- if (!isoString) return '';
4077
- const date = new Date(isoString);
4078
- const now = new Date();
4079
- const diff = date - now;
4080
-
4081
- if (diff <= 0) return 'expired';
4082
- if (diff < MS_PER_MINUTE) return `${Math.floor(diff / MS_PER_SECOND)}s left`;
4083
- if (diff < MS_PER_HOUR) return `${Math.floor(diff / MS_PER_MINUTE)}m left`;
4084
- return `${Math.floor(diff / MS_PER_HOUR)}h left`;
4085
- }
4086
-
4087
- function formatEventType(type) {
4088
- return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
4089
- }
4090
-
4091
- function getEventActor(event) {
4092
- const explicitAuthor = typeof event.author === 'string' && event.author.trim() ? event.author : null;
4093
- if (explicitAuthor) return explicitAuthor;
4094
-
4095
- const explicitAgent = typeof event.agent_id === 'string' && event.agent_id.trim() ? event.agent_id : null;
4096
- if (explicitAgent) return explicitAgent;
4097
-
4098
- const dataAuthor = event.data && typeof event.data.author === 'string' && event.data.author.trim()
4099
- ? event.data.author
4100
- : null;
4101
- return dataAuthor || 'system';
4102
- }
4103
-
4104
- function truncateText(text, max = 160) {
4105
- if (!text || text.length <= max) return text;
4106
- return `${text.slice(0, max - 3)}...`;
4107
- }
4108
-
4109
- function formatEventDetail(event) {
4110
- if (!event || !event.data) return '';
4111
-
4112
- if (event.type === 'task_created') {
4113
- const assignee = typeof event.data.assignee === 'string' ? event.data.assignee : null;
4114
- const project = typeof event.data.project === 'string' ? event.data.project : null;
4115
- if (assignee && project) return `Assigned to ${assignee} in ${project}`;
4116
- if (assignee) return `Assigned to ${assignee}`;
4117
- if (project) return `Created in ${project}`;
4118
- return '';
4119
- }
4120
-
4121
- if (event.type === 'status_changed') {
4122
- const from = typeof event.data.from === 'string' ? event.data.from : null;
4123
- const to = typeof event.data.to === 'string' ? event.data.to : null;
4124
- if (!from || !to) return '';
4125
- return `${from} → ${to}`;
4126
- }
4127
-
4128
- if (event.type === 'task_updated') {
4129
- const field = typeof event.data.field === 'string' ? event.data.field : null;
4130
- if (!field) return 'Task updated';
4131
- return `${field} updated`;
4132
- }
4133
-
4134
- if (event.type === 'comment_added') {
4135
- const text = typeof event.data.text === 'string' ? event.data.text : '';
4136
- return truncateText(text);
4137
- }
4138
-
4139
- if (event.type === 'checkpoint_recorded') {
4140
- const name = typeof event.data.name === 'string' ? event.data.name : null;
4141
- return name ? `Checkpoint: ${name}` : 'Checkpoint recorded';
4142
- }
4143
-
4144
- if (event.type === 'task_moved') {
4145
- const fromProject = typeof event.data.from_project === 'string' ? event.data.from_project : null;
4146
- const toProject = typeof event.data.to_project === 'string' ? event.data.to_project : null;
4147
- if (!fromProject || !toProject) return '';
4148
- return `${fromProject} → ${toProject}`;
4149
- }
4150
-
4151
- if (event.type === 'dependency_added') {
4152
- const depId = typeof event.data.depends_on_id === 'string' ? event.data.depends_on_id : null;
4153
- return depId ? `Added dependency on ${depId}` : 'Added dependency';
4154
- }
4155
-
4156
- if (event.type === 'dependency_removed') {
4157
- const depId = typeof event.data.depends_on_id === 'string' ? event.data.depends_on_id : null;
4158
- return depId ? `Removed dependency on ${depId}` : 'Removed dependency';
4159
- }
4160
-
4161
- if (event.type === 'task_archived') {
4162
- return 'Task archived';
4163
- }
4164
-
4165
- return '';
4166
- }
4167
-
4168
- function isTypingTarget(target) {
4169
- if (!target || !(target instanceof Element)) return false;
4170
- if (target.closest('input, textarea, select')) return true;
4171
- return target.isContentEditable;
4172
- }
4173
-
4174
- // Event listeners
4175
- dateFilter.addEventListener('change', () => {
4176
- savePreferences();
4177
- requestPoll();
4178
- });
4179
-
4180
- taskSearchInput.addEventListener('input', () => {
4181
- applyTaskSearch(taskSearchInput.value);
4182
- });
4183
-
4184
- taskSearchClear.addEventListener('click', () => {
4185
- applyTaskSearch('');
4186
- taskSearchInput.focus();
4187
- });
4188
-
4189
- projectFilter.addEventListener('change', () => {
4190
- savePreferences();
4191
- requestPoll();
4192
- });
4193
-
4194
- assigneeFilter.addEventListener('change', () => {
4195
- savePreferences();
4196
- renderBoard();
4197
- });
4198
-
4199
- activityAssigneeFilter.addEventListener('change', () => {
4200
- savePreferences();
4201
- renderActivity();
4202
- });
4203
-
4204
- activityKeywordFilter.addEventListener('input', () => {
4205
- updateActivityAssigneeOptions();
4206
- savePreferences();
4207
- renderActivity();
4208
- });
4209
-
4210
- refreshFilter.addEventListener('change', () => {
4211
- savePreferences();
4212
- reconnectAttempt = 0;
4213
- if (shouldRunLiveUpdates() && streamState !== 'live') {
4214
- connectEventStream();
4215
- }
4216
- requestPoll();
4217
- });
4218
-
4219
- shortcutsBtn.addEventListener('click', () => {
4220
- setShortcutsModalOpen(true);
4221
- });
4222
-
4223
- shortcutsClose.addEventListener('click', () => {
4224
- setShortcutsModalOpen(false);
4225
- });
4226
-
4227
- shortcutsModalOverlay.addEventListener('click', (e) => {
4228
- if (e.target === shortcutsModalOverlay) {
4229
- setShortcutsModalOpen(false);
4230
- }
4231
- });
4232
-
4233
- activityBtn.addEventListener('click', () => {
4234
- setActivityPanelOpen(true);
4235
- });
4236
-
4237
- activityClose.addEventListener('click', () => {
4238
- setActivityPanelOpen(false);
4239
- });
4240
-
4241
- activityList.addEventListener('click', (e) => {
4242
- const item = e.target.closest('.activity-item');
4243
- if (!item || !activityList.contains(item)) return;
4244
- const { taskId } = item.dataset;
4245
- if (!taskId) return;
4246
- openTaskModal(taskId);
4247
- });
4248
-
4249
- modalTaskIdCopy.addEventListener('click', async () => {
4250
- const taskId = modalTaskIdCopy.dataset.taskId || '';
4251
- if (!taskId) return;
4252
-
4253
- const copied = await copyTextToClipboard(taskId);
4254
- setTaskIdCopyFeedback(copied ? 'copied' : 'failed');
4255
- });
4256
-
4257
- modalClose.addEventListener('click', closeModal);
4258
- modalOverlay.addEventListener('click', (e) => {
4259
- if (e.target === modalOverlay) closeModal();
4260
- });
4261
-
4262
- document.addEventListener('keydown', (e) => {
4263
- if (e.key === 'Escape') {
4264
- closeModal();
4265
- setActivityPanelOpen(false);
4266
- setShortcutsModalOpen(false);
4267
- settingsDropdown.classList.remove('open');
4268
- return;
4269
- }
4270
-
4271
- if (isTypingTarget(e.target)) {
4272
- return;
4273
- }
4274
-
4275
- if (e.key === '/') {
4276
- e.preventDefault();
4277
- taskSearchInput.focus();
4278
- taskSearchInput.select();
4279
- return;
4280
- }
4281
-
4282
- if (e.key === '?') {
4283
- e.preventDefault();
4284
- setShortcutsModalOpen(true);
4285
- return;
4286
- }
4287
-
4288
- if (e.key.toLowerCase() === 'a') {
4289
- e.preventDefault();
4290
- setActivityPanelOpen(!activityPanel.classList.contains('open'));
4291
- }
4292
- });
4293
-
4294
- // Visibility/focus API
4295
- document.addEventListener('visibilitychange', () => {
4296
- if (document.hidden) {
4297
- pauseLiveUpdates();
4298
- } else {
4299
- windowHasFocus = typeof document.hasFocus === 'function' ? document.hasFocus() : true;
4300
- resumeLiveUpdates();
4301
- }
4302
- });
4303
-
4304
- window.addEventListener('focus', () => {
4305
- windowHasFocus = true;
4306
- resumeLiveUpdates();
4307
- });
4308
-
4309
- window.addEventListener('blur', () => {
4310
- windowHasFocus = false;
4311
- pauseLiveUpdates();
4312
- });
4313
-
4314
- // Mobile tabs
4315
- mobileTabs.addEventListener('click', (e) => {
4316
- const tab = e.target.closest('.mobile-tab');
4317
- if (!tab) return;
4318
- setActiveTab(tab.dataset.status);
4319
- });
4320
-
4321
- // Hamburger menu (toggle filters on mobile)
4322
- hamburgerBtn.addEventListener('click', () => {
4323
- const filters = document.querySelector('.header-filters');
4324
- filters.classList.toggle('open');
4325
- });
4326
-
4327
- // Settings dropdown
4328
- settingsToggle.addEventListener('click', (e) => {
4329
- e.stopPropagation();
4330
- settingsDropdown.classList.toggle('open');
4331
- });
4332
-
4333
- document.addEventListener('click', () => {
4334
- settingsDropdown.classList.remove('open');
4335
- });
4336
-
4337
- settingsDropdown.addEventListener('click', (e) => e.stopPropagation());
4338
-
4339
- // Column visibility checkboxes
4340
- settingsDropdown.querySelectorAll('.column-checkboxes input').forEach(cb => {
4341
- cb.addEventListener('change', () => {
4342
- updateColumnVisibility();
4343
- updateAssigneeOptions();
4344
- updateActivityAssigneeOptions();
4345
- savePreferences();
4346
- renderBoard();
4347
- renderActivity();
4348
- });
4349
- });
4350
-
4351
- // Show subtasks toggle
4352
- showSubtasksCheckbox.addEventListener('change', () => {
4353
- updateAssigneeOptions();
4354
- updateActivityAssigneeOptions();
4355
- savePreferences();
4356
- renderBoard();
4357
- renderActivity();
4358
- });
4359
-
4360
- collapseAllParentsBtn.addEventListener('click', () => {
4361
- collapseAllParents();
4362
- });
4363
-
4364
- expandAllParentsBtn.addEventListener('click', () => {
4365
- expandAllParents();
4366
- });
4367
-
4368
- // View selection
4369
- function setActiveView(view) {
4370
- const allowedViews = new Set(['kanban', 'calendar', 'graph']);
4371
- if (!allowedViews.has(view)) {
4372
- view = 'kanban';
4373
- }
4374
-
4375
- const graphOption = viewFilter.querySelector('option[value="graph"]');
4376
- if (view === 'graph' && graphOption && graphOption.disabled) {
4377
- view = 'kanban';
4378
- }
4379
-
4380
- // Dismiss calendar popover when leaving calendar view
4381
- if (activeView === 'calendar' && view !== 'calendar') {
4382
- dismissPopover();
4383
- }
4384
-
4385
- activeView = view;
4386
- viewFilter.value = view;
4387
-
4388
- // Show/hide containers
4389
- board.style.display = view === 'kanban' ? '' : 'none';
4390
- calendarContainer.classList.toggle('hidden', view !== 'calendar');
4391
- graphContainer.classList.toggle('hidden', view !== 'graph');
4392
- mobileTabs.style.display = view === 'kanban' ? '' : 'none';
4393
- document.getElementById('mobileCardsContainer').style.display = view === 'kanban' ? '' : 'none';
4394
-
4395
- // Hide date filter when calendar is active (calendar has its own month navigation)
4396
- dateFilter.closest('.filter-group').style.display = view === 'calendar' ? 'none' : '';
4397
-
4398
- // Pause/resume graph
4399
- if (view === 'graph') {
4400
- if (!graphInitialized && typeof ForceGraph !== 'undefined') {
4401
- initGraph();
4402
- } else if (graphInstance) {
4403
- graphInstance.resumeAnimation();
4404
- }
4405
- } else if (graphInstance) {
4406
- graphInstance.pauseAnimation();
4407
- }
4408
-
4409
- savePreferences();
4410
- requestPoll();
4411
- }
4412
-
4413
- viewFilter.addEventListener('change', () => {
4414
- if (viewFilter.value !== activeView) {
4415
- setActiveView(viewFilter.value);
4416
- }
4417
- });
4418
-
4419
- // Event delegation for card clicks (avoids re-adding handlers on every render)
4420
- function handleCardContainerClick(e) {
4421
- const toggle = e.target.closest('[data-action="toggle-subtasks"]');
4422
- if (toggle) {
4423
- e.preventDefault();
4424
- e.stopPropagation();
4425
- const parentId = toggle.dataset.parentId;
4426
- toggleParentCollapsed(parentId);
4427
- return;
4428
- }
4429
-
4430
- const card = e.target.closest('.card');
4431
- if (card) openTaskModal(card.dataset.taskId);
4432
- }
4433
-
4434
- board.addEventListener('click', handleCardContainerClick);
4435
- document.getElementById('mobileCardsContainer').addEventListener('click', handleCardContainerClick);
4436
-
4437
- function registerServiceWorker() {
4438
- if (!('serviceWorker' in navigator)) {
4439
- return;
4440
- }
4441
-
4442
- window.addEventListener('load', () => {
4443
- navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
4444
- // PWA support is optional; ignore registration failures.
4445
- });
4446
- });
4447
- }
4448
-
4449
- // Initialize
4450
- loadPreferences();
4451
- bindColumnScrollIndicators();
4452
- registerServiceWorker();
4453
- connectEventStream();
4454
- requestPoll();
4455
-
4456
- // Update connection indicator every second (age + reconnect countdown)
4457
- setInterval(() => {
4458
- updateConnectionStatus();
4459
- }, 1000);
4460
- </script>
4461
-
4462
- <!-- Markdown rendering -->
4463
- <script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
4464
- <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
4465
-
4466
- <!-- d3 for graph layout forces -->
4467
- <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js" defer></script>
4468
- <!-- Force-graph library for Graph view -->
4469
- <script src="https://cdn.jsdelivr.net/npm/force-graph@1/dist/force-graph.min.js"
4470
- defer
4471
- onerror="handleGraphLibError()"></script>
14
+ <div id="root"></div>
4472
15
  </body>
4473
16
  </html>