mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
2bcdd52037
commit
c6ceaa5f6b
5 changed files with 160 additions and 18 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue