avtc-pi-featyard 1.1.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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +194 -0
  3. package/agents/fy-design-reviewer.md +77 -0
  4. package/agents/fy-feature-verifier.md +24 -0
  5. package/agents/fy-general-reviewer.md +81 -0
  6. package/agents/fy-guidelines-reviewer.md +46 -0
  7. package/agents/fy-implementer.md +20 -0
  8. package/agents/fy-performance-reviewer.md +46 -0
  9. package/agents/fy-plan-reviewer.md +74 -0
  10. package/agents/fy-plan-verifier.md +44 -0
  11. package/agents/fy-quality-reviewer.md +50 -0
  12. package/agents/fy-requirements-reviewer.md +46 -0
  13. package/agents/fy-researcher.md +6 -0
  14. package/agents/fy-security-reviewer.md +46 -0
  15. package/agents/fy-task-verifier.md +42 -0
  16. package/agents/fy-testing-reviewer.md +60 -0
  17. package/assets/images/kanban-board.png +0 -0
  18. package/assets/images/settings-dropdown.png +0 -0
  19. package/assets/images/settings.png +0 -0
  20. package/assets/images/widget.png +0 -0
  21. package/docs/CONFIGURATION.md +266 -0
  22. package/index.ts +8 -0
  23. package/package.json +85 -0
  24. package/skills/fy-design/SKILL.md +108 -0
  25. package/skills/fy-design-review/SKILL.md +45 -0
  26. package/skills/fy-finish/SKILL.md +28 -0
  27. package/skills/fy-implement/SKILL.md +36 -0
  28. package/skills/fy-plan/SKILL.md +77 -0
  29. package/skills/fy-plan-review/SKILL.md +45 -0
  30. package/skills/fy-research/SKILL.md +38 -0
  31. package/skills/fy-review/SKILL.md +88 -0
  32. package/skills/fy-verify/SKILL.md +23 -0
  33. package/src/commands/auto-agent-commands.ts +748 -0
  34. package/src/commands/kanban-commands.ts +278 -0
  35. package/src/commands/workflow-commands.ts +531 -0
  36. package/src/compaction/compact-handler.ts +371 -0
  37. package/src/compaction/compact-message.ts +52 -0
  38. package/src/compaction/compact-trigger.ts +121 -0
  39. package/src/compaction/safe-editor-write.ts +18 -0
  40. package/src/events/agent/agent-end.ts +19 -0
  41. package/src/events/agent/agent-settled.ts +20 -0
  42. package/src/events/agent/agent-start.ts +18 -0
  43. package/src/events/agent/turn-end.ts +20 -0
  44. package/src/events/agent/turn-start.ts +19 -0
  45. package/src/events/event-deps.ts +33 -0
  46. package/src/events/index.ts +91 -0
  47. package/src/events/input/input.ts +143 -0
  48. package/src/events/session/context.ts +68 -0
  49. package/src/events/session/model-select.ts +20 -0
  50. package/src/events/session/session-compact.ts +20 -0
  51. package/src/events/session/session-shutdown.ts +26 -0
  52. package/src/events/session/session-start.ts +45 -0
  53. package/src/events/session/session-tree.ts +17 -0
  54. package/src/events/tool/tool-call.ts +58 -0
  55. package/src/events/tool/tool-result.ts +54 -0
  56. package/src/git/git-queries.ts +154 -0
  57. package/src/git/resolve-base-branch.ts +173 -0
  58. package/src/git/worktrees/worktree-helpers.ts +142 -0
  59. package/src/git/worktrees/worktree-interception.ts +66 -0
  60. package/src/git/worktrees/worktree-lifecycle.ts +380 -0
  61. package/src/guardrails/completion-gating.ts +63 -0
  62. package/src/guardrails/file-classifier.ts +461 -0
  63. package/src/guardrails/force-add-guard.ts +312 -0
  64. package/src/guardrails/guardrail-tracker.ts +73 -0
  65. package/src/guardrails/guardrail-violations.ts +47 -0
  66. package/src/guardrails/guardrails-engine.ts +663 -0
  67. package/src/guardrails/session-guardrails.ts +75 -0
  68. package/src/guardrails/shell-decompose.ts +285 -0
  69. package/src/guardrails/tdd-enforcement.ts +122 -0
  70. package/src/guardrails/test-output.ts +272 -0
  71. package/src/index.ts +598 -0
  72. package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
  73. package/src/integrations/subagent-integration.ts +166 -0
  74. package/src/integrations/todo-integration.ts +58 -0
  75. package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
  76. package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
  77. package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
  78. package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
  79. package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
  80. package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
  81. package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
  82. package/src/kanban/data/kanban-database.ts +851 -0
  83. package/src/kanban/data/kanban-db-schema.ts +137 -0
  84. package/src/kanban/data/kanban-detect-project.ts +43 -0
  85. package/src/kanban/data/kanban-move-feature.ts +43 -0
  86. package/src/kanban/data/kanban-types.ts +64 -0
  87. package/src/kanban/ensure-feature.ts +226 -0
  88. package/src/kanban/kanban-board-ui/app.js +1125 -0
  89. package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
  90. package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
  91. package/src/kanban/kanban-board-ui/index.html +127 -0
  92. package/src/kanban/kanban-board-ui/style.css +820 -0
  93. package/src/kanban/kanban-bridge.ts +216 -0
  94. package/src/kanban/kanban-context.ts +43 -0
  95. package/src/kanban/kanban-events.ts +34 -0
  96. package/src/kanban/kanban-generate-title.ts +259 -0
  97. package/src/kanban/kanban-generate-topic.ts +170 -0
  98. package/src/kanban/kanban-operations.ts +170 -0
  99. package/src/kanban/kanban-server.ts +931 -0
  100. package/src/kanban/kanban-turn-handlers.ts +55 -0
  101. package/src/kanban/model-capture.ts +34 -0
  102. package/src/log.ts +47 -0
  103. package/src/phases/env-sync.ts +64 -0
  104. package/src/phases/execution-mode.ts +79 -0
  105. package/src/phases/implement-to-verify.ts +31 -0
  106. package/src/phases/phase-progression.ts +249 -0
  107. package/src/phases/phase-transitions.ts +226 -0
  108. package/src/phases/workflow-router.ts +98 -0
  109. package/src/prompts/finish-instructions.ts +143 -0
  110. package/src/prompts/skill-block-builder.ts +73 -0
  111. package/src/prompts/skill-expansion.ts +262 -0
  112. package/src/prompts/task-gate-skill.ts +83 -0
  113. package/src/prompts/template-engine.ts +433 -0
  114. package/src/prompts/text-blocks.ts +279 -0
  115. package/src/review/review-context.ts +56 -0
  116. package/src/review/review-counter.ts +66 -0
  117. package/src/review/review-empty-loop-tracking.ts +52 -0
  118. package/src/review/review-loops.ts +217 -0
  119. package/src/review/review-report.ts +101 -0
  120. package/src/settings/model-overrides.ts +269 -0
  121. package/src/settings/settings-schema.ts +458 -0
  122. package/src/settings/settings-types.ts +81 -0
  123. package/src/settings/settings-ui.ts +222 -0
  124. package/src/shared/errors.ts +24 -0
  125. package/src/shared/global.d.ts +22 -0
  126. package/src/shared/session-entries.ts +24 -0
  127. package/src/shared/types.ts +195 -0
  128. package/src/shared/workflow-refs.ts +253 -0
  129. package/src/shared/workflow-types.ts +180 -0
  130. package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
  131. package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
  132. package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
  133. package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
  134. package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
  135. package/src/state/archive-artifacts.ts +602 -0
  136. package/src/state/artifact-junction.ts +462 -0
  137. package/src/state/artifact-paths.ts +230 -0
  138. package/src/state/feature-activation.ts +239 -0
  139. package/src/state/feature-management.ts +325 -0
  140. package/src/state/feature-record-store.ts +39 -0
  141. package/src/state/feature-session.ts +263 -0
  142. package/src/state/feature-state.ts +439 -0
  143. package/src/state/post-turn-dispatch.ts +106 -0
  144. package/src/state/session-lifecycle.ts +346 -0
  145. package/src/state/state-persistence.ts +122 -0
  146. package/src/state/worth-notes.ts +89 -0
  147. package/src/tools/add-to-backlog.ts +117 -0
  148. package/src/tools/phase-ready.ts +742 -0
  149. package/src/tools/task-ready-advance.ts +235 -0
  150. package/src/tools/text-result.ts +15 -0
  151. package/src/ui/featyard-widget.ts +250 -0
  152. package/src/ui/manage-features-dialog.ts +246 -0
  153. package/src/ui/select-dialog.ts +27 -0
