diff --git a/e2e/tests/planner/planner-subtask-scheduled-visibility.spec.ts b/e2e/tests/planner/planner-subtask-scheduled-visibility.spec.ts new file mode 100644 index 0000000000..e8af71c94d --- /dev/null +++ b/e2e/tests/planner/planner-subtask-scheduled-visibility.spec.ts @@ -0,0 +1,84 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from '../../fixtures/test.fixture'; + +const QUICK_ACCESS_BUTTON = '.quick-access button'; + +/** + * Schedule a task for a quick-access day (day-only, no time) via the keyboard + * shortcut ('S' opens the schedule dialog for the focused task) and the + * dialog's quick-access buttons (Today=0, Tomorrow=1, Next Week=2, Next + * Month=3). onQuickAccessClick auto-submits, so clicking a day both sets the + * due day and closes the dialog. + * + * The keyboard path is used deliberately: it is independent of whether the task + * is already scheduled (a scheduled task's detail item shows a "Reschedule" + * control instead of "Schedule Task") and of task height (hovering a parent + * with sub-tasks lands on a sub-task row). + */ +const scheduleTaskForQuickDay = async ( + page: Page, + task: Locator, + quickAccessIndex: number, +): Promise => { + await task.scrollIntoViewIfNeeded(); + await task.focus(); + await expect(task).toBeFocused(); + + await page.keyboard.press('s'); + + const scheduleDialog = page.locator('dialog-schedule-task'); + await expect(scheduleDialog).toBeVisible({ timeout: 10000 }); + // No time set -> this creates a date-only dueDay, not a timed dueWithTime. + await expect(scheduleDialog.locator('input[type="time"]')).toHaveValue(''); + + await scheduleDialog.locator(QUICK_ACCESS_BUTTON).nth(quickAccessIndex).click(); + await scheduleDialog.waitFor({ state: 'hidden', timeout: 10000 }); +}; + +const getTomorrowDateString = (): string => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const year = tomorrow.getFullYear(); + const month = String(tomorrow.getMonth() + 1).padStart(2, '0'); + const day = String(tomorrow.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +test.describe('Planner: scheduled subtask visibility (#9019)', () => { + test('keeps a date-only subtask visible on its own day after planning its parent', async ({ + page, + plannerPage, + taskPage, + workViewPage, + }) => { + await workViewPage.waitForTaskList(); + await workViewPage.addTask('Parent 9019'); + + const parentTask = taskPage.getTaskByText('Parent 9019').first(); + await expect(parentTask).toBeVisible(); + await workViewPage.addSubTask(parentTask, 'Sub 9019'); + + const subTask = taskPage + .getSubTasks(parentTask) + .filter({ hasText: 'Sub 9019' }) + .first(); + await expect(subTask).toBeVisible(); + + // Schedule the SUBTASK for tomorrow (day-only). + await scheduleTaskForQuickDay(page, subTask, 1); + + // Plan the PARENT for a different day (next week). Before #9019 this removed + // the subtask from EVERY planner day, so it vanished from the planner. + await scheduleTaskForQuickDay(page, parentTask, 2); + + await plannerPage.navigateToPlanner(); + await plannerPage.waitForPlannerView(); + + const subTaskDay = page.locator(`planner-day[data-day="${getTomorrowDateString()}"]`); + await expect(subTaskDay).toBeVisible({ timeout: 15000 }); + await expect( + subTaskDay.locator('planner-task').filter({ hasText: 'Sub 9019' }), + ).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.spec.ts index 6c9d47bdd9..6497f3f341 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.spec.ts @@ -83,6 +83,26 @@ describe('plannerSharedMetaReducer', () => { expect(resultState.planner.days['2099-12-31']).toContain('task1'); }); + it('should not touch a sub-task scheduled for a different day when transferring the parent (#9019)', () => { + const todayStr = getDbDateStr(); + const testState = createStateWithExistingTasks([], [], [], []); + testState.planner = { + ...testState.planner, + days: { + '2024-01-16': ['sub1'], // sub scheduled for its own day + '2024-01-20': ['other'], + }, + }; + const task = createMockTask({ id: 'parent', subTaskIds: ['sub1', 'sub2'] }); + // parent is transferred onto a different day than the sub-task + const action = createTransferTaskAction(task, todayStr, 0, '2024-01-20', todayStr); + + metaReducer(testState, action); + const resultState = mockReducer.calls.mostRecent().args[0]; + expect(resultState.planner.days['2024-01-16']).toEqual(['sub1']); + expect(resultState.planner.days['2024-01-20']).toEqual(['parent', 'other']); + }); + it('should update task.dueDay when transferring from today to different day', () => { const todayStr = getDbDateStr(); const newDay = '2026-01-15'; @@ -366,6 +386,27 @@ describe('plannerSharedMetaReducer', () => { ]); }); + it('should only collapse sub-tasks on the target day, not erase them from other days (#9019)', () => { + const testState = createStateWithExistingTasks([], [], [], []); + // sub1 is scheduled for a *different* day than the parent is planned for + testState.planner = { + ...testState.planner, + days: { + '2024-01-16': ['sub1'], + '2024-01-20': ['other'], + }, + }; + const task = createMockTask({ id: 'parent', subTaskIds: ['sub1', 'sub2'] }); + const action = createPlanTaskForDayAction(task, '2024-01-20', false); + + metaReducer(testState, action); + const resultState = mockReducer.calls.mostRecent().args[0]; + // sub1 stays on its own day (2024-01-16); the parent lands on 2024-01-20. + // Before the fix, planning the parent wiped sub1 from ALL days. + expect(resultState.planner.days['2024-01-16']).toEqual(['sub1']); + expect(resultState.planner.days['2024-01-20']).toEqual(['other', 'parent']); + }); + // Virtual tag pattern tests: TODAY_TAG membership is determined by task.dueDay, // NOT by task.tagIds. TODAY_TAG should NEVER be in task.tagIds. // See: docs/ai/today-tag-architecture.md diff --git a/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts index e58ffb1669..fa5b055cc8 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts @@ -13,7 +13,7 @@ import { getTag, hasInvalidTodayTag, removeTaskFromPlannerDays, - removeTasksFromPlannerDays, + removeTasksFromSinglePlannerDay, updateTags, } from './task-shared-helpers'; import { @@ -84,7 +84,8 @@ const handleTransferTask = ( task.id, ...targetDays.slice(targetIndex), ]) - // when moving a parent to the day, remove all sub-tasks + // when moving a parent to the day, collapse its sub-tasks under it on + // THIS day only (other days keep their own scheduling — see #9019) .filter((id: string) => !task.subTaskIds.includes(id)); } @@ -247,9 +248,12 @@ const handlePlanTaskForDay = ( // Add to target day if not today (today's ordering is managed by TODAY_TAG.taskIds) if (day !== todayStr) { state = addTaskToPlannerDay(state, task.id, day, isAddToTop ? 0 : Infinity); - // When moving a parent, remove sub-tasks from the target day + // Collapse the parent's sub-tasks under it for THIS day only. Previously we + // removed them from ALL planner days, which silently erased any day the user + // had scheduled a sub-task for once its parent was planned anywhere (#9019). + // Matches the target-day-only behaviour of handleTransferTask. if (task.subTaskIds.length > 0) { - state = removeTasksFromPlannerDays(state, task.subTaskIds); + state = removeTasksFromSinglePlannerDay(state, task.subTaskIds, day); } } diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-helpers.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-helpers.ts index eb94f22a79..1183555c9d 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-helpers.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-helpers.ts @@ -423,6 +423,42 @@ export const removeTasksFromPlannerDays = ( }; }; +/** + * Removes multiple tasks from a SINGLE planner day, leaving all other days + * intact. Distinct from `removeTasksFromPlannerDays` (all days) — the scope + * difference is data-loss-sensitive, hence the explicit name. + * @param state Root state + * @param taskIds Task IDs to remove + * @param day Day (date string) to remove them from + * @returns Updated state, or original state if no changes + */ +export const removeTasksFromSinglePlannerDay = ( + state: RootState, + taskIds: string[], + day: string, +): RootState => { + const dayTasks = state.planner?.days?.[day]; + if (!dayTasks || taskIds.length === 0) { + return state; + } + + const filtered = removeTasksFromList(dayTasks, taskIds); + if (filtered.length === dayTasks.length) { + return state; + } + + return { + ...state, + [plannerFeatureKey]: { + ...state.planner, + days: { + ...state.planner.days, + [day]: filtered, + }, + }, + }; +}; + /** * Adds a task to a specific planner day. * Removes the task from all other days first (task can only be in one day).