fix: navigate to time action (#20928)

* fix: navigate to time action

* change-date -> DateSelectionModal; use luxon; use handle* for callback fn name

* refactor change-date dialogs

* Review comments

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Min Idzelis
2025-10-16 13:44:09 -04:00
committed by GitHub
parent d0eae97037
commit 2919ee4c65
19 changed files with 647 additions and 477 deletions

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import Combobox from '$lib/components/shared-components/combobox.svelte';
import DateInput from '$lib/elements/DateInput.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toIsoDate } from '$lib/modals/timezone-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset } from '@immich/sdk';
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
import { mdiCalendarEdit } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
initialDate?: DateTime;
initialTimeZone?: string;
timezoneInput?: boolean;
asset: TimelineAsset;
onClose: (success: boolean) => void;
}
let { initialDate = DateTime.now(), initialTimeZone, timezoneInput = true, asset, onClose }: Props = $props();
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
const timezones = $derived(getTimezones(selectedDate));
// svelte-ignore state_referenced_locally
let lastSelectedTimezone = $state(getPreferredTimeZone(initialDate, initialTimeZone, timezones));
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
const handleClose = async () => {
if (!date.isValid || !selectedOption) {
onClose(false);
return;
}
// Get the local date/time components from the selected string using neutral timezone
const isoDate = toIsoDate(selectedDate, selectedOption);
try {
await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal: isoDate } });
onClose(true);
} catch (error) {
handleError(error, $t('errors.unable_to_change_date'));
onClose(false);
}
};
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<Modal title={$t('edit_date_and_time')} icon={mdiCalendarEdit} onClose={() => onClose(false)} size="small">
<ModalBody>
<VStack fullWidth>
<HStack fullWidth>
<label class="immich-form-label" for="datetime">{$t('date_and_time')}</label>
</HStack>
<HStack fullWidth>
<DateInput
class="immich-form-input text-gray-700 w-full"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
</HStack>
{#if timezoneInput}
<div class="w-full">
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
/>
</div>
{/if}
</VStack>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth onclick={handleClose}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,208 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
import { calcNewDate } from '$lib/modals/timezone-utils';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { DateTime } from 'luxon';
import AssetSelectionChangeDateModal from './AssetSelectionChangeDateModal.svelte';
describe('DateSelectionModal component', () => {
const initialDate = DateTime.fromISO('2024-01-01');
const initialTimeZone = 'Europe/Berlin';
const onClose = vi.fn();
const getRelativeInputToggle = () => screen.getByTestId('edit-by-offset-switch');
const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement;
const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement;
const getCancelButton = () => screen.getByText('cancel');
const getConfirmButton = () => screen.getByText('confirm');
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', getVisualViewportMock());
vi.resetAllMocks();
Element.prototype.animate = getAnimateMock();
});
afterAll(async () => {
await waitFor(() => {
// check that bits-ui body scroll-lock class is gone
expect(document.body.style.pointerEvents).not.toBe('none');
});
});
test('should render correct values', () => {
render(AssetSelectionChangeDateModal, {
initialDate,
initialTimeZone,
assets: [],
onClose,
});
expect(getDateInput().value).toBe('2024-01-01T00:00');
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
});
test('calls onConfirm with correct date on confirm', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.click(getConfirmButton());
expect(sdkMock.updateAssets).toHaveBeenCalledWith({
assetBulkUpdateDto: {
ids: [],
dateTimeOriginal: '2024-01-01T00:00:00.000+01:00',
},
});
});
test('calls onCancel on cancel', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.click(getCancelButton());
expect(onClose).toHaveBeenCalled();
});
describe('when date is in daylight saving time', () => {
const dstDate = DateTime.fromISO('2024-07-01');
test('should render correct timezone with offset', () => {
render(AssetSelectionChangeDateModal, {
initialDate: dstDate,
initialTimeZone,
assets: [],
onClose,
});
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+02:00)');
});
test('calls onConfirm with correct date on confirm', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate: dstDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.click(getConfirmButton());
expect(sdkMock.updateAssets).toHaveBeenCalledWith({
assetBulkUpdateDto: {
ids: [],
dateTimeOriginal: '2024-07-01T00:00:00.000+02:00',
},
});
});
});
test('calls onConfirm with correct offset in relative mode', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.click(getRelativeInputToggle());
const dayInput = screen.getByPlaceholderText('days');
const hoursInput = screen.getByPlaceholderText('hours');
const minutesInput = screen.getByPlaceholderText('minutes');
const days = 5;
const hours = 4;
const minutes = 3;
await fireEvent.input(dayInput, { target: { value: days } });
await fireEvent.input(hoursInput, { target: { value: hours } });
await fireEvent.input(minutesInput, { target: { value: minutes } });
await fireEvent.click(getConfirmButton());
expect(sdkMock.updateAssets).toHaveBeenCalledWith({
assetBulkUpdateDto: {
ids: [],
dateTimeRelative: days * 60 * 24 + hours * 60 + minutes,
timeZone: 'Europe/Berlin',
},
});
});
test('calls onConfirm with correct timeZone in relative mode', async () => {
const user = userEvent.setup();
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await user.click(getRelativeInputToggle());
await user.type(getTimeZoneInput(), initialTimeZone);
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
await user.click(getConfirmButton());
expect(sdkMock.updateAssets).toHaveBeenCalledWith({
assetBulkUpdateDto: {
ids: [],
dateTimeRelative: 0,
timeZone: 'Europe/Berlin',
},
});
});
test('correctly handles date preview', () => {
const testCases = [
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
duration: 0,
timezone: undefined,
expectedResult: '2024-01-01T00:00:00.000',
},
{
timestamp: DateTime.fromISO('2024-01-01T04:00:00.000+05:00', { setZone: true }),
duration: 0,
timezone: undefined,
expectedResult: '2024-01-01T04:00:00.000',
},
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+00:00', { setZone: true }),
duration: 0,
timezone: 'Europe/Berlin',
expectedResult: '2024-01-01T01:00:00.000',
},
{
timestamp: DateTime.fromISO('2024-07-01T00:00:00.000+00:00', { setZone: true }),
duration: 0,
timezone: 'Europe/Berlin',
expectedResult: '2024-07-01T02:00:00.000',
},
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
duration: 1440,
timezone: undefined,
expectedResult: '2024-01-02T00:00:00.000',
},
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
duration: -1440,
timezone: undefined,
expectedResult: '2023-12-31T00:00:00.000',
},
{
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000-01:00', { setZone: true }),
duration: -1440,
timezone: 'America/Anchorage',
expectedResult: '2023-12-30T16:00:00.000',
},
];
for (const testCase of testCases) {
expect(calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone), JSON.stringify(testCase)).toBe(
testCase.expectedResult,
);
}
});
});

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import Combobox from '$lib/components/shared-components/combobox.svelte';
import DateInput from '$lib/elements/DateInput.svelte';
import DurationInput from '$lib/elements/DurationInput.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toIsoDate, type ZoneOption } from '$lib/modals/timezone-utils';
import { user } from '$lib/stores/user.store';
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { Button, Field, HStack, Modal, ModalBody, ModalFooter, Switch, VStack } from '@immich/ui';
import { mdiCalendarEdit } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
initialDate?: DateTime;
initialTimeZone?: string;
assets: TimelineAsset[];
onClose: (success: boolean) => void;
}
let { initialDate = DateTime.now(), initialTimeZone, assets, onClose }: Props = $props();
let showRelative = $state(false);
let selectedDuration = $state(0);
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
const timezones = $derived(getTimezones(selectedDate));
// svelte-ignore state_referenced_locally
let lastSelectedTimezone = $state(getPreferredTimeZone(initialDate, initialTimeZone, timezones));
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
const handleConfirm = async () => {
const ids = getOwnedAssetsWithWarning(assets, $user);
try {
if (showRelative && (selectedDuration || selectedOption)) {
await updateAssets({
assetBulkUpdateDto: {
ids,
dateTimeRelative: selectedDuration,
timeZone: selectedOption?.value,
},
});
onClose(true);
return;
}
const isoDate = toIsoDate(selectedDate, selectedOption);
await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal: isoDate } });
onClose(true);
} catch (error) {
handleError(error, $t('errors.unable_to_change_date'));
onClose(false);
}
};
// let before = $derived(DateTime.fromObject(assets[0].localDateTime).toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
// let after = $derived(
// currentInterval ? calcNewDate(currentInterval.end, selectedDuration, selectedOption?.value) : undefined,
// );
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<Modal title={$t('edit_date_and_time')} icon={mdiCalendarEdit} onClose={() => onClose(false)} size="small">
<ModalBody>
<VStack fullWidth>
<HStack fullWidth>
<Field label={$t('edit_date_and_time_by_offset')}>
<Switch data-testid="edit-by-offset-switch" bind:checked={showRelative} />
</Field>
</HStack>
{#if showRelative}
<HStack fullWidth>
<label class="immich-form-label" for="relativedatetime">{$t('offset')}</label>
</HStack>
<HStack fullWidth>
<DurationInput class="immich-form-input text-gray-700" id="relativedatetime" bind:value={selectedDuration} />
</HStack>
{:else}
<HStack fullWidth>
<label class="immich-form-label" for="datetime">{$t('date_and_time')}</label>
</HStack>
<HStack fullWidth>
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
</HStack>
{/if}
<div class="w-full">
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => (lastSelectedTimezone = option as ZoneOption)}
></Combobox>
</div>
<!-- <Card color="secondary" class={!showRelative || !currentInterval ? 'invisible' : ''}>
<CardBody class="p-2">
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 items-center">
<div class="col-span-2 immich-form-label" data-testid="interval-preview">Preview</div>
<Text size="small" class="-mt-2 immich-form-label col-span-2"
>Showing changes for first selected asset only</Text
>
<label class="immich-form-label" for="from">Before</label>
<DateInput
class="dark:text-gray-300 text-gray-700 text-base"
id="from"
type="datetime-local"
readonly
bind:value={before}
/>
<label class="immich-form-label" for="to">After</label>
<DateInput
class="dark:text-gray-300 text-gray-700 text-base"
id="to"
type="datetime-local"
readonly
bind:value={after}
/>
</div>
</CardBody>
</Card> -->
</VStack>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>
{$t('cancel')}
</Button>
<Button shape="round" color="primary" fullWidth onclick={handleConfirm} disabled={!date.isValid}>
{$t('confirm')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
import { mdiNavigationVariantOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
timelineManager: TimelineManager;
onClose: (asset?: TimelineAsset) => void;
}
let { timelineManager, onClose }: Props = $props();
const initialDate = DateTime.now();
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
const timezones = $derived(getTimezones(selectedDate));
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption: ZoneOption | undefined = $derived(getPreferredTimeZone(initialDate, undefined, timezones));
const handleClose = async () => {
if (!date.isValid || !selectedOption) {
onClose();
return;
}
// Get the local date/time components from the selected string using neutral timezone
const dateTime = toDatetime(selectedDate, selectedOption) as DateTime<true>;
const asset = await timelineManager.getClosestAssetToDate(dateTime.toObject());
onClose(asset);
};
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<Modal title={$t('navigate_to_time')} icon={mdiNavigationVariantOutline} onClose={() => onClose()}>
<ModalBody>
<VStack fullWidth>
<HStack fullWidth>
<label class="immich-form-label" for="datetime">{$t('date_and_time')}</label>
</HStack>
<HStack fullWidth>
<DateInput
class="immich-form-input text-gray-700 w-full"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth onclick={handleClose}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -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') },

View File

@@ -0,0 +1,149 @@
import { DateTime, Duration } from 'luxon';
export type ZoneOption = {
/**
* Timezone name with offset
*
* e.g. Asia/Jerusalem (+03:00)
*/
label: string;
/**
* Timezone name
*
* e.g. Asia/Jerusalem
*/
value: string;
/**
* Timezone offset in minutes
*
* e.g. 300
*/
offsetMinutes: number;
/**
* True iff the date is valid
*
* Dates may be invalid for various reasons, for example setting a day that does not exist (30 Feb 2024).
* Due to daylight saving time, 2:30am is invalid for Europe/Berlin on Mar 31 2024.The two following local times
* are one second apart:
*
* - Mar 31 2024 01:59:59 (GMT+0100, unix timestamp 1725058799)
* - Mar 31 2024 03:00:00 (GMT+0200, unix timestamp 1711846800)
*
* Mar 31 2024 02:30:00 does not exist in Europe/Berlin, this is an invalid date/time/time zone combination.
*/
valid: boolean;
};
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const knownTimezones = Intl.supportedValuesOf('timeZone');
export function getTimezones(selectedDate: string) {
// Use a fixed modern date to calculate stable timezone offsets for the list
// This ensures that the offsets shown in the combobox are always current,
// regardless of the historical date selected by the user.
return knownTimezones
.map((zone) => zoneOptionForDate(zone, selectedDate))
.filter((zone) => zone.valid)
.sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
}
export function getModernOffsetForZoneAndDate(
zone: string,
dateString: string,
): { offsetMinutes: number; offsetFormat: string } {
const dt = DateTime.fromISO(dateString, { zone });
// we determine the *modern* offset for this zone based on its current rules.
// To do this, we "move" the date to the current year, keeping the local time components.
// This allows Luxon to apply current-year DST rules.
const modernYearDt = dt.set({ year: DateTime.now().year });
// Calculate the offset at that modern year's date.
const modernOffsetMinutes = modernYearDt.setZone(zone, { keepLocalTime: true }).offset;
const modernOffsetFormat = modernYearDt.setZone(zone, { keepLocalTime: true }).toFormat('ZZ');
return { offsetMinutes: modernOffsetMinutes, offsetFormat: modernOffsetFormat };
}
function zoneOptionForDate(zone: string, date: string) {
const { offsetMinutes, offsetFormat: zoneOffsetAtDate } = getModernOffsetForZoneAndDate(zone, date);
// For validity, we still need to check if the exact date/time exists in the *original* timezone (for gaps/overlaps).
const dateForValidity = DateTime.fromISO(date, { zone });
const valid = dateForValidity.isValid && date === dateForValidity.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
return {
value: zone,
offsetMinutes,
label: zone + ' (' + zoneOffsetAtDate + ')' + (valid ? '' : ' [invalid date!]'),
valid,
};
}
function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) {
const offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes;
if (offsetDifference != 0) {
return offsetDifference;
}
return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' });
}
/*
* If the time zone is not given, find the timezone to select for a given time, date, and offset (e.g. +02:00).
*
* This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)"
* instead of just the raw offset or something like "UTC+02:00".
*
* The provided information (initialDate, from some asset) includes the offset (e.g. +02:00), but no information about
* the actual time zone. As several countries/regions may share the same offset, for example Berlin (Germany) and
* Blantyre (Malawi) sharing +02:00 in summer, we have to guess and somehow pick a suitable time zone.
*
* If the time zone configured by the user (in the browser) provides the same offset for the given date (accounting
* for daylight saving time and other weirdness), we prefer to show it. This way, for German users, we might be able
* to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre".
*/
export function getPreferredTimeZone(
date: DateTime,
initialTimeZone: string | undefined,
timezones: ZoneOption[],
selectedOption?: ZoneOption,
) {
const offset = date.offset;
const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone);
const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset);
const utcFallback = {
label: 'UTC (+00:00)',
offsetMinutes: 0,
value: 'UTC',
valid: true,
};
return previousSelection ?? fromInitialTimeZone ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback;
}
export function toDatetime(selectedDate: string, selectedZone: ZoneOption) {
const dtComponents = DateTime.fromISO(selectedDate, { zone: 'utc' });
// Determine the modern, DST-aware offset for the selected IANA zone
const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedZone.value, selectedDate);
// Construct the final ISO string with a fixed-offset zone.
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.
return DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
}
export function toIsoDate(selectedDate: string, selectedZone: ZoneOption) {
return toDatetime(selectedDate, selectedZone).toISO({ includeOffset: true })!;
}
export const calcNewDate = (timestamp: DateTime, selectedDuration: number, timezone?: string) => {
let newDateTime = timestamp.plus({ minutes: selectedDuration });
if (timezone) {
newDateTime = newDateTime.setZone(timezone);
}
return newDateTime.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
};