@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/lib/help.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { version } from "../../package.json";
|
|
2
|
+
|
|
3
|
+
export const MAIN_HELP = `tk v${version} - Task tracker for AI agents
|
|
4
|
+
|
|
5
|
+
USAGE:
|
|
6
|
+
tk <command> [options]
|
|
7
|
+
|
|
8
|
+
COMMANDS:
|
|
9
|
+
init Initialize .tasks/ directory
|
|
10
|
+
add Create task
|
|
11
|
+
ls, list List tasks
|
|
12
|
+
ready List ready tasks (active/open + unblocked)
|
|
13
|
+
show Show task details
|
|
14
|
+
start Start working (open -> active)
|
|
15
|
+
done Complete task
|
|
16
|
+
reopen Reopen task
|
|
17
|
+
edit Edit task
|
|
18
|
+
log Add log entry
|
|
19
|
+
block Add blocker
|
|
20
|
+
unblock Remove blocker
|
|
21
|
+
mv, move Move task to different project
|
|
22
|
+
rm, remove Delete task
|
|
23
|
+
clean Remove old done tasks
|
|
24
|
+
check Check for data issues
|
|
25
|
+
config Show/set configuration
|
|
26
|
+
completions Output shell completions
|
|
27
|
+
|
|
28
|
+
GLOBAL OPTIONS:
|
|
29
|
+
-C <dir> Run in different directory
|
|
30
|
+
--json Output as JSON
|
|
31
|
+
-h, --help Show help
|
|
32
|
+
-V Show version
|
|
33
|
+
|
|
34
|
+
Run 'tk <command> --help' for command-specific options.
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export const COMMAND_HELP: Record<string, string> = {
|
|
38
|
+
init: `tk init - Initialize .tasks/ directory
|
|
39
|
+
|
|
40
|
+
USAGE:
|
|
41
|
+
tk init [options]
|
|
42
|
+
|
|
43
|
+
OPTIONS:
|
|
44
|
+
-P, --project <name> Set default project name
|
|
45
|
+
`,
|
|
46
|
+
add: `tk add - Create a new task
|
|
47
|
+
|
|
48
|
+
USAGE:
|
|
49
|
+
tk add <title> [options]
|
|
50
|
+
|
|
51
|
+
OPTIONS:
|
|
52
|
+
-p, --priority <0-4> Priority (0=none, 1=urgent, 2=high, 3=medium, 4=low)
|
|
53
|
+
-P, --project <name> Project prefix for ID
|
|
54
|
+
-d, --description <text> Description
|
|
55
|
+
-l, --labels <csv> Labels (comma-separated)
|
|
56
|
+
-A, --assignees <csv> Assignees (comma-separated, @me for git user)
|
|
57
|
+
--parent <id> Parent task ID
|
|
58
|
+
--estimate <n> Estimate (user-defined units)
|
|
59
|
+
--due <date> Due date (YYYY-MM-DD or +Nh/+Nd/+Nw/+Nm)
|
|
60
|
+
|
|
61
|
+
EXAMPLES:
|
|
62
|
+
tk add "Fix login bug" -p 1
|
|
63
|
+
tk add "New feature" -P api -l bug,urgent
|
|
64
|
+
tk add "Sprint task" --due +7d
|
|
65
|
+
`,
|
|
66
|
+
ls: `tk ls - List tasks
|
|
67
|
+
|
|
68
|
+
USAGE:
|
|
69
|
+
tk ls [options]
|
|
70
|
+
|
|
71
|
+
OPTIONS:
|
|
72
|
+
-s, --status <status> Filter by status (open, active, done)
|
|
73
|
+
-p, --priority <0-4> Filter by priority
|
|
74
|
+
-P, --project <name> Filter by project
|
|
75
|
+
-l, --label <label> Filter by label
|
|
76
|
+
--assignee <name> Filter by assignee
|
|
77
|
+
--parent <id> Filter by parent
|
|
78
|
+
--roots Show only root tasks (no parent)
|
|
79
|
+
--overdue Show only overdue tasks
|
|
80
|
+
-n, --limit <n> Limit results (default: 20)
|
|
81
|
+
-a, --all Show all (no limit)
|
|
82
|
+
|
|
83
|
+
EXAMPLES:
|
|
84
|
+
tk ls -s open -p 1 # Urgent open tasks
|
|
85
|
+
tk ls --overdue # Overdue tasks
|
|
86
|
+
tk ls -P api --roots # Root tasks in api project
|
|
87
|
+
`,
|
|
88
|
+
list: `tk list - List tasks (alias for 'ls')
|
|
89
|
+
|
|
90
|
+
Run 'tk ls --help' for options.
|
|
91
|
+
`,
|
|
92
|
+
ready: `tk ready - List ready tasks (active/open + unblocked)
|
|
93
|
+
|
|
94
|
+
USAGE:
|
|
95
|
+
tk ready
|
|
96
|
+
|
|
97
|
+
Shows active or open tasks that are not blocked by any incomplete task.
|
|
98
|
+
`,
|
|
99
|
+
show: `tk show - Show task details
|
|
100
|
+
|
|
101
|
+
USAGE:
|
|
102
|
+
tk show <id>
|
|
103
|
+
|
|
104
|
+
EXAMPLES:
|
|
105
|
+
tk show tk-1
|
|
106
|
+
tk show 1 # If unambiguous
|
|
107
|
+
`,
|
|
108
|
+
start: `tk start - Start working on a task
|
|
109
|
+
|
|
110
|
+
USAGE:
|
|
111
|
+
tk start <id>
|
|
112
|
+
|
|
113
|
+
Changes status from open to active.
|
|
114
|
+
`,
|
|
115
|
+
done: `tk done - Complete a task
|
|
116
|
+
|
|
117
|
+
USAGE:
|
|
118
|
+
tk done <id>
|
|
119
|
+
|
|
120
|
+
Changes status to done.
|
|
121
|
+
`,
|
|
122
|
+
reopen: `tk reopen - Reopen a task
|
|
123
|
+
|
|
124
|
+
USAGE:
|
|
125
|
+
tk reopen <id>
|
|
126
|
+
|
|
127
|
+
Changes status back to open.
|
|
128
|
+
`,
|
|
129
|
+
edit: `tk edit - Edit a task
|
|
130
|
+
|
|
131
|
+
USAGE:
|
|
132
|
+
tk edit <id> [options]
|
|
133
|
+
|
|
134
|
+
OPTIONS:
|
|
135
|
+
-t, --title <text> New title
|
|
136
|
+
-d, --description <text> New description
|
|
137
|
+
-p, --priority <0-4> New priority
|
|
138
|
+
-l, --labels <csv> Replace labels (use +tag/-tag to add/remove)
|
|
139
|
+
-A, --assignees <csv> Replace assignees
|
|
140
|
+
--parent <id> Set parent (use - to clear)
|
|
141
|
+
--estimate <n> Set estimate (use - to clear)
|
|
142
|
+
--due <date> Set due date (use - to clear)
|
|
143
|
+
|
|
144
|
+
EXAMPLES:
|
|
145
|
+
tk edit tk-1 -t "New title"
|
|
146
|
+
tk edit tk-1 -l +urgent # Add label
|
|
147
|
+
tk edit tk-1 --due - # Clear due date
|
|
148
|
+
`,
|
|
149
|
+
log: `tk log - Add a log entry to a task
|
|
150
|
+
|
|
151
|
+
USAGE:
|
|
152
|
+
tk log <id> "<message>"
|
|
153
|
+
|
|
154
|
+
Message must be quoted.
|
|
155
|
+
|
|
156
|
+
EXAMPLES:
|
|
157
|
+
tk log tk-1 "Started implementation"
|
|
158
|
+
tk log tk-1 "Blocked on API changes"
|
|
159
|
+
`,
|
|
160
|
+
block: `tk block - Add a blocker dependency
|
|
161
|
+
|
|
162
|
+
USAGE:
|
|
163
|
+
tk block <task> <blocker>
|
|
164
|
+
|
|
165
|
+
The first task becomes blocked by the second.
|
|
166
|
+
|
|
167
|
+
EXAMPLES:
|
|
168
|
+
tk block tk-2 tk-1 # tk-2 is blocked by tk-1
|
|
169
|
+
`,
|
|
170
|
+
unblock: `tk unblock - Remove a blocker dependency
|
|
171
|
+
|
|
172
|
+
USAGE:
|
|
173
|
+
tk unblock <task> <blocker>
|
|
174
|
+
|
|
175
|
+
EXAMPLES:
|
|
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.
|
|
197
|
+
`,
|
|
198
|
+
rm: `tk rm - Delete a task
|
|
199
|
+
|
|
200
|
+
USAGE:
|
|
201
|
+
tk rm <id>
|
|
202
|
+
|
|
203
|
+
EXAMPLES:
|
|
204
|
+
tk rm tk-1
|
|
205
|
+
`,
|
|
206
|
+
remove: `tk remove - Delete a task (alias for 'rm')
|
|
207
|
+
|
|
208
|
+
USAGE:
|
|
209
|
+
tk remove <id>
|
|
210
|
+
`,
|
|
211
|
+
clean: `tk clean - Remove old completed tasks
|
|
212
|
+
|
|
213
|
+
USAGE:
|
|
214
|
+
tk clean [options]
|
|
215
|
+
|
|
216
|
+
OPTIONS:
|
|
217
|
+
--older-than <days> Age threshold in days (default: from config, 14)
|
|
218
|
+
-f, --force Remove all done tasks (ignores age and disabled state)
|
|
219
|
+
|
|
220
|
+
EXAMPLES:
|
|
221
|
+
tk clean # Remove done tasks older than config.clean_after days
|
|
222
|
+
tk clean --older-than 30 # Remove done tasks older than 30 days
|
|
223
|
+
tk clean --force # Remove all done tasks regardless of age
|
|
224
|
+
`,
|
|
225
|
+
check: `tk check - Check for data issues
|
|
226
|
+
|
|
227
|
+
USAGE:
|
|
228
|
+
tk check
|
|
229
|
+
|
|
230
|
+
Scans all tasks for issues. Auto-fixable issues (orphaned references, ID
|
|
231
|
+
mismatches) are fixed automatically. Unfixable issues (corrupted JSON) are
|
|
232
|
+
reported for manual intervention.
|
|
233
|
+
|
|
234
|
+
Note: Auto-fixing also happens during normal task operations (show, done,
|
|
235
|
+
edit, etc.) - this command is for bulk cleanup or diagnostics.
|
|
236
|
+
|
|
237
|
+
EXAMPLES:
|
|
238
|
+
tk check # Scan and fix all tasks
|
|
239
|
+
`,
|
|
240
|
+
config: `tk config - Show or set configuration
|
|
241
|
+
|
|
242
|
+
USAGE:
|
|
243
|
+
tk config Show all config
|
|
244
|
+
tk config project Show default project
|
|
245
|
+
tk config project <name> Set default project
|
|
246
|
+
tk config project <new> --rename <old> Rename project (old-* → new-*)
|
|
247
|
+
|
|
248
|
+
EXAMPLES:
|
|
249
|
+
tk config project api
|
|
250
|
+
tk config project lsmvec --rename cloudlsmvec
|
|
251
|
+
`,
|
|
252
|
+
completions: `tk completions - Output shell completions
|
|
253
|
+
|
|
254
|
+
USAGE:
|
|
255
|
+
tk completions <shell>
|
|
256
|
+
|
|
257
|
+
SHELLS:
|
|
258
|
+
bash Bash completion script
|
|
259
|
+
zsh Zsh completion script
|
|
260
|
+
fish Fish completion script
|
|
261
|
+
|
|
262
|
+
EXAMPLES:
|
|
263
|
+
eval "$(tk completions bash)" # Add to ~/.bashrc
|
|
264
|
+
eval "$(tk completions zsh)" # Add to ~/.zshrc
|
|
265
|
+
`,
|
|
266
|
+
};
|
|
@@ -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 (
|
|
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
|
|
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 %
|
|
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
|
|