diff --git a/src/features/tasks/context/TaskContext.tsx b/src/features/tasks/context/TaskContext.tsx
index 180098b..dd76cce 100644
--- a/src/features/tasks/context/TaskContext.tsx
+++ b/src/features/tasks/context/TaskContext.tsx
@@ -36,6 +36,7 @@ interface TaskContextValue {
moveTask: (taskId: string, newStatus: TaskStatus) => void;
selectTask: (task: Task | null) => void;
resetTasks: () => void;
+ clearError: () => void;
};
}
@@ -96,31 +97,85 @@ export function TaskProvider({ children }: { children: ReactNode }) {
}, []);
const createTask = useCallback((dto: CreateTaskDTO): Task => {
- const newTask = taskRepository.create(dto);
- dispatch({ type: 'ADD_TASK', payload: newTask });
- return newTask;
+ try {
+ const newTask = taskRepository.create(dto);
+ dispatch({ type: 'ADD_TASK', payload: newTask });
+ return newTask;
+ } catch (error) {
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to create task' });
+ throw error;
+ }
}, []);
const updateTask = useCallback((id: string, dto: UpdateTaskDTO) => {
- const updated = taskRepository.update(id, dto);
- if (updated) {
- dispatch({ type: 'UPDATE_TASK', payload: updated });
+ const currentTask = state.tasks.find(t => t.id === id);
+ if (!currentTask) return;
+
+ const optimisticUpdate: Task = {
+ ...currentTask,
+ ...dto,
+ updatedAt: new Date(),
+ };
+
+ dispatch({ type: 'UPDATE_TASK', payload: optimisticUpdate });
+
+ try {
+ const updated = taskRepository.update(id, dto);
+ if (updated) {
+ dispatch({ type: 'UPDATE_TASK', payload: updated });
+ } else {
+ dispatch({ type: 'UPDATE_TASK', payload: currentTask });
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to update task' });
+ }
+ } catch (error) {
+ dispatch({ type: 'UPDATE_TASK', payload: currentTask });
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to update task' });
}
- }, []);
+ }, [state.tasks]);
const deleteTask = useCallback((id: string) => {
- const success = taskRepository.delete(id);
- if (success) {
- dispatch({ type: 'DELETE_TASK', payload: id });
+ const taskToDelete = state.tasks.find(t => t.id === id);
+ if (!taskToDelete) return;
+
+ dispatch({ type: 'DELETE_TASK', payload: id });
+
+ try {
+ const success = taskRepository.delete(id);
+ if (!success) {
+ dispatch({ type: 'ADD_TASK', payload: taskToDelete });
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to delete task' });
+ }
+ } catch (error) {
+ dispatch({ type: 'ADD_TASK', payload: taskToDelete });
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to delete task' });
}
- }, []);
+ }, [state.tasks]);
const moveTask = useCallback((taskId: string, newStatus: TaskStatus) => {
- const updated = taskRepository.moveTask(taskId, newStatus);
- if (updated) {
- dispatch({ type: 'UPDATE_TASK', payload: updated });
+ const currentTask = state.tasks.find(t => t.id === taskId);
+ if (!currentTask) return;
+
+ const optimisticUpdate: Task = {
+ ...currentTask,
+ status: newStatus,
+ updatedAt: new Date(),
+ };
+
+ dispatch({ type: 'UPDATE_TASK', payload: optimisticUpdate });
+
+ try {
+ const updated = taskRepository.moveTask(taskId, newStatus);
+ if (updated) {
+ dispatch({ type: 'UPDATE_TASK', payload: updated });
+ } else {
+ dispatch({ type: 'UPDATE_TASK', payload: currentTask });
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to move task' });
+ }
+ } catch (error) {
+ dispatch({ type: 'UPDATE_TASK', payload: currentTask });
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to move task' });
}
- }, []);
+ }, [state.tasks]);
const selectTask = useCallback((task: Task | null) => {
dispatch({ type: 'SELECT_TASK', payload: task });
@@ -131,6 +186,10 @@ export function TaskProvider({ children }: { children: ReactNode }) {
dispatch({ type: 'SET_TASKS', payload: tasks });
}, []);
+ const clearError = useCallback(() => {
+ dispatch({ type: 'SET_ERROR', payload: null });
+ }, []);
+
const value: TaskContextValue = {
state,
actions: {
@@ -141,6 +200,7 @@ export function TaskProvider({ children }: { children: ReactNode }) {
moveTask,
selectTask,
resetTasks,
+ clearError,
},
};
diff --git a/src/features/tasks/hooks/useTaskFilters.ts b/src/features/tasks/hooks/useTaskFilters.ts
new file mode 100644
index 0000000..ee20de1
--- /dev/null
+++ b/src/features/tasks/hooks/useTaskFilters.ts
@@ -0,0 +1,108 @@
+import { useState, useMemo, useCallback } from 'react';
+import type { Task, TaskStatus, TaskPriority } from '../types';
+import { useDebouncedValue } from '../../../core/hooks/useDebounce';
+
+export interface TaskFilters {
+ searchQuery: string;
+ status: TaskStatus | 'all';
+ priority: TaskPriority | 'all';
+ assignee: string | 'all';
+ tags: string[];
+}
+
+const defaultFilters: TaskFilters = {
+ searchQuery: '',
+ status: 'all',
+ priority: 'all',
+ assignee: 'all',
+ tags: [],
+};
+
+export function useTaskFilters(tasks: Task[]) {
+ const [filters, setFilters] = useState
(defaultFilters);
+ const debouncedSearchQuery = useDebouncedValue(filters.searchQuery, 300);
+
+ const updateFilter = useCallback((
+ key: K,
+ value: TaskFilters[K]
+ ) => {
+ setFilters(prev => ({ ...prev, [key]: value }));
+ }, []);
+
+ const resetFilters = useCallback(() => {
+ setFilters(defaultFilters);
+ }, []);
+
+ const filteredTasks = useMemo(() => {
+ let result = [...tasks];
+
+ if (debouncedSearchQuery) {
+ const query = debouncedSearchQuery.toLowerCase();
+ result = result.filter(task =>
+ task.title.toLowerCase().includes(query) ||
+ task.description.toLowerCase().includes(query) ||
+ task.tags.some(tag => tag.toLowerCase().includes(query)) ||
+ task.assignee?.toLowerCase().includes(query)
+ );
+ }
+
+ if (filters.status !== 'all') {
+ result = result.filter(task => task.status === filters.status);
+ }
+
+ if (filters.priority !== 'all') {
+ result = result.filter(task => task.priority === filters.priority);
+ }
+
+ if (filters.assignee !== 'all') {
+ result = result.filter(task => task.assignee === filters.assignee);
+ }
+
+ if (filters.tags.length > 0) {
+ result = result.filter(task =>
+ filters.tags.some(tag => task.tags.includes(tag))
+ );
+ }
+
+ return result;
+ }, [tasks, debouncedSearchQuery, filters]);
+
+ const availableAssignees = useMemo(() => {
+ const assignees = new Set();
+ tasks.forEach(task => {
+ if (task.assignee) {
+ assignees.add(task.assignee);
+ }
+ });
+ return Array.from(assignees).sort();
+ }, [tasks]);
+
+ const availableTags = useMemo(() => {
+ const tags = new Set();
+ tasks.forEach(task => {
+ task.tags.forEach(tag => tags.add(tag));
+ });
+ return Array.from(tags).sort();
+ }, [tasks]);
+
+ const activeFilterCount = useMemo(() => {
+ let count = 0;
+ if (filters.searchQuery) count++;
+ if (filters.status !== 'all') count++;
+ if (filters.priority !== 'all') count++;
+ if (filters.assignee !== 'all') count++;
+ if (filters.tags.length > 0) count++;
+ return count;
+ }, [filters]);
+
+ return {
+ filters,
+ filteredTasks,
+ updateFilter,
+ resetFilters,
+ availableAssignees,
+ availableTags,
+ activeFilterCount,
+ };
+}
+
diff --git a/src/features/tasks/hooks/useTasks.ts b/src/features/tasks/hooks/useTasks.ts
index e3f8b06..525a65f 100644
--- a/src/features/tasks/hooks/useTasks.ts
+++ b/src/features/tasks/hooks/useTasks.ts
@@ -6,18 +6,22 @@
import { useEffect, useMemo } from 'react';
import { useTaskContext } from '../context/TaskContext';
-import { COLUMNS_CONFIG, type TaskStatus, type TaskColumn } from '../types';
+import { COLUMNS_CONFIG, type TaskStatus, type TaskColumn, type TaskPriority } from '../types';
+
+const priorityOrder: Record = {
+ critical: 4,
+ high: 3,
+ medium: 2,
+ low: 1,
+};
export function useTasks() {
const { state, actions } = useTaskContext();
- // Load tasks on mount
useEffect(() => {
actions.loadTasks();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [actions]);
- // Organize tasks into columns - memoized for performance
const columns: TaskColumn[] = useMemo(() => {
const statusOrder: TaskStatus[] = ['backlog', 'in_progress', 'review', 'done'];
@@ -27,7 +31,11 @@ export function useTasks() {
color: COLUMNS_CONFIG[status].color,
tasks: state.tasks
.filter(task => task.status === status)
- .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()),
+ .sort((a, b) => {
+ const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority];
+ if (priorityDiff !== 0) return priorityDiff;
+ return b.updatedAt.getTime() - a.updatedAt.getTime();
+ }),
}));
}, [state.tasks]);
@@ -50,6 +58,7 @@ export function useTasks() {
isLoading: state.isLoading,
error: state.error,
selectedTask: state.selectedTask,
+ clearError: actions.clearError,
...actions,
};
}
diff --git a/src/shared/components/ErrorBoundary.tsx b/src/shared/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..0f6e19c
--- /dev/null
+++ b/src/shared/components/ErrorBoundary.tsx
@@ -0,0 +1,82 @@
+import { Component, type ReactNode, type ErrorInfo } from 'react';
+import { Button } from './Button';
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+ fallback?: ReactNode;
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+}
+
+export class ErrorBoundary extends Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error('ErrorBoundary caught an error:', error, errorInfo);
+ this.props.onError?.(error, errorInfo);
+ }
+
+ handleReset = () => {
+ this.setState({ hasError: false, error: null });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ if (this.props.fallback) {
+ return this.props.fallback;
+ }
+
+ return (
+
+
+
+
Something went wrong
+
+ {this.state.error?.message || 'An unexpected error occurred'}
+
+
+
+
+
+ {process.env.NODE_ENV === 'development' && this.state.error && (
+
+ Error details
+
+ {this.state.error.stack}
+
+
+ )}
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
diff --git a/src/shared/components/index.ts b/src/shared/components/index.ts
index 95171dc..32c9631 100644
--- a/src/shared/components/index.ts
+++ b/src/shared/components/index.ts
@@ -19,3 +19,4 @@ export { ProgressBar, ProgressRing } from './Progress';
export { Pagination } from './Pagination';
export { SearchInput } from './SearchInput';
export { ConfirmDialog } from './ConfirmDialog';
+export { ErrorBoundary } from './ErrorBoundary';