@nijaru/tk 0.0.4 → 0.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/tk",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Minimal task tracker",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.test.ts CHANGED
@@ -57,6 +57,19 @@ describe("tk CLI", () => {
57
57
  expect(lsHelp).toContain("--status");
58
58
  });
59
59
 
60
+ test("tk mv --help shows mv help", async () => {
61
+ const { stdout, exitCode } = await run(["mv", "--help"], testDir);
62
+ expect(exitCode).toBe(0);
63
+ expect(stdout).toContain("tk mv");
64
+ expect(stdout).toContain("<project>");
65
+ });
66
+
67
+ test("tk move --help shows move help", async () => {
68
+ const { stdout, exitCode } = await run(["move", "--help"], testDir);
69
+ expect(exitCode).toBe(0);
70
+ expect(stdout).toContain("tk move");
71
+ });
72
+
60
73
  test("help <command> shows command-specific help", async () => {
61
74
  const { stdout, exitCode } = await run(["help", "add"], testDir);
62
75
  expect(exitCode).toBe(0);
@@ -320,6 +333,62 @@ describe("tk CLI", () => {
320
333
  expect(lines[0]).toContain("Done Second");
321
334
  expect(lines[1]).toContain("Done First");
322
335
  });
336
+
337
+ test("shows [due today] annotation for task due today", async () => {
338
+ await run(["add", "Due Today Task", "--due", "+0d"], testDir);
339
+
340
+ const { stdout } = await run(["ls"], testDir);
341
+ expect(stdout).toContain("[due today]");
342
+ });
343
+
344
+ test("shows [due Nd] annotation for task due within threshold", async () => {
345
+ await run(["add", "Due Soon Task", "--due", "+3d"], testDir);
346
+
347
+ const { stdout } = await run(["ls"], testDir);
348
+ expect(stdout).toContain("[due 3d]");
349
+ });
350
+
351
+ test("no due-soon annotation for task due beyond threshold", async () => {
352
+ await run(["add", "Far Future Task", "--due", "2099-01-01"], testDir);
353
+
354
+ const { stdout } = await run(["ls"], testDir);
355
+ expect(stdout).not.toContain("[due ");
356
+ });
357
+
358
+ test("no due-soon annotation for overdue task (shows [OVERDUE] instead)", async () => {
359
+ await run(["add", "Overdue Task", "--due", "2020-01-01"], testDir);
360
+
361
+ const { stdout } = await run(["ls"], testDir);
362
+ expect(stdout).toContain("[OVERDUE]");
363
+ expect(stdout).not.toContain("[due today]");
364
+ });
365
+
366
+ test("--json includes days_until_due field", async () => {
367
+ await run(["add", "Due Today", "--due", "+0d"], testDir);
368
+
369
+ const { stdout } = await run(["ls", "--json"], testDir);
370
+ const tasks = JSON.parse(stdout);
371
+ expect(tasks[0]).toHaveProperty("days_until_due");
372
+ expect(tasks[0].days_until_due).toBe(0);
373
+ });
374
+ });
375
+
376
+ describe("show (due-soon)", () => {
377
+ test("shows [due today] in detail view", async () => {
378
+ const { stdout: idOut } = await run(["add", "Due Today", "--due", "+0d"], testDir);
379
+ const id = idOut.trim();
380
+
381
+ const { stdout } = await run(["show", id], testDir);
382
+ expect(stdout).toContain("[due today]");
383
+ });
384
+
385
+ test("shows [due Nd] in detail view", async () => {
386
+ const { stdout: idOut } = await run(["add", "Due Soon", "--due", "+4d"], testDir);
387
+ const id = idOut.trim();
388
+
389
+ const { stdout } = await run(["show", id], testDir);
390
+ expect(stdout).toContain("[due 4d]");
391
+ });
323
392
  });
324
393
 
