fix(web): timeline time bucket issue (#20438)

This commit is contained in:
Jason Rasmussen 2025-07-30 12:21:02 -04:00 committed by GitHub
parent 097e132fba
commit d5a01c0310
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 36 additions and 45 deletions

View File

@ -29,12 +29,7 @@
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
getTimes,
toTimelineAsset,
type ScrubberListener,
type TimelinePlainYearMonth,
} from '$lib/utils/timeline-util';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@ -343,7 +338,7 @@
const monthsLength = timelineManager.months.length; const monthsLength = timelineManager.months.length;
for (let i = -1; i < monthsLength + 1; i++) { for (let i = -1; i < monthsLength + 1; i++) {
let monthGroup: TimelinePlainYearMonth | undefined; let monthGroup: TimelineYearMonth | undefined;
let monthGroupHeight = 0; let monthGroupHeight = 0;
if (i === -1) { if (i === -1) {
// lead-in // lead-in

View File

@ -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 { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { DayGroup } from './day-group.svelte'; import type { DayGroup } from './day-group.svelte';
@ -13,11 +13,11 @@ export class GroupInsertionCache {
changedDayGroups = new SvelteSet<DayGroup>(); changedDayGroups = new SvelteSet<DayGroup>();
newDayGroups = new SvelteSet<DayGroup>(); newDayGroups = new SvelteSet<DayGroup>();
getDayGroup({ year, month, day }: TimelinePlainDate): DayGroup | undefined { getDayGroup({ year, month, day }: TimelineDate): DayGroup | undefined {
return this.#lookupCache[year]?.[month]?.[day]; return this.#lookupCache[year]?.[month]?.[day];
} }
setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelinePlainDate) { setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelineDate) {
if (!this.#lookupCache[year]) { if (!this.#lookupCache[year]) {
this.#lookupCache[year] = {}; this.#lookupCache[year] = {};
} }

View File

@ -1,7 +1,6 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { toISOYearMonthUTC } from '$lib/utils/timeline-util'; import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
import { getTimeBucket } from '@immich/sdk'; import { getTimeBucket } from '@immich/sdk';
import type { MonthGroup } from '../month-group.svelte'; import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte'; import type { TimelineManager } from '../timeline-manager.svelte';
import type { TimelineManagerOptions } from '../types'; import type { TimelineManagerOptions } from '../types';

View File

@ -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 { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
@ -70,7 +70,7 @@ export function runAssetOperation(
const changedMonthGroups = new SvelteSet<MonthGroup>(); const changedMonthGroups = new SvelteSet<MonthGroup>();
let idsToProcess = new SvelteSet(ids); let idsToProcess = new SvelteSet(ids);
const idsProcessed = new SvelteSet<string>(); const idsProcessed = new SvelteSet<string>();
const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = []; const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
for (const month of timelineManager.months) { for (const month of timelineManager.months) {
if (idsToProcess.size > 0) { if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation); const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);

View File

@ -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 { AssetOrder } from '@immich/sdk';
import type { MonthGroup } from '../month-group.svelte'; import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte'; import type { TimelineManager } from '../timeline-manager.svelte';
@ -42,7 +42,7 @@ export function findMonthGroupForAsset(timelineManager: TimelineManager, id: str
export function getMonthGroupByDate( export function getMonthGroupByDate(
timelineManager: TimelineManager, timelineManager: TimelineManager,
targetYearMonth: TimelinePlainYearMonth, targetYearMonth: TimelineYearMonth,
): MonthGroup | undefined { ): MonthGroup | undefined {
return timelineManager.months.find( return timelineManager.months.find(
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month, (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; return range;
} }
export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelinePlainYearMonth) { export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
for (const month of timelineManager.months) { for (const month of timelineManager.months) {
const { year, month: monthNum } = month.yearMonth; const { year, month: monthNum } = month.yearMonth;
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) { if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {

View File

@ -10,8 +10,8 @@ import {
fromTimelinePlainYearMonth, fromTimelinePlainYearMonth,
getTimes, getTimes,
setDifference, setDifference,
type TimelinePlainDateTime, type TimelineDateTime,
type TimelinePlainYearMonth, type TimelineYearMonth,
} from '$lib/utils/timeline-util'; } from '$lib/utils/timeline-util';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -47,11 +47,11 @@ export class MonthGroup {
isHeightActual: boolean = $state(false); isHeightActual: boolean = $state(false);
readonly monthGroupTitle: string; readonly monthGroupTitle: string;
readonly yearMonth: TimelinePlainYearMonth; readonly yearMonth: TimelineYearMonth;
constructor( constructor(
store: TimelineManager, store: TimelineManager,
yearMonth: TimelinePlainYearMonth, yearMonth: TimelineYearMonth,
initialCount: number, initialCount: number,
order: AssetOrder = AssetOrder.Desc, order: AssetOrder = AssetOrder.Desc,
) { ) {
@ -351,7 +351,7 @@ export class MonthGroup {
} }
} }
findClosest(target: TimelinePlainDateTime) { findClosest(target: TimelineDateTime) {
const targetDate = fromTimelinePlainDateTime(target); const targetDate = fromTimelinePlainDateTime(target);
let closest = undefined; let closest = undefined;
let smallestDiff = Infinity; let smallestDiff = Infinity;

View File

@ -3,7 +3,7 @@ import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task'; 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 { clamp, debounce, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
@ -387,7 +387,7 @@ export class TimelineManager {
}; };
} }
async loadMonthGroup(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise<void> { async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true; let cancelable = true;
if (options) { if (options) {
cancelable = options.cancelable; 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); await this.loadMonthGroup(yearMonth, options);
return getMonthGroupByDate(this, yearMonth); return getMonthGroupByDate(this, yearMonth);
} }
@ -514,7 +514,7 @@ export class TimelineManager {
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier'); return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
} }
async getClosestAssetToDate(dateTime: TimelinePlainDateTime) { async getClosestAssetToDate(dateTime: TimelineDateTime) {
const monthGroup = findMonthGroupForDate(this, dateTime); const monthGroup = findMonthGroupForDate(this, dateTime);
if (!monthGroup) { if (!monthGroup) {
return; return;

View File

@ -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'; import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk';
export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sdk').getTimeBuckets>[0]; export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sdk').getTimeBuckets>[0];
@ -17,8 +17,8 @@ export type TimelineAsset = {
ownerId: string; ownerId: string;
ratio: number; ratio: number;
thumbhash: string | null; thumbhash: string | null;
localDateTime: TimelinePlainDateTime; localDateTime: TimelineDateTime;
fileCreatedAt: TimelinePlainDateTime; fileCreatedAt: TimelineDateTime;
visibility: AssetVisibility; visibility: AssetVisibility;
isFavorite: boolean; isFavorite: boolean;
isTrashed: boolean; isTrashed: boolean;
@ -35,7 +35,7 @@ export type TimelineAsset = {
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
export type MoveAsset = { asset: TimelineAsset; date: TimelinePlainDate }; export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
export interface Viewport { export interface Viewport {
width: number; width: number;

View File

@ -7,16 +7,16 @@ import { SvelteSet } from 'svelte/reactivity';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
// Move type definitions to the top // Move type definitions to the top
export type TimelinePlainYearMonth = { export type TimelineYearMonth = {
year: number; year: number;
month: number; month: number;
}; };
export type TimelinePlainDate = TimelinePlainYearMonth & { export type TimelineDate = TimelineYearMonth & {
day: number; day: number;
}; };
export type TimelinePlainDateTime = TimelinePlainDate & { export type TimelineDateTime = TimelineDate & {
hour: number; hour: number;
minute: number; minute: number;
second: number; second: number;
@ -33,29 +33,26 @@ export type ScrubberListener = (
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> => export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime<true>; DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime<true>;
export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelinePlainDateTime => export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelineDateTime =>
(fromISODateTime(isoDateTime, timeZone) as DateTime<true>).toObject(); (fromISODateTime(isoDateTime, timeZone) as DateTime<true>).toObject();
// used for AssetResponseDto.localDateTime, amongst others // used for AssetResponseDto.localDateTime, amongst others
export const fromISODateTimeUTC = (isoDateTimeUtc: string) => fromISODateTime(isoDateTimeUtc, 'UTC'); export const fromISODateTimeUTC = (isoDateTimeUtc: string) => fromISODateTime(isoDateTimeUtc, 'UTC');
export const fromISODateTimeUTCToObject = (isoDateTimeUtc: string): TimelinePlainDateTime => export const fromISODateTimeUTCToObject = (isoDateTimeUtc: string): TimelineDateTime =>
(fromISODateTimeUTC(isoDateTimeUtc) as DateTime<true>).toObject(); (fromISODateTimeUTC(isoDateTimeUtc) as DateTime<true>).toObject();
// used to create equivalent of AssetResponseDto.localDateTime in UTC, but without timezone information // used to create equivalent of AssetResponseDto.localDateTime in UTC, but without timezone information
export const fromISODateTimeTruncateTZToObject = ( export const fromISODateTimeTruncateTZToObject = (
isoDateTimeUtc: string, isoDateTimeUtc: string,
timeZone: string | undefined, timeZone: string | undefined,
): TimelinePlainDateTime => ): TimelineDateTime =>
( (
fromISODateTime(isoDateTimeUtc, timeZone ?? 'UTC').setZone('UTC', { keepLocalTime: true }) as DateTime<true> fromISODateTime(isoDateTimeUtc, timeZone ?? 'UTC').setZone('UTC', { keepLocalTime: true }) as DateTime<true>
).toObject(); ).toObject();
// Used to derive a local date time from an ISO string and a UTC offset in hours // Used to derive a local date time from an ISO string and a UTC offset in hours
export const fromISODateTimeWithOffsetToObject = ( export const fromISODateTimeWithOffsetToObject = (isoDateTimeUtc: string, utcOffsetHours: number): TimelineDateTime => {
isoDateTimeUtc: string,
utcOffsetHours: number,
): TimelinePlainDateTime => {
const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc); const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc);
// Apply the offset to get the local time // 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<true> => export const fromTimelinePlainDateTime = (timelineDateTime: TimelineDateTime): DateTime<true> =>
DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime<true>; DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime<true>;
export const fromTimelinePlainDate = (timelineYearMonth: TimelinePlainDate): DateTime<true> => export const fromTimelinePlainDate = (timelineYearMonth: TimelineDate): DateTime<true> =>
DateTime.fromObject( DateTime.fromObject(
{ year: timelineYearMonth.year, month: timelineYearMonth.month, day: timelineYearMonth.day }, { year: timelineYearMonth.year, month: timelineYearMonth.month, day: timelineYearMonth.day },
{ zone: 'local', locale: get(locale) }, { zone: 'local', locale: get(locale) },
) as DateTime<true>; ) as DateTime<true>;
export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearMonth): DateTime<true> => export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelineYearMonth): DateTime<true> =>
DateTime.fromObject( DateTime.fromObject(
{ year: timelineYearMonth.year, month: timelineYearMonth.month }, { year: timelineYearMonth.year, month: timelineYearMonth.month },
{ zone: 'local', locale: get(locale) }, { zone: 'local', locale: get(locale) },
) as DateTime<true>; ) as DateTime<true>;
export const toISOYearMonthUTC = (timelineYearMonth: TimelinePlainYearMonth): string => export const toISOYearMonthUTC = ({ year, month }: TimelineYearMonth): string =>
(fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toISO(); `${year}-${month.toString().padStart(2, '0')}-01T00:00:00.000Z`;
export function formatMonthGroupTitle(_date: DateTime): string { export function formatMonthGroupTitle(_date: DateTime): string {
if (!_date.isValid) { if (!_date.isValid) {
@ -193,7 +190,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset => export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
(unknownAsset as TimelineAsset).ratio !== undefined; (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]; const [aDateTime, bDateTime] = ascending ? [a, b] : [b, a];
if (aDateTime.year !== bDateTime.year) { if (aDateTime.year !== bDateTime.year) {