diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 8f2cca3..4b767b6 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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().default([]), subtasks: jsonb("subtasks").$type().default([]), progressNotes: jsonb("progress_notes").$type().default([]), diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index 5517219..f422916 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -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(), diff --git a/frontend/src/components/CreateTaskModal.tsx b/frontend/src/components/CreateTaskModal.tsx index 2175069..8cb09d3 100644 --- a/frontend/src/components/CreateTaskModal.tsx +++ b/frontend/src/components/CreateTaskModal.tsx @@ -12,6 +12,7 @@ interface CreateTaskModalProps { priority?: string; projectId?: string; dueDate?: string; + estimatedHours?: number; }) => void; } @@ -22,6 +23,7 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp const [priority, setPriority] = useState("medium"); const [projectId, setProjectId] = useState(""); const [dueDate, setDueDate] = useState(""); + const [estimatedHours, setEstimatedHours] = useState(""); const [projects, setProjects] = useState([]); const [showAdvanced, setShowAdvanced] = useState(false); @@ -53,6 +55,7 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp priority, projectId: projectId || undefined, dueDate: dueDate ? new Date(dueDate).toISOString() : undefined, + estimatedHours: estimatedHours ? Number(estimatedHours) : undefined, }); // Reset form setTitle(""); @@ -61,6 +64,7 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp setPriority("medium"); setProjectId(""); setDueDate(""); + setEstimatedHours(""); setShowAdvanced(false); onClose(); }; @@ -179,6 +183,23 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp + {/* Estimated Hours */} +
+ +
+ setEstimatedHours(e.target.value)} + placeholder="0" + className="w-24 text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white" + /> + hours +
+
+ {/* Source */}
diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx index 4342093..6f026da 100644 --- a/frontend/src/components/KanbanBoard.tsx +++ b/frontend/src/components/KanbanBoard.tsx @@ -118,6 +118,11 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC 🏷️ {tag} ))} + {task.estimatedHours != null && task.estimatedHours > 0 && ( + + ⏱ {task.estimatedHours}h + + )} {task.dueDate && (() => { const due = new Date(task.dueDate); const diffMs = due.getTime() - Date.now(); diff --git a/frontend/src/components/TaskCard.tsx b/frontend/src/components/TaskCard.tsx index e2b77c7..0ba6864 100644 --- a/frontend/src/components/TaskCard.tsx +++ b/frontend/src/components/TaskCard.tsx @@ -133,6 +133,11 @@ export function TaskCard({ {timeAgo(task.createdAt)} + {task.estimatedHours != null && task.estimatedHours > 0 && ( + + ⏱ {task.estimatedHours}h + + )} {noteCount > 0 && ( 💬 {noteCount} diff --git a/frontend/src/components/TaskDetailPanel.tsx b/frontend/src/components/TaskDetailPanel.tsx index ca50d74..7b0e5c8 100644 --- a/frontend/src/components/TaskDetailPanel.tsx +++ b/frontend/src/components/TaskDetailPanel.tsx @@ -258,6 +258,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, const [draftProjectId, setDraftProjectId] = useState(task.projectId || ""); const [draftDueDate, setDraftDueDate] = useState(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); const [draftAssigneeName, setDraftAssigneeName] = useState(task.assigneeName || ""); + const [draftEstimatedHours, setDraftEstimatedHours] = useState(task.estimatedHours != null ? String(task.estimatedHours) : ""); const [draftTags, setDraftTags] = useState(task.tags || []); const [tagInput, setTagInput] = useState(""); const [projects, setProjects] = useState([]); @@ -284,8 +285,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, setDraftProjectId(task.projectId || ""); setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); setDraftAssigneeName(task.assigneeName || ""); + setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : ""); setDraftTags(task.tags || []); - }, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName, task.tags]); + }, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName, task.estimatedHours, task.tags]); const currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""; const isDirty = @@ -296,6 +298,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, draftProjectId !== (task.projectId || "") || draftDueDate !== currentDueDate || draftAssigneeName !== (task.assigneeName || "") || + draftEstimatedHours !== (task.estimatedHours != null ? String(task.estimatedHours) : "") || JSON.stringify(draftTags) !== JSON.stringify(task.tags || []); const handleCancel = () => { @@ -306,6 +309,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, setDraftProjectId(task.projectId || ""); setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); setDraftAssigneeName(task.assigneeName || ""); + setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : ""); setDraftTags(task.tags || []); }; @@ -321,6 +325,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, if (draftProjectId !== (task.projectId || "")) updates.projectId = draftProjectId || null; if (draftDueDate !== currentDueDate) updates.dueDate = draftDueDate ? new Date(draftDueDate).toISOString() : null; if (draftAssigneeName !== (task.assigneeName || "")) updates.assigneeName = draftAssigneeName || null; + if (draftEstimatedHours !== (task.estimatedHours != null ? String(task.estimatedHours) : "")) { + (updates as any).estimatedHours = draftEstimatedHours ? Number(draftEstimatedHours) : null; + } if (JSON.stringify(draftTags) !== JSON.stringify(task.tags || [])) (updates as any).tags = draftTags; await updateTask(task.id, updates, token); onTaskUpdated(); @@ -585,6 +592,38 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, )}
+ {/* Estimated Hours */} +
+

Estimated Hours

+ {hasToken ? ( +
+ setDraftEstimatedHours(e.target.value)} + placeholder="0" + className="w-24 text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500" + /> + hours + {draftEstimatedHours && ( + + )} +
+ ) : ( + + {task.estimatedHours != null ? `${task.estimatedHours}h` : No estimate} + + )} +
+ {/* Tags */}

