diff --git a/src/app/features/task-repeat-cfg/task-repeat-cfg.service.spec.ts b/src/app/features/task-repeat-cfg/task-repeat-cfg.service.spec.ts index 20d67740fc..8fa643a314 100644 --- a/src/app/features/task-repeat-cfg/task-repeat-cfg.service.spec.ts +++ b/src/app/features/task-repeat-cfg/task-repeat-cfg.service.spec.ts @@ -1145,6 +1145,326 @@ describe('TaskRepeatCfgService', () => { }); }); + describe('Multi-day recurring task with startTime (#6269)', () => { + // Reproduction test: Simulates a daily recurring task with startTime across multiple days. + // Tests that _getActionsForTaskRepeatCfg creates correct actions for each day. + + const createDailyCfgWithTime = ( + startDateStr: string, + lastCreationDayStr: string, + ): TaskRepeatCfg => ({ + ...DEFAULT_TASK_REPEAT_CFG, + id: 'daily-time-cfg', + title: 'Daily Standup', + projectId: 'project-A', + repeatCycle: 'DAILY', + repeatEvery: 1, + startDate: startDateStr, + lastTaskCreationDay: lastCreationDayStr, + startTime: '09:00', + remindAt: TaskReminderOptionId.AtStart, + tagIds: [], + }); + + it('should create task with dueWithTime for day 1', async () => { + const day1 = new Date(); + day1.setHours(10, 0, 0, 0); + const day1Str = formatIsoDate(day1); + + const yesterday = new Date(day1); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = formatIsoDate(yesterday); + + const weekAgo = new Date(day1); + weekAgo.setDate(weekAgo.getDate() - 7); + const weekAgoStr = formatIsoDate(weekAgo); + + const cfg = createDailyCfgWithTime(weekAgoStr, yesterdayStr); + const expectedId = getRepeatableTaskId(cfg.id, day1Str); + + taskService.getTasksWithSubTasksByRepeatCfgId$.and.returnValue(of([])); + taskService.createNewTaskWithDefaults.and.callFake((args: any) => ({ + ...mockTask, + id: args.id || mockTask.id, + dueDay: args.additional?.dueDay, + projectId: args.additional?.projectId || mockTask.projectId, + })); + + const actions = await service._getActionsForTaskRepeatCfg(cfg, day1.getTime()); + + // Should create: addTask, updateTaskRepeatCfg, scheduleTaskWithTime + expect(actions.length).toBe(3); + expect(actions[0].type).toBe(TaskSharedActions.addTask.type); + expect(actions[1].type).toBe(updateTaskRepeatCfg.type); + expect(actions[2].type).toBe(TaskSharedActions.scheduleTaskWithTime.type); + + // Verify the task has correct dueDay + const addTaskAction = actions[0] as ReturnType; + expect(addTaskAction.task.dueDay).toBe(day1Str); + expect(addTaskAction.task.id).toBe(expectedId); + + // Verify scheduleTaskWithTime sets dueWithTime + const scheduleAction = actions[2] as ReturnType< + typeof TaskSharedActions.scheduleTaskWithTime + >; + expect(scheduleAction.dueWithTime).toBeDefined(); + expect(scheduleAction.remindAt).toBeDefined(); + }); + + it('should create tasks for consecutive days (day 2 and day 3)', async () => { + const day1 = new Date(); + day1.setHours(10, 0, 0, 0); + const day1Str = formatIsoDate(day1); + + const weekAgo = new Date(day1); + weekAgo.setDate(weekAgo.getDate() - 7); + const weekAgoStr = formatIsoDate(weekAgo); + + // For day 2: lastTaskCreationDay = day1 + const day2 = new Date(day1); + day2.setDate(day2.getDate() + 1); + const day2Str = formatIsoDate(day2); + + const cfgAfterDay1 = createDailyCfgWithTime(weekAgoStr, day1Str); + const expectedIdDay2 = getRepeatableTaskId(cfgAfterDay1.id, day2Str); + + taskService.getTasksWithSubTasksByRepeatCfgId$.and.returnValue(of([])); + taskService.createNewTaskWithDefaults.and.callFake((args: any) => ({ + ...mockTask, + id: args.id || mockTask.id, + dueDay: args.additional?.dueDay, + projectId: args.additional?.projectId || mockTask.projectId, + })); + + const actionsDay2 = await service._getActionsForTaskRepeatCfg( + cfgAfterDay1, + day2.getTime(), + ); + + expect(actionsDay2.length).toBe(3); + const addTaskDay2 = actionsDay2[0] as ReturnType; + expect(addTaskDay2.task.dueDay).toBe(day2Str); + expect(addTaskDay2.task.id).toBe(expectedIdDay2); + + // For day 3: lastTaskCreationDay = day2 + const day3 = new Date(day2); + day3.setDate(day3.getDate() + 1); + const day3Str = formatIsoDate(day3); + + const cfgAfterDay2 = createDailyCfgWithTime(weekAgoStr, day2Str); + const expectedIdDay3 = getRepeatableTaskId(cfgAfterDay2.id, day3Str); + + const actionsDay3 = await service._getActionsForTaskRepeatCfg( + cfgAfterDay2, + day3.getTime(), + ); + + expect(actionsDay3.length).toBe(3); + const addTaskDay3 = actionsDay3[0] as ReturnType; + expect(addTaskDay3.task.dueDay).toBe(day3Str); + expect(addTaskDay3.task.id).toBe(expectedIdDay3); + }); + }); + + describe('Sync scenario: lastTaskCreationDay set but task missing (#6269)', () => { + // Reproduction test: When Device A creates a task and syncs the repeat config + // (with updated lastTaskCreationDay) but the task entity hasn't synced yet, + // Device B should NOT attempt to create a duplicate because lastTaskCreationDay + // blocks it. The task entity will arrive via sync separately. + + it('should NOT create task when lastTaskCreationDay is already today (sync race)', async () => { + const today = new Date(); + today.setHours(10, 0, 0, 0); + const todayStr = formatIsoDate(today); + + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + const weekAgoStr = formatIsoDate(weekAgo); + + // Config already has lastTaskCreationDay = today (synced from Device A) + const cfgWithTodayCreation: TaskRepeatCfg = { + ...DEFAULT_TASK_REPEAT_CFG, + id: 'sync-race-cfg', + title: 'Synced Task', + projectId: 'project-A', + repeatCycle: 'DAILY', + repeatEvery: 1, + startDate: weekAgoStr, + lastTaskCreationDay: todayStr, + tagIds: [], + }; + + // No tasks exist locally (task hasn't synced yet) + taskService.getTasksWithSubTasksByRepeatCfgId$.and.returnValue(of([])); + + // devError calls window.confirm; return false so it doesn't throw + const confirmSpy = window.confirm as jasmine.Spy; + confirmSpy.and.returnValue(false); + + const actions = await service._getActionsForTaskRepeatCfg( + cfgWithTodayCreation, + today.getTime(), + ); + + // getNewestPossibleDueDate returns null because lastTaskCreationDay === today + // blocks the loop (checkDate <= lastTaskCreation). devError fires, returns []. + expect(actions).toEqual([]); + + confirmSpy.and.returnValue(true); + }); + }); + + describe('workContextId mismatch (#6269)', () => { + // Reproduction test: When a repeat config has projectId='project-A' but + // the active work context is 'project-B', the created task should still + // have projectId='project-A' (from config), but the addTask action's + // workContextId uses the ACTIVE context. + + it('should use active work context for addTask, not the config projectId', async () => { + const today = new Date(); + today.setHours(10, 0, 0, 0); + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = formatIsoDate(yesterday); + + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + const weekAgoStr = formatIsoDate(weekAgo); + + // Config is for project-A + const cfgForProjectA: TaskRepeatCfg = { + ...DEFAULT_TASK_REPEAT_CFG, + id: 'ctx-mismatch-cfg', + title: 'Project A Task', + projectId: 'project-A', + repeatCycle: 'DAILY', + repeatEvery: 1, + startDate: weekAgoStr, + lastTaskCreationDay: yesterdayStr, + tagIds: [], + }; + + taskService.getTasksWithSubTasksByRepeatCfgId$.and.returnValue(of([])); + taskService.createNewTaskWithDefaults.and.callFake((args: any) => ({ + ...mockTask, + id: args.id || mockTask.id, + dueDay: args.additional?.dueDay, + projectId: args.additional?.projectId || undefined, + })); + + const actions = await service._getActionsForTaskRepeatCfg( + cfgForProjectA, + today.getTime(), + ); + + expect(actions.length).toBeGreaterThan(0); + const addTaskAction = actions[0] as ReturnType; + + // Task entity has projectId from config + expect(addTaskAction.task.projectId).toBe('project-A'); + + // After fix: The addTask action uses the config's projectId, not the active context. + // This ensures correct behavior regardless of which project is active on this device. + expect(addTaskAction.workContextId).toBe('project-A'); + expect(addTaskAction.workContextType).toBe(WorkContextType.PROJECT); + }); + }); + + describe('Overdue scheduled task removal + new instance creation (#6269)', () => { + // Reproduction test: Verifies that when a recurring task has dueWithTime + // set to yesterday (past) and dueDay is undefined (mutual exclusivity), + // a new task instance can still be created for today. + + it('should create a new task instance for today even when yesterday instance had dueWithTime', async () => { + const today = new Date(); + today.setHours(10, 0, 0, 0); + const todayStr = formatIsoDate(today); + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = formatIsoDate(yesterday); + + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + const weekAgoStr = formatIsoDate(weekAgo); + + // The config was last created yesterday + const cfg: TaskRepeatCfg = { + ...DEFAULT_TASK_REPEAT_CFG, + id: 'overdue-cfg', + title: 'Daily Standup', + projectId: 'project-A', + repeatCycle: 'DAILY', + repeatEvery: 1, + startDate: weekAgoStr, + lastTaskCreationDay: yesterdayStr, + startTime: '09:00', + remindAt: TaskReminderOptionId.AtStart, + tagIds: [], + }; + + // Yesterday's task exists with dueWithTime (dueDay undefined due to mutual exclusivity) + const yesterdayTaskId = getRepeatableTaskId(cfg.id, yesterdayStr); + const yesterdayTask: TaskWithSubTasks = { + ...mockTaskWithSubTasks, + id: yesterdayTaskId, + // Set created to yesterday so getDbDateStr(created) != today + created: yesterday.getTime(), + dueDay: undefined, + dueWithTime: new Date( + yesterday.getFullYear(), + yesterday.getMonth(), + yesterday.getDate(), + 9, + 0, + ).getTime(), + }; + taskService.getTasksWithSubTasksByRepeatCfgId$.and.returnValue(of([yesterdayTask])); + taskService.createNewTaskWithDefaults.and.callFake((args: any) => ({ + ...mockTask, + id: args.id || mockTask.id, + dueDay: args.additional?.dueDay, + projectId: args.additional?.projectId || mockTask.projectId, + })); + + const actions = await service._getActionsForTaskRepeatCfg(cfg, today.getTime()); + + // Should create new task for today (yesterday's task has different ID and dueDay) + expect(actions.length).toBe(3); + expect(actions[0].type).toBe(TaskSharedActions.addTask.type); + + const addTaskAction = actions[0] as ReturnType; + expect(addTaskAction.task.dueDay).toBe(todayStr); + expect(addTaskAction.task.id).toBe(getRepeatableTaskId(cfg.id, todayStr)); + }); + + it('should detect overdue task correctly when dueWithTime is in the past and dueDay is undefined', () => { + // This test documents the overdue detection logic from selectOverdueTasks: + // A task with dueWithTime < todayStart is considered overdue, regardless of dueDay. + const today = new Date(); + const todayStart = new Date(today); + todayStart.setHours(0, 0, 0, 0); + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + // Task with dueWithTime = yesterday 9am, dueDay = undefined (mutual exclusivity) + const yesterdayDueWithTime = new Date( + yesterday.getFullYear(), + yesterday.getMonth(), + yesterday.getDate(), + 9, + 0, + ).getTime(); + + // Verify the overdue condition: dueWithTime < todayStart + expect(yesterdayDueWithTime < todayStart.getTime()).toBe(true); + // Verify dueDay check: when dueDay is undefined, the dueWithTime check catches it + expect(undefined as string | undefined).toBeFalsy(); + }); + }); + describe('addTaskRepeatCfgToTask dispatch (#5594)', () => { // Note: First occurrence calculation and lastTaskCreationDay updates // are handled by the updateTaskAfterMakingItRepeatable$ effect. diff --git a/src/app/features/task-repeat-cfg/task-repeat-cfg.service.ts b/src/app/features/task-repeat-cfg/task-repeat-cfg.service.ts index f5e73d5467..8b0b5b3bf7 100644 --- a/src/app/features/task-repeat-cfg/task-repeat-cfg.service.ts +++ b/src/app/features/task-repeat-cfg/task-repeat-cfg.service.ts @@ -246,9 +246,16 @@ export class TaskRepeatCfgService { )[] = [ TaskSharedActions.addTask({ task: taskWithTargetDates, - workContextType: this._workContextService - .activeWorkContextType as WorkContextType, - workContextId: this._workContextService.activeWorkContextId as string, + // Use the repeat config's projectId when available to ensure the task + // is associated with the correct project regardless of which project is + // currently active on this device. Falls back to active context for + // tag-based repeat configs without a project. + workContextType: taskRepeatCfg.projectId + ? WorkContextType.PROJECT + : (this._workContextService.activeWorkContextType as WorkContextType), + workContextId: taskRepeatCfg.projectId + ? taskRepeatCfg.projectId + : (this._workContextService.activeWorkContextId as string), isAddToBacklog: false, isAddToBottom, }), diff --git a/src/app/features/tasks/store/task-due.effects.ts b/src/app/features/tasks/store/task-due.effects.ts index 9369d5df87..22e06e7a77 100644 --- a/src/app/features/tasks/store/task-due.effects.ts +++ b/src/app/features/tasks/store/task-due.effects.ts @@ -10,15 +10,20 @@ import { switchMap, withLatestFrom, } from 'rxjs/operators'; -import { EMPTY, of } from 'rxjs'; +import { combineLatest, EMPTY, of } from 'rxjs'; import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service'; import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; import { Store } from '@ngrx/store'; -import { selectOverdueTasksOnToday, selectTasksDueForDay } from './task.selectors'; +import { + selectOverdueTasksOnToday, + selectTasksDueForDay, + selectTasksWithDueTimeForRange, +} from './task.selectors'; import { SyncWrapperService } from '../../../imex/sync/sync-wrapper.service'; import { selectTodayTaskIds } from '../../work-context/store/work-context.selectors'; import { AddTasksForTomorrowService } from '../../add-tasks-for-tomorrow/add-tasks-for-tomorrow.service'; import { getDbDateStr } from '../../../util/get-db-date-str'; +import { getDateRangeForDay } from '../../../util/get-date-range-for-day'; import { TaskLog } from '../../../core/log'; import { SyncTriggerService } from '../../../imex/sync/sync-trigger.service'; import { environment } from '../../../../environments/environment'; @@ -130,11 +135,27 @@ export class TaskDueEffects { switchMap(() => this._syncWrapperService.afterCurrentSyncDoneOrSyncDisabled$), switchMap(() => { const todayStr = getDbDateStr(); - return this._store$.select(selectTasksDueForDay, { day: todayStr }).pipe( + const todayRange = getDateRangeForDay(Date.now()); + return combineLatest([ + this._store$.select(selectTasksDueForDay, { day: todayStr }), + this._store$.select(selectTasksWithDueTimeForRange, todayRange), + ]).pipe( first(), withLatestFrom(this._store$.select(selectTodayTaskIds)), - map(([tasksDueToday, todayTaskIds]) => { - const missingTaskIds = tasksDueToday + map(([[tasksDueByDay, tasksDueByTime], todayTaskIds]) => { + // Merge both lists, deduplicating by ID. + // Tasks with dueWithTime set have dueDay cleared (mutual exclusivity), + // so we must check both selectors to catch all tasks due today. + const seenIds = new Set(); + const allTasksDueToday = [...tasksDueByDay, ...tasksDueByTime].filter( + (task) => { + if (seenIds.has(task.id)) return false; + seenIds.add(task.id); + return true; + }, + ); + + const missingTaskIds = allTasksDueToday .filter((task) => !todayTaskIds.includes(task.id)) // Exclude subtasks whose parent is already in TODAY // (preventParentAndSubTaskInTodayList$ will remove them anyway, @@ -146,8 +167,9 @@ export class TaskDueEffects { // Debug log to investigate repeated operations TaskLog.log('[TaskDueEffects] ensureTasksDueTodayInTodayTag check:', { - tasksDueTodayCount: tasksDueToday.length, - tasksDueTodayIds: tasksDueToday.map((t) => t.id), + tasksDueByDayCount: tasksDueByDay.length, + tasksDueByTimeCount: tasksDueByTime.length, + tasksDueTodayIds: allTasksDueToday.map((t) => t.id), todayTaskIdsCount: todayTaskIds.length, missingTaskIds, willDispatch: missingTaskIds.length > 0, @@ -157,7 +179,7 @@ export class TaskDueEffects { TaskLog.err( '[TaskDueEffects] Found tasks due today missing from TODAY tag:', { - tasksDueToday: tasksDueToday.length, + tasksDueToday: allTasksDueToday.length, todayTaskIds: todayTaskIds.length, missingTaskIds, }, diff --git a/src/app/root-store/meta/task-shared-meta-reducers/repeat-task-today-tag.integration.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/repeat-task-today-tag.integration.spec.ts new file mode 100644 index 0000000000..4c8fc96fcb --- /dev/null +++ b/src/app/root-store/meta/task-shared-meta-reducers/repeat-task-today-tag.integration.spec.ts @@ -0,0 +1,453 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/** + * Integration test for recurring task + TODAY_TAG behavior across day boundaries. + * + * Tests the exact sequence of meta-reducer actions that occur when: + * 1. A daily recurring task with startTime is created + * 2. The day changes + * 3. Synced operations from another device are applied + * + * Uses the full combined meta-reducer chain to catch interactions between + * the CRUD, scheduling, and tag meta-reducers. + * + * Related: https://github.com/johannesjo/super-productivity/issues/6269 + */ +import { Action, ActionReducer } from '@ngrx/store'; +import { createCombinedTaskSharedMetaReducer, updateTaskEntity } from './test-helpers'; +import { + createBaseState, + createMockTask, + createStateWithExistingTasks, +} from './test-utils'; +import { TaskSharedActions } from '../task-shared.actions'; +import { RootState } from '../../root-state'; +import { TASK_FEATURE_NAME } from '../../../features/tasks/store/task.reducer'; +import { TAG_FEATURE_NAME } from '../../../features/tag/store/tag.reducer'; +import { Task } from '../../../features/tasks/task.model'; +import { Tag } from '../../../features/tag/tag.model'; +import { WorkContextType } from '../../../features/work-context/work-context.model'; +import { getRepeatableTaskId } from '../../../features/task-repeat-cfg/get-repeatable-task-id.util'; + +describe('Recurring task TODAY_TAG integration (#6269)', () => { + let combinedReducer: ActionReducer; + let baseState: RootState; + + // Day 1: June 15, 2024 at noon + const DAY1 = new Date(2024, 5, 15, 12, 0, 0, 0); + // Day 2: June 16, 2024 at noon + const DAY2 = new Date(2024, 5, 16, 12, 0, 0, 0); + // Day 3: June 17, 2024 at noon + const DAY3 = new Date(2024, 5, 17, 12, 0, 0, 0); + + const REPEAT_CFG_ID = 'daily-standup-cfg'; + + const day1Str = '2024-06-15'; + const day2Str = '2024-06-16'; + const day3Str = '2024-06-17'; + + // 9:00 AM on each day (for dueWithTime) + const day1_9am = new Date(2024, 5, 15, 9, 0, 0, 0).getTime(); + const day2_9am = new Date(2024, 5, 16, 9, 0, 0, 0).getTime(); + const day3_9am = new Date(2024, 5, 17, 9, 0, 0, 0).getTime(); + + const day1TaskId = getRepeatableTaskId(REPEAT_CFG_ID, day1Str); + const day2TaskId = getRepeatableTaskId(REPEAT_CFG_ID, day2Str); + const day3TaskId = getRepeatableTaskId(REPEAT_CFG_ID, day3Str); + + const passthrough = (state: any, _action: Action) => state; + + beforeEach(() => { + jasmine.clock().install(); + jasmine.clock().mockDate(DAY1); + combinedReducer = createCombinedTaskSharedMetaReducer(passthrough); + baseState = createBaseState(); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + // Helpers + const getTodayTagTaskIds = (state: RootState): string[] => { + return (state[TAG_FEATURE_NAME].entities['TODAY'] as Tag)?.taskIds || []; + }; + + const getTask = (state: RootState, taskId: string): Task | undefined => { + return state[TASK_FEATURE_NAME].entities[taskId] as Task | undefined; + }; + + const createAddTaskAction = (taskId: string, dueDay: string) => + TaskSharedActions.addTask({ + task: createMockTask({ + id: taskId, + title: 'Daily Standup', + dueDay, + repeatCfgId: REPEAT_CFG_ID, + }), + workContextType: WorkContextType.PROJECT, + workContextId: 'project1', + isAddToBacklog: false, + isAddToBottom: false, + }); + + const createScheduleAction = (taskId: string, dueWithTime: number) => + TaskSharedActions.scheduleTaskWithTime({ + task: createMockTask({ id: taskId }), + dueWithTime, + remindAt: dueWithTime, + isMoveToBacklog: false, + isSkipAutoRemoveFromToday: true, + }); + + describe('Day 1: Create recurring task with startTime', () => { + it('should add task to TODAY_TAG after addTask with dueDay=today', () => { + const action = createAddTaskAction(day1TaskId, day1Str); + const state = combinedReducer(baseState, action); + + expect(getTodayTagTaskIds(state)).toContain(day1TaskId); + const task = getTask(state, day1TaskId); + expect(task?.dueDay).toBe(day1Str); + }); + + it('should keep task in TODAY_TAG after scheduleTaskWithTime clears dueDay', () => { + // Step 1: addTask sets dueDay = today → adds to TODAY_TAG + let state = combinedReducer(baseState, createAddTaskAction(day1TaskId, day1Str)); + expect(getTodayTagTaskIds(state)).toContain(day1TaskId); + + // Step 2: scheduleTaskWithTime sets dueWithTime, clears dueDay + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + + // Task should STILL be in TODAY_TAG (dueWithTime is today) + expect(getTodayTagTaskIds(state)).toContain(day1TaskId); + + // Verify mutual exclusivity: dueDay cleared, dueWithTime set + const task = getTask(state, day1TaskId); + expect(task?.dueDay).toBeUndefined(); + expect(task?.dueWithTime).toBe(day1_9am); + }); + }); + + describe('Day 2: Day change with overdue Day 1 task', () => { + let stateAfterDay1: RootState; + + beforeEach(() => { + // Set up: Day 1 task exists with dueWithTime, dueDay=undefined + stateAfterDay1 = combinedReducer( + baseState, + createAddTaskAction(day1TaskId, day1Str), + ); + stateAfterDay1 = combinedReducer( + stateAfterDay1, + createScheduleAction(day1TaskId, day1_9am), + ); + + // Now advance to Day 2 + jasmine.clock().mockDate(DAY2); + }); + + it('should remove Day 1 task from TODAY_TAG via removeTasksFromTodayTag', () => { + // The removeOverdueFromToday$ effect dispatches this when it detects + // dueWithTime < todayStart + const removeAction = TaskSharedActions.removeTasksFromTodayTag({ + taskIds: [day1TaskId], + }); + const state = combinedReducer(stateAfterDay1, removeAction); + + expect(getTodayTagTaskIds(state)).not.toContain(day1TaskId); + }); + + it('should add Day 2 task to TODAY_TAG via addTask + scheduleTaskWithTime', () => { + // Remove Day 1's overdue task + let state = combinedReducer( + stateAfterDay1, + TaskSharedActions.removeTasksFromTodayTag({ taskIds: [day1TaskId] }), + ); + + // Create Day 2's task + state = combinedReducer(state, createAddTaskAction(day2TaskId, day2Str)); + expect(getTodayTagTaskIds(state)).toContain(day2TaskId); + + // Schedule Day 2's task + state = combinedReducer(state, createScheduleAction(day2TaskId, day2_9am)); + expect(getTodayTagTaskIds(state)).toContain(day2TaskId); + + // Verify Day 2 task has correct state + const task = getTask(state, day2TaskId); + expect(task?.dueDay).toBeUndefined(); + expect(task?.dueWithTime).toBe(day2_9am); + }); + }); + + describe('Day 2: Sync replay of Day 1 operations from another device', () => { + it('should NOT add Day 1 task to Day 2 TODAY_TAG when synced operations replay', () => { + // Start on Day 2 with empty state + jasmine.clock().mockDate(DAY2); + let state = baseState; + + // Device A's operations arrive via sync: addTask with dueDay = Day 1 + state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str)); + + // Day 1's dueDay !== Day 2's today → should NOT be in TODAY_TAG + expect(getTodayTagTaskIds(state)).not.toContain(day1TaskId); + + // scheduleTaskWithTime with dueWithTime = Day 1 9am + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + + // isToday(day1_9am) on Day 2 → false → should NOT be in TODAY_TAG + expect(getTodayTagTaskIds(state)).not.toContain(day1TaskId); + }); + + it('should keep Day 2 task in TODAY_TAG even after Day 1 sync operations', () => { + jasmine.clock().mockDate(DAY2); + let state = baseState; + + // First: Day 2's task was already created locally + state = combinedReducer(state, createAddTaskAction(day2TaskId, day2Str)); + state = combinedReducer(state, createScheduleAction(day2TaskId, day2_9am)); + expect(getTodayTagTaskIds(state)).toContain(day2TaskId); + + // Then: Day 1's operations arrive via sync + state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str)); + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + + // Day 2's task should STILL be in TODAY_TAG + expect(getTodayTagTaskIds(state)).toContain(day2TaskId); + // Day 1's task should NOT be in TODAY_TAG + expect(getTodayTagTaskIds(state)).not.toContain(day1TaskId); + }); + }); + + describe('Day 3: Consecutive day changes', () => { + it('should correctly manage TODAY_TAG across 3 consecutive days', () => { + // === Day 1 === + jasmine.clock().mockDate(DAY1); + let state = baseState; + state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str)); + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + + expect(getTodayTagTaskIds(state)).toContain(day1TaskId); + + // === Day 2 === + jasmine.clock().mockDate(DAY2); + + // Remove Day 1's overdue task + state = combinedReducer( + state, + TaskSharedActions.removeTasksFromTodayTag({ taskIds: [day1TaskId] }), + ); + expect(getTodayTagTaskIds(state)).not.toContain(day1TaskId); + + // Create Day 2's task + state = combinedReducer(state, createAddTaskAction(day2TaskId, day2Str)); + state = combinedReducer(state, createScheduleAction(day2TaskId, day2_9am)); + expect(getTodayTagTaskIds(state)).toContain(day2TaskId); + + // === Day 3 === + jasmine.clock().mockDate(DAY3); + + // Remove Day 2's overdue task + state = combinedReducer( + state, + TaskSharedActions.removeTasksFromTodayTag({ taskIds: [day2TaskId] }), + ); + expect(getTodayTagTaskIds(state)).not.toContain(day2TaskId); + + // Create Day 3's task + state = combinedReducer(state, createAddTaskAction(day3TaskId, day3Str)); + state = combinedReducer(state, createScheduleAction(day3TaskId, day3_9am)); + expect(getTodayTagTaskIds(state)).toContain(day3TaskId); + + // Verify final state: only Day 3 in TODAY + const todayIds = getTodayTagTaskIds(state); + expect(todayIds).toContain(day3TaskId); + expect(todayIds).not.toContain(day1TaskId); + expect(todayIds).not.toContain(day2TaskId); + }); + }); + + describe('planTasksForToday recovery for tasks with dueWithTime', () => { + it('should add task with dueWithTime=today to TODAY_TAG via planTasksForToday', () => { + // Scenario: Task exists with dueWithTime=today but somehow isn't in TODAY_TAG + // (e.g., after sync replay or race condition) + jasmine.clock().mockDate(DAY1); + let state = baseState; + + // Add task via addTask (sets dueDay=today, in TODAY_TAG) + state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str)); + // Schedule it (clears dueDay, sets dueWithTime) + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + expect(getTodayTagTaskIds(state)).toContain(day1TaskId); + + // Simulate: task falls out of TODAY_TAG (e.g., due to a bug or sync race) + state = combinedReducer( + state, + TaskSharedActions.removeTasksFromTodayTag({ taskIds: [day1TaskId] }), + ); + expect(getTodayTagTaskIds(state)).not.toContain(day1TaskId); + + // Verify the task still has dueWithTime set (dueDay is undefined) + const task = getTask(state, day1TaskId); + expect(task?.dueWithTime).toBe(day1_9am); + expect(task?.dueDay).toBeUndefined(); + + // Recovery: planTasksForToday should re-add the task + // This is what ensureTasksDueTodayInTodayTag$ dispatches + state = combinedReducer( + state, + TaskSharedActions.planTasksForToday({ + taskIds: [day1TaskId], + isSkipRemoveReminder: true, + }), + ); + + // Task should be back in TODAY_TAG + expect(getTodayTagTaskIds(state)).toContain(day1TaskId); + // planTasksForToday sets dueDay=today and preserves dueWithTime if for today + const recoveredTask = getTask(state, day1TaskId); + expect(recoveredTask?.dueDay).toBe(day1Str); + }); + + it('should handle task that only has dueWithTime (no dueDay) during recovery', () => { + // This tests the exact gap that the ensureTasksDueTodayInTodayTag$ fix addresses: + // A task with dueWithTime=today but dueDay=undefined should be recoverable + jasmine.clock().mockDate(DAY1); + + // Set up: task exists with dueWithTime only + let state = createStateWithExistingTasks(['task1'], [], [], []); + state = updateTaskEntity(state, 'task1', { + dueWithTime: day1_9am, + dueDay: undefined, + remindAt: day1_9am, + }); + + // Task is NOT in TODAY_TAG (simulating the bug) + expect(getTodayTagTaskIds(state)).not.toContain('task1'); + + // Apply planTasksForToday (what ensureTasksDueTodayInTodayTag$ dispatches) + state = combinedReducer( + state, + TaskSharedActions.planTasksForToday({ + taskIds: ['task1'], + isSkipRemoveReminder: true, + }), + ); + + // Task should now be in TODAY_TAG + expect(getTodayTagTaskIds(state)).toContain('task1'); + // And dueDay should be set to today + const task = getTask(state, 'task1'); + expect(task?.dueDay).toBe(day1Str); + }); + }); + + describe('Selector gap: selectTasksDueForDay misses dueWithTime-only tasks', () => { + // This documents the root cause of #6269: + // After scheduleTaskWithTime clears dueDay, the task becomes invisible + // to selectTasksDueForDay. The ensureTasksDueTodayInTodayTag$ effect + // (before fix) only used selectTasksDueForDay, so it could never + // recover tasks that had dueWithTime set but dueDay=undefined. + + it('should demonstrate that scheduled task has dueDay=undefined after scheduling', () => { + jasmine.clock().mockDate(DAY1); + let state = baseState; + + // Create and schedule a task (simulates recurring task creation) + state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str)); + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + + const task = getTask(state, day1TaskId); + // After scheduleTaskWithTime: dueDay is cleared (mutual exclusivity) + expect(task?.dueDay).toBeUndefined(); + expect(task?.dueWithTime).toBe(day1_9am); + }); + + it('should show selectTasksDueForDay does NOT find dueWithTime-only tasks', () => { + jasmine.clock().mockDate(DAY1); + let state = baseState; + + state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str)); + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + + // Simulate what selectTasksDueForDay does + const taskEntities = state[TASK_FEATURE_NAME].entities; + const allTasks = Object.values(taskEntities).filter(Boolean) as Task[]; + const tasksDueByDay = allTasks.filter((t) => t.dueDay === day1Str); + + // dueDay was cleared by scheduleTaskWithTime → NOT found + expect(tasksDueByDay.length).toBe(0); + expect(tasksDueByDay.find((t) => t.id === day1TaskId)).toBeUndefined(); + }); + + it('should show selectTasksWithDueTimeForRange DOES find dueWithTime-only tasks', () => { + jasmine.clock().mockDate(DAY1); + let state = baseState; + + state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str)); + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + + // Simulate what selectTasksWithDueTimeForRange does + const dayStart = new Date(DAY1); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(DAY1); + dayEnd.setHours(23, 59, 59, 0); + + const taskEntities = state[TASK_FEATURE_NAME].entities; + const allTasks = Object.values(taskEntities).filter(Boolean) as Task[]; + const tasksDueByTime = allTasks.filter( + (t) => + typeof t.dueWithTime === 'number' && + t.dueWithTime >= dayStart.getTime() && + t.dueWithTime <= dayEnd.getTime(), + ); + + // dueWithTime IS set → found by time range selector + expect(tasksDueByTime.length).toBe(1); + expect(tasksDueByTime[0].id).toBe(day1TaskId); + }); + }); + + describe('Multi-device sync: operations arrive out of order', () => { + it('should handle addTask arriving before scheduleTaskWithTime from sync', () => { + jasmine.clock().mockDate(DAY1); + let state = baseState; + + // Sync: addTask arrives first (dueDay = today) + state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str)); + expect(getTodayTagTaskIds(state)).toContain(day1TaskId); + + // dueDay is set + expect(getTask(state, day1TaskId)?.dueDay).toBe(day1Str); + + // Sync: scheduleTaskWithTime arrives later + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + expect(getTodayTagTaskIds(state)).toContain(day1TaskId); + + // dueDay cleared (mutual exclusivity), dueWithTime set + const task = getTask(state, day1TaskId); + expect(task?.dueDay).toBeUndefined(); + expect(task?.dueWithTime).toBe(day1_9am); + }); + + it('should handle scheduleTaskWithTime for yesterday arriving on today', () => { + // This is the critical sync scenario: Device A created a task yesterday + // with dueWithTime = yesterday 9am. Today, Device B receives this operation. + jasmine.clock().mockDate(DAY2); + let state = baseState; + + // Device A's addTask from yesterday arrives (dueDay = yesterday) + state = combinedReducer(state, createAddTaskAction(day1TaskId, day1Str)); + // dueDay = yesterday !== today → NOT in TODAY_TAG + expect(getTodayTagTaskIds(state)).not.toContain(day1TaskId); + + // Device A's scheduleTaskWithTime from yesterday arrives + state = combinedReducer(state, createScheduleAction(day1TaskId, day1_9am)); + // isToday(day1_9am) on Day 2 → false → should NOT add to TODAY_TAG + expect(getTodayTagTaskIds(state)).not.toContain(day1TaskId); + + // Verify task state + const task = getTask(state, day1TaskId); + expect(task?.dueWithTime).toBe(day1_9am); + expect(task?.dueDay).toBeUndefined(); + }); + }); +});