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.
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +107 -64
- package/dist/server.js.map +1 -1
- package/dist/server.test.js +299 -189
- package/dist/server.test.js.map +1 -1
- package/dist/ui/assets/Paired-CHegtbHO.js +1 -0
- package/dist/ui/assets/force-graph-B5HrL0HG.js +42 -0
- package/dist/ui/assets/index-CdpHwHFG.css +1 -0
- package/dist/ui/assets/index-CgGSX2ei.js +5 -0
- package/dist/ui/assets/index-DhxVdKMf.js +109 -0
- package/dist/ui/index.html +9 -4466
- package/dist/ui/legacy.html +4473 -0
- package/dist/ui/screenshot-mobile.png +0 -0
- package/dist/ui/screenshot-wide.png +0 -0
- package/dist/ui/site.webmanifest +27 -0
- package/dist/ui-embed.d.ts +8 -8
- package/dist/ui-embed.d.ts.map +1 -1
- package/dist/ui-embed.js +71 -29
- package/dist/ui-embed.js.map +1 -1
- package/package.json +16 -3
|
@@ -0,0 +1,4473 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<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>
|
|
1689
|
+
</head>
|
|
1690
|
+
<body>
|
|
1691
|
+
<header class="header">
|
|
1692
|
+
<div class="header-left">
|
|
1693
|
+
<button class="hamburger" id="hamburgerBtn">☰</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>×</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">×</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">×</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">×</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 ? '▶' : '▼';
|
|
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">←</button>
|
|
3106
|
+
<div class="calendar-month-label">${escapeHtml(monthLabel)}</div>
|
|
3107
|
+
<button class="calendar-nav-btn" id="calNext">→</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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>
|
|
4472
|
+
</body>
|
|
4473
|
+
</html>
|