diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.spec.ts index b75cecee10..d0c5471a4a 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.spec.ts @@ -1064,6 +1064,252 @@ describe('taskSharedCrudMetaReducer', () => { }); }); + describe('parent/sub-task tag conflict resolution', () => { + describe('addTask action', () => { + it('should remove parent from tag when adding sub-task to same tag', () => { + // Setup: parent task already in tag + const testState = createStateWithExistingTasks( + ['parent-task'], + [], + ['parent-task'], + ); + + // Create sub-task that will be added to the same tag + const subTask = createMockTask({ + id: 'sub-task', + parentId: 'parent-task', + tagIds: ['tag1'], + }); + + const action = TaskSharedActions.addTask({ + task: subTask, + workContextId: 'project1', + workContextType: WorkContextType.PROJECT, + isAddToBottom: false, + isAddToBacklog: false, + }); + + metaReducer(testState, action); + + // Parent should be removed from tag, and tag removed from parent + expectStateUpdate( + { + ...expectTaskUpdate('parent-task', { + tagIds: [], // Tag should be removed from parent + }), + ...expectTagUpdate('tag1', { + taskIds: ['sub-task'], // Only sub-task should remain + }), + }, + action, + mockReducer, + testState, + ); + }); + + it('should remove sub-tasks from tag when adding parent to same tag', () => { + // Setup: sub-tasks already in tag + const testState = createStateWithExistingTasks( + ['sub1', 'sub2', 'other-task'], + [], + ['sub1', 'sub2', 'other-task'], + ); + + // Update sub-tasks to have parentId + testState[TASK_FEATURE_NAME].entities.sub1 = createMockTask({ + id: 'sub1', + parentId: 'parent-task', + tagIds: ['tag1'], + }); + testState[TASK_FEATURE_NAME].entities.sub2 = createMockTask({ + id: 'sub2', + parentId: 'parent-task', + tagIds: ['tag1'], + }); + + // Create parent task with sub-tasks + const parentTask = createMockTask({ + id: 'parent-task', + subTaskIds: ['sub1', 'sub2'], + tagIds: ['tag1'], + }); + + const action = TaskSharedActions.addTask({ + task: parentTask, + workContextId: 'project1', + workContextType: WorkContextType.PROJECT, + isAddToBottom: false, + isAddToBacklog: false, + }); + + metaReducer(testState, action); + + // Sub-tasks should be removed from tag, and tag removed from sub-tasks + expectStateUpdate( + { + ...expectTaskUpdate('sub1', { + tagIds: [], // Tag should be removed + }), + ...expectTaskUpdate('sub2', { + tagIds: [], // Tag should be removed + }), + ...expectTagUpdate('tag1', { + taskIds: ['parent-task', 'other-task'], // Only parent and other-task + }), + }, + action, + mockReducer, + testState, + ); + }); + }); + + describe('updateTask action', () => { + it('should remove parent from tag when adding tag to sub-task', () => { + // Setup: parent task in tag, sub-task without tag + const testState = createStateWithExistingTasks( + ['parent-task', 'sub-task'], + [], + ['parent-task'], + ); + + testState[TASK_FEATURE_NAME].entities['sub-task'] = createMockTask({ + id: 'sub-task', + parentId: 'parent-task', + tagIds: [], + }); + + const action = TaskSharedActions.updateTask({ + task: { + id: 'sub-task', + changes: { tagIds: ['tag1'] }, + }, + }); + + metaReducer(testState, action); + + expectStateUpdate( + { + ...expectTaskUpdate('parent-task', { + tagIds: [], // Tag removed from parent + }), + ...expectTaskUpdate('sub-task', { + tagIds: ['tag1'], // Tag added to sub-task + }), + ...expectTagUpdate('tag1', { + taskIds: ['sub-task'], // Only sub-task in tag + }), + }, + action, + mockReducer, + testState, + ); + }); + + it('should remove sub-tasks from tag when adding tag to parent', () => { + // Setup: sub-tasks in tag, parent without tag + const testState = createStateWithExistingTasks( + ['parent-task', 'sub1', 'sub2'], + [], + ['sub1', 'sub2'], + ); + + testState[TASK_FEATURE_NAME].entities['parent-task'] = createMockTask({ + id: 'parent-task', + subTaskIds: ['sub1', 'sub2'], + tagIds: [], + }); + testState[TASK_FEATURE_NAME].entities.sub1 = createMockTask({ + id: 'sub1', + parentId: 'parent-task', + tagIds: ['tag1'], + }); + testState[TASK_FEATURE_NAME].entities.sub2 = createMockTask({ + id: 'sub2', + parentId: 'parent-task', + tagIds: ['tag1'], + }); + + const action = TaskSharedActions.updateTask({ + task: { + id: 'parent-task', + changes: { tagIds: ['tag1'] }, + }, + }); + + metaReducer(testState, action); + + expectStateUpdate( + { + ...expectTaskUpdate('parent-task', { + tagIds: ['tag1'], // Tag added to parent + }), + ...expectTaskUpdate('sub1', { + tagIds: [], // Tag removed from sub-task + }), + ...expectTaskUpdate('sub2', { + tagIds: [], // Tag removed from sub-task + }), + ...expectTagUpdate('tag1', { + taskIds: ['parent-task'], // Only parent in tag + }), + }, + action, + mockReducer, + testState, + ); + }); + }); + + describe('convertToMainTask action', () => { + it('should remove parent from tag when converting sub-task to main task with same tag', () => { + // Setup: parent and sub-task exist, parent is in tag + const testState = createStateWithExistingTasks( + ['parent-task', 'sub-task'], + [], + ['parent-task'], + ); + + testState[TASK_FEATURE_NAME].entities['parent-task'] = createMockTask({ + id: 'parent-task', + subTaskIds: ['sub-task'], + tagIds: ['tag1'], + }); + testState[TASK_FEATURE_NAME].entities['sub-task'] = createMockTask({ + id: 'sub-task', + parentId: 'parent-task', + tagIds: [], + }); + + const action = TaskSharedActions.convertToMainTask({ + task: testState[TASK_FEATURE_NAME].entities['sub-task'] as Task, + parentTagIds: ['tag1'], + isPlanForToday: false, + }); + + metaReducer(testState, action); + + expectStateUpdate( + { + ...expectTaskUpdate('parent-task', { + tagIds: [], // Tag removed from parent + }), + ...expectTaskUpdate('sub-task', { + parentId: undefined, // No longer a sub-task + tagIds: ['tag1'], // Inherited parent's tag + }), + ...expectTagUpdate('tag1', { + taskIds: ['sub-task'], // Only converted task in tag + }), + }, + action, + mockReducer, + testState, + ); + }); + }); + }); + describe('other actions', () => { it('should pass through other actions to the reducer', () => { const action = { type: 'SOME_OTHER_ACTION' }; diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts index 002569d170..993c277ff1 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts @@ -33,6 +33,74 @@ import { updateTags, } from './task-shared-helpers'; +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +/** + * Removes parent or sub-tasks from a tag's task list to maintain the constraint + * that parent and sub-tasks should never be in the same tag list. + * Also removes the tag from the conflicting tasks. + * + * @param state - The current state + * @param taskId - The task being added + * @param tagId - The tag to check + * @returns Updated state with conflicts resolved + */ +const removeConflictingTasksFromTag = ( + state: RootState, + taskId: string, + tagId: string, +): RootState => { + const task = state[TASK_FEATURE_NAME].entities[taskId] as Task; + if (!task) return state; + + const tag = getTag(state, tagId); + const conflictingTaskIds: string[] = []; + + // If this is a sub-task, check if parent is in the tag + if (task.parentId && tag.taskIds.includes(task.parentId)) { + conflictingTaskIds.push(task.parentId); + } + + // If this is a parent task, check if any sub-tasks are in the tag + if (task.subTaskIds && task.subTaskIds.length > 0) { + const subTasksInTag = task.subTaskIds.filter((subId) => tag.taskIds.includes(subId)); + conflictingTaskIds.push(...subTasksInTag); + } + + if (conflictingTaskIds.length === 0) { + return state; + } + + // Update the conflicting tasks to remove the tag + const taskUpdates: Update[] = conflictingTaskIds.map((conflictingTaskId) => { + const conflictingTask = state[TASK_FEATURE_NAME].entities[conflictingTaskId] as Task; + return { + id: conflictingTaskId, + changes: { + tagIds: conflictingTask.tagIds.filter((id) => id !== tagId), + }, + }; + }); + + // Update the task state + const updatedState = { + ...state, + [TASK_FEATURE_NAME]: taskAdapter.updateMany(taskUpdates, state[TASK_FEATURE_NAME]), + }; + + // Update the tag to remove conflicting tasks + const tagUpdate: Update = { + id: tagId, + changes: { + taskIds: tag.taskIds.filter((id) => !conflictingTaskIds.includes(id)), + }, + }; + + return updateTags(updatedState, [tagUpdate]); +}; + // ============================================================================= // ACTION HANDLERS // ============================================================================= @@ -74,11 +142,21 @@ const handleAddTask = ( ...(task.dueDay === getWorklogStr() ? [TODAY_TAG.id] : []), ].filter((tagId) => state[TAG_FEATURE_NAME].entities[tagId]); + // First, handle conflicts for all tags + for (const tagId of tagIdsToUpdate) { + updatedState = removeConflictingTasksFromTag(updatedState, task.id, tagId); + } + + // Then add the task to all its tags const tagUpdates = tagIdsToUpdate.map( (tagId): Update => ({ id: tagId, changes: { - taskIds: addTaskToList(getTag(state, tagId).taskIds, task.id, isAddToBottom), + taskIds: addTaskToList( + getTag(updatedState, tagId).taskIds, + task.id, + isAddToBottom, + ), }, }), ); @@ -134,6 +212,56 @@ const handleConvertToMainTask = ( ...parentTagIds, ...(isPlanForToday ? [TODAY_TAG.id] : []), ].filter((tagId) => state[TAG_FEATURE_NAME].entities[tagId]); + + // For convertToMainTask, we need to manually check if parent is in the tags + // since the parent-child relationship was already cleaned up + const parentTaskId = task.parentId; + if (parentTaskId) { + // Remove parent from all tags that the converted task will be in + const parentTaskUpdates: Update[] = []; + const tagUpdatesForParent: Update[] = []; + + for (const tagId of tagIdsToUpdate) { + const tag = getTag(updatedState, tagId); + if (tag.taskIds.includes(parentTaskId)) { + // Remove tag from parent task + const currentParent = updatedState[TASK_FEATURE_NAME].entities[ + parentTaskId + ] as Task; + if (currentParent && currentParent.tagIds.includes(tagId)) { + parentTaskUpdates.push({ + id: parentTaskId, + changes: { + tagIds: currentParent.tagIds.filter((id) => id !== tagId), + }, + }); + } + // Remove parent from tag + tagUpdatesForParent.push({ + id: tagId, + changes: { + taskIds: tag.taskIds.filter((id) => id !== parentTaskId), + }, + }); + } + } + + if (parentTaskUpdates.length > 0) { + updatedState = { + ...updatedState, + [TASK_FEATURE_NAME]: taskAdapter.updateMany( + parentTaskUpdates, + updatedState[TASK_FEATURE_NAME], + ), + }; + } + + if (tagUpdatesForParent.length > 0) { + updatedState = updateTags(updatedState, tagUpdatesForParent); + } + } + + // Then add the task to all its tags at the beginning const tagUpdates = tagIdsToUpdate.map( (tagId): Update => ({ id: tagId, @@ -295,11 +423,18 @@ const handleTagUpdates = ( .filter((newId) => !oldTagIds.includes(newId)) .filter((tagId) => state[TAG_FEATURE_NAME].entities[tagId]); // Only existing tags + let updatedState = state; + + // First, handle conflicts for all tags we're adding to + for (const tagId of tagsToAddTo) { + updatedState = removeConflictingTasksFromTag(updatedState, taskId, tagId); + } + const removeUpdates = tagsToRemoveFrom.map( (tagId): Update => ({ id: tagId, changes: { - taskIds: getTag(state, tagId).taskIds.filter((id) => id !== taskId), + taskIds: getTag(updatedState, tagId).taskIds.filter((id) => id !== taskId), }, }), ); @@ -308,12 +443,12 @@ const handleTagUpdates = ( (tagId): Update => ({ id: tagId, changes: { - taskIds: unique([taskId, ...getTag(state, tagId).taskIds]), + taskIds: unique([taskId, ...getTag(updatedState, tagId).taskIds]), }, }), ); - return updateTags(state, [...removeUpdates, ...addUpdates]); + return updateTags(updatedState, [...removeUpdates, ...addUpdates]); }; // ============================================================================= diff --git a/src/app/root-store/meta/task-shared.reducer.ts b/src/app/root-store/meta/task-shared.reducer.ts deleted file mode 100644 index cb69d42421..0000000000 --- a/src/app/root-store/meta/task-shared.reducer.ts +++ /dev/null @@ -1,993 +0,0 @@ -import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; -import { Update } from '@ngrx/entity'; -import { RootState } from '../root-state'; -import { TaskSharedActions } from './task-shared.actions'; -import { - PROJECT_FEATURE_NAME, - projectAdapter, -} from '../../features/project/store/project.reducer'; -import { TAG_FEATURE_NAME, tagAdapter } from '../../features/tag/store/tag.reducer'; -import { TASK_FEATURE_NAME, taskAdapter } from '../../features/tasks/store/task.reducer'; -import { - deleteTaskHelper, - removeTaskFromParentSideEffects, - updateDoneOnForTask, - updateTimeEstimateForTask, - updateTimeSpentForTask, -} from '../../features/tasks/store/task.reducer.util'; -import { Tag } from '../../features/tag/tag.model'; -import { Project } from '../../features/project/project.model'; -import { DEFAULT_TASK, Task, TaskWithSubTasks } from '../../features/tasks/task.model'; -import { calcTotalTimeSpent } from '../../features/tasks/util/calc-total-time-spent'; -import { TODAY_TAG } from '../../features/tag/tag.const'; -import { getWorklogStr } from '../../util/get-work-log-str'; -import { unique } from '../../util/unique'; -import { isToday } from '../../util/is-today.util'; -import { moveItemBeforeItem } from '../../util/move-item-before-item'; -import { PlannerActions } from '../../features/planner/store/planner.actions'; - -// ============================================================================= -// TYPES & UTILITIES -// ============================================================================= - -type ProjectTaskList = 'backlogTaskIds' | 'taskIds'; -type TaskEntity = { id: string; projectId?: string | null; tagIds?: string[] }; -type TaskWithTags = Task & { tagIds: string[] }; -type ActionHandler = (state: RootState) => RootState; -type ActionHandlerMap = Record; - -// ============================================================================= -// MAIN ACTION HANDLERS -// ============================================================================= - -// Organized action handler mappings by domain -const createActionHandlers = (state: RootState, action: Action): ActionHandlerMap => ({ - // Task Management - [TaskSharedActions.addTask.type]: () => { - const { task, isAddToBottom, isAddToBacklog } = action as ReturnType< - typeof TaskSharedActions.addTask - >; - return handleAddTask(state, task, isAddToBottom, isAddToBacklog); - }, - [TaskSharedActions.convertToMainTask.type]: () => { - const { task, parentTagIds, isPlanForToday } = action as ReturnType< - typeof TaskSharedActions.convertToMainTask - >; - return handleConvertToMainTask(state, task, parentTagIds, isPlanForToday); - }, - [TaskSharedActions.deleteTask.type]: () => { - const { task } = action as ReturnType; - return handleDeleteTask(state, task); - }, - [TaskSharedActions.deleteTasks.type]: () => { - const { taskIds } = action as ReturnType; - return handleDeleteTasks(state, taskIds); - }, - [TaskSharedActions.updateTask.type]: () => { - const { task, isIgnoreShortSyntax } = action as ReturnType< - typeof TaskSharedActions.updateTask - >; - return handleUpdateTask(state, task, isIgnoreShortSyntax); - }, - - // Task Lifecycle - [TaskSharedActions.moveToArchive.type]: () => { - const { tasks } = action as ReturnType; - return handleMoveToArchive(state, tasks); - }, - [TaskSharedActions.restoreTask.type]: () => { - const { task, subTasks } = action as ReturnType; - return handleRestoreTask(state, task, subTasks); - }, - - // Task Scheduling - [TaskSharedActions.scheduleTaskWithTime.type]: () => { - const { task, dueWithTime } = action as ReturnType< - typeof TaskSharedActions.scheduleTaskWithTime - >; - return handleScheduleTaskWithTime(state, task, dueWithTime); - }, - [TaskSharedActions.reScheduleTaskWithTime.type]: () => { - const { task, dueWithTime } = action as ReturnType< - typeof TaskSharedActions.reScheduleTaskWithTime - >; - return handleScheduleTaskWithTime(state, task, dueWithTime); - }, - [TaskSharedActions.unscheduleTask.type]: () => { - const { id } = action as ReturnType; - return handleUnScheduleTask(state, id); - }, - - // Project Management - [TaskSharedActions.moveToOtherProject.type]: () => { - const { task, targetProjectId } = action as ReturnType< - typeof TaskSharedActions.moveToOtherProject - >; - return handleMoveToOtherProject(state, task, targetProjectId); - }, - [TaskSharedActions.deleteProject.type]: () => { - const { allTaskIds } = action as ReturnType; - return handleDeleteProject(state, allTaskIds); - }, - - // Today Tag Management - [TaskSharedActions.planTasksForToday.type]: () => { - const { taskIds, parentTaskMap = {} } = action as ReturnType< - typeof TaskSharedActions.planTasksForToday - >; - return handlePlanTasksForToday(state, taskIds, parentTaskMap); - }, - [TaskSharedActions.removeTasksFromTodayTag.type]: () => { - const { taskIds } = action as ReturnType< - typeof TaskSharedActions.removeTasksFromTodayTag - >; - return handleRemoveTasksFromTodayTag(state, taskIds); - }, - [TaskSharedActions.moveTaskInTodayTagList.type]: () => { - const { toTaskId, fromTaskId } = action as ReturnType< - typeof TaskSharedActions.moveTaskInTodayTagList - >; - return handleMoveTaskInTodayTagList(state, toTaskId, fromTaskId); - }, - - // Tag Management - [TaskSharedActions.removeTagsForAllTasks.type]: () => { - const { tagIdsToRemove } = action as ReturnType< - typeof TaskSharedActions.removeTagsForAllTasks - >; - return handleRemoveTagsForAllTasks(state, tagIdsToRemove); - }, - - // Planner Actions - [PlannerActions.transferTask.type]: () => { - const { task, today, targetIndex, newDay, prevDay, targetTaskId } = - action as ReturnType; - return handleTransferTask( - state, - task, - today, - targetIndex, - newDay, - prevDay, - targetTaskId, - ); - }, - [PlannerActions.planTaskForDay.type]: () => { - const { task, day, isAddToTop } = action as ReturnType< - typeof PlannerActions.planTaskForDay - >; - return handlePlanTaskForDay(state, task, day, isAddToTop || false); - }, - [PlannerActions.moveBeforeTask.type]: () => { - const { fromTask, toTaskId } = action as ReturnType< - typeof PlannerActions.moveBeforeTask - >; - return handleMoveBeforeTask(state, fromTask, toTaskId); - }, -}); - -/** - * Meta-reducer that handles cross-cutting concerns for task, project, and tag state updates - */ -export const taskSharedMetaReducer: MetaReducer = ( - reducer: ActionReducer, -) => { - return (state: unknown, action: Action) => { - if (!state) return reducer(state, action); - - const rootState = state as RootState; - const actionHandlers = createActionHandlers(rootState, action); - const handler = actionHandlers[action.type]; - const updatedState = handler ? handler(rootState) : rootState; - - return reducer(updatedState, action); - }; -}; - -// ============================================================================= -// ACTION HANDLERS -// ============================================================================= - -const handleAddTask = ( - state: RootState, - task: TaskWithTags, - isAddToBottom: boolean, - isAddToBacklog: boolean, -): RootState => { - let updatedState = state; - - // Add task to task state - const newTask: Task = { - ...DEFAULT_TASK, - ...task, - timeSpent: calcTotalTimeSpent(task.timeSpentOnDay || {}), - projectId: task.projectId || '', - }; - updatedState = { - ...updatedState, - [TASK_FEATURE_NAME]: taskAdapter.addOne(newTask, updatedState[TASK_FEATURE_NAME]), - }; - - // Update project if task has projectId - if (task.projectId && state[PROJECT_FEATURE_NAME].entities[task.projectId]) { - const project = getProject(state, task.projectId); - const targetList: ProjectTaskList = - isAddToBacklog && project.isEnableBacklog ? 'backlogTaskIds' : 'taskIds'; - - updatedState = updateProject(updatedState, task.projectId, { - [targetList]: addTaskToList(project[targetList], task.id, isAddToBottom), - }); - } - - // Update tags - only update tags that exist - const tagIdsToUpdate = [ - ...task.tagIds, - ...(task.dueDay === getWorklogStr() ? [TODAY_TAG.id] : []), - ].filter((tagId) => state[TAG_FEATURE_NAME].entities[tagId]); - - const tagUpdates = tagIdsToUpdate.map( - (tagId): Update => ({ - id: tagId, - changes: { - taskIds: addTaskToList(getTag(state, tagId).taskIds, task.id, isAddToBottom), - }, - }), - ); - - return updateTags(updatedState, tagUpdates); -}; - -const handleConvertToMainTask = ( - state: RootState, - task: Task, - parentTagIds: string[], - isPlanForToday?: boolean, -): RootState => { - // First, get the parent task to copy its properties - const parentTask = state[TASK_FEATURE_NAME].entities[task.parentId as string] as Task; - if (!parentTask) { - throw new Error('No parent for sub task'); - } - - // Handle parent-child relationship cleanup and task entity updates - const taskStateAfterParentCleanup = removeTaskFromParentSideEffects( - state[TASK_FEATURE_NAME], - task as Task, - ); - - const updatedTaskState = taskAdapter.updateOne( - { - id: task.id, - changes: { - parentId: undefined, - tagIds: [...parentTask.tagIds], - ...(isPlanForToday ? { dueDay: getWorklogStr() } : {}), - }, - }, - taskStateAfterParentCleanup, - ); - - let updatedState = { - ...state, - [TASK_FEATURE_NAME]: updatedTaskState, - }; - - // Update project if task has projectId - if (task.projectId && state[PROJECT_FEATURE_NAME].entities[task.projectId]) { - const project = getProject(state, task.projectId); - updatedState = updateProject(updatedState, task.projectId, { - taskIds: [task.id, ...project.taskIds], - }); - } - - // Update tags - only update tags that exist - const tagIdsToUpdate = [ - ...parentTagIds, - ...(isPlanForToday ? [TODAY_TAG.id] : []), - ].filter((tagId) => state[TAG_FEATURE_NAME].entities[tagId]); - const tagUpdates = tagIdsToUpdate.map( - (tagId): Update => ({ - id: tagId, - changes: { - taskIds: [task.id, ...getTag(updatedState, tagId).taskIds], - }, - }), - ); - - return updateTags(updatedState, tagUpdates); -}; - -const handleDeleteTask = ( - state: RootState, - task: { - id: string; - projectId?: string | null; - tagIds: string[]; - subTasks?: Task[]; - subTaskIds?: string[]; - }, -): RootState => { - let updatedState = state; - - // Delete task from task state using helper - updatedState = { - ...updatedState, - [TASK_FEATURE_NAME]: deleteTaskHelper( - updatedState[TASK_FEATURE_NAME], - task as TaskWithSubTasks, - ), - }; - - // Update project if task has projectId - if (task.projectId && state[PROJECT_FEATURE_NAME].entities[task.projectId]) { - const project = getProject(state, task.projectId); - updatedState = updateProject(updatedState, task.projectId, { - taskIds: removeTasksFromList(project.taskIds, [task.id]), - backlogTaskIds: removeTasksFromList(project.backlogTaskIds, [task.id]), - }); - } - - // Update tags - collect all affected tags and tasks to remove - // Only include tags that actually exist in the state to prevent errors - const potentialTagIds = [ - TODAY_TAG.id, // always check today list - ...task.tagIds, - ...(task.subTasks || []).flatMap((st) => st.tagIds || []), - ]; - - const affectedTagIds = unique( - potentialTagIds.filter((tagId) => state[TAG_FEATURE_NAME].entities[tagId]), - ); - - const taskIdsToRemove = [task.id, ...(task.subTaskIds || [])]; - - const tagUpdates = affectedTagIds.map( - (tagId): Update => ({ - id: tagId, - changes: { - taskIds: removeTasksFromList(getTag(state, tagId).taskIds, taskIdsToRemove), - }, - }), - ); - - return updateTags(updatedState, tagUpdates); -}; - -const handleDeleteTasks = (state: RootState, taskIds: string[]): RootState => { - let updatedState = state; - - // Get all task IDs including subtasks - const allIds = taskIds.reduce((acc: string[], id: string) => { - const task = state[TASK_FEATURE_NAME].entities[id] as Task; - return task ? [...acc, id, ...task.subTaskIds] : [...acc, id]; - }, []); - - // Remove tasks from task state - const newTaskState = taskAdapter.removeMany(allIds, updatedState[TASK_FEATURE_NAME]); - updatedState = { - ...updatedState, - [TASK_FEATURE_NAME]: { - ...newTaskState, - currentTaskId: - newTaskState.currentTaskId && taskIds.includes(newTaskState.currentTaskId) - ? null - : newTaskState.currentTaskId, - }, - }; - - // Only update tags that actually contain at least one of the tasks being deleted - const affectedTags = (state[TAG_FEATURE_NAME].ids as string[]).filter((tagId) => { - const tag = state[TAG_FEATURE_NAME].entities[tagId]; - if (!tag) return false; - return taskIds.some((taskId) => tag.taskIds.includes(taskId)); - }); - - const tagUpdates = affectedTags.map( - (tagId): Update => ({ - id: tagId, - changes: { - taskIds: removeTasksFromList(getTag(state, tagId).taskIds, taskIds), - }, - }), - ); - - return updateTags(updatedState, tagUpdates); -}; - -const handleMoveToArchive = (state: RootState, tasks: TaskWithSubTasks[]): RootState => { - const taskIdsToArchive = tasks.flatMap((t) => [t.id, ...t.subTasks.map((st) => st.id)]); - - // Update projects - const projectIds = unique( - tasks.map((t) => t.projectId).filter((pid): pid is string => !!pid), - ); - - let updatedState = state; - - if (projectIds.length > 0) { - const projectUpdates = projectIds.map((pid): Update => { - const project = getProject(state, pid); - return { - id: pid, - changes: { - taskIds: removeTasksFromList(project.taskIds, taskIdsToArchive), - backlogTaskIds: removeTasksFromList(project.backlogTaskIds, taskIdsToArchive), - }, - }; - }); - - updatedState = { - ...updatedState, - [PROJECT_FEATURE_NAME]: projectAdapter.updateMany( - projectUpdates, - updatedState[PROJECT_FEATURE_NAME], - ), - }; - } - - // Update tags - const affectedTagIds = unique([ - TODAY_TAG.id, // always cleanup today tag - ...tasks.flatMap((t) => [...t.tagIds, ...t.subTasks.flatMap((st) => st.tagIds)]), - ]); - - const tagUpdates = affectedTagIds.map( - (tagId): Update => ({ - id: tagId, - changes: { - taskIds: removeTasksFromList(getTag(state, tagId).taskIds, taskIdsToArchive), - }, - }), - ); - - return updateTags(updatedState, tagUpdates); -}; - -const handleRestoreTask = ( - state: RootState, - task: TaskEntity, - subTasks: Task[], -): RootState => { - // First, restore the task entities with proper state - const restoredTask = { - ...task, - isDone: false, - doneOn: undefined, - }; - - const updatedTaskState = taskAdapter.addMany( - [restoredTask as Task, ...subTasks], - state[TASK_FEATURE_NAME], - ); - - let updatedState = { - ...state, - [TASK_FEATURE_NAME]: updatedTaskState, - }; - - // Update project if task has projectId - if (task.projectId) { - const project = getProject(state, task.projectId); - updatedState = updateProject(updatedState, task.projectId, { - taskIds: unique([...project.taskIds, task.id]), - }); - } - - // Update tags - group tasks by tagId - const allTasks = [task, ...subTasks]; - const tagTaskMap = allTasks.reduce( - (map, t) => { - (t.tagIds || []).forEach((tagId) => { - if (!map[tagId]) map[tagId] = []; - map[tagId].push(t.id); - }); - return map; - }, - {} as Record, - ); - - const tagUpdates = Object.entries(tagTaskMap) - .filter(([tagId]) => state[TAG_FEATURE_NAME].entities[tagId]) // Only update existing tags - .map( - ([tagId, taskIds]): Update => ({ - id: tagId, - changes: { - taskIds: unique([...getTag(updatedState, tagId).taskIds, ...taskIds]), - }, - }), - ); - - return updateTags(updatedState, tagUpdates); -}; - -const handleScheduleTaskWithTime = ( - state: RootState, - task: { id: string }, - dueWithTime: number, -): RootState => { - // Check if task already has the same dueWithTime - const currentTask = state[TASK_FEATURE_NAME].entities[task.id] as Task; - if (!currentTask) { - return state; - } - - const todayTag = getTag(state, TODAY_TAG.id); - const isScheduledForToday = isToday(dueWithTime); - const isCurrentlyInToday = todayTag.taskIds.includes(task.id); - - // If task is already correctly scheduled, don't change state - if ( - currentTask.dueWithTime === dueWithTime && - isScheduledForToday === isCurrentlyInToday - ) { - return state; - } - - // First, update the task entity with the scheduling data - const updatedState = { - ...state, - [TASK_FEATURE_NAME]: taskAdapter.updateOne( - { - id: task.id, - changes: { - dueWithTime, - dueDay: undefined, - }, - }, - state[TASK_FEATURE_NAME], - ), - }; - - // No tag change needed - if (isScheduledForToday === isCurrentlyInToday) { - return updatedState; - } - - const newTaskIds = isScheduledForToday - ? unique([task.id, ...todayTag.taskIds]) // Add to top, prevent duplicates - : todayTag.taskIds.filter((id) => id !== task.id); // Remove - - return updateTags(updatedState, [ - { - id: TODAY_TAG.id, - changes: { taskIds: newTaskIds }, - }, - ]); -}; - -const handleUnScheduleTask = (state: RootState, taskId: string): RootState => { - // First, update the task entity to clear scheduling data - const updatedState = { - ...state, - [TASK_FEATURE_NAME]: taskAdapter.updateOne( - { - id: taskId, - changes: { - dueDay: undefined, - dueWithTime: undefined, - }, - }, - state[TASK_FEATURE_NAME], - ), - }; - - // Then, handle today tag updates - const todayTag = getTag(updatedState, TODAY_TAG.id); - - if (!todayTag.taskIds.includes(taskId)) { - return updatedState; - } - - return updateTags(updatedState, [ - { - id: TODAY_TAG.id, - changes: { - taskIds: todayTag.taskIds.filter((id) => id !== taskId), - }, - }, - ]); -}; - -const handleUpdateTask = ( - state: RootState, - taskUpdate: Update, - isIgnoreShortSyntax?: boolean, -): RootState => { - const taskId = taskUpdate.id as string; - const currentTask = state[TASK_FEATURE_NAME].entities[taskId] as Task; - - if (!currentTask) { - return state; - } - - let updatedState = state; - - // Handle tag changes if tagIds are being updated - if (taskUpdate.changes.tagIds) { - const oldTagIds = currentTask.tagIds; - const newTagIds = taskUpdate.changes.tagIds; - - updatedState = handleTagUpdates(updatedState, taskId, oldTagIds, newTagIds); - } - - // Handle task state updates using existing task reducer logic - let taskState = updatedState[TASK_FEATURE_NAME]; - const { timeSpentOnDay, timeEstimate } = taskUpdate.changes; - - taskState = timeSpentOnDay - ? updateTimeSpentForTask(taskId, timeSpentOnDay, taskState) - : taskState; - taskState = updateTimeEstimateForTask(taskUpdate, timeEstimate, taskState); - taskState = updateDoneOnForTask(taskUpdate, taskState); - taskState = taskAdapter.updateOne(taskUpdate, taskState); - - return { - ...updatedState, - [TASK_FEATURE_NAME]: taskState, - }; -}; - -const handleTagUpdates = ( - state: RootState, - taskId: string, - oldTagIds: string[], - newTagIds: string[], -): RootState => { - const tagsToRemoveFrom = oldTagIds - .filter((oldId) => !newTagIds.includes(oldId)) - .filter((tagId) => state[TAG_FEATURE_NAME].entities[tagId]); // Only existing tags - const tagsToAddTo = newTagIds - .filter((newId) => !oldTagIds.includes(newId)) - .filter((tagId) => state[TAG_FEATURE_NAME].entities[tagId]); // Only existing tags - - const removeUpdates = tagsToRemoveFrom.map( - (tagId): Update => ({ - id: tagId, - changes: { - taskIds: getTag(state, tagId).taskIds.filter((id) => id !== taskId), - }, - }), - ); - - const addUpdates = tagsToAddTo.map( - (tagId): Update => ({ - id: tagId, - changes: { - taskIds: unique([taskId, ...getTag(state, tagId).taskIds]), - }, - }), - ); - - return updateTags(state, [...removeUpdates, ...addUpdates]); -}; - -const handleMoveToOtherProject = ( - state: RootState, - task: TaskWithSubTasks, - targetProjectId: string, -): RootState => { - const currentProjectId = task.projectId; - const allTaskIds = [task.id, ...task.subTaskIds]; - - let updatedState = state; - - // Remove tasks from current project if it exists - if (currentProjectId && state[PROJECT_FEATURE_NAME].entities[currentProjectId]) { - const currentProject = getProject(state, currentProjectId); - updatedState = updateProject(updatedState, currentProjectId, { - taskIds: removeTasksFromList(currentProject.taskIds, allTaskIds), - backlogTaskIds: removeTasksFromList(currentProject.backlogTaskIds, allTaskIds), - }); - } - - // Add tasks to target project - if (state[PROJECT_FEATURE_NAME].entities[targetProjectId]) { - const targetProject = getProject(updatedState, targetProjectId); - // Add all tasks to the regular task list (not backlog) when moving projects - // This ensures tasks are visible in the new project - updatedState = updateProject(updatedState, targetProjectId, { - taskIds: unique([...targetProject.taskIds, ...allTaskIds]), - }); - } - - // Update all tasks with new projectId - const taskUpdates: Update[] = allTaskIds.map((id) => ({ - id, - changes: { - projectId: targetProjectId, - }, - })); - - updatedState = { - ...updatedState, - [TASK_FEATURE_NAME]: taskAdapter.updateMany( - taskUpdates, - updatedState[TASK_FEATURE_NAME], - ), - }; - - return updatedState; -}; - -const handleDeleteProject = (state: RootState, allTaskIds: string[]): RootState => { - const tagUpdates = (state[TAG_FEATURE_NAME].ids as string[]).map( - (tagId): Update => ({ - id: tagId, - changes: { - taskIds: removeTasksFromList(getTag(state, tagId).taskIds, allTaskIds), - }, - }), - ); - - return updateTags(state, tagUpdates); -}; - -const handlePlanTasksForToday = ( - state: RootState, - taskIds: string[], - parentTaskMap: Record, -): RootState => { - const todayTag = getTag(state, TODAY_TAG.id); - const today = getWorklogStr(); - - // Filter out tasks that are already in today or whose parent is in today - const newTasksForToday = taskIds.filter((taskId) => { - if (todayTag.taskIds.includes(taskId)) return false; - const parentId = parentTaskMap[taskId]; - return !parentId || !todayTag.taskIds.includes(parentId); - }); - - // First, update the task entities with dueDay - const taskUpdates: Update[] = taskIds.map((taskId) => ({ - id: taskId, - changes: { - dueDay: today, - }, - })); - - const updatedState = { - ...state, - [TASK_FEATURE_NAME]: taskAdapter.updateMany(taskUpdates, state[TASK_FEATURE_NAME]), - }; - - // Then, update the today tag - return updateTags(updatedState, [ - { - id: TODAY_TAG.id, - changes: { - taskIds: unique([...newTasksForToday, ...todayTag.taskIds]), - }, - }, - ]); -}; - -const handleRemoveTasksFromTodayTag = ( - state: RootState, - taskIds: string[], -): RootState => { - const todayTag = getTag(state, TODAY_TAG.id); - - return updateTags(state, [ - { - id: TODAY_TAG.id, - changes: { - taskIds: removeTasksFromList(todayTag.taskIds, taskIds), - }, - }, - ]); -}; - -const handleRemoveTagsForAllTasks = ( - state: RootState, - tagIdsToRemove: string[], -): RootState => { - const taskState = state[TASK_FEATURE_NAME]; - - // Update all tasks to remove the specified tags - const taskUpdates: Update[] = []; - Object.values(taskState.entities).forEach((task) => { - if (task && task.tagIds && task.tagIds.some((id) => tagIdsToRemove.includes(id))) { - taskUpdates.push({ - id: task.id, - changes: { - tagIds: task.tagIds.filter((id) => !tagIdsToRemove.includes(id)), - }, - }); - } - }); - - return { - ...state, - [TASK_FEATURE_NAME]: taskAdapter.updateMany(taskUpdates, taskState), - }; -}; - -const handleTransferTask = ( - state: RootState, - task: Task, - today: string, - targetIndex: number, - newDay: string, - prevDay: string, - targetTaskId?: string, -): RootState => { - const todayTag = getTag(state, TODAY_TAG.id); - - if (prevDay === today && newDay !== today) { - return updateTags(state, [ - { - id: TODAY_TAG.id, - changes: { - taskIds: todayTag.taskIds.filter((id) => id !== task.id), - }, - }, - ]); - } - - if (prevDay !== today && newDay === today) { - const taskIds = [...todayTag.taskIds]; - const targetIndexToUse = targetTaskId - ? todayTag.taskIds.findIndex((id) => id === targetTaskId) - : targetIndex; - taskIds.splice(targetIndexToUse, 0, task.id); - - return updateTags(state, [ - { - id: TODAY_TAG.id, - changes: { - taskIds: unique(taskIds), - }, - }, - ]); - } - - return state; -}; - -const handlePlanTaskForDay = ( - state: RootState, - task: Task, - day: string, - isAddToTop: boolean, -): RootState => { - const todayStr = getWorklogStr(); - const todayTag = getTag(state, TODAY_TAG.id); - - if (day === todayStr && !todayTag.taskIds.includes(task.id)) { - return updateTags(state, [ - { - id: todayTag.id, - changes: { - taskIds: unique( - isAddToTop - ? [task.id, ...todayTag.taskIds] - : [...todayTag.taskIds.filter((tid) => tid !== task.id), task.id], - ), - }, - }, - ]); - } else if (day !== todayStr && todayTag.taskIds.includes(task.id)) { - return updateTags(state, [ - { - id: todayTag.id, - changes: { - taskIds: todayTag.taskIds.filter((id) => id !== task.id), - }, - }, - ]); - } - - return state; -}; - -const handleMoveBeforeTask = ( - state: RootState, - fromTask: Task, - toTaskId: string, -): RootState => { - const todayTag = getTag(state, TODAY_TAG.id); - - if (todayTag.taskIds.includes(toTaskId)) { - const taskIds = todayTag.taskIds.filter((id) => id !== fromTask.id); - const targetIndex = taskIds.indexOf(toTaskId); - taskIds.splice(targetIndex, 0, fromTask.id); - - return updateTags(state, [ - { - id: todayTag.id, - changes: { - taskIds: unique(taskIds), - }, - }, - ]); - } else if (todayTag.taskIds.includes(fromTask.id)) { - return updateTags(state, [ - { - id: todayTag.id, - changes: { - taskIds: todayTag.taskIds.filter((id) => id !== fromTask.id), - }, - }, - ]); - } - - return state; -}; - -const handleMoveTaskInTodayTagList = ( - state: RootState, - toTaskId: string, - fromTaskId: string, -): RootState => { - const todayTag = getTag(state, TODAY_TAG.id); - - // If either task is not in the Today list, don't perform the move - if (!todayTag.taskIds.includes(fromTaskId) || !todayTag.taskIds.includes(toTaskId)) { - return state; - } - - return updateTags(state, [ - { - id: todayTag.id, - changes: { - taskIds: moveItemBeforeItem(todayTag.taskIds, fromTaskId, toTaskId), - }, - }, - ]); -}; - -// ============================================================================= -// SHARED HELPER FUNCTIONS -// ============================================================================= - -// State update helpers -const updateProject = ( - state: RootState, - projectId: string, - changes: Partial, -): RootState => ({ - ...state, - [PROJECT_FEATURE_NAME]: projectAdapter.updateOne( - { id: projectId, changes }, - state[PROJECT_FEATURE_NAME], - ), -}); - -const updateTags = (state: RootState, updates: Update[]): RootState => ({ - ...state, - [TAG_FEATURE_NAME]: tagAdapter.updateMany(updates, state[TAG_FEATURE_NAME]), -}); - -// Entity getters -const getTag = (state: RootState, tagId: string): Tag => - state[TAG_FEATURE_NAME].entities[tagId] as Tag; - -const getProject = (state: RootState, projectId: string): Project => - state[PROJECT_FEATURE_NAME].entities[projectId] as Project; - -// List manipulation helpers -const addTaskToList = ( - taskIds: string[], - taskId: string, - isAddToBottom: boolean, -): string[] => (isAddToBottom ? [...taskIds, taskId] : [taskId, ...taskIds]); - -const removeTasksFromList = (taskIds: string[], toRemove: string[]): string[] => - taskIds.filter((id) => !toRemove.includes(id)); - -// Today tag specific helpers (currently unused but kept for potential future use) -// const addTaskToTodayTag = (state: RootState, taskId: string, isAddToTop = false): RootState => { -// const todayTag = getTag(state, TODAY_TAG.id); -// const newTaskIds = isAddToTop -// ? addToTopOfList(todayTag.taskIds, taskId) -// : addToBottomOfList(todayTag.taskIds, taskId); -// return updateTodayTag(state, newTaskIds); -// }; - -// const removeTaskFromTodayTag = (state: RootState, taskId: string): RootState => { -// const todayTag = getTag(state, TODAY_TAG.id); -// return updateTodayTag(state, removeTasksFromList(todayTag.taskIds, [taskId])); -// }; - -// const isTaskInTodayTag = (state: RootState, taskId: string): boolean => -// getTag(state, TODAY_TAG.id).taskIds.includes(taskId);