diff --git a/apps/web/components/tasks/task-detail-panel.tsx b/apps/web/components/tasks/task-detail-panel.tsx index 6757ec5..d18f084 100644 --- a/apps/web/components/tasks/task-detail-panel.tsx +++ b/apps/web/components/tasks/task-detail-panel.tsx @@ -82,6 +82,9 @@ export function TaskDetailPanel({ const [saving, setSaving] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); + const [timerRunning, setTimerRunning] = useState(false); + const [timerStartedAt, setTimerStartedAt] = useState(null); + const [elapsedSeconds, setElapsedSeconds] = useState(0); // Fetch task details when panel opens useEffect(() => { @@ -162,6 +165,59 @@ export function TaskDetailPanel({ } } + // Timer tick effect + useEffect(() => { + if (!timerRunning || !timerStartedAt) { + setElapsedSeconds(0); + return; + } + const interval = setInterval(() => { + setElapsedSeconds(Math.floor((Date.now() - timerStartedAt.getTime()) / 1000)); + }, 1000); + return () => clearInterval(interval); + }, [timerRunning, timerStartedAt]); + + function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return \`\${h}h \${m}m\`; + if (m > 0) return \`\${m}m \${s}s\`; + return \`\${s}s\`; + } + + async function handleStartTimer() { + setTimerRunning(true); + setTimerStartedAt(new Date()); + } + + async function handleStopTimer() { + if (!timerStartedAt || !task) return; + const deltaMinutes = Math.round((Date.now() - timerStartedAt.getTime()) / 60000); + if (deltaMinutes < 1) { + setTimerRunning(false); + setTimerStartedAt(null); + return; + } + const newTracked = (task.trackedMinutes || 0) + deltaMinutes; + try { + const response = await fetch(\`/api/domains/\${domainId}/tasks/\${task.id}\`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ trackedMinutes: newTracked }), + }); + if (!response.ok) throw new Error("Unable to save tracked time"); + setTask({ ...task, trackedMinutes: newTracked }); + onUpdate(); + toast.success(\`Tracked \${deltaMinutes} minute\${deltaMinutes !== 1 ? "s" : ""}\`); + } catch { + toast.error("Unable to save tracked time"); + } finally { + setTimerRunning(false); + setTimerStartedAt(null); + } + } + return ( @@ -262,6 +318,61 @@ export function TaskDetailPanel({ + {/* Time Tracking */} +
+ +
+
+ + Tracked: {task.trackedMinutes || 0}m / {task.estimatedMinutes || 0}m + + {task.estimatedMinutes && task.estimatedMinutes > 0 && ( + + {Math.min(100, Math.round(((task.trackedMinutes || 0) / task.estimatedMinutes) * 100))}% + + )} +
+ {task.estimatedMinutes && task.estimatedMinutes > 0 && ( +
+
+
+ )} +
+ {timerRunning ? ( + <> + + + {formatDuration(elapsedSeconds)} + + + ) : ( + + )} +
+
+
+ {/* Tags */} {task.tags && task.tags.length > 0 && (
diff --git a/apps/web/components/tasks/tasks-kanban-view.tsx b/apps/web/components/tasks/tasks-kanban-view.tsx index 5906265..efbe1d7 100644 --- a/apps/web/components/tasks/tasks-kanban-view.tsx +++ b/apps/web/components/tasks/tasks-kanban-view.tsx @@ -99,6 +99,15 @@ function TaskCard({ {new Date(task.dueDate).toLocaleDateString()} )} + {task.estimatedMinutes && ( + + + {task.estimatedMinutes}m + + )} {task.tags?.length > 0 && (
{task.tags.slice(0, 3).map((tag) => (