Tags

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9b94393..237fa83 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Task, Project, ProjectWithTasks } from "./types"; +import type { Task, Project, ProjectWithTasks, VelocityStats } from "./types"; const BASE = "/api/tasks"; @@ -38,7 +38,7 @@ export async function reorderTasks(ids: string[], token?: string): Promise } export async function createTask( - task: { title: string; description?: string; source?: string; priority?: string; status?: string; projectId?: string; dueDate?: string }, + task: { title: string; description?: string; source?: string; priority?: string; status?: string; projectId?: string; dueDate?: string; estimatedHours?: number }, token?: string ): Promise { const headers: Record = { "Content-Type": "application/json" }; @@ -64,6 +64,14 @@ export async function deleteTask(id: string, token?: string): Promise { if (!res.ok) throw new Error("Failed to delete task"); } +// ─── Velocity Stats ─── + +export async function fetchVelocityStats(): Promise { + const res = await fetch(`${BASE}/stats/velocity`, { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch velocity stats"); + return res.json(); +} + // ─── Projects API ─── const PROJECTS_BASE = "/api/projects"; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 214436a..b2e810a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,4 +1,13 @@ export type TaskStatus = "active" | "queued" | "blocked" | "completed" | "cancelled"; + +export interface VelocityStats { + dailyCompletions: { date: string; count: number }[]; + completedThisWeek: number; + avgPerWeek: number; + totalEstimatedHours: number; + estimatedTaskCount: number; + unestimatedTaskCount: number; +} export type TaskPriority = "critical" | "high" | "medium" | "low"; export type TaskSource = "donovan" | "david" | "hammer" | "heartbeat" | "cron" | "other"; @@ -48,6 +57,7 @@ export interface Task { assigneeName: string | null; projectId: string | null; dueDate: string | null; + estimatedHours: number | null; tags: string[]; subtasks: Subtask[]; progressNotes: ProgressNote[]; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index cd0bd91..d204827 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,8 +1,8 @@ import { useMemo, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useTasks } from "../hooks/useTasks"; -import { fetchProjects } from "../lib/api"; -import type { Task, ProgressNote, Project } from "../lib/types"; +import { fetchProjects, fetchVelocityStats } from "../lib/api"; +import type { Task, ProgressNote, Project, VelocityStats } from "../lib/types"; function StatCard({ label, value, icon, color }: { label: string; value: number; icon: string; color: string }) { return ( @@ -72,14 +72,103 @@ function RecentActivity({ tasks }: { tasks: Task[] }) { ); } +function VelocityChart({ stats }: { stats: VelocityStats | null }) { + if (!stats) return null; + + const { dailyCompletions, completedThisWeek, avgPerWeek, totalEstimatedHours, estimatedTaskCount, unestimatedTaskCount } = stats; + const maxCount = Math.max(...dailyCompletions.map(d => d.count), 1); + + // Show only last 7 days for the chart + const last7 = dailyCompletions.slice(-7); + + const dayLabels = last7.map(d => { + const date = new Date(d.date + "T12:00:00"); + return date.toLocaleDateString(undefined, { weekday: "short" }); + }); + + return ( +
+
+

📊 Velocity

+
+
+ {/* Stats row */} +
+
+
{completedThisWeek}
+
This week
+
+
+
{avgPerWeek}
+
Avg/week
+
+
+
+ {totalEstimatedHours > 0 ? `${totalEstimatedHours}h` : "—"} +
+
+ {totalEstimatedHours > 0 + ? `${estimatedTaskCount} tasks est.` + : `${unestimatedTaskCount} unestimated`} +
+
+
+ + {/* Bar chart - last 7 days */} +
+ {last7.map((d, i) => { + const pct = maxCount > 0 ? (d.count / maxCount) * 100 : 0; + const isToday = i === last7.length - 1; + return ( +
+ + {d.count > 0 ? d.count : ""} + +
+
0 + ? "bg-green-400 dark:bg-green-500" + : "bg-gray-200 dark:bg-gray-700" + }`} + style={{ height: `${Math.max(pct, d.count > 0 ? 10 : 4)}%` }} + title={`${d.date}: ${d.count} completed`} + /> +
+ + {dayLabels[i]} + +
+ ); + })} +
+
+ Tasks completed per day (last 7 days) +
+
+
+ ); +} + export function DashboardPage() { const { tasks, loading } = useTasks(10000); const [projects, setProjects] = useState([]); + const [velocityStats, setVelocityStats] = useState(null); useEffect(() => { fetchProjects().then(setProjects).catch(() => {}); + fetchVelocityStats().then(setVelocityStats).catch(() => {}); }, []); + // Refresh velocity stats when tasks change + useEffect(() => { + if (tasks.length > 0) { + fetchVelocityStats().then(setVelocityStats).catch(() => {}); + } + }, [tasks.length]); + const projectMap = useMemo(() => { const map: Record = {}; for (const p of projects) map[p.id] = p.name; @@ -131,6 +220,9 @@ export function DashboardPage() {
+ {/* Velocity Chart */} + +
{/* Currently Working On */}
diff --git a/frontend/src/pages/TaskPage.tsx b/frontend/src/pages/TaskPage.tsx index e942677..7bc7baa 100644 --- a/frontend/src/pages/TaskPage.tsx +++ b/frontend/src/pages/TaskPage.tsx @@ -617,6 +617,12 @@ export function TaskPage() { {project.name}
)} + {task.estimatedHours != null && task.estimatedHours > 0 && ( +
+ Estimate + ⏱ {task.estimatedHours}h +
+ )} {task.tags?.length > 0 && (
Tags