feat: add estimatedHours field and fix aggregator project duplication
- Add estimatedHours field to Task type for time tracking/invoicing - Parse estimated_hours (snake_case) and estimatedHours (camelCase) from frontmatter - Serialize to snake_case format - Add --hours flag to CLI create/edit commands - Add estimatedHours to MCP schema and handlers - Display estimated hours in TaskCard with clock icon - Add editable estimatedHours input in TaskDetailsModal - Fix aggregator showing duplicate projects with different colors (now deduplicates by project name) - Add comprehensive test suite for estimatedHours feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b9ee50e824
commit
fb51f12663
|
|
@ -4,6 +4,7 @@ title: 'Attention Pipeline: Workload visualization for upcoming work'
|
||||||
status: To Do
|
status: To Do
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2025-12-26 01:57'
|
created_date: '2025-12-26 01:57'
|
||||||
|
updated_date: '2025-12-26 01:57'
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
- dashboard
|
- dashboard
|
||||||
|
|
@ -11,6 +12,7 @@ labels:
|
||||||
- ux
|
- ux
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
|
estimated_hours: 16
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@
|
||||||
* - Serves aggregated task data with project metadata
|
* - Serves aggregated task data with project metadata
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Server, type ServerWebSocket, $ } from "bun";
|
import { type FSWatcher, watch } from "node:fs";
|
||||||
import { watch, type FSWatcher } from "node:fs";
|
import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
||||||
import { readdir, stat, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
import { basename, dirname, join } from "node:path";
|
||||||
import { join, basename, dirname } from "node:path";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { $, type Server, type ServerWebSocket } from "bun";
|
||||||
import { parseTask } from "../markdown/parser.ts";
|
import { parseTask } from "../markdown/parser.ts";
|
||||||
import type { Task } from "../types/index.ts";
|
import type { Task } from "../types/index.ts";
|
||||||
import { sortByTaskId } from "../utils/task-sorting.ts";
|
import { sortByTaskId } from "../utils/task-sorting.ts";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
// @ts-expect-error - Bun file import
|
// @ts-expect-error - Bun file import
|
||||||
import favicon from "../web/favicon.png" with { type: "file" };
|
import favicon from "../web/favicon.png" with { type: "file" };
|
||||||
|
|
@ -56,7 +56,7 @@ const DEFAULT_COLORS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export class BacklogAggregator {
|
export class BacklogAggregator {
|
||||||
private server: Server | null = null;
|
private server: Server<unknown> | null = null;
|
||||||
private sockets = new Set<ServerWebSocket<unknown>>();
|
private sockets = new Set<ServerWebSocket<unknown>>();
|
||||||
private projects = new Map<string, ProjectConfig>();
|
private projects = new Map<string, ProjectConfig>();
|
||||||
private tasks = new Map<string, AggregatedTask>();
|
private tasks = new Map<string, AggregatedTask>();
|
||||||
|
|
@ -74,7 +74,9 @@ export class BacklogAggregator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNextColor(): string {
|
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++;
|
this.colorIndex++;
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +123,7 @@ export class BacklogAggregator {
|
||||||
GET: async () => Response.json({ status: "ok", projects: this.projects.size, tasks: this.tasks.size }),
|
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);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
// Handle WebSocket upgrade
|
// Handle WebSocket upgrade
|
||||||
|
|
@ -220,7 +222,7 @@ export class BacklogAggregator {
|
||||||
const taskPath = join(tasksPath, entry.name);
|
const taskPath = join(tasksPath, entry.name);
|
||||||
try {
|
try {
|
||||||
const content = await readFile(taskPath, "utf-8");
|
const content = await readFile(taskPath, "utf-8");
|
||||||
const task = parseTask(content, taskPath);
|
const task = parseTask(content);
|
||||||
if (task) {
|
if (task) {
|
||||||
const key = `${projectPath}:${task.id}`;
|
const key = `${projectPath}:${task.id}`;
|
||||||
const existing = this.tasks.get(key);
|
const existing = this.tasks.get(key);
|
||||||
|
|
@ -318,13 +320,22 @@ export class BacklogAggregator {
|
||||||
try {
|
try {
|
||||||
const configContent = await readFile(configPath, "utf-8");
|
const configContent = await readFile(configPath, "utf-8");
|
||||||
const nameMatch = configContent.match(/project_name:\s*["']?([^"'\n]+)["']?/);
|
const nameMatch = configContent.match(/project_name:\s*["']?([^"'\n]+)["']?/);
|
||||||
if (nameMatch) {
|
if (nameMatch?.[1]) {
|
||||||
projectName = nameMatch[1].trim();
|
projectName = nameMatch[1].trim();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Use directory name
|
// 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 = {
|
const project: ProjectConfig = {
|
||||||
path: projectPath,
|
path: projectPath,
|
||||||
name: projectName,
|
name: projectName,
|
||||||
|
|
@ -390,7 +401,7 @@ export class BacklogAggregator {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await readFile(taskPath, "utf-8");
|
const content = await readFile(taskPath, "utf-8");
|
||||||
const task = parseTask(content, taskPath);
|
const task = parseTask(content);
|
||||||
if (task) {
|
if (task) {
|
||||||
const aggregatedTask: AggregatedTask = {
|
const aggregatedTask: AggregatedTask = {
|
||||||
...task,
|
...task,
|
||||||
|
|
@ -659,14 +670,14 @@ created_date: '${dateStr}'
|
||||||
if (labels && labels.length > 0) {
|
if (labels && labels.length > 0) {
|
||||||
frontmatter += `labels: [${labels.join(", ")}]\n`;
|
frontmatter += `labels: [${labels.join(", ")}]\n`;
|
||||||
} else {
|
} else {
|
||||||
frontmatter += `labels: []\n`;
|
frontmatter += "labels: []\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (priority) {
|
if (priority) {
|
||||||
frontmatter += `priority: ${priority}\n`;
|
frontmatter += `priority: ${priority}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
frontmatter += `dependencies: []\n---\n\n`;
|
frontmatter += "dependencies: []\n---\n\n";
|
||||||
|
|
||||||
let content = frontmatter;
|
let content = frontmatter;
|
||||||
content += `## Description\n\n${description || "No description provided."}\n`;
|
content += `## Description\n\n${description || "No description provided."}\n`;
|
||||||
|
|
@ -839,7 +850,7 @@ created_date: '${dateStr}'
|
||||||
|
|
||||||
// Find and replace the description section
|
// Find and replace the description section
|
||||||
// Handle both Windows (\r\n) and Unix (\n) line endings
|
// 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}`;
|
const newDescriptionSection = `## Description${lineEnding}${lineEnding}${newDescription}${lineEnding}`;
|
||||||
|
|
||||||
if (descriptionRegex.test(content)) {
|
if (descriptionRegex.test(content)) {
|
||||||
|
|
@ -883,7 +894,7 @@ created_date: '${dateStr}'
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||||
const match = entry.name.match(/^task-(\d+)\.md$/);
|
const match = entry.name.match(/^task-(\d+)\.md$/);
|
||||||
if (match) {
|
if (match?.[1]) {
|
||||||
const id = Number.parseInt(match[1], 10);
|
const id = Number.parseInt(match[1], 10);
|
||||||
if (id > maxId) maxId = id;
|
if (id > maxId) maxId = id;
|
||||||
}
|
}
|
||||||
|
|
@ -901,10 +912,12 @@ created_date: '${dateStr}'
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const portIndex = args.indexOf("--port");
|
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 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 });
|
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("--plan <text>", "add implementation plan")
|
||||||
.option("--notes <text>", "add implementation notes")
|
.option("--notes <text>", "add implementation notes")
|
||||||
|
.option("--hours <number>", "set estimated hours to complete")
|
||||||
.option("--draft")
|
.option("--draft")
|
||||||
.option("-p, --parent <taskId>", "specify parent task ID")
|
.option("-p, --parent <taskId>", "specify parent task ID")
|
||||||
.option(
|
.option(
|
||||||
|
|
@ -1253,6 +1254,17 @@ taskCmd
|
||||||
task.implementationNotes = String(options.notes);
|
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
|
// Workaround for bun compile issue with commander options
|
||||||
const isPlainFlag = options.plain || process.argv.includes("--plain");
|
const isPlainFlag = options.plain || process.argv.includes("--plain");
|
||||||
|
|
||||||
|
|
@ -1764,6 +1776,7 @@ taskCmd
|
||||||
const soFar = Array.isArray(previous) ? previous : previous ? [previous] : [];
|
const soFar = Array.isArray(previous) ? previous : previous ? [previous] : [];
|
||||||
return [...soFar, value];
|
return [...soFar, value];
|
||||||
})
|
})
|
||||||
|
.option("--hours <number>", "set estimated hours to complete")
|
||||||
.action(async (taskId: string, options) => {
|
.action(async (taskId: string, options) => {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const core = new Core(cwd);
|
const core = new Core(cwd);
|
||||||
|
|
@ -1907,6 +1920,15 @@ taskCmd
|
||||||
if (uncheckCriteria) {
|
if (uncheckCriteria) {
|
||||||
editArgs.acceptanceCriteriaUncheck = 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;
|
let updatedTask: Task;
|
||||||
try {
|
try {
|
||||||
|
|
@ -3077,9 +3099,7 @@ program
|
||||||
const { BacklogAggregator } = await import("./aggregator/index.ts");
|
const { BacklogAggregator } = await import("./aggregator/index.ts");
|
||||||
|
|
||||||
const port = Number.parseInt(options.port, 10) || 6420;
|
const port = Number.parseInt(options.port, 10) || 6420;
|
||||||
const scanPaths = options.paths
|
const scanPaths = options.paths ? options.paths.split(",").map((p: string) => p.trim()) : [process.cwd()];
|
||||||
? options.paths.split(",").map((p: string) => p.trim())
|
|
||||||
: [process.cwd()];
|
|
||||||
|
|
||||||
const aggregator = new BacklogAggregator({ port, scanPaths });
|
const aggregator = new BacklogAggregator({ port, scanPaths });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,7 @@ export class Core {
|
||||||
acceptanceCriteriaItems?: import("../types/index.ts").AcceptanceCriterion[];
|
acceptanceCriteriaItems?: import("../types/index.ts").AcceptanceCriterion[];
|
||||||
implementationPlan?: string;
|
implementationPlan?: string;
|
||||||
implementationNotes?: string;
|
implementationNotes?: string;
|
||||||
|
estimatedHours?: number;
|
||||||
},
|
},
|
||||||
autoCommit?: boolean,
|
autoCommit?: boolean,
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
|
|
@ -437,6 +438,7 @@ export class Core {
|
||||||
}),
|
}),
|
||||||
...(typeof taskData.implementationPlan === "string" && { implementationPlan: taskData.implementationPlan }),
|
...(typeof taskData.implementationPlan === "string" && { implementationPlan: taskData.implementationPlan }),
|
||||||
...(typeof taskData.implementationNotes === "string" && { implementationNotes: taskData.implementationNotes }),
|
...(typeof taskData.implementationNotes === "string" && { implementationNotes: taskData.implementationNotes }),
|
||||||
|
...(typeof taskData.estimatedHours === "number" && { estimatedHours: taskData.estimatedHours }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this should be a draft based on status
|
// Check if this should be a draft based on status
|
||||||
|
|
@ -512,6 +514,7 @@ export class Core {
|
||||||
...(typeof input.implementationNotes === "string" && { implementationNotes: input.implementationNotes }),
|
...(typeof input.implementationNotes === "string" && { implementationNotes: input.implementationNotes }),
|
||||||
...(acceptanceCriteriaItems.length > 0 && { acceptanceCriteriaItems }),
|
...(acceptanceCriteriaItems.length > 0 && { acceptanceCriteriaItems }),
|
||||||
...(statusHistory.length > 0 && { statusHistory }),
|
...(statusHistory.length > 0 && { statusHistory }),
|
||||||
|
...(typeof input.estimatedHours === "number" && { estimatedHours: input.estimatedHours }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDraft = (status || "").toLowerCase() === "draft";
|
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) {
|
if (input.assignee !== undefined) {
|
||||||
const sanitizedAssignee = normalizeStringList(input.assignee) ?? [];
|
const sanitizedAssignee = normalizeStringList(input.assignee) ?? [];
|
||||||
if (!stringArraysEqual(sanitizedAssignee, task.assignee ?? [])) {
|
if (!stringArraysEqual(sanitizedAssignee, task.assignee ?? [])) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import matter from "gray-matter";
|
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";
|
import { AcceptanceCriteriaManager, extractStructuredSection, STRUCTURED_SECTION_KEYS } from "./structured-sections.ts";
|
||||||
|
|
||||||
function preprocessFrontmatter(frontmatter: string): string {
|
function preprocessFrontmatter(frontmatter: string): string {
|
||||||
|
|
@ -163,6 +170,12 @@ export function parseTask(content: string): Task {
|
||||||
onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined,
|
onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined,
|
||||||
statusHistory: parseStatusHistory(frontmatter.status_history),
|
statusHistory: parseStatusHistory(frontmatter.status_history),
|
||||||
doToday: frontmatter.do_today === true || frontmatter.doToday === true,
|
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.onStatusChange && { onStatusChange: task.onStatusChange }),
|
||||||
...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }),
|
...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }),
|
||||||
...(task.doToday && { do_today: task.doToday }),
|
...(task.doToday && { do_today: task.doToday }),
|
||||||
|
...(task.estimatedHours !== undefined && { estimated_hours: task.estimatedHours }),
|
||||||
};
|
};
|
||||||
|
|
||||||
let contentBody = task.rawContent ?? "";
|
let contentBody = task.rawContent ?? "";
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export type TaskCreateArgs = {
|
||||||
parentTaskId?: string;
|
parentTaskId?: string;
|
||||||
acceptanceCriteria?: string[];
|
acceptanceCriteria?: string[];
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
|
estimatedHours?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TaskListArgs = {
|
export type TaskListArgs = {
|
||||||
|
|
@ -75,6 +76,7 @@ export class TaskHandlers {
|
||||||
dependencies: args.dependencies,
|
dependencies: args.dependencies,
|
||||||
parentTaskId: args.parentTaskId,
|
parentTaskId: args.parentTaskId,
|
||||||
acceptanceCriteria,
|
acceptanceCriteria,
|
||||||
|
estimatedHours: args.estimatedHours,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await formatTaskCallResult(createdTask);
|
return await formatTaskCallResult(createdTask);
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,11 @@ export function generateTaskCreateSchema(config: BacklogConfig): JsonSchema {
|
||||||
type: "string",
|
type: "string",
|
||||||
maxLength: 50,
|
maxLength: 50,
|
||||||
},
|
},
|
||||||
|
estimatedHours: {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
description: "Estimated hours to complete this task",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ["title"],
|
required: ["title"],
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -200,6 +205,11 @@ export function generateTaskEditSchema(config: BacklogConfig): JsonSchema {
|
||||||
},
|
},
|
||||||
maxItems: 50,
|
maxItems: 50,
|
||||||
},
|
},
|
||||||
|
estimatedHours: {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
description: "Estimated hours to complete this task",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ["id"],
|
required: ["id"],
|
||||||
additionalProperties: false,
|
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[];
|
statusHistory?: StatusHistoryEntry[];
|
||||||
/** Flag to mark task for "Do Today" daily focus list */
|
/** Flag to mark task for "Do Today" daily focus list */
|
||||||
doToday?: boolean;
|
doToday?: boolean;
|
||||||
|
/** Estimated hours to complete the task (for time tracking and invoicing) */
|
||||||
|
estimatedHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -72,6 +74,7 @@ export interface TaskCreateInput {
|
||||||
implementationNotes?: string;
|
implementationNotes?: string;
|
||||||
acceptanceCriteria?: AcceptanceCriterionInput[];
|
acceptanceCriteria?: AcceptanceCriterionInput[];
|
||||||
rawContent?: string;
|
rawContent?: string;
|
||||||
|
estimatedHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskUpdateInput {
|
export interface TaskUpdateInput {
|
||||||
|
|
@ -100,6 +103,7 @@ export interface TaskUpdateInput {
|
||||||
uncheckAcceptanceCriteria?: number[];
|
uncheckAcceptanceCriteria?: number[];
|
||||||
rawContent?: string;
|
rawContent?: string;
|
||||||
doToday?: boolean;
|
doToday?: boolean;
|
||||||
|
estimatedHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskListFilter {
|
export interface TaskListFilter {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export interface TaskEditArgs {
|
||||||
acceptanceCriteriaRemove?: number[];
|
acceptanceCriteriaRemove?: number[];
|
||||||
acceptanceCriteriaCheck?: number[];
|
acceptanceCriteriaCheck?: number[];
|
||||||
acceptanceCriteriaUncheck?: number[];
|
acceptanceCriteriaUncheck?: number[];
|
||||||
|
estimatedHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskEditRequest = TaskEditArgs & { id: string };
|
export type TaskEditRequest = TaskEditArgs & { id: string };
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ export function buildTaskUpdateInput(args: TaskEditArgs): TaskUpdateInput {
|
||||||
updateInput.ordinal = args.ordinal;
|
updateInput.ordinal = args.ordinal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof args.estimatedHours === "number") {
|
||||||
|
updateInput.estimatedHours = args.estimatedHours;
|
||||||
|
}
|
||||||
|
|
||||||
const labels = normalizeStringList(args.labels);
|
const labels = normalizeStringList(args.labels);
|
||||||
if (labels) {
|
if (labels) {
|
||||||
updateInput.labels = 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">
|
<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>
|
<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 && (
|
{task.priority && (
|
||||||
<span className={`font-medium transition-colors duration-200 ${
|
<span className={`font-medium transition-colors duration-200 ${
|
||||||
task.priority === 'high' ? 'text-red-600 dark:text-red-400' :
|
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>
|
||||||
</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 [labels, setLabels] = useState<string[]>(task?.labels || []);
|
||||||
const [priority, setPriority] = useState<string>(task?.priority || "");
|
const [priority, setPriority] = useState<string>(task?.priority || "");
|
||||||
const [dependencies, setDependencies] = useState<string[]>(task?.dependencies || []);
|
const [dependencies, setDependencies] = useState<string[]>(task?.dependencies || []);
|
||||||
|
const [estimatedHours, setEstimatedHours] = useState<string>(task?.estimatedHours !== undefined ? String(task.estimatedHours) : "");
|
||||||
const [availableTasks, setAvailableTasks] = useState<Task[]>([]);
|
const [availableTasks, setAvailableTasks] = useState<Task[]>([]);
|
||||||
|
|
||||||
// Keep a baseline for dirty-check
|
// Keep a baseline for dirty-check
|
||||||
|
|
@ -115,6 +116,7 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
||||||
setLabels(task?.labels || []);
|
setLabels(task?.labels || []);
|
||||||
setPriority(task?.priority || "");
|
setPriority(task?.priority || "");
|
||||||
setDependencies(task?.dependencies || []);
|
setDependencies(task?.dependencies || []);
|
||||||
|
setEstimatedHours(task?.estimatedHours !== undefined ? String(task.estimatedHours) : "");
|
||||||
setMode(isCreateMode ? "create" : "preview");
|
setMode(isCreateMode ? "create" : "preview");
|
||||||
setError(null);
|
setError(null);
|
||||||
// Preload tasks for dependency picker
|
// Preload tasks for dependency picker
|
||||||
|
|
@ -151,6 +153,7 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const parsedHours = estimatedHours.trim() ? parseFloat(estimatedHours) : undefined;
|
||||||
const taskData: Partial<Task> = {
|
const taskData: Partial<Task> = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description,
|
description,
|
||||||
|
|
@ -162,6 +165,7 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
||||||
labels,
|
labels,
|
||||||
priority: (priority === "" ? undefined : priority) as "high" | "medium" | "low" | undefined,
|
priority: (priority === "" ? undefined : priority) as "high" | "medium" | "low" | undefined,
|
||||||
dependencies,
|
dependencies,
|
||||||
|
estimatedHours: parsedHours !== undefined && !isNaN(parsedHours) && parsedHours >= 0 ? parsedHours : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCreateMode && onSubmit) {
|
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.labels !== undefined) setLabels(updates.labels as string[]);
|
||||||
if (updates.priority !== undefined) setPriority(String(updates.priority));
|
if (updates.priority !== undefined) setPriority(String(updates.priority));
|
||||||
if (updates.dependencies !== undefined) setDependencies(updates.dependencies as string[]);
|
if (updates.dependencies !== undefined) setDependencies(updates.dependencies as string[]);
|
||||||
|
if (updates.estimatedHours !== undefined) setEstimatedHours(String(updates.estimatedHours));
|
||||||
|
|
||||||
// Only update server if editing existing task
|
// Only update server if editing existing task
|
||||||
if (task) {
|
if (task) {
|
||||||
|
|
@ -517,6 +522,33 @@ export const TaskDetailsModal: React.FC<Props> = ({ task, isOpen, onClose, onSav
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 */}
|
{/* Dependencies */}
|
||||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3">
|
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3">
|
||||||
<SectionHeader title="Dependencies" />
|
<SectionHeader title="Dependencies" />
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue