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.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/agents/fy-design-reviewer.md +77 -0
- package/agents/fy-feature-verifier.md +24 -0
- package/agents/fy-general-reviewer.md +81 -0
- package/agents/fy-guidelines-reviewer.md +46 -0
- package/agents/fy-implementer.md +20 -0
- package/agents/fy-performance-reviewer.md +46 -0
- package/agents/fy-plan-reviewer.md +74 -0
- package/agents/fy-plan-verifier.md +44 -0
- package/agents/fy-quality-reviewer.md +50 -0
- package/agents/fy-requirements-reviewer.md +46 -0
- package/agents/fy-researcher.md +6 -0
- package/agents/fy-security-reviewer.md +46 -0
- package/agents/fy-task-verifier.md +42 -0
- package/agents/fy-testing-reviewer.md +60 -0
- package/assets/images/kanban-board.png +0 -0
- package/assets/images/settings-dropdown.png +0 -0
- package/assets/images/settings.png +0 -0
- package/assets/images/widget.png +0 -0
- package/docs/CONFIGURATION.md +266 -0
- package/index.ts +8 -0
- package/package.json +85 -0
- package/skills/fy-design/SKILL.md +108 -0
- package/skills/fy-design-review/SKILL.md +45 -0
- package/skills/fy-finish/SKILL.md +28 -0
- package/skills/fy-implement/SKILL.md +36 -0
- package/skills/fy-plan/SKILL.md +77 -0
- package/skills/fy-plan-review/SKILL.md +45 -0
- package/skills/fy-research/SKILL.md +38 -0
- package/skills/fy-review/SKILL.md +88 -0
- package/skills/fy-verify/SKILL.md +23 -0
- package/src/commands/auto-agent-commands.ts +748 -0
- package/src/commands/kanban-commands.ts +278 -0
- package/src/commands/workflow-commands.ts +531 -0
- package/src/compaction/compact-handler.ts +371 -0
- package/src/compaction/compact-message.ts +52 -0
- package/src/compaction/compact-trigger.ts +121 -0
- package/src/compaction/safe-editor-write.ts +18 -0
- package/src/events/agent/agent-end.ts +19 -0
- package/src/events/agent/agent-settled.ts +20 -0
- package/src/events/agent/agent-start.ts +18 -0
- package/src/events/agent/turn-end.ts +20 -0
- package/src/events/agent/turn-start.ts +19 -0
- package/src/events/event-deps.ts +33 -0
- package/src/events/index.ts +91 -0
- package/src/events/input/input.ts +143 -0
- package/src/events/session/context.ts +68 -0
- package/src/events/session/model-select.ts +20 -0
- package/src/events/session/session-compact.ts +20 -0
- package/src/events/session/session-shutdown.ts +26 -0
- package/src/events/session/session-start.ts +45 -0
- package/src/events/session/session-tree.ts +17 -0
- package/src/events/tool/tool-call.ts +58 -0
- package/src/events/tool/tool-result.ts +54 -0
- package/src/git/git-queries.ts +154 -0
- package/src/git/resolve-base-branch.ts +173 -0
- package/src/git/worktrees/worktree-helpers.ts +142 -0
- package/src/git/worktrees/worktree-interception.ts +66 -0
- package/src/git/worktrees/worktree-lifecycle.ts +380 -0
- package/src/guardrails/completion-gating.ts +63 -0
- package/src/guardrails/file-classifier.ts +461 -0
- package/src/guardrails/force-add-guard.ts +312 -0
- package/src/guardrails/guardrail-tracker.ts +73 -0
- package/src/guardrails/guardrail-violations.ts +47 -0
- package/src/guardrails/guardrails-engine.ts +663 -0
- package/src/guardrails/session-guardrails.ts +75 -0
- package/src/guardrails/shell-decompose.ts +285 -0
- package/src/guardrails/tdd-enforcement.ts +122 -0
- package/src/guardrails/test-output.ts +272 -0
- package/src/index.ts +598 -0
- package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
- package/src/integrations/subagent-integration.ts +166 -0
- package/src/integrations/todo-integration.ts +58 -0
- package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
- package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
- package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
- package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
- package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
- package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
- package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
- package/src/kanban/data/kanban-database.ts +851 -0
- package/src/kanban/data/kanban-db-schema.ts +137 -0
- package/src/kanban/data/kanban-detect-project.ts +43 -0
- package/src/kanban/data/kanban-move-feature.ts +43 -0
- package/src/kanban/data/kanban-types.ts +64 -0
- package/src/kanban/ensure-feature.ts +226 -0
- package/src/kanban/kanban-board-ui/app.js +1125 -0
- package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
- package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
- package/src/kanban/kanban-board-ui/index.html +127 -0
- package/src/kanban/kanban-board-ui/style.css +820 -0
- package/src/kanban/kanban-bridge.ts +216 -0
- package/src/kanban/kanban-context.ts +43 -0
- package/src/kanban/kanban-events.ts +34 -0
- package/src/kanban/kanban-generate-title.ts +259 -0
- package/src/kanban/kanban-generate-topic.ts +170 -0
- package/src/kanban/kanban-operations.ts +170 -0
- package/src/kanban/kanban-server.ts +931 -0
- package/src/kanban/kanban-turn-handlers.ts +55 -0
- package/src/kanban/model-capture.ts +34 -0
- package/src/log.ts +47 -0
- package/src/phases/env-sync.ts +64 -0
- package/src/phases/execution-mode.ts +79 -0
- package/src/phases/implement-to-verify.ts +31 -0
- package/src/phases/phase-progression.ts +249 -0
- package/src/phases/phase-transitions.ts +226 -0
- package/src/phases/workflow-router.ts +98 -0
- package/src/prompts/finish-instructions.ts +143 -0
- package/src/prompts/skill-block-builder.ts +73 -0
- package/src/prompts/skill-expansion.ts +262 -0
- package/src/prompts/task-gate-skill.ts +83 -0
- package/src/prompts/template-engine.ts +433 -0
- package/src/prompts/text-blocks.ts +279 -0
- package/src/review/review-context.ts +56 -0
- package/src/review/review-counter.ts +66 -0
- package/src/review/review-empty-loop-tracking.ts +52 -0
- package/src/review/review-loops.ts +217 -0
- package/src/review/review-report.ts +101 -0
- package/src/settings/model-overrides.ts +269 -0
- package/src/settings/settings-schema.ts +458 -0
- package/src/settings/settings-types.ts +81 -0
- package/src/settings/settings-ui.ts +222 -0
- package/src/shared/errors.ts +24 -0
- package/src/shared/global.d.ts +22 -0
- package/src/shared/session-entries.ts +24 -0
- package/src/shared/types.ts +195 -0
- package/src/shared/workflow-refs.ts +253 -0
- package/src/shared/workflow-types.ts +180 -0
- package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
- package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
- package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
- package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
- package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
- package/src/state/archive-artifacts.ts +602 -0
- package/src/state/artifact-junction.ts +462 -0
- package/src/state/artifact-paths.ts +230 -0
- package/src/state/feature-activation.ts +239 -0
- package/src/state/feature-management.ts +325 -0
- package/src/state/feature-record-store.ts +39 -0
- package/src/state/feature-session.ts +263 -0
- package/src/state/feature-state.ts +439 -0
- package/src/state/post-turn-dispatch.ts +106 -0
- package/src/state/session-lifecycle.ts +346 -0
- package/src/state/state-persistence.ts +122 -0
- package/src/state/worth-notes.ts +89 -0
- package/src/tools/add-to-backlog.ts +117 -0
- package/src/tools/phase-ready.ts +742 -0
- package/src/tools/task-ready-advance.ts +235 -0
- package/src/tools/text-result.ts +15 -0
- package/src/ui/featyard-widget.ts +250 -0
- package/src/ui/manage-features-dialog.ts +246 -0
- 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
|
+
});
|