@@ -0,0 +1,1125 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ const WEB_UI_SESSION = "web-ui";
5
+ // Lane order must match LANE_ORDER in types.ts — kept in sync manually.
6
+ const LANES = ["backlog", "design", "design-approval", "ready", "in-progress", "uat", "done"];
7
+
8
+ const LANE_LABELS = {
9
+ backlog: "Backlog",
10
+ design: "Design",
11
+ "design-approval": "Design Approval",
12
+ ready: "Ready",
13
+ "in-progress": "In Progress",
14
+ uat: "UAT",
15
+ done: "Done",
16
+ };
17
+
18
+ const LANE_COLORS = {
19
+ backlog: "#6b7280",
20
+ design: "#8b5cf6",
21
+ "design-approval": "#f59e0b",
22
+ ready: "#3b82f6",
23
+ "in-progress": "#10b981",
24
+ uat: "#ec4899",
25
+ done: "#6366f1",
26
+ };
27
+
28
+ let currentProjectId = null;
29
+ let boardData = {};
30
+ let refreshInterval = null;
31
+ let scrollPauseTimeout = null;
32
+ let contextMenuEl = null;
33
+ let contextMenuFeatureId = null;
34
+ let contextMenuFeatureTitle = null;
35
+ let contextMenuFeatureLocked = false;
36
+
37
+ // Within-lane drag-and-drop reorder state
38
+ let insertionLineEl = null;
39
+ let draggedFeatureId = null;
40
+ let draggedFeatureLane = null;
41
+ let lastInsertionTarget = null;
42
+ let lastInsertionPosition = null;
43
+
44
+ /** Enable/disable action buttons based on whether a project is selected. */
45
+ function updateActionButtons() {
46
+ for (const id of ["add-btn", "import-btn", "export-btn"]) {
47
+ const btn = document.getElementById(id);
48
+ if (btn) btn.disabled = !currentProjectId;
49
+ }
50
+ }
51
+
52
+ /** Find a feature by ID from the current boardData. */
53
+ function findFeatureById(id) {
54
+ for (const lane of LANES) {
55
+ const features = boardData[lane] || [];
56
+ const found = features.find((f) => f.id === id);
57
+ if (found) return found;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ const MAX_CONFIRM_TITLE_LENGTH = 100;
63
+
64
+ /** Shared delete flow: confirm → API call → refresh. */
65
+ async function deleteFeature(featureId, title) {
66
+ const safeTitle = title.length > MAX_CONFIRM_TITLE_LENGTH ? `${title.slice(0, MAX_CONFIRM_TITLE_LENGTH)}…` : title;
67
+ if (!confirm(`Delete feature "${safeTitle}"? This cannot be undone.`)) return;
68
+ try {
69
+ await api(`/api/features/${featureId}`, { method: "DELETE" });
70
+ document.getElementById("detail-modal").classList.add("hidden");
71
+ hideContextMenu();
72
+ await loadBoard();
73
+ } catch (err) {
74
+ if (err.status === 409) {
75
+ alert("This feature was locked by another process. Release the lock first and try again.");
76
+ } else {
77
+ alert(`Failed to delete feature: ${err.message || ""}`);
78
+ }
79
+ }
80
+ }
81
+
82
+ /** Shared release flow: API call → close modal → refresh. Re-throws on error so callers can handle it. */
83
+ async function releaseFeature(featureId) {
84
+ try {
85
+ await api(`/api/features/${featureId}/release`, { method: "POST" });
86
+ document.getElementById("detail-modal").classList.add("hidden");
87
+ hideContextMenu();
88
+ await loadBoard();
89
+ } catch (err) {
90
+ alert(`Failed to release lock: ${err.message}`);
91
+ throw err; // Re-throw so caller can restore button state
92
+ }
93
+ }
94
+
95
+ /** Shared lock flow: API call → close modal → refresh. Re-throws on error so callers can handle it. */
96
+ async function lockFeature(featureId) {
97
+ try {
98
+ await api(`/api/features/${featureId}/lock`, { method: "POST" });
99
+ document.getElementById("detail-modal").classList.add("hidden");
100
+ hideContextMenu();
101
+ await loadBoard();
102
+ } catch (err) {
103
+ alert(`Failed to lock feature: ${err.message}`);
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ /** Hide the context menu. */
109
+ function hideContextMenu() {
110
+ if (contextMenuEl) contextMenuEl.classList.add("hidden");
111
+ }
112
+
113
+ /** Show the context menu at the given position for the given feature. */
114
+ function showContextMenu(x, y, feature) {
115
+ contextMenuFeatureId = feature.id;
116
+ contextMenuFeatureTitle = feature.title;
117
+ contextMenuFeatureLocked = !!feature.locked_at;
118
+
119
+ // Update menu items
120
+ const lockItem = contextMenuEl.querySelector(".ctx-lock");
121
+ const releaseItem = contextMenuEl.querySelector(".ctx-release");
122
+ const deleteItem = contextMenuEl.querySelector(".ctx-delete");
123
+ lockItem.style.display = contextMenuFeatureLocked ? "none" : "block";
124
+ releaseItem.style.display = contextMenuFeatureLocked ? "block" : "none";
125
+ if (contextMenuFeatureLocked) {
126
+ deleteItem.classList.add("disabled");
127
+ deleteItem.title = "Release lock first";
128
+ } else {
129
+ deleteItem.classList.remove("disabled");
130
+ deleteItem.title = "";
131
+ }
132
+
133
+ // Position: use fixed positioning at mouse coords, clamp to viewport
134
+ contextMenuEl.classList.remove("hidden");
135
+ contextMenuEl.style.left = `${x}px`;
136
+ contextMenuEl.style.top = `${y}px`;
137
+
138
+ // Clamp after making visible (need dimensions)
139
+ const rect = contextMenuEl.getBoundingClientRect();
140
+ if (rect.right > window.innerWidth) {
141
+ contextMenuEl.style.left = `${window.innerWidth - rect.width - 8}px`;
142
+ }
143
+ if (rect.bottom > window.innerHeight) {
144
+ contextMenuEl.style.top = `${window.innerHeight - rect.height - 8}px`;
145
+ }
146
+ }
147
+
148
+ // ---- API helpers ----
149
+
150
+ async function api(path, options = {}) {
151
+ const headers = options.headers || {};
152
+ if (window.__KANBAN_AUTH_TOKEN) {
153
+ headers.Authorization = `Bearer ${window.__KANBAN_AUTH_TOKEN}`;
154
+ }
155
+ const res = await fetch(path, { ...options, headers });
156
+ if (!res.ok) {
157
+ const errBody = await res.json().catch(() => ({}));
158
+ const err = new Error(errBody.error || `API error: ${res.status} ${res.statusText}`);
159
+ err.status = res.status;
160
+ throw err;
161
+ }
162
+ return res.json();
163
+ }
164
+
165
+ // ---- Project selector ----
166
+
167
+ async function loadProjects() {
168
+ const projects = await api("/api/projects");
169
+ const select = document.getElementById("project-select");
170
+ select.innerHTML = '<option value="">Select project...</option>';
171
+ for (const p of projects) {
172
+ const opt = document.createElement("option");
173
+ opt.value = p.id;
174
+ opt.textContent = p.name;
175
+ if (String(currentProjectId) === String(p.id)) opt.selected = true;
176
+ select.appendChild(opt);
177
+ }
178
+ if (projects.length === 1) {
179
+ select.value = projects[0].id;
180
+ currentProjectId = projects[0].id;
181
+ } else if (currentProjectId) {
182
+ select.value = currentProjectId;
183
+ }
184
+ updateActionButtons();
185
+ if (currentProjectId) loadBoard();
186
+ }
187
+
188
+ // ---- Board rendering ----
189
+
190
+ /** Fetch board data and re-render without dismissing UI banners. */
191
+ async function refreshBoardData() {
192
+ if (!currentProjectId) return;
193
+ try {
194
+ // Save scroll positions before DOM rebuild
195
+ const scrollPositions = {};
196
+ for (const laneCards of document.querySelectorAll(".lane-cards")) {
197
+ scrollPositions[laneCards.dataset.lane] = laneCards.scrollTop;
198
+ }
199
+
200
+ boardData = await api(`/api/board/${currentProjectId}`);
201
+ renderBoard();
202
+
203
+ // Restore scroll positions after rebuild
204
+ for (const laneCards of document.querySelectorAll(".lane-cards")) {
205
+ const lane = laneCards.dataset.lane;
206
+ if (scrollPositions[lane] != null) {
207
+ laneCards.scrollTop = scrollPositions[lane];
208
+ }
209
+ }
210
+ } catch (err) {
211
+ console.error("Failed to load board:", err);
212
+ }
213
+ }
214
+
215
+ /** Full board refresh — also dismisses import banner and context menu. */
216
+ async function loadBoard() {
217
+ if (!currentProjectId) return;
218
+ hideContextMenu();
219
+ // Dismiss import banner on board refresh
220
+ const banner = document.getElementById("import-banner");
221
+ if (banner) banner.classList.add("hidden");
222
+ await refreshBoardData();
223
+ }
224
+
225
+ function showInsertionLine(card, position) {
226
+ // Skip DOM work if insertion line hasn't moved
227
+ if (card === lastInsertionTarget && position === lastInsertionPosition) return;
228
+ lastInsertionTarget = card;
229
+ lastInsertionPosition = position;
230
+ const rect = card.getBoundingClientRect();
231
+ const laneCards = card.closest(".lane-cards");
232
+ const laneRect = laneCards.getBoundingClientRect();
233
+ insertionLineEl.classList.remove("hidden");
234
+ insertionLineEl.style.left = `${rect.left - laneRect.left + 4}px`;
235
+ insertionLineEl.style.width = `${rect.width - 8}px`;
236
+ if (position === "before") {
237
+ insertionLineEl.style.top = `${rect.top - laneRect.top + laneCards.scrollTop - 1}px`;
238
+ } else {
239
+ insertionLineEl.style.top = `${rect.bottom - laneRect.top + laneCards.scrollTop - 1}px`;
240
+ }
241
+ // Position relative to the lane-cards container
242
+ laneCards.style.position = "relative";
243
+ laneCards.appendChild(insertionLineEl);
244
+ }
245
+
246
+ function hideInsertionLine() {
247
+ insertionLineEl.classList.add("hidden");
248
+ lastInsertionTarget = null;
249
+ lastInsertionPosition = null;
250
+ }
251
+
252
+ function cleanupDrag() {
253
+ hideInsertionLine();
254
+ draggedFeatureId = null;
255
+ draggedFeatureLane = null;
256
+ }
257
+
258
+ function renderBoard() {
259
+ // Note: hideContextMenu() intentionally NOT called here.
260
+ // Auto-refresh calls renderBoard via loadBoard; we don't want to
261
+ // dismiss the context menu while the user is interacting with it.
262
+ const board = document.getElementById("board");
263
+ board.innerHTML = "";
264
+
265
+ for (const lane of LANES) {
266
+ const features = boardData[lane] || [];
267
+ const laneEl = document.createElement("div");
268
+ laneEl.className = "lane";
269
+ laneEl.style.setProperty("--lane-color", LANE_COLORS[lane]);
270
+ laneEl.dataset.lane = lane;
271
+
272
+ laneEl.innerHTML = `
273
+ <div class="lane-header">
274
+ <span>${LANE_LABELS[lane]}</span>
275
+ <span class="lane-count">${features.length}</span>
276
+ </div>
277
+ <div class="lane-cards" data-lane="${lane}"></div>
278
+ `;
279
+
280
+ const cardsContainer = laneEl.querySelector(".lane-cards");
281
+
282
+ // Sort by priority (higher number = higher priority, matches DB ORDER BY DESC)
283
+ features.sort((a, b) => b.priority - a.priority);
284
+
285
+ for (const f of features) {
286
+ const card = createCard(f);
287
+ cardsContainer.appendChild(card);
288
+ }
289
+
290
+ // Drop zone
291
+ setupDropZone(cardsContainer, lane);
292
+ board.appendChild(laneEl);
293
+ }
294
+ }
295
+
296
+ function createCard(feature) {
297
+ const card = document.createElement("div");
298
+ const overlayClass = feature.overlay_status ? ` overlay-${feature.overlay_status}` : "";
299
+ card.className = `card${feature.locked_at ? " locked" : ""}${overlayClass}`;
300
+ card.draggable = !feature.overlay_status;
301
+ card.dataset.featureId = feature.id;
302
+
303
+ card.innerHTML = `
304
+ <div class="card-title">${escapeHtml(feature.title)}</div>
305
+ <div class="card-slug">${escapeHtml(feature.slug)}</div>
306
+ <div class="card-id">#${feature.id}</div>
307
+ ${feature.priority > 0 ? `<div class="card-priority">P${feature.priority}</div>` : ""}
308
+ ${feature.overlay_status === "waiting-for-response" ? '<div class="overlay-indicator">⏳ Waiting for response</div>' : ""}
309
+ ${feature.locked_at && !feature.overlay_status ? '<div class="lock-indicator">🔒 Locked</div>' : ""}
310
+ `;
311
+
312
+ // Drag
313
+ card.addEventListener("dragstart", (e) => {
314
+ e.dataTransfer.setData("text/plain", String(feature.id));
315
+ e.dataTransfer.setData("application/x-lane", feature.lane);
316
+ draggedFeatureId = feature.id;
317
+ draggedFeatureLane = feature.lane;
318
+ card.classList.add("dragging");
319
+ // Pause auto-refresh during drag to prevent DOM invalidation
320
+ stopAutoRefresh();
321
+ });
322
+ card.addEventListener("dragend", () => {
323
+ card.classList.remove("dragging");
324
+ cleanupDrag();
325
+ // Resume auto-refresh after drag
326
+ startAutoRefresh();
327
+ });
328
+
329
+ // Within-lane and cross-lane dragover — show insertion line
330
+ // Note: locked target cards are valid drop targets per spec — insertion line should show
331
+ card.addEventListener("dragover", (e) => {
332
+ if (!draggedFeatureId) return;
333
+ e.preventDefault();
334
+ const rect = card.getBoundingClientRect();
335
+ const midY = rect.top + rect.height / 2;
336
+ const position = e.clientY < midY ? "before" : "after";
337
+ showInsertionLine(card, position);
338
+ });
339
+
340
+ // Within-lane drop — reorder
341
+ card.addEventListener("drop", (e) => handleCardDrop(e, card));
342
+
343
+ // Click to show details
344
+ card.addEventListener("click", (_e) => {
345
+ if (!card.classList.contains("dragging")) showDetail(feature);
346
+ });
347
+
348
+ return card;
349
+ }
350
+
351
+ /** Handle drop on a card — works for both within-lane reorder and cross-lane move with precise placement. */
352
+ function handleCardDrop(e, card) {
353
+ if (!draggedFeatureId) return;
354
+ const container = card.closest(".lane-cards");
355
+ const targetLane = container.dataset.lane;
356
+ const isCrossLane = targetLane !== draggedFeatureLane;
357
+
358
+ e.stopPropagation();
359
+ e.preventDefault();
360
+
361
+ const rect = card.getBoundingClientRect();
362
+ const midY = rect.top + rect.height / 2;
363
+ const insertBefore = e.clientY < midY;
364
+
365
+ // Build new ordering of cards in the target lane
366
+ const cards = [...container.querySelectorAll(".card")];
367
+ const draggedCard = isCrossLane
368
+ ? null // card doesn't exist in target lane yet
369
+ : cards.find((c) => c.dataset.featureId === String(draggedFeatureId));
370
+
371
+ if (!isCrossLane && !draggedCard) {
372
+ cleanupDrag();
373
+ return;
374
+ }
375
+
376
+ // Remove dragged card from list (within-lane only — it won't be in the list for cross-lane)
377
+ const filtered = isCrossLane ? cards : cards.filter((c) => c !== draggedCard);
378
+
379
+ // Find insertion index
380
+ const targetIndex = filtered.indexOf(card);
381
+ if (targetIndex === -1) {
382
+ cleanupDrag();
383
+ return;
384
+ }
385
+ const insertIndex = insertBefore ? targetIndex : targetIndex + 1;
386
+
387
+ if (isCrossLane) {
388
+ // Cross-lane: move first, then reorder
389
+ handleCrossLaneDrop(draggedFeatureId, targetLane, filtered, insertIndex);
390
+ } else {
391
+ // Within-lane: reorder only
392
+ handleWithinLaneReorder(cards, draggedCard, filtered, insertIndex, targetLane);
393
+ }
394
+ }
395
+
396
+ /** Build a feature lookup map from current boardData to avoid repeated linear scans. */
397
+ function buildFeatureMap(targetLane) {
398
+ const map = new Map();
399
+ const lanes = targetLane ? [targetLane] : LANES;
400
+ for (const laneKey of lanes) {
401
+ for (const f of boardData[laneKey] || []) {
402
+ map.set(f.id, f);
403
+ }
404
+ }
405
+ return map;
406
+ }
407
+
408
+ /** Handle within-lane reorder after a drop. */
409
+ async function handleWithinLaneReorder(cards, draggedCard, filtered, insertIndex, lane) {
410
+ filtered.splice(insertIndex, 0, draggedCard);
411
+
412
+ // Build feature lookup map for target lane only
413
+ const featureMap = buildFeatureMap(lane);
414
+
415
+ // Collect unlocked feature IDs in new order
416
+ const featureIds = filtered
417
+ .filter((c) => {
418
+ const f = featureMap.get(Number(c.dataset.featureId));
419
+ return f && !f.locked_at;
420
+ })
421
+ .map((c) => Number(c.dataset.featureId));
422
+
423
+ // Check if order actually changed
424
+ const originalIds = cards
425
+ .filter((c) => {
426
+ const f = featureMap.get(Number(c.dataset.featureId));
427
+ return f && !f.locked_at;
428
+ })
429
+ .map((c) => Number(c.dataset.featureId));
430
+
431
+ const orderChanged = featureIds.length !== originalIds.length || featureIds.some((id, i) => id !== originalIds[i]);
432
+ if (!orderChanged) {
433
+ cleanupDrag();
434
+ return;
435
+ }
436
+
437
+ if (featureIds.length < 2) {
438
+ cleanupDrag();
439
+ return;
440
+ }
441
+
442
+ // Call reorder API
443
+ try {
444
+ await api("/api/features/reorder", {
445
+ method: "POST",
446
+ headers: { "Content-Type": "application/json" },
447
+ body: JSON.stringify({ featureIds, projectId: currentProjectId, lane }),
448
+ });
449
+ await refreshBoardData();
450
+ } catch (err) {
451
+ alert(`Failed to reorder: ${err.message || ""}`);
452
+ } finally {
453
+ cleanupDrag();
454
+ }
455
+ }
456
+
457
+ /** Handle cross-lane move with precise placement after a drop. */
458
+ async function handleCrossLaneDrop(featureId, targetLane, existingCards, insertIndex) {
459
+ try {
460
+ // Step 1: Move the card to the new lane (position doesn't matter, we'll reorder)
461
+ await api(`/api/features/${featureId}/move`, {
462
+ method: "POST",
463
+ headers: { "Content-Type": "application/json" },
464
+ body: JSON.stringify({ toLane: targetLane, changedBy: WEB_UI_SESSION, position: "top" }),
465
+ });
466
+
467
+ // Step 2: Build the desired ordering with the moved card inserted
468
+ // Collect existing feature IDs (excluding locked) using DOM class — avoids stale boardData
469
+ const existingIds = existingCards
470
+ .filter((c) => !c.classList.contains("locked"))
471
+ .map((c) => Number(c.dataset.featureId));
472
+
473
+ // Insert the moved card at the desired position
474
+ const movedId = Number(featureId);
475
+ existingIds.splice(insertIndex, 0, movedId);
476
+
477
+ if (existingIds.length < 2) {
478
+ await loadBoard();
479
+ cleanupDrag();
480
+ return;
481
+ }
482
+
483
+ // Step 3: Reorder to place the moved card at the exact position
484
+ try {
485
+ await api("/api/features/reorder", {
486
+ method: "POST",
487
+ headers: { "Content-Type": "application/json" },
488
+ body: JSON.stringify({ featureIds: existingIds, projectId: currentProjectId, lane: targetLane }),
489
+ });
490
+ } catch (reorderErr) {
491
+ // Move succeeded but reorder failed — refresh to show actual state
492
+ await refreshBoardData();
493
+ alert(`Card moved to ${targetLane} but exact position could not be set. ${reorderErr.message || ""}`);
494
+ return;
495
+ }
496
+
497
+ await refreshBoardData();
498
+ } catch (err) {
499
+ alert(`Failed to move feature: ${err.message || ""}`);
500
+ await refreshBoardData();
501
+ } finally {
502
+ cleanupDrag();
503
+ }
504
+ }
505
+
506
+ function setupDropZone(container, lane) {
507
+ container.addEventListener("dragover", (e) => {
508
+ e.preventDefault();
509
+ // Same-lane drags: per-card handlers show insertion line
510
+ // Cross-lane drags: per-card handlers show insertion line, lane highlight not needed
511
+ if (draggedFeatureLane && draggedFeatureLane === lane) return;
512
+ // Only highlight if not hovering over a card (empty lane area)
513
+ if (e.target.closest(".card")) return;
514
+ container.classList.add("drag-over");
515
+ });
516
+ container.addEventListener("dragleave", () => {
517
+ container.classList.remove("drag-over");
518
+ });
519
+ container.addEventListener("drop", async (e) => {
520
+ e.preventDefault();
521
+ container.classList.remove("drag-over");
522
+ const featureId = e.dataTransfer.getData("text/plain");
523
+ if (!featureId) return;
524
+
525
+ // Skip same-lane drops (handled by per-card drop handler)
526
+ const sourceLane = e.dataTransfer.getData("application/x-lane");
527
+ if (sourceLane === lane) return;
528
+
529
+ // Check if drop landed directly on the container (not on a card)
530
+ // If it landed on a card, the per-card handler already dealt with it
531
+ const droppedOnCard = e.target.closest(".card");
532
+ if (droppedOnCard) return;
533
+
534
+ // Dropped on empty area of lane — move to bottom
535
+ try {
536
+ await api(`/api/features/${featureId}/move`, {
537
+ method: "POST",
538
+ headers: { "Content-Type": "application/json" },
539
+ body: JSON.stringify({ toLane: lane, changedBy: WEB_UI_SESSION }),
540
+ });
541
+ await loadBoard();
542
+ } catch (err) {
543
+ console.error("Failed to move feature:", err);
544
+ } finally {
545
+ cleanupDrag();
546
+ }
547
+ });
548
+ }
549
+
550
+ // ---- Detail modal with tabs ----
551
+
552
+ let currentDetailFeature = null;
553
+ let detailHistoryLoaded = false;
554
+
555
+ function switchDetailTab(tabName) {
556
+ document.querySelectorAll(".detail-tab").forEach((tab) => {
557
+ tab.classList.toggle("active", tab.dataset.tab === tabName);
558
+ });
559
+ document.querySelectorAll(".detail-tab-content").forEach((content) => {
560
+ content.classList.remove("active");
561
+ });
562
+ document.getElementById(`detail-tab-${tabName}`).classList.add("active");
563
+
564
+ // Lazy-load history when switching to it
565
+ if (tabName === "history" && !detailHistoryLoaded && currentDetailFeature) {
566
+ loadDetailHistory(currentDetailFeature);
567
+ }
568
+ }
569
+
570
+ async function loadDetailHistory(feature) {
571
+ const historyBody = document.getElementById("detail-history-body");
572
+ historyBody.innerHTML = '<p style="color:#64748b">Loading...</p>';
573
+ try {
574
+ const history = await api(`/api/features/${feature.id}/history`);
575
+ if (history.length === 0) {
576
+ historyBody.innerHTML = '<p style="color:#64748b">No history entries.</p>';
577
+ } else {
578
+ let html = "";
579
+ for (const h of history) {
580
+ html += `<div class="history-entry">
581
+ <span class="lane-change">${h.from_lane ?? "—"} → ${h.to_lane}</span>
582
+ by ${escapeHtml(h.changed_by)} <span style="color:#64748b">${new Date(h.created_at).toLocaleString()}</span>
583
+ </div>`;
584
+ }
585
+ historyBody.innerHTML = html;
586
+ }
587
+ detailHistoryLoaded = true;
588
+ } catch {
589
+ historyBody.innerHTML = '<p style="color:#f87171">Failed to load history.</p>';
590
+ }
591
+ }
592
+
593
+ async function showDetail(feature) {
594
+ currentDetailFeature = feature;
595
+ detailHistoryLoaded = false;
596
+
597
+ const modal = document.getElementById("detail-modal");
598
+ const isEditable = feature.lane === "backlog";
599
+ const textarea = document.getElementById("detail-description");
600
+
601
+ document.getElementById("detail-title").textContent = feature.title;
602
+ document.getElementById("detail-id").textContent = `#${feature.id} · ${escapeHtml(feature.slug)}`;
603
+
604
+ // Meta row
605
+ document.getElementById("detail-meta-lane").textContent = LANE_LABELS[feature.lane] || feature.lane;
606
+ document.getElementById("detail-meta-priority").textContent = feature.priority;
607
+ document.getElementById("detail-meta-created").textContent = new Date(feature.created_at).toLocaleDateString();
608
+
609
+ // Description textarea — editable for backlog, read-only otherwise
610
+ textarea.value = feature.description || "";
611
+ textarea.readOnly = !isEditable;
612
+ if (isEditable) {
613
+ textarea.classList.remove("readonly");
614
+ textarea.classList.add("editable");
615
+ } else {
616
+ textarea.classList.remove("editable");
617
+ textarea.classList.add("readonly");
618
+ }
619
+
620
+ // Save button — only visible for backlog items
621
+ const saveBtn = document.getElementById("detail-save-btn");
622
+ saveBtn.style.display = isEditable ? "inline-block" : "none";
623
+
624
+ // Status indicators
625
+ const statusRow = document.getElementById("detail-status-row");
626
+ if (feature.overlay_status === "waiting-for-response") {
627
+ statusRow.innerHTML =
628
+ '<span class="detail-label">Status</span><span class="detail-value overlay-indicator">⏳ Waiting for response</span>';
629
+ statusRow.style.display = "flex";
630
+ } else {
631
+ statusRow.style.display = "none";
632
+ }
633
+
634
+ const lockRow = document.getElementById("detail-lock-row");
635
+ if (feature.locked_at) {
636
+ lockRow.innerHTML = `<span class="detail-label">Locked</span><span class="detail-value">🔒 Since ${new Date(feature.locked_at).toLocaleString()}</span>`;
637
+ lockRow.style.display = "flex";
638
+ } else {
639
+ lockRow.style.display = "none";
640
+ }
641
+
642
+ // Release lock button
643
+ const releaseBtn = document.getElementById("release-lock-btn");
644
+ releaseBtn.style.display = feature.locked_at ? "block" : "none";
645
+ releaseBtn.disabled = false;
646
+ releaseBtn.textContent = "Release Lock";
647
+
648
+ // Lock button (shown when feature is NOT locked)
649
+ const lockBtn = document.getElementById("lock-btn");
650
+ lockBtn.style.display = feature.locked_at ? "none" : "block";
651
+ lockBtn.disabled = false;
652
+ lockBtn.textContent = "Lock Feature";
653
+
654
+ // Delete button — disabled when locked
655
+ const deleteBtn = document.getElementById("delete-btn");
656
+ deleteBtn.disabled = !!feature.locked_at;
657
+
658
+ // Switch to Details tab
659
+ switchDetailTab("details");
660
+ modal.classList.remove("hidden");
661
+ }
662
+
663
+ // ---- Auto-refresh ----
664
+
665
+ function startAutoRefresh(intervalMs = 5000) {
666
+ stopAutoRefresh();
667
+ // Use refreshBoardData (not loadBoard) to avoid dismissing context menu
668
+ refreshInterval = setInterval(refreshBoardData, intervalMs);
669
+ }
670
+
671
+ function stopAutoRefresh() {
672
+ if (refreshInterval) {
673
+ clearInterval(refreshInterval);
674
+ refreshInterval = null;
675
+ }
676
+ }
677
+
678
+ // ---- Utilities ----
679
+
680
+ function escapeHtml(str) {
681
+ if (!str) return "";
682
+ const div = document.createElement("div");
683
+ div.textContent = str;
684
+ return div.innerHTML;
685
+ }
686
+
687
+ // ---- Scroll pause during active scrolling ----
688
+
689
+ /** Pause auto-refresh while the user is scrolling in any lane, resume 1s after scroll stops. */
690
+ function setupScrollPause() {
691
+ const board = document.getElementById("board");
692
+ board.addEventListener(
693
+ "scroll",
694
+ (e) => {
695
+ const laneCards = e.target.closest(".lane-cards");
696
+ if (!laneCards) return;
697
+ stopAutoRefresh();
698
+ clearTimeout(scrollPauseTimeout);
699
+ scrollPauseTimeout = setTimeout(startAutoRefresh, 1000);
700
+ },
701
+ { passive: true },
702
+ );
703
+ }
704
+
705
+ // ---- Init ----
706
+
707
+ document.addEventListener("DOMContentLoaded", () => {
708
+ // Create insertion line for within-lane reorder
709
+ insertionLineEl = document.createElement("div");
710
+ insertionLineEl.className = "insertion-line hidden";
711
+ document.body.appendChild(insertionLineEl);
712
+
713
+ const select = document.getElementById("project-select");
714
+ select.addEventListener("change", () => {
715
+ currentProjectId = select.value ? Number(select.value) : null;
716
+ updateActionButtons();
717
+ loadBoard();
718
+ });
719
+
720
+ document.getElementById("refresh-btn").addEventListener("click", loadBoard);
721
+
722
+ // ---- Context menu ----
723
+ contextMenuEl = document.createElement("div");
724
+ contextMenuEl.className = "context-menu hidden";
725
+ contextMenuEl.innerHTML = `
726
+ <div class="context-menu-item ctx-lock">Lock Feature</div>
727
+ <div class="context-menu-item ctx-release">Release Lock</div>
728
+ <div class="context-menu-item ctx-delete danger">Delete Feature</div>
729
+ `;
730
+ document.body.appendChild(contextMenuEl);
731
+
732
+ // Context menu item click handlers
733
+ contextMenuEl.querySelector(".ctx-lock").addEventListener("click", (e) => {
734
+ e.stopPropagation();
735
+ if (contextMenuFeatureLocked) return;
736
+ if (contextMenuFeatureId !== null) lockFeature(contextMenuFeatureId).catch(() => {});
737
+ });
738
+ contextMenuEl.querySelector(".ctx-release").addEventListener("click", (e) => {
739
+ e.stopPropagation();
740
+ if (contextMenuFeatureId !== null) releaseFeature(contextMenuFeatureId).catch(() => {});
741
+ });
742
+ contextMenuEl.querySelector(".ctx-delete").addEventListener("click", (e) => {
743
+ e.stopPropagation();
744
+ if (contextMenuFeatureLocked) return;
745
+ if (contextMenuFeatureId !== null) deleteFeature(contextMenuFeatureId, contextMenuFeatureTitle);
746
+ });
747
+
748
+ // Right-click on board cards → show context menu
749
+ document.getElementById("board").addEventListener("contextmenu", (e) => {
750
+ hideContextMenu(); // Dismiss any existing menu first
751
+ const card = e.target.closest(".card");
752
+ if (!card) return;
753
+ e.preventDefault();
754
+ const featureId = Number(card.dataset.featureId);
755
+ const feature = findFeatureById(featureId);
756
+ if (!feature) return;
757
+ showContextMenu(e.clientX, e.clientY, feature);
758
+ });
759
+
760
+ // Dismiss context menu
761
+ document.addEventListener("click", (e) => {
762
+ if (contextMenuEl && !contextMenuEl.contains(e.target)) hideContextMenu();
763
+ });
764
+ document.addEventListener("keydown", (e) => {
765
+ if (e.key === "Escape") hideContextMenu();
766
+ });
767
+ document.getElementById("board").addEventListener("scroll", () => hideContextMenu());
768
+
769
+ // Detail modal close
770
+ document.querySelector("#detail-modal .modal-close").addEventListener("click", () => {
771
+ document.getElementById("detail-modal").classList.add("hidden");
772
+ });
773
+ document.getElementById("detail-modal").addEventListener("click", (e) => {
774
+ if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden");
775
+ });
776
+
777
+ // Detail modal tab switching
778
+ document.querySelectorAll(".detail-tab").forEach((tab) => {
779
+ tab.addEventListener("click", () => switchDetailTab(tab.dataset.tab));
780
+ });
781
+
782
+ // Save description (backlog items)
783
+ document.getElementById("detail-save-btn").addEventListener("click", async () => {
784
+ if (!currentDetailFeature) return;
785
+ const saveBtn = document.getElementById("detail-save-btn");
786
+ const textarea = document.getElementById("detail-description");
787
+ const newDesc = textarea.value.trim() || null;
788
+ saveBtn.disabled = true;
789
+ saveBtn.textContent = "Saving...";
790
+ try {
791
+ await api(`/api/features/${currentDetailFeature.id}`, {
792
+ method: "PATCH",
793
+ headers: { "Content-Type": "application/json" },
794
+ body: JSON.stringify({ description: newDesc }),
795
+ });
796
+ saveBtn.textContent = "Saved ✓";
797
+ refreshBoardData();
798
+ setTimeout(() => {
799
+ saveBtn.textContent = "Save";
800
+ saveBtn.disabled = false;
801
+ }, 1500);
802
+ } catch (err) {
803
+ alert(`Failed to save: ${err.message}`);
804
+ saveBtn.textContent = "Save";
805
+ saveBtn.disabled = false;
806
+ }
807
+ });
808
+
809
+ // Release lock (uses shared function, preserves button feedback)
810
+ document.getElementById("release-lock-btn").addEventListener("click", () => {
811
+ if (!currentDetailFeature) return;
812
+ const releaseBtn = document.getElementById("release-lock-btn");
813
+ releaseBtn.disabled = true;
814
+ releaseBtn.textContent = "Releasing...";
815
+ releaseFeature(currentDetailFeature.id).catch((_err) => {
816
+ releaseBtn.disabled = false;
817
+ releaseBtn.textContent = "Release Lock";
818
+ });
819
+ });
820
+
821
+ // Lock feature (uses shared function, preserves button feedback)
822
+ document.getElementById("lock-btn").addEventListener("click", () => {
823
+ if (!currentDetailFeature) return;
824
+ const lockBtn = document.getElementById("lock-btn");
825
+ lockBtn.disabled = true;
826
+ lockBtn.textContent = "Locking...";
827
+ lockFeature(currentDetailFeature.id).catch((_err) => {
828
+ lockBtn.disabled = false;
829
+ lockBtn.textContent = "Lock Feature";
830
+ });
831
+ });
832
+
833
+ // Delete feature (uses shared function)
834
+ document.getElementById("delete-btn").addEventListener("click", () => {
835
+ if (!currentDetailFeature) return;
836
+ deleteFeature(currentDetailFeature.id, currentDetailFeature.title);
837
+ });
838
+
839
+ // Add feature modal
840
+ const addModal = document.getElementById("add-modal");
841
+ const addTitle = document.getElementById("add-title");
842
+ const addDescription = document.getElementById("add-description");
843
+ const addPositionBtns = document.querySelectorAll(".position-toggle-btn");
844
+ let addPosition = "bottom";
845
+ addPositionBtns.forEach((btn) => {
846
+ btn.addEventListener("click", () => {
847
+ addPositionBtns.forEach((b) => {
848
+ b.classList.remove("active");
849
+ });
850
+ btn.classList.add("active");
851
+ addPosition = btn.dataset.position;
852
+ });
853
+ });
854
+ const addLane = document.getElementById("add-lane");
855
+ const addSubmitBtn = document.getElementById("add-submit-btn");
856
+
857
+ // Populate lane options
858
+ for (const lane of LANES) {
859
+ const opt = document.createElement("option");
860
+ opt.value = lane;
861
+ opt.textContent = LANE_LABELS[lane];
862
+ if (lane === "backlog") opt.selected = true;
863
+ addLane.appendChild(opt);
864
+ }
865
+
866
+ // Open modal
867
+ document.getElementById("add-btn").addEventListener("click", () => {
868
+ addTitle.value = "";
869
+ addDescription.value = "";
870
+ addPosition = "bottom";
871
+ addPositionBtns.forEach((b) => {
872
+ b.classList.toggle("active", b.dataset.position === "bottom");
873
+ });
874
+ addLane.value = "backlog";
875
+ addModal.classList.remove("hidden");
876
+ });
877
+
878
+ // Generate title from description
879
+ const addGenerateBtn = document.getElementById("add-generate-title-btn");
880
+ addGenerateBtn.addEventListener("click", async () => {
881
+ const description = addDescription.value.trim();
882
+ if (!description) {
883
+ alert("Enter a description first, then generate a title.");
884
+ return;
885
+ }
886
+ addGenerateBtn.disabled = true;
887
+ addGenerateBtn.textContent = "⏳";
888
+ try {
889
+ const result = await api("/api/generate-title", {
890
+ method: "POST",
891
+ headers: { "Content-Type": "application/json" },
892
+ body: JSON.stringify({ description }),
893
+ });
894
+ addTitle.value = result.title;
895
+ } catch (err) {
896
+ alert(`Failed to generate title: ${err.message}`);
897
+ } finally {
898
+ addGenerateBtn.disabled = false;
899
+ addGenerateBtn.textContent = "✨";
900
+ }
901
+ });
902
+
903
+ // Close modal (× button)
904
+ document.querySelector("#add-modal .modal-close").addEventListener("click", () => {
905
+ addModal.classList.add("hidden");
906
+ });
907
+
908
+ // Close modal (backdrop click)
909
+ addModal.addEventListener("click", (e) => {
910
+ if (e.target === e.currentTarget) addModal.classList.add("hidden");
911
+ });
912
+
913
+ // Submit
914
+ addSubmitBtn.addEventListener("click", async () => {
915
+ const title = addTitle.value;
916
+ if (title.trim().length === 0) {
917
+ alert("Title is required");
918
+ return;
919
+ }
920
+ addSubmitBtn.disabled = true;
921
+ addSubmitBtn.textContent = "Adding...";
922
+ try {
923
+ await api("/api/features", {
924
+ method: "POST",
925
+ headers: { "Content-Type": "application/json" },
926
+ body: JSON.stringify({
927
+ projectId: currentProjectId,
928
+ title: title.trim(),
929
+ description: addDescription.value.trim() || null,
930
+ position: addPosition,
931
+ lane: addLane.value,
932
+ }),
933
+ });
934
+ addModal.classList.add("hidden");
935
+ loadBoard().catch(() => {});
936
+ } catch (err) {
937
+ alert(err.message);
938
+ } finally {
939
+ addSubmitBtn.disabled = false;
940
+ addSubmitBtn.textContent = "Add Feature";
941
+ }
942
+ });
943
+
944
+ // ---- Import ----
945
+ const importFileInput = document.getElementById("import-file");
946
+ const importConfirmModal = document.getElementById("import-confirm-modal");
947
+ const importConfirmCount = document.getElementById("import-confirm-count");
948
+ const importHeaderCheck = document.getElementById("import-header-check");
949
+ let importRows = [];
950
+
951
+ document.getElementById("import-btn").addEventListener("click", () => {
952
+ importFileInput.value = "";
953
+ importFileInput.click();
954
+ });
955
+
956
+ importFileInput.addEventListener("change", () => {
957
+ const file = importFileInput.files[0];
958
+ if (!file) return;
959
+ const reader = new FileReader();
960
+ reader.onload = () => {
961
+ const raw = parseCSV(reader.result);
962
+ // Filter empty rows
963
+ importRows = raw.filter((r) => r.trim().length > 0);
964
+ if (importRows.length === 0) {
965
+ alert("CSV is empty");
966
+ return;
967
+ }
968
+ // Auto-detect header
969
+ const first = importRows[0].toLowerCase();
970
+ const headerKeywords = ["description", "title", "task", "idea", "name"];
971
+ importHeaderCheck.checked = headerKeywords.some((kw) => first.includes(kw));
972
+ importConfirmCount.textContent = `${importRows.length} rows detected`;
973
+ importConfirmModal.classList.remove("hidden");
974
+ };
975
+ reader.readAsText(file);
976
+ });
977
+
978
+ // Import confirm modal close
979
+ document.querySelector("#import-confirm-modal .modal-close").addEventListener("click", () => {
980
+ importConfirmModal.classList.add("hidden");
981
+ });
982
+ importConfirmModal.addEventListener("click", (e) => {
983
+ if (e.target === e.currentTarget) importConfirmModal.classList.add("hidden");
984
+ });
985
+ document.getElementById("import-cancel-btn").addEventListener("click", () => {
986
+ importConfirmModal.classList.add("hidden");
987
+ });
988
+
989
+ // Import confirm
990
+ document.getElementById("import-confirm-btn").addEventListener("click", async () => {
991
+ let rows = importRows;
992
+ if (importHeaderCheck.checked) rows = rows.slice(1);
993
+ if (rows.length === 0) {
994
+ alert("No data rows after skipping header");
995
+ return;
996
+ }
997
+ importConfirmModal.classList.add("hidden");
998
+
999
+ // Show progress banner
1000
+ const banner = document.getElementById("import-banner");
1001
+ const minutes = Math.max(1, Math.round((rows.length * 3) / 60));
1002
+ banner.textContent = `Importing ${rows.length} features (est. ${minutes} min)...`;
1003
+ banner.className = "import-banner";
1004
+ banner.classList.remove("hidden");
1005
+ stopAutoRefresh();
1006
+
1007
+ try {
1008
+ const result = await api("/api/features/batch", {
1009
+ method: "POST",
1010
+ headers: { "Content-Type": "application/json" },
1011
+ body: JSON.stringify({ projectId: currentProjectId, rows: rows.map((d) => ({ description: d })) }),
1012
+ });
1013
+
1014
+ banner.className = "import-banner success";
1015
+ if (result.skipped > 0) {
1016
+ banner.textContent = `✓ Imported ${result.imported} of ${result.imported + result.skipped} (${result.skipped} skipped)`;
1017
+ // Show skipped modal
1018
+ const skippedModal = document.getElementById("skipped-modal");
1019
+ document.getElementById("skipped-title").textContent = `${result.skipped} rows skipped`;
1020
+ const details = result.skippedRows.map((s) => `Row ${s.row}: ${s.reason}\n "${s.description}"`).join("\n\n");
1021
+ document.getElementById("skipped-details").textContent = details;
1022
+ skippedModal.classList.remove("hidden");
1023
+ } else {
1024
+ banner.textContent = `✓ Imported ${result.imported} features`;
1025
+ }
1026
+ refreshBoardData().catch(() => {});
1027
+ } catch (err) {
1028
+ banner.className = "import-banner warning";
1029
+ banner.textContent = `Import failed: ${err.message}`;
1030
+ }
1031
+ startAutoRefresh();
1032
+ });
1033
+
1034
+ // Skipped modal close + copy
1035
+ document.querySelector("#skipped-modal .modal-close").addEventListener("click", () => {
1036
+ document.getElementById("skipped-modal").classList.add("hidden");
1037
+ });
1038
+ document.getElementById("skipped-modal").addEventListener("click", (e) => {
1039
+ if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden");
1040
+ });
1041
+ document.getElementById("skipped-copy-btn").addEventListener("click", () => {
1042
+ const text = document.getElementById("skipped-details").textContent;
1043
+ navigator.clipboard.writeText(text).then(() => {
1044
+ document.getElementById("skipped-copy-btn").textContent = "Copied!";
1045
+ setTimeout(() => {
1046
+ document.getElementById("skipped-copy-btn").textContent = "Copy Details";
1047
+ }, 2000);
1048
+ });
1049
+ });
1050
+
1051
+ // ---- Export ----
1052
+ const exportDropdown = document.getElementById("export-dropdown");
1053
+ const exportCheckboxes = document.getElementById("export-lane-checkboxes");
1054
+ const exportToggleAll = document.getElementById("export-toggle-all");
1055
+
1056
+ // Populate lane checkboxes
1057
+ for (const lane of LANES) {
1058
+ const label = document.createElement("label");
1059
+ label.innerHTML = `<input type="checkbox" value="${lane}" checked /> ${LANE_LABELS[lane]}`;
1060
+ exportCheckboxes.appendChild(label);
1061
+ }
1062
+
1063
+ document.getElementById("export-btn").addEventListener("click", (e) => {
1064
+ e.stopPropagation();
1065
+ exportDropdown.classList.toggle("hidden");
1066
+ });
1067
+
1068
+ // Close dropdown on outside click
1069
+ document.addEventListener("click", (e) => {
1070
+ if (!exportDropdown.contains(e.target) && e.target.id !== "export-btn") {
1071
+ exportDropdown.classList.add("hidden");
1072
+ }
1073
+ });
1074
+
1075
+ // Toggle all
1076
+ exportToggleAll.addEventListener("click", () => {
1077
+ const boxes = exportCheckboxes.querySelectorAll("input[type=checkbox]");
1078
+ const allChecked = [...boxes].every((b) => b.checked);
1079
+ exportToggleAll.textContent = allChecked ? "Select All" : "Deselect All";
1080
+ boxes.forEach((b) => {
1081
+ b.checked = !allChecked;
1082
+ });
1083
+ });
1084
+
1085
+ // Download
1086
+ document.getElementById("export-download-btn").addEventListener("click", async () => {
1087
+ const checked = [...exportCheckboxes.querySelectorAll("input:checked")].map((b) => b.value);
1088
+ if (checked.length === 0) {
1089
+ alert("Select at least one lane");
1090
+ return;
1091
+ }
1092
+ exportDropdown.classList.add("hidden");
1093
+
1094
+ try {
1095
+ const res = await fetch(`/api/board/${currentProjectId}/export?lanes=${checked.join(",")}`, {
1096
+ headers: { Authorization: `Bearer ${window.__KANBAN_AUTH_TOKEN}` },
1097
+ });
1098
+ if (!res.ok) {
1099
+ const err = await res.json().catch(() => ({}));
1100
+ throw new Error(err.error || `Export failed: ${res.status}`);
1101
+ }
1102
+ const blob = await res.blob();
1103
+ const url = URL.createObjectURL(blob);
1104
+ const a = document.createElement("a");
1105
+ a.href = url;
1106
+ a.download = res.headers.get("Content-Disposition")?.match(/filename="(.+?)"/)?.[1] || "kanban-export.csv";
1107
+ a.click();
1108
+ URL.revokeObjectURL(url);
1109
+ } catch (err) {
1110
+ alert(err.message);
1111
+ }
1112
+ });
1113
+
1114
+ // Auto-detect project from URL query parameter
1115
+ const urlParams = new URLSearchParams(window.location.search);
1116
+ const projectParam = urlParams.get("project");
1117
+ if (projectParam) {
1118
+ currentProjectId = Number(projectParam);
1119
+ updateActionButtons();
1120
+ }
1121
+
1122
+ setupScrollPause();
1123
+ loadProjects();
1124
+ startAutoRefresh();
1125
+ });