Fix and enhance navigate to time functionality

• Add i18n support for navigate button and dialog title
• Update ChangeDate component to return DateTime object alongside ISO string
• Fix closest date finding algorithm to handle cases where exact month doesn't exist
• Add navigate to time (g) shortcut to keyboard shortcuts modal
This commit is contained in:
midzelis 2025-08-14 20:06:22 +00:00
parent c229b4d71a
commit cc6d64e259
9 changed files with 82 additions and 25 deletions

View file

@ -1339,6 +1339,8 @@
"my_albums": "My albums", "my_albums": "My albums",
"name": "Name", "name": "Name",
"name_or_nickname": "Name or nickname", "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_photos_upload": "Use cellular data to backup photos",
"network_requirement_videos_upload": "Use cellular data to backup videos", "network_requirement_videos_upload": "Use cellular data to backup videos",
"network_requirements_updated": "Network requirements changed, resetting backup queue", "network_requirements_updated": "Network requirements changed, resetting backup queue",

View file

@ -9,7 +9,7 @@
"build:stats": "BUILD_STATS=true vite build", "build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package", "package": "svelte-kit package",
"preview": "vite preview", "preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte", "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings",
"check:typescript": "tsc --noEmit", "check:typescript": "tsc --noEmit",
"check:watch": "npm run check:svelte -- --watch", "check:watch": "npm run check:svelte -- --watch",
"check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript", "check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript",

View file

