diff --git a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts index e2291dde4d..d53244e306 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts @@ -1442,4 +1442,322 @@ describe('lwwUpdateMetaReducer', () => { expect(todayTag.taskIds).not.toContain(TASK_ID); }); }); + + describe('parent.subTaskIds sync on task parentId change', () => { + const PARENT_A = 'parent-a'; + const PARENT_B = 'parent-b'; + const SUBTASK_ID = 'subtask-1'; + + const createStateWithParents = ( + subtaskParentId: string | undefined, + parentASubTaskIds: string[], + parentBSubTaskIds: string[], + ): Partial => + ({ + [TASK_FEATURE_NAME]: { + ids: [SUBTASK_ID, PARENT_A, PARENT_B], + entities: { + [SUBTASK_ID]: createMockTask({ + id: SUBTASK_ID, + parentId: subtaskParentId, + subTaskIds: [], + }), + [PARENT_A]: createMockTask({ + id: PARENT_A, + parentId: undefined, + subTaskIds: parentASubTaskIds, + }), + [PARENT_B]: createMockTask({ + id: PARENT_B, + parentId: undefined, + subTaskIds: parentBSubTaskIds, + }), + }, + currentTaskId: null, + selectedTaskId: null, + taskDetailTargetPanel: null, + isDataLoaded: true, + lastCurrentTaskId: null, + }, + [PROJECT_FEATURE_NAME]: { + ids: [], + entities: {}, + }, + [TAG_FEATURE_NAME]: { + ids: [], + entities: {}, + }, + }) as Partial; + + it('should move subtask from old parent to new parent when parentId changes', () => { + // Subtask is under Parent A, LWW update moves it to Parent B + const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []); + const action = { + type: '[TASK] LWW Update', + id: SUBTASK_ID, + parentId: PARENT_B, + title: 'Updated Subtask', + meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID }, + }; + + reducer(state, action); + + const updatedState = mockReducer.calls.mostRecent().args[0] as Partial; + const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task; + const parentB = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_B] as Task; + + expect(parentA.subTaskIds).not.toContain(SUBTASK_ID); + expect(parentB.subTaskIds).toContain(SUBTASK_ID); + }); + + it('should add subtask to parent.subTaskIds when task becomes subtask', () => { + // Task is top-level, LWW update makes it a subtask of Parent A + const state = createStateWithParents(undefined, [], []); + const action = { + type: '[TASK] LWW Update', + id: SUBTASK_ID, + parentId: PARENT_A, + title: 'Now a subtask', + meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID }, + }; + + reducer(state, action); + + const updatedState = mockReducer.calls.mostRecent().args[0] as Partial; + const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task; + + expect(parentA.subTaskIds).toContain(SUBTASK_ID); + }); + + it('should remove subtask from parent.subTaskIds when parentId is removed', () => { + // Subtask is under Parent A, LWW update makes it a top-level task + const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []); + const action = { + type: '[TASK] LWW Update', + id: SUBTASK_ID, + parentId: null, + title: 'Now a top-level task', + meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID }, + }; + + reducer(state, action); + + const updatedState = mockReducer.calls.mostRecent().args[0] as Partial; + const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task; + + expect(parentA.subTaskIds).not.toContain(SUBTASK_ID); + }); + + it('should not duplicate subtask in parent.subTaskIds if already present', () => { + // Subtask already in Parent B's subTaskIds (edge case) + const state = createStateWithParents(PARENT_A, [SUBTASK_ID], [SUBTASK_ID]); + const action = { + type: '[TASK] LWW Update', + id: SUBTASK_ID, + parentId: PARENT_B, + title: 'Updated Subtask', + meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID }, + }; + + reducer(state, action); + + const updatedState = mockReducer.calls.mostRecent().args[0] as Partial; + const parentB = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_B] as Task; + + // Should still only have one instance + expect(parentB.subTaskIds.filter((id) => id === SUBTASK_ID).length).toBe(1); + }); + + it('should not update parent.subTaskIds when parentId does not change', () => { + const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []); + const action = { + type: '[TASK] LWW Update', + id: SUBTASK_ID, + parentId: PARENT_A, // Same as existing + title: 'Updated Title Only', + meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID }, + }; + + reducer(state, action); + + const updatedState = mockReducer.calls.mostRecent().args[0] as Partial; + const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task; + const parentB = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_B] as Task; + + // Parent A should still have the subtask + expect(parentA.subTaskIds).toContain(SUBTASK_ID); + // Parent B should still be empty + expect(parentB.subTaskIds).not.toContain(SUBTASK_ID); + }); + + it('should handle parentId change when old parent does not exist', () => { + // Old parent was deleted, new parent exists + const state = { + [TASK_FEATURE_NAME]: { + ids: [SUBTASK_ID, PARENT_B], + entities: { + [SUBTASK_ID]: createMockTask({ + id: SUBTASK_ID, + parentId: 'deleted-parent', + subTaskIds: [], + }), + [PARENT_B]: createMockTask({ + id: PARENT_B, + parentId: undefined, + subTaskIds: [], + }), + }, + currentTaskId: null, + selectedTaskId: null, + taskDetailTargetPanel: null, + isDataLoaded: true, + lastCurrentTaskId: null, + }, + [PROJECT_FEATURE_NAME]: { + ids: [], + entities: {}, + }, + [TAG_FEATURE_NAME]: { + ids: [], + entities: {}, + }, + } as Partial; + + const action = { + type: '[TASK] LWW Update', + id: SUBTASK_ID, + parentId: PARENT_B, + title: 'Updated Subtask', + meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID }, + }; + + // Should not throw + reducer(state, action); + + const updatedState = mockReducer.calls.mostRecent().args[0] as Partial; + const parentB = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_B] as Task; + + expect(parentB.subTaskIds).toContain(SUBTASK_ID); + }); + + it('should handle parentId change when new parent does not exist', () => { + // New parent doesn't exist (edge case) + const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []); + const action = { + type: '[TASK] LWW Update', + id: SUBTASK_ID, + parentId: 'non-existent-parent', + title: 'Updated Subtask', + meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID }, + }; + + // Should not throw + reducer(state, action); + + const updatedState = mockReducer.calls.mostRecent().args[0] as Partial; + const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task; + + // Old parent should have subtask removed + expect(parentA.subTaskIds).not.toContain(SUBTASK_ID); + }); + + it('should preserve parent.subTaskIds order when adding subtask', () => { + const existingSubtasks = ['sub-a', 'sub-b', 'sub-c']; + const state = { + [TASK_FEATURE_NAME]: { + ids: [SUBTASK_ID, PARENT_A], + entities: { + [SUBTASK_ID]: createMockTask({ + id: SUBTASK_ID, + parentId: undefined, + subTaskIds: [], + }), + [PARENT_A]: createMockTask({ + id: PARENT_A, + parentId: undefined, + subTaskIds: existingSubtasks, + }), + }, + currentTaskId: null, + selectedTaskId: null, + taskDetailTargetPanel: null, + isDataLoaded: true, + lastCurrentTaskId: null, + }, + [PROJECT_FEATURE_NAME]: { + ids: [], + entities: {}, + }, + [TAG_FEATURE_NAME]: { + ids: [], + entities: {}, + }, + } as Partial; + + const action = { + type: '[TASK] LWW Update', + id: SUBTASK_ID, + parentId: PARENT_A, + title: 'Becoming a subtask', + meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID }, + }; + + reducer(state, action); + + const updatedState = mockReducer.calls.mostRecent().args[0] as Partial; + const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task; + + // Original order should be preserved, new subtask appended at end + expect(parentA.subTaskIds).toEqual([...existingSubtasks, SUBTASK_ID]); + }); + + it('should preserve parent.subTaskIds order when removing subtask', () => { + const state = { + [TASK_FEATURE_NAME]: { + ids: [SUBTASK_ID, PARENT_A], + entities: { + [SUBTASK_ID]: createMockTask({ + id: SUBTASK_ID, + parentId: PARENT_A, + subTaskIds: [], + }), + [PARENT_A]: createMockTask({ + id: PARENT_A, + parentId: undefined, + subTaskIds: ['first', SUBTASK_ID, 'last'], + }), + }, + currentTaskId: null, + selectedTaskId: null, + taskDetailTargetPanel: null, + isDataLoaded: true, + lastCurrentTaskId: null, + }, + [PROJECT_FEATURE_NAME]: { + ids: [], + entities: {}, + }, + [TAG_FEATURE_NAME]: { + ids: [], + entities: {}, + }, + } as Partial; + + const action = { + type: '[TASK] LWW Update', + id: SUBTASK_ID, + parentId: null, // Becoming top-level + title: 'No longer a subtask', + meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID }, + }; + + reducer(state, action); + + const updatedState = mockReducer.calls.mostRecent().args[0] as Partial; + const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task; + + // Order should be preserved after removal + expect(parentA.subTaskIds).toEqual(['first', 'last']); + }); + }); }); diff --git a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts index 5049b806f2..8d66ce6c8d 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts @@ -11,6 +11,11 @@ import { Project } from '../../../features/project/project.model'; import { TAG_FEATURE_NAME, tagAdapter } from '../../../features/tag/store/tag.reducer'; import { Tag } from '../../../features/tag/tag.model'; import { TODAY_TAG } from '../../../features/tag/tag.const'; +import { + TASK_FEATURE_NAME, + taskAdapter, +} from '../../../features/tasks/store/task.reducer'; +import { Task } from '../../../features/tasks/task.model'; import { unique } from '../../../util/unique'; import { getDbDateStr } from '../../../util/get-db-date-str'; @@ -240,6 +245,71 @@ const syncTodayTagTaskIds = ( }; }; +/** + * Updates parent task's subTaskIds arrays when a task's parentId changes via LWW Update. + * + * When LWW conflict resolution updates a task's parentId (making it a subtask or + * moving it to a different parent), we must also update the corresponding parent + * task's subTaskIds arrays to maintain bidirectional consistency: + * - Remove task from old parent's subTaskIds (if it was a subtask) + * - Add task to new parent's subTaskIds (if it becomes a subtask) + * + * This is necessary because the original moveToOtherProject or convertToSubtask + * actions update both the task and parent entities atomically, but LWW Update + * only syncs the TASK entity state. + */ +const syncParentSubTaskIds = ( + state: RootState, + taskId: string, + oldParentId: string | undefined, + newParentId: string | undefined, +): RootState => { + // If parentId didn't change, nothing to do + if (oldParentId === newParentId) { + return state; + } + + let taskState = state[TASK_FEATURE_NAME]; + + // Remove from old parent's subTaskIds + if (oldParentId && taskState.entities[oldParentId]) { + const oldParent = taskState.entities[oldParentId] as Task; + if (oldParent.subTaskIds.includes(taskId)) { + taskState = taskAdapter.updateOne( + { + id: oldParentId, + changes: { + subTaskIds: oldParent.subTaskIds.filter((id) => id !== taskId), + }, + }, + taskState, + ); + } + } + + // Add to new parent's subTaskIds + if (newParentId && taskState.entities[newParentId]) { + const newParent = taskState.entities[newParentId] as Task; + // Only add if not already present + if (!newParent.subTaskIds.includes(taskId)) { + taskState = taskAdapter.updateOne( + { + id: newParentId, + changes: { + subTaskIds: unique([...newParent.subTaskIds, taskId]), + }, + }, + taskState, + ); + } + } + + return { + ...state, + [TASK_FEATURE_NAME]: taskState, + }; +}; + /** * Meta-reducer that handles LWW (Last-Write-Wins) Update actions. * @@ -377,6 +447,16 @@ export const lwwUpdateMetaReducer: MetaReducer = ( const oldDueDay = existingEntity?.dueDay as string | undefined; const newDueDay = entityData['dueDay'] as string | undefined; updatedState = syncTodayTagTaskIds(updatedState, entityId, oldDueDay, newDueDay); + + // Sync parent.subTaskIds when parentId changes + const oldParentId = existingEntity?.parentId as string | undefined; + const newParentId = entityData['parentId'] as string | undefined; + updatedState = syncParentSubTaskIds( + updatedState, + entityId, + oldParentId, + newParentId, + ); } return reducer(updatedState, action);