164 lines
6.0 KiB
Svelte

<script lang="ts">
import { DateTime } from 'luxon';
import ConfirmDialog from './dialog/confirm-dialog.svelte';
import Combobox, { type ComboBoxOption } from './combobox.svelte';
import DateInput from '../elements/date-input.svelte';
import { t } from 'svelte-i18n';
interface Props {
initialDate?: DateTime;
initialTimeZone?: string;
onCancel: () => void;
onConfirm: (date: string) => void;
}
let { initialDate = DateTime.now(), initialTimeZone = '', onCancel, onConfirm }: Props = $props();
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 knownTimezones = Intl.supportedValuesOf('timeZone');
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm"));
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 selectedOption: ZoneOption | undefined = $state(getPreferredTimeZone(initialDate, userTimeZone, timezones));
function zoneOptionForDate(zone: string, date: string) {
const dateAtZone: DateTime = DateTime.fromISO(date, { zone });
const zoneOffsetAtDate = dateAtZone.toFormat('ZZ');
const valid = dateAtZone.isValid && date.toString() === dateAtZone.toFormat("yyyy-MM-dd'T'HH:mm");
return {
value: zone,
offsetMinutes: dateAtZone.offset,
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(
date: DateTime,
userTimeZone: string,
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;
}
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 = () => {
const value = date.toISO();
if (value) {
onConfirm(value);
}
};
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 }));
</script>
<ConfirmDialog
confirmColor="primary"
title={$t('edit_date_and_time')}
prompt="Please select a new date:"
disabled={!date.isValid}
onConfirm={handleConfirm}
{onCancel}
>
<!-- @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()}
<div class="flex flex-col text-left gap-2">
<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>
<div>
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => handleOnSelect(option)}
/>
</div>
</div>
{/snippet}
</ConfirmDialog>