Skip to content

Commit 1beefbd

Browse files
dcramerclaude
andcommitted
feat(cli): Add optional in-progress status for tasks
Add started_at timestamp field to track tasks that are being actively worked on. Tasks can still go directly from pending to complete. New features: - `dex start <id>` command to mark task as in progress - `dex start <id> --force` to re-claim an already-started task - `dex list --in-progress` to filter in-progress tasks - In-progress tasks show [>] icon in blue - Status dashboard shows "In Progress" section before "Ready to Work" - Auto-sets started_at when completing a task that was never started Fixes #85 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 409410d commit 1beefbd

22 files changed

+577
-20
lines changed

docs/src/pages/cli.astro

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,14 @@ dex create "Add token refresh" --parent abc123`}
7070
<li><code>--completed</code> — Only completed tasks</li>
7171
<li><code>--ready</code> — Only unblocked tasks</li>
7272
<li><code>--blocked</code> — Only blocked tasks</li>
73+
<li><code>-i, --in-progress</code> — Only in-progress tasks</li>
7374
<li><code>--query &lt;text&gt;</code> — Search in name/description</li>
7475
<li><code>--issue &lt;number&gt;</code> — Find task by GitHub issue number</li>
7576
<li><code>--flat</code> — Plain list instead of tree</li>
7677
</ul>
7778
<p><strong>Indicators:</strong></p>
7879
<ul>
80+
<li><code>[>]</code> — Task is in progress (blue)</li>
7981
<li><code>[B: xyz]</code> — Task is blocked by task xyz</li>
8082
<li><code>[GH-42]</code> — Task is linked to GitHub issue #42</li>
8183
</ul>
@@ -113,6 +115,23 @@ dex show abc123 --expand # Show ancestor descriptions`}
113115
</Terminal>
114116
</div>
115117

118+
<div class="command-card">
119+
<h3>dex start</h3>
120+
<div class="synopsis">dex start &lt;id&gt; [options]</div>
121+
<p>Mark a task as in progress. Use this to indicate you're actively working on a task.</p>
122+
<ul>
123+
<li><code>-f, --force</code> — Re-claim a task that's already in progress</li>
124+
</ul>
125+
<Terminal title="Terminal">
126+
<Code
127+
code={`dex start abc123 # Mark task as in progress
128+
dex start abc123 --force # Re-claim a task already in progress`}
129+
lang="bash"
130+
theme="vitesse-black"
131+
/>
132+
</Terminal>
133+
</div>
134+
116135
<div class="command-card">
117136
<h3>dex complete</h3>
118137
<div class="synopsis">dex complete &lt;id&gt; --result &lt;result&gt; [options]</div>

src/cli/formatting.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,24 @@ export interface FormatTaskOptions {
99
githubIssue?: number; // GitHub issue number if linked (directly or via ancestor)
1010
}
1111

