tokenbill-mcp 1.1.0 → 1.1.6
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/README.md +13 -3
- package/package.json +18 -5
- package/uploader/duet/board.html +1154 -704
- package/uploader/duet/core.js +116 -7
- package/uploader/duet/web.js +32 -1
package/uploader/duet/board.html
CHANGED
|
@@ -1,704 +1,1154 @@
|
|
|
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">
|
|
6
|
-
<title>Duet</title>
|
|
7
|
-
<style>
|
|
8
|
-
/* 웹폰트를 불러오지 않는다. 네트워크를 쓰지 않는 것이 이 도구의 원칙이라
|
|
9
|
-
설치돼 있는 글꼴만 쓴다 (Pretendard가 있으면 그것, 없으면 맑은 고딕). */
|
|
10
|
-
:root{
|
|
11
|
-
--bg:#F3F4F7; --panel:#fff; --panel-2:#F8F9FB; --line:#DCDFE6; --line-2:#EBEDF2;
|
|
12
|
-
--ink:#171A21; --ink-2:#4C5261; --ink-3:#8A90A0;
|
|
13
|
-
--accent:#1F4E8C; --accent-ink:#fff;
|
|
14
|
-
--진행중:#2563B8; --진행중-bg:#E3ECFA;
|
|
15
|
-
--대기:#B7791F; --대기-bg:#FBF0DA;
|
|
16
|
-
--중지:#6B7280; --중지-bg:#ECEEF2;
|
|
17
|
-
--확인필요:#C2410C; --확인필요-bg:#FEE9DC;
|
|
18
|
-
--보류:#6B7280; --보류-bg:#ECEEF2;
|
|
19
|
-
--완료:#1E7F4F; --완료-bg:#DDF2E6;
|
|
20
|
-
--취소:#B42318; --취소-bg:#FBE9E7;
|
|
21
|
-
--shadow:0 1px 2px rgba(23,26,33,.06), 0 4px 14px rgba(23,26,33,.06);
|
|
22
|
-
--radius:10px;
|
|
23
|
-
}
|
|
24
|
-
@media (prefers-color-scheme: dark){
|
|
25
|
-
:root{
|
|
26
|
-
--bg:#13161B; --panel:#1B1F26; --panel-2:#20252D; --line:#2E343E; --line-2:#272C35;
|
|
27
|
-
--ink:#ECEEF2; --ink-2:#AEB4C0; --ink-3:#737A88;
|
|
28
|
-
--accent:#7FA6E8; --accent-ink:#0F172A;
|
|
29
|
-
--진행중:#7FA6E8; --진행중-bg:#1C2A40;
|
|
30
|
-
--대기:#E2B25C; --대기-bg:#3A2E15;
|
|
31
|
-
--중지:#A0A6B3; --중지-bg:#2A2F38;
|
|
32
|
-
--확인필요:#F59E6B; --확인필요-bg:#3D2314;
|
|
33
|
-
--보류:#A0A6B3; --보류-bg:#2A2F38;
|
|
34
|
-
--완료:#5FC98F; --완료-bg:#16321F;
|
|
35
|
-
--취소:#F08A80; --취소-bg:#3A1E1B;
|
|
36
|
-
--shadow:0 1px 2px rgba(0,0,0,.4), 0 4px 14px rgba(0,0,0,.35);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
*{box-sizing:border-box}
|
|
40
|
-
[hidden]{display:none!important}
|
|
41
|
-
body{
|
|
42
|
-
margin:0; background:var(--bg); color:var(--ink); font-size:14px; line-height:1.5;
|
|
43
|
-
font-family:"Pretendard","Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;
|
|
44
|
-
}
|
|
45
|
-
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
|
|
46
|
-
button,input,select,textarea{font:inherit;color:inherit}
|
|
47
|
-
button{cursor:pointer}
|
|
48
|
-
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
49
|
-
|
|
50
|
-
.wrap{max-width:1180px;margin:0 auto;padding:28px 24px 80px}
|
|
51
|
-
header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}
|
|
52
|
-
h1{margin:0;font-size:22px;font-weight:700;letter-spacing:-.01em}
|
|
53
|
-
.sub{color:var(--ink-3);font-size:12.5px;margin-top:2px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
54
|
-
.pulse{width:7px;height:7px;border-radius:50%;background:var(--취소);display:inline-block;margin-right:4px}
|
|
55
|
-
.pulse.on{background:var(--완료)}
|
|
56
|
-
|
|
57
|
-
.btn{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:7px 12px;font-weight:500;font-size:13px}
|
|
58
|
-
.btn:hover{background:var(--panel-2)}
|
|
59
|
-
.btn.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
|
|
60
|
-
.btn.primary:hover{filter:brightness(1.08)}
|
|
61
|
-
.btn.ghost{border-color:transparent;background:transparent;color:var(--ink-3);padding:3px 8px;font-size:11.5px}
|
|
62
|
-
.btn.ghost:hover{background:var(--panel-2);color:var(--ink)}
|
|
63
|
-
.btn.small{padding:4px 9px;font-size:12px}
|
|
64
|
-
.btn[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
|
|
65
|
-
.acts{display:flex;gap:2px;align-items:center}
|
|
66
|
-
|
|
67
|
-
.toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px}
|
|
68
|
-
.
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
.
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
.
|
|
77
|
-
|
|
78
|
-
.grid{
|
|
79
|
-
.
|
|
80
|
-
.
|
|
81
|
-
.
|
|
82
|
-
.
|
|
83
|
-
.
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
.
|
|
87
|
-
|
|
88
|
-
.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.
|
|
93
|
-
.
|
|
94
|
-
.
|
|
95
|
-
.
|
|
96
|
-
.
|
|
97
|
-
.
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
.
|
|
101
|
-
.
|
|
102
|
-
.
|
|
103
|
-
.
|
|
104
|
-
.
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
.
|
|
108
|
-
.
|
|
109
|
-
.
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
|
|
113
|
-
.
|
|
114
|
-
.
|
|
115
|
-
.
|
|
116
|
-
.
|
|
117
|
-
.
|
|
118
|
-
.
|
|
119
|
-
.
|
|
120
|
-
.
|
|
121
|
-
.
|
|
122
|
-
|
|
123
|
-
.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
.
|
|
129
|
-
.
|
|
130
|
-
.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
.
|
|
138
|
-
.
|
|
139
|
-
.
|
|
140
|
-
.
|
|
141
|
-
.
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
.
|
|
150
|
-
.
|
|
151
|
-
.
|
|
152
|
-
.
|
|
153
|
-
.
|
|
154
|
-
.
|
|
155
|
-
.
|
|
156
|
-
.
|
|
157
|
-
.
|
|
158
|
-
.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
.
|
|
164
|
-
.
|
|
165
|
-
.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
.
|
|
173
|
-
.
|
|
174
|
-
.
|
|
175
|
-
.
|
|
176
|
-
.
|
|
177
|
-
.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
</
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
<button
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const sel =
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
el("
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
return
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
})
|
|
626
|
-
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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">
|
|
6
|
+
<title>Duet</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* 웹폰트를 불러오지 않는다. 네트워크를 쓰지 않는 것이 이 도구의 원칙이라
|
|
9
|
+
설치돼 있는 글꼴만 쓴다 (Pretendard가 있으면 그것, 없으면 맑은 고딕). */
|
|
10
|
+
:root{
|
|
11
|
+
--bg:#F3F4F7; --panel:#fff; --panel-2:#F8F9FB; --line:#DCDFE6; --line-2:#EBEDF2;
|
|
12
|
+
--ink:#171A21; --ink-2:#4C5261; --ink-3:#8A90A0;
|
|
13
|
+
--accent:#1F4E8C; --accent-ink:#fff;
|
|
14
|
+
--진행중:#2563B8; --진행중-bg:#E3ECFA;
|
|
15
|
+
--대기:#B7791F; --대기-bg:#FBF0DA;
|
|
16
|
+
--중지:#6B7280; --중지-bg:#ECEEF2;
|
|
17
|
+
--확인필요:#C2410C; --확인필요-bg:#FEE9DC;
|
|
18
|
+
--보류:#6B7280; --보류-bg:#ECEEF2;
|
|
19
|
+
--완료:#1E7F4F; --완료-bg:#DDF2E6;
|
|
20
|
+
--취소:#B42318; --취소-bg:#FBE9E7;
|
|
21
|
+
--shadow:0 1px 2px rgba(23,26,33,.06), 0 4px 14px rgba(23,26,33,.06);
|
|
22
|
+
--radius:10px;
|
|
23
|
+
}
|
|
24
|
+
@media (prefers-color-scheme: dark){
|
|
25
|
+
:root{
|
|
26
|
+
--bg:#13161B; --panel:#1B1F26; --panel-2:#20252D; --line:#2E343E; --line-2:#272C35;
|
|
27
|
+
--ink:#ECEEF2; --ink-2:#AEB4C0; --ink-3:#737A88;
|
|
28
|
+
--accent:#7FA6E8; --accent-ink:#0F172A;
|
|
29
|
+
--진행중:#7FA6E8; --진행중-bg:#1C2A40;
|
|
30
|
+
--대기:#E2B25C; --대기-bg:#3A2E15;
|
|
31
|
+
--중지:#A0A6B3; --중지-bg:#2A2F38;
|
|
32
|
+
--확인필요:#F59E6B; --확인필요-bg:#3D2314;
|
|
33
|
+
--보류:#A0A6B3; --보류-bg:#2A2F38;
|
|
34
|
+
--완료:#5FC98F; --완료-bg:#16321F;
|
|
35
|
+
--취소:#F08A80; --취소-bg:#3A1E1B;
|
|
36
|
+
--shadow:0 1px 2px rgba(0,0,0,.4), 0 4px 14px rgba(0,0,0,.35);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
*{box-sizing:border-box}
|
|
40
|
+
[hidden]{display:none!important}
|
|
41
|
+
body{
|
|
42
|
+
margin:0; background:var(--bg); color:var(--ink); font-size:14px; line-height:1.5;
|
|
43
|
+
font-family:"Pretendard","Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;
|
|
44
|
+
}
|
|
45
|
+
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
|
|
46
|
+
button,input,select,textarea{font:inherit;color:inherit}
|
|
47
|
+
button{cursor:pointer}
|
|
48
|
+
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
49
|
+
|
|
50
|
+
.wrap{max-width:1180px;margin:0 auto;padding:28px 24px 80px}
|
|
51
|
+
header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}
|
|
52
|
+
h1{margin:0;font-size:22px;font-weight:700;letter-spacing:-.01em}
|
|
53
|
+
.sub{color:var(--ink-3);font-size:12.5px;margin-top:2px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
54
|
+
.pulse{width:7px;height:7px;border-radius:50%;background:var(--취소);display:inline-block;margin-right:4px}
|
|
55
|
+
.pulse.on{background:var(--완료)}
|
|
56
|
+
|
|
57
|
+
.btn{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:7px 12px;font-weight:500;font-size:13px}
|
|
58
|
+
.btn:hover{background:var(--panel-2)}
|
|
59
|
+
.btn.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
|
|
60
|
+
.btn.primary:hover{filter:brightness(1.08)}
|
|
61
|
+
.btn.ghost{border-color:transparent;background:transparent;color:var(--ink-3);padding:3px 8px;font-size:11.5px}
|
|
62
|
+
.btn.ghost:hover{background:var(--panel-2);color:var(--ink)}
|
|
63
|
+
.btn.small{padding:4px 9px;font-size:12px}
|
|
64
|
+
.btn[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
|
|
65
|
+
.acts{display:flex;gap:2px;align-items:center}
|
|
66
|
+
|
|
67
|
+
.toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px}
|
|
68
|
+
.vtabs{display:flex;gap:2px;border-bottom:1px solid var(--line);margin:-4px 0 14px}
|
|
69
|
+
.vtabs button{border:0;background:none;padding:9px 14px;font-size:14px;font-weight:600;color:var(--ink-3);cursor:pointer;
|
|
70
|
+
border-bottom:2px solid transparent;margin-bottom:-1px;font-family:inherit}
|
|
71
|
+
.vtabs button[aria-selected="true"]{color:var(--ink);border-bottom-color:var(--accent)}
|
|
72
|
+
/* ── 스프린트 타임라인 ── */
|
|
73
|
+
.tl{background:var(--panel);border:1px solid var(--line-2);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
|
|
74
|
+
.tl-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line-2);background:var(--panel-2)}
|
|
75
|
+
.tl-bar select{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:5px 8px;font:inherit;font-size:13px;color:var(--ink)}
|
|
76
|
+
.tl-range{font-size:13px;font-weight:600;min-width:120px;text-align:center;font-variant-numeric:tabular-nums}
|
|
77
|
+
.tl-body{overflow-x:auto}
|
|
78
|
+
.tl-grid{min-width:720px;position:relative}
|
|
79
|
+
.tl-row{display:grid;grid-template-columns:230px 1fr;align-items:center;min-height:30px;border-bottom:1px solid var(--line-2)}
|
|
80
|
+
.tl-row:last-child{border-bottom:0}
|
|
81
|
+
.tl-row.clickable{cursor:pointer}
|
|
82
|
+
.tl-row.clickable:hover{background:var(--panel-2)}
|
|
83
|
+
.tl-label{padding:5px 12px;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}
|
|
84
|
+
.tl-label .tid{color:var(--ink-3);margin-right:6px;font-size:11.5px}
|
|
85
|
+
.tl-label.grp{font-weight:700;color:var(--ink-2);font-size:12px;background:var(--panel-2)}
|
|
86
|
+
.tl-track{position:relative;height:100%;min-height:30px}
|
|
87
|
+
.tl-day{position:absolute;top:0;bottom:0;border-left:1px solid var(--line-2)}
|
|
88
|
+
.tl-day.we{background:color-mix(in srgb,var(--line-2) 45%,transparent)}
|
|
89
|
+
.tl-head .tl-track{min-height:28px}
|
|
90
|
+
.tl-tick{position:absolute;top:7px;font-size:10.5px;color:var(--ink-3);transform:translateX(3px);white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
91
|
+
.tl-today{position:absolute;top:0;bottom:0;border-left:2px solid var(--확인필요);z-index:3;pointer-events:none}
|
|
92
|
+
.tl-band{position:absolute;top:5px;bottom:5px;border-radius:6px;background:var(--진행중-bg);border:1px solid color-mix(in srgb,var(--진행중) 35%,transparent);
|
|
93
|
+
font-size:11.5px;color:var(--진행중);padding:0 8px;display:flex;align-items:center;gap:6px;overflow:hidden;white-space:nowrap;cursor:pointer}
|
|
94
|
+
.tl-band.past{background:var(--panel-2);color:var(--ink-3);border-color:var(--line)}
|
|
95
|
+
.tl-band.on{box-shadow:0 0 0 2px var(--accent)}
|
|
96
|
+
.tl-seg{position:absolute;top:9px;bottom:9px;border-radius:4px;opacity:.28;z-index:1}
|
|
97
|
+
.tl-act{position:absolute;top:9px;bottom:9px;border-radius:3px;z-index:2}
|
|
98
|
+
.tl-run{position:absolute;top:13px;height:4px;border-radius:2px;z-index:1;opacity:.55;
|
|
99
|
+
background-image:repeating-linear-gradient(90deg,currentColor 0 5px,transparent 5px 9px)}
|
|
100
|
+
.tl-empty{padding:40px 20px;text-align:center;color:var(--ink-3);font-size:13px}
|
|
101
|
+
.tl-legend{display:flex;gap:14px;flex-wrap:wrap;padding:8px 14px;font-size:11.5px;color:var(--ink-3);border-top:1px solid var(--line-2)}
|
|
102
|
+
.tl-legend i{display:inline-block;width:14px;height:8px;border-radius:2px;margin-right:5px;vertical-align:0}
|
|
103
|
+
.chips{display:flex;gap:6px;flex-wrap:wrap}
|
|
104
|
+
.chip{border:1px solid var(--line);background:var(--panel);border-radius:999px;padding:5px 12px;font-size:12.5px;color:var(--ink-2);
|
|
105
|
+
display:inline-flex;gap:6px;align-items:center}
|
|
106
|
+
.chip .dot{width:7px;height:7px;border-radius:50%;display:inline-block}
|
|
107
|
+
.chip .n{color:var(--ink-3)}
|
|
108
|
+
.chip[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
|
|
109
|
+
.chip[aria-pressed="true"] .n{color:inherit;opacity:.7}
|
|
110
|
+
.spacer{flex:1}
|
|
111
|
+
.search{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:6px 10px;min-width:220px}
|
|
112
|
+
|
|
113
|
+
.grid{display:flex;flex-direction:column;gap:14px}
|
|
114
|
+
.title{font-weight:600;font-size:15px;line-height:1.35;flex:1;min-width:0;overflow-wrap:break-word;word-break:keep-all;border-radius:4px;padding:0 3px;margin:0 -3px}
|
|
115
|
+
.title[contenteditable]:hover{background:var(--panel-2)}
|
|
116
|
+
.tid{color:var(--ink-3);font-weight:500;font-size:13px;padding-top:1px;flex:none}
|
|
117
|
+
.desc{padding:0 16px 10px 30px}
|
|
118
|
+
.desc div{color:var(--ink-2);font-size:12.5px;white-space:pre-wrap;border-radius:4px;padding:0 3px;margin:0 -3px}
|
|
119
|
+
.desc div[contenteditable]:hover{background:var(--panel-2)}
|
|
120
|
+
.desc div:empty::before{content:"No description";color:var(--ink-3)}
|
|
121
|
+
.warn{padding:0 16px 12px 30px;color:var(--확인필요);font-size:12.5px}
|
|
122
|
+
|
|
123
|
+
.sel{border:1px solid var(--line);background:var(--panel);border-radius:6px;padding:4px 24px 4px 8px;font-size:12.5px;
|
|
124
|
+
font-weight:500;appearance:none;-webkit-appearance:none;
|
|
125
|
+
background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);
|
|
126
|
+
background-position:calc(100% - 12px) 55%, calc(100% - 8px) 55%;background-size:4px 4px;background-repeat:no-repeat}
|
|
127
|
+
.sel.s-진행중{color:var(--진행중);background-color:var(--진행중-bg);border-color:transparent}
|
|
128
|
+
.sel.s-대기{color:var(--대기);background-color:var(--대기-bg);border-color:transparent}
|
|
129
|
+
.sel.s-중지{color:var(--중지);background-color:var(--중지-bg);border-color:transparent}
|
|
130
|
+
.sel.s-확인필요{color:var(--확인필요);background-color:var(--확인필요-bg);border-color:transparent}
|
|
131
|
+
.sel.s-보류{color:var(--보류);background-color:var(--보류-bg);border-color:transparent}
|
|
132
|
+
.sel.s-완료{color:var(--완료);background-color:var(--완료-bg);border-color:transparent}
|
|
133
|
+
.sel.s-취소{color:var(--취소);background-color:var(--취소-bg);border-color:transparent}
|
|
134
|
+
|
|
135
|
+
.sessions{border-top:1px solid var(--line-2)}
|
|
136
|
+
.sess{border-bottom:1px solid var(--line-2)}
|
|
137
|
+
.sess:last-child{border-bottom:0}
|
|
138
|
+
.sess summary{display:grid;grid-template-columns:18px 1fr auto auto;gap:8px;align-items:center;padding:8px 12px 8px 16px;list-style:none}
|
|
139
|
+
.sess summary::-webkit-details-marker{display:none}
|
|
140
|
+
.sess summary:hover{background:var(--panel-2)}
|
|
141
|
+
.sd{width:8px;height:8px;border-radius:50%;justify-self:center;background:var(--중지)}
|
|
142
|
+
.sess.s-진행중 .sd{background:var(--진행중);box-shadow:0 0 0 3px var(--진행중-bg)}
|
|
143
|
+
.sess.s-완료 .sd{background:var(--완료)}
|
|
144
|
+
.nm{min-width:0;display:flex;flex-direction:column;line-height:1.3}
|
|
145
|
+
.nm b{font-weight:500;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
146
|
+
.nm span{font-size:11px;color:var(--ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
147
|
+
.locked{font-size:10px;color:var(--ink-3)}
|
|
148
|
+
.sess.s-완료 .nm b{color:var(--ink-3)}
|
|
149
|
+
.tag{font-size:11px;padding:1px 7px;border-radius:999px;white-space:nowrap}
|
|
150
|
+
.tag.s-진행중{color:var(--진행중);background:var(--진행중-bg)}
|
|
151
|
+
.tag.s-완료{color:var(--완료);background:var(--완료-bg)}
|
|
152
|
+
.tag.s-중지{color:var(--중지);background:var(--중지-bg)}
|
|
153
|
+
.tag.s-확인필요{color:var(--확인필요);background:var(--확인필요-bg)}
|
|
154
|
+
.tag.s-대기{color:var(--대기);background:var(--대기-bg)}
|
|
155
|
+
.tag.s-보류{color:var(--보류);background:var(--보류-bg)}
|
|
156
|
+
.tag.s-취소{color:var(--취소);background:var(--취소-bg)}
|
|
157
|
+
.log{margin:0;padding:0 14px 12px 38px;list-style:none}
|
|
158
|
+
.log li{display:flex;gap:8px;font-size:12.5px;color:var(--ink-2);margin-bottom:3px}
|
|
159
|
+
.log time{color:var(--ink-3);font-size:11.5px;flex:none;padding-top:1px}
|
|
160
|
+
.log .none{color:var(--ink-3)}
|
|
161
|
+
.foot{display:flex;justify-content:space-between;gap:10px;align-items:center;padding:7px 14px 7px 16px;
|
|
162
|
+
border-top:1px solid var(--line-2);font-size:11.5px;color:var(--ink-3)}
|
|
163
|
+
.foot .mono{flex:none}
|
|
164
|
+
.foot .acts{flex:none;margin-left:auto}
|
|
165
|
+
.empty{text-align:center;padding:60px 20px;color:var(--ink-3);border:1px dashed var(--line);border-radius:var(--radius)}
|
|
166
|
+
|
|
167
|
+
dialog{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:12px;padding:0;width:min(520px,92vw);box-shadow:var(--shadow)}
|
|
168
|
+
dialog::backdrop{background:rgba(10,12,16,.45)}
|
|
169
|
+
dialog form{padding:20px}
|
|
170
|
+
dialog h2{margin:0 0 14px;font-size:16px}
|
|
171
|
+
dialog .note{margin:-4px 0 12px;font-size:12px;color:var(--ink-3)}
|
|
172
|
+
.field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
|
|
173
|
+
.field label{font-size:12px;color:var(--ink-2);font-weight:500}
|
|
174
|
+
.field input,.field textarea{border:1px solid var(--line);background:var(--panel-2);border-radius:8px;padding:8px 10px}
|
|
175
|
+
.field textarea{min-height:80px;resize:vertical}
|
|
176
|
+
.actions{display:flex;justify-content:flex-end;gap:8px}
|
|
177
|
+
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--ink);color:var(--bg);
|
|
178
|
+
padding:8px 14px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s}
|
|
179
|
+
.toast.show{opacity:1}
|
|
180
|
+
@media (max-width:640px){.wrap{padding:18px 14px 60px}}
|
|
181
|
+
@media (prefers-reduced-motion: reduce){*{transition:none!important}}
|
|
182
|
+
|
|
183
|
+
/* 프로젝트 상자 */
|
|
184
|
+
.pcard{background:var(--panel);border:1px solid var(--line-2);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
|
|
185
|
+
.phead{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line-2);background:var(--panel-2);cursor:pointer;user-select:none}
|
|
186
|
+
.phead:hover{background:var(--line-2)}
|
|
187
|
+
.phead h2{margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em;display:flex;align-items:center;gap:8px}
|
|
188
|
+
.phead h2 .caret{display:inline-block;font-size:10px;color:var(--ink-3);transition:transform .15s}
|
|
189
|
+
.pcard.folded .phead{border-bottom:0}
|
|
190
|
+
.pcard.folded .phead h2 .caret{transform:rotate(-90deg)}
|
|
191
|
+
.pcard.folded .trows,.pcard.folded .pempty,.pcard.folded .sprintbar{display:none}
|
|
192
|
+
.sprintbar{display:flex;gap:6px;flex-wrap:wrap;align-items:center;padding:8px 16px;border-bottom:1px solid var(--line-2)}
|
|
193
|
+
.spchip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--panel);border-radius:999px;
|
|
194
|
+
padding:3px 10px;font-size:12px;cursor:pointer;color:var(--ink-2);font-family:inherit}
|
|
195
|
+
.spchip[aria-pressed="true"]{border-color:var(--ink);color:var(--ink);font-weight:600}
|
|
196
|
+
.spchip .spmeta{color:var(--ink-3);font-weight:400;font-size:11px}
|
|
197
|
+
.spchip .spnow{width:6px;height:6px;border-radius:50%;background:var(--진행중)}
|
|
198
|
+
.spchip .spx{border:0;background:none;color:var(--ink-3);cursor:pointer;padding:0 0 0 2px;font-size:13px;line-height:1}
|
|
199
|
+
.spsel{border:1px solid var(--line);background:var(--panel);border-radius:6px;padding:3px 6px;font-size:11.5px;color:var(--ink-2);
|
|
200
|
+
max-width:120px;font-family:inherit}
|
|
201
|
+
.pc{font-size:12px;color:var(--ink-3)}
|
|
202
|
+
.pempty{padding:14px 16px;color:var(--ink-3);font-size:12.5px}
|
|
203
|
+
|
|
204
|
+
/* 타스크 줄 */
|
|
205
|
+
.trow{border-bottom:1px solid var(--line-2)}
|
|
206
|
+
.trow:last-child{border-bottom:0}
|
|
207
|
+
.trow summary{display:flex;align-items:center;gap:10px;padding:9px 16px 9px 12px;list-style:none;cursor:pointer}
|
|
208
|
+
.trow summary::-webkit-details-marker{display:none}
|
|
209
|
+
.trow summary:hover{background:var(--panel-2)}
|
|
210
|
+
.trow .stripe{width:4px;height:22px;margin:0;border-radius:2px;flex:none}
|
|
211
|
+
.trow .title{font-size:14px;line-height:1.3}
|
|
212
|
+
.trow.s-완료 .title,.trow.s-취소 .title{color:var(--ink-3)}
|
|
213
|
+
.warn-dot{color:var(--확인필요);flex:none}
|
|
214
|
+
.tbody{padding:6px 16px 10px 30px;background:var(--bg)}
|
|
215
|
+
.tbody .desc,.tbody .warn,.tbody .foot{padding-left:0;padding-right:0}
|
|
216
|
+
.tbody .sessions{background:var(--panel);border:1px solid var(--line-2);border-radius:8px;margin-bottom:8px}
|
|
217
|
+
.tbody .foot{border-top:0;padding-top:2px}
|
|
218
|
+
.lock{border:0;background:none;padding:0 2px;font-size:12px;cursor:pointer;line-height:1}
|
|
219
|
+
.lock:hover{filter:brightness(1.2)}
|
|
220
|
+
.x{border:0;background:none;color:var(--ink-3);font-size:16px;line-height:1;padding:0 6px;cursor:pointer;border-radius:4px}
|
|
221
|
+
.x:hover{color:var(--취소);background:var(--취소-bg)}
|
|
222
|
+
</style>
|
|
223
|
+
</head>
|
|
224
|
+
<body>
|
|
225
|
+
<div class="wrap">
|
|
226
|
+
<header>
|
|
227
|
+
<div>
|
|
228
|
+
<h1>Duet</h1>
|
|
229
|
+
<div class="sub"><span class="pulse" id="pulse"></span><span id="state">Connecting…</span></div>
|
|
230
|
+
</div>
|
|
231
|
+
<div style="display:flex;gap:8px">
|
|
232
|
+
<button class="btn" id="lang" title="한국어 / English"></button>
|
|
233
|
+
<button class="btn" id="feedback" title="Report a bug or suggest something — opens a GitHub issue">Feedback</button>
|
|
234
|
+
<button class="btn" id="archive" aria-pressed="false">Archived</button>
|
|
235
|
+
<button class="btn" id="exportCsv" title="Download the tasks shown on screen as CSV (opens in Google Sheets / Excel)">CSV</button>
|
|
236
|
+
<button class="btn" id="exportMd" title="Copy the tasks shown on screen as Markdown">Markdown</button>
|
|
237
|
+
<button class="btn primary" id="new">+ New Project</button>
|
|
238
|
+
</div>
|
|
239
|
+
</header>
|
|
240
|
+
|
|
241
|
+
<nav class="vtabs" role="tablist">
|
|
242
|
+
<button type="button" role="tab" data-view="tasks" id="tabTasks">Tasks</button>
|
|
243
|
+
<button type="button" role="tab" data-view="sprints" id="tabSprints">Sprints</button>
|
|
244
|
+
</nav>
|
|
245
|
+
|
|
246
|
+
<div id="viewTasks">
|
|
247
|
+
<div class="toolbar">
|
|
248
|
+
<div class="chips" id="chips"></div>
|
|
249
|
+
<div class="spacer"></div>
|
|
250
|
+
<input class="search" id="q" type="search" placeholder="Search">
|
|
251
|
+
</div>
|
|
252
|
+
<div class="grid" id="grid"></div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div id="viewSprints" hidden>
|
|
256
|
+
<div class="tl" id="timeline"></div>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<!-- 새 프로젝트. 프로젝트는 첫 타스크와 함께 태어난다 — 빈 상자를 만들지 않는다. -->
|
|
261
|
+
<dialog id="dlgProject">
|
|
262
|
+
<form id="frmProject">
|
|
263
|
+
<h2>New Project</h2>
|
|
264
|
+
<div class="field"><label for="pName">Project name</label><input id="pName" required placeholder="e.g. nefss" autocomplete="off"></div>
|
|
265
|
+
<div class="field"><label for="pTitle">First task</label><input id="pTitle" required placeholder="e.g. pc101pm 전환"></div>
|
|
266
|
+
<div class="field"><label for="pDesc">Description (optional)</label><textarea id="pDesc" placeholder="What this task is about"></textarea></div>
|
|
267
|
+
<div class="actions">
|
|
268
|
+
<button type="button" class="btn" id="pCancel">Cancel</button>
|
|
269
|
+
<button type="submit" class="btn primary">Create</button>
|
|
270
|
+
</div>
|
|
271
|
+
</form>
|
|
272
|
+
</dialog>
|
|
273
|
+
|
|
274
|
+
<!-- 프로젝트 상자 안의 새 타스크 -->
|
|
275
|
+
<dialog id="dlgTask">
|
|
276
|
+
<form id="frmTask">
|
|
277
|
+
<h2 id="tHead">New Task</h2>
|
|
278
|
+
<div class="field"><label for="tTitle">Title</label><input id="tTitle" required placeholder="e.g. pc101pm 전환"></div>
|
|
279
|
+
<div class="field"><label for="tDesc">Description (optional)</label><textarea id="tDesc" placeholder="What this task is about"></textarea></div>
|
|
280
|
+
<div class="actions">
|
|
281
|
+
<button type="button" class="btn" id="tCancel">Cancel</button>
|
|
282
|
+
<button type="submit" class="btn primary">Create</button>
|
|
283
|
+
</div>
|
|
284
|
+
</form>
|
|
285
|
+
</dialog>
|
|
286
|
+
<!-- 새 스프린트. 기간만 정하면 그 기간에 시작한 타스크가 저절로 묶인다. -->
|
|
287
|
+
<dialog id="dlgSprint">
|
|
288
|
+
<form id="frmSprint">
|
|
289
|
+
<h2 id="sHead">New Sprint</h2>
|
|
290
|
+
<div class="field"><label for="sName">Name</label><input id="sName" placeholder="e.g. Sep week 3" autocomplete="off"></div>
|
|
291
|
+
<div class="field"><label for="sStart">Start</label><input id="sStart" type="date" required></div>
|
|
292
|
+
<div class="field"><label for="sEnd">End</label><input id="sEnd" type="date" required></div>
|
|
293
|
+
<p class="note" id="sNote">Tasks started within this period join the sprint automatically.</p>
|
|
294
|
+
<div class="actions">
|
|
295
|
+
<button type="button" class="btn" id="sCancel">Cancel</button>
|
|
296
|
+
<button type="submit" class="btn primary">Create</button>
|
|
297
|
+
</div>
|
|
298
|
+
</form>
|
|
299
|
+
</dialog>
|
|
300
|
+
<!-- 피드백 = GitHub 이슈. 여기서 적은 글을 새 이슈 화면에 채워서 연다. 보내는 것은 브라우저다. -->
|
|
301
|
+
<dialog id="dlgFeedback">
|
|
302
|
+
<form id="frmFeedback">
|
|
303
|
+
<h2>Feedback</h2>
|
|
304
|
+
<div class="field"><label for="fbTitle">Title</label><input id="fbTitle" required placeholder="What's wrong, or what would help"></div>
|
|
305
|
+
<div class="field"><label for="fbBody">Details (optional)</label><textarea id="fbBody" placeholder="What you did, what you expected, what happened"></textarea></div>
|
|
306
|
+
<p class="note">Opens a new GitHub issue with this filled in. You submit it there.</p>
|
|
307
|
+
<div class="actions">
|
|
308
|
+
<button type="button" class="btn" id="fbCancel">Cancel</button>
|
|
309
|
+
<button type="submit" class="btn primary">Open issue</button>
|
|
310
|
+
</div>
|
|
311
|
+
</form>
|
|
312
|
+
</dialog>
|
|
313
|
+
<div class="toast" id="toast"></div>
|
|
314
|
+
|
|
315
|
+
<script>
|
|
316
|
+
// 파일과 도구는 한글 상태 이름을 쓴다 (Claude가 보고하는 말이다). 화면에서만 영문으로 보여준다.
|
|
317
|
+
const STATUSES = ["대기", "진행중", "확인 필요", "완료", "보류", "취소"];
|
|
318
|
+
const LABEL = { "대기": "Waiting", "진행중": "Running", "확인 필요": "Needs review",
|
|
319
|
+
"완료": "Done", "보류": "On hold", "취소": "Cancelled", "중지": "Stopped" };
|
|
320
|
+
// 화면 언어 — 브라우저가 한국어면 한국어, 그 외 영어. 헤더 버튼으로 전환(기억됨).
|
|
321
|
+
let LANG = "en";
|
|
322
|
+
try { LANG = localStorage.getItem("duet-lang") ||
|
|
323
|
+
(((navigator.language || "").toLowerCase().indexOf("ko") === 0) ? "ko" : "en"); } catch {}
|
|
324
|
+
const t = (en, ko) => (LANG === "ko" ? ko : en);
|
|
325
|
+
const label = (st) => (LANG === "ko" ? st : (LABEL[st] || st));
|
|
326
|
+
// 사람이 고를 수 있는 것은 셋 — 끝났다, 잠시 둔다, 안 한다. 대기·진행중·확인 필요는 세션이
|
|
327
|
+
// 없느냐 살아 있느냐 끊겼느냐는 사실이지 고르는 게 아니다. 되돌리려면 🔒를 푼다.
|
|
328
|
+
const HUMAN = ["완료", "보류", "취소"];
|
|
329
|
+
const UNSORTED = "_미분류"; // 프로젝트를 모르는 타스크가 모이는 폴더 이름. 주소에도 이렇게 쓴다
|
|
330
|
+
const ALL = "*";
|
|
331
|
+
// CSS 클래스·변수 이름에는 띄어쓰기를 못 넣는다.
|
|
332
|
+
const slug = (st) => st.replace(/\s+/g, "");
|
|
333
|
+
const plural = (n, word) => LANG === "ko"
|
|
334
|
+
? `${word === "task" ? "타스크" : "프로젝트"} ${n}개`
|
|
335
|
+
: `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
336
|
+
let tasks = [];
|
|
337
|
+
let projects = []; // 타스크가 없는 프로젝트 폴더도 상자로 뜬다
|
|
338
|
+
let sprints = {}; // 프로젝트 이름 → [{id, name, start, end}]
|
|
339
|
+
const NO_SPRINT = "없음";
|
|
340
|
+
let filter = ALL;
|
|
341
|
+
let query = "";
|
|
342
|
+
let archived = false; // 보관함을 보고 있나
|
|
343
|
+
let drawn = null; // 마지막으로 그린 내용. 같으면 다시 그리지 않는다.
|
|
344
|
+
const opened = new Set(); // 펼쳐 둔 타스크 ref·세션 id
|
|
345
|
+
// 접어 둔 프로젝트. 새로고침해도 접힌 채로 — 화면 코드가 바뀌면 탭이 스스로 다시 뜨기 때문에 브라우저에 둔다.
|
|
346
|
+
const folded = new Set((() => { try { return JSON.parse(localStorage.getItem("duet.folded") || "[]"); } catch (_) { return []; } })());
|
|
347
|
+
const rememberFolded = () => { try { localStorage.setItem("duet.folded", JSON.stringify([...folded])); } catch (_) {} };
|
|
348
|
+
let newTaskProject = ""; // 새 타스크 대화상자가 열린 프로젝트
|
|
349
|
+
let about = { version: "", repo: "", projects: 0 }; // 피드백 본문 끝에 붙일 것
|
|
350
|
+
let page = null; // 이 탭이 처음 받은 화면 도장. 서버 것이 달라지면 화면 코드가 바뀐 것이다
|
|
351
|
+
|
|
352
|
+
const $ = (s) => document.querySelector(s);
|
|
353
|
+
const el = (tag, props = {}, ...kids) => {
|
|
354
|
+
const node = Object.assign(document.createElement(tag), props);
|
|
355
|
+
for (const kid of kids.flat(2)) if (kid != null) node.append(kid);
|
|
356
|
+
return node;
|
|
357
|
+
};
|
|
358
|
+
const when = (stamp) => stamp ? stamp.replace("T", " ").split(".")[0].slice(5) : "";
|
|
359
|
+
|
|
360
|
+
// ── 화면 언어 적용 (정적 요소) ──
|
|
361
|
+
(function localize() {
|
|
362
|
+
const set = (sel, txt) => { const n = document.querySelector(sel); if (n) n.textContent = txt; };
|
|
363
|
+
const ph = (sel, txt) => { const n = document.querySelector(sel); if (n) n.placeholder = txt; };
|
|
364
|
+
const lab = (id, txt) => set(`label[for="${id}"]`, txt);
|
|
365
|
+
set("#lang", LANG === "ko" ? "EN" : "한국어");
|
|
366
|
+
document.querySelector("#lang").onclick = () => {
|
|
367
|
+
try { localStorage.setItem("duet-lang", LANG === "ko" ? "en" : "ko"); } catch {}
|
|
368
|
+
location.reload();
|
|
369
|
+
};
|
|
370
|
+
if (LANG !== "ko") return;
|
|
371
|
+
set("#feedback", "피드백");
|
|
372
|
+
document.querySelector("#exportCsv").title = "화면에 보이는 타스크를 CSV로 내려받기 (구글 시트·엑셀에서 열림)";
|
|
373
|
+
set("#exportMd", "마크다운");
|
|
374
|
+
document.querySelector("#exportMd").title = "화면에 보이는 타스크를 마크다운으로 복사";
|
|
375
|
+
document.querySelector("#feedback").title = "버그 신고·제안 — GitHub 새 이슈 창이 열립니다";
|
|
376
|
+
set("#new", "+ 새 프로젝트");
|
|
377
|
+
ph("#q", "검색");
|
|
378
|
+
set("#state", "연결 중…");
|
|
379
|
+
set("#dlgProject h2", "새 프로젝트");
|
|
380
|
+
lab("pName", "프로젝트 이름"); ph("#pName", "예: nefss");
|
|
381
|
+
lab("pTitle", "첫 타스크"); ph("#pTitle", "예: pc101pm 전환");
|
|
382
|
+
lab("pDesc", "설명 (선택)"); ph("#pDesc", "무슨 일인지 한 줄");
|
|
383
|
+
set("#pCancel", "취소"); set("#frmProject .primary", "만들기");
|
|
384
|
+
set("#tHead", "새 타스크");
|
|
385
|
+
lab("tTitle", "제목"); ph("#tTitle", "예: pc101pm 전환");
|
|
386
|
+
lab("tDesc", "설명 (선택)"); ph("#tDesc", "무슨 일인지 한 줄");
|
|
387
|
+
set("#tCancel", "취소"); set("#frmTask .primary", "만들기");
|
|
388
|
+
lab("sName", "이름"); ph("#sName", "예: 9월 3주차");
|
|
389
|
+
lab("sStart", "시작일"); lab("sEnd", "종료일");
|
|
390
|
+
set("#sNote", "이 기간에 시작한 타스크가 자동으로 묶입니다.");
|
|
391
|
+
set("#sCancel", "취소"); set("#frmSprint .primary", "만들기");
|
|
392
|
+
set("#dlgFeedback h2", "피드백");
|
|
393
|
+
lab("fbTitle", "제목"); ph("#fbTitle", "불편한 점, 있었으면 하는 것");
|
|
394
|
+
lab("fbBody", "내용 (선택)"); ph("#fbBody", "무엇을 했고, 무엇을 기대했고, 어떻게 됐는지");
|
|
395
|
+
set("#dlgFeedback .note", "여기 적은 내용이 채워진 GitHub 새 이슈 창이 열립니다. 제출은 거기서 하세요.");
|
|
396
|
+
set("#fbCancel", "취소"); set("#frmFeedback .primary", "이슈 열기");
|
|
397
|
+
})();
|
|
398
|
+
const JSON_HEADERS = { "content-type": "application/json" };
|
|
399
|
+
|
|
400
|
+
async function api(path, options) {
|
|
401
|
+
const res = await fetch(path, options);
|
|
402
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText);
|
|
403
|
+
return res.json();
|
|
404
|
+
}
|
|
405
|
+
const post = (path, body) => api(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body ?? {}) });
|
|
406
|
+
|
|
407
|
+
let toastTimer;
|
|
408
|
+
function toast(message) {
|
|
409
|
+
const node = $("#toast");
|
|
410
|
+
node.textContent = message;
|
|
411
|
+
node.classList.add("show");
|
|
412
|
+
clearTimeout(toastTimer);
|
|
413
|
+
toastTimer = setTimeout(() => node.classList.remove("show"), 2600);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* ---------- 타스크 줄 ---------- */
|
|
417
|
+
|
|
418
|
+
// 살아 있는 세션이 붙어 있는 타스크. 그 창이 일하는 중이라 사람은 손대지 않는다 —
|
|
419
|
+
// 상태는 그 세션이 끝나면 저절로 바뀌고, 제목·설명도 세션이 끝난 뒤에 고친다.
|
|
420
|
+
const busyTask = (task) => task.counts["진행중"] > 0;
|
|
421
|
+
|
|
422
|
+
function statusSelect(task) {
|
|
423
|
+
if (busyTask(task)) {
|
|
424
|
+
return el("span", { className: `tag s-${slug(task.status)}`, textContent: label(task.status),
|
|
425
|
+
title: t("A session is working on this. The status updates itself when it finishes.",
|
|
426
|
+
"세션이 작업 중입니다. 끝나면 상태가 저절로 바뀝니다.") });
|
|
427
|
+
}
|
|
428
|
+
// PMS답게 지금 상태만 보여준다. 고르면 그것이 상태가 된다 (= task.md의 `상태:` 줄).
|
|
429
|
+
const sel = el("select", { className: `sel s-${slug(task.status)}`,
|
|
430
|
+
title: t("Pick a status. It resets automatically when a new session joins this task.",
|
|
431
|
+
"상태를 고르세요. 새 세션이 이 타스크에 붙으면 자동으로 풀립니다.") });
|
|
432
|
+
if (!HUMAN.includes(task.status)) {
|
|
433
|
+
sel.append(el("option", { value: "", textContent: label(task.status), selected: true, disabled: true }));
|
|
434
|
+
}
|
|
435
|
+
for (const st of HUMAN) {
|
|
436
|
+
sel.append(el("option", { value: st, textContent: label(st), selected: task.status === st }));
|
|
437
|
+
}
|
|
438
|
+
// 고정 해제 항목은 없다 — 사람이 정한 상태는 새 세션이 그 타스크에 붙는 순간 저절로 풀린다.
|
|
439
|
+
sel.onchange = async () => {
|
|
440
|
+
try {
|
|
441
|
+
await post(`/api/tasks/${task.ref}/status`, { status: sel.value });
|
|
442
|
+
toast(`${task.id} → ${label(sel.value)}`);
|
|
443
|
+
refresh(true);
|
|
444
|
+
} catch (e) { toast(e.message); }
|
|
445
|
+
};
|
|
446
|
+
return sel;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// (자물쇠 버튼은 제거됨 — 고정 해제는 상태 드롭다운의 'Auto ↻' 항목이 담당한다)
|
|
450
|
+
|
|
451
|
+
// 제목·설명 인라인 편집. 포커스를 잃을 때만 보낸다.
|
|
452
|
+
function editable(task, field, text, className) {
|
|
453
|
+
if (busyTask(task)) {
|
|
454
|
+
return el("div", { className, textContent: text, title: t("Locked while a session is working on it.", "세션이 작업 중이라 잠시 수정할 수 없어요.") });
|
|
455
|
+
}
|
|
456
|
+
const node = el("div", { className, contentEditable: "true", spellcheck: false, textContent: text });
|
|
457
|
+
node.dataset.editing = "";
|
|
458
|
+
node.onfocus = () => { node.dataset.editing = "1"; };
|
|
459
|
+
node.onkeydown = (e) => {
|
|
460
|
+
if (e.key === "Enter" && field === "title") { e.preventDefault(); node.blur(); }
|
|
461
|
+
if (e.key === "Escape") { node.textContent = text; node.blur(); }
|
|
462
|
+
};
|
|
463
|
+
node.onblur = async () => {
|
|
464
|
+
node.dataset.editing = "";
|
|
465
|
+
const value = node.textContent.trim();
|
|
466
|
+
if (value === text) return;
|
|
467
|
+
if (field === "title" && !value) { node.textContent = text; return; }
|
|
468
|
+
try {
|
|
469
|
+
await api(`/api/tasks/${task.ref}`, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify({ [field]: value }) });
|
|
470
|
+
toast(t(`${task.id} ${field} updated`, `${task.id} ${field === "title" ? "제목" : "설명"} 수정됨`));
|
|
471
|
+
refresh(true);
|
|
472
|
+
} catch (e) { toast(e.message); node.textContent = text; }
|
|
473
|
+
};
|
|
474
|
+
return node;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 끝난 세션만 사람이 바꾼다. 살아 있는 세션은 그 프로세스가 자기 파일의 주인이라
|
|
478
|
+
// 태그만 보여주고 손대지 못하게 한다.
|
|
479
|
+
function sessionControl(task, s) {
|
|
480
|
+
if (s.alive) return el("span", { className: `tag s-${s.status}`, textContent: label(s.status) });
|
|
481
|
+
|
|
482
|
+
const sel = el("select", { className: `sel s-${s.status}`, title: "Count this stopped session as done?" });
|
|
483
|
+
for (const v of ["완료", "중지"]) {
|
|
484
|
+
sel.append(el("option", { value: v, textContent: label(v), selected: s.status === v }));
|
|
485
|
+
}
|
|
486
|
+
sel.onclick = (e) => e.stopPropagation();
|
|
487
|
+
sel.onchange = async (e) => {
|
|
488
|
+
e.stopPropagation();
|
|
489
|
+
try {
|
|
490
|
+
await post(`/api/tasks/${task.ref}/sessions/${s.id}/status`, { status: sel.value });
|
|
491
|
+
toast(`${s.title || s.id} → ${label(sel.value)}`);
|
|
492
|
+
refresh(true);
|
|
493
|
+
} catch (err) { toast(err.message); refresh(true); }
|
|
494
|
+
};
|
|
495
|
+
return sel;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function sessionRow(task, s) {
|
|
499
|
+
const logs = s.progress.map((p) => el("li", {},
|
|
500
|
+
el("time", { className: "mono", textContent: when(p.t) }),
|
|
501
|
+
el("span", { textContent: p.msg + (p.pct != null ? ` (${p.pct}%)` : "") })));
|
|
502
|
+
if (s.summary) {
|
|
503
|
+
logs.push(el("li", {},
|
|
504
|
+
el("time", { className: "mono", textContent: "→" }),
|
|
505
|
+
el("span", { style: `color:var(--${s.status})`, textContent: s.summary })));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 펼쳐 둔 세션은 다시 그려도 펼쳐진 채로. 살아 있는 세션은 30초마다 하트비트를
|
|
509
|
+
// 쓰므로, 기억해 두지 않으면 로그를 읽는 중에 접힌다.
|
|
510
|
+
const row = el("details", { className: `sess s-${s.status}`, open: opened.has(s.id) },
|
|
511
|
+
el("summary", {},
|
|
512
|
+
el("span", { className: "sd" }),
|
|
513
|
+
el("span", { className: "nm" },
|
|
514
|
+
el("b", { textContent: s.title || s.id }),
|
|
515
|
+
el("span", { textContent: `${s.client} · ${s.alive ? t("working", "작업 중") : when(s.heartbeat)}` })),
|
|
516
|
+
sessionControl(task, s)),
|
|
517
|
+
el("ul", { className: "log" },
|
|
518
|
+
logs.length ? logs : el("li", { className: "none" }, "No progress reported yet")));
|
|
519
|
+
row.ontoggle = () => { row.open ? opened.add(s.id) : opened.delete(s.id); };
|
|
520
|
+
return row;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function ghost(label, onclick) {
|
|
524
|
+
return el("button", { className: "btn ghost", type: "button", textContent: label, onclick });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// 타스크 한 줄. 누르면 설명·세션이 펼쳐진다.
|
|
528
|
+
function taskRow(task) {
|
|
529
|
+
const total = task.sessions.length;
|
|
530
|
+
|
|
531
|
+
// summary 안의 편집 칸·드롭다운은 누를 때 펼침이 같이 토글되지 않게 막는다.
|
|
532
|
+
const title = editable(task, "title", task.title, "title");
|
|
533
|
+
title.onclick = (e) => e.stopPropagation();
|
|
534
|
+
const sel = statusSelect(task);
|
|
535
|
+
sel.onclick = (e) => e.stopPropagation();
|
|
536
|
+
|
|
537
|
+
const row = el("details", { className: `trow s-${slug(task.status)}`, open: opened.has(task.ref) },
|
|
538
|
+
el("summary", {},
|
|
539
|
+
el("span", { className: "stripe", style: `background:var(--${slug(task.status)})` }),
|
|
540
|
+
el("span", { className: "tid mono", textContent: task.id }),
|
|
541
|
+
title,
|
|
542
|
+
task.warning ? el("span", { className: "warn-dot", title: task.warning, textContent: "⚠" }) : null,
|
|
543
|
+
sprintSelect(task),
|
|
544
|
+
sel,
|
|
545
|
+
busyTask(task) || archived ? null : xButton(t("Delete task", "타스크 삭제"), () => deleteTask(task))),
|
|
546
|
+
el("div", { className: "tbody" },
|
|
547
|
+
el("div", { className: "desc" }, editable(task, "description", task.description, "")),
|
|
548
|
+
task.warning ? el("div", { className: "warn", textContent: `⚠ ${task.warning}` }) : null,
|
|
549
|
+
total ? el("div", { className: "sessions" }, task.sessions.map((s) => sessionRow(task, s))) : null,
|
|
550
|
+
el("div", { className: "foot" },
|
|
551
|
+
el("span", { className: "mono", title: task.folder, textContent: task.ref }),
|
|
552
|
+
task.last_activity
|
|
553
|
+
? el("span", { className: "mono", textContent: `Last activity ${when(task.last_activity)}` })
|
|
554
|
+
: null,
|
|
555
|
+
el("span", { className: "acts" }, ghost("▲ Collapse", () => { row.open = false; })))));
|
|
556
|
+
row.ontoggle = () => { row.open ? opened.add(task.ref) : opened.delete(task.ref); };
|
|
557
|
+
return row;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 지우는 손잡이는 × 하나. 살아 있는 세션이 붙어 있으면 서버가 거절한다.
|
|
561
|
+
function xButton(title, onclick) {
|
|
562
|
+
const b = el("button", { className: "x", type: "button", textContent: "×", title });
|
|
563
|
+
b.onclick = (e) => { e.stopPropagation(); e.preventDefault(); onclick(); };
|
|
564
|
+
return b;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function remove(path, message) {
|
|
568
|
+
if (!confirm(message)) return;
|
|
569
|
+
try {
|
|
570
|
+
const res = await api(path, { method: "DELETE" });
|
|
571
|
+
toast(res.note);
|
|
572
|
+
refresh(true);
|
|
573
|
+
} catch (e) { toast(e.message); }
|
|
574
|
+
}
|
|
575
|
+
const deleteTask = (task) =>
|
|
576
|
+
remove(`/api/tasks/${task.ref}`, `[${task.ref}] ${task.title}\n` +
|
|
577
|
+
t("Delete this task and its session history? This cannot be undone.",
|
|
578
|
+
"이 타스크와 세션 기록을 삭제할까요? 되돌릴 수 없습니다."));
|
|
579
|
+
const deleteProject = (name, count) =>
|
|
580
|
+
remove(`/api/projects/${name || UNSORTED}`,
|
|
581
|
+
t(`Delete project '${name || UNSORTED}' and its ${plural(count, "task")}? This cannot be undone.`,
|
|
582
|
+
`'${name || UNSORTED}' 프로젝트와 ${plural(count, "task")}를 삭제할까요? 되돌릴 수 없습니다.`));
|
|
583
|
+
|
|
584
|
+
/* ---------- 프로젝트 상자 ---------- */
|
|
585
|
+
|
|
586
|
+
async function act(path, body, fallback) {
|
|
587
|
+
try {
|
|
588
|
+
const res = await post(path, body);
|
|
589
|
+
toast(res.note || fallback);
|
|
590
|
+
refresh(true);
|
|
591
|
+
} catch (e) { toast(e.message); }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function projectButtons(name, list) {
|
|
595
|
+
if (archived) {
|
|
596
|
+
return [el("button", { className: "btn small", type: "button", textContent: t("Unarchive", "꺼내기"),
|
|
597
|
+
onclick: () => act("/api/projects/unarchive", { name }, t("Restored", "보드로 꺼냈습니다")) })];
|
|
598
|
+
}
|
|
599
|
+
const out = [el("button", { className: "btn small", type: "button", textContent: t("+ Task", "+ 타스크"),
|
|
600
|
+
onclick: () => openTaskDialog(name) })];
|
|
601
|
+
// 살아 있는 세션이 있으면 서버가 거절한다. 여기서는 미리 숨기지 않고 이유를 토스트로 보여준다.
|
|
602
|
+
out.push(el("button", { className: "btn small", type: "button", textContent: t("Archive", "보관"),
|
|
603
|
+
title: t("Move the whole project folder to the archive. Nothing is deleted.",
|
|
604
|
+
"프로젝트 폴더째 보관함으로 옮깁니다. 지워지는 것은 없습니다."),
|
|
605
|
+
onclick: () => act("/api/projects/archive", { name }, t("Archived", "보관했습니다")) }));
|
|
606
|
+
out.push(xButton(t("Delete project", "프로젝트 삭제"), () => deleteProject(name, list.length)));
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// 프로젝트 하나가 상자 하나. 그 안에 타스크가 줄로 들어간다.
|
|
611
|
+
function projectCard(name, list) {
|
|
612
|
+
const running = list.filter((t) => t.status === "진행중").length;
|
|
613
|
+
const attention = list.filter((t) => t.status === "확인 필요").length;
|
|
614
|
+
const key = name || UNSORTED;
|
|
615
|
+
const head = el("div", { className: "phead", title: t("Click to fold or unfold", "누르면 접거나 펼칩니다") },
|
|
616
|
+
el("h2", {}, el("span", { className: "caret", textContent: "▼" }), el("span", { textContent: key })),
|
|
617
|
+
el("span", { className: "spacer" }),
|
|
618
|
+
running ? el("span", { className: "tag s-진행중", textContent: t(`${running} running`, `진행중 ${running}`) }) : null,
|
|
619
|
+
attention ? el("span", { className: "tag s-확인필요", textContent: t(`${attention} needs review`, `확인 필요 ${attention}`) }) : null,
|
|
620
|
+
el("span", { className: "pc mono", textContent: plural(list.length, "task") }),
|
|
621
|
+
el("span", { className: "acts" }, projectButtons(name, list)));
|
|
622
|
+
const card = el("article", { className: "pcard" + (folded.has(key) ? " folded" : "") },
|
|
623
|
+
head,
|
|
624
|
+
list.length
|
|
625
|
+
? el("div", { className: "trows" }, list.map(taskRow))
|
|
626
|
+
: el("div", { className: "pempty", textContent: t("No tasks yet. Add one with + Task.", "아직 타스크가 없어요 — [+ 타스크]로 추가하세요.") }));
|
|
627
|
+
// 머리 아무 데나 누르면 접힌다. 버튼(+ Task·Archive·×)은 자기 일만 한다.
|
|
628
|
+
head.onclick = (e) => {
|
|
629
|
+
if (e.target.closest("button")) return;
|
|
630
|
+
card.classList.toggle("folded");
|
|
631
|
+
card.classList.contains("folded") ? folded.add(key) : folded.delete(key);
|
|
632
|
+
rememberFolded();
|
|
633
|
+
};
|
|
634
|
+
return card;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/* ---------- 그리기 ---------- */
|
|
638
|
+
|
|
639
|
+
function tally() {
|
|
640
|
+
const c = { [ALL]: tasks.length };
|
|
641
|
+
for (const st of STATUSES) c[st] = 0;
|
|
642
|
+
for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1;
|
|
643
|
+
return c;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function draw() {
|
|
647
|
+
const c = tally();
|
|
648
|
+
const pick = (key) => { filter = filter === key ? ALL : key; drawn = null; draw(); };
|
|
649
|
+
|
|
650
|
+
// 상태 칩이 곧 필터다. 하나 누르면 그것만, 다시 누르면 전부.
|
|
651
|
+
$("#chips").replaceChildren(...[ALL, ...STATUSES].map((s) => {
|
|
652
|
+
const chip = el("button", { className: "chip" },
|
|
653
|
+
s === ALL ? null : el("span", { className: "dot", style: `background:var(--${slug(s)})` }),
|
|
654
|
+
el("span", { textContent: s === ALL ? t("All", "전체") : label(s) }),
|
|
655
|
+
el("span", { className: "n mono", textContent: String(c[s]) }));
|
|
656
|
+
chip.setAttribute("aria-pressed", String(filter === s));
|
|
657
|
+
chip.onclick = () => pick(s);
|
|
658
|
+
return chip;
|
|
659
|
+
}));
|
|
660
|
+
|
|
661
|
+
const shown = visibleTasks();
|
|
662
|
+
const q = query.trim().toLowerCase();
|
|
663
|
+
|
|
664
|
+
// 프로젝트별로 묶는다. 모르는 것은 맨 아래.
|
|
665
|
+
const groups = new Map();
|
|
666
|
+
const filtering = filter !== ALL || q;
|
|
667
|
+
if (!filtering) for (const p of projects) groups.set(p, []); // 빈 프로젝트도 상자로
|
|
668
|
+
for (const t of shown) {
|
|
669
|
+
const key = t.project || "";
|
|
670
|
+
(groups.get(key) ?? groups.set(key, []).get(key)).push(t);
|
|
671
|
+
}
|
|
672
|
+
for (const list of groups.values()) list.sort((a, b) => a.id.localeCompare(b.id));
|
|
673
|
+
// 프로젝트는 최근에 움직인 것이 위.
|
|
674
|
+
const latest = (key) => Math.max(0, ...groups.get(key).map((t) => Date.parse(t.last_activity) || 0));
|
|
675
|
+
const order = [...groups.keys()].sort((a, b) => {
|
|
676
|
+
if (!a) return 1;
|
|
677
|
+
if (!b) return -1;
|
|
678
|
+
return (latest(b) - latest(a)) || a.localeCompare(b, "ko");
|
|
679
|
+
});
|
|
680
|
+
const cards = order.map((key) => projectCard(key, groups.get(key)));
|
|
681
|
+
|
|
682
|
+
$("#grid").replaceChildren(...(cards.length ? cards : [
|
|
683
|
+
el("div", { className: "empty", textContent: tasks.length
|
|
684
|
+
? t("No tasks match.", "조건에 맞는 타스크가 없어요.")
|
|
685
|
+
: archived ? t("The archive is empty.", "보관함이 비어 있어요.")
|
|
686
|
+
: t("No projects yet. Start with + New Project.", "아직 프로젝트가 없어요 — [+ 새 프로젝트]로 시작하세요.") }),
|
|
687
|
+
]));
|
|
688
|
+
if (view === "sprints") drawTimeline();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/* ---------- 새로 읽기 ---------- */
|
|
692
|
+
|
|
693
|
+
function busy() {
|
|
694
|
+
// 고치는 중이거나 드롭다운을 만지는 중이면 다시 그리지 않는다. 커서가 사라진다.
|
|
695
|
+
const active = document.activeElement;
|
|
696
|
+
return !!active && (active.dataset?.editing === "1" || active.tagName === "SELECT");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function refresh(force = false) {
|
|
700
|
+
try {
|
|
701
|
+
const data = await api(archived ? "/api/archive" : "/api/tasks");
|
|
702
|
+
const count = archived ? new Set(data.tasks.map((t) => t.project)).size : (data.projects || []).length;
|
|
703
|
+
// 재설치로 화면 파일이 바뀌었다. 데이터는 매번 새로 읽지만 화면 코드는 탭이 들고 있으므로
|
|
704
|
+
// 여기서 한 번 스스로 새로고침한다. 편집 중이면 그 입력이 날아가니 끝날 때까지 미룬다.
|
|
705
|
+
if (page === null) page = data.page;
|
|
706
|
+
else if (data.page && data.page !== page && !busy()) { location.reload(); return; }
|
|
707
|
+
$("#pulse").classList.add("on");
|
|
708
|
+
$("#pulse").title = data.home;
|
|
709
|
+
about = { version: data.version || "", repo: data.repo || "", projects: count }; // 경로는 볼 일이 드물다. 점에 마우스를 올리면 나온다
|
|
710
|
+
$("#state").textContent = `${archived ? t("Archive", "보관함") : t("Live", "실시간")} · ${plural(count, "project")} · ${plural(data.tasks.length, "task")}`;
|
|
711
|
+
// 보관함 안에서는 같은 버튼이 "돌아가기"다. 토글이라는 걸 사람이 알 필요 없다.
|
|
712
|
+
$("#archive").textContent = archived ? t("← Board", "← 보드")
|
|
713
|
+
: (data.archived ? t(`Archived ${data.archived}`, `보관함 ${data.archived}`) : t("Archived", "보관함"));
|
|
714
|
+
|
|
715
|
+
const shape = JSON.stringify([archived, data.projects, data.tasks, data.sprints]);
|
|
716
|
+
if (!force && (busy() || shape === drawn)) return;
|
|
717
|
+
drawn = shape;
|
|
718
|
+
tasks = data.tasks;
|
|
719
|
+
projects = data.projects || [];
|
|
720
|
+
sprints = data.sprints || {};
|
|
721
|
+
draw();
|
|
722
|
+
} catch (e) {
|
|
723
|
+
$("#pulse").classList.remove("on");
|
|
724
|
+
$("#state").textContent = `${t("Offline", "연결 끊김")} — ${e.message}`;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/* ---------- 손잡이 ---------- */
|
|
729
|
+
|
|
730
|
+
$("#q").addEventListener("input", (e) => { query = e.target.value; draw(); });
|
|
731
|
+
/* ---------- 내보내기 (CSV·마크다운) — 지금 화면에 보이는 필터 결과 기준 ---------- */
|
|
732
|
+
|
|
733
|
+
// 상태 칩·검색이 적용된 타스크. 화면(draw)과 내보내기가 같은 함수를 써서 어긋나지 않는다.
|
|
734
|
+
function visibleTasks() {
|
|
735
|
+
const q = query.trim().toLowerCase();
|
|
736
|
+
const hit = (t) => !q || [t.id, t.title, t.description, t.project,
|
|
737
|
+
...t.sessions.flatMap((s) => [s.title, s.client, s.summary || "", ...s.progress.map((p) => p.msg)]),
|
|
738
|
+
].some((text) => String(text).toLowerCase().includes(q));
|
|
739
|
+
return tasks.filter((t) => (filter === ALL || t.status === filter) && hit(t));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/* ---------- 스프린트 ---------- */
|
|
743
|
+
|
|
744
|
+
function localDay(d = new Date()) {
|
|
745
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
746
|
+
}
|
|
747
|
+
const projectSprints = (name) => sprints[name || ""] || [];
|
|
748
|
+
const sprintName = (name, id) => (projectSprints(name).find((sp) => sp.id === id) || {}).name || "";
|
|
749
|
+
// 오늘이 든 스프린트 — 상자를 처음 열면 이것부터 보여준다
|
|
750
|
+
function currentSprint(name) {
|
|
751
|
+
const today = localDay();
|
|
752
|
+
return projectSprints(name).find((sp) => sp.start <= today && today <= sp.end) || null;
|
|
753
|
+
}
|
|
754
|
+
function daysLabel(sp) {
|
|
755
|
+
const today = localDay(), day = 86400000;
|
|
756
|
+
if (today < sp.start) return t(`starts in ${Math.round((new Date(sp.start) - new Date(today)) / day)}d`, `${Math.round((new Date(sp.start) - new Date(today)) / day)}일 뒤 시작`);
|
|
757
|
+
if (today > sp.end) return t("ended", "종료");
|
|
758
|
+
const left = Math.round((new Date(sp.end) - new Date(today)) / day);
|
|
759
|
+
return left === 0 ? t("last day", "오늘 마감") : `D-${left}`;
|
|
760
|
+
}
|
|
761
|
+
const mmdd = (s) => s.slice(5).replace("-", "/"); // 2026-09-15 → 09/15
|
|
762
|
+
|
|
763
|
+
/* ---------- 보기 탭: 타스크 / 스프린트 ---------- */
|
|
764
|
+
|
|
765
|
+
let view = (() => { try { return localStorage.getItem("duet.view") === "sprints" ? "sprints" : "tasks"; } catch { return "tasks"; } })();
|
|
766
|
+
function setView(v) {
|
|
767
|
+
view = v;
|
|
768
|
+
try { localStorage.setItem("duet.view", v); } catch {}
|
|
769
|
+
$("#viewTasks").hidden = v !== "tasks";
|
|
770
|
+
$("#viewSprints").hidden = v !== "sprints";
|
|
771
|
+
$("#tabTasks").setAttribute("aria-selected", String(v === "tasks"));
|
|
772
|
+
$("#tabSprints").setAttribute("aria-selected", String(v === "sprints"));
|
|
773
|
+
if (v === "sprints") drawTimeline();
|
|
774
|
+
}
|
|
775
|
+
$("#tabTasks").onclick = () => setView("tasks");
|
|
776
|
+
$("#tabSprints").onclick = () => setView("sprints");
|
|
777
|
+
|
|
778
|
+
/* ---------- 스프린트 타임라인 ---------- */
|
|
779
|
+
// 날짜 축 하나에 스프린트 구간(띠)과 타스크 막대를 그린다.
|
|
780
|
+
// 막대 = 첫 세션 시작 ~ 마지막 활동(진행 중이면 오늘). 그 안에서 실제로 세션이 있던 날은 진하게.
|
|
781
|
+
|
|
782
|
+
const DAY_MS = 86400000;
|
|
783
|
+
const addDays = (day, n) => localDay(new Date(new Date(day + "T00:00:00").getTime() + n * DAY_MS));
|
|
784
|
+
const dayDiff = (a, b) => Math.round((new Date(b + "T00:00:00") - new Date(a + "T00:00:00")) / DAY_MS);
|
|
785
|
+
let tlProject = null; // 프로젝트 이름 | ALL(전체 프로젝트)
|
|
786
|
+
let tlRange = null; // { start, end, sprint? }
|
|
787
|
+
|
|
788
|
+
function taskSpan(task) {
|
|
789
|
+
if (!task.sessions.length) return null; // 한 번도 손대지 않은 타스크는 막대가 없다
|
|
790
|
+
const start = task.started;
|
|
791
|
+
const running = task.status === "진행중";
|
|
792
|
+
const last = (task.last_activity || "").slice(0, 10) || start;
|
|
793
|
+
const end = running ? localDay() : (last < start ? start : last);
|
|
794
|
+
// 세션마다 시작일~마지막 하트비트일을 '손댄 날'로
|
|
795
|
+
const days = new Set();
|
|
796
|
+
for (const s of task.sessions) {
|
|
797
|
+
const a = (s.started || "").slice(0, 10), b = (s.heartbeat || s.started || "").slice(0, 10);
|
|
798
|
+
if (!a) continue;
|
|
799
|
+
for (let d = a; d <= (b < a ? a : b); d = addDays(d, 1)) days.add(d);
|
|
800
|
+
}
|
|
801
|
+
return { start, end, running, days };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function tlProjects() {
|
|
805
|
+
const names = new Set(tasks.map((x) => x.project || ""));
|
|
806
|
+
return [...names].sort((a, b) => (!a ? 1 : !b ? -1 : a.localeCompare(b, "ko")));
|
|
807
|
+
}
|
|
808
|
+
function defaultRange(project) {
|
|
809
|
+
if (project !== ALL) {
|
|
810
|
+
const cur = currentSprint(project);
|
|
811
|
+
if (cur) return { start: cur.start, end: cur.end, sprint: cur.id };
|
|
812
|
+
}
|
|
813
|
+
return { start: addDays(localDay(), -13), end: localDay() };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function drawTimeline() {
|
|
817
|
+
const box = $("#timeline");
|
|
818
|
+
const names = tlProjects();
|
|
819
|
+
if (tlProject === null || (tlProject !== ALL && !names.includes(tlProject))) {
|
|
820
|
+
// 처음엔 스프린트가 있는 프로젝트 중 최근 것, 없으면 전체
|
|
821
|
+
tlProject = names.find((n) => projectSprints(n).length) ?? ALL;
|
|
822
|
+
tlRange = null;
|
|
823
|
+
}
|
|
824
|
+
if (!tlRange) tlRange = defaultRange(tlProject);
|
|
825
|
+
const { start, end } = tlRange;
|
|
826
|
+
const span = dayDiff(start, end) + 1;
|
|
827
|
+
const pct = (day) => (Math.max(0, Math.min(span, dayDiff(start, day))) / span) * 100;
|
|
828
|
+
|
|
829
|
+
// ── 상단 조작부 ──
|
|
830
|
+
const projSel = el("select", { title: t("Project", "프로젝트") },
|
|
831
|
+
el("option", { value: ALL, textContent: t("All projects", "전체 프로젝트"), selected: tlProject === ALL }),
|
|
832
|
+
...names.map((n) => el("option", { value: n, textContent: n || UNSORTED, selected: tlProject === n })));
|
|
833
|
+
projSel.onchange = () => { tlProject = projSel.value; tlRange = null; drawTimeline(); };
|
|
834
|
+
const shift = (dir) => {
|
|
835
|
+
const len = span;
|
|
836
|
+
tlRange = { start: addDays(start, dir * len), end: addDays(end, dir * len) };
|
|
837
|
+
drawTimeline();
|
|
838
|
+
};
|
|
839
|
+
const spList = tlProject === ALL ? [] : projectSprints(tlProject);
|
|
840
|
+
const rangeChips = [
|
|
841
|
+
el("button", { className: "btn small", type: "button", textContent: t("2 weeks", "최근 2주"),
|
|
842
|
+
"aria-pressed": String(!tlRange.sprint && end === localDay() && span === 14),
|
|
843
|
+
onclick: () => { tlRange = { start: addDays(localDay(), -13), end: localDay() }; drawTimeline(); } }),
|
|
844
|
+
...spList.map((sp) => {
|
|
845
|
+
const b = el("button", { className: "btn small", type: "button", textContent: sp.name,
|
|
846
|
+
title: `${sp.start} ~ ${sp.end}`,
|
|
847
|
+
onclick: () => { tlRange = { start: sp.start, end: sp.end, sprint: sp.id }; drawTimeline(); } });
|
|
848
|
+
b.setAttribute("aria-pressed", String(tlRange.sprint === sp.id));
|
|
849
|
+
return b;
|
|
850
|
+
}),
|
|
851
|
+
];
|
|
852
|
+
rangeChips[0].setAttribute("aria-pressed", String(!tlRange.sprint && end === localDay() && span === 14));
|
|
853
|
+
const bar = el("div", { className: "tl-bar" },
|
|
854
|
+
projSel,
|
|
855
|
+
el("button", { className: "btn small", type: "button", textContent: "◀", onclick: () => shift(-1) }),
|
|
856
|
+
el("span", { className: "tl-range", textContent: `${mmdd(start)} ~ ${mmdd(end)}` }),
|
|
857
|
+
el("button", { className: "btn small", type: "button", textContent: "▶", onclick: () => shift(1) }),
|
|
858
|
+
...rangeChips,
|
|
859
|
+
el("span", { className: "spacer" }),
|
|
860
|
+
tlProject !== ALL && !archived
|
|
861
|
+
? el("button", { className: "btn small", type: "button", textContent: t("+ Sprint", "+ 스프린트"), onclick: () => openSprintDialog(tlProject) })
|
|
862
|
+
: null,
|
|
863
|
+
tlRange.sprint && !archived
|
|
864
|
+
? el("button", { className: "btn small", type: "button", textContent: t("Delete sprint", "스프린트 삭제"),
|
|
865
|
+
onclick: async () => {
|
|
866
|
+
const sp = spList.find((x) => x.id === tlRange.sprint);
|
|
867
|
+
if (!sp || !confirm(t(`Delete sprint '${sp.name}'? Tasks stay — only the grouping goes.`,
|
|
868
|
+
`'${sp.name}' 스프린트를 삭제할까요? 타스크는 그대로 남고 묶음만 사라집니다.`))) return;
|
|
869
|
+
try { await post("/api/sprints/delete", { project: tlProject, id: sp.id }); tlRange = null; refresh(true); }
|
|
870
|
+
catch (err) { toast(err.message); }
|
|
871
|
+
} })
|
|
872
|
+
: null);
|
|
873
|
+
|
|
874
|
+
// ── 격자 ──
|
|
875
|
+
const dayCols = [];
|
|
876
|
+
const every = span <= 21 ? 1 : span <= 45 ? 7 : 14;
|
|
877
|
+
for (let i = 0; i < span; i++) {
|
|
878
|
+
const d = addDays(start, i), dow = new Date(d + "T00:00:00").getDay();
|
|
879
|
+
dayCols.push({ d, left: (i / span) * 100, we: dow === 0 || dow === 6, tick: i % every === 0 });
|
|
880
|
+
}
|
|
881
|
+
const today = localDay();
|
|
882
|
+
const todayLine = () => (today >= start && today <= end)
|
|
883
|
+
? el("div", { className: "tl-today", style: `left:${pct(today) + 50 / span}%` }) : null;
|
|
884
|
+
const trackBg = () => dayCols.map((c) => el("div", { className: "tl-day" + (c.we ? " we" : ""), style: `left:${c.left}%;width:${100 / span}%` }));
|
|
885
|
+
const row = (labelNode, children, opts = {}) => {
|
|
886
|
+
const r = el("div", { className: "tl-row" + (opts.click ? " clickable" : "") + (opts.cls ? " " + opts.cls : "") },
|
|
887
|
+
labelNode, el("div", { className: "tl-track" }, ...trackBg(), ...children, todayLine()));
|
|
888
|
+
if (opts.click) r.onclick = opts.click;
|
|
889
|
+
if (opts.title) r.title = opts.title;
|
|
890
|
+
return r;
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
const rows = [];
|
|
894
|
+
rows.push(row(el("div", { className: "tl-label", textContent: "" }),
|
|
895
|
+
dayCols.filter((c) => c.tick).map((c) => el("span", { className: "tl-tick", style: `left:${c.left}%`, textContent: mmdd(c.d) })),
|
|
896
|
+
{ cls: "tl-head" }));
|
|
897
|
+
|
|
898
|
+
const inRange = (x) => { const sp = taskSpan(x); return sp && sp.start <= end && sp.end >= start ? sp : null; };
|
|
899
|
+
const groups = tlProject === ALL ? tlProjects() : [tlProject];
|
|
900
|
+
let shownCount = 0;
|
|
901
|
+
for (const g of groups) {
|
|
902
|
+
const list = tasks.filter((x) => (x.project || "") === g).map((x) => [x, inRange(x)]).filter(([, s]) => s)
|
|
903
|
+
.sort((a, b) => a[1].start.localeCompare(b[1].start) || a[0].id.localeCompare(b[0].id));
|
|
904
|
+
const bands = projectSprints(g).filter((sp) => sp.start <= end && sp.end >= start);
|
|
905
|
+
if (!list.length && !bands.length) continue;
|
|
906
|
+
if (tlProject === ALL) rows.push(row(el("div", { className: "tl-label grp", textContent: g || UNSORTED }), []));
|
|
907
|
+
if (bands.length) {
|
|
908
|
+
rows.push(row(el("div", { className: "tl-label", textContent: t("Sprints", "스프린트") }),
|
|
909
|
+
bands.map((sp) => {
|
|
910
|
+
const mine = tasks.filter((x) => (x.project || "") === g && x.sprint === sp.id);
|
|
911
|
+
const done = mine.filter((x) => x.status === "완료").length;
|
|
912
|
+
const l = pct(sp.start), r = pct(addDays(sp.end, 1));
|
|
913
|
+
const b = el("div", { className: "tl-band" + (sp.end < today ? " past" : "") + (tlRange.sprint === sp.id ? " on" : ""),
|
|
914
|
+
style: `left:${l}%;width:${Math.max(r - l, 0.5)}%`,
|
|
915
|
+
title: `${sp.name} · ${sp.start} ~ ${sp.end} · ${done}/${mine.length}`,
|
|
916
|
+
textContent: `${sp.name} · ${done}/${mine.length} · ${daysLabel(sp)}` });
|
|
917
|
+
b.onclick = (e) => { e.stopPropagation(); tlProject = g; tlRange = { start: sp.start, end: sp.end, sprint: sp.id }; drawTimeline(); };
|
|
918
|
+
return b;
|
|
919
|
+
})));
|
|
920
|
+
}
|
|
921
|
+
for (const [x, s] of list) {
|
|
922
|
+
shownCount++;
|
|
923
|
+
const color = `var(--${slug(x.status)})`;
|
|
924
|
+
const l = pct(s.start), r = pct(addDays(s.end, 1));
|
|
925
|
+
const parts = [el("div", { className: "tl-seg", style: `left:${l}%;width:${Math.max(r - l, 100 / span / 2)}%;background:${color}` })];
|
|
926
|
+
for (const d of s.days) {
|
|
927
|
+
if (d < start || d > end) continue;
|
|
928
|
+
parts.push(el("div", { className: "tl-act", style: `left:${pct(d) + 8 / span}%;width:${84 / span}%;background:${color}` }));
|
|
929
|
+
}
|
|
930
|
+
if (s.running) parts.push(el("div", { className: "tl-run", style: `left:${pct(s.start)}%;width:${r - pct(s.start)}%;color:${color}` }));
|
|
931
|
+
rows.push(row(
|
|
932
|
+
el("div", { className: "tl-label" }, el("span", { className: "tid mono", textContent: x.id }), document.createTextNode(x.title)),
|
|
933
|
+
parts,
|
|
934
|
+
{ title: `[${x.ref}] ${x.title} · ${label(x.status)} · ${s.start} ~ ${s.running ? t("now", "진행 중") : s.end} · ${t(`${x.sessions.length} sessions`, `세션 ${x.sessions.length}개`)}`,
|
|
935
|
+
click: () => { opened.add(x.ref); filter = ALL; query = ""; $("#q").value = ""; drawn = null; setView("tasks"); draw();
|
|
936
|
+
setTimeout(() => { const n = [...document.querySelectorAll(".trow")].find((e) => e.querySelector(".tid")?.textContent === x.id && e.closest(".pcard")?.querySelector("h2")?.textContent.includes(x.project || UNSORTED)); if (n) n.scrollIntoView({ block: "center" }); }, 50); } }));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const body = shownCount || rows.length > 1
|
|
941
|
+
? el("div", { className: "tl-body" }, el("div", { className: "tl-grid" }, ...rows))
|
|
942
|
+
: el("div", { className: "tl-empty", textContent: t("No work in this period. Move with ◀ ▶ or pick another project.",
|
|
943
|
+
"이 기간에 한 일이 없어요. ◀ ▶로 옮기거나 다른 프로젝트를 골라 보세요.") });
|
|
944
|
+
// 범례는 영어로 고정. 막대 색 = 타스크 상태 색이라, 지금 화면에 나온 상태만 색 견본으로 보여준다.
|
|
945
|
+
const shownStatuses = STATUSES.filter((st) => groups.some((g) =>
|
|
946
|
+
tasks.some((x) => (x.project || "") === g && x.status === st && inRange(x))));
|
|
947
|
+
const legend = el("div", { className: "tl-legend" },
|
|
948
|
+
...shownStatuses.map((st) => el("span", {}, el("i", { style: `background:var(--${slug(st)})` }), document.createTextNode(LABEL[st] || st))),
|
|
949
|
+
shownStatuses.length ? el("span", { style: "color:var(--line)", textContent: "|" }) : null,
|
|
950
|
+
el("span", {}, el("i", { style: "background:var(--ink-2)" }), document.createTextNode("Days worked")),
|
|
951
|
+
el("span", {}, el("i", { style: "background:var(--ink-2);opacity:.28" }), document.createTextNode("Task span")),
|
|
952
|
+
el("span", {}, el("i", { style: "background:var(--확인필요);width:2px" }), document.createTextNode("Today")),
|
|
953
|
+
el("span", { textContent: "Click a bar to open the task." }));
|
|
954
|
+
box.replaceChildren(bar, body, legend);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function sprintSelect(task) {
|
|
958
|
+
const spList = projectSprints(task.project);
|
|
959
|
+
if (!spList.length || archived) return null;
|
|
960
|
+
const sel = el("select", { className: "spsel", title: t("Sprint — auto by start date, pick to move", "스프린트 — 시작일로 자동 배정, 고르면 옮겨집니다") });
|
|
961
|
+
for (const sp of spList) sel.append(el("option", { value: sp.id, textContent: sp.name, selected: task.sprint === sp.id }));
|
|
962
|
+
sel.append(el("option", { value: NO_SPRINT, textContent: t("No sprint", "스프린트 밖"), selected: !task.sprint }));
|
|
963
|
+
sel.onclick = (e) => e.stopPropagation();
|
|
964
|
+
sel.onchange = async () => {
|
|
965
|
+
try {
|
|
966
|
+
await post(`/api/tasks/${task.ref}/sprint`, { sprint: sel.value });
|
|
967
|
+
toast(`${task.id} → ${sel.value === NO_SPRINT ? t("no sprint", "스프린트 밖") : sprintName(task.project, sel.value)}`);
|
|
968
|
+
refresh(true);
|
|
969
|
+
} catch (e) { toast(e.message); }
|
|
970
|
+
};
|
|
971
|
+
return sel;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function openSprintDialog(project) {
|
|
975
|
+
newSprintProject = project;
|
|
976
|
+
$("#frmSprint").reset();
|
|
977
|
+
// 기본값: 마지막 스프린트 다음 날부터 2주, 없으면 오늘부터 2주
|
|
978
|
+
const last = projectSprints(project).slice(-1)[0];
|
|
979
|
+
const start = last && last.end >= localDay() ? new Date(new Date(last.end).getTime() + 86400000) : new Date();
|
|
980
|
+
const end = new Date(start.getTime() + 13 * 86400000);
|
|
981
|
+
$("#sStart").value = localDay(start);
|
|
982
|
+
$("#sEnd").value = localDay(end);
|
|
983
|
+
$("#sHead").textContent = `${project || UNSORTED} — ${t("New Sprint", "새 스프린트")}`;
|
|
984
|
+
$("#dlgSprint").showModal();
|
|
985
|
+
$("#sName").focus();
|
|
986
|
+
}
|
|
987
|
+
let newSprintProject = "";
|
|
988
|
+
$("#sCancel").addEventListener("click", () => $("#dlgSprint").close());
|
|
989
|
+
$("#frmSprint").addEventListener("submit", async (e) => {
|
|
990
|
+
e.preventDefault();
|
|
991
|
+
try {
|
|
992
|
+
const sp = await post("/api/sprints", { project: newSprintProject, name: $("#sName").value, start: $("#sStart").value, end: $("#sEnd").value });
|
|
993
|
+
$("#dlgSprint").close();
|
|
994
|
+
tlProject = newSprintProject; tlRange = { start: sp.start, end: sp.end, sprint: sp.id };
|
|
995
|
+
toast(t(`Sprint '${sp.name}' created`, `'${sp.name}' 스프린트를 만들었어요`));
|
|
996
|
+
refresh(true);
|
|
997
|
+
} catch (err) { toast(err.message); }
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
function exportRows() {
|
|
1001
|
+
// 스프린트 탭에서는 타임라인에 보이는 것(프로젝트·기간), 타스크 탭에서는 칩·검색이 적용된 것
|
|
1002
|
+
const source = view === "sprints" && tlRange
|
|
1003
|
+
? tasks.filter((x) => (tlProject === ALL || (x.project || "") === tlProject)
|
|
1004
|
+
&& (() => { const s = taskSpan(x); return s && s.start <= tlRange.end && s.end >= tlRange.start; })())
|
|
1005
|
+
: visibleTasks();
|
|
1006
|
+
return source.map((x) => {
|
|
1007
|
+
const sessions = x.sessions || [];
|
|
1008
|
+
const logs = sessions.flatMap((s) => (s.progress || []).map((p) => ({ t: p.t, msg: p.msg })))
|
|
1009
|
+
.sort((a, b) => String(a.t).localeCompare(String(b.t)));
|
|
1010
|
+
const done = sessions.map((s) => s.summary).filter(Boolean);
|
|
1011
|
+
return {
|
|
1012
|
+
project: x.project || "", id: x.id, title: x.title, status: x.status,
|
|
1013
|
+
sprint: x.sprint ? sprintName(x.project, x.sprint) : "",
|
|
1014
|
+
description: x.description || "", sessions: sessions.length,
|
|
1015
|
+
last: (x.last_activity || "").replace("T", " ").split(".")[0],
|
|
1016
|
+
latest: logs.length ? logs[logs.length - 1].msg : "", summary: done.join(" / "), logs,
|
|
1017
|
+
};
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
function stamp() { return new Date().toISOString().slice(0, 10); }
|
|
1021
|
+
|
|
1022
|
+
$("#exportCsv").addEventListener("click", () => {
|
|
1023
|
+
const rows = exportRows();
|
|
1024
|
+
const head = [t("project", "프로젝트"), t("sprint", "스프린트"), t("id", "번호"), t("title", "제목"), t("status", "상태"),
|
|
1025
|
+
t("description", "설명"), t("sessions", "세션 수"), t("last activity", "마지막 활동"),
|
|
1026
|
+
t("latest log", "최근 진행"), t("summary", "마무리")];
|
|
1027
|
+
const q = (v) => `"${String(v).replace(/"/g, '""').replace(/\r?\n/g, " ")}"`;
|
|
1028
|
+
const body = rows.map((r) => [r.project, r.sprint, r.id, r.title, label(r.status), r.description,
|
|
1029
|
+
r.sessions, r.last, r.latest, r.summary].map(q).join(","));
|
|
1030
|
+
// BOM을 붙여야 엑셀이 한글을 깨뜨리지 않는다. 구글 시트는 있어도 무방.
|
|
1031
|
+
const blob = new Blob(["" + [head.map(q).join(","), ...body].join("\r\n")], { type: "text/csv;charset=utf-8" });
|
|
1032
|
+
const a = el("a", { href: URL.createObjectURL(blob), download: `duet-${archived ? "archive" : "tasks"}-${stamp()}.csv` });
|
|
1033
|
+
document.body.append(a); a.click(); a.remove();
|
|
1034
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
1035
|
+
toast(t(`Exported ${rows.length} tasks as CSV`, `타스크 ${rows.length}개를 CSV로 내보냈어요`));
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
$("#exportMd").addEventListener("click", async () => {
|
|
1039
|
+
const rows = exportRows();
|
|
1040
|
+
const byProject = new Map();
|
|
1041
|
+
for (const r of rows) {
|
|
1042
|
+
const k = r.project || UNSORTED;
|
|
1043
|
+
if (!byProject.has(k)) byProject.set(k, []);
|
|
1044
|
+
byProject.get(k).push(r);
|
|
1045
|
+
}
|
|
1046
|
+
const out = [`# Duet — ${stamp()}`, ""];
|
|
1047
|
+
for (const [proj, list] of byProject) {
|
|
1048
|
+
out.push(`## ${proj}`, "");
|
|
1049
|
+
for (const r of list) {
|
|
1050
|
+
out.push(`- **${r.id} ${r.title}** · ${label(r.status)}${r.sprint ? ` · ${r.sprint}` : ""}`);
|
|
1051
|
+
if (r.description) out.push(` - ${r.description}`);
|
|
1052
|
+
for (const l of r.logs.slice(-3)) out.push(` - ${String(l.t).slice(5, 10)} ${l.msg}`);
|
|
1053
|
+
if (r.summary) out.push(` - ✔ ${r.summary}`);
|
|
1054
|
+
}
|
|
1055
|
+
out.push("");
|
|
1056
|
+
}
|
|
1057
|
+
const md = out.join("\n");
|
|
1058
|
+
try {
|
|
1059
|
+
await navigator.clipboard.writeText(md);
|
|
1060
|
+
toast(t(`Copied ${rows.length} tasks as Markdown`, `타스크 ${rows.length}개를 마크다운으로 복사했어요`));
|
|
1061
|
+
} catch {
|
|
1062
|
+
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
|
|
1063
|
+
const a = el("a", { href: URL.createObjectURL(blob), download: `duet-${stamp()}.md` });
|
|
1064
|
+
document.body.append(a); a.click(); a.remove();
|
|
1065
|
+
toast(t("Clipboard blocked — downloaded as .md", "클립보드가 막혀 .md 파일로 내려받았어요"));
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
$("#archive").addEventListener("click", (e) => {
|
|
1070
|
+
archived = !archived;
|
|
1071
|
+
e.currentTarget.setAttribute("aria-pressed", String(archived));
|
|
1072
|
+
filter = ALL;
|
|
1073
|
+
refresh(true);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
// 한글 입력 중의 Enter는 IME가 조합을 끝내는 데 쓴다. 그 Enter로 폼이 넘어가면 안 되고,
|
|
1077
|
+
// 조합이 끝난 뒤의 Enter는 넘어가야 한다. 브라우저 기본 동작만으로는 이게 어긋난다.
|
|
1078
|
+
function submitOnEnter(input, form) {
|
|
1079
|
+
input.addEventListener("keydown", (e) => {
|
|
1080
|
+
if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); form.requestSubmit(); }
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// 새 프로젝트 = 이름 + 첫 타스크. 폴더는 첫 타스크와 함께 생긴다.
|
|
1085
|
+
$("#new").addEventListener("click", () => { $("#frmProject").reset(); $("#dlgProject").showModal(); $("#pName").focus(); });
|
|
1086
|
+
$("#pCancel").addEventListener("click", () => $("#dlgProject").close());
|
|
1087
|
+
submitOnEnter($("#pName"), $("#frmProject"));
|
|
1088
|
+
submitOnEnter($("#pTitle"), $("#frmProject"));
|
|
1089
|
+
$("#frmProject").addEventListener("submit", async (e) => {
|
|
1090
|
+
e.preventDefault();
|
|
1091
|
+
const project = $("#pName").value.trim();
|
|
1092
|
+
const title = $("#pTitle").value.trim();
|
|
1093
|
+
if (!project || !title) return;
|
|
1094
|
+
try {
|
|
1095
|
+
const task = await post("/api/tasks", { title, description: $("#pDesc").value, project });
|
|
1096
|
+
$("#dlgProject").close();
|
|
1097
|
+
toast(`Created ${project} with [${task.id}] ${task.title}`);
|
|
1098
|
+
refresh(true);
|
|
1099
|
+
} catch (err) { toast(err.message); }
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
// 프로젝트 상자 안의 새 타스크
|
|
1103
|
+
function openTaskDialog(project) {
|
|
1104
|
+
newTaskProject = project;
|
|
1105
|
+
$("#frmTask").reset();
|
|
1106
|
+
$("#tHead").textContent = `${project || UNSORTED} — New Task`;
|
|
1107
|
+
$("#dlgTask").showModal();
|
|
1108
|
+
$("#tTitle").focus();
|
|
1109
|
+
}
|
|
1110
|
+
$("#tCancel").addEventListener("click", () => $("#dlgTask").close());
|
|
1111
|
+
submitOnEnter($("#tTitle"), $("#frmTask"));
|
|
1112
|
+
$("#frmTask").addEventListener("submit", async (e) => {
|
|
1113
|
+
e.preventDefault();
|
|
1114
|
+
const title = $("#tTitle").value.trim();
|
|
1115
|
+
if (!title) return;
|
|
1116
|
+
try {
|
|
1117
|
+
const task = await post("/api/tasks", { title, description: $("#tDesc").value, project: newTaskProject });
|
|
1118
|
+
$("#dlgTask").close();
|
|
1119
|
+
toast(`Created [${task.ref}] ${task.title}`);
|
|
1120
|
+
refresh(true);
|
|
1121
|
+
} catch (err) { toast(err.message); }
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
// 피드백. GitHub 새 이슈 주소에 제목·본문을 실어 새 탭으로 연다. 토큰도 서버 호출도 없다.
|
|
1125
|
+
$("#feedback").addEventListener("click", () => { $("#frmFeedback").reset(); $("#dlgFeedback").showModal(); $("#fbTitle").focus(); });
|
|
1126
|
+
$("#fbCancel").addEventListener("click", () => $("#dlgFeedback").close());
|
|
1127
|
+
submitOnEnter($("#fbTitle"), $("#frmFeedback"));
|
|
1128
|
+
$("#frmFeedback").addEventListener("submit", (e) => {
|
|
1129
|
+
e.preventDefault();
|
|
1130
|
+
const title = $("#fbTitle").value.trim();
|
|
1131
|
+
if (!title) return;
|
|
1132
|
+
const body = `${$("#fbBody").value.trim()}\n\n---\nDuet ${about.version} · ${archived ? "archive" : "board"} · ${plural(about.projects, "project")} · ${plural(tasks.length, "task")} · ${navigator.userAgent}`;
|
|
1133
|
+
const url = `${about.repo}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
|
|
1134
|
+
window.open(url, "_blank", "noopener");
|
|
1135
|
+
$("#dlgFeedback").close();
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
setView(view);
|
|
1139
|
+
refresh();
|
|
1140
|
+
|
|
1141
|
+
// 폴더가 바뀔 때만 다시 읽는다. 서버가 /api/events로 알려준다.
|
|
1142
|
+
// 연결이 끊기면 브라우저가 알아서 다시 붙고, 그 사이는 15초 폴링으로 버틴다.
|
|
1143
|
+
let fallback = null;
|
|
1144
|
+
function listen() {
|
|
1145
|
+
const es = new EventSource("/api/events");
|
|
1146
|
+
es.addEventListener("hello", () => { clearInterval(fallback); fallback = null; refresh(); });
|
|
1147
|
+
es.addEventListener("change", () => refresh());
|
|
1148
|
+
es.addEventListener("ping", (e) => { if (page && e.data && e.data !== page) refresh(); });
|
|
1149
|
+
es.onerror = () => { if (!fallback) fallback = setInterval(refresh, 15000); };
|
|
1150
|
+
}
|
|
1151
|
+
listen();
|
|
1152
|
+
</script>
|
|
1153
|
+
</body>
|
|
1154
|
+
</html>
|