@nijaru/tk 0.0.3 → 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 +1 -1
- package/src/cli.test.ts +236 -40
- package/src/cli.ts +52 -308
- package/src/db/storage.ts +87 -51
- package/src/lib/completions.ts +23 -8
- package/src/lib/format.test.ts +81 -5
- package/src/lib/format.ts +48 -35
- package/src/lib/help.ts +266 -0
- package/src/lib/time.test.ts +222 -0
- package/src/lib/time.ts +24 -1
- package/src/types.ts +4 -4
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]
|
|
81
|
-
const sB = STATUS_ORDER[b.status]
|
|
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
|
-
|
|
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
|
}
|
|
@@ -158,7 +163,7 @@ export function renameProject(oldProject: string, newProject: string): RenameRes
|
|
|
158
163
|
// Find tasks to rename
|
|
159
164
|
const toRename = allTasks.filter((t) => t.project === oldProject);
|
|
160
165
|
if (toRename.length === 0) {
|
|
161
|
-
throw new Error(`No tasks found with project "${oldProject}"
|
|
166
|
+
throw new Error(`No tasks found with project "${oldProject}". Run 'tk ls' to see projects.`);
|
|
162
167
|
}
|
|
163
168
|
|
|
164
169
|
// Check for collisions
|
|
@@ -166,7 +171,7 @@ export function renameProject(oldProject: string, newProject: string): RenameRes
|
|
|
166
171
|
for (const task of toRename) {
|
|
167
172
|
const newId = `${newProject}-${task.ref}`;
|
|
168
173
|
if (existingIds.has(newId)) {
|
|
169
|
-
throw new Error(`Cannot rename: "${newId}" already exists
|
|
174
|
+
throw new Error(`Cannot rename: "${newId}" already exists. Choose a different project name.`);
|
|
170
175
|
}
|
|
171
176
|
}
|
|
172
177
|
|
|
@@ -234,18 +239,75 @@ export function renameProject(oldProject: string, newProject: string): RenameRes
|
|
|
234
239
|
};
|
|
235
240
|
}
|
|
236
241
|
|
|
237
|
-
export
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
//
|
|
888
|
-
if (parseId(input)) {
|
|
889
|
-
|
|
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
|
-
|
|
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[] {
|
package/src/lib/completions.ts
CHANGED
|
@@ -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
|
|
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
|
-
'
|
|
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
|
|
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'
|
package/src/lib/format.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
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", () => {
|
|
@@ -92,13 +94,13 @@ describe("formatTaskRow", () => {
|
|
|
92
94
|
expect(parts.length).toBe(4); // ID | PRIO | STATUS | TITLE
|
|
93
95
|
});
|
|
94
96
|
|
|
95
|
-
test("truncates long project names
|
|
97
|
+
test("truncates long project names with ellipsis", () => {
|
|
96
98
|
const longProjectTask: TaskWithMeta = {
|
|
97
99
|
...task,
|
|
98
100
|
id: "mylongproject-a1b2",
|
|
99
101
|
};
|
|
100
102
|
const result = formatTaskRow(longProjectTask, false);
|
|
101
|
-
expect(result).toContain("
|
|
103
|
+
expect(result).toContain("mylon…-a1b2");
|
|
102
104
|
expect(result).not.toContain("mylongproject");
|
|
103
105
|
});
|
|
104
106
|
|
|
@@ -127,11 +129,81 @@ 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", () => {
|
|
133
|
-
test("returns message for empty list", () => {
|
|
134
|
-
expect(formatTaskList([])).toBe("No tasks found.");
|
|
201
|
+
test("returns helpful message for empty list", () => {
|
|
202
|
+
expect(formatTaskList([])).toBe("No tasks found. Run 'tk add \"title\"' to create one.");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("allows custom empty hint", () => {
|
|
206
|
+
expect(formatTaskList([], undefined, "Custom hint")).toBe("Custom hint");
|
|
135
207
|
});
|
|
136
208
|
|
|
137
209
|
test("includes header and divider", () => {
|
|
@@ -157,6 +229,7 @@ describe("formatTaskList", () => {
|
|
|
157
229
|
external: {},
|
|
158
230
|
blocked_by_incomplete: false,
|
|
159
231
|
is_overdue: false,
|
|
232
|
+
days_until_due: null,
|
|
160
233
|
},
|
|
161
234
|
];
|
|
162
235
|
const result = formatTaskList(tasks, false);
|
|
@@ -190,6 +263,7 @@ describe("formatTaskList", () => {
|
|
|
190
263
|
external: {},
|
|
191
264
|
blocked_by_incomplete: false,
|
|
192
265
|
is_overdue: false,
|
|
266
|
+
days_until_due: null,
|
|
193
267
|
},
|
|
194
268
|
{
|
|
195
269
|
id: "tk-c3d4",
|
|
@@ -212,6 +286,7 @@ describe("formatTaskList", () => {
|
|
|
212
286
|
external: {},
|
|
213
287
|
blocked_by_incomplete: false,
|
|
214
288
|
is_overdue: false,
|
|
289
|
+
days_until_due: null,
|
|
215
290
|
},
|
|
216
291
|
];
|
|
217
292
|
const result = formatTaskList(tasks, false);
|
|
@@ -244,6 +319,7 @@ describe("formatTaskDetail", () => {
|
|
|
244
319
|
external: {},
|
|
245
320
|
blocked_by_incomplete: true,
|
|
246
321
|
is_overdue: false,
|
|
322
|
+
days_until_due: null,
|
|
247
323
|
};
|
|
248
324
|
|
|
249
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
|
-
|
|
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) */
|
|
@@ -43,23 +42,30 @@ export function dim(msg: string): string {
|
|
|
43
42
|
return shouldUseColor() ? `${DIM}${msg}${RESET}` : msg;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
function truncate(text: string, maxLen: number): string {
|
|
46
|
+
return text.length <= maxLen ? text : text.slice(0, maxLen - 1) + "…";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatId(id: string, maxLen = 11): string {
|
|
50
|
+
// Truncate project prefix to fit maxLen, keep full 4-char ref
|
|
51
|
+
// "myproject-a1b2" -> "mypr…-a1b2"
|
|
49
52
|
const dash = id.lastIndexOf("-");
|
|
50
|
-
if (dash === -1) return id
|
|
53
|
+
if (dash === -1) return truncate(id, maxLen);
|
|
51
54
|
const project = id.slice(0, dash);
|
|
52
55
|
const ref = id.slice(dash + 1);
|
|
53
|
-
const
|
|
54
|
-
return
|
|
56
|
+
const maxProjectLen = maxLen - ref.length - 1; // -1 for dash
|
|
57
|
+
return project.length > maxProjectLen
|
|
58
|
+
? `${truncate(project, maxProjectLen)}-${ref}`
|
|
59
|
+
: `${project}-${ref}`;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
export function formatTaskRow(task: TaskWithMeta, useColor?: boolean): string {
|
|
58
63
|
const color = useColor ?? shouldUseColor();
|
|
59
64
|
const id = formatId(task.id).padEnd(11);
|
|
60
65
|
const priority = formatPriority(task.priority).padEnd(4);
|
|
61
|
-
const title = task.title.slice(0, 50);
|
|
62
66
|
const isOverdue = task.is_overdue;
|
|
67
|
+
const dueSoon =
|
|
68
|
+
!isOverdue && task.days_until_due !== null && task.days_until_due <= DUE_SOON_THRESHOLD;
|
|
63
69
|
|
|
64
70
|
let statusText: string = task.status;
|
|
65
71
|
if (task.status === "done" && task.completed_at) {
|
|
@@ -69,17 +75,29 @@ export function formatTaskRow(task: TaskWithMeta, useColor?: boolean): string {
|
|
|
69
75
|
|
|
70
76
|
if (color) {
|
|
71
77
|
const pc = PRIORITY_COLORS[task.priority];
|
|
72
|
-
const sc = isOverdue ? OVERDUE_COLOR : STATUS_COLORS[task.status];
|
|
78
|
+
const sc = isOverdue ? OVERDUE_COLOR : dueSoon ? DUE_SOON_COLOR : STATUS_COLORS[task.status];
|
|
73
79
|
const tc = task.status === "done" ? DIM : "";
|
|
80
|
+
const title = truncate(task.title, 50);
|
|
74
81
|
return `${id} | ${pc}${priority}${RESET} | ${sc}${status}${RESET} | ${tc}${title}${RESET}`;
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
let dueSoonMarker = "";
|
|
85
|
+
if (dueSoon) {
|
|
86
|
+
dueSoonMarker = task.days_until_due === 0 ? " [due today]" : ` [due ${task.days_until_due}d]`;
|
|
87
|
+
}
|
|
77
88
|
const overdueMarker = isOverdue ? " [OVERDUE]" : "";
|
|
78
|
-
|
|
89
|
+
const title = truncate(task.title, 50);
|
|
90
|
+
return `${id} | ${priority} | ${status} | ${title}${overdueMarker}${dueSoonMarker}`;
|
|
79
91
|
}
|
|
80
92
|
|
|
81
|
-
export function formatTaskList(
|
|
82
|
-
|
|
93
|
+
export function formatTaskList(
|
|
94
|
+
tasks: TaskWithMeta[],
|
|
95
|
+
useColor?: boolean,
|
|
96
|
+
emptyHint?: string,
|
|
97
|
+
): string {
|
|
98
|
+
if (tasks.length === 0) {
|
|
99
|
+
return emptyHint ?? "No tasks found. Run 'tk add \"title\"' to create one.";
|
|
100
|
+
}
|
|
83
101
|
const color = useColor ?? shouldUseColor();
|
|
84
102
|
|
|
85
103
|
const header = "ID | PRIO | STATUS | TITLE";
|
|
@@ -124,8 +142,16 @@ export function formatTaskDetail(task: TaskWithMeta, logs: LogEntry[], useColor?
|
|
|
124
142
|
}
|
|
125
143
|
|
|
126
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
|
+
}
|
|
127
153
|
const overdueStr = task.is_overdue ? ` ${oc}[OVERDUE]${r}` : "";
|
|
128
|
-
lines.push(`Due: ${task.due_date}${overdueStr}`);
|
|
154
|
+
lines.push(`Due: ${task.due_date}${overdueStr}${dueSoonStr}`);
|
|
129
155
|
}
|
|
130
156
|
|
|
131
157
|
lines.push(`Created: ${formatDate(task.created_at)}`);
|
|
@@ -155,22 +181,9 @@ export function formatJson(data: unknown): string {
|
|
|
155
181
|
return JSON.stringify(data, null, 2);
|
|
156
182
|
}
|
|
157
183
|
|
|
158
|
-
export function formatConfig(config: {
|
|
159
|
-
version: number;
|
|
160
|
-
project: string;
|
|
161
|
-
aliases: Record<string, string>;
|
|
162
|
-
}): string {
|
|
184
|
+
export function formatConfig(config: { version: number; project: string }): string {
|
|
163
185
|
const lines: string[] = [];
|
|
164
186
|
lines.push(`Version: ${config.version}`);
|
|
165
187
|
lines.push(`Project: ${config.project}`);
|
|
166
|
-
|
|
167
|
-
if (Object.keys(config.aliases).length > 0) {
|
|
168
|
-
lines.push("");
|
|
169
|
-
lines.push("Aliases:");
|
|
170
|
-
for (const [alias, path] of Object.entries(config.aliases)) {
|
|
171
|
-
lines.push(` ${alias} → ${path}`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
188
|
return lines.join("\n");
|
|
176
189
|
}
|