diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 401f22b1f5..07196c3b22 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -9,6 +9,7 @@ import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import Portal from '$lib/elements/Portal.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte'; + import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; @@ -18,6 +19,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; + import { isAssetViewerRoute } from '$lib/utils/navigation'; import { getSegmentIdentifier, getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; @@ -128,13 +130,25 @@ timelineManager.scrollableElement = scrollableElement; }); - const getAssetPosition = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId); + const scrollToAssetPosition = (assetId: string, month: TimelineMonth) => { + const position = month.findAssetAbsolutePosition(assetId); - const getAssetHeight = (assetId: string, month: TimelineMonth) => month.findAssetAbsolutePosition(assetId); + if (!position) { + return; + } - const assetIsVisible = (assetTop: number): boolean => { - if (!scrollableElement) { - return false; + // Need to update window positions/intersections because may have + // gone from invisible to visible. + timelineManager.updateVisibleWindow(); + + const assetTop = position.top; + const assetBottom = position.top + position.height; + const visibleTop = timelineManager.visibleWindow.top; + const visibleBottom = timelineManager.visibleWindow.bottom; + + // Check if the asset is already at least partially visible in the viewport + if (isIntersecting(assetTop, assetBottom, visibleTop, visibleBottom)) { + return; } const currentTop = scrollableElement?.scrollTop || 0; @@ -159,33 +173,21 @@ timelineManager.scrollTo(scrollTarget); }; - const scrollToAssetId = async (assetId: string) => { - const month = await timelineManager.search.getMonthForAsset(assetId); + const scrollAndLoadAsset = async (assetId: string) => { + const month = await timelineManager.findMonthForAsset(assetId); if (!month) { return false; } - - const height = getAssetHeight(assetId, month); - - // If the asset is already visible, then don't scroll. - if (assetIsVisible(height)) { - // need to update window positions/intersections because since the - // went from invisible to visible - timelineManager.updateVisibleWindow(); - return true; - } - - timelineManager.scrollTo(height); + scrollToAssetPosition(assetId, month); return true; }; const scrollToAsset = (asset: TimelineAsset) => { - const month = timelineManager.getSegmentForAssetId(asset.id) as TimelineMonth | undefined; + const month = timelineManager.search.findMonthForAsset(asset.id)?.month; if (!month) { return false; } - const height = getAssetHeight(asset.id, month); - timelineManager.scrollTo(height); + scrollToAssetPosition(asset.id, month); return true; }; diff --git a/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts b/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts index 5bbb369e9e..0a5058ba41 100644 --- a/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts +++ b/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts @@ -41,7 +41,7 @@ export abstract class ScrollSegment { abstract get viewerAssets(): ViewerAsset[]; - abstract findAssetAbsolutePosition(assetId: string): number; + abstract findAssetAbsolutePosition(assetId: string): { top: number; height: number } | undefined; protected abstract fetch(signal: AbortSignal): Promise; diff --git a/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts index e23251c6d6..72dec0a6e2 100644 --- a/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts @@ -1,5 +1,5 @@ -import { onCreateTimelineDay } from '$lib/managers/timeline-manager/internal/TestHooks.svelte'; import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; +import { onCreateTimelineDay } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte'; import type { AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; import type { CommonLayoutOptions } from '$lib/utils/layout-utils'; diff --git a/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineInsertionCache.svelte.ts similarity index 93% rename from web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts rename to web/src/lib/managers/timeline-manager/TimelineInsertionCache.svelte.ts index a8fd78240d..5ef64b9157 100644 --- a/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineInsertionCache.svelte.ts @@ -1,7 +1,8 @@ import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { setDifference, type TimelineDate } from '$lib/utils/timeline-util'; +import { setDifference } from '$lib/managers/timeline-manager/utils.svelte'; +import { type TimelineDate } from '$lib/utils/timeline-util'; import { AssetOrder } from '@immich/sdk'; export class GroupInsertionCache { diff --git a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts index af1da46c8b..7ca6b9902e 100644 --- a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts @@ -1,9 +1,8 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; -import { getMonthByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte'; -import { setTestHooks } from '$lib/managers/timeline-manager/internal/TestHooks.svelte'; import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; +import { setTestHooks } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { AbortError } from '$lib/utils'; import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util'; @@ -134,10 +133,10 @@ describe('TimelineManager', () => { }); it('loads a month', async () => { - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(0); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(3); }); it('ignores invalid months', async () => { @@ -146,13 +145,13 @@ describe('TimelineManager', () => { }); it('cancels month loading', async () => { - const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!; + const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 })!; void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort'); month?.cancel(); expect(abortSpy).toBeCalledTimes(1); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(3); }); it('prevents loading months multiple times', async () => { @@ -167,7 +166,7 @@ describe('TimelineManager', () => { }); it('allows loading a canceled month', async () => { - const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!; + const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 })!; const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); month.cancel(); @@ -244,7 +243,7 @@ describe('TimelineManager', () => { ); timelineManager.upsertAssets([assetOne, assetTwo, assetThree]); - const month = getMonthByDate(timelineManager, { year: 2024, month: 1 }); + const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 }); expect(month).not.toBeNull(); expect(month?.assets.length).toEqual(3); expect(month?.assets[0].id).toEqual(assetOne.id); @@ -455,15 +454,15 @@ describe('TimelineManager', () => { timelineManager.upsertAssets([asset]); expect(timelineManager.segments.length).toEqual(1); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })).not.toBeUndefined(); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(1); timelineManager.upsertAssets([updatedAsset]); expect(timelineManager.segments.length).toEqual(2); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0); - expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); - expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })).not.toBeUndefined(); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(0); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })).not.toBeUndefined(); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })?.assets.length).toEqual(1); }); }); @@ -593,7 +592,7 @@ describe('TimelineManager', () => { it('returns previous assetId', async () => { await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); - const month = getMonthByDate(timelineManager, { year: 2024, month: 1 }); + const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 }); const a = month!.assets[0]; const b = month!.assets[1]; @@ -605,8 +604,8 @@ describe('TimelineManager', () => { await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 })); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 })); - const month = getMonthByDate(timelineManager, { year: 2024, month: 2 }); - const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 }); + const month = timelineManager.search.findMonthByDate({ year: 2024, month: 2 }); + const previousMonth = timelineManager.search.findMonthByDate({ year: 2024, month: 3 }); const a = month!.assets[0]; const b = previousMonth!.assets[0]; const previous = await timelineManager.getLaterAsset(a); @@ -615,8 +614,8 @@ describe('TimelineManager', () => { it('loads previous month', async () => { await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 })); - const month = getMonthByDate(timelineManager, { year: 2024, month: 2 }); - const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 }); + const month = timelineManager.search.findMonthByDate({ year: 2024, month: 2 }); + const previousMonth = timelineManager.search.findMonthByDate({ year: 2024, month: 3 }); const a = month!.getFirstAsset(); const b = previousMonth!.getFirstAsset(); const loadmonthSpy = vi.spyOn(month!.loader!, 'execute'); @@ -654,8 +653,8 @@ describe('TimelineManager', () => { }); it('returns null for invalid months', () => { - expect(getMonthByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined(); - expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined(); + expect(timelineManager.search.findMonthByDate({ year: -1, month: -1 })).toBeUndefined(); + expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })).toBeUndefined(); }); it('returns the month index', () => { diff --git a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts index 580948fda4..d39621b518 100644 --- a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts @@ -1,18 +1,10 @@ import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; +import { GroupInsertionCache } from '$lib/managers/timeline-manager/TimelineInsertionCache.svelte'; import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; -import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; -import { - findClosestGroupForDate, - findMonthForAsset as findMonthForAssetUtil, - findMonthForDate, - getAssetWithOffset, - getMonthByDate, - retrieveRange as retrieveRangeUtil, -} from '$lib/managers/timeline-manager/internal/search-support.svelte'; -import { isMismatched, updateObject } from '$lib/managers/timeline-manager/internal/utils.svelte'; -import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; +import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte'; +import { TimelineWebsocketExtension } from '$lib/managers/timeline-manager/TimelineWebsocketExtension'; import type { AssetDescriptor, AssetOperation, @@ -22,10 +14,10 @@ import type { TimelineManagerOptions, Viewport, } from '$lib/managers/timeline-manager/types'; +import { isMismatched, setDifferenceInPlace, updateObject } from '$lib/managers/timeline-manager/utils.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; import { getSegmentIdentifier, - setDifferenceInPlace, toTimelineAsset, type TimelineDateTime, type TimelineYearMonth, @@ -36,14 +28,10 @@ import { SvelteDate, SvelteSet } from 'svelte/reactivity'; export class TimelineManager extends VirtualScrollManager { override bottomSectionHeight = $state(60); - - segments: TimelineMonth[] = $state([]); - albumAssets: Set = new SvelteSet(); - scrubberMonths: ScrubberMonth[] = $state([]); - scrubberTimelineHeight: number = $state(0); - - limitedScroll = $derived(this.maxScrollPercent < 0.5); - initTask = new CancellableTask( + readonly search = new TimelineSearchExtension(this); + readonly albumAssets: Set = new SvelteSet(); + readonly limitedScroll = $derived(this.maxScrollPercent < 0.5); + readonly initTask = new CancellableTask( () => { this.isInitialized = true; if (this.#options.albumId || this.#options.personId) { @@ -58,10 +46,12 @@ export class TimelineManager extends VirtualScrollManager { () => void 0, ); - static #INIT_OPTIONS = {}; - #websocketSupport: WebsocketSupport | undefined; - #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS; + segments: TimelineMonth[] = $state([]); + scrubberMonths: ScrubberMonth[] = $state([]); + scrubberTimelineHeight: number = $state(0); + #websocketSupport: TimelineWebsocketExtension | undefined; + #options: TimelineManagerOptions = {}; #scrollableElement: HTMLElement | undefined = $state(); constructor() { @@ -106,7 +96,7 @@ export class TimelineManager extends VirtualScrollManager { if (options.deferInit) { return; } - if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) { + if (isEqual(this.#options, options)) { return; } await this.initTask.reset(); @@ -141,7 +131,7 @@ export class TimelineManager extends VirtualScrollManager { if (this.#websocketSupport) { throw new Error('TimelineManager already connected'); } - this.#websocketSupport = new WebsocketSupport(this); + this.#websocketSupport = new TimelineWebsocketExtension(this); this.#websocketSupport.connectWebsocketEvents(); } @@ -164,7 +154,7 @@ export class TimelineManager extends VirtualScrollManager { await this.initTask.waitUntilCompletion(); } - let { month } = findMonthForAssetUtil(this, id) ?? {}; + let { month } = this.search.findMonthForAsset(id) ?? {}; if (month) { return month; } @@ -187,11 +177,11 @@ export class TimelineManager extends VirtualScrollManager { async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) { await this.loadSegment(getSegmentIdentifier(yearMonth), options); - return getMonthByDate(this, yearMonth); + return this.search.findMonthByDate(yearMonth); } getMonthByAssetId(assetId: string) { - const monthInfo = findMonthForAssetUtil(this, assetId); + const monthInfo = this.search.findMonthForAsset(assetId); return monthInfo?.month; } @@ -274,7 +264,7 @@ export class TimelineManager extends VirtualScrollManager { } protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void { - let month = getMonthByDate(this, asset.localDateTime); + let month = this.search.findMonthByDate(asset.localDateTime); if (!month) { month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order); @@ -361,20 +351,20 @@ export class TimelineManager extends VirtualScrollManager { assetDescriptor: AssetDescriptor, interval: 'asset' | 'day' | 'month' | 'year' = 'asset', ): Promise { - return await getAssetWithOffset(this, assetDescriptor, interval, 'later'); + return await this.search.getAssetWithOffset(assetDescriptor, interval, 'later'); } async getEarlierAsset( assetDescriptor: AssetDescriptor, interval: 'asset' | 'day' | 'month' | 'year' = 'asset', ): Promise { - return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier'); + return await this.search.getAssetWithOffset(assetDescriptor, interval, 'earlier'); } async getClosestAssetToDate(dateTime: TimelineDateTime) { - let month = findMonthForDate(this, dateTime); + let month = this.search.findMonthForDate(dateTime); if (!month) { - month = findClosestGroupForDate(this.segments, dateTime); + month = this.search.findClosestGroupForDate(this.segments, dateTime); if (!month) { return; } @@ -390,17 +380,7 @@ export class TimelineManager extends VirtualScrollManager { } async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) { - return retrieveRangeUtil(this, start, end); - } - - clearDeferredLayout(month: TimelineMonth) { - const hasDeferred = month.days.some((group) => group.deferredLayout); - if (hasDeferred) { - month.updateGeometry({ invalidateHeight: true, noDefer: true }); - for (const group of month.days) { - group.deferredLayout = false; - } - } + return this.search.retrieveRange(start, end); } async *assetsIterator(options?: { diff --git a/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts index c4e558f5f5..a570460e64 100644 --- a/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts @@ -1,9 +1,10 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; -import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; -import { onCreateTimelineMonth } from '$lib/managers/timeline-manager/internal/TestHooks.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; +import { GroupInsertionCache } from '$lib/managers/timeline-manager/TimelineInsertionCache.svelte'; import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; +import { onCreateTimelineMonth } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte'; import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types'; +import { setDifferenceInPlace } from '$lib/managers/timeline-manager/utils.svelte'; import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; import { ScrollSegment, type SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; import { @@ -15,7 +16,6 @@ import { fromTimelinePlainYearMonth, getSegmentIdentifier, getTimes, - setDifferenceInPlace, toISOYearMonthUTC, type TimelineDateTime, type TimelineYearMonth, @@ -70,17 +70,19 @@ export class TimelineMonth extends ScrollSegment { findAssetAbsolutePosition(assetId: string) { this.#clearDeferredLayout(); - for (const group of this.days) { - const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId); + for (const day of this.days) { + const viewerAsset = day.viewerAssets.find((viewAsset) => viewAsset.id === assetId); if (viewerAsset) { if (!viewerAsset.position) { console.warn('No position for asset'); - break; + return; } - return this.top + group.top + viewerAsset.position.top + this.scrollManager.headerHeight; + return { + top: this.top + day.top + viewerAsset.position.top + this.#timelineManager.headerHeight, + height: viewerAsset.position.height, + }; } } - return -1; } protected async fetch(signal: AbortSignal): Promise { diff --git a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.spec.ts b/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.spec.ts similarity index 70% rename from web/src/lib/managers/timeline-manager/internal/search-support.svelte.spec.ts rename to web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.spec.ts index 7dc4456838..b406161b5d 100644 --- a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.spec.ts @@ -1,5 +1,6 @@ +import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; -import { findClosestGroupForDate } from '$lib/managers/timeline-manager/internal/search-support.svelte'; +import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte'; import { describe, expect, it } from 'vitest'; function createMockMonthGroup(year: number, month: number): TimelineMonth { @@ -9,32 +10,36 @@ function createMockMonthGroup(year: number, month: number): TimelineMonth { } describe('findClosestGroupForDate', () => { + let search: TimelineSearchExtension; + beforeEach(() => { + search = new TimelineSearchExtension(new TimelineManager()); + }); it('should return undefined for empty months array', () => { - const result = findClosestGroupForDate([], { year: 2024, month: 1 }); + const result = search.findClosestGroupForDate([], { year: 2024, month: 1 }); expect(result).toBeUndefined(); }); it('should return the only month when there is only one month', () => { const months = [createMockMonthGroup(2024, 6)]; - const result = findClosestGroupForDate(months, { year: 2025, month: 1 }); + const result = search.findClosestGroupForDate(months, { year: 2025, month: 1 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should return exact match when available', () => { const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)]; - const result = findClosestGroupForDate(months, { year: 2024, month: 6 }); + const result = search.findClosestGroupForDate(months, { year: 2024, month: 6 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should find closest month when target is between two months', () => { const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)]; - const result = findClosestGroupForDate(months, { year: 2024, month: 4 }); + const result = search.findClosestGroupForDate(months, { year: 2024, month: 4 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should handle year boundaries correctly (2023-12 vs 2024-01)', () => { const months = [createMockMonthGroup(2023, 12), createMockMonthGroup(2024, 2)]; - const result = findClosestGroupForDate(months, { year: 2024, month: 1 }); + const result = search.findClosestGroupForDate(months, { year: 2024, month: 1 }); // 2024-01 is 1 month from 2023-12 and 1 month from 2024-02 // Should return first encountered with min distance (2023-12) expect(result?.yearMonth).toEqual({ year: 2023, month: 12 }); @@ -42,33 +47,33 @@ describe('findClosestGroupForDate', () => { it('should correctly calculate distance across years', () => { const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 6)]; - const result = findClosestGroupForDate(months, { year: 2023, month: 6 }); + const result = search.findClosestGroupForDate(months, { year: 2023, month: 6 }); // Both are exactly 12 months away, should return first encountered expect(result?.yearMonth).toEqual({ year: 2022, month: 6 }); }); it('should handle target before all months', () => { const months = [createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)]; - const result = findClosestGroupForDate(months, { year: 2024, month: 1 }); + const result = search.findClosestGroupForDate(months, { year: 2024, month: 1 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should handle target after all months', () => { const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6)]; - const result = findClosestGroupForDate(months, { year: 2025, month: 1 }); + const result = search.findClosestGroupForDate(months, { year: 2025, month: 1 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should handle multiple years correctly', () => { const months = [createMockMonthGroup(2020, 1), createMockMonthGroup(2022, 1), createMockMonthGroup(2024, 1)]; - const result = findClosestGroupForDate(months, { year: 2023, month: 1 }); + const result = search.findClosestGroupForDate(months, { year: 2023, month: 1 }); // 2023-01 is 12 months from 2022-01 and 12 months from 2024-01 expect(result?.yearMonth).toEqual({ year: 2022, month: 1 }); }); it('should prefer closer month when one is clearly closer', () => { const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 10)]; - const result = findClosestGroupForDate(months, { year: 2024, month: 11 }); + const result = search.findClosestGroupForDate(months, { year: 2024, month: 11 }); // 2024-11 is 1 month from 2024-10 and 10 months from 2024-01 expect(result?.yearMonth).toEqual({ year: 2024, month: 10 }); }); diff --git a/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.ts new file mode 100644 index 0000000000..02f2f1f5dd --- /dev/null +++ b/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.ts @@ -0,0 +1,161 @@ +import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; +import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; +import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types'; +import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; +import { AssetOrder } from '@immich/sdk'; +import { DateTime } from 'luxon'; + +export class TimelineSearchExtension { + #timelineManager: TimelineManager; + constructor(timelineManager: TimelineManager) { + this.#timelineManager = timelineManager; + } + async getAssetWithOffset( + assetDescriptor: AssetDescriptor, + interval: 'asset' | 'day' | 'month' | 'year' = 'asset', + direction: Direction, + ): Promise { + const { asset, month } = this.findMonthForAsset(assetDescriptor.id) ?? {}; + if (!month || !asset) { + return; + } + + switch (interval) { + case 'asset': { + return this.getAssetByAssetOffset(asset, month, direction); + } + case 'day': { + return this.getAssetByDayOffset(asset, month, direction); + } + case 'month': { + return this.getAssetByMonthOffset(month, direction); + } + case 'year': { + return this.getAssetByYearOffset(month, direction); + } + } + } + + findMonthForAsset(id: string) { + for (const month of this.#timelineManager.segments) { + const asset = month.findAssetById({ id }); + if (asset) { + return { month, asset }; + } + } + } + + findMonthByDate(targetYearMonth: TimelineYearMonth): TimelineMonth | undefined { + return this.#timelineManager.segments.find( + (month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month, + ); + } + + async getAssetByAssetOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) { + const day = month.findDayForAsset(asset); + for await (const targetAsset of this.#timelineManager.assetsIterator({ + startMonth: month, + startDay: day, + startAsset: asset, + direction, + })) { + if (asset.id !== targetAsset.id) { + return targetAsset; + } + } + } + + async getAssetByDayOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) { + const day = month.findDayForAsset(asset); + for await (const targetAsset of this.#timelineManager.assetsIterator({ + startMonth: month, + startDay: day, + startAsset: asset, + direction, + })) { + if (targetAsset.localDateTime.day !== asset.localDateTime.day) { + return targetAsset; + } + } + } + + async getAssetByMonthOffset(month: TimelineMonth, direction: Direction) { + for (const targetMonth of this.#timelineManager.monthIterator({ startMonth: month, direction })) { + if (targetMonth.yearMonth.month !== month.yearMonth.month) { + const { value, done } = await this.#timelineManager + .assetsIterator({ startMonth: targetMonth, direction }) + .next(); + return done ? undefined : value; + } + } + } + + async getAssetByYearOffset(month: TimelineMonth, direction: Direction) { + for (const targetMonth of this.#timelineManager.monthIterator({ startMonth: month, direction })) { + if (targetMonth.yearMonth.year !== month.yearMonth.year) { + const { value, done } = await this.#timelineManager + .assetsIterator({ startMonth: targetMonth, direction }) + .next(); + return done ? undefined : value; + } + } + } + + async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) { + let { asset: startAsset, month: startMonth } = this.findMonthForAsset(start.id) ?? {}; + if (!startMonth || !startAsset) { + return []; + } + let { asset: endAsset, month: endMonth } = this.findMonthForAsset(end.id) ?? {}; + if (!endMonth || !endAsset) { + return []; + } + const assetOrder: AssetOrder = this.#timelineManager.getAssetOrder(); + if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) { + [startAsset, endAsset] = [endAsset, startAsset]; + [startMonth, endMonth] = [endMonth, startMonth]; + } + + const range: TimelineAsset[] = []; + const startDay = startMonth.findDayForAsset(startAsset); + for await (const targetAsset of this.#timelineManager.assetsIterator({ + startMonth, + startDay, + startAsset, + })) { + range.push(targetAsset); + if (targetAsset.id === endAsset.id) { + break; + } + } + return range; + } + + findMonthForDate(targetYearMonth: TimelineYearMonth) { + for (const month of this.#timelineManager.segments) { + const { year, month: monthNum } = month.yearMonth; + if (monthNum === targetYearMonth.month && year === targetYearMonth.year) { + return month; + } + } + } + + findClosestGroupForDate(months: TimelineMonth[], targetYearMonth: TimelineYearMonth) { + const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month }); + + let closestMonth: TimelineMonth | undefined; + let minDifference = Number.MAX_SAFE_INTEGER; + + for (const month of months) { + const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month }); + const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months); + + if (totalDiff < minDifference) { + minDifference = totalDiff; + closestMonth = month; + } + } + + return closestMonth; + } +} diff --git a/web/src/lib/managers/timeline-manager/internal/TestHooks.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineTestHooks.svelte.ts similarity index 100% rename from web/src/lib/managers/timeline-manager/internal/TestHooks.svelte.ts rename to web/src/lib/managers/timeline-manager/TimelineTestHooks.svelte.ts index 720586e4be..35688e3c6e 100644 --- a/web/src/lib/managers/timeline-manager/internal/TestHooks.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineTestHooks.svelte.ts @@ -1,13 +1,13 @@ import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; -let testHooks: TestHooks | undefined = undefined; - export type TestHooks = { onCreateTimelineMonth(month: TimelineMonth): unknown; onCreateTimelineDay(day: TimelineDay): unknown; }; +let testHooks: TestHooks | undefined = undefined; + export const setTestHooks = (hooks: TestHooks) => { testHooks = hooks; }; diff --git a/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineWebsocketExtension.ts similarity index 94% rename from web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts rename to web/src/lib/managers/timeline-manager/TimelineWebsocketExtension.ts index e0f8aac80f..7b9e446bd3 100644 --- a/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineWebsocketExtension.ts @@ -5,12 +5,9 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import { throttle } from 'lodash-es'; import type { Unsubscriber } from 'svelte/store'; -export class WebsocketSupport { - #pendingChanges: PendingChange[] = []; - #unsubscribers: Unsubscriber[] = []; - #timelineManager: TimelineManager; - - #processPendingChanges = throttle(() => { +export class TimelineWebsocketExtension { + readonly #timelineManager: TimelineManager; + readonly #processPendingChanges = throttle(() => { const { add, update, remove } = this.#getPendingChangeBatches(); if (add.length > 0) { this.#timelineManager.upsertAssets(add); @@ -24,6 +21,9 @@ export class WebsocketSupport { this.#pendingChanges = []; }, 2500); + #pendingChanges: PendingChange[] = []; + #unsubscribers: Unsubscriber[] = []; + constructor(timeineManager: TimelineManager) { this.#timelineManager = timeineManager; } diff --git a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts deleted file mode 100644 index 46b8d29b81..0000000000 --- a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; -import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; -import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; -import { AssetOrder } from '@immich/sdk'; -import { DateTime } from 'luxon'; - -export async function getAssetWithOffset( - timelineManager: TimelineManager, - assetDescriptor: AssetDescriptor, - interval: 'asset' | 'day' | 'month' | 'year' = 'asset', - direction: Direction, -): Promise { - const { asset, month } = findMonthForAsset(timelineManager, assetDescriptor.id) ?? {}; - if (!month || !asset) { - return; - } - - switch (interval) { - case 'asset': { - return getAssetByAssetOffset(timelineManager, asset, month, direction); - } - case 'day': { - return getAssetByDayOffset(timelineManager, asset, month, direction); - } - case 'month': { - return getAssetByMonthOffset(timelineManager, month, direction); - } - case 'year': { - return getAssetByYearOffset(timelineManager, month, direction); - } - } -} - -export function findMonthForAsset(timelineManager: TimelineManager, id: string) { - for (const month of timelineManager.segments) { - const asset = month.findAssetById({ id }); - if (asset) { - return { month, asset }; - } - } -} - -export function getMonthByDate( - timelineManager: TimelineManager, - targetYearMonth: TimelineYearMonth, -): TimelineMonth | undefined { - return timelineManager.segments.find( - (month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month, - ); -} - -async function getAssetByAssetOffset( - timelineManager: TimelineManager, - asset: TimelineAsset, - month: TimelineMonth, - direction: Direction, -) { - const day = month.findDayForAsset(asset); - for await (const targetAsset of timelineManager.assetsIterator({ - startMonth: month, - startDay: day, - startAsset: asset, - direction, - })) { - if (asset.id !== targetAsset.id) { - return targetAsset; - } - } -} - -async function getAssetByDayOffset( - timelineManager: TimelineManager, - asset: TimelineAsset, - month: TimelineMonth, - direction: Direction, -) { - const day = month.findDayForAsset(asset); - for await (const targetAsset of timelineManager.assetsIterator({ - startMonth: month, - startDay: day, - startAsset: asset, - direction, - })) { - if (targetAsset.localDateTime.day !== asset.localDateTime.day) { - return targetAsset; - } - } -} - -async function getAssetByMonthOffset(timelineManager: TimelineManager, month: TimelineMonth, direction: Direction) { - for (const targetMonth of timelineManager.monthIterator({ startMonth: month, direction })) { - if (targetMonth.yearMonth.month !== month.yearMonth.month) { - const { value, done } = await timelineManager.assetsIterator({ startMonth: targetMonth, direction }).next(); - return done ? undefined : value; - } - } -} - -async function getAssetByYearOffset(timelineManager: TimelineManager, month: TimelineMonth, direction: Direction) { - for (const targetMonth of timelineManager.monthIterator({ startMonth: month, direction })) { - if (targetMonth.yearMonth.year !== month.yearMonth.year) { - const { value, done } = await timelineManager.assetsIterator({ startMonth: targetMonth, direction }).next(); - return done ? undefined : value; - } - } -} - -export async function retrieveRange(timelineManager: TimelineManager, start: AssetDescriptor, end: AssetDescriptor) { - let { asset: startAsset, month: startMonth } = findMonthForAsset(timelineManager, start.id) ?? {}; - if (!startMonth || !startAsset) { - return []; - } - let { asset: endAsset, month: endMonth } = findMonthForAsset(timelineManager, end.id) ?? {}; - if (!endMonth || !endAsset) { - return []; - } - const assetOrder: AssetOrder = timelineManager.getAssetOrder(); - if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) { - [startAsset, endAsset] = [endAsset, startAsset]; - [startMonth, endMonth] = [endMonth, startMonth]; - } - - const range: TimelineAsset[] = []; - const startDay = startMonth.findDayForAsset(startAsset); - for await (const targetAsset of timelineManager.assetsIterator({ - startMonth, - startDay, - startAsset, - })) { - range.push(targetAsset); - if (targetAsset.id === endAsset.id) { - break; - } - } - return range; -} - -export function findMonthForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) { - for (const month of timelineManager.segments) { - const { year, month: monthNum } = month.yearMonth; - if (monthNum === targetYearMonth.month && year === targetYearMonth.year) { - return month; - } - } -} - -export function findClosestGroupForDate(months: TimelineMonth[], targetYearMonth: TimelineYearMonth) { - const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month }); - - let closestMonth: TimelineMonth | undefined; - let minDifference = Number.MAX_SAFE_INTEGER; - - for (const month of months) { - const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month }); - const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months); - - if (totalDiff < minDifference) { - minDifference = totalDiff; - closestMonth = month; - } - } - - return closestMonth; -} diff --git a/web/src/lib/managers/timeline-manager/internal/utils.svelte.ts b/web/src/lib/managers/timeline-manager/internal/utils.svelte.ts deleted file mode 100644 index a1b580a966..0000000000 --- a/web/src/lib/managers/timeline-manager/internal/utils.svelte.ts +++ /dev/null @@ -1,28 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function updateObject(target: any, source: any): boolean { - if (!target) { - return false; - } - let updated = false; - for (const key in source) { - if (!Object.prototype.hasOwnProperty.call(source, key)) { - continue; - } - if (key === '__proto__' || key === 'constructor') { - continue; - } - const isDate = target[key] instanceof Date; - if (typeof target[key] === 'object' && !isDate) { - updated = updated || updateObject(target[key], source[key]); - } else { - if (target[key] !== source[key]) { - target[key] = source[key]; - updated = true; - } - } - } - return updated; -} -export function isMismatched(option: T | undefined, value: T): boolean { - return option === undefined ? false : option !== value; -} diff --git a/web/src/lib/managers/timeline-manager/utils.svelte.ts b/web/src/lib/managers/timeline-manager/utils.svelte.ts index 2aba6470ee..d4aa1e900d 100644 --- a/web/src/lib/managers/timeline-manager/utils.svelte.ts +++ b/web/src/lib/managers/timeline-manager/utils.svelte.ts @@ -2,3 +2,58 @@ import type { TimelineAsset } from './types'; export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset); export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset)); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function updateObject(target: any, source: any): boolean { + if (!target) { + return false; + } + let updated = false; + for (const key in source) { + if (!Object.prototype.hasOwnProperty.call(source, key)) { + continue; + } + if (key === '__proto__' || key === 'constructor') { + continue; + } + const isDate = target[key] instanceof Date; + if (typeof target[key] === 'object' && !isDate) { + updated = updated || updateObject(target[key], source[key]); + } else { + if (target[key] !== source[key]) { + target[key] = source[key]; + updated = true; + } + } + } + return updated; +} +export function isMismatched(option: T | undefined, value: T): boolean { + return option === undefined ? false : option !== value; +} + +export function setDifference(setA: Set, setB: Set): Set { + // Check if native Set.prototype.difference is available (ES2025) + const setWithDifference = setA as unknown as Set & { difference?: (other: Set) => Set }; + if (setWithDifference.difference && typeof setWithDifference.difference === 'function') { + return setWithDifference.difference(setB); + } + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const result = new Set(); + for (const value of setA) { + if (!setB.has(value)) { + result.add(value); + } + } + return result; +} + +/** + * Removes all elements of setB from setA in-place (mutates setA). + */ +export function setDifferenceInPlace(setA: Set, setB: Set): Set { + for (const value of setB) { + setA.delete(value); + } + return setA; +}