feat(planner): implement endless scroll for future days

Convert planner from fixed 15-day view to endless scroller that loads 7 more days when scrolling to the last day. Uses IntersectionObserver for efficient visibility detection with proper cleanup.
This commit is contained in:
Johannes Millan 2026-01-20 18:14:38 +01:00
parent 2bcdd52037
commit c6ceaa5f6b
5 changed files with 160 additions and 18 deletions

View file

@ -6,4 +6,9 @@
@for (day of days$ | async; track day.dayDate) {
<planner-day [day]="day"></planner-day>
}
@if (isLoadingMore$ | async) {
<div class="loading-more">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
</div>

View file

@ -26,3 +26,16 @@
padding: 16px;
}
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
min-width: 260px;
padding: 2rem;
@include mq(xs, max) {
min-width: 100%;
width: 100%;
}
}

View file

@ -1,4 +1,12 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
ElementRef,
inject,
viewChildren,
} from '@angular/core';
import { Observable } from 'rxjs';
import { T } from '../../../t.const';
import { PlannerDay } from '../planner.model';
@ -8,21 +16,94 @@ import { AsyncPipe } from '@angular/common';
import { Store } from '@ngrx/store';
import { selectUndoneOverdue } from '../../tasks/store/task.selectors';
import { PlannerDayOverdueComponent } from '../planner-day-overdue/planner-day-overdue.component';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
@Component({
selector: 'planner-plan-view',
templateUrl: './planner-plan-view.component.html',
styleUrl: './planner-plan-view.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PlannerDayComponent, AsyncPipe, PlannerDayOverdueComponent],
imports: [
PlannerDayComponent,
AsyncPipe,
PlannerDayOverdueComponent,
MatProgressSpinner,
],
})
export class PlannerPlanViewComponent {
private _plannerService = inject(PlannerService);
private _store = inject(Store);
private _destroyRef = inject(DestroyRef);
overdue$ = this._store.select(selectUndoneOverdue);
days$: Observable<PlannerDay[]> = this._plannerService.days$;
isLoadingMore$ = this._plannerService.isLoadingMore$;
dayElements = viewChildren(PlannerDayComponent, { read: ElementRef });
private _intersectionObserver?: IntersectionObserver;
private _lastObservedElement?: Element;
protected readonly T = T;
constructor() {
// Setup intersection observer when day elements change
effect(() => {
const elements = this.dayElements();
if (elements.length > 0) {
this._setupIntersectionObserver(elements);
}
});
// Cleanup observer on component destroy
this._destroyRef.onDestroy(() => {
this._intersectionObserver?.disconnect();
});
}
private _setupIntersectionObserver(elements: readonly ElementRef[]): void {
// Disconnect existing observer
this._intersectionObserver?.disconnect();
// Get last day element
const lastElement = elements[elements.length - 1]?.nativeElement;
// If no element, return early
if (!lastElement) {
return;
}
// If same element as last time, no need to recreate observer
if (lastElement === this._lastObservedElement) {
// Just re-observe the same element with the existing observer
this._intersectionObserver?.observe(lastElement);
return;
}
// Store the last observed element
this._lastObservedElement = lastElement;
// Create new IntersectionObserver
this._intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// Only trigger if:
// 1. Entry is actually intersecting
// 2. Not already loading
if (entry.isIntersecting && !this._plannerService.isLoadingMore$.value) {
// Clear the last observed element so we can observe the next one
this._lastObservedElement = undefined;
// Trigger loading more days
this._plannerService.loadMoreDays();
}
});
},
{
threshold: 0.1,
},
);
// Observe the last day element
this._intersectionObserver.observe(lastElement);
}
}

View file

@ -1,5 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { combineLatest, Observable, of } from 'rxjs';
import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs';
import { first, map, shareReplay, switchMap, tap } from 'rxjs/operators';
import { selectAllTasksWithDueTime } from '../tasks/store/task.selectors';
import { Store } from '@ngrx/store';
@ -24,22 +24,43 @@ export class PlannerService {
private _dateService = inject(DateService);
private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
private _daysToShowCount$ = new BehaviorSubject<number>(15);
public isLoadingMore$ = new BehaviorSubject<boolean>(false);
includedWeekDays$ = of([0, 1, 2, 3, 4, 5, 6]);
daysToShow$ = this._globalTrackingIntervalService.todayDateStr$.pipe(
tap((val) => Log.log('daysToShow$', val)),
switchMap(() => this.includedWeekDays$),
map((includedWeekDays) => {
const today = new Date().getTime();
const todayDayNr = new Date(today).getDay();
const nrOfDaysToShow = 15;
const daysToShow: string[] = [];
for (let i = 0; i < nrOfDaysToShow; i++) {
if (includedWeekDays.includes((i + todayDayNr) % 7)) {
// eslint-disable-next-line no-mixed-operators
daysToShow.push(this._dateService.todayStr(today + i * 24 * 60 * 60 * 1000));
}
daysToShow$ = combineLatest([
this._daysToShowCount$,
this._globalTrackingIntervalService.todayDateStr$,
this.includedWeekDays$,
]).pipe(
tap(([count, todayStr]) => Log.log('daysToShow$', { count, todayStr })),
map(([count, _, includedWeekDays]) => {
// Guard against empty includedWeekDays to prevent infinite loop
if (includedWeekDays.length === 0) {
return [];
}
const today = new Date().getTime();
const daysToShow: string[] = [];
// CRITICAL FIX: Loop until we have the required count of days
// (not just iterate N times which produces fewer days if weekends are excluded)
let daysAdded = 0;
let offset = 0;
while (daysAdded < count) {
// eslint-disable-next-line no-mixed-operators
const dayOfWeek = new Date(today + offset * 24 * 60 * 60 * 1000).getDay();
if (includedWeekDays.includes(dayOfWeek)) {
daysToShow.push(
// eslint-disable-next-line no-mixed-operators
this._dateService.todayStr(today + offset * 24 * 60 * 60 * 1000),
);
daysAdded++;
}
offset++;
}
return daysToShow;
}),
);
@ -118,4 +139,15 @@ export class PlannerService {
)
.toPromise();
}
loadMoreDays(): void {
this.isLoadingMore$.next(true);
// Yield to event loop to ensure loading state is visible
setTimeout(() => {
const currentCount = this._daysToShowCount$.value;
this._daysToShowCount$.next(currentCount + 7);
this.isLoadingMore$.next(false);
}, 0);
}
}

View file

@ -103,6 +103,8 @@ export class OperationApplierService {
// Trigger archive reload for UI if archive-affecting operations were applied
if (archiveResult.hadArchiveAffectingOp) {
this.store.dispatch(remoteArchiveDataApplied());
// Yield to let the remoteArchiveDataApplied effect run (refreshWorklog)
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
} finally {
@ -140,7 +142,8 @@ export class OperationApplierService {
const appliedOps: Operation[] = [];
let hadArchiveAffectingOp = false;
for (const op of ops) {
for (let i = 0; i < ops.length; i++) {
const op = ops[i];
try {
const action = convertOpToAction(op);
@ -150,6 +153,9 @@ export class OperationApplierService {
// Track if any archive-affecting operations were processed (for UI refresh)
if (isArchiveAffectingAction(action)) {
hadArchiveAffectingOp = true;
// Yield after EACH archive-affecting operation to prevent UI freeze.
// Archive operations involve slow IndexedDB writes that can block the event loop.
await new Promise((resolve) => setTimeout(resolve, 0));
}
appliedOps.push(op);
@ -171,6 +177,11 @@ export class OperationApplierService {
}
}
// Final yield after processing all operations to ensure last operation completes
if (ops.length > 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
return { appliedOps, hadArchiveAffectingOp };
}
}