diff --git a/package-lock.json b/package-lock.json index f6fb699..a721684 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,6 +109,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.8.tgz", "integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -443,6 +444,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -465,6 +467,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -1691,14 +1694,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/react": { "version": "19.0.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", "devOptional": true, + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1752,6 +1755,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.0.tgz", "integrity": "sha512-MFDaO9CYiard9j9VepMNa9MTcqVvSny2N4hkY6roquzj8pdCBRENhErrteaQuu7Yjn1ppk0v1/ZF9CG3KIlrTA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.24.0", "@typescript-eslint/types": "8.24.0", @@ -2090,6 +2094,7 @@ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-1.6.1.tgz", "integrity": "sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==", "dev": true, + "peer": true, "dependencies": { "@vitest/utils": "1.6.1", "fast-glob": "^3.3.2", @@ -2158,6 +2163,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2352,6 +2358,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -3066,6 +3073,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", "integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4274,6 +4282,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", "dev": true, + "peer": true, "dependencies": { "cssstyle": "^4.0.1", "data-urls": "^5.0.0", @@ -5109,6 +5118,7 @@ "version": "19.0.0", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5117,6 +5127,7 @@ "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -5899,6 +5910,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6039,6 +6051,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.5.1", @@ -6615,6 +6628,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", diff --git a/src/App.tsx b/src/App.tsx index 9d8208b..e942852 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { TaskBoard, TaskProvider } from './features/tasks'; import { AuthProvider, useAuth } from './features/auth'; import { ProjectProvider } from './features/projects'; import { ToastProvider } from './shared/components/Toast'; +import { ErrorBoundary } from './shared/components/ErrorBoundary'; import { GlobalSearch, SearchTrigger } from './features/search'; import { Spinner } from './shared/components/Loading'; import { Avatar } from './shared/components/Avatar'; @@ -485,13 +486,15 @@ function AppLayout() { function App() { return ( - - - - - - - + + + + + + + + + ); } diff --git a/src/core/api/http-client.ts b/src/core/api/http-client.ts index 5ca27f5..ad38a09 100644 --- a/src/core/api/http-client.ts +++ b/src/core/api/http-client.ts @@ -172,12 +172,18 @@ class HttpClient { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); - const response = await fetch(fullUrl, { - ...fetchConfig, - headers, - body, - signal: controller.signal, - }); + let response: Response; + try { + response = await fetch(fullUrl, { + ...fetchConfig, + headers, + body, + signal: controller.signal, + }); + } catch (fetchError) { + clearTimeout(timeoutId); + throw fetchError; + } clearTimeout(timeoutId); @@ -317,6 +323,13 @@ class HttpClient { }; } + if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) { + return { + code: ERROR_CODES.NETWORK_ERROR, + message: 'Network connection failed. Please check your internet connection.', + }; + } + return { code: ERROR_CODES.NETWORK_ERROR, message: error.message || 'Network error occurred', diff --git a/src/core/hooks/index.ts b/src/core/hooks/index.ts index a2f652f..f49d4d7 100644 --- a/src/core/hooks/index.ts +++ b/src/core/hooks/index.ts @@ -34,3 +34,4 @@ export { useInterval, useTimeout, useCountdown, useTimer, usePolling } from './u export { useToggle } from './useToggle'; export { useDisclosure, useDisclosures, useModal, useConfirmDialog } from './useDisclosure'; export type { UseDisclosureReturn, UseDisclosureOptions } from './useDisclosure'; +export { useThrottle, useIdleCallback, useIntersectionObserver } from './usePerformance'; diff --git a/src/core/hooks/usePerformance.ts b/src/core/hooks/usePerformance.ts new file mode 100644 index 0000000..92d2442 --- /dev/null +++ b/src/core/hooks/usePerformance.ts @@ -0,0 +1,85 @@ +import { useEffect, useRef, useCallback } from 'react'; + +export function useThrottle unknown>( + callback: T, + delay: number +): T { + const lastRun = useRef(0); + const timeoutRef = useRef | null>(null); + + const throttledCallback = useCallback( + ((...args: Parameters) => { + const now = Date.now(); + const timeSinceLastRun = now - lastRun.current; + + if (timeSinceLastRun >= delay) { + lastRun.current = now; + return callback(...args); + } + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + lastRun.current = Date.now(); + callback(...args); + }, delay - timeSinceLastRun); + }) as T, + [callback, delay] + ); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return throttledCallback; +} + +export function useIdleCallback(callback: () => void, deps: unknown[] = []) { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + if (typeof requestIdleCallback !== 'undefined') { + const id = requestIdleCallback(() => { + callbackRef.current(); + }); + return () => cancelIdleCallback(id); + } else { + const timeoutId = setTimeout(() => { + callbackRef.current(); + }, 1); + return () => clearTimeout(timeoutId); + } + }, deps); +} + +export function useIntersectionObserver( + elementRef: React.RefObject, + callback: (isIntersecting: boolean) => void, + options?: IntersectionObserverInit +) { + useEffect(() => { + const element = elementRef.current; + if (!element) return; + + const observer = new IntersectionObserver(([entry]) => { + callback(entry.isIntersecting); + }, options); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [elementRef, callback, options]); +} + diff --git a/src/features/tasks/components/TaskBoard.tsx b/src/features/tasks/components/TaskBoard.tsx index ebca878..c32fb23 100644 --- a/src/features/tasks/components/TaskBoard.tsx +++ b/src/features/tasks/components/TaskBoard.tsx @@ -3,18 +3,23 @@ * Orchestrates the Kanban board UI */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useTasks } from '../hooks/useTasks'; +import { useTaskFilters } from '../hooks/useTaskFilters'; +import { useKeyboardShortcuts } from '../../../core/hooks/useKeyboardShortcut'; import { TaskColumn } from './TaskColumn'; import { AddTaskModal } from './AddTaskModal'; import { TaskDetailModal } from './TaskDetailModal'; -import { Button } from '../../../shared/components'; +import { Button, SearchInput, Select, Badge } from '../../../shared/components'; +import type { TaskStatus, TaskPriority } from '../types'; export function TaskBoard() { const { - columns, + tasks, + columns: allColumns, stats, - isLoading, + isLoading, + error, selectedTask, createTask, moveTask, @@ -22,9 +27,46 @@ export function TaskBoard() { updateTask, selectTask, resetTasks, + clearError, } = useTasks(); + const { + filters, + filteredTasks, + updateFilter, + resetFilters, + availableAssignees, + availableTags, + activeFilterCount, + } = useTaskFilters(tasks); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [showFilters, setShowFilters] = useState(false); + + const columns = useMemo(() => { + return allColumns.map(column => ({ + ...column, + tasks: filteredTasks.filter(task => task.status === column.id), + })); + }, [allColumns, filteredTasks]); + + useKeyboardShortcuts({ + 'mod+k': () => { + const searchInput = document.querySelector('.task-board-search input'); + searchInput?.focus(); + }, + 'mod+n': (e) => { + e.preventDefault(); + setIsAddModalOpen(true); + }, + 'escape': () => { + if (isAddModalOpen) { + setIsAddModalOpen(false); + } else if (selectedTask) { + selectTask(null); + } + }, + }); if (isLoading) { return ( @@ -37,7 +79,25 @@ export function TaskBoard() { return (
- {/* Header */} + {error && ( +
+ + + + + + {error} + +
+ )}

@@ -64,6 +124,83 @@ export function TaskBoard() {

+
+
+ updateFilter('searchQuery', e.target.value)} + placeholder="Search tasks... (⌘K)" + className="task-board-search" + /> +
+
+ + {activeFilterCount > 0 && ( + + )} +
+
+ + {showFilters && ( +
+
+ + updateFilter('priority', e.target.value as TaskPriority | 'all')} + options={[ + { value: 'all', label: 'All Priorities' }, + { value: 'critical', label: 'Critical' }, + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + { value: 'low', label: 'Low' }, + ]} + /> +
+
+ +