From 3606a632930b2c1bb8ce12afdf4f84f9a732f57b Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:16:37 +0100 Subject: [PATCH] Draggable Kanban Task Reordering (#1217) * auto-claude: subtask-1-1 - Add TaskOrderState type to task.ts Add TaskOrderState type as Record to map kanban columns to ordered task IDs for drag-and-drop reordering. Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-1-2 - Add task order state and actions to task-store.ts - Import arrayMove from @dnd-kit/sortable - Import TaskOrderState type from shared types - Add taskOrder state (TaskOrderState | null) for per-column ordering - Add setTaskOrder action to set full task order state - Add reorderTasksInColumn action using arrayMove pattern - Add loadTaskOrder action to load from localStorage - Add saveTaskOrder action to persist to localStorage - Add helper functions: getTaskOrderKey, createEmptyTaskOrder - Update clearTasks to also clear taskOrder state * auto-claude: subtask-1-3 - Add localStorage persistence helpers for task order Add clearTaskOrder function to complete the localStorage persistence helpers for task order management. The loadTaskOrder and saveTaskOrder functions were already implemented. This adds: - clearTaskOrder(projectId): Removes task order from localStorage and resets state Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-2-1 - Import arrayMove from @dnd-kit/sortable and add wi - Import arrayMove from @dnd-kit/sortable in KanbanBoard.tsx - Import useTaskStore to access reorderTasksInColumn and saveTaskOrder actions - Add within-column reorder logic to handleDragEnd: - Detect same-column drops (when task.status === overTask.status) - Call reorderTasksInColumn to update order in store via arrayMove - Persist order to localStorage via saveTaskOrder Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-2-2 - Update tasksByStatus useMemo to apply custom order Updated the tasksByStatus useMemo in KanbanBoard to support custom task ordering: - Added taskOrder state selector from useTaskStore - If custom order exists for a column, sort tasks by their order index - Filter out stale IDs (task IDs in order that no longer exist) - Prepend new tasks (not in order) at top with createdAt sort - Fallback to createdAt sort (newest first) when no custom order exists Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-2-3 - Add useEffect to load task order on mount and when project changes * auto-claude: subtask-3-1 - Handle cross-column drag: place task at top When a task is moved to a new column via drag-and-drop: - Added moveTaskToColumnTop action to task-store.ts - Removes task from source column order array - Adds task to index 0 (top) of target column order array - Persists order to localStorage after cross-column moves - Works for both dropping on column and dropping on task in diff column Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-3-2 - Handle new task placement: new tasks added to backlog appear at index 0 - Modified addTask() to also update taskOrder state when adding new tasks - New tasks are inserted at index 0 (top) of their status column's order array - Follows the existing pattern from moveTaskToColumnTop() - Includes safety check to prevent duplicate task IDs in order array * auto-claude: subtask-3-3 - Handle deleted/stale tasks in kanban order Add cleanup effect that detects and removes stale task IDs from the persisted task order when tasks are deleted. This ensures the order stored in localStorage stays in sync with actual tasks. - Add setTaskOrder store selector for updating order state - Add useEffect that filters out stale IDs when tasks change - Persist cleaned order to localStorage when stale IDs are found Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-4-1 - Create unit tests for task order state management Add comprehensive unit tests for kanban board drag-and-drop reordering: - Test setTaskOrder: basic setting, replacing, empty columns, all column orders - Test reorderTasksInColumn with arrayMove: various reordering scenarios, edge cases (null order, missing IDs, single task, adjacent tasks) - Test loadTaskOrder: localStorage retrieval, empty state creation, project-specific keys, error handling for corrupted/inaccessible data - Test saveTaskOrder: localStorage persistence, null handling, error handling - Test clearTaskOrder: removal from localStorage, project-specific keys - Test moveTaskToColumnTop: cross-column moves, source removal, deduplication - Test addTask integration: new tasks added to top of column order - Integration tests: full load/reorder/save cycle, project switching Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-4-2 - Add localStorage persistence edge case tests * auto-claude: subtask-4-3 - Add unit tests for order filtering Add comprehensive unit tests for task order filtering logic: - Stale ID removal tests: verify IDs for deleted tasks are filtered out - New task placement tests: verify new tasks appear at top of column - Cross-column move tests: verify order updates when tasks move between columns Co-Authored-By: Claude Opus 4.5 * fix(kanban): address follow-up review findings - Wrap saveTaskOrder in useCallback to prevent useEffect dependency loop - Add runtime validation for localStorage data in loadTaskOrder - Remove variable shadowing of projectId in handleDragEnd * test: update task-order tests to match new validation behavior loadTaskOrder now validates localStorage data and resets to empty order when invalid data (null, arrays) is found instead of storing it directly. * fix(kanban): improve task order validation and reordering reliability - Add comprehensive validation for localStorage task order data: - Validate each column value is a string array - Merge with empty order to handle partial/corrupted data - Fix saveTaskOrder to return false when nothing to save - Fix reordering for tasks not yet in order array by syncing visual order before calling reorderTasksInColumn Resolves PR review findings: NEWCODE-001, NEW-001, NEW-005 Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Test User Co-authored-by: Claude Opus 4.5 --- .../src/renderer/__tests__/task-order.test.ts | 1165 +++++++++++++++++ .../src/renderer/components/KanbanBoard.tsx | 192 ++- .../src/renderer/stores/task-store.ts | 186 ++- .../src/shared/i18n/locales/en/tasks.json | 2 + .../src/shared/i18n/locales/fr/tasks.json | 2 + apps/frontend/src/shared/types/task.ts | 3 + 6 files changed, 1529 insertions(+), 21 deletions(-) create mode 100644 apps/frontend/src/renderer/__tests__/task-order.test.ts diff --git a/apps/frontend/src/renderer/__tests__/task-order.test.ts b/apps/frontend/src/renderer/__tests__/task-order.test.ts new file mode 100644 index 00000000..d6b0f1f4 --- /dev/null +++ b/apps/frontend/src/renderer/__tests__/task-order.test.ts @@ -0,0 +1,1165 @@ +/** + * Unit tests for Task Order State Management + * Tests Zustand store actions for kanban board drag-and-drop reordering + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useTaskStore } from '../stores/task-store'; +import type { Task, TaskStatus, TaskOrderState } from '../../shared/types'; + +// Helper to create test tasks +function createTestTask(overrides: Partial = {}): Task { + return { + id: `task-${Date.now()}-${Math.random().toString(36).substring(7)}`, + specId: 'test-spec-001', + projectId: 'project-1', + title: 'Test Task', + description: 'Test description', + status: 'backlog' as TaskStatus, + subtasks: [], + logs: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides + }; +} + +// Helper to create a test task order state +function createTestTaskOrder(overrides: Partial = {}): TaskOrderState { + return { + backlog: [], + in_progress: [], + ai_review: [], + human_review: [], + pr_created: [], + done: [], + ...overrides + }; +} + +describe('Task Order State Management', () => { + beforeEach(() => { + // Reset store to initial state before each test + useTaskStore.setState({ + tasks: [], + selectedTaskId: null, + isLoading: false, + error: null, + taskOrder: null + }); + // Clear localStorage + localStorage.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + describe('setTaskOrder', () => { + it('should set task order state', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'] + }); + + useTaskStore.getState().setTaskOrder(order); + + expect(useTaskStore.getState().taskOrder).toEqual(order); + }); + + it('should replace existing task order', () => { + const initialOrder = createTestTaskOrder({ + backlog: ['old-task-1', 'old-task-2'] + }); + const newOrder = createTestTaskOrder({ + backlog: ['new-task-1', 'new-task-2', 'new-task-3'] + }); + + useTaskStore.getState().setTaskOrder(initialOrder); + useTaskStore.getState().setTaskOrder(newOrder); + + expect(useTaskStore.getState().taskOrder).toEqual(newOrder); + }); + + it('should handle empty column arrays', () => { + const order = createTestTaskOrder(); + + useTaskStore.getState().setTaskOrder(order); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual([]); + expect(useTaskStore.getState().taskOrder?.in_progress).toEqual([]); + }); + + it('should preserve all column orders', () => { + const order = createTestTaskOrder({ + backlog: ['task-1'], + in_progress: ['task-2'], + ai_review: ['task-3'], + human_review: ['task-4'], + pr_created: ['task-5'], + done: ['task-6'] + }); + + useTaskStore.getState().setTaskOrder(order); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1']); + expect(useTaskStore.getState().taskOrder?.in_progress).toEqual(['task-2']); + expect(useTaskStore.getState().taskOrder?.ai_review).toEqual(['task-3']); + expect(useTaskStore.getState().taskOrder?.human_review).toEqual(['task-4']); + expect(useTaskStore.getState().taskOrder?.pr_created).toEqual(['task-5']); + expect(useTaskStore.getState().taskOrder?.done).toEqual(['task-6']); + }); + }); + + describe('reorderTasksInColumn', () => { + it('should reorder tasks within a column using arrayMove', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Move task-1 to position of task-3 + useTaskStore.getState().reorderTasksInColumn('backlog', 'task-1', 'task-3'); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-2', 'task-3', 'task-1']); + }); + + it('should move task from later position to earlier position', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3', 'task-4'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Move task-4 to position of task-2 + useTaskStore.getState().reorderTasksInColumn('backlog', 'task-4', 'task-2'); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1', 'task-4', 'task-2', 'task-3']); + }); + + it('should handle reordering in different columns', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2'], + in_progress: ['task-3', 'task-4', 'task-5'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Reorder in_progress column + useTaskStore.getState().reorderTasksInColumn('in_progress', 'task-5', 'task-3'); + + expect(useTaskStore.getState().taskOrder?.in_progress).toEqual(['task-5', 'task-3', 'task-4']); + // backlog should remain unchanged + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1', 'task-2']); + }); + + it('should do nothing if taskOrder is null', () => { + useTaskStore.setState({ taskOrder: null }); + + useTaskStore.getState().reorderTasksInColumn('backlog', 'task-1', 'task-2'); + + expect(useTaskStore.getState().taskOrder).toBeNull(); + }); + + it('should do nothing if activeId is not in the column', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().reorderTasksInColumn('backlog', 'nonexistent', 'task-2'); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1', 'task-2', 'task-3']); + }); + + it('should do nothing if overId is not in the column', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().reorderTasksInColumn('backlog', 'task-1', 'nonexistent'); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1', 'task-2', 'task-3']); + }); + + it('should do nothing if both activeId and overId are not in the column', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().reorderTasksInColumn('backlog', 'nonexistent-1', 'nonexistent-2'); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1', 'task-2', 'task-3']); + }); + + it('should handle reordering with same active and over id (no change)', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().reorderTasksInColumn('backlog', 'task-2', 'task-2'); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1', 'task-2', 'task-3']); + }); + + it('should handle column with only one task', () => { + const order = createTestTaskOrder({ + backlog: ['task-1'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Cannot reorder a single task (overId won't exist) + useTaskStore.getState().reorderTasksInColumn('backlog', 'task-1', 'task-2'); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1']); + }); + + it('should handle reordering adjacent tasks', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Swap task-1 and task-2 + useTaskStore.getState().reorderTasksInColumn('backlog', 'task-1', 'task-2'); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-2', 'task-1', 'task-3']); + }); + }); + + describe('loadTaskOrder', () => { + it('should load task order from localStorage', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2'], + in_progress: ['task-3'] + }); + localStorage.setItem('task-order-state-project-1', JSON.stringify(order)); + + useTaskStore.getState().loadTaskOrder('project-1'); + + expect(useTaskStore.getState().taskOrder).toEqual(order); + }); + + it('should create empty task order if no stored order exists', () => { + useTaskStore.getState().loadTaskOrder('project-1'); + + expect(useTaskStore.getState().taskOrder).toEqual({ + backlog: [], + in_progress: [], + ai_review: [], + human_review: [], + pr_created: [], + done: [] + }); + }); + + it('should use project-specific localStorage keys', () => { + const order1 = createTestTaskOrder({ backlog: ['project1-task'] }); + const order2 = createTestTaskOrder({ backlog: ['project2-task'] }); + localStorage.setItem('task-order-state-project-1', JSON.stringify(order1)); + localStorage.setItem('task-order-state-project-2', JSON.stringify(order2)); + + useTaskStore.getState().loadTaskOrder('project-1'); + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['project1-task']); + + useTaskStore.getState().loadTaskOrder('project-2'); + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['project2-task']); + }); + + it('should handle corrupted localStorage data gracefully', () => { + // Spy on console.error to verify error logging + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + localStorage.setItem('task-order-state-project-1', 'invalid-json{{{'); + + useTaskStore.getState().loadTaskOrder('project-1'); + + // Should fall back to empty order state + expect(useTaskStore.getState().taskOrder).toEqual({ + backlog: [], + in_progress: [], + ai_review: [], + human_review: [], + pr_created: [], + done: [] + }); + expect(consoleSpy).toHaveBeenCalledWith('Failed to load task order:', expect.any(Error)); + + consoleSpy.mockRestore(); + }); + + it('should handle localStorage access errors', () => { + // Spy on console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Mock localStorage.getItem to throw + const originalGetItem = localStorage.getItem; + localStorage.getItem = vi.fn(() => { + throw new Error('Storage quota exceeded'); + }); + + useTaskStore.getState().loadTaskOrder('project-1'); + + // Should fall back to empty order state + expect(useTaskStore.getState().taskOrder).toEqual({ + backlog: [], + in_progress: [], + ai_review: [], + human_review: [], + pr_created: [], + done: [] + }); + + localStorage.getItem = originalGetItem; + consoleSpy.mockRestore(); + }); + }); + + describe('saveTaskOrder', () => { + it('should save task order to localStorage', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2'], + in_progress: ['task-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().saveTaskOrder('project-1'); + + const stored = localStorage.getItem('task-order-state-project-1'); + expect(stored).toBeTruthy(); + expect(JSON.parse(stored!)).toEqual(order); + }); + + it('should not save if taskOrder is null', () => { + useTaskStore.setState({ taskOrder: null }); + + useTaskStore.getState().saveTaskOrder('project-1'); + + const stored = localStorage.getItem('task-order-state-project-1'); + expect(stored).toBeNull(); + }); + + it('should use project-specific localStorage keys', () => { + const order = createTestTaskOrder({ backlog: ['test-task'] }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().saveTaskOrder('my-project-id'); + + expect(localStorage.getItem('task-order-state-my-project-id')).toBeTruthy(); + expect(localStorage.getItem('task-order-state-other-project')).toBeNull(); + }); + + it('should handle localStorage write errors gracefully', () => { + // Spy on console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const order = createTestTaskOrder({ backlog: ['task-1'] }); + useTaskStore.setState({ taskOrder: order }); + + // Mock localStorage.setItem to throw + const originalSetItem = localStorage.setItem; + localStorage.setItem = vi.fn(() => { + throw new Error('Storage quota exceeded'); + }); + + // Should not throw + expect(() => { + useTaskStore.getState().saveTaskOrder('project-1'); + }).not.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to save task order:', expect.any(Error)); + + localStorage.setItem = originalSetItem; + consoleSpy.mockRestore(); + }); + + it('should overwrite existing stored order', () => { + const initialOrder = createTestTaskOrder({ backlog: ['old-task'] }); + localStorage.setItem('task-order-state-project-1', JSON.stringify(initialOrder)); + + const newOrder = createTestTaskOrder({ backlog: ['new-task-1', 'new-task-2'] }); + useTaskStore.setState({ taskOrder: newOrder }); + + useTaskStore.getState().saveTaskOrder('project-1'); + + const stored = JSON.parse(localStorage.getItem('task-order-state-project-1')!); + expect(stored.backlog).toEqual(['new-task-1', 'new-task-2']); + }); + }); + + describe('clearTaskOrder', () => { + it('should clear task order from localStorage', () => { + const order = createTestTaskOrder({ backlog: ['task-1'] }); + localStorage.setItem('task-order-state-project-1', JSON.stringify(order)); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().clearTaskOrder('project-1'); + + expect(localStorage.getItem('task-order-state-project-1')).toBeNull(); + expect(useTaskStore.getState().taskOrder).toBeNull(); + }); + + it('should use project-specific localStorage keys', () => { + localStorage.setItem('task-order-state-project-1', JSON.stringify(createTestTaskOrder())); + localStorage.setItem('task-order-state-project-2', JSON.stringify(createTestTaskOrder())); + + useTaskStore.getState().clearTaskOrder('project-1'); + + expect(localStorage.getItem('task-order-state-project-1')).toBeNull(); + expect(localStorage.getItem('task-order-state-project-2')).toBeTruthy(); + }); + + it('should handle localStorage removal errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Mock localStorage.removeItem to throw + const originalRemoveItem = localStorage.removeItem; + localStorage.removeItem = vi.fn(() => { + throw new Error('Storage error'); + }); + + // Should not throw + expect(() => { + useTaskStore.getState().clearTaskOrder('project-1'); + }).not.toThrow(); + + localStorage.removeItem = originalRemoveItem; + consoleSpy.mockRestore(); + }); + }); + + describe('moveTaskToColumnTop', () => { + it('should move task to top of target column', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2'], + in_progress: ['task-3', 'task-4'] + }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().moveTaskToColumnTop('task-2', 'in_progress', 'backlog'); + + expect(useTaskStore.getState().taskOrder?.in_progress).toEqual(['task-2', 'task-3', 'task-4']); + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1']); + }); + + it('should remove task from source column when provided', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'], + in_progress: ['task-4'] + }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().moveTaskToColumnTop('task-2', 'in_progress', 'backlog'); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1', 'task-3']); + }); + + it('should work without source column (only add to target)', () => { + const order = createTestTaskOrder({ + backlog: ['task-1'], + in_progress: ['task-2', 'task-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().moveTaskToColumnTop('new-task', 'in_progress'); + + expect(useTaskStore.getState().taskOrder?.in_progress).toEqual(['new-task', 'task-2', 'task-3']); + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1']); + }); + + it('should handle task already in target column (remove duplicate first)', () => { + const order = createTestTaskOrder({ + in_progress: ['task-1', 'task-2', 'task-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Move task-3 to top of same column (simulates cross-column then same-column scenario) + useTaskStore.getState().moveTaskToColumnTop('task-3', 'in_progress'); + + expect(useTaskStore.getState().taskOrder?.in_progress).toEqual(['task-3', 'task-1', 'task-2']); + }); + + it('should do nothing if taskOrder is null', () => { + useTaskStore.setState({ taskOrder: null }); + + useTaskStore.getState().moveTaskToColumnTop('task-1', 'in_progress', 'backlog'); + + expect(useTaskStore.getState().taskOrder).toBeNull(); + }); + + it('should initialize target column if it does not exist in order', () => { + // Create order with partial columns (simulating missing column) + const order = { + backlog: ['task-1'], + in_progress: [], + ai_review: [], + human_review: [], + pr_created: [], + done: [] + } as TaskOrderState; + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().moveTaskToColumnTop('task-1', 'in_progress', 'backlog'); + + expect(useTaskStore.getState().taskOrder?.in_progress).toEqual(['task-1']); + }); + }); + + describe('addTask with task order', () => { + it('should add new task to top of column order', () => { + const order = createTestTaskOrder({ + backlog: ['existing-task-1', 'existing-task-2'] + }); + useTaskStore.setState({ taskOrder: order, tasks: [] }); + + const newTask = createTestTask({ id: 'new-task', status: 'backlog' }); + useTaskStore.getState().addTask(newTask); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual([ + 'new-task', + 'existing-task-1', + 'existing-task-2' + ]); + }); + + it('should add task to correct column based on status', () => { + const order = createTestTaskOrder({ + backlog: ['backlog-task'], + in_progress: ['progress-task'] + }); + useTaskStore.setState({ taskOrder: order, tasks: [] }); + + const newTask = createTestTask({ id: 'new-progress-task', status: 'in_progress' }); + useTaskStore.getState().addTask(newTask); + + expect(useTaskStore.getState().taskOrder?.in_progress).toEqual([ + 'new-progress-task', + 'progress-task' + ]); + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['backlog-task']); + }); + + it('should not modify order if taskOrder is null', () => { + useTaskStore.setState({ taskOrder: null, tasks: [] }); + + const newTask = createTestTask({ id: 'new-task', status: 'backlog' }); + useTaskStore.getState().addTask(newTask); + + expect(useTaskStore.getState().taskOrder).toBeNull(); + expect(useTaskStore.getState().tasks).toHaveLength(1); + }); + + it('should handle adding task when column does not exist in order', () => { + const order = createTestTaskOrder({ + backlog: ['task-1'] + }); + useTaskStore.setState({ taskOrder: order, tasks: [] }); + + // This should work because createTestTaskOrder initializes all columns + const newTask = createTestTask({ id: 'new-task', status: 'done' }); + useTaskStore.getState().addTask(newTask); + + expect(useTaskStore.getState().taskOrder?.done).toEqual(['new-task']); + }); + + it('should prevent duplicate task IDs in order', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2'] + }); + useTaskStore.setState({ taskOrder: order, tasks: [] }); + + // Try to add a task with existing ID + const duplicateTask = createTestTask({ id: 'task-1', status: 'backlog' }); + useTaskStore.getState().addTask(duplicateTask); + + // Should add to top but remove existing occurrence + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1', 'task-2']); + }); + }); + + describe('localStorage persistence edge cases', () => { + it('should handle empty string in localStorage', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + localStorage.setItem('task-order-state-project-1', ''); + + useTaskStore.getState().loadTaskOrder('project-1'); + + // Empty string causes JSON.parse to throw - should fall back to empty order + expect(useTaskStore.getState().taskOrder).toEqual({ + backlog: [], + in_progress: [], + ai_review: [], + human_review: [], + pr_created: [], + done: [] + }); + + consoleSpy.mockRestore(); + }); + + it('should handle partial/incomplete JSON object', () => { + // JSON that parses but is missing some columns + const partialOrder = { backlog: ['task-1'], in_progress: ['task-2'] }; + localStorage.setItem('task-order-state-project-1', JSON.stringify(partialOrder)); + + useTaskStore.getState().loadTaskOrder('project-1'); + + // Should load whatever was stored (partial data) + const order = useTaskStore.getState().taskOrder; + expect(order?.backlog).toEqual(['task-1']); + expect(order?.in_progress).toEqual(['task-2']); + // Missing columns will be undefined in the stored object + }); + + it('should handle null stored value', () => { + localStorage.setItem('task-order-state-project-1', JSON.stringify(null)); + + useTaskStore.getState().loadTaskOrder('project-1'); + + // null is valid JSON but not a valid TaskOrderState - store resets to empty order + const order = useTaskStore.getState().taskOrder; + expect(order).not.toBeNull(); + expect(order?.backlog).toEqual([]); + }); + + it('should handle array instead of object stored', () => { + localStorage.setItem('task-order-state-project-1', JSON.stringify(['task-1', 'task-2'])); + + useTaskStore.getState().loadTaskOrder('project-1'); + + // Array is valid JSON but wrong structure - store resets to empty order + const order = useTaskStore.getState().taskOrder; + expect(Array.isArray(order)).toBe(false); + expect(order?.backlog).toEqual([]); + }); + + it('should round-trip save and load with exact data preservation', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'], + in_progress: ['task-4'], + ai_review: [], + human_review: ['task-5', 'task-6'], + pr_created: [], + done: ['task-7', 'task-8', 'task-9', 'task-10'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Save + useTaskStore.getState().saveTaskOrder('round-trip-test'); + + // Clear state + useTaskStore.setState({ taskOrder: null }); + expect(useTaskStore.getState().taskOrder).toBeNull(); + + // Load + useTaskStore.getState().loadTaskOrder('round-trip-test'); + + // Verify exact preservation + expect(useTaskStore.getState().taskOrder).toEqual(order); + }); + + it('should handle special characters in project ID', () => { + const order = createTestTaskOrder({ backlog: ['special-task'] }); + useTaskStore.setState({ taskOrder: order }); + + const specialProjectId = 'project/with:special@chars!'; + useTaskStore.getState().saveTaskOrder(specialProjectId); + + useTaskStore.setState({ taskOrder: null }); + useTaskStore.getState().loadTaskOrder(specialProjectId); + + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['special-task']); + }); + + it('should isolate different projects completely', () => { + // Set up three different projects with different orders + const orders = { + 'project-a': createTestTaskOrder({ backlog: ['a-task-1', 'a-task-2'] }), + 'project-b': createTestTaskOrder({ in_progress: ['b-task-1'] }), + 'project-c': createTestTaskOrder({ done: ['c-task-1', 'c-task-2', 'c-task-3'] }) + }; + + // Save all three + for (const [projectId, order] of Object.entries(orders)) { + useTaskStore.setState({ taskOrder: order }); + useTaskStore.getState().saveTaskOrder(projectId); + } + + // Clear and verify each loads independently + for (const [projectId, expectedOrder] of Object.entries(orders)) { + useTaskStore.setState({ taskOrder: null }); + useTaskStore.getState().loadTaskOrder(projectId); + expect(useTaskStore.getState().taskOrder).toEqual(expectedOrder); + } + }); + + it('should handle very long task ID arrays', () => { + // Create an order with many task IDs + const manyTaskIds = Array.from({ length: 100 }, (_, i) => `task-${i}`); + const order = createTestTaskOrder({ backlog: manyTaskIds }); + useTaskStore.setState({ taskOrder: order }); + + useTaskStore.getState().saveTaskOrder('many-tasks-project'); + useTaskStore.setState({ taskOrder: null }); + useTaskStore.getState().loadTaskOrder('many-tasks-project'); + + expect(useTaskStore.getState().taskOrder?.backlog).toHaveLength(100); + expect(useTaskStore.getState().taskOrder?.backlog[0]).toBe('task-0'); + expect(useTaskStore.getState().taskOrder?.backlog[99]).toBe('task-99'); + }); + }); + + describe('order filtering: stale ID removal', () => { + it('should filter out stale IDs that do not exist in tasks', () => { + // Scenario: Task order has IDs for tasks that have been deleted + const tasks = [ + createTestTask({ id: 'task-1', status: 'backlog' }), + createTestTask({ id: 'task-3', status: 'backlog' }) + ]; + + // Order contains 'task-2' which no longer exists + const orderWithStaleIds = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'] + }); + + useTaskStore.setState({ tasks, taskOrder: orderWithStaleIds }); + + // Build a set of current task IDs and filter out stale IDs + const currentTaskIds = new Set(tasks.map(t => t.id)); + const columnOrder = useTaskStore.getState().taskOrder?.backlog || []; + const validOrder = columnOrder.filter(id => currentTaskIds.has(id)); + + // Stale ID should be filtered out + expect(validOrder).toEqual(['task-1', 'task-3']); + expect(validOrder).not.toContain('task-2'); + }); + + it('should return empty array when all IDs are stale', () => { + // Scenario: All tasks have been deleted + const tasks: Task[] = []; + + const orderWithOnlyStaleIds = createTestTaskOrder({ + backlog: ['deleted-task-1', 'deleted-task-2', 'deleted-task-3'] + }); + + useTaskStore.setState({ tasks, taskOrder: orderWithOnlyStaleIds }); + + // Filter out stale IDs + const currentTaskIds = new Set(tasks.map(t => t.id)); + const columnOrder = useTaskStore.getState().taskOrder?.backlog || []; + const validOrder = columnOrder.filter(id => currentTaskIds.has(id)); + + expect(validOrder).toEqual([]); + expect(validOrder).toHaveLength(0); + }); + + it('should preserve valid IDs while removing stale ones', () => { + const tasks = [ + createTestTask({ id: 'valid-1', status: 'in_progress' }), + createTestTask({ id: 'valid-3', status: 'in_progress' }), + createTestTask({ id: 'valid-5', status: 'in_progress' }) + ]; + + // Order with alternating valid/stale IDs + const mixedOrder = createTestTaskOrder({ + in_progress: ['valid-1', 'stale-2', 'valid-3', 'stale-4', 'valid-5'] + }); + + useTaskStore.setState({ tasks, taskOrder: mixedOrder }); + + // Filter stale IDs + const currentTaskIds = new Set(tasks.map(t => t.id)); + const columnOrder = useTaskStore.getState().taskOrder?.in_progress || []; + const validOrder = columnOrder.filter(id => currentTaskIds.has(id)); + + // Should keep relative order of valid IDs + expect(validOrder).toEqual(['valid-1', 'valid-3', 'valid-5']); + }); + + it('should handle stale IDs across multiple columns', () => { + const tasks = [ + createTestTask({ id: 'backlog-task', status: 'backlog' }), + createTestTask({ id: 'progress-task', status: 'in_progress' }), + createTestTask({ id: 'done-task', status: 'done' }) + ]; + + const orderWithStaleInMultipleColumns = createTestTaskOrder({ + backlog: ['backlog-task', 'stale-backlog'], + in_progress: ['stale-progress', 'progress-task'], + done: ['stale-done-1', 'done-task', 'stale-done-2'] + }); + + useTaskStore.setState({ tasks, taskOrder: orderWithStaleInMultipleColumns }); + + const currentTaskIds = new Set(tasks.map(t => t.id)); + const taskOrder = useTaskStore.getState().taskOrder!; + + // Filter each column + const validBacklog = taskOrder.backlog.filter(id => currentTaskIds.has(id)); + const validProgress = taskOrder.in_progress.filter(id => currentTaskIds.has(id)); + const validDone = taskOrder.done.filter(id => currentTaskIds.has(id)); + + expect(validBacklog).toEqual(['backlog-task']); + expect(validProgress).toEqual(['progress-task']); + expect(validDone).toEqual(['done-task']); + }); + + it('should not modify order if all IDs are valid', () => { + const tasks = [ + createTestTask({ id: 'task-1', status: 'backlog' }), + createTestTask({ id: 'task-2', status: 'backlog' }), + createTestTask({ id: 'task-3', status: 'backlog' }) + ]; + + const validOrder = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'] + }); + + useTaskStore.setState({ tasks, taskOrder: validOrder }); + + const currentTaskIds = new Set(tasks.map(t => t.id)); + const columnOrder = useTaskStore.getState().taskOrder?.backlog || []; + const filteredOrder = columnOrder.filter(id => currentTaskIds.has(id)); + + // Should be identical + expect(filteredOrder).toEqual(['task-1', 'task-2', 'task-3']); + expect(filteredOrder.length).toBe(columnOrder.length); + }); + }); + + describe('order filtering: new task placement at top', () => { + it('should identify new tasks not present in custom order', () => { + const tasks = [ + createTestTask({ id: 'existing-1', status: 'backlog' }), + createTestTask({ id: 'existing-2', status: 'backlog' }), + createTestTask({ id: 'new-task', status: 'backlog' }) // Not in order + ]; + + const orderWithoutNewTask = createTestTaskOrder({ + backlog: ['existing-1', 'existing-2'] + }); + + useTaskStore.setState({ tasks, taskOrder: orderWithoutNewTask }); + + const columnOrder = useTaskStore.getState().taskOrder?.backlog || []; + const orderSet = new Set(columnOrder); + const columnTasks = tasks.filter(t => t.status === 'backlog'); + + // Find new tasks (not in order) + const newTasks = columnTasks.filter(t => !orderSet.has(t.id)); + + expect(newTasks).toHaveLength(1); + expect(newTasks[0].id).toBe('new-task'); + }); + + it('should identify multiple new tasks not in order', () => { + const tasks = [ + createTestTask({ id: 'existing-1', status: 'backlog' }), + createTestTask({ id: 'new-task-1', status: 'backlog' }), + createTestTask({ id: 'new-task-2', status: 'backlog' }), + createTestTask({ id: 'new-task-3', status: 'backlog' }) + ]; + + const orderWithOnlyOne = createTestTaskOrder({ + backlog: ['existing-1'] + }); + + useTaskStore.setState({ tasks, taskOrder: orderWithOnlyOne }); + + const columnOrder = useTaskStore.getState().taskOrder?.backlog || []; + const orderSet = new Set(columnOrder); + const columnTasks = tasks.filter(t => t.status === 'backlog'); + + const newTasks = columnTasks.filter(t => !orderSet.has(t.id)); + + expect(newTasks).toHaveLength(3); + expect(newTasks.map(t => t.id)).toContain('new-task-1'); + expect(newTasks.map(t => t.id)).toContain('new-task-2'); + expect(newTasks.map(t => t.id)).toContain('new-task-3'); + }); + + it('should correctly separate ordered and unordered tasks', () => { + const tasks = [ + createTestTask({ id: 'ordered-1', status: 'in_progress' }), + createTestTask({ id: 'ordered-2', status: 'in_progress' }), + createTestTask({ id: 'unordered-1', status: 'in_progress' }), + createTestTask({ id: 'ordered-3', status: 'in_progress' }), + createTestTask({ id: 'unordered-2', status: 'in_progress' }) + ]; + + const partialOrder = createTestTaskOrder({ + in_progress: ['ordered-1', 'ordered-2', 'ordered-3'] + }); + + useTaskStore.setState({ tasks, taskOrder: partialOrder }); + + const columnOrder = useTaskStore.getState().taskOrder?.in_progress || []; + const orderSet = new Set(columnOrder); + const columnTasks = tasks.filter(t => t.status === 'in_progress'); + + const orderedTasks = columnTasks.filter(t => orderSet.has(t.id)); + const unorderedTasks = columnTasks.filter(t => !orderSet.has(t.id)); + + expect(orderedTasks).toHaveLength(3); + expect(unorderedTasks).toHaveLength(2); + expect(orderedTasks.map(t => t.id)).toEqual(['ordered-1', 'ordered-2', 'ordered-3']); + expect(unorderedTasks.map(t => t.id)).toContain('unordered-1'); + expect(unorderedTasks.map(t => t.id)).toContain('unordered-2'); + }); + + it('should handle empty order (all tasks are new)', () => { + const tasks = [ + createTestTask({ id: 'new-1', status: 'backlog' }), + createTestTask({ id: 'new-2', status: 'backlog' }), + createTestTask({ id: 'new-3', status: 'backlog' }) + ]; + + const emptyOrder = createTestTaskOrder({ + backlog: [] + }); + + useTaskStore.setState({ tasks, taskOrder: emptyOrder }); + + const columnOrder = useTaskStore.getState().taskOrder?.backlog || []; + const orderSet = new Set(columnOrder); + const columnTasks = tasks.filter(t => t.status === 'backlog'); + + const newTasks = columnTasks.filter(t => !orderSet.has(t.id)); + + // All tasks should be considered new + expect(newTasks).toHaveLength(3); + expect(newTasks.map(t => t.id)).toEqual(['new-1', 'new-2', 'new-3']); + }); + + it('should addTask to place new task at top of order', () => { + const existingOrder = createTestTaskOrder({ + backlog: ['existing-1', 'existing-2'] + }); + + useTaskStore.setState({ tasks: [], taskOrder: existingOrder }); + + // Add a new task + const newTask = createTestTask({ id: 'brand-new', status: 'backlog' }); + useTaskStore.getState().addTask(newTask); + + // New task should be at the top of the order + const order = useTaskStore.getState().taskOrder; + expect(order?.backlog[0]).toBe('brand-new'); + expect(order?.backlog).toEqual(['brand-new', 'existing-1', 'existing-2']); + }); + + it('should addTask to correct column based on task status', () => { + const existingOrder = createTestTaskOrder({ + backlog: ['backlog-task'], + in_progress: ['progress-task'], + done: ['done-task'] + }); + + useTaskStore.setState({ tasks: [], taskOrder: existingOrder }); + + // Add a task to in_progress + const newProgressTask = createTestTask({ id: 'new-progress', status: 'in_progress' }); + useTaskStore.getState().addTask(newProgressTask); + + const order = useTaskStore.getState().taskOrder; + // Should be at top of in_progress + expect(order?.in_progress[0]).toBe('new-progress'); + // Should not affect other columns + expect(order?.backlog).toEqual(['backlog-task']); + expect(order?.done).toEqual(['done-task']); + }); + }); + + describe('order filtering: cross-column move updates', () => { + it('should remove task from source column and add to target column on move', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2', 'task-3'], + in_progress: ['task-4', 'task-5'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Move task-2 from backlog to in_progress + useTaskStore.getState().moveTaskToColumnTop('task-2', 'in_progress', 'backlog'); + + const updatedOrder = useTaskStore.getState().taskOrder; + // Removed from source + expect(updatedOrder?.backlog).toEqual(['task-1', 'task-3']); + // Added to top of target + expect(updatedOrder?.in_progress).toEqual(['task-2', 'task-4', 'task-5']); + }); + + it('should move task to top of target column preserving target order', () => { + const order = createTestTaskOrder({ + ai_review: ['review-1', 'review-2', 'review-3'], + human_review: ['human-1', 'human-2'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Move from ai_review to human_review + useTaskStore.getState().moveTaskToColumnTop('review-2', 'human_review', 'ai_review'); + + const updatedOrder = useTaskStore.getState().taskOrder; + // Should be at top of human_review + expect(updatedOrder?.human_review[0]).toBe('review-2'); + // Existing tasks pushed down + expect(updatedOrder?.human_review).toEqual(['review-2', 'human-1', 'human-2']); + }); + + it('should handle moving to empty column', () => { + const order = createTestTaskOrder({ + backlog: ['task-1', 'task-2'], + done: [] + }); + useTaskStore.setState({ taskOrder: order }); + + // Move to empty done column + useTaskStore.getState().moveTaskToColumnTop('task-1', 'done', 'backlog'); + + const updatedOrder = useTaskStore.getState().taskOrder; + expect(updatedOrder?.done).toEqual(['task-1']); + expect(updatedOrder?.backlog).toEqual(['task-2']); + }); + + it('should handle moving from single-item column', () => { + const order = createTestTaskOrder({ + in_progress: ['lone-task'], + done: ['done-1', 'done-2'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Move the only task out of in_progress + useTaskStore.getState().moveTaskToColumnTop('lone-task', 'done', 'in_progress'); + + const updatedOrder = useTaskStore.getState().taskOrder; + expect(updatedOrder?.in_progress).toEqual([]); + expect(updatedOrder?.done[0]).toBe('lone-task'); + }); + + it('should handle sequential cross-column moves', () => { + const order = createTestTaskOrder({ + backlog: ['task-1'], + in_progress: [], + ai_review: [], + done: [] + }); + useTaskStore.setState({ taskOrder: order }); + + // Move task through multiple columns (simulating workflow) + useTaskStore.getState().moveTaskToColumnTop('task-1', 'in_progress', 'backlog'); + + let updatedOrder = useTaskStore.getState().taskOrder; + expect(updatedOrder?.backlog).toEqual([]); + expect(updatedOrder?.in_progress).toEqual(['task-1']); + + useTaskStore.getState().moveTaskToColumnTop('task-1', 'ai_review', 'in_progress'); + + updatedOrder = useTaskStore.getState().taskOrder; + expect(updatedOrder?.in_progress).toEqual([]); + expect(updatedOrder?.ai_review).toEqual(['task-1']); + + useTaskStore.getState().moveTaskToColumnTop('task-1', 'done', 'ai_review'); + + updatedOrder = useTaskStore.getState().taskOrder; + expect(updatedOrder?.ai_review).toEqual([]); + expect(updatedOrder?.done).toEqual(['task-1']); + }); + + it('should handle moving task that is already in target column (dedup)', () => { + // Edge case: somehow task ID ended up in both columns + const orderWithDup = createTestTaskOrder({ + backlog: ['task-1', 'task-2'], + in_progress: ['task-2', 'task-3'] // task-2 is duplicated + }); + useTaskStore.setState({ taskOrder: orderWithDup }); + + // Move task-2 from backlog to in_progress + useTaskStore.getState().moveTaskToColumnTop('task-2', 'in_progress', 'backlog'); + + const updatedOrder = useTaskStore.getState().taskOrder; + // Should be removed from backlog + expect(updatedOrder?.backlog).toEqual(['task-1']); + // Should appear exactly once at top of in_progress + expect(updatedOrder?.in_progress[0]).toBe('task-2'); + // Should be deduplicated + const task2Count = updatedOrder?.in_progress.filter(id => id === 'task-2').length; + expect(task2Count).toBe(1); + }); + + it('should preserve unaffected columns during cross-column move', () => { + const order = createTestTaskOrder({ + backlog: ['backlog-1', 'backlog-2'], + in_progress: ['progress-1'], + ai_review: ['review-1', 'review-2'], + human_review: ['human-1'], + done: ['done-1', 'done-2', 'done-3'] + }); + useTaskStore.setState({ taskOrder: order }); + + // Move from backlog to in_progress + useTaskStore.getState().moveTaskToColumnTop('backlog-1', 'in_progress', 'backlog'); + + const updatedOrder = useTaskStore.getState().taskOrder; + // Affected columns updated + expect(updatedOrder?.backlog).toEqual(['backlog-2']); + expect(updatedOrder?.in_progress).toEqual(['backlog-1', 'progress-1']); + // Unaffected columns preserved exactly + expect(updatedOrder?.ai_review).toEqual(['review-1', 'review-2']); + expect(updatedOrder?.human_review).toEqual(['human-1']); + expect(updatedOrder?.done).toEqual(['done-1', 'done-2', 'done-3']); + }); + }); + + describe('integration: load, reorder, save cycle', () => { + it('should persist reordering through load/save cycle', () => { + // 1. Load empty order + useTaskStore.getState().loadTaskOrder('test-project'); + expect(useTaskStore.getState().taskOrder).toBeDefined(); + + // 2. Set up initial order + const order = createTestTaskOrder({ + backlog: ['task-a', 'task-b', 'task-c'] + }); + useTaskStore.getState().setTaskOrder(order); + + // 3. Reorder + useTaskStore.getState().reorderTasksInColumn('backlog', 'task-c', 'task-a'); + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-c', 'task-a', 'task-b']); + + // 4. Save + useTaskStore.getState().saveTaskOrder('test-project'); + + // 5. Clear state + useTaskStore.setState({ taskOrder: null }); + + // 6. Reload + useTaskStore.getState().loadTaskOrder('test-project'); + + // 7. Verify order persisted + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-c', 'task-a', 'task-b']); + }); + + it('should handle project switching correctly', () => { + // Set up orders for two projects + const order1 = createTestTaskOrder({ backlog: ['project1-task'] }); + const order2 = createTestTaskOrder({ backlog: ['project2-task'] }); + + // Save project 1 order + useTaskStore.setState({ taskOrder: order1 }); + useTaskStore.getState().saveTaskOrder('project-1'); + + // Save project 2 order + useTaskStore.setState({ taskOrder: order2 }); + useTaskStore.getState().saveTaskOrder('project-2'); + + // Clear and switch between projects + useTaskStore.setState({ taskOrder: null }); + + useTaskStore.getState().loadTaskOrder('project-1'); + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['project1-task']); + + useTaskStore.getState().loadTaskOrder('project-2'); + expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['project2-task']); + }); + }); +}); diff --git a/apps/frontend/src/renderer/components/KanbanBoard.tsx b/apps/frontend/src/renderer/components/KanbanBoard.tsx index 5e225e98..9df23530 100644 --- a/apps/frontend/src/renderer/components/KanbanBoard.tsx +++ b/apps/frontend/src/renderer/components/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, memo, useCallback, useEffect } from 'react'; +import { useState, useMemo, memo, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useViewState } from '../contexts/ViewStateContext'; import { @@ -28,11 +28,11 @@ import { TaskCard } from './TaskCard'; import { SortableTaskCard } from './SortableTaskCard'; import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants'; import { cn } from '../lib/utils'; -import { persistTaskStatus, forceCompleteTask, archiveTasks } from '../stores/task-store'; +import { persistTaskStatus, forceCompleteTask, archiveTasks, useTaskStore } from '../stores/task-store'; import { useToast } from '../hooks/use-toast'; import { WorktreeCleanupDialog } from './WorktreeCleanupDialog'; import { BulkPRDialog } from './BulkPRDialog'; -import type { Task, TaskStatus } from '../../shared/types'; +import type { Task, TaskStatus, TaskOrderState } from '../../shared/types'; // Type guard for valid drop column targets - preserves literal type from TASK_STATUS_COLUMNS const VALID_DROP_COLUMNS = new Set(TASK_STATUS_COLUMNS); @@ -40,6 +40,15 @@ function isValidDropColumn(id: string): id is typeof TASK_STATUS_COLUMNS[number] return VALID_DROP_COLUMNS.has(id); } +/** + * Get the visual column for a task status. + * pr_created tasks are displayed in the 'done' column, so we map them accordingly. + * This is used to compare visual positions during drag-and-drop operations. + */ +function getVisualColumn(status: TaskStatus): typeof TASK_STATUS_COLUMNS[number] { + return status === 'pr_created' ? 'done' : status; +} + interface KanbanBoardProps { tasks: Task[]; onTaskClick: (task: Task) => void; @@ -464,6 +473,9 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR }) ); + // Get task order from store for custom ordering + const taskOrder = useTaskStore((state) => state.taskOrder); + const tasksByStatus = useMemo(() => { // Note: pr_created tasks are shown in the 'done' column since they're essentially complete const grouped: Record = { @@ -482,17 +494,51 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR } }); - // Sort tasks within each column by createdAt (newest first) + // Sort tasks within each column Object.keys(grouped).forEach((status) => { - grouped[status as typeof TASK_STATUS_COLUMNS[number]].sort((a, b) => { - const dateA = new Date(a.createdAt).getTime(); - const dateB = new Date(b.createdAt).getTime(); - return dateB - dateA; // Descending order (newest first) - }); + const statusKey = status as typeof TASK_STATUS_COLUMNS[number]; + const columnTasks = grouped[statusKey]; + const columnOrder = taskOrder?.[statusKey]; + + if (columnOrder && columnOrder.length > 0) { + // Custom order exists: sort by order index + // 1. Create a set of current task IDs for fast lookup (filters stale IDs) + const currentTaskIds = new Set(columnTasks.map(t => t.id)); + + // 2. Create valid order by filtering out stale IDs + const validOrder = columnOrder.filter(id => currentTaskIds.has(id)); + const validOrderSet = new Set(validOrder); + + // 3. Find new tasks not in order (prepend at top) + const newTasks = columnTasks.filter(t => !validOrderSet.has(t.id)); + // Sort new tasks by createdAt (newest first) + newTasks.sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateB - dateA; + }); + + // 4. Sort ordered tasks by their index in validOrder + // Pre-compute index map for O(n) sorting instead of O(n²) with indexOf + const indexMap = new Map(validOrder.map((id, idx) => [id, idx])); + const orderedTasks = columnTasks + .filter(t => validOrderSet.has(t.id)) + .sort((a, b) => (indexMap.get(a.id) ?? 0) - (indexMap.get(b.id) ?? 0)); + + // 5. Prepend new tasks at top, then ordered tasks + grouped[statusKey] = [...newTasks, ...orderedTasks]; + } else { + // No custom order: fallback to createdAt sort (newest first) + grouped[statusKey].sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateB - dateA; + }); + } }); return grouped; - }, [filteredTasks]); + }, [filteredTasks, taskOrder]); // Prune stale IDs when tasks move out of human_review column useEffect(() => { @@ -650,6 +696,73 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR } }; + // Get task order actions from store + const reorderTasksInColumn = useTaskStore((state) => state.reorderTasksInColumn); + const moveTaskToColumnTop = useTaskStore((state) => state.moveTaskToColumnTop); + const saveTaskOrderToStorage = useTaskStore((state) => state.saveTaskOrder); + const loadTaskOrder = useTaskStore((state) => state.loadTaskOrder); + const setTaskOrder = useTaskStore((state) => state.setTaskOrder); + + // Get projectId from tasks (all tasks in KanbanBoard share the same project) + const projectId = useMemo(() => tasks[0]?.projectId ?? null, [tasks]); + + const saveTaskOrder = useCallback((projectIdToSave: string) => { + const success = saveTaskOrderToStorage(projectIdToSave); + if (!success) { + toast({ + title: t('kanban.orderSaveFailedTitle'), + description: t('kanban.orderSaveFailedDescription'), + variant: 'destructive' + }); + } + return success; + }, [saveTaskOrderToStorage, toast, t]); + + // Load task order on mount and when project changes + useEffect(() => { + if (projectId) { + loadTaskOrder(projectId); + } + }, [projectId, loadTaskOrder]); + + // Clean up stale task IDs from order when tasks change (e.g., after deletion) + // This ensures the persisted order doesn't contain IDs for deleted tasks + useEffect(() => { + if (!projectId || !taskOrder) return; + + // Build a set of current task IDs for fast lookup + const currentTaskIds = new Set(tasks.map(t => t.id)); + + // Check each column for stale IDs + let hasStaleIds = false; + const cleanedOrder: typeof taskOrder = { + backlog: [], + in_progress: [], + ai_review: [], + human_review: [], + pr_created: [], + done: [] + }; + + for (const status of Object.keys(taskOrder) as Array) { + const columnOrder = taskOrder[status] || []; + const cleanedColumnOrder = columnOrder.filter(id => currentTaskIds.has(id)); + + cleanedOrder[status] = cleanedColumnOrder; + + // Check if any IDs were removed + if (cleanedColumnOrder.length !== columnOrder.length) { + hasStaleIds = true; + } + } + + // If stale IDs were found, update the order and persist + if (hasStaleIds) { + setTaskOrder(cleanedOrder); + saveTaskOrder(projectId); + } + }, [tasks, taskOrder, projectId, setTaskOrder, saveTaskOrder]); + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveTask(null); @@ -666,6 +779,14 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR const task = tasks.find((t) => t.id === activeTaskId); if (task && task.status !== newStatus) { + // Move task to top of target column's order array + moveTaskToColumnTop(activeTaskId, newStatus, task.status); + + // Persist task order + if (projectId) { + saveTaskOrder(projectId); + } + // Persist status change to file and update local state handleStatusChange(activeTaskId, newStatus, task).catch((err) => console.error('[KanbanBoard] Status change failed:', err) @@ -674,16 +795,55 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR return; } - // Check if dropped on another task - move to that task's column + // Check if dropped on another task const overTask = tasks.find((t) => t.id === overId); if (overTask) { const task = tasks.find((t) => t.id === activeTaskId); - if (task && task.status !== overTask.status) { - // Persist status change to file and update local state - handleStatusChange(activeTaskId, overTask.status, task).catch((err) => - console.error('[KanbanBoard] Status change failed:', err) - ); + if (!task) return; + + // Compare visual columns (pr_created maps to 'done' visually) + const taskVisualColumn = getVisualColumn(task.status); + const overTaskVisualColumn = getVisualColumn(overTask.status); + + // Same visual column: reorder within column + if (taskVisualColumn === overTaskVisualColumn) { + // Ensure both tasks are in the order array before reordering + // This handles tasks that existed before ordering was enabled + const currentColumnOrder = taskOrder?.[taskVisualColumn] ?? []; + const activeInOrder = currentColumnOrder.includes(activeTaskId); + const overInOrder = currentColumnOrder.includes(overId); + + if (!activeInOrder || !overInOrder) { + // Sync the current visual order to the stored order + // This ensures existing tasks can be reordered + const visualOrder = tasksByStatus[taskVisualColumn].map(t => t.id); + setTaskOrder({ + ...taskOrder, + [taskVisualColumn]: visualOrder + } as TaskOrderState); + } + + // Reorder tasks within the same column using the visual column key + reorderTasksInColumn(taskVisualColumn, activeTaskId, overId); + + if (projectId) { + saveTaskOrder(projectId); + } + return; } + + // Different visual column: move to that task's column (status change) + // Use the visual column key for ordering to ensure consistency + moveTaskToColumnTop(activeTaskId, overTaskVisualColumn, taskVisualColumn); + + // Persist task order + if (projectId) { + saveTaskOrder(projectId); + } + + handleStatusChange(activeTaskId, overTask.status, task).catch((err) => + console.error('[KanbanBoard] Status change failed:', err) + ); } }; diff --git a/apps/frontend/src/renderer/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index f1d7f7a7..9a02ed24 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; -import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft, ImageAttachment } from '../../shared/types'; +import { arrayMove } from '@dnd-kit/sortable'; +import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft, ImageAttachment, TaskOrderState } from '../../shared/types'; import { debugLog } from '../../shared/utils/debug-logger'; import { isTerminalPhase } from '../../shared/constants/phase-protocol'; @@ -8,6 +9,7 @@ interface TaskState { selectedTaskId: string | null; isLoading: boolean; error: string | null; + taskOrder: TaskOrderState | null; // Per-column task ordering for kanban board // Actions setTasks: (tasks: Task[]) => void; @@ -22,6 +24,13 @@ interface TaskState { setLoading: (loading: boolean) => void; setError: (error: string | null) => void; clearTasks: () => void; + // Task order actions for kanban drag-and-drop reordering + setTaskOrder: (order: TaskOrderState) => void; + reorderTasksInColumn: (status: TaskStatus, activeId: string, overId: string) => void; + moveTaskToColumnTop: (taskId: string, targetStatus: TaskStatus, sourceStatus?: TaskStatus) => void; + loadTaskOrder: (projectId: string) => void; + saveTaskOrder: (projectId: string) => boolean; + clearTaskOrder: (projectId: string) => void; // Selectors getSelectedTask: () => Task | undefined; @@ -95,18 +104,68 @@ function validatePlanData(plan: ImplementationPlan): boolean { return true; } +// localStorage key prefix for task order persistence +const TASK_ORDER_KEY_PREFIX = 'task-order-state'; + +/** + * Get the localStorage key for a project's task order + */ +function getTaskOrderKey(projectId: string): string { + return `${TASK_ORDER_KEY_PREFIX}-${projectId}`; +} + +/** + * Create an empty task order state with all status columns + */ +function createEmptyTaskOrder(): TaskOrderState { + return { + backlog: [], + in_progress: [], + ai_review: [], + human_review: [], + pr_created: [], + done: [] + }; +} + export const useTaskStore = create((set, get) => ({ tasks: [], selectedTaskId: null, isLoading: false, error: null, + taskOrder: null, setTasks: (tasks) => set({ tasks }), addTask: (task) => - set((state) => ({ - tasks: [...state.tasks, task] - })), + set((state) => { + // Determine which column the task belongs to based on its status + const status = task.status || 'backlog'; + + // Update task order if it exists - new tasks go to top of their column + let taskOrder = state.taskOrder; + if (taskOrder) { + const newTaskOrder = { ...taskOrder }; + + // Add task ID to the top of the appropriate column + if (newTaskOrder[status]) { + // Ensure the task isn't already in the array (safety check) + newTaskOrder[status] = newTaskOrder[status].filter(id => id !== task.id); + // Add to top (index 0) + newTaskOrder[status] = [task.id, ...newTaskOrder[status]]; + } else { + // Initialize column order array if it doesn't exist + newTaskOrder[status] = [task.id]; + } + + taskOrder = newTaskOrder; + } + + return { + tasks: [...state.tasks, task], + taskOrder + }; + }), updateTask: (taskId, updates) => set((state) => { @@ -402,7 +461,124 @@ export const useTaskStore = create((set, get) => ({ setError: (error) => set({ error }), - clearTasks: () => set({ tasks: [], selectedTaskId: null }), + clearTasks: () => set({ tasks: [], selectedTaskId: null, taskOrder: null }), + + // Task order actions for kanban drag-and-drop reordering + setTaskOrder: (order) => set({ taskOrder: order }), + + reorderTasksInColumn: (status, activeId, overId) => { + set((state) => { + if (!state.taskOrder) return state; + + const columnOrder = state.taskOrder[status]; + if (!columnOrder) return state; + + const oldIndex = columnOrder.indexOf(activeId); + const newIndex = columnOrder.indexOf(overId); + + // Both tasks must be in the column order array + if (oldIndex === -1 || newIndex === -1) return state; + + return { + taskOrder: { + ...state.taskOrder, + [status]: arrayMove(columnOrder, oldIndex, newIndex) + } + }; + }); + }, + + moveTaskToColumnTop: (taskId, targetStatus, sourceStatus) => { + set((state) => { + if (!state.taskOrder) return state; + + // Create a copy of the task order to modify + const newTaskOrder = { ...state.taskOrder }; + + // Remove from source column if provided + if (sourceStatus && newTaskOrder[sourceStatus]) { + newTaskOrder[sourceStatus] = newTaskOrder[sourceStatus].filter(id => id !== taskId); + } + + // Add to top of target column + if (newTaskOrder[targetStatus]) { + // Remove from target column first (in case it already exists there) + newTaskOrder[targetStatus] = newTaskOrder[targetStatus].filter(id => id !== taskId); + // Add to top (index 0) + newTaskOrder[targetStatus] = [taskId, ...newTaskOrder[targetStatus]]; + } else { + // Initialize column order array if it doesn't exist + newTaskOrder[targetStatus] = [taskId]; + } + + return { taskOrder: newTaskOrder }; + }); + }, + + loadTaskOrder: (projectId) => { + try { + const key = getTaskOrderKey(projectId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + // Validate structure before assigning - type assertion is compile-time only + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + console.warn('Invalid task order data in localStorage, resetting to empty'); + set({ taskOrder: createEmptyTaskOrder() }); + return; + } + + // Helper to validate column values are string arrays + const isValidColumnArray = (val: unknown): val is string[] => + Array.isArray(val) && val.every(item => typeof item === 'string'); + + // Merge with empty order to handle partial data and validate each column + const emptyOrder = createEmptyTaskOrder(); + const validatedOrder: TaskOrderState = { + backlog: isValidColumnArray(parsed.backlog) ? parsed.backlog : emptyOrder.backlog, + in_progress: isValidColumnArray(parsed.in_progress) ? parsed.in_progress : emptyOrder.in_progress, + ai_review: isValidColumnArray(parsed.ai_review) ? parsed.ai_review : emptyOrder.ai_review, + human_review: isValidColumnArray(parsed.human_review) ? parsed.human_review : emptyOrder.human_review, + pr_created: isValidColumnArray(parsed.pr_created) ? parsed.pr_created : emptyOrder.pr_created, + done: isValidColumnArray(parsed.done) ? parsed.done : emptyOrder.done + }; + + set({ taskOrder: validatedOrder }); + } else { + set({ taskOrder: createEmptyTaskOrder() }); + } + } catch (error) { + console.error('Failed to load task order:', error); + set({ taskOrder: createEmptyTaskOrder() }); + } + }, + + saveTaskOrder: (projectId) => { + try { + const state = get(); + if (!state.taskOrder) { + // Nothing to save - return false to indicate no save occurred + return false; + } + + const key = getTaskOrderKey(projectId); + localStorage.setItem(key, JSON.stringify(state.taskOrder)); + return true; + } catch (error) { + console.error('Failed to save task order:', error); + return false; + } + }, + + clearTaskOrder: (projectId) => { + try { + const key = getTaskOrderKey(projectId); + localStorage.removeItem(key); + set({ taskOrder: null }); + } catch (error) { + console.error('Failed to clear task order:', error); + } + }, getSelectedTask: () => { const state = get(); diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index dea4989a..1be09f76 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -85,6 +85,8 @@ "keepWorktree": "Keep Worktree", "deleteWorktree": "Delete Worktree & Mark Done", "refreshTasks": "Refresh Tasks", + "orderSaveFailedTitle": "Reorder not saved", + "orderSaveFailedDescription": "Your task order change was applied but couldn't be saved to storage. It will be lost on refresh.", "selectAll": "Select all", "deselectAll": "Deselect all", "selectedCount": "{{count}} selected", diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index 666286d0..74d9a967 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -85,6 +85,8 @@ "keepWorktree": "Garder le Worktree", "deleteWorktree": "Supprimer le Worktree & Marquer Terminé", "refreshTasks": "Actualiser les tâches", + "orderSaveFailedTitle": "Réorganisation non enregistrée", + "orderSaveFailedDescription": "Votre changement d'ordre des tâches a été appliqué mais n'a pas pu être sauvegardé. Il sera perdu lors du rafraîchissement.", "selectAll": "Tout sélectionner", "deselectAll": "Tout désélectionner", "selectedCount": "{{count}} sélectionné(s)", diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index e3f89802..30e0fb19 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -7,6 +7,9 @@ import type { ExecutionPhase as ExecutionPhaseType, CompletablePhase } from '../ export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done'; +// Maps task status columns to ordered task IDs for kanban board reordering +export type TaskOrderState = Record; + // Reason why a task is in human_review status // - 'completed': All subtasks done and QA passed, ready for final approval/merge // - 'errors': Subtasks failed during execution