From c695aeecfc0796d043ec3e4d0e11c65dfdb62c68 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 6 Dec 2025 22:26:45 -0800 Subject: [PATCH] feat: add velocity dashboard, Do Today feature, and label toggle filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add real velocity metrics using statusHistory data (cycle time, throughput, weekly bars) - Add doToday field to tasks with star toggle in TaskCard and TaskList - Add "Today" filter button to show only starred tasks - Make label tags clickable toggles for filtering (click to add/remove filters) - All filters sync with URL parameters for shareable links Closes task-001 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/core/backlog.ts | 7 ++ src/core/statistics.ts | 107 +++++++++++++++++ src/markdown/parser.ts | 1 + src/markdown/serializer.ts | 1 + src/types/index.ts | 3 + src/web/components/Statistics.tsx | 91 ++++++++++++++- src/web/components/TaskCard.tsx | 94 ++++++++++++--- src/web/components/TaskList.tsx | 184 ++++++++++++++++++++++++++---- 8 files changed, 450 insertions(+), 38 deletions(-) diff --git a/src/core/backlog.ts b/src/core/backlog.ts index 9aedeb8..5c719b0 100644 --- a/src/core/backlog.ts +++ b/src/core/backlog.ts @@ -648,6 +648,13 @@ export class Core { } } + if (input.doToday !== undefined) { + if (task.doToday !== input.doToday) { + task.doToday = input.doToday; + mutated = true; + } + } + if (input.assignee !== undefined) { const sanitizedAssignee = normalizeStringList(input.assignee) ?? []; if (!stringArraysEqual(sanitizedAssignee, task.assignee ?? [])) { diff --git a/src/core/statistics.ts b/src/core/statistics.ts index b58645a..94b58d1 100644 --- a/src/core/statistics.ts +++ b/src/core/statistics.ts @@ -1,5 +1,20 @@ import type { Task } from "../types/index.ts"; +export interface VelocityStats { + /** Tasks completed in the last 7 days */ + completedLast7Days: number; + /** Average time from To Do to Done in milliseconds */ + avgCycleTime: number | null; + /** Average time from To Do to In Progress in milliseconds */ + avgTimeToStart: number | null; + /** Average tasks completed per week (based on last 4 weeks) */ + weeklyThroughput: number; + /** Weekly completion counts for the last 4 weeks [this week, last week, 2 weeks ago, 3 weeks ago] */ + weeklyCompletions: number[]; + /** Number of tasks with status history data */ + tasksWithHistory: number; +} + export interface TaskStatistics { statusCounts: Map; priorityCounts: Map; @@ -16,6 +31,87 @@ export interface TaskStatistics { staleTasks: Task[]; blockedTasks: Task[]; }; + velocity: VelocityStats; + /** Tasks marked for "Do Today" */ + doTodayTasks: Task[]; +} + +/** + * Parse a timestamp string to Date object + */ +function parseTimestamp(ts: string | undefined): Date | null { + if (!ts) return null; + // Handle "YYYY-MM-DD HH:mm" format + const date = new Date(ts.replace(" ", "T")); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** + * Calculate velocity metrics from task status history + */ +function calculateVelocityStats(tasks: Task[]): VelocityStats { + const now = new Date(); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const fourWeeksAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000); + + let completedLast7Days = 0; + const cycleTimes: number[] = []; + const timesToStart: number[] = []; + const weeklyCompletions = [0, 0, 0, 0]; // Last 4 weeks + let tasksWithHistory = 0; + + for (const task of tasks) { + const history = task.statusHistory || []; + if (history.length === 0) continue; + + tasksWithHistory++; + + // Find status transitions + let todoTime: Date | null = null; + let inProgressTime: Date | null = null; + let doneTime: Date | null = null; + + for (const entry of history) { + const ts = parseTimestamp(entry.timestamp); + if (!ts) continue; + const status = entry.status.toLowerCase(); + if (status === "to do" && !todoTime) todoTime = ts; + if (status === "in progress" && !inProgressTime) inProgressTime = ts; + if (status === "done" && !doneTime) doneTime = ts; + } + + // Count completions in last 7 days + if (doneTime && doneTime >= sevenDaysAgo) { + completedLast7Days++; + } + + // Track weekly completions + if (doneTime && doneTime >= fourWeeksAgo) { + const weekIndex = Math.floor((now.getTime() - doneTime.getTime()) / (7 * 24 * 60 * 60 * 1000)); + if (weekIndex >= 0 && weekIndex < 4 && weeklyCompletions[weekIndex] !== undefined) { + weeklyCompletions[weekIndex] = weeklyCompletions[weekIndex] + 1; + } + } + + // Calculate cycle time (To Do -> Done) + if (todoTime && doneTime && doneTime > todoTime) { + cycleTimes.push(doneTime.getTime() - todoTime.getTime()); + } + + // Calculate time to start (To Do -> In Progress) + if (todoTime && inProgressTime && inProgressTime > todoTime) { + timesToStart.push(inProgressTime.getTime() - todoTime.getTime()); + } + } + + return { + completedLast7Days, + avgCycleTime: cycleTimes.length > 0 ? cycleTimes.reduce((a, b) => a + b, 0) / cycleTimes.length : null, + avgTimeToStart: timesToStart.length > 0 ? timesToStart.reduce((a, b) => a + b, 0) / timesToStart.length : null, + weeklyThroughput: weeklyCompletions.reduce((a, b) => a + b, 0) / 4, + weeklyCompletions, + tasksWithHistory, + }; } /** @@ -45,6 +141,7 @@ export function getTaskStatistics(tasks: Task[], drafts: Task[], statuses: strin const recentlyUpdated: Task[] = []; const staleTasks: Task[] = []; const blockedTasks: Task[] = []; + const doTodayTasks: Task[] = []; let totalAge = 0; let taskCount = 0; @@ -55,6 +152,11 @@ export function getTaskStatistics(tasks: Task[], drafts: Task[], statuses: strin continue; } + // Track "Do Today" tasks + if (task.doToday) { + doTodayTasks.push(task); + } + // Count by status const currentCount = statusCounts.get(task.status) || 0; statusCounts.set(task.status, currentCount + 1); @@ -142,6 +244,9 @@ export function getTaskStatistics(tasks: Task[], drafts: Task[], statuses: strin const totalTasks = Array.from(statusCounts.values()).reduce((sum, count) => sum + count, 0); const completionPercentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + // Calculate velocity stats + const velocity = calculateVelocityStats(tasks); + return { statusCounts, priorityCounts, @@ -158,5 +263,7 @@ export function getTaskStatistics(tasks: Task[], drafts: Task[], statuses: strin staleTasks: staleTasks.slice(0, 5), // Top 5 stale tasks blockedTasks: blockedTasks.slice(0, 5), // Top 5 blocked tasks }, + velocity, + doTodayTasks, }; } diff --git a/src/markdown/parser.ts b/src/markdown/parser.ts index 2cd4eb2..330d67d 100644 --- a/src/markdown/parser.ts +++ b/src/markdown/parser.ts @@ -162,6 +162,7 @@ export function parseTask(content: string): Task { ordinal: frontmatter.ordinal !== undefined ? Number(frontmatter.ordinal) : undefined, onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined, statusHistory: parseStatusHistory(frontmatter.status_history), + doToday: frontmatter.do_today === true || frontmatter.doToday === true, }; } diff --git a/src/markdown/serializer.ts b/src/markdown/serializer.ts index 8654445..e67c17b 100644 --- a/src/markdown/serializer.ts +++ b/src/markdown/serializer.ts @@ -22,6 +22,7 @@ export function serializeTask(task: Task): string { ...(task.ordinal !== undefined && { ordinal: task.ordinal }), ...(task.onStatusChange && { onStatusChange: task.onStatusChange }), ...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }), + ...(task.doToday && { do_today: task.doToday }), }; let contentBody = task.rawContent ?? ""; diff --git a/src/types/index.ts b/src/types/index.ts index 931e168..10e11af 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -48,6 +48,8 @@ export interface Task { onStatusChange?: string; /** History of status transitions with timestamps for velocity tracking */ statusHistory?: StatusHistoryEntry[]; + /** Flag to mark task for "Do Today" daily focus list */ + doToday?: boolean; } /** @@ -97,6 +99,7 @@ export interface TaskUpdateInput { checkAcceptanceCriteria?: number[]; uncheckAcceptanceCriteria?: number[]; rawContent?: string; + doToday?: boolean; } export interface TaskListFilter { diff --git a/src/web/components/Statistics.tsx b/src/web/components/Statistics.tsx index 003bfd8..49f9d69 100644 --- a/src/web/components/Statistics.tsx +++ b/src/web/components/Statistics.tsx @@ -1,12 +1,14 @@ import React, { useState, useEffect } from 'react'; import { apiClient } from '../lib/api'; -import type { TaskStatistics } from '../../core/statistics'; +import type { TaskStatistics, VelocityStats } from '../../core/statistics'; import type { Task } from '../../types'; import LoadingSpinner from './LoadingSpinner'; interface StatisticsData extends Omit { statusCounts: Record; priorityCounts: Record; + velocity: VelocityStats; + doTodayTasks: Task[]; } interface StatisticsProps { @@ -242,6 +244,16 @@ const Statistics: React.FC = ({ tasks, isLoading: externalLoadi } }; + const formatDuration = (ms: number): string => { + if (!ms || ms <= 0) return '-'; + const hours = ms / (1000 * 60 * 60); + if (hours < 24) return `${Math.round(hours)}h`; + const days = hours / 24; + if (days < 7) return `${days.toFixed(1)}d`; + const weeks = days / 7; + return `${weeks.toFixed(1)}w`; + }; + return (
{/* Header */} @@ -321,7 +333,7 @@ const Statistics: React.FC = ({ tasks, isLoading: externalLoadi

Overall Progress

-
@@ -332,6 +344,81 @@ const Statistics: React.FC = ({ tasks, isLoading: externalLoadi
+ {/* Task Velocity */} +
+
+

Task Velocity

+ {statistics.velocity.tasksWithHistory === 0 && ( + + No history data yet + + )} +
+ + {/* Velocity Key Metrics */} +
+
+
+ {statistics.velocity.completedLast7Days} +
+
Completed (7d)
+
+
+
+ {statistics.velocity.avgCycleTime + ? formatDuration(statistics.velocity.avgCycleTime) + : '-'} +
+
Avg Cycle Time
+
+
+
+ {statistics.velocity.avgTimeToStart + ? formatDuration(statistics.velocity.avgTimeToStart) + : '-'} +
+
Avg Time to Start
+
+
+
+ {statistics.velocity.weeklyThroughput.toFixed(1)} +
+
Avg/Week (4w)
+
+
+ + {/* Weekly Velocity Bars */} +
+

Weekly Completions

+ {(() => { + const weekLabels = ['This week', 'Last week', '2 weeks ago', '3 weeks ago']; + const maxWeekly = Math.max(...statistics.velocity.weeklyCompletions, 1); + return statistics.velocity.weeklyCompletions.map((count, i) => ( +
+ {weekLabels[i]} +
+
+
+ + {count} done + +
+ )); + })()} +
+ + {statistics.velocity.tasksWithHistory > 0 && ( +
+

+ Based on {statistics.velocity.tasksWithHistory} task{statistics.velocity.tasksWithHistory !== 1 ? 's' : ''} with status history +

+
+ )} +
+ {/* Status and Priority Distribution */}
{/* Status Distribution */} diff --git a/src/web/components/TaskCard.tsx b/src/web/components/TaskCard.tsx index edcd71b..beacb6c 100644 --- a/src/web/components/TaskCard.tsx +++ b/src/web/components/TaskCard.tsx @@ -9,10 +9,13 @@ interface TaskCardProps { onDragEnd?: () => void; onDelete?: (taskId: string) => void; onArchive?: (taskId: string) => void; + onToggleDoToday?: (taskId: string, doToday: boolean) => void; + onLabelClick?: (label: string) => void; + activeLabels?: string[]; status?: string; } -const TaskCard: React.FC = ({ task, onEdit, onDragStart, onDragEnd, onDelete, onArchive, status }) => { +const TaskCard: React.FC = ({ task, onUpdate, onEdit, onDragStart, onDragEnd, onDelete, onArchive, onToggleDoToday, onLabelClick, activeLabels = [], status }) => { const [isDragging, setIsDragging] = React.useState(false); const [showBranchTooltip, setShowBranchTooltip] = React.useState(false); const [isHovering, setIsHovering] = React.useState(false); @@ -91,6 +94,20 @@ const TaskCard: React.FC = ({ task, onEdit, onDragStart, onDragEn } }; + const handleDoTodayClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onToggleDoToday && !isFromOtherBranch) { + onToggleDoToday(task.id, !task.doToday); + } + }; + + const handleLabelClick = (e: React.MouseEvent, label: string) => { + e.stopPropagation(); + if (onLabelClick) { + onLabelClick(label); + } + }; + return (
= ({ task, onEdit, onDragStart, onDragEn )}
-

- {task.title} -

+
+ {/* Do Today Star */} + {onToggleDoToday && !isFromOtherBranch && ( + + )} + {/* Read-only star indicator for doToday tasks from other branches */} + {task.doToday && isFromOtherBranch && ( + + + + + + )} +

+ {task.title} +

+
{task.id}
@@ -182,14 +225,33 @@ const TaskCard: React.FC = ({ task, onEdit, onDragStart, onDragEn {task.labels.length > 0 && (
- {task.labels.map(label => ( - - {label} - - ))} + {task.labels.map(label => { + const isActive = activeLabels.includes(label); + return onLabelClick ? ( + + ) : ( + + {label} + + ); + })}
)} diff --git a/src/web/components/TaskList.tsx b/src/web/components/TaskList.tsx index 1a162f7..8420088 100644 --- a/src/web/components/TaskList.tsx +++ b/src/web/components/TaskList.tsx @@ -15,6 +15,7 @@ interface TaskListProps { tasks: Task[]; availableStatuses: string[]; onRefreshData?: () => Promise; + onUpdateTask?: (taskId: string, updates: Partial) => Promise; } const PRIORITY_OPTIONS: Array<{ label: string; value: "" | SearchPriorityFilter }> = [ @@ -32,13 +33,18 @@ function sortTasksByIdDescending(list: Task[]): Task[] { }); } -const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, availableStatuses, onRefreshData }) => { +const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, availableStatuses, onRefreshData, onUpdateTask }) => { const [searchParams, setSearchParams] = useSearchParams(); const [searchValue, setSearchValue] = useState(() => searchParams.get("query") ?? ""); const [statusFilter, setStatusFilter] = useState(() => searchParams.get("status") ?? ""); const [priorityFilter, setPriorityFilter] = useState<"" | SearchPriorityFilter>( () => (searchParams.get("priority") as SearchPriorityFilter | null) ?? "", ); + const [doTodayFilter, setDoTodayFilter] = useState(() => searchParams.get("doToday") === "true"); + const [labelFilters, setLabelFilters] = useState(() => { + const labels = searchParams.get("labels"); + return labels ? labels.split(",").filter(Boolean) : []; + }); const [displayTasks, setDisplayTasks] = useState(() => sortTasksByIdDescending(tasks)); const [error, setError] = useState(null); const [showCleanupModal, setShowCleanupModal] = useState(false); @@ -46,13 +52,16 @@ const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, avail const sortedBaseTasks = useMemo(() => sortTasksByIdDescending(tasks), [tasks]); const normalizedSearch = searchValue.trim(); - const hasActiveFilters = Boolean(normalizedSearch || statusFilter || priorityFilter); + const hasActiveFilters = Boolean(normalizedSearch || statusFilter || priorityFilter || doTodayFilter || labelFilters.length > 0); const totalTasks = sortedBaseTasks.length; useEffect(() => { const paramQuery = searchParams.get("query") ?? ""; const paramStatus = searchParams.get("status") ?? ""; const paramPriority = (searchParams.get("priority") as SearchPriorityFilter | null) ?? ""; + const paramDoToday = searchParams.get("doToday") === "true"; + const paramLabels = searchParams.get("labels"); + const labelsArray = paramLabels ? paramLabels.split(",").filter(Boolean) : []; if (paramQuery !== searchValue) { setSearchValue(paramQuery); @@ -63,6 +72,12 @@ const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, avail if (paramPriority !== priorityFilter) { setPriorityFilter(paramPriority); } + if (paramDoToday !== doTodayFilter) { + setDoTodayFilter(paramDoToday); + } + if (JSON.stringify(labelsArray) !== JSON.stringify(labelFilters)) { + setLabelFilters(labelsArray); + } }, [searchParams]); useEffect(() => { @@ -82,17 +97,35 @@ const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, avail const fetchFilteredTasks = async () => { try { - const results = await apiClient.search({ - query: normalizedSearch || undefined, - types: ["task"], - status: statusFilter || undefined, - priority: (priorityFilter || undefined) as SearchPriorityFilter | undefined, - }); - if (cancelled) { - return; + // First get base results from API (for text search, status, priority) + let filteredTasks: Task[]; + if (normalizedSearch || statusFilter || priorityFilter) { + const results = await apiClient.search({ + query: normalizedSearch || undefined, + types: ["task"], + status: statusFilter || undefined, + priority: (priorityFilter || undefined) as SearchPriorityFilter | undefined, + }); + if (cancelled) { + return; + } + const taskResults = results.filter((result): result is TaskSearchResult => result.type === "task"); + filteredTasks = taskResults.map((result) => result.task); + } else { + filteredTasks = tasks; } - const taskResults = results.filter((result): result is TaskSearchResult => result.type === "task"); - setDisplayTasks(sortTasksByIdDescending(taskResults.map((result) => result.task))); + + // Apply client-side filters for doToday and labels + if (doTodayFilter) { + filteredTasks = filteredTasks.filter(task => task.doToday === true); + } + if (labelFilters.length > 0) { + filteredTasks = filteredTasks.filter(task => + labelFilters.every(label => task.labels.includes(label)) + ); + } + + setDisplayTasks(sortTasksByIdDescending(filteredTasks)); } catch (err) { console.error("Failed to apply task filters:", err); if (!cancelled) { @@ -107,9 +140,15 @@ const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, avail return () => { cancelled = true; }; - }, [hasActiveFilters, normalizedSearch, priorityFilter, statusFilter, tasks]); + }, [hasActiveFilters, normalizedSearch, priorityFilter, statusFilter, doTodayFilter, labelFilters, tasks]); - const syncUrl = (nextQuery: string, nextStatus: string, nextPriority: "" | SearchPriorityFilter) => { + const syncUrl = ( + nextQuery: string, + nextStatus: string, + nextPriority: "" | SearchPriorityFilter, + nextDoToday: boolean = doTodayFilter, + nextLabels: string[] = labelFilters + ) => { const params = new URLSearchParams(); const trimmedQuery = nextQuery.trim(); if (trimmedQuery) { @@ -121,6 +160,12 @@ const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, avail if (nextPriority) { params.set("priority", nextPriority); } + if (nextDoToday) { + params.set("doToday", "true"); + } + if (nextLabels.length > 0) { + params.set("labels", nextLabels.join(",")); + } setSearchParams(params, { replace: true }); }; @@ -139,11 +184,33 @@ const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, avail syncUrl(searchValue, statusFilter, value); }; + const handleDoTodayToggle = () => { + const newValue = !doTodayFilter; + setDoTodayFilter(newValue); + syncUrl(searchValue, statusFilter, priorityFilter, newValue, labelFilters); + }; + + const handleLabelToggle = (label: string) => { + const newLabels = labelFilters.includes(label) + ? labelFilters.filter(l => l !== label) + : [...labelFilters, label]; + setLabelFilters(newLabels); + syncUrl(searchValue, statusFilter, priorityFilter, doTodayFilter, newLabels); + }; + + const handleToggleDoToday = async (taskId: string, doToday: boolean) => { + if (onUpdateTask) { + await onUpdateTask(taskId, { doToday }); + } + }; + const handleClearFilters = () => { setSearchValue(""); setStatusFilter(""); setPriorityFilter(""); - syncUrl("", "", ""); + setDoTodayFilter(false); + setLabelFilters([]); + syncUrl("", "", "", false, []); setDisplayTasks(sortedBaseTasks); setError(null); }; @@ -256,6 +323,40 @@ const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, avail ))} + {/* Do Today Toggle */} + + + {/* Active Label Filters */} + {labelFilters.length > 0 && ( +
+ {labelFilters.map(label => ( + + ))} +
+ )} + {statusFilter.toLowerCase() === 'done' && displayTasks.length > 0 && ( + )} + {/* Read-only star for doToday from other branch */} + {task.doToday && isFromOtherBranch && ( + + + + + + )}

{task.title}

{task.status} @@ -365,11 +493,27 @@ const TaskList: React.FC = ({ onEditTask, onNewTask, tasks, avail )} {task.labels && task.labels.length > 0 && (
- {task.labels.map((label) => ( - - {label} - - ))} + {task.labels.map((label) => { + const isActive = labelFilters.includes(label); + return ( + + ); + })}
)}