diff --git a/i18n/en.json b/i18n/en.json index 23e1071a2d..47af9e0521 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1360,6 +1360,8 @@ "my_albums": "My albums", "name": "Name", "name_or_nickname": "Name or nickname", + "navigate": "Navigate", + "navigate_to_time": "Navigate to Time", "network_requirement_photos_upload": "Use cellular data to backup photos", "network_requirement_videos_upload": "Use cellular data to backup videos", "network_requirements": "Network Requirements", diff --git a/web/src/lib/components/shared-components/change-date.spec.ts b/web/src/lib/components/shared-components/change-date.spec.ts index 63926a44a6..14742d798c 100644 --- a/web/src/lib/components/shared-components/change-date.spec.ts +++ b/web/src/lib/components/shared-components/change-date.spec.ts @@ -8,6 +8,9 @@ import ChangeDate from './change-date.svelte'; describe('ChangeDate component', () => { const initialDate = DateTime.fromISO('2024-01-01'); const initialTimeZone = 'Europe/Berlin'; + const targetDate = DateTime.fromISO('2024-01-01').setZone('UTC+1', { + keepLocalTime: true, + }); const currentInterval = { start: DateTime.fromISO('2000-02-01T14:00:00+01:00'), end: DateTime.fromISO('2001-02-01T14:00:00+01:00'), @@ -50,7 +53,11 @@ describe('ChangeDate component', () => { await fireEvent.click(getConfirmButton()); - expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-01-01T00:00:00.000+01:00' }); + expect(onConfirm).toHaveBeenCalledWith({ + mode: 'absolute', + date: '2024-01-01T00:00:00.000+01:00', + dateTime: targetDate, + }); }); test('calls onCancel on cancel', async () => { @@ -65,7 +72,9 @@ describe('ChangeDate component', () => { describe('when date is in daylight saving time', () => { const dstDate = DateTime.fromISO('2024-07-01'); - + const targetDate = DateTime.fromISO('2024-07-01').setZone('UTC+2', { + keepLocalTime: true, + }); test('should render correct timezone with offset', () => { render(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm }); @@ -79,7 +88,11 @@ describe('ChangeDate component', () => { await fireEvent.click(getConfirmButton()); - expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-07-01T00:00:00.000+02:00' }); + expect(onConfirm).toHaveBeenCalledWith({ + mode: 'absolute', + date: '2024-07-01T00:00:00.000+02:00', + dateTime: targetDate, + }); }); }); diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index f7acf06890..6785c0333b 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -4,7 +4,7 @@ import { locale } from '$lib/stores/preferences.store'; import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js'; import { ConfirmModal, Field, Switch } from '@immich/ui'; - import { mdiCalendarEditOutline } from '@mdi/js'; + import { mdiCalendarEdit } from '@mdi/js'; import { DateTime, Duration } from 'luxon'; import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; @@ -17,6 +17,8 @@ timezoneInput?: boolean; withDuration?: boolean; currentInterval?: { start: DateTime; end: DateTime }; + icon?: string; + confirmText?: string; onCancel: () => void; onConfirm: (result: AbsoluteResult | RelativeResult) => void; } @@ -28,6 +30,8 @@ timezoneInput = true, withDuration = true, currentInterval = undefined, + icon = mdiCalendarEdit, + confirmText, onCancel, onConfirm, }: Props = $props(); @@ -35,6 +39,7 @@ export type AbsoluteResult = { mode: 'absolute'; date: string; + dateTime: DateTime; }; export type RelativeResult = { @@ -191,9 +196,15 @@ const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`; // Create a DateTime object in this fixed-offset zone, preserving the local time. - const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone }); + const fixedOffsetDateTime = DateTime.fromObject(dtComponents.toObject(), { + zone: fixedOffsetZone, + }) as DateTime; - onConfirm({ mode: 'absolute', date: finalDateTime.toISO({ includeOffset: true })! }); + onConfirm({ + mode: 'absolute', + date: fixedOffsetDateTime.toISO({ includeOffset: true })!, + dateTime: fixedOffsetDateTime, + }); } if (showRelative && (selectedDuration || selectedRelativeOption)) { @@ -237,7 +248,8 @@ (confirmed ? handleConfirm() : onCancel())} diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 2849b50815..45a756a51e 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -833,15 +833,14 @@ title="Navigate to Time" initialDate={DateTime.now()} timezoneInput={false} - onConfirm={async (dateString: AbsoluteResult | RelativeResult) => { + onConfirm={async (result: AbsoluteResult | RelativeResult) => { isShowSelectDate = false; - if (dateString.mode == 'absolute') { - const asset = await timelineManager.getClosestAssetToDate( - (DateTime.fromISO(dateString.date) as DateTime).toObject(), - ); - if (asset) { - setFocusAsset(asset); - } + if (result.mode !== 'absolute') { + return; + } + const asset = await timelineManager.getClosestAssetToDate(result.dateTime.toObject()); + if (asset) { + setFocusAsset(asset); } }} onCancel={() => (isShowSelectDate = false)} 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 7e6ae734dc..f1203016af 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 @@ -143,3 +143,24 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe } } } + +export function findClosestGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) { + let closestMonth: MonthGroup | undefined; + let minDifference = Number.MAX_SAFE_INTEGER; + + for (const month of timelineManager.months) { + const { year, month: monthNum } = month.yearMonth; + + // Calculate the absolute difference in months + const yearDiff = Math.abs(year - targetYearMonth.year); + const monthDiff = Math.abs(monthNum - targetYearMonth.month); + const totalDiff = yearDiff * 12 + monthDiff; + + if (totalDiff < minDifference) { + minDifference = totalDiff; + closestMonth = month; + } + } + + return closestMonth; +} 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 172cd07a02..3a257085ca 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -16,6 +16,7 @@ import { runAssetOperation, } from '$lib/managers/timeline-manager/internal/operations-support.svelte'; import { + findClosestGroupForDate, findMonthGroupForAsset as findMonthGroupForAssetUtil, findMonthGroupForDate, getAssetWithOffset, @@ -523,9 +524,13 @@ export class TimelineManager { } async getClosestAssetToDate(dateTime: TimelineDateTime) { - const monthGroup = findMonthGroupForDate(this, dateTime); + let monthGroup = findMonthGroupForDate(this, dateTime); if (!monthGroup) { - return; + // if exact match not found, find closest + monthGroup = findClosestGroupForDate(this, dateTime); + if (!monthGroup) { + return; + } } await this.loadMonthGroup(dateTime, { cancelable: false }); const asset = monthGroup.findClosest(dateTime); diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte index 9bd7b29b94..ebb5ea3c60 100644 --- a/web/src/lib/modals/ShortcutsModal.svelte +++ b/web/src/lib/modals/ShortcutsModal.svelte @@ -27,6 +27,7 @@ { key: ['D', 'd'], action: $t('previous_or_next_day') }, { key: ['M', 'm'], action: $t('previous_or_next_month') }, { key: ['Y', 'y'], action: $t('previous_or_next_year') }, + { key: ['g'], action: $t('navigate_to_time') }, { key: ['x'], action: $t('select') }, { key: ['Esc'], action: $t('back_close_deselect') }, { key: ['Ctrl', 'k'], action: $t('search_your_photos') },