From b37f2edd1b3085ecfbe94efd083f796dca127133 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 30 Jan 2026 16:53:27 +0100 Subject: [PATCH] fix(tasks): ensure scheduled recurring tasks stay in TODAY tag (#6269) The ensureTasksDueTodayInTodayTag$ defensive effect only checked selectTasksDueForDay (dueDay field), but scheduleTaskWithTime clears dueDay when setting dueWithTime (mutual exclusivity). This caused scheduled recurring tasks to become invisible to the recovery effect on synced devices, leading to tasks disappearing from Today view. Now also checks selectTasksWithDueTimeForRange to catch tasks with only dueWithTime set. Also fixes workContextId in repeat task creation to use the config's projectId instead of the active work context. --- .../task-repeat-cfg.service.spec.ts | 320 +++++++++++++ .../task-repeat-cfg.service.ts | 13 +- .../features/tasks/store/task-due.effects.ts | 38 +- .../repeat-task-today-tag.integration.spec.ts | 453 ++++++++++++++++++ 4 files changed, 813 insertions(+), 11 deletions(-) create mode 100644 src/app/root-store/meta/task-shared-meta-reducers/repeat-task-today-tag.integration.spec.ts 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(); + }); + }); +});