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