mirror of https://github.com/immich-app/immich.git
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>pull/22656/merge
parent
d0eae97037
commit
2919ee4c65
@ -1,284 +0,0 @@
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import DurationInput from '$lib/elements/DurationInput.svelte';
|
||||
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 { DateTime, Duration } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import Combobox, { type ComboBoxOption } from './combobox.svelte';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
initialDate?: DateTime;
|
||||
initialTimeZone?: string;
|
||||
timezoneInput?: boolean;
|
||||
withDuration?: boolean;
|
||||
currentInterval?: { start: DateTime; end: DateTime };
|
||||
onCancel: () => void;
|
||||
onConfirm: (result: AbsoluteResult | RelativeResult) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
initialDate = DateTime.now(),
|
||||
initialTimeZone = '',
|
||||
title = $t('edit_date_and_time'),
|
||||
timezoneInput = true,
|
||||
withDuration = true,
|
||||
currentInterval = undefined,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: Props = $props();
|
||||
|
||||
export type AbsoluteResult = {
|
||||
mode: 'absolute';
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type RelativeResult = {
|
||||
mode: 'relative';
|
||||
duration?: number;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
let showRelative = $state(false);
|
||||
|
||||
let selectedDuration = $state(0);
|
||||
|
||||
const knownTimezones = Intl.supportedValuesOf('timeZone');
|
||||
|
||||
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
|
||||
// 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.
|
||||
let timezones: ZoneOption[] = knownTimezones
|
||||
.map((zone) => zoneOptionForDate(zone, selectedDate))
|
||||
.filter((zone) => zone.valid)
|
||||
.sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
|
||||
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
|
||||
let selectedAbsoluteOption: ZoneOption | undefined = $state(
|
||||
getPreferredTimeZone(userTimeZone, timezones, initialDate),
|
||||
);
|
||||
let selectedRelativeOption: ZoneOption | undefined = $state(undefined);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* 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".
|
||||
*/
|
||||
function getPreferredTimeZone(
|
||||
userTimeZone: string,
|
||||
timezones: ZoneOption[],
|
||||
date?: DateTime,
|
||||
selectedOption?: ZoneOption,
|
||||
) {
|
||||
const offset = date?.offset;
|
||||
const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
|
||||
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
|
||||
let sameAsUserTimeZone;
|
||||
let firstWithSameOffset;
|
||||
if (offset !== undefined) {
|
||||
sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone);
|
||||
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;
|
||||
}
|
||||
|
||||
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 sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) {
|
||||
let offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes;
|
||||
if (offsetDifference != 0) {
|
||||
return offsetDifference;
|
||||
}
|
||||
return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!showRelative && date.isValid && selectedAbsoluteOption) {
|
||||
// Get the local date/time components from the selected string using neutral timezone
|
||||
const dtComponents = DateTime.fromISO(selectedDate, { zone: 'utc' });
|
||||
|
||||
// Determine the modern, DST-aware offset for the selected IANA zone
|
||||
const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedAbsoluteOption.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.
|
||||
const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
|
||||
|
||||
onConfirm({ mode: 'absolute', date: finalDateTime.toISO({ includeOffset: true })! });
|
||||
}
|
||||
|
||||
if (showRelative && (selectedDuration || selectedRelativeOption)) {
|
||||
onConfirm({ mode: 'relative', duration: selectedDuration, timeZone: selectedRelativeOption?.value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnSelect = (option?: ComboBoxOption) => {
|
||||
if (showRelative) {
|
||||
selectedRelativeOption = option
|
||||
? getPreferredTimeZone(userTimeZone, timezones, undefined, option as ZoneOption)
|
||||
: undefined;
|
||||
} else {
|
||||
if (option) {
|
||||
selectedAbsoluteOption = getPreferredTimeZone(userTimeZone, timezones, initialDate, option as ZoneOption);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let selectedOption = $derived(showRelative ? selectedRelativeOption : selectedAbsoluteOption);
|
||||
|
||||
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
|
||||
let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedAbsoluteOption?.value, setZone: true }));
|
||||
|
||||
export function calcNewDate(timestamp: DateTime, selectedDuration: number, timezone?: string) {
|
||||
timestamp = timestamp.plus({ minutes: selectedDuration });
|
||||
if (timezone) {
|
||||
timestamp = timestamp.setZone(timezone);
|
||||
}
|
||||
return getDateTimeOffsetLocaleString(timestamp, { locale: get(locale) });
|
||||
}
|
||||
|
||||
let intervalFrom = $derived.by(() =>
|
||||
currentInterval ? calcNewDate(currentInterval.start, selectedDuration, selectedRelativeOption?.value) : undefined,
|
||||
);
|
||||
let intervalTo = $derived.by(() =>
|
||||
currentInterval ? calcNewDate(currentInterval.end, selectedDuration, selectedRelativeOption?.value) : undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<ConfirmModal
|
||||
confirmColor="primary"
|
||||
{title}
|
||||
icon={mdiCalendarEditOutline}
|
||||
prompt="Please select a new date:"
|
||||
disabled={!date.isValid}
|
||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||
>
|
||||
{#snippet promptSnippet()}
|
||||
{#if withDuration}
|
||||
<div class="mb-5">
|
||||
<Field label={$t('edit_date_and_time_by_offset')}>
|
||||
<Switch data-testid="edit-by-offset-switch" bind:checked={showRelative} />
|
||||
</Field>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col text-start min-h-[140px]">
|
||||
<div>
|
||||
<div class="flex flex-col" style="display: {showRelative ? 'none' : 'flex'}">
|
||||
<label for="datetime">{$t('date_and_time')}</label>
|
||||
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
|
||||
</div>
|
||||
<div class="flex flex-col" style="display: {showRelative ? 'flex' : 'none'}">
|
||||
<div class="flex flex-col">
|
||||
<label for="relativedatetime">{$t('offset')}</label>
|
||||
<DurationInput class="immich-form-input" id="relativedatetime" bind:value={selectedDuration} />
|
||||
</div>
|
||||
</div>
|
||||
{#if timezoneInput}
|
||||
<div>
|
||||
<Combobox
|
||||
bind:selectedOption
|
||||
label={$t('timezone')}
|
||||
options={timezones}
|
||||
placeholder={$t('search_timezone')}
|
||||
onSelect={(option) => handleOnSelect(option)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col" style="display: {showRelative && currentInterval ? 'flex' : 'none'}">
|
||||
<span data-testid="interval-preview"
|
||||
>{$t('edit_date_and_time_by_offset_interval', { values: { from: intervalFrom, to: intervalTo } })}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import { findClosestGroupForDate } from './search-support.svelte';
|
||||
|
||||
function createMockMonthGroup(year: number, month: number): MonthGroup {
|
||||
return {
|
||||
yearMonth: { year, month },
|
||||
} as MonthGroup;
|
||||
}
|
||||
|
||||
describe('findClosestGroupForDate', () => {
|
||||
it('should return undefined for empty months array', () => {
|
||||
const result = findClosestGroupForDate([], { year: 2024, month: 1 });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the only month when there is only one month', () => {
|
||||
const months = [createMockMonthGroup(2024, 6)];
|
||||
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should return exact match when available', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 6 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should find closest month when target is between two months', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 4 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle year boundaries correctly (2023-12 vs 2024-01)', () => {
|
||||
const months = [createMockMonthGroup(2023, 12), createMockMonthGroup(2024, 2)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
|
||||
// 2024-01 is 1 month from 2023-12 and 1 month from 2024-02
|
||||
// Should return first encountered with min distance (2023-12)
|
||||
expect(result?.yearMonth).toEqual({ year: 2023, month: 12 });
|
||||
});
|
||||
|
||||
it('should correctly calculate distance across years', () => {
|
||||
const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 6)];
|
||||
const result = findClosestGroupForDate(months, { year: 2023, month: 6 });
|
||||
// Both are exactly 12 months away, should return first encountered
|
||||
expect(result?.yearMonth).toEqual({ year: 2022, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle target before all months', () => {
|
||||
const months = [createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle target after all months', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6)];
|
||||
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle multiple years correctly', () => {
|
||||
const months = [createMockMonthGroup(2020, 1), createMockMonthGroup(2022, 1), createMockMonthGroup(2024, 1)];
|
||||
const result = findClosestGroupForDate(months, { year: 2023, month: 1 });
|
||||
// 2023-01 is 12 months from 2022-01 and 12 months from 2024-01
|
||||
expect(result?.yearMonth).toEqual({ year: 2022, month: 1 });
|
||||
});
|
||||
|
||||
it('should prefer closer month when one is clearly closer', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 10)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 11 });
|
||||
// 2024-11 is 1 month from 2024-10 and 10 months from 2024-01
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 10 });
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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");
|
||||
};
|
||||
Loading…
Reference in New Issue