From d5a01c03105c0f59398134385210167d5ed4cf92 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 30 Jul 2025 12:21:02 -0400 Subject: [PATCH] fix(web): timeline time bucket issue (#20438) --- .../components/photos-page/asset-grid.svelte | 9 ++---- .../group-insertion-cache.svelte.ts | 6 ++-- .../internal/load-support.svelte.ts | 1 - .../internal/operations-support.svelte.ts | 4 +-- .../internal/search-support.svelte.ts | 6 ++-- .../timeline-manager/month-group.svelte.ts | 10 +++---- .../timeline-manager.svelte.ts | 8 ++--- .../lib/managers/timeline-manager/types.ts | 8 ++--- web/src/lib/utils/timeline-util.ts | 29 +++++++++---------- 9 files changed, 36 insertions(+), 45 deletions(-) diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index bd7900f2b6..808a4cf54c 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -29,12 +29,7 @@ import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { navigate } from '$lib/utils/navigation'; - import { - getTimes, - toTimelineAsset, - type ScrubberListener, - type TimelinePlainYearMonth, - } from '$lib/utils/timeline-util'; + import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { modalManager } from '@immich/ui'; import { DateTime } from 'luxon'; @@ -343,7 +338,7 @@ const monthsLength = timelineManager.months.length; for (let i = -1; i < monthsLength + 1; i++) { - let monthGroup: TimelinePlainYearMonth | undefined; + let monthGroup: TimelineYearMonth | undefined; let monthGroupHeight = 0; if (i === -1) { // lead-in diff --git a/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts b/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts index e511df9bf0..aa4bae8919 100644 --- a/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts +++ b/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts @@ -1,4 +1,4 @@ -import { setDifference, type TimelinePlainDate } from '$lib/utils/timeline-util'; +import { setDifference, type TimelineDate } from '$lib/utils/timeline-util'; import { AssetOrder } from '@immich/sdk'; import { SvelteSet } from 'svelte/reactivity'; import type { DayGroup } from './day-group.svelte'; @@ -13,11 +13,11 @@ export class GroupInsertionCache { changedDayGroups = new SvelteSet(); newDayGroups = new SvelteSet(); - getDayGroup({ year, month, day }: TimelinePlainDate): DayGroup | undefined { + getDayGroup({ year, month, day }: TimelineDate): DayGroup | undefined { return this.#lookupCache[year]?.[month]?.[day]; } - setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelinePlainDate) { + setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelineDate) { if (!this.#lookupCache[year]) { this.#lookupCache[year] = {}; } diff --git a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts index 6146fdb600..82a9e8083d 100644 --- a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts @@ -1,7 +1,6 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { toISOYearMonthUTC } from '$lib/utils/timeline-util'; import { getTimeBucket } from '@immich/sdk'; - import type { MonthGroup } from '../month-group.svelte'; import type { TimelineManager } from '../timeline-manager.svelte'; import type { TimelineManagerOptions } from '../types'; diff --git a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts index 4419de2103..4bc99c0315 100644 --- a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts @@ -1,4 +1,4 @@ -import { setDifference, type TimelinePlainDate } from '$lib/utils/timeline-util'; +import { setDifference, type TimelineDate } from '$lib/utils/timeline-util'; import { AssetOrder } from '@immich/sdk'; import { SvelteSet } from 'svelte/reactivity'; @@ -70,7 +70,7 @@ export function runAssetOperation( const changedMonthGroups = new SvelteSet(); let idsToProcess = new SvelteSet(ids); const idsProcessed = new SvelteSet(); - const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = []; + const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = []; for (const month of timelineManager.months) { if (idsToProcess.size > 0) { const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation); 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 index f85246933f..7e6ae734dc 100644 --- a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts @@ -1,4 +1,4 @@ -import { plainDateTimeCompare, type TimelinePlainYearMonth } from '$lib/utils/timeline-util'; +import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { AssetOrder } from '@immich/sdk'; import type { MonthGroup } from '../month-group.svelte'; import type { TimelineManager } from '../timeline-manager.svelte'; @@ -42,7 +42,7 @@ export function findMonthGroupForAsset(timelineManager: TimelineManager, id: str export function getMonthGroupByDate( timelineManager: TimelineManager, - targetYearMonth: TimelinePlainYearMonth, + targetYearMonth: TimelineYearMonth, ): MonthGroup | undefined { return timelineManager.months.find( (month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month, @@ -135,7 +135,7 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass return range; } -export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelinePlainYearMonth) { +export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) { for (const month of timelineManager.months) { const { year, month: monthNum } = month.yearMonth; if (monthNum === targetYearMonth.month && year === targetYearMonth.year) { diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 9f7112963a..03d138f680 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -10,8 +10,8 @@ import { fromTimelinePlainYearMonth, getTimes, setDifference, - type TimelinePlainDateTime, - type TimelinePlainYearMonth, + type TimelineDateTime, + type TimelineYearMonth, } from '$lib/utils/timeline-util'; import { t } from 'svelte-i18n'; @@ -47,11 +47,11 @@ export class MonthGroup { isHeightActual: boolean = $state(false); readonly monthGroupTitle: string; - readonly yearMonth: TimelinePlainYearMonth; + readonly yearMonth: TimelineYearMonth; constructor( store: TimelineManager, - yearMonth: TimelinePlainYearMonth, + yearMonth: TimelineYearMonth, initialCount: number, order: AssetOrder = AssetOrder.Desc, ) { @@ -351,7 +351,7 @@ export class MonthGroup { } } - findClosest(target: TimelinePlainDateTime) { + findClosest(target: TimelineDateTime) { const targetDate = fromTimelinePlainDateTime(target); let closest = undefined; let smallestDiff = Infinity; diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index ff8d1b1347..2e31fa9bc1 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -3,7 +3,7 @@ import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; -import { toTimelineAsset, type TimelinePlainDateTime, type TimelinePlainYearMonth } from '$lib/utils/timeline-util'; +import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { clamp, debounce, isEqual } from 'lodash-es'; import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; @@ -387,7 +387,7 @@ export class TimelineManager { }; } - async loadMonthGroup(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise { + async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise { let cancelable = true; if (options) { cancelable = options.cancelable; @@ -433,7 +433,7 @@ export class TimelineManager { } } - async #loadMonthGroupAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) { + async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) { await this.loadMonthGroup(yearMonth, options); return getMonthGroupByDate(this, yearMonth); } @@ -514,7 +514,7 @@ export class TimelineManager { return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier'); } - async getClosestAssetToDate(dateTime: TimelinePlainDateTime) { + async getClosestAssetToDate(dateTime: TimelineDateTime) { const monthGroup = findMonthGroupForDate(this, dateTime); if (!monthGroup) { return; diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 8e5523758b..18ee0426f3 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -1,4 +1,4 @@ -import type { TimelinePlainDate, TimelinePlainDateTime } from '$lib/utils/timeline-util'; +import type { TimelineDate, TimelineDateTime } from '$lib/utils/timeline-util'; import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk'; export type AssetApiGetTimeBucketsRequest = Parameters[0]; @@ -17,8 +17,8 @@ export type TimelineAsset = { ownerId: string; ratio: number; thumbhash: string | null; - localDateTime: TimelinePlainDateTime; - fileCreatedAt: TimelinePlainDateTime; + localDateTime: TimelineDateTime; + fileCreatedAt: TimelineDateTime; visibility: AssetVisibility; isFavorite: boolean; isTrashed: boolean; @@ -35,7 +35,7 @@ export type TimelineAsset = { export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; -export type MoveAsset = { asset: TimelineAsset; date: TimelinePlainDate }; +export type MoveAsset = { asset: TimelineAsset; date: TimelineDate }; export interface Viewport { width: number; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index dc237c2223..c160c65922 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -7,16 +7,16 @@ import { SvelteSet } from 'svelte/reactivity'; import { get } from 'svelte/store'; // Move type definitions to the top -export type TimelinePlainYearMonth = { +export type TimelineYearMonth = { year: number; month: number; }; -export type TimelinePlainDate = TimelinePlainYearMonth & { +export type TimelineDate = TimelineYearMonth & { day: number; }; -export type TimelinePlainDateTime = TimelinePlainDate & { +export type TimelineDateTime = TimelineDate & { hour: number; minute: number; second: number; @@ -33,29 +33,26 @@ export type ScrubberListener = ( export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime => DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime; -export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelinePlainDateTime => +export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelineDateTime => (fromISODateTime(isoDateTime, timeZone) as DateTime).toObject(); // used for AssetResponseDto.localDateTime, amongst others export const fromISODateTimeUTC = (isoDateTimeUtc: string) => fromISODateTime(isoDateTimeUtc, 'UTC'); -export const fromISODateTimeUTCToObject = (isoDateTimeUtc: string): TimelinePlainDateTime => +export const fromISODateTimeUTCToObject = (isoDateTimeUtc: string): TimelineDateTime => (fromISODateTimeUTC(isoDateTimeUtc) as DateTime).toObject(); // used to create equivalent of AssetResponseDto.localDateTime in UTC, but without timezone information export const fromISODateTimeTruncateTZToObject = ( isoDateTimeUtc: string, timeZone: string | undefined, -): TimelinePlainDateTime => +): TimelineDateTime => ( fromISODateTime(isoDateTimeUtc, timeZone ?? 'UTC').setZone('UTC', { keepLocalTime: true }) as DateTime ).toObject(); // Used to derive a local date time from an ISO string and a UTC offset in hours -export const fromISODateTimeWithOffsetToObject = ( - isoDateTimeUtc: string, - utcOffsetHours: number, -): TimelinePlainDateTime => { +export const fromISODateTimeWithOffsetToObject = (isoDateTimeUtc: string, utcOffsetHours: number): TimelineDateTime => { const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc); // Apply the offset to get the local time @@ -82,23 +79,23 @@ export const getTimes = (isoDateTimeUtc: string, localUtcOffsetHours: number) => }; }; -export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime => +export const fromTimelinePlainDateTime = (timelineDateTime: TimelineDateTime): DateTime => DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime; -export const fromTimelinePlainDate = (timelineYearMonth: TimelinePlainDate): DateTime => +export const fromTimelinePlainDate = (timelineYearMonth: TimelineDate): DateTime => DateTime.fromObject( { year: timelineYearMonth.year, month: timelineYearMonth.month, day: timelineYearMonth.day }, { zone: 'local', locale: get(locale) }, ) as DateTime; -export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearMonth): DateTime => +export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelineYearMonth): DateTime => DateTime.fromObject( { year: timelineYearMonth.year, month: timelineYearMonth.month }, { zone: 'local', locale: get(locale) }, ) as DateTime; -export const toISOYearMonthUTC = (timelineYearMonth: TimelinePlainYearMonth): string => - (fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime).toISO(); +export const toISOYearMonthUTC = ({ year, month }: TimelineYearMonth): string => + `${year}-${month.toString().padStart(2, '0')}-01T00:00:00.000Z`; export function formatMonthGroupTitle(_date: DateTime): string { if (!_date.isValid) { @@ -193,7 +190,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset => (unknownAsset as TimelineAsset).ratio !== undefined; -export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTime, b: TimelinePlainDateTime) => { +export const plainDateTimeCompare = (ascending: boolean, a: TimelineDateTime, b: TimelineDateTime) => { const [aDateTime, bDateTime] = ascending ? [a, b] : [b, a]; if (aDateTime.year !== bDateTime.year) {