Compare commits
3 Commits
c695aeecfc
...
fb51f12663
| Author | SHA1 | Date |
|---|---|---|
|
|
fb51f12663 | |
|
|
b9ee50e824 | |
|
|
3c43af697b |
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
id: task-002
|
||||
title: Add actualHours tracking and time estimation reporting
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-12-26 01:50'
|
||||
labels:
|
||||
- feature
|
||||
- time-tracking
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Continue the time tracking and estimation feature set:
|
||||
|
||||
1. **actualHours field**: Add ability to track actual hours spent on completed tasks
|
||||
- Add `actualHours` field to Task type
|
||||
- Add CLI support (`--actual-hours` flag)
|
||||
- Add MCP support
|
||||
- Add web UI input in TaskDetailsModal
|
||||
|
||||
2. **Velocity/Estimation Reporting**:
|
||||
- Add estimated vs actual hours comparison in Statistics
|
||||
- Show total estimated hours for backlog
|
||||
- Show estimation accuracy metrics
|
||||
- Add to velocity dashboard
|
||||
|
||||
3. **Integration possibilities**:
|
||||
- Time tracking tool integrations (Toggl, etc.)
|
||||
- Invoicing data export
|
||||
- Project estimation roll-ups
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 actualHours field added to Task type
|
||||
- [ ] #2 CLI --actual-hours flag working
|
||||
- [ ] #3 Web UI shows actual hours input
|
||||
- [ ] #4 Statistics page shows estimated vs actual comparison
|
||||
- [ ] #5 Tests for actualHours functionality
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
id: task-003
|
||||
title: 'Attention Pipeline: Workload visualization for upcoming work'
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-12-26 01:57'
|
||||
updated_date: '2025-12-26 01:57'
|
||||
labels:
|
||||
- feature
|
||||
- dashboard
|
||||
- time-tracking
|
||||
- ux
|
||||
dependencies: []
|
||||
priority: high
|
||||
estimated_hours: 16
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
## Overview
|
||||
Create a visual "attention pipeline" that shows workload distribution over time, helping users assess how busy they are at any given point in the future.
|
||||
|
||||
## MVP (Phase 1) - Using Existing Data
|
||||
Build a workload view using existing fields (no schema changes):
|
||||
|
||||
### Grouping Logic
|
||||
- **Today**: Tasks with `doToday: true`
|
||||
- **This Week**: Tasks with `priority: high`
|
||||
- **Next 2 Weeks**: Tasks with `priority: medium`
|
||||
- **Backlog**: Tasks with `priority: low` or no priority
|
||||
|
||||
### UI Components
|
||||
1. **Workload Tab** in web dashboard (alongside Board, Statistics)
|
||||
2. **Group Cards** showing:
|
||||
- Task list for each time bucket
|
||||
- Total estimated hours
|
||||
- Progress bar (capacity vs scheduled)
|
||||
3. **Summary Stats**:
|
||||
- Daily capacity setting (configurable, default 6h)
|
||||
- "Runway" calculation (days until backlog cleared)
|
||||
- Overload warnings
|
||||
|
||||
### Interactions
|
||||
- Click task to open details
|
||||
- Quick-toggle doToday from this view
|
||||
- Collapse/expand groups
|
||||
|
||||
## Phase 2 - With Scheduling Fields
|
||||
- Add `targetDate` field to Task type
|
||||
- Calendar heatmap visualization
|
||||
- Drag-drop scheduling
|
||||
- Conflict detection
|
||||
|
||||
## Phase 3 - Advanced
|
||||
- Capacity configuration (work days, hours/day)
|
||||
- Sprint/iteration support
|
||||
- Integration with external calendars
|
||||
- Forecasting ("when will X be done?")
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 New Workload tab visible in web dashboard
|
||||
- [ ] #2 Tasks grouped by Today/This Week/Next 2 Weeks/Backlog
|
||||
- [ ] #3 Total estimated hours shown per group
|
||||
- [ ] #4 Capacity setting with runway calculation
|
||||
- [ ] #5 Click task to open details modal
|
||||
- [ ] #6 Responsive design (mobile-friendly)
|
||||
<!-- AC:END -->
|
||||
|
|
@ -8,14 +8,14 @@
|
|||
* - Serves aggregated task data with project metadata
|
||||
*/
|
||||
|
||||
import { type Server, type ServerWebSocket, $ } from "bun";
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readdir, stat, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
||||
import { join, basename, dirname } from "node:path";
|
||||
import { type FSWatcher, watch } from "node:fs";
|
||||
import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { $, type Server, type ServerWebSocket } from "bun";
|
||||
import { parseTask } from "../markdown/parser.ts";
|
||||
import type { Task } from "../types/index.ts";
|
||||
import { sortByTaskId } from "../utils/task-sorting.ts";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// @ts-expect-error - Bun file import
|
||||
import favicon from "../web/favicon.png" with { type: "file" };
|
||||
|
|
@ -56,7 +56,7 @@ const DEFAULT_COLORS = [
|
|||
];
|
||||
|
||||
export class BacklogAggregator {
|
||||
private server: Server | null = null;
|
||||
private server: Server<unknown> | null = null;
|
||||
private sockets = new Set<ServerWebSocket<unknown>>();
|
||||
private projects = new Map<string, ProjectConfig>();
|
||||
private tasks = new Map<string, AggregatedTask>();
|
||||
|
|
@ -74,7 +74,9 @@ export class BacklogAggregator {
|
|||
}
|
||||
|
||||
private getNextColor(): string {
|
||||
const color = this.config.colors[this.colorIndex % this.config.colors.length];
|
||||
const colors = this.config.colors.length > 0 ? this.config.colors : DEFAULT_COLORS;
|
||||
// DEFAULT_COLORS always has elements, so this is safe
|
||||
const color = colors[this.colorIndex % colors.length] as string;
|
||||
this.colorIndex++;
|
||||
return color;
|
||||
}
|
||||
|
|
@ -121,7 +123,7 @@ export class BacklogAggregator {
|
|||
GET: async () => Response.json({ status: "ok", projects: this.projects.size, tasks: this.tasks.size }),
|
||||
},
|
||||
},
|
||||
fetch: async (req: Request, server: Server) => {
|
||||
fetch: async (req: Request, server: Server<unknown>) => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Handle WebSocket upgrade
|
||||
|
|
@ -220,7 +222,7 @@ export class BacklogAggregator {
|
|||
const taskPath = join(tasksPath, entry.name);
|
||||
try {
|
||||
const content = await readFile(taskPath, "utf-8");
|
||||
const task = parseTask(content, taskPath);
|
||||
const task = parseTask(content);
|
||||
if (task) {
|
||||
const key = `${projectPath}:${task.id}`;
|
||||
const existing = this.tasks.get(key);
|
||||
|
|
@ -318,13 +320,22 @@ export class BacklogAggregator {
|
|||
try {
|
||||
const configContent = await readFile(configPath, "utf-8");
|
||||
const nameMatch = configContent.match(/project_name:\s*["']?([^"'\n]+)["']?/);
|
||||
if (nameMatch) {
|
||||
if (nameMatch?.[1]) {
|
||||
projectName = nameMatch[1].trim();
|
||||
}
|
||||
} catch {
|
||||
// Use directory name
|
||||
}
|
||||
|
||||
// Skip if a project with this name already exists (prevents duplicates from symlinks/mirrors)
|
||||
const existingProjectWithName = Array.from(this.projects.values()).find((p) => p.name === projectName);
|
||||
if (existingProjectWithName) {
|
||||
console.log(
|
||||
`Skipping duplicate project: ${projectName} (${projectPath}) - already exists at ${existingProjectWithName.path}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const project: ProjectConfig = {
|
||||
path: projectPath,
|
||||
name: projectName,
|
||||
|
|
@ -390,7 +401,7 @@ export class BacklogAggregator {
|
|||
|
||||
try {
|
||||
const content = await readFile(taskPath, "utf-8");
|
||||
const task = parseTask(content, taskPath);
|
||||
const task = parseTask(content);
|
||||
if (task) {
|
||||
const aggregatedTask: AggregatedTask = {
|
||||
...task,
|
||||
|
|
@ -659,14 +670,14 @@ created_date: '${dateStr}'
|
|||
if (labels && labels.length > 0) {
|
||||
frontmatter += `labels: [${labels.join(", ")}]\n`;
|
||||
} else {
|
||||
frontmatter += `labels: []\n`;
|
||||
frontmatter += "labels: []\n";
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
frontmatter += `priority: ${priority}\n`;
|
||||
}
|
||||
|
||||
frontmatter += `dependencies: []\n---\n\n`;
|
||||
frontmatter += "dependencies: []\n---\n\n";
|
||||
|
||||
let content = frontmatter;
|
||||
content += `## Description\n\n${description || "No description provided."}\n`;
|
||||
|
|
@ -839,7 +850,7 @@ created_date: '${dateStr}'
|
|||
|
||||
// Find and replace the description section
|
||||
// Handle both Windows (\r\n) and Unix (\n) line endings
|
||||
const descriptionRegex = /## Description\r?\n\r?\n[\s\S]*?(?=\r?\n## |\r?\n---|\Z)/;
|
||||
const descriptionRegex = /## Description\r?\n\r?\n[\s\S]*?(?=\r?\n## |\r?\n---|Z)/;
|
||||
const newDescriptionSection = `## Description${lineEnding}${lineEnding}${newDescription}${lineEnding}`;
|
||||
|
||||
if (descriptionRegex.test(content)) {
|
||||
|
|
@ -883,7 +894,7 @@ created_date: '${dateStr}'
|
|||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||
const match = entry.name.match(/^task-(\d+)\.md$/);
|
||||
if (match) {
|
||||
if (match?.[1]) {
|
||||
const id = Number.parseInt(match[1], 10);
|
||||
if (id > maxId) maxId = id;
|
||||
}
|
||||
|
|
@ -901,10 +912,12 @@ created_date: '${dateStr}'
|
|||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const portIndex = args.indexOf("--port");
|
||||
const port = portIndex !== -1 ? Number.parseInt(args[portIndex + 1], 10) : 6420;
|
||||
const portArg = portIndex !== -1 ? args[portIndex + 1] : undefined;
|
||||
const port = portArg ? Number.parseInt(portArg, 10) : 6420;
|
||||
|
||||
const pathsIndex = args.indexOf("--paths");
|
||||
const paths = pathsIndex !== -1 ? args[pathsIndex + 1].split(",") : ["/opt/websites", "/opt/apps", "/opt/ops"];
|
||||
const pathsArg = pathsIndex !== -1 ? args[pathsIndex + 1] : undefined;
|
||||
const paths = pathsArg ? pathsArg.split(",") : ["/opt/websites", "/opt/apps", "/opt/ops"];
|
||||
|
||||
const aggregator = new BacklogAggregator({ port, scanPaths: paths });
|
||||
|
||||
|
|
|
|||
26
src/cli.ts
26
src/cli.ts
|
|
@ -1189,6 +1189,7 @@ taskCmd
|
|||
)
|
||||
.option("--plan <text>", "add implementation plan")
|
||||
.option("--notes <text>", "add implementation notes")
|
||||
.option("--hours <number>", "set estimated hours to complete")
|
||||
.option("--draft")
|
||||
.option("-p, --parent <taskId>", "specify parent task ID")
|
||||
.option(
|
||||
|
|
@ -1253,6 +1254,17 @@ taskCmd
|
|||
task.implementationNotes = String(options.notes);
|
||||
}
|
||||
|
||||
// Handle estimated hours
|
||||
if (options.hours !== undefined) {
|
||||
const parsed = Number(options.hours);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
console.error("Invalid hours. Must be a non-negative number.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
task.estimatedHours = parsed;
|
||||
}
|
||||
|
||||
// Workaround for bun compile issue with commander options
|
||||
const isPlainFlag = options.plain || process.argv.includes("--plain");
|
||||
|
||||
|
|
@ -1764,6 +1776,7 @@ taskCmd
|
|||
const soFar = Array.isArray(previous) ? previous : previous ? [previous] : [];
|
||||
return [...soFar, value];
|
||||
})
|
||||
.option("--hours <number>", "set estimated hours to complete")
|
||||
.action(async (taskId: string, options) => {
|
||||
const cwd = process.cwd();
|
||||
const core = new Core(cwd);
|
||||
|
|
@ -1907,6 +1920,15 @@ taskCmd
|
|||
if (uncheckCriteria) {
|
||||
editArgs.acceptanceCriteriaUncheck = uncheckCriteria;
|
||||
}
|
||||
if (options.hours !== undefined) {
|
||||
const parsed = Number(options.hours);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
console.error("Invalid hours. Must be a non-negative number.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
editArgs.estimatedHours = parsed;
|
||||
}
|
||||
|
||||
let updatedTask: Task;
|
||||
try {
|
||||
|
|
@ -3077,9 +3099,7 @@ program
|
|||
const { BacklogAggregator } = await import("./aggregator/index.ts");
|
||||
|
||||
const port = Number.parseInt(options.port, 10) || 6420;
|
||||
const scanPaths = options.paths
|
||||
? options.paths.split(",").map((p: string) => p.trim())
|
||||
: [process.cwd()];
|
||||
const scanPaths = options.paths ? options.paths.split(",").map((p: string) => p.trim()) : [process.cwd()];
|
||||
|
||||
const aggregator = new BacklogAggregator({ port, scanPaths });
|
||||
|
||||
|
|
|
|||
|
|
@ -414,6 +414,7 @@ export class Core {
|
|||
acceptanceCriteriaItems?: import("../types/index.ts").AcceptanceCriterion[];
|
||||
implementationPlan?: string;
|
||||
implementationNotes?: string;
|
||||
estimatedHours?: number;
|
||||
},
|
||||
autoCommit?: boolean,
|
||||
): Promise<Task> {
|
||||
|
|
@ -437,6 +438,7 @@ export class Core {
|
|||
}),
|
||||
...(typeof taskData.implementationPlan === "string" && { implementationPlan: taskData.implementationPlan }),
|
||||
...(typeof taskData.implementationNotes === "string" && { implementationNotes: taskData.implementationNotes }),
|
||||
...(typeof taskData.estimatedHours === "number" && { estimatedHours: taskData.estimatedHours }),
|
||||
};
|
||||
|
||||
// Check if this should be a draft based on status
|
||||
|
|
@ -512,6 +514,7 @@ export class Core {
|
|||
...(typeof input.implementationNotes === "string" && { implementationNotes: input.implementationNotes }),
|
||||
...(acceptanceCriteriaItems.length > 0 && { acceptanceCriteriaItems }),
|
||||
...(statusHistory.length > 0 && { statusHistory }),
|
||||
...(typeof input.estimatedHours === "number" && { estimatedHours: input.estimatedHours }),
|
||||
};
|
||||
|
||||
const isDraft = (status || "").toLowerCase() === "draft";
|
||||
|
|
@ -655,6 +658,16 @@ export class Core {
|
|||
}
|
||||
}
|
||||
|
||||
if (input.estimatedHours !== undefined) {
|
||||
if (Number.isNaN(input.estimatedHours) || input.estimatedHours < 0) {
|
||||
throw new Error("Estimated hours must be a non-negative number.");
|
||||
}
|
||||
if (task.estimatedHours !== input.estimatedHours) {
|
||||
task.estimatedHours = input.estimatedHours;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.assignee !== undefined) {
|
||||
const sanitizedAssignee = normalizeStringList(input.assignee) ?? [];
|
||||
if (!stringArraysEqual(sanitizedAssignee, task.assignee ?? [])) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import matter from "gray-matter";
|
||||
import type { AcceptanceCriterion, Decision, Document, ParsedMarkdown, StatusHistoryEntry, Task } from "../types/index.ts";
|
||||
import type {
|
||||
AcceptanceCriterion,
|
||||
Decision,
|
||||
Document,
|
||||
ParsedMarkdown,
|
||||
StatusHistoryEntry,
|
||||
Task,
|
||||
} from "../types/index.ts";
|
||||
import { AcceptanceCriteriaManager, extractStructuredSection, STRUCTURED_SECTION_KEYS } from "./structured-sections.ts";
|
||||
|
||||
function preprocessFrontmatter(frontmatter: string): string {
|
||||
|
|
@ -163,6 +170,12 @@ export function parseTask(content: string): Task {
|
|||
onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined,
|
||||
statusHistory: parseStatusHistory(frontmatter.status_history),
|
||||
doToday: frontmatter.do_today === true || frontmatter.doToday === true,
|
||||
estimatedHours:
|
||||
frontmatter.estimated_hours !== undefined
|
||||
? Number(frontmatter.estimated_hours)
|
||||
: frontmatter.estimatedHours !== undefined
|
||||
? Number(frontmatter.estimatedHours)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export function serializeTask(task: Task): string {
|
|||
...(task.onStatusChange && { onStatusChange: task.onStatusChange }),
|
||||
...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }),
|
||||
...(task.doToday && { do_today: task.doToday }),
|
||||
...(task.estimatedHours !== undefined && { estimated_hours: task.estimatedHours }),
|
||||
};
|
||||
|
||||
let contentBody = task.rawContent ?? "";
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type TaskCreateArgs = {
|
|||
parentTaskId?: string;
|
||||
acceptanceCriteria?: string[];
|
||||
dependencies?: string[];
|
||||
estimatedHours?: number;
|
||||
};
|
||||
|
||||
export type TaskListArgs = {
|
||||
|
|
@ -75,6 +76,7 @@ export class TaskHandlers {
|
|||
dependencies: args.dependencies,
|
||||
parentTaskId: args.parentTaskId,
|
||||
acceptanceCriteria,
|
||||
estimatedHours: args.estimatedHours,
|
||||
});
|
||||
|
||||
return await formatTaskCallResult(createdTask);
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ export function generateTaskCreateSchema(config: BacklogConfig): JsonSchema {
|
|||
type: "string",
|
||||
maxLength: 50,
|
||||
},
|
||||
estimatedHours: {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
description: "Estimated hours to complete this task",
|
||||
},
|
||||
},
|
||||
required: ["title"],
|
||||
additionalProperties: false,
|
||||
|
|
@ -200,6 +205,11 @@ export function generateTaskEditSchema(config: BacklogConfig): JsonSchema {
|
|||
},
|
||||
maxItems: 50,
|
||||
},
|
||||
estimatedHours: {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
description: "Estimated hours to complete this task",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Core } from "../core/backlog.ts";
|
||||
import { parseTask } from "../markdown/parser.ts";
|
||||
import { serializeTask } from "../markdown/serializer.ts";
|
||||
import type { Task } from "../types/index.ts";
|
||||
|
||||
describe("Estimated Hours", () => {
|
||||
describe("Parser", () => {
|
||||
it("should parse estimatedHours from frontmatter (snake_case)", () => {
|
||||
const content = `---
|
||||
id: task-1
|
||||
title: "Task with hours"
|
||||
status: "To Do"
|
||||
estimated_hours: 4.5
|
||||
---
|
||||
|
||||
Task description.`;
|
||||
|
||||
const task = parseTask(content);
|
||||
|
||||
expect(task.estimatedHours).toBe(4.5);
|
||||
});
|
||||
|
||||
it("should parse estimatedHours from frontmatter (camelCase)", () => {
|
||||
const content = `---
|
||||
id: task-2
|
||||
title: "Task with hours"
|
||||
status: "To Do"
|
||||
estimatedHours: 8
|
||||
---
|
||||
|
||||
Task description.`;
|
||||
|
||||
const task = parseTask(content);
|
||||
|
||||
expect(task.estimatedHours).toBe(8);
|
||||
});
|
||||
|
||||
it("should prefer snake_case over camelCase", () => {
|
||||
const content = `---
|
||||
id: task-3
|
||||
title: "Task with both"
|
||||
status: "To Do"
|
||||
estimated_hours: 2
|
||||
estimatedHours: 5
|
||||
---
|
||||
|
||||
Task description.`;
|
||||
|
||||
const task = parseTask(content);
|
||||
|
||||
expect(task.estimatedHours).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle missing estimatedHours", () => {
|
||||
const content = `---
|
||||
id: task-4
|
||||
title: "Task without hours"
|
||||
status: "To Do"
|
||||
---
|
||||
|
||||
Task description.`;
|
||||
|
||||
const task = parseTask(content);
|
||||
|
||||
expect(task.estimatedHours).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle zero estimatedHours", () => {
|
||||
const content = `---
|
||||
id: task-5
|
||||
title: "Zero hours task"
|
||||
status: "To Do"
|
||||
estimated_hours: 0
|
||||
---
|
||||
|
||||
Task description.`;
|
||||
|
||||
const task = parseTask(content);
|
||||
|
||||
expect(task.estimatedHours).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle integer estimatedHours", () => {
|
||||
const content = `---
|
||||
id: task-6
|
||||
title: "Integer hours task"
|
||||
status: "To Do"
|
||||
estimated_hours: 10
|
||||
---
|
||||
|
||||
Task description.`;
|
||||
|
||||
const task = parseTask(content);
|
||||
|
||||
expect(task.estimatedHours).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Serializer", () => {
|
||||
it("should serialize estimatedHours to snake_case", () => {
|
||||
const task: Task = {
|
||||
id: "task-1",
|
||||
title: "Test Task",
|
||||
status: "To Do",
|
||||
assignee: [],
|
||||
createdDate: "2025-12-25",
|
||||
labels: [],
|
||||
dependencies: [],
|
||||
description: "Task description.",
|
||||
estimatedHours: 4.5,
|
||||
};
|
||||
|
||||
const result = serializeTask(task);
|
||||
|
||||
expect(result).toContain("estimated_hours: 4.5");
|
||||
});
|
||||
|
||||
it("should not include estimatedHours if undefined", () => {
|
||||
const task: Task = {
|
||||
id: "task-2",
|
||||
title: "Test Task",
|
||||
status: "To Do",
|
||||
assignee: [],
|
||||
createdDate: "2025-12-25",
|
||||
labels: [],
|
||||
dependencies: [],
|
||||
description: "Task description.",
|
||||
};
|
||||
|
||||
const result = serializeTask(task);
|
||||
|
||||
expect(result).not.toContain("estimated_hours");
|
||||
expect(result).not.toContain("estimatedHours");
|
||||
});
|
||||
|
||||
it("should serialize zero estimatedHours", () => {
|
||||
const task: Task = {
|
||||
id: "task-3",
|
||||
title: "Zero Hours Task",
|
||||
status: "To Do",
|
||||
assignee: [],
|
||||
createdDate: "2025-12-25",
|
||||
labels: [],
|
||||
dependencies: [],
|
||||
description: "Task description.",
|
||||
estimatedHours: 0,
|
||||
};
|
||||
|
||||
const result = serializeTask(task);
|
||||
|
||||
expect(result).toContain("estimated_hours: 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Round-trip", () => {
|
||||
it("should preserve estimatedHours through parse/serialize cycle", () => {
|
||||
const originalContent = `---
|
||||
id: task-roundtrip
|
||||
title: "Round Trip Task"
|
||||
status: "In Progress"
|
||||
estimated_hours: 12.75
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
This task should preserve its estimated hours.`;
|
||||
|
||||
const task = parseTask(originalContent);
|
||||
expect(task.estimatedHours).toBe(12.75);
|
||||
|
||||
const serialized = serializeTask(task);
|
||||
expect(serialized).toContain("estimated_hours: 12.75");
|
||||
|
||||
const reparsed = parseTask(serialized);
|
||||
expect(reparsed.estimatedHours).toBe(12.75);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Core API", () => {
|
||||
let testDir: string;
|
||||
let core: Core;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = mkdtempSync(join(tmpdir(), "backlog-estimated-hours-test-"));
|
||||
core = new Core(testDir);
|
||||
await core.filesystem.ensureBacklogStructure();
|
||||
|
||||
// Write config with valid statuses
|
||||
const configContent = `project_name: Test Project
|
||||
statuses:
|
||||
- To Do
|
||||
- In Progress
|
||||
- Done
|
||||
labels: []
|
||||
milestones: []
|
||||
`;
|
||||
await writeFile(join(testDir, "backlog", "config.yml"), configContent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it("should create task with estimatedHours", async () => {
|
||||
const task = await core.createTaskFromData(
|
||||
{
|
||||
title: "Task with hours",
|
||||
status: "To Do",
|
||||
estimatedHours: 6,
|
||||
},
|
||||
false, // disable auto-commit
|
||||
);
|
||||
|
||||
expect(task.estimatedHours).toBe(6);
|
||||
});
|
||||
|
||||
it("should update task estimatedHours", async () => {
|
||||
const task = await core.createTaskFromData(
|
||||
{
|
||||
title: "Task to update",
|
||||
status: "To Do",
|
||||
estimatedHours: 4,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const updated = await core.updateTaskFromInput(
|
||||
task.id,
|
||||
{
|
||||
estimatedHours: 8,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(updated.estimatedHours).toBe(8);
|
||||
});
|
||||
|
||||
it("should preserve estimatedHours when updating other fields", async () => {
|
||||
const task = await core.createTaskFromData(
|
||||
{
|
||||
title: "Task with hours",
|
||||
status: "To Do",
|
||||
estimatedHours: 10,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const updated = await core.updateTaskFromInput(
|
||||
task.id,
|
||||
{
|
||||
status: "In Progress",
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(updated.estimatedHours).toBe(10);
|
||||
expect(updated.status).toBe("In Progress");
|
||||
});
|
||||
|
||||
it("should reject negative estimatedHours", async () => {
|
||||
const task = await core.createTaskFromData(
|
||||
{
|
||||
title: "Task to test",
|
||||
status: "To Do",
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(
|
||||
core.updateTaskFromInput(
|
||||
task.id,
|
||||
{
|
||||
estimatedHours: -5,
|
||||
},
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -50,6 +50,8 @@ export interface Task {
|
|||
statusHistory?: StatusHistoryEntry[];
|
||||
/** Flag to mark task for "Do Today" daily focus list */
|
||||
doToday?: boolean;
|
||||
/** Estimated hours to complete the task (for time tracking and invoicing) */
|
||||
estimatedHours?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,6 +74,7 @@ export interface TaskCreateInput {
|
|||
implementationNotes?: string;
|
||||
acceptanceCriteria?: AcceptanceCriterionInput[];
|
||||
rawContent?: string;
|
||||
estimatedHours?: number;
|
||||
}
|
||||
|
||||
export interface TaskUpdateInput {
|
||||
|
|
@ -100,6 +103,7 @@ export interface TaskUpdateInput {
|
|||
uncheckAcceptanceCriteria?: number[];
|
||||
rawContent?: string;
|
||||
doToday?: boolean;
|
||||
estimatedHours?: number;
|
||||
}
|
||||
|
||||
export interface TaskListFilter {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface TaskEditArgs {
|
|||
acceptanceCriteriaRemove?: number[];
|
||||
acceptanceCriteriaCheck?: number[];
|
||||
acceptanceCriteriaUncheck?: number[];
|
||||
estimatedHours?: number;
|
||||
}
|
||||
|
||||
export type TaskEditRequest = TaskEditArgs & { id: string };
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ export function buildTaskUpdateInput(args: TaskEditArgs): TaskUpdateInput {
|
|||
updateInput.ordinal = args.ordinal;
|
||||
}
|
||||
|
||||
if (typeof args.estimatedHours === "number") {
|
||||
updateInput.estimatedHours = args.estimatedHours;
|
||||
}
|
||||
|
||||
const labels = normalizeStringList(args.labels);
|
||||
if (labels) {
|
||||
updateInput.labels = labels;
|
||||
|
|
|
|||
|
|
@ -275,6 +275,15 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onUpdate, onEdit, onDragStart
|
|||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mt-3 pt-2 border-t border-gray-100 dark:border-gray-600 transition-colors duration-200">
|
||||
<span>Created: {formatDate(task.createdDate)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{task.estimatedHours !== undefined && task.estimatedHours > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-blue-600 dark:text-blue-400 font-medium" title="Estimated hours">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{task.estimatedHours}h
|
||||
</span>
|
||||
)}
|
||||
{task.priority && (
|
||||
<span className={`font-medium transition-colors duration-200 ${
|
||||
task.priority === 'high' ? 'text-red-600 dark:text-red-400' :
|
||||
|
|
@ -287,6 +296,7 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onUpdate, onEdit, onDragStart
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
|||
const [labels, setLabels] = useState<string[]>(task?.labels || []);
|
||||
const [priority, setPriority] = useState<string>(task?.priority || "");
|
||||
const [dependencies, setDependencies] = useState<string[]>(task?.dependencies || []);
|
||||
const [estimatedHours, setEstimatedHours] = useState<string>(task?.estimatedHours !== undefined ? String(task.estimatedHours) : "");
|
||||
const [availableTasks, setAvailableTasks] = useState<Task[]>([]);
|
||||
|
||||
// Keep a baseline for dirty-check
|
||||
|
|
@ -115,6 +116,7 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
|||
setLabels(task?.labels || []);
|
||||
setPriority(task?.priority || "");
|
||||
setDependencies(task?.dependencies || []);
|
||||
setEstimatedHours(task?.estimatedHours !== undefined ? String(task.estimatedHours) : "");
|
||||
setMode(isCreateMode ? "create" : "preview");
|
||||
setError(null);
|
||||
// Preload tasks for dependency picker
|
||||
|
|
@ -151,6 +153,7 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
|||
}
|
||||
|
||||
try {
|
||||
const parsedHours = estimatedHours.trim() ? parseFloat(estimatedHours) : undefined;
|
||||
const taskData: Partial<Task> = {
|
||||
title: title.trim(),
|
||||
description,
|
||||
|
|
@ -162,6 +165,7 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
|||
labels,
|
||||
priority: (priority === "" ? undefined : priority) as "high" | "medium" | "low" | undefined,
|
||||
dependencies,
|
||||
estimatedHours: parsedHours !== undefined && !isNaN(parsedHours) && parsedHours >= 0 ? parsedHours : undefined,
|
||||
};
|
||||
|
||||
if (isCreateMode && onSubmit) {
|
||||
|
|
@ -219,6 +223,7 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
|||
if (updates.labels !== undefined) setLabels(updates.labels as string[]);
|
||||
if (updates.priority !== undefined) setPriority(String(updates.priority));
|
||||
if (updates.dependencies !== undefined) setDependencies(updates.dependencies as string[]);
|
||||
if (updates.estimatedHours !== undefined) setEstimatedHours(String(updates.estimatedHours));
|
||||
|
||||
// Only update server if editing existing task
|
||||
if (task) {
|
||||
|
|
@ -517,6 +522,33 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Estimated Hours */}
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3">
|
||||
<SectionHeader title="Estimated Hours" />
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 focus:border-transparent transition-colors duration-200 ${isFromOtherBranch ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
value={estimatedHours}
|
||||
onChange={(e) => setEstimatedHours(e.target.value)}
|
||||
onBlur={() => {
|
||||
const parsed = parseFloat(estimatedHours);
|
||||
if (!isNaN(parsed) && parsed >= 0) {
|
||||
handleInlineMetaUpdate({ estimatedHours: parsed });
|
||||
} else if (estimatedHours.trim() === "") {
|
||||
// Clear the value if empty
|
||||
handleInlineMetaUpdate({ estimatedHours: undefined as any });
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 4.5"
|
||||
disabled={isFromOtherBranch}
|
||||
/>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3">
|
||||
<SectionHeader title="Dependencies" />
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue