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:
parent
85482d4028
commit
c695aeecfc
|
|
@ -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 ?? [])) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "";
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue