mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 08:56:41 +00:00
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:
parent
472e98471c
commit
b80e81f3e3
32 changed files with 1310 additions and 198 deletions
148
package-lock.json
generated
148
package-lock.json
generated
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
157
src/app/features/tasks/util/get-deadline-auto-plan-fields.ts
Normal file
157
src/app/features/tasks/util/get-deadline-auto-plan-fields.ts
Normal 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,
|
||||
}
|
||||
: {};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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]: () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue