kanbango 3.4.1 → 3.5.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/AGENTS.md +2 -0
- package/CHANGELOG.md +13 -0
- package/agent-playbook.js +8 -1
- package/index.html +1222 -1314
- package/kanban.js +108 -4
- package/mcp-server.js +59 -10
- package/package.json +1 -1
- package/tests/run.js +2 -0
package/index.html
CHANGED
|
@@ -12,13 +12,15 @@
|
|
|
12
12
|
--planned: #2563eb;
|
|
13
13
|
--icebox: #64748b;
|
|
14
14
|
--done: #16a34a;
|
|
15
|
-
--bg: #
|
|
15
|
+
--bg: #e8eaef;
|
|
16
16
|
--card: #ffffff;
|
|
17
17
|
--border: #dde1e9;
|
|
18
18
|
--text: #1e293b;
|
|
19
19
|
--muted: #8fa0b8;
|
|
20
20
|
--radius: 8px;
|
|
21
|
-
--
|
|
21
|
+
--rail-w: 220px;
|
|
22
|
+
--paper-w: 380px;
|
|
23
|
+
--now-h: auto;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
body {
|
|
@@ -32,7 +34,7 @@ body {
|
|
|
32
34
|
flex-direction: column;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
/* ──
|
|
37
|
+
/* ── Topbar ── */
|
|
36
38
|
header {
|
|
37
39
|
padding: 0 16px;
|
|
38
40
|
height: 44px;
|
|
@@ -59,8 +61,11 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
59
61
|
font-family: inherit;
|
|
60
62
|
}
|
|
61
63
|
.btn-new:hover { filter: brightness(0.9); }
|
|
64
|
+
.btn-new.muted { background: #94a3b8; }
|
|
65
|
+
.btn-new.muted.on { background: #6366f1; }
|
|
66
|
+
.btn-new.secondary { background: #64748b; }
|
|
62
67
|
|
|
63
|
-
/* ── New
|
|
68
|
+
/* ── New form strips ── */
|
|
64
69
|
.new-epic-strip {
|
|
65
70
|
background: #fff;
|
|
66
71
|
border-bottom: 1px solid var(--border);
|
|
@@ -69,6 +74,7 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
69
74
|
gap: 8px;
|
|
70
75
|
align-items: center;
|
|
71
76
|
flex-shrink: 0;
|
|
77
|
+
flex-wrap: wrap;
|
|
72
78
|
}
|
|
73
79
|
.new-epic-strip.open { display: flex; }
|
|
74
80
|
.ne-field { display: flex; flex-direction: column; gap: 3px; }
|
|
@@ -90,9 +96,9 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
90
96
|
color: var(--text);
|
|
91
97
|
}
|
|
92
98
|
.ne-input:focus { border-color: var(--planned); }
|
|
93
|
-
.ne-title { width:
|
|
99
|
+
.ne-title { width: 220px; }
|
|
94
100
|
.ne-col { width: 130px; }
|
|
95
|
-
.ne-group { width:
|
|
101
|
+
.ne-group { width: 180px; }
|
|
96
102
|
.btn-sm {
|
|
97
103
|
border: none; cursor: pointer; border-radius: var(--radius);
|
|
98
104
|
font-size: 11px; padding: 4px 10px; font-weight: 600;
|
|
@@ -103,339 +109,203 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
103
109
|
.btn-cancel { background: #f1f5f9; color: var(--muted); }
|
|
104
110
|
.btn-cancel:hover { background: #e2e8f0; }
|
|
105
111
|
|
|
106
|
-
/* ──
|
|
107
|
-
|
|
112
|
+
/* ── Cockpit ── */
|
|
113
|
+
.cockpit {
|
|
108
114
|
flex: 1;
|
|
109
|
-
|
|
110
|
-
padding: 0 12px 16px;
|
|
111
|
-
}
|
|
112
|
-
#board-wrap::-webkit-scrollbar { width: 5px; }
|
|
113
|
-
#board-wrap::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
|
|
114
|
-
|
|
115
|
-
/* ── Sticky column headers ── */
|
|
116
|
-
.col-headers {
|
|
115
|
+
min-height: 0;
|
|
117
116
|
display: grid;
|
|
118
|
-
grid-template-columns: var(--
|
|
119
|
-
|
|
120
|
-
top: 0;
|
|
121
|
-
background: var(--bg);
|
|
122
|
-
z-index: 20;
|
|
123
|
-
padding: 8px 0 4px;
|
|
117
|
+
grid-template-columns: var(--rail-w) minmax(0, 1fr) var(--paper-w);
|
|
118
|
+
gap: 0;
|
|
124
119
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
|
|
121
|
+
/* ── Rail ── */
|
|
122
|
+
#rail {
|
|
123
|
+
background: #f8fafc;
|
|
124
|
+
border-right: 1px solid var(--border);
|
|
125
|
+
overflow-y: auto;
|
|
126
|
+
padding: 10px 8px;
|
|
131
127
|
display: flex;
|
|
132
|
-
|
|
133
|
-
gap:
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
gap: 4px;
|
|
134
130
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
padding: 1px 6px;
|
|
131
|
+
#rail::-webkit-scrollbar { width: 4px; }
|
|
132
|
+
#rail::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 2px; }
|
|
133
|
+
.rail-label {
|
|
139
134
|
font-size: 10px;
|
|
135
|
+
font-weight: 700;
|
|
136
|
+
text-transform: uppercase;
|
|
137
|
+
letter-spacing: 0.6px;
|
|
140
138
|
color: var(--muted);
|
|
141
|
-
|
|
139
|
+
padding: 4px 8px 6px;
|
|
142
140
|
}
|
|
143
|
-
.
|
|
144
|
-
.ch.planned { color: var(--planned); }
|
|
145
|
-
.ch.active { color: var(--active); }
|
|
146
|
-
.ch.done { color: var(--done); }
|
|
147
|
-
|
|
148
|
-
/* ── Swimlane ── */
|
|
149
|
-
.swimlane {
|
|
150
|
-
background: #fff;
|
|
151
|
-
border: 1px solid var(--border);
|
|
152
|
-
border-radius: var(--radius);
|
|
153
|
-
margin-bottom: 6px;
|
|
154
|
-
overflow: hidden;
|
|
155
|
-
}
|
|
156
|
-
.sl-header {
|
|
141
|
+
.rail-item {
|
|
157
142
|
display: flex;
|
|
158
143
|
align-items: center;
|
|
159
144
|
gap: 8px;
|
|
160
|
-
padding: 8px
|
|
161
|
-
|
|
162
|
-
border
|
|
163
|
-
|
|
145
|
+
padding: 8px 10px;
|
|
146
|
+
border-radius: 8px;
|
|
147
|
+
border: 1px solid transparent;
|
|
148
|
+
background: transparent;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
text-align: left;
|
|
151
|
+
font-family: inherit;
|
|
152
|
+
color: var(--text);
|
|
153
|
+
width: 100%;
|
|
164
154
|
}
|
|
165
|
-
.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
155
|
+
.rail-item:hover { background: #fff; border-color: var(--border); }
|
|
156
|
+
.rail-item.on {
|
|
157
|
+
background: #fff;
|
|
158
|
+
border-color: #c7d2fe;
|
|
159
|
+
box-shadow: 0 1px 3px rgba(99,102,241,0.12);
|
|
160
|
+
}
|
|
161
|
+
.rail-item .ri-title {
|
|
169
162
|
flex: 1;
|
|
170
163
|
min-width: 0;
|
|
164
|
+
font-size: 12px;
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
white-space: nowrap;
|
|
167
|
+
overflow: hidden;
|
|
168
|
+
text-overflow: ellipsis;
|
|
171
169
|
}
|
|
172
|
-
.
|
|
173
|
-
.sl-id {
|
|
170
|
+
.rail-item .ri-id {
|
|
174
171
|
font-size: 10px;
|
|
175
172
|
font-weight: 700;
|
|
176
173
|
color: var(--muted);
|
|
177
174
|
background: #e2e8f0;
|
|
178
175
|
border-radius: 4px;
|
|
179
|
-
padding: 1px
|
|
180
|
-
|
|
176
|
+
padding: 1px 5px;
|
|
177
|
+
flex-shrink: 0;
|
|
181
178
|
}
|
|
182
|
-
.
|
|
183
|
-
font-size:
|
|
179
|
+
.rail-item .ri-cnt {
|
|
180
|
+
font-size: 10px;
|
|
181
|
+
font-weight: 700;
|
|
184
182
|
color: var(--muted);
|
|
185
|
-
|
|
186
|
-
border-radius: 10px;
|
|
187
|
-
padding: 1px 6px;
|
|
188
|
-
font-weight: 600;
|
|
183
|
+
flex-shrink: 0;
|
|
189
184
|
}
|
|
190
|
-
.
|
|
191
|
-
font-size:
|
|
185
|
+
.rail-item .ri-status {
|
|
186
|
+
font-size: 9px;
|
|
192
187
|
font-weight: 700;
|
|
193
188
|
text-transform: uppercase;
|
|
194
|
-
letter-spacing: 0.
|
|
189
|
+
letter-spacing: 0.3px;
|
|
195
190
|
border-radius: 999px;
|
|
196
|
-
padding:
|
|
197
|
-
|
|
198
|
-
.sl-status.empty { background: #f1f5f9; color: #64748b; }
|
|
199
|
-
.sl-status.planned { background: #dbeafe; color: #1d4ed8; }
|
|
200
|
-
.sl-status.active { background: #ffedd5; color: #c2410c; }
|
|
201
|
-
.sl-status.done { background: #dcfce7; color: #15803d; }
|
|
202
|
-
.sl-status.archived { background: #f1f5f9; color: #64748b; }
|
|
203
|
-
.sl-progress {
|
|
204
|
-
font-size: 11px;
|
|
205
|
-
color: var(--muted);
|
|
206
|
-
white-space: nowrap;
|
|
191
|
+
padding: 1px 6px;
|
|
192
|
+
flex-shrink: 0;
|
|
207
193
|
}
|
|
208
|
-
.
|
|
194
|
+
.ri-status.empty { background: #f1f5f9; color: #64748b; }
|
|
195
|
+
.ri-status.planned { background: #dbeafe; color: #1d4ed8; }
|
|
196
|
+
.ri-status.active { background: #ffedd5; color: #c2410c; }
|
|
197
|
+
.ri-status.done { background: #dcfce7; color: #15803d; }
|
|
198
|
+
.ri-status.archived { background: #f1f5f9; color: #64748b; }
|
|
199
|
+
|
|
200
|
+
/* ── Board center ── */
|
|
201
|
+
.board-center {
|
|
209
202
|
display: flex;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
flex-direction: column;
|
|
204
|
+
min-width: 0;
|
|
205
|
+
min-height: 0;
|
|
206
|
+
overflow: hidden;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ── NOW ── */
|
|
210
|
+
#now {
|
|
213
211
|
flex-shrink: 0;
|
|
212
|
+
background: linear-gradient(180deg, #fff7ed 0%, #ffedd5 100%);
|
|
213
|
+
border-bottom: 1px solid #fed7aa;
|
|
214
|
+
padding: 10px 12px 12px;
|
|
215
|
+
max-height: 42%;
|
|
216
|
+
overflow-y: auto;
|
|
214
217
|
}
|
|
215
|
-
.
|
|
216
|
-
|
|
217
|
-
background: #fff;
|
|
218
|
-
color: var(--muted);
|
|
219
|
-
border-radius: 6px;
|
|
220
|
-
font-size: 11px;
|
|
221
|
-
font-weight: 600;
|
|
222
|
-
padding: 3px 9px;
|
|
223
|
-
cursor: pointer;
|
|
224
|
-
font-family: inherit;
|
|
225
|
-
display: inline-flex;
|
|
218
|
+
.now-head {
|
|
219
|
+
display: flex;
|
|
226
220
|
align-items: center;
|
|
227
|
-
gap:
|
|
221
|
+
gap: 8px;
|
|
222
|
+
margin-bottom: 8px;
|
|
228
223
|
}
|
|
229
|
-
.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
224
|
+
.now-title {
|
|
225
|
+
font-size: 11px;
|
|
226
|
+
font-weight: 800;
|
|
227
|
+
letter-spacing: 0.8px;
|
|
228
|
+
text-transform: uppercase;
|
|
229
|
+
color: var(--active);
|
|
234
230
|
}
|
|
235
|
-
.
|
|
236
|
-
.sl-toggle.on .chev { transform: rotate(90deg); }
|
|
237
|
-
.sl-edit-btn {
|
|
238
|
-
border: none;
|
|
239
|
-
background: #e2e8f0;
|
|
240
|
-
color: var(--muted);
|
|
241
|
-
border-radius: 6px;
|
|
231
|
+
.now-count {
|
|
242
232
|
font-size: 10px;
|
|
243
233
|
font-weight: 700;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
.sl-edit-btn:hover { background: #cbd5e1; color: var(--text); }
|
|
249
|
-
.sl-edit-btn.active { background: #c7d2fe; color: #3730a3; }
|
|
250
|
-
|
|
251
|
-
/* ── Epic detail panel (swimlane context) ── */
|
|
252
|
-
.sl-epic-shell {
|
|
253
|
-
border-bottom: 1px solid var(--border);
|
|
254
|
-
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
|
255
|
-
}
|
|
256
|
-
.sl-epic-detail {
|
|
257
|
-
padding: 12px 14px 14px;
|
|
258
|
-
display: flex;
|
|
259
|
-
flex-direction: column;
|
|
260
|
-
gap: 10px;
|
|
261
|
-
font-size: 12px;
|
|
234
|
+
background: rgba(232,93,4,0.12);
|
|
235
|
+
color: var(--active);
|
|
236
|
+
border-radius: 10px;
|
|
237
|
+
padding: 1px 7px;
|
|
262
238
|
}
|
|
263
|
-
.
|
|
239
|
+
.now-cards {
|
|
264
240
|
display: flex;
|
|
265
241
|
flex-wrap: wrap;
|
|
266
|
-
gap: 8px
|
|
267
|
-
align-items: flex-start;
|
|
268
|
-
justify-content: space-between;
|
|
269
|
-
}
|
|
270
|
-
.sl-epic-goals {
|
|
271
|
-
flex: 1;
|
|
272
|
-
min-width: 220px;
|
|
273
|
-
background: #fff;
|
|
274
|
-
border: 1px solid #e2e8f0;
|
|
275
|
-
border-left: 3px solid #6366f1;
|
|
276
|
-
border-radius: 8px;
|
|
277
|
-
padding: 10px 12px;
|
|
278
|
-
}
|
|
279
|
-
.sl-epic-goals .sl-epic-label { color: #6366f1; }
|
|
280
|
-
.sl-epic-goals .sl-epic-text {
|
|
281
|
-
font-size: 13px;
|
|
282
|
-
font-weight: 600;
|
|
283
|
-
line-height: 1.45;
|
|
284
|
-
color: #1e293b;
|
|
242
|
+
gap: 8px;
|
|
285
243
|
}
|
|
286
|
-
.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
gap: 6px;
|
|
290
|
-
align-content: flex-start;
|
|
244
|
+
.now-cards .card {
|
|
245
|
+
width: 240px;
|
|
246
|
+
max-width: 100%;
|
|
291
247
|
}
|
|
292
|
-
.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
border-radius: 999px;
|
|
299
|
-
padding: 3px 10px;
|
|
300
|
-
font-size: 11px;
|
|
301
|
-
font-weight: 600;
|
|
302
|
-
color: #475569;
|
|
248
|
+
.now-empty {
|
|
249
|
+
font-size: 12px;
|
|
250
|
+
color: #c2410c;
|
|
251
|
+
opacity: 0.7;
|
|
252
|
+
font-style: italic;
|
|
253
|
+
padding: 6px 0;
|
|
303
254
|
}
|
|
304
|
-
.sl-chip strong { color: #0f172a; font-weight: 700; }
|
|
305
|
-
.sl-chip.done { border-color: #bbf7d0; background: #f0fdf4; color: #166534; }
|
|
306
|
-
.sl-chip.active { border-color: #fed7aa; background: #fff7ed; color: #c2410c; }
|
|
307
|
-
.sl-chip.planned { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; }
|
|
308
|
-
.sl-chip.icebox { border-color: #e2e8f0; background: #f8fafc; color: #64748b; }
|
|
309
255
|
|
|
310
|
-
|
|
256
|
+
/* ── Columns ── */
|
|
257
|
+
#board-cols {
|
|
258
|
+
flex: 1;
|
|
259
|
+
min-height: 0;
|
|
311
260
|
display: grid;
|
|
312
|
-
grid-template-columns:
|
|
313
|
-
gap:
|
|
314
|
-
|
|
315
|
-
@media (max-width: 900px) {
|
|
316
|
-
.sl-epic-body { grid-template-columns: 1fr; }
|
|
261
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
262
|
+
gap: 0;
|
|
263
|
+
overflow: hidden;
|
|
317
264
|
}
|
|
318
|
-
.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
border-radius: 8px;
|
|
322
|
-
padding: 10px 12px;
|
|
265
|
+
.col {
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-direction: column;
|
|
323
268
|
min-width: 0;
|
|
269
|
+
border-right: 1px solid var(--border);
|
|
270
|
+
background: #f4f5f8;
|
|
324
271
|
}
|
|
325
|
-
.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
.sl-epic-label {
|
|
331
|
-
font-size: 10px;
|
|
272
|
+
.col:last-child { border-right: none; }
|
|
273
|
+
.col-head {
|
|
274
|
+
padding: 8px 12px;
|
|
275
|
+
font-size: 11px;
|
|
332
276
|
font-weight: 700;
|
|
333
277
|
text-transform: uppercase;
|
|
334
|
-
letter-spacing: 0.
|
|
335
|
-
color: var(--muted);
|
|
336
|
-
margin-bottom: 6px;
|
|
337
|
-
}
|
|
338
|
-
.sl-epic-text {
|
|
339
|
-
color: #334155;
|
|
340
|
-
white-space: pre-wrap;
|
|
341
|
-
line-height: 1.5;
|
|
342
|
-
word-break: break-word;
|
|
343
|
-
}
|
|
344
|
-
.sl-epic-text.empty { color: #94a3b8; font-style: italic; }
|
|
345
|
-
.sl-epic-list {
|
|
346
|
-
margin: 0;
|
|
347
|
-
padding: 0;
|
|
348
|
-
list-style: none;
|
|
278
|
+
letter-spacing: 0.7px;
|
|
349
279
|
display: flex;
|
|
350
|
-
|
|
351
|
-
gap:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
padding: 4px 8px 4px 22px;
|
|
356
|
-
background: #f8fafc;
|
|
357
|
-
border-radius: 6px;
|
|
358
|
-
line-height: 1.4;
|
|
359
|
-
color: #334155;
|
|
360
|
-
}
|
|
361
|
-
.sl-epic-list li::before {
|
|
362
|
-
content: "";
|
|
363
|
-
position: absolute;
|
|
364
|
-
left: 8px;
|
|
365
|
-
top: 10px;
|
|
366
|
-
width: 6px;
|
|
367
|
-
height: 6px;
|
|
368
|
-
border-radius: 50%;
|
|
369
|
-
background: #6366f1;
|
|
370
|
-
}
|
|
371
|
-
.sl-epic-list.out li {
|
|
372
|
-
background: #f8fafc;
|
|
373
|
-
color: #94a3b8;
|
|
374
|
-
}
|
|
375
|
-
.sl-epic-list.out li::before { background: #cbd5e1; }
|
|
376
|
-
.sl-epic-notes {
|
|
377
|
-
border-style: dashed;
|
|
378
|
-
color: #64748b;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
.sl-epic-edit-grid {
|
|
382
|
-
display: grid;
|
|
383
|
-
grid-template-columns: 1fr 1fr;
|
|
384
|
-
gap: 8px 12px;
|
|
385
|
-
padding: 12px 14px 14px;
|
|
280
|
+
align-items: center;
|
|
281
|
+
gap: 6px;
|
|
282
|
+
flex-shrink: 0;
|
|
283
|
+
background: #eef0f3;
|
|
284
|
+
border-bottom: 1px solid var(--border);
|
|
386
285
|
}
|
|
387
|
-
.
|
|
388
|
-
.
|
|
389
|
-
|
|
286
|
+
.col-head.icebox { color: var(--icebox); }
|
|
287
|
+
.col-head.planned { color: var(--planned); }
|
|
288
|
+
.col-head.done { color: var(--done); }
|
|
289
|
+
.ch-count {
|
|
290
|
+
background: rgba(0,0,0,0.07);
|
|
291
|
+
border-radius: 10px;
|
|
292
|
+
padding: 1px 6px;
|
|
390
293
|
font-size: 10px;
|
|
391
|
-
font-weight: 700;
|
|
392
|
-
text-transform: uppercase;
|
|
393
|
-
letter-spacing: 0.5px;
|
|
394
294
|
color: var(--muted);
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
.sl-epic-edit-grid input,
|
|
398
|
-
.sl-epic-edit-grid textarea {
|
|
399
|
-
width: 100%;
|
|
400
|
-
border: 1px solid var(--border);
|
|
401
|
-
border-radius: var(--radius);
|
|
402
|
-
padding: 5px 8px;
|
|
403
|
-
font-size: 12px;
|
|
404
|
-
font-family: inherit;
|
|
405
|
-
color: var(--text);
|
|
406
|
-
box-sizing: border-box;
|
|
407
|
-
resize: vertical;
|
|
408
|
-
background: #fff;
|
|
409
|
-
}
|
|
410
|
-
.sl-epic-edit-grid textarea { min-height: 52px; }
|
|
411
|
-
.sl-epic-edit-actions {
|
|
412
|
-
grid-column: 1 / -1;
|
|
413
|
-
display: flex;
|
|
414
|
-
gap: 8px;
|
|
415
|
-
justify-content: flex-end;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/* ── Swimlane body grid ── */
|
|
419
|
-
.sl-body {
|
|
420
|
-
display: grid;
|
|
421
|
-
grid-template-columns: var(--col-w);
|
|
422
|
-
min-height: 60px;
|
|
295
|
+
font-weight: 600;
|
|
423
296
|
}
|
|
424
|
-
.
|
|
425
|
-
|
|
426
|
-
|
|
297
|
+
.col-body {
|
|
298
|
+
flex: 1;
|
|
299
|
+
overflow-y: auto;
|
|
427
300
|
padding: 8px;
|
|
428
|
-
min-height: 56px;
|
|
429
|
-
border-right: 1px solid #f1f5f9;
|
|
430
301
|
display: flex;
|
|
431
302
|
flex-direction: column;
|
|
432
303
|
gap: 6px;
|
|
433
304
|
}
|
|
434
|
-
.
|
|
435
|
-
.
|
|
436
|
-
.lane.col-done { background: #fafdfb; }
|
|
305
|
+
.col-body::-webkit-scrollbar { width: 4px; }
|
|
306
|
+
.col-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 2px; }
|
|
437
307
|
|
|
438
|
-
/* ──
|
|
308
|
+
/* ── Cards ── */
|
|
439
309
|
.card {
|
|
440
310
|
background: var(--card);
|
|
441
311
|
border-radius: var(--radius);
|
|
@@ -447,16 +317,17 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
447
317
|
position: relative;
|
|
448
318
|
}
|
|
449
319
|
.card:hover { box-shadow: 0 3px 10px rgba(0,0,0,0.11); }
|
|
450
|
-
|
|
320
|
+
.card.selected {
|
|
321
|
+
box-shadow: 0 0 0 2px var(--planned);
|
|
322
|
+
}
|
|
451
323
|
.card.col-planned { border-left-color: var(--planned); }
|
|
452
324
|
.card.col-active {
|
|
453
325
|
border-left-color: var(--active);
|
|
454
326
|
background: #fffdf9;
|
|
455
327
|
box-shadow: 0 2px 6px rgba(232,93,4,0.10);
|
|
456
328
|
}
|
|
457
|
-
.card.col-
|
|
458
|
-
|
|
459
|
-
/* Icebox: tile style */
|
|
329
|
+
.card.col-active.selected { box-shadow: 0 0 0 2px var(--active); }
|
|
330
|
+
.card.col-done { border-left-color: var(--done); opacity: 0.7; }
|
|
460
331
|
.card.col-icebox {
|
|
461
332
|
background: #edf0f7;
|
|
462
333
|
border-left: none;
|
|
@@ -473,107 +344,69 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
473
344
|
background: rgba(0,0,0,0.05); border-radius: 4px;
|
|
474
345
|
padding: 1px 5px; white-space: nowrap;
|
|
475
346
|
}
|
|
476
|
-
.card
|
|
477
|
-
.card-title { font-size:
|
|
478
|
-
.card.col-icebox .card-title { font-size: 13px; font-weight: 500; color: #475569; }
|
|
347
|
+
.card-title { font-size: 13px; font-weight: 600; line-height: 1.3; word-break: break-word; color: var(--text); }
|
|
348
|
+
.card.col-icebox .card-title { font-size: 12px; font-weight: 500; color: #475569; }
|
|
479
349
|
.card.col-done .card-title { text-decoration: line-through; color: var(--muted); }
|
|
480
350
|
|
|
481
|
-
/* ── Progress bar ── */
|
|
482
351
|
.prog-row { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
|
|
483
352
|
.prog-bar { flex: 1; height: 4px; background: #dde1e9; border-radius: 2px; overflow: hidden; }
|
|
484
|
-
.prog-fill { height: 100%; border-radius: 2px;
|
|
353
|
+
.prog-fill { height: 100%; border-radius: 2px; }
|
|
485
354
|
.prog-fill.planned { background: var(--planned); }
|
|
486
355
|
.prog-fill.active { background: var(--active); }
|
|
487
356
|
.prog-fill.done { background: var(--done); }
|
|
488
357
|
.prog-fill.icebox { background: var(--icebox); }
|
|
489
358
|
.prog-txt { font-size: 10px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
490
359
|
|
|
491
|
-
/* ── Card action bar ── */
|
|
492
360
|
.card-actions {
|
|
493
361
|
display: flex; gap: 4px; margin-top: 8px;
|
|
494
362
|
opacity: 0; transition: opacity 0.12s;
|
|
495
363
|
}
|
|
496
364
|
.card:hover .card-actions,
|
|
497
|
-
.card.
|
|
498
|
-
.card.editing .card-actions { opacity: 1; }
|
|
365
|
+
.card.selected .card-actions { opacity: 1; }
|
|
499
366
|
|
|
500
367
|
.cbtn {
|
|
501
368
|
border: none; cursor: pointer; border-radius: 5px;
|
|
502
369
|
font-size: 11px; padding: 3px 8px; font-weight: 600;
|
|
503
370
|
font-family: inherit; background: #edf0f5; color: var(--muted);
|
|
504
|
-
transition: background 0.1s, color 0.1s;
|
|
505
371
|
}
|
|
506
372
|
.cbtn:hover { background: #dde4f0; color: var(--text); }
|
|
507
|
-
|
|
508
|
-
/* Arrow move buttons */
|
|
509
|
-
.cbtn.arr {
|
|
510
|
-
padding: 3px 9px;
|
|
511
|
-
font-size: 13px;
|
|
512
|
-
line-height: 1;
|
|
513
|
-
}
|
|
373
|
+
.cbtn.arr { padding: 3px 9px; font-size: 13px; line-height: 1; }
|
|
514
374
|
.cbtn.arr:hover { background: #d0d8f0; color: var(--planned); }
|
|
515
|
-
.card.col-active .cbtn.arr:hover { background: #ffe4d0; color: var(--active); }
|
|
516
|
-
|
|
517
|
-
.cbtn.edit-btn { color: #5a7ab5; }
|
|
518
|
-
.cbtn.edit-btn:hover { background: #e8efff; color: var(--planned); }
|
|
519
375
|
|
|
520
|
-
|
|
521
|
-
.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
border-top: 1px solid #edf0f5;
|
|
526
|
-
}
|
|
527
|
-
.tasks-list {
|
|
528
|
-
display: flex; flex-direction: column; gap: 4px;
|
|
529
|
-
}
|
|
530
|
-
.task-row { display: flex; align-items: flex-start; gap: 7px; }
|
|
531
|
-
.task-row input[type="checkbox"] {
|
|
532
|
-
margin-top: 2px; cursor: pointer; flex-shrink: 0;
|
|
533
|
-
accent-color: var(--planned);
|
|
376
|
+
.lane-add {
|
|
377
|
+
background: none; border: 1.5px dashed #d1d5db; border-radius: var(--radius);
|
|
378
|
+
padding: 5px 10px; font-size: 11px; color: var(--muted); cursor: pointer;
|
|
379
|
+
width: 100%; text-align: left; font-family: inherit;
|
|
380
|
+
transition: all 0.15s; margin-top: 2px;
|
|
534
381
|
}
|
|
535
|
-
.
|
|
536
|
-
|
|
537
|
-
.
|
|
382
|
+
.lane-add:hover { border-color: var(--planned); color: var(--planned); background: #eff6ff; }
|
|
383
|
+
|
|
384
|
+
.lane-addinp {
|
|
385
|
+
background: #fff;
|
|
386
|
+
border: 1px solid var(--border);
|
|
387
|
+
border-radius: var(--radius);
|
|
388
|
+
padding: 7px;
|
|
389
|
+
margin-top: 2px;
|
|
538
390
|
display: flex;
|
|
539
391
|
flex-direction: column;
|
|
540
|
-
gap:
|
|
392
|
+
gap: 6px;
|
|
541
393
|
}
|
|
542
|
-
.
|
|
543
|
-
font-size:
|
|
394
|
+
.lane-add-label {
|
|
395
|
+
font-size: 10px;
|
|
396
|
+
font-weight: 700;
|
|
397
|
+
text-transform: uppercase;
|
|
398
|
+
letter-spacing: 0.6px;
|
|
544
399
|
color: var(--muted);
|
|
545
|
-
line-height: 1.4;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/* ── Edit mode ── */
|
|
549
|
-
.card.editing { box-shadow: 0 0 0 2px var(--planned); cursor: default; }
|
|
550
|
-
|
|
551
|
-
.edit-title-input {
|
|
552
|
-
width: 100%; border: 1px solid var(--planned); border-radius: 4px;
|
|
553
|
-
padding: 4px 7px; font-size: 13px; font-weight: 500; font-family: inherit;
|
|
554
|
-
color: var(--text); outline: none; margin-top: 2px;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
.edit-tasks { margin-top: 9px; border-top: 1px solid #f1f5f9; padding-top: 9px; }
|
|
558
|
-
.edit-task-row {
|
|
559
|
-
display: flex; align-items: center; gap: 6px; margin-bottom: 4px;
|
|
560
400
|
}
|
|
561
|
-
.
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
.edit-task-fields {
|
|
570
|
-
flex: 1;
|
|
571
|
-
display: flex;
|
|
572
|
-
flex-direction: column;
|
|
573
|
-
gap: 4px;
|
|
401
|
+
.lane-add-title {
|
|
402
|
+
width: 100%;
|
|
403
|
+
border: 1px solid var(--planned);
|
|
404
|
+
border-radius: 4px;
|
|
405
|
+
padding: 4px 7px;
|
|
406
|
+
font-size: 12px;
|
|
407
|
+
font-family: inherit;
|
|
408
|
+
outline: none;
|
|
574
409
|
}
|
|
575
|
-
.edit-task-desc,
|
|
576
|
-
.edit-block,
|
|
577
410
|
.lane-add-textarea {
|
|
578
411
|
width: 100%;
|
|
579
412
|
border: 1px solid #d7deea;
|
|
@@ -584,75 +417,106 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
584
417
|
color: var(--text);
|
|
585
418
|
outline: none;
|
|
586
419
|
resize: vertical;
|
|
587
|
-
min-height:
|
|
420
|
+
min-height: 48px;
|
|
588
421
|
background: #fff;
|
|
589
422
|
}
|
|
590
|
-
.
|
|
591
|
-
|
|
423
|
+
.lane-add-actions { display: flex; gap: 6px; }
|
|
424
|
+
|
|
425
|
+
/* ── Paper ── */
|
|
426
|
+
#paper {
|
|
427
|
+
background: #fff;
|
|
428
|
+
border-left: 1px solid var(--border);
|
|
429
|
+
display: flex;
|
|
430
|
+
flex-direction: column;
|
|
431
|
+
min-height: 0;
|
|
432
|
+
overflow: hidden;
|
|
592
433
|
}
|
|
593
|
-
.
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
434
|
+
.paper-empty {
|
|
435
|
+
flex: 1;
|
|
436
|
+
display: flex;
|
|
437
|
+
align-items: center;
|
|
438
|
+
justify-content: center;
|
|
439
|
+
color: var(--muted);
|
|
440
|
+
font-size: 13px;
|
|
441
|
+
padding: 24px;
|
|
442
|
+
text-align: center;
|
|
443
|
+
line-height: 1.5;
|
|
597
444
|
}
|
|
598
|
-
.
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
445
|
+
.paper-head {
|
|
446
|
+
padding: 12px 14px 10px;
|
|
447
|
+
border-bottom: 1px solid var(--border);
|
|
448
|
+
flex-shrink: 0;
|
|
449
|
+
display: flex;
|
|
450
|
+
flex-direction: column;
|
|
451
|
+
gap: 8px;
|
|
602
452
|
}
|
|
603
|
-
.
|
|
453
|
+
.paper-kicker {
|
|
454
|
+
display: flex;
|
|
455
|
+
align-items: center;
|
|
456
|
+
gap: 6px;
|
|
457
|
+
flex-wrap: wrap;
|
|
458
|
+
}
|
|
459
|
+
.paper-kind {
|
|
604
460
|
font-size: 10px;
|
|
605
|
-
font-weight:
|
|
606
|
-
text-transform: uppercase;
|
|
461
|
+
font-weight: 800;
|
|
607
462
|
letter-spacing: 0.6px;
|
|
463
|
+
text-transform: uppercase;
|
|
608
464
|
color: var(--muted);
|
|
609
|
-
margin-bottom: 4px;
|
|
610
465
|
}
|
|
611
|
-
.
|
|
612
|
-
font-size:
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
466
|
+
.paper-badge {
|
|
467
|
+
font-size: 10px;
|
|
468
|
+
font-weight: 700;
|
|
469
|
+
background: #e2e8f0;
|
|
470
|
+
color: #475569;
|
|
471
|
+
border-radius: 4px;
|
|
472
|
+
padding: 1px 6px;
|
|
616
473
|
}
|
|
617
|
-
.
|
|
618
|
-
|
|
474
|
+
.paper-badge.col-active { background: #ffedd5; color: #c2410c; }
|
|
475
|
+
.paper-badge.col-planned { background: #dbeafe; color: #1d4ed8; }
|
|
476
|
+
.paper-badge.col-icebox { background: #e2e8f0; color: #64748b; }
|
|
477
|
+
.paper-badge.col-done { background: #dcfce7; color: #15803d; }
|
|
478
|
+
.paper-title {
|
|
479
|
+
font-size: 16px;
|
|
480
|
+
font-weight: 700;
|
|
481
|
+
line-height: 1.3;
|
|
482
|
+
word-break: break-word;
|
|
619
483
|
}
|
|
620
|
-
.
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
border: 1px solid var(--border);
|
|
625
|
-
border-radius: var(--radius);
|
|
626
|
-
overflow-x: auto;
|
|
627
|
-
text-align: center;
|
|
484
|
+
.paper-actions {
|
|
485
|
+
display: flex;
|
|
486
|
+
flex-wrap: wrap;
|
|
487
|
+
gap: 6px;
|
|
628
488
|
}
|
|
629
|
-
.
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
padding:
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
text-align: left;
|
|
489
|
+
.paper-body {
|
|
490
|
+
flex: 1;
|
|
491
|
+
overflow-y: auto;
|
|
492
|
+
padding: 12px 14px 20px;
|
|
493
|
+
display: flex;
|
|
494
|
+
flex-direction: column;
|
|
495
|
+
gap: 12px;
|
|
637
496
|
}
|
|
638
|
-
.
|
|
497
|
+
.paper-body::-webkit-scrollbar { width: 5px; }
|
|
498
|
+
.paper-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 3px; }
|
|
499
|
+
|
|
500
|
+
.detail-section {
|
|
501
|
+
padding-top: 2px;
|
|
502
|
+
}
|
|
503
|
+
.detail-label {
|
|
639
504
|
font-size: 10px;
|
|
640
505
|
font-weight: 700;
|
|
641
506
|
text-transform: uppercase;
|
|
642
|
-
letter-spacing: 0.
|
|
643
|
-
color:
|
|
644
|
-
margin-bottom:
|
|
507
|
+
letter-spacing: 0.6px;
|
|
508
|
+
color: var(--muted);
|
|
509
|
+
margin-bottom: 5px;
|
|
645
510
|
}
|
|
646
|
-
.
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
511
|
+
.detail-text {
|
|
512
|
+
font-size: 12px;
|
|
513
|
+
line-height: 1.5;
|
|
514
|
+
color: var(--text);
|
|
650
515
|
white-space: pre-wrap;
|
|
651
516
|
word-break: break-word;
|
|
652
|
-
color: var(--text);
|
|
653
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
654
517
|
}
|
|
655
|
-
.
|
|
518
|
+
.detail-text .md-text { white-space: pre-wrap; }
|
|
519
|
+
.detail-text.empty { color: #94a3b8; font-style: italic; }
|
|
656
520
|
.detail-list {
|
|
657
521
|
margin: 0;
|
|
658
522
|
padding-left: 18px;
|
|
@@ -664,86 +528,167 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
664
528
|
border-left: 3px solid #e8b4b4;
|
|
665
529
|
padding-left: 8px;
|
|
666
530
|
}
|
|
667
|
-
.detail-section.out-of-scope .detail-label {
|
|
668
|
-
|
|
531
|
+
.detail-section.out-of-scope .detail-label { color: #a86a6a; }
|
|
532
|
+
.detail-section.out-of-scope .detail-list { color: #7a5a5a; }
|
|
533
|
+
|
|
534
|
+
.scope-pair {
|
|
535
|
+
display: grid;
|
|
536
|
+
grid-template-columns: 1fr 1fr;
|
|
537
|
+
gap: 10px;
|
|
538
|
+
}
|
|
539
|
+
.chip-row {
|
|
540
|
+
display: flex;
|
|
541
|
+
flex-wrap: wrap;
|
|
542
|
+
gap: 6px;
|
|
669
543
|
}
|
|
670
|
-
.
|
|
671
|
-
|
|
544
|
+
.sl-chip {
|
|
545
|
+
display: inline-flex;
|
|
546
|
+
align-items: center;
|
|
547
|
+
gap: 4px;
|
|
548
|
+
background: #f8fafc;
|
|
549
|
+
border: 1px solid #e2e8f0;
|
|
550
|
+
border-radius: 999px;
|
|
551
|
+
padding: 3px 10px;
|
|
552
|
+
font-size: 11px;
|
|
553
|
+
font-weight: 600;
|
|
554
|
+
color: #475569;
|
|
672
555
|
}
|
|
673
|
-
.
|
|
674
|
-
|
|
675
|
-
|
|
556
|
+
.sl-chip strong { color: #0f172a; font-weight: 700; }
|
|
557
|
+
.sl-chip.done { border-color: #bbf7d0; background: #f0fdf4; color: #166534; }
|
|
558
|
+
.sl-chip.active { border-color: #fed7aa; background: #fff7ed; color: #c2410c; }
|
|
559
|
+
.sl-chip.planned { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; }
|
|
560
|
+
.sl-chip.icebox { border-color: #e2e8f0; background: #f8fafc; color: #64748b; }
|
|
561
|
+
|
|
562
|
+
.subtask-label {
|
|
563
|
+
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
|
564
|
+
letter-spacing: 0.6px; color: var(--muted);
|
|
565
|
+
margin-bottom: 6px;
|
|
676
566
|
}
|
|
677
|
-
.
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
padding-top: 8px;
|
|
683
|
-
border-top: 1px solid #f1f5f9;
|
|
567
|
+
.tasks-list { display: flex; flex-direction: column; gap: 4px; }
|
|
568
|
+
.task-row { display: flex; align-items: flex-start; gap: 7px; }
|
|
569
|
+
.task-row input[type="checkbox"] {
|
|
570
|
+
margin-top: 2px; cursor: pointer; flex-shrink: 0;
|
|
571
|
+
accent-color: var(--planned);
|
|
684
572
|
}
|
|
685
|
-
.
|
|
573
|
+
.task-row label { font-size: 12px; cursor: pointer; line-height: 1.4; color: var(--text); }
|
|
574
|
+
.task-row.done label { text-decoration: line-through; color: var(--muted); }
|
|
575
|
+
.task-copy { display: flex; flex-direction: column; gap: 2px; }
|
|
576
|
+
.task-desc { font-size: 11px; color: var(--muted); line-height: 1.4; }
|
|
577
|
+
|
|
578
|
+
.adr-card, .evidence-card {
|
|
579
|
+
background: #f8fafc;
|
|
580
|
+
border: 1px solid #e2e8f0;
|
|
581
|
+
border-radius: 8px;
|
|
582
|
+
padding: 8px 10px;
|
|
583
|
+
margin-bottom: 6px;
|
|
584
|
+
}
|
|
585
|
+
.adr-card .adr-decision { font-size: 12px; font-weight: 700; margin-bottom: 3px; }
|
|
586
|
+
.adr-card .adr-why { font-size: 12px; color: #475569; line-height: 1.4; }
|
|
587
|
+
.adr-card .adr-meta, .evidence-card .ev-meta {
|
|
588
|
+
font-size: 10px; color: var(--muted); margin-top: 4px;
|
|
589
|
+
}
|
|
590
|
+
.evidence-card .ev-cmd {
|
|
591
|
+
font-size: 11px;
|
|
592
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
686
593
|
background: #fff;
|
|
687
|
-
border:
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
display: flex;
|
|
692
|
-
flex-direction: column;
|
|
693
|
-
gap: 6px;
|
|
594
|
+
border-radius: 4px;
|
|
595
|
+
padding: 4px 6px;
|
|
596
|
+
margin-bottom: 4px;
|
|
597
|
+
word-break: break-all;
|
|
694
598
|
}
|
|
695
|
-
.
|
|
696
|
-
font-size:
|
|
697
|
-
font-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
599
|
+
.evidence-card .ev-diff {
|
|
600
|
+
font-size: 11px;
|
|
601
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
602
|
+
white-space: pre-wrap;
|
|
603
|
+
max-height: 120px;
|
|
604
|
+
overflow: auto;
|
|
605
|
+
color: #334155;
|
|
701
606
|
}
|
|
702
|
-
|
|
607
|
+
|
|
608
|
+
/* Paper edit */
|
|
609
|
+
.edit-title-input {
|
|
610
|
+
width: 100%; border: 1px solid var(--planned); border-radius: 4px;
|
|
611
|
+
padding: 6px 8px; font-size: 15px; font-weight: 600; font-family: inherit;
|
|
612
|
+
color: var(--text); outline: none;
|
|
613
|
+
}
|
|
614
|
+
.edit-block, .edit-task-desc {
|
|
703
615
|
width: 100%;
|
|
704
|
-
border: 1px solid
|
|
616
|
+
border: 1px solid #d7deea;
|
|
705
617
|
border-radius: 4px;
|
|
706
|
-
padding:
|
|
618
|
+
padding: 5px 7px;
|
|
707
619
|
font-size: 12px;
|
|
708
620
|
font-family: inherit;
|
|
621
|
+
color: var(--text);
|
|
709
622
|
outline: none;
|
|
623
|
+
resize: vertical;
|
|
624
|
+
min-height: 56px;
|
|
625
|
+
background: #fff;
|
|
710
626
|
}
|
|
711
|
-
.
|
|
712
|
-
|
|
713
|
-
|
|
627
|
+
.edit-block:focus, .edit-task-desc:focus { border-color: var(--planned); }
|
|
628
|
+
.edit-block.out-of-scope { border-color: #e8c4c4; background: #fffbfb; }
|
|
629
|
+
.edit-task-row {
|
|
630
|
+
display: flex; align-items: flex-start; gap: 6px; margin-bottom: 6px;
|
|
631
|
+
}
|
|
632
|
+
.edit-task-row input[type="checkbox"] { margin-top: 6px; flex-shrink: 0; accent-color: var(--planned); cursor: pointer; }
|
|
633
|
+
.edit-task-fields { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
|
634
|
+
.edit-task-input {
|
|
635
|
+
width: 100%; border: 1px solid transparent; border-radius: 3px;
|
|
636
|
+
padding: 3px 5px; font-size: 12px; font-family: inherit;
|
|
637
|
+
color: var(--text); outline: none; background: transparent;
|
|
714
638
|
}
|
|
639
|
+
.edit-task-input:focus { border-color: var(--planned); background: #fff; }
|
|
715
640
|
.del-task {
|
|
716
641
|
flex-shrink: 0; border: none; background: none; cursor: pointer;
|
|
717
|
-
color: var(--muted); font-size: 14px; line-height: 1; padding:
|
|
718
|
-
opacity: 0; transition: opacity 0.1s;
|
|
642
|
+
color: var(--muted); font-size: 14px; line-height: 1; padding: 4px 2px;
|
|
719
643
|
}
|
|
720
|
-
.edit-task-row:hover .del-task { opacity: 1; }
|
|
721
644
|
.del-task:hover { color: #dc2626; }
|
|
722
|
-
|
|
723
|
-
.add-task-row { margin-top: 5px; }
|
|
724
645
|
.add-task-inp {
|
|
725
646
|
width: 100%; border: 1px dashed #cbd5e1; border-radius: 3px;
|
|
726
|
-
padding:
|
|
647
|
+
padding: 4px 7px; font-size: 12px; font-family: inherit;
|
|
727
648
|
color: var(--text); outline: none; background: transparent;
|
|
728
649
|
}
|
|
729
650
|
.add-task-inp:focus { border-color: var(--planned); border-style: solid; background: #fff; }
|
|
730
|
-
.add-task-inp::placeholder { color: var(--muted); }
|
|
731
|
-
|
|
732
651
|
.edit-footer {
|
|
733
|
-
display: flex; gap:
|
|
734
|
-
border-top: 1px solid #f1f5f9;
|
|
652
|
+
display: flex; gap: 6px; padding-top: 8px;
|
|
653
|
+
border-top: 1px solid #f1f5f9; margin-top: 4px;
|
|
735
654
|
}
|
|
736
655
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
656
|
+
.md-mermaid {
|
|
657
|
+
margin: 8px 0;
|
|
658
|
+
padding: 10px;
|
|
659
|
+
background: #f8fafc;
|
|
660
|
+
border: 1px solid var(--border);
|
|
661
|
+
border-radius: var(--radius);
|
|
662
|
+
overflow-x: auto;
|
|
663
|
+
text-align: center;
|
|
664
|
+
}
|
|
665
|
+
.md-mermaid svg { max-width: 100%; height: auto; }
|
|
666
|
+
.md-mermaid-error {
|
|
667
|
+
margin: 8px 0;
|
|
668
|
+
padding: 8px 10px;
|
|
669
|
+
background: #fef2f2;
|
|
670
|
+
border: 1px solid #fecaca;
|
|
671
|
+
border-radius: var(--radius);
|
|
672
|
+
text-align: left;
|
|
673
|
+
}
|
|
674
|
+
.md-mermaid-error .md-err-label {
|
|
675
|
+
font-size: 10px;
|
|
676
|
+
font-weight: 700;
|
|
677
|
+
text-transform: uppercase;
|
|
678
|
+
letter-spacing: 0.5px;
|
|
679
|
+
color: #b91c1c;
|
|
680
|
+
margin-bottom: 6px;
|
|
681
|
+
}
|
|
682
|
+
.md-mermaid-error pre {
|
|
683
|
+
margin: 0;
|
|
684
|
+
font-size: 11px;
|
|
685
|
+
line-height: 1.4;
|
|
686
|
+
white-space: pre-wrap;
|
|
687
|
+
word-break: break-word;
|
|
688
|
+
color: var(--text);
|
|
689
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
743
690
|
}
|
|
744
|
-
.lane-add:hover { border-color: var(--planned); color: var(--planned); background: #eff6ff; }
|
|
745
691
|
|
|
746
|
-
/* ── Toast ── */
|
|
747
692
|
.toast {
|
|
748
693
|
position: fixed; bottom: 16px; right: 16px; background: #1e293b;
|
|
749
694
|
color: #fff; padding: 8px 14px; border-radius: 8px;
|
|
@@ -752,6 +697,15 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
752
697
|
pointer-events: none; z-index: 999;
|
|
753
698
|
}
|
|
754
699
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
700
|
+
|
|
701
|
+
@media (max-width: 1100px) {
|
|
702
|
+
:root { --paper-w: 320px; --rail-w: 180px; }
|
|
703
|
+
}
|
|
704
|
+
@media (max-width: 900px) {
|
|
705
|
+
.cockpit { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
|
|
706
|
+
#rail { max-height: 140px; border-right: none; border-bottom: 1px solid var(--border); flex-direction: row; flex-wrap: wrap; }
|
|
707
|
+
#paper { border-left: none; border-top: 1px solid var(--border); max-height: 40vh; }
|
|
708
|
+
}
|
|
755
709
|
</style>
|
|
756
710
|
</head>
|
|
757
711
|
<body>
|
|
@@ -760,8 +714,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
760
714
|
<h1>Donna Kanban</h1>
|
|
761
715
|
<span class="hstats" id="hstats">—</span>
|
|
762
716
|
<span class="hspacer"></span>
|
|
763
|
-
<button class="btn-new" id="btn-show-archived" onclick="toggleShowArchived()"
|
|
764
|
-
<button class="btn-new" id="btn-new-epic" onclick="toggleNewEpicForm()"
|
|
717
|
+
<button class="btn-new muted" id="btn-show-archived" onclick="toggleShowArchived()">Show archived</button>
|
|
718
|
+
<button class="btn-new secondary" id="btn-new-epic" onclick="toggleNewEpicForm()">+ New epic</button>
|
|
765
719
|
<button class="btn-new" id="btn-new" onclick="toggleNewForm()">+ New task</button>
|
|
766
720
|
</header>
|
|
767
721
|
|
|
@@ -770,11 +724,11 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
770
724
|
<div class="ne-label">Epic title</div>
|
|
771
725
|
<input class="ne-input ne-title" id="nx-title" placeholder="Initiative name…" autocomplete="off">
|
|
772
726
|
</div>
|
|
773
|
-
<div class="ne-field" style="flex:1;min-width:
|
|
727
|
+
<div class="ne-field" style="flex:1;min-width:160px">
|
|
774
728
|
<div class="ne-label">Description</div>
|
|
775
729
|
<input class="ne-input" id="nx-description" placeholder="Context for agents…" autocomplete="off" style="width:100%">
|
|
776
730
|
</div>
|
|
777
|
-
<div class="ne-field" style="flex:1;min-width:
|
|
731
|
+
<div class="ne-field" style="flex:1;min-width:140px">
|
|
778
732
|
<div class="ne-label">Goals</div>
|
|
779
733
|
<input class="ne-input" id="nx-goals" placeholder="Outcome…" autocomplete="off" style="width:100%">
|
|
780
734
|
</div>
|
|
@@ -792,10 +746,10 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
792
746
|
<div class="ne-field">
|
|
793
747
|
<div class="ne-label">Column</div>
|
|
794
748
|
<select class="ne-input ne-col" id="ne-col">
|
|
795
|
-
<option value="icebox"
|
|
796
|
-
<option value="planned" selected
|
|
797
|
-
<option value="active"
|
|
798
|
-
<option value="done"
|
|
749
|
+
<option value="icebox">Icebox</option>
|
|
750
|
+
<option value="planned" selected>Planned</option>
|
|
751
|
+
<option value="active">NOW (active)</option>
|
|
752
|
+
<option value="done">Done</option>
|
|
799
753
|
</select>
|
|
800
754
|
</div>
|
|
801
755
|
<div class="ne-field">
|
|
@@ -809,34 +763,61 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
809
763
|
</div>
|
|
810
764
|
</div>
|
|
811
765
|
|
|
812
|
-
<
|
|
813
|
-
<
|
|
814
|
-
|
|
815
|
-
|
|
766
|
+
<main class="cockpit">
|
|
767
|
+
<aside id="rail"></aside>
|
|
768
|
+
|
|
769
|
+
<section class="board-center">
|
|
770
|
+
<div id="now">
|
|
771
|
+
<div class="now-head">
|
|
772
|
+
<span class="now-title">NOW</span>
|
|
773
|
+
<span class="now-count" id="now-count">0</span>
|
|
774
|
+
</div>
|
|
775
|
+
<div class="now-cards" id="now-cards"></div>
|
|
776
|
+
</div>
|
|
777
|
+
<div id="board-cols">
|
|
778
|
+
<div class="col" data-col="icebox">
|
|
779
|
+
<div class="col-head icebox">Icebox <span class="ch-count" id="cnt-icebox">0</span></div>
|
|
780
|
+
<div class="col-body" id="col-icebox"></div>
|
|
781
|
+
</div>
|
|
782
|
+
<div class="col" data-col="planned">
|
|
783
|
+
<div class="col-head planned">Planned <span class="ch-count" id="cnt-planned">0</span></div>
|
|
784
|
+
<div class="col-body" id="col-planned"></div>
|
|
785
|
+
</div>
|
|
786
|
+
<div class="col" data-col="done">
|
|
787
|
+
<div class="col-head done">Done <span class="ch-count" id="cnt-done">0</span></div>
|
|
788
|
+
<div class="col-body" id="col-done"></div>
|
|
789
|
+
</div>
|
|
790
|
+
</div>
|
|
791
|
+
</section>
|
|
792
|
+
|
|
793
|
+
<aside id="paper">
|
|
794
|
+
<div class="paper-empty">Select a card or epic</div>
|
|
795
|
+
</aside>
|
|
796
|
+
</main>
|
|
816
797
|
|
|
817
798
|
<div class="toast" id="toast"></div>
|
|
818
799
|
|
|
819
800
|
<script src="/vendor/fenced-text.js"></script>
|
|
820
801
|
<script src="/vendor/mermaid.min.js"></script>
|
|
821
802
|
<script>
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
{ id: "
|
|
825
|
-
{ id: "
|
|
826
|
-
{ id: "done", label: "
|
|
803
|
+
// Board columns exclude active — active lives in NOW only.
|
|
804
|
+
const BOARD_COLS = [
|
|
805
|
+
{ id: "icebox", label: "Icebox" },
|
|
806
|
+
{ id: "planned", label: "Planned" },
|
|
807
|
+
{ id: "done", label: "Done" }
|
|
827
808
|
];
|
|
809
|
+
// Move order: icebox ↔ planned ↔ active(NOW) ↔ done
|
|
810
|
+
const MOVE_ORDER = ["icebox", "planned", "active", "done"];
|
|
828
811
|
|
|
829
|
-
let allTasks
|
|
812
|
+
let allTasks = [];
|
|
830
813
|
let allEpicEntities = [];
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
let
|
|
835
|
-
let
|
|
836
|
-
let
|
|
837
|
-
let
|
|
838
|
-
let editingEpicId = null;
|
|
839
|
-
let dirtyEpicEdits = {}; // epicId -> draft
|
|
814
|
+
/** @type {null|string} null=all, "—"=no epic, "E001"=epic id */
|
|
815
|
+
let filterKey = null;
|
|
816
|
+
/** @type {null|{kind:'task'|'epic', id:string}} */
|
|
817
|
+
let paperTarget = null;
|
|
818
|
+
let paperEditing = false;
|
|
819
|
+
let dirtyEdits = {};
|
|
820
|
+
let dirtyEpicEdits = {};
|
|
840
821
|
let showArchived = false;
|
|
841
822
|
|
|
842
823
|
// ── Load & render ─────────────────────────────────────────────────────────────
|
|
@@ -850,203 +831,672 @@ async function load() {
|
|
|
850
831
|
allTasks = await boardRes.json();
|
|
851
832
|
allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
|
|
852
833
|
refreshEpicDatalist();
|
|
834
|
+
// Drop paper target if entity vanished
|
|
835
|
+
if (paperTarget) {
|
|
836
|
+
if (paperTarget.kind === "task" && !allTasks.find(t => t.id === paperTarget.id)) {
|
|
837
|
+
paperTarget = null;
|
|
838
|
+
paperEditing = false;
|
|
839
|
+
}
|
|
840
|
+
if (paperTarget && paperTarget.kind === "epic" && !allEpicEntities.find(e => e.id === paperTarget.id)) {
|
|
841
|
+
paperTarget = null;
|
|
842
|
+
paperEditing = false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
853
845
|
render();
|
|
854
846
|
}
|
|
855
847
|
|
|
856
|
-
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
848
|
+
function refreshEpicDatalist() {
|
|
849
|
+
const list = document.getElementById("epic-datalist");
|
|
850
|
+
if (!list) return;
|
|
851
|
+
list.innerHTML = allEpicEntities.map(e =>
|
|
852
|
+
`<option value="${escHtml(e.id)}">${escHtml(e.title)}</option>`
|
|
853
|
+
).join("");
|
|
861
854
|
}
|
|
862
855
|
|
|
863
|
-
|
|
864
|
-
if (
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
await load();
|
|
856
|
+
function filteredTasks() {
|
|
857
|
+
if (filterKey === null) return allTasks.slice();
|
|
858
|
+
if (filterKey === "—") return allTasks.filter(t => !t.epic_id);
|
|
859
|
+
return allTasks.filter(t => t.epic_id === filterKey);
|
|
868
860
|
}
|
|
869
861
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
862
|
+
function render() {
|
|
863
|
+
const tasks = filteredTasks();
|
|
864
|
+
const total = allTasks.length;
|
|
865
|
+
const done = allTasks.filter(t => t.column === "done").length;
|
|
866
|
+
document.getElementById("hstats").textContent =
|
|
867
|
+
`${done}/${total} done · ${allEpicEntities.length} epics` +
|
|
868
|
+
(filterKey === null ? "" : filterKey === "—" ? " · filter: No epic" : ` · filter: ${filterKey}`);
|
|
869
|
+
|
|
870
|
+
renderRail();
|
|
871
|
+
renderNow(tasks);
|
|
872
|
+
renderBoardCols(tasks);
|
|
873
|
+
renderPaper();
|
|
874
|
+
queueMermaidRun(document.getElementById("paper"));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ── Rail ──────────────────────────────────────────────────────────────────────
|
|
878
|
+
|
|
879
|
+
function renderRail() {
|
|
880
|
+
const rail = document.getElementById("rail");
|
|
881
|
+
rail.innerHTML = "";
|
|
882
|
+
rail.appendChild(Object.assign(el("div", "rail-label"), { textContent: "Initiatives" }));
|
|
883
|
+
|
|
884
|
+
rail.appendChild(makeRailItem({
|
|
885
|
+
key: null,
|
|
886
|
+
title: "All",
|
|
887
|
+
idLabel: null,
|
|
888
|
+
count: allTasks.length,
|
|
889
|
+
status: null,
|
|
890
|
+
on: filterKey === null && !(paperTarget && paperTarget.kind === "epic")
|
|
891
|
+
}));
|
|
892
|
+
|
|
893
|
+
const orphanCount = allTasks.filter(t => !t.epic_id).length;
|
|
894
|
+
rail.appendChild(makeRailItem({
|
|
895
|
+
key: "—",
|
|
896
|
+
title: "No epic",
|
|
897
|
+
idLabel: null,
|
|
898
|
+
count: orphanCount,
|
|
899
|
+
status: null,
|
|
900
|
+
on: filterKey === "—"
|
|
901
|
+
}));
|
|
902
|
+
|
|
903
|
+
allEpicEntities
|
|
904
|
+
.slice()
|
|
905
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)))
|
|
906
|
+
.forEach(entity => {
|
|
907
|
+
const p = entity.progress || {};
|
|
908
|
+
const cnt = p.tasks_total != null
|
|
909
|
+
? p.tasks_total
|
|
910
|
+
: allTasks.filter(t => t.epic_id === entity.id).length;
|
|
911
|
+
const selected = paperTarget && paperTarget.kind === "epic" && paperTarget.id === entity.id;
|
|
912
|
+
rail.appendChild(makeRailItem({
|
|
913
|
+
key: entity.id,
|
|
914
|
+
title: entity.title || entity.id,
|
|
915
|
+
idLabel: entity.id,
|
|
916
|
+
count: cnt,
|
|
917
|
+
status: entity.status || null,
|
|
918
|
+
on: selected || filterKey === entity.id,
|
|
919
|
+
entity
|
|
920
|
+
}));
|
|
921
|
+
});
|
|
876
922
|
}
|
|
877
923
|
|
|
878
|
-
function
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
924
|
+
function makeRailItem({ key, title, idLabel, count, status, on, entity }) {
|
|
925
|
+
const btn = document.createElement("button");
|
|
926
|
+
btn.type = "button";
|
|
927
|
+
btn.className = `rail-item${on ? " on" : ""}`;
|
|
928
|
+
btn.innerHTML = `
|
|
929
|
+
<span class="ri-title">${escHtml(title)}</span>
|
|
930
|
+
${idLabel ? `<span class="ri-id">${escHtml(idLabel)}</span>` : ""}
|
|
931
|
+
${status ? `<span class="ri-status ${escHtml(status)}">${escHtml(status)}</span>` : ""}
|
|
932
|
+
<span class="ri-cnt">${count}</span>
|
|
933
|
+
`;
|
|
934
|
+
btn.onclick = () => {
|
|
935
|
+
if (key === null) {
|
|
936
|
+
filterKey = null;
|
|
937
|
+
// keep paper if open
|
|
938
|
+
} else if (key === "—") {
|
|
939
|
+
filterKey = "—";
|
|
940
|
+
if (paperTarget && paperTarget.kind === "epic") {
|
|
941
|
+
paperTarget = null;
|
|
942
|
+
paperEditing = false;
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
filterKey = key;
|
|
946
|
+
openPaper("epic", key);
|
|
947
|
+
return; // openPaper → render
|
|
948
|
+
}
|
|
949
|
+
render();
|
|
950
|
+
};
|
|
951
|
+
return btn;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ── NOW + columns ─────────────────────────────────────────────────────────────
|
|
955
|
+
|
|
956
|
+
function renderNow(tasks) {
|
|
957
|
+
const active = tasks.filter(t => t.column === "active");
|
|
958
|
+
document.getElementById("now-count").textContent = String(active.length);
|
|
959
|
+
const wrap = document.getElementById("now-cards");
|
|
960
|
+
wrap.innerHTML = "";
|
|
961
|
+
if (active.length === 0) {
|
|
962
|
+
wrap.appendChild(Object.assign(el("div", "now-empty"), {
|
|
963
|
+
textContent: "Nothing in NOW — move a planned card here →"
|
|
964
|
+
}));
|
|
965
|
+
} else {
|
|
966
|
+
active.forEach(t => wrap.appendChild(renderCard(t, "active")));
|
|
884
967
|
}
|
|
885
|
-
|
|
968
|
+
// lane-add into NOW
|
|
969
|
+
const addBtn = document.createElement("button");
|
|
970
|
+
addBtn.className = "lane-add";
|
|
971
|
+
addBtn.textContent = "+ Add to NOW";
|
|
972
|
+
addBtn.style.width = "240px";
|
|
973
|
+
addBtn.onclick = e => {
|
|
974
|
+
e.stopPropagation();
|
|
975
|
+
showLaneAdd(wrap, "active", currentEpicRef(), addBtn);
|
|
976
|
+
};
|
|
977
|
+
wrap.appendChild(addBtn);
|
|
886
978
|
}
|
|
887
979
|
|
|
888
|
-
function
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
980
|
+
function renderBoardCols(tasks) {
|
|
981
|
+
BOARD_COLS.forEach(col => {
|
|
982
|
+
const list = tasks.filter(t => t.column === col.id);
|
|
983
|
+
document.getElementById("cnt-" + col.id).textContent = String(list.length);
|
|
984
|
+
const body = document.getElementById("col-" + col.id);
|
|
985
|
+
body.innerHTML = "";
|
|
986
|
+
list.forEach(t => body.appendChild(renderCard(t, col.id)));
|
|
987
|
+
if (col.id !== "done") {
|
|
988
|
+
const addBtn = document.createElement("button");
|
|
989
|
+
addBtn.className = "lane-add";
|
|
990
|
+
addBtn.textContent = "+ Add here";
|
|
991
|
+
addBtn.onclick = e => {
|
|
992
|
+
e.stopPropagation();
|
|
993
|
+
showLaneAdd(body, col.id, currentEpicRef(), addBtn);
|
|
994
|
+
};
|
|
995
|
+
body.appendChild(addBtn);
|
|
996
|
+
}
|
|
997
|
+
});
|
|
894
998
|
}
|
|
895
999
|
|
|
896
|
-
function
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1000
|
+
function currentEpicRef() {
|
|
1001
|
+
if (filterKey && filterKey !== "—") return filterKey;
|
|
1002
|
+
return "";
|
|
1003
|
+
}
|
|
900
1004
|
|
|
901
|
-
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
hdr.appendChild(h);
|
|
1005
|
+
function getNeighborCols(colId) {
|
|
1006
|
+
const idx = MOVE_ORDER.indexOf(colId);
|
|
1007
|
+
return {
|
|
1008
|
+
prev: idx > 0 ? MOVE_ORDER[idx - 1] : null,
|
|
1009
|
+
next: idx >= 0 && idx < MOVE_ORDER.length - 1 ? MOVE_ORDER[idx + 1] : null
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function moveCard(taskId, targetColId) {
|
|
1014
|
+
await fetch(`/api/epics/${enc(taskId)}/move`, {
|
|
1015
|
+
method: "PATCH",
|
|
1016
|
+
headers: { "Content-Type": "application/json" },
|
|
1017
|
+
body: JSON.stringify({ column: targetColId })
|
|
915
1018
|
});
|
|
1019
|
+
await load();
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function renderCard(task, colId) {
|
|
1023
|
+
const selected = paperTarget && paperTarget.kind === "task" && paperTarget.id === task.id;
|
|
1024
|
+
const card = el("div", `card col-${colId}${selected ? " selected" : ""}`);
|
|
1025
|
+
card.dataset.id = task.id;
|
|
1026
|
+
|
|
1027
|
+
const subtasks = task.subtasks || [];
|
|
1028
|
+
const totalT = subtasks.length;
|
|
1029
|
+
const doneT = subtasks.filter(t => t.done).length;
|
|
1030
|
+
const pct = totalT > 0 ? Math.round(doneT / totalT * 100) : 0;
|
|
1031
|
+
const shortId = (String(task.id).match(/^PI-\d+/) || [task.id])[0];
|
|
1032
|
+
const displayTitle = String(task.title || "").replace(/^[\w.-]+:\s*/, "");
|
|
1033
|
+
|
|
1034
|
+
const top = el("div", "card-top");
|
|
1035
|
+
const idBadge = el("span", "card-id");
|
|
1036
|
+
idBadge.textContent = shortId;
|
|
1037
|
+
top.appendChild(idBadge);
|
|
1038
|
+
card.appendChild(top);
|
|
1039
|
+
|
|
1040
|
+
const titleEl = el("div", "card-title");
|
|
1041
|
+
titleEl.textContent = displayTitle;
|
|
1042
|
+
card.appendChild(titleEl);
|
|
1043
|
+
|
|
1044
|
+
if (totalT > 0 && colId !== "icebox") {
|
|
1045
|
+
const pr = el("div", "prog-row");
|
|
1046
|
+
pr.innerHTML = `
|
|
1047
|
+
<div class="prog-bar"><div class="prog-fill ${colId}" style="width:${pct}%"></div></div>
|
|
1048
|
+
<span class="prog-txt">${doneT}/${totalT}</span>
|
|
1049
|
+
`;
|
|
1050
|
+
card.appendChild(pr);
|
|
1051
|
+
} else if (totalT > 0 && colId === "icebox") {
|
|
1052
|
+
const badge = el("span", "prog-txt");
|
|
1053
|
+
badge.style.cssText = "display:inline-block;margin-top:5px;background:rgba(100,116,139,0.12);border-radius:4px;padding:1px 6px;";
|
|
1054
|
+
badge.textContent = `${totalT} subtasks`;
|
|
1055
|
+
card.appendChild(badge);
|
|
1056
|
+
}
|
|
916
1057
|
|
|
917
|
-
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1058
|
+
const actions = el("div", "card-actions");
|
|
1059
|
+
const { prev, next } = getNeighborCols(colId);
|
|
1060
|
+
if (prev) {
|
|
1061
|
+
const lb = document.createElement("button");
|
|
1062
|
+
lb.className = "cbtn arr";
|
|
1063
|
+
lb.textContent = "←";
|
|
1064
|
+
lb.title = `→ ${prev}`;
|
|
1065
|
+
lb.onclick = async e => { e.stopPropagation(); await moveCard(task.id, prev); };
|
|
1066
|
+
actions.appendChild(lb);
|
|
1067
|
+
}
|
|
1068
|
+
if (next) {
|
|
1069
|
+
const rb = document.createElement("button");
|
|
1070
|
+
rb.className = "cbtn arr";
|
|
1071
|
+
rb.textContent = "→";
|
|
1072
|
+
rb.title = `→ ${next}`;
|
|
1073
|
+
rb.onclick = async e => { e.stopPropagation(); await moveCard(task.id, next); };
|
|
1074
|
+
actions.appendChild(rb);
|
|
1075
|
+
}
|
|
1076
|
+
const delBtn = document.createElement("button");
|
|
1077
|
+
delBtn.className = "cbtn";
|
|
1078
|
+
delBtn.textContent = "✕";
|
|
1079
|
+
delBtn.title = "Delete task";
|
|
1080
|
+
delBtn.onclick = e => {
|
|
1081
|
+
e.stopPropagation();
|
|
1082
|
+
deleteTaskCard(task.id, displayTitle);
|
|
1083
|
+
};
|
|
1084
|
+
actions.appendChild(delBtn);
|
|
1085
|
+
card.appendChild(actions);
|
|
1086
|
+
|
|
1087
|
+
card.onclick = e => {
|
|
1088
|
+
if (e.target.tagName === "BUTTON" || e.target.tagName === "INPUT") return;
|
|
1089
|
+
openPaper("task", task.id);
|
|
1090
|
+
};
|
|
1091
|
+
return card;
|
|
922
1092
|
}
|
|
923
1093
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1094
|
+
// ── Paper ─────────────────────────────────────────────────────────────────────
|
|
1095
|
+
|
|
1096
|
+
function openPaper(kind, id) {
|
|
1097
|
+
if (paperTarget && (paperTarget.kind !== kind || paperTarget.id !== id)) {
|
|
1098
|
+
paperEditing = false;
|
|
1099
|
+
dirtyEdits = {};
|
|
1100
|
+
dirtyEpicEdits = {};
|
|
1101
|
+
}
|
|
1102
|
+
paperTarget = { kind, id };
|
|
1103
|
+
render();
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function closePaper() {
|
|
1107
|
+
paperTarget = null;
|
|
1108
|
+
paperEditing = false;
|
|
1109
|
+
dirtyEdits = {};
|
|
1110
|
+
dirtyEpicEdits = {};
|
|
1111
|
+
render();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function renderPaper() {
|
|
1115
|
+
const paper = document.getElementById("paper");
|
|
1116
|
+
paper.innerHTML = "";
|
|
1117
|
+
|
|
1118
|
+
if (!paperTarget) {
|
|
1119
|
+
paper.appendChild(Object.assign(el("div", "paper-empty"), {
|
|
1120
|
+
textContent: "Select a card or epic to open the paper"
|
|
1121
|
+
}));
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (paperTarget.kind === "task") {
|
|
1126
|
+
const task = allTasks.find(t => t.id === paperTarget.id);
|
|
1127
|
+
if (!task) {
|
|
1128
|
+
paper.appendChild(Object.assign(el("div", "paper-empty"), { textContent: "Task not found" }));
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (paperEditing) renderTaskPaperEdit(paper, task);
|
|
1132
|
+
else renderTaskPaperView(paper, task);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const entity = allEpicEntities.find(e => e.id === paperTarget.id);
|
|
1137
|
+
if (!entity) {
|
|
1138
|
+
paper.appendChild(Object.assign(el("div", "paper-empty"), { textContent: "Epic not found" }));
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (paperEditing) renderEpicPaperEdit(paper, entity);
|
|
1142
|
+
else renderEpicPaperView(paper, entity);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function paperHead(kindLabel, badges, title, actions) {
|
|
1146
|
+
const head = el("div", "paper-head");
|
|
1147
|
+
const kicker = el("div", "paper-kicker");
|
|
1148
|
+
kicker.appendChild(Object.assign(el("span", "paper-kind"), { textContent: kindLabel }));
|
|
1149
|
+
(badges || []).forEach(b => {
|
|
1150
|
+
const sp = el("span", `paper-badge${b.cls ? " " + b.cls : ""}`);
|
|
1151
|
+
sp.textContent = b.text;
|
|
1152
|
+
kicker.appendChild(sp);
|
|
934
1153
|
});
|
|
1154
|
+
head.appendChild(kicker);
|
|
1155
|
+
head.appendChild(Object.assign(el("div", "paper-title"), { textContent: title }));
|
|
1156
|
+
if (actions && actions.length) {
|
|
1157
|
+
const row = el("div", "paper-actions");
|
|
1158
|
+
actions.forEach(a => row.appendChild(a));
|
|
1159
|
+
head.appendChild(row);
|
|
1160
|
+
}
|
|
1161
|
+
return head;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function makePaperBtn(label, cls, onClick) {
|
|
1165
|
+
const b = document.createElement("button");
|
|
1166
|
+
b.type = "button";
|
|
1167
|
+
b.className = cls || "btn-sm btn-cancel";
|
|
1168
|
+
b.textContent = label;
|
|
1169
|
+
b.onclick = onClick;
|
|
1170
|
+
return b;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function renderTaskPaperView(paper, task) {
|
|
1174
|
+
const displayTitle = String(task.title || "").replace(/^[\w.-]+:\s*/, "");
|
|
1175
|
+
const badges = [
|
|
1176
|
+
{ text: task.id, cls: "" },
|
|
1177
|
+
{ text: task.column === "active" ? "NOW" : task.column, cls: "col-" + task.column }
|
|
1178
|
+
];
|
|
1179
|
+
if (task.epic_id) badges.push({ text: task.epic_id, cls: "" });
|
|
1180
|
+
if (task.plan && task.plan.status) badges.push({ text: "plan:" + task.plan.status, cls: "" });
|
|
1181
|
+
|
|
1182
|
+
const actions = [
|
|
1183
|
+
makePaperBtn("Edit", "btn-sm btn-confirm", () => { paperEditing = true; render(); }),
|
|
1184
|
+
makePaperBtn("Close", "btn-sm btn-cancel", () => closePaper())
|
|
1185
|
+
];
|
|
1186
|
+
paper.appendChild(paperHead("Task", badges, displayTitle, actions));
|
|
1187
|
+
|
|
1188
|
+
const body = el("div", "paper-body");
|
|
1189
|
+
|
|
1190
|
+
if (task.description) body.appendChild(renderDetailSection("What", task.description));
|
|
1191
|
+
if (task.rationale) body.appendChild(renderDetailSection("Rationale", task.rationale));
|
|
1192
|
+
if (task.outcome) body.appendChild(renderDetailSection("Outcome", task.outcome));
|
|
1193
|
+
if (task.specs) body.appendChild(renderDetailSection("Specs", task.specs));
|
|
1194
|
+
|
|
1195
|
+
if ((task.in_scope && task.in_scope.length) || (task.out_of_scope && task.out_of_scope.length)) {
|
|
1196
|
+
const pair = el("div", "scope-pair");
|
|
1197
|
+
if (task.in_scope && task.in_scope.length) {
|
|
1198
|
+
pair.appendChild(renderDetailListSection("In Scope", task.in_scope));
|
|
1199
|
+
}
|
|
1200
|
+
if (task.out_of_scope && task.out_of_scope.length) {
|
|
1201
|
+
pair.appendChild(renderDetailListSection("Out of Scope", task.out_of_scope, "out-of-scope"));
|
|
1202
|
+
}
|
|
1203
|
+
body.appendChild(pair);
|
|
1204
|
+
}
|
|
935
1205
|
|
|
936
|
-
|
|
937
|
-
.
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1206
|
+
if (task.acceptance_criteria && task.acceptance_criteria.length) {
|
|
1207
|
+
body.appendChild(renderDetailListSection("Acceptance Criteria", task.acceptance_criteria));
|
|
1208
|
+
}
|
|
1209
|
+
if (task.test_cases && task.test_cases.length) {
|
|
1210
|
+
body.appendChild(renderDetailListSection("Test Cases", task.test_cases));
|
|
1211
|
+
}
|
|
1212
|
+
if (task.notes) body.appendChild(renderDetailSection("Notes", task.notes));
|
|
1213
|
+
|
|
1214
|
+
const subtasks = task.subtasks || [];
|
|
1215
|
+
if (subtasks.length > 0) {
|
|
1216
|
+
const wrap = el("div", "detail-section");
|
|
1217
|
+
wrap.appendChild(Object.assign(el("div", "subtask-label"), {
|
|
1218
|
+
textContent: `Steps / subtasks (${subtasks.filter(s => s.done).length}/${subtasks.length})`
|
|
1219
|
+
}));
|
|
1220
|
+
const list = el("div", "tasks-list");
|
|
1221
|
+
subtasks.forEach((st, idx) => {
|
|
1222
|
+
const row = el("div", `task-row${st.done ? " done" : ""}`);
|
|
1223
|
+
const cbId = `p-${task.id}-${idx}`;
|
|
1224
|
+
row.innerHTML = `
|
|
1225
|
+
<input type="checkbox" id="${cbId}" ${st.done ? "checked" : ""}>
|
|
1226
|
+
<div class="task-copy">
|
|
1227
|
+
<label for="${cbId}">${escHtml(st.text)}</label>
|
|
1228
|
+
${st.description ? `<div class="task-desc">${escHtml(st.description)}</div>` : ""}
|
|
1229
|
+
</div>
|
|
1230
|
+
`;
|
|
1231
|
+
row.querySelector("input").onchange = async function() {
|
|
1232
|
+
this.disabled = true;
|
|
1233
|
+
await fetch(`/api/epics/${enc(task.id)}/tasks/${idx}`, { method: "PATCH" });
|
|
1234
|
+
await load();
|
|
949
1235
|
};
|
|
1236
|
+
list.appendChild(row);
|
|
950
1237
|
});
|
|
951
|
-
|
|
1238
|
+
wrap.appendChild(list);
|
|
1239
|
+
body.appendChild(wrap);
|
|
1240
|
+
}
|
|
952
1241
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1242
|
+
// ADR (read-only)
|
|
1243
|
+
const adrs = Array.isArray(task.adr) ? task.adr : [];
|
|
1244
|
+
if (adrs.length > 0) {
|
|
1245
|
+
const wrap = el("div", "detail-section");
|
|
1246
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: "ADR" }));
|
|
1247
|
+
adrs.forEach(a => {
|
|
1248
|
+
const card = el("div", "adr-card");
|
|
1249
|
+
card.innerHTML = `
|
|
1250
|
+
<div class="adr-decision">${escHtml(a.decision || "")}</div>
|
|
1251
|
+
<div class="adr-why">${escHtml(a.why || "")}</div>
|
|
1252
|
+
<div class="adr-meta">${escHtml(a.id || "")}${a.created ? " · " + escHtml(a.created) : ""}</div>
|
|
1253
|
+
`;
|
|
1254
|
+
wrap.appendChild(card);
|
|
1255
|
+
});
|
|
1256
|
+
body.appendChild(wrap);
|
|
956
1257
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1258
|
+
|
|
1259
|
+
// Evidence (read-only)
|
|
1260
|
+
const evidence = Array.isArray(task.evidence) ? task.evidence : [];
|
|
1261
|
+
if (evidence.length > 0) {
|
|
1262
|
+
const wrap = el("div", "detail-section");
|
|
1263
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: "Evidence" }));
|
|
1264
|
+
evidence.forEach(ev => {
|
|
1265
|
+
const card = el("div", "evidence-card");
|
|
1266
|
+
const cmd = el("div", "ev-cmd");
|
|
1267
|
+
cmd.textContent = ev.test_command || "(no command)";
|
|
1268
|
+
card.appendChild(cmd);
|
|
1269
|
+
const meta = el("div", "ev-meta");
|
|
1270
|
+
meta.textContent = `exit ${ev.exit_code == null ? "?" : ev.exit_code}${ev.created ? " · " + ev.created : ""}`;
|
|
1271
|
+
card.appendChild(meta);
|
|
1272
|
+
if (ev.diff) {
|
|
1273
|
+
const diff = el("div", "ev-diff");
|
|
1274
|
+
diff.textContent = ev.diff;
|
|
1275
|
+
card.appendChild(diff);
|
|
1276
|
+
}
|
|
1277
|
+
wrap.appendChild(card);
|
|
1278
|
+
});
|
|
1279
|
+
body.appendChild(wrap);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
paper.appendChild(body);
|
|
960
1283
|
}
|
|
961
1284
|
|
|
962
|
-
function
|
|
963
|
-
const
|
|
964
|
-
const
|
|
965
|
-
|
|
1285
|
+
function renderTaskPaperEdit(paper, task) {
|
|
1286
|
+
const displayTitle = String(task.title || "").replace(/^[\w.-]+:\s*/, "");
|
|
1287
|
+
const dirty = dirtyEdits[task.id] || {
|
|
1288
|
+
title: displayTitle,
|
|
1289
|
+
description: task.description || "",
|
|
1290
|
+
rationale: task.rationale || "",
|
|
1291
|
+
outcome: task.outcome || "",
|
|
1292
|
+
specs: task.specs || "",
|
|
1293
|
+
in_scope: (task.in_scope || []).join("\n"),
|
|
1294
|
+
out_of_scope: (task.out_of_scope || []).join("\n"),
|
|
1295
|
+
acceptance_criteria: (task.acceptance_criteria || []).join("\n"),
|
|
1296
|
+
test_cases: (task.test_cases || []).join("\n"),
|
|
1297
|
+
notes: task.notes || "",
|
|
1298
|
+
tasks: (task.subtasks || []).map(t => ({ ...t }))
|
|
1299
|
+
};
|
|
1300
|
+
dirtyEdits[task.id] = dirty;
|
|
966
1301
|
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1302
|
+
const badges = [
|
|
1303
|
+
{ text: task.id, cls: "" },
|
|
1304
|
+
{ text: task.column === "active" ? "NOW" : task.column, cls: "col-" + task.column }
|
|
1305
|
+
];
|
|
1306
|
+
paper.appendChild(paperHead("Task · edit", badges, "Editing", []));
|
|
1307
|
+
|
|
1308
|
+
const body = el("div", "paper-body");
|
|
1309
|
+
|
|
1310
|
+
const titleInput = document.createElement("input");
|
|
1311
|
+
titleInput.className = "edit-title-input";
|
|
1312
|
+
titleInput.value = dirty.title;
|
|
1313
|
+
titleInput.oninput = () => { dirty.title = titleInput.value; dirtyEdits[task.id] = dirty; };
|
|
1314
|
+
body.appendChild(titleInput);
|
|
1315
|
+
|
|
1316
|
+
body.appendChild(renderEditBlock("What", dirty.description, v => { dirty.description = v; dirtyEdits[task.id] = dirty; }, "What we are doing…"));
|
|
1317
|
+
body.appendChild(renderEditBlock("Rationale", dirty.rationale, v => { dirty.rationale = v; dirtyEdits[task.id] = dirty; }, "Why this approach…"));
|
|
1318
|
+
body.appendChild(renderEditBlock("Outcome", dirty.outcome, v => { dirty.outcome = v; dirtyEdits[task.id] = dirty; }, "Intended effect…"));
|
|
1319
|
+
body.appendChild(renderEditBlock("Specs", dirty.specs, v => { dirty.specs = v; dirtyEdits[task.id] = dirty; }, "APIs, constraints…"));
|
|
1320
|
+
body.appendChild(renderEditBlock("In Scope", dirty.in_scope, v => { dirty.in_scope = v; dirtyEdits[task.id] = dirty; }, "One item per line"));
|
|
1321
|
+
body.appendChild(renderEditBlock("Out of Scope", dirty.out_of_scope, v => { dirty.out_of_scope = v; dirtyEdits[task.id] = dirty; }, "One item per line", "out-of-scope"));
|
|
1322
|
+
body.appendChild(renderEditBlock("Acceptance Criteria", dirty.acceptance_criteria, v => { dirty.acceptance_criteria = v; dirtyEdits[task.id] = dirty; }, "One condition per line"));
|
|
1323
|
+
body.appendChild(renderEditBlock("Test Cases", dirty.test_cases, v => { dirty.test_cases = v; dirtyEdits[task.id] = dirty; }, "One scenario per line"));
|
|
1324
|
+
body.appendChild(renderEditBlock("Notes", dirty.notes, v => { dirty.notes = v; dirtyEdits[task.id] = dirty; }, "Optional notes"));
|
|
1325
|
+
|
|
1326
|
+
const editTasks = el("div", "detail-section");
|
|
1327
|
+
editTasks.appendChild(Object.assign(el("div", "subtask-label"), { textContent: "Steps / subtasks" }));
|
|
1328
|
+
const taskRows = el("div");
|
|
1329
|
+
const refreshRows = () => {
|
|
1330
|
+
taskRows.innerHTML = "";
|
|
1331
|
+
dirty.tasks.forEach((st, idx) => {
|
|
1332
|
+
const row = el("div", "edit-task-row");
|
|
1333
|
+
const cb = document.createElement("input");
|
|
1334
|
+
cb.type = "checkbox";
|
|
1335
|
+
cb.checked = st.done;
|
|
1336
|
+
cb.onchange = () => { dirty.tasks[idx].done = cb.checked; dirtyEdits[task.id] = dirty; };
|
|
1337
|
+
const fields = el("div", "edit-task-fields");
|
|
1338
|
+
const inp = document.createElement("input");
|
|
1339
|
+
inp.className = "edit-task-input";
|
|
1340
|
+
inp.value = st.text;
|
|
1341
|
+
inp.oninput = () => { dirty.tasks[idx].text = inp.value; dirtyEdits[task.id] = dirty; };
|
|
1342
|
+
const desc = document.createElement("textarea");
|
|
1343
|
+
desc.className = "edit-task-desc";
|
|
1344
|
+
desc.placeholder = "Subtask description…";
|
|
1345
|
+
desc.value = st.description || "";
|
|
1346
|
+
desc.oninput = () => { dirty.tasks[idx].description = desc.value; dirtyEdits[task.id] = dirty; };
|
|
1347
|
+
const del = document.createElement("button");
|
|
1348
|
+
del.className = "del-task";
|
|
1349
|
+
del.textContent = "×";
|
|
1350
|
+
del.onclick = () => { dirty.tasks.splice(idx, 1); dirtyEdits[task.id] = dirty; refreshRows(); };
|
|
1351
|
+
fields.appendChild(inp);
|
|
1352
|
+
fields.appendChild(desc);
|
|
1353
|
+
row.appendChild(cb);
|
|
1354
|
+
row.appendChild(fields);
|
|
1355
|
+
row.appendChild(del);
|
|
1356
|
+
taskRows.appendChild(row);
|
|
1357
|
+
});
|
|
1358
|
+
};
|
|
1359
|
+
refreshRows();
|
|
1360
|
+
editTasks.appendChild(taskRows);
|
|
1361
|
+
const addInp = document.createElement("input");
|
|
1362
|
+
addInp.className = "add-task-inp";
|
|
1363
|
+
addInp.placeholder = "+ New step… (Enter)";
|
|
1364
|
+
addInp.onkeydown = e => {
|
|
1365
|
+
if (e.key === "Enter" && addInp.value.trim()) {
|
|
1366
|
+
dirty.tasks.push({ done: false, text: addInp.value.trim(), description: "" });
|
|
1367
|
+
dirtyEdits[task.id] = dirty;
|
|
1368
|
+
addInp.value = "";
|
|
1369
|
+
refreshRows();
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
editTasks.appendChild(addInp);
|
|
1373
|
+
body.appendChild(editTasks);
|
|
1374
|
+
|
|
1375
|
+
// ADR / evidence stay read-only hint
|
|
1376
|
+
if ((task.adr && task.adr.length) || (task.evidence && task.evidence.length)) {
|
|
1377
|
+
const hint = el("div", "detail-text empty");
|
|
1378
|
+
hint.textContent = "ADR and evidence are read-only in GUI (set via MCP/CLI).";
|
|
1379
|
+
body.appendChild(hint);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const footer = el("div", "edit-footer");
|
|
1383
|
+
footer.appendChild(makePaperBtn("✓ Save", "btn-sm btn-confirm", async () => {
|
|
1384
|
+
await saveTaskEdit(task.id);
|
|
1385
|
+
}));
|
|
1386
|
+
footer.appendChild(makePaperBtn("Cancel", "btn-sm btn-cancel", () => {
|
|
1387
|
+
delete dirtyEdits[task.id];
|
|
1388
|
+
paperEditing = false;
|
|
1389
|
+
render();
|
|
1390
|
+
}));
|
|
1391
|
+
body.appendChild(footer);
|
|
1392
|
+
|
|
1393
|
+
paper.appendChild(body);
|
|
1394
|
+
setTimeout(() => titleInput.focus(), 0);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
async function saveTaskEdit(taskId) {
|
|
1398
|
+
const dirty = dirtyEdits[taskId];
|
|
1399
|
+
if (!dirty) { paperEditing = false; render(); return; }
|
|
1400
|
+
const body = {
|
|
1401
|
+
title: dirty.title,
|
|
1402
|
+
description: dirty.description,
|
|
1403
|
+
rationale: dirty.rationale,
|
|
1404
|
+
outcome: dirty.outcome,
|
|
1405
|
+
specs: dirty.specs,
|
|
1406
|
+
in_scope: splitLines(dirty.in_scope),
|
|
1407
|
+
out_of_scope: splitLines(dirty.out_of_scope),
|
|
1408
|
+
acceptance_criteria: splitLines(dirty.acceptance_criteria),
|
|
1409
|
+
test_cases: splitLines(dirty.test_cases),
|
|
1410
|
+
notes: dirty.notes,
|
|
1411
|
+
subtasks: dirty.tasks.filter(t => t.text.trim()).map((st, idx) => ({
|
|
1412
|
+
id: st.id || `st-${idx + 1}`,
|
|
1413
|
+
text: st.text.trim(),
|
|
1414
|
+
done: Boolean(st.done),
|
|
1415
|
+
description: (st.description || "").trim()
|
|
1416
|
+
}))
|
|
1417
|
+
};
|
|
1418
|
+
try {
|
|
1419
|
+
const res = await fetch(`/api/epics/${enc(taskId)}`, {
|
|
1420
|
+
method: "PATCH",
|
|
1421
|
+
headers: { "Content-Type": "application/json" },
|
|
1422
|
+
body: JSON.stringify(body)
|
|
1423
|
+
});
|
|
1424
|
+
if (!res.ok) throw new Error();
|
|
1425
|
+
toast("Saved ✓");
|
|
1426
|
+
delete dirtyEdits[taskId];
|
|
1427
|
+
paperEditing = false;
|
|
1428
|
+
await load();
|
|
1429
|
+
} catch {
|
|
1430
|
+
toast("Save error!", true);
|
|
976
1431
|
}
|
|
977
|
-
|
|
1432
|
+
}
|
|
978
1433
|
|
|
979
|
-
|
|
980
|
-
|
|
1434
|
+
function renderEpicPaperView(paper, entity) {
|
|
1435
|
+
const p = entity.progress || {};
|
|
1436
|
+
const badges = [
|
|
1437
|
+
{ text: entity.id, cls: "" },
|
|
1438
|
+
{ text: entity.status || "empty", cls: "" }
|
|
1439
|
+
];
|
|
1440
|
+
if (entity.archived) badges.push({ text: "archived", cls: "" });
|
|
1441
|
+
|
|
1442
|
+
const actions = [
|
|
1443
|
+
makePaperBtn("Edit", "btn-sm btn-confirm", () => { paperEditing = true; render(); }),
|
|
1444
|
+
makePaperBtn(entity.archived ? "Unarchive" : "Archive", "btn-sm btn-cancel", () => {
|
|
1445
|
+
archiveEpic(entity.id, !entity.archived);
|
|
1446
|
+
}),
|
|
1447
|
+
makePaperBtn("Delete", "btn-sm btn-cancel", () => {
|
|
1448
|
+
deleteEpicEntity(entity.id, entity.title || entity.id);
|
|
1449
|
+
}),
|
|
1450
|
+
makePaperBtn("Close", "btn-sm btn-cancel", () => closePaper())
|
|
1451
|
+
];
|
|
1452
|
+
paper.appendChild(paperHead("Epic", badges, entity.title || entity.id, actions));
|
|
1453
|
+
|
|
1454
|
+
const body = el("div", "paper-body");
|
|
1455
|
+
|
|
1456
|
+
const chips = el("div", "chip-row");
|
|
1457
|
+
chips.innerHTML = `
|
|
981
1458
|
<span class="sl-chip"><strong>${p.tasks_total || 0}</strong> total</span>
|
|
982
|
-
<span class="sl-chip active"><strong>${p.tasks_active || 0}</strong>
|
|
1459
|
+
<span class="sl-chip active"><strong>${p.tasks_active || 0}</strong> NOW</span>
|
|
983
1460
|
<span class="sl-chip planned"><strong>${p.tasks_planned || 0}</strong> planned</span>
|
|
984
1461
|
<span class="sl-chip icebox"><strong>${p.tasks_icebox || 0}</strong> icebox</span>
|
|
985
1462
|
<span class="sl-chip done"><strong>${p.tasks_done || 0}</strong> done</span>
|
|
986
1463
|
`;
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
if (entity.
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
descCard.appendChild(Object.assign(el("div", "sl-epic-text empty"), {
|
|
1000
|
-
textContent: "No description yet — click Edit epic to add what this initiative does."
|
|
1001
|
-
}));
|
|
1002
|
-
}
|
|
1003
|
-
body.appendChild(descCard);
|
|
1004
|
-
|
|
1005
|
-
if (entity.rationale) {
|
|
1006
|
-
const rationaleCard = el("div", "sl-epic-card");
|
|
1007
|
-
rationaleCard.appendChild(Object.assign(el("div", "sl-epic-label"), { textContent: "Rationale" }));
|
|
1008
|
-
const rationaleText = el("div", "sl-epic-text");
|
|
1009
|
-
fillRichText(rationaleText, entity.rationale);
|
|
1010
|
-
rationaleCard.appendChild(rationaleText);
|
|
1011
|
-
body.appendChild(rationaleCard);
|
|
1012
|
-
}
|
|
1013
|
-
if (entity.outcome) {
|
|
1014
|
-
const outcomeCard = el("div", "sl-epic-card");
|
|
1015
|
-
outcomeCard.appendChild(Object.assign(el("div", "sl-epic-label"), { textContent: "Outcome" }));
|
|
1016
|
-
const outcomeText = el("div", "sl-epic-text");
|
|
1017
|
-
fillRichText(outcomeText, entity.outcome);
|
|
1018
|
-
outcomeCard.appendChild(outcomeText);
|
|
1019
|
-
body.appendChild(outcomeCard);
|
|
1464
|
+
body.appendChild(chips);
|
|
1465
|
+
|
|
1466
|
+
if (entity.goals) body.appendChild(renderDetailSection("Goals", entity.goals));
|
|
1467
|
+
if (entity.description) body.appendChild(renderDetailSection("What", entity.description));
|
|
1468
|
+
if (entity.rationale) body.appendChild(renderDetailSection("Rationale", entity.rationale));
|
|
1469
|
+
if (entity.outcome) body.appendChild(renderDetailSection("Outcome", entity.outcome));
|
|
1470
|
+
|
|
1471
|
+
if ((entity.in_scope && entity.in_scope.length) || (entity.out_of_scope && entity.out_of_scope.length)) {
|
|
1472
|
+
const pair = el("div", "scope-pair");
|
|
1473
|
+
pair.appendChild(renderDetailListSection("In Scope", entity.in_scope || []));
|
|
1474
|
+
pair.appendChild(renderDetailListSection("Out of Scope", entity.out_of_scope || [], "out-of-scope"));
|
|
1475
|
+
body.appendChild(pair);
|
|
1020
1476
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
notes.appendChild(Object.assign(el("div", "sl-epic-label"), { textContent: "Notes" }));
|
|
1039
|
-
const notesText = el("div", "sl-epic-text");
|
|
1040
|
-
fillRichText(notesText, entity.notes);
|
|
1041
|
-
notes.appendChild(notesText);
|
|
1042
|
-
panel.appendChild(notes);
|
|
1477
|
+
if (entity.notes) body.appendChild(renderDetailSection("Notes", entity.notes));
|
|
1478
|
+
|
|
1479
|
+
// Rollup ADRs from child tasks (shapeEpic full)
|
|
1480
|
+
const adrs = Array.isArray(entity.adrs) ? entity.adrs : [];
|
|
1481
|
+
if (adrs.length > 0) {
|
|
1482
|
+
const wrap = el("div", "detail-section");
|
|
1483
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: "ADR (from tasks)" }));
|
|
1484
|
+
adrs.forEach(a => {
|
|
1485
|
+
const card = el("div", "adr-card");
|
|
1486
|
+
card.innerHTML = `
|
|
1487
|
+
<div class="adr-decision">${escHtml(a.decision || "")}</div>
|
|
1488
|
+
<div class="adr-why">${escHtml(a.why || "")}</div>
|
|
1489
|
+
<div class="adr-meta">${escHtml(a.task_id || "")}${a.task_title ? " · " + escHtml(a.task_title) : ""}${a.created ? " · " + escHtml(a.created) : ""}</div>
|
|
1490
|
+
`;
|
|
1491
|
+
wrap.appendChild(card);
|
|
1492
|
+
});
|
|
1493
|
+
body.appendChild(wrap);
|
|
1043
1494
|
}
|
|
1044
1495
|
|
|
1045
|
-
|
|
1046
|
-
return shell;
|
|
1496
|
+
paper.appendChild(body);
|
|
1047
1497
|
}
|
|
1048
1498
|
|
|
1049
|
-
function
|
|
1499
|
+
function renderEpicPaperEdit(paper, entity) {
|
|
1050
1500
|
const dirty = dirtyEpicEdits[entity.id] || {
|
|
1051
1501
|
title: entity.title || "",
|
|
1052
1502
|
description: entity.description || "",
|
|
@@ -1059,105 +1509,43 @@ function renderEpicDetailEdit(entity) {
|
|
|
1059
1509
|
};
|
|
1060
1510
|
dirtyEpicEdits[entity.id] = dirty;
|
|
1061
1511
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1512
|
+
paper.appendChild(paperHead("Epic · edit", [{ text: entity.id, cls: "" }], "Editing", []));
|
|
1513
|
+
const body = el("div", "paper-body");
|
|
1064
1514
|
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
const titleWrap = el("div", "span2");
|
|
1068
|
-
titleWrap.innerHTML = `<label>Title</label>`;
|
|
1515
|
+
const titleWrap = el("div", "detail-section");
|
|
1516
|
+
titleWrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: "Title" }));
|
|
1069
1517
|
const titleInp = document.createElement("input");
|
|
1518
|
+
titleInp.className = "edit-title-input";
|
|
1070
1519
|
titleInp.value = dirty.title;
|
|
1071
1520
|
titleInp.oninput = () => { dirty.title = titleInp.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1072
1521
|
titleWrap.appendChild(titleInp);
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
descTa.placeholder = "What this initiative does…";
|
|
1089
|
-
descTa.style.minHeight = "72px";
|
|
1090
|
-
descTa.oninput = () => { dirty.description = descTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1091
|
-
descWrap.appendChild(descTa);
|
|
1092
|
-
grid.appendChild(descWrap);
|
|
1093
|
-
|
|
1094
|
-
const rationaleWrap = el("div", "span2");
|
|
1095
|
-
rationaleWrap.innerHTML = `<label>Rationale</label>`;
|
|
1096
|
-
const rationaleTa = document.createElement("textarea");
|
|
1097
|
-
rationaleTa.value = dirty.rationale;
|
|
1098
|
-
rationaleTa.placeholder = "Why this approach, not alternatives…";
|
|
1099
|
-
rationaleTa.oninput = () => { dirty.rationale = rationaleTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1100
|
-
rationaleWrap.appendChild(rationaleTa);
|
|
1101
|
-
grid.appendChild(rationaleWrap);
|
|
1102
|
-
|
|
1103
|
-
const outcomeWrap = el("div", "span2");
|
|
1104
|
-
outcomeWrap.innerHTML = `<label>Outcome</label>`;
|
|
1105
|
-
const outcomeTa = document.createElement("textarea");
|
|
1106
|
-
outcomeTa.value = dirty.outcome;
|
|
1107
|
-
outcomeTa.placeholder = "Intended effect / goal…";
|
|
1108
|
-
outcomeTa.oninput = () => { dirty.outcome = outcomeTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1109
|
-
outcomeWrap.appendChild(outcomeTa);
|
|
1110
|
-
grid.appendChild(outcomeWrap);
|
|
1111
|
-
|
|
1112
|
-
const inWrap = el("div", "");
|
|
1113
|
-
inWrap.innerHTML = `<label>In scope (one per line)</label>`;
|
|
1114
|
-
const inTa = document.createElement("textarea");
|
|
1115
|
-
inTa.value = dirty.in_scope;
|
|
1116
|
-
inTa.oninput = () => { dirty.in_scope = inTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1117
|
-
inWrap.appendChild(inTa);
|
|
1118
|
-
grid.appendChild(inWrap);
|
|
1119
|
-
|
|
1120
|
-
const outWrap = el("div", "");
|
|
1121
|
-
outWrap.innerHTML = `<label>Out of scope (one per line)</label>`;
|
|
1122
|
-
const outTa = document.createElement("textarea");
|
|
1123
|
-
outTa.value = dirty.out_of_scope;
|
|
1124
|
-
outTa.oninput = () => { dirty.out_of_scope = outTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1125
|
-
outWrap.appendChild(outTa);
|
|
1126
|
-
grid.appendChild(outWrap);
|
|
1127
|
-
|
|
1128
|
-
const notesWrap = el("div", "span2");
|
|
1129
|
-
notesWrap.innerHTML = `<label>Notes</label>`;
|
|
1130
|
-
const notesTa = document.createElement("textarea");
|
|
1131
|
-
notesTa.value = dirty.notes;
|
|
1132
|
-
notesTa.oninput = () => { dirty.notes = notesTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1133
|
-
notesWrap.appendChild(notesTa);
|
|
1134
|
-
grid.appendChild(notesWrap);
|
|
1135
|
-
|
|
1136
|
-
const actions = el("div", "sl-epic-edit-actions");
|
|
1137
|
-
const saveBtn = document.createElement("button");
|
|
1138
|
-
saveBtn.className = "btn-sm btn-confirm";
|
|
1139
|
-
saveBtn.textContent = "✓ Save epic";
|
|
1140
|
-
saveBtn.onclick = async e => { e.stopPropagation(); await saveEpicEdit(entity.id); };
|
|
1141
|
-
const cancelBtn = document.createElement("button");
|
|
1142
|
-
cancelBtn.className = "btn-sm btn-cancel";
|
|
1143
|
-
cancelBtn.textContent = "Cancel";
|
|
1144
|
-
cancelBtn.onclick = e => {
|
|
1145
|
-
e.stopPropagation();
|
|
1522
|
+
body.appendChild(titleWrap);
|
|
1523
|
+
|
|
1524
|
+
body.appendChild(renderEditBlock("Goals", dirty.goals, v => { dirty.goals = v; dirtyEpicEdits[entity.id] = dirty; }, "Outcome one-liner…"));
|
|
1525
|
+
body.appendChild(renderEditBlock("What", dirty.description, v => { dirty.description = v; dirtyEpicEdits[entity.id] = dirty; }, "What this initiative does…"));
|
|
1526
|
+
body.appendChild(renderEditBlock("Rationale", dirty.rationale, v => { dirty.rationale = v; dirtyEpicEdits[entity.id] = dirty; }, "Why…"));
|
|
1527
|
+
body.appendChild(renderEditBlock("Outcome", dirty.outcome, v => { dirty.outcome = v; dirtyEpicEdits[entity.id] = dirty; }, "Intended effect…"));
|
|
1528
|
+
body.appendChild(renderEditBlock("In Scope", dirty.in_scope, v => { dirty.in_scope = v; dirtyEpicEdits[entity.id] = dirty; }, "One per line"));
|
|
1529
|
+
body.appendChild(renderEditBlock("Out of Scope", dirty.out_of_scope, v => { dirty.out_of_scope = v; dirtyEpicEdits[entity.id] = dirty; }, "One per line", "out-of-scope"));
|
|
1530
|
+
body.appendChild(renderEditBlock("Notes", dirty.notes, v => { dirty.notes = v; dirtyEpicEdits[entity.id] = dirty; }, "Notes"));
|
|
1531
|
+
|
|
1532
|
+
const footer = el("div", "edit-footer");
|
|
1533
|
+
footer.appendChild(makePaperBtn("✓ Save epic", "btn-sm btn-confirm", async () => {
|
|
1534
|
+
await saveEpicEdit(entity.id);
|
|
1535
|
+
}));
|
|
1536
|
+
footer.appendChild(makePaperBtn("Cancel", "btn-sm btn-cancel", () => {
|
|
1146
1537
|
delete dirtyEpicEdits[entity.id];
|
|
1147
|
-
|
|
1538
|
+
paperEditing = false;
|
|
1148
1539
|
render();
|
|
1149
|
-
};
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
shell.appendChild(grid);
|
|
1155
|
-
return shell;
|
|
1540
|
+
}));
|
|
1541
|
+
body.appendChild(footer);
|
|
1542
|
+
paper.appendChild(body);
|
|
1543
|
+
setTimeout(() => titleInp.focus(), 0);
|
|
1156
1544
|
}
|
|
1157
1545
|
|
|
1158
1546
|
async function saveEpicEdit(epicId) {
|
|
1159
1547
|
const dirty = dirtyEpicEdits[epicId];
|
|
1160
|
-
if (!dirty) {
|
|
1548
|
+
if (!dirty) { paperEditing = false; render(); return; }
|
|
1161
1549
|
const res = await fetch(`/api/epic-entities/${enc(epicId)}`, {
|
|
1162
1550
|
method: "PATCH",
|
|
1163
1551
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1174,7 +1562,7 @@ async function saveEpicEdit(epicId) {
|
|
|
1174
1562
|
});
|
|
1175
1563
|
if (res.ok) {
|
|
1176
1564
|
delete dirtyEpicEdits[epicId];
|
|
1177
|
-
|
|
1565
|
+
paperEditing = false;
|
|
1178
1566
|
toast("Epic saved ✓");
|
|
1179
1567
|
await load();
|
|
1180
1568
|
} else {
|
|
@@ -1182,516 +1570,62 @@ async function saveEpicEdit(epicId) {
|
|
|
1182
1570
|
}
|
|
1183
1571
|
}
|
|
1184
1572
|
|
|
1185
|
-
// ──
|
|
1186
|
-
|
|
1187
|
-
function toggleSet(set, key) {
|
|
1188
|
-
if (set.has(key)) set.delete(key);
|
|
1189
|
-
else set.add(key);
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
function makeToggleBtn(label, isOn, onClick) {
|
|
1193
|
-
const btn = document.createElement("button");
|
|
1194
|
-
btn.type = "button";
|
|
1195
|
-
btn.className = `sl-toggle${isOn ? " on" : ""}`;
|
|
1196
|
-
btn.innerHTML = `<span class="chev">▶</span>${label}`;
|
|
1197
|
-
btn.onclick = e => { e.stopPropagation(); onClick(); };
|
|
1198
|
-
return btn;
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
function renderSwimlane({ key, name, epicId, entity, tasks }) {
|
|
1202
|
-
const infoOpen = entity ? !collapsedInfo.has(key) : false;
|
|
1203
|
-
const tasksOpen = !collapsedTasks.has(key);
|
|
1204
|
-
const isEditingEpic = epicId && editingEpicId === epicId;
|
|
1205
|
-
|
|
1206
|
-
const wrap = el("div", "swimlane");
|
|
1207
|
-
|
|
1208
|
-
const hdr = el("div", "sl-header");
|
|
1209
|
-
const totalTasks = tasks.reduce((s, e) => s + (e.subtasks || []).length, 0);
|
|
1210
|
-
const doneTasks = tasks.reduce((s, e) => s + (e.subtasks || []).filter(t => t.done).length, 0);
|
|
1211
|
-
const pct = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : null;
|
|
1212
|
-
const cardsDone = tasks.filter(t => t.column === "done").length;
|
|
1213
|
-
const status = entity ? (entity.status || "empty") : null;
|
|
1214
|
-
|
|
1215
|
-
const main = el("div", "sl-header-main");
|
|
1216
|
-
const titleHtml = name === "—"
|
|
1217
|
-
? "Uncategorized (no epic)"
|
|
1218
|
-
: escHtml(name);
|
|
1219
|
-
main.innerHTML = `
|
|
1220
|
-
<span class="sl-name">${titleHtml}</span>
|
|
1221
|
-
${epicId ? `<span class="sl-id">${escHtml(epicId)}</span>` : ""}
|
|
1222
|
-
${status ? `<span class="sl-status ${escHtml(status)}">${escHtml(status)}</span>` : ""}
|
|
1223
|
-
<span class="sl-cnt">${tasks.length} cards · ${cardsDone} done</span>
|
|
1224
|
-
${pct !== null ? `<span class="sl-progress">${pct}% subtasks</span>` : ""}
|
|
1225
|
-
`;
|
|
1226
|
-
hdr.appendChild(main);
|
|
1227
|
-
|
|
1228
|
-
const toggles = el("div", "sl-toggles");
|
|
1229
|
-
|
|
1230
|
-
if (entity) {
|
|
1231
|
-
toggles.appendChild(makeToggleBtn("Info", infoOpen, () => {
|
|
1232
|
-
toggleSet(collapsedInfo, key);
|
|
1233
|
-
render();
|
|
1234
|
-
}));
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
toggles.appendChild(makeToggleBtn("Tasks", tasksOpen, () => {
|
|
1238
|
-
toggleSet(collapsedTasks, key);
|
|
1239
|
-
render();
|
|
1240
|
-
}));
|
|
1241
|
-
|
|
1242
|
-
if (epicId && entity) {
|
|
1243
|
-
const editBtn = document.createElement("button");
|
|
1244
|
-
editBtn.type = "button";
|
|
1245
|
-
editBtn.className = `sl-edit-btn${isEditingEpic ? " active" : ""}`;
|
|
1246
|
-
editBtn.textContent = isEditingEpic ? "Editing…" : "Edit";
|
|
1247
|
-
editBtn.onclick = e => {
|
|
1248
|
-
e.stopPropagation();
|
|
1249
|
-
if (editingEpicId === epicId) {
|
|
1250
|
-
editingEpicId = null;
|
|
1251
|
-
delete dirtyEpicEdits[epicId];
|
|
1252
|
-
} else {
|
|
1253
|
-
editingEpicId = epicId;
|
|
1254
|
-
collapsedInfo.delete(key);
|
|
1255
|
-
}
|
|
1256
|
-
render();
|
|
1257
|
-
};
|
|
1258
|
-
toggles.appendChild(editBtn);
|
|
1259
|
-
|
|
1260
|
-
const archBtn = document.createElement("button");
|
|
1261
|
-
archBtn.type = "button";
|
|
1262
|
-
archBtn.className = "sl-edit-btn";
|
|
1263
|
-
archBtn.textContent = entity.archived ? "Unarchive" : "Archive";
|
|
1264
|
-
archBtn.onclick = e => {
|
|
1265
|
-
e.stopPropagation();
|
|
1266
|
-
archiveEpic(epicId, !entity.archived);
|
|
1267
|
-
};
|
|
1268
|
-
toggles.appendChild(archBtn);
|
|
1269
|
-
|
|
1270
|
-
const delBtn = document.createElement("button");
|
|
1271
|
-
delBtn.type = "button";
|
|
1272
|
-
delBtn.className = "sl-edit-btn";
|
|
1273
|
-
delBtn.textContent = "Delete";
|
|
1274
|
-
delBtn.title = "Delete epic and all child tasks";
|
|
1275
|
-
delBtn.onclick = e => {
|
|
1276
|
-
e.stopPropagation();
|
|
1277
|
-
deleteEpicEntity(epicId, entity.title || name);
|
|
1278
|
-
};
|
|
1279
|
-
toggles.appendChild(delBtn);
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
hdr.appendChild(toggles);
|
|
1283
|
-
wrap.appendChild(hdr);
|
|
1284
|
-
|
|
1285
|
-
if (entity && (infoOpen || isEditingEpic)) {
|
|
1286
|
-
wrap.appendChild(
|
|
1287
|
-
isEditingEpic ? renderEpicDetailEdit(entity) : renderEpicDetailView(entity)
|
|
1288
|
-
);
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
const body = el("div", `sl-body${tasksOpen ? "" : " hidden"}`);
|
|
1292
|
-
COL_DEFS.forEach(col => {
|
|
1293
|
-
const lane = el("div", `lane col-${col.id}`);
|
|
1294
|
-
tasks.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
|
|
1295
|
-
if (col.id !== "done") {
|
|
1296
|
-
const addBtn = document.createElement("button");
|
|
1297
|
-
addBtn.className = "lane-add";
|
|
1298
|
-
addBtn.textContent = "+ Add here";
|
|
1299
|
-
addBtn.onclick = e => { e.stopPropagation(); showLaneAdd(lane, col.id, epicId || name, addBtn); };
|
|
1300
|
-
lane.appendChild(addBtn);
|
|
1301
|
-
}
|
|
1302
|
-
body.appendChild(lane);
|
|
1303
|
-
});
|
|
1304
|
-
wrap.appendChild(body);
|
|
1305
|
-
return wrap;
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
// ── Card ──────────────────────────────────────────────────────────────────────
|
|
1309
|
-
|
|
1310
|
-
function getNeighborCols(colId) {
|
|
1311
|
-
const idx = COL_DEFS.findIndex(c => c.id === colId);
|
|
1312
|
-
return {
|
|
1313
|
-
prev: idx > 0 ? COL_DEFS[idx - 1] : null,
|
|
1314
|
-
next: idx < COL_DEFS.length - 1 ? COL_DEFS[idx + 1] : null,
|
|
1315
|
-
};
|
|
1316
|
-
}
|
|
1573
|
+
// ── CRUD helpers ──────────────────────────────────────────────────────────────
|
|
1317
1574
|
|
|
1318
|
-
async function
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
headers: { "Content-Type": "application/json" },
|
|
1322
|
-
body: JSON.stringify({ column: targetColId }),
|
|
1323
|
-
});
|
|
1324
|
-
expanded.delete(epicId);
|
|
1575
|
+
async function archiveEpic(epicId, archived) {
|
|
1576
|
+
const action = archived ? "archive" : "unarchive";
|
|
1577
|
+
await fetch(`/api/epic-entities/${enc(epicId)}/${action}`, { method: "POST" });
|
|
1325
1578
|
await load();
|
|
1326
1579
|
}
|
|
1327
1580
|
|
|
1328
|
-
function
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
const totalT = epic.subtasks.length;
|
|
1335
|
-
const doneT = epic.subtasks.filter(t => t.done).length;
|
|
1336
|
-
const pct = totalT > 0 ? Math.round(doneT / totalT * 100) : 0;
|
|
1337
|
-
const shortId = (epic.id.match(/^PI-\d+/) || [epic.id])[0];
|
|
1338
|
-
const displayTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
|
|
1339
|
-
|
|
1340
|
-
if (isEditing) {
|
|
1341
|
-
renderEditMode(card, epic, colId, shortId);
|
|
1342
|
-
} else {
|
|
1343
|
-
// Top row: ID badge
|
|
1344
|
-
const top = el("div", "card-top");
|
|
1345
|
-
const idBadge = el("span", "card-id");
|
|
1346
|
-
idBadge.textContent = shortId;
|
|
1347
|
-
top.appendChild(idBadge);
|
|
1348
|
-
card.appendChild(top);
|
|
1349
|
-
|
|
1350
|
-
// Title
|
|
1351
|
-
const titleEl = el("div", "card-title");
|
|
1352
|
-
titleEl.textContent = displayTitle;
|
|
1353
|
-
card.appendChild(titleEl);
|
|
1354
|
-
|
|
1355
|
-
// Progress (skip for icebox — frozen, not in progress)
|
|
1356
|
-
if (totalT > 0 && colId !== "icebox") {
|
|
1357
|
-
const pr = el("div", "prog-row");
|
|
1358
|
-
pr.innerHTML = `
|
|
1359
|
-
<div class="prog-bar"><div class="prog-fill ${colId}" style="width:${pct}%"></div></div>
|
|
1360
|
-
<span class="prog-txt">${doneT}/${totalT}</span>
|
|
1361
|
-
`;
|
|
1362
|
-
card.appendChild(pr);
|
|
1363
|
-
} else if (totalT > 0 && colId === "icebox") {
|
|
1364
|
-
// For icebox show just a count badge
|
|
1365
|
-
const badge = el("span", "prog-txt");
|
|
1366
|
-
badge.style.cssText = "display:inline-block;margin-top:5px;background:rgba(100,116,139,0.12);border-radius:4px;padding:1px 6px;";
|
|
1367
|
-
badge.textContent = `${totalT} subtasks`;
|
|
1368
|
-
card.appendChild(badge);
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
// Actions
|
|
1372
|
-
const actions = el("div", "card-actions");
|
|
1373
|
-
|
|
1374
|
-
// ← arrow
|
|
1375
|
-
const { prev, next } = getNeighborCols(colId);
|
|
1376
|
-
if (prev) {
|
|
1377
|
-
const lb = document.createElement("button");
|
|
1378
|
-
lb.className = "cbtn arr";
|
|
1379
|
-
lb.textContent = "←";
|
|
1380
|
-
lb.title = `→ ${prev.label}`;
|
|
1381
|
-
lb.onclick = async e => { e.stopPropagation(); await moveCard(epic.id, prev.id); };
|
|
1382
|
-
actions.appendChild(lb);
|
|
1383
|
-
}
|
|
1384
|
-
// → arrow
|
|
1385
|
-
if (next) {
|
|
1386
|
-
const rb = document.createElement("button");
|
|
1387
|
-
rb.className = "cbtn arr";
|
|
1388
|
-
rb.textContent = "→";
|
|
1389
|
-
rb.title = `→ ${next.label}`;
|
|
1390
|
-
rb.onclick = async e => { e.stopPropagation(); await moveCard(epic.id, next.id); };
|
|
1391
|
-
actions.appendChild(rb);
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
// Expand details button
|
|
1395
|
-
const expBtn = document.createElement("button");
|
|
1396
|
-
expBtn.className = "cbtn";
|
|
1397
|
-
expBtn.textContent = isExpanded ? "▲" : "▼ Details";
|
|
1398
|
-
expBtn.onclick = e => { e.stopPropagation(); toggleExpand(epic.id); };
|
|
1399
|
-
actions.appendChild(expBtn);
|
|
1400
|
-
|
|
1401
|
-
// Edit button
|
|
1402
|
-
const editBtn = document.createElement("button");
|
|
1403
|
-
editBtn.className = "cbtn edit-btn";
|
|
1404
|
-
editBtn.textContent = "✎";
|
|
1405
|
-
editBtn.title = "Edit";
|
|
1406
|
-
editBtn.onclick = e => { e.stopPropagation(); startEdit(epic.id); };
|
|
1407
|
-
actions.appendChild(editBtn);
|
|
1408
|
-
|
|
1409
|
-
const delBtn = document.createElement("button");
|
|
1410
|
-
delBtn.className = "cbtn";
|
|
1411
|
-
delBtn.textContent = "✕";
|
|
1412
|
-
delBtn.title = "Delete task";
|
|
1413
|
-
delBtn.onclick = e => {
|
|
1414
|
-
e.stopPropagation();
|
|
1415
|
-
deleteTaskCard(epic.id, displayTitle);
|
|
1416
|
-
};
|
|
1417
|
-
actions.appendChild(delBtn);
|
|
1418
|
-
|
|
1419
|
-
card.appendChild(actions);
|
|
1420
|
-
|
|
1421
|
-
// Details + subtasks (expanded, view mode)
|
|
1422
|
-
if (isExpanded) {
|
|
1423
|
-
appendDetailSections(card, epic);
|
|
1424
|
-
|
|
1425
|
-
if (totalT > 0) {
|
|
1426
|
-
const lbl = el("div", "subtask-label");
|
|
1427
|
-
lbl.textContent = "Subtasks";
|
|
1428
|
-
card.appendChild(lbl);
|
|
1429
|
-
|
|
1430
|
-
const list = el("div", "tasks-list");
|
|
1431
|
-
epic.subtasks.forEach((task, idx) => {
|
|
1432
|
-
const row = el("div", `task-row${task.done ? " done" : ""}`);
|
|
1433
|
-
const cbId = `v-${epic.id}-${idx}`;
|
|
1434
|
-
row.innerHTML = `
|
|
1435
|
-
<input type="checkbox" id="${cbId}" ${task.done ? "checked" : ""}>
|
|
1436
|
-
<div class="task-copy">
|
|
1437
|
-
<label for="${cbId}">${escHtml(task.text)}</label>
|
|
1438
|
-
${task.description ? `<div class="task-desc">${escHtml(task.description)}</div>` : ""}
|
|
1439
|
-
</div>
|
|
1440
|
-
`;
|
|
1441
|
-
row.querySelector("input").onchange = async function() {
|
|
1442
|
-
this.disabled = true;
|
|
1443
|
-
await fetch(`/api/epics/${enc(epic.id)}/tasks/${idx}`, { method: "PATCH" });
|
|
1444
|
-
await load();
|
|
1445
|
-
};
|
|
1446
|
-
list.appendChild(row);
|
|
1447
|
-
});
|
|
1448
|
-
card.appendChild(list);
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
card.onclick = e => {
|
|
1453
|
-
if (e.target.tagName === "BUTTON" || e.target.tagName === "INPUT" || e.target.tagName === "LABEL") return;
|
|
1454
|
-
toggleExpand(epic.id);
|
|
1455
|
-
};
|
|
1581
|
+
async function deleteEpicEntity(epicId, title) {
|
|
1582
|
+
if (!confirm(`Delete epic ${epicId} "${title}" and ALL its tasks? This cannot be undone.`)) return;
|
|
1583
|
+
await fetch(`/api/epic-entities/${enc(epicId)}`, { method: "DELETE" });
|
|
1584
|
+
if (paperTarget && paperTarget.kind === "epic" && paperTarget.id === epicId) {
|
|
1585
|
+
paperTarget = null;
|
|
1586
|
+
paperEditing = false;
|
|
1456
1587
|
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
function renderEditMode(card, epic, colId, shortId) {
|
|
1462
|
-
const displayTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
|
|
1463
|
-
const dirty = dirtyEdits[epic.id] || {
|
|
1464
|
-
title: displayTitle,
|
|
1465
|
-
description: epic.description || "",
|
|
1466
|
-
rationale: epic.rationale || "",
|
|
1467
|
-
outcome: epic.outcome || "",
|
|
1468
|
-
specs: epic.specs || "",
|
|
1469
|
-
in_scope: (epic.in_scope || []).join("\n"),
|
|
1470
|
-
out_of_scope: (epic.out_of_scope || []).join("\n"),
|
|
1471
|
-
acceptance_criteria: (epic.acceptance_criteria || []).join("\n"),
|
|
1472
|
-
test_cases: (epic.test_cases || []).join("\n"),
|
|
1473
|
-
notes: epic.notes || "",
|
|
1474
|
-
tasks: epic.subtasks.map(t => ({ ...t }))
|
|
1475
|
-
};
|
|
1476
|
-
dirtyEdits[epic.id] = dirty;
|
|
1477
|
-
|
|
1478
|
-
const top = el("div", "card-top");
|
|
1479
|
-
const idBadge = el("span", "card-id");
|
|
1480
|
-
idBadge.textContent = shortId;
|
|
1481
|
-
top.appendChild(idBadge);
|
|
1482
|
-
card.appendChild(top);
|
|
1483
|
-
|
|
1484
|
-
// Title input
|
|
1485
|
-
const titleInput = document.createElement("input");
|
|
1486
|
-
titleInput.className = "edit-title-input";
|
|
1487
|
-
titleInput.value = dirty.title;
|
|
1488
|
-
titleInput.oninput = () => { dirty.title = titleInput.value; dirtyEdits[epic.id] = dirty; };
|
|
1489
|
-
card.appendChild(titleInput);
|
|
1490
|
-
|
|
1491
|
-
const meta = el("div", "edit-meta");
|
|
1492
|
-
meta.appendChild(renderEditBlock("What", dirty.description, value => {
|
|
1493
|
-
dirty.description = value;
|
|
1494
|
-
dirtyEdits[epic.id] = dirty;
|
|
1495
|
-
}, "What we are doing..."));
|
|
1496
|
-
meta.appendChild(renderEditBlock("Rationale", dirty.rationale, value => {
|
|
1497
|
-
dirty.rationale = value;
|
|
1498
|
-
dirtyEdits[epic.id] = dirty;
|
|
1499
|
-
}, "Why this approach, not alternatives..."));
|
|
1500
|
-
meta.appendChild(renderEditBlock("Outcome", dirty.outcome, value => {
|
|
1501
|
-
dirty.outcome = value;
|
|
1502
|
-
dirtyEdits[epic.id] = dirty;
|
|
1503
|
-
}, "Intended effect / goal..."));
|
|
1504
|
-
meta.appendChild(renderEditBlock("Specs", dirty.specs, value => {
|
|
1505
|
-
dirty.specs = value;
|
|
1506
|
-
dirtyEdits[epic.id] = dirty;
|
|
1507
|
-
}, "APIs, constraints, edge cases (how)..."));
|
|
1508
|
-
meta.appendChild(renderEditBlock("In Scope", dirty.in_scope, value => {
|
|
1509
|
-
dirty.in_scope = value;
|
|
1510
|
-
dirtyEdits[epic.id] = dirty;
|
|
1511
|
-
}, "What this task covers (one item per line)"));
|
|
1512
|
-
meta.appendChild(renderEditBlock("Out of Scope", dirty.out_of_scope, value => {
|
|
1513
|
-
dirty.out_of_scope = value;
|
|
1514
|
-
dirtyEdits[epic.id] = dirty;
|
|
1515
|
-
}, "What is explicitly not included (one item per line)", "out-of-scope"));
|
|
1516
|
-
meta.appendChild(renderEditBlock("Acceptance Criteria", dirty.acceptance_criteria, value => {
|
|
1517
|
-
dirty.acceptance_criteria = value;
|
|
1518
|
-
dirtyEdits[epic.id] = dirty;
|
|
1519
|
-
}, "One condition per line"));
|
|
1520
|
-
meta.appendChild(renderEditBlock("Test Cases", dirty.test_cases, value => {
|
|
1521
|
-
dirty.test_cases = value;
|
|
1522
|
-
dirtyEdits[epic.id] = dirty;
|
|
1523
|
-
}, "One scenario per line"));
|
|
1524
|
-
meta.appendChild(renderEditBlock("Notes", dirty.notes, value => {
|
|
1525
|
-
dirty.notes = value;
|
|
1526
|
-
dirtyEdits[epic.id] = dirty;
|
|
1527
|
-
}, "Optional notes"));
|
|
1528
|
-
card.appendChild(meta);
|
|
1529
|
-
|
|
1530
|
-
// Subtasks section
|
|
1531
|
-
const editTasks = el("div", "edit-tasks");
|
|
1532
|
-
const subtaskLbl = el("div", "subtask-label");
|
|
1533
|
-
subtaskLbl.textContent = "Subtasks";
|
|
1534
|
-
editTasks.appendChild(subtaskLbl);
|
|
1535
|
-
|
|
1536
|
-
const taskRows = el("div");
|
|
1537
|
-
|
|
1538
|
-
const refreshRows = () => {
|
|
1539
|
-
taskRows.innerHTML = "";
|
|
1540
|
-
dirty.tasks.forEach((task, idx) => {
|
|
1541
|
-
const row = el("div", "edit-task-row");
|
|
1542
|
-
const cb = document.createElement("input");
|
|
1543
|
-
cb.type = "checkbox";
|
|
1544
|
-
cb.checked = task.done;
|
|
1545
|
-
cb.onchange = () => { dirty.tasks[idx].done = cb.checked; dirtyEdits[epic.id] = dirty; };
|
|
1546
|
-
|
|
1547
|
-
const fields = el("div", "edit-task-fields");
|
|
1548
|
-
const inp = document.createElement("input");
|
|
1549
|
-
inp.className = "edit-task-input";
|
|
1550
|
-
inp.value = task.text;
|
|
1551
|
-
inp.oninput = () => { dirty.tasks[idx].text = inp.value; dirtyEdits[epic.id] = dirty; };
|
|
1552
|
-
inp.onkeydown = e => {
|
|
1553
|
-
if (e.key === "Enter") { e.preventDefault(); addSubtaskInput.focus(); }
|
|
1554
|
-
if (e.key === "Backspace" && inp.value === "") {
|
|
1555
|
-
e.preventDefault();
|
|
1556
|
-
dirty.tasks.splice(idx, 1);
|
|
1557
|
-
dirtyEdits[epic.id] = dirty;
|
|
1558
|
-
refreshRows();
|
|
1559
|
-
}
|
|
1560
|
-
};
|
|
1561
|
-
|
|
1562
|
-
const desc = document.createElement("textarea");
|
|
1563
|
-
desc.className = "edit-task-desc";
|
|
1564
|
-
desc.placeholder = "Subtask description...";
|
|
1565
|
-
desc.value = task.description || "";
|
|
1566
|
-
desc.oninput = () => { dirty.tasks[idx].description = desc.value; dirtyEdits[epic.id] = dirty; };
|
|
1567
|
-
|
|
1568
|
-
const delBtn = document.createElement("button");
|
|
1569
|
-
delBtn.className = "del-task";
|
|
1570
|
-
delBtn.textContent = "×";
|
|
1571
|
-
delBtn.title = "Remove subtask";
|
|
1572
|
-
delBtn.onclick = () => { dirty.tasks.splice(idx, 1); dirtyEdits[epic.id] = dirty; refreshRows(); };
|
|
1573
|
-
|
|
1574
|
-
fields.appendChild(inp);
|
|
1575
|
-
fields.appendChild(desc);
|
|
1576
|
-
row.appendChild(cb); row.appendChild(fields); row.appendChild(delBtn);
|
|
1577
|
-
taskRows.appendChild(row);
|
|
1578
|
-
});
|
|
1579
|
-
};
|
|
1580
|
-
refreshRows();
|
|
1581
|
-
editTasks.appendChild(taskRows);
|
|
1582
|
-
|
|
1583
|
-
// Add subtask input
|
|
1584
|
-
const addSubtaskInput = document.createElement("input");
|
|
1585
|
-
addSubtaskInput.className = "add-task-inp";
|
|
1586
|
-
addSubtaskInput.placeholder = "+ New subtask… (Enter)";
|
|
1587
|
-
addSubtaskInput.onkeydown = e => {
|
|
1588
|
-
if (e.key === "Enter" && addSubtaskInput.value.trim()) {
|
|
1589
|
-
dirty.tasks.push({ done: false, text: addSubtaskInput.value.trim(), description: "" });
|
|
1590
|
-
dirtyEdits[epic.id] = dirty;
|
|
1591
|
-
addSubtaskInput.value = "";
|
|
1592
|
-
refreshRows();
|
|
1593
|
-
}
|
|
1594
|
-
};
|
|
1595
|
-
editTasks.appendChild(addSubtaskInput);
|
|
1596
|
-
card.appendChild(editTasks);
|
|
1597
|
-
|
|
1598
|
-
// Footer
|
|
1599
|
-
const footer = el("div", "edit-footer");
|
|
1600
|
-
const saveBtn = document.createElement("button");
|
|
1601
|
-
saveBtn.className = "btn-sm btn-confirm";
|
|
1602
|
-
saveBtn.textContent = "✓ Save";
|
|
1603
|
-
saveBtn.onclick = async e => { e.stopPropagation(); await saveEdit(epic.id); };
|
|
1604
|
-
|
|
1605
|
-
const cancelBtn = document.createElement("button");
|
|
1606
|
-
cancelBtn.className = "btn-sm btn-cancel";
|
|
1607
|
-
cancelBtn.textContent = "Cancel";
|
|
1608
|
-
cancelBtn.onclick = e => {
|
|
1609
|
-
e.stopPropagation(); delete dirtyEdits[epic.id]; editingId = null; render();
|
|
1610
|
-
};
|
|
1611
|
-
|
|
1612
|
-
footer.appendChild(saveBtn); footer.appendChild(cancelBtn);
|
|
1613
|
-
card.appendChild(footer);
|
|
1614
|
-
setTimeout(() => titleInput.focus(), 0);
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
// ── Edit helpers ──────────────────────────────────────────────────────────────
|
|
1618
|
-
|
|
1619
|
-
function startEdit(id) {
|
|
1620
|
-
if (editingId && editingId !== id) delete dirtyEdits[editingId];
|
|
1621
|
-
editingId = id;
|
|
1622
|
-
expanded.add(id);
|
|
1623
|
-
render();
|
|
1588
|
+
if (filterKey === epicId) filterKey = null;
|
|
1589
|
+
await load();
|
|
1624
1590
|
}
|
|
1625
1591
|
|
|
1626
|
-
async function
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
if (epic) {
|
|
1633
|
-
const origTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
|
|
1634
|
-
if (dirty.title !== origTitle) body.title = dirty.title;
|
|
1635
|
-
body.description = dirty.description;
|
|
1636
|
-
body.rationale = dirty.rationale;
|
|
1637
|
-
body.outcome = dirty.outcome;
|
|
1638
|
-
body.specs = dirty.specs;
|
|
1639
|
-
body.in_scope = splitLines(dirty.in_scope);
|
|
1640
|
-
body.out_of_scope = splitLines(dirty.out_of_scope);
|
|
1641
|
-
body.acceptance_criteria = splitLines(dirty.acceptance_criteria);
|
|
1642
|
-
body.test_cases = splitLines(dirty.test_cases);
|
|
1643
|
-
body.notes = dirty.notes;
|
|
1644
|
-
body.subtasks = dirty.tasks.filter(t => t.text.trim()).map((task, idx) => ({
|
|
1645
|
-
id: task.id || `st-${idx + 1}`,
|
|
1646
|
-
text: task.text.trim(),
|
|
1647
|
-
done: Boolean(task.done),
|
|
1648
|
-
description: (task.description || "").trim()
|
|
1649
|
-
}));
|
|
1592
|
+
async function deleteTaskCard(taskId, title) {
|
|
1593
|
+
if (!confirm(`Delete task ${taskId}: ${title}?`)) return;
|
|
1594
|
+
await fetch(`/api/tasks/${enc(taskId)}`, { method: "DELETE" });
|
|
1595
|
+
if (paperTarget && paperTarget.kind === "task" && paperTarget.id === taskId) {
|
|
1596
|
+
paperTarget = null;
|
|
1597
|
+
paperEditing = false;
|
|
1650
1598
|
}
|
|
1651
|
-
try {
|
|
1652
|
-
const res = await fetch(`/api/epics/${enc(epicId)}`, {
|
|
1653
|
-
method: "PATCH",
|
|
1654
|
-
headers: { "Content-Type": "application/json" },
|
|
1655
|
-
body: JSON.stringify(body),
|
|
1656
|
-
});
|
|
1657
|
-
if (!res.ok) throw new Error();
|
|
1658
|
-
toast("Saved ✓");
|
|
1659
|
-
} catch { toast("Save error!", true); }
|
|
1660
|
-
delete dirtyEdits[epicId];
|
|
1661
|
-
editingId = null;
|
|
1662
1599
|
await load();
|
|
1663
1600
|
}
|
|
1664
1601
|
|
|
1665
|
-
function
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1602
|
+
function toggleShowArchived() {
|
|
1603
|
+
showArchived = !showArchived;
|
|
1604
|
+
const btn = document.getElementById("btn-show-archived");
|
|
1605
|
+
if (btn) {
|
|
1606
|
+
btn.textContent = showArchived ? "Hide archived" : "Show archived";
|
|
1607
|
+
btn.classList.toggle("on", showArchived);
|
|
1608
|
+
}
|
|
1609
|
+
load();
|
|
1669
1610
|
}
|
|
1670
1611
|
|
|
1671
|
-
// ── Lane
|
|
1612
|
+
// ── Lane add ──────────────────────────────────────────────────────────────────
|
|
1672
1613
|
|
|
1673
|
-
function showLaneAdd(
|
|
1674
|
-
const existing =
|
|
1614
|
+
function showLaneAdd(container, colId, epicRef, addBtn) {
|
|
1615
|
+
const existing = container.querySelector(".lane-addinp");
|
|
1675
1616
|
if (existing) { existing.remove(); return; }
|
|
1676
1617
|
|
|
1677
1618
|
const wrap = el("div", "lane-addinp");
|
|
1678
|
-
|
|
1679
|
-
const titleLabel = el("div", "lane-add-label");
|
|
1680
|
-
titleLabel.textContent = "Task title";
|
|
1681
|
-
wrap.appendChild(titleLabel);
|
|
1682
|
-
|
|
1619
|
+
wrap.appendChild(Object.assign(el("div", "lane-add-label"), { textContent: "Task title" }));
|
|
1683
1620
|
const inp = document.createElement("input");
|
|
1684
1621
|
inp.className = "lane-add-title";
|
|
1685
1622
|
inp.placeholder = "New task name…";
|
|
1686
1623
|
inp.autocomplete = "off";
|
|
1687
1624
|
|
|
1688
|
-
|
|
1689
|
-
descLabel.textContent = "Description";
|
|
1690
|
-
wrap.appendChild(descLabel);
|
|
1691
|
-
|
|
1625
|
+
wrap.appendChild(Object.assign(el("div", "lane-add-label"), { textContent: "Description" }));
|
|
1692
1626
|
const description = document.createElement("textarea");
|
|
1693
1627
|
description.className = "lane-add-textarea";
|
|
1694
|
-
description.placeholder = "
|
|
1628
|
+
description.placeholder = "Optional…";
|
|
1695
1629
|
|
|
1696
1630
|
const actions = el("div", "lane-add-actions");
|
|
1697
1631
|
const confirmBtn = document.createElement("button");
|
|
@@ -1706,17 +1640,21 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
1706
1640
|
if (!title) return;
|
|
1707
1641
|
inp.disabled = true;
|
|
1708
1642
|
description.disabled = true;
|
|
1709
|
-
const
|
|
1643
|
+
const payload = {
|
|
1644
|
+
title,
|
|
1645
|
+
column: colId,
|
|
1646
|
+
description: description.value.trim()
|
|
1647
|
+
};
|
|
1648
|
+
if (epicRef) {
|
|
1649
|
+
payload.epic = epicRef;
|
|
1650
|
+
if (/^E\d+/i.test(epicRef)) payload.epic_id = epicRef;
|
|
1651
|
+
} else {
|
|
1652
|
+
payload.epic = "—";
|
|
1653
|
+
}
|
|
1710
1654
|
const res = await fetch("/api/tasks", {
|
|
1711
1655
|
method: "POST",
|
|
1712
1656
|
headers: { "Content-Type": "application/json" },
|
|
1713
|
-
body: JSON.stringify(
|
|
1714
|
-
title,
|
|
1715
|
-
column: colId,
|
|
1716
|
-
epic: epicRef,
|
|
1717
|
-
epic_id: epicRef && /^E\d+/i.test(epicRef) ? epicRef : undefined,
|
|
1718
|
-
description: description.value.trim()
|
|
1719
|
-
}),
|
|
1657
|
+
body: JSON.stringify(payload)
|
|
1720
1658
|
});
|
|
1721
1659
|
if (res.ok) {
|
|
1722
1660
|
toast("Added ✓");
|
|
@@ -1739,11 +1677,12 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
1739
1677
|
actions.appendChild(confirmBtn);
|
|
1740
1678
|
actions.appendChild(cancelBtn);
|
|
1741
1679
|
wrap.appendChild(actions);
|
|
1742
|
-
|
|
1680
|
+
if (addBtn && addBtn.parentNode === container) container.insertBefore(wrap, addBtn);
|
|
1681
|
+
else container.appendChild(wrap);
|
|
1743
1682
|
inp.focus();
|
|
1744
1683
|
}
|
|
1745
1684
|
|
|
1746
|
-
// ── New task / epic forms
|
|
1685
|
+
// ── New task / epic forms ─────────────────────────────────────────────────────
|
|
1747
1686
|
|
|
1748
1687
|
function toggleNewForm() {
|
|
1749
1688
|
const epicStrip = document.getElementById("new-epic-strip");
|
|
@@ -1751,6 +1690,9 @@ function toggleNewForm() {
|
|
|
1751
1690
|
const strip = document.getElementById("new-strip");
|
|
1752
1691
|
strip.classList.toggle("open");
|
|
1753
1692
|
if (strip.classList.contains("open")) {
|
|
1693
|
+
if (filterKey && filterKey !== "—") {
|
|
1694
|
+
document.getElementById("ne-group").value = filterKey;
|
|
1695
|
+
}
|
|
1754
1696
|
document.getElementById("ne-title").focus();
|
|
1755
1697
|
}
|
|
1756
1698
|
}
|
|
@@ -1767,14 +1709,17 @@ function toggleNewEpicForm() {
|
|
|
1767
1709
|
|
|
1768
1710
|
async function submitNew() {
|
|
1769
1711
|
const title = document.getElementById("ne-title").value.trim();
|
|
1770
|
-
const col
|
|
1712
|
+
const col = document.getElementById("ne-col").value;
|
|
1771
1713
|
const group = document.getElementById("ne-group").value.trim();
|
|
1772
1714
|
if (!title) { document.getElementById("ne-title").focus(); return; }
|
|
1773
1715
|
|
|
1716
|
+
const payload = { title, column: col, epic: group || "—" };
|
|
1717
|
+
if (group && /^E\d+/i.test(group)) payload.epic_id = group;
|
|
1718
|
+
|
|
1774
1719
|
const res = await fetch("/api/tasks", {
|
|
1775
1720
|
method: "POST",
|
|
1776
1721
|
headers: { "Content-Type": "application/json" },
|
|
1777
|
-
body: JSON.stringify(
|
|
1722
|
+
body: JSON.stringify(payload)
|
|
1778
1723
|
});
|
|
1779
1724
|
if (res.ok) {
|
|
1780
1725
|
toast("Task added ✓");
|
|
@@ -1796,7 +1741,7 @@ async function submitNewEpic() {
|
|
|
1796
1741
|
const res = await fetch("/api/epics", {
|
|
1797
1742
|
method: "POST",
|
|
1798
1743
|
headers: { "Content-Type": "application/json" },
|
|
1799
|
-
body: JSON.stringify({ title, description, goals })
|
|
1744
|
+
body: JSON.stringify({ title, description, goals })
|
|
1800
1745
|
});
|
|
1801
1746
|
if (res.ok) {
|
|
1802
1747
|
toast("Epic added ✓");
|
|
@@ -1804,13 +1749,18 @@ async function submitNewEpic() {
|
|
|
1804
1749
|
document.getElementById("nx-description").value = "";
|
|
1805
1750
|
document.getElementById("nx-goals").value = "";
|
|
1806
1751
|
toggleNewEpicForm();
|
|
1752
|
+
const created = await res.json();
|
|
1753
|
+
if (created && created.id) {
|
|
1754
|
+
filterKey = created.id;
|
|
1755
|
+
openPaper("epic", created.id);
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1807
1758
|
await load();
|
|
1808
1759
|
} else {
|
|
1809
1760
|
toast("Error!", true);
|
|
1810
1761
|
}
|
|
1811
1762
|
}
|
|
1812
1763
|
|
|
1813
|
-
// Key handler for new form
|
|
1814
1764
|
document.getElementById("ne-title").addEventListener("keydown", e => {
|
|
1815
1765
|
if (e.key === "Enter") submitNew();
|
|
1816
1766
|
if (e.key === "Escape") toggleNewForm();
|
|
@@ -1830,47 +1780,57 @@ function el(tag, cls) {
|
|
|
1830
1780
|
function splitLines(value) {
|
|
1831
1781
|
return String(value || "").split("\n").map(line => line.trim()).filter(Boolean);
|
|
1832
1782
|
}
|
|
1833
|
-
function appendDetailSections(card, epic) {
|
|
1834
|
-
if (epic.description) {
|
|
1835
|
-
card.appendChild(renderDetailSection("What", epic.description));
|
|
1836
|
-
}
|
|
1837
|
-
if (epic.rationale) {
|
|
1838
|
-
card.appendChild(renderDetailSection("Rationale", epic.rationale));
|
|
1839
|
-
}
|
|
1840
|
-
if (epic.outcome) {
|
|
1841
|
-
card.appendChild(renderDetailSection("Outcome", epic.outcome));
|
|
1842
|
-
}
|
|
1843
|
-
if (epic.specs) {
|
|
1844
|
-
card.appendChild(renderDetailSection("Specs", epic.specs));
|
|
1845
|
-
}
|
|
1846
|
-
if (epic.in_scope && epic.in_scope.length > 0) {
|
|
1847
|
-
card.appendChild(renderDetailListSection("In Scope", epic.in_scope));
|
|
1848
|
-
}
|
|
1849
|
-
if (epic.out_of_scope && epic.out_of_scope.length > 0) {
|
|
1850
|
-
card.appendChild(renderDetailListSection("Out of Scope", epic.out_of_scope, "out-of-scope"));
|
|
1851
|
-
}
|
|
1852
|
-
if (epic.acceptance_criteria && epic.acceptance_criteria.length > 0) {
|
|
1853
|
-
card.appendChild(renderDetailListSection("Acceptance Criteria", epic.acceptance_criteria));
|
|
1854
|
-
}
|
|
1855
|
-
if (epic.test_cases && epic.test_cases.length > 0) {
|
|
1856
|
-
card.appendChild(renderDetailListSection("Test Cases", epic.test_cases));
|
|
1857
|
-
}
|
|
1858
|
-
if (epic.notes) {
|
|
1859
|
-
card.appendChild(renderDetailSection("Notes", epic.notes));
|
|
1860
|
-
}
|
|
1861
|
-
}
|
|
1862
1783
|
function renderDetailSection(label, text) {
|
|
1863
1784
|
const wrap = el("div", "detail-section");
|
|
1864
|
-
|
|
1865
|
-
labelEl.textContent = label;
|
|
1785
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: label }));
|
|
1866
1786
|
const textEl = el("div", "detail-text");
|
|
1867
1787
|
fillRichText(textEl, text);
|
|
1868
|
-
wrap.appendChild(labelEl);
|
|
1869
1788
|
wrap.appendChild(textEl);
|
|
1870
1789
|
return wrap;
|
|
1871
1790
|
}
|
|
1791
|
+
function renderDetailListSection(label, items, extraClass) {
|
|
1792
|
+
const wrap = el("div", extraClass ? `detail-section ${extraClass}` : "detail-section");
|
|
1793
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: label }));
|
|
1794
|
+
if (!items || items.length === 0) {
|
|
1795
|
+
wrap.appendChild(Object.assign(el("div", "detail-text empty"), { textContent: "Nothing listed" }));
|
|
1796
|
+
return wrap;
|
|
1797
|
+
}
|
|
1798
|
+
const list = el("ul", "detail-list");
|
|
1799
|
+
items.forEach(item => {
|
|
1800
|
+
const li = document.createElement("li");
|
|
1801
|
+
li.textContent = item;
|
|
1802
|
+
list.appendChild(li);
|
|
1803
|
+
});
|
|
1804
|
+
wrap.appendChild(list);
|
|
1805
|
+
return wrap;
|
|
1806
|
+
}
|
|
1807
|
+
function renderEditBlock(label, value, onInput, placeholder, extraClass) {
|
|
1808
|
+
const wrap = el("div", extraClass ? `detail-section ${extraClass}` : "detail-section");
|
|
1809
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: label }));
|
|
1810
|
+
const textarea = document.createElement("textarea");
|
|
1811
|
+
textarea.className = extraClass ? `edit-block ${extraClass}` : "edit-block";
|
|
1812
|
+
textarea.value = value;
|
|
1813
|
+
textarea.placeholder = placeholder || "";
|
|
1814
|
+
textarea.oninput = () => onInput(textarea.value);
|
|
1815
|
+
wrap.appendChild(textarea);
|
|
1816
|
+
return wrap;
|
|
1817
|
+
}
|
|
1818
|
+
function escHtml(s) {
|
|
1819
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1820
|
+
}
|
|
1821
|
+
function enc(s) { return encodeURIComponent(s); }
|
|
1822
|
+
|
|
1823
|
+
let toastTimer;
|
|
1824
|
+
function toast(msg, err = false) {
|
|
1825
|
+
const t = document.getElementById("toast");
|
|
1826
|
+
t.textContent = msg;
|
|
1827
|
+
t.style.background = err ? "#dc2626" : "#1e293b";
|
|
1828
|
+
t.classList.add("show");
|
|
1829
|
+
clearTimeout(toastTimer);
|
|
1830
|
+
toastTimer = setTimeout(() => t.classList.remove("show"), 2500);
|
|
1831
|
+
}
|
|
1872
1832
|
|
|
1873
|
-
// ── Mermaid
|
|
1833
|
+
// ── Mermaid ───────────────────────────────────────────────────────────────────
|
|
1874
1834
|
|
|
1875
1835
|
let mermaidReady = false;
|
|
1876
1836
|
let mermaidRunTimer = null;
|
|
@@ -1887,7 +1847,6 @@ function fillRichText(container, text) {
|
|
|
1887
1847
|
container.textContent = "";
|
|
1888
1848
|
const segments = parseFencedSegments(text);
|
|
1889
1849
|
if (segments.length === 0) return;
|
|
1890
|
-
|
|
1891
1850
|
segments.forEach(seg => {
|
|
1892
1851
|
if (seg.type === "mermaid") {
|
|
1893
1852
|
const box = el("div", "md-mermaid");
|
|
@@ -1922,11 +1881,7 @@ function ensureMermaidInit() {
|
|
|
1922
1881
|
if (mermaidReady) return true;
|
|
1923
1882
|
if (typeof mermaid === "undefined" || !mermaid) return false;
|
|
1924
1883
|
try {
|
|
1925
|
-
mermaid.initialize({
|
|
1926
|
-
startOnLoad: false,
|
|
1927
|
-
securityLevel: "strict",
|
|
1928
|
-
theme: "neutral"
|
|
1929
|
-
});
|
|
1884
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "neutral" });
|
|
1930
1885
|
mermaidReady = true;
|
|
1931
1886
|
return true;
|
|
1932
1887
|
} catch (err) {
|
|
@@ -1941,7 +1896,6 @@ async function runMermaidIn(root) {
|
|
|
1941
1896
|
n => !n.dataset.processed && n.textContent.trim()
|
|
1942
1897
|
);
|
|
1943
1898
|
if (nodes.length === 0) return;
|
|
1944
|
-
|
|
1945
1899
|
if (!ensureMermaidInit()) {
|
|
1946
1900
|
nodes.forEach(pre => {
|
|
1947
1901
|
const box = pre.closest(".md-mermaid") || pre.parentElement;
|
|
@@ -1949,15 +1903,12 @@ async function runMermaidIn(root) {
|
|
|
1949
1903
|
});
|
|
1950
1904
|
return;
|
|
1951
1905
|
}
|
|
1952
|
-
|
|
1953
|
-
// Prefer per-node render so one bad diagram never blocks siblings.
|
|
1954
1906
|
for (const pre of nodes) {
|
|
1955
1907
|
const source = pre.textContent;
|
|
1956
1908
|
const box = pre.closest(".md-mermaid") || pre.parentElement;
|
|
1957
1909
|
const id = `mmd-${Date.now()}-${++mermaidSeq}`;
|
|
1958
1910
|
try {
|
|
1959
1911
|
const out = await mermaid.render(id, source);
|
|
1960
|
-
// Board re-render may detach this node while await was in flight.
|
|
1961
1912
|
if (!pre.isConnected) continue;
|
|
1962
1913
|
const svg = typeof out === "string" ? out : (out && out.svg);
|
|
1963
1914
|
if (!svg) throw new Error("empty mermaid output");
|
|
@@ -1968,7 +1919,6 @@ async function runMermaidIn(root) {
|
|
|
1968
1919
|
pre.outerHTML = svg;
|
|
1969
1920
|
}
|
|
1970
1921
|
} catch (err) {
|
|
1971
|
-
// Remove orphaned error SVG mermaid may leave in <body>
|
|
1972
1922
|
const stray = document.getElementById(id);
|
|
1973
1923
|
if (stray) stray.remove();
|
|
1974
1924
|
const strayD = document.getElementById("d" + id);
|
|
@@ -1988,49 +1938,7 @@ function queueMermaidRun(root) {
|
|
|
1988
1938
|
runMermaidIn(root).catch(err => console.error("mermaid run failed", err));
|
|
1989
1939
|
}, 0);
|
|
1990
1940
|
}
|
|
1991
|
-
function renderDetailListSection(label, items, extraClass) {
|
|
1992
|
-
const wrap = el("div", extraClass ? `detail-section ${extraClass}` : "detail-section");
|
|
1993
|
-
const labelEl = el("div", "detail-label");
|
|
1994
|
-
labelEl.textContent = label;
|
|
1995
|
-
const list = el("ul", "detail-list");
|
|
1996
|
-
items.forEach(item => {
|
|
1997
|
-
const li = document.createElement("li");
|
|
1998
|
-
li.textContent = item;
|
|
1999
|
-
list.appendChild(li);
|
|
2000
|
-
});
|
|
2001
|
-
wrap.appendChild(labelEl);
|
|
2002
|
-
wrap.appendChild(list);
|
|
2003
|
-
return wrap;
|
|
2004
|
-
}
|
|
2005
|
-
function renderEditBlock(label, value, onInput, placeholder, extraClass) {
|
|
2006
|
-
const wrap = el("div", extraClass ? `detail-section ${extraClass}` : null);
|
|
2007
|
-
const labelEl = el("div", "detail-label");
|
|
2008
|
-
labelEl.textContent = label;
|
|
2009
|
-
const textarea = document.createElement("textarea");
|
|
2010
|
-
textarea.className = extraClass ? `edit-block ${extraClass}` : "edit-block";
|
|
2011
|
-
textarea.value = value;
|
|
2012
|
-
textarea.placeholder = placeholder;
|
|
2013
|
-
textarea.oninput = () => onInput(textarea.value);
|
|
2014
|
-
wrap.appendChild(labelEl);
|
|
2015
|
-
wrap.appendChild(textarea);
|
|
2016
|
-
return wrap;
|
|
2017
|
-
}
|
|
2018
|
-
function escHtml(s) {
|
|
2019
|
-
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
|
2020
|
-
}
|
|
2021
|
-
function enc(s) { return encodeURIComponent(s); }
|
|
2022
|
-
|
|
2023
|
-
let toastTimer;
|
|
2024
|
-
function toast(msg, err = false) {
|
|
2025
|
-
const t = document.getElementById("toast");
|
|
2026
|
-
t.textContent = msg;
|
|
2027
|
-
t.style.background = err ? "#dc2626" : "#1e293b";
|
|
2028
|
-
t.classList.add("show");
|
|
2029
|
-
clearTimeout(toastTimer);
|
|
2030
|
-
toastTimer = setTimeout(() => t.classList.remove("show"), 2500);
|
|
2031
|
-
}
|
|
2032
1941
|
|
|
2033
|
-
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
2034
1942
|
load();
|
|
2035
1943
|
</script>
|
|
2036
1944
|
</body>
|