mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(sync): preserve partial LWW relationships
This commit is contained in:
parent
24725c9360
commit
c5080af1e9
4 changed files with 315 additions and 67 deletions
|
|
@ -335,6 +335,33 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
expect(recreated.projectId).toBe('INBOX_PROJECT');
|
||||
});
|
||||
|
||||
it('should add recreated task to backfilled project taskIds when partial TASK LWW Update lacks projectId', () => {
|
||||
const state = createMockState();
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: 'rpt_no_project_visible',
|
||||
dueDay: '2026-04-29',
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK',
|
||||
entityId: 'rpt_no_project_visible',
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const recreated = updatedState[TASK_FEATURE_NAME]?.entities[
|
||||
'rpt_no_project_visible'
|
||||
] as Task;
|
||||
const inboxProject = updatedState[PROJECT_FEATURE_NAME]?.entities[
|
||||
INBOX_PROJECT.id
|
||||
] as Project;
|
||||
|
||||
expect(recreated.projectId).toBe(INBOX_PROJECT.id);
|
||||
expect(inboxProject.taskIds).toContain('rpt_no_project_visible');
|
||||
});
|
||||
|
||||
// #7330 follow-up #2: a producer that emits `title: null` (rather than
|
||||
// omitting it) used to slip past the partial-keys check (=== undefined)
|
||||
// AND have `null` overwrite the DEFAULT_TASK default in the spread.
|
||||
|
|
@ -1380,6 +1407,59 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
// Project B should still be empty
|
||||
expect(projectB.taskIds).not.toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it('should preserve project.taskIds when projectId is omitted from partial LWW payload', () => {
|
||||
const state = createStateWithProjects(PROJECT_A, [TASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
title: 'Updated Task Title Only',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const projectA = updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A] as Project;
|
||||
|
||||
expect(projectA.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it('should preserve backlog membership when projectId is omitted from partial LWW payload', () => {
|
||||
const state = {
|
||||
...createStateWithProjects(PROJECT_A, [], []),
|
||||
[PROJECT_FEATURE_NAME]: {
|
||||
ids: [PROJECT_A, PROJECT_B],
|
||||
entities: {
|
||||
[PROJECT_A]: createMockProject({
|
||||
id: PROJECT_A,
|
||||
title: 'Project A',
|
||||
taskIds: [],
|
||||
backlogTaskIds: [TASK_ID],
|
||||
}),
|
||||
[PROJECT_B]: createMockProject({
|
||||
id: PROJECT_B,
|
||||
title: 'Project B',
|
||||
taskIds: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as Partial<RootState>;
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
title: 'Updated Backlog Task Title Only',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const projectA = updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A] as Project;
|
||||
|
||||
expect(projectA.taskIds).not.toContain(TASK_ID);
|
||||
expect(projectA.backlogTaskIds).toContain(TASK_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag.taskIds sync on task tagIds change', () => {
|
||||
|
|
@ -1559,6 +1639,25 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
expect(tagC.taskIds).not.toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it('should preserve tag.taskIds when tagIds is omitted from partial LWW payload', () => {
|
||||
const state = createStateWithTags([TAG_A, TAG_B], [TASK_ID], [TASK_ID]);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
title: 'Updated Task Title Only',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const tagA = updatedState[TAG_FEATURE_NAME]?.entities[TAG_A] as Tag;
|
||||
const tagB = updatedState[TAG_FEATURE_NAME]?.entities[TAG_B] as Tag;
|
||||
|
||||
expect(tagA.taskIds).toContain(TASK_ID);
|
||||
expect(tagB.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it('should handle task with no previous tags getting tags', () => {
|
||||
const state = createStateWithTags([], [], []);
|
||||
const action = {
|
||||
|
|
@ -1726,9 +1825,8 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
expect(projectA.taskIds).not.toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should handle subtask becoming orphan (parentId removed via LWW, same project)', () => {
|
||||
it('should add promoted subtask to project.taskIds when parentId is removed in same project', () => {
|
||||
// Subtask loses its parent via LWW update but stays in same project.
|
||||
// Since oldProjectId === newProjectId, syncProjectTaskIds returns early — no taskIds change.
|
||||
const state = {
|
||||
[TASK_FEATURE_NAME]: {
|
||||
ids: [SUBTASK_ID],
|
||||
|
|
@ -1774,8 +1872,61 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
|
||||
// Task should now be a top-level task
|
||||
expect(subtask.parentId).toBeNull();
|
||||
// projectId didn't change (same project), so syncProjectTaskIds exits early
|
||||
expect(projectA.taskIds).toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should remove task from project.taskIds when it becomes a subtask in same project', () => {
|
||||
const state = {
|
||||
[TASK_FEATURE_NAME]: {
|
||||
ids: [SUBTASK_ID, PARENT_TASK],
|
||||
entities: {
|
||||
[SUBTASK_ID]: createMockTask({
|
||||
id: SUBTASK_ID,
|
||||
parentId: undefined,
|
||||
projectId: PROJECT_A,
|
||||
}),
|
||||
[PARENT_TASK]: createMockTask({
|
||||
id: PARENT_TASK,
|
||||
parentId: undefined,
|
||||
projectId: PROJECT_A,
|
||||
subTaskIds: [],
|
||||
}),
|
||||
},
|
||||
currentTaskId: null,
|
||||
selectedTaskId: null,
|
||||
taskDetailTargetPanel: null,
|
||||
isDataLoaded: true,
|
||||
lastCurrentTaskId: null,
|
||||
},
|
||||
[PROJECT_FEATURE_NAME]: {
|
||||
ids: [PROJECT_A],
|
||||
entities: {
|
||||
[PROJECT_A]: createMockProject({ id: PROJECT_A, taskIds: [SUBTASK_ID] }),
|
||||
},
|
||||
},
|
||||
[TAG_FEATURE_NAME]: {
|
||||
ids: [],
|
||||
entities: {},
|
||||
},
|
||||
} as Partial<RootState>;
|
||||
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
parentId: PARENT_TASK,
|
||||
projectId: PROJECT_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<RootState>;
|
||||
const projectA = updatedState[PROJECT_FEATURE_NAME]?.entities[PROJECT_A] as Project;
|
||||
const parent = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_TASK] as Task;
|
||||
|
||||
expect(projectA.taskIds).not.toContain(SUBTASK_ID);
|
||||
expect(parent.subTaskIds).toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should add promoted subtask to new project.taskIds when parentId is cleared and projectId changes', () => {
|
||||
|
|
@ -2330,6 +2481,23 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
|
||||
expect(todayTag.taskIds).not.toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it('should preserve TODAY_TAG.taskIds when due fields are omitted from partial LWW payload', () => {
|
||||
const state = createStateWithTodayTag(TODAY_STR, [TASK_ID]);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
title: 'Updated Task Title Only',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const todayTag = updatedState[TAG_FEATURE_NAME]?.entities[TODAY_TAG.id] as Tag;
|
||||
|
||||
expect(todayTag.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TODAY_TAG.taskIds sync on task dueWithTime change', () => {
|
||||
|
|
@ -2446,6 +2614,23 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
expect(todayTag.taskIds).toEqual([TASK_ID, 'other-task']);
|
||||
});
|
||||
|
||||
it('should preserve TODAY_TAG.taskIds when dueWithTime is omitted from partial LWW payload', () => {
|
||||
const state = createStateWithDueWithTime(undefined, TODAY_TIMESTAMP, [TASK_ID]);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: TASK_ID,
|
||||
title: 'Updated Task Title Only',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: TASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const todayTag = updatedState[TAG_FEATURE_NAME]?.entities[TODAY_TAG.id] as Tag;
|
||||
|
||||
expect(todayTag.taskIds).toContain(TASK_ID);
|
||||
});
|
||||
|
||||
it('should use dueWithTime over dueDay when both present (mutual exclusivity)', () => {
|
||||
// Task has dueDay = today, LWW update sets dueWithTime to tomorrow
|
||||
// dueWithTime should take precedence — task should be removed from today
|
||||
|
|
@ -2640,6 +2825,23 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
expect(parentB.subTaskIds).not.toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should preserve parent.subTaskIds when parentId is omitted from partial LWW payload', () => {
|
||||
const state = createStateWithParents(PARENT_A, [SUBTASK_ID], []);
|
||||
const action = {
|
||||
type: '[TASK] LWW Update',
|
||||
id: SUBTASK_ID,
|
||||
title: 'Updated Subtask Title Only',
|
||||
meta: { isPersistent: true, entityType: 'TASK', entityId: SUBTASK_ID },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const parentA = updatedState[TASK_FEATURE_NAME]?.entities[PARENT_A] as Task;
|
||||
|
||||
expect(parentA.subTaskIds).toContain(SUBTASK_ID);
|
||||
});
|
||||
|
||||
it('should handle parentId change when old parent does not exist', () => {
|
||||
// Old parent was deleted, new parent exists
|
||||
const state = {
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
|
|||
import { isTodayWithOffset } from '../../../util/is-today.util';
|
||||
|
||||
/**
|
||||
* Updates project.taskIds arrays when a task's projectId changes via LWW Update.
|
||||
* Updates project.taskIds arrays when a task's project membership changes via LWW Update.
|
||||
*
|
||||
* When LWW conflict resolution updates a task's projectId, we must also update
|
||||
* the corresponding project.taskIds arrays to maintain bidirectional consistency:
|
||||
* - Remove task from old project's taskIds (if it exists there)
|
||||
* - Add task to new project's taskIds (if not already there)
|
||||
* When LWW conflict resolution updates a task's projectId or parentId, we must
|
||||
* also update the corresponding project.taskIds arrays to maintain consistency:
|
||||
* - Remove task from old project's taskIds if it moved away or became a subtask
|
||||
* - Add task to new project's taskIds if it is now a root task
|
||||
*
|
||||
* This is necessary because the original moveToOtherProject action updates both
|
||||
* the task and project entities atomically, but LWW Update only syncs the TASK
|
||||
|
|
@ -46,22 +46,17 @@ const syncProjectTaskIds = (
|
|||
taskId: string,
|
||||
oldProjectId: string | undefined,
|
||||
newProjectId: string | undefined,
|
||||
isSubTask: boolean,
|
||||
oldIsSubTask: boolean,
|
||||
newIsSubTask: boolean,
|
||||
): RootState => {
|
||||
// Don't add subtasks to project.taskIds - only parent tasks should be there
|
||||
if (isSubTask) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// If projectId didn't change, nothing to do
|
||||
if (oldProjectId === newProjectId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
let projectState = state[PROJECT_FEATURE_NAME];
|
||||
const shouldRemoveFromOldProject =
|
||||
!!oldProjectId && (oldProjectId !== newProjectId || newIsSubTask);
|
||||
const shouldAddToNewProject =
|
||||
!!newProjectId && !newIsSubTask && (oldProjectId !== newProjectId || oldIsSubTask);
|
||||
|
||||
// Remove from old project's taskIds
|
||||
if (oldProjectId && projectState.entities[oldProjectId]) {
|
||||
if (shouldRemoveFromOldProject && oldProjectId && projectState.entities[oldProjectId]) {
|
||||
const oldProject = projectState.entities[oldProjectId] as Project;
|
||||
const filteredTaskIds = oldProject.taskIds.filter((id) => id !== taskId);
|
||||
const filteredBacklogTaskIds = oldProject.backlogTaskIds.filter(
|
||||
|
|
@ -84,7 +79,7 @@ const syncProjectTaskIds = (
|
|||
projectState,
|
||||
);
|
||||
}
|
||||
} else if (oldProjectId) {
|
||||
} else if (shouldRemoveFromOldProject && oldProjectId) {
|
||||
// Old project was deleted before LWW update arrived - benign race condition
|
||||
OpLog.warn(
|
||||
`lwwUpdateMetaReducer: syncProjectTaskIds: old project ${oldProjectId} not found for task ${taskId}`,
|
||||
|
|
@ -92,7 +87,7 @@ const syncProjectTaskIds = (
|
|||
}
|
||||
|
||||
// Add to new project's taskIds
|
||||
if (newProjectId && projectState.entities[newProjectId]) {
|
||||
if (shouldAddToNewProject && newProjectId && projectState.entities[newProjectId]) {
|
||||
const newProject = projectState.entities[newProjectId] as Project;
|
||||
// Only add if not already present
|
||||
if (!newProject.taskIds.includes(taskId)) {
|
||||
|
|
@ -106,17 +101,19 @@ const syncProjectTaskIds = (
|
|||
projectState,
|
||||
);
|
||||
}
|
||||
} else if (newProjectId) {
|
||||
} else if (shouldAddToNewProject && newProjectId) {
|
||||
// New project was deleted before LWW update arrived - benign race condition
|
||||
OpLog.warn(
|
||||
`lwwUpdateMetaReducer: syncProjectTaskIds: new project ${newProjectId} not found for task ${taskId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
[PROJECT_FEATURE_NAME]: projectState,
|
||||
};
|
||||
return projectState === state[PROJECT_FEATURE_NAME]
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
[PROJECT_FEATURE_NAME]: projectState,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -608,38 +605,41 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
...rootState,
|
||||
[featureName]: updatedFeatureState,
|
||||
};
|
||||
const updatedEntity = (
|
||||
updatedFeatureState as unknown as {
|
||||
entities?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
).entities?.[entityId];
|
||||
|
||||
// For TASK entities, sync related entities when relationships change
|
||||
if (entityType === 'TASK') {
|
||||
if (entityType === 'TASK' && updatedEntity) {
|
||||
// Sync project.taskIds when projectId changes
|
||||
const oldProjectId = existingEntity?.projectId as string | undefined;
|
||||
const newProjectId = entityData['projectId'] as string | undefined;
|
||||
// Use the NEW parentId to decide subtask status: if the LWW update
|
||||
// clears parentId (promoting to main task), we should add it to project.taskIds.
|
||||
// Fall back to existing only when the LWW update doesn't include parentId at all.
|
||||
const isSubTask = !!('parentId' in entityData
|
||||
? entityData['parentId']
|
||||
: existingEntity?.parentId);
|
||||
const newProjectId = updatedEntity.projectId as string | undefined;
|
||||
const oldIsSubTask = !!existingEntity?.parentId;
|
||||
const newParentId = updatedEntity.parentId as string | undefined;
|
||||
const newIsSubTask = !!newParentId;
|
||||
|
||||
updatedState = syncProjectTaskIds(
|
||||
updatedState,
|
||||
entityId,
|
||||
oldProjectId,
|
||||
newProjectId,
|
||||
isSubTask,
|
||||
oldIsSubTask,
|
||||
newIsSubTask,
|
||||
);
|
||||
|
||||
// Sync tag.taskIds when tagIds changes
|
||||
const oldTagIds = (existingEntity?.tagIds as string[]) || [];
|
||||
const newTagIds = (entityData['tagIds'] as string[]) || [];
|
||||
const newTagIds = (updatedEntity.tagIds as string[]) || [];
|
||||
|
||||
updatedState = syncTagTaskIds(updatedState, entityId, oldTagIds, newTagIds);
|
||||
|
||||
// Sync TODAY_TAG.taskIds when dueDay or dueWithTime changes (virtual tag based on dueDay/dueWithTime)
|
||||
const oldDueDay = existingEntity?.dueDay as string | undefined;
|
||||
const newDueDay = entityData['dueDay'] as string | undefined;
|
||||
const newDueDay = updatedEntity.dueDay as string | undefined;
|
||||
const oldDueWithTime = existingEntity?.dueWithTime as number | undefined;
|
||||
const newDueWithTime = entityData['dueWithTime'] as number | undefined;
|
||||
const newDueWithTime = updatedEntity.dueWithTime as number | undefined;
|
||||
updatedState = syncTodayTagTaskIds(
|
||||
updatedState,
|
||||
entityId,
|
||||
|
|
@ -651,7 +651,6 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
|
||||
// Sync parent.subTaskIds when parentId changes
|
||||
const oldParentId = existingEntity?.parentId as string | undefined;
|
||||
const newParentId = entityData['parentId'] as string | undefined;
|
||||
updatedState = syncParentSubTaskIds(
|
||||
updatedState,
|
||||
entityId,
|
||||
|
|
|
|||
|
|
@ -466,6 +466,46 @@ describe('validateAndFixDataConsistencyAfterBatchUpdate', () => {
|
|||
expect(tag1?.taskIds).toContain('valid-task');
|
||||
});
|
||||
|
||||
it('should not mutate original tag state when orphan cleanup removes tag references', () => {
|
||||
const state = createStateWithExistingTasks(['orphan-with-tag', 'valid-task']);
|
||||
|
||||
state[TASK_FEATURE_NAME].entities['orphan-with-tag'] = {
|
||||
...state[TASK_FEATURE_NAME].entities['orphan-with-tag']!,
|
||||
parentId: 'non-existent-parent',
|
||||
tagIds: ['tag1'],
|
||||
};
|
||||
|
||||
state[TAG_FEATURE_NAME].entities = {
|
||||
tag1: createMockTag({
|
||||
id: 'tag1',
|
||||
title: 'Tag 1',
|
||||
taskIds: ['orphan-with-tag', 'valid-task'],
|
||||
}),
|
||||
};
|
||||
|
||||
const originalTagState = state[TAG_FEATURE_NAME];
|
||||
const originalTagEntities = state[TAG_FEATURE_NAME].entities;
|
||||
const originalTag = state[TAG_FEATURE_NAME].entities['tag1'];
|
||||
|
||||
const result = validateAndFixDataConsistencyAfterBatchUpdate(
|
||||
state,
|
||||
'project1',
|
||||
[], // tasksToAdd
|
||||
[], // tasksToUpdate
|
||||
[], // taskIdsToDelete
|
||||
null, // newTaskOrder
|
||||
);
|
||||
|
||||
expect(result[TAG_FEATURE_NAME]).not.toBe(originalTagState);
|
||||
expect(result[TAG_FEATURE_NAME].entities).not.toBe(originalTagEntities);
|
||||
expect(result[TAG_FEATURE_NAME].entities['tag1']).not.toBe(originalTag);
|
||||
expect(state[TAG_FEATURE_NAME].entities['tag1']?.taskIds).toEqual([
|
||||
'orphan-with-tag',
|
||||
'valid-task',
|
||||
]);
|
||||
expect(result[TAG_FEATURE_NAME].entities['tag1']?.taskIds).toEqual(['valid-task']);
|
||||
});
|
||||
|
||||
it('should handle cascading deletion of orphaned subtasks', () => {
|
||||
const state = createStateWithExistingTasks(['parent', 'child', 'grandchild']);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,36 @@ const arraysHaveSameElements = (a: string[], b: string[]): boolean => {
|
|||
return b.every((id) => setA.has(id));
|
||||
};
|
||||
|
||||
type TagUpdate = { id: string; changes: Partial<Tag> };
|
||||
|
||||
const applyTagUpdates = (state: RootState, updates: TagUpdate[]): RootState => {
|
||||
if (updates.length === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tagState = {
|
||||
...state[TAG_FEATURE_NAME],
|
||||
entities: {
|
||||
...state[TAG_FEATURE_NAME].entities,
|
||||
},
|
||||
};
|
||||
|
||||
updates.forEach((update) => {
|
||||
const tag = tagState.entities[update.id];
|
||||
if (tag) {
|
||||
tagState.entities[update.id] = {
|
||||
...tag,
|
||||
...update.changes,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
[TAG_FEATURE_NAME]: tagState,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and fixes data consistency across tasks, projects, and tags
|
||||
* Ensures bidirectional consistency: project.taskIds ↔ task.projectId, tag.taskIds ↔ task.tagIds
|
||||
|
|
@ -31,7 +61,6 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
|
|||
taskIdsToDelete: string[],
|
||||
newTaskOrder: string[] | null,
|
||||
): RootState => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let newState = { ...state };
|
||||
|
||||
// Get all affected task IDs for validation
|
||||
|
|
@ -125,7 +154,7 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
|
|||
});
|
||||
|
||||
// Apply tag updates
|
||||
const tagUpdates: { id: string; changes: Partial<Tag> }[] = [];
|
||||
const tagUpdates: TagUpdate[] = [];
|
||||
tagsToUpdate.forEach((taskIds, tagId) => {
|
||||
const currentTag = newState[TAG_FEATURE_NAME].entities[tagId];
|
||||
if (currentTag) {
|
||||
|
|
@ -142,21 +171,7 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
|
|||
}
|
||||
});
|
||||
|
||||
if (tagUpdates.length > 0) {
|
||||
newState[TAG_FEATURE_NAME] = {
|
||||
...newState[TAG_FEATURE_NAME],
|
||||
entities: {
|
||||
...newState[TAG_FEATURE_NAME].entities,
|
||||
},
|
||||
};
|
||||
|
||||
tagUpdates.forEach((update) => {
|
||||
newState[TAG_FEATURE_NAME].entities[update.id] = {
|
||||
...newState[TAG_FEATURE_NAME].entities[update.id]!,
|
||||
...update.changes,
|
||||
};
|
||||
});
|
||||
}
|
||||
newState = applyTagUpdates(newState, tagUpdates);
|
||||
|
||||
// =========================================================================
|
||||
// 3. PARENT-CHILD CONSISTENCY: Validate parent.subTaskIds ↔ child.parentId
|
||||
|
|
@ -319,7 +334,7 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
|
|||
}
|
||||
|
||||
// Clean up non-existent task references from tags
|
||||
const tagCleanupUpdates: { id: string; changes: Partial<Tag> }[] = [];
|
||||
const tagCleanupUpdates: TagUpdate[] = [];
|
||||
const remainingTaskIds = new Set(Object.keys(newState[TASK_FEATURE_NAME].entities));
|
||||
|
||||
Object.values(newState[TAG_FEATURE_NAME].entities).forEach((tag) => {
|
||||
|
|
@ -334,15 +349,7 @@ export const validateAndFixDataConsistencyAfterBatchUpdate = (
|
|||
}
|
||||
});
|
||||
|
||||
// Apply tag cleanup updates
|
||||
if (tagCleanupUpdates.length > 0) {
|
||||
tagCleanupUpdates.forEach((update) => {
|
||||
newState[TAG_FEATURE_NAME].entities[update.id] = {
|
||||
...newState[TAG_FEATURE_NAME].entities[update.id]!,
|
||||
...update.changes,
|
||||
};
|
||||
});
|
||||
}
|
||||
newState = applyTagUpdates(newState, tagCleanupUpdates);
|
||||
|
||||
return newState;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue