feat: improve performance during route changes

This commit is contained in:
Johannes Millan 2025-10-24 12:48:25 +02:00
parent c34f258c50
commit fddedf3fa6
3 changed files with 144 additions and 38 deletions

View file

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

View file

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

View file

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