12+
export interface TaskStatusDisplay {
13+
icon: string;
14+
color: string;
15+
}
16+
17+
/**
18+
* Get the status icon and color for a task based on its state.
19+
*/
20+
export function getTaskStatusDisplay(task: Task): TaskStatusDisplay {
21+
if (task.completed) {
22+
return { icon: "[x]", color: colors.green };
23+
}
24+
if (task.started_at) {
25+
return { icon: "[>]", color: colors.blue };
26+
}
27+
return { icon: "[ ]", color: colors.yellow };
28+
}
29+
1230
export function formatAge(isoDate: string): string {
1331
const ms = Date.now() - new Date(isoDate).getTime();
1432
const mins = Math.floor(ms / 60000);
@@ -124,8 +142,7 @@ export function formatTask(
124142
githubIssue,
125143
} = options;
126144

127-
const statusIcon = task.completed ? "[x]" : "[ ]";
128-
const statusColor = task.completed ? colors.green : colors.yellow;
145+
const { icon: statusIcon, color: statusColor } = getTaskStatusDisplay(task);
129146
const priority =
130147
task.priority !== 1
131148
? ` ${colors.cyan}[p${task.priority}]${colors.reset}`
@@ -168,6 +185,9 @@ export function formatTask(
168185
}
169186
output += `\n${verbosePrefix} ${"Created:".padEnd(labelWidth)} ${colors.dim}${task.created_at}${colors.reset}`;
170187
output += `\n${verbosePrefix} ${"Updated:".padEnd(labelWidth)} ${colors.dim}${task.updated_at}${colors.reset}`;
188+
if (task.started_at) {
189+
output += `\n${verbosePrefix} ${"Started:".padEnd(labelWidth)} ${colors.dim}${task.started_at}${colors.reset}`;
190+
}
171191
if (task.completed_at) {
172192
output += `\n${verbosePrefix} ${"Completed:".padEnd(labelWidth)} ${colors.dim}${task.completed_at}${colors.reset}`;
173193
}

src/cli/help.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ ${colors.bold}COMMANDS:${colors.reset}
2121
list --flat List without tree hierarchy
2222
list --all Include completed tasks
2323
list --archived List archived tasks
24+
list --in-progress List only in-progress tasks
2425
list --query "login" Search name/description
2526
list --json Output as JSON (for scripts)
2627
show <id>... View task details (truncated)
@@ -29,6 +30,8 @@ ${colors.bold}COMMANDS:${colors.reset}
2930
show <id> --json Output as JSON (for scripts)
3031
edit <id> [-n "..."] Edit task
3132
update Alias for edit command
33+
start <id> Mark task as in progress
34+
start <id> --force Re-claim task already in progress
3235
complete <id> --result "..." Mark completed with result
3336
done Alias for complete command
3437
delete <id> Remove task (prompts if has subtasks)

src/cli/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { statusCommand } from "./status.js";
2121
import { configCommand } from "./config.js";
2222
import { dirCommand } from "./dir.js";
2323
import { archiveCommand } from "./archive.js";
24+
import { startCommand } from "./start.js";
2425

2526
export type { CliOptions } from "./utils.js";
2627

@@ -83,6 +84,8 @@ export async function runCli(
8384
case "complete":
8485
case "done":
8586
return await completeCommand(args.slice(1), options);
87+
case "start":
88+
return await startCommand(args.slice(1), options);
8689
case "delete":
8790
case "rm":
8891
case "remove":

src/cli/list.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export async function listCommand(
102102
flat: { short: "f", hasValue: false },
103103
blocked: { short: "b", hasValue: false },
104104
ready: { short: "r", hasValue: false },
105+
"in-progress": { short: "i", hasValue: false },
105106
issue: { hasValue: true },
106107
commit: { hasValue: true },
107108
json: { hasValue: false },
@@ -125,6 +126,7 @@ ${colors.bold}OPTIONS:${colors.reset}
125126
--archived List archived tasks instead of active tasks
126127
-b, --blocked Show only blocked tasks (have incomplete blockers)
127128
-r, --ready Show only ready tasks (pending with no blockers)
129+
-i, --in-progress Show only in-progress tasks (started but not completed)
128130
-q, --query <text> Search in name and description (deprecated: use positional)
129131
-f, --flat Show flat list instead of tree view
130132
--issue <number> Find task by GitHub issue number
@@ -147,6 +149,7 @@ ${colors.bold}EXAMPLES:${colors.reset}
147149
dex list --archived # Show archived tasks
148150
dex list --ready # Show tasks ready to work on
149151
dex list --blocked # Show tasks waiting on dependencies
152+
dex list --in-progress # Show tasks currently in progress
150153
dex list --issue 42 # Find task linked to GitHub issue #42
151154
dex list --commit abc123 # Find task linked to commit abc123
152155
dex list --json | jq '.' # Output JSON for scripting
@@ -208,6 +211,7 @@ ${colors.bold}EXAMPLES:${colors.reset}
208211
query,
209212
blocked: getBooleanFlag(flags, "blocked") || undefined,
210213
ready: getBooleanFlag(flags, "ready") || undefined,
214+
in_progress: getBooleanFlag(flags, "in-progress") || undefined,
211215
});
212216

213217
// Filter by GitHub issue number
@@ -266,6 +270,7 @@ ${colors.bold}EXAMPLES:${colors.reset}
266270
Boolean(query) ||
267271
getBooleanFlag(flags, "blocked") ||
268272
getBooleanFlag(flags, "ready") ||
273+
getBooleanFlag(flags, "in-progress") ||
269274
issueFilter !== undefined ||
270275
commitFilter !== undefined;
271276

src/cli/show.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import type { CliOptions } from "./utils.js";
33
import { createService, exitIfTaskNotFound } from "./utils.js";
44
import { colors, stripAnsi, terminalWidth } from "./colors.js";
55
import { getBooleanFlag, parseArgs } from "./args.js";
6-
import { pluralize, truncateText, wrapText } from "./formatting.js";
6+
import {
7+
getTaskStatusDisplay,
8+
pluralize,
9+
truncateText,
10+
wrapText,
11+
} from "./formatting.js";
712
import { isArchivedTask } from "../core/task-service.js";
813

914
// Max name length for tree display
@@ -55,8 +60,8 @@ function formatTreeTask(
5560
truncateName = SHOW_TREE_NAME_MAX_LENGTH,
5661
childCount,
5762
} = options;
58-
const statusIcon = task.completed ? "[x]" : "[ ]";
59-
const statusColor = task.completed ? colors.green : colors.yellow;
63+
64+
const { icon: statusIcon, color: statusColor } = getTaskStatusDisplay(task);
6065
const name = truncateText(task.name, truncateName);
6166
const childInfo =
6267
childCount !== undefined && childCount > 0
@@ -181,8 +186,7 @@ export function formatTaskShow(
181186
lines.push(""); // Blank line after tree
182187
} else {
183188
// No hierarchy - just show the task header
184-
const statusIcon = task.completed ? "[x]" : "[ ]";
185-
const statusColor = task.completed ? colors.green : colors.yellow;
189+
const { icon: statusIcon, color: statusColor } = getTaskStatusDisplay(task);
186190
lines.push(
187191
`${statusColor}${statusIcon}${colors.reset} ${colors.bold}${task.id}${colors.reset}${priority}: ${task.name}`,
188192
);
@@ -276,6 +280,11 @@ export function formatTaskShow(
276280
lines.push(
277281
`${"Updated:".padEnd(labelWidth)} ${colors.dim}${task.updated_at}${colors.reset}`,
278282
);
283+
if (task.started_at) {
284+
lines.push(
285+
`${"Started:".padEnd(labelWidth)} ${colors.dim}${task.started_at}${colors.reset}`,
286+
);
287+
}
279288
if (task.completed_at) {
280289
lines.push(
281290
`${"Completed:".padEnd(labelWidth)} ${colors.dim}${task.completed_at}${colors.reset}`,

src/cli/start.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { runCli } from "./index.js";
3+
import {
4+
createCliTestFixture,
5+
createTaskAndGetId,
6+
CliTestFixture,
7+
} from "./test-helpers.js";
8+
9+
describe("start command", () => {
10+
let fixture: CliTestFixture;
11+
12+
beforeEach(() => {
13+
fixture = createCliTestFixture();
14+
});
15+
16+
afterEach(() => {
17+
fixture.cleanup();
18+
});
19+
20+
it("marks a pending task as in progress", async () => {
21+
const taskId = await createTaskAndGetId(fixture, "To start");
22+
23+
await runCli(["start", taskId], { storage: fixture.storage });
24+
25+
const out = fixture.output.stdout.join("\n");
26+
expect(out).toContain("Started");
27+
expect(out).toContain(taskId);
28+
29+
// Verify started_at is set
30+
const tasks = await fixture.storage.readAsync();
31+
const task = tasks.tasks.find((t) => t.id === taskId);
32+
expect(task?.started_at).toBeTruthy();
33+
});
34+
35+
it("fails when starting an already-started task without force", async () => {
36+
const taskId = await createTaskAndGetId(fixture, "To start");
37+
38+
// Start the task once
39+
await runCli(["start", taskId], { storage: fixture.storage });
40+
fixture.output.stdout.length = 0;
41+
fixture.output.stderr.length = 0;
42+
43+
// Try to start it again without force
44+
await expect(
45+
runCli(["start", taskId], { storage: fixture.storage }),
46+
).rejects.toThrow("process.exit");
47+
48+
const err = fixture.output.stderr.join("\n");
49+
expect(err).toContain("already in progress");
50+
expect(err).toContain("--force");
51+
});
52+
53+
it("succeeds when starting an already-started task with --force", async () => {
54+
const taskId = await createTaskAndGetId(fixture, "To start");
55+
56+
// Start the task once
57+
await runCli(["start", taskId], { storage: fixture.storage });
58+
59+
// Get the original started_at
60+
let tasks = await fixture.storage.readAsync();
61+
const originalStartedAt = tasks.tasks.find(
62+
(t) => t.id === taskId,
63+
)?.started_at;
64+
65+
// Wait a tiny bit to ensure timestamp would be different
66+
await new Promise((resolve) => setTimeout(resolve, 10));
67+
68+
fixture.output.stdout.length = 0;
69+
fixture.output.stderr.length = 0;
70+
71+
// Start again with force
72+
await runCli(["start", taskId, "--force"], { storage: fixture.storage });
73+
74+
const out = fixture.output.stdout.join("\n");
75+
expect(out).toContain("Started");
76+
77+
// Verify started_at was updated
78+
tasks = await fixture.storage.readAsync();
79+
const newStartedAt = tasks.tasks.find((t) => t.id === taskId)?.started_at;
80+
expect(newStartedAt).toBeTruthy();
81+
expect(newStartedAt).not.toBe(originalStartedAt);
82+
});
83+
84+
it("fails when starting a completed task", async () => {
85+
const taskId = await createTaskAndGetId(fixture, "To complete first");
86+
87+
// Complete the task
88+
await runCli(["complete", taskId, "-r", "Done"], {
89+
storage: fixture.storage,
90+
});
91+
fixture.output.stdout.length = 0;
92+
fixture.output.stderr.length = 0;
93+
94+
// Try to start it
95+
await expect(
96+
runCli(["start", taskId], { storage: fixture.storage }),
97+
).rejects.toThrow("process.exit");
98+
99+
const err = fixture.output.stderr.join("\n");
100+
expect(err).toContain("Cannot start a completed task");
101+
});
102+
103+
it("fails for nonexistent task", async () => {
104+
await expect(
105+
runCli(["start", "nonexist"], { storage: fixture.storage }),
106+
).rejects.toThrow("process.exit");
107+
108+
const err = fixture.output.stderr.join("\n");
109+
expect(err).toContain("not found");
110+
});
111+
112+
it("requires task ID", async () => {
113+
await expect(
114+
runCli(["start"], { storage: fixture.storage }),
115+
).rejects.toThrow("process.exit");
116+
117+
const err = fixture.output.stderr.join("\n");
118+
expect(err).toContain("Task ID is required");
119+
});
120+
121+
it("shows help with --help flag", async () => {
122+
await runCli(["start", "--help"], { storage: fixture.storage });
123+
124+
const out = fixture.output.stdout.join("\n");
125+
expect(out).toContain("dex start");
126+
expect(out).toContain("--force");
127+
expect(out).toContain("in progress");
128+
});
129+
});

src/cli/start.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { CliOptions, createService, formatCliError } from "./utils.js";
2+
import { colors } from "./colors.js";
3+
import { getBooleanFlag, parseArgs } from "./args.js";
4+
import { formatTaskShow } from "./show.js";
5+
6+
export async function startCommand(
7+
args: string[],
8+
options: CliOptions,
9+
): Promise<void> {
10+
const { positional, flags } = parseArgs(
11+
args,
12+
{
13+
force: { short: "f", hasValue: false },
14+
help: { short: "h", hasValue: false },
15+
},
16+
"start",
17+
);
18+
19+
if (getBooleanFlag(flags, "help")) {
20+
console.log(`${colors.bold}dex start${colors.reset} - Mark a task as in progress
21+
22+
${colors.bold}USAGE:${colors.reset}
23+
dex start <task-id> [options]
24+
25+
${colors.bold}ARGUMENTS:${colors.reset}
26+
<task-id> Task ID to start (required)
27+
28+
${colors.bold}OPTIONS:${colors.reset}
29+
-f, --force Re-claim a task that's already in progress
30+
-h, --help Show this help message
31+
32+
${colors.bold}EXAMPLES:${colors.reset}
33+
dex start abc123 # Mark task as in progress
34+
dex start abc123 --force # Re-claim a task already in progress
35+
`);
36+
return;
37+
}
38+
39+
const id = positional[0];
40+
const force = getBooleanFlag(flags, "force");
41+
42+
if (!id) {
43+
console.error(`${colors.red}Error:${colors.reset} Task ID is required`);
44+
console.error(`Usage: dex start <task-id>`);
45+
process.exit(1);
46+
}
47+
48+
const service = createService(options);
49+
try {
50+
const task = await service.start(id, { force });
51+
52+
console.log(
53+
`${colors.green}Started${colors.reset} task ${colors.bold}${id}${colors.reset}`,
54+
);
55+
console.log(formatTaskShow(task));
56+
} catch (err) {
57+
console.error(formatCliError(err));
58+
process.exit(1);
59+
}
60+
}

0 commit comments

Comments
 (0)