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:
@@ -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([]),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user