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

@@ -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<Project[]>([]);
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
</div>
</div>
{/* Estimated Hours */}
<div>
<label className="text-xs font-medium text-gray-500 block mb-1">Estimated Hours</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
step="1"
value={estimatedHours}
onChange={(e) => 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"
/>
<span className="text-sm text-gray-500">hours</span>
</div>
</div>
{/* Source */}
<div>
<label className="text-xs font-medium text-gray-500 block mb-1">Source</label>

View File

@@ -118,6 +118,11 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC
🏷 {tag}
</span>
))}
{task.estimatedHours != null && task.estimatedHours > 0 && (
<span className="text-[10px] text-gray-500 dark:text-gray-400">
{task.estimatedHours}h
</span>
)}
{task.dueDate && (() => {
const due = new Date(task.dueDate);
const diffMs = due.getTime() - Date.now();

View File

@@ -133,6 +133,11 @@ export function TaskCard({
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500">
{timeAgo(task.createdAt)}
</span>
{task.estimatedHours != null && task.estimatedHours > 0 && (
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500 flex items-center gap-0.5">
{task.estimatedHours}h
</span>
)}
{noteCount > 0 && (
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500 flex items-center gap-0.5">
💬 {noteCount}

View File

@@ -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<string>(task.estimatedHours != null ? String(task.estimatedHours) : "");
const [draftTags, setDraftTags] = useState<string[]>(task.tags || []);
const [tagInput, setTagInput] = useState("");
const [projects, setProjects] = useState<Project[]>([]);
@@ -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,
)}
</div>
{/* Estimated Hours */}
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Estimated Hours</h3>
{hasToken ? (
<div className="flex items-center gap-2">
<input
type="number"
min="0"
step="1"
value={draftEstimatedHours}
onChange={(e) => 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"
/>
<span className="text-sm text-gray-500 dark:text-gray-400">hours</span>
{draftEstimatedHours && (
<button
onClick={() => setDraftEstimatedHours("")}
className="text-xs text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition"
title="Clear estimate"
>
</button>
)}
</div>
) : (
<span className="text-sm text-gray-700 dark:text-gray-300">
{task.estimatedHours != null ? `${task.estimatedHours}h` : <span className="text-gray-400 dark:text-gray-500 italic">No estimate</span>}
</span>
)}
</div>
{/* Tags */}
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Tags</h3>

View File

@@ -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<void>
}
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<Task> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
@@ -64,6 +64,14 @@ export async function deleteTask(id: string, token?: string): Promise<void> {
if (!res.ok) throw new Error("Failed to delete task");
}
// ─── Velocity Stats ───
export async function fetchVelocityStats(): Promise<VelocityStats> {
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";

View File

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

View File

@@ -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 (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">
<div className="px-5 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 className="font-semibold text-gray-900 dark:text-gray-100">📊 Velocity</h2>
</div>
<div className="p-5">
{/* Stats row */}
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="text-center">
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400">{completedThisWeek}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">This week</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{avgPerWeek}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Avg/week</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-violet-600 dark:text-violet-400">
{totalEstimatedHours > 0 ? `${totalEstimatedHours}h` : "—"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{totalEstimatedHours > 0
? `${estimatedTaskCount} tasks est.`
: `${unestimatedTaskCount} unestimated`}
</div>
</div>
</div>
{/* Bar chart - last 7 days */}
<div className="flex items-end gap-1.5 h-24">
{last7.map((d, i) => {
const pct = maxCount > 0 ? (d.count / maxCount) * 100 : 0;
const isToday = i === last7.length - 1;
return (
<div key={d.date} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-mono">
{d.count > 0 ? d.count : ""}
</span>
<div className="w-full flex items-end" style={{ height: "60px" }}>
<div
className={`w-full rounded-t transition-all duration-500 ${
isToday
? "bg-amber-500 dark:bg-amber-400"
: 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`}
/>
</div>
<span className={`text-[10px] ${isToday ? "text-amber-600 dark:text-amber-400 font-bold" : "text-gray-400 dark:text-gray-500"}`}>
{dayLabels[i]}
</span>
</div>
);
})}
</div>
<div className="text-center mt-2">
<span className="text-[10px] text-gray-400 dark:text-gray-500">Tasks completed per day (last 7 days)</span>
</div>
</div>
</div>
);
}
export function DashboardPage() {
const { tasks, loading } = useTasks(10000);
const [projects, setProjects] = useState<Project[]>([]);
const [velocityStats, setVelocityStats] = useState<VelocityStats | null>(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<string, string> = {};
for (const p of projects) map[p.id] = p.name;
@@ -131,6 +220,9 @@ export function DashboardPage() {
<StatCard label="Completed" value={stats.completed} icon="✅" color="bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-300" />
</div>
{/* Velocity Chart */}
<VelocityChart stats={velocityStats} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Currently Working On */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">

View File

@@ -617,6 +617,12 @@ export function TaskPage() {
<span className="text-gray-700 dark:text-gray-300 font-medium">{project.name}</span>
</div>
)}
{task.estimatedHours != null && task.estimatedHours > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Estimate</span>
<span className="text-gray-700 dark:text-gray-300 font-medium"> {task.estimatedHours}h</span>
</div>
)}
{task.tags?.length > 0 && (
<div>
<span className="text-gray-500 dark:text-gray-400 block mb-1">Tags</span>