Relocate/rename internal timeline functions to follow TimelineExtensions pattern

This commit is contained in:
midzelis
2025-10-28 12:29:40 +00:00
parent 4ca76b24e9
commit 6d3dda7e2e
14 changed files with 322 additions and 310 deletions

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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', () => {

View File

@@ -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<string> = 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<string> = 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<TimelineAsset | undefined> {
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<TimelineAsset | undefined> {
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?: {

View File

@@ -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<unknown> {

View File

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

View File

@@ -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<TimelineAsset | undefined> {
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;
}
}

View File

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

View File

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

View File

@@ -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<TimelineAsset | undefined> {
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;
}

View File

@@ -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<T>(option: T | undefined, value: T): boolean {
return option === undefined ? false : option !== value;
}

View File

@@ -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<T>(option: T | undefined, value: T): boolean {
return option === undefined ? false : option !== value;
}
export function setDifference<T>(setA: Set<T>, setB: Set<T>): Set<T> {
// Check if native Set.prototype.difference is available (ES2025)
const setWithDifference = setA as unknown as Set<T> & { difference?: (other: Set<T>) => Set<T> };
if (setWithDifference.difference && typeof setWithDifference.difference === 'function') {
return setWithDifference.difference(setB);
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const result = new Set<T>();
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<T>(setA: Set<T>, setB: Set<T>): Set<T> {
for (const value of setB) {
setA.delete(value);
}
return setA;
}