From 550cf8d39963f30da7273b884c6007ce9da9cdf0 Mon Sep 17 00:00:00 2001 From: Audionut Date: Wed, 22 Oct 2025 14:07:34 +1000 Subject: [PATCH] New: Option to specify timezone for formatting times in the UI --- frontend/src/Calendar/Agenda/AgendaEvent.tsx | 17 +- .../src/Calendar/Events/CalendarEvent.tsx | 14 +- .../Calendar/Events/CalendarEventGroup.tsx | 14 +- .../InteractiveSearchRow.tsx | 3 +- frontend/src/Settings/UI/UISettings.tsx | 13 ++ .../src/Utilities/Date/convertToTimezone.ts | 26 +++ frontend/src/Utilities/Date/formatDateTime.ts | 21 +- frontend/src/Utilities/Date/formatTime.ts | 9 +- .../src/Utilities/Date/getRelativeDate.ts | 18 +- .../src/Utilities/Date/timeZoneOptions.ts | 192 ++++++++++++++++++ frontend/src/typings/Settings/UiSettings.ts | 1 + package.json | 2 + .../Configuration/ConfigService.cs | 7 + .../Configuration/IConfigService.cs | 1 + src/Sonarr.Api.V3/Config/UiConfigResource.cs | 2 + yarn.lock | 16 +- 16 files changed, 321 insertions(+), 35 deletions(-) create mode 100644 frontend/src/Utilities/Date/convertToTimezone.ts create mode 100644 frontend/src/Utilities/Date/timeZoneOptions.ts diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx index f32b4b22b..0188be058 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.tsx +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -1,5 +1,4 @@ import classNames from 'classnames'; -import moment from 'moment'; import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider'; @@ -15,6 +14,7 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import { icons, kinds } from 'Helpers/Props'; import useSeries from 'Series/useSeries'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { convertToTimezone } from 'Utilities/Date/convertToTimezone'; import formatTime from 'Utilities/Date/formatTime'; import padNumber from 'Utilities/Number/padNumber'; import translate from 'Utilities/String/translate'; @@ -58,9 +58,8 @@ function AgendaEvent(props: AgendaEventProps) { const series = useSeries(seriesId)!; const episodeFile = useEpisodeFile(episodeFileId); const queueItem = useQueueItemForEpisode(id); - const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector( - createUISettingsSelector() - ); + const { timeFormat, longDateFormat, enableColorImpairedMode, timeZone } = + useSelector(createUISettingsSelector()); const { showEpisodeInformation, @@ -71,8 +70,11 @@ function AgendaEvent(props: AgendaEventProps) { const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); - const startTime = moment(airDateUtc); - const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const startTime = convertToTimezone(airDateUtc, timeZone); + const endTime = convertToTimezone(airDateUtc, timeZone).add( + series.runtime, + 'minutes' + ); const downloading = !!(queueItem || grabbed); const isMonitored = series.monitored && monitored; const statusStyle = getStatusStyle( @@ -110,9 +112,10 @@ function AgendaEvent(props: AgendaEventProps) { )} >
- {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(airDateUtc, timeFormat, { timeZone })} -{' '} {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true, + timeZone, })}
diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx index 7c92a5e8c..64ed3d153 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.tsx +++ b/frontend/src/Calendar/Events/CalendarEvent.tsx @@ -1,5 +1,4 @@ import classNames from 'classnames'; -import moment from 'moment'; import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider'; @@ -14,6 +13,7 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import { icons, kinds } from 'Helpers/Props'; import useSeries from 'Series/useSeries'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { convertToTimezone } from 'Utilities/Date/convertToTimezone'; import formatTime from 'Utilities/Date/formatTime'; import padNumber from 'Utilities/Number/padNumber'; import translate from 'Utilities/String/translate'; @@ -60,7 +60,7 @@ function CalendarEvent(props: CalendarEventProps) { const episodeFile = useEpisodeFile(episodeFileId); const queueItem = useQueueItemForEpisode(id); - const { timeFormat, enableColorImpairedMode } = useSelector( + const { timeFormat, enableColorImpairedMode, timeZone } = useSelector( createUISettingsSelector() ); @@ -88,8 +88,11 @@ function CalendarEvent(props: CalendarEventProps) { return null; } - const startTime = moment(airDateUtc); - const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const startTime = convertToTimezone(airDateUtc, timeZone); + const endTime = convertToTimezone(airDateUtc, timeZone).add( + series.runtime, + 'minutes' + ); const isDownloading = !!(queueItem || grabbed); const isMonitored = series.monitored && monitored; const statusStyle = getStatusStyle( @@ -217,9 +220,10 @@ function CalendarEvent(props: CalendarEventProps) { ) : null}
- {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(airDateUtc, timeFormat, { timeZone })} -{' '} {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true, + timeZone, })}
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.tsx b/frontend/src/Calendar/Events/CalendarEventGroup.tsx index ea2aa0567..1458011c1 100644 --- a/frontend/src/Calendar/Events/CalendarEventGroup.tsx +++ b/frontend/src/Calendar/Events/CalendarEventGroup.tsx @@ -1,5 +1,4 @@ import classNames from 'classnames'; -import moment from 'moment'; import React, { useCallback, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider'; @@ -12,6 +11,7 @@ import { icons, kinds } from 'Helpers/Props'; import useSeries from 'Series/useSeries'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import { CalendarItem } from 'typings/Calendar'; +import { convertToTimezone } from 'Utilities/Date/convertToTimezone'; import formatTime from 'Utilities/Date/formatTime'; import padNumber from 'Utilities/Number/padNumber'; import translate from 'Utilities/String/translate'; @@ -34,7 +34,7 @@ function CalendarEventGroup({ const isDownloading = useIsDownloadingEpisodes(episodeIds); const series = useSeries(seriesId)!; - const { timeFormat, enableColorImpairedMode } = useSelector( + const { timeFormat, enableColorImpairedMode, timeZone } = useSelector( createUISettingsSelector() ); @@ -46,8 +46,11 @@ function CalendarEventGroup({ const firstEpisode = events[0]; const lastEpisode = events[events.length - 1]; const airDateUtc = firstEpisode.airDateUtc; - const startTime = moment(airDateUtc); - const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); + const startTime = convertToTimezone(airDateUtc, timeZone); + const endTime = convertToTimezone(lastEpisode.airDateUtc, timeZone).add( + series.runtime, + 'minutes' + ); const seasonNumber = firstEpisode.seasonNumber; const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } = @@ -194,9 +197,10 @@ function CalendarEventGroup({
- {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(airDateUtc, timeFormat, { timeZone })} -{' '} {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true, + timeZone, })}
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx index cf325c59d..7a83cee68 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx @@ -116,7 +116,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { onGrabPress, } = props; - const { longDateFormat, timeFormat } = useSelector( + const { longDateFormat, timeFormat, timeZone } = useSelector( createUISettingsSelector() ); @@ -174,6 +174,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { className={styles.age} title={formatDateTime(publishDate, longDateFormat, timeFormat, { includeSeconds: true, + timeZone, })} > {formatAge(age, ageHours, ageMinutes)} diff --git a/frontend/src/Settings/UI/UISettings.tsx b/frontend/src/Settings/UI/UISettings.tsx index 65d829c06..c5cb3d149 100644 --- a/frontend/src/Settings/UI/UISettings.tsx +++ b/frontend/src/Settings/UI/UISettings.tsx @@ -21,6 +21,7 @@ import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector'; import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; import themes from 'Styles/Themes'; import { InputChanged } from 'typings/inputs'; +import timeZoneOptions from 'Utilities/Date/timeZoneOptions'; import titleCase from 'Utilities/String/titleCase'; import translate from 'Utilities/String/translate'; @@ -218,6 +219,18 @@ function UISettings() { /> + + {translate('TimeZone')} + + + + {translate('ShowRelativeDates')} { + if (!date) { + return moment(); + } + + if (!timeZone) { + return moment(date); + } + + try { + return moment.tz(date, timeZone); + } catch (error) { + console.warn( + `Error converting to timezone ${timeZone}. Using system timezone.`, + error + ); + return moment(date); + } +}; + +export default convertToTimezone; diff --git a/frontend/src/Utilities/Date/formatDateTime.ts b/frontend/src/Utilities/Date/formatDateTime.ts index 048296ba1..678f80538 100644 --- a/frontend/src/Utilities/Date/formatDateTime.ts +++ b/frontend/src/Utilities/Date/formatDateTime.ts @@ -1,11 +1,15 @@ -import moment, { MomentInput } from 'moment'; +import moment from 'moment-timezone'; import translate from 'Utilities/String/translate'; +import { convertToTimezone } from './convertToTimezone'; import formatTime from './formatTime'; import isToday from './isToday'; import isTomorrow from './isTomorrow'; import isYesterday from './isYesterday'; -function getRelativeDay(date: MomentInput, includeRelativeDate: boolean) { +function getRelativeDay( + date: moment.MomentInput, + includeRelativeDate: boolean +) { if (!includeRelativeDate) { return ''; } @@ -26,20 +30,23 @@ function getRelativeDay(date: MomentInput, includeRelativeDate: boolean) { } function formatDateTime( - date: MomentInput, + date: moment.MomentInput, dateFormat: string, timeFormat: string, - { includeSeconds = false, includeRelativeDay = false } = {} + { includeSeconds = false, includeRelativeDay = false, timeZone = '' } = {} ) { if (!date) { return ''; } - const relativeDay = getRelativeDay(date, includeRelativeDay); - const formattedDate = moment(date).format(dateFormat); - const formattedTime = formatTime(date, timeFormat, { + const dateTime = convertToTimezone(date, timeZone); + + const relativeDay = getRelativeDay(dateTime, includeRelativeDay); + const formattedDate = dateTime.format(dateFormat); + const formattedTime = formatTime(dateTime, timeFormat, { includeMinuteZero: true, includeSeconds, + timeZone, }); if (relativeDay) { diff --git a/frontend/src/Utilities/Date/formatTime.ts b/frontend/src/Utilities/Date/formatTime.ts index f65bf28e4..8ba9c7d46 100644 --- a/frontend/src/Utilities/Date/formatTime.ts +++ b/frontend/src/Utilities/Date/formatTime.ts @@ -1,15 +1,16 @@ -import moment, { MomentInput } from 'moment'; +import moment from 'moment-timezone'; +import { convertToTimezone } from './convertToTimezone'; function formatTime( - date: MomentInput, + date: moment.MomentInput, timeFormat: string, - { includeMinuteZero = false, includeSeconds = false } = {} + { includeMinuteZero = false, includeSeconds = false, timeZone = '' } = {} ) { if (!date) { return ''; } - const time = moment(date); + const time = convertToTimezone(date, timeZone); if (includeSeconds) { timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss'); diff --git a/frontend/src/Utilities/Date/getRelativeDate.ts b/frontend/src/Utilities/Date/getRelativeDate.ts index 5a7218ecc..1a3d56096 100644 --- a/frontend/src/Utilities/Date/getRelativeDate.ts +++ b/frontend/src/Utilities/Date/getRelativeDate.ts @@ -1,10 +1,10 @@ -import moment from 'moment'; import formatTime from 'Utilities/Date/formatTime'; import isInNextWeek from 'Utilities/Date/isInNextWeek'; import isToday from 'Utilities/Date/isToday'; import isTomorrow from 'Utilities/Date/isTomorrow'; import isYesterday from 'Utilities/Date/isYesterday'; import translate from 'Utilities/String/translate'; +import { convertToTimezone } from './convertToTimezone'; import formatDateTime from './formatDateTime'; interface GetRelativeDateOptions { @@ -12,6 +12,7 @@ interface GetRelativeDateOptions { shortDateFormat: string; showRelativeDates: boolean; timeFormat?: string; + timeZone?: string; includeSeconds?: boolean; timeForToday?: boolean; includeTime?: boolean; @@ -22,6 +23,7 @@ function getRelativeDate({ shortDateFormat, showRelativeDates, timeFormat, + timeZone = '', includeSeconds = false, timeForToday = false, includeTime = false, @@ -41,6 +43,7 @@ function getRelativeDate({ ? formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds, + timeZone, }) : ''; @@ -49,7 +52,8 @@ function getRelativeDate({ } if (!showRelativeDates) { - return moment(date).format(shortDateFormat); + const dateTime = convertToTimezone(date, timeZone); + return dateTime.format(shortDateFormat); } if (isYesterday(date)) { @@ -69,14 +73,18 @@ function getRelativeDate({ } if (isInNextWeek(date)) { - const day = moment(date).format('dddd'); + const dateTime = convertToTimezone(date, timeZone); + const day = dateTime.format('dddd'); return includeTime ? translate('DayOfWeekAt', { day, time }) : day; } return includeTime && timeFormat - ? formatDateTime(date, shortDateFormat, timeFormat, { includeSeconds }) - : moment(date).format(shortDateFormat); + ? formatDateTime(date, shortDateFormat, timeFormat, { + includeSeconds, + timeZone, + }) + : convertToTimezone(date, timeZone).format(shortDateFormat); } export default getRelativeDate; diff --git a/frontend/src/Utilities/Date/timeZoneOptions.ts b/frontend/src/Utilities/Date/timeZoneOptions.ts new file mode 100644 index 000000000..0af94c6c6 --- /dev/null +++ b/frontend/src/Utilities/Date/timeZoneOptions.ts @@ -0,0 +1,192 @@ +import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput'; +import translate from 'Utilities/String/translate'; + +export const timeZoneOptions: EnhancedSelectInputValue[] = [ + { + key: '', + get value() { + return translate('SystemDefault'); + }, + }, + + // UTC + { key: 'UTC', value: 'UTC' }, + + // Africa (Major cities and unique timezones) + { key: 'Africa/Abidjan', value: 'Africa/Abidjan' }, + { key: 'Africa/Algiers', value: 'Africa/Algiers' }, + { key: 'Africa/Cairo', value: 'Africa/Cairo' }, + { key: 'Africa/Casablanca', value: 'Africa/Casablanca' }, + { key: 'Africa/Johannesburg', value: 'Africa/Johannesburg' }, + { key: 'Africa/Lagos', value: 'Africa/Lagos' }, + { key: 'Africa/Nairobi', value: 'Africa/Nairobi' }, + { key: 'Africa/Tripoli', value: 'Africa/Tripoli' }, + + // America - North America (Major US/Canada zones) + { key: 'America/New_York', value: 'America/New_York (Eastern)' }, + { key: 'America/Chicago', value: 'America/Chicago (Central)' }, + { key: 'America/Denver', value: 'America/Denver (Mountain)' }, + { key: 'America/Los_Angeles', value: 'America/Los_Angeles (Pacific)' }, + { key: 'America/Anchorage', value: 'America/Anchorage (Alaska)' }, + { key: 'America/Adak', value: 'America/Adak (Hawaii-Aleutian)' }, + { key: 'America/Phoenix', value: 'America/Phoenix (Arizona)' }, + { key: 'America/Toronto', value: 'America/Toronto' }, + { key: 'America/Vancouver', value: 'America/Vancouver' }, + { key: 'America/Halifax', value: 'America/Halifax' }, + { key: 'America/St_Johns', value: 'America/St_Johns (Newfoundland)' }, + + // America - Mexico + { key: 'America/Mexico_City', value: 'America/Mexico_City' }, + { key: 'America/Cancun', value: 'America/Cancun' }, + { key: 'America/Tijuana', value: 'America/Tijuana' }, + + // America - Central America + { key: 'America/Guatemala', value: 'America/Guatemala' }, + { key: 'America/Costa_Rica', value: 'America/Costa_Rica' }, + { key: 'America/Panama', value: 'America/Panama' }, + + // America - Caribbean + { key: 'America/Havana', value: 'America/Havana' }, + { key: 'America/Jamaica', value: 'America/Jamaica' }, + { key: 'America/Puerto_Rico', value: 'America/Puerto_Rico' }, + + // America - South America + { key: 'America/Bogota', value: 'America/Bogota' }, + { key: 'America/Caracas', value: 'America/Caracas' }, + { key: 'America/Guyana', value: 'America/Guyana' }, + { key: 'America/La_Paz', value: 'America/La_Paz' }, + { key: 'America/Lima', value: 'America/Lima' }, + { key: 'America/Santiago', value: 'America/Santiago' }, + { key: 'America/Asuncion', value: 'America/Asuncion' }, + { key: 'America/Montevideo', value: 'America/Montevideo' }, + { + key: 'America/Argentina/Buenos_Aires', + value: 'America/Argentina/Buenos_Aires', + }, + { key: 'America/Sao_Paulo', value: 'America/Sao_Paulo' }, + { key: 'America/Manaus', value: 'America/Manaus' }, + { key: 'America/Fortaleza', value: 'America/Fortaleza' }, + { key: 'America/Noronha', value: 'America/Noronha' }, + + // Antarctica (Research stations) + { key: 'Antarctica/McMurdo', value: 'Antarctica/McMurdo' }, + { key: 'Antarctica/Palmer', value: 'Antarctica/Palmer' }, + + // Arctic + { key: 'Arctic/Longyearbyen', value: 'Arctic/Longyearbyen' }, + + // Asia - East Asia + { key: 'Asia/Tokyo', value: 'Asia/Tokyo' }, + { key: 'Asia/Seoul', value: 'Asia/Seoul' }, + { key: 'Asia/Shanghai', value: 'Asia/Shanghai' }, + { key: 'Asia/Hong_Kong', value: 'Asia/Hong_Kong' }, + { key: 'Asia/Taipei', value: 'Asia/Taipei' }, + { key: 'Asia/Macau', value: 'Asia/Macau' }, + + // Asia - Southeast Asia + { key: 'Asia/Singapore', value: 'Asia/Singapore' }, + { key: 'Asia/Kuala_Lumpur', value: 'Asia/Kuala_Lumpur' }, + { key: 'Asia/Jakarta', value: 'Asia/Jakarta' }, + { key: 'Asia/Manila', value: 'Asia/Manila' }, + { key: 'Asia/Bangkok', value: 'Asia/Bangkok' }, + { key: 'Asia/Ho_Chi_Minh', value: 'Asia/Ho_Chi_Minh' }, + + // Asia - South Asia + { key: 'Asia/Kolkata', value: 'Asia/Kolkata' }, + { key: 'Asia/Dhaka', value: 'Asia/Dhaka' }, + { key: 'Asia/Karachi', value: 'Asia/Karachi' }, + { key: 'Asia/Kathmandu', value: 'Asia/Kathmandu' }, + { key: 'Asia/Colombo', value: 'Asia/Colombo' }, + + // Asia - Central Asia + { key: 'Asia/Almaty', value: 'Asia/Almaty' }, + { key: 'Asia/Tashkent', value: 'Asia/Tashkent' }, + { key: 'Asia/Bishkek', value: 'Asia/Bishkek' }, + { key: 'Asia/Dushanbe', value: 'Asia/Dushanbe' }, + + // Asia - Western Asia/Middle East + { key: 'Asia/Dubai', value: 'Asia/Dubai' }, + { key: 'Asia/Riyadh', value: 'Asia/Riyadh' }, + { key: 'Asia/Kuwait', value: 'Asia/Kuwait' }, + { key: 'Asia/Qatar', value: 'Asia/Qatar' }, + { key: 'Asia/Bahrain', value: 'Asia/Bahrain' }, + { key: 'Asia/Jerusalem', value: 'Asia/Jerusalem' }, + { key: 'Asia/Beirut', value: 'Asia/Beirut' }, + { key: 'Asia/Damascus', value: 'Asia/Damascus' }, + { key: 'Asia/Baghdad', value: 'Asia/Baghdad' }, + { key: 'Asia/Tehran', value: 'Asia/Tehran' }, + + // Asia - Russia + { key: 'Europe/Moscow', value: 'Europe/Moscow' }, + { key: 'Asia/Yekaterinburg', value: 'Asia/Yekaterinburg' }, + { key: 'Asia/Novosibirsk', value: 'Asia/Novosibirsk' }, + { key: 'Asia/Krasnoyarsk', value: 'Asia/Krasnoyarsk' }, + { key: 'Asia/Irkutsk', value: 'Asia/Irkutsk' }, + { key: 'Asia/Yakutsk', value: 'Asia/Yakutsk' }, + { key: 'Asia/Vladivostok', value: 'Asia/Vladivostok' }, + { key: 'Asia/Sakhalin', value: 'Asia/Sakhalin' }, + { key: 'Asia/Kamchatka', value: 'Asia/Kamchatka' }, + + // Atlantic + { key: 'Atlantic/Azores', value: 'Atlantic/Azores' }, + { key: 'Atlantic/Canary', value: 'Atlantic/Canary' }, + { key: 'Atlantic/Cape_Verde', value: 'Atlantic/Cape_Verde' }, + { key: 'Atlantic/Reykjavik', value: 'Atlantic/Reykjavik' }, + + // Australia & New Zealand + { key: 'Australia/Sydney', value: 'Australia/Sydney' }, + { key: 'Australia/Melbourne', value: 'Australia/Melbourne' }, + { key: 'Australia/Brisbane', value: 'Australia/Brisbane' }, + { key: 'Australia/Perth', value: 'Australia/Perth' }, + { key: 'Australia/Adelaide', value: 'Australia/Adelaide' }, + { key: 'Australia/Darwin', value: 'Australia/Darwin' }, + { key: 'Australia/Hobart', value: 'Australia/Hobart' }, + { key: 'Pacific/Auckland', value: 'Pacific/Auckland' }, + { key: 'Pacific/Chatham', value: 'Pacific/Chatham' }, + + // Europe - Western Europe + { key: 'Europe/London', value: 'Europe/London' }, + { key: 'Europe/Dublin', value: 'Europe/Dublin' }, + { key: 'Europe/Paris', value: 'Europe/Paris' }, + { key: 'Europe/Berlin', value: 'Europe/Berlin' }, + { key: 'Europe/Amsterdam', value: 'Europe/Amsterdam' }, + { key: 'Europe/Brussels', value: 'Europe/Brussels' }, + { key: 'Europe/Zurich', value: 'Europe/Zurich' }, + { key: 'Europe/Vienna', value: 'Europe/Vienna' }, + { key: 'Europe/Rome', value: 'Europe/Rome' }, + { key: 'Europe/Madrid', value: 'Europe/Madrid' }, + { key: 'Europe/Lisbon', value: 'Europe/Lisbon' }, + + // Europe - Northern Europe + { key: 'Europe/Stockholm', value: 'Europe/Stockholm' }, + { key: 'Europe/Oslo', value: 'Europe/Oslo' }, + { key: 'Europe/Copenhagen', value: 'Europe/Copenhagen' }, + { key: 'Europe/Helsinki', value: 'Europe/Helsinki' }, + + // Europe - Eastern Europe + { key: 'Europe/Warsaw', value: 'Europe/Warsaw' }, + { key: 'Europe/Prague', value: 'Europe/Prague' }, + { key: 'Europe/Budapest', value: 'Europe/Budapest' }, + { key: 'Europe/Bucharest', value: 'Europe/Bucharest' }, + { key: 'Europe/Sofia', value: 'Europe/Sofia' }, + { key: 'Europe/Athens', value: 'Europe/Athens' }, + { key: 'Europe/Istanbul', value: 'Europe/Istanbul' }, + { key: 'Europe/Kiev', value: 'Europe/Kiev' }, + { key: 'Europe/Minsk', value: 'Europe/Minsk' }, + + // Indian Ocean + { key: 'Indian/Mauritius', value: 'Indian/Mauritius' }, + { key: 'Indian/Maldives', value: 'Indian/Maldives' }, + + // Pacific - Major Island Nations + { key: 'Pacific/Honolulu', value: 'Pacific/Honolulu' }, + { key: 'Pacific/Fiji', value: 'Pacific/Fiji' }, + { key: 'Pacific/Guam', value: 'Pacific/Guam' }, + { key: 'Pacific/Tahiti', value: 'Pacific/Tahiti' }, + { key: 'Pacific/Apia', value: 'Pacific/Apia' }, + { key: 'Pacific/Tongatapu', value: 'Pacific/Tongatapu' }, + { key: 'Pacific/Port_Moresby', value: 'Pacific/Port_Moresby' }, + { key: 'Pacific/Noumea', value: 'Pacific/Noumea' }, +]; + +export default timeZoneOptions; diff --git a/frontend/src/typings/Settings/UiSettings.ts b/frontend/src/typings/Settings/UiSettings.ts index c0f892939..328465293 100644 --- a/frontend/src/typings/Settings/UiSettings.ts +++ b/frontend/src/typings/Settings/UiSettings.ts @@ -4,6 +4,7 @@ export default interface UiSettings { shortDateFormat: string; longDateFormat: string; timeFormat: string; + timeZone: string; firstDayOfWeek: number; enableColorImpairedMode: boolean; calendarWeekColumnHeader: string; diff --git a/package.json b/package.json index d6bfd6988..3755bcfc4 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "lodash": "4.17.21", "mobile-detect": "1.4.5", "moment": "2.30.1", + "moment-timezone": "0.6.0", "mousetrap": "1.6.5", "normalize.css": "8.0.1", "prop-types": "15.8.1", @@ -94,6 +95,7 @@ "@babel/preset-react": "7.27.1", "@babel/preset-typescript": "7.27.1", "@types/lodash": "4.14.195", + "@types/moment-timezone": "0.5.30", "@types/mousetrap": "1.6.15", "@types/qs": "6.9.16", "@types/react-autosuggest": "10.1.11", diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 8b24be03c..cf5a35529 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -343,6 +343,13 @@ namespace NzbDrone.Core.Configuration set { SetValue("TimeFormat", value); } } + public string TimeZone + { + get { return GetValue("TimeZone", ""); } + + set { SetValue("TimeZone", value); } + } + public bool ShowRelativeDates { get { return GetValueBoolean("ShowRelativeDates", true); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 2b5a32dfa..5ebb51b94 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -68,6 +68,7 @@ namespace NzbDrone.Core.Configuration string ShortDateFormat { get; set; } string LongDateFormat { get; set; } string TimeFormat { get; set; } + string TimeZone { get; set; } bool ShowRelativeDates { get; set; } bool EnableColorImpairedMode { get; set; } int UILanguage { get; set; } diff --git a/src/Sonarr.Api.V3/Config/UiConfigResource.cs b/src/Sonarr.Api.V3/Config/UiConfigResource.cs index 69e3fb318..ccdc02d15 100644 --- a/src/Sonarr.Api.V3/Config/UiConfigResource.cs +++ b/src/Sonarr.Api.V3/Config/UiConfigResource.cs @@ -13,6 +13,7 @@ namespace Sonarr.Api.V3.Config public string ShortDateFormat { get; set; } public string LongDateFormat { get; set; } public string TimeFormat { get; set; } + public string TimeZone { get; set; } public bool ShowRelativeDates { get; set; } public bool EnableColorImpairedMode { get; set; } @@ -32,6 +33,7 @@ namespace Sonarr.Api.V3.Config ShortDateFormat = model.ShortDateFormat, LongDateFormat = model.LongDateFormat, TimeFormat = model.TimeFormat, + TimeZone = model.TimeZone, ShowRelativeDates = model.ShowRelativeDates, EnableColorImpairedMode = model.EnableColorImpairedMode, diff --git a/yarn.lock b/yarn.lock index 02deec1dc..f25a18e31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1395,6 +1395,13 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== +"@types/moment-timezone@0.5.30": + version "0.5.30" + resolved "https://registry.yarnpkg.com/@types/moment-timezone/-/moment-timezone-0.5.30.tgz#340ed45fe3e715f4a011f5cfceb7cb52aad46fc7" + integrity sha512-aDVfCsjYnAQaV/E9Qc24C5Njx1CoDjXsEgkxtp9NyXDpYu4CCbmclb6QhWloS9UTU/8YROUEEdEkWI0D7DxnKg== + dependencies: + moment-timezone "*" + "@types/mousetrap@1.6.15": version "1.6.15" resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.15.tgz#f144a0c539a4cef553a631824651d48267e53c86" @@ -4751,7 +4758,14 @@ mobile-detect@1.4.5: resolved "https://registry.yarnpkg.com/mobile-detect/-/mobile-detect-1.4.5.tgz#da393c3c413ca1a9bcdd9ced653c38281c0fb6ad" integrity sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g== -moment@2.30.1: +moment-timezone@*, moment-timezone@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.6.0.tgz#c5a6519171f31a64739ea75d33f5c136c08ff608" + integrity sha512-ldA5lRNm3iJCWZcBCab4pnNL3HSZYXVb/3TYr75/1WCTWYuTqYUb5f/S384pncYjJ88lbO8Z4uPDvmoluHJc8Q== + dependencies: + moment "^2.29.4" + +moment@2.30.1, moment@^2.29.4: version "2.30.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==