From fddedf3fa674aa84eaa898dce03745d0e8c441e2 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 24 Oct 2025 12:48:25 +0200 Subject: [PATCH] feat: improve performance during route changes --- .../task-view-customizer.service.ts | 27 ++++-- .../work-context/work-context.service.ts | 72 +++++++++++++++- .../features/work-view/work-view.component.ts | 83 ++++++++++++------- 3 files changed, 144 insertions(+), 38 deletions(-) diff --git a/src/app/features/task-view-customizer/task-view-customizer.service.ts b/src/app/features/task-view-customizer/task-view-customizer.service.ts index e4d344ed0c..b9f92d4698 100644 --- a/src/app/features/task-view-customizer/task-view-customizer.service.ts +++ b/src/app/features/task-view-customizer/task-view-customizer.service.ts @@ -1,6 +1,6 @@ import { Injectable, signal, inject } from '@angular/core'; -import { Observable, combineLatest } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { Observable, animationFrameScheduler, combineLatest } from 'rxjs'; +import { map, observeOn } from 'rxjs/operators'; import { TaskWithSubTasks } from '../tasks/task.model'; import { selectAllProjects } from '../project/store/project.selectors'; import { selectAllTags } from './../tag/store/tag.reducer'; @@ -66,11 +66,26 @@ export class TaskViewCustomizerService { toObservable(this.selectedFilter), toObservable(this.filterInputValue), ]).pipe( + observeOn(animationFrameScheduler), map(([undone, sort, group, filter, filterVal]) => { - const filtered = this.applyFilter(undone, filter, filterVal); - const sorted = this.applySort(filtered, sort); - const grouped = - group !== 'default' ? this.applyGrouping(sorted, group) : undefined; + const normalizedFilterVal = + typeof filterVal === 'string' ? filterVal.trim() : filterVal; + const filterValueToUse = + typeof normalizedFilterVal === 'string' ? normalizedFilterVal : ''; + + const isDefaultFilter = filter === 'default' || !filterValueToUse; + const isDefaultSort = sort === 'default'; + const isDefaultGroup = group === 'default'; + + if (isDefaultFilter && isDefaultSort && isDefaultGroup) { + return { list: undone }; + } + + const filtered = isDefaultFilter + ? undone + : this.applyFilter(undone, filter, filterValueToUse); + const sorted = isDefaultSort ? filtered : this.applySort(filtered, sort); + const grouped = !isDefaultGroup ? this.applyGrouping(sorted, group) : undefined; return { list: sorted, grouped }; }), ); diff --git a/src/app/features/work-context/work-context.service.ts b/src/app/features/work-context/work-context.service.ts index ed000172c1..95bed3157b 100644 --- a/src/app/features/work-context/work-context.service.ts +++ b/src/app/features/work-context/work-context.service.ts @@ -11,7 +11,7 @@ import { WorkContextType, } from './work-context.model'; import { setActiveWorkContext } from './store/work-context.actions'; -import { NavigationEnd, Router } from '@angular/router'; +import { NavigationEnd, NavigationStart, Router } from '@angular/router'; import { concatMap, delayWhen, @@ -81,6 +81,10 @@ export class WorkContextService { private _translateService = inject(TranslateService); private _timeTrackingService = inject(TimeTrackingService); private _taskArchiveService = inject(TaskArchiveService); + private _pendingNavigationUrl: string | null = null; + private readonly _navigationMeasureName = 'work-view-route'; + private readonly _navigationStartMark = 'work-view-route:start'; + private readonly _navigationEndMark = 'work-view-route:end'; // here because to avoid circular dependencies // should be treated as private @@ -409,6 +413,10 @@ export class WorkContextService { this.activeWorkContextType = v.activeType; }); + this._router.events + .pipe(filter((event): event is NavigationStart => event instanceof NavigationStart)) + .subscribe((event) => this._markNavigationStart(event.url)); + // we need all data to be loaded before we dispatch a setActiveContext action this._router.events .pipe( @@ -427,6 +435,8 @@ export class WorkContextService { ), ) .subscribe(({ urlAfterRedirects }: NavigationEnd) => { + this._markNavigationEnd(urlAfterRedirects); + const split = urlAfterRedirects.split('/'); const id = split[2]; @@ -668,4 +678,64 @@ export class WorkContextService { private _setActiveContext(activeId: string, activeType: WorkContextType): void { this._store$.dispatch(setActiveWorkContext({ activeId, activeType })); } + + private _markNavigationStart(url: string): void { + if (!this._isPerformanceApiAvailable() || !this._shouldTrackNavigation(url)) { + return; + } + + this._pendingNavigationUrl = url; + performance.mark(this._navigationStartMark); + } + + private _markNavigationEnd(url: string): void { + if ( + !this._isPerformanceApiAvailable() || + !this._pendingNavigationUrl || + !this._shouldTrackNavigation(url) + ) { + return; + } + + performance.mark(this._navigationEndMark); + try { + performance.measure( + this._navigationMeasureName, + this._navigationStartMark, + this._navigationEndMark, + ); + } catch { + // ignore measure errors (e.g. start mark missing) + } finally { + this._clearNavigationMarks(); + this._pendingNavigationUrl = null; + } + } + + private _clearNavigationMarks(): void { + if (!this._isPerformanceApiAvailable()) { + return; + } + try { + performance.clearMarks(this._navigationStartMark); + performance.clearMarks(this._navigationEndMark); + performance.clearMeasures(this._navigationMeasureName); + } catch { + // ignore cleanup errors + } + } + + private _shouldTrackNavigation(url: string): boolean { + return /(tag|project)\/.+\/tasks/.test(url); + } + + private _isPerformanceApiAvailable(): boolean { + return ( + typeof performance !== 'undefined' && + typeof performance.mark === 'function' && + typeof performance.measure === 'function' && + typeof performance.clearMarks === 'function' && + typeof performance.clearMeasures === 'function' + ); + } } diff --git a/src/app/features/work-view/work-view.component.ts b/src/app/features/work-view/work-view.component.ts index 158265b31e..8aedd4cea7 100644 --- a/src/app/features/work-view/work-view.component.ts +++ b/src/app/features/work-view/work-view.component.ts @@ -1,11 +1,11 @@ import { - AfterContentInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, effect, ElementRef, + afterNextRender, inject, input, OnDestroy, @@ -51,7 +51,6 @@ import { AsyncPipe, CommonModule } from '@angular/common'; import { MsToStringPipe } from '../../ui/duration/ms-to-string.pipe'; import { TranslatePipe } from '@ngx-translate/core'; import { - flattenTasks, selectLaterTodayTasksWithSubTasks, selectOverdueTasksWithSubTasks, } from '../tasks/store/task.selectors'; @@ -94,7 +93,7 @@ import { FinishDayBtnComponent } from './finish-day-btn/finish-day-btn.component FinishDayBtnComponent, ], }) -export class WorkViewComponent implements OnInit, OnDestroy, AfterContentInit { +export class WorkViewComponent implements OnInit, OnDestroy { taskService = inject(TaskService); takeABreakService = inject(TakeABreakService); planningModeService = inject(PlanningModeService); @@ -177,29 +176,18 @@ export class WorkViewComponent implements OnInit, OnDestroy, AfterContentInit { const currentSelectedId = this.selectedTaskId(); if (!currentSelectedId) return; - const undoneArr = flattenTasks(this.undoneTasks()); - if (undoneArr.some((t) => t.id === currentSelectedId)) return; - - const doneArr = flattenTasks(this.doneTasks()); - if (doneArr.some((t) => t.id === currentSelectedId)) return; - - if ( - this.laterTodayTasks().some( - (t) => t.id === currentSelectedId || t.subTaskIds.includes(currentSelectedId), - ) - ) - return; + if (this._hasTaskInList(this.undoneTasks(), currentSelectedId)) return; + if (this._hasTaskInList(this.doneTasks(), currentSelectedId)) return; + if (this._hasTaskInList(this.laterTodayTasks(), currentSelectedId)) return; if ( this.workContextService.activeWorkContextId === TODAY_TAG.id && - this.overdueTasks().length > 0 && - flattenTasks(this.overdueTasks()).some((t) => t.id === currentSelectedId) + this._hasTaskInList(this.overdueTasks(), currentSelectedId) ) return; // Check if task is in backlog - const backlogArr = flattenTasks(this.backlogTasks()); - if (backlogArr.some((t) => t.id === currentSelectedId)) return; + if (this._hasTaskInList(this.backlogTasks(), currentSelectedId)) return; // if task really is gone this.taskService.setSelectedId(null); @@ -231,6 +219,8 @@ export class WorkViewComponent implements OnInit, OnDestroy, AfterContentInit { localStorage.removeItem(LS.OVERDUE_TASKS_HIDDEN); } }); + + afterNextRender(() => this._initScrollTracking()); } ngOnInit(): void { @@ -251,22 +241,11 @@ export class WorkViewComponent implements OnInit, OnDestroy, AfterContentInit { ); } - ngAfterContentInit(): void { - this._subs.add( - this.upperContainerScroll$.subscribe(({ target }) => { - if ((target as HTMLElement).scrollTop !== 0) { - this.layoutService.isScrolled.set(true); - } else { - this.layoutService.isScrolled.set(false); - } - }), - ); - } - ngOnDestroy(): void { if (this._switchListAnimationTimeout) { window.clearTimeout(this._switchListAnimationTimeout); } + this._subs.unsubscribe(); this.layoutService.isScrolled.set(false); } @@ -323,4 +302,46 @@ export class WorkViewComponent implements OnInit, OnDestroy, AfterContentInit { }), ); } + + private _initScrollTracking(): void { + this._subs.add( + this.upperContainerScroll$.subscribe(({ target }) => { + if ((target as HTMLElement).scrollTop !== 0) { + this.layoutService.isScrolled.set(true); + } else { + this.layoutService.isScrolled.set(false); + } + }), + ); + } + + private _hasTaskInList( + taskList: TaskWithSubTasks[] | null | undefined, + taskId: string, + ): boolean { + if (!taskList || !taskList.length) { + return false; + } + + for (const task of taskList) { + if (!task) { + continue; + } + + if (task.id === taskId) { + return true; + } + + const subTasks = task.subTasks; + if (Array.isArray(subTasks) && subTasks.length) { + for (const subTask of subTasks) { + if (subTask && subTask.id === taskId) { + return true; + } + } + } + } + + return false; + } }