feat: add velocity dashboard, Do Today feature, and label toggle filters

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-06 22:26:45 -08:00
parent 85482d4028
commit c695aeecfc
8 changed files with 450 additions and 38 deletions

View File

@ -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) { if (input.assignee !== undefined) {
const sanitizedAssignee = normalizeStringList(input.assignee) ?? []; const sanitizedAssignee = normalizeStringList(input.assignee) ?? [];
if (!stringArraysEqual(sanitizedAssignee, task.assignee ?? [])) { if (!stringArraysEqual(sanitizedAssignee, task.assignee ?? [])) {

View File

@ -1,5 +1,20 @@
import type { Task } from "../types/index.ts"; 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 { export interface TaskStatistics {
statusCounts: Map<string, number>; statusCounts: Map<string, number>;
priorityCounts: Map<string, number>; priorityCounts: Map<string, number>;
@ -16,6 +31,87 @@ export interface TaskStatistics {
staleTasks: Task[]; staleTasks: Task[];
blockedTasks: 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 recentlyUpdated: Task[] = [];
const staleTasks: Task[] = []; const staleTasks: Task[] = [];
const blockedTasks: Task[] = []; const blockedTasks: Task[] = [];
const doTodayTasks: Task[] = [];
let totalAge = 0; let totalAge = 0;
let taskCount = 0; let taskCount = 0;
@ -55,6 +152,11 @@ export function getTaskStatistics(tasks: Task[], drafts: Task[], statuses: strin
continue; continue;
} }
// Track "Do Today" tasks
if (task.doToday) {
doTodayTasks.push(task);
}
// Count by status // Count by status
const currentCount = statusCounts.get(task.status) || 0; const currentCount = statusCounts.get(task.status) || 0;
statusCounts.set(task.status, currentCount + 1); 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 totalTasks = Array.from(statusCounts.values()).reduce((sum, count) => sum + count, 0);
const completionPercentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; const completionPercentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
// Calculate velocity stats
const velocity = calculateVelocityStats(tasks);
return { return {
statusCounts, statusCounts,
priorityCounts, priorityCounts,
@ -158,5 +263,7 @@ export function getTaskStatistics(tasks: Task[], drafts: Task[], statuses: strin
staleTasks: staleTasks.slice(0, 5), // Top 5 stale tasks staleTasks: staleTasks.slice(0, 5), // Top 5 stale tasks
blockedTasks: blockedTasks.slice(0, 5), // Top 5 blocked tasks blockedTasks: blockedTasks.slice(0, 5), // Top 5 blocked tasks
}, },
velocity,
doTodayTasks,
}; };
} }

View File

@ -162,6 +162,7 @@ export function parseTask(content: string): Task {
ordinal: frontmatter.ordinal !== undefined ? Number(frontmatter.ordinal) : undefined, ordinal: frontmatter.ordinal !== undefined ? Number(frontmatter.ordinal) : undefined,
onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined, onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined,
statusHistory: parseStatusHistory(frontmatter.status_history), statusHistory: parseStatusHistory(frontmatter.status_history),
doToday: frontmatter.do_today === true || frontmatter.doToday === true,
}; };
} }

View File

@ -22,6 +22,7 @@ export function serializeTask(task: Task): string {
...(task.ordinal !== undefined && { ordinal: task.ordinal }), ...(task.ordinal !== undefined && { ordinal: task.ordinal }),
...(task.onStatusChange && { onStatusChange: task.onStatusChange }), ...(task.onStatusChange && { onStatusChange: task.onStatusChange }),
...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }), ...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }),
...(task.doToday && { do_today: task.doToday }),
}; };
let contentBody = task.rawContent ?? ""; let contentBody = task.rawContent ?? "";

View File

