feat(tasks): auto add tasks with deadlines today to Today view (#7650)

* feat(tasks): auto add tasks with deadlines today to Today view

* fix(tasks): preserve schedule when completing tasks

* fix(tasks): cover scheduled completion edge cases

* chore(deps)(deps): bump cloudflare/wrangler-action from 3.15.0 to 4.0.0 (#7653)

Bumps [cloudflare/wrangler-action](https://github.com/cloudflare/wrangler-action) from 3.15.0 to 4.0.0.
- [Release notes](https://github.com/cloudflare/wrangler-action/releases)
- [Changelog](https://github.com/cloudflare/wrangler-action/blob/main/CHANGELOG.md)
- [Commits](9acf94ace1...ebbaa15849)

---
updated-dependencies:
- dependency-name: cloudflare/wrangler-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps)(deps): bump the github-actions-minor group with 4 updates (#7652)

Bumps the github-actions-minor group with 4 updates: [step-security/harden-runner](https://github.com/step-security/harden-runner), [browser-actions/setup-chrome](https://github.com/browser-actions/setup-chrome), [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) and [github/codeql-action](https://github.com/github/codeql-action).


Updates `step-security/harden-runner` from 2.19.1 to 2.19.3
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](a5ad31d6a1...ab7a9404c0)

Updates `browser-actions/setup-chrome` from 2.1.1 to 2.1.2
- [Release notes](https://github.com/browser-actions/setup-chrome/releases)
- [Changelog](https://github.com/browser-actions/setup-chrome/blob/master/CHANGELOG.md)
- [Commits](4f8e94349a...2e1d749697)

Updates `anthropics/claude-code-action` from 1.0.111 to 1.0.123
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](fefa07e9c6...51ea8ea73a)

Updates `github/codeql-action` from 4.35.3 to 4.35.5
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](e46ed2cbd0...9e0d7b8d25)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.19.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
- dependency-name: browser-actions/setup-chrome
  dependency-version: 2.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.123
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
- dependency-name: github/codeql-action
  dependency-version: 4.35.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(plugins): allow iframe-only plugin installs

* fix(config): validate start of next day boundary

* fix(sync): support separate Nextcloud login name

Refs #7617

* test(tasks): add scheduled completion round trips

* fix(focus-mode): allow active timers to switch to flowtime

* test(tasks): expand completion replay matrix

* perf(sync): use indexed findFirst for op-download minSeq

Prisma operation.aggregate({ _min: { serverSeq } }) compiles to MIN()
over a `SELECT ... OFFSET 0` subquery. The OFFSET is a Postgres planner
optimization fence, so the (user_id, server_seq) index could not serve a
first-row seek and the query degraded to a per-user O(N) scan. Under a
client reconnect stampede this ran minutes inside the 60s interactive
download transaction, blowing the tx timeout (500s) and exhausting the
connection pool (cascading upload+download failures).

Replace it with findFirst ordered by serverSeq asc, which compiles to
ORDER BY server_seq ASC LIMIT 1 — a guaranteed index seek on the existing
unique (user_id, server_seq) index. Behaviour-preserving: same minSeq
value and same null-when-empty semantics.

Update both consuming specs (operation-download.service, gap-detection)
to the two-findFirst-call shape and add a regression test asserting the
indexed query is used and the aggregate path is not reintroduced.

* fix(supersync): use 127.0.0.1 in caddy healthcheck to avoid ::1 refusal

busybox wget in caddy:2.11-alpine resolves `localhost` to ::1 first, but Caddy binds its admin API to IPv4 127.0.0.1:2019. The healthcheck got "connection refused" and marked a healthy Caddy (serving prod traffic with valid TLS) as unhealthy, making deploy.sh report a false "Container startup failed\!".

* test(focus-mode): update skip break e2e assertion

* test(focus-mode): update skip break expectation

* docs(supersync): align caddy admin healthcheck note to 127.0.0.1

Comment-only follow-up to the docker-compose healthcheck fix: the Caddyfile note still said localhost:2019; align it to the literal IP the probe now uses.

* test(focus-mode): restore mode selector locator

* refactor(tasks): make deadline auto-planning atomic

* test(focus-mode): restore mode selector locator

* fix(tasks): prevent duplicate subtask links

* fix(tasks): remove noisy warning chips

* fix(schedule): position over-budget badge correctly in day panel

* feat(tasks): auto add tasks with deadlines today to Today view

* refactor(tasks): make deadline auto-planning atomic

* Checkpoint deadline auto-plan review work

* fix(tasks): make deadline auto-planning atomic

* fix(tasks): clear orphaned remindAt when deadline auto-plan clears dueWithTime

Overdue tasks moved to Today via deadline auto-planning had their
dueWithTime cleared but kept remindAt, leaving a reminder anchored to a
time the task no longer has. Clear remindAt in the same atomic reducer
pass (one op), matching the planTasksForToday/dismissReminderOnly
convention. Document the auto-plan policy and deliberate decisions on
getDeadlineAutoPlanDecision.

* perf(tasks): O(1) Today-membership lookups in deadline auto-plan

Multi-review follow-up:
- getDeadlineAutoPlanDecision takes ReadonlySet instead of array; the
  defensive effect now passes its accumulating Set directly and hoists a
  Set for the due-task filters, removing the O(N*M) array-includes scan
  on the date-rollover path.
- Drop redundant isTodayWithOffset import; reuse the local
  getDateStrWithOffset helper (identical under the positive-finite
  timestamp guard).
- Un-export isTaskDueTodayBySchedule (file-internal only).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Het Savani 2026-05-19 00:57:40 +05:30 committed by GitHub
parent 472e98471c
commit b80e81f3e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1310 additions and 198 deletions

148
package-lock.json generated
View file

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

View file

@ -92,6 +92,7 @@ export const ACTION_TYPE_TO_CODE: Record<ActionType, string> = {
[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',

View file

@ -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;

View file

@ -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<Project[]> = 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,
),
}),
);

View file

@ -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;
}

View file

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

View file

@ -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: {

View file

@ -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

View file

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

View file

@ -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<Action> = of();
let actions$: Subject<Action>;
let effects: TaskDueEffects;
let store: MockStore;
let globalTrackingIntervalService: {
@ -105,6 +106,7 @@ describe('TaskDueEffects', () => {
});
beforeEach(() => {
actions$ = new Subject<Action>();
// Create behavior subjects to control the stream emissions
const todayDateStr$ = new BehaviorSubject<string>(todayStr);
const afterCurrentSyncDoneOrSyncDisabled$ = new BehaviorSubject<boolean>(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', () => {

View file

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

View file

@ -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

View file

@ -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<TaskState>(
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<TaskState>(
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<TaskState>(
...stateCopy.entities,
[parentId]: {
...parentTask,
subTaskIds: [...parentTask.subTaskIds, task.id],
subTaskIds: parentSubTaskIds,
},
},
};

View file

@ -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<typeof TaskSharedActions.addTask>;
expect(action.autoPlanToday).toBe('2026-01-05');
expect(action.autoPlanStartOfNextDayDiffMs).toBe(0);
});
});
describe('addToToday', () => {

View file

@ -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;

View file

@ -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;

View file

@ -49,16 +49,6 @@
[isShowProjectTagNever]="isInSubTaskList()"
></tag-list>
}
@if (isOutsideWorkHours()) {
<div
class="task-info-chip"
[matTooltip]="T.F.TASK.CMP.OUTSIDE_WORK_HOURS | translate"
[attr.aria-label]="T.F.TASK.CMP.OUTSIDE_WORK_HOURS | translate"
>
<mat-icon>info</mat-icon>
<span>{{ T.F.TASK.CMP.OUTSIDE_WORK_HOURS | translate }}</span>
</div>
}
</div>
</div>

View file

@ -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<string>() },
);
private readonly _timelineConfig = toSignal(this._store.select(selectTimelineConfig), {
initialValue: null,
});
task = input.required<TaskWithSubTasks>();
isBacklog = input<boolean>(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;

View file

@ -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<DateService, 'todayStr' | 'getStartOfNextDayDiffMs'>
>;
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> = {}): 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<string>())).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<string>(),
),
).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<string>(),
),
).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<string>(),
),
).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<string>(), parentTask),
).toEqual({
shouldAutoPlan: false,
shouldUpdateDueDay: false,
shouldClearDueWithTime: false,
shouldClearRemindAt: false,
});
});
});

