@nijaru/tk 0.0.2 → 0.0.3

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
@@ -33,9 +33,9 @@ myapp-x9k2
33
33
  $ tk block x9k2 a7b3 # tests blocked by auth (just use ref)
34
34
 
35
35
  $ tk ready # what can I work on?
36
- ID | PRIO | STATUS | TITLE
37
- ------------------------------------------------------------
38
- myapp-a7b3 | p1 | open | Implement auth
36
+ ID | PRIO | STATUS | TITLE
37
+ -----------------------------------------------------------------
38
+ myapp-a7b3 | p1 | open | Implement auth
39
39
 
40
40
  $ tk start a7b3 # just the ref works everywhere
41
41
  Started: myapp-a7b3
@@ -47,9 +47,9 @@ $ tk done a7b3
47
47
  Completed: myapp-a7b3
48
48
 
49
49
  $ tk ready # tests now unblocked
50
- ID | PRIO | STATUS | TITLE
51
- ------------------------------------------------------------
52
- myapp-x9k2 | p2 | open | Write tests
50
+ ID | PRIO | STATUS | TITLE
51
+ -----------------------------------------------------------------
52
+ myapp-x9k2 | p2 | open | Write tests
53
53
  ```
54
54
 
55
55
  ## Commands
@@ -59,7 +59,7 @@ myapp-x9k2 | p2 | open | Write tests
59
59
  | `tk init` | Initialize (project name from directory) |
60
60
  | `tk add <title>` | Create task |
61
61
  | `tk ls` / `tk list` | List tasks |
62
- | `tk ready` | List open + unblocked tasks |
62
+ | `tk ready` | List active/open + unblocked tasks |
63
63
  | `tk show <id>` | Show task details |
64
64
  | `tk start <id>` | Start working (open → active) |
65
65
  | `tk done <id>` | Complete task |
@@ -130,7 +130,7 @@ tk clean --force # Force clean even if disabled in config
130
130
  tk config # Show all config
131
131
  tk config project # Show default project
132
132
  tk config project api # Set default project
133
- tk config project lsmvec --rename cloudlsmvec # Rename all cloudlsmvec-* → lsmvec-*
133
+ tk config project lsmvec --rename cloudlsmvec # Rename cloudlsmvec-* → lsmvec-*
134
134
  tk config alias # List aliases
135
135
  tk config alias web src/web # Add alias
136
136
  tk config alias --rm web # Remove alias
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/tk",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Minimal task tracker",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.test.ts CHANGED
@@ -243,16 +243,96 @@ describe("tk CLI", () => {
243
243
  const { stdout } = await run(["ls"], testDir);
244
244
  expect(stdout).toContain("No tasks found");
245
245
  });
246
+
247
+ test("sorts by status (active > open > done)", async () => {
248
+ const { stdout: idDone } = await run(["add", "Done task"], testDir);
249
+ await run(["done", idDone.trim()], testDir);
250
+ const { stdout: idActive } = await run(["add", "Active task"], testDir);
251
+ await run(["start", idActive.trim()], testDir);
252
+ await run(["add", "Open task"], testDir);
253
+
254
+ const { stdout } = await run(["ls"], testDir);
255
+ const lines = stdout
256
+ .trim()
257
+ .split("\n")
258
+ .filter((l) => l.includes("task"));
259
+ expect(lines[0]).toContain("Active task");
260
+ expect(lines[1]).toContain("Open task");
261
+ expect(lines[2]).toContain("Done task");
262
+ });
263
+
264
+ test("sorts by priority (p1-4, then p0/none)", async () => {
265
+ await run(["add", "Medium", "-p", "3"], testDir);
266
+ await run(["add", "Urgent", "-p", "1"], testDir);
267
+ await run(["add", "None", "-p", "0"], testDir);
268
+ await run(["add", "Low", "-p", "4"], testDir);
269
+
270
+ const { stdout } = await run(["ls"], testDir);
271
+ const lines = stdout
272
+ .trim()
273
+ .split("\n")
274
+ .filter((l) => /Medium|Urgent|None|Low/.test(l));
275
+ expect(lines[0]).toContain("Urgent");
276
+ expect(lines[1]).toContain("Medium");
277
+ expect(lines[2]).toContain("Low");
278
+ expect(lines[3]).toContain("None");
279
+ });
280
+
281
+ test("hoists overdue tasks to the top of their status group", async () => {
282
+ await run(["add", "Urgent Future", "-p", "1", "--due", "2099-01-01"], testDir);
283
+ await run(["add", "Low Overdue", "-p", "4", "--due", "2020-01-01"], testDir);
284
+
285
+ const { stdout } = await run(["ls"], testDir);
286
+ const lines = stdout
287
+ .trim()
288
+ .split("\n")
289
+ .filter((l) => /Future|Overdue/.test(l));
290
+ expect(lines[0]).toContain("Low Overdue");
291
+ expect(lines[1]).toContain("Urgent Future");
292
+ });
293
+
294
+ test("sorts by due date when priority is equal", async () => {
295
+ await run(["add", "Later", "-p", "3", "--due", "2099-01-02"], testDir);
296
+ await run(["add", "Earlier", "-p", "3", "--due", "2099-01-01"], testDir);
297
+
298
+ const { stdout } = await run(["ls"], testDir);
299
+ const lines = stdout
300
+ .trim()
301
+ .split("\n")
302
+ .filter((l) => /Earlier|Later/.test(l));
303
+ expect(lines[0]).toContain("Earlier");
304
+ expect(lines[1]).toContain("Later");
305
+ });
306
+
307
+ test("sorts done tasks by completion time (newest first)", async () => {
308
+ const { stdout: id1 } = await run(["add", "Done First"], testDir);
309
+ const { stdout: id2 } = await run(["add", "Done Second"], testDir);
310
+ await run(["done", id1.trim()], testDir);
311
+ // Ensure time difference
312
+ await new Promise((r) => setTimeout(r, 10));
313
+ await run(["done", id2.trim()], testDir);
314
+
315
+ const { stdout } = await run(["ls", "-s", "done"], testDir);
316
+ const lines = stdout
317
+ .trim()
318
+ .split("\n")
319
+ .filter((l) => l.includes("Done"));
320
+ expect(lines[0]).toContain("Done Second");
321
+ expect(lines[1]).toContain("Done First");
322
+ });
246
323
  });
247
324
 
248
325
  describe("ready", () => {
249
- test("shows only unblocked open tasks", async () => {
250
- const { stdout: id1 } = await run(["add", "Ready task"], testDir);
326
+ test("shows active and unblocked open tasks", async () => {
327
+ const { stdout: id1 } = await run(["add", "Active task"], testDir);
328
+ await run(["start", id1.trim()], testDir);
329
+ await run(["add", "Open task"], testDir);
251
330
  const { stdout: id2 } = await run(["add", "Blocked task"], testDir);
252
331
  await run(["block", id2.trim(), id1.trim()], testDir);
253
332
 
254
333
  const { stdout } = await run(["ready"], testDir);
255
- expect(stdout).toContain("Ready task");
334
+ expect(stdout).toContain("Active task");
335
+ expect(stdout).toContain("Open task");
256
336
  expect(stdout).not.toContain("Blocked task");
257
337
  });
258
338
 
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { version } from "../package.json";
4
4
 
5
5
  import * as storage from "./db/storage";
6
6
  import { parsePriority } from "./lib/priority";
7
+ import { parseDueDate, parseEstimate } from "./lib/time";
7
8
  import {
8
9
  formatTaskList,
9
10
  formatTaskDetail,
@@ -15,12 +16,27 @@ import {
15
16
  } from "./lib/format";
16
17
  import { findRoot, setWorkingDir } from "./lib/root";
17
18
  import { parseId } from "./types";
18
- import type { Status } from "./types";
19
+ import type { Status, TaskWithMeta } from "./types";
19
20
  import { BASH_COMPLETION, ZSH_COMPLETION, FISH_COMPLETION } from "./lib/completions";
20
21
 
21
22
  const VALID_STATUSES: Status[] = ["open", "active", "done"];
22
23
  const PROJECT_PATTERN = /^[a-z][a-z0-9]*$/;
23
24
 
25
+ const COMMON_OPTIONS = {
26
+ project: { type: "string", short: "P" },
27
+ priority: { type: "string", short: "p" },
28
+ labels: { type: "string", short: "l" },
29
+ } as const;
30
+
31
+ const TASK_MUTATION_OPTIONS = {
32
+ ...COMMON_OPTIONS,
33
+ description: { type: "string", short: "d" },
34
+ assignees: { type: "string", short: "A" },
35
+ parent: { type: "string" },
36
+ estimate: { type: "string" },
37
+ due: { type: "string" },
38
+ } as const;
39
+
24
40
  function validateProject(name: string): void {
25
41
  if (!PROJECT_PATTERN.test(name)) {
26
42
  throw new Error(
@@ -39,74 +55,16 @@ function parseStatus(input: string | undefined): Status | undefined {
39
55
 
40
56
  function parseLimit(input: string | undefined): number | undefined {
41
57
  if (!input) return undefined;
42
- const n = parseInt(input, 10);
43
- if (isNaN(n) || n < 1) {
58
+ if (!/^\d+$/.test(input)) {
44
59
  throw new Error(`Invalid limit: ${input}. Must be a positive number.`);
45
60
  }
46
- return n;
47
- }
48
-
49
- function parseEstimate(input: string | undefined): number | undefined {
50
- if (!input) return undefined;
51
- const n = parseInt(input, 10);
52
- if (isNaN(n) || n < 0) {
53
- throw new Error(`Invalid estimate: ${input}. Must be a non-negative number.`);
61
+ const n = Number(input);
62
+ if (n < 1) {
63
+ throw new Error(`Invalid limit: ${input}. Must be a positive number.`);
54
64
  }
55
65
  return n;
56
66
  }
57
67
 
58
- function formatLocalDate(date: Date): string {
59
- const year = date.getFullYear();
60
- const month = String(date.getMonth() + 1).padStart(2, "0");
61
- const day = String(date.getDate()).padStart(2, "0");
62
- return `${year}-${month}-${day}`;
63
- }
64
-
65
- function parseDueDate(input: string | undefined): string | undefined {
66
- if (!input) return undefined;
67
- if (input === "-") return undefined; // clear
68
-
69
- // Handle relative dates like +7d
70
- if (input.startsWith("+")) {
71
- const match = input.match(/^\+(\d+)([dwmh])$/);
72
- if (match && match[1] && match[2]) {
73
- const num = match[1];
74
- const unit = match[2];
75
- const n = parseInt(num, 10);
76
- const now = new Date();
77
- switch (unit) {
78
- case "h":
79
- now.setHours(now.getHours() + n);
80
- break;
81
- case "d":
82
- now.setDate(now.getDate() + n);
83
- break;
84
- case "w":
85
- now.setDate(now.getDate() + n * 7);
86
- break;
87
- case "m":
88
- now.setMonth(now.getMonth() + n);
89
- break;
90
- }
91
- return formatLocalDate(now);
92
- }
93
- throw new Error(`Invalid relative date: ${input}. Use format like +7d, +2w, +1m`);
94
- }
95
-
96
- // Validate YYYY-MM-DD format - return as-is to avoid timezone issues
97
- const dateMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
98
- if (dateMatch) {
99
- const [, , month, day] = dateMatch;
100
- const m = parseInt(month!, 10);
101
- const d = parseInt(day!, 10);
102
- // Basic validation: month 1-12, day 1-31
103
- if (m >= 1 && m <= 12 && d >= 1 && d <= 31) {
104
- return input; // Return as-is, already in correct format
105
- }
106
- }
107
- throw new Error(`Invalid date: ${input}. Use YYYY-MM-DD or +Nd format.`);
108
- }
109
-
110
68
  function parseLabels(input: string | undefined): string[] | undefined {
111
69
  if (!input) return undefined;
112
70
  return input
@@ -146,9 +104,6 @@ function resolveId(input: string | undefined, context: string): string {
146
104
  const resolved = storage.resolveId(input);
147
105
  if (resolved) return resolved;
148
106
 
149
- // Check if it's a valid full ID format
150
- if (parseId(input)) return input;
151
-
152
107
  // Check for ambiguous matches
153
108
  const matches = storage.findMatchingIds(input);
154
109
  if (matches.length > 1) {
@@ -157,9 +112,26 @@ function resolveId(input: string | undefined, context: string): string {
157
112
  );
158
113
  }
159
114
 
115
+ // If it's a valid full ID format but doesn't exist, we still return it
116
+ // and let getTask handle the "not found" error consistently.
117
+ if (parseId(input)) return input;
118
+
160
119
  throw new Error(`Task not found: ${input}`);
161
120
  }
162
121
 
122
+ /**
123
+ * Resolves an ID and fetches the task.
124
+ * Handles "Task not found" and cleanup output automatically.
125
+ */
126
+ function resolveTask(input: string | undefined, context: string): TaskWithMeta {
127
+ const id = resolveId(input, context);
128
+ const result = storage.getTask(id);
129
+ if (!result) error(`Task not found: ${id}`);
130
+
131
+ outputCleanup(id, result.cleanup);
132
+ return result.task;
133
+ }
134
+
163
135
  const rawArgs = process.argv.slice(2);
164
136
 
165
137
  function isFlag(arg: string): boolean {
@@ -233,7 +205,7 @@ COMMANDS:
233
205
  init Initialize .tasks/ directory
234
206
  add Create task
235
207
  ls, list List tasks
236
- ready List ready tasks (open + unblocked)
208
+ ready List ready tasks (active/open + unblocked)
237
209
  show Show task details
238
210
  start Start working (open -> active)
239
211
  done Complete task
@@ -314,12 +286,12 @@ EXAMPLES:
314
286
 
315
287
  Run 'tk ls --help' for options.
316
288
  `,
