@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 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 dirname2, join as join2 } from "path";
7
- import { readFileSync as readFileSync2 } from "fs";
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/cli.ts
219
- import process from "process";
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(readFileSync2(join2(__dirname, "..", "package.json"), "utf-8"));
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
- process.exit(1);
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
- process.exit(1);
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
- process.exit(1);
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
- process.exit(1);
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
- process.exit(1);
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 task = updateTask(getStorage(), id, { stage: "done" });
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
- process.exit(1);
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
- process.exit(1);
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 = dirname2(storage);
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.parse(process.argv);
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.0",
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 --ext .ts",
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
- "@typescript-eslint/eslint-plugin": "^7.0.0",
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>