325
394
  describe("ready", () => {
@@ -604,25 +673,29 @@ describe("tk CLI", () => {
604
673
  describe("init", () => {
605
674
  test("creates .tasks directory", async () => {
606
675
  const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
607
- Bun.spawnSync(["git", "init"], { cwd: newDir });
608
-
609
- const { stdout, exitCode } = await run(["init"], newDir);
610
- expect(exitCode).toBe(0);
611
- expect(stdout).toContain("Initialized");
612
- expect(existsSync(join(newDir, ".tasks"))).toBe(true);
613
-
614
- rmSync(newDir, { recursive: true, force: true });
676
+ try {
677
+ Bun.spawnSync(["git", "init"], { cwd: newDir });
678
+
679
+ const { stdout, exitCode } = await run(["init"], newDir);
680
+ expect(exitCode).toBe(0);
681
+ expect(stdout).toContain("Initialized");
682
+ expect(existsSync(join(newDir, ".tasks"))).toBe(true);
683
+ } finally {
684
+ rmSync(newDir, { recursive: true, force: true });
685
+ }
615
686
  });
616
687
 
617
688
  test("creates .tasks with custom project", async () => {
618
689
  const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
619
- Bun.spawnSync(["git", "init"], { cwd: newDir });
620
-
621
- await run(["init", "-P", "api"], newDir);
622
- const { stdout } = await run(["add", "Task"], newDir);
623
- expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
624
-
625
- rmSync(newDir, { recursive: true, force: true });
690
+ try {
691
+ Bun.spawnSync(["git", "init"], { cwd: newDir });
692
+
693
+ await run(["init", "-P", "api"], newDir);
694
+ const { stdout } = await run(["add", "Task"], newDir);
695
+ expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
696
+ } finally {
697
+ rmSync(newDir, { recursive: true, force: true });
698
+ }
626
699
  });
627
700
 
628
701
  test("reports already initialized", async () => {
@@ -661,15 +734,6 @@ describe("tk CLI", () => {
661
734
  expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
662
735
  });
663
736
 
664
- test("manages aliases", async () => {
665
- await run(["init"], testDir);
666
- await run(["config", "alias", "api", "packages/api"], testDir);
667
-
668
- const { stdout } = await run(["config", "alias"], testDir);
669
- expect(stdout).toContain("api");
670
- expect(stdout).toContain("packages/api");
671
- });
672
-
673
737
  test("renames project and updates references", async () => {
674
738
  await run(["init", "-P", "old"], testDir);
675
739
 
@@ -804,22 +868,14 @@ describe("tk CLI", () => {
804
868
  });
805
869
 
806
870
  test("ambiguous ID shows matching tasks", async () => {
807
- // Create tasks with same ref prefix in different projects
808
- const { stdout: id1 } = await run(["add", "Task 1", "-P", "api"], testDir);
809
- const { stdout: id2 } = await run(["add", "Task 2", "-P", "web"], testDir);
810
- const ref1 = id1.trim().split("-")[1] ?? "";
811
- const ref2 = id2.trim().split("-")[1] ?? "";
812
-
813
- // Use first char which might match both
814
- const prefix = ref1[0] ?? "";
815
-
816
- // Only test if both refs start with same char (otherwise not ambiguous)
817
- if (ref2.startsWith(prefix)) {
818
- const { stderr, exitCode } = await run(["show", prefix], testDir);
819
- expect(exitCode).toBe(1);
820
- expect(stderr).toContain("Ambiguous");
821
- expect(stderr).toContain("matches");
822
- }
871
+ // Two tasks in the same project share the "api-" prefix guaranteed ambiguous
872
+ await run(["add", "Task 1", "-P", "api"], testDir);
873
+ await run(["add", "Task 2", "-P", "api"], testDir);
874
+
875
+ const { stderr, exitCode } = await run(["show", "api"], testDir);
876
+ expect(exitCode).toBe(1);
877
+ expect(stderr).toContain("Ambiguous");
878
+ expect(stderr).toContain("matches");
823
879
  });
824
880
 
825
881
  test("not found shows clear error", async () => {
@@ -973,4 +1029,144 @@ describe("tk CLI", () => {
973
1029
  expect(result).toHaveProperty("unfixable");
974
1030
  });
975
1031
  });
1032
+
1033
+ describe("mv", () => {
1034
+ test("moves task to a different project", async () => {
1035
+ await run(["init"], testDir);
1036
+ const { stdout: idOut } = await run(["add", "Move me", "-P", "src"], testDir);
1037
+ const oldId = idOut.trim();
1038
+ const ref = oldId.split("-")[1];
1039
+
1040
+ const { stdout, exitCode } = await run(["mv", oldId, "dst"], testDir);
1041
+ expect(exitCode).toBe(0);
1042
+ const newId = `dst-${ref}`;
1043
+ expect(stdout).toContain(oldId);
1044
+ expect(stdout).toContain(newId);
1045
+
1046
+ // Old ID gone, new ID exists with same content
1047
+ const { exitCode: notFound } = await run(["show", oldId], testDir);
1048
+ expect(notFound).toBe(1);
1049
+
1050
+ const { stdout: showOut } = await run(["show", newId, "--json"], testDir);
1051
+ const task = JSON.parse(showOut);
1052
+ expect(task.project).toBe("dst");
1053
+ expect(task.title).toBe("Move me");
1054
+ });
1055
+
1056
+ test("updates blocked_by and parent references", async () => {
1057
+ await run(["init"], testDir);
1058
+ const { stdout: idA } = await run(["add", "A", "-P", "src"], testDir);
1059
+ const { stdout: idB } = await run(["add", "B", "-P", "src"], testDir);
1060
+ const aId = idA.trim();
1061
+ const bId = idB.trim();
1062
+
1063
+ // B is blocked by A, and B has A as parent
1064
+ await run(["block", bId, aId], testDir);
1065
+ await run(["edit", bId, "--parent", aId], testDir);
1066
+
1067
+ const ref = aId.split("-")[1];
1068
+ await run(["mv", aId, "dst"], testDir);
1069
+ const newAId = `dst-${ref}`;
1070
+
1071
+ const { stdout: showB } = await run(["show", bId, "--json"], testDir);
1072
+ const bTask = JSON.parse(showB);
1073
+ expect(bTask.blocked_by).toContain(newAId);
1074
+ expect(bTask.parent).toBe(newAId);
1075
+ });
1076
+
1077
+ test("errors when moving to same project", async () => {
1078
+ await run(["init"], testDir);
1079
+ const { stdout: idOut } = await run(["add", "Task", "-P", "api"], testDir);
1080
+ const id = idOut.trim();
1081
+
1082
+ const { stderr, exitCode } = await run(["mv", id, "api"], testDir);
1083
+ expect(exitCode).toBe(1);
1084
+ expect(stderr).toContain("already in project");
1085
+ });
1086
+
1087
+ test("errors on ref collision", async () => {
1088
+ await run(["init"], testDir);
1089
+ const { stdout: idOut } = await run(["add", "Task A", "-P", "src"], testDir);
1090
+ const id = idOut.trim();
1091
+ const ref = id.split("-")[1]!;
1092
+
1093
+ // Manually create a collision in dst project
1094
+ const fs = await import("fs");
1095
+ const path = await import("path");
1096
+ const collisionPath = path.join(testDir, ".tasks", `dst-${ref}.json`);
1097
+ fs.writeFileSync(
1098
+ collisionPath,
1099
+ JSON.stringify({
1100
+ project: "dst",
1101
+ ref,
1102
+ title: "Collision",
1103
+ status: "open",
1104
+ priority: 3,
1105
+ labels: [],
1106
+ assignees: [],
1107
+ parent: null,
1108
+ blocked_by: [],
1109
+ estimate: null,
1110
+ due_date: null,
1111
+ logs: [],
1112
+ description: null,
1113
+ created_at: new Date().toISOString(),
1114
+ updated_at: new Date().toISOString(),
1115
+ completed_at: null,
1116
+ external: {},
1117
+ }),
1118
+ );
1119
+
1120
+ const { stderr, exitCode } = await run(["mv", id, "dst"], testDir);
1121
+ expect(exitCode).toBe(1);
1122
+ expect(stderr).toContain("already exists");
1123
+ });
1124
+
1125
+ test("errors on missing arguments", async () => {
1126
+ await run(["init"], testDir);
1127
+ const { stdout: idOut } = await run(["add", "Task"], testDir);
1128
+ const id = idOut.trim();
1129
+
1130
+ const { stderr: noProject } = await run(["mv", id], testDir);
1131
+ expect(noProject).toContain("Project required");
1132
+ });
1133
+
1134
+ test("errors on invalid project name", async () => {
1135
+ await run(["init"], testDir);
1136
+ const { stdout: idOut } = await run(["add", "Task", "-P", "src"], testDir);
1137
+ const id = idOut.trim();
1138
+
1139
+ const { stderr, exitCode } = await run(["mv", id, "Bad-Project"], testDir);
1140
+ expect(exitCode).toBe(1);
1141
+ expect(stderr).toContain("Invalid project name");
1142
+ });
1143
+
1144
+ test("--json output structure", async () => {
1145
+ await run(["init"], testDir);
1146
+ const { stdout: idOut } = await run(["add", "Task", "-P", "src"], testDir);
1147
+ const id = idOut.trim();
1148
+
1149
+ const { stdout, exitCode } = await run(["mv", id, "dst", "--json"], testDir);
1150
+ expect(exitCode).toBe(0);
1151
+ const result = JSON.parse(stdout);
1152
+ expect(result).toHaveProperty("old_id");
1153
+ expect(result).toHaveProperty("new_id");
1154
+ expect(result).toHaveProperty("referencesUpdated");
1155
+ });
1156
+
1157
+ test("partial ID resolution", async () => {
1158
+ await run(["init"], testDir);
1159
+ const { stdout: idOut } = await run(["add", "Task", "-P", "src"], testDir);
1160
+ const id = idOut.trim();
1161
+ const ref = id.split("-")[1]!;
1162
+
1163
+ // Use just the ref as partial ID
1164
+ const { exitCode } = await run(["mv", ref, "dst"], testDir);
1165
+ expect(exitCode).toBe(0);
1166
+
1167
+ // Verify moved
1168
+ const { exitCode: found } = await run(["show", `dst-${ref}`], testDir);
1169
+ expect(found).toBe(0);
1170
+ });
1171
+ });
976
1172
  });
package/src/cli.ts CHANGED
@@ -308,8 +308,9 @@ function main() {
308
308
  const { values } = parseArgs({
309
309
  args,
310
310
  options: {
311
- ...COMMON_OPTIONS,
312
- label: COMMON_OPTIONS.labels, // alias for consistency
311
+ project: { type: "string", short: "P" },
312
+ priority: { type: "string", short: "p" },
313
+ label: { type: "string", short: "l" },
313
314
  status: { type: "string", short: "s" },
314
315
  assignee: { type: "string" },
315
316
  parent: { type: "string" },
@@ -337,7 +338,7 @@ function main() {
337
338
  status,
338
339
  priority,
339
340
  project: values.project,
340
- label: values.label ?? values.labels,
341
+ label: values.label,
341
342
  assignee: values.assignee,
342
343
  parent: parentFilter,
343
344
  roots: values.roots,
@@ -402,12 +403,12 @@ function main() {
402
403
  let labels: string[] | undefined;
403
404
  if (values.labels) {
404
405
  if (values.labels.startsWith("+")) {
405
- // Add label (avoid duplicates)
406
- const newLabel = values.labels.slice(1);
406
+ const newLabel = values.labels.slice(1).trim();
407
+ if (!newLabel) throw new Error("Label name required after '+'");
407
408
  labels = task.labels.includes(newLabel) ? task.labels : [...task.labels, newLabel];
408
409
  } else if (values.labels.startsWith("-")) {
409
- // Remove label
410
- const removeLabel = values.labels.slice(1);
410
+ const removeLabel = values.labels.slice(1).trim();
411
+ if (!removeLabel) throw new Error("Label name required after '-'");
411
412
  labels = task.labels.filter((l: string) => l !== removeLabel);
412
413
  } else {
413
414
  // Replace labels
@@ -430,7 +431,7 @@ function main() {
430
431
 
431
432
  const updated = storage.updateTask(task.id, {
432
433
  title: values.title?.trim() || undefined,
433
- description: values.description,
434
+ description: values.description === "-" ? null : values.description,
434
435
  priority: values.priority ? parsePriority(values.priority) : undefined,
435
436
  labels,
436
437
  assignees: parseAssignees(values.assignees),
@@ -496,6 +497,19 @@ function main() {
496
497
  break;
497
498
  }
498
499
 
500
+ case "mv":
501
+ case "move": {
502
+ const id = resolveId(args[0], "mv");
503
+ const newProject = args[1];
504
+ if (!newProject) error("Project required: tk mv <id> <project>");
505
+ validateProject(newProject);
506
+ const result = storage.moveTask(id, newProject);
507
+ const refMsg =
508
+ result.referencesUpdated > 0 ? `\nUpdated ${result.referencesUpdated} references` : "";
509
+ output(result, green(`Moved: ${result.old_id} → ${result.new_id}${refMsg}`));
510
+ break;
511
+ }
512
+
499
513
  case "clean": {
500
514
  const config = storage.getConfig();
501
515
  const { values } = parseArgs({
@@ -616,46 +630,8 @@ function main() {
616
630
  }
617
631
  break;
618
632
  }
619
- case "alias": {
620
- const { values, positionals } = parseArgs({
621
- args: args.slice(1),
622
- options: {
623
- rm: { type: "string" },
624
- list: { type: "boolean" },
625
- },
626
- allowPositionals: true,
627
- });
628
-
629
- if (values.rm) {
630
- const updated = storage.removeAlias(values.rm);
631
- output(updated, green(`Removed alias: ${values.rm}`));
632
- } else if (positionals.length >= 2) {
633
- const alias = positionals[0];
634
- const path = positionals[1];
635
- if (!alias || !path || !alias.trim()) {
636
- error("Alias name and path required: tk config alias <name> <path>");
637
- }
638
- const updated = storage.setAlias(alias, path);
639
- output(updated, green(`Added alias: ${alias} → ${path}`));
640
- } else {
641
- // List aliases
642
- const aliases = config.aliases;
643
- if (Object.keys(aliases).length === 0) {
644
- output(
645
- { aliases: {} },
646
- "No aliases defined. Add one with: tk config alias <name> <path>",
647
- );
648
- } else {
649
- const lines = Object.entries(aliases)
650
- .map(([a, p]) => `${a} → ${p}`)
651
- .join("\n");
652
- output({ aliases }, lines);
653
- }
654
- }
655
- break;
656
- }
657
633
  default:
658
- error(`Unknown config command: ${subcommand}. Valid: project, alias.`);
634
+ error(`Unknown config command: ${subcommand}. Valid: project.`);
659
635
  }
660
636
  break;
661
637
  }
package/src/db/storage.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from "fs";
12
12
  import { join, resolve, basename } from "path";
13
13
  import { getTasksDir, getWorkingDir } from "../lib/root";
14
- import { isTaskOverdue } from "../lib/time";
14
+ import { isTaskOverdue, daysUntilDue } from "../lib/time";
15
15
  import type { Task, Config, Status, Priority, TaskWithMeta, LogEntry } from "../types";
16
16
  import { DEFAULT_CONFIG, taskId, parseId, generateRef } from "../types";
17
17
 
@@ -77,8 +77,8 @@ const STATUS_ORDER: Record<Status, number> = { active: 0, open: 1, done: 2 };
77
77
  * 3. If done: completed_at (newest first)
78
78
  */
79
79
  export function compareTasks(a: Task, b: Task): number {
80
- const sA = STATUS_ORDER[a.status] ?? 99;
81
- const sB = STATUS_ORDER[b.status] ?? 99;
80
+ const sA = STATUS_ORDER[a.status];
81
+ const sB = STATUS_ORDER[b.status];
82
82
  if (sA !== sB) return sA - sB;
83
83
 
84
84
  if (a.status !== "done") {
@@ -119,7 +119,12 @@ export function getConfig(): Config {
119
119
  }
120
120
  try {
121
121
  const text = readFileSync(configPath, "utf-8");
122
- return { ...DEFAULT_CONFIG, ...JSON.parse(text) };
122
+ const parsed = JSON.parse(text);
123
+ return {
124
+ ...DEFAULT_CONFIG,
125
+ ...parsed,
126
+ defaults: { ...DEFAULT_CONFIG.defaults, ...parsed.defaults },
127
+ };
123
128
  } catch {
124
129
  return { ...DEFAULT_CONFIG };
125
130
  }
@@ -234,18 +239,75 @@ export function renameProject(oldProject: string, newProject: string): RenameRes
234
239
  };
235
240
  }
236
241
 
237
- export function setAlias(alias: string, path: string): Config {
238
- const config = getConfig();
239
- config.aliases[alias] = path;
240
- saveConfig(config);
241
- return config;
242
+ export interface MoveResult {
243
+ old_id: string;
244
+ new_id: string;
245
+ referencesUpdated: number;
242
246
  }
243
247
 
244
- export function removeAlias(alias: string): Config {
245
- const config = getConfig();
246
- delete config.aliases[alias];
247
- saveConfig(config);
248
- return config;
248
+ export function moveTask(oldId: string, newProject: string): MoveResult {
249
+ const tasksDir = getTasksDir();
250
+ if (!existsSync(tasksDir)) {
251
+ throw new Error("No .tasks/ directory found");
252
+ }
253
+
254
+ const parsed = parseId(oldId);
255
+ if (!parsed) {
256
+ throw new Error(`Invalid task ID: ${oldId}`);
257
+ }
258
+
259
+ if (parsed.project === newProject) {
260
+ throw new Error(`Task ${oldId} is already in project "${newProject}"`);
261
+ }
262
+
263
+ const oldPath = getTaskPath(tasksDir, oldId);
264
+ if (!existsSync(oldPath)) {
265
+ throw new Error(`Task not found: ${oldId}`);
266
+ }
267
+
268
+ const newId = `${newProject}-${parsed.ref}`;
269
+ const newPath = getTaskPath(tasksDir, newId);
270
+ if (existsSync(newPath)) {
271
+ throw new Error(
272
+ `Cannot move: "${newId}" already exists. The ref conflicts with an existing task.`,
273
+ );
274
+ }
275
+
276
+ const task = readTaskFile(oldPath, tasksDir);
277
+ if (!task) {
278
+ throw new Error(`Failed to read task: ${oldId}`);
279
+ }
280
+
281
+ // Write new file with updated project
282
+ task.project = newProject;
283
+ task.updated_at = new Date().toISOString();
284
+ atomicWrite(newPath, JSON.stringify(task, null, 2));
285
+ unlinkSync(oldPath);
286
+
287
+ // Update all blocked_by and parent refs in other tasks
288
+ let referencesUpdated = 0;
289
+ const allTasks = getAllTasks(tasksDir);
290
+ for (const other of allTasks) {
291
+ let modified = false;
292
+
293
+ if (other.blocked_by.includes(oldId)) {
294
+ other.blocked_by = other.blocked_by.map((id) => (id === oldId ? newId : id));
295
+ referencesUpdated++;
296
+ modified = true;
297
+ }
298
+
299
+ if (other.parent === oldId) {
300
+ other.parent = newId;
301
+ referencesUpdated++;
302
+ modified = true;
303
+ }
304
+
305
+ if (modified) {
306
+ atomicWrite(getTaskPath(tasksDir, taskId(other)), JSON.stringify(other, null, 2));
307
+ }
308
+ }
309
+
310
+ return { old_id: oldId, new_id: newId, referencesUpdated };
249
311
  }
250
312
 
251
313
  // --- Task File Operations ---
@@ -282,7 +344,8 @@ function isValidTaskStructure(obj: unknown): obj is Task {
282
344
  typeof t.ref === "string" &&
283
345
  typeof t.title === "string" &&
284
346
  typeof t.status === "string" &&
285
- Array.isArray(t.blocked_by)
347
+ Array.isArray(t.blocked_by) &&
348
+ Array.isArray(t.logs)
286
349
  );
287
350
  }
288
351
 
@@ -513,6 +576,7 @@ export function enrichTask(task: Task, statusMap?: Map<string, Status>): TaskWit
513
576
  id: taskId(task),
514
577
  is_overdue: isTaskOverdue(task.due_date, task.status),
515
578
  blocked_by_incomplete: blockedByIncomplete,
579
+ days_until_due: daysUntilDue(task.due_date, task.status),
516
580
  };
517
581
  }
518
582
 
@@ -594,9 +658,6 @@ export function getTask(id: string): TaskResult | null {
594
658
  };
595
659
  }
596
660
 
597
- // Deprecated alias for getTask
598
- export const getTaskWithMeta = getTask;
599
-
600
661
  export interface ListOptions {
601
662
  status?: Status;
602
663
  priority?: Priority;
@@ -685,7 +746,7 @@ export function updateTaskStatus(id: string, status: Status): (Task & { id: stri
685
746
 
686
747
  export interface UpdateTaskOptions {
687
748
  title?: string;
688
- description?: string;
749
+ description?: string | null;
689
750
  priority?: Priority;
690
751
  labels?: string[];
691
752
  assignees?: string[];
@@ -884,38 +945,13 @@ export function resolveId(input: string): string | null {
884
945
  const tasksDir = getTasksDir();
885
946
  if (!existsSync(tasksDir)) return null;
886
947
 
887
- // If it's already a full valid ID, check if task exists
888
- if (parseId(input)) {
889
- if (existsSync(getTaskPath(tasksDir, input))) {
890
- return input;
891
- }
892
- }
893
-
894
- const inputLower = input.toLowerCase();
895
-
896
- // Read filenames only (no file content parsing)
897
- const files = readdirSync(tasksDir);
898
- const matches: string[] = [];
899
-
900
- for (const file of files) {
901
- if (!file.endsWith(".json") || file === "config.json") continue;
902
-
903
- // Extract ID from filename: "project-ref.json" -> "project-ref"
904
- const id = file.slice(0, -5);
905
- if (!parseId(id)) continue;
906
-
907
- // Match by full ID prefix or just ref prefix
908
- const ref = id.split("-")[1] ?? "";
909
- if (id.startsWith(inputLower) || ref.startsWith(inputLower)) {
910
- matches.push(id);
911
- }
912
- }
913
-
914
- if (matches.length === 1 && matches[0]) {
915
- return matches[0];
948
+ // Fast path: full valid ID that exists
949
+ if (parseId(input) && existsSync(getTaskPath(tasksDir, input))) {
950
+ return input;
916
951
  }
917
952
 
918
- return null;
953
+ const matches = findMatchingIds(input);
954
+ return matches.length === 1 ? (matches[0] ?? null) : null;
919
955
  }
920
956
 
921
957
  export function findMatchingIds(input: string): string[] {
@@ -12,7 +12,7 @@ _tk() {
12
12
  local cur prev words cword
13
13
  _init_completion || return
14
14
 
15
- local commands="init add ls list ready show start done reopen edit log block unblock rm remove clean check config completions help"
15
+ local commands="init add ls list ready show start done reopen edit log block unblock mv move rm remove clean check config completions help"
16
16
  local global_opts="--json --help -h --version -V"
17
17
 
18
18
  # Find the command (first non-option word after 'tk')
@@ -60,6 +60,13 @@ _tk() {
60
60
  COMPREPLY=($(compgen -W "$(_tk_task_ids)" -- "$cur"))
61
61
  fi
62
62
  ;;
63
+ mv|move)
64
+ if [[ "$cur" == -* ]]; then
65
+ COMPREPLY=($(compgen -W "--json" -- "$cur"))
66
+ else
67
+ COMPREPLY=($(compgen -W "$(_tk_task_ids)" -- "$cur"))
68
+ fi
69
+ ;;
63
70
  edit)
64
71
  if [[ "$cur" == -* ]]; then
65
72
  COMPREPLY=($(compgen -W "-t --title -d --description -p --priority -l --labels -A --assignees --parent --estimate --due --json" -- "$cur"))
@@ -90,9 +97,7 @@ _tk() {
90
97
  ;;
91
98
  config)
92
99
  if [[ "$prev" == "config" ]]; then
93
- COMPREPLY=($(compgen -W "project alias --rm" -- "$cur"))
94
- elif [[ "$prev" == "alias" ]]; then
95
- COMPREPLY=($(compgen -W "--rm" -- "$cur"))
100
+ COMPREPLY=($(compgen -W "project" -- "$cur"))
96
101
  fi
97
102
  ;;
98
103
  init)
@@ -139,6 +144,8 @@ _tk() {
139
144
  'log:Add log entry'
140
145
  'block:Add blocker'
141
146
  'unblock:Remove blocker'
147
+ 'mv:Move task to different project'
148
+ 'move:Move task to different project'
142
149
  'rm:Delete task'
143
150
  'remove:Delete task'
144
151
  'clean:Remove old done tasks'
@@ -207,6 +214,12 @@ _tk() {
207
214
  '--json[Output as JSON]' \
208
215
  '1:task id:_tk_task_ids'
209
216
  ;;
217
+ mv|move)
218
+ _arguments \
219
+ '--json[Output as JSON]' \
220
+ '1:task id:_tk_task_ids' \
221
+ '2:project:'
222
+ ;;
210
223
  edit)
211
224
  _arguments \
212
225
  '(-t --title)'{-t,--title}'[Title]:title:' \
@@ -244,8 +257,7 @@ _tk() {
244
257
  ;;
245
258
  config)
246
259
  _arguments \
247
- '--rm[Remove alias]' \
248
- '1:subcommand:(project alias)'
260
+ '1:subcommand:(project)'
249
261
  ;;
250
262
  init)
251
263
  _arguments \
@@ -332,6 +344,8 @@ complete -c tk -n __tk_needs_command -f -a edit -d 'Edit task'
332
344
  complete -c tk -n __tk_needs_command -f -a log -d 'Add log entry'
333
345
  complete -c tk -n __tk_needs_command -f -a block -d 'Add blocker'
334
346
  complete -c tk -n __tk_needs_command -f -a unblock -d 'Remove blocker'
347
+ complete -c tk -n __tk_needs_command -f -a mv -d 'Move task to different project'
348
+ complete -c tk -n __tk_needs_command -f -a move -d 'Move task to different project'
335
349
  complete -c tk -n __tk_needs_command -f -a rm -d 'Delete task'
336
350
  complete -c tk -n __tk_needs_command -f -a remove -d 'Delete task'
337
351
  complete -c tk -n __tk_needs_command -f -a clean -d 'Remove old done tasks'
@@ -380,6 +394,8 @@ complete -c tk -n '__tk_using_command show' -f -a '(__tk_task_ids)' -d 'Task ID'
380
394
  complete -c tk -n '__tk_using_command start' -f -a '(__tk_task_ids)' -d 'Task ID'
381
395
  complete -c tk -n '__tk_using_command done' -f -a '(__tk_task_ids)' -d 'Task ID'
382
396
  complete -c tk -n '__tk_using_command reopen' -f -a '(__tk_task_ids)' -d 'Task ID'
397
+ complete -c tk -n '__tk_using_command mv' -f -a '(__tk_task_ids)' -d 'Task ID'
398
+ complete -c tk -n '__tk_using_command move' -f -a '(__tk_task_ids)' -d 'Task ID'
383
399
  complete -c tk -n '__tk_using_command rm' -f -a '(__tk_task_ids)' -d 'Task ID'
384
400
  complete -c tk -n '__tk_using_command remove' -f -a '(__tk_task_ids)' -d 'Task ID'
385
401
  complete -c tk -n '__tk_using_command log' -f -a '(__tk_task_ids)' -d 'Task ID'
@@ -414,8 +430,7 @@ complete -c tk -n '__tk_using_command clean' -l json -d 'Output as JSON'
414
430
  complete -c tk -n '__tk_using_command check' -l json -d 'Output as JSON'
415
431
 
416
432
  # config command
417
- complete -c tk -n '__tk_using_command config' -f -a 'project alias' -d 'Config option'
418
- complete -c tk -n '__tk_using_command config' -l rm -d 'Remove alias'
433
+ complete -c tk -n '__tk_using_command config' -f -a 'project' -d 'Config option'
419
434
 
420
435
  # init command
421
436
  complete -c tk -n '__tk_using_command init' -s P -l project -d 'Project'
@@ -33,7 +33,8 @@ describe("shouldUseColor", () => {
33
33
 
34
34
  test("respects NO_COLOR empty string as color enabled", () => {
35
35
  process.env.NO_COLOR = "";
36
- // When NO_COLOR is empty, color is allowed (depends on TTY)
36
+ // NO_COLOR="" is treated as unset color depends on TTY (always false in tests)
37
+ expect(shouldUseColor()).toBe(false);
37
38
  });
38
39
  });
39
40
 
@@ -59,6 +60,7 @@ describe("formatTaskRow", () => {
59
60
  external: {},
60
61
  blocked_by_incomplete: false,
61
62
  is_overdue: false,
63
+ days_until_due: null,
62
64
  };
63
65
 
64
66
  test("formats task without color", () => {
@@ -127,6 +129,72 @@ describe("formatTaskRow", () => {
127
129
  const result = formatTaskRow(todayTask, false);
128
130
  expect(result).not.toContain("[OVERDUE]");
129
131
  });
132
+
133
+ test("shows [due today] for tasks due today", () => {
134
+ const today = new Date().toISOString().split("T")[0] ?? null;
135
+ const todayTask: TaskWithMeta = {
136
+ ...task,
137
+ due_date: today,
138
+ is_overdue: false,
139
+ days_until_due: 0,
140
+ };
141
+ const result = formatTaskRow(todayTask, false);
142
+ expect(result).toContain("[due today]");
143
+ expect(result).not.toContain("[OVERDUE]");
144
+ });
145
+
146
+ test("shows [due Nd] for tasks due in N days within threshold", () => {
147
+ const future = new Date();
148
+ future.setDate(future.getDate() + 3);
149
+ const dateStr = future.toISOString().split("T")[0] ?? null;
150
+ const soonTask: TaskWithMeta = {
151
+ ...task,
152
+ due_date: dateStr,
153
+ is_overdue: false,
154
+ days_until_due: 3,
155
+ };
156
+ const result = formatTaskRow(soonTask, false);
157
+ expect(result).toContain("[due 3d]");
158
+ });
159
+
160
+ test("shows no due-soon marker at 8 days", () => {
161
+ const future = new Date();
162
+ future.setDate(future.getDate() + 8);
163
+ const dateStr = future.toISOString().split("T")[0] ?? null;
164
+ const notSoonTask: TaskWithMeta = {
165
+ ...task,
166
+ due_date: dateStr,
167
+ is_overdue: false,
168
+ days_until_due: 8,
169
+ };
170
+ const result = formatTaskRow(notSoonTask, false);
171
+ expect(result).not.toContain("[due");
172
+ expect(result).not.toContain("[OVERDUE]");
173
+ });
174
+
175
+ test("shows no due-soon marker when overdue", () => {
176
+ const overdueWithSoon: TaskWithMeta = {
177
+ ...task,
178
+ due_date: "2020-01-01",
179
+ is_overdue: true,
180
+ days_until_due: null,
181
+ };
182
+ const result = formatTaskRow(overdueWithSoon, false);
183
+ expect(result).toContain("[OVERDUE]");
184
+ expect(result).not.toContain("[due today]");
185
+ expect(result).not.toContain("[due ");
186
+ });
187
+
188
+ test("shows no due-soon marker for done tasks", () => {
189
+ const doneTask: TaskWithMeta = {
190
+ ...task,
191
+ status: "done",
192
+ days_until_due: null,
193
+ completed_at: new Date().toISOString(),
194
+ };
195
+ const result = formatTaskRow(doneTask, false);
196
+ expect(result).not.toContain("[due");
197
+ });
130
198
  });
131
199
 
132
200
  describe("formatTaskList", () => {
@@ -161,6 +229,7 @@ describe("formatTaskList", () => {
161
229
  external: {},
162
230
  blocked_by_incomplete: false,
163
231
  is_overdue: false,
232
+ days_until_due: null,
164
233
  },
165
234
  ];
166
235
  const result = formatTaskList(tasks, false);
@@ -194,6 +263,7 @@ describe("formatTaskList", () => {
194
263
  external: {},
195
264
  blocked_by_incomplete: false,
196
265
  is_overdue: false,
266
+ days_until_due: null,
197
267
  },
198
268
  {
199
269
  id: "tk-c3d4",
@@ -216,6 +286,7 @@ describe("formatTaskList", () => {
216
286
  external: {},
217
287
  blocked_by_incomplete: false,
218
288
  is_overdue: false,
289
+ days_until_due: null,
219
290
  },
220
291
  ];
221
292
  const result = formatTaskList(tasks, false);
@@ -248,6 +319,7 @@ describe("formatTaskDetail", () => {
248
319
  external: {},
249
320
  blocked_by_incomplete: true,
250
321
  is_overdue: false,
322
+ days_until_due: null,
251
323
  };
252
324
 
253
325
  test("includes all task fields", () => {
package/src/lib/format.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { TaskWithMeta, LogEntry } from "../types";
2
2
  import { PRIORITY_COLORS, STATUS_COLORS, OVERDUE_COLOR, RESET } from "../types";
3
3
  import { formatPriority } from "./priority";
4
- import { formatDate, formatRelativeTime } from "./time";
4
+ import { formatDate, formatRelativeTime, DUE_SOON_THRESHOLD } from "./time";
5
+
6
+ const DUE_SOON_COLOR = "\x1b[33m"; // yellow
5
7
 
6
8
  // Message colors
7
9
  const GREEN = "\x1b[32m";
@@ -10,17 +12,14 @@ const YELLOW = "\x1b[33m";
10
12
  const DIM = "\x1b[2m";
11
13
 
12
14
  /**
13
- * Determines if color output should be used.
15
+ * Determines if color output should be used for the given stream.
14
16
  * Respects NO_COLOR env var (https://no-color.org/) and TTY detection.
15
17
  */
16
- export function shouldUseColor(): boolean {
18
+ export function shouldUseColor(stream: NodeJS.WriteStream = process.stdout): boolean {
17
19
  if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
18
20
  return false;
19
21
  }
20
- if (!process.stdout.isTTY) {
21
- return false;
22
- }
23
- return true;
22
+ return stream.isTTY ?? false;
24
23
  }
25
24
 
26
25
  /** Format text green (success) */
@@ -28,9 +27,9 @@ export function green(msg: string): string {
28
27
  return shouldUseColor() ? `${GREEN}${msg}${RESET}` : msg;
29
28
  }
30
29
 
31
- /** Format text red (error) */
30
+ /** Format text red (error) — checks stderr since errors are written there */
32
31
  export function red(msg: string): string {
33
- return shouldUseColor() ? `${RED}${msg}${RESET}` : msg;
32
+ return shouldUseColor(process.stderr) ? `${RED}${msg}${RESET}` : msg;
34
33
  }
35
34
 
36
35
  /** Format text yellow (warning) */
@@ -64,8 +63,9 @@ export function formatTaskRow(task: TaskWithMeta, useColor?: boolean): string {
64
63
  const color = useColor ?? shouldUseColor();
65
64
  const id = formatId(task.id).padEnd(11);
66
65
  const priority = formatPriority(task.priority).padEnd(4);
67
- const title = truncate(task.title, 50);
68
66
  const isOverdue = task.is_overdue;
67
+ const dueSoon =
68
+ !isOverdue && task.days_until_due !== null && task.days_until_due <= DUE_SOON_THRESHOLD;
69
69
 
70
70
  let statusText: string = task.status;
71
71
  if (task.status === "done" && task.completed_at) {
@@ -75,13 +75,19 @@ export function formatTaskRow(task: TaskWithMeta, useColor?: boolean): string {
75
75
 
76
76
  if (color) {
77
77
  const pc = PRIORITY_COLORS[task.priority];
78
- const sc = isOverdue ? OVERDUE_COLOR : STATUS_COLORS[task.status];
78
+ const sc = isOverdue ? OVERDUE_COLOR : dueSoon ? DUE_SOON_COLOR : STATUS_COLORS[task.status];
79
79
  const tc = task.status === "done" ? DIM : "";
80
+ const title = truncate(task.title, 50);
80
81
  return `${id} | ${pc}${priority}${RESET} | ${sc}${status}${RESET} | ${tc}${title}${RESET}`;
81
82
  }
82
83
 
84
+ let dueSoonMarker = "";
85
+ if (dueSoon) {
86
+ dueSoonMarker = task.days_until_due === 0 ? " [due today]" : ` [due ${task.days_until_due}d]`;
87
+ }
83
88
  const overdueMarker = isOverdue ? " [OVERDUE]" : "";
84
- return `${id} | ${priority} | ${status} | ${title}${overdueMarker}`;
89
+ const title = truncate(task.title, 50);
90
+ return `${id} | ${priority} | ${status} | ${title}${overdueMarker}${dueSoonMarker}`;
85
91
  }
86
92
 
87
93
  export function formatTaskList(
@@ -136,8 +142,16 @@ export function formatTaskDetail(task: TaskWithMeta, logs: LogEntry[], useColor?
136
142
  }
137
143
 
138
144
  if (task.due_date) {
145
+ const yc = color ? DUE_SOON_COLOR : "";
146
+ const dueSoon =
147
+ !task.is_overdue && task.days_until_due !== null && task.days_until_due <= DUE_SOON_THRESHOLD;
148
+ let dueSoonStr = "";
149
+ if (dueSoon) {
150
+ const marker = task.days_until_due === 0 ? "[due today]" : `[due ${task.days_until_due}d]`;
151
+ dueSoonStr = ` ${yc}${marker}${r}`;
152
+ }
139
153
  const overdueStr = task.is_overdue ? ` ${oc}[OVERDUE]${r}` : "";
140
- lines.push(`Due: ${task.due_date}${overdueStr}`);
154
+ lines.push(`Due: ${task.due_date}${overdueStr}${dueSoonStr}`);
141
155
  }
142
156
 
143
157
  lines.push(`Created: ${formatDate(task.created_at)}`);
@@ -167,22 +181,9 @@ export function formatJson(data: unknown): string {
167
181
  return JSON.stringify(data, null, 2);
168
182
  }
169
183
 
170
- export function formatConfig(config: {
171
- version: number;
172
- project: string;
173
- aliases: Record<string, string>;
174
- }): string {
184
+ export function formatConfig(config: { version: number; project: string }): string {
175
185
  const lines: string[] = [];
176
186
  lines.push(`Version: ${config.version}`);
177
187
  lines.push(`Project: ${config.project}`);
178
-
179
- if (Object.keys(config.aliases).length > 0) {
180
- lines.push("");
181
- lines.push("Aliases:");
182
- for (const [alias, path] of Object.entries(config.aliases)) {
183
- lines.push(` ${alias} → ${path}`);
184
- }
185
- }
186
-
187
188
  return lines.join("\n");
188
189
  }
package/src/lib/help.ts CHANGED
@@ -18,6 +18,7 @@ COMMANDS:
18
18
  log Add log entry
19
19
  block Add blocker
20
20
  unblock Remove blocker
21
+ mv, move Move task to different project
21
22
  rm, remove Delete task
22
23
  clean Remove old done tasks
23
24
  check Check for data issues
@@ -173,6 +174,26 @@ USAGE:
173
174
 
174
175
  EXAMPLES:
175
176
  tk unblock tk-2 tk-1
177
+ `,
178
+ mv: `tk mv - Move a task to a different project
179
+
180
+ USAGE:
181
+ tk mv <id> <project>
182
+
183
+ Moves a task to a new project, keeping the same ref.
184
+ Errors if the new ID already exists.
185
+ Updates all blocked_by and parent references.
186
+
187
+ EXAMPLES:
188
+ tk mv api-a1b2 web # Moves api-a1b2 → web-a1b2
189
+ tk mv a1b2 archive # Partial ID resolution
190
+ `,
191
+ move: `tk move - Move a task to a different project (alias for 'mv')
192
+
193
+ USAGE:
194
+ tk move <id> <project>
195
+
196
+ Run 'tk mv --help' for options.
176
197
  `,
177
198
  rm: `tk rm - Delete a task
178
199
 
@@ -223,14 +244,10 @@ USAGE:
223
244
  tk config project Show default project
224
245
  tk config project <name> Set default project
225
246
  tk config project <new> --rename <old> Rename project (old-* → new-*)
226
- tk config alias List aliases
227
- tk config alias <name> <path> Add alias
228
- tk config alias --rm <name> Remove alias
229
247
 
230
248
  EXAMPLES:
231
249
  tk config project api
232
250
  tk config project lsmvec --rename cloudlsmvec
233
- tk config alias web src/web
234
251
  `,
235
252
  completions: `tk completions - Output shell completions
236
253
 
@@ -0,0 +1,222 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import {
3
+ isTaskOverdue,
4
+ daysUntilDue,
5
+ formatRelativeTime,
6
+ formatDate,
7
+ formatLocalDate,
8
+ parseDueDate,
9
+ parseEstimate,
10
+ DUE_SOON_THRESHOLD,
11
+ } from "./time";
12
+
13
+ describe("isTaskOverdue", () => {
14
+ test("returns false when no due date", () => {
15
+ expect(isTaskOverdue(null, "open")).toBe(false);
16
+ });
17
+
18
+ test("returns false for done tasks regardless of date", () => {
19
+ expect(isTaskOverdue("2020-01-01", "done")).toBe(false);
20
+ });
21
+
22
+ test("returns false for active done tasks", () => {
23
+ expect(isTaskOverdue("2020-01-01", "done")).toBe(false);
24
+ });
25
+
26
+ test("returns true for past dates (open)", () => {
27
+ expect(isTaskOverdue("2020-01-01", "open")).toBe(true);
28
+ });
29
+
30
+ test("returns true for past dates (active)", () => {
31
+ expect(isTaskOverdue("2020-06-15", "active")).toBe(true);
32
+ });
33
+
34
+ test("returns false for today", () => {
35
+ const today = new Date().toISOString().split("T")[0]!;
36
+ expect(isTaskOverdue(today, "open")).toBe(false);
37
+ });
38
+
39
+ test("returns false for future dates", () => {
40
+ const future = new Date();
41
+ future.setDate(future.getDate() + 7);
42
+ const dateStr = future.toISOString().split("T")[0]!;
43
+ expect(isTaskOverdue(dateStr, "open")).toBe(false);
44
+ });
45
+
46
+ test("returns false for invalid date string", () => {
47
+ expect(isTaskOverdue("not-a-date", "open")).toBe(false);
48
+ });
49
+ });
50
+
51
+ describe("daysUntilDue", () => {
52
+ test("returns null when no due date", () => {
53
+ expect(daysUntilDue(null, "open")).toBeNull();
54
+ });
55
+
56
+ test("returns null when no due date (active)", () => {
57
+ expect(daysUntilDue(null, "active")).toBeNull();
58
+ });
59
+
60
+ test("returns null for done tasks", () => {
61
+ const future = new Date();
62
+ future.setDate(future.getDate() + 3);
63
+ const dateStr = future.toISOString().split("T")[0]!;
64
+ expect(daysUntilDue(dateStr, "done")).toBeNull();
65
+ });
66
+
67
+ test("returns 0 for due today (open)", () => {
68
+ const today = new Date().toISOString().split("T")[0]!;
69
+ expect(daysUntilDue(today, "open")).toBe(0);
70
+ });
71
+
72
+ test("returns 0 for due today (active)", () => {
73
+ const today = new Date().toISOString().split("T")[0]!;
74
+ expect(daysUntilDue(today, "active")).toBe(0);
75
+ });
76
+
77
+ test("returns N for due in N days", () => {
78
+ const future = new Date();
79
+ future.setDate(future.getDate() + 3);
80
+ const dateStr = future.toISOString().split("T")[0]!;
81
+ expect(daysUntilDue(dateStr, "open")).toBe(3);
82
+ });
83
+
84
+ test("returns null for past dates (already overdue)", () => {
85
+ expect(daysUntilDue("2020-01-01", "open")).toBeNull();
86
+ });
87
+
88
+ test("returns null for invalid date string", () => {
89
+ expect(daysUntilDue("not-a-date", "open")).toBeNull();
90
+ });
91
+
92
+ test("DUE_SOON_THRESHOLD is 7", () => {
93
+ expect(DUE_SOON_THRESHOLD).toBe(7);
94
+ });
95
+ });
96
+
97
+ describe("formatRelativeTime", () => {
98
+ test("returns 'now' for very recent timestamps", () => {
99
+ const ts = new Date().toISOString();
100
+ expect(formatRelativeTime(ts)).toBe("now");
101
+ });
102
+
103
+ test("returns minutes for timestamps minutes ago", () => {
104
+ const ts = new Date(Date.now() - 5 * 60 * 1000).toISOString();
105
+ expect(formatRelativeTime(ts)).toBe("5m");
106
+ });
107
+
108
+ test("returns hours for timestamps hours ago", () => {
109
+ const ts = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
110
+ expect(formatRelativeTime(ts)).toBe("3h");
111
+ });
112
+
113
+ test("returns days for timestamps days ago", () => {
114
+ const ts = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
115
+ expect(formatRelativeTime(ts)).toBe("2d");
116
+ });
117
+ });
118
+
119
+ describe("formatDate", () => {
120
+ test("returns a non-empty string for a valid ISO timestamp", () => {
121
+ const result = formatDate("2024-01-15T10:30:00.000Z");
122
+ expect(typeof result).toBe("string");
123
+ expect(result.length).toBeGreaterThan(0);
124
+ });
125
+ });
126
+
127
+ describe("formatLocalDate", () => {
128
+ test("formats date as YYYY-MM-DD", () => {
129
+ const date = new Date(2026, 0, 15); // Jan 15 2026
130
+ expect(formatLocalDate(date)).toBe("2026-01-15");
131
+ });
132
+
133
+ test("zero-pads month and day", () => {
134
+ const date = new Date(2026, 1, 5); // Feb 5 2026
135
+ expect(formatLocalDate(date)).toBe("2026-02-05");
136
+ });
137
+ });
138
+
139
+ describe("parseDueDate", () => {
140
+ test("returns undefined for undefined input", () => {
141
+ expect(parseDueDate(undefined)).toBeUndefined();
142
+ });
143
+
144
+ test("returns undefined for '-' (clear sentinel)", () => {
145
+ expect(parseDueDate("-")).toBeUndefined();
146
+ });
147
+
148
+ test("returns valid YYYY-MM-DD unchanged", () => {
149
+ expect(parseDueDate("2026-06-15")).toBe("2026-06-15");
150
+ });
151
+
152
+ test("parses +Nd relative date", () => {
153
+ const result = parseDueDate("+7d");
154
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
155
+ const resultDate = new Date(result!);
156
+ const today = new Date();
157
+ const diffDays = Math.round((resultDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
158
+ expect(diffDays).toBeGreaterThanOrEqual(6);
159
+ expect(diffDays).toBeLessThanOrEqual(8);
160
+ });
161
+
162
+ test("parses +Nw relative date", () => {
163
+ const result = parseDueDate("+2w");
164
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
165
+ });
166
+
167
+ test("parses +Nm relative date", () => {
168
+ const result = parseDueDate("+1m");
169
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
170
+ });
171
+
172
+ test("parses +Nh relative date", () => {
173
+ const result = parseDueDate("+3h");
174
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
175
+ });
176
+
177
+ test("throws for invalid relative format", () => {
178
+ expect(() => parseDueDate("+7x")).toThrow("Invalid relative date");
179
+ });
180
+
181
+ test("throws for bare + with no content", () => {
182
+ expect(() => parseDueDate("+")).toThrow("Invalid relative date");
183
+ });
184
+
185
+ test("throws for non-date string", () => {
186
+ expect(() => parseDueDate("not-a-date")).toThrow("Invalid date format");
187
+ });
188
+
189
+ test("throws for invalid calendar date (month 13)", () => {
190
+ expect(() => parseDueDate("2026-13-01")).toThrow("Invalid date format");
191
+ });
192
+
193
+ test("throws for invalid calendar date (day 32)", () => {
194
+ expect(() => parseDueDate("2026-01-32")).toThrow("Invalid date format");
195
+ });
196
+ });
197
+
198
+ describe("parseEstimate", () => {
199
+ test("returns undefined for undefined input", () => {
200
+ expect(parseEstimate(undefined)).toBeUndefined();
201
+ });
202
+
203
+ test("parses valid integer string", () => {
204
+ expect(parseEstimate("42")).toBe(42);
205
+ });
206
+
207
+ test("parses zero", () => {
208
+ expect(parseEstimate("0")).toBe(0);
209
+ });
210
+
211
+ test("throws for non-numeric string", () => {
212
+ expect(() => parseEstimate("abc")).toThrow("Invalid estimate");
213
+ });
214
+
215
+ test("throws for decimal", () => {
216
+ expect(() => parseEstimate("3.5")).toThrow("Invalid estimate");
217
+ });
218
+
219
+ test("throws for negative number", () => {
220
+ expect(() => parseEstimate("-1")).toThrow("Invalid estimate");
221
+ });
222
+ });
package/src/lib/time.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Status } from "../types";
2
2
 
3
+ export const DUE_SOON_THRESHOLD = 7;
4
+
3
5
  /**
4
6
  * Determines if a task is overdue.
5
7
  * Done tasks are never overdue.
@@ -12,11 +14,32 @@ export function isTaskOverdue(dueDate: string | null, status: Status): boolean {
12
14
  const year = parts[0];
13
15
  const month = parts[1];
14
16
  const day = parts[2];
15
- if (!year || !month || !day) return false;
17
+ if (year === undefined || !month || !day) return false;
16
18
  const due = new Date(year, month - 1, day);
17
19
  return due < today;
18
20
  }
19
21
 
22
+ /**
23
+ * Returns the number of days until a task is due (0 = today), or null if:
24
+ * - No due date
25
+ * - Task is done
26
+ * - Task is already overdue
27
+ */
28
+ export function daysUntilDue(dueDate: string | null, status: Status): number | null {
29
+ if (!dueDate || status === "done") return null;
30
+ const today = new Date();
31
+ today.setHours(0, 0, 0, 0);
32
+ const parts = dueDate.split("-").map(Number);
33
+ const year = parts[0];
34
+ const month = parts[1];
35
+ const day = parts[2];
36
+ if (year === undefined || !month || !day) return null;
37
+ const due = new Date(year, month - 1, day);
38
+ const diffMs = due.getTime() - today.getTime();
39
+ if (diffMs < 0) return null; // already overdue
40
+ return Math.round(diffMs / (1000 * 60 * 60 * 24));
41
+ }
42
+
20
43
  /**
21
44
  * Format a timestamp into a human-readable relative time (e.g., "2d", "5h").
22
45
  */
package/src/types.ts CHANGED
@@ -51,7 +51,8 @@ export function parseId(id: string): { project: string; ref: string } | null {
51
51
  // Generate random 4-char ref (a-z0-9)
52
52
  export function generateRef(): string {
53
53
  const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; // 36 chars
54
- const maxValid = 252; // 36 * 7 - reject >= 252 to avoid modulo bias
54
+ const charsetLen = chars.length; // 36
55
+ const maxValid = charsetLen * Math.floor(256 / charsetLen); // 252: reject >= maxValid to avoid modulo bias
55
56
  const result: string[] = [];
56
57
 
57
58
  while (result.length < 4) {
@@ -59,7 +60,7 @@ export function generateRef(): string {
59
60
  crypto.getRandomValues(bytes);
60
61
  for (const b of bytes) {
61
62
  if (b < maxValid && result.length < 4) {
62
- result.push(chars[b % 36]!);
63
+ result.push(chars[b % charsetLen]!);
63
64
  }
64
65
  }
65
66
  }
@@ -70,6 +71,7 @@ export interface TaskWithMeta extends Task {
70
71
  id: string; // computed from project-ref
71
72
  blocked_by_incomplete: boolean;
72
73
  is_overdue: boolean;
74
+ days_until_due: number | null;
73
75
  }
74
76
 
75
77
  export interface Config {
@@ -80,7 +82,6 @@ export interface Config {
80
82
  labels: string[];
81
83
  assignees: string[];
82
84
  };
83
- aliases: Record<string, string>; // alias -> path for auto-project detection
84
85
  clean_after: number | false; // days to keep done tasks, or false to disable
85
86
  }
86
87
 
@@ -92,7 +93,6 @@ export const DEFAULT_CONFIG: Config = {
92
93
  labels: [],
93
94
  assignees: [],
94
95
  },
95
- aliases: {},
96
96
  clean_after: 14,
97
97
  };
98
98