mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* fix(task): schedule overdue tasks for today via Shift+T and guard stale task focus (#8851) * fix(planner): only bind data-task-id on planner-task when focusable The unconditional data-task-id host binding broke the e2e done-confirmation strategy in e2e/pages/task.page.ts, which selects its wait branch based on the attribute's presence. Boards render planner-task cards without an inner <task> element, so the id-based wait could never resolve (#8851). * refactor(tasks): share overdue predicate across selector and util The rebase onto #8858 re-inlined the overdue comparison into selectOverdueTaskIds and dropped the delegation to isTaskOverdue, so the two overdue definitions (the overdue list vs. the Shift+T "Add to Today" path) were duplicated and free to drift — the exact footgun #8851 set out to avoid, and the util's JSDoc still claimed the selector delegated to it. Extract isTaskOverdueByThreshold as the single source of truth for the comparison and getLogicalTodayStartMs for the boundary. isTaskOverdue and selectOverdueTaskIds both route through it, so they cannot drift. The selector still computes the threshold once per recompute (no per-task date parsing), preserving #8858's perf posture. No behavior change. * test(e2e): base markTaskAsDone strategy on <task> host, not attr planner-task now carries data-task-id in the Planner overdue list (#8851), so keying the done-confirmation strategy off data-task-id presence would send a wrapper down the <task> path — document.querySelectorAll('task') finds no match and the wait hangs 10s. Key it off the element actually being a <task> instead. No behavior change for real <task> rows; every wrapper keeps the 300ms wrapper path. Verified: worklog-basic (real task) and boards #7498 (planner-task wrapper) both pass. --------- Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
1436 lines
48 KiB
TypeScript
1436 lines
48 KiB
TypeScript
import {
|
|
AfterViewInit,
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
DestroyRef,
|
|
effect,
|
|
ElementRef,
|
|
forwardRef,
|
|
HostListener,
|
|
inject,
|
|
input,
|
|
OnDestroy,
|
|
signal,
|
|
untracked,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { TaskService } from '../task.service';
|
|
import { EMPTY, forkJoin, Subscription } from 'rxjs';
|
|
import {
|
|
HideSubTasksMode,
|
|
SubmitTrigger,
|
|
TaskCopy,
|
|
TaskDetailTargetPanel,
|
|
TaskWithSubTasks,
|
|
} from '../task.model';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { DialogTimeEstimateComponent } from '../dialog-time-estimate/dialog-time-estimate.component';
|
|
import {
|
|
expandFadeAnimation,
|
|
expandInOnlyAnimation,
|
|
} from '../../../ui/animations/expand.ani';
|
|
import {
|
|
ChecklistProgress,
|
|
getChecklistProgress,
|
|
} from '../../markdown-checklist/get-checklist-progress';
|
|
import { GlobalConfigService } from '../../config/global-config.service';
|
|
import { concatMap, first, tap } from 'rxjs/operators';
|
|
import { DoneToggleComponent } from '../../../ui/done-toggle/done-toggle.component';
|
|
import { SwipeBlockComponent } from '../../../ui/swipe-block/swipe-block.component';
|
|
import {
|
|
CollapsibleComponent,
|
|
GROUP_NAV_SELECTOR,
|
|
} from '../../../ui/collapsible/collapsible.component';
|
|
import { findAdjacentFocusable } from '../../../util/find-adjacent-focusable';
|
|
import { TaskAttachmentService } from '../task-attachment/task-attachment.service';
|
|
import { DialogEditTaskAttachmentComponent } from '../task-attachment/dialog-edit-attachment/dialog-edit-task-attachment.component';
|
|
import { ProjectService } from '../../project/project.service';
|
|
import { Project } from '../../project/project.model';
|
|
import { _MISSING_PROJECT_, DEFAULT_PROJECT_ICON } from '../../project/project.const';
|
|
import { T } from '../../../t.const';
|
|
import {
|
|
MatMenu,
|
|
MatMenuContent,
|
|
MatMenuItem,
|
|
MatMenuTrigger,
|
|
} from '@angular/material/menu';
|
|
import { WorkContextService } from '../../work-context/work-context.service';
|
|
import { throttle } from '../../../util/decorators';
|
|
import { TaskRepeatCfgService } from '../../task-repeat-cfg/task-repeat-cfg.service';
|
|
import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component';
|
|
import { openFullscreenMarkdownDialog } from '../../../ui/dialog-fullscreen-markdown/open-fullscreen-markdown-dialog';
|
|
import { Location } from '@angular/common';
|
|
import { Update } from '@ngrx/entity';
|
|
import { DateAdapter } from '@angular/material/core';
|
|
import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
|
|
import { combineDateAndTime } from '../../../util/combine-date-and-time';
|
|
import { getNextWeekDayOffset } from '../../../util/get-next-week-day-offset';
|
|
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
|
|
import { DateService } from '../../../core/date/date.service';
|
|
import { isTouchActive } from '../../../util/input-intent';
|
|
import { IS_HYBRID_DEVICE } from '../../../util/is-mouse-primary';
|
|
import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants';
|
|
import {
|
|
EMPTY_KEYBOARD_CONFIG,
|
|
KeyboardConfig,
|
|
keyboardConfigOrEmpty,
|
|
} from '@sp/keyboard-config';
|
|
import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component';
|
|
import { PlannerActions } from '../../planner/store/planner.actions';
|
|
import { PlannerService } from '../../planner/planner.service';
|
|
import { DialogDeadlineComponent } from '../dialog-deadline/dialog-deadline.component';
|
|
import { isDeadlineOverdue as isDeadlineOverdueFn } from '../util/is-deadline-overdue';
|
|
import { isTaskOverdue } from '../util/is-task-overdue';
|
|
import { isDeadlineApproaching as isDeadlineApproachingFn } from '../util/is-deadline-approaching';
|
|
import { TaskContextMenuComponent } from '../task-context-menu/task-context-menu.component';
|
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
import { ICAL_TYPE, PLAINSPACE_TYPE } from '../../issue/issue.const';
|
|
import { TaskTitleComponent } from '../../../ui/task-title/task-title.component';
|
|
import { MatIcon } from '@angular/material/icon';
|
|
import { MatIconButton, MatMiniFabButton } from '@angular/material/button';
|
|
import { TaskHoverControlsComponent } from './task-hover-controls/task-hover-controls.component';
|
|
import { ProgressBarComponent } from '../../../ui/progress-bar/progress-bar.component';
|
|
import { TaskListComponent } from '../task-list/task-list.component';
|
|
import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe';
|
|
import { ShortPlannedAtPipe } from '../../../ui/pipes/short-planned-at.pipe';
|
|
import { LocalDateStrPipe } from '../../../ui/pipes/local-date-str.pipe';
|
|
import { LocaleDatePipe } from '../../../ui/pipes/locale-date.pipe';
|
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
|
import { SubTaskTotalTimeSpentPipe } from '../pipes/sub-task-total-time-spent.pipe';
|
|
import { TagListComponent } from '../../tag/tag-list/tag-list.component';
|
|
import { TagToggleMenuListComponent } from '../../tag/tag-toggle-menu-list/tag-toggle-menu-list.component';
|
|
import { Store } from '@ngrx/store';
|
|
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
|
import { environment } from '../../../../environments/environment';
|
|
import { TODAY_TAG } from '../../tag/tag.const';
|
|
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
|
|
import { TaskLog } from '../../../core/log';
|
|
import { LayoutService } from '../../../core-ui/layout/layout.service';
|
|
import { TaskFocusService } from '../task-focus.service';
|
|
import { MatTooltip } from '@angular/material/tooltip';
|
|
import { millisecondsDiffToRemindOption } from '../util/remind-option-to-milliseconds';
|
|
import { MenuTreeService } from '../../menu-tree/menu-tree.service';
|
|
import { SelectOptionRowComponent } from '../../../ui/select-option-row/select-option-row.component';
|
|
import { SnackService } from '../../../core/snack/snack.service';
|
|
import {
|
|
AddSubtaskInputComponent,
|
|
AddSubtaskInputCloseReason,
|
|
} from '../add-subtask-input/add-subtask-input.component';
|
|
import { AddSubtaskInputService } from '../add-subtask-input/add-subtask-input.service';
|
|
|
|
@Component({
|
|
selector: 'task',
|
|
templateUrl: './task.component.html',
|
|
styleUrls: ['./task.component.scss'],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
animations: [expandInOnlyAnimation, expandFadeAnimation],
|
|
/* eslint-disable @typescript-eslint/naming-convention*/
|
|
host: {
|
|
'[id]': 'taskIdWithPrefix()',
|
|
'[attr.data-task-id]': 'task().id',
|
|
'[tabindex]': '0',
|
|
'[class.isDone]': 'task().isDone',
|
|
'[class.isCurrent]': 'isCurrent()',
|
|
'[class.isSelected]': 'isSelected()',
|
|
'[class.hasNoSubTasks]': 'task().subTaskIds.length === 0',
|
|
'[class.isDragReady]': 'isDragReady()',
|
|
'[class.isOverdue]': 'isOverdue()',
|
|
'(contextmenu)': 'onHostContextMenu($event)',
|
|
},
|
|
imports: [
|
|
MatIcon,
|
|
MatMenuTrigger,
|
|
MatIconButton,
|
|
TaskTitleComponent,
|
|
TaskHoverControlsComponent,
|
|
ProgressBarComponent,
|
|
MatMiniFabButton,
|
|
forwardRef(() => TaskListComponent),
|
|
TaskContextMenuComponent,
|
|
MatMenu,
|
|
MatMenuContent,
|
|
MatMenuItem,
|
|
MsToStringPipe,
|
|
LocalDateStrPipe,
|
|
TranslatePipe,
|
|
MatTooltip,
|
|
SubTaskTotalTimeSpentPipe,
|
|
TagListComponent,
|
|
ShortPlannedAtPipe,
|
|
TagToggleMenuListComponent,
|
|
DoneToggleComponent,
|
|
SwipeBlockComponent,
|
|
SelectOptionRowComponent,
|
|
AddSubtaskInputComponent,
|
|
],
|
|
})
|
|
export class TaskComponent implements OnDestroy, AfterViewInit {
|
|
private readonly _taskService = inject(TaskService);
|
|
private readonly _taskRepeatCfgService = inject(TaskRepeatCfgService);
|
|
private readonly _matDialog = inject(MatDialog);
|
|
private readonly _location = inject(Location);
|
|
private readonly _configService = inject(GlobalConfigService);
|
|
private readonly _attachmentService = inject(TaskAttachmentService);
|
|
private readonly _elementRef = inject(ElementRef);
|
|
private readonly _dateAdapter = inject(DateAdapter);
|
|
private readonly _store = inject(Store);
|
|
private readonly _projectService = inject(ProjectService);
|
|
private readonly _taskFocusService = inject(TaskFocusService);
|
|
private readonly _dateService = inject(DateService);
|
|
private readonly _destroyRef = inject(DestroyRef);
|
|
private readonly _menuTreeService = inject(MenuTreeService);
|
|
private readonly _snackService = inject(SnackService);
|
|
private readonly _translateService = inject(TranslateService);
|
|
private readonly _datePipe = inject(LocaleDatePipe);
|
|
private readonly _plannerService = inject(PlannerService);
|
|
private readonly _addSubtaskInputService = inject(AddSubtaskInputService);
|
|
|
|
readonly workContextService = inject(WorkContextService);
|
|
readonly layoutService = inject(LayoutService);
|
|
readonly globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
|
|
|
|
task = input.required<TaskWithSubTasks>();
|
|
isBacklog = input<boolean>(false);
|
|
isInSubTaskList = input<boolean>(false);
|
|
showDoneAnimation = signal(false);
|
|
showUndoneAnimation = signal(false);
|
|
|
|
// Use shared signals from services to avoid creating 600+ subscriptions on initial render
|
|
isCurrent = computed(() => this._taskService.currentTaskId() === this.task().id);
|
|
isSelected = computed(() => this._taskService.selectedTaskId() === this.task().id);
|
|
isShowCloseButton = computed(() => {
|
|
// Only show close button when task is selected AND not on mobile (bottom panel)
|
|
return this.isSelected() && !this.layoutService.isXs();
|
|
});
|
|
|
|
// Determines if the toggle detail panel button should be visible
|
|
isShowToggleButton = computed(() => {
|
|
const t = this.task();
|
|
// A checklist already shows its own badge (which opens the notes panel), so
|
|
// suppress the redundant plain 'chat' notes indicator. The button is kept for
|
|
// the 'close' (panel open) and 'update' (issue changed) states.
|
|
if (this.checklistProgress() && this.toggleButtonIcon() === 'chat') {
|
|
return false;
|
|
}
|
|
// iCal and Plainspace mirror all their issue data into native task fields, so
|
|
// their detail panel only repeats what's already on the task. Don't surface the
|
|
// button just because they carry an issueId — only for real notes, a remote
|
|
// update (the 'update' icon), or when the panel is open.
|
|
const isMirroredIssueType =
|
|
t.issueType === ICAL_TYPE || t.issueType === PLAINSPACE_TYPE;
|
|
return (
|
|
!!t.notes ||
|
|
(!!t.issueId && !isMirroredIssueType) ||
|
|
!!t.issueWasUpdated ||
|
|
this.isShowCloseButton()
|
|
);
|
|
});
|
|
|
|
// Determines which icon to show in the toggle button
|
|
toggleButtonIcon = computed((): 'chat' | 'close' | 'update' => {
|
|
const t = this.task();
|
|
if (t.issueWasUpdated) return 'update';
|
|
if (this.isShowCloseButton()) return 'close';
|
|
return 'chat';
|
|
});
|
|
|
|
isTodayListActive = computed(() => this.workContextService.isTodayListSignal());
|
|
taskIdWithPrefix = computed(() => 't-' + this.task().id);
|
|
isRepeatTaskCreatedToday = computed(
|
|
() => !!(this.task().repeatCfgId && this._dateService.isToday(this.task().created)),
|
|
);
|
|
isOverdue = computed(() => {
|
|
const t = this.task();
|
|
const todayStr = this.globalTrackingIntervalService.todayDateStr();
|
|
return (
|
|
!t.isDone &&
|
|
((t.dueWithTime &&
|
|
!this._dateService.isToday(t.dueWithTime) &&
|
|
t.dueWithTime < Date.now()) ||
|
|
// Note: String comparison works correctly here because dueDay is in YYYY-MM-DD format
|
|
// which is lexicographically sortable. This avoids timezone conversion issues that occur
|
|
// when creating Date objects from date strings.
|
|
// Guard: only compare if dueDay is a valid YYYY-MM-DD string to avoid corrupted data
|
|
// producing false overdue results (see #6908)
|
|
(t.dueDay &&
|
|
isDBDateStr(t.dueDay) &&
|
|
t.dueDay !== todayStr &&
|
|
t.dueDay < todayStr))
|
|
);
|
|
});
|
|
isScheduledToday = computed(() => {
|
|
const t = this.task();
|
|
const todayStr = this.globalTrackingIntervalService.todayDateStr();
|
|
return (
|
|
(t.dueWithTime && this._dateService.isToday(t.dueWithTime)) ||
|
|
(t.dueDay && t.dueDay === todayStr)
|
|
);
|
|
});
|
|
hasTimeConflict = computed(() => {
|
|
const task = this.task();
|
|
return (
|
|
typeof task.dueWithTime === 'number' &&
|
|
this._taskService.timeConflictTaskIds().has(task.id)
|
|
);
|
|
});
|
|
|
|
isShowDueDayBtn = computed(() => {
|
|
const dueDay = this.task().dueDay;
|
|
return (
|
|
dueDay &&
|
|
isDBDateStr(dueDay) &&
|
|
(!this.isTodayListActive() ||
|
|
this.isOverdue() ||
|
|
dueDay !== this.globalTrackingIntervalService.todayDateStr())
|
|
);
|
|
});
|
|
|
|
progress = computed<number>(() => {
|
|
const t = this.task();
|
|
return (t.timeEstimate && (t.timeSpent / t.timeEstimate) * 100) || 0;
|
|
});
|
|
|
|
// Checklist progress derived from markdown checklist in task notes (null = no checklist)
|
|
checklistProgress = computed<ChecklistProgress | null>(() =>
|
|
getChecklistProgress(this.task().notes),
|
|
);
|
|
|
|
isShowRemoveFromToday = computed(() => {
|
|
return (
|
|
!this.isTodayListActive() &&
|
|
!this.task().isDone &&
|
|
this.task().dueDay === this.globalTrackingIntervalService.todayDateStr()
|
|
);
|
|
});
|
|
|
|
isShowAddToToday = computed(() => {
|
|
const task = this.task();
|
|
const todayStr = this.globalTrackingIntervalService.todayDateStr();
|
|
return this.isTodayListActive()
|
|
? (task.dueWithTime && !this._dateService.isToday(task.dueWithTime)) ||
|
|
(task.dueDay && task.dueDay !== todayStr)
|
|
: !this.isShowRemoveFromToday() &&
|
|
task.dueDay !== todayStr &&
|
|
(!task.dueWithTime || !this._dateService.isToday(task.dueWithTime));
|
|
});
|
|
|
|
isDeadlineOverdue = computed(() =>
|
|
isDeadlineOverdueFn(this.task(), this.globalTrackingIntervalService.todayDateStr()),
|
|
);
|
|
|
|
isDeadlineApproaching = computed(() =>
|
|
isDeadlineApproachingFn(
|
|
this.task(),
|
|
this.globalTrackingIntervalService.todayDateStr(),
|
|
),
|
|
);
|
|
|
|
hasDeadline = computed(() => {
|
|
const t = this.task();
|
|
return !!(t.deadlineDay || t.deadlineWithTime);
|
|
});
|
|
|
|
T: typeof T = T;
|
|
readonly DEFAULT_PROJECT_ICON = DEFAULT_PROJECT_ICON;
|
|
isTouchActive = isTouchActive;
|
|
isDragOver: boolean = false;
|
|
isDragReady = signal(false);
|
|
private _dragReadyTimeout: number | undefined;
|
|
private _doneAnimationTimeout: number | undefined;
|
|
private _touchListenerCleanups: (() => void)[] = [];
|
|
ShowSubTasksMode: typeof HideSubTasksMode = HideSubTasksMode;
|
|
isFirstLineHover: boolean = false;
|
|
_nextFocusTaskEl?: HTMLElement;
|
|
|
|
readonly taskTitleEditEl = viewChild<TaskTitleComponent>('taskTitleEditEl');
|
|
readonly projectMenuTrigger = viewChild('projectMenuTriggerEl', {
|
|
read: MatMenuTrigger,
|
|
});
|
|
readonly tagToggleMenuList = viewChild('tagToggleMenuList', {
|
|
read: TagToggleMenuListComponent,
|
|
});
|
|
readonly taskContextMenu = viewChild('taskContextMenu', {
|
|
read: TaskContextMenuComponent,
|
|
});
|
|
readonly addSubtaskInput = viewChild(AddSubtaskInputComponent);
|
|
readonly isAddSubtaskInputVisible = signal(false);
|
|
// Task the draft was opened from (may be a subtask), captured before focus
|
|
// moves into the input, so Escape can return focus there. See onAddSubtaskInputClosed.
|
|
private _subtaskInputOriginTaskId: string | null = null;
|
|
|
|
private readonly _addSubtaskInputRequestEffect = effect(() => {
|
|
const requestedParentId = this._addSubtaskInputService.openRequest();
|
|
if (requestedParentId === null || requestedParentId !== untracked(this.task).id) {
|
|
return;
|
|
}
|
|
|
|
window.setTimeout(() => {
|
|
// Consume the request so it isn't replayed (stealing focus) the next time
|
|
// this row is re-created with the same id, e.g. navigating away and back.
|
|
this._addSubtaskInputService.consume();
|
|
// Focus is still on the originating task here (the input isn't shown yet),
|
|
// so capture it now — once the input is focused, the parent row claims it.
|
|
this._subtaskInputOriginTaskId =
|
|
this._taskFocusService.focusedTaskId() ??
|
|
this._taskFocusService.lastFocusedTaskComponent()?.task().id ??
|
|
this.task().id;
|
|
const currentTask = this.task();
|
|
if (currentTask._hideSubTasksMode === HideSubTasksMode.HideAll) {
|
|
this._taskService.showSubTasks(currentTask.id);
|
|
}
|
|
this.isAddSubtaskInputVisible.set(true);
|
|
window.setTimeout(() => this.addSubtaskInput()?.focus());
|
|
});
|
|
});
|
|
|
|
// Lazy-loaded project list - only fetched when project menu opens
|
|
moveToProjectList = signal<Project[] | undefined>(undefined);
|
|
projectFolderMap = computed(() => this._menuTreeService.projectFolderMap());
|
|
private _loadedProjectListForProjectId: string | null | undefined;
|
|
private _moveToProjectListSub?: Subscription;
|
|
|
|
// Parent title derived directly from task data when available in subtask model.
|
|
// Falls back to a live store subscription only when needed (non-subtask-list with parentId).
|
|
private _parentTaskTitle = signal<string | null>(null);
|
|
parentTitle = computed(() => {
|
|
const t = this.task();
|
|
if (!t.parentId || this.isInSubTaskList()) {
|
|
return null;
|
|
}
|
|
return this._parentTaskTitle();
|
|
});
|
|
|
|
isProjectMenuLoaded = signal(false);
|
|
|
|
private _parentTitleEffect = effect((onCleanup) => {
|
|
const t = this.task();
|
|
const isInSubTaskList = this.isInSubTaskList();
|
|
if (!t.parentId || isInSubTaskList) {
|
|
this._parentTaskTitle.set(null);
|
|
return;
|
|
}
|
|
// Only subscribe when this task has a parentId and is NOT in a subtask list
|
|
const sub = this._taskService.getByIdLive$(t.parentId).subscribe((parent) => {
|
|
this._parentTaskTitle.set(parent?.title ?? null);
|
|
});
|
|
onCleanup(() => sub.unsubscribe());
|
|
});
|
|
|
|
private _dragEnterTarget?: HTMLElement;
|
|
private _doubleClickTimeout?: number;
|
|
private _isTaskDeleteTriggered = false;
|
|
isContextMenuLoaded = signal(false);
|
|
|
|
// methods come last
|
|
|
|
@HostListener('focusin', ['$event']) onFocus(ev: FocusEvent): void {
|
|
// focusin bubbles, so events from nested <task> elements (subtasks) reach
|
|
// every ancestor task host. Only the innermost task should claim focus.
|
|
if (!this._isInnermostTaskFor(ev.target)) {
|
|
return;
|
|
}
|
|
this._taskFocusService.focusedTaskId.set(this.task().id);
|
|
this._taskFocusService.lastFocusedTaskComponent.set(this);
|
|
|
|
// If detail panel is open for another task, update it to show this task (#6578)
|
|
// Skip if this task is inside the detail panel (e.g. subtask list)
|
|
if (this._elementRef.nativeElement.closest('task-detail-panel')) {
|
|
return;
|
|
}
|
|
// Skip when focus came from clicking the toggle-detail-panel button itself —
|
|
// the click handler owns that action; running both inverts the toggle (#7694).
|
|
if (ev.target instanceof Element && ev.target.closest('.show-additional-info-btn')) {
|
|
return;
|
|
}
|
|
const selectedTaskId = this._taskService.selectedTaskId();
|
|
if (selectedTaskId && selectedTaskId !== this.task().id) {
|
|
this._taskService.setSelectedId(this.task().id);
|
|
}
|
|
}
|
|
|
|
@HostListener('focusout', ['$event']) onBlur(ev: FocusEvent): void {
|
|
if (!this._isInnermostTaskFor(ev.target)) {
|
|
return;
|
|
}
|
|
if (
|
|
ev.relatedTarget instanceof Node &&
|
|
this._elementRef.nativeElement.contains(ev.relatedTarget)
|
|
) {
|
|
return;
|
|
}
|
|
this._taskFocusService.focusedTaskId.set(null);
|
|
}
|
|
|
|
private _isInnermostTaskFor(target: EventTarget | null): boolean {
|
|
return (
|
|
target instanceof Element &&
|
|
target.closest('task') === this._elementRef.nativeElement
|
|
);
|
|
}
|
|
|
|
@HostListener('dragenter', ['$event']) onDragEnter(ev: DragEvent): void {
|
|
this._dragEnterTarget = ev.target as HTMLElement;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
this.isDragOver = true;
|
|
}
|
|
|
|
@HostListener('dragleave', ['$event']) onDragLeave(ev: DragEvent): void {
|
|
if (this._dragEnterTarget === (ev.target as HTMLElement)) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
this.isDragOver = false;
|
|
}
|
|
}
|
|
|
|
@HostListener('drop', ['$event']) onDrop(ev: DragEvent): void {
|
|
ev.preventDefault();
|
|
this.focusSelf();
|
|
this._attachmentService.createFromDrop(ev, this.task().id, true);
|
|
ev.stopPropagation();
|
|
this.isDragOver = false;
|
|
}
|
|
|
|
ngAfterViewInit(): void {
|
|
if (isTouchActive() || IS_HYBRID_DEVICE) {
|
|
const el = this._elementRef.nativeElement;
|
|
const onStart = (): void => this.onHostTouchStart();
|
|
const onEnd = (): void => this.onHostTouchEnd();
|
|
el.addEventListener('touchstart', onStart, { passive: true });
|
|
el.addEventListener('touchend', onEnd, { passive: true });
|
|
el.addEventListener('touchmove', onEnd, { passive: true });
|
|
this._touchListenerCleanups = [
|
|
() => el.removeEventListener('touchstart', onStart),
|
|
() => el.removeEventListener('touchend', onEnd),
|
|
() => el.removeEventListener('touchmove', onEnd),
|
|
];
|
|
}
|
|
|
|
// Dev-time sanity check: TODAY_TAG should NEVER be in task.tagIds (virtual tag pattern)
|
|
// Membership is determined by task.dueDay. See: docs/ai/today-tag-architecture.md
|
|
if (!environment.production) {
|
|
if (this.task().tagIds.includes(TODAY_TAG.id)) {
|
|
throw new Error('Task should not have TODAY_TAG in tagIds - it is a virtual tag');
|
|
}
|
|
}
|
|
|
|
// hacky but relatively performant
|
|
const t = this.task();
|
|
if (t.parentId && !t.title.length && Date.now() - 200 < t.created) {
|
|
setTimeout(() => {
|
|
// when there are multiple instances with the same task we should focus the last one, since it is the one in the
|
|
// task side panel
|
|
const otherTaskEl = document.querySelectorAll('#t-' + CSS.escape(t.id));
|
|
if (
|
|
otherTaskEl?.length <= 1 ||
|
|
Array.from(otherTaskEl).findIndex(
|
|
(item) => item === this._elementRef.nativeElement,
|
|
) ===
|
|
otherTaskEl.length - 1
|
|
) {
|
|
this.focusTitleForEdit();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
window.clearTimeout(this._doubleClickTimeout);
|
|
window.clearTimeout(this._dragReadyTimeout);
|
|
window.clearTimeout(this._doneAnimationTimeout);
|
|
this._touchListenerCleanups.forEach((fn) => fn());
|
|
this._moveToProjectListSub?.unsubscribe();
|
|
}
|
|
|
|
scheduleTask(): void {
|
|
this._storeNextFocusEl();
|
|
this._matDialog
|
|
.open(DialogScheduleTaskComponent, {
|
|
// we focus inside dialog instead
|
|
autoFocus: false,
|
|
data: { task: this.task() },
|
|
})
|
|
.afterClosed()
|
|
.subscribe((isPlanned) => {
|
|
this.focusSelfOrNextIfNotPossible();
|
|
});
|
|
}
|
|
|
|
openDeadlineDialog(): void {
|
|
this._storeNextFocusEl();
|
|
this._matDialog
|
|
.open(DialogDeadlineComponent, {
|
|
autoFocus: false,
|
|
data: { task: this.task() },
|
|
})
|
|
.afterClosed()
|
|
.subscribe(() => {
|
|
this.focusSelfOrNextIfNotPossible();
|
|
});
|
|
}
|
|
|
|
async scheduleTaskTomorrow(): Promise<void> {
|
|
const tDate = this._dateService.getLogicalTodayDate();
|
|
tDate.setDate(tDate.getDate() + 1);
|
|
await this._scheduleForDay(tDate);
|
|
}
|
|
|
|
async scheduleTaskNextWeek(): Promise<void> {
|
|
const tDate = this._dateService.getLogicalTodayDate();
|
|
const dayOffset = getNextWeekDayOffset(this._dateAdapter, tDate);
|
|
tDate.setDate(tDate.getDate() + dayOffset);
|
|
await this._scheduleForDay(tDate);
|
|
}
|
|
|
|
async scheduleTaskNextMonth(): Promise<void> {
|
|
const tDate = this._dateService.getLogicalTodayDate();
|
|
tDate.setDate(1);
|
|
tDate.setMonth(tDate.getMonth() + 1);
|
|
await this._scheduleForDay(tDate);
|
|
}
|
|
|
|
private async _scheduleForDay(dayDate: Date): Promise<void> {
|
|
const day = getDbDateStr(dayDate);
|
|
const task = this.task();
|
|
if (task.dueWithTime) {
|
|
const newDate = combineDateAndTime(dayDate, new Date(task.dueWithTime));
|
|
const remindCfg = task.reminderId
|
|
? millisecondsDiffToRemindOption(task.dueWithTime, task.remindAt)
|
|
: (this._configService.cfg()?.reminder.defaultTaskRemindOption ??
|
|
DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!);
|
|
|
|
this._taskService.scheduleTask(task, newDate.getTime(), remindCfg, false);
|
|
this._snackService.open({
|
|
type: 'SUCCESS',
|
|
msg: T.F.PLANNER.S.TASK_PLANNED_FOR,
|
|
ico: 'today',
|
|
translateParams: {
|
|
date: this._dateService.isToday(newDate)
|
|
? this._translateService.instant(T.G.TODAY_TAG_TITLE)
|
|
: (this._datePipe.transform(day, 'shortDate') as string),
|
|
extra: await this._plannerService.getSnackExtraStr(day),
|
|
},
|
|
});
|
|
} else {
|
|
this._store.dispatch(
|
|
PlannerActions.planTaskForDay({
|
|
task: task as TaskCopy,
|
|
day,
|
|
isShowSnack: true,
|
|
}),
|
|
);
|
|
}
|
|
this.focusSelfOrNextIfNotPossible();
|
|
}
|
|
|
|
async editTaskRepeatCfg(): Promise<void> {
|
|
const { DialogEditTaskRepeatCfgComponent } =
|
|
await import('../../task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component');
|
|
this._matDialog
|
|
.open(DialogEditTaskRepeatCfgComponent, {
|
|
data: {
|
|
task: this.task(),
|
|
targetDate: this.task().dueDay || getDbDateStr(new Date(this.task().created)),
|
|
},
|
|
})
|
|
.afterClosed()
|
|
.subscribe(() => this.focusSelf());
|
|
}
|
|
|
|
deleteTask(isClick: boolean = false): void {
|
|
// NOTE: prevents attempts to delete the same task multiple times
|
|
if (this._isTaskDeleteTriggered) {
|
|
return;
|
|
}
|
|
|
|
const isConfirmBeforeTaskDelete =
|
|
this._configService.cfg()?.tasks?.isConfirmBeforeDelete ?? true;
|
|
|
|
if (isConfirmBeforeTaskDelete) {
|
|
this._matDialog
|
|
.open(DialogConfirmComponent, {
|
|
data: {
|
|
okTxt: T.F.TASK.D_CONFIRM_DELETE.OK,
|
|
message: T.F.TASK.D_CONFIRM_DELETE.MSG,
|
|
translateParams: { title: this.task().title },
|
|
},
|
|
})
|
|
.afterClosed()
|
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
.subscribe((isConfirm) => {
|
|
if (isConfirm) {
|
|
this._performDelete(isClick);
|
|
}
|
|
});
|
|
} else {
|
|
this._performDelete(isClick);
|
|
}
|
|
}
|
|
|
|
private _performDelete(isClick: boolean): void {
|
|
// NOTE: in case we want the focus behaviour on click we could use:
|
|
// this.focusSelf();
|
|
if (!isClick) {
|
|
this.focusNext(true);
|
|
}
|
|
|
|
this._isTaskDeleteTriggered = true;
|
|
this._taskService.remove(this.task());
|
|
}
|
|
|
|
startTask(): void {
|
|
this._taskService.setCurrentId(this.task().id);
|
|
this.focusSelf();
|
|
}
|
|
|
|
pauseTask(): void {
|
|
this._taskService.pauseCurrent();
|
|
}
|
|
|
|
togglePlayPause(): void {
|
|
if (this.isCurrent()) {
|
|
this.pauseTask();
|
|
} else {
|
|
this.startTask();
|
|
}
|
|
}
|
|
|
|
private _moveAndRefocus(
|
|
move: (id: string, parentId: string | undefined, isBacklog: boolean) => void,
|
|
): void {
|
|
const t = this.task();
|
|
// Top-level done tasks in the main list are ordered by completion date, so
|
|
// a manual reorder can't take effect — skip it to avoid emitting a spurious
|
|
// taskIds-reorder op that would sync to other devices. Done subtasks and
|
|
// backlog tasks are not auto-sorted, so they stay reorderable.
|
|
if (t.isDone && !t.parentId && !this.isBacklog()) {
|
|
return;
|
|
}
|
|
move(t.id, t.parentId, this.isBacklog());
|
|
// timeout required to let changes take place
|
|
setTimeout(() => this.focusSelf());
|
|
setTimeout(() => this.focusSelf(), 10);
|
|
}
|
|
|
|
moveTaskUp(): void {
|
|
this._moveAndRefocus((id, parentId, isBacklog) =>
|
|
this._taskService.moveUp(id, parentId, isBacklog),
|
|
);
|
|
}
|
|
|
|
moveTaskDown(): void {
|
|
this._moveAndRefocus((id, parentId, isBacklog) =>
|
|
this._taskService.moveDown(id, parentId, isBacklog),
|
|
);
|
|
}
|
|
|
|
moveTaskToTop(): void {
|
|
this._moveAndRefocus((id, parentId, isBacklog) =>
|
|
this._taskService.moveToTop(id, parentId, isBacklog),
|
|
);
|
|
}
|
|
|
|
moveTaskToBottom(): void {
|
|
this._moveAndRefocus((id, parentId, isBacklog) =>
|
|
this._taskService.moveToBottom(id, parentId, isBacklog),
|
|
);
|
|
}
|
|
|
|
handleArrowUp(): void {
|
|
if (this._focusGroupHeaderIfAtBoundary('prev')) {
|
|
return;
|
|
}
|
|
this.focusPrevious();
|
|
}
|
|
|
|
handleArrowDown(): void {
|
|
if (this._focusGroupHeaderIfAtBoundary('next')) {
|
|
return;
|
|
}
|
|
this.focusNext();
|
|
}
|
|
|
|
private _focusGroupHeaderIfAtBoundary(direction: 'prev' | 'next'): boolean {
|
|
const adjacent = findAdjacentFocusable(
|
|
this._elementRef.nativeElement,
|
|
direction,
|
|
GROUP_NAV_SELECTOR,
|
|
);
|
|
if (adjacent?.matches('.collapsible-header')) {
|
|
adjacent.focus();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
handleArrowLeft(): void {
|
|
const t = this.task();
|
|
const hasSubTasks = t.subTasks && t.subTasks.length > 0;
|
|
|
|
if (this.isSelected()) {
|
|
this.hideDetailPanel();
|
|
return;
|
|
}
|
|
if (hasSubTasks && t._hideSubTasksMode !== HideSubTasksMode.HideAll) {
|
|
this._taskService.toggleSubTaskMode(t.id, true, false);
|
|
return;
|
|
}
|
|
if (!t.parentId) {
|
|
const group = CollapsibleComponent.findClosestGroup(this._elementRef.nativeElement);
|
|
if (group?.isExpanded) {
|
|
group.focusHeader();
|
|
group.collapseIfExpanded();
|
|
return;
|
|
}
|
|
}
|
|
this.focusPrevious();
|
|
}
|
|
|
|
handleArrowRight(): void {
|
|
const t = this.task();
|
|
const hasSubTasks = t.subTasks && t.subTasks.length > 0;
|
|
|
|
if (hasSubTasks && t._hideSubTasksMode !== undefined) {
|
|
this._taskService.toggleSubTaskMode(t.id, false, false);
|
|
} else if (!this.isSelected()) {
|
|
this.showDetailPanel();
|
|
} else {
|
|
this.focusNext();
|
|
}
|
|
}
|
|
|
|
moveToBacklogWithFocus(): void {
|
|
const t = this.task();
|
|
if (t.projectId && !t.parentId) {
|
|
this.focusPrevious(true);
|
|
this.moveToBacklog();
|
|
}
|
|
}
|
|
|
|
moveToTodayWithFocus(): void {
|
|
const t = this.task();
|
|
if (t.projectId) {
|
|
this.focusNext(true, true);
|
|
this.moveToToday();
|
|
}
|
|
}
|
|
|
|
openProjectMenu(): void {
|
|
if (this.task().parentId) {
|
|
return;
|
|
}
|
|
this._loadProjectListIfNeeded();
|
|
if (!this.isProjectMenuLoaded()) {
|
|
this.isProjectMenuLoaded.set(true);
|
|
setTimeout(() => this.projectMenuTrigger()?.openMenu());
|
|
return;
|
|
}
|
|
this.projectMenuTrigger()?.openMenu();
|
|
}
|
|
|
|
_loadProjectListIfNeeded(): void {
|
|
// Only load if not already loaded
|
|
const currentProjectId = this.task().projectId || null;
|
|
const isLoadedForCurrentProject =
|
|
this._loadedProjectListForProjectId === currentProjectId &&
|
|
this._moveToProjectListSub &&
|
|
!this._moveToProjectListSub.closed;
|
|
|
|
if (isLoadedForCurrentProject) {
|
|
return;
|
|
}
|
|
|
|
this._moveToProjectListSub?.unsubscribe();
|
|
this._loadedProjectListForProjectId = currentProjectId;
|
|
|
|
this._moveToProjectListSub = this._projectService
|
|
.getProjectsWithoutIdInTreeOrder$(currentProjectId)
|
|
.subscribe((projects) => {
|
|
this.moveToProjectList.set(projects);
|
|
});
|
|
}
|
|
|
|
updateTaskTitleIfChanged({
|
|
newVal,
|
|
wasChanged,
|
|
blurEvent,
|
|
submitTrigger,
|
|
}: {
|
|
newVal: string;
|
|
wasChanged: boolean;
|
|
blurEvent?: FocusEvent;
|
|
submitTrigger: SubmitTrigger;
|
|
}): void {
|
|
const task = this.task();
|
|
|
|
if (wasChanged) {
|
|
this._taskService.update(task.id, { title: newVal });
|
|
}
|
|
|
|
if (submitTrigger === 'modEnter') {
|
|
this.addSubTask();
|
|
return;
|
|
}
|
|
|
|
// Only focus self if no input/textarea is receiving focus next
|
|
// This prevents stealing focus from any user input that was just clicked for editing
|
|
const nextFocusTarget = blurEvent?.relatedTarget as HTMLElement | null;
|
|
const isNextTargetInput =
|
|
nextFocusTarget &&
|
|
(nextFocusTarget.tagName.toLowerCase() === 'input' ||
|
|
nextFocusTarget.tagName.toLowerCase() === 'textarea' ||
|
|
nextFocusTarget.closest('task') !== null);
|
|
|
|
if (!isNextTargetInput) {
|
|
this.focusSelf();
|
|
}
|
|
}
|
|
|
|
openNotesFullscreen(): void {
|
|
const task = this.task();
|
|
// Saves-and-closes on a navigation (resize across the mobile breakpoint,
|
|
// Android back) instead of dropping the edit — see openFullscreenMarkdownDialog
|
|
// (#8434).
|
|
const dialogRef = openFullscreenMarkdownDialog(this._matDialog, this._location, {
|
|
content: task.notes || '',
|
|
taskId: task.id,
|
|
});
|
|
|
|
dialogRef.afterClosed().subscribe((result) => {
|
|
if (result?.action === 'DELETE') {
|
|
this._taskService.update(task.id, { notes: '' });
|
|
} else if (typeof result === 'string') {
|
|
this._taskService.update(task.id, { notes: result });
|
|
}
|
|
this.focusSelf();
|
|
});
|
|
}
|
|
|
|
estimateTime(): void {
|
|
if (this.task().subTaskIds?.length > 0) {
|
|
return;
|
|
}
|
|
|
|
this._matDialog
|
|
.open(DialogTimeEstimateComponent, {
|
|
data: { task: this.task() },
|
|
})
|
|
.afterClosed()
|
|
.subscribe(() => this.focusSelf());
|
|
}
|
|
|
|
addAttachment(): void {
|
|
this._matDialog
|
|
.open(DialogEditTaskAttachmentComponent, {
|
|
data: {},
|
|
})
|
|
.afterClosed()
|
|
.subscribe((result) => {
|
|
if (result) {
|
|
this._attachmentService.addAttachment(this.task().id, result);
|
|
}
|
|
this.focusSelf();
|
|
});
|
|
}
|
|
|
|
addSubTask(): void {
|
|
const task = this.task();
|
|
const parentId = task.parentId || task.id;
|
|
if (!task.parentId && task._hideSubTasksMode === HideSubTasksMode.HideAll) {
|
|
this._taskService.showSubTasks(task.id);
|
|
}
|
|
this._addSubtaskInputService.requestOpen(parentId);
|
|
}
|
|
|
|
onAddSubtaskInputClosed(reason: AddSubtaskInputCloseReason): void {
|
|
this.isAddSubtaskInputVisible.set(false);
|
|
const originTaskId = this._subtaskInputOriginTaskId;
|
|
this._subtaskInputOriginTaskId = null;
|
|
if (reason === 'escape') {
|
|
// Return focus to the task the draft was opened from (which may be a
|
|
// subtask) so keyboard navigation continues from there after cancelling.
|
|
this._refocusTaskAfterDraftCancel(originTaskId);
|
|
}
|
|
}
|
|
|
|
private _refocusTaskAfterDraftCancel(taskId: string | null): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
const targetId = taskId ?? this.task().id;
|
|
// Deferred so focus lands after the input's removal settles.
|
|
window.setTimeout(() => this._focusTaskById(targetId));
|
|
}
|
|
|
|
private _focusTaskById(taskId: string): void {
|
|
// A task can render in two places at once (main list + detail side panel);
|
|
// prefer the last instance — the side-panel one — mirroring the inline-edit
|
|
// focus resolution above.
|
|
const els = document.querySelectorAll<HTMLElement>('#t-' + CSS.escape(taskId));
|
|
els[els.length - 1]?.focus();
|
|
}
|
|
|
|
@throttle(200, { leading: true, trailing: false })
|
|
toggleDoneKeyboard(): void {
|
|
this.toggleTaskDone();
|
|
}
|
|
|
|
onSwipeRightTriggered(isTriggered: boolean): void {
|
|
if (this.task().isDone) {
|
|
this.showUndoneAnimation.set(isTriggered);
|
|
} else {
|
|
this.showDoneAnimation.set(isTriggered);
|
|
}
|
|
}
|
|
|
|
toggleTaskDone(): void {
|
|
window.clearTimeout(this._doneAnimationTimeout);
|
|
this.focusNext(true, true);
|
|
this._doneAnimationTimeout = this._taskService.toggleDoneWithAnimation(
|
|
this.task().id,
|
|
this.task().isDone,
|
|
(v) => this.showDoneAnimation.set(v),
|
|
);
|
|
}
|
|
|
|
showDetailPanel(): void {
|
|
this._taskService.setSelectedId(this.task().id);
|
|
this.focusSelf();
|
|
}
|
|
|
|
hideDetailPanel(): void {
|
|
// Explicitly deselect to close the panel; do NOT re-select (that's showDetailPanel's job).
|
|
this._taskService.setSelectedId(null);
|
|
this.focusSelf();
|
|
}
|
|
|
|
private _wasClickedInDoubleClickRange = false;
|
|
|
|
// Clicking the detail-panel toggle button while it shows the accent "issue
|
|
// updated" icon also dismisses that badge (toggleButtonIcon() === 'update'
|
|
// iff issueWasUpdated). This is the always-available way to clear it — unlike
|
|
// the issue-content "mark as checked" button, it does not depend on the issue
|
|
// data (re)loading, so the badge can never get stuck (e.g. removed remote issue).
|
|
onToggleDetailPanelBtnClick(ev?: MouseEvent): void {
|
|
const task = this.task();
|
|
if (task.issueWasUpdated) {
|
|
this._taskService.markIssueUpdatesAsRead(task.id);
|
|
}
|
|
this.toggleShowDetailPanel(ev);
|
|
}
|
|
|
|
toggleShowDetailPanel(ev?: MouseEvent): void {
|
|
const isInTaskDetailPanel =
|
|
this._elementRef.nativeElement.closest('task-detail-panel');
|
|
if (isInTaskDetailPanel && !this._wasClickedInDoubleClickRange) {
|
|
this._wasClickedInDoubleClickRange = true;
|
|
window.clearTimeout(this._doubleClickTimeout);
|
|
this._doubleClickTimeout = window.setTimeout(() => {
|
|
this._wasClickedInDoubleClickRange = false;
|
|
}, 400);
|
|
return;
|
|
}
|
|
|
|
if (this.isSelected()) {
|
|
this._taskService.setSelectedId(null);
|
|
} else {
|
|
this._taskService.setSelectedId(this.task().id);
|
|
}
|
|
if (ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
}
|
|
}
|
|
|
|
toggleShowAttachments(): void {
|
|
this._taskService.setSelectedId(this.task().id, TaskDetailTargetPanel.Attachments);
|
|
this.focusSelf();
|
|
}
|
|
|
|
openNotesPanel(): void {
|
|
this._taskService.setSelectedId(this.task().id, TaskDetailTargetPanel.Notes);
|
|
this.focusSelf();
|
|
}
|
|
|
|
toggleSubTaskMode(): void {
|
|
this._taskService.toggleSubTaskMode(this.task().id, true, true);
|
|
this.focusSelf();
|
|
}
|
|
|
|
isTagMenuVisible = signal(false);
|
|
|
|
async editTags(): Promise<void> {
|
|
this.isTagMenuVisible.set(true);
|
|
setTimeout(() => {
|
|
this.tagToggleMenuList()?.openMenu();
|
|
});
|
|
}
|
|
|
|
toggleTag(tagId: string): void {
|
|
const task = this.task();
|
|
const tagIds = task.tagIds.includes(tagId)
|
|
? task.tagIds.filter((id) => id !== tagId)
|
|
: [...task.tagIds, tagId];
|
|
|
|
this.onTagsUpdated(tagIds);
|
|
}
|
|
|
|
addToMyDay(): void {
|
|
const task = this.task();
|
|
this._store.dispatch(
|
|
TaskSharedActions.planTasksForToday({
|
|
taskIds: [task.id],
|
|
today: this._dateService.todayStr(),
|
|
startOfNextDayDiffMs: this._dateService.getStartOfNextDayDiffMs(),
|
|
parentTaskMap: { [task.id]: task.parentId },
|
|
}),
|
|
);
|
|
}
|
|
|
|
unschedule(): void {
|
|
this._store.dispatch(
|
|
TaskSharedActions.unscheduleTask({
|
|
id: this.task().id,
|
|
}),
|
|
);
|
|
}
|
|
|
|
titleBarClick(event: MouseEvent): void {
|
|
const targetEl = event.target as HTMLElement;
|
|
if (targetEl.closest('task-title')) {
|
|
return;
|
|
}
|
|
if (isTouchActive() && this.task().title.length) {
|
|
this.toggleShowDetailPanel(event);
|
|
} else {
|
|
this.focusSelf();
|
|
}
|
|
}
|
|
|
|
focusPrevious(isFocusReverseIfNotPossible: boolean = false): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
|
|
const taskEls = Array.from(document.querySelectorAll('task'));
|
|
const activeEl =
|
|
document.activeElement?.tagName.toLowerCase() === 'task'
|
|
? document.activeElement
|
|
: document.activeElement?.closest('task');
|
|
const currentIndex = taskEls.findIndex((el) => el === activeEl);
|
|
const prevEl = taskEls[currentIndex - 1] as HTMLElement;
|
|
|
|
if (prevEl) {
|
|
prevEl.focus();
|
|
// hacky but works
|
|
setTimeout(() => {
|
|
if (document.activeElement !== prevEl) {
|
|
prevEl.focus();
|
|
}
|
|
});
|
|
} else if (isFocusReverseIfNotPossible) {
|
|
this.focusNext();
|
|
}
|
|
}
|
|
|
|
focusNext(
|
|
isFocusReverseIfNotPossible: boolean = false,
|
|
isTaskMovedInList = false,
|
|
): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
|
|
const nextEl = this._getNextFocusEl(isTaskMovedInList);
|
|
this._nextFocusTaskEl = undefined;
|
|
|
|
if (nextEl) {
|
|
nextEl.focus();
|
|
// hacky but works
|
|
setTimeout(() => {
|
|
if (document.activeElement !== nextEl) {
|
|
nextEl.focus();
|
|
}
|
|
});
|
|
} else if (isFocusReverseIfNotPossible) {
|
|
this.focusPrevious();
|
|
}
|
|
}
|
|
|
|
focusSelf(): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
this._focusSelfElement();
|
|
}
|
|
|
|
focusSelfOrNextIfNotPossible(): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
|
|
this.focusSelf();
|
|
// we don't clear the timeout since this should be executed if task is gone
|
|
window.setTimeout(() => {
|
|
if (
|
|
!document.activeElement ||
|
|
document.activeElement.tagName.toLowerCase() !== 'task'
|
|
) {
|
|
this.focusNext(true);
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
private _focusSelfElement(): void {
|
|
this._elementRef.nativeElement.focus();
|
|
}
|
|
|
|
focusTitleForEdit(): void {
|
|
const taskTitleEditEl = this.taskTitleEditEl();
|
|
if (!taskTitleEditEl) {
|
|
TaskLog.log(taskTitleEditEl);
|
|
throw new Error('No el');
|
|
}
|
|
taskTitleEditEl.focusInput();
|
|
}
|
|
|
|
onHostTouchStart(): void {
|
|
this._dragReadyTimeout = window.setTimeout(() => {
|
|
this.isDragReady.set(true);
|
|
}, DRAG_DELAY_FOR_TOUCH);
|
|
}
|
|
|
|
onHostTouchEnd(): void {
|
|
this._cancelDragReady();
|
|
}
|
|
|
|
// A confirmed horizontal swipe (open menu / mark done) is not a drag, so
|
|
// cancel the pending long-press drag-ready state before it can fire mid-swipe.
|
|
onSwipeStart(): void {
|
|
this._cancelDragReady();
|
|
}
|
|
|
|
private _cancelDragReady(): void {
|
|
window.clearTimeout(this._dragReadyTimeout);
|
|
this.isDragReady.set(false);
|
|
}
|
|
|
|
onHostContextMenu(event: MouseEvent): void {
|
|
if (isTouchActive()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
this.openContextMenu(event);
|
|
}
|
|
|
|
openContextMenu(event?: TouchEvent | MouseEvent | KeyboardEvent): void {
|
|
this.taskTitleEditEl()?.cancelEditing();
|
|
if (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if ('stopImmediatePropagation' in event) {
|
|
event.stopImmediatePropagation();
|
|
}
|
|
}
|
|
|
|
if (!this.isContextMenuLoaded()) {
|
|
this.isContextMenuLoaded.set(true);
|
|
setTimeout(() => {
|
|
this.taskContextMenu()?.open(event);
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.taskContextMenu()?.open(event);
|
|
}
|
|
|
|
onTagsUpdated(tagIds: string[]): void {
|
|
this._taskService.updateTags(this.task(), tagIds);
|
|
}
|
|
|
|
// TODO extract so service
|
|
moveTaskToProject(projectId: string): void {
|
|
const t = this.task();
|
|
if (projectId === t.projectId) {
|
|
return;
|
|
} else if (!t.repeatCfgId) {
|
|
this._taskService.moveToProject(t, projectId);
|
|
setTimeout(() => this.focusNext(true));
|
|
} else {
|
|
forkJoin([
|
|
this._taskRepeatCfgService
|
|
.getTaskRepeatCfgByIdAllowUndefined$(t.repeatCfgId)
|
|
.pipe(first()),
|
|
this._taskService.getTasksWithSubTasksByRepeatCfgId$(t.repeatCfgId).pipe(first()),
|
|
this._taskService.getArchiveTasksForRepeatCfgId(t.repeatCfgId),
|
|
this._projectService.getByIdOnce$(projectId),
|
|
])
|
|
.pipe(
|
|
concatMap(
|
|
([
|
|
reminderCfg,
|
|
nonArchiveInstancesWithSubTasks,
|
|
archiveInstances,
|
|
targetProject,
|
|
]) => {
|
|
TaskLog.log({
|
|
reminderCfg,
|
|
nonArchiveInstancesWithSubTasks,
|
|
archiveInstances,
|
|
});
|
|
|
|
// Repeat config was deleted (e.g. via cross-client sync) but the task
|
|
// still references it — treat it as a plain task move instead of
|
|
// crashing on the missing config. (#8715)
|
|
if (!reminderCfg) {
|
|
this._taskService.moveToProject(this.task(), projectId);
|
|
setTimeout(() => this.focusNext(true));
|
|
return EMPTY;
|
|
}
|
|
|
|
// if there is only a single instance (probably just created) than directly update the task repeat cfg
|
|
if (
|
|
nonArchiveInstancesWithSubTasks.length === 1 &&
|
|
archiveInstances.length === 0
|
|
) {
|
|
this._taskRepeatCfgService.updateTaskRepeatCfg(reminderCfg.id, {
|
|
projectId,
|
|
});
|
|
this._taskService.moveToProject(this.task(), projectId);
|
|
setTimeout(() => this.focusNext(true));
|
|
return EMPTY;
|
|
}
|
|
|
|
return this._matDialog
|
|
.open(DialogConfirmComponent, {
|
|
data: {
|
|
okTxt: T.F.TASK_REPEAT.D_CONFIRM_MOVE_TO_PROJECT.OK,
|
|
message: T.F.TASK_REPEAT.D_CONFIRM_MOVE_TO_PROJECT.MSG,
|
|
translateParams: {
|
|
projectName: targetProject?.title ?? _MISSING_PROJECT_,
|
|
tasksNr:
|
|
nonArchiveInstancesWithSubTasks.length + archiveInstances.length,
|
|
},
|
|
},
|
|
})
|
|
.afterClosed()
|
|
.pipe(
|
|
tap((isConfirm) => {
|
|
if (isConfirm) {
|
|
this._taskRepeatCfgService.updateTaskRepeatCfg(reminderCfg.id, {
|
|
projectId,
|
|
});
|
|
nonArchiveInstancesWithSubTasks.forEach((nonArchiveTask) => {
|
|
this._taskService.moveToProject(nonArchiveTask, projectId);
|
|
});
|
|
|
|
const archiveUpdates: Update<TaskCopy>[] = [];
|
|
archiveInstances.forEach((archiveTask) => {
|
|
archiveUpdates.push({
|
|
id: archiveTask.id,
|
|
changes: { projectId },
|
|
});
|
|
if (archiveTask.subTaskIds.length) {
|
|
archiveTask.subTaskIds.forEach((subId) => {
|
|
archiveUpdates.push({
|
|
id: subId,
|
|
changes: { projectId },
|
|
});
|
|
});
|
|
}
|
|
});
|
|
this._taskService.updateArchiveTasks(archiveUpdates);
|
|
setTimeout(() => this.focusNext(true));
|
|
}
|
|
}),
|
|
);
|
|
},
|
|
),
|
|
)
|
|
.subscribe(() => this.focusSelf());
|
|
}
|
|
}
|
|
|
|
moveToBacklog(): void {
|
|
const t = this.task();
|
|
if (t.projectId && !t.parentId) {
|
|
// Moving to the backlog is a list-position change only; it must not
|
|
// alter the task's schedule (#8592).
|
|
this._projectService.moveTaskToBacklog(t.id, t.projectId);
|
|
}
|
|
}
|
|
|
|
moveToToday(): void {
|
|
const t = this.task();
|
|
if (!t.projectId) {
|
|
return;
|
|
}
|
|
// An overdue task is never in the backlog, so the position-only move below
|
|
// early-returns for it (moveProjectTaskToRegularListAuto) and Shift+T would
|
|
// no-op. Schedule it for today instead — the same thing the "Add to My Day"
|
|
// button and Schedule → Today do (#8851). Overdue vs. backlog→regular are
|
|
// cleanly separated because overdue tasks are never in the backlog. Exclude
|
|
// done tasks: a done task with a stale past dueDay can still sit in the
|
|
// backlog, and it should take the position-only move, not be re-added to
|
|
// Today. (isTaskOverdue stays done-agnostic — selectOverdueTasks needs
|
|
// done tasks included.)
|
|
if (
|
|
!t.isDone &&
|
|
isTaskOverdue(
|
|
t,
|
|
this._dateService.todayStr(),
|
|
this._dateService.getStartOfNextDayDiffMs(),
|
|
)
|
|
) {
|
|
this.addToMyDay();
|
|
return;
|
|
}
|
|
// Moving to the regular list is a list-position change only; it must not
|
|
// schedule the task for today (#8592).
|
|
this._projectService.moveTaskToTodayList(t.id, t.projectId);
|
|
}
|
|
|
|
trackByProjectId(i: number, project: Project): string {
|
|
return project.id;
|
|
}
|
|
|
|
private _storeNextFocusEl(): void {
|
|
this._nextFocusTaskEl = this._getNextFocusEl();
|
|
}
|
|
|
|
private _getNextFocusEl(isTaskMovedInList = false): HTMLElement | undefined {
|
|
if (this._nextFocusTaskEl) {
|
|
return this._nextFocusTaskEl;
|
|
}
|
|
|
|
const taskEls = Array.from(document.querySelectorAll('task'));
|
|
const activeEl =
|
|
document.activeElement?.tagName.toLowerCase() === 'task'
|
|
? document.activeElement
|
|
: document.activeElement?.closest('task');
|
|
const currentIndex = taskEls.findIndex((el) => el === activeEl);
|
|
const nextEl = isTaskMovedInList
|
|
? (() => {
|
|
// if a parent task is moved in list, as it is for when toggling done,
|
|
// we don't want to focus the next sub-task, but the next main task instead
|
|
if (this.task().subTaskIds.length) {
|
|
return taskEls.find((el, i) => {
|
|
return i > currentIndex && el.parentElement?.closest('task');
|
|
}) as HTMLElement | undefined;
|
|
}
|
|
return taskEls[currentIndex + 1] as HTMLElement;
|
|
})()
|
|
: (taskEls[currentIndex + 1] as HTMLElement);
|
|
return nextEl;
|
|
}
|
|
|
|
get kb(): KeyboardConfig {
|
|
if (isTouchActive()) {
|
|
return EMPTY_KEYBOARD_CONFIG;
|
|
}
|
|
return keyboardConfigOrEmpty(this._configService.cfg()?.keyboard as KeyboardConfig);
|
|
}
|
|
|
|
protected readonly ICAL_TYPE = ICAL_TYPE;
|
|
}
|