Compare commits

...

3 Commits

Author SHA1 Message Date
Jeff Emmett fb51f12663 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>
2025-12-25 21:27:59 -05:00
Jeff Emmett b9ee50e824 Create task task-003 2025-12-25 20:57:41 -05:00
Jeff Emmett 3c43af697b Create task task-002 2025-12-25 20:50:30 -05:00
16 changed files with 557 additions and 31 deletions

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 });

View File

@ -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 });

View File

@ -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 ?? [])) {

View File

@ -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,
};
}

View File

@ -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 ?? "";

View File

@ -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);

View File

@ -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,

View File

@ -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();
});
});
});

View File

@ -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 {

View File

@ -22,6 +22,7 @@ export interface TaskEditArgs {
acceptanceCriteriaRemove?: number[];
acceptanceCriteriaCheck?: number[];
acceptanceCriteriaUncheck?: number[];
estimatedHours?: number;
}
export type TaskEditRequest = TaskEditArgs & { id: string };

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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