diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index da8e38b2f8..4eb5f59886 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -7,7 +7,7 @@ import { type CommonLayoutOptions, type CommonPosition, } from '$lib/utils/layout-utils'; -import { formatDateGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util'; +import { toTimelineAsset } from '$lib/utils/timeline-util'; import { TUNABLES } from '$lib/utils/tunables'; import { AssetOrder, @@ -18,7 +18,6 @@ import { type TimeBucketAssetResponseDto, } from '@immich/sdk'; import { clamp, debounce, isEqual, throttle } from 'lodash-es'; -import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; import { get, writable, type Unsubscriber } from 'svelte/store'; @@ -71,7 +70,7 @@ export type TimelineAsset = { ownerId: string; ratio: number; thumbhash: string | null; - localDateTime: string; + localDateTime: Date; isArchived: boolean; isFavorite: boolean; isTrashed: boolean; @@ -123,7 +122,8 @@ export class AssetDateGroup { // --- public readonly bucket: AssetBucket; readonly index: number; - readonly date: DateTime; + readonly date: Date; + readonly groupTitle: string; readonly dayOfMonth: number; intersetingAssets: IntersectingAsset[] = $state([]); @@ -138,24 +138,23 @@ export class AssetDateGroup { col = $state(0); deferredLayout = false; - constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) { + constructor(bucket: AssetBucket, index: number, date: Date, dayOfMonth: number) { this.index = index; this.bucket = bucket; this.date = date; this.dayOfMonth = dayOfMonth; + this.groupTitle = new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()).toLocaleString( + get(locale), + { timeZone: 'UTC', weekday: 'short', year: 'numeric', month: 'long', day: 'numeric' }, + ); } sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) { - this.intersetingAssets.sort((a, b) => { - const aDate = DateTime.fromISO(a.asset!.localDateTime).toUTC(); - const bDate = DateTime.fromISO(b.asset!.localDateTime).toUTC(); - - if (sortOrder === AssetOrder.Asc) { - return aDate.diff(bDate).milliseconds; - } - - return bDate.diff(aDate).milliseconds; - }); + if (sortOrder === AssetOrder.Asc) { + this.intersetingAssets.sort((a, b) => a.asset!.localDateTime.valueOf() - b.asset!.localDateTime.valueOf()); + } else { + this.intersetingAssets.sort((a, b) => b.asset!.localDateTime.valueOf() - a.asset!.localDateTime.valueOf()); + } } getFirstAsset() { @@ -185,27 +184,28 @@ export class AssetDateGroup { let changedGeometry = false; for (const assetId of unprocessedIds) { const index = this.intersetingAssets.findIndex((ia) => ia.id == assetId); - if (index !== -1) { - const asset = this.intersetingAssets[index].asset!; - const oldTime = asset.localDateTime; - let { remove } = operation(asset); - const newTime = asset.localDateTime; - if (oldTime !== newTime) { - const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); - const year = utc.get('year'); - const month = utc.get('month'); - if (this.bucket.year !== year || this.bucket.month !== month) { - remove = true; - moveAssets.push({ asset, year, month }); - } - } - unprocessedIds.delete(assetId); - processedIds.add(assetId); - if (remove || this.bucket.store.isExcluded(asset)) { - this.intersetingAssets.splice(index, 1); - changedGeometry = true; + if (index === -1) { + continue; + } + + const asset = this.intersetingAssets[index].asset!; + const oldTime = asset.localDateTime; + let { remove } = operation(asset); + const newTime = asset.localDateTime; + if (oldTime.valueOf() !== newTime.valueOf()) { + const year = newTime.getUTCFullYear(); + const month = newTime.getUTCMonth(); + if (this.bucket.year !== year || this.bucket.month !== month) { + remove = true; + moveAssets.push({ asset, year, month }); } } + unprocessedIds.delete(assetId); + processedIds.add(assetId); + if (remove || this.bucket.store.isExcluded(asset)) { + this.intersetingAssets.splice(index, 1); + changedGeometry = true; + } } return { moveAssets, processedIds, unprocessedIds, changedGeometry }; } @@ -228,10 +228,6 @@ export class AssetDateGroup { get absoluteDateGroupTop() { return this.bucket.top + this.top; } - - get groupTitle() { - return formatDateGroupTitle(this.date); - } } export interface Viewport { @@ -262,6 +258,7 @@ class AddContext { } } } + export class AssetBucket { // --- public --- #intersecting: boolean = $state(false); @@ -298,22 +295,20 @@ export class AssetBucket { readonly month: number; readonly year: number; - constructor(store: AssetStore, utcDate: DateTime, initialCount: number, order: AssetOrder = AssetOrder.Desc) { + constructor(store: AssetStore, date: Date, initialCount: number, order: AssetOrder = AssetOrder.Desc) { this.store = store; this.#initialCount = initialCount; this.#sortOrder = order; - const year = utcDate.get('year'); - const month = utcDate.get('month'); - const bucketDateFormatted = utcDate.toJSDate().toLocaleString(get(locale), { + const bucketDateFormatted = date.toLocaleString(get(locale), { month: 'short', year: 'numeric', timeZone: 'UTC', }); - this.bucketDate = utcDate.toISO()!.toString(); + this.bucketDate = date.toISOString(); this.bucketDateFormatted = bucketDateFormatted; - this.month = month; - this.year = year; + this.month = date.getUTCMonth(); + this.year = date.getUTCFullYear(); this.loader = new CancellableTask( () => { @@ -370,10 +365,10 @@ export class AssetBucket { sortDateGroups() { if (this.#sortOrder === AssetOrder.Asc) { - return this.dateGroups.sort((a, b) => a.date.diff(b.date).milliseconds); + return this.dateGroups.sort((a, b) => a.date.valueOf() - b.date.valueOf()); } - return this.dateGroups.sort((a, b) => b.date.diff(a.date).milliseconds); + return this.dateGroups.sort((a, b) => b.date.valueOf() - a.date.valueOf()); } runAssetOperation(ids: Set, operation: AssetOperation) { @@ -419,7 +414,9 @@ export class AssetBucket { // note - if the assets are not part of this bucket, they will not be added addAssets(bucketAssets: TimeBucketAssetResponseDto) { + const time1 = performance.now(); const addContext = new AddContext(); + const people: string[] = []; for (let i = 0; i < bucketAssets.id.length; i++) { const timelineAsset: TimelineAsset = { city: bucketAssets.city[i], @@ -432,9 +429,9 @@ export class AssetBucket { isTrashed: Boolean(bucketAssets.isTrashed[i]), isVideo: !bucketAssets.isImage[i], livePhotoVideoId: bucketAssets.livePhotoVideoId[i], - localDateTime: bucketAssets.localDateTime[i], + localDateTime: new Date(bucketAssets.localDateTime[i]), ownerId: bucketAssets.ownerId[i], - people: [], + people, projectionType: bucketAssets.projectionType[i], ratio: bucketAssets.ratio[i], stack: bucketAssets.stack?.[i] @@ -450,18 +447,17 @@ export class AssetBucket { } addContext.sort(this, this.#sortOrder); + const time2 = performance.now(); + const time = time2 - time1; + console.log(`AssetBucket.addAssets took ${time}ms`); return addContext.unprocessedAssets; } addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) { - const { id, localDateTime } = timelineAsset; - const date = DateTime.fromISO(localDateTime).toUTC(); - - const month = date.get('month'); - const year = date.get('year'); - + const month = timelineAsset.localDateTime.getUTCMonth(); + const year = timelineAsset.localDateTime.getUTCFullYear(); if (this.month === month && this.year === year) { - const day = date.get('day'); + const day = timelineAsset.localDateTime.getUTCDay(); let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day]; if (!dateGroup) { dateGroup = this.findDateGroupByDay(day); @@ -469,25 +465,26 @@ export class AssetBucket { addContext.lookupCache[day] = dateGroup; } } + if (dateGroup) { const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset); - if (dateGroup.intersetingAssets.some((a) => a.id === id)) { - console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`); - } else { - dateGroup.intersetingAssets.push(intersectingAsset); - addContext.changedDateGroups.add(dateGroup); - } + dateGroup.intersetingAssets.push(intersectingAsset); + addContext.changedDateGroups.add(dateGroup); } else { - dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day); + dateGroup = new AssetDateGroup(this, this.dateGroups.length, timelineAsset.localDateTime, day); dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, timelineAsset)); this.dateGroups.push(dateGroup); addContext.lookupCache[day] = dateGroup; addContext.newDateGroups.add(dateGroup); } } else { + console.warn( + `Year ${year} and month ${month} do not match bucket year ${this.year} and month ${this.month} (${this.bucketDate} vs ${timelineAsset.localDateTime.toISOString()})`, + ); addContext.unprocessedAssets.push(timelineAsset); } } + getRandomDateGroup() { const random = Math.floor(Math.random() * this.dateGroups.length); return this.dateGroups[random]; @@ -536,6 +533,7 @@ export class AssetBucket { } } } + get bucketHeight() { return this.#bucketHeight; } @@ -929,8 +927,7 @@ export class AssetStore { }); this.buckets = timebuckets.map((bucket) => { - const utcDate = DateTime.fromISO(bucket.timeBucket).toUTC(); - return new AssetBucket(this, utcDate, bucket.count, this.#options.order); + return new AssetBucket(this, new Date(bucket.timeBucket), bucket.count, this.#options.order); }); this.albumAssets.clear(); this.#updateViewportGeometry(false); @@ -964,6 +961,7 @@ export class AssetStore { await this.#initialiazeTimeBuckets(); }, true); } + public destroy() { this.disconnect(); this.isInitialized = false; @@ -1031,6 +1029,7 @@ export class AssetStore { rowWidth: Math.floor(viewportWidth), }; } + #updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { if (invalidateHeight) { bucket.isBucketHeightActual = false; @@ -1110,9 +1109,9 @@ export class AssetStore { cancelable = options.cancelable; } - const date = DateTime.fromISO(bucketDate).toUTC(); - const year = date.get('year'); - const month = date.get('month'); + const date = new Date(bucketDate); + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); const bucket = this.getBucketByDate(year, month); if (!bucket) { return; @@ -1187,13 +1186,12 @@ export class AssetStore { const updatedDateGroups = new Set(); for (const asset of assets) { - const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); - const year = utc.get('year'); - const month = utc.get('month'); + const year = asset.localDateTime.getUTCFullYear(); + const month = asset.localDateTime.getUTCMonth(); let bucket = this.getBucketByDate(year, month); if (!bucket) { - bucket = new AssetBucket(this, utc, 1, this.#options.order); + bucket = new AssetBucket(this, asset.localDateTime, 1, this.#options.order); this.buckets.push(bucket); } const addContext = new AddContext(); @@ -1236,23 +1234,21 @@ export class AssetStore { } } - async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) { - let date = DateTime.fromISO(localDateTime).toUTC(); + async #loadBucketAtTime(localDateTime: Date, options?: { cancelable: boolean }) { // Only support TimeBucketSize.Month - date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); - const iso = date.toISO()!; - const year = date.get('year'); - const month = date.get('month'); - await this.loadBucket(iso, options); + const year = localDateTime.getUTCFullYear(); + const month = localDateTime.getUTCMonth(); + localDateTime = new Date(year, month); + await this.loadBucket(localDateTime.toISOString(), options); return this.getBucketByDate(year, month); } - async #getBucketInfoForAsset(asset: { id: string; localDateTime: string }, options?: { cancelable: boolean }) { + async #getBucketInfoForAsset(asset: { id: string; localDateTime: Date | string }, options?: { cancelable: boolean }) { const bucketInfo = this.#findBucketForAsset(asset.id); if (bucketInfo) { return bucketInfo; } - await this.#loadBucketAtTime(asset.localDateTime, options); + await this.#loadBucketAtTime(new Date(asset.localDateTime), options); return this.#findBucketForAsset(asset.id); } @@ -1351,7 +1347,7 @@ export class AssetStore { return this.buckets[0]?.getFirstAsset(); } - async getPreviousAsset(asset: { id: string; localDateTime: string }): Promise { + async getPreviousAsset(asset: { id: string; localDateTime: Date | string }): Promise { let bucket = await this.#getBucketInfoForAsset(asset); if (!bucket) { return; @@ -1394,7 +1390,7 @@ export class AssetStore { } } - async getNextAsset(asset: { id: string; localDateTime: string }): Promise { + async getNextAsset(asset: { id: string; localDateTime: Date | string }): Promise { let bucket = await this.#getBucketInfoForAsset(asset); if (!bucket) { return; diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index f6e00a9a8d..ccc586ab4d 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -2,7 +2,6 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { locale } from '$lib/stores/preferences.store'; import { t } from 'svelte-i18n'; import { derived, get } from 'svelte/store'; -import { fromLocalDateTime } from './timeline-util'; /** * Calculate thumbnail size based on number of assets and viewport width @@ -40,7 +39,7 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number export const getAltText = derived(t, ($t) => { return (asset: TimelineAsset) => { - const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) }); + const date = asset.localDateTime.toLocaleString(get(locale), { dateStyle: 'long', timeZone: 'UTC' }); const hasPlace = asset.city && asset.country; const peopleCount = asset.people.length; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 1c9110b14c..7ac429b834 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -19,6 +19,8 @@ export const fromLocalDateTime = (localDateTime: string) => export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) => DateTime.fromISO(dateTimeOriginal, { zone: timeZone }); +export const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth()); + export function formatGroupTitle(_date: DateTime): string { if (!_date.isValid) { return _date.toString(); @@ -77,7 +79,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): ownerId: assetResponse.ownerId, ratio, thumbhash: assetResponse.thumbhash, - localDateTime: assetResponse.localDateTime, + localDateTime: new Date(assetResponse.localDateTime), isFavorite: assetResponse.isFavorite, isArchived: assetResponse.isArchived, isTrashed: assetResponse.isTrashed, diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index aef7b5f16d..ce7aceaeed 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -32,7 +32,7 @@ export const timelineAssetFactory = Sync.makeFactory({ ratio: Sync.each(() => faker.number.int()), ownerId: Sync.each(() => faker.string.uuid()), thumbhash: Sync.each(() => faker.string.alphanumeric(28)), - localDateTime: Sync.each(() => faker.date.past().toISOString()), + localDateTime: Sync.each(() => faker.date.past()), isFavorite: Sync.each(() => faker.datatype.boolean()), isArchived: false, isTrashed: false, @@ -75,7 +75,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => { bucketAssets.isImage.push(asset.isImage ? 1 : 0); bucketAssets.isTrashed.push(asset.isTrashed ? 1 : 0); bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!); - bucketAssets.localDateTime.push(asset.localDateTime); + bucketAssets.localDateTime.push(asset.localDateTime.toISOString()); bucketAssets.ownerId.push(asset.ownerId); bucketAssets.projectionType.push(asset.projectionType!); bucketAssets.ratio.push(asset.ratio);