mdboard 1.0.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/index.html ADDED
@@ -0,0 +1,1067 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>mdboard</title>
7
+ <style>
8
+ /* ── Reset & Variables ───────────────────────────────────── */
9
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
10
+ :root{
11
+ --bg:#0A0A0B;--surface:#141415;--surface2:#1A1A1C;--border:#232326;--border2:#2E2E33;
12
+ --text:#E8E8ED;--text2:#8B8B93;--text3:#5A5A63;
13
+ --accent:#5B6EF5;--accent-dim:rgba(91,110,245,.15);
14
+ --success:#2EA043;--success-dim:rgba(46,160,67,.15);
15
+ --warning:#D4A72C;--warning-dim:rgba(212,167,44,.15);
16
+ --danger:#DA3633;--danger-dim:rgba(218,54,51,.15);
17
+ --purple:#8B5CF6;--purple-dim:rgba(139,92,246,.15);
18
+ --font:system-ui,-apple-system,'Segoe UI',sans-serif;
19
+ --mono:ui-monospace,'SF Mono','Cascadia Code',monospace;
20
+ --radius:8px;--radius-sm:4px;
21
+ --sidebar-w:220px;
22
+ }
23
+ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;line-height:1.5}
24
+ a{color:var(--accent);text-decoration:none}
25
+
26
+ /* ── Layout ──────────────────────────────────────────────── */
27
+ .app{display:flex;height:100vh;overflow:hidden}
28
+ .sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
29
+ .sidebar-logo{padding:20px 16px 16px;font-family:var(--mono);font-size:15px;font-weight:700;color:var(--text);letter-spacing:-.02em}
30
+ .sidebar-logo span{color:var(--accent)}
31
+ #sidebar-nav{flex:1;padding:8px}
32
+ #sidebar-nav a{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:var(--radius-sm);color:var(--text2);font-size:13px;font-weight:500;transition:all .15s;border-left:2px solid transparent;margin-bottom:2px}
33
+ #sidebar-nav a:hover{color:var(--text);background:var(--surface2)}
34
+ #sidebar-nav a.active{color:var(--text);border-left-color:var(--accent);background:var(--accent-dim)}
35
+ #sidebar-nav svg{width:16px;height:16px;flex-shrink:0}
36
+ .sidebar-footer{padding:16px;font-size:11px;color:var(--text3);border-top:1px solid var(--border)}
37
+
38
+ .main{flex:1;display:flex;flex-direction:column;overflow:hidden}
39
+
40
+ /* ── Header ──────────────────────────────────────────────── */
41
+ .header{padding:16px 24px;border-bottom:1px solid var(--border);background:var(--surface);display:flex;align-items:center;gap:24px;flex-wrap:wrap}
42
+ .header-title{flex-shrink:0}
43
+ .header-title h1{font-size:18px;font-weight:700;line-height:1.3}
44
+ .header-title p{font-size:12px;color:var(--text2);margin-top:2px}
45
+ .header-section{display:flex;flex-direction:column;gap:4px;min-width:120px}
46
+ .header-section-label{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);font-weight:600}
47
+ .header-section-value{font-size:13px;font-weight:600}
48
+ .progress{height:4px;background:var(--surface2);border-radius:2px;overflow:hidden;width:120px}
49
+ .progress-lg{height:8px;border-radius:4px}
50
+ .progress-fill{height:100%;border-radius:inherit;transition:width .3s}
51
+ .progress-accent .progress-fill{background:var(--accent)}
52
+ .progress-success .progress-fill{background:var(--success)}
53
+ .progress-label{display:flex;justify-content:space-between;font-size:11px;color:var(--text2);margin-bottom:4px}
54
+ #h-stats{display:flex;gap:20px;margin-left:auto}
55
+ .stat{display:flex;flex-direction:column;align-items:center;min-width:48px}
56
+ .stat-val{font-size:20px;font-weight:700;font-family:var(--mono);line-height:1.2}
57
+ .stat-label{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--text3);font-weight:500}
58
+
59
+ /* ── Content ─────────────────────────────────────────────── */
60
+ .content{flex:1;overflow-y:auto;padding:20px 24px}
61
+ .view{display:none}
62
+ .view.active{display:block}
63
+
64
+ /* ── Icons ───────────────────────────────────────────────── */
65
+ .icon{width:16px;height:16px;flex-shrink:0;vertical-align:middle;display:inline-block}
66
+ .icon-sm{width:14px;height:14px}
67
+
68
+ /* ── Filters ─────────────────────────────────────────────── */
69
+ .filter-bar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center}
70
+ .filter-bar input,.filter-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:var(--radius-sm);font-size:12px;font-family:var(--font)}
71
+ .filter-bar input{min-width:160px;flex:1;max-width:280px}
72
+ .filter-bar select{min-width:110px}
73
+ .filter-bar input:focus,.filter-bar select:focus{outline:none;border-color:var(--accent)}
74
+ .filter-bar label{font-size:11px;color:var(--text3);font-weight:600;text-transform:uppercase;letter-spacing:.04em}
75
+
76
+ /* ── Board View ──────────────────────────────────────────── */
77
+ #sprint-bar{display:flex;align-items:center;gap:20px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px;font-size:13px}
78
+ .sprint-goal{flex:1}
79
+ .sprint-goal b{color:var(--text)}
80
+ .sprint-dates{font-size:12px;color:var(--text2);font-family:var(--mono)}
81
+ #board{display:flex;gap:12px;min-height:400px;overflow-x:auto;padding-bottom:16px}
82
+ .column{flex:1;min-width:220px;max-width:300px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);display:flex;flex-direction:column;transition:border-color .2s,background .2s}
83
+ .column.drag-over{border-color:var(--accent);background:var(--accent-dim)}
84
+ .column-header{padding:12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px}
85
+ .col-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);display:flex;align-items:center;gap:6px}
86
+ .col-count{font-size:11px;font-family:var(--mono);background:var(--surface2);padding:2px 8px;border-radius:10px;color:var(--text3)}
87
+ .column-body{flex:1;overflow-y:auto;padding:8px;min-height:80px}
88
+ .card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;margin-bottom:8px;cursor:grab;transition:all .15s;border-left:3px solid var(--text3)}
89
+ .card:hover{border-color:var(--border2);background:var(--surface2)}
90
+ .card:active{cursor:grabbing}
91
+ .card.dragging{opacity:.4;transform:scale(.97)}
92
+ .card[data-status="backlog"]{border-left-color:var(--text3)}
93
+ .card[data-status="todo"]{border-left-color:var(--accent)}
94
+ .card[data-status="in-progress"]{border-left-color:var(--warning)}
95
+ .card[data-status="in-review"]{border-left-color:var(--purple)}
96
+ .card[data-status="done"]{border-left-color:var(--success)}
97
+ .card[data-status="blocked"]{border-left-color:var(--danger)}
98
+ .card-header{display:flex;align-items:center;gap:6px;margin-bottom:4px}
99
+ .card-id{font-family:var(--mono);font-size:11px;color:var(--text3)}
100
+ .card-priority{display:flex;align-items:center}
101
+ .card-title{font-size:13px;font-weight:500;line-height:1.4;margin-bottom:8px}
102
+ .card-meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
103
+ .card-assigned{font-size:11px;color:var(--text3)}
104
+
105
+ /* ── Shared: Pills & Badges ─────────────────────────────── */
106
+ .pill{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;white-space:nowrap}
107
+ .pill-points{background:var(--surface2);color:var(--text2);font-family:var(--mono)}
108
+ .pill-epic{color:#fff;font-size:10px;font-weight:600}
109
+ .badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:var(--radius-sm);font-size:11px;font-weight:600;text-transform:capitalize}
110
+ .badge-backlog{background:var(--surface2);color:var(--text2)}
111
+ .badge-todo{background:var(--accent-dim);color:var(--accent)}
112
+ .badge-in-progress{background:var(--warning-dim);color:var(--warning)}
113
+ .badge-in-review{background:var(--purple-dim);color:var(--purple)}
114
+ .badge-done{background:var(--success-dim);color:var(--success)}
115
+ .badge-blocked{background:var(--danger-dim);color:var(--danger)}
116
+ .badge-active{background:var(--accent-dim);color:var(--accent)}
117
+ .badge-planned{background:var(--surface2);color:var(--text2)}
118
+ .badge-completed{background:var(--success-dim);color:var(--success)}
119
+ .badge-cancelled{background:var(--danger-dim);color:var(--danger)}
120
+ .badge-urgent{background:var(--danger-dim);color:var(--danger)}
121
+ .badge-high{background:var(--danger-dim);color:var(--danger)}
122
+ .badge-medium{background:var(--warning-dim);color:var(--warning)}
123
+ .badge-low{background:var(--success-dim);color:var(--success)}
124
+
125
+ /* ── Table View ──────────────────────────────────────────── */
126
+ .ftable{width:100%;border-collapse:collapse;font-size:13px}
127
+ .ftable th{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em;font-weight:600;cursor:pointer;white-space:nowrap;user-select:none}
128
+ .ftable th:hover{color:var(--text)}
129
+ .ftable th.sorted{color:var(--accent)}
130
+ .sort-arrow{font-size:9px;margin-left:4px;opacity:.4}
131
+ th.sorted .sort-arrow{opacity:1;color:var(--accent)}
132
+ .ftable td{padding:8px 12px;border-bottom:1px solid var(--border)}
133
+ .ftable tr:hover td{background:var(--surface)}
134
+ .ftable tr.expanded td{background:var(--surface);border-bottom-color:transparent}
135
+ .td-id{font-family:var(--mono);font-size:12px;color:var(--text2);white-space:nowrap}
136
+ .td-title{cursor:pointer;font-weight:500}
137
+ .td-title:hover{color:var(--accent)}
138
+ .detail-row td{background:var(--surface)!important;padding:16px 24px}
139
+ .detail-content{font-size:13px;line-height:1.6}
140
+ .detail-content h4{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);margin-bottom:8px}
141
+ .detail-content ul{padding-left:20px;margin-top:4px}
142
+ .detail-content li{margin-bottom:4px;color:var(--text2)}
143
+ .clickable-row{cursor:pointer}
144
+ .clickable-row:hover td{background:var(--accent-dim)!important}
145
+
146
+ /* ── Milestones View ─────────────────────────────────────── */
147
+ .ms-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:16px;cursor:pointer;transition:border-color .15s}
148
+ .ms-card:hover{border-color:var(--border2)}
149
+ .ms-header{display:flex;align-items:center;gap:12px;margin-bottom:16px}
150
+ .ms-header h2{font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px}
151
+ .ms-deadline{font-size:12px;color:var(--text2);font-family:var(--mono);margin-left:auto}
152
+ .ms-progress{margin-bottom:16px}
153
+ .ms-epics{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
154
+ .ms-epic{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;cursor:pointer;transition:border-color .15s}
155
+ .ms-epic:hover{border-color:var(--border2)}
156
+ .epic-name{font-weight:600;font-size:13px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
157
+ .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
158
+ .epic-counts{font-size:11px;color:var(--text2);margin-bottom:6px}
159
+ .epic-meta{display:flex;align-items:center;gap:6px;margin-top:8px}
160
+
161
+ /* ── Metrics View ────────────────────────────────────────── */
162
+ .metrics-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}
163
+ .metric-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px}
164
+ .metric-card h3{font-size:13px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.04em;margin-bottom:16px}
165
+ .health-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--border)}
166
+ .health-row:last-child{border-bottom:none}
167
+ .health-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
168
+ .health-label{flex:1;font-size:13px;color:var(--text2)}
169
+ .health-val{font-family:var(--mono);font-size:13px;font-weight:600}
170
+
171
+ /* ── Detail Panel ────────────────────────────────────────── */
172
+ .panel-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:99;opacity:0;pointer-events:none;transition:opacity .2s}
173
+ .panel-overlay.open{opacity:1;pointer-events:auto}
174
+ .detail-panel{position:fixed;top:0;right:0;width:520px;max-width:92vw;height:100vh;background:var(--surface);border-left:1px solid var(--border);z-index:100;transform:translateX(100%);transition:transform .25s ease;display:flex;flex-direction:column;overflow:hidden}
175
+ .detail-panel.open{transform:translateX(0)}
176
+ .panel-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0}
177
+ .panel-type{font-size:10px;padding:3px 8px;border-radius:var(--radius-sm);background:var(--accent-dim);color:var(--accent);font-weight:700;text-transform:uppercase;letter-spacing:.04em}
178
+ .panel-item-id{font-family:var(--mono);font-size:13px;color:var(--text2)}
179
+ .panel-close{margin-left:auto;background:none;border:none;color:var(--text2);cursor:pointer;font-size:18px;padding:4px 8px;border-radius:var(--radius-sm);line-height:1}
180
+ .panel-close:hover{background:var(--surface2);color:var(--text)}
181
+ .panel-body{flex:1;overflow-y:auto;padding:20px}
182
+ .panel-field{margin-bottom:16px}
183
+ .panel-field label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text3);font-weight:600;margin-bottom:6px}
184
+ .panel-field input,.panel-field select,.panel-field textarea{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:var(--radius-sm);font-family:var(--font);font-size:13px}
185
+ .panel-field input:focus,.panel-field select:focus,.panel-field textarea:focus{outline:none;border-color:var(--accent)}
186
+ .panel-field textarea{min-height:120px;resize:vertical;font-family:var(--mono);font-size:12px;line-height:1.6}
187
+ .panel-field .field-with-icon{display:flex;align-items:center;gap:8px}
188
+ .panel-field .field-with-icon select{flex:1}
189
+ .panel-props{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
190
+ .panel-footer{padding:16px 20px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;flex-shrink:0}
191
+ .btn{padding:8px 16px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--surface2);color:var(--text);transition:all .15s;font-family:var(--font)}
192
+ .btn:hover{background:var(--border)}
193
+ .btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
194
+ .btn-primary:hover{opacity:.9}
195
+ .btn-danger{background:transparent;border-color:var(--danger);color:var(--danger)}
196
+ .btn-danger:hover{background:var(--danger-dim)}
197
+
198
+ /* ── Toast ───────────────────────────────────────────────── */
199
+ .toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px;z-index:200;animation:toast-in .3s ease;display:flex;align-items:center;gap:8px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
200
+ .toast-success{border-color:var(--success)}
201
+ .toast-error{border-color:var(--danger)}
202
+ @keyframes toast-in{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
203
+
204
+ /* ── Empty & Loading ─────────────────────────────────────── */
205
+ .empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 20px;color:var(--text3);text-align:center}
206
+ .empty svg{width:48px;height:48px;margin-bottom:16px;opacity:.4}
207
+ .empty p{max-width:360px;line-height:1.6}
208
+ .empty code{background:var(--surface2);padding:2px 6px;border-radius:var(--radius-sm);font-family:var(--mono);font-size:12px;color:var(--accent)}
209
+ .skeleton{background:var(--surface2);border-radius:var(--radius-sm);animation:pulse 1.5s ease-in-out infinite}
210
+ .skeleton-card{height:80px;margin-bottom:8px;border-radius:var(--radius)}
211
+ .skeleton-line{height:14px;margin-bottom:8px;width:60%}
212
+ .loading-container{padding:20px}
213
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
214
+
215
+ /* ── Responsive ──────────────────────────────────────────── */
216
+ @media(max-width:1024px){
217
+ .sidebar{width:56px}
218
+ .sidebar-logo span,.sidebar-footer,#sidebar-nav a span{display:none}
219
+ .sidebar-logo{padding:16px 12px;text-align:center}
220
+ #sidebar-nav a{justify-content:center;padding:10px}
221
+ .header{padding:12px 16px}
222
+ .content{padding:16px}
223
+ .detail-panel{width:100%;max-width:100%}
224
+ }
225
+ @media(max-width:768px){
226
+ #h-stats{flex-wrap:wrap;gap:12px}
227
+ .header{gap:12px}
228
+ .metrics-grid{grid-template-columns:1fr}
229
+ .panel-props{grid-template-columns:1fr}
230
+ }
231
+ </style>
232
+ </head>
233
+ <body>
234
+ <div class="app">
235
+ <!-- Sidebar -->
236
+ <aside class="sidebar">
237
+ <div class="sidebar-logo"><span>&#9632;</span> mdboard</div>
238
+ <nav id="sidebar-nav">
239
+ <a href="#board" data-view="board" class="active">
240
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
241
+ <span>Board</span>
242
+ </a>
243
+ <a href="#table" data-view="table">
244
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
245
+ <span>Table</span>
246
+ </a>
247
+ <a href="#milestones" data-view="milestones">
248
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
249
+ <span>Milestones</span>
250
+ </a>
251
+ <a href="#metrics" data-view="metrics">
252
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 20V10M12 20V4M6 20v-6"/></svg>
253
+ <span>Metrics</span>
254
+ </a>
255
+ </nav>
256
+ <div class="sidebar-footer">mdboard</div>
257
+ </aside>
258
+
259
+ <div class="main">
260
+ <!-- Header -->
261
+ <header class="header">
262
+ <div class="header-title">
263
+ <h1 id="h-project-name">Loading...</h1>
264
+ <p id="h-project-desc"></p>
265
+ </div>
266
+ <div class="header-section" id="h-milestone-wrap" style="display:none">
267
+ <span class="header-section-label">Milestone</span>
268
+ <span class="header-section-value" id="h-milestone-name"></span>
269
+ <div class="progress progress-accent"><div class="progress-fill" id="h-milestone-progress" style="width:0%"></div></div>
270
+ </div>
271
+ <div class="header-section" id="h-sprint-wrap" style="display:none">
272
+ <span class="header-section-label">Sprint</span>
273
+ <span class="header-section-value" id="h-sprint-name"></span>
274
+ <span style="font-size:11px;color:var(--text2)" id="h-sprint-days"></span>
275
+ </div>
276
+ <div id="h-stats"></div>
277
+ </header>
278
+
279
+ <!-- Content -->
280
+ <div class="content">
281
+ <div id="view-board" class="view active">
282
+ <div id="sprint-bar" style="display:none"></div>
283
+ <div id="board-filters" class="filter-bar"></div>
284
+ <div id="board"></div>
285
+ </div>
286
+ <div id="view-table" class="view">
287
+ <div id="table-controls" class="filter-bar"></div>
288
+ <div id="table-wrapper"></div>
289
+ </div>
290
+ <div id="view-milestones" class="view">
291
+ <div id="ms-filters" class="filter-bar"></div>
292
+ <div id="milestones-container"></div>
293
+ </div>
294
+ <div id="view-metrics" class="view">
295
+ <div id="metrics-container"></div>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ </div>
300
+
301
+ <!-- Detail Panel -->
302
+ <div id="panel-overlay" class="panel-overlay"></div>
303
+ <div id="detail-panel" class="detail-panel"></div>
304
+
305
+ <script>
306
+ /* ══════════════════════════════════════════════════════════════
307
+ ICONS — Linear-style status, priority, and milestone icons
308
+ ══════════════════════════════════════════════════════════════ */
309
+ function statusIcon(status) {
310
+ switch (status) {
311
+ case 'backlog':
312
+ return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#5A5A63" stroke-width="1.5" stroke-dasharray="3 2"/></svg>';
313
+ case 'todo':
314
+ return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#8B8B93" stroke-width="1.5"/></svg>';
315
+ case 'in-progress':
316
+ return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#D4A72C" stroke-width="1.5"/><path d="M8 2A6 6 0 0 1 8 14Z" fill="#D4A72C"/></svg>';
317
+ case 'in-review':
318
+ return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#8B5CF6" stroke-width="1.5"/><path d="M8 2A6 6 0 1 1 2 8L8 8Z" fill="#8B5CF6"/></svg>';
319
+ case 'done':
320
+ return '<svg class="icon" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="#2EA043"/><path d="M5 8.5L7 10.5L11 5.5" stroke="#fff" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>';
321
+ case 'blocked':
322
+ return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#DA3633" stroke-width="1.5"/><path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="#DA3633" stroke-width="1.5" stroke-linecap="round"/></svg>';
323
+ case 'cancelled':
324
+ return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#5A5A63" stroke-width="1.5"/><path d="M4.5 11.5L11.5 4.5" stroke="#5A5A63" stroke-width="1.5" stroke-linecap="round"/></svg>';
325
+ default:
326
+ return '<svg class="icon" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#5A5A63" stroke-width="1.5" stroke-dasharray="3 2"/></svg>';
327
+ }
328
+ }
329
+
330
+ function priorityIcon(priority) {
331
+ var c, n;
332
+ switch (priority) {
333
+ case 'urgent': c = '#F97316'; n = 4; break;
334
+ case 'high': c = '#F97316'; n = 3; break;
335
+ case 'medium': c = '#D4A72C'; n = 2; break;
336
+ case 'low': c = '#5B6EF5'; n = 1; break;
337
+ default: c = '#3A3A40'; n = 0; break;
338
+ }
339
+ var bars = '';
340
+ for (var i = 0; i < 4; i++) {
341
+ var filled = i < n;
342
+ var h = 3 + i * 3;
343
+ var y = 14 - h;
344
+ bars += '<rect x="' + (1.5 + i * 3.5) + '" y="' + y + '" width="2" height="' + h + '" rx=".5" fill="' + (filled ? c : '#2E2E33') + '"/>';
345
+ }
346
+ return '<svg class="icon" viewBox="0 0 16 16">' + bars + '</svg>';
347
+ }
348
+
349
+ function milestoneIcon(status) {
350
+ switch (status) {
351
+ case 'active':
352
+ return '<svg class="icon" viewBox="0 0 16 16"><path d="M8 1L15 8L8 15L1 8Z" fill="none" stroke="#5B6EF5" stroke-width="1.5"/><path d="M8 5L11 8L8 11L5 8Z" fill="#5B6EF5"/></svg>';
353
+ case 'completed':
354
+ return '<svg class="icon" viewBox="0 0 16 16"><path d="M8 1L15 8L8 15L1 8Z" fill="#2EA043"/><path d="M5.5 8L7 9.5L10.5 6" stroke="#fff" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>';
355
+ default:
356
+ return '<svg class="icon" viewBox="0 0 16 16"><path d="M8 1L15 8L8 15L1 8Z" fill="none" stroke="#8B8B93" stroke-width="1.5"/></svg>';
357
+ }
358
+ }
359
+
360
+ /* ══════════════════════════════════════════════════════════════
361
+ DATA STORE
362
+ ══════════════════════════════════════════════════════════════ */
363
+ var D = {
364
+ project: null, milestones: [], epics: [], features: [],
365
+ sprints: [], allSprints: [], metrics: null, health: null, loaded: false
366
+ };
367
+
368
+ /* ── Helpers ─────────────────────────────────────────────── */
369
+ function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
370
+
371
+ function epicColor(name) {
372
+ var colors = ['#5B6EF5','#8B5CF6','#EC4899','#F59E0B','#10B981','#06B6D4','#F97316','#6366F1'];
373
+ var hash = 0;
374
+ for (var i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
375
+ return colors[Math.abs(hash) % colors.length];
376
+ }
377
+
378
+ function badgeClass(prefix, value) {
379
+ if (!value) return prefix + '-backlog';
380
+ return prefix + '-' + value.toLowerCase().replace(/\s+/g, '-');
381
+ }
382
+
383
+ function fmtDate(d) { return d ? String(d).substring(0, 10) : ''; }
384
+
385
+ function daysRemaining(endDate) {
386
+ if (!endDate) return null;
387
+ var end = new Date(endDate); var now = new Date();
388
+ return Math.ceil((end - now) / 86400000);
389
+ }
390
+
391
+ var STATUSES = ['backlog','todo','in-progress','in-review','done','blocked','cancelled'];
392
+ var PRIORITIES = ['urgent','high','medium','low'];
393
+ var STATUS_LABELS = {backlog:'Backlog',todo:'Todo','in-progress':'In Progress','in-review':'In Review',done:'Done',blocked:'Blocked',cancelled:'Cancelled'};
394
+ var PRIORITY_LABELS = {urgent:'Urgent',high:'High',medium:'Medium',low:'Low'};
395
+
396
+ /* ── Toast ───────────────────────────────────────────────── */
397
+ function showToast(msg, type) {
398
+ var el = document.createElement('div');
399
+ el.className = 'toast toast-' + (type || 'success');
400
+ el.textContent = msg;
401
+ document.body.appendChild(el);
402
+ setTimeout(function() { el.remove(); }, 3000);
403
+ }
404
+
405
+ /* ── Data Fetching ───────────────────────────────────────── */
406
+ async function fetchJson(url) {
407
+ try { var r = await fetch(url); return r.ok ? await r.json() : null; }
408
+ catch { return null; }
409
+ }
410
+
411
+ async function patchItem(type, id, updates) {
412
+ try {
413
+ var r = await fetch('/api/' + type + '/' + encodeURIComponent(id), {
414
+ method: 'PATCH',
415
+ headers: { 'Content-Type': 'application/json' },
416
+ body: JSON.stringify(updates),
417
+ });
418
+ var data = await r.json();
419
+ if (data && data.ok) {
420
+ showToast('Saved successfully', 'success');
421
+ return true;
422
+ } else {
423
+ showToast('Error: ' + (data.error || 'Unknown'), 'error');
424
+ return false;
425
+ }
426
+ } catch (e) {
427
+ showToast('Error: ' + e.message, 'error');
428
+ return false;
429
+ }
430
+ }
431
+
432
+ async function loadAll() {
433
+ var results = await Promise.all([
434
+ fetchJson('/api/project'), fetchJson('/api/milestones'), fetchJson('/api/epics'),
435
+ fetchJson('/api/features'), fetchJson('/api/sprint'), fetchJson('/api/metrics'),
436
+ fetchJson('/api/health'), fetchJson('/api/sprints')
437
+ ]);
438
+ D.project = results[0]; D.milestones = results[1] || []; D.epics = results[2] || [];
439
+ D.features = results[3] || [];
440
+ if (results[4]) D.sprints = [results[4]];
441
+ D.metrics = results[5]; D.health = results[6];
442
+ D.allSprints = results[7] || [];
443
+ D.loaded = true;
444
+ }
445
+
446
+ function refreshData() { loadAll().then(function() { renderAll(); }); }
447
+
448
+ /* ── SSE — Hot Reload ────────────────────────────────────── */
449
+ function connectSSE() {
450
+ try {
451
+ var src = new EventSource('/api/events');
452
+ src.onmessage = function() { refreshData(); };
453
+ src.onerror = function() { setTimeout(connectSSE, 5000); src.close(); };
454
+ } catch { /* no SSE support */ }
455
+ }
456
+
457
+ /* ══════════════════════════════════════════════════════════════
458
+ RENDER
459
+ ══════════════════════════════════════════════════════════════ */
460
+ function renderAll() { renderHeader(); renderBoardFilters(); renderBoard(); renderTableControls(); renderTableBody(); renderMsFilters(); renderMilestones(); renderMetrics(); }
461
+
462
+ function renderHeader() {
463
+ var p = D.project || {};
464
+ document.getElementById('h-project-name').textContent = p.name || 'Project';
465
+ document.getElementById('h-project-desc').textContent = p.description || '';
466
+
467
+ var mw = document.getElementById('h-milestone-wrap');
468
+ var am = D.milestones.find(function(m) { return m.status === 'active'; });
469
+ if (am) {
470
+ mw.style.display = '';
471
+ document.getElementById('h-milestone-name').textContent = am.title || am.id || '';
472
+ document.getElementById('h-milestone-progress').style.width = (am.progress || 0) + '%';
473
+ } else { mw.style.display = 'none'; }
474
+
475
+ var sw = document.getElementById('h-sprint-wrap');
476
+ var as = D.sprints.find(function(s) { return s.status === 'active'; });
477
+ if (as) {
478
+ sw.style.display = '';
479
+ document.getElementById('h-sprint-name').textContent = as.goal || as.id || '';
480
+ var dr = daysRemaining(as.end_date);
481
+ document.getElementById('h-sprint-days').textContent = dr !== null ? (dr >= 0 ? dr + ' days left' : Math.abs(dr) + ' days over') : '';
482
+ } else { sw.style.display = 'none'; }
483
+
484
+ var total = D.features.length;
485
+ var done = D.features.filter(function(f) { return f.status === 'done'; }).length;
486
+ var inProg = D.features.filter(function(f) { return f.status === 'in-progress'; }).length;
487
+ var vel = D.health && D.health.velocity != null ? D.health.velocity : (total ? Math.round(done / total * 100) : 0);
488
+ document.getElementById('h-stats').innerHTML =
489
+ '<div class="stat"><span class="stat-val">' + total + '</span><span class="stat-label">Features</span></div>' +
490
+ '<div class="stat"><span class="stat-val" style="color:var(--success)">' + done + '</span><span class="stat-label">Done</span></div>' +
491
+ '<div class="stat"><span class="stat-val" style="color:var(--warning)">' + inProg + '</span><span class="stat-label">In Progress</span></div>' +
492
+ '<div class="stat"><span class="stat-val" style="color:var(--accent)">' + vel + '%</span><span class="stat-label">Velocity</span></div>';
493
+ }
494
+
495
+ /* ══════════════════════════════════════════════════════════════
496
+ BOARD VIEW
497
+ ══════════════════════════════════════════════════════════════ */
498
+ var BOARD_COLS = ['backlog','todo','in-progress','in-review','done'];
499
+ var boardFilters = { search: '', priority: '', epic: '', milestone: '' };
500
+
501
+ function renderBoardFilters() {
502
+ var c = document.getElementById('board-filters');
503
+ var ep = [], ms = [];
504
+ D.features.forEach(function(f) {
505
+ if (f.epic && ep.indexOf(f.epic) === -1) ep.push(f.epic);
506
+ if (f.milestone && ms.indexOf(f.milestone) === -1) ms.push(f.milestone);
507
+ });
508
+
509
+ function opts(arr, key, label) {
510
+ return '<select data-filter="' + key + '"><option value="">All ' + label + '</option>' +
511
+ arr.map(function(v) { return '<option value="' + escHtml(v) + '"' + (boardFilters[key] === v ? ' selected' : '') + '>' + escHtml(v) + '</option>'; }).join('') + '</select>';
512
+ }
513
+
514
+ c.innerHTML = '<input type="text" data-filter="search" placeholder="Search cards..." value="' + escHtml(boardFilters.search) + '">' +
515
+ '<select data-filter="priority"><option value="">All Priorities</option>' +
516
+ PRIORITIES.map(function(p) { return '<option value="' + p + '"' + (boardFilters.priority === p ? ' selected' : '') + '>' + (PRIORITY_LABELS[p] || p) + '</option>'; }).join('') + '</select>' +
517
+ opts(ep, 'epic', 'Epics') + opts(ms, 'milestone', 'Milestones');
518
+
519
+ c.querySelectorAll('input[data-filter]').forEach(function(el) {
520
+ el.addEventListener('input', function() { boardFilters[el.dataset.filter] = el.value; renderBoard(); });
521
+ });
522
+ c.querySelectorAll('select[data-filter]').forEach(function(el) {
523
+ el.addEventListener('change', function() { boardFilters[el.dataset.filter] = el.value; renderBoard(); });
524
+ });
525
+ }
526
+
527
+ function getFilteredBoardFeatures() {
528
+ var list = D.features.slice();
529
+ var f = boardFilters;
530
+ if (f.search) { var q = f.search.toLowerCase(); list = list.filter(function(x) { return (x.id || '').toLowerCase().indexOf(q) !== -1 || (x.title || '').toLowerCase().indexOf(q) !== -1; }); }
531
+ if (f.priority) list = list.filter(function(x) { return x.priority === f.priority; });
532
+ if (f.epic) list = list.filter(function(x) { return x.epic === f.epic; });
533
+ if (f.milestone) list = list.filter(function(x) { return x.milestone === f.milestone; });
534
+ return list;
535
+ }
536
+
537
+ function renderBoard() {
538
+ var sprint = D.sprints.find(function(s) { return s.status === 'active'; });
539
+ var sbar = document.getElementById('sprint-bar');
540
+ if (sprint) {
541
+ var planned = sprint.planned_points || 0;
542
+ var completed = sprint.completed_points || 0;
543
+ var pct = planned ? Math.round(completed / planned * 100) : 0;
544
+ sbar.style.display = '';
545
+ sbar.innerHTML =
546
+ '<div class="sprint-goal"><b>' + escHtml(sprint.id || '') + '</b>' + (sprint.goal ? ' &mdash; ' + escHtml(sprint.goal) : '') + '</div>' +
547
+ '<div style="width:160px"><div class="progress progress-accent"><div class="progress-fill" style="width:' + pct + '%"></div></div>' +
548
+ '<div style="font-size:11px;color:var(--text3);margin-top:2px;font-family:var(--mono)">' + completed + '/' + planned + ' pts</div></div>' +
549
+ '<div class="sprint-dates">' + fmtDate(sprint.start_date) + ' &mdash; ' + fmtDate(sprint.end_date) + '</div>';
550
+ } else { sbar.style.display = 'none'; }
551
+
552
+ var board = document.getElementById('board');
553
+ if (!D.loaded) {
554
+ board.innerHTML = BOARD_COLS.map(function() {
555
+ return '<div class="column"><div class="column-header"><div class="skeleton skeleton-line" style="width:80px;height:16px"></div></div><div class="column-body">' +
556
+ '<div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div></div></div>';
557
+ }).join('');
558
+ return;
559
+ }
560
+
561
+ var filteredFeatures = getFilteredBoardFeatures();
562
+
563
+ if (!D.features.length) {
564
+ board.innerHTML = '<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M9 12h6M12 9v6"/></svg><p>No features yet. Create feature files in your project/ directory to get started.</p></div>';
565
+ return;
566
+ }
567
+
568
+ board.innerHTML = BOARD_COLS.map(function(status) {
569
+ var cards = filteredFeatures.filter(function(f) { return f.status === status; });
570
+ return '<div class="column" data-status="' + status + '">' +
571
+ '<div class="column-header"><span class="col-title">' + statusIcon(status) + ' ' + (STATUS_LABELS[status] || status) + '</span><span class="col-count">' + cards.length + '</span></div>' +
572
+ '<div class="column-body">' + cards.map(renderCard).join('') + '</div></div>';
573
+ }).join('');
574
+
575
+ setupDragDrop();
576
+ }
577
+
578
+ function renderCard(f) {
579
+ var ec = f.epic ? epicColor(f.epic) : '';
580
+ var assigned = Array.isArray(f.assigned) ? f.assigned.join(', ') : (f.assigned || '');
581
+ return '<div class="card" draggable="true" data-id="' + escHtml(f.id || '') + '" data-status="' + escHtml(f.status || '') + '">' +
582
+ '<div class="card-header">' +
583
+ '<span class="card-priority">' + priorityIcon(f.priority) + '</span>' +
584
+ '<span class="card-id">' + escHtml(f.id || '') + '</span>' +
585
+ '</div>' +
586
+ '<div class="card-title">' + escHtml(f.title || '') + '</div>' +
587
+ '<div class="card-meta">' +
588
+ (f.points != null ? '<span class="pill pill-points">' + f.points + ' pts</span>' : '') +
589
+ (f.epic ? '<span class="pill pill-epic" style="background:' + ec + '">' + escHtml(f.epic) + '</span>' : '') +
590
+ (assigned ? '<span class="card-assigned">' + escHtml(assigned) + '</span>' : '') +
591
+ '</div></div>';
592
+ }
593
+
594
+ /* ── Drag & Drop ─────────────────────────────────────────── */
595
+ function setupDragDrop() {
596
+ document.querySelectorAll('.card[draggable]').forEach(function(card) {
597
+ card.addEventListener('dragstart', function(e) {
598
+ e.dataTransfer.setData('text/plain', card.dataset.id);
599
+ e.dataTransfer.effectAllowed = 'move';
600
+ card.classList.add('dragging');
601
+ });
602
+ card.addEventListener('dragend', function() {
603
+ card.classList.remove('dragging');
604
+ document.querySelectorAll('.column.drag-over').forEach(function(c) { c.classList.remove('drag-over'); });
605
+ });
606
+ card.addEventListener('click', function(e) {
607
+ if (e.defaultPrevented) return;
608
+ var feat = D.features.find(function(f) { return f.id === card.dataset.id; });
609
+ if (feat) openPanel('features', feat);
610
+ });
611
+ });
612
+
613
+ document.querySelectorAll('.column').forEach(function(col) {
614
+ col.addEventListener('dragover', function(e) {
615
+ e.preventDefault();
616
+ e.dataTransfer.dropEffect = 'move';
617
+ col.classList.add('drag-over');
618
+ });
619
+ col.addEventListener('dragleave', function(e) {
620
+ if (e.target === col || !col.contains(e.relatedTarget)) {
621
+ col.classList.remove('drag-over');
622
+ }
623
+ });
624
+ col.addEventListener('drop', function(e) {
625
+ e.preventDefault();
626
+ col.classList.remove('drag-over');
627
+ var featureId = e.dataTransfer.getData('text/plain');
628
+ var newStatus = col.dataset.status;
629
+ if (!featureId || !newStatus) return;
630
+
631
+ // Optimistic update
632
+ var feat = D.features.find(function(f) { return f.id === featureId; });
633
+ if (feat && feat.status !== newStatus) {
634
+ feat.status = newStatus;
635
+ renderBoard();
636
+ patchItem('features', featureId, { status: newStatus });
637
+ }
638
+ });
639
+ });
640
+ }
641
+
642
+ /* ══════════════════════════════════════════════════════════════
643
+ TABLE VIEW
644
+ ══════════════════════════════════════════════════════════════ */
645
+ var tableSort = { col: 'id', asc: true };
646
+ var tableFilters = { milestone: '', epic: '', status: '', sprint: '', search: '', priority: '' };
647
+ var expandedRow = null;
648
+ var TABLE_COLS = [
649
+ {key:'id',label:'ID'},{key:'title',label:'Title'},{key:'epic',label:'Epic'},
650
+ {key:'milestone',label:'Milestone'},{key:'sprint',label:'Sprint'},{key:'status',label:'Status'},
651
+ {key:'points',label:'Pts'},{key:'priority',label:'Priority'},{key:'assigned',label:'Assigned'}
652
+ ];
653
+
654
+ function renderTableControls() {
655
+ var c = document.getElementById('table-controls');
656
+ var ms = [], ep = [], st = [], sp = [];
657
+ D.features.forEach(function(f) {
658
+ if (f.milestone && ms.indexOf(f.milestone) === -1) ms.push(f.milestone);
659
+ if (f.epic && ep.indexOf(f.epic) === -1) ep.push(f.epic);
660
+ if (f.status && st.indexOf(f.status) === -1) st.push(f.status);
661
+ if (f.sprint && sp.indexOf(f.sprint) === -1) sp.push(f.sprint);
662
+ });
663
+ function opts(arr, filter, label) {
664
+ return '<select data-tf="' + filter + '"><option value="">All ' + label + '</option>' +
665
+ arr.map(function(v) { return '<option value="' + escHtml(v) + '"' + (tableFilters[filter] === v ? ' selected' : '') + '>' + escHtml(v) + '</option>'; }).join('') + '</select>';
666
+ }
667
+ c.innerHTML = '<input type="text" data-tf="search" placeholder="Search ID or title..." value="' + escHtml(tableFilters.search) + '">' +
668
+ opts(ms, 'milestone', 'Milestones') + opts(ep, 'epic', 'Epics') + opts(st, 'status', 'Statuses') + opts(sp, 'sprint', 'Sprints') +
669
+ '<select data-tf="priority"><option value="">All Priorities</option>' +
670
+ PRIORITIES.map(function(p) { return '<option value="' + p + '"' + (tableFilters.priority === p ? ' selected' : '') + '>' + (PRIORITY_LABELS[p] || p) + '</option>'; }).join('') + '</select>';
671
+
672
+ c.querySelector('input[data-tf="search"]').addEventListener('input', function(e) { tableFilters.search = e.target.value; renderTableBody(); });
673
+ c.querySelectorAll('select[data-tf]').forEach(function(sel) {
674
+ sel.addEventListener('change', function() { tableFilters[sel.dataset.tf] = sel.value; renderTableBody(); });
675
+ });
676
+ }
677
+
678
+ function getFilteredFeatures() {
679
+ var list = D.features.slice();
680
+ var f = tableFilters;
681
+ if (f.search) { var q = f.search.toLowerCase(); list = list.filter(function(x) { return (x.id || '').toLowerCase().indexOf(q) !== -1 || (x.title || '').toLowerCase().indexOf(q) !== -1; }); }
682
+ if (f.milestone) list = list.filter(function(x) { return x.milestone === f.milestone; });
683
+ if (f.epic) list = list.filter(function(x) { return x.epic === f.epic; });
684
+ if (f.status) list = list.filter(function(x) { return x.status === f.status; });
685
+ if (f.sprint) list = list.filter(function(x) { return x.sprint === f.sprint; });
686
+ if (f.priority) list = list.filter(function(x) { return x.priority === f.priority; });
687
+ var col = tableSort.col, dir = tableSort.asc ? 1 : -1;
688
+ list.sort(function(a, b) {
689
+ var va = a[col] == null ? '' : a[col], vb = b[col] == null ? '' : b[col];
690
+ if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
691
+ return String(va).localeCompare(String(vb)) * dir;
692
+ });
693
+ return list;
694
+ }
695
+
696
+ function renderTableBody() {
697
+ var w = document.getElementById('table-wrapper');
698
+ if (!D.loaded) { w.innerHTML = '<div class="loading-container">' + '<div class="skeleton skeleton-line" style="width:100%;height:32px"></div>'.repeat(6) + '</div>'; return; }
699
+ var features = getFilteredFeatures();
700
+ if (!features.length) {
701
+ w.innerHTML = '<div class="empty"><p>' + (D.features.length ? 'No features match filters.' : 'No features yet.') + '</p></div>';
702
+ return;
703
+ }
704
+ var html = '<table class="ftable"><thead><tr>';
705
+ TABLE_COLS.forEach(function(c) {
706
+ var sorted = tableSort.col === c.key;
707
+ html += '<th class="' + (sorted ? 'sorted' : '') + '" data-col="' + c.key + '">' + c.label + '<span class="sort-arrow">' + (sorted ? (tableSort.asc ? '\u25B2' : '\u25BC') : '') + '</span></th>';
708
+ });
709
+ html += '</tr></thead><tbody>';
710
+ features.forEach(function(f) {
711
+ var assigned = Array.isArray(f.assigned) ? f.assigned.join(', ') : (f.assigned || '');
712
+ html += '<tr class="clickable-row" data-fid="' + escHtml(f.id || '') + '">' +
713
+ '<td class="td-id">' + escHtml(f.id || '') + '</td>' +
714
+ '<td class="td-title">' + escHtml(f.title || '') + '</td>' +
715
+ '<td>' + (f.epic ? '<span class="pill pill-epic" style="background:' + epicColor(f.epic) + '">' + escHtml(f.epic) + '</span>' : '') + '</td>' +
716
+ '<td>' + escHtml(f.milestone || '') + '</td>' +
717
+ '<td>' + escHtml(f.sprint || '') + '</td>' +
718
+ '<td><span class="badge ' + badgeClass('badge', f.status) + '">' + statusIcon(f.status) + ' ' + escHtml(f.status || '') + '</span></td>' +
719
+ '<td>' + (f.points != null ? f.points : '') + '</td>' +
720
+ '<td>' + (f.priority ? '<span class="badge ' + badgeClass('badge', f.priority) + '">' + priorityIcon(f.priority) + ' ' + escHtml(f.priority) + '</span>' : '') + '</td>' +
721
+ '<td>' + escHtml(assigned) + '</td></tr>';
722
+ });
723
+ html += '</tbody></table>';
724
+ w.innerHTML = html;
725
+
726
+ w.querySelectorAll('th[data-col]').forEach(function(th) {
727
+ th.addEventListener('click', function() {
728
+ if (tableSort.col === th.dataset.col) tableSort.asc = !tableSort.asc;
729
+ else { tableSort.col = th.dataset.col; tableSort.asc = true; }
730
+ renderTableBody();
731
+ });
732
+ });
733
+ w.querySelectorAll('.clickable-row').forEach(function(tr) {
734
+ tr.addEventListener('click', function() {
735
+ var feat = D.features.find(function(f) { return f.id === tr.dataset.fid; });
736
+ if (feat) openPanel('features', feat);
737
+ });
738
+ });
739
+ }
740
+
741
+ /* ══════════════════════════════════════════════════════════════
742
+ MILESTONES VIEW
743
+ ══════════════════════════════════════════════════════════════ */
744
+ var msFilter = { status: '' };
745
+
746
+ function renderMsFilters() {
747
+ var c = document.getElementById('ms-filters');
748
+ c.innerHTML = '<label>Filter:</label>' +
749
+ '<select data-msf="status"><option value="">All Statuses</option>' +
750
+ ['planned','active','completed'].map(function(s) {
751
+ return '<option value="' + s + '"' + (msFilter.status === s ? ' selected' : '') + '>' + s.charAt(0).toUpperCase() + s.slice(1) + '</option>';
752
+ }).join('') + '</select>';
753
+ c.querySelector('select[data-msf]').addEventListener('change', function(e) { msFilter.status = e.target.value; renderMilestones(); });
754
+ }
755
+
756
+ function renderMilestones() {
757
+ var c = document.getElementById('milestones-container');
758
+ if (!D.loaded) { c.innerHTML = '<div class="loading-container"><div class="skeleton skeleton-card" style="height:200px"></div></div>'; return; }
759
+
760
+ var milestones = D.milestones;
761
+ if (msFilter.status) milestones = milestones.filter(function(m) { return m.status === msFilter.status; });
762
+
763
+ if (!milestones.length) {
764
+ c.innerHTML = '<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg><p>' + (D.milestones.length ? 'No milestones match filter.' : 'No milestones yet. Create milestone directories under project/milestones/ to get started.') + '</p></div>';
765
+ return;
766
+ }
767
+
768
+ c.innerHTML = milestones.map(function(ms) {
769
+ var pct = ms.progress || 0;
770
+ var fc = ms.featureCount || 0, cc = ms.completedCount || 0;
771
+ var msEpics = D.epics.filter(function(e) { return e.milestone === (ms.id || ms._dir); });
772
+ var epicCards = msEpics.length ? msEpics.map(function(e) {
773
+ var ep = e.progress || 0;
774
+ var ec = epicColor(e.id || e.title || '');
775
+ return '<div class="ms-epic" data-epic-id="' + escHtml(e.id || '') + '"><div class="epic-name"><span class="dot" style="background:' + ec + '"></span>' + escHtml(e.title || e.id || '') + '</div>' +
776
+ '<div class="epic-counts">' + (e.completedCount || 0) + ' / ' + (e.featureCount || 0) + ' features &middot; ' + (e.totalPoints || 0) + ' pts</div>' +
777
+ '<div class="progress progress-accent"><div class="progress-fill" style="width:' + ep + '%;background:' + ec + '"></div></div>' +
778
+ '<div class="epic-meta">' +
779
+ (e.status ? '<span class="badge ' + badgeClass('badge', e.status) + '">' + statusIcon(e.status) + ' ' + escHtml(e.status) + '</span>' : '') +
780
+ (e.priority ? '<span class="badge ' + badgeClass('badge', e.priority) + '">' + priorityIcon(e.priority) + ' ' + escHtml(e.priority) + '</span>' : '') +
781
+ '</div></div>';
782
+ }).join('') : '<div style="color:var(--text3);font-size:.82rem">No epics yet.</div>';
783
+
784
+ return '<div class="ms-card" data-ms-id="' + escHtml(ms.id || '') + '">' +
785
+ '<div class="ms-header"><h2>' + milestoneIcon(ms.status) + ' ' + escHtml(ms.title || ms.id || '') + '</h2>' +
786
+ '<span class="badge ' + badgeClass('badge', ms.status) + '">' + escHtml(ms.status || '') + '</span>' +
787
+ (ms.deadline ? '<span class="ms-deadline">' + fmtDate(ms.deadline) + '</span>' : '') + '</div>' +
788
+ '<div class="ms-progress"><div class="progress-label"><span>' + cc + ' / ' + fc + ' features</span><span>' + pct + '%</span></div>' +
789
+ '<div class="progress progress-lg progress-success"><div class="progress-fill" style="width:' + pct + '%"></div></div></div>' +
790
+ '<div class="ms-epics">' + epicCards + '</div></div>';
791
+ }).join('');
792
+
793
+ // Click handlers for milestones
794
+ c.querySelectorAll('.ms-card[data-ms-id]').forEach(function(card) {
795
+ card.addEventListener('click', function(e) {
796
+ if (e.target.closest('.ms-epic')) return;
797
+ var ms = D.milestones.find(function(m) { return m.id === card.dataset.msId; });
798
+ if (ms) openPanel('milestones', ms);
799
+ });
800
+ });
801
+
802
+ // Click handlers for epics
803
+ c.querySelectorAll('.ms-epic[data-epic-id]').forEach(function(card) {
804
+ card.addEventListener('click', function(e) {
805
+ e.stopPropagation();
806
+ var epic = D.epics.find(function(ep) { return ep.id === card.dataset.epicId; });
807
+ if (epic) openPanel('epics', epic);
808
+ });
809
+ });
810
+ }
811
+
812
+ /* ══════════════════════════════════════════════════════════════
813
+ METRICS VIEW
814
+ ══════════════════════════════════════════════════════════════ */
815
+ function renderMetrics() {
816
+ var c = document.getElementById('metrics-container');
817
+ if (!D.loaded) { c.innerHTML = '<div class="metrics-grid">' + '<div class="skeleton skeleton-card" style="height:200px"></div>'.repeat(4) + '</div>'; return; }
818
+
819
+ var msProg = D.milestones.length ? D.milestones.map(function(ms) {
820
+ var pct = ms.progress || 0;
821
+ var clr = pct >= 100 ? 'var(--success)' : pct > 50 ? 'var(--warning)' : 'var(--accent)';
822
+ return '<div class="health-row"><div class="health-dot" style="background:' + clr + '"></div>' + milestoneIcon(ms.status) + '<div class="health-label">' + escHtml(ms.title || ms.id || '') + '</div><div class="health-val">' + pct + '%</div></div>';
823
+ }).join('') : '<div style="color:var(--text3);padding:8px 0">No milestones.</div>';
824
+
825
+ var statuses = {};
826
+ D.features.forEach(function(f) { var s = f.status || 'unknown'; statuses[s] = (statuses[s] || 0) + 1; });
827
+ var statusHtml = Object.keys(statuses).map(function(s) {
828
+ return '<div class="health-row">' + statusIcon(s) + '<div class="health-label" style="text-transform:capitalize">' + escHtml(s) + '</div><div class="health-val">' + statuses[s] + '</div></div>';
829
+ }).join('') || '<div style="color:var(--text3);padding:8px 0">No data.</div>';
830
+
831
+ var priorities = {};
832
+ D.features.forEach(function(f) { var p = f.priority || 'none'; priorities[p] = (priorities[p] || 0) + 1; });
833
+ var priorityHtml = Object.keys(priorities).map(function(p) {
834
+ return '<div class="health-row">' + priorityIcon(p) + '<div class="health-label" style="text-transform:capitalize">' + escHtml(p) + '</div><div class="health-val">' + priorities[p] + '</div></div>';
835
+ }).join('') || '<div style="color:var(--text3);padding:8px 0">No data.</div>';
836
+
837
+ var h = D.health || {};
838
+ var qualHtml = '<div class="health-row"><div class="health-dot" style="background:var(--text2)"></div><div class="health-label">Total Features</div><div class="health-val">' + (h.totalFeatures || D.features.length) + '</div></div>' +
839
+ '<div class="health-row"><div class="health-dot" style="background:var(--success)"></div><div class="health-label">Completed</div><div class="health-val">' + (h.completedFeatures || 0) + '</div></div>' +
840
+ '<div class="health-row"><div class="health-dot" style="background:var(--warning)"></div><div class="health-label">In Progress</div><div class="health-val">' + (h.inProgressFeatures || 0) + '</div></div>' +
841
+ '<div class="health-row"><div class="health-dot" style="background:var(--accent)"></div><div class="health-label">Avg Velocity</div><div class="health-val">' + (h.velocity != null ? h.velocity + '%' : 'N/A') + '</div></div>';
842
+
843
+ c.innerHTML = '<div class="metrics-grid">' +
844
+ '<div class="metric-card"><h3>Milestone Progress</h3>' + msProg + '</div>' +
845
+ '<div class="metric-card"><h3>Status Breakdown</h3>' + statusHtml + '</div>' +
846
+ '<div class="metric-card"><h3>Priority Breakdown</h3>' + priorityHtml + '</div>' +
847
+ '<div class="metric-card"><h3>Project Health</h3>' + qualHtml + '</div></div>';
848
+ }
849
+
850
+ /* ══════════════════════════════════════════════════════════════
851
+ DETAIL PANEL — Edit features, epics, milestones
852
+ ══════════════════════════════════════════════════════════════ */
853
+ var panelState = { open: false, type: null, item: null };
854
+
855
+ function openPanel(type, item) {
856
+ panelState = { open: true, type: type, item: JSON.parse(JSON.stringify(item)) };
857
+ renderPanel();
858
+ document.getElementById('detail-panel').classList.add('open');
859
+ document.getElementById('panel-overlay').classList.add('open');
860
+ }
861
+
862
+ function closePanel() {
863
+ panelState = { open: false, type: null, item: null };
864
+ document.getElementById('detail-panel').classList.remove('open');
865
+ document.getElementById('panel-overlay').classList.remove('open');
866
+ }
867
+
868
+ function renderPanel() {
869
+ var panel = document.getElementById('detail-panel');
870
+ var t = panelState.type;
871
+ var item = panelState.item;
872
+ if (!item) return;
873
+
874
+ var typeLabel = t === 'features' ? 'Feature' : t === 'epics' ? 'Epic' : 'Milestone';
875
+
876
+ var html = '<div class="panel-header">' +
877
+ '<span class="panel-type">' + typeLabel + '</span>' +
878
+ '<span class="panel-item-id">' + escHtml(item.id || '') + '</span>' +
879
+ '<button class="panel-close" id="panel-close-btn">&times;</button>' +
880
+ '</div>';
881
+
882
+ html += '<div class="panel-body">';
883
+
884
+ // Title
885
+ html += '<div class="panel-field"><label>Title</label><input type="text" id="p-title" value="' + escHtml(item.title || '') + '"></div>';
886
+
887
+ // Status + Priority row
888
+ html += '<div class="panel-props">';
889
+
890
+ // Status
891
+ var statusOptions = t === 'features' ? STATUSES : ['planned', 'active', 'completed', 'cancelled'];
892
+ var statusLabels = t === 'features' ? STATUS_LABELS : { planned: 'Planned', active: 'Active', completed: 'Completed', cancelled: 'Cancelled' };
893
+ html += '<div class="panel-field"><label>Status</label><div class="field-with-icon"><span id="p-status-icon">' + statusIcon(item.status) + '</span>' +
894
+ '<select id="p-status">' + statusOptions.map(function(s) {
895
+ return '<option value="' + s + '"' + (item.status === s ? ' selected' : '') + '>' + (statusLabels[s] || s) + '</option>';
896
+ }).join('') + '</select></div></div>';
897
+
898
+ // Priority
899
+ if (t === 'features' || t === 'epics') {
900
+ html += '<div class="panel-field"><label>Priority</label><div class="field-with-icon"><span id="p-priority-icon">' + priorityIcon(item.priority) + '</span>' +
901
+ '<select id="p-priority"><option value="">None</option>' + PRIORITIES.map(function(p) {
902
+ return '<option value="' + p + '"' + (item.priority === p ? ' selected' : '') + '>' + (PRIORITY_LABELS[p] || p) + '</option>';
903
+ }).join('') + '</select></div></div>';
904
+ }
905
+
906
+ html += '</div>'; // close panel-props
907
+
908
+ // Type-specific fields
909
+ if (t === 'features') {
910
+ html += '<div class="panel-props">';
911
+ html += '<div class="panel-field"><label>Points</label><input type="number" id="p-points" min="0" max="100" value="' + (item.points != null ? item.points : '') + '"></div>';
912
+
913
+ var assignedVal = Array.isArray(item.assigned) ? item.assigned.join(', ') : (item.assigned || '');
914
+ html += '<div class="panel-field"><label>Assigned</label><input type="text" id="p-assigned" value="' + escHtml(assignedVal) + '" placeholder="agent-name"></div>';
915
+ html += '</div>';
916
+
917
+ html += '<div class="panel-props">';
918
+ // Sprint
919
+ html += '<div class="panel-field"><label>Sprint</label><select id="p-sprint"><option value="">None</option>' +
920
+ D.allSprints.map(function(s) { return '<option value="' + escHtml(s.id || '') + '"' + (item.sprint === s.id ? ' selected' : '') + '>' + escHtml(s.id || '') + '</option>'; }).join('') +
921
+ '</select></div>';
922
+
923
+ // Epic (read-only info)
924
+ html += '<div class="panel-field"><label>Epic</label><input type="text" id="p-epic" value="' + escHtml(item.epic || '') + '" disabled style="opacity:.6" title="Epic is determined by file location"></div>';
925
+ html += '</div>';
926
+ }
927
+
928
+ if (t === 'milestones') {
929
+ html += '<div class="panel-field"><label>Deadline</label><input type="date" id="p-deadline" value="' + fmtDate(item.deadline) + '"></div>';
930
+ }
931
+
932
+ // Description / Content
933
+ html += '<div class="panel-field"><label>Description</label><textarea id="p-content">' + escHtml(item.content || '') + '</textarea></div>';
934
+
935
+ html += '</div>'; // close panel-body
936
+
937
+ html += '<div class="panel-footer">' +
938
+ '<button class="btn" id="panel-cancel-btn">Cancel</button>' +
939
+ '<button class="btn btn-primary" id="panel-save-btn">Save Changes</button>' +
940
+ '</div>';
941
+
942
+ panel.innerHTML = html;
943
+
944
+ // Event listeners
945
+ document.getElementById('panel-close-btn').addEventListener('click', closePanel);
946
+ document.getElementById('panel-cancel-btn').addEventListener('click', closePanel);
947
+ document.getElementById('panel-save-btn').addEventListener('click', savePanel);
948
+
949
+ // Live icon updates
950
+ var statusSel = document.getElementById('p-status');
951
+ if (statusSel) {
952
+ statusSel.addEventListener('change', function() {
953
+ var iconEl = document.getElementById('p-status-icon');
954
+ if (iconEl) iconEl.innerHTML = statusIcon(statusSel.value);
955
+ });
956
+ }
957
+ var prioritySel = document.getElementById('p-priority');
958
+ if (prioritySel) {
959
+ prioritySel.addEventListener('change', function() {
960
+ var iconEl = document.getElementById('p-priority-icon');
961
+ if (iconEl) iconEl.innerHTML = priorityIcon(prioritySel.value);
962
+ });
963
+ }
964
+ }
965
+
966
+ async function savePanel() {
967
+ var t = panelState.type;
968
+ var item = panelState.item;
969
+ if (!t || !item) return;
970
+
971
+ var updates = {};
972
+
973
+ var titleEl = document.getElementById('p-title');
974
+ if (titleEl && titleEl.value !== (item.title || '')) updates.title = titleEl.value;
975
+
976
+ var statusEl = document.getElementById('p-status');
977
+ if (statusEl && statusEl.value !== (item.status || '')) updates.status = statusEl.value;
978
+
979
+ if (t === 'features' || t === 'epics') {
980
+ var priorityEl = document.getElementById('p-priority');
981
+ if (priorityEl && priorityEl.value !== (item.priority || '')) updates.priority = priorityEl.value || null;
982
+ }
983
+
984
+ if (t === 'features') {
985
+ var pointsEl = document.getElementById('p-points');
986
+ if (pointsEl) {
987
+ var pv = pointsEl.value ? Number(pointsEl.value) : null;
988
+ if (pv !== item.points) updates.points = pv;
989
+ }
990
+
991
+ var assignedEl = document.getElementById('p-assigned');
992
+ if (assignedEl) {
993
+ var av = assignedEl.value.trim();
994
+ var origAssigned = Array.isArray(item.assigned) ? item.assigned.join(', ') : (item.assigned || '');
995
+ if (av !== origAssigned) updates.assigned = av || null;
996
+ }
997
+
998
+ var sprintEl = document.getElementById('p-sprint');
999
+ if (sprintEl && sprintEl.value !== (item.sprint || '')) updates.sprint = sprintEl.value || null;
1000
+ }
1001
+
1002
+ if (t === 'milestones') {
1003
+ var deadlineEl = document.getElementById('p-deadline');
1004
+ if (deadlineEl && deadlineEl.value !== fmtDate(item.deadline)) updates.deadline = deadlineEl.value || null;
1005
+ }
1006
+
1007
+ var contentEl = document.getElementById('p-content');
1008
+ if (contentEl && contentEl.value !== (item.content || '')) updates.content = contentEl.value;
1009
+
1010
+ if (Object.keys(updates).length === 0) {
1011
+ showToast('No changes to save', 'success');
1012
+ closePanel();
1013
+ return;
1014
+ }
1015
+
1016
+ var ok = await patchItem(t, item.id, updates);
1017
+ if (ok) {
1018
+ closePanel();
1019
+ refreshData();
1020
+ }
1021
+ }
1022
+
1023
+ // Close panel on overlay click or Escape
1024
+ document.getElementById('panel-overlay').addEventListener('click', closePanel);
1025
+ document.addEventListener('keydown', function(e) {
1026
+ if (e.key === 'Escape' && panelState.open) closePanel();
1027
+ });
1028
+
1029
+ /* ══════════════════════════════════════════════════════════════
1030
+ NAVIGATION
1031
+ ══════════════════════════════════════════════════════════════ */
1032
+ function switchView(name) {
1033
+ document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); });
1034
+ document.querySelectorAll('#sidebar-nav a').forEach(function(a) { a.classList.remove('active'); });
1035
+ var t = document.getElementById('view-' + name);
1036
+ if (t) t.classList.add('active');
1037
+ var l = document.querySelector('#sidebar-nav a[data-view="' + name + '"]');
1038
+ if (l) l.classList.add('active');
1039
+ }
1040
+
1041
+ document.getElementById('sidebar-nav').addEventListener('click', function(e) {
1042
+ var a = e.target.closest('a[data-view]');
1043
+ if (!a) return;
1044
+ e.preventDefault();
1045
+ switchView(a.dataset.view);
1046
+ window.location.hash = a.dataset.view;
1047
+ });
1048
+
1049
+ function handleHash() {
1050
+ var hash = window.location.hash.replace('#', '') || 'board';
1051
+ var valid = ['board','table','milestones','metrics'];
1052
+ switchView(valid.indexOf(hash) !== -1 ? hash : 'board');
1053
+ }
1054
+ window.addEventListener('hashchange', handleHash);
1055
+
1056
+ /* ══════════════════════════════════════════════════════════════
1057
+ INIT
1058
+ ══════════════════════════════════════════════════════════════ */
1059
+ (async function() {
1060
+ handleHash();
1061
+ await loadAll();
1062
+ renderAll();
1063
+ connectSSE();
1064
+ })();
1065
+ </script>
1066
+ </body>
1067
+ </html>