@ -5,9 +5,11 @@
setFocusToAsset as setFocusAssetInit, setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit, setFocusTo as setFocusToInit,
} from '$lib/components/photos-page/actions/focus-actions'; } from '$lib/components/photos-page/actions/focus-actions';
import ChangeDate, { AbsoluteResult, RelativeResult } from '$lib/components/shared-components/change-date.svelte'; import ChangeDate, {
type AbsoluteResult,
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
@ -20,7 +22,10 @@
import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions'; import { deleteAssets, updateStackedAssetInTimeline } 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 { AssetVisibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiCalendarBlankOutline } from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import DeleteAssetDialog from './delete-asset-dialog.svelte'; import DeleteAssetDialog from './delete-asset-dialog.svelte';
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
@ -197,15 +202,16 @@
{#if isShowSelectDate} {#if isShowSelectDate}
<ChangeDate <ChangeDate
title="Navigate to Time" withDuration={false}
icon={mdiCalendarBlankOutline}
confirmText={$t('navigate')}
title={$t('navigate_to_time')}
initialDate={DateTime.now()} initialDate={DateTime.now()}
timezoneInput={false} timezoneInput={false}
onConfirm={async (result: AbsoluteResult | RelativeResult) => { onConfirm={async (result: AbsoluteResult | RelativeResult) => {
isShowSelectDate = false; isShowSelectDate = false;
if (result.mode === 'absolute') { if (result.mode === 'absolute') {
const asset = await timelineManager.getClosestAssetToDate( const asset = await timelineManager.getClosestAssetToDate(result.dateTime.toObject());
(DateTime.fromISO(result.date) as DateTime<true>).toObject(),
);
if (asset) { if (asset) {
setFocusAsset(asset); setFocusAsset(asset);
} }

View file

@ -15,7 +15,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util'; import { getTimes, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
@ -82,7 +82,7 @@
onThumbnailClick, onThumbnailClick,
}: Props = $props(); }: Props = $props();
let { isViewing: showAssetViewer, gridScrollTarget, mutex, viewingAsset } = assetViewingStore; let { isViewing: showAssetViewer, gridScrollTarget, asset: viewingAsset } = assetViewingStore;
let element: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();

View file

@ -8,6 +8,9 @@ import ChangeDate from './change-date.svelte';
describe('ChangeDate component', () => { describe('ChangeDate component', () => {
const initialDate = DateTime.fromISO('2024-01-01'); const initialDate = DateTime.fromISO('2024-01-01');
const initialTimeZone = 'Europe/Berlin'; const initialTimeZone = 'Europe/Berlin';
const targetDate = DateTime.fromISO('2024-01-01').setZone('UTC+1', {
keepLocalTime: true,
});
const currentInterval = { const currentInterval = {
start: DateTime.fromISO('2000-02-01T14:00:00+01:00'), start: DateTime.fromISO('2000-02-01T14:00:00+01:00'),
end: DateTime.fromISO('2001-02-01T14:00:00+01:00'), end: DateTime.fromISO('2001-02-01T14:00:00+01:00'),
@ -43,7 +46,11 @@ describe('ChangeDate component', () => {
await fireEvent.click(getConfirmButton()); 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 () => { test('calls onCancel on cancel', async () => {
@ -58,7 +65,9 @@ describe('ChangeDate component', () => {
describe('when date is in daylight saving time', () => { describe('when date is in daylight saving time', () => {
const dstDate = DateTime.fromISO('2024-07-01'); 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', () => { test('should render correct timezone with offset', () => {
render(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm }); render(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm });
@ -72,7 +81,11 @@ describe('ChangeDate component', () => {
await fireEvent.click(getConfirmButton()); 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,
});
}); });
}); });

View file

@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import { ConfirmModal } from '@immich/ui'; import { locale } from '$lib/stores/preferences.store';
import { mdiCalendarEditOutline } from '@mdi/js'; import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js';
import { ConfirmModal, Field, Switch } from '@immich/ui';
import { mdiCalendarEdit } from '@mdi/js';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import DateInput from '../elements/date-input.svelte';
import Combobox, { type ComboBoxOption } from './combobox.svelte';
import DurationInput from '../elements/duration-input.svelte';
import { Field, Switch } from '@immich/ui';
import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js';
import { locale } from '$lib/stores/preferences.store';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import DateInput from '../elements/date-input.svelte';
import DurationInput from '../elements/duration-input.svelte';
import Combobox, { type ComboBoxOption } from './combobox.svelte';
interface Props { interface Props {
title?: string; title?: string;
@ -18,6 +17,8 @@
timezoneInput?: boolean; timezoneInput?: boolean;
withDuration?: boolean; withDuration?: boolean;
currentInterval?: { start: DateTime; end: DateTime }; currentInterval?: { start: DateTime; end: DateTime };
icon?: string;
confirmText?: string;
onCancel: () => void; onCancel: () => void;
onConfirm: (result: AbsoluteResult | RelativeResult) => void; onConfirm: (result: AbsoluteResult | RelativeResult) => void;
} }
@ -29,6 +30,8 @@
timezoneInput = true, timezoneInput = true,
withDuration = true, withDuration = true,
currentInterval = undefined, currentInterval = undefined,
icon = mdiCalendarEdit,
confirmText,
onCancel, onCancel,
onConfirm, onConfirm,
}: Props = $props(); }: Props = $props();
@ -36,6 +39,7 @@
export type AbsoluteResult = { export type AbsoluteResult = {
mode: 'absolute'; mode: 'absolute';
date: string; date: string;
dateTime: DateTime<true>;
}; };
export type RelativeResult = { export type RelativeResult = {
@ -192,9 +196,13 @@
const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`; 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. // Create a DateTime object in this fixed-offset zone, preserving the local time.
const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone }); const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone }) as DateTime<true>;
onConfirm({ mode: 'absolute', date: finalDateTime.toISO({ includeOffset: true })! }); onConfirm({
mode: 'absolute',
date: finalDateTime.toISO({ includeOffset: true }),
dateTime: finalDateTime,
});
} }
if (showRelative && (selectedDuration || selectedRelativeOption)) { if (showRelative && (selectedDuration || selectedRelativeOption)) {
@ -238,7 +246,8 @@
<ConfirmModal <ConfirmModal
confirmColor="primary" confirmColor="primary"
{title} {title}
icon={mdiCalendarEditOutline} {icon}
{confirmText}
prompt="Please select a new date:" prompt="Please select a new date:"
disabled={!date.isValid} disabled={!date.isValid}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())} onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}

View file

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

View file

@ -16,6 +16,7 @@ import {
runAssetOperation, runAssetOperation,
} from '$lib/managers/timeline-manager/internal/operations-support.svelte'; } from '$lib/managers/timeline-manager/internal/operations-support.svelte';
import { import {
findClosestGroupForDate,
findMonthGroupForAsset as findMonthGroupForAssetUtil, findMonthGroupForAsset as findMonthGroupForAssetUtil,
findMonthGroupForDate, findMonthGroupForDate,
getAssetWithOffset, getAssetWithOffset,
@ -523,9 +524,13 @@ export class TimelineManager {
} }
async getClosestAssetToDate(dateTime: TimelineDateTime) { async getClosestAssetToDate(dateTime: TimelineDateTime) {
const monthGroup = findMonthGroupForDate(this, dateTime); let monthGroup = findMonthGroupForDate(this, dateTime);
if (!monthGroup) { if (!monthGroup) {
return; // if exact match not found, find closest
monthGroup = findClosestGroupForDate(this, dateTime);
if (!monthGroup) {
return;
}
} }
await this.loadMonthGroup(dateTime, { cancelable: false }); await this.loadMonthGroup(dateTime, { cancelable: false });
const asset = monthGroup.findClosest(dateTime); const asset = monthGroup.findClosest(dateTime);

View file

@ -28,6 +28,7 @@
{ key: ['D', 'd'], action: $t('previous_or_next_day') }, { key: ['D', 'd'], action: $t('previous_or_next_day') },
{ key: ['M', 'm'], action: $t('previous_or_next_month') }, { key: ['M', 'm'], action: $t('previous_or_next_month') },
{ key: ['Y', 'y'], action: $t('previous_or_next_year') }, { key: ['Y', 'y'], action: $t('previous_or_next_year') },
{ key: ['g'], action: $t('navigate_to_time') },
{ key: ['x'], action: $t('select') }, { key: ['x'], action: $t('select') },
{ key: ['Esc'], action: $t('back_close_deselect') }, { key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') }, { key: ['Ctrl', 'k'], action: $t('search_your_photos') },