View file

@ -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<Task, 'dueDay' | 'dueWithTime'>,
context: DeadlineAutoPlanContext,
): string | undefined => {
if (isPositiveFiniteTimestamp(task.dueWithTime)) {
return getDateStrWithOffset(task.dueWithTime, context);
}
return task.dueDay ?? undefined;
};
const isTaskDueTodayBySchedule = (
task: Pick<Task, 'dueDay' | 'dueWithTime'>,
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<string>,
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<DateService, 'todayStr' | 'getStartOfNextDayDiffMs'>,
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,
}
: {};
};

View file

@ -18,7 +18,6 @@
>
<div
class="item"
[class.is-info]="isEstimateRemainingOverloaded()"
matTooltip="{{ T.WW.TODAY_REMAINING | translate }} ~{{
todayRemainingInProject() | msToString
}}"
@ -34,15 +33,6 @@
}}</strong>
<mat-icon style="margin-left: 2px">hourglass_empty</mat-icon>
</span>
@if (isEstimateRemainingOverloaded()) {
<span
class="status-info-chip"
[matTooltip]="T.WW.ESTIMATE_REMAINING_OVER_8H | translate"
>
<mat-icon>info</mat-icon>
<span>{{ T.WW.ESTIMATE_REMAINING_OVER_8H | translate }}</span>
</span>
}
</div>
<div class="item">
<span class="label">{{ T.WW.WORKING_TODAY | translate }}</span>

View file

@ -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 {

View file

@ -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(

View file

@ -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', () => {

View file

@ -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',

View file

@ -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<typeof TaskSharedActions.planDeadlineTasksForToday> =>
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', () => {

View file

@ -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<typeof TaskSharedActions.setDeadline>;
const {
taskId,
deadlineDay,
deadlineWithTime,
deadlineRemindAt,
autoPlanToday,
autoPlanStartOfNextDayDiffMs,
} = action as ReturnType<typeof TaskSharedActions.setDeadline>;
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]: () => {

View file

@ -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<any, Action>,
): ActionReducer<any, Action> => {
// 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;
};

View file

@ -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: {

View file

@ -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',

View file

@ -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',

View file

@ -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",