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.
This commit is contained in:
Johannes Millan 2026-01-30 16:53:27 +01:00
parent f1dd503b8e
commit b37f2edd1b
4 changed files with 813 additions and 11 deletions

View file

@ -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<typeof TaskSharedActions.addTask>;
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<typeof TaskSharedActions.addTask>;
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<typeof TaskSharedActions.addTask>;
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<typeof TaskSharedActions.addTask>;
// 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<typeof TaskSharedActions.addTask>;
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.

View file

@ -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,
}),

View file

@ -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<string>();
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,
},

View file

@ -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<any, Action>;
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();
});
});
});