claudeboard 2.10.1 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/expo-health.js +47 -2
- package/agents/orchestrator.js +4 -0
- package/agents/qa.js +19 -6
- package/bin/cli.js +2 -0
- package/dashboard/index.html +187 -12
- package/dashboard/server.js +41 -2
- package/package.json +1 -1
package/agents/expo-health.js
CHANGED
|
@@ -150,7 +150,13 @@ async function tryStartExpo(projectPath, port) {
|
|
|
150
150
|
|
|
151
151
|
try {
|
|
152
152
|
const { spawn } = require("child_process");
|
|
153
|
-
|
|
153
|
+
// Use iOS simulator if available, fallback to web
|
|
154
|
+
const useIOS = process.env.CLAUDEBOARD_IOS === "1" || await isSimulatorAvailable();
|
|
155
|
+
const expoArgs = useIOS
|
|
156
|
+
? ["expo", "start", "--ios", "--port", String(port)]
|
|
157
|
+
: ["expo", "start", "--web", "--port", String(port)];
|
|
158
|
+
|
|
159
|
+
proc = spawn("npx", expoArgs, {
|
|
154
160
|
cwd: projectPath,
|
|
155
161
|
env: (() => { const e = { ...process.env, CI: "1", EXPO_NO_INTERACTIVE: "1", EXPO_NO_DOTENV: "0" }; delete e.ANTHROPIC_API_KEY; return e; })(),
|
|
156
162
|
stdio: "pipe",
|
|
@@ -178,7 +184,10 @@ async function tryStartExpo(projectPath, port) {
|
|
|
178
184
|
text.includes("Metro waiting on") ||
|
|
179
185
|
text.includes(`http://localhost:${port}`) ||
|
|
180
186
|
text.includes("Bundling complete") ||
|
|
181
|
-
text.includes("Web is waiting on")
|
|
187
|
+
text.includes("Web is waiting on") ||
|
|
188
|
+
text.includes("Opening on iOS") ||
|
|
189
|
+
text.includes("Installed on iOS") ||
|
|
190
|
+
text.includes("Building JavaScript bundle")
|
|
182
191
|
) {
|
|
183
192
|
setTimeout(() => {
|
|
184
193
|
// If we haven't already failed, declare success
|
|
@@ -309,3 +318,39 @@ function hasFatalNpmError(output) {
|
|
|
309
318
|
function getFatalError(output) {
|
|
310
319
|
return output.split("\n").find(l => l.includes("npm error") && !l.includes("peer")) || output.slice(-200);
|
|
311
320
|
}
|
|
321
|
+
|
|
322
|
+
// ── iOS Simulator detection ───────────────────────────────────────────────────
|
|
323
|
+
async function isSimulatorAvailable() {
|
|
324
|
+
try {
|
|
325
|
+
const { execSync } = await import("child_process");
|
|
326
|
+
const output = execSync("xcrun simctl list devices booted 2>&1", { encoding: "utf8" });
|
|
327
|
+
// Check if any device is already booted
|
|
328
|
+
if (output.includes("Booted")) return true;
|
|
329
|
+
// Check if any iOS runtime is available
|
|
330
|
+
const runtimes = execSync("xcrun simctl list runtimes 2>&1", { encoding: "utf8" });
|
|
331
|
+
return runtimes.includes("iOS") && !runtimes.includes("unavailable");
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Take a screenshot of the iOS simulator
|
|
338
|
+
export async function screenshotSimulator(outputPath) {
|
|
339
|
+
try {
|
|
340
|
+
const { execSync } = await import("child_process");
|
|
341
|
+
execSync(`xcrun simctl io booted screenshot "${outputPath}"`, { stdio: "pipe" });
|
|
342
|
+
const data = fs.readFileSync(outputPath);
|
|
343
|
+
return { success: true, base64: data.toString("base64"), path: outputPath };
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return { success: false, error: e.message };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Tap on the iOS simulator at given coordinates
|
|
350
|
+
export async function tapSimulator(x, y) {
|
|
351
|
+
try {
|
|
352
|
+
const { execSync } = await import("child_process");
|
|
353
|
+
execSync(`xcrun simctl io booted tap ${x} ${y}`, { stdio: "pipe" });
|
|
354
|
+
return true;
|
|
355
|
+
} catch { return false; }
|
|
356
|
+
}
|
package/agents/orchestrator.js
CHANGED
|
@@ -47,8 +47,12 @@ export async function runOrchestrator(config) {
|
|
|
47
47
|
projectName,
|
|
48
48
|
expoPort = 8081,
|
|
49
49
|
skipArchitect = false,
|
|
50
|
+
useIOS = false,
|
|
50
51
|
} = config;
|
|
51
52
|
|
|
53
|
+
// Set env var so expo-health and QA agents know to use iOS simulator
|
|
54
|
+
if (useIOS) process.env.CLAUDEBOARD_IOS = "1";
|
|
55
|
+
|
|
52
56
|
console.log(chalk.cyan("\n╔═══════════════════════════════════════╗"));
|
|
53
57
|
console.log(chalk.cyan("║") + chalk.bold(" 🤖 CLAUDEBOARD ORCHESTRATOR STARTING ") + chalk.cyan("║"));
|
|
54
58
|
console.log(chalk.cyan("╚═══════════════════════════════════════╝\n"));
|
package/agents/qa.js
CHANGED
|
@@ -2,6 +2,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
|
2
2
|
import { addLog, completeTask, failTask } from "./board-client.js";
|
|
3
3
|
import { runCommand } from "../tools/terminal.js";
|
|
4
4
|
import { screenshotExpoWeb } from "../tools/screenshot.js";
|
|
5
|
+
import { screenshotSimulator } from "./expo-health.js";
|
|
5
6
|
import { listFiles, readFile } from "../tools/filesystem.js";
|
|
6
7
|
import { execSync } from "child_process";
|
|
7
8
|
import path from "path";
|
|
@@ -76,16 +77,28 @@ export async function runQAAgent(task, devResult, projectPath, prdContent, expoP
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
// ── 3. Take screenshot
|
|
80
|
+
// ── 3. Take screenshot — iOS simulator first, fallback to web ────────────
|
|
80
81
|
let screenshotBase64 = null;
|
|
81
82
|
let screenshotPath = null;
|
|
82
83
|
if (expoRunning) {
|
|
83
84
|
await addLog(task.id, "Taking screenshot...", "progress");
|
|
84
85
|
const screenshotDir = path.join(projectPath, ".claudeboard-screenshots");
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
if (!fs.existsSync(screenshotDir)) fs.mkdirSync(screenshotDir, { recursive: true });
|
|
87
|
+
|
|
88
|
+
// Try iOS simulator first (more accurate for native apps)
|
|
89
|
+
const simPath = path.join(screenshotDir, `sim_${task.id}_${Date.now()}.png`);
|
|
90
|
+
const simShot = await screenshotSimulator(simPath);
|
|
91
|
+
if (simShot.success) {
|
|
92
|
+
screenshotBase64 = simShot.base64;
|
|
93
|
+
screenshotPath = simShot.path;
|
|
94
|
+
await addLog(task.id, "📱 iOS simulator screenshot taken", "progress");
|
|
95
|
+
} else {
|
|
96
|
+
// Fallback to Expo Web screenshot
|
|
97
|
+
const shot = await screenshotExpoWeb(expoPort, screenshotDir);
|
|
98
|
+
if (shot.success && shot.base64) {
|
|
99
|
+
screenshotBase64 = shot.base64;
|
|
100
|
+
screenshotPath = shot.path;
|
|
101
|
+
}
|
|
89
102
|
}
|
|
90
103
|
}
|
|
91
104
|
|
|
@@ -106,7 +119,7 @@ export async function runQAAgent(task, devResult, projectPath, prdContent, expoP
|
|
|
106
119
|
// ── Claude Code QA review ─────────────────────────────────────────────────────
|
|
107
120
|
async function runClaudeCodeQA(task, projectPath, prdContent, expoRunning, screenshotBase64, screenshotPath) {
|
|
108
121
|
const screenshotNote = screenshotBase64
|
|
109
|
-
? `A screenshot of the app has been saved to: ${screenshotPath}\nRead it with the Read tool and evaluate the visual result.`
|
|
122
|
+
? `A screenshot of the app (from iOS Simulator or Expo Web) has been saved to: ${screenshotPath}\nRead it with the Read tool and evaluate the visual result — check layout, text, colors, spacing.`
|
|
110
123
|
: expoRunning
|
|
111
124
|
? "Expo is running but screenshot failed — evaluate code only."
|
|
112
125
|
: "Expo is not running — evaluate code quality only (TypeScript, completeness, correctness).";
|
package/bin/cli.js
CHANGED
|
@@ -137,6 +137,7 @@ program
|
|
|
137
137
|
.requiredOption("--project <path>", "Path to your app project directory")
|
|
138
138
|
.option("--restart", "Force restart from scratch (ignore existing tasks in board)")
|
|
139
139
|
.option("--expo-port <port>", "Expo Web port for QA screenshots", "8081")
|
|
140
|
+
.option("--ios", "Use iOS Simulator instead of Expo Web for QA (requires Xcode)")
|
|
140
141
|
.action(async (opts) => {
|
|
141
142
|
console.log(LOGO);
|
|
142
143
|
const config = loadConfig();
|
|
@@ -190,6 +191,7 @@ program
|
|
|
190
191
|
projectName: config.projectName,
|
|
191
192
|
expoPort: parseInt(opts.expoPort),
|
|
192
193
|
forceRestart: !!opts.restart,
|
|
194
|
+
useIOS: !!opts.ios,
|
|
193
195
|
});
|
|
194
196
|
});
|
|
195
197
|
|
package/dashboard/index.html
CHANGED
|
@@ -847,6 +847,36 @@
|
|
|
847
847
|
border-color: rgba(248,113,113,0.5);
|
|
848
848
|
}
|
|
849
849
|
|
|
850
|
+
/* ── CARD ACTION BAR (edit/delete/retry) ── */
|
|
851
|
+
.card-actions {
|
|
852
|
+
display: flex;
|
|
853
|
+
gap: 4px;
|
|
854
|
+
margin-top: 8px;
|
|
855
|
+
opacity: 0;
|
|
856
|
+
transition: opacity 0.15s;
|
|
857
|
+
}
|
|
858
|
+
.card:hover .card-actions { opacity: 1; }
|
|
859
|
+
.card-action-btn {
|
|
860
|
+
flex: 1;
|
|
861
|
+
padding: 4px 0;
|
|
862
|
+
border-radius: 4px;
|
|
863
|
+
border: 1px solid var(--border);
|
|
864
|
+
background: rgba(255,255,255,0.03);
|
|
865
|
+
color: var(--muted);
|
|
866
|
+
font-family: var(--mono);
|
|
867
|
+
font-size: 10px;
|
|
868
|
+
cursor: pointer;
|
|
869
|
+
transition: all 0.15s;
|
|
870
|
+
text-align: center;
|
|
871
|
+
}
|
|
872
|
+
.card-action-btn:hover { background: rgba(255,255,255,0.08); color: var(--text); }
|
|
873
|
+
.card-action-btn.retry { color: var(--yellow); border-color: rgba(251,191,36,0.3); }
|
|
874
|
+
.card-action-btn.retry:hover { background: rgba(251,191,36,0.1); }
|
|
875
|
+
.card-action-btn.del { color: var(--red); border-color: rgba(248,113,113,0.2); }
|
|
876
|
+
.card-action-btn.del:hover { background: rgba(248,113,113,0.12); }
|
|
877
|
+
.card-action-btn.edit { color: var(--accent); border-color: rgba(108,138,255,0.2); }
|
|
878
|
+
.card-action-btn.edit:hover { background: rgba(108,138,255,0.1); }
|
|
879
|
+
|
|
850
880
|
/* Empty column state */
|
|
851
881
|
.col-empty {
|
|
852
882
|
text-align: center;
|
|
@@ -1011,6 +1041,46 @@
|
|
|
1011
1041
|
</div>
|
|
1012
1042
|
</div>
|
|
1013
1043
|
|
|
1044
|
+
<!-- EDIT TASK MODAL -->
|
|
1045
|
+
<div class="overlay" id="editModal" onclick="if(event.target===this)closeEditTask()">
|
|
1046
|
+
<div class="modal">
|
|
1047
|
+
<div class="modal-title" data-i18n="editTaskTitle">Edit Task</div>
|
|
1048
|
+
<input type="hidden" id="e-id">
|
|
1049
|
+
<div class="field">
|
|
1050
|
+
<label data-i18n="fieldTitle">Title</label>
|
|
1051
|
+
<input type="text" id="e-title">
|
|
1052
|
+
</div>
|
|
1053
|
+
<div class="field">
|
|
1054
|
+
<label data-i18n="fieldDesc">Description</label>
|
|
1055
|
+
<textarea id="e-desc" rows="4"></textarea>
|
|
1056
|
+
</div>
|
|
1057
|
+
<div class="modal-grid">
|
|
1058
|
+
<div class="field">
|
|
1059
|
+
<label data-i18n="fieldPriority">Priority</label>
|
|
1060
|
+
<select id="e-priority">
|
|
1061
|
+
<option value="high" data-i18n="prioHigh">High</option>
|
|
1062
|
+
<option value="medium" data-i18n="prioMed">Medium</option>
|
|
1063
|
+
<option value="low" data-i18n="prioLow">Low</option>
|
|
1064
|
+
</select>
|
|
1065
|
+
</div>
|
|
1066
|
+
<div class="field">
|
|
1067
|
+
<label data-i18n="fieldType">Type</label>
|
|
1068
|
+
<select id="e-type">
|
|
1069
|
+
<option value="feature" data-i18n="typeFeature">Feature</option>
|
|
1070
|
+
<option value="bug" data-i18n="typeBug">Bug</option>
|
|
1071
|
+
<option value="config" data-i18n="typeConfig">Config</option>
|
|
1072
|
+
<option value="refactor" data-i18n="typeRefactor">Refactor</option>
|
|
1073
|
+
<option value="test" data-i18n="typeTest">Test</option>
|
|
1074
|
+
</select>
|
|
1075
|
+
</div>
|
|
1076
|
+
</div>
|
|
1077
|
+
<div class="modal-actions">
|
|
1078
|
+
<button class="btn-cancel" onclick="closeEditTask()" data-i18n="cancel">Cancel</button>
|
|
1079
|
+
<button class="btn-create" onclick="submitEditTask()" data-i18n="saveTask">Save Changes</button>
|
|
1080
|
+
</div>
|
|
1081
|
+
</div>
|
|
1082
|
+
</div>
|
|
1083
|
+
|
|
1014
1084
|
<!-- RETRY / EDIT MODAL -->
|
|
1015
1085
|
<div class="overlay" id="retryModal" onclick="if(event.target===this)closeRetry()">
|
|
1016
1086
|
<div class="modal">
|
|
@@ -1131,7 +1201,7 @@ function connectWS() {
|
|
|
1131
1201
|
ws.onclose = () => { setWS(false); setTimeout(connectWS, 2000); };
|
|
1132
1202
|
ws.onmessage = (e) => {
|
|
1133
1203
|
const { event, data } = JSON.parse(e.data);
|
|
1134
|
-
if (['task_update','task_added','task_started','task_complete','task_failed'].includes(event)) {
|
|
1204
|
+
if (['task_update','task_added','task_started','task_complete','task_failed','task_deleted','task_reordered'].includes(event)) {
|
|
1135
1205
|
loadBoard();
|
|
1136
1206
|
}
|
|
1137
1207
|
if (event === 'log') {
|
|
@@ -1219,7 +1289,9 @@ function renderKanban() {
|
|
|
1219
1289
|
continue;
|
|
1220
1290
|
}
|
|
1221
1291
|
|
|
1222
|
-
|
|
1292
|
+
// Sort by priority_order before rendering
|
|
1293
|
+
const sorted = [...list].sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99));
|
|
1294
|
+
body.innerHTML = sorted.map((t, i) => cardHTML(t, status === 'todo' ? i + 1 : null)).join('');
|
|
1223
1295
|
|
|
1224
1296
|
// Attach drag events
|
|
1225
1297
|
body.querySelectorAll('.card').forEach(card => {
|
|
@@ -1230,15 +1302,17 @@ function renderKanban() {
|
|
|
1230
1302
|
}
|
|
1231
1303
|
}
|
|
1232
1304
|
|
|
1233
|
-
function cardHTML(task) {
|
|
1305
|
+
function cardHTML(task, position = null) {
|
|
1234
1306
|
const icons = { todo: '', in_progress: '', done: '✓', error: '✕', blocked: '—' };
|
|
1235
1307
|
const shortEpic = (task.epicName || '').split(' ').slice(0,2).join(' ');
|
|
1236
1308
|
const isError = task.status === 'error';
|
|
1309
|
+
const posNum = position ? `<span style="font-family:var(--mono);font-size:9px;color:var(--dim);background:rgba(255,255,255,0.05);border-radius:3px;padding:1px 5px;flex-shrink:0">#${position}</span>` : '';
|
|
1237
1310
|
return `
|
|
1238
1311
|
<div class="card fade-in" draggable="true" data-id="${task.id}" data-status="${task.status}">
|
|
1239
1312
|
<div class="card-top">
|
|
1240
1313
|
<div class="card-status ${task.status}">${icons[task.status] || ''}</div>
|
|
1241
1314
|
<div class="card-title">${esc(task.title)}</div>
|
|
1315
|
+
${posNum}
|
|
1242
1316
|
</div>
|
|
1243
1317
|
${task.description ? `<div class="card-desc">${esc(task.description.split('\n')[0])}</div>` : ''}
|
|
1244
1318
|
<div class="card-footer">
|
|
@@ -1246,7 +1320,11 @@ function cardHTML(task) {
|
|
|
1246
1320
|
<span class="tag ${task.type}">${task.type}</span>
|
|
1247
1321
|
${shortEpic ? `<span class="card-epic">${esc(shortEpic)}</span>` : ''}
|
|
1248
1322
|
</div>
|
|
1249
|
-
|
|
1323
|
+
<div class="card-actions" onclick="event.stopPropagation()">
|
|
1324
|
+
${isError ? `<button class="card-action-btn retry" onclick="openRetry('${task.id}')">${t('retryCard')}</button>` : ''}
|
|
1325
|
+
<button class="card-action-btn edit" onclick="openEditTask('${task.id}')" title="Edit">✎</button>
|
|
1326
|
+
<button class="card-action-btn del" onclick="deleteTask('${task.id}')" title="Delete">✕</button>
|
|
1327
|
+
</div>
|
|
1250
1328
|
</div>`;
|
|
1251
1329
|
}
|
|
1252
1330
|
|
|
@@ -1269,11 +1347,31 @@ function onDragOver(e, status) {
|
|
|
1269
1347
|
e.preventDefault();
|
|
1270
1348
|
e.dataTransfer.dropEffect = 'move';
|
|
1271
1349
|
const col = e.currentTarget;
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1350
|
+
|
|
1351
|
+
// Find the card being hovered over to insert placeholder before it
|
|
1352
|
+
const draggingCard = document.querySelector('.card.dragging');
|
|
1353
|
+
const cards = [...col.querySelectorAll('.card:not(.dragging)')];
|
|
1354
|
+
|
|
1355
|
+
// Remove existing placeholder
|
|
1356
|
+
col.querySelectorAll('.drop-placeholder').forEach(p => p.remove());
|
|
1357
|
+
|
|
1358
|
+
// Find insertion point based on mouse Y position
|
|
1359
|
+
let insertBefore = null;
|
|
1360
|
+
for (const card of cards) {
|
|
1361
|
+
const rect = card.getBoundingClientRect();
|
|
1362
|
+
if (e.clientY < rect.top + rect.height / 2) {
|
|
1363
|
+
insertBefore = card;
|
|
1364
|
+
break;
|
|
1365
|
+
}
|
|
1276
1366
|
}
|
|
1367
|
+
|
|
1368
|
+
const ph = document.createElement('div');
|
|
1369
|
+
ph.className = 'drop-placeholder';
|
|
1370
|
+
if (insertBefore) col.insertBefore(ph, insertBefore);
|
|
1371
|
+
else col.appendChild(ph);
|
|
1372
|
+
|
|
1373
|
+
// Store insertion target for onDrop
|
|
1374
|
+
col.dataset.insertBefore = insertBefore?.dataset?.id || '';
|
|
1277
1375
|
}
|
|
1278
1376
|
|
|
1279
1377
|
function onDragLeave(e) {
|
|
@@ -1289,10 +1387,43 @@ async function onDrop(e, status) {
|
|
|
1289
1387
|
document.querySelectorAll('.drop-placeholder').forEach(p => p.remove());
|
|
1290
1388
|
if (!draggedId) return;
|
|
1291
1389
|
|
|
1292
|
-
|
|
1293
|
-
|
|
1390
|
+
const col = e.currentTarget;
|
|
1391
|
+
const insertBeforeId = col.dataset.insertBefore || '';
|
|
1392
|
+
delete col.dataset.insertBefore;
|
|
1393
|
+
|
|
1394
|
+
// Build the new ordered list of ids for this column
|
|
1395
|
+
// Start from the DOM — what's visually shown — plus insert dragged card
|
|
1396
|
+
const otherIds = [...col.querySelectorAll('.card')]
|
|
1397
|
+
.map(c => c.dataset.id)
|
|
1398
|
+
.filter(id => id && id !== draggedId);
|
|
1399
|
+
|
|
1400
|
+
let newOrderedIds;
|
|
1401
|
+
if (!insertBeforeId) {
|
|
1402
|
+
newOrderedIds = [...otherIds, draggedId]; // dropped at end
|
|
1403
|
+
} else {
|
|
1404
|
+
const insertIdx = otherIds.indexOf(insertBeforeId);
|
|
1405
|
+
if (insertIdx === -1) {
|
|
1406
|
+
newOrderedIds = [draggedId, ...otherIds];
|
|
1407
|
+
} else {
|
|
1408
|
+
newOrderedIds = [...otherIds.slice(0, insertIdx), draggedId, ...otherIds.slice(insertIdx)];
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Update status first if moving to different column
|
|
1413
|
+
const draggedTask = allTasks().find(t => t.id === draggedId);
|
|
1414
|
+
if (draggedTask?.status !== status) {
|
|
1415
|
+
await fetch(`/api/tasks/${draggedId}`, {
|
|
1416
|
+
method: 'PATCH',
|
|
1417
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1418
|
+
body: JSON.stringify({ status }),
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Persist the new order in one call
|
|
1423
|
+
await fetch('/api/tasks/reorder', {
|
|
1424
|
+
method: 'POST',
|
|
1294
1425
|
headers: { 'Content-Type': 'application/json' },
|
|
1295
|
-
body: JSON.stringify({
|
|
1426
|
+
body: JSON.stringify({ taskIds: newOrderedIds }),
|
|
1296
1427
|
});
|
|
1297
1428
|
|
|
1298
1429
|
draggedId = null;
|
|
@@ -1619,6 +1750,48 @@ function initTerminal() {
|
|
|
1619
1750
|
});
|
|
1620
1751
|
}
|
|
1621
1752
|
|
|
1753
|
+
// ── EDIT TASK ─────────────────────────────────────────────────────────────────
|
|
1754
|
+
function openEditTask(id) {
|
|
1755
|
+
const task = allTasks().find(t => t.id === id);
|
|
1756
|
+
if (!task) return;
|
|
1757
|
+
document.getElementById('e-id').value = id;
|
|
1758
|
+
document.getElementById('e-title').value = task.title || '';
|
|
1759
|
+
document.getElementById('e-desc').value = task.description || '';
|
|
1760
|
+
document.getElementById('e-priority').value = task.priority || 'medium';
|
|
1761
|
+
document.getElementById('e-type').value = task.type || 'feature';
|
|
1762
|
+
document.getElementById('editModal').style.display = 'flex';
|
|
1763
|
+
applyTranslations();
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function closeEditTask() {
|
|
1767
|
+
document.getElementById('editModal').style.display = 'none';
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
async function submitEditTask() {
|
|
1771
|
+
const id = document.getElementById('e-id').value;
|
|
1772
|
+
const title = document.getElementById('e-title').value.trim();
|
|
1773
|
+
const description = document.getElementById('e-desc').value.trim();
|
|
1774
|
+
const priority = document.getElementById('e-priority').value;
|
|
1775
|
+
const type = document.getElementById('e-type').value;
|
|
1776
|
+
if (!title) return;
|
|
1777
|
+
|
|
1778
|
+
const priorityOrder = { high: 1, medium: 2, low: 3 };
|
|
1779
|
+
await fetch(`/api/tasks/${id}`, {
|
|
1780
|
+
method: 'PATCH',
|
|
1781
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1782
|
+
body: JSON.stringify({ title, description, priority, type, priority_order: priorityOrder[priority] }),
|
|
1783
|
+
});
|
|
1784
|
+
closeEditTask();
|
|
1785
|
+
loadBoard();
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// ── DELETE TASK ───────────────────────────────────────────────────────────────
|
|
1789
|
+
async function deleteTask(id) {
|
|
1790
|
+
if (!confirm(currentLang === 'es' ? '¿Eliminar esta tarea?' : 'Delete this task?')) return;
|
|
1791
|
+
await fetch(`/api/tasks/${id}`, { method: 'DELETE' });
|
|
1792
|
+
loadBoard();
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1622
1795
|
// ── UTILS ─────────────────────────────────────────────────────────────────────
|
|
1623
1796
|
function esc(s) {
|
|
1624
1797
|
if (!s) return '';
|
|
@@ -1626,7 +1799,7 @@ function esc(s) {
|
|
|
1626
1799
|
}
|
|
1627
1800
|
|
|
1628
1801
|
document.addEventListener('keydown', e => {
|
|
1629
|
-
if (e.key === 'Escape') { closeModal(); closeRetry(); }
|
|
1802
|
+
if (e.key === 'Escape') { closeModal(); closeRetry(); closeEditTask(); }
|
|
1630
1803
|
if ((e.metaKey || e.ctrlKey) && e.key === 'n') { e.preventDefault(); openModal(); }
|
|
1631
1804
|
});
|
|
1632
1805
|
|
|
@@ -1660,6 +1833,7 @@ const STRINGS = {
|
|
|
1660
1833
|
expoIdle:'Expo not started. Click "Start Expo" to install dependencies and launch with tunnel.',
|
|
1661
1834
|
scanWith:'SCAN WITH EXPO GO',
|
|
1662
1835
|
retryCard:'↩ Retry',
|
|
1836
|
+
editTaskTitle:'Edit Task', saveTask:'Save Changes', deleteConfirm:'Delete this task?',
|
|
1663
1837
|
},
|
|
1664
1838
|
es: {
|
|
1665
1839
|
todo:'pendiente', running:'en curso', done:'listo', failed:'fallido',
|
|
@@ -1689,6 +1863,7 @@ const STRINGS = {
|
|
|
1689
1863
|
expoIdle:'Expo no iniciado. Hacé clic en "Iniciar Expo" para instalar dependencias y lanzar con tunnel.',
|
|
1690
1864
|
scanWith:'ESCANEAR CON EXPO GO',
|
|
1691
1865
|
retryCard:'↩ Reintentar',
|
|
1866
|
+
editTaskTitle:'Editar tarea', saveTask:'Guardar cambios', deleteConfirm:'¿Eliminar esta tarea?',
|
|
1692
1867
|
}
|
|
1693
1868
|
};
|
|
1694
1869
|
|
package/dashboard/server.js
CHANGED
|
@@ -287,11 +287,30 @@ app.post("/api/supabase/query", async (req, res) => {
|
|
|
287
287
|
|
|
288
288
|
app.get("/api/board", async (req, res) => {
|
|
289
289
|
const { data: epics } = await supabase
|
|
290
|
-
.from("cb_epics").select("*, cb_tasks(*)")
|
|
290
|
+
.from("cb_epics").select("*, cb_tasks(*)")
|
|
291
|
+
.eq("project", PROJECT).order("created_at");
|
|
292
|
+
|
|
293
|
+
// Orphan tasks (manually created, no epic_id)
|
|
294
|
+
const { data: orphanTasks } = await supabase
|
|
295
|
+
.from("cb_tasks").select("*")
|
|
296
|
+
.eq("project", PROJECT).is("epic_id", null)
|
|
297
|
+
.order("priority_order", { ascending: true });
|
|
298
|
+
|
|
299
|
+
// Sort tasks within each epic by priority_order
|
|
300
|
+
const sortedEpics = (epics || []).map(epic => ({
|
|
301
|
+
...epic,
|
|
302
|
+
cb_tasks: (epic.cb_tasks || []).sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99)),
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
// Orphan tasks appear as a virtual "Manual Tasks" epic
|
|
306
|
+
if (orphanTasks?.length) {
|
|
307
|
+
sortedEpics.push({ id: "manual", name: "— Manual Tasks", cb_tasks: orphanTasks });
|
|
308
|
+
}
|
|
309
|
+
|
|
291
310
|
const { data: logs } = await supabase
|
|
292
311
|
.from("cb_logs").select("*").eq("project", PROJECT)
|
|
293
312
|
.order("created_at", { ascending: false }).limit(50);
|
|
294
|
-
res.json({ epics:
|
|
313
|
+
res.json({ epics: sortedEpics, logs: logs || [], project: PROJECT });
|
|
295
314
|
});
|
|
296
315
|
|
|
297
316
|
app.get("/api/tasks/next", async (req, res) => {
|
|
@@ -350,6 +369,26 @@ app.patch("/api/tasks/:id", async (req, res) => {
|
|
|
350
369
|
res.json({ ok: true });
|
|
351
370
|
});
|
|
352
371
|
|
|
372
|
+
// DELETE a task
|
|
373
|
+
app.delete("/api/tasks/:id", async (req, res) => {
|
|
374
|
+
await supabase.from("cb_logs").delete().eq("task_id", req.params.id);
|
|
375
|
+
await supabase.from("cb_tasks").delete().eq("id", req.params.id);
|
|
376
|
+
broadcast("task_deleted", { id: req.params.id });
|
|
377
|
+
res.json({ ok: true });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// POST reorder — receives ordered array of task ids for a column, renumbers them
|
|
381
|
+
app.post("/api/tasks/reorder", async (req, res) => {
|
|
382
|
+
const { taskIds } = req.body; // array of ids in new order
|
|
383
|
+
for (let i = 0; i < taskIds.length; i++) {
|
|
384
|
+
await supabase.from("cb_tasks")
|
|
385
|
+
.update({ priority_order: i + 1 })
|
|
386
|
+
.eq("id", taskIds[i]);
|
|
387
|
+
}
|
|
388
|
+
broadcast("task_reordered", { taskIds });
|
|
389
|
+
res.json({ ok: true });
|
|
390
|
+
});
|
|
391
|
+
|
|
353
392
|
app.get("/api/tasks/:id/logs", async (req, res) => {
|
|
354
393
|
const { data } = await supabase.from("cb_logs").select("*")
|
|
355
394
|
.eq("task_id", req.params.id).order("created_at");
|