@ -48,6 +48,8 @@ export interface Task {
onStatusChange?: string; onStatusChange?: string;
/** History of status transitions with timestamps for velocity tracking */ /** History of status transitions with timestamps for velocity tracking */
statusHistory?: StatusHistoryEntry[]; statusHistory?: StatusHistoryEntry[];
/** Flag to mark task for "Do Today" daily focus list */
doToday?: boolean;
} }
/** /**
@ -97,6 +99,7 @@ export interface TaskUpdateInput {
checkAcceptanceCriteria?: number[]; checkAcceptanceCriteria?: number[];
uncheckAcceptanceCriteria?: number[]; uncheckAcceptanceCriteria?: number[];
rawContent?: string; rawContent?: string;
doToday?: boolean;
} }
export interface TaskListFilter { export interface TaskListFilter {

View File

@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { apiClient } from '../lib/api'; import { apiClient } from '../lib/api';
import type { TaskStatistics } from '../../core/statistics'; import type { TaskStatistics, VelocityStats } from '../../core/statistics';
import type { Task } from '../../types'; import type { Task } from '../../types';
import LoadingSpinner from './LoadingSpinner'; import LoadingSpinner from './LoadingSpinner';
interface StatisticsData extends Omit<TaskStatistics, 'statusCounts' | 'priorityCounts'> { interface StatisticsData extends Omit<TaskStatistics, 'statusCounts' | 'priorityCounts'> {
statusCounts: Record<string, number>; statusCounts: Record<string, number>;
priorityCounts: Record<string, number>; priorityCounts: Record<string, number>;
velocity: VelocityStats;
doTodayTasks: Task[];
} }
interface StatisticsProps { interface StatisticsProps {
@ -242,6 +244,16 @@ const Statistics: React.FC<StatisticsProps> = ({ 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 ( return (
<div className="max-w-7xl mx-auto p-6 space-y-8"> <div className="max-w-7xl mx-auto p-6 space-y-8">
{/* Header */} {/* Header */}
@ -332,6 +344,81 @@ const Statistics: React.FC<StatisticsProps> = ({ tasks, isLoading: externalLoadi
</div> </div>
</div> </div>
{/* Task Velocity */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Task Velocity</h3>
{statistics.velocity.tasksWithHistory === 0 && (
<span className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-2 py-1 rounded">
No history data yet
</span>
)}
</div>
{/* Velocity Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{statistics.velocity.completedLast7Days}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Completed (7d)</div>
</div>
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{statistics.velocity.avgCycleTime
? formatDuration(statistics.velocity.avgCycleTime)
: '-'}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Avg Cycle Time</div>
</div>
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{statistics.velocity.avgTimeToStart
? formatDuration(statistics.velocity.avgTimeToStart)
: '-'}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Avg Time to Start</div>
</div>
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{statistics.velocity.weeklyThroughput.toFixed(1)}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Avg/Week (4w)</div>
</div>
</div>
{/* Weekly Velocity Bars */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Weekly Completions</h4>
{(() => {
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) => (
<div key={weekLabels[i]} className="flex items-center gap-3">
<span className="text-xs text-gray-500 dark:text-gray-400 w-24">{weekLabels[i]}</span>
<div className="flex-1 bg-gray-200 dark:bg-gray-600 rounded-circle h-4">
<div
className="bg-gradient-to-r from-blue-400 to-blue-600 h-4 rounded-circle transition-all duration-300"
style={{ width: `${(count / maxWeekly) * 100}%` }}
></div>
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 w-16 text-right">
{count} done
</span>
</div>
));
})()}
</div>
{statistics.velocity.tasksWithHistory > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400">
Based on {statistics.velocity.tasksWithHistory} task{statistics.velocity.tasksWithHistory !== 1 ? 's' : ''} with status history
</p>
</div>
)}
</div>
{/* Status and Priority Distribution */} {/* Status and Priority Distribution */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Status Distribution */} {/* Status Distribution */}

View File

@ -9,10 +9,13 @@ interface TaskCardProps {
onDragEnd?: () => void; onDragEnd?: () => void;
onDelete?: (taskId: string) => void; onDelete?: (taskId: string) => void;
onArchive?: (taskId: string) => void; onArchive?: (taskId: string) => void;
onToggleDoToday?: (taskId: string, doToday: boolean) => void;
onLabelClick?: (label: string) => void;
activeLabels?: string[];
status?: string; status?: string;
} }
const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEnd, onDelete, onArchive, status }) => { const TaskCard: React.FC<TaskCardProps> = ({ task, onUpdate, onEdit, onDragStart, onDragEnd, onDelete, onArchive, onToggleDoToday, onLabelClick, activeLabels = [], status }) => {
const [isDragging, setIsDragging] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false);
const [showBranchTooltip, setShowBranchTooltip] = React.useState(false); const [showBranchTooltip, setShowBranchTooltip] = React.useState(false);
const [isHovering, setIsHovering] = React.useState(false); const [isHovering, setIsHovering] = React.useState(false);
@ -91,6 +94,20 @@ const TaskCard: React.FC<TaskCardProps> = ({ 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 ( return (
<div <div
className="relative" className="relative"
@ -164,13 +181,39 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEn
)} )}
<div className="mb-2"> <div className="mb-2">
<h4 className={`font-semibold text-sm line-clamp-2 transition-colors duration-200 ${ <div className="flex items-start gap-1">
isFromOtherBranch {/* Do Today Star */}
? 'text-gray-600 dark:text-gray-400' {onToggleDoToday && !isFromOtherBranch && (
: 'text-gray-900 dark:text-gray-100' <button
}`}> onClick={handleDoTodayClick}
{task.title} className={`flex-shrink-0 p-0.5 rounded transition-colors duration-150 cursor-pointer ${
</h4> task.doToday
? 'text-amber-500 hover:text-amber-600'
: 'text-gray-300 dark:text-gray-500 hover:text-amber-400 dark:hover:text-amber-400'
}`}
title={task.doToday ? 'Remove from Do Today' : 'Add to Do Today'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill={task.doToday ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
</button>
)}
{/* Read-only star indicator for doToday tasks from other branches */}
{task.doToday && isFromOtherBranch && (
<span className="flex-shrink-0 p-0.5 text-amber-500">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
</span>
)}
<h4 className={`font-semibold text-sm line-clamp-2 transition-colors duration-200 ${
isFromOtherBranch
? 'text-gray-600 dark:text-gray-400'
: 'text-gray-900 dark:text-gray-100'
}`}>
{task.title}
</h4>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 transition-colors duration-200">{task.id}</span> <span className="text-xs text-gray-500 dark:text-gray-400 transition-colors duration-200">{task.id}</span>
</div> </div>
@ -182,14 +225,33 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEn
{task.labels.length > 0 && ( {task.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2"> <div className="flex flex-wrap gap-1 mb-2">
{task.labels.map(label => ( {task.labels.map(label => {
<span const isActive = activeLabels.includes(label);
key={label} return onLabelClick ? (
className="inline-block px-2 py-1 text-xs bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded transition-colors duration-200" <button
> key={label}
{label} onClick={(e) => handleLabelClick(e, label)}
</span> className={`inline-block px-2 py-1 text-xs rounded transition-colors duration-200 cursor-pointer ${
))} isActive
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 hover:text-blue-700 dark:hover:text-blue-300'
}`}
title={isActive ? `Remove "${label}" filter` : `Filter by "${label}"`}
>
{label}
{isActive && (
<span className="ml-1">×</span>
)}
</button>
) : (
<span
key={label}
className="inline-block px-2 py-1 text-xs bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded transition-colors duration-200"
>
{label}
</span>
);
})}
</div> </div>
)} )}

View File

@ -15,6 +15,7 @@ interface TaskListProps {
tasks: Task[]; tasks: Task[];
availableStatuses: string[]; availableStatuses: string[];
onRefreshData?: () => Promise<void>; onRefreshData?: () => Promise<void>;
onUpdateTask?: (taskId: string, updates: Partial<Task>) => Promise<void>;
} }
const PRIORITY_OPTIONS: Array<{ label: string; value: "" | SearchPriorityFilter }> = [ const PRIORITY_OPTIONS: Array<{ label: string; value: "" | SearchPriorityFilter }> = [
@ -32,13 +33,18 @@ function sortTasksByIdDescending(list: Task[]): Task[] {
}); });
} }
const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, availableStatuses, onRefreshData }) => { const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, availableStatuses, onRefreshData, onUpdateTask }) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [searchValue, setSearchValue] = useState(() => searchParams.get("query") ?? ""); const [searchValue, setSearchValue] = useState(() => searchParams.get("query") ?? "");
const [statusFilter, setStatusFilter] = useState(() => searchParams.get("status") ?? ""); const [statusFilter, setStatusFilter] = useState(() => searchParams.get("status") ?? "");
const [priorityFilter, setPriorityFilter] = useState<"" | SearchPriorityFilter>( const [priorityFilter, setPriorityFilter] = useState<"" | SearchPriorityFilter>(
() => (searchParams.get("priority") as SearchPriorityFilter | null) ?? "", () => (searchParams.get("priority") as SearchPriorityFilter | null) ?? "",
); );
const [doTodayFilter, setDoTodayFilter] = useState(() => searchParams.get("doToday") === "true");
const [labelFilters, setLabelFilters] = useState<string[]>(() => {
const labels = searchParams.get("labels");
return labels ? labels.split(",").filter(Boolean) : [];
});
const [displayTasks, setDisplayTasks] = useState<Task[]>(() => sortTasksByIdDescending(tasks)); const [displayTasks, setDisplayTasks] = useState<Task[]>(() => sortTasksByIdDescending(tasks));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showCleanupModal, setShowCleanupModal] = useState(false); const [showCleanupModal, setShowCleanupModal] = useState(false);
@ -46,13 +52,16 @@ const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, avail
const sortedBaseTasks = useMemo(() => sortTasksByIdDescending(tasks), [tasks]); const sortedBaseTasks = useMemo(() => sortTasksByIdDescending(tasks), [tasks]);
const normalizedSearch = searchValue.trim(); const normalizedSearch = searchValue.trim();
const hasActiveFilters = Boolean(normalizedSearch || statusFilter || priorityFilter); const hasActiveFilters = Boolean(normalizedSearch || statusFilter || priorityFilter || doTodayFilter || labelFilters.length > 0);
const totalTasks = sortedBaseTasks.length; const totalTasks = sortedBaseTasks.length;
useEffect(() => { useEffect(() => {
const paramQuery = searchParams.get("query") ?? ""; const paramQuery = searchParams.get("query") ?? "";
const paramStatus = searchParams.get("status") ?? ""; const paramStatus = searchParams.get("status") ?? "";
const paramPriority = (searchParams.get("priority") as SearchPriorityFilter | null) ?? ""; 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) { if (paramQuery !== searchValue) {
setSearchValue(paramQuery); setSearchValue(paramQuery);
@ -63,6 +72,12 @@ const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, avail
if (paramPriority !== priorityFilter) { if (paramPriority !== priorityFilter) {
setPriorityFilter(paramPriority); setPriorityFilter(paramPriority);
} }
if (paramDoToday !== doTodayFilter) {
setDoTodayFilter(paramDoToday);
}
if (JSON.stringify(labelsArray) !== JSON.stringify(labelFilters)) {
setLabelFilters(labelsArray);
}
}, [searchParams]); }, [searchParams]);
useEffect(() => { useEffect(() => {
@ -82,17 +97,35 @@ const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, avail
const fetchFilteredTasks = async () => { const fetchFilteredTasks = async () => {
try { try {
const results = await apiClient.search({ // First get base results from API (for text search, status, priority)
query: normalizedSearch || undefined, let filteredTasks: Task[];
types: ["task"], if (normalizedSearch || statusFilter || priorityFilter) {
status: statusFilter || undefined, const results = await apiClient.search({
priority: (priorityFilter || undefined) as SearchPriorityFilter | undefined, query: normalizedSearch || undefined,
}); types: ["task"],
if (cancelled) { status: statusFilter || undefined,
return; 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) { } catch (err) {
console.error("Failed to apply task filters:", err); console.error("Failed to apply task filters:", err);
if (!cancelled) { if (!cancelled) {
@ -107,9 +140,15 @@ const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, avail
return () => { return () => {
cancelled = true; 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 params = new URLSearchParams();
const trimmedQuery = nextQuery.trim(); const trimmedQuery = nextQuery.trim();
if (trimmedQuery) { if (trimmedQuery) {
@ -121,6 +160,12 @@ const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, avail
if (nextPriority) { if (nextPriority) {
params.set("priority", nextPriority); params.set("priority", nextPriority);
} }
if (nextDoToday) {
params.set("doToday", "true");
}
if (nextLabels.length > 0) {
params.set("labels", nextLabels.join(","));
}
setSearchParams(params, { replace: true }); setSearchParams(params, { replace: true });
}; };
@ -139,11 +184,33 @@ const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, avail
syncUrl(searchValue, statusFilter, value); 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 = () => { const handleClearFilters = () => {
setSearchValue(""); setSearchValue("");
setStatusFilter(""); setStatusFilter("");
setPriorityFilter(""); setPriorityFilter("");
syncUrl("", "", ""); setDoTodayFilter(false);
setLabelFilters([]);
syncUrl("", "", "", false, []);
setDisplayTasks(sortedBaseTasks); setDisplayTasks(sortedBaseTasks);
setError(null); setError(null);
}; };
@ -256,6 +323,40 @@ const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, avail
))} ))}
</select> </select>
{/* Do Today Toggle */}
<button
type="button"
onClick={handleDoTodayToggle}
className={`py-2 px-3 text-sm border rounded-lg transition-colors duration-200 cursor-pointer flex items-center gap-1.5 ${
doTodayFilter
? 'border-amber-400 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={doTodayFilter ? 'Showing Do Today tasks only' : 'Show Do Today tasks only'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill={doTodayFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
Today
</button>
{/* Active Label Filters */}
{labelFilters.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{labelFilters.map(label => (
<button
key={label}
type="button"
onClick={() => handleLabelToggle(label)}
className="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors duration-200 cursor-pointer flex items-center gap-1"
>
{label}
<span>×</span>
</button>
))}
</div>
)}
{statusFilter.toLowerCase() === 'done' && displayTasks.length > 0 && ( {statusFilter.toLowerCase() === 'done' && displayTasks.length > 0 && (
<button <button
type="button" type="button"
@ -334,6 +435,33 @@ const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, avail
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3 mb-2"> <div className="flex items-center space-x-3 mb-2">
{/* Do Today Star */}
{onUpdateTask && !isFromOtherBranch && (
<button
onClick={(e) => {
e.stopPropagation();
handleToggleDoToday(task.id, !task.doToday);
}}
className={`p-0.5 rounded transition-colors duration-150 cursor-pointer ${
task.doToday
? 'text-amber-500 hover:text-amber-600'
: 'text-gray-300 dark:text-gray-500 hover:text-amber-400 dark:hover:text-amber-400'
}`}
title={task.doToday ? 'Remove from Do Today' : 'Add to Do Today'}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill={task.doToday ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
</button>
)}
{/* Read-only star for doToday from other branch */}
{task.doToday && isFromOtherBranch && (
<span className="p-0.5 text-amber-500">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
</span>
)}
<h3 className={`text-lg font-medium ${isFromOtherBranch ? 'text-gray-600 dark:text-gray-400' : 'text-gray-900 dark:text-white'}`}>{task.title}</h3> <h3 className={`text-lg font-medium ${isFromOtherBranch ? 'text-gray-600 dark:text-gray-400' : 'text-gray-900 dark:text-white'}`}>{task.title}</h3>
<span className={`px-2 py-1 text-xs font-medium rounded-circle ${getStatusColor(task.status)}`}> <span className={`px-2 py-1 text-xs font-medium rounded-circle ${getStatusColor(task.status)}`}>
{task.status} {task.status}
@ -365,11 +493,27 @@ const TaskList: React.FC<TaskListProps> = ({ onEditTask, onNewTask, tasks, avail
)} )}
{task.labels && task.labels.length > 0 && ( {task.labels && task.labels.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{task.labels.map((label) => ( {task.labels.map((label) => {
<span key={label} className="px-2 py-1 text-xs bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 rounded-circle"> const isActive = labelFilters.includes(label);
{label} return (
</span> <button
))} key={label}
onClick={(e) => {
e.stopPropagation();
handleLabelToggle(label);
}}
className={`px-2 py-1 text-xs rounded-circle transition-colors duration-200 cursor-pointer ${
isActive
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 hover:bg-blue-100 dark:hover:bg-blue-900/50 hover:text-blue-700 dark:hover:text-blue-300'
}`}
title={isActive ? `Remove "${label}" filter` : `Filter by "${label}"`}
>
{label}
{isActive && <span className="ml-1">×</span>}
</button>
);
})}
</div> </div>
)} )}
</div> </div>