diff --git a/package-lock.json b/package-lock.json index 7e7bb8878f..01b9aa67e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3401,6 +3401,24 @@ "node": ">=10.12.0" } }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@electron/asar/node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -3434,19 +3452,16 @@ } }, "node_modules/@electron/asar/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/@electron/fuses": { @@ -3863,17 +3878,34 @@ "node": ">=16.4" } }, - "node_modules/@electron/universal/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -12212,6 +12244,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/cacache/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -12241,6 +12290,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/cacache/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/cacache/node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -12264,22 +12329,6 @@ "dev": true, "license": "ISC" }, - "node_modules/cacache/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacache/node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -16205,20 +16254,34 @@ "minimatch": "^5.0.1" } }, - "node_modules/filelist/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=10" } }, "node_modules/fill-range": { @@ -23264,6 +23327,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/core/persistence/operation-log/compact/action-type-codes.ts b/src/app/core/persistence/operation-log/compact/action-type-codes.ts index 05b87664c3..921be96979 100644 --- a/src/app/core/persistence/operation-log/compact/action-type-codes.ts +++ b/src/app/core/persistence/operation-log/compact/action-type-codes.ts @@ -92,6 +92,7 @@ export const ACTION_TYPE_TO_CODE: Record = { [ActionType.TASK_SHARED_DELETE_REPEAT_CFG]: 'HRC', [ActionType.TASK_SHARED_APPLY_SHORT_SYNTAX]: 'HSS', [ActionType.TASK_SHARED_SET_DEADLINE]: 'HDL', + [ActionType.TASK_SHARED_PLAN_DEADLINE_FOR_TODAY]: 'HDT', [ActionType.TASK_SHARED_REMOVE_DEADLINE]: 'HXD', [ActionType.TASK_SHARED_CLEAR_DEADLINE_REMINDER]: 'HCR', diff --git a/src/app/features/project/project.service.spec.ts b/src/app/features/project/project.service.spec.ts index c6cf210f61..3f1470b5ce 100644 --- a/src/app/features/project/project.service.spec.ts +++ b/src/app/features/project/project.service.spec.ts @@ -18,6 +18,7 @@ import { WorkContextType } from '../work-context/work-context.model'; import { T } from '../../t.const'; import { selectNoteFeatureState } from '../note/store/note.reducer'; import { NoteState } from '../note/note.model'; +import { DateService } from '../../core/date/date.service'; describe('ProjectService', () => { let service: ProjectService; @@ -136,6 +137,13 @@ describe('ProjectService', () => { useValue: { getTaskRepeatCfgsWithLabels$: () => of([]) }, }, { provide: TimeTrackingService, useValue: timeTrackingService }, + { + provide: DateService, + useValue: { + todayStr: () => '2026-01-05', + getStartOfNextDayDiffMs: () => 0, + }, + }, ], }); workContextService.activeWorkContext$ = EMPTY; diff --git a/src/app/features/project/project.service.ts b/src/app/features/project/project.service.ts index 75301c5ac9..71fb309bb1 100644 --- a/src/app/features/project/project.service.ts +++ b/src/app/features/project/project.service.ts @@ -47,6 +47,8 @@ import { selectNoteFeatureState } from '../note/store/note.reducer'; import { addNote } from '../note/store/note.actions'; import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component'; import { LOCAL_ACTIONS } from '../../util/local-actions.token'; +import { DateService } from '../../core/date/date.service'; +import { getDeadlineAutoPlanFields } from '../tasks/util/get-deadline-auto-plan-fields'; @Injectable({ providedIn: 'root', @@ -59,6 +61,7 @@ export class ProjectService { private readonly _taskService = inject(TaskService); private readonly _translate = inject(TranslateService); private readonly _matDialog = inject(MatDialog); + private readonly _dateService = inject(DateService); list$: Observable = this._store$.pipe(select(selectUnarchivedProjects)); list = toSignal(this.list$, { initialValue: [] }); @@ -344,6 +347,11 @@ export class ProjectService { workContextType: WorkContextType.PROJECT, isAddToBacklog: isBacklog, isAddToBottom: true, + ...getDeadlineAutoPlanFields( + this._dateService, + newParentTask.deadlineDay, + newParentTask.deadlineWithTime, + ), }), ); diff --git a/src/app/features/schedule/schedule-week/schedule-week.component.scss b/src/app/features/schedule/schedule-week/schedule-week.component.scss index e15abbb918..bea5f54bd5 100644 --- a/src/app/features/schedule/schedule-week/schedule-week.component.scss +++ b/src/app/features/schedule/schedule-week/schedule-week.component.scss @@ -202,6 +202,15 @@ border: none !important; } + // Panel is a single, full-width day cell with centered content. Anchoring + // the badge absolutely to the right edge leaves a large gap from the + // centered date; let it flow as a centered flex child next to the date + // instead (the week-view multi-column layout keeps the absolute variant). + .over-budget-badge { + position: static; + transform: none; + } + .filler { display: none; } 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 c4368676eb..f37914d50f 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 @@ -29,6 +29,7 @@ import { TaskSharedActions } from '../../root-store/meta/task-shared.actions'; import { getDbDateStr } from '../../util/get-db-date-str'; import { TODAY_TAG } from '../tag/tag.const'; import { getRepeatableTaskId } from './get-repeatable-task-id.util'; +import { DateService } from '../../core/date/date.service'; describe('TaskRepeatCfgService', () => { let service: TaskRepeatCfgService; @@ -96,6 +97,14 @@ describe('TaskRepeatCfgService', () => { { provide: MatDialog, useValue: matDialogSpy }, { provide: TaskService, useValue: taskServiceSpy }, { provide: WorkContextService, useValue: workContextServiceSpy }, + { + provide: DateService, + useValue: { + todayStr: () => getDbDateStr(), + isToday: (date: number | Date) => getDbDateStr(date) === getDbDateStr(), + getStartOfNextDayDiffMs: () => 0, + }, + }, ], }); 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 f96a86632b..88642522ac 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 @@ -40,6 +40,7 @@ import { selectTaskRepeatCfgsForExactDay, } from './store/task-repeat-cfg.selectors'; import { getRepeatableTaskId } from './get-repeatable-task-id.util'; +import { getDeadlineAutoPlanFields } from '../tasks/util/get-deadline-auto-plan-fields'; @Injectable({ providedIn: 'root', @@ -293,6 +294,11 @@ export class TaskRepeatCfgService { : (this._workContextService.activeWorkContextId as string), isAddToBacklog: false, isAddToBottom, + ...getDeadlineAutoPlanFields( + this._dateService, + taskWithTargetDates.deadlineDay, + taskWithTargetDates.deadlineWithTime, + ), }), updateTaskRepeatCfg({ taskRepeatCfg: { diff --git a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.spec.ts b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.spec.ts index 00ff2a38e2..c27128bf7b 100644 --- a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.spec.ts +++ b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.spec.ts @@ -29,6 +29,8 @@ describe('DialogDeadlineComponent.submit() input validation', () => { deadlineDay?: string; deadlineWithTime?: number; deadlineRemindAt?: number; + autoPlanToday?: string; + autoPlanStartOfNextDayDiffMs?: number; }; const buildTask = (id = 'task-1'): Task => @@ -61,7 +63,14 @@ describe('DialogDeadlineComponent.submit() input validation', () => { provideMockStore({ initialState: {} }), { provide: MatDialogRef, useValue: matDialogRefSpy }, { provide: MAT_DIALOG_DATA, useValue: { task: buildTask() } }, - { provide: DateService, useValue: { isToday: () => false } }, + { + provide: DateService, + useValue: { + isToday: () => false, + todayStr: () => '2026-05-06', + getStartOfNextDayDiffMs: () => 0, + }, + }, { provide: GlobalConfigService, useValue: { localization: () => undefined, cfg: () => undefined }, @@ -92,6 +101,7 @@ describe('DialogDeadlineComponent.submit() input validation', () => { expect(calls.length).toBe(1); expect(calls[0].deadlineWithTime).toBeDefined(); expect(calls[0].deadlineDay).toBeUndefined(); + expect(calls[0].autoPlanToday).toBe('2026-05-06'); expect(matDialogRefSpy.close).toHaveBeenCalled(); }); @@ -106,6 +116,7 @@ describe('DialogDeadlineComponent.submit() input validation', () => { expect(calls.length).toBe(1); expect(calls[0].deadlineDay).toBe('2026-05-06'); expect(calls[0].deadlineWithTime).toBeUndefined(); + expect(calls[0].autoPlanToday).toBe('2026-05-06'); }); // Currently FAILS — proves issue #7490. After the fix, submit() must not diff --git a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts index 6a13e63fc2..36eb2f2d9c 100644 --- a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts +++ b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts @@ -47,6 +47,7 @@ import { MatInput } from '@angular/material/input'; import { TimeStepDirective } from '../../../ui/time-step/time-step.directive'; import { GlobalConfigService } from '../../config/global-config.service'; import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const'; +import { getDeadlineAutoPlanFields } from '../util/get-deadline-auto-plan-fields'; const DEFAULT_TIME = '09:00'; @@ -242,6 +243,7 @@ export class DialogDeadlineComponent implements AfterViewInit { taskId: this.task.id, deadlineWithTime: deadlineTimestamp, deadlineRemindAt, + ...getDeadlineAutoPlanFields(this._dateService, undefined, deadlineTimestamp), }), ); } else { @@ -252,6 +254,7 @@ export class DialogDeadlineComponent implements AfterViewInit { TaskSharedActions.setDeadline({ taskId: this.task.id, deadlineDay: newDay, + ...getDeadlineAutoPlanFields(this._dateService, newDay), }), ); } diff --git a/src/app/features/tasks/store/task-due.effects.spec.ts b/src/app/features/tasks/store/task-due.effects.spec.ts index 6fd759f344..df5bd74302 100644 --- a/src/app/features/tasks/store/task-due.effects.spec.ts +++ b/src/app/features/tasks/store/task-due.effects.spec.ts @@ -9,6 +9,7 @@ import { SyncWrapperService } from '../../../imex/sync/sync-wrapper.service'; import { AddTasksForTomorrowService } from '../../add-tasks-for-tomorrow/add-tasks-for-tomorrow.service'; import { SyncTriggerService } from '../../../imex/sync/sync-trigger.service'; import { + selectAllTasks, selectOverdueTasksOnToday, selectTasksDueForDay, selectTasksWithDueTimeForRange, @@ -49,7 +50,7 @@ describe('getOverdueIdsInTodayOrder', () => { describe('TaskDueEffects', () => { let previousTimeout: number; - const actions$: Observable = of(); + let actions$: Subject; let effects: TaskDueEffects; let store: MockStore; let globalTrackingIntervalService: { @@ -105,6 +106,7 @@ describe('TaskDueEffects', () => { }); beforeEach(() => { + actions$ = new Subject(); // Create behavior subjects to control the stream emissions const todayDateStr$ = new BehaviorSubject(todayStr); const afterCurrentSyncDoneOrSyncDisabled$ = new BehaviorSubject(true); @@ -140,6 +142,7 @@ describe('TaskDueEffects', () => { { selector: selectStartOfNextDayDiffMs, value: startOfNextDayDiffMs }, { selector: selectTasksDueForDay, value: [] }, { selector: selectTasksWithDueTimeForRange, value: [] }, + { selector: selectAllTasks, value: [] }, ], }), { @@ -455,6 +458,189 @@ describe('TaskDueEffects', () => { expect(emitted).toBe(false); subscription.unsubscribe(); })); + + it('should dispatch planDeadlineTasksForToday for task with whole-day deadline today', fakeAsync(() => { + const taskWithDeadlineDay = createTask('deadline-day-1', { + deadlineDay: todayStr, + }); + + store.overrideSelector(selectAllTasks, [taskWithDeadlineDay]); + store.overrideSelector(selectTodayTaskIds, ['other-task']); + store.overrideSelector(selectTodayTagTaskIds, ['other-task']); + store.refreshState(); + + let emittedAction: Action | undefined; + const subscription = effects.ensureTasksDueTodayInTodayTag$ + .pipe(take(1)) + .subscribe((action) => { + emittedAction = action; + }); + + globalTrackingIntervalService.todayDateStr$.next(todayStr); + syncWrapperService.afterCurrentSyncDoneOrSyncDisabled$.next(true); + tick(ENSURE_TASKS_DUE_DEBOUNCE_MS); + + expect(emittedAction).toEqual( + jasmine.objectContaining({ + taskIds: ['deadline-day-1'], + today: todayStr, + startOfNextDayDiffMs, + }), + ); + expect(emittedAction?.type).toBe(TaskSharedActions.planDeadlineTasksForToday.type); + subscription.unsubscribe(); + })); + + it('should dispatch planDeadlineTasksForToday for task with timed deadline today', fakeAsync(() => { + const todayDate = new Date(); + todayDate.setHours(12, 0, 0, 0); + const todayTimestamp = todayDate.getTime(); + const taskWithDeadlineTime = createTask('deadline-time-1', { + deadlineWithTime: todayTimestamp, + }); + + store.overrideSelector(selectAllTasks, [taskWithDeadlineTime]); + store.overrideSelector(selectTodayTaskIds, ['other-task']); + store.overrideSelector(selectTodayTagTaskIds, ['other-task']); + store.refreshState(); + + let emittedAction: Action | undefined; + const subscription = effects.ensureTasksDueTodayInTodayTag$ + .pipe(take(1)) + .subscribe((action) => { + emittedAction = action; + }); + + globalTrackingIntervalService.todayDateStr$.next(todayStr); + syncWrapperService.afterCurrentSyncDoneOrSyncDisabled$.next(true); + tick(ENSURE_TASKS_DUE_DEBOUNCE_MS); + + expect(emittedAction).toEqual( + jasmine.objectContaining({ + taskIds: ['deadline-time-1'], + today: todayStr, + startOfNextDayDiffMs, + }), + ); + expect(emittedAction?.type).toBe(TaskSharedActions.planDeadlineTasksForToday.type); + subscription.unsubscribe(); + })); + + it('should not emit when deadline task is already in TODAY tag', fakeAsync(() => { + const taskWithDeadlineDay = createTask('deadline-already-in-today', { + deadlineDay: todayStr, + dueDay: todayStr, + }); + + store.overrideSelector(selectAllTasks, [taskWithDeadlineDay]); + // Task is already in Today + store.overrideSelector(selectTodayTaskIds, ['deadline-already-in-today']); + store.overrideSelector(selectTodayTagTaskIds, ['deadline-already-in-today']); + store.refreshState(); + + let emitted = false; + const subscription = effects.ensureTasksDueTodayInTodayTag$.subscribe(() => { + emitted = true; + }); + + globalTrackingIntervalService.todayDateStr$.next(todayStr); + syncWrapperService.afterCurrentSyncDoneOrSyncDisabled$.next(true); + tick(ENSURE_TASKS_DUE_DEBOUNCE_MS); + + expect(emitted).toBe(false); + subscription.unsubscribe(); + })); + + it('should deduplicate when task appears in both deadline and due selectors', fakeAsync(() => { + // Edge case: same task ID appears in both due and deadline selectors + // (shouldn't happen in practice due to mutual exclusivity, but dedup must handle it) + const taskForDueSelector = createTaskWithDueDay('task-dual', todayStr); + const taskForDeadlineSelector = createTask('task-dual', { deadlineDay: todayStr }); + + store.overrideSelector(selectTasksDueForDay, [taskForDueSelector]); + store.overrideSelector(selectAllTasks, [taskForDeadlineSelector]); + store.overrideSelector(selectTodayTaskIds, []); + store.overrideSelector(selectTodayTagTaskIds, []); + store.refreshState(); + + let emittedAction: Action | undefined; + const subscription = effects.ensureTasksDueTodayInTodayTag$ + .pipe(take(1)) + .subscribe((action) => { + emittedAction = action; + }); + + globalTrackingIntervalService.todayDateStr$.next(todayStr); + syncWrapperService.afterCurrentSyncDoneOrSyncDisabled$.next(true); + tick(ENSURE_TASKS_DUE_DEBOUNCE_MS); + + // Only one ID despite appearing in two selectors + expect(emittedAction).toEqual( + jasmine.objectContaining({ + taskIds: ['task-dual'], + }), + ); + expect(emittedAction?.type).toBe(TaskSharedActions.planTasksForToday.type); + subscription.unsubscribe(); + })); + + it('should not emit for deadline task scheduled for a future day', fakeAsync(() => { + const taskWithDeadlineDay = createTask('deadline-future-schedule', { + deadlineDay: todayStr, + dueDay: '2999-01-01', + }); + + store.overrideSelector(selectAllTasks, [taskWithDeadlineDay]); + store.overrideSelector(selectTodayTaskIds, []); + store.overrideSelector(selectTodayTagTaskIds, []); + store.refreshState(); + + let emitted = false; + const subscription = effects.ensureTasksDueTodayInTodayTag$.subscribe(() => { + emitted = true; + }); + + globalTrackingIntervalService.todayDateStr$.next(todayStr); + syncWrapperService.afterCurrentSyncDoneOrSyncDisabled$.next(true); + tick(ENSURE_TASKS_DUE_DEBOUNCE_MS); + + expect(emitted).toBe(false); + subscription.unsubscribe(); + })); + + it('should only include parent deadline task when parent and subtask are candidates', fakeAsync(() => { + const parentTask = createTask('deadline-parent', { + deadlineDay: todayStr, + }); + const subTask = createTask('deadline-sub', { + deadlineDay: todayStr, + parentId: 'deadline-parent', + }); + + store.overrideSelector(selectAllTasks, [subTask, parentTask]); + store.overrideSelector(selectTodayTaskIds, []); + store.overrideSelector(selectTodayTagTaskIds, []); + store.refreshState(); + + let emittedAction: Action | undefined; + const subscription = effects.ensureTasksDueTodayInTodayTag$ + .pipe(take(1)) + .subscribe((action) => { + emittedAction = action; + }); + + globalTrackingIntervalService.todayDateStr$.next(todayStr); + syncWrapperService.afterCurrentSyncDoneOrSyncDisabled$.next(true); + tick(ENSURE_TASKS_DUE_DEBOUNCE_MS); + + expect(emittedAction).toEqual( + jasmine.objectContaining({ + taskIds: ['deadline-parent'], + }), + ); + expect(emittedAction?.type).toBe(TaskSharedActions.planDeadlineTasksForToday.type); + subscription.unsubscribe(); + })); }); describe('effect initialization', () => { diff --git a/src/app/features/tasks/store/task-due.effects.ts b/src/app/features/tasks/store/task-due.effects.ts index 5926f26d64..e7a7fc6e5b 100644 --- a/src/app/features/tasks/store/task-due.effects.ts +++ b/src/app/features/tasks/store/task-due.effects.ts @@ -6,15 +6,15 @@ import { distinctUntilChanged, filter, first, - map, switchMap, withLatestFrom, } from 'rxjs/operators'; 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 { Action, Store } from '@ngrx/store'; import { + selectAllTasks, selectOverdueTasksOnToday, selectTasksDueForDay, selectTasksWithDueTimeForRange, @@ -33,6 +33,7 @@ import { environment } from '../../../../environments/environment'; import { HydrationStateService } from '../../../op-log/apply/hydration-state.service'; import { waitForSyncWindow } from '../../../util/wait-for-sync-window.operator'; import { selectTodayTagTaskIds } from '../../tag/store/tag.reducer'; +import { getDeadlineAutoPlanDecision } from '../util/get-deadline-auto-plan-fields'; export const getOverdueIdsInTodayOrder = ( overdue: ReadonlyArray<{ id: string }>, @@ -185,63 +186,121 @@ export class TaskDueEffects { return combineLatest([ this._store$.select(selectTasksDueForDay, { day: todayStr }), this._store$.select(selectTasksWithDueTimeForRange, todayRange), + this._store$.select(selectAllTasks), ]).pipe( first(), - withLatestFrom(this._store$.select(selectTodayTaskIds)), - 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; - }, - ); + withLatestFrom( + this._store$.select(selectTodayTaskIds), + this._store$.select(selectTodayTagTaskIds), + ), + concatMap( + ([ + [tasksDueByDay, tasksDueByTime, allTasks], + todayTaskIds, + todayTagTaskIds, + ]) => { + const context = { today: todayStr, startOfNextDayDiffMs }; + const taskById = new Map( + allTasks.map((task) => [task.id, task] as const), + ); - const missingTaskIds = allTasksDueToday - .filter((task) => !todayTaskIds.includes(task.id)) - // Exclude subtasks whose parent is already in TODAY - // (preventParentAndSubTaskInTodayList$ will remove them anyway, - // causing an infinite add/remove loop and phantom sync changes) - .filter( - (task) => !task.parentId || !todayTaskIds.includes(task.parentId), - ) - .map((task) => task.id); - - // Debug log to investigate repeated operations - TaskLog.log('[TaskDueEffects] ensureTasksDueTodayInTodayTag check:', { - tasksDueByDayCount: tasksDueByDay.length, - tasksDueByTimeCount: tasksDueByTime.length, - tasksDueTodayIds: allTasksDueToday.map((t) => t.id), - todayTaskIdsCount: todayTaskIds.length, - missingTaskIds, - willDispatch: missingTaskIds.length > 0, - }); - - if (!environment.production && missingTaskIds.length > 0) { - TaskLog.err( - '[TaskDueEffects] Found tasks due today missing from TODAY tag:', - { - tasksDueToday: allTasksDueToday.length, - todayTaskIds: todayTaskIds.length, - missingTaskIds, + // Tasks with dueWithTime set have dueDay cleared (mutual exclusivity), + // so we must check both due selectors to catch all scheduled tasks. + const seenIds = new Set(); + const allTasksDueToday = [...tasksDueByDay, ...tasksDueByTime].filter( + (task) => { + if (seenIds.has(task.id)) return false; + seenIds.add(task.id); + return true; }, ); - } - return missingTaskIds.length > 0 - ? TaskSharedActions.planTasksForToday({ - taskIds: missingTaskIds, - today: todayStr, - startOfNextDayDiffMs, - isSkipRemoveReminder: true, - }) - : null; - }), - filter((action) => !!action), + const todayTaskIdSet = new Set(todayTaskIds); + const missingDueTaskIds = allTasksDueToday + .filter((task) => !todayTaskIdSet.has(task.id)) + // Exclude subtasks whose parent is already in TODAY + // (preventParentAndSubTaskInTodayList$ will remove them anyway, + // causing an infinite add/remove loop and phantom sync changes) + .filter( + (task) => !task.parentId || !todayTaskIdSet.has(task.parentId), + ) + .map((task) => task.id); + + const plannedTodayIds = new Set([ + ...todayTagTaskIds, + ...missingDueTaskIds, + ]); + const missingDeadlineTaskIds: string[] = []; + + for (const task of [...allTasks].sort( + (a, b) => Number(!!a.parentId) - Number(!!b.parentId), + )) { + const parentTask = task.parentId + ? taskById.get(task.parentId) + : undefined; + const decision = getDeadlineAutoPlanDecision( + task, + context, + plannedTodayIds, + parentTask, + ); + + if (decision.shouldAutoPlan) { + missingDeadlineTaskIds.push(task.id); + plannedTodayIds.add(task.id); + } + } + + // Debug log to investigate repeated operations + TaskLog.log('[TaskDueEffects] ensureTasksDueTodayInTodayTag check:', { + tasksDueByDayCount: tasksDueByDay.length, + tasksDueByTimeCount: tasksDueByTime.length, + tasksDueTodayIds: allTasksDueToday.map((t) => t.id), + todayTaskIdsCount: todayTaskIds.length, + todayTagTaskIdsCount: todayTagTaskIds.length, + missingDueTaskIds, + missingDeadlineTaskIds, + willDispatch: + missingDueTaskIds.length > 0 || missingDeadlineTaskIds.length > 0, + }); + + if ( + !environment.production && + (missingDueTaskIds.length > 0 || missingDeadlineTaskIds.length > 0) + ) { + TaskLog.err('[TaskDueEffects] Found tasks missing from TODAY tag:', { + tasksDueToday: allTasksDueToday.length, + todayTaskIds: todayTaskIds.length, + missingDueTaskIds, + missingDeadlineTaskIds, + }); + } + + const actions: Action[] = []; + if (missingDueTaskIds.length > 0) { + actions.push( + TaskSharedActions.planTasksForToday({ + taskIds: missingDueTaskIds, + today: todayStr, + startOfNextDayDiffMs, + isSkipRemoveReminder: true, + }), + ); + } + + if (missingDeadlineTaskIds.length > 0) { + actions.push( + TaskSharedActions.planDeadlineTasksForToday({ + taskIds: missingDeadlineTaskIds, + today: todayStr, + startOfNextDayDiffMs, + }), + ); + } + + return actions.length > 0 ? of(...actions) : EMPTY; + }, + ), ); }), ), diff --git a/src/app/features/tasks/store/task.reducer.spec.ts b/src/app/features/tasks/store/task.reducer.spec.ts index d50fdecfca..98ae25be1c 100644 --- a/src/app/features/tasks/store/task.reducer.spec.ts +++ b/src/app/features/tasks/store/task.reducer.spec.ts @@ -145,6 +145,23 @@ describe('Task Reducer', () => { expect(state.entities['subTask3']).toEqual({ ...newSubTask, parentId: 'task1' }); }); + it('should not duplicate parent subTaskIds when addSubTask is replayed', () => { + const newSubTask = createTask('subTask3'); + const action = fromActions.addSubTask({ + task: newSubTask, + parentId: 'task1', + }); + + const stateAfterFirstAdd = taskReducer(stateWithTasks, action); + const stateAfterReplay = taskReducer(stateAfterFirstAdd, action); + + expect(stateAfterReplay.entities['task1']!.subTaskIds).toEqual([ + 'subTask1', + 'subTask2', + 'subTask3', + ]); + }); + it('should roll up the parent time estimate when adding a subtask with an estimate', () => { const parent = createTask('parent'); const subTask = createTask('subTask', { timeEstimate: 2.5 * 60 * 60 * 1000 }); @@ -1033,6 +1050,27 @@ describe('Task Reducer', () => { }); }); + describe('loadAllData - subTaskIds normalization', () => { + it('should remove duplicate subTaskIds on load', () => { + const parent = createTask('parent', { subTaskIds: ['subTask', 'subTask'] }); + const subTask = createTask('subTask', { parentId: 'parent' }); + const appDataComplete = { + task: { + ids: ['parent', 'subTask'], + entities: { parent, subTask }, + currentTaskId: null, + selectedTaskId: null, + lastCurrentTaskId: null, + isDataLoaded: false, + }, + } as any; + + const result = taskReducer(initialTaskState, loadAllData({ appDataComplete })); + + expect(result.entities['parent']!.subTaskIds).toEqual(['subTask']); + }); + }); + describe('addSubTask - undefined timeSpentOnDay guard', () => { it('should not crash when the first subtask of a parent has undefined timeSpentOnDay', () => { // The crash at task.reducer.ts:473 only fires on the FIRST subtask of a parent diff --git a/src/app/features/tasks/store/task.reducer.ts b/src/app/features/tasks/store/task.reducer.ts index 1d1dbcfc27..3d7d331503 100644 --- a/src/app/features/tasks/store/task.reducer.ts +++ b/src/app/features/tasks/store/task.reducer.ts @@ -107,6 +107,30 @@ const normalizeTimeSpentOnDay = (state: TaskState): TaskState => { return { ...state, entities }; }; +const normalizeSubTaskIds = (state: TaskState): TaskState => { + const parentIdsWithDuplicates: string[] = []; + for (const id of state.ids as string[]) { + const task = state.entities[id]; + if (task?.subTaskIds && new Set(task.subTaskIds).size !== task.subTaskIds.length) { + parentIdsWithDuplicates.push(id); + } + } + if (parentIdsWithDuplicates.length === 0) return state; + + const entities: TaskState['entities'] = { ...state.entities }; + for (const id of parentIdsWithDuplicates) { + const task = state.entities[id]; + if (task) { + entities[id] = { + ...task, + subTaskIds: unique(task.subTaskIds), + }; + } + } + + return { ...state, entities }; +}; + // REDUCER // ------- export const initialTaskState: TaskState = taskAdapter.getInitialState({ @@ -135,13 +159,15 @@ export const taskReducer = createReducer( const sanitized = hasOrphans ? { ...task, ids: ids.filter((id) => !!task.entities[id]) } : task; - return normalizeTimeSpentOnDay({ - ...sanitized, - currentTaskId: null, - selectedTaskId: null, - lastCurrentTaskId: task.currentTaskId, - isDataLoaded: true, - } as TaskState); + return normalizeSubTaskIds( + normalizeTimeSpentOnDay({ + ...sanitized, + currentTaskId: null, + selectedTaskId: null, + lastCurrentTaskId: task.currentTaskId, + isDataLoaded: true, + } as TaskState), + ); }), on(TaskSharedActions.deleteProject, (state, { allTaskIds }) => { @@ -555,6 +581,12 @@ export const taskReducer = createReducer( state, ); + // Keep this guard at the reducer boundary: normal creators generate fresh IDs, + // but op-log replay/import or direct action dispatch can replay the same task ID. + const parentSubTaskIds = parentTask.subTaskIds.includes(task.id) + ? parentTask.subTaskIds + : [...parentTask.subTaskIds, task.id]; + const stateWithParentTask: TaskState = { ...stateCopy, // update current task to new sub task if parent was current before @@ -564,7 +596,7 @@ export const taskReducer = createReducer( ...stateCopy.entities, [parentId]: { ...parentTask, - subTaskIds: [...parentTask.subTaskIds, task.id], + subTaskIds: parentSubTaskIds, }, }, }; diff --git a/src/app/features/tasks/task.service.spec.ts b/src/app/features/tasks/task.service.spec.ts index e37e093f56..3d30a3fa46 100644 --- a/src/app/features/tasks/task.service.spec.ts +++ b/src/app/features/tasks/task.service.spec.ts @@ -279,6 +279,16 @@ describe('TaskService', () => { expect(action.task.notes).toBe('Test notes'); }); + + it('should include auto-plan context when adding a task with a deadline today', () => { + service.add('New Task', false, { deadlineDay: '2026-01-05' }); + + const dispatchCall = (store.dispatch as jasmine.Spy).calls.mostRecent(); + const action = dispatchCall.args[0] as ReturnType; + + expect(action.autoPlanToday).toBe('2026-01-05'); + expect(action.autoPlanStartOfNextDayDiffMs).toBe(0); + }); }); describe('addToToday', () => { diff --git a/src/app/features/tasks/task.service.ts b/src/app/features/tasks/task.service.ts index a43743a428..41daea68fc 100644 --- a/src/app/features/tasks/task.service.ts +++ b/src/app/features/tasks/task.service.ts @@ -104,6 +104,7 @@ import { DEFAULT_GLOBAL_CONFIG } from '../config/default-global-config.const'; import { TaskFocusService } from './task-focus.service'; import { DeletedTaskIssueSidecarService } from '../issue/two-way-sync/deleted-task-issue-sidecar.service'; import { TimeBlockDeleteSidecarService } from '../calendar-integration/time-block/time-block-delete-sidecar.service'; +import { getDeadlineAutoPlanFields } from './util/get-deadline-auto-plan-fields'; @Injectable({ providedIn: 'root', @@ -411,6 +412,11 @@ export class TaskService { workContextType, isAddToBacklog, isAddToBottom, + ...getDeadlineAutoPlanFields( + this._dateService, + task.deadlineDay, + task.deadlineWithTime, + ), }), ); return task && task.id; diff --git a/src/app/features/tasks/task/_task-controls.scss b/src/app/features/tasks/task/_task-controls.scss index 1fe4314aa9..a13d3660ee 100644 --- a/src/app/features/tasks/task/_task-controls.scss +++ b/src/app/features/tasks/task/_task-controls.scss @@ -448,27 +448,6 @@ done-toggle { } } -.task-info-chip { - display: inline-flex; - align-items: center; - gap: var(--s-quarter); - margin: 0 0 var(--s-half) 6px; - padding: 2px 6px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--text-color-muted) 32%, transparent); - background: color-mix(in srgb, var(--bg-lighter) 90%, var(--text-color-muted) 10%); - color: var(--text-color-muted); - font-size: 11px; - line-height: 1.2; - white-space: nowrap; - - mat-icon { - width: 14px; - height: 14px; - font-size: 14px; - } -} - @media (min-width: #{$layout-sm}) and (max-width: #{$layout-xl - 1px}) { .closeBtn { min-width: 40px; diff --git a/src/app/features/tasks/task/task.component.html b/src/app/features/tasks/task/task.component.html index e0e192f2e1..1ed54bf847 100644 --- a/src/app/features/tasks/task/task.component.html +++ b/src/app/features/tasks/task/task.component.html @@ -49,16 +49,6 @@ [isShowProjectTagNever]="isInSubTaskList()" > } - @if (isOutsideWorkHours()) { -
- info - {{ T.F.TASK.CMP.OUTSIDE_WORK_HOURS | translate }} -
- } diff --git a/src/app/features/tasks/task/task.component.ts b/src/app/features/tasks/task/task.component.ts index 15b3bace6d..906e7944f2 100644 --- a/src/app/features/tasks/task/task.component.ts +++ b/src/app/features/tasks/task/task.component.ts @@ -89,8 +89,6 @@ import { LayoutService } from '../../../core-ui/layout/layout.service'; import { TaskFocusService } from '../task-focus.service'; import { selectTimeConflictTaskIds } from '../store/task.selectors'; import { MatTooltip } from '@angular/material/tooltip'; -import { selectTimelineConfig } from '../../config/store/global-config.reducer'; -import { isTaskOutsideWorkHours } from '../util/is-task-outside-work-hours'; @Component({ selector: 'task', @@ -156,9 +154,6 @@ export class TaskComponent implements OnDestroy, AfterViewInit { this._store.select(selectTimeConflictTaskIds), { initialValue: new Set() }, ); - private readonly _timelineConfig = toSignal(this._store.select(selectTimelineConfig), { - initialValue: null, - }); task = input.required(); isBacklog = input(false); @@ -231,9 +226,6 @@ export class TaskComponent implements OnDestroy, AfterViewInit { typeof task.dueWithTime === 'number' && this._timeConflictTaskIds().has(task.id) ); }); - isOutsideWorkHours = computed(() => - isTaskOutsideWorkHours(this.task(), this._timelineConfig()), - ); isShowDueDayBtn = computed(() => { const dueDay = this.task().dueDay; diff --git a/src/app/features/tasks/util/get-deadline-auto-plan-fields.spec.ts b/src/app/features/tasks/util/get-deadline-auto-plan-fields.spec.ts new file mode 100644 index 0000000000..351d60bdca --- /dev/null +++ b/src/app/features/tasks/util/get-deadline-auto-plan-fields.spec.ts @@ -0,0 +1,149 @@ +import type { DateService } from '../../../core/date/date.service'; +import { DEFAULT_TASK, Task } from '../task.model'; +import { + getDeadlineAutoPlanDecision, + getDeadlineAutoPlanFields, +} from './get-deadline-auto-plan-fields'; + +describe('getDeadlineAutoPlanFields', () => { + let dateService: jasmine.SpyObj< + Pick + >; + + beforeEach(() => { + dateService = jasmine.createSpyObj('DateService', [ + 'todayStr', + 'getStartOfNextDayDiffMs', + ]); + dateService.todayStr.and.returnValue('2026-01-05'); + dateService.getStartOfNextDayDiffMs.and.returnValue(123); + }); + + it('should include auto-plan context for a whole-day deadline today', () => { + expect(getDeadlineAutoPlanFields(dateService, '2026-01-05')).toEqual({ + autoPlanToday: '2026-01-05', + autoPlanStartOfNextDayDiffMs: 123, + }); + }); + + it('should include auto-plan context for a timed deadline today', () => { + dateService.getStartOfNextDayDiffMs.and.returnValue(0); + const deadlineWithTime = new Date(2026, 0, 5, 12).getTime(); + + expect(getDeadlineAutoPlanFields(dateService, undefined, deadlineWithTime)).toEqual({ + autoPlanToday: '2026-01-05', + autoPlanStartOfNextDayDiffMs: 0, + }); + }); + + it('should return no auto-plan context for future deadlines', () => { + dateService.getStartOfNextDayDiffMs.and.returnValue(0); + const deadlineWithTime = new Date(2026, 0, 6, 12).getTime(); + + expect(getDeadlineAutoPlanFields(dateService, '2026-01-06')).toEqual({}); + expect(getDeadlineAutoPlanFields(dateService, undefined, deadlineWithTime)).toEqual( + {}, + ); + }); + + it('should let deadlineWithTime take precedence over stale deadlineDay', () => { + dateService.getStartOfNextDayDiffMs.and.returnValue(0); + const futureDeadlineWithTime = new Date(2026, 0, 6, 12).getTime(); + + expect( + getDeadlineAutoPlanFields(dateService, '2026-01-05', futureDeadlineWithTime), + ).toEqual({}); + }); +}); + +describe('getDeadlineAutoPlanDecision', () => { + const context = { + today: '2026-01-05', + startOfNextDayDiffMs: 0, + }; + + const createTask = (overrides: Partial = {}): Task => + ({ + ...DEFAULT_TASK, + id: 'task1', + title: 'Task 1', + projectId: 'project1', + created: new Date(2026, 0, 1, 12).getTime(), + deadlineDay: context.today, + ...overrides, + }) as Task; + + it('should set dueDay for an unscheduled task with a deadline today', () => { + expect(getDeadlineAutoPlanDecision(createTask(), context, new Set())).toEqual( + { + shouldAutoPlan: true, + shouldUpdateDueDay: true, + shouldClearDueWithTime: false, + shouldClearRemindAt: false, + }, + ); + }); + + it('should only add Today ordering when the task is already due today', () => { + expect( + getDeadlineAutoPlanDecision( + createTask({ dueWithTime: new Date(2026, 0, 5, 12).getTime() }), + context, + new Set(), + ), + ).toEqual({ + shouldAutoPlan: true, + shouldUpdateDueDay: false, + shouldClearDueWithTime: false, + shouldClearRemindAt: false, + }); + }); + + it('should move overdue timed tasks to dueDay today and clear dueWithTime + remindAt', () => { + expect( + getDeadlineAutoPlanDecision( + createTask({ dueWithTime: new Date(2026, 0, 4, 12).getTime() }), + context, + new Set(), + ), + ).toEqual({ + shouldAutoPlan: true, + shouldUpdateDueDay: true, + shouldClearDueWithTime: true, + shouldClearRemindAt: true, + }); + }); + + it('should skip future-scheduled tasks', () => { + expect( + getDeadlineAutoPlanDecision( + createTask({ dueWithTime: new Date(2026, 0, 6, 12).getTime() }), + context, + new Set(), + ), + ).toEqual({ + shouldAutoPlan: false, + shouldUpdateDueDay: false, + shouldClearDueWithTime: false, + shouldClearRemindAt: false, + }); + }); + + it('should skip subtasks whose parent is due today', () => { + const parentTask = createTask({ + id: 'parent', + deadlineDay: undefined, + dueDay: context.today, + }); + const subTask = createTask({ id: 'sub', parentId: 'parent' }); + + expect( + getDeadlineAutoPlanDecision(subTask, context, new Set(), parentTask), + ).toEqual({ + shouldAutoPlan: false, + shouldUpdateDueDay: false, + shouldClearDueWithTime: false, + shouldClearRemindAt: false, + }); + }); +}); diff --git a/src/app/features/tasks/util/get-deadline-auto-plan-fields.ts b/src/app/features/tasks/util/get-deadline-auto-plan-fields.ts new file mode 100644 index 0000000000..b1cbcd3e74 --- /dev/null +++ b/src/app/features/tasks/util/get-deadline-auto-plan-fields.ts @@ -0,0 +1,157 @@ +import type { DateService } from '../../../core/date/date.service'; +import type { Task } from '../task.model'; +import { getDbDateStr } from '../../../util/get-db-date-str'; + +export type DeadlineAutoPlanFields = { + autoPlanToday?: string; + autoPlanStartOfNextDayDiffMs?: number; +}; + +export type DeadlineAutoPlanContext = { + today: string; + startOfNextDayDiffMs: number; +}; + +export type DeadlineAutoPlanDecision = { + shouldAutoPlan: boolean; + shouldUpdateDueDay: boolean; + shouldClearDueWithTime: boolean; + /** + * Always mirrors `shouldClearDueWithTime`: a `remindAt` is anchored to + * `dueWithTime`, so clearing the scheduled time must also clear its reminder + * to avoid an orphaned notification firing for a time the task no longer has. + */ + shouldClearRemindAt: boolean; +}; + +const NO_AUTO_PLAN: DeadlineAutoPlanDecision = { + shouldAutoPlan: false, + shouldUpdateDueDay: false, + shouldClearDueWithTime: false, + shouldClearRemindAt: false, +}; + +const isPositiveFiniteTimestamp = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value > 0; + +const getDateStrWithOffset = ( + timestamp: number, + context: DeadlineAutoPlanContext, +): string => getDbDateStr(new Date(timestamp - context.startOfNextDayDiffMs)); + +const isDeadlineTodayForAutoPlan = ( + deadlineDay: string | null | undefined, + deadlineWithTime: number | null | undefined, + context: DeadlineAutoPlanContext, +): boolean => + isPositiveFiniteTimestamp(deadlineWithTime) + ? getDateStrWithOffset(deadlineWithTime, context) === context.today + : deadlineDay === context.today; + +const getDueScheduleDay = ( + task: Pick, + context: DeadlineAutoPlanContext, +): string | undefined => { + if (isPositiveFiniteTimestamp(task.dueWithTime)) { + return getDateStrWithOffset(task.dueWithTime, context); + } + + return task.dueDay ?? undefined; +}; + +const isTaskDueTodayBySchedule = ( + task: Pick, + context: DeadlineAutoPlanContext, +): boolean => getDueScheduleDay(task, context) === context.today; + +/** + * Decides whether a task with a deadline today should be auto-planned into + * Today, and how. Single source of truth shared by the immediate (meta-reducer) + * and defensive (date-rollover effect) paths so both behave identically. + * + * Policy (deliberate, see issue #7488 / PR #7650 review): + * - Done task, or deadline not today → skip. + * - Subtask whose parent is already in Today / due today → skip (parent carries it). + * - Already due today by schedule → add to Today order only; never touch + * `dueDay` / `dueWithTime` / `remindAt`. + * - No due date/time → set `dueDay = today`, add to Today. + * - Overdue (scheduled before today) → move to `dueDay = today`; clear a stale + * `dueWithTime` and its now-orphaned `remindAt`. + * - Scheduled for a FUTURE day/time → skip. Honoring the user's explicit + * forward plan is intentionally preferred over the today deadline. + */ +export const getDeadlineAutoPlanDecision = ( + task: Task, + context: DeadlineAutoPlanContext, + todayTaskIds: ReadonlySet, + parentTask?: Task, +): DeadlineAutoPlanDecision => { + if ( + task.isDone || + !isDeadlineTodayForAutoPlan(task.deadlineDay, task.deadlineWithTime, context) + ) { + return NO_AUTO_PLAN; + } + + if ( + task.parentId && + (todayTaskIds.has(task.parentId) || + (parentTask && isTaskDueTodayBySchedule(parentTask, context))) + ) { + return NO_AUTO_PLAN; + } + + const isInTodayOrder = todayTaskIds.has(task.id); + + if (isTaskDueTodayBySchedule(task, context)) { + return isInTodayOrder + ? NO_AUTO_PLAN + : { + shouldAutoPlan: true, + shouldUpdateDueDay: false, + shouldClearDueWithTime: false, + shouldClearRemindAt: false, + }; + } + + const dueScheduleDay = getDueScheduleDay(task, context); + + if (!dueScheduleDay) { + return { + shouldAutoPlan: true, + shouldUpdateDueDay: true, + shouldClearDueWithTime: false, + shouldClearRemindAt: false, + }; + } + + if (dueScheduleDay < context.today) { + const shouldClearDueWithTime = isPositiveFiniteTimestamp(task.dueWithTime); + return { + shouldAutoPlan: true, + shouldUpdateDueDay: true, + shouldClearDueWithTime, + shouldClearRemindAt: shouldClearDueWithTime, + }; + } + + return NO_AUTO_PLAN; +}; + +export const getDeadlineAutoPlanFields = ( + dateService: Pick, + deadlineDay?: string | null, + deadlineWithTime?: number | null, +): DeadlineAutoPlanFields => { + const context: DeadlineAutoPlanContext = { + today: dateService.todayStr(), + startOfNextDayDiffMs: dateService.getStartOfNextDayDiffMs(), + }; + + return isDeadlineTodayForAutoPlan(deadlineDay, deadlineWithTime, context) + ? { + autoPlanToday: context.today, + autoPlanStartOfNextDayDiffMs: context.startOfNextDayDiffMs, + } + : {}; +}; diff --git a/src/app/features/work-view/work-view.component.html b/src/app/features/work-view/work-view.component.html index 20e55b34cf..9e48dbd4e6 100644 --- a/src/app/features/work-view/work-view.component.html +++ b/src/app/features/work-view/work-view.component.html @@ -18,7 +18,6 @@ >
hourglass_empty - @if (isEstimateRemainingOverloaded()) { - - info - {{ T.WW.ESTIMATE_REMAINING_OVER_8H | translate }} - - }
{{ T.WW.WORKING_TODAY | translate }} diff --git a/src/app/features/work-view/work-view.component.scss b/src/app/features/work-view/work-view.component.scss index 50f208982d..7cbd28039f 100644 --- a/src/app/features/work-view/work-view.component.scss +++ b/src/app/features/work-view/work-view.component.scss @@ -87,11 +87,6 @@ h2 { margin: 0 calc(1.5 * var(--s)); text-align: center; position: relative; - - &.is-info .time-val, - &.is-info mat-icon { - color: var(--text-color-muted); - } } .controls { @@ -110,26 +105,6 @@ h2 { align-items: center; justify-content: center; } - - .status-info-chip { - display: inline-flex; - align-items: center; - gap: var(--s-quarter); - margin-top: var(--s-half); - padding: 2px 8px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--text-color-muted) 32%, transparent); - background: color-mix(in srgb, var(--bg-lighter) 90%, var(--text-color-muted) 10%); - color: var(--text-color-muted); - font-size: 11px; - line-height: 1.2; - - mat-icon { - width: 14px; - height: 14px; - font-size: 14px; - } - } } .take-a-break-reset-btn { diff --git a/src/app/features/work-view/work-view.component.ts b/src/app/features/work-view/work-view.component.ts index 55a0932f26..b5b668e251 100644 --- a/src/app/features/work-view/work-view.component.ts +++ b/src/app/features/work-view/work-view.component.ts @@ -85,7 +85,6 @@ import { RepeatCfgPreviewComponent } from '../task-repeat-cfg/repeat-cfg-preview import { recordSearchNavDebug } from '../../util/search-nav-debug'; import { dragDelayForTouch } from '../../util/input-intent'; import { DateService } from '../../core/date/date.service'; -import { DEFAULT_WORK_HOURS } from '../planner/util/calculate-available-hours'; @Component({ selector: 'work-view', @@ -195,9 +194,6 @@ export class WorkViewComponent implements OnInit, OnDestroy { () => !this.customizerService.isCustomized() && this.repeatCfgsForContext().length > 0, ); - isEstimateRemainingOverloaded = computed( - () => this.estimateRemainingToday() > DEFAULT_WORK_HOURS, - ); // Section Logic sections = toSignal( diff --git a/src/app/op-log/core/action-types.enum.spec.ts b/src/app/op-log/core/action-types.enum.spec.ts index a96c73e050..50c4e6a101 100644 --- a/src/app/op-log/core/action-types.enum.spec.ts +++ b/src/app/op-log/core/action-types.enum.spec.ts @@ -12,8 +12,8 @@ describe('ActionType enum', () => { const enumValues = Object.values(ActionType) as string[]; const mappingKeys = Object.keys(ACTION_TYPE_TO_CODE); - it('should have exactly 142 members', () => { - expect(enumValues.length).toBe(142); + it('should have exactly 143 members', () => { + expect(enumValues.length).toBe(143); }); it('should have 1:1 correspondence with ACTION_TYPE_TO_CODE', () => { @@ -42,6 +42,9 @@ describe('ActionType enum', () => { expect(ActionType.TASK_SHARED_ADD).toBe('[Task Shared] addTask'); expect(ActionType.TASK_SHARED_UPDATE).toBe('[Task Shared] updateTask'); expect(ActionType.TASK_SHARED_DELETE).toBe('[Task Shared] deleteTask'); + expect(ActionType.TASK_SHARED_PLAN_DEADLINE_FOR_TODAY).toBe( + '[Task Shared] planDeadlineTasksForToday', + ); }); it('should have correct Tag action types', () => { diff --git a/src/app/op-log/core/action-types.enum.ts b/src/app/op-log/core/action-types.enum.ts index e08227fd8f..e5cb817d2f 100644 --- a/src/app/op-log/core/action-types.enum.ts +++ b/src/app/op-log/core/action-types.enum.ts @@ -71,6 +71,7 @@ export enum ActionType { TASK_SHARED_DELETE_REPEAT_CFG = '[Task Shared] deleteTaskRepeatCfg', TASK_SHARED_APPLY_SHORT_SYNTAX = '[Task Shared] applyShortSyntax', TASK_SHARED_SET_DEADLINE = '[Task Shared] setDeadline', + TASK_SHARED_PLAN_DEADLINE_FOR_TODAY = '[Task Shared] planDeadlineTasksForToday', TASK_SHARED_REMOVE_DEADLINE = '[Task Shared] removeDeadline', TASK_SHARED_CLEAR_DEADLINE_REMINDER = '[Task Shared] clearDeadlineReminder', diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-deadline.reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-deadline.reducer.spec.ts index 8d69ae8b7a..73a85d13f5 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-deadline.reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-deadline.reducer.spec.ts @@ -241,6 +241,274 @@ describe('taskSharedDeadlineMetaReducer', () => { testState, ); }); + + describe('auto-planning when deadline is today', () => { + const todayStr = '2024-05-18'; + const autoPlanContext = { + autoPlanToday: todayStr, + autoPlanStartOfNextDayDiffMs: 0, + }; + + it('should add to Today and set dueDay if not scheduled', () => { + const testState = createStateWithExistingTasks(['task1'], [], [], []); + + const action = TaskSharedActions.setDeadline({ + taskId: 'task1', + deadlineDay: todayStr, + ...autoPlanContext, + }); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1 as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(updatedTask.dueDay).toBe(todayStr); + expect(todayTag.taskIds).toContain('task1'); + }); + + it('should not auto-plan legacy actions without persisted auto-plan context', () => { + const testState = createStateWithExistingTasks(['task1'], [], [], []); + + const action = TaskSharedActions.setDeadline({ + taskId: 'task1', + deadlineDay: todayStr, + }); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1 as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(updatedTask.deadlineDay).toBe(todayStr); + expect(updatedTask.dueDay).toBeUndefined(); + expect(todayTag.taskIds).not.toContain('task1'); + }); + + it('should add to Today and keep schedule if already scheduled for today', () => { + const testState = createStateWithExistingTasks(['task1'], [], [], []); + testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({ + id: 'task1', + dueDay: todayStr, + }); + + const action = TaskSharedActions.setDeadline({ + taskId: 'task1', + deadlineDay: todayStr, + ...autoPlanContext, + }); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1 as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(updatedTask.dueDay).toBe(todayStr); + expect(todayTag.taskIds).toContain('task1'); + }); + + it('should preserve dueWithTime and remindAt if already scheduled with time today', () => { + const testState = createStateWithExistingTasks(['task1'], [], [], []); + const dueWithTime = new Date(2024, 4, 18, 12).getTime(); + const remindAt = new Date(2024, 4, 18, 11).getTime(); + testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({ + id: 'task1', + dueWithTime, + remindAt, + }); + + const action = TaskSharedActions.setDeadline({ + taskId: 'task1', + deadlineDay: todayStr, + ...autoPlanContext, + }); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1 as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(updatedTask.dueWithTime).toBe(dueWithTime); + expect(updatedTask.dueDay).toBeUndefined(); + expect(updatedTask.remindAt).toBe(remindAt); + expect(todayTag.taskIds).toContain('task1'); + }); + + it('should clear dueWithTime, remindAt, set dueDay, and add to Today if overdue', () => { + const testState = createStateWithExistingTasks(['task1'], [], [], []); + const pastTimestamp = new Date(2024, 0, 1, 10).getTime(); + testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({ + id: 'task1', + dueWithTime: pastTimestamp, + remindAt: pastTimestamp, // orphaned once dueWithTime cleared -> must clear + }); + + const action = TaskSharedActions.setDeadline({ + taskId: 'task1', + deadlineDay: todayStr, + ...autoPlanContext, + }); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1 as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(updatedTask.dueDay).toBe(todayStr); + expect(updatedTask.dueWithTime).toBeUndefined(); + expect(updatedTask.remindAt).toBeUndefined(); + expect(todayTag.taskIds).toContain('task1'); + }); + + it('should SKIP auto-planning if task is scheduled for a FUTURE day/time', () => { + const testState = createStateWithExistingTasks(['task1'], [], [], []); + + // Future schedule + const futureStr = '2025-01-01'; + testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({ + id: 'task1', + dueDay: futureStr, + }); + + const action = TaskSharedActions.setDeadline({ + taskId: 'task1', + deadlineDay: todayStr, + ...autoPlanContext, + }); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1 as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + // Should not have auto-planned (dueDay stays future, not in TODAY) + expect(updatedTask.dueDay).toBe(futureStr); + expect(todayTag?.taskIds || []).not.toContain('task1'); + }); + + it('should skip done tasks', () => { + const testState = createStateWithExistingTasks(['task1'], [], [], []); + testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({ + id: 'task1', + isDone: true, + }); + + const action = TaskSharedActions.setDeadline({ + taskId: 'task1', + deadlineDay: todayStr, + ...autoPlanContext, + }); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1 as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(updatedTask.dueDay).toBeUndefined(); + expect(todayTag.taskIds).not.toContain('task1'); + }); + + it('should skip subtasks when their parent is due today', () => { + const testState = createStateWithExistingTasks(['parent', 'sub'], [], [], []); + testState[TASK_FEATURE_NAME].entities.parent = createMockTask({ + id: 'parent', + dueDay: todayStr, + }); + testState[TASK_FEATURE_NAME].entities.sub = createMockTask({ + id: 'sub', + parentId: 'parent', + }); + + const action = TaskSharedActions.setDeadline({ + taskId: 'sub', + deadlineDay: todayStr, + ...autoPlanContext, + }); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.sub as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(updatedTask.dueDay).toBeUndefined(); + expect(todayTag.taskIds).not.toContain('sub'); + }); + }); + }); + + describe('planDeadlineTasksForToday action', () => { + const todayStr = '2024-05-18'; + const createAction = ( + taskIds: string[], + ): ReturnType => + TaskSharedActions.planDeadlineTasksForToday({ + taskIds, + today: todayStr, + startOfNextDayDiffMs: 0, + }); + + it('should atomically plan unscheduled tasks with deadline today', () => { + const testState = createStateWithExistingTasks(['task1'], [], [], []); + testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({ + id: 'task1', + deadlineDay: todayStr, + }); + + const action = createAction(['task1']); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1 as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(updatedTask.dueDay).toBe(todayStr); + expect(todayTag.taskIds).toContain('task1'); + }); + + it('should skip future-scheduled tasks', () => { + const testState = createStateWithExistingTasks(['task1'], [], [], []); + testState[TASK_FEATURE_NAME].entities.task1 = createMockTask({ + id: 'task1', + deadlineDay: todayStr, + dueDay: '2024-06-01', + }); + + const action = createAction(['task1']); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const updatedTask = updatedState[TASK_FEATURE_NAME].entities.task1 as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(updatedTask.dueDay).toBe('2024-06-01'); + expect(todayTag.taskIds).not.toContain('task1'); + }); + + it('should process parents before subtasks', () => { + const testState = createStateWithExistingTasks(['parent', 'sub'], [], [], []); + testState[TASK_FEATURE_NAME].entities.parent = createMockTask({ + id: 'parent', + deadlineDay: todayStr, + }); + testState[TASK_FEATURE_NAME].entities.sub = createMockTask({ + id: 'sub', + parentId: 'parent', + deadlineDay: todayStr, + }); + + const action = createAction(['sub', 'parent']); + + metaReducer(testState, action); + const updatedState = mockReducer.calls.mostRecent().args[0]; + const parent = updatedState[TASK_FEATURE_NAME].entities.parent as Task; + const sub = updatedState[TASK_FEATURE_NAME].entities.sub as Task; + const todayTag = updatedState.tag.entities['TODAY']; + + expect(parent.dueDay).toBe(todayStr); + expect(sub.dueDay).toBeUndefined(); + expect(todayTag.taskIds).toContain('parent'); + expect(todayTag.taskIds).not.toContain('sub'); + }); }); describe('removeDeadline action', () => { diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-deadline.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-deadline.reducer.ts index 27abc05fd5..8fc099f8c9 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-deadline.reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-deadline.reducer.ts @@ -8,17 +8,99 @@ import { import { Task } from '../../../features/tasks/task.model'; import { ActionHandlerMap } from './task-shared-helpers'; import { isDBDateStr } from '../../../util/get-db-date-str'; +import { TODAY_TAG } from '../../../features/tag/tag.const'; +import { getTag, updateTags, removeTaskFromPlannerDays } from './task-shared-helpers'; +import { unique } from '../../../util/unique'; +import { getDeadlineAutoPlanDecision } from '../../../features/tasks/util/get-deadline-auto-plan-fields'; +import type { DeadlineAutoPlanContext } from '../../../features/tasks/util/get-deadline-auto-plan-fields'; // ============================================================================= // ACTION HANDLERS // ============================================================================= +const getAutoPlanContext = ( + today?: string, + startOfNextDayDiffMs?: number, +): DeadlineAutoPlanContext | undefined => + today && isDBDateStr(today) && Number.isFinite(startOfNextDayDiffMs) + ? { today, startOfNextDayDiffMs: startOfNextDayDiffMs as number } + : undefined; + +const autoPlanTaskDueToDeadline = ( + state: RootState, + taskId: string, + context?: DeadlineAutoPlanContext, +): RootState => { + if (!context) return state; + + const task = state[TASK_FEATURE_NAME].entities[taskId] as Task; + if (!task) return state; + + const todayTag = getTag(state, TODAY_TAG.id); + const parentTask = task.parentId + ? (state[TASK_FEATURE_NAME].entities[task.parentId] as Task | undefined) + : undefined; + const decision = getDeadlineAutoPlanDecision( + task, + context, + new Set(todayTag.taskIds), + parentTask, + ); + + if (!decision.shouldAutoPlan) { + return state; + } + + let updatedState = state; + + if ( + decision.shouldUpdateDueDay || + decision.shouldClearDueWithTime || + decision.shouldClearRemindAt + ) { + updatedState = { + ...updatedState, + [TASK_FEATURE_NAME]: taskAdapter.updateOne( + { + id: taskId, + changes: { + ...(decision.shouldUpdateDueDay ? { dueDay: context.today } : {}), + ...(decision.shouldClearDueWithTime ? { dueWithTime: undefined } : {}), + ...(decision.shouldClearRemindAt ? { remindAt: undefined } : {}), + }, + }, + updatedState[TASK_FEATURE_NAME], + ), + }; + + if (decision.shouldUpdateDueDay) { + updatedState = removeTaskFromPlannerDays(updatedState, taskId); + } + } + + // Add to TODAY_TAG + if (!getTag(updatedState, TODAY_TAG.id).taskIds.includes(taskId)) { + updatedState = updateTags(updatedState, [ + { + id: TODAY_TAG.id, + changes: { + taskIds: unique([taskId, ...getTag(updatedState, TODAY_TAG.id).taskIds]), + }, + }, + ]); + } + + return updatedState; +}; + const handleSetDeadline = ( state: RootState, taskId: string, deadlineDay?: string, deadlineWithTime?: number, deadlineRemindAt?: number, + autoPlanToday?: string, + autoPlanStartOfNextDayDiffMs?: number, ): RootState => { const currentTask = state[TASK_FEATURE_NAME].entities[taskId] as Task; if (!currentTask) return state; @@ -37,7 +119,7 @@ const handleSetDeadline = ( return state; } - return { + const updatedState = { ...state, [TASK_FEATURE_NAME]: taskAdapter.updateOne( { @@ -52,8 +134,24 @@ const handleSetDeadline = ( state[TASK_FEATURE_NAME], ), }; + + return autoPlanTaskDueToDeadline( + updatedState, + taskId, + getAutoPlanContext(autoPlanToday, autoPlanStartOfNextDayDiffMs), + ); }; +const sortTaskIdsForDeadlinePlanning = ( + state: RootState, + taskIds: readonly string[], +): string[] => + [...taskIds].sort((a, b) => { + const taskA = state[TASK_FEATURE_NAME].entities[a] as Task | undefined; + const taskB = state[TASK_FEATURE_NAME].entities[b] as Task | undefined; + return Number(!!taskA?.parentId) - Number(!!taskB?.parentId); + }); + const handleRemoveDeadline = (state: RootState, taskId: string): RootState => { const currentTask = state[TASK_FEATURE_NAME].entities[taskId] as Task; if (!currentTask) return state; @@ -97,15 +195,46 @@ const handleClearDeadlineReminder = (state: RootState, taskId: string): RootStat // ============================================================================= const createActionHandlers = (state: RootState, action: Action): ActionHandlerMap => ({ + [TaskSharedActions.addTask.type]: () => { + const { task, autoPlanToday, autoPlanStartOfNextDayDiffMs } = action as ReturnType< + typeof TaskSharedActions.addTask + >; + if (task.deadlineDay || task.deadlineWithTime !== undefined) { + return autoPlanTaskDueToDeadline( + state, + task.id, + getAutoPlanContext(autoPlanToday, autoPlanStartOfNextDayDiffMs), + ); + } + return state; + }, [TaskSharedActions.setDeadline.type]: () => { - const { taskId, deadlineDay, deadlineWithTime, deadlineRemindAt } = - action as ReturnType; + const { + taskId, + deadlineDay, + deadlineWithTime, + deadlineRemindAt, + autoPlanToday, + autoPlanStartOfNextDayDiffMs, + } = action as ReturnType; return handleSetDeadline( state, taskId, deadlineDay, deadlineWithTime, deadlineRemindAt, + autoPlanToday, + autoPlanStartOfNextDayDiffMs, + ); + }, + [TaskSharedActions.planDeadlineTasksForToday.type]: () => { + const { taskIds, today, startOfNextDayDiffMs } = action as ReturnType< + typeof TaskSharedActions.planDeadlineTasksForToday + >; + const context = getAutoPlanContext(today, startOfNextDayDiffMs); + return sortTaskIdsForDeadlinePlanning(state, taskIds).reduce( + (updatedState, taskId) => autoPlanTaskDueToDeadline(updatedState, taskId, context), + state, ); }, [TaskSharedActions.removeDeadline.type]: () => { diff --git a/src/app/root-store/meta/task-shared-meta-reducers/test-helpers.ts b/src/app/root-store/meta/task-shared-meta-reducers/test-helpers.ts index d5e24f6465..0b3be2f710 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/test-helpers.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/test-helpers.ts @@ -2,6 +2,7 @@ import { Action, ActionReducer } from '@ngrx/store'; import { taskSharedCrudMetaReducer } from './task-shared-crud.reducer'; import { taskSharedLifecycleMetaReducer } from './task-shared-lifecycle.reducer'; import { taskSharedSchedulingMetaReducer } from './task-shared-scheduling.reducer'; +import { taskSharedDeadlineMetaReducer } from './task-shared-deadline.reducer'; import { projectSharedMetaReducer } from './project-shared.reducer'; import { tagSharedMetaReducer } from './tag-shared.reducer'; import { plannerSharedMetaReducer } from './planner-shared.reducer'; @@ -16,14 +17,15 @@ import { Task } from '../../../features/tasks/task.model'; export const createCombinedTaskSharedMetaReducer = ( reducer: ActionReducer, ): ActionReducer => { - // Apply meta-reducers in the same order as in main.ts + // Wrap in reverse registry order so execution matches META_REDUCERS. let combinedReducer = reducer; - combinedReducer = taskSharedCrudMetaReducer(combinedReducer); - combinedReducer = taskSharedLifecycleMetaReducer(combinedReducer); - combinedReducer = taskSharedSchedulingMetaReducer(combinedReducer); - combinedReducer = projectSharedMetaReducer(combinedReducer); - combinedReducer = tagSharedMetaReducer(combinedReducer); combinedReducer = plannerSharedMetaReducer(combinedReducer); + combinedReducer = tagSharedMetaReducer(combinedReducer); + combinedReducer = projectSharedMetaReducer(combinedReducer); + combinedReducer = taskSharedDeadlineMetaReducer(combinedReducer); + combinedReducer = taskSharedSchedulingMetaReducer(combinedReducer); + combinedReducer = taskSharedLifecycleMetaReducer(combinedReducer); + combinedReducer = taskSharedCrudMetaReducer(combinedReducer); return combinedReducer; }; diff --git a/src/app/root-store/meta/task-shared.actions.ts b/src/app/root-store/meta/task-shared.actions.ts index 03d74ea9b3..cb9ed1863c 100644 --- a/src/app/root-store/meta/task-shared.actions.ts +++ b/src/app/root-store/meta/task-shared.actions.ts @@ -23,6 +23,8 @@ export const TaskSharedActions = createActionGroup({ isAddToBacklog: boolean; isAddToBottom: boolean; isIgnoreShortSyntax?: boolean; + autoPlanToday?: string; + autoPlanStartOfNextDayDiffMs?: number; }) => ({ ...taskProps, meta: { @@ -183,6 +185,8 @@ export const TaskSharedActions = createActionGroup({ deadlineDay?: string; deadlineWithTime?: number; deadlineRemindAt?: number; + autoPlanToday?: string; + autoPlanStartOfNextDayDiffMs?: number; }) => ({ ...taskProps, meta: { @@ -193,6 +197,21 @@ export const TaskSharedActions = createActionGroup({ } satisfies PersistentActionMeta, }), + planDeadlineTasksForToday: (taskProps: { + taskIds: string[]; + today: string; + startOfNextDayDiffMs: number; + }) => ({ + ...taskProps, + meta: { + isPersistent: true, + entityType: 'TASK', + entityIds: taskProps.taskIds, + opType: OpType.Update, + isBulk: true, + } satisfies PersistentActionMeta, + }), + removeDeadline: (taskProps: { taskId: string }) => ({ ...taskProps, meta: { diff --git a/src/app/root-store/meta/task-shared.reducer.spec.ts b/src/app/root-store/meta/task-shared.reducer.spec.ts index d3a694d358..42b136fe8f 100644 --- a/src/app/root-store/meta/task-shared.reducer.spec.ts +++ b/src/app/root-store/meta/task-shared.reducer.spec.ts @@ -263,6 +263,22 @@ describe('taskSharedMetaReducer', () => { ); }); + it('should auto-plan newly added tasks with a deadline today in the same action', () => { + const today = getDbDateStr(); + const action = createAddTaskAction( + { deadlineDay: today }, + { autoPlanToday: today, autoPlanStartOfNextDayDiffMs: 0 }, + ); + + expectStateUpdate( + { + ...expectTaskUpdate('task1', { deadlineDay: today, dueDay: today }), + ...expectTagUpdate('TODAY', { taskIds: ['task1'] }), + }, + action, + ); + }); + it('should create task entity with correct properties', () => { const taskData = { id: 'task1', diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 8e8911d6fb..2231d862b5 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1671,7 +1671,6 @@ const T = { OPEN_ATTACH: 'F.TASK.CMP.OPEN_ATTACH', OPEN_ISSUE: 'F.TASK.CMP.OPEN_ISSUE', OPEN_TIME: 'F.TASK.CMP.OPEN_TIME', - OUTSIDE_WORK_HOURS: 'F.TASK.CMP.OUTSIDE_WORK_HOURS', REMOVE_DEADLINE: 'F.TASK.CMP.REMOVE_DEADLINE', REMOVE_FROM_MY_DAY: 'F.TASK.CMP.REMOVE_FROM_MY_DAY', SCHEDULE: 'F.TASK.CMP.SCHEDULE', @@ -2908,7 +2907,6 @@ const T = { DONE_TASKS: 'WW.DONE_TASKS', DONE_TASKS_IN_ARCHIVE: 'WW.DONE_TASKS_IN_ARCHIVE', ESTIMATE_REMAINING: 'WW.ESTIMATE_REMAINING', - ESTIMATE_REMAINING_OVER_8H: 'WW.ESTIMATE_REMAINING_OVER_8H', FINISH_DAY: 'WW.FINISH_DAY', FINISH_DAY_TOOLTIP: 'WW.FINISH_DAY_TOOLTIP', LATER_TODAY: 'WW.LATER_TODAY', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2486730385..537304769a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1633,7 +1633,6 @@ "OPEN_ATTACH": "Attach file or link", "OPEN_ISSUE": "Open in browser", "OPEN_TIME": "Time tracking", - "OUTSIDE_WORK_HOURS": "Outside work hours", "REMOVE_DEADLINE": "Remove deadline", "REMOVE_FROM_MY_DAY": "Remove from Today", "SCHEDULE": "Schedule task", @@ -2854,7 +2853,6 @@ "DONE_TASKS": "Completed Tasks", "DONE_TASKS_IN_ARCHIVE": "There are currently no completed tasks here, but there are some already archived.", "ESTIMATE_REMAINING": "Estimate remaining:", - "ESTIMATE_REMAINING_OVER_8H": "Over 8h", "FINISH_DAY": "Finish Day", "FINISH_DAY_TOOLTIP": "Evaluate your day, move all completed tasks to the archive (optionally) and/or plan your next day.", "LATER_TODAY": "Later Today",