feat: task time estimates and velocity chart on dashboard

- Added estimatedHours column to tasks schema
- Backend: create/update support for estimatedHours
- New /api/tasks/stats/velocity endpoint: daily completions, weekly velocity, estimate totals
- Dashboard: velocity chart with 7-day bar chart, this week count, avg/week, estimate summary
- TaskDetailPanel: estimated hours input field
- CreateTaskModal: estimated hours in advanced options
- TaskCard, KanbanBoard, TaskPage: estimate badge display
This commit is contained in:
2026-01-29 11:35:50 +00:00
parent 6459734bc7
commit dd401290c1
10 changed files with 254 additions and 5 deletions

View File

@@ -82,6 +82,7 @@ export const tasks = pgTable("tasks", {
assigneeName: text("assignee_name"),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
dueDate: timestamp("due_date", { withTimezone: true }),
estimatedHours: integer("estimated_hours"),
tags: jsonb("tags").$type<string[]>().default([]),
subtasks: jsonb("subtasks").$type<Subtask[]>().default([]),
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),

View File

@@ -154,6 +154,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
taskNumber: nextNumber,
projectId: body.projectId || null,
dueDate: body.dueDate ? new Date(body.dueDate) : null,
estimatedHours: body.estimatedHours ?? null,
tags: body.tags || [],
subtasks: [],
progressNotes: [],
@@ -194,11 +195,70 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
),
projectId: t.Optional(t.Union([t.String(), t.Null()])),
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
tags: t.Optional(t.Array(t.String())),
}),
}
)
// GET stats - velocity and estimates - requires auth
.get("/stats/velocity", async ({ request, headers }) => {
await requireSessionOrBearer(request, headers);
const allTasks = await db.select().from(tasks);
// Build daily completion counts for last 14 days
const dailyCompletions: { date: string; count: number }[] = [];
for (let i = 13; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split("T")[0];
const count = allTasks.filter(t => {
if (!t.completedAt) return false;
const completedDate = new Date(t.completedAt).toISOString().split("T")[0];
return completedDate === dateStr;
}).length;
dailyCompletions.push({ date: dateStr, count });
}
// Estimate totals
const activeAndQueued = allTasks.filter(t => t.status === "active" || t.status === "queued");
const totalEstimated = activeAndQueued.reduce((sum, t) => sum + (t.estimatedHours || 0), 0);
const estimatedCount = activeAndQueued.filter(t => t.estimatedHours).length;
const unestimatedCount = activeAndQueued.length - estimatedCount;
// Completed this week (Mon-Sun)
const now = new Date();
const dayOfWeek = now.getDay();
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(now);
monday.setDate(monday.getDate() - mondayOffset);
monday.setHours(0, 0, 0, 0);
const completedThisWeek = allTasks.filter(t => {
if (!t.completedAt) return false;
return new Date(t.completedAt) >= monday;
}).length;
// Average tasks completed per week (last 4 weeks)
const fourWeeksAgo = new Date();
fourWeeksAgo.setDate(fourWeeksAgo.getDate() - 28);
const completedLast4Weeks = allTasks.filter(t => {
if (!t.completedAt) return false;
return new Date(t.completedAt) >= fourWeeksAgo;
}).length;
const avgPerWeek = Math.round((completedLast4Weeks / 4) * 10) / 10;
return {
dailyCompletions,
completedThisWeek,
avgPerWeek,
totalEstimatedHours: totalEstimated,
estimatedTaskCount: estimatedCount,
unestimatedTaskCount: unestimatedCount,
};
})
// GET single task by ID or number - requires session or bearer auth
.get(
"/:id",
@@ -242,6 +302,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
if (body.assigneeName !== undefined) updates.assigneeName = body.assigneeName;
if (body.projectId !== undefined) updates.projectId = body.projectId;
if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null;
if (body.estimatedHours !== undefined) updates.estimatedHours = body.estimatedHours;
if (body.tags !== undefined) updates.tags = body.tags;
if (body.subtasks !== undefined) updates.subtasks = body.subtasks;
if (body.progressNotes !== undefined) updates.progressNotes = body.progressNotes;
@@ -273,6 +334,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
assigneeName: t.Optional(t.Union([t.String(), t.Null()])),
projectId: t.Optional(t.Union([t.String(), t.Null()])),
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
tags: t.Optional(t.Array(t.String())),
subtasks: t.Optional(t.Array(t.Object({
id: t.String(),