317
- ready: `tk ready - List ready tasks (open + unblocked)
289
+ ready: `tk ready - List ready tasks (active/open + unblocked)
318
290
 
319
291
  USAGE:
320
292
  tk ready
321
293
 
322
- Shows open tasks that are not blocked by any incomplete task.
294
+ Shows active or open tasks that are not blocked by any incomplete task.
323
295
  `,
324
296
  show: `tk show - Show task details
325
297
 
@@ -538,16 +510,7 @@ function main() {
538
510
  case "add": {
539
511
  const { values, positionals } = parseArgs({
540
512
  args,
541
- options: {
542
- description: { type: "string", short: "d" },
543
- priority: { type: "string", short: "p" },
544
- project: { type: "string", short: "P" },
545
- labels: { type: "string", short: "l" },
546
- assignees: { type: "string", short: "A" },
547
- parent: { type: "string" },
548
- estimate: { type: "string" },
549
- due: { type: "string" },
550
- },
513
+ options: TASK_MUTATION_OPTIONS,
551
514
  allowPositionals: true,
552
515
  });
553
516
  const title = positionals[0]?.trim();
@@ -588,10 +551,9 @@ function main() {
588
551
  const { values } = parseArgs({
589
552
  args,
590
553
  options: {
554
+ ...COMMON_OPTIONS,
555
+ label: COMMON_OPTIONS.labels, // alias for consistency
591
556
  status: { type: "string", short: "s" },
592
- priority: { type: "string", short: "p" },
593
- project: { type: "string", short: "P" },
594
- label: { type: "string", short: "l" },
595
557
  assignee: { type: "string" },
596
558
  parent: { type: "string" },
597
559
  roots: { type: "boolean" },
@@ -617,7 +579,7 @@ function main() {
617
579
  status,
618
580
  priority,
619
581
  project: values.project,
620
- label: values.label,
582
+ label: values.label ?? values.labels,
621
583
  assignee: values.assignee,
622
584
  parent: parentFilter,
623
585
  roots: values.roots,
@@ -635,44 +597,32 @@ function main() {
635
597
  }
636
598
 
637
599
  case "show": {
638
- const id = resolveId(args[0], "show");
639
- const result = storage.getTaskWithMeta(id);
640
- if (!result) error(`Task not found: ${id}`);
641
- outputCleanup(id, result.cleanup);
642
- output(result.task, formatTaskDetail(result.task, result.task.logs));
600
+ const task = resolveTask(args[0], "show");
601
+ output(task, formatTaskDetail(task, task.logs));
643
602
  break;
644
603
  }
645
604
 
646
605
  case "start": {
647
- const id = resolveId(args[0], "start");
648
- const result = storage.getTask(id);
649
- if (!result) error(`Task not found: ${id}`);
650
- outputCleanup(id, result.cleanup);
651
- if (result.task.status === "active")
652
- error(`Task already active. Use 'tk done ${id}' to complete it.`);
653
- if (result.task.status === "done") error(`Task already done. Use 'tk reopen ${id}' first.`);
654
- const updated = storage.updateTaskStatus(id, "active");
655
- output(updated, green(`Started: ${id}`));
606
+ const task = resolveTask(args[0], "start");
607
+ if (task.status === "active")
608
+ error(`Task already active. Use 'tk done ${task.id}' to complete it.`);
609
+ if (task.status === "done") error(`Task already done. Use 'tk reopen ${task.id}' first.`);
610
+ const updated = storage.updateTaskStatus(task.id, "active");
611
+ output(updated, green(`Started: ${task.id}`));
656
612
  break;
657
613
  }
658
614
 
659
615
  case "done": {
660
- const id = resolveId(args[0], "done");
661
- const result = storage.getTask(id);
662
- if (!result) error(`Task not found: ${id}`);
663
- outputCleanup(id, result.cleanup);
664
- const updated = storage.updateTaskStatus(id, "done");
665
- output(updated, green(`Completed: ${id}`));
616
+ const task = resolveTask(args[0], "done");
617
+ const updated = storage.updateTaskStatus(task.id, "done");
618
+ output(updated, green(`Completed: ${task.id}`));
666
619
  break;
667
620
  }
668
621
 
669
622
  case "reopen": {
670
- const id = resolveId(args[0], "reopen");
671
- const result = storage.getTask(id);
672
- if (!result) error(`Task not found: ${id}`);
673
- outputCleanup(id, result.cleanup);
674
- const updated = storage.updateTaskStatus(id, "open");
675
- output(updated, green(`Reopened: ${id}`));
623
+ const task = resolveTask(args[0], "reopen");
624
+ const updated = storage.updateTaskStatus(task.id, "open");
625
+ output(updated, green(`Reopened: ${task.id}`));
676
626
  break;
677
627
  }
678
628
 
@@ -680,21 +630,12 @@ function main() {
680
630
  const { values, positionals } = parseArgs({
681
631
  args,
682
632
  options: {
633
+ ...TASK_MUTATION_OPTIONS,
683
634
  title: { type: "string", short: "t" },
684
- description: { type: "string", short: "d" },
685
- priority: { type: "string", short: "p" },
686
- labels: { type: "string", short: "l" },
687
- assignees: { type: "string", short: "A" },
688
- parent: { type: "string" },
689
- estimate: { type: "string" },
690
- due: { type: "string" },
691
635
  },
692
636
  allowPositionals: true,
693
637
  });
694
- const id = resolveId(positionals[0], "edit");
695
- const result = storage.getTask(id);
696
- if (!result) error(`Task not found: ${id}`);
697
- outputCleanup(id, result.cleanup);
638
+ const task = resolveTask(positionals[0], "edit");
698
639
 
699
640
  // Handle label modifications (+tag, -tag)
700
641
  let labels: string[] | undefined;
@@ -702,13 +643,11 @@ function main() {
702
643
  if (values.labels.startsWith("+")) {
703
644
  // Add label (avoid duplicates)
704
645
  const newLabel = values.labels.slice(1);
705
- labels = result.task.labels.includes(newLabel)
706
- ? result.task.labels
707
- : [...result.task.labels, newLabel];
646
+ labels = task.labels.includes(newLabel) ? task.labels : [...task.labels, newLabel];
708
647
  } else if (values.labels.startsWith("-")) {
709
648
  // Remove label
710
649
  const removeLabel = values.labels.slice(1);
711
- labels = result.task.labels.filter((l) => l !== removeLabel);
650
+ labels = task.labels.filter((l: string) => l !== removeLabel);
712
651
  } else {
713
652
  // Replace labels
714
653
  labels = parseLabels(values.labels);
@@ -723,11 +662,11 @@ function main() {
723
662
  const resolved = storage.resolveId(values.parent);
724
663
  if (!resolved) error(`Parent task not found: ${values.parent}`);
725
664
  resolvedParent = resolved;
726
- const parentResult = storage.validateParent(resolvedParent, id);
665
+ const parentResult = storage.validateParent(resolvedParent, task.id);
727
666
  if (!parentResult.ok) error(parentResult.error!);
728
667
  }
729
668
 
730
- const updated = storage.updateTask(id, {
669
+ const updated = storage.updateTask(task.id, {
731
670
  title: values.title?.trim() || undefined,
732
671
  description: values.description,
733
672
  priority: values.priority ? parsePriority(values.priority) : undefined,
@@ -737,12 +676,12 @@ function main() {
737
676
  estimate: values.estimate === "-" ? null : (parseEstimate(values.estimate) ?? undefined),
738
677
  due_date: values.due === "-" ? null : (parseDueDate(values.due) ?? undefined),
739
678
  });
740
- output(updated, green(`Updated: ${id}`));
679
+ output(updated, green(`Updated: ${task.id}`));
741
680
  break;
742
681
  }
743
682
 
744
683
  case "log": {
745
- const id = resolveId(args[0], "log");
684
+ const task = resolveTask(args[0], "log");
746
685
  const message = args[1]?.trim();
747
686
  if (!message) error('Message required: tk log <id> "<message>"');
748
687
  if (args.length > 2) {
@@ -751,11 +690,8 @@ function main() {
751
690
  ` Got ${args.length - 1} arguments instead of 1`,
752
691
  );
753
692
  }
754
- const result = storage.getTask(id);
755
- if (!result) error(`Task not found: ${id}`);
756
- outputCleanup(id, result.cleanup);
757
- const entry = storage.addLogEntry(id, message);
758
- output(entry, green(`Logged: ${id}`));
693
+ const entry = storage.addLogEntry(task.id, message);
694
+ output(entry, green(`Logged: ${task.id}`));
759
695
  break;
760
696
  }
761
697
 
@@ -809,11 +745,10 @@ function main() {
809
745
  // Get days from CLI or config
810
746
  let days: number | false;
811
747
  if (values["older-than"] !== undefined) {
812
- const n = parseInt(values["older-than"], 10);
813
- if (isNaN(n) || n < 0) {
748
+ if (!/^\d+$/.test(values["older-than"])) {
814
749
  error(`Invalid --older-than: ${values["older-than"]}. Use a number of days.`);
815
750
  }
816
- days = n;
751
+ days = Number(values["older-than"]);
817
752
  } else {
818
753
  days = config.clean_after;
819
754
  // Validate config value at runtime