mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 18:22:37 -04:00 
			
		
		
		
	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:
		
							parent
							
								
									d0eae97037
								
							
						
					
					
						commit
						2919ee4c65
					
				| @ -1364,6 +1364,8 @@ | ||||
|   "my_albums": "My albums", | ||||
|   "name": "Name", | ||||
|   "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_videos_upload": "Use cellular data to backup videos", | ||||
|   "network_requirements": "Network Requirements", | ||||
| @ -1373,6 +1375,7 @@ | ||||
|   "never": "Never", | ||||
|   "new_album": "New Album", | ||||
|   "new_api_key": "New API Key", | ||||
|   "new_date_range": "New date range", | ||||
|   "new_password": "New password", | ||||
|   "new_person": "New person", | ||||
|   "new_pin_code": "New PIN code", | ||||
|  | ||||
| @ -5,12 +5,9 @@ | ||||
|   import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte'; | ||||
|   import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte'; | ||||
|   import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte'; | ||||
|   import ChangeDate, { | ||||
|     type AbsoluteResult, | ||||
|     type RelativeResult, | ||||
|   } from '$lib/components/shared-components/change-date.svelte'; | ||||
|   import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants'; | ||||
|   import { authManager } from '$lib/managers/auth-manager.svelte'; | ||||
|   import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte'; | ||||
|   import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; | ||||
|   import { boundingBoxesArray } from '$lib/stores/people.store'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
| @ -19,12 +16,11 @@ | ||||
|   import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils'; | ||||
|   import { delay, getDimensions } from '$lib/utils/asset-utils'; | ||||
|   import { getByteUnitString } from '$lib/utils/byte-units'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; | ||||
|   import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util'; | ||||
|   import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { getParentPath } from '$lib/utils/tree-utils'; | ||||
|   import { AssetMediaSize, getAssetInfo, updateAsset, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; | ||||
|   import { Icon, IconButton, LoadingSpinner } from '@immich/ui'; | ||||
|   import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; | ||||
|   import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui'; | ||||
|   import { | ||||
|     mdiCalendar, | ||||
|     mdiCameraIris, | ||||
| @ -59,7 +55,7 @@ | ||||
|   let people = $derived(asset.people || []); | ||||
|   let unassignedFaces = $derived(asset.unassignedFaces || []); | ||||
|   let showingHiddenPeople = $state(false); | ||||
|   let timeZone = $derived(asset.exifInfo?.timeZone); | ||||
|   let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined); | ||||
|   let dateTime = $derived( | ||||
|     timeZone && asset.exifInfo?.dateTimeOriginal | ||||
|       ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) | ||||
| @ -112,18 +108,13 @@ | ||||
| 
 | ||||
|   const toggleAssetPath = () => (showAssetPath = !showAssetPath); | ||||
| 
 | ||||
|   let isShowChangeDate = $state(false); | ||||
| 
 | ||||
|   async function handleConfirmChangeDate(result: AbsoluteResult | RelativeResult) { | ||||
|     isShowChangeDate = false; | ||||
|     try { | ||||
|       if (result.mode === 'absolute') { | ||||
|         await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal: result.date } }); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.unable_to_change_date')); | ||||
|   const handleChangeDate = async () => { | ||||
|     if (!isOwner) { | ||||
|       return; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|     await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime }); | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <section class="relative p-2"> | ||||
| @ -280,7 +271,7 @@ | ||||
|       <button | ||||
|         type="button" | ||||
|         class="flex w-full text-start justify-between place-items-start gap-4 py-4" | ||||
|         onclick={() => (isOwner ? (isShowChangeDate = true) : null)} | ||||
|         onclick={handleChangeDate} | ||||
|         title={isOwner ? $t('edit_date') : ''} | ||||
|         class:hover:text-primary={isOwner} | ||||
|       > | ||||
| @ -336,16 +327,6 @@ | ||||
|       </div> | ||||
|     {/if} | ||||
| 
 | ||||
|     {#if isShowChangeDate} | ||||
|       <ChangeDate | ||||
|         initialDate={dateTime} | ||||
|         initialTimeZone={timeZone ?? ''} | ||||
|         withDuration={false} | ||||
|         onConfirm={handleConfirmChangeDate} | ||||
|         onCancel={() => (isShowChangeDate = false)} | ||||
|       /> | ||||
|     {/if} | ||||
| 
 | ||||
|     <div class="flex gap-4 py-4"> | ||||
|       <div><Icon icon={mdiImageOutline} size="24" /></div> | ||||
| 
 | ||||
|  | ||||
| @ -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> | ||||
| @ -1,18 +1,11 @@ | ||||
| <script lang="ts"> | ||||
|   import ChangeDate, { | ||||
|     type AbsoluteResult, | ||||
|     type RelativeResult, | ||||
|   } from '$lib/components/shared-components/change-date.svelte'; | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; | ||||
|   import { user } from '$lib/stores/user.store'; | ||||
|   import { getSelectedAssets } from '$lib/utils/asset-utils'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util.js'; | ||||
|   import { updateAssets } from '@immich/sdk'; | ||||
|   import AssetSelectionChangeDateModal from '$lib/modals/AssetSelectionChangeDateModal.svelte'; | ||||
|   import { modalManager } from '@immich/ui'; | ||||
|   import { mdiCalendarEditOutline } from '@mdi/js'; | ||||
|   import { DateTime, Duration } from 'luxon'; | ||||
|   import { DateTime } from 'luxon'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; | ||||
|   interface Props { | ||||
|     menuItem?: boolean; | ||||
|   } | ||||
| @ -20,66 +13,17 @@ | ||||
|   let { menuItem = false }: Props = $props(); | ||||
|   const { clearSelect, getOwnedAssets } = getAssetControlContext(); | ||||
| 
 | ||||
|   let isShowChangeDate = $state(false); | ||||
| 
 | ||||
|   let currentInterval = $derived.by(() => { | ||||
|     if (isShowChangeDate) { | ||||
|       const ids = getSelectedAssets(getOwnedAssets(), $user); | ||||
|       const assets = getOwnedAssets().filter((asset) => ids.includes(asset.id)); | ||||
|       const imageTimestamps = assets.map((asset) => { | ||||
|         let localDateTime = fromTimelinePlainDateTime(asset.localDateTime); | ||||
|         let fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt); | ||||
|         let offsetMinutes = localDateTime.diff(fileCreatedAt, 'minutes').shiftTo('minutes').minutes; | ||||
|         const timeZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`; | ||||
|         return fileCreatedAt.setZone('utc', { keepLocalTime: true }).setZone(timeZone); | ||||
|       }); | ||||
|       let minTimestamp = imageTimestamps[0]; | ||||
|       let maxTimestamp = imageTimestamps[0]; | ||||
|       for (let current of imageTimestamps) { | ||||
|         if (current < minTimestamp) { | ||||
|           minTimestamp = current; | ||||
|         } | ||||
| 
 | ||||
|         if (current > maxTimestamp) { | ||||
|           maxTimestamp = current; | ||||
|         } | ||||
|       } | ||||
|       return { start: minTimestamp, end: maxTimestamp }; | ||||
|   const handleChangeDate = async () => { | ||||
|     const success = await modalManager.show(AssetSelectionChangeDateModal, { | ||||
|       initialDate: DateTime.now(), | ||||
|       assets: getOwnedAssets(), | ||||
|     }); | ||||
|     if (success) { | ||||
|       clearSelect(); | ||||
|     } | ||||
|     return undefined; | ||||
|   }); | ||||
| 
 | ||||
|   const handleConfirm = async (result: AbsoluteResult | RelativeResult) => { | ||||
|     isShowChangeDate = false; | ||||
|     const ids = getSelectedAssets(getOwnedAssets(), $user); | ||||
| 
 | ||||
|     try { | ||||
|       if (result.mode === 'absolute') { | ||||
|         await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal: result.date } }); | ||||
|       } else if (result.mode === 'relative') { | ||||
|         await updateAssets({ | ||||
|           assetBulkUpdateDto: { | ||||
|             ids, | ||||
|             dateTimeRelative: result.duration, | ||||
|             timeZone: result.timeZone, | ||||
|           }, | ||||
|         }); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.unable_to_change_date')); | ||||
|     } | ||||
|     clearSelect(); | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| {#if menuItem} | ||||
|   <MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} /> | ||||
| {/if} | ||||
| {#if isShowChangeDate} | ||||
|   <ChangeDate | ||||
|     initialDate={DateTime.now()} | ||||
|     {currentInterval} | ||||
|     onConfirm={handleConfirm} | ||||
|     onCancel={() => (isShowChangeDate = false)} | ||||
|   /> | ||||
|   <MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={handleChangeDate} /> | ||||
| {/if} | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|   import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; | ||||
|   import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte'; | ||||
|   import { user } from '$lib/stores/user.store'; | ||||
|   import { getSelectedAssets } from '$lib/utils/asset-utils'; | ||||
|   import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { updateAssets } from '@immich/sdk'; | ||||
|   import { modalManager } from '@immich/ui'; | ||||
| @ -20,7 +20,7 @@ | ||||
|   const handleUpdateDescription = async () => { | ||||
|     const description = await modalManager.show(AssetUpdateDescriptionConfirmModal); | ||||
|     if (description) { | ||||
|       const ids = getSelectedAssets(getOwnedAssets(), $user); | ||||
|       const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user); | ||||
| 
 | ||||
|       try { | ||||
|         await updateAssets({ assetBulkUpdateDto: { ids, description } }); | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|   import ChangeLocation from '$lib/components/shared-components/change-location.svelte'; | ||||
|   import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; | ||||
|   import { user } from '$lib/stores/user.store'; | ||||
|   import { getSelectedAssets } from '$lib/utils/asset-utils'; | ||||
|   import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { updateAssets } from '@immich/sdk'; | ||||
|   import { mdiMapMarkerMultipleOutline } from '@mdi/js'; | ||||
| @ -25,7 +25,7 @@ | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const ids = getSelectedAssets(getOwnedAssets(), $user); | ||||
|     const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user); | ||||
| 
 | ||||
|     try { | ||||
|       await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } }); | ||||
|  | ||||
| @ -2,10 +2,6 @@ | ||||
|   import { goto } from '$app/navigation'; | ||||
|   import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; | ||||
|   import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte'; | ||||
|   import ChangeDate, { | ||||
|     type AbsoluteResult, | ||||
|     type RelativeResult, | ||||
|   } from '$lib/components/shared-components/change-date.svelte'; | ||||
|   import { | ||||
|     setFocusToAsset as setFocusAssetInit, | ||||
|     setFocusTo as setFocusToInit, | ||||
| @ -13,6 +9,7 @@ | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; | ||||
|   import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; | ||||
|   import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte'; | ||||
|   import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; | ||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
| @ -24,8 +21,6 @@ | ||||
|   import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; | ||||
|   import { AssetVisibility } from '@immich/sdk'; | ||||
|   import { modalManager } from '@immich/ui'; | ||||
|   import { DateTime } from 'luxon'; | ||||
|   let { isViewing: showAssetViewer } = assetViewingStore; | ||||
| 
 | ||||
|   interface Props { | ||||
|     timelineManager: TimelineManager; | ||||
| @ -43,7 +38,7 @@ | ||||
|     scrollToAsset, | ||||
|   }: Props = $props(); | ||||
| 
 | ||||
|   let isShowSelectDate = $state(false); | ||||
|   const { isViewing: showAssetViewer } = assetViewingStore; | ||||
| 
 | ||||
|   const trashOrDelete = async (force: boolean = false) => { | ||||
|     isShowDeleteConfirmation = false; | ||||
| @ -150,6 +145,13 @@ | ||||
|   const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager); | ||||
|   const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset); | ||||
| 
 | ||||
|   const handleOpenDateModal = async () => { | ||||
|     const asset = await modalManager.show(NavigateToDateModal, { timelineManager }); | ||||
|     if (asset) { | ||||
|       setFocusAsset(asset); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   let shortcutList = $derived( | ||||
|     (() => { | ||||
|       if (searchStore.isSearchEnabled || $showAssetViewer) { | ||||
| @ -168,7 +170,7 @@ | ||||
|         { shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') }, | ||||
|         { shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') }, | ||||
|         { shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') }, | ||||
|         { shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) }, | ||||
|         { shortcut: { key: 'G' }, onShortcut: handleOpenDateModal }, | ||||
|       ]; | ||||
|       if (onEscape) { | ||||
|         shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape }); | ||||
| @ -198,24 +200,3 @@ | ||||
|     onConfirm={() => handlePromiseError(trashOrDelete(true))} | ||||
|   /> | ||||
| {/if} | ||||
| 
 | ||||
| {#if isShowSelectDate} | ||||
|   <ChangeDate | ||||
|     withDuration={false} | ||||
|     title="Navigate to Time" | ||||
|     initialDate={DateTime.now()} | ||||
|     timezoneInput={false} | ||||
|     onConfirm={async (dateString: AbsoluteResult | RelativeResult) => { | ||||
|       isShowSelectDate = false; | ||||
|       if (dateString.mode == 'absolute') { | ||||
|         const asset = await timelineManager.getClosestAssetToDate( | ||||
|           (DateTime.fromISO(dateString.date) as DateTime<true>).toObject(), | ||||
|         ); | ||||
|         if (asset) { | ||||
|           setFocusAsset(asset); | ||||
|         } | ||||
|       } | ||||
|     }} | ||||
|     onCancel={() => (isShowSelectDate = false)} | ||||
|   /> | ||||
| {/if} | ||||
|  | ||||
| @ -1,5 +1,7 @@ | ||||
| <script lang="ts"> | ||||
|   interface Props { | ||||
|   import type { HTMLInputAttributes } from 'svelte/elements'; | ||||
| 
 | ||||
|   interface Props extends HTMLInputAttributes { | ||||
|     type: 'date' | 'datetime-local'; | ||||
|     value?: string; | ||||
|     min?: string; | ||||
|  | ||||
| @ -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 }); | ||||
|   }); | ||||
| }); | ||||
| @ -1,5 +1,6 @@ | ||||
| import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; | ||||
| import { AssetOrder } from '@immich/sdk'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import type { MonthGroup } from '../month-group.svelte'; | ||||
| import type { TimelineManager } from '../timeline-manager.svelte'; | ||||
| import type { AssetDescriptor, Direction, TimelineAsset } from '../types'; | ||||
| @ -143,3 +144,22 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function findClosestGroupForDate(months: MonthGroup[], targetYearMonth: TimelineYearMonth) { | ||||
|   const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month }); | ||||
| 
 | ||||
|   let closestMonth: MonthGroup | undefined; | ||||
|   let minDifference = Number.MAX_SAFE_INTEGER; | ||||
| 
 | ||||
|   for (const month of months) { | ||||
|     const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month }); | ||||
|     const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months); | ||||
| 
 | ||||
|     if (totalDiff < minDifference) { | ||||
|       minDifference = totalDiff; | ||||
|       closestMonth = month; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return closestMonth; | ||||
| } | ||||
|  | ||||
| @ -16,6 +16,7 @@ import { | ||||
|   runAssetOperation, | ||||
| } from '$lib/managers/timeline-manager/internal/operations-support.svelte'; | ||||
| import { | ||||
|   findClosestGroupForDate, | ||||
|   findMonthGroupForAsset as findMonthGroupForAssetUtil, | ||||
|   findMonthGroupForDate, | ||||
|   getAssetWithOffset, | ||||
| @ -584,9 +585,13 @@ export class TimelineManager { | ||||
|   } | ||||
| 
 | ||||
|   async getClosestAssetToDate(dateTime: TimelineDateTime) { | ||||
|     const monthGroup = findMonthGroupForDate(this, dateTime); | ||||
|     let monthGroup = findMonthGroupForDate(this, dateTime); | ||||
|     if (!monthGroup) { | ||||
|       return; | ||||
|       // if exact match not found, find closest
 | ||||
|       monthGroup = findClosestGroupForDate(this.months, dateTime); | ||||
|       if (!monthGroup) { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|     await this.loadMonthGroup(dateTime, { cancelable: false }); | ||||
|     const asset = monthGroup.findClosest(dateTime); | ||||
|  | ||||
							
								
								
									
										84
									
								
								web/src/lib/modals/AssetChangeDateModal.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								web/src/lib/modals/AssetChangeDateModal.svelte
									
									
									
									
									
										Normal 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> | ||||
| @ -1,33 +1,30 @@ | ||||
| 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 ChangeDate from './change-date.svelte'; | ||||
| import AssetSelectionChangeDateModal from './AssetSelectionChangeDateModal.svelte'; | ||||
| 
 | ||||
| describe('ChangeDate component', () => { | ||||
| describe('DateSelectionModal component', () => { | ||||
|   const initialDate = DateTime.fromISO('2024-01-01'); | ||||
|   const initialTimeZone = 'Europe/Berlin'; | ||||
|   const currentInterval = { | ||||
|     start: DateTime.fromISO('2000-02-01T14:00:00+01:00'), | ||||
|     end: DateTime.fromISO('2001-02-01T14:00:00+01:00'), | ||||
|   }; | ||||
|   const onCancel = vi.fn(); | ||||
|   const onConfirm = vi.fn(); | ||||
| 
 | ||||
|   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'); | ||||
|   const getCancelButton = () => screen.getByText('cancel'); | ||||
|   const getConfirmButton = () => screen.getByText('confirm'); | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); | ||||
|     vi.stubGlobal('visualViewport', getVisualViewportMock()); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     vi.resetAllMocks(); | ||||
|     Element.prototype.animate = getAnimateMock(); | ||||
|   }); | ||||
| 
 | ||||
|   afterAll(async () => { | ||||
| @ -38,54 +35,75 @@ describe('ChangeDate component', () => { | ||||
|   }); | ||||
| 
 | ||||
|   test('should render correct values', () => { | ||||
|     render(ChangeDate, { initialDate, initialTimeZone, onCancel, onConfirm }); | ||||
|     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(ChangeDate, { | ||||
|       props: { initialDate, initialTimeZone, onCancel, onConfirm }, | ||||
|     render(AssetSelectionChangeDateModal, { | ||||
|       props: { initialDate, initialTimeZone, assets: [], onClose }, | ||||
|     }); | ||||
| 
 | ||||
|     await fireEvent.click(getConfirmButton()); | ||||
| 
 | ||||
|     expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-01-01T00:00:00.000+01:00' }); | ||||
|     expect(sdkMock.updateAssets).toHaveBeenCalledWith({ | ||||
|       assetBulkUpdateDto: { | ||||
|         ids: [], | ||||
|         dateTimeOriginal: '2024-01-01T00:00:00.000+01:00', | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   test('calls onCancel on cancel', async () => { | ||||
|     render(ChangeDate, { | ||||
|       props: { initialDate, initialTimeZone, onCancel, onConfirm }, | ||||
|     render(AssetSelectionChangeDateModal, { | ||||
|       props: { initialDate, initialTimeZone, assets: [], onClose }, | ||||
|     }); | ||||
| 
 | ||||
|     await fireEvent.click(getCancelButton()); | ||||
| 
 | ||||
|     expect(onCancel).toHaveBeenCalled(); | ||||
|     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(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm }); | ||||
|       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(ChangeDate, { | ||||
|         props: { initialDate: dstDate, initialTimeZone, onCancel, onConfirm }, | ||||
|       render(AssetSelectionChangeDateModal, { | ||||
|         props: { initialDate: dstDate, initialTimeZone, assets: [], onClose }, | ||||
|       }); | ||||
| 
 | ||||
|       await fireEvent.click(getConfirmButton()); | ||||
| 
 | ||||
|       expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-07-01T00:00:00.000+02:00' }); | ||||
|       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(ChangeDate, { | ||||
|       props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm }, | ||||
|     render(AssetSelectionChangeDateModal, { | ||||
|       props: { initialDate, initialTimeZone, assets: [], onClose }, | ||||
|     }); | ||||
| 
 | ||||
|     await fireEvent.click(getRelativeInputToggle()); | ||||
| @ -104,17 +122,19 @@ describe('ChangeDate component', () => { | ||||
| 
 | ||||
|     await fireEvent.click(getConfirmButton()); | ||||
| 
 | ||||
|     expect(onConfirm).toHaveBeenCalledWith({ | ||||
|       mode: 'relative', | ||||
|       duration: days * 60 * 24 + hours * 60 + minutes, | ||||
|       timeZone: undefined, | ||||
|     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(ChangeDate, { | ||||
|       props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm }, | ||||
|     render(AssetSelectionChangeDateModal, { | ||||
|       props: { initialDate, initialTimeZone, assets: [], onClose }, | ||||
|     }); | ||||
| 
 | ||||
|     await user.click(getRelativeInputToggle()); | ||||
| @ -123,10 +143,13 @@ describe('ChangeDate component', () => { | ||||
|     await user.keyboard('{Enter}'); | ||||
| 
 | ||||
|     await user.click(getConfirmButton()); | ||||
|     expect(onConfirm).toHaveBeenCalledWith({ | ||||
|       mode: 'relative', | ||||
|       duration: 0, | ||||
|       timeZone: initialTimeZone, | ||||
| 
 | ||||
|     expect(sdkMock.updateAssets).toHaveBeenCalledWith({ | ||||
|       assetBulkUpdateDto: { | ||||
|         ids: [], | ||||
|         dateTimeRelative: 0, | ||||
|         timeZone: 'Europe/Berlin', | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
| @ -136,55 +159,50 @@ describe('ChangeDate component', () => { | ||||
|         timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }), | ||||
|         duration: 0, | ||||
|         timezone: undefined, | ||||
|         expectedResult: 'Jan 1, 2024, 12:00 AM GMT+01:00', | ||||
|         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: 'Jan 1, 2024, 4:00 AM GMT+05:00', | ||||
|         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: 'Jan 1, 2024, 1:00 AM GMT+01:00', | ||||
|         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: 'Jul 1, 2024, 2:00 AM GMT+02:00', | ||||
|         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: 'Jan 2, 2024, 12:00 AM GMT+01:00', | ||||
|         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: 'Dec 31, 2023, 12:00 AM GMT+01:00', | ||||
|         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: 'Dec 30, 2023, 4:00 PM GMT-09:00', | ||||
|         expectedResult: '2023-12-30T16:00:00.000', | ||||
|       }, | ||||
|     ]; | ||||
| 
 | ||||
|     const component = render(ChangeDate, { | ||||
|       props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm }, | ||||
|     }); | ||||
| 
 | ||||
|     for (const testCase of testCases) { | ||||
|       expect( | ||||
|         component.component.calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone), | ||||
|         JSON.stringify(testCase), | ||||
|       ).toBe(testCase.expectedResult); | ||||
|       expect(calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone), JSON.stringify(testCase)).toBe( | ||||
|         testCase.expectedResult, | ||||
|       ); | ||||
|     } | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										136
									
								
								web/src/lib/modals/AssetSelectionChangeDateModal.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								web/src/lib/modals/AssetSelectionChangeDateModal.svelte
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										61
									
								
								web/src/lib/modals/NavigateToDateModal.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								web/src/lib/modals/NavigateToDateModal.svelte
									
									
									
									
									
										Normal 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> | ||||
| @ -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') }, | ||||
|  | ||||
							
								
								
									
										149
									
								
								web/src/lib/modals/timezone-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								web/src/lib/modals/timezone-utils.ts
									
									
									
									
									
										Normal 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"); | ||||
| }; | ||||
| @ -405,7 +405,7 @@ export const getAssetType = (type: AssetTypeEnum) => { | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const getSelectedAssets = (assets: TimelineAsset[], user: UserResponseDto | null): string[] => { | ||||
| export const getOwnedAssetsWithWarning = (assets: TimelineAsset[], user: UserResponseDto | null): string[] => { | ||||
|   const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id); | ||||
| 
 | ||||
|   const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length; | ||||
|  | ||||
| @ -154,12 +154,6 @@ export function formatGroupTitle(_date: DateTime): string { | ||||
| export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => | ||||
|   date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); | ||||
| 
 | ||||
| export const getDateTimeOffsetLocaleString = (date: DateTime, opts?: LocaleOptions): string => | ||||
|   date.toLocaleString( | ||||
|     { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'longOffset' }, | ||||
|     opts, | ||||
|   ); | ||||
| 
 | ||||
| export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => { | ||||
|   if (isTimelineAsset(unknownAsset)) { | ||||
|     return unknownAsset; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user