@metyatech/task-tracker 0.1.0 → 0.2.1
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/README.md +8 -1
- package/dist/cli.js +291 -17
- package/package.json +6 -5
- package/public/index.html +515 -0
package/README.md
CHANGED
|
@@ -104,6 +104,13 @@ npm run format:check # Prettier (check)
|
|
|
104
104
|
npm run verify # format:check + lint + build + test
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
## Community
|
|
108
|
+
|
|
109
|
+
- [Security Policy](SECURITY.md)
|
|
110
|
+
- [Contributing](CONTRIBUTING.md)
|
|
111
|
+
- [Code of Conduct](CODE_OF_CONDUCT.md)
|
|
112
|
+
- [Changelog](CHANGELOG.md)
|
|
113
|
+
|
|
107
114
|
## License
|
|
108
115
|
|
|
109
|
-
MIT © metyatech
|
|
116
|
+
MIT © [metyatech](https://github.com/metyatech)
|
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { fileURLToPath } from "url";
|
|
6
|
-
import { dirname as
|
|
7
|
-
import { readFileSync as
|
|
5
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
7
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
8
8
|
|
|
9
9
|
// src/storage.ts
|
|
10
10
|
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from "fs";
|
|
@@ -146,6 +146,30 @@ function removeTask(storagePath, id) {
|
|
|
146
146
|
writeTasks(storagePath, filtered);
|
|
147
147
|
return true;
|
|
148
148
|
}
|
|
149
|
+
function purgeTasks(storagePath, options = {}) {
|
|
150
|
+
const tasks = readTasks(storagePath);
|
|
151
|
+
const doneTasks = tasks.filter((t) => DONE_STAGES.includes(t.stage));
|
|
152
|
+
let toPurge;
|
|
153
|
+
if (options.keep !== void 0) {
|
|
154
|
+
const sorted = [...doneTasks].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
155
|
+
toPurge = sorted.slice(options.keep);
|
|
156
|
+
} else {
|
|
157
|
+
toPurge = doneTasks;
|
|
158
|
+
}
|
|
159
|
+
if (!options.dryRun && toPurge.length > 0) {
|
|
160
|
+
const purgeIds = new Set(toPurge.map((t) => t.id));
|
|
161
|
+
const remaining = tasks.filter((t) => !purgeIds.has(t.id));
|
|
162
|
+
writeTasks(storagePath, remaining);
|
|
163
|
+
}
|
|
164
|
+
return { purged: toPurge, count: toPurge.length };
|
|
165
|
+
}
|
|
166
|
+
function autoPurgeTasks(storagePath, options = {}) {
|
|
167
|
+
const keep = options.keep ?? 20;
|
|
168
|
+
if (keep === 0) {
|
|
169
|
+
return { purged: [], count: 0 };
|
|
170
|
+
}
|
|
171
|
+
return purgeTasks(storagePath, { keep });
|
|
172
|
+
}
|
|
149
173
|
|
|
150
174
|
// src/format.ts
|
|
151
175
|
import chalk from "chalk";
|
|
@@ -215,13 +239,233 @@ function formatCheckReport(activeTasks, repoStatus) {
|
|
|
215
239
|
return lines.join("\n");
|
|
216
240
|
}
|
|
217
241
|
|
|
218
|
-
// src/
|
|
219
|
-
import
|
|
242
|
+
// src/gui.ts
|
|
243
|
+
import { createServer } from "http";
|
|
244
|
+
import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
|
|
245
|
+
import { join as join3, dirname as dirname2, resolve } from "path";
|
|
246
|
+
import { fileURLToPath } from "url";
|
|
247
|
+
import { exec } from "child_process";
|
|
248
|
+
|
|
249
|
+
// src/scanner.ts
|
|
250
|
+
import { existsSync as existsSync2, readdirSync, statSync } from "fs";
|
|
251
|
+
import { join as join2, basename } from "path";
|
|
252
|
+
function scanTaskFiles(dir) {
|
|
253
|
+
const rootFile = join2(dir, ".tasks.jsonl");
|
|
254
|
+
const root = existsSync2(rootFile) ? { path: rootFile, dir, name: basename(dir) } : null;
|
|
255
|
+
const repos = [];
|
|
256
|
+
try {
|
|
257
|
+
const entries = readdirSync(dir);
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
if (entry.startsWith(".")) continue;
|
|
260
|
+
const subDir = join2(dir, entry);
|
|
261
|
+
try {
|
|
262
|
+
if (statSync(subDir).isDirectory()) {
|
|
263
|
+
const taskFile = join2(subDir, ".tasks.jsonl");
|
|
264
|
+
if (existsSync2(taskFile)) {
|
|
265
|
+
repos.push({ path: taskFile, dir: subDir, name: entry });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
}
|
|
273
|
+
return { root, repos };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/gui.ts
|
|
220
277
|
var __filename = fileURLToPath(import.meta.url);
|
|
221
278
|
var __dirname = dirname2(__filename);
|
|
279
|
+
function getHtmlPath() {
|
|
280
|
+
const candidates = [
|
|
281
|
+
join3(__dirname, "public", "index.html"),
|
|
282
|
+
join3(__dirname, "..", "public", "index.html")
|
|
283
|
+
];
|
|
284
|
+
for (const p of candidates) {
|
|
285
|
+
if (existsSync3(p)) return p;
|
|
286
|
+
}
|
|
287
|
+
throw new Error("Could not find public/index.html. Make sure the public/ directory exists.");
|
|
288
|
+
}
|
|
289
|
+
function parseBody(req) {
|
|
290
|
+
return new Promise((resolve2, reject) => {
|
|
291
|
+
let body = "";
|
|
292
|
+
req.on("data", (chunk) => {
|
|
293
|
+
body += chunk.toString();
|
|
294
|
+
});
|
|
295
|
+
req.on("end", () => {
|
|
296
|
+
try {
|
|
297
|
+
resolve2(body ? JSON.parse(body) : {});
|
|
298
|
+
} catch {
|
|
299
|
+
reject(new Error("Invalid JSON body"));
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
req.on("error", reject);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
function sendJson(res, data, status = 200) {
|
|
306
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
307
|
+
res.end(JSON.stringify(data));
|
|
308
|
+
}
|
|
309
|
+
function sendError(res, message, status = 400) {
|
|
310
|
+
sendJson(res, { error: message }, status);
|
|
311
|
+
}
|
|
312
|
+
function tryListen(server, port, maxAttempts = 10) {
|
|
313
|
+
return new Promise((resolve2, reject) => {
|
|
314
|
+
const onError = (err) => {
|
|
315
|
+
server.removeListener("error", onError);
|
|
316
|
+
if (err.code === "EADDRINUSE" && maxAttempts > 1) {
|
|
317
|
+
tryListen(server, port + 1, maxAttempts - 1).then(resolve2, reject);
|
|
318
|
+
} else {
|
|
319
|
+
reject(err);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
server.once("error", onError);
|
|
323
|
+
server.listen(port, "127.0.0.1", () => {
|
|
324
|
+
server.removeListener("error", onError);
|
|
325
|
+
resolve2(port);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
function openBrowser(url) {
|
|
330
|
+
let cmd;
|
|
331
|
+
if (process.platform === "win32") {
|
|
332
|
+
cmd = `start "" "${url}"`;
|
|
333
|
+
} else if (process.platform === "darwin") {
|
|
334
|
+
cmd = `open "${url}"`;
|
|
335
|
+
} else {
|
|
336
|
+
cmd = `xdg-open "${url}"`;
|
|
337
|
+
}
|
|
338
|
+
exec(cmd, (err) => {
|
|
339
|
+
if (err) {
|
|
340
|
+
console.log(`Could not open browser automatically. Visit: ${url}`);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
function startGui(dir, port = 3333) {
|
|
345
|
+
const resolvedDir = resolve(dir);
|
|
346
|
+
const server = createServer((req, res) => {
|
|
347
|
+
void (async () => {
|
|
348
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
349
|
+
const method = req.method ?? "GET";
|
|
350
|
+
const pathname = url.pathname;
|
|
351
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
352
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
353
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
354
|
+
if (method === "OPTIONS") {
|
|
355
|
+
res.writeHead(204);
|
|
356
|
+
res.end();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
if (pathname === "/" && method === "GET") {
|
|
361
|
+
const html = readFileSync2(getHtmlPath(), "utf-8");
|
|
362
|
+
const injected = html.replace("__GUI_DIR__", JSON.stringify(resolvedDir));
|
|
363
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
364
|
+
res.end(injected);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (pathname === "/api/tasks" && method === "GET") {
|
|
368
|
+
const queryDir = url.searchParams.get("dir") ?? resolvedDir;
|
|
369
|
+
const scan = scanTaskFiles(queryDir);
|
|
370
|
+
const result = {
|
|
371
|
+
root: scan.root ? {
|
|
372
|
+
path: scan.root.path,
|
|
373
|
+
dir: scan.root.dir,
|
|
374
|
+
name: scan.root.name,
|
|
375
|
+
tasks: readTasks(scan.root.path)
|
|
376
|
+
} : null,
|
|
377
|
+
repos: scan.repos.map((r) => ({
|
|
378
|
+
path: r.path,
|
|
379
|
+
dir: r.dir,
|
|
380
|
+
name: r.name,
|
|
381
|
+
tasks: readTasks(r.path)
|
|
382
|
+
}))
|
|
383
|
+
};
|
|
384
|
+
sendJson(res, result);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (pathname === "/api/tasks/purge" && method === "POST") {
|
|
388
|
+
const body = await parseBody(req);
|
|
389
|
+
const targetDir = typeof body.dir === "string" ? body.dir : resolvedDir;
|
|
390
|
+
const taskFile = join3(targetDir, ".tasks.jsonl");
|
|
391
|
+
const result = purgeTasks(taskFile);
|
|
392
|
+
sendJson(res, { count: result.count, ids: result.purged.map((t) => t.id) });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (pathname === "/api/tasks" && method === "POST") {
|
|
396
|
+
const body = await parseBody(req);
|
|
397
|
+
if (typeof body.description !== "string" || !body.description) {
|
|
398
|
+
sendError(res, "description is required");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const targetDir = typeof body.dir === "string" ? body.dir : resolvedDir;
|
|
402
|
+
const taskFile = join3(targetDir, ".tasks.jsonl");
|
|
403
|
+
const stage = typeof body.stage === "string" && STAGES.includes(body.stage) ? body.stage : void 0;
|
|
404
|
+
const task = createTask(taskFile, body.description, { stage });
|
|
405
|
+
sendJson(res, task, 201);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const taskIdMatch = pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
409
|
+
if (taskIdMatch) {
|
|
410
|
+
const id = taskIdMatch[1];
|
|
411
|
+
if (method === "PUT") {
|
|
412
|
+
const body = await parseBody(req);
|
|
413
|
+
const targetDir = typeof body.dir === "string" ? body.dir : resolvedDir;
|
|
414
|
+
const taskFile = join3(targetDir, ".tasks.jsonl");
|
|
415
|
+
const updates = {};
|
|
416
|
+
if (typeof body.stage === "string" && STAGES.includes(body.stage)) {
|
|
417
|
+
updates.stage = body.stage;
|
|
418
|
+
}
|
|
419
|
+
if (typeof body.description === "string" && body.description) {
|
|
420
|
+
updates.description = body.description;
|
|
421
|
+
}
|
|
422
|
+
const task = updateTask(taskFile, id, updates);
|
|
423
|
+
if (!task) {
|
|
424
|
+
sendError(res, "Task not found", 404);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
sendJson(res, task);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (method === "DELETE") {
|
|
431
|
+
const queryDir = url.searchParams.get("dir") ?? resolvedDir;
|
|
432
|
+
const taskFile = join3(queryDir, ".tasks.jsonl");
|
|
433
|
+
const removed = removeTask(taskFile, id);
|
|
434
|
+
if (!removed) {
|
|
435
|
+
sendError(res, "Task not found", 404);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
sendJson(res, { removed: true });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
443
|
+
res.end("Not found");
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.error("[gui] Request error:", err);
|
|
446
|
+
sendError(res, "Internal server error", 500);
|
|
447
|
+
}
|
|
448
|
+
})();
|
|
449
|
+
});
|
|
450
|
+
tryListen(server, port).then((actualPort) => {
|
|
451
|
+
const url = `http://localhost:${actualPort}`;
|
|
452
|
+
console.log(`Task Tracker GUI running at ${url}`);
|
|
453
|
+
console.log(`Watching: ${resolvedDir}`);
|
|
454
|
+
console.log("Press Ctrl+C to stop.");
|
|
455
|
+
openBrowser(url);
|
|
456
|
+
}).catch((err) => {
|
|
457
|
+
console.error("Failed to start GUI server:", err.message);
|
|
458
|
+
process.exit(1);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// src/cli.ts
|
|
463
|
+
import process2 from "process";
|
|
464
|
+
var __filename2 = fileURLToPath2(import.meta.url);
|
|
465
|
+
var __dirname2 = dirname3(__filename2);
|
|
222
466
|
var version = "0.0.0";
|
|
223
467
|
try {
|
|
224
|
-
const pkg = JSON.parse(
|
|
468
|
+
const pkg = JSON.parse(readFileSync3(join4(__dirname2, "..", "package.json"), "utf-8"));
|
|
225
469
|
version = pkg.version;
|
|
226
470
|
} catch {
|
|
227
471
|
}
|
|
@@ -233,7 +477,7 @@ function getStorage() {
|
|
|
233
477
|
return getStoragePath();
|
|
234
478
|
} catch (e) {
|
|
235
479
|
console.error(e instanceof Error ? e.message : "Not in a git repository");
|
|
236
|
-
|
|
480
|
+
process2.exit(1);
|
|
237
481
|
}
|
|
238
482
|
}
|
|
239
483
|
var program = new Command();
|
|
@@ -242,7 +486,7 @@ program.command("add <description>").description("Add a new task").option("--sta
|
|
|
242
486
|
if (!isValidStage(opts.stage)) {
|
|
243
487
|
console.error(`Invalid stage: ${opts.stage}
|
|
244
488
|
Valid stages: ${STAGES.join(", ")}`);
|
|
245
|
-
|
|
489
|
+
process2.exit(1);
|
|
246
490
|
}
|
|
247
491
|
const task = createTask(getStorage(), description, { stage: opts.stage });
|
|
248
492
|
if (opts.json) {
|
|
@@ -256,7 +500,7 @@ program.command("list").description("List tasks").option("--all", "Include compl
|
|
|
256
500
|
if (opts.stage && !stage) {
|
|
257
501
|
console.error(`Invalid stage: ${opts.stage}
|
|
258
502
|
Valid stages: ${STAGES.join(", ")}`);
|
|
259
|
-
|
|
503
|
+
process2.exit(1);
|
|
260
504
|
}
|
|
261
505
|
const tasks = listTasks(getStorage(), { all: opts.all, stage });
|
|
262
506
|
if (opts.json) {
|
|
@@ -269,7 +513,7 @@ program.command("update <id>").description("Update a task").option("--stage <sta
|
|
|
269
513
|
if (opts.stage && !isValidStage(opts.stage)) {
|
|
270
514
|
console.error(`Invalid stage: ${opts.stage}
|
|
271
515
|
Valid stages: ${STAGES.join(", ")}`);
|
|
272
|
-
|
|
516
|
+
process2.exit(1);
|
|
273
517
|
}
|
|
274
518
|
const updates = {};
|
|
275
519
|
if (opts.stage) updates.stage = opts.stage;
|
|
@@ -277,7 +521,7 @@ Valid stages: ${STAGES.join(", ")}`);
|
|
|
277
521
|
const task = updateTask(getStorage(), id, updates);
|
|
278
522
|
if (!task) {
|
|
279
523
|
console.error(`Task not found: ${id}`);
|
|
280
|
-
|
|
524
|
+
process2.exit(1);
|
|
281
525
|
}
|
|
282
526
|
if (opts.json) {
|
|
283
527
|
console.log(JSON.stringify(task, null, 2));
|
|
@@ -285,25 +529,30 @@ Valid stages: ${STAGES.join(", ")}`);
|
|
|
285
529
|
console.log("Updated: " + formatTask(task));
|
|
286
530
|
}
|
|
287
531
|
});
|
|
288
|
-
program.command("done <id>").description("Mark task as done").action((id) => {
|
|
289
|
-
const
|
|
532
|
+
program.command("done <id>").description("Mark task as done").option("--keep <n>", "Auto-purge: keep N most recent done tasks (0=disabled)", "20").action((id, opts) => {
|
|
533
|
+
const storage = getStorage();
|
|
534
|
+
const task = updateTask(storage, id, { stage: "done" });
|
|
290
535
|
if (!task) {
|
|
291
536
|
console.error(`Task not found: ${id}`);
|
|
292
|
-
|
|
537
|
+
process2.exit(1);
|
|
293
538
|
}
|
|
294
539
|
console.log("Done: " + formatTask(task));
|
|
540
|
+
const keep = parseInt(opts.keep, 10);
|
|
541
|
+
if (!isNaN(keep)) {
|
|
542
|
+
autoPurgeTasks(storage, { keep });
|
|
543
|
+
}
|
|
295
544
|
});
|
|
296
545
|
program.command("remove <id>").description("Remove a task permanently").action((id) => {
|
|
297
546
|
const removed = removeTask(getStorage(), id);
|
|
298
547
|
if (!removed) {
|
|
299
548
|
console.error(`Task not found: ${id}`);
|
|
300
|
-
|
|
549
|
+
process2.exit(1);
|
|
301
550
|
}
|
|
302
551
|
console.log(`Removed task: ${id}`);
|
|
303
552
|
});
|
|
304
553
|
program.command("check").description("Show active tasks and git status for this repo").option("--json", "JSON output").action((opts) => {
|
|
305
554
|
const storage = getStorage();
|
|
306
|
-
const repoRoot =
|
|
555
|
+
const repoRoot = dirname3(storage);
|
|
307
556
|
const activeTasks = listTasks(storage, { all: false });
|
|
308
557
|
const repoStatus = getRepoStatus(repoRoot);
|
|
309
558
|
if (opts.json) {
|
|
@@ -312,4 +561,29 @@ program.command("check").description("Show active tasks and git status for this
|
|
|
312
561
|
console.log(formatCheckReport(activeTasks, repoStatus));
|
|
313
562
|
}
|
|
314
563
|
});
|
|
315
|
-
program.
|
|
564
|
+
program.command("purge").description("Remove all done tasks from storage").option("--dry-run", "Show what would be removed without removing").option("--keep <n>", "Keep N most recent done tasks, purge the rest").option("--json", "JSON output").action((opts) => {
|
|
565
|
+
const keep = opts.keep !== void 0 ? parseInt(opts.keep, 10) : void 0;
|
|
566
|
+
const result = purgeTasks(getStorage(), { dryRun: opts.dryRun, keep });
|
|
567
|
+
const ids = result.purged.map((t) => t.id);
|
|
568
|
+
if (opts.json) {
|
|
569
|
+
console.log(JSON.stringify({ count: result.count, ids }));
|
|
570
|
+
} else if (opts.dryRun) {
|
|
571
|
+
if (result.count === 0) {
|
|
572
|
+
console.log("No done tasks to purge.");
|
|
573
|
+
} else {
|
|
574
|
+
console.log(`Would purge ${result.count} task(s): ${ids.join(", ")}`);
|
|
575
|
+
}
|
|
576
|
+
} else {
|
|
577
|
+
if (result.count === 0) {
|
|
578
|
+
console.log("No done tasks to purge.");
|
|
579
|
+
} else {
|
|
580
|
+
console.log(`Purged ${result.count} task(s): ${ids.join(", ")}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
program.command("gui [dir]").description("Start the web GUI (defaults to current directory)").option("--port <port>", "Port to listen on", "3333").action((dir, opts) => {
|
|
585
|
+
const targetDir = dir ?? process2.cwd();
|
|
586
|
+
const port = parseInt(opts.port, 10) || 3333;
|
|
587
|
+
startGui(targetDir, port);
|
|
588
|
+
});
|
|
589
|
+
program.parse(process2.argv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metyatech/task-tracker",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Persistent task lifecycle tracker for AI agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
|
+
"public",
|
|
11
12
|
"LICENSE",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
"scripts": {
|
|
29
30
|
"build": "tsup",
|
|
30
31
|
"test": "vitest run",
|
|
31
|
-
"lint": "eslint src
|
|
32
|
+
"lint": "eslint src",
|
|
32
33
|
"format": "prettier --write \"src/**/*.ts\" \"*.json\" \"*.md\"",
|
|
33
34
|
"format:check": "prettier --check \"src/**/*.ts\" \"*.json\" \"*.md\"",
|
|
34
35
|
"verify": "npm run format:check && npm run lint && npm run build && npm run test"
|
|
@@ -39,13 +40,13 @@
|
|
|
39
40
|
"nanoid": "^5.0.7"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
43
|
+
"@eslint/js": "^9.0.0",
|
|
42
44
|
"@types/node": "^20.0.0",
|
|
43
|
-
"
|
|
44
|
-
"@typescript-eslint/parser": "^7.0.0",
|
|
45
|
-
"eslint": "^8.57.0",
|
|
45
|
+
"eslint": "^9.0.0",
|
|
46
46
|
"prettier": "^3.2.0",
|
|
47
47
|
"tsup": "^8.0.0",
|
|
48
48
|
"typescript": "^5.4.0",
|
|
49
|
+
"typescript-eslint": "^8.0.0",
|
|
49
50
|
"vitest": "^1.4.0"
|
|
50
51
|
}
|
|
51
52
|
}
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Task Tracker</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<style>
|
|
9
|
+
.stage-cell {
|
|
10
|
+
width: 18px;
|
|
11
|
+
height: 14px;
|
|
12
|
+
border-radius: 2px;
|
|
13
|
+
flex-shrink: 0;
|
|
14
|
+
transition: opacity 0.1s;
|
|
15
|
+
}
|
|
16
|
+
.task-desc-input {
|
|
17
|
+
background: #374151;
|
|
18
|
+
color: #f3f4f6;
|
|
19
|
+
border: 1px solid #6b7280;
|
|
20
|
+
border-radius: 4px;
|
|
21
|
+
padding: 1px 6px;
|
|
22
|
+
font-size: 0.875rem;
|
|
23
|
+
width: 100%;
|
|
24
|
+
outline: none;
|
|
25
|
+
}
|
|
26
|
+
.task-desc-input:focus {
|
|
27
|
+
border-color: #3b82f6;
|
|
28
|
+
}
|
|
29
|
+
.stage-picker {
|
|
30
|
+
position: absolute;
|
|
31
|
+
z-index: 50;
|
|
32
|
+
top: calc(100% + 4px);
|
|
33
|
+
left: 0;
|
|
34
|
+
background: #1f2937;
|
|
35
|
+
border: 1px solid #4b5563;
|
|
36
|
+
border-radius: 6px;
|
|
37
|
+
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
|
38
|
+
padding: 4px;
|
|
39
|
+
min-width: 150px;
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
gap: 1px;
|
|
43
|
+
}
|
|
44
|
+
.stage-picker-btn {
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: 8px;
|
|
48
|
+
padding: 5px 8px;
|
|
49
|
+
border-radius: 4px;
|
|
50
|
+
font-size: 0.75rem;
|
|
51
|
+
text-align: left;
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
color: #d1d5db;
|
|
54
|
+
background: transparent;
|
|
55
|
+
border: none;
|
|
56
|
+
width: 100%;
|
|
57
|
+
}
|
|
58
|
+
.stage-picker-btn:hover {
|
|
59
|
+
background: #374151;
|
|
60
|
+
}
|
|
61
|
+
.stage-picker-btn.active {
|
|
62
|
+
background: #374151;
|
|
63
|
+
font-weight: 600;
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body class="bg-gray-900 text-gray-100 min-h-screen" style="font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;">
|
|
68
|
+
<!-- Header -->
|
|
69
|
+
<header class="sticky top-0 z-10 bg-gray-950 border-b border-gray-700 px-4 py-3 flex items-center gap-3 flex-wrap" style="background:#030712;">
|
|
70
|
+
<h1 class="font-bold text-white text-base">Task Tracker</h1>
|
|
71
|
+
<span id="dir-label" class="text-gray-500 text-xs truncate flex-1" style="min-width:0;"></span>
|
|
72
|
+
<div class="flex items-center gap-2 flex-shrink-0">
|
|
73
|
+
<label class="text-xs text-gray-500" for="tz-select">TZ:</label>
|
|
74
|
+
<select id="tz-select" class="bg-gray-800 text-gray-300 text-xs rounded px-2 py-1 border border-gray-700 focus:outline-none focus:border-blue-500"></select>
|
|
75
|
+
</div>
|
|
76
|
+
<button onclick="loadTasks()" class="text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 px-2 py-1 rounded flex-shrink-0">↻ Refresh</button>
|
|
77
|
+
</header>
|
|
78
|
+
|
|
79
|
+
<!-- Main -->
|
|
80
|
+
<main class="max-w-5xl mx-auto px-4 py-6 space-y-6" id="main">
|
|
81
|
+
<p class="text-gray-500 text-sm text-center py-16">Loading...</p>
|
|
82
|
+
</main>
|
|
83
|
+
|
|
84
|
+
<script>
|
|
85
|
+
// Injected by server
|
|
86
|
+
const GUI_DIR = __GUI_DIR__;
|
|
87
|
+
|
|
88
|
+
const STAGES = [
|
|
89
|
+
'pending','in-progress','implemented','verified','committed',
|
|
90
|
+
'pushed','pr-created','merged','released','published','done'
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const STAGE_COLORS = {
|
|
94
|
+
'pending': '#6b7280',
|
|
95
|
+
'in-progress': '#3b82f6',
|
|
96
|
+
'implemented': '#06b6d4',
|
|
97
|
+
'verified': '#eab308',
|
|
98
|
+
'committed': '#a855f7',
|
|
99
|
+
'pushed': '#22c55e',
|
|
100
|
+
'pr-created': '#84cc16',
|
|
101
|
+
'merged': '#16a34a',
|
|
102
|
+
'released': '#38bdf8',
|
|
103
|
+
'published': '#4ade80',
|
|
104
|
+
'done': '#374151',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// ── Timezone setup ─────────────────────────────────────────────
|
|
108
|
+
const tzSelect = document.getElementById('tz-select');
|
|
109
|
+
let currentTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const zones = Intl.supportedValuesOf('timeZone');
|
|
113
|
+
zones.forEach(tz => {
|
|
114
|
+
const opt = document.createElement('option');
|
|
115
|
+
opt.value = tz;
|
|
116
|
+
opt.textContent = tz;
|
|
117
|
+
if (tz === currentTz) opt.selected = true;
|
|
118
|
+
tzSelect.appendChild(opt);
|
|
119
|
+
});
|
|
120
|
+
} catch {
|
|
121
|
+
const opt = document.createElement('option');
|
|
122
|
+
opt.value = currentTz;
|
|
123
|
+
opt.textContent = currentTz;
|
|
124
|
+
opt.selected = true;
|
|
125
|
+
tzSelect.appendChild(opt);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
tzSelect.addEventListener('change', () => {
|
|
129
|
+
currentTz = tzSelect.value;
|
|
130
|
+
renderAll(lastData);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
document.getElementById('dir-label').textContent = GUI_DIR;
|
|
134
|
+
|
|
135
|
+
// ── Helpers ────────────────────────────────────────────────────
|
|
136
|
+
function formatDate(iso) {
|
|
137
|
+
if (!iso) return '';
|
|
138
|
+
try {
|
|
139
|
+
return new Date(iso).toLocaleString(undefined, {
|
|
140
|
+
timeZone: currentTz,
|
|
141
|
+
dateStyle: 'short',
|
|
142
|
+
timeStyle: 'short',
|
|
143
|
+
});
|
|
144
|
+
} catch {
|
|
145
|
+
return iso;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stageIndex(stage) {
|
|
150
|
+
return STAGES.indexOf(stage);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Stage gauge ────────────────────────────────────────────────
|
|
154
|
+
let activeDropdown = null;
|
|
155
|
+
|
|
156
|
+
function makeGauge(task, repoDir) {
|
|
157
|
+
const idx = stageIndex(task.stage);
|
|
158
|
+
const wrap = document.createElement('div');
|
|
159
|
+
wrap.style.cssText = 'position:relative; display:inline-flex; gap:2px; cursor:pointer;';
|
|
160
|
+
wrap.title = 'Click to change stage';
|
|
161
|
+
|
|
162
|
+
STAGES.forEach((s, i) => {
|
|
163
|
+
const cell = document.createElement('div');
|
|
164
|
+
cell.className = 'stage-cell';
|
|
165
|
+
cell.style.backgroundColor = STAGE_COLORS[s];
|
|
166
|
+
cell.style.opacity = i <= idx ? '1' : '0.15';
|
|
167
|
+
cell.title = s;
|
|
168
|
+
wrap.appendChild(cell);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
wrap.addEventListener('click', (e) => {
|
|
172
|
+
e.stopPropagation();
|
|
173
|
+
showStagePicker(wrap, task, repoDir);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return wrap;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function showStagePicker(anchor, task, repoDir) {
|
|
180
|
+
if (activeDropdown) {
|
|
181
|
+
activeDropdown.remove();
|
|
182
|
+
activeDropdown = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const picker = document.createElement('div');
|
|
186
|
+
picker.className = 'stage-picker';
|
|
187
|
+
|
|
188
|
+
STAGES.forEach(s => {
|
|
189
|
+
const btn = document.createElement('button');
|
|
190
|
+
btn.className = 'stage-picker-btn' + (s === task.stage ? ' active' : '');
|
|
191
|
+
|
|
192
|
+
const dot = document.createElement('span');
|
|
193
|
+
dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:50%;background:${STAGE_COLORS[s]};flex-shrink:0;`;
|
|
194
|
+
|
|
195
|
+
const label = document.createElement('span');
|
|
196
|
+
label.textContent = s;
|
|
197
|
+
if (s === task.stage) label.style.color = STAGE_COLORS[s];
|
|
198
|
+
|
|
199
|
+
btn.appendChild(dot);
|
|
200
|
+
btn.appendChild(label);
|
|
201
|
+
|
|
202
|
+
btn.addEventListener('click', async () => {
|
|
203
|
+
picker.remove();
|
|
204
|
+
activeDropdown = null;
|
|
205
|
+
await fetch(`/api/tasks/${task.id}`, {
|
|
206
|
+
method: 'PUT',
|
|
207
|
+
headers: { 'Content-Type': 'application/json' },
|
|
208
|
+
body: JSON.stringify({ dir: repoDir, stage: s }),
|
|
209
|
+
});
|
|
210
|
+
loadTasks();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
picker.appendChild(btn);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
anchor.appendChild(picker);
|
|
217
|
+
activeDropdown = picker;
|
|
218
|
+
|
|
219
|
+
const closeHandler = (e) => {
|
|
220
|
+
if (!picker.contains(e.target)) {
|
|
221
|
+
picker.remove();
|
|
222
|
+
if (activeDropdown === picker) activeDropdown = null;
|
|
223
|
+
document.removeEventListener('click', closeHandler);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Task row ───────────────────────────────────────────────────
|
|
230
|
+
function makeTaskRow(task, repoDir) {
|
|
231
|
+
const row = document.createElement('div');
|
|
232
|
+
row.style.cssText = 'background:#1f2937;border:1px solid #374151;border-radius:6px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;';
|
|
233
|
+
|
|
234
|
+
// Top row: gauge + stage badge + delete button
|
|
235
|
+
const topRow = document.createElement('div');
|
|
236
|
+
topRow.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;';
|
|
237
|
+
|
|
238
|
+
const gauge = makeGauge(task, repoDir);
|
|
239
|
+
|
|
240
|
+
const stageBadge = document.createElement('span');
|
|
241
|
+
stageBadge.style.cssText = `font-size:0.7rem;padding:1px 6px;border-radius:4px;background:${STAGE_COLORS[task.stage]}22;color:${STAGE_COLORS[task.stage]};border:1px solid ${STAGE_COLORS[task.stage]}44;flex-shrink:0;`;
|
|
242
|
+
stageBadge.textContent = task.stage;
|
|
243
|
+
|
|
244
|
+
const spacer = document.createElement('div');
|
|
245
|
+
spacer.style.flex = '1';
|
|
246
|
+
|
|
247
|
+
const deleteBtn = document.createElement('button');
|
|
248
|
+
deleteBtn.style.cssText = 'color:#6b7280;font-size:1rem;line-height:1;padding:0 4px;background:none;border:none;cursor:pointer;flex-shrink:0;';
|
|
249
|
+
deleteBtn.textContent = '×';
|
|
250
|
+
deleteBtn.title = 'Delete task';
|
|
251
|
+
deleteBtn.onmouseenter = () => { deleteBtn.style.color = '#f87171'; };
|
|
252
|
+
deleteBtn.onmouseleave = () => { deleteBtn.style.color = '#6b7280'; };
|
|
253
|
+
deleteBtn.addEventListener('click', async (e) => {
|
|
254
|
+
e.stopPropagation();
|
|
255
|
+
if (confirm(`Delete task "${task.description}"?`)) {
|
|
256
|
+
await fetch(`/api/tasks/${task.id}?dir=${encodeURIComponent(repoDir)}`, { method: 'DELETE' });
|
|
257
|
+
loadTasks();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
topRow.appendChild(gauge);
|
|
262
|
+
topRow.appendChild(stageBadge);
|
|
263
|
+
topRow.appendChild(spacer);
|
|
264
|
+
topRow.appendChild(deleteBtn);
|
|
265
|
+
|
|
266
|
+
// Description row
|
|
267
|
+
const descEl = document.createElement('span');
|
|
268
|
+
descEl.style.cssText = 'font-size:0.875rem;color:#f3f4f6;cursor:pointer;display:block;';
|
|
269
|
+
descEl.textContent = task.description;
|
|
270
|
+
descEl.title = 'Click to edit description';
|
|
271
|
+
descEl.addEventListener('click', () => {
|
|
272
|
+
const input = document.createElement('input');
|
|
273
|
+
input.type = 'text';
|
|
274
|
+
input.value = task.description;
|
|
275
|
+
input.className = 'task-desc-input';
|
|
276
|
+
descEl.replaceWith(input);
|
|
277
|
+
input.focus();
|
|
278
|
+
input.select();
|
|
279
|
+
const save = async () => {
|
|
280
|
+
const newDesc = input.value.trim();
|
|
281
|
+
if (newDesc && newDesc !== task.description) {
|
|
282
|
+
await fetch(`/api/tasks/${task.id}`, {
|
|
283
|
+
method: 'PUT',
|
|
284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
285
|
+
body: JSON.stringify({ dir: repoDir, description: newDesc }),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
loadTasks();
|
|
289
|
+
};
|
|
290
|
+
input.addEventListener('blur', save);
|
|
291
|
+
input.addEventListener('keydown', (e) => {
|
|
292
|
+
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
|
293
|
+
if (e.key === 'Escape') { loadTasks(); }
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Meta row
|
|
298
|
+
const meta = document.createElement('div');
|
|
299
|
+
meta.style.cssText = 'font-size:0.7rem;color:#6b7280;';
|
|
300
|
+
meta.textContent = `#${task.id} · created ${formatDate(task.createdAt)} · updated ${formatDate(task.updatedAt)}`;
|
|
301
|
+
|
|
302
|
+
row.appendChild(topRow);
|
|
303
|
+
row.appendChild(descEl);
|
|
304
|
+
row.appendChild(meta);
|
|
305
|
+
|
|
306
|
+
return row;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Add-task form ──────────────────────────────────────────────
|
|
310
|
+
function toggleAddForm(sectionEl, info) {
|
|
311
|
+
let formDiv = sectionEl.querySelector('.add-form-container');
|
|
312
|
+
|
|
313
|
+
if (formDiv && !formDiv.classList.contains('hidden')) {
|
|
314
|
+
formDiv.classList.add('hidden');
|
|
315
|
+
formDiv.innerHTML = '';
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!formDiv) {
|
|
320
|
+
formDiv = document.createElement('div');
|
|
321
|
+
formDiv.className = 'add-form-container';
|
|
322
|
+
formDiv.style.cssText = 'border-bottom:1px solid #374151;padding:12px 16px;background:#0f172a;';
|
|
323
|
+
const header = sectionEl.querySelector('.section-header');
|
|
324
|
+
header.insertAdjacentElement('afterend', formDiv);
|
|
325
|
+
} else {
|
|
326
|
+
formDiv.classList.remove('hidden');
|
|
327
|
+
formDiv.innerHTML = '';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const form = document.createElement('div');
|
|
331
|
+
form.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;';
|
|
332
|
+
|
|
333
|
+
const input = document.createElement('input');
|
|
334
|
+
input.type = 'text';
|
|
335
|
+
input.placeholder = 'Task description...';
|
|
336
|
+
input.style.cssText = 'flex:1;min-width:180px;background:#374151;color:#f3f4f6;border:1px solid #6b7280;border-radius:4px;padding:6px 10px;font-size:0.875rem;outline:none;';
|
|
337
|
+
input.addEventListener('focus', () => { input.style.borderColor = '#3b82f6'; });
|
|
338
|
+
input.addEventListener('blur', () => { input.style.borderColor = '#6b7280'; });
|
|
339
|
+
|
|
340
|
+
const stageSelect = document.createElement('select');
|
|
341
|
+
stageSelect.style.cssText = 'background:#374151;color:#d1d5db;border:1px solid #6b7280;border-radius:4px;padding:6px 8px;font-size:0.875rem;outline:none;';
|
|
342
|
+
STAGES.forEach(s => {
|
|
343
|
+
const opt = document.createElement('option');
|
|
344
|
+
opt.value = s;
|
|
345
|
+
opt.textContent = s;
|
|
346
|
+
if (s === 'pending') opt.selected = true;
|
|
347
|
+
stageSelect.appendChild(opt);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const addBtn = document.createElement('button');
|
|
351
|
+
addBtn.textContent = 'Add';
|
|
352
|
+
addBtn.style.cssText = 'background:#2563eb;color:#fff;border:none;border-radius:4px;padding:6px 14px;font-size:0.875rem;cursor:pointer;flex-shrink:0;';
|
|
353
|
+
addBtn.onmouseenter = () => { addBtn.style.background = '#1d4ed8'; };
|
|
354
|
+
addBtn.onmouseleave = () => { addBtn.style.background = '#2563eb'; };
|
|
355
|
+
|
|
356
|
+
const cancelBtn = document.createElement('button');
|
|
357
|
+
cancelBtn.textContent = 'Cancel';
|
|
358
|
+
cancelBtn.style.cssText = 'background:#374151;color:#d1d5db;border:none;border-radius:4px;padding:6px 14px;font-size:0.875rem;cursor:pointer;flex-shrink:0;';
|
|
359
|
+
cancelBtn.onmouseenter = () => { cancelBtn.style.background = '#4b5563'; };
|
|
360
|
+
cancelBtn.onmouseleave = () => { cancelBtn.style.background = '#374151'; };
|
|
361
|
+
|
|
362
|
+
const doAdd = async () => {
|
|
363
|
+
const desc = input.value.trim();
|
|
364
|
+
if (!desc) { input.focus(); return; }
|
|
365
|
+
await fetch('/api/tasks', {
|
|
366
|
+
method: 'POST',
|
|
367
|
+
headers: { 'Content-Type': 'application/json' },
|
|
368
|
+
body: JSON.stringify({ dir: info.dir, description: desc, stage: stageSelect.value }),
|
|
369
|
+
});
|
|
370
|
+
formDiv.classList.add('hidden');
|
|
371
|
+
formDiv.innerHTML = '';
|
|
372
|
+
loadTasks();
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
addBtn.addEventListener('click', doAdd);
|
|
376
|
+
cancelBtn.addEventListener('click', () => {
|
|
377
|
+
formDiv.classList.add('hidden');
|
|
378
|
+
formDiv.innerHTML = '';
|
|
379
|
+
});
|
|
380
|
+
input.addEventListener('keydown', (e) => {
|
|
381
|
+
if (e.key === 'Enter') doAdd();
|
|
382
|
+
if (e.key === 'Escape') cancelBtn.click();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
form.appendChild(input);
|
|
386
|
+
form.appendChild(stageSelect);
|
|
387
|
+
form.appendChild(addBtn);
|
|
388
|
+
form.appendChild(cancelBtn);
|
|
389
|
+
formDiv.appendChild(form);
|
|
390
|
+
setTimeout(() => input.focus(), 0);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Section ────────────────────────────────────────────────────
|
|
394
|
+
function makeSection(info, isRoot) {
|
|
395
|
+
const section = document.createElement('div');
|
|
396
|
+
section.style.cssText = 'background:#1f2937;border:1px solid #374151;border-radius:8px;overflow:hidden;';
|
|
397
|
+
|
|
398
|
+
// Header
|
|
399
|
+
const header = document.createElement('div');
|
|
400
|
+
header.className = 'section-header';
|
|
401
|
+
header.style.cssText = 'display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid #374151;background:#111827;flex-wrap:wrap;';
|
|
402
|
+
|
|
403
|
+
const icon = isRoot ? '📁' : '📦';
|
|
404
|
+
const title = document.createElement('h2');
|
|
405
|
+
title.style.cssText = 'font-weight:700;font-size:0.875rem;color:#f9fafb;flex-shrink:0;';
|
|
406
|
+
title.textContent = `${icon} ${info.name}${isRoot ? ' (root)' : ''}`;
|
|
407
|
+
|
|
408
|
+
const activeTasks = info.tasks.filter(t => t.stage !== 'done');
|
|
409
|
+
const doneTasks = info.tasks.filter(t => t.stage === 'done');
|
|
410
|
+
|
|
411
|
+
const count = document.createElement('span');
|
|
412
|
+
count.style.cssText = 'font-size:0.75rem;color:#9ca3af;flex:1;';
|
|
413
|
+
count.textContent = `${activeTasks.length} active / ${info.tasks.length} total`;
|
|
414
|
+
|
|
415
|
+
const purgeBtn = document.createElement('button');
|
|
416
|
+
purgeBtn.textContent = 'Purge done';
|
|
417
|
+
purgeBtn.style.cssText = `font-size:0.75rem;padding:4px 10px;border-radius:4px;border:none;cursor:pointer;flex-shrink:0;background:${doneTasks.length > 0 ? '#374151' : '#1f2937'};color:${doneTasks.length > 0 ? '#d1d5db' : '#6b7280'};`;
|
|
418
|
+
purgeBtn.disabled = doneTasks.length === 0;
|
|
419
|
+
if (doneTasks.length > 0) {
|
|
420
|
+
purgeBtn.onmouseenter = () => { purgeBtn.style.background = '#7f1d1d'; purgeBtn.style.color = '#fca5a5'; };
|
|
421
|
+
purgeBtn.onmouseleave = () => { purgeBtn.style.background = '#374151'; purgeBtn.style.color = '#d1d5db'; };
|
|
422
|
+
}
|
|
423
|
+
purgeBtn.addEventListener('click', async () => {
|
|
424
|
+
if (confirm(`Purge ${doneTasks.length} done task(s) from "${info.name}"?`)) {
|
|
425
|
+
await fetch('/api/tasks/purge', {
|
|
426
|
+
method: 'POST',
|
|
427
|
+
headers: { 'Content-Type': 'application/json' },
|
|
428
|
+
body: JSON.stringify({ dir: info.dir }),
|
|
429
|
+
});
|
|
430
|
+
loadTasks();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const addBtn = document.createElement('button');
|
|
435
|
+
addBtn.textContent = '+ Add task';
|
|
436
|
+
addBtn.style.cssText = 'font-size:0.75rem;padding:4px 10px;border-radius:4px;border:none;cursor:pointer;flex-shrink:0;background:#1d4ed8;color:#fff;';
|
|
437
|
+
addBtn.onmouseenter = () => { addBtn.style.background = '#1e40af'; };
|
|
438
|
+
addBtn.onmouseleave = () => { addBtn.style.background = '#1d4ed8'; };
|
|
439
|
+
addBtn.addEventListener('click', () => toggleAddForm(section, info));
|
|
440
|
+
|
|
441
|
+
header.appendChild(title);
|
|
442
|
+
header.appendChild(count);
|
|
443
|
+
header.appendChild(purgeBtn);
|
|
444
|
+
header.appendChild(addBtn);
|
|
445
|
+
|
|
446
|
+
// Task list
|
|
447
|
+
const taskList = document.createElement('div');
|
|
448
|
+
taskList.style.cssText = 'padding:12px;display:flex;flex-direction:column;gap:8px;';
|
|
449
|
+
|
|
450
|
+
if (info.tasks.length === 0) {
|
|
451
|
+
const empty = document.createElement('p');
|
|
452
|
+
empty.style.cssText = 'color:#6b7280;font-size:0.875rem;text-align:center;padding:24px 0;';
|
|
453
|
+
empty.textContent = 'No tasks. Click "+ Add task" to create one.';
|
|
454
|
+
taskList.appendChild(empty);
|
|
455
|
+
} else {
|
|
456
|
+
// Active tasks first (sorted by updatedAt desc), then done
|
|
457
|
+
const sorted = [...info.tasks].sort((a, b) => {
|
|
458
|
+
if (a.stage === 'done' && b.stage !== 'done') return 1;
|
|
459
|
+
if (a.stage !== 'done' && b.stage === 'done') return -1;
|
|
460
|
+
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
461
|
+
});
|
|
462
|
+
sorted.forEach(task => taskList.appendChild(makeTaskRow(task, info.dir)));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
section.appendChild(header);
|
|
466
|
+
section.appendChild(taskList);
|
|
467
|
+
return section;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── Render ─────────────────────────────────────────────────────
|
|
471
|
+
let lastData = null;
|
|
472
|
+
|
|
473
|
+
function renderAll(data) {
|
|
474
|
+
if (!data) return;
|
|
475
|
+
lastData = data;
|
|
476
|
+
const main = document.getElementById('main');
|
|
477
|
+
main.innerHTML = '';
|
|
478
|
+
|
|
479
|
+
if (data.root) {
|
|
480
|
+
main.appendChild(makeSection(data.root, true));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (data.repos && data.repos.length > 0) {
|
|
484
|
+
data.repos.forEach(repo => main.appendChild(makeSection(repo, false)));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!data.root && (!data.repos || data.repos.length === 0)) {
|
|
488
|
+
const empty = document.createElement('div');
|
|
489
|
+
empty.style.cssText = 'text-align:center;padding:64px 0;color:#6b7280;';
|
|
490
|
+
empty.innerHTML = `
|
|
491
|
+
<p style="font-size:1.125rem;margin-bottom:8px;">No .tasks.jsonl files found</p>
|
|
492
|
+
<p style="font-size:0.875rem;">Run <code style="background:#1f2937;padding:1px 6px;border-radius:3px;">task-tracker add "task"</code> in a git repo to get started</p>
|
|
493
|
+
`;
|
|
494
|
+
main.appendChild(empty);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Data fetching ──────────────────────────────────────────────
|
|
499
|
+
async function loadTasks() {
|
|
500
|
+
try {
|
|
501
|
+
const res = await fetch(`/api/tasks?dir=${encodeURIComponent(GUI_DIR)}`);
|
|
502
|
+
if (res.ok) {
|
|
503
|
+
const data = await res.json();
|
|
504
|
+
renderAll(data);
|
|
505
|
+
}
|
|
506
|
+
} catch (e) {
|
|
507
|
+
console.error('Failed to load tasks:', e);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
loadTasks();
|
|
512
|
+
setInterval(loadTasks, 5000);
|
|
513
|
+
</script>
|
|
514
|
+
</body>
|
|
515
|
+
</html>
|