mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -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", |   "my_albums": "My albums", | ||||||
|   "name": "Name", |   "name": "Name", | ||||||
|   "name_or_nickname": "Name or nickname", |   "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_photos_upload": "Use cellular data to backup photos", | ||||||
|   "network_requirement_videos_upload": "Use cellular data to backup videos", |   "network_requirement_videos_upload": "Use cellular data to backup videos", | ||||||
|   "network_requirements": "Network Requirements", |   "network_requirements": "Network Requirements", | ||||||
| @ -1373,6 +1375,7 @@ | |||||||
|   "never": "Never", |   "never": "Never", | ||||||
|   "new_album": "New Album", |   "new_album": "New Album", | ||||||
|   "new_api_key": "New API Key", |   "new_api_key": "New API Key", | ||||||
|  |   "new_date_range": "New date range", | ||||||
|   "new_password": "New password", |   "new_password": "New password", | ||||||
|   "new_person": "New person", |   "new_person": "New person", | ||||||
|   "new_pin_code": "New PIN code", |   "new_pin_code": "New PIN code", | ||||||
|  | |||||||
| @ -5,12 +5,9 @@ | |||||||
|   import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte'; |   import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte'; | ||||||
|   import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.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 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 { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants'; | ||||||
|   import { authManager } from '$lib/managers/auth-manager.svelte'; |   import { authManager } from '$lib/managers/auth-manager.svelte'; | ||||||
|  |   import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte'; | ||||||
|   import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; |   import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; | ||||||
|   import { boundingBoxesArray } from '$lib/stores/people.store'; |   import { boundingBoxesArray } from '$lib/stores/people.store'; | ||||||
|   import { locale } from '$lib/stores/preferences.store'; |   import { locale } from '$lib/stores/preferences.store'; | ||||||
| @ -19,12 +16,11 @@ | |||||||
|   import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils'; |   import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils'; | ||||||
|   import { delay, getDimensions } from '$lib/utils/asset-utils'; |   import { delay, getDimensions } from '$lib/utils/asset-utils'; | ||||||
|   import { getByteUnitString } from '$lib/utils/byte-units'; |   import { getByteUnitString } from '$lib/utils/byte-units'; | ||||||
|   import { handleError } from '$lib/utils/handle-error'; |  | ||||||
|   import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; |   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 { getParentPath } from '$lib/utils/tree-utils'; | ||||||
|   import { AssetMediaSize, getAssetInfo, updateAsset, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; |   import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; | ||||||
|   import { Icon, IconButton, LoadingSpinner } from '@immich/ui'; |   import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui'; | ||||||
|   import { |   import { | ||||||
|     mdiCalendar, |     mdiCalendar, | ||||||
|     mdiCameraIris, |     mdiCameraIris, | ||||||
| @ -59,7 +55,7 @@ | |||||||
|   let people = $derived(asset.people || []); |   let people = $derived(asset.people || []); | ||||||
|   let unassignedFaces = $derived(asset.unassignedFaces || []); |   let unassignedFaces = $derived(asset.unassignedFaces || []); | ||||||
|   let showingHiddenPeople = $state(false); |   let showingHiddenPeople = $state(false); | ||||||
|   let timeZone = $derived(asset.exifInfo?.timeZone); |   let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined); | ||||||
|   let dateTime = $derived( |   let dateTime = $derived( | ||||||
|     timeZone && asset.exifInfo?.dateTimeOriginal |     timeZone && asset.exifInfo?.dateTimeOriginal | ||||||
|       ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) |       ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) | ||||||
| @ -112,18 +108,13 @@ | |||||||
| 
 | 
 | ||||||
|   const toggleAssetPath = () => (showAssetPath = !showAssetPath); |   const toggleAssetPath = () => (showAssetPath = !showAssetPath); | ||||||
| 
 | 
 | ||||||
|   let isShowChangeDate = $state(false); |   const handleChangeDate = async () => { | ||||||
| 
 |     if (!isOwner) { | ||||||
|   async function handleConfirmChangeDate(result: AbsoluteResult | RelativeResult) { |       return; | ||||||
|     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')); |  | ||||||
|     } |     } | ||||||
|   } | 
 | ||||||
|  |     await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime }); | ||||||
|  |   }; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <section class="relative p-2"> | <section class="relative p-2"> | ||||||
| @ -280,7 +271,7 @@ | |||||||
|       <button |       <button | ||||||
|         type="button" |         type="button" | ||||||
|         class="flex w-full text-start justify-between place-items-start gap-4 py-4" |         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') : ''} |         title={isOwner ? $t('edit_date') : ''} | ||||||
|         class:hover:text-primary={isOwner} |         class:hover:text-primary={isOwner} | ||||||
|       > |       > | ||||||
| @ -336,16 +327,6 @@ | |||||||
|       </div> |       </div> | ||||||
|     {/if} |     {/if} | ||||||
| 
 | 
 | ||||||
