2023-11-30 04:52:28 +01:00
|
|
|
<script lang="ts">
|
2025-05-12 18:02:49 -04:00
|
|
|
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
2025-07-09 12:12:16 +10:00
|
|
|
import { mdiCalendarEditOutline } from '@mdi/js';
|
2025-06-08 23:58:52 -03:00
|
|
|
import { DateTime, Duration } from 'luxon';
|
2024-06-04 21:53:00 +02:00
|
|
|
import { t } from 'svelte-i18n';
|
2025-05-02 19:34:53 +02:00
|
|
|
import DateInput from '../elements/date-input.svelte';
|
|
|
|
|
import Combobox, { type ComboBoxOption } from './combobox.svelte';
|
2024-02-22 15:36:14 +01:00
|
|
|
|
2024-11-14 08:43:25 -06:00
|
|
|
interface Props {
|
2025-05-28 09:55:14 -04:00
|
|
|
title?: string;
|
2024-11-14 08:43:25 -06:00
|
|
|
initialDate?: DateTime;
|
|
|
|
|
initialTimeZone?: string;
|
2025-05-28 09:55:14 -04:00
|
|
|
timezoneInput?: boolean;
|
2024-11-14 08:43:25 -06:00
|
|
|
onCancel: () => void;
|
|
|
|
|
onConfirm: (date: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-28 09:55:14 -04:00
|
|
|
let {
|
|
|
|
|
initialDate = DateTime.now(),
|
|
|
|
|
initialTimeZone = '',
|
|
|
|
|
title = $t('edit_date_and_time'),
|
|
|
|
|
timezoneInput = true,
|
|
|
|
|
onCancel,
|
|
|
|
|
onConfirm,
|
|
|
|
|
}: Props = $props();
|
2023-11-30 04:52:28 +01:00
|
|
|
|
2024-01-27 11:36:40 -07:00
|
|
|
type ZoneOption = {
|
|
|
|
|
/**
|
2024-09-04 16:47:40 +02:00
|
|
|
* Timezone name with offset
|
2024-01-27 11:36:40 -07:00
|
|
|
*
|
2024-07-01 06:41:47 +03:00
|
|
|
* e.g. Asia/Jerusalem (+03:00)
|
2024-01-27 11:36:40 -07:00
|
|
|
*/
|
|
|
|
|
label: string;
|
|
|
|
|
|
|
|
|
|
/**
|
2024-09-04 16:47:40 +02:00
|
|
|
* Timezone name
|
2024-01-27 11:36:40 -07:00
|
|
|
*
|
2024-09-04 16:47:40 +02:00
|
|
|
* e.g. Asia/Jerusalem
|
2024-01-27 11:36:40 -07:00
|
|
|
*/
|
|
|
|
|
value: string;
|
2024-09-04 16:47:40 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Timezone offset in minutes
|
|
|
|
|
*
|
|
|
|
|
* e.g. 300
|
|
|
|
|
*/
|
|
|
|
|
offsetMinutes: number;
|
2023-11-30 04:52:28 +01:00
|
|
|
|
2024-09-05 16:12:22 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2023-11-30 04:52:28 +01:00
|
|
|
};
|
|
|
|
|
|
2024-09-05 16:12:22 +02:00
|
|
|
const knownTimezones = Intl.supportedValuesOf('timeZone');
|
|
|
|
|
|
2024-11-14 08:43:25 -06:00
|
|
|
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
|
|
|
|
|
|
|
|
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm"));
|
2025-06-08 23:58:52 -03:00
|
|
|
// 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.
|
2024-11-14 08:43:25 -06:00
|
|
|
let timezones: ZoneOption[] = knownTimezones
|
2024-09-05 16:12:22 +02:00
|
|
|
.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
|
2024-11-14 08:43:25 -06:00
|
|
|
let selectedOption: ZoneOption | undefined = $state(getPreferredTimeZone(initialDate, userTimeZone, timezones));
|
2023-11-30 04:52:28 +01:00
|
|
|
|
2024-09-05 16:12:22 +02:00
|
|
|
function zoneOptionForDate(zone: string, date: string) {
|
2025-06-08 23:58:52 -03:00
|
|
|
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");
|
2024-09-05 16:12:22 +02:00
|
|
|
return {
|
|
|
|
|
value: zone,
|
2025-06-08 23:58:52 -03:00
|
|
|
offsetMinutes,
|
2024-09-05 16:12:22 +02:00
|
|
|
label: zone + ' (' + zoneOffsetAtDate + ')' + (valid ? '' : ' [invalid date!]'),
|
|
|
|
|
valid,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
2024-09-09 21:26:21 +02:00
|
|
|
* If the time zone is not given, find the timezone to select for a given time, date, and offset (e.g. +02:00).
|
2024-09-05 16:12:22 +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(
|
|
|
|
|
date: DateTime,
|
|
|
|
|
userTimeZone: string,
|
|
|
|
|
timezones: ZoneOption[],
|
|
|
|
|
selectedOption?: ZoneOption,
|
|
|
|
|
) {
|
|
|
|
|
const offset = date.offset;
|
|
|
|
|
const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
|
2024-09-09 21:26:21 +02:00
|
|
|
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
|
2024-09-05 16:12:22 +02:00
|
|
|
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,
|
|
|
|
|
};
|
2024-09-09 21:26:21 +02:00
|
|
|
return previousSelection ?? fromInitialTimeZone ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback;
|
2024-09-05 16:12:22 +02:00
|
|
|
}
|
|
|
|
|
|
2025-06-08 23:58:52 -03:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-05 16:12:22 +02:00
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-30 04:52:28 +01:00
|
|
|
const handleConfirm = () => {
|
2025-06-08 23:58:52 -03:00
|
|
|
if (date.isValid && selectedOption) {
|
|
|
|
|
// 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(selectedOption.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(finalDateTime.toISO({ includeOffset: true })!);
|
2023-11-30 04:52:28 +01:00
|
|
|
}
|
|
|
|
|
};
|
2024-11-14 08:43:25 -06:00
|
|
|
|
|
|
|
|
const handleOnSelect = (option?: ComboBoxOption) => {
|
|
|
|
|
if (option) {
|
|
|
|
|
selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, option as ZoneOption);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// 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: selectedOption?.value, setZone: true }));
|
2023-11-30 04:52:28 +01:00
|
|
|
</script>
|
|
|
|
|
|
2025-05-12 18:02:49 -04:00
|
|
|
<ConfirmModal
|
2024-04-30 06:59:32 +09:00
|
|
|
confirmColor="primary"
|
2025-05-28 09:55:14 -04:00
|
|
|
{title}
|
2025-07-09 12:12:16 +10:00
|
|
|
icon={mdiCalendarEditOutline}
|
2024-04-30 06:59:32 +09:00
|
|
|
prompt="Please select a new date:"
|
|
|
|
|
disabled={!date.isValid}
|
2025-05-02 19:34:53 +02:00
|
|
|
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
2024-04-30 06:59:32 +09:00
|
|
|
>
|
2024-11-14 08:43:25 -06:00
|
|
|
<!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component -->
|
|
|
|
|
<!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component -->
|
|
|
|
|
{#snippet promptSnippet()}
|
2025-04-28 09:53:53 -04:00
|
|
|
<div class="flex flex-col text-start gap-2">
|
2024-11-14 08:43:25 -06:00
|
|
|
<div class="flex flex-col">
|
|
|
|
|
<label for="datetime">{$t('date_and_time')}</label>
|
|
|
|
|
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
|
|
|
|
|
</div>
|
2025-05-28 09:55:14 -04:00
|
|
|
{#if timezoneInput}
|
|
|
|
|
<div>
|
|
|
|
|
<Combobox
|
|
|
|
|
bind:selectedOption
|
|
|
|
|
label={$t('timezone')}
|
|
|
|
|
options={timezones}
|
|
|
|
|
placeholder={$t('search_timezone')}
|
|
|
|
|
onSelect={(option) => handleOnSelect(option)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
2023-11-30 04:52:28 +01:00
|
|
|
</div>
|
2024-11-14 08:43:25 -06:00
|
|
|
{/snippet}
|
2025-05-12 18:02:49 -04:00
|
|
|
</ConfirmModal>
|