|     {#if isShowChangeDate} |  | ||||||
|       <ChangeDate |  | ||||||
|         initialDate={dateTime} |  | ||||||
|         initialTimeZone={timeZone ?? ''} |  | ||||||
|         withDuration={false} |  | ||||||
|         onConfirm={handleConfirmChangeDate} |  | ||||||
|         onCancel={() => (isShowChangeDate = false)} |  | ||||||
|       /> |  | ||||||
|     {/if} |  | ||||||
| 
 |  | ||||||
|     <div class="flex gap-4 py-4"> |     <div class="flex gap-4 py-4"> | ||||||
|       <div><Icon icon={mdiImageOutline} size="24" /></div> |       <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"> | <script lang="ts"> | ||||||
|   import ChangeDate, { |   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||||
|     type AbsoluteResult, |  | ||||||
|     type RelativeResult, |  | ||||||
|   } from '$lib/components/shared-components/change-date.svelte'; |  | ||||||
|   import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; |   import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; | ||||||
|   import { user } from '$lib/stores/user.store'; |   import AssetSelectionChangeDateModal from '$lib/modals/AssetSelectionChangeDateModal.svelte'; | ||||||
|   import { getSelectedAssets } from '$lib/utils/asset-utils'; |   import { modalManager } from '@immich/ui'; | ||||||
|   import { handleError } from '$lib/utils/handle-error'; |  | ||||||
|   import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util.js'; |  | ||||||
|   import { updateAssets } from '@immich/sdk'; |  | ||||||
|   import { mdiCalendarEditOutline } from '@mdi/js'; |   import { mdiCalendarEditOutline } from '@mdi/js'; | ||||||
|   import { DateTime, Duration } from 'luxon'; |   import { DateTime } from 'luxon'; | ||||||
|   import { t } from 'svelte-i18n'; |   import { t } from 'svelte-i18n'; | ||||||
|   import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; |  | ||||||
|   interface Props { |   interface Props { | ||||||
|     menuItem?: boolean; |     menuItem?: boolean; | ||||||
|   } |   } | ||||||
| @ -20,66 +13,17 @@ | |||||||
|   let { menuItem = false }: Props = $props(); |   let { menuItem = false }: Props = $props(); | ||||||
|   const { clearSelect, getOwnedAssets } = getAssetControlContext(); |   const { clearSelect, getOwnedAssets } = getAssetControlContext(); | ||||||
| 
 | 
 | ||||||
|   let isShowChangeDate = $state(false); |   const handleChangeDate = async () => { | ||||||
| 
 |     const success = await modalManager.show(AssetSelectionChangeDateModal, { | ||||||
|   let currentInterval = $derived.by(() => { |       initialDate: DateTime.now(), | ||||||
|     if (isShowChangeDate) { |       assets: getOwnedAssets(), | ||||||
|       const ids = getSelectedAssets(getOwnedAssets(), $user); |     }); | ||||||
|       const assets = getOwnedAssets().filter((asset) => ids.includes(asset.id)); |     if (success) { | ||||||
|       const imageTimestamps = assets.map((asset) => { |       clearSelect(); | ||||||
|         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 }; |  | ||||||
|     } |     } | ||||||
|     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> | </script> | ||||||
| 
 | 
 | ||||||
| {#if menuItem} | {#if menuItem} | ||||||
|   <MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} /> |   <MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={handleChangeDate} /> | ||||||
| {/if} |  | ||||||
| {#if isShowChangeDate} |  | ||||||
|   <ChangeDate |  | ||||||
|     initialDate={DateTime.now()} |  | ||||||
|     {currentInterval} |  | ||||||
|     onConfirm={handleConfirm} |  | ||||||
|     onCancel={() => (isShowChangeDate = false)} |  | ||||||
|   /> |  | ||||||
| {/if} | {/if} | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|   import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; |   import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; | ||||||
|   import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte'; |   import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte'; | ||||||
|   import { user } from '$lib/stores/user.store'; |   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 { handleError } from '$lib/utils/handle-error'; | ||||||
|   import { updateAssets } from '@immich/sdk'; |   import { updateAssets } from '@immich/sdk'; | ||||||
|   import { modalManager } from '@immich/ui'; |   import { modalManager } from '@immich/ui'; | ||||||
| @ -20,7 +20,7 @@ | |||||||
|   const handleUpdateDescription = async () => { |   const handleUpdateDescription = async () => { | ||||||
|     const description = await modalManager.show(AssetUpdateDescriptionConfirmModal); |     const description = await modalManager.show(AssetUpdateDescriptionConfirmModal); | ||||||
|     if (description) { |     if (description) { | ||||||
|       const ids = getSelectedAssets(getOwnedAssets(), $user); |       const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user); | ||||||
| 
 | 
 | ||||||
|       try { |       try { | ||||||
|         await updateAssets({ assetBulkUpdateDto: { ids, description } }); |         await updateAssets({ assetBulkUpdateDto: { ids, description } }); | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|   import ChangeLocation from '$lib/components/shared-components/change-location.svelte'; |   import ChangeLocation from '$lib/components/shared-components/change-location.svelte'; | ||||||
|   import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; |   import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; | ||||||
|   import { user } from '$lib/stores/user.store'; |   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 { handleError } from '$lib/utils/handle-error'; | ||||||
|   import { updateAssets } from '@immich/sdk'; |   import { updateAssets } from '@immich/sdk'; | ||||||
|   import { mdiMapMarkerMultipleOutline } from '@mdi/js'; |   import { mdiMapMarkerMultipleOutline } from '@mdi/js'; | ||||||
| @ -25,7 +25,7 @@ | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const ids = getSelectedAssets(getOwnedAssets(), $user); |     const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user); | ||||||
| 
 | 
 | ||||||
|     try { |     try { | ||||||
|       await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } }); |       await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } }); | ||||||
|  | |||||||
| @ -2,10 +2,6 @@ | |||||||
|   import { goto } from '$app/navigation'; |   import { goto } from '$app/navigation'; | ||||||
|   import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; |   import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; | ||||||
|   import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte'; |   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 { |   import { | ||||||
|     setFocusToAsset as setFocusAssetInit, |     setFocusToAsset as setFocusAssetInit, | ||||||
|     setFocusTo as setFocusToInit, |     setFocusTo as setFocusToInit, | ||||||
| @ -13,6 +9,7 @@ | |||||||
|   import { AppRoute } from '$lib/constants'; |   import { AppRoute } from '$lib/constants'; | ||||||
|   import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; |   import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; | ||||||
|   import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; |   import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; | ||||||
|  |   import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte'; | ||||||
|   import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; |   import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; | ||||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; |   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; |   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||||
| @ -24,8 +21,6 @@ | |||||||
|   import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; |   import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; | ||||||
|   import { AssetVisibility } from '@immich/sdk'; |   import { AssetVisibility } from '@immich/sdk'; | ||||||
|   import { modalManager } from '@immich/ui'; |   import { modalManager } from '@immich/ui'; | ||||||
|   import { DateTime } from 'luxon'; |  | ||||||
|   let { isViewing: showAssetViewer } = assetViewingStore; |  | ||||||
| 
 | 
 | ||||||
|   interface Props { |   interface Props { | ||||||
|     timelineManager: TimelineManager; |     timelineManager: TimelineManager; | ||||||
| @ -43,7 +38,7 @@ | |||||||
|     scrollToAsset, |     scrollToAsset, | ||||||
|   }: Props = $props(); |   }: Props = $props(); | ||||||
| 
 | 
 | ||||||
|   let isShowSelectDate = $state(false); |   const { isViewing: showAssetViewer } = assetViewingStore; | ||||||
| 
 | 
 | ||||||
|   const trashOrDelete = async (force: boolean = false) => { |   const trashOrDelete = async (force: boolean = false) => { | ||||||
|     isShowDeleteConfirmation = false; |     isShowDeleteConfirmation = false; | ||||||
| @ -150,6 +145,13 @@ | |||||||
|   const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager); |   const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager); | ||||||
|   const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset); |   const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset); | ||||||
| 
 | 
 | ||||||
|  |   const handleOpenDateModal = async () => { | ||||||
|  |     const asset = await modalManager.show(NavigateToDateModal, { timelineManager }); | ||||||
|  |     if (asset) { | ||||||
|  |       setFocusAsset(asset); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|   let shortcutList = $derived( |   let shortcutList = $derived( | ||||||
|     (() => { |     (() => { | ||||||
|       if (searchStore.isSearchEnabled || $showAssetViewer) { |       if (searchStore.isSearchEnabled || $showAssetViewer) { | ||||||
| @ -168,7 +170,7 @@ | |||||||
|         { shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') }, |         { shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') }, | ||||||
|         { shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') }, |         { shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') }, | ||||||
|         { shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') }, |         { shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') }, | ||||||
|         { shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) }, |         { shortcut: { key: 'G' }, onShortcut: handleOpenDateModal }, | ||||||
|       ]; |       ]; | ||||||
|       if (onEscape) { |       if (onEscape) { | ||||||
|         shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape }); |         shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape }); | ||||||
| @ -198,24 +200,3 @@ | |||||||
|     onConfirm={() => handlePromiseError(trashOrDelete(true))} |     onConfirm={() => handlePromiseError(trashOrDelete(true))} | ||||||
|   /> |   /> | ||||||
| {/if} | {/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"> | <script lang="ts"> | ||||||
|   interface Props { |   import type { HTMLInputAttributes } from 'svelte/elements'; | ||||||
|  | 
 | ||||||
|  |   interface Props extends HTMLInputAttributes { | ||||||
|     type: 'date' | 'datetime-local'; |     type: 'date' | 'datetime-local'; | ||||||
|     value?: string; |     value?: string; | ||||||
|     min?: 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 { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; | ||||||
| import { AssetOrder } from '@immich/sdk'; | import { AssetOrder } from '@immich/sdk'; | ||||||
|  | import { DateTime } from 'luxon'; | ||||||
| import type { MonthGroup } from '../month-group.svelte'; | import type { MonthGroup } from '../month-group.svelte'; | ||||||
| import type { TimelineManager } from '../timeline-manager.svelte'; | import type { TimelineManager } from '../timeline-manager.svelte'; | ||||||
| import type { AssetDescriptor, Direction, TimelineAsset } from '../types'; | 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, |   runAssetOperation, | ||||||
| } from '$lib/managers/timeline-manager/internal/operations-support.svelte'; | } from '$lib/managers/timeline-manager/internal/operations-support.svelte'; | ||||||
| import { | import { | ||||||
|  |   findClosestGroupForDate, | ||||||
|   findMonthGroupForAsset as findMonthGroupForAssetUtil, |   findMonthGroupForAsset as findMonthGroupForAssetUtil, | ||||||
|   findMonthGroupForDate, |   findMonthGroupForDate, | ||||||
|   getAssetWithOffset, |   getAssetWithOffset, | ||||||
| @ -584,9 +585,13 @@ export class TimelineManager { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async getClosestAssetToDate(dateTime: TimelineDateTime) { |   async getClosestAssetToDate(dateTime: TimelineDateTime) { | ||||||
|     const monthGroup = findMonthGroupForDate(this, dateTime); |     let monthGroup = findMonthGroupForDate(this, dateTime); | ||||||
|     if (!monthGroup) { |     if (!monthGroup) { | ||||||
|       return; |       // if exact match not found, find closest
 | ||||||
|  |       monthGroup = findClosestGroupForDate(this.months, dateTime); | ||||||
|  |       if (!monthGroup) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|     await this.loadMonthGroup(dateTime, { cancelable: false }); |     await this.loadMonthGroup(dateTime, { cancelable: false }); | ||||||
|     const asset = monthGroup.findClosest(dateTime); |     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 { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock'; | ||||||
|  | import { sdkMock } from '$lib/__mocks__/sdk.mock'; | ||||||
| import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.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 { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; | ||||||
| import userEvent from '@testing-library/user-event'; | import userEvent from '@testing-library/user-event'; | ||||||
| import { DateTime } from 'luxon'; | 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 initialDate = DateTime.fromISO('2024-01-01'); | ||||||
|   const initialTimeZone = 'Europe/Berlin'; |   const initialTimeZone = 'Europe/Berlin'; | ||||||
|   const currentInterval = { | 
 | ||||||
|     start: DateTime.fromISO('2000-02-01T14:00:00+01:00'), |   const onClose = vi.fn(); | ||||||
|     end: DateTime.fromISO('2001-02-01T14:00:00+01:00'), |  | ||||||
|   }; |  | ||||||
|   const onCancel = vi.fn(); |  | ||||||
|   const onConfirm = vi.fn(); |  | ||||||
| 
 | 
 | ||||||
|   const getRelativeInputToggle = () => screen.getByTestId('edit-by-offset-switch'); |   const getRelativeInputToggle = () => screen.getByTestId('edit-by-offset-switch'); | ||||||
|   const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement; |   const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement; | ||||||
|   const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement; |   const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement; | ||||||
|   const getCancelButton = () => screen.getByText('Cancel'); |   const getCancelButton = () => screen.getByText('cancel'); | ||||||
|   const getConfirmButton = () => screen.getByText('Confirm'); |   const getConfirmButton = () => screen.getByText('confirm'); | ||||||
| 
 | 
 | ||||||
|   beforeEach(() => { |   beforeEach(() => { | ||||||
|     vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); |     vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); | ||||||
|     vi.stubGlobal('visualViewport', getVisualViewportMock()); |     vi.stubGlobal('visualViewport', getVisualViewportMock()); | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   afterEach(() => { |  | ||||||
|     vi.resetAllMocks(); |     vi.resetAllMocks(); | ||||||
|  |     Element.prototype.animate = getAnimateMock(); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   afterAll(async () => { |   afterAll(async () => { | ||||||
| @ -38,54 +35,75 @@ describe('ChangeDate component', () => { | |||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   test('should render correct values', () => { |   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(getDateInput().value).toBe('2024-01-01T00:00'); | ||||||
|     expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)'); |     expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)'); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   test('calls onConfirm with correct date on confirm', async () => { |   test('calls onConfirm with correct date on confirm', async () => { | ||||||
|     render(ChangeDate, { |     render(AssetSelectionChangeDateModal, { | ||||||
|       props: { initialDate, initialTimeZone, onCancel, onConfirm }, |       props: { initialDate, initialTimeZone, assets: [], onClose }, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     await fireEvent.click(getConfirmButton()); |     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 () => { |   test('calls onCancel on cancel', async () => { | ||||||
|     render(ChangeDate, { |     render(AssetSelectionChangeDateModal, { | ||||||
|       props: { initialDate, initialTimeZone, onCancel, onConfirm }, |       props: { initialDate, initialTimeZone, assets: [], onClose }, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     await fireEvent.click(getCancelButton()); |     await fireEvent.click(getCancelButton()); | ||||||
| 
 | 
 | ||||||
|     expect(onCancel).toHaveBeenCalled(); |     expect(onClose).toHaveBeenCalled(); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('when date is in daylight saving time', () => { |   describe('when date is in daylight saving time', () => { | ||||||
|     const dstDate = DateTime.fromISO('2024-07-01'); |     const dstDate = DateTime.fromISO('2024-07-01'); | ||||||
| 
 | 
 | ||||||
|     test('should render correct timezone with offset', () => { |     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)'); |       expect(getTimeZoneInput().value).toBe('Europe/Berlin (+02:00)'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     test('calls onConfirm with correct date on confirm', async () => { |     test('calls onConfirm with correct date on confirm', async () => { | ||||||
|       render(ChangeDate, { |       render(AssetSelectionChangeDateModal, { | ||||||
|         props: { initialDate: dstDate, initialTimeZone, onCancel, onConfirm }, |         props: { initialDate: dstDate, initialTimeZone, assets: [], onClose }, | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|       await fireEvent.click(getConfirmButton()); |       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 () => { |   test('calls onConfirm with correct offset in relative mode', async () => { | ||||||
|     render(ChangeDate, { |     render(AssetSelectionChangeDateModal, { | ||||||
|       props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm }, |       props: { initialDate, initialTimeZone, assets: [], onClose }, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     await fireEvent.click(getRelativeInputToggle()); |     await fireEvent.click(getRelativeInputToggle()); | ||||||
| @ -104,17 +122,19 @@ describe('ChangeDate component', () => { | |||||||
| 
 | 
 | ||||||
|     await fireEvent.click(getConfirmButton()); |     await fireEvent.click(getConfirmButton()); | ||||||
| 
 | 
 | ||||||
|     expect(onConfirm).toHaveBeenCalledWith({ |     expect(sdkMock.updateAssets).toHaveBeenCalledWith({ | ||||||
|       mode: 'relative', |       assetBulkUpdateDto: { | ||||||
|       duration: days * 60 * 24 + hours * 60 + minutes, |         ids: [], | ||||||
|       timeZone: undefined, |         dateTimeRelative: days * 60 * 24 + hours * 60 + minutes, | ||||||
|  |         timeZone: 'Europe/Berlin', | ||||||
|  |       }, | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   test('calls onConfirm with correct timeZone in relative mode', async () => { |   test('calls onConfirm with correct timeZone in relative mode', async () => { | ||||||
|     const user = userEvent.setup(); |     const user = userEvent.setup(); | ||||||
|     render(ChangeDate, { |     render(AssetSelectionChangeDateModal, { | ||||||
|       props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm }, |       props: { initialDate, initialTimeZone, assets: [], onClose }, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     await user.click(getRelativeInputToggle()); |     await user.click(getRelativeInputToggle()); | ||||||
| @ -123,10 +143,13 @@ describe('ChangeDate component', () => { | |||||||
|     await user.keyboard('{Enter}'); |     await user.keyboard('{Enter}'); | ||||||
| 
 | 
 | ||||||
|     await user.click(getConfirmButton()); |     await user.click(getConfirmButton()); | ||||||
|     expect(onConfirm).toHaveBeenCalledWith({ | 
 | ||||||
|       mode: 'relative', |     expect(sdkMock.updateAssets).toHaveBeenCalledWith({ | ||||||
|       duration: 0, |       assetBulkUpdateDto: { | ||||||
|       timeZone: initialTimeZone, |         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 }), |         timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }), | ||||||
|         duration: 0, |         duration: 0, | ||||||
|         timezone: undefined, |         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 }), |         timestamp: DateTime.fromISO('2024-01-01T04:00:00.000+05:00', { setZone: true }), | ||||||
|         duration: 0, |         duration: 0, | ||||||
|         timezone: undefined, |         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 }), |         timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+00:00', { setZone: true }), | ||||||
|         duration: 0, |         duration: 0, | ||||||
|         timezone: 'Europe/Berlin', |         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 }), |         timestamp: DateTime.fromISO('2024-07-01T00:00:00.000+00:00', { setZone: true }), | ||||||
|         duration: 0, |         duration: 0, | ||||||
|         timezone: 'Europe/Berlin', |         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 }), |         timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }), | ||||||
|         duration: 1440, |         duration: 1440, | ||||||
|         timezone: undefined, |         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 }), |         timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }), | ||||||
|         duration: -1440, |         duration: -1440, | ||||||
|         timezone: undefined, |         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 }), |         timestamp: DateTime.fromISO('2024-01-01T00:00:00.000-01:00', { setZone: true }), | ||||||
|         duration: -1440, |         duration: -1440, | ||||||
|         timezone: 'America/Anchorage', |         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) { |     for (const testCase of testCases) { | ||||||
|       expect( |       expect(calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone), JSON.stringify(testCase)).toBe( | ||||||
|         component.component.calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone), |         testCase.expectedResult, | ||||||
|         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: ['D', 'd'], action: $t('previous_or_next_day') }, | ||||||
|         { key: ['M', 'm'], action: $t('previous_or_next_month') }, |         { key: ['M', 'm'], action: $t('previous_or_next_month') }, | ||||||
|         { key: ['Y', 'y'], action: $t('previous_or_next_year') }, |         { key: ['Y', 'y'], action: $t('previous_or_next_year') }, | ||||||
|  |         { key: ['g'], action: $t('navigate_to_time') }, | ||||||
|         { key: ['x'], action: $t('select') }, |         { key: ['x'], action: $t('select') }, | ||||||
|         { key: ['Esc'], action: $t('back_close_deselect') }, |         { key: ['Esc'], action: $t('back_close_deselect') }, | ||||||
|         { key: ['Ctrl', 'k'], action: $t('search_your_photos') }, |         { 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 ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id); | ||||||
| 
 | 
 | ||||||
|   const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length; |   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 => | export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => | ||||||
|   date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); |   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 => { | export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => { | ||||||
|   if (isTimelineAsset(unknownAsset)) { |   if (isTimelineAsset(unknownAsset)) { | ||||||
|     return unknownAsset; |     return unknownAsset; | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user