diff --git a/apps/studio/components/interfaces/Account/Preferences/TimezoneSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/TimezoneSettings.tsx new file mode 100644 index 00000000000..24ebb78d7ea --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/TimezoneSettings.tsx @@ -0,0 +1,166 @@ +import { useFlag } from 'common' +import { CheckIcon, ChevronsUpDown, Globe } from 'lucide-react' +import { useId, useMemo, useState } from 'react' +import { + Button, + Card, + CardContent, + cn, + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + ScrollArea, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' + +import { findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones' +import { useTimezone } from '@/lib/datetime' +import { guessLocalTimezone } from '@/lib/dayjs' +import { useTrack } from '@/lib/telemetry/track' + +const AUTO_OPTION_VALUE = '__auto__' + +export const TimezoneSettings = () => { + const timezonePickerEnabled = useFlag('timezonePicker') + const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone() + const track = useTrack() + const [open, setOpen] = useState(false) + const listboxId = useId() + + // Browser timezone is captured once and stays stable even when the user has + // overridden the dashboard timezone — that's the value the "Auto detect" + // option will revert to. + const browserTimezone = useMemo(() => guessLocalTimezone(), []) + + const triggerLabel = useMemo(() => findTimezoneByIana(timezone)?.text ?? timezone, [timezone]) + + if (!timezonePickerEnabled) return null + + const handleSelect = (nextStored: string) => { + setTimezone(nextStored) + const resolvedNext = nextStored || guessLocalTimezone() + track('timezone_picker_clicked', { + previousTimezone: timezone, + nextTimezone: resolvedNext, + isAutoDetected: nextStored === '', + source: 'account_preferences', + }) + setOpen(false) + } + + return ( + + + + Timezone + + Choose how dates and times in logs and other dashboard surfaces are displayed. + + + + + + + + + + + + + + + + No timezones found + + + handleSelect('')} + > +
+ Auto detect + + {browserTimezone} + +
+ +
+ {TIMEZONES_BY_IANA.map((entry) => { + const ianaName = entry.utc[0] + const isSelected = !isAutoDetected && storedTimezone === ianaName + return ( + handleSelect(ianaName)} + > + {entry.text} + + + ) + })} +
+
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/PITR.constants.ts b/apps/studio/components/interfaces/Database/Backups/PITR/PITR.constants.ts index 354e142106d..08ee0bb3b29 100644 --- a/apps/studio/components/interfaces/Database/Backups/PITR/PITR.constants.ts +++ b/apps/studio/components/interfaces/Database/Backups/PITR/PITR.constants.ts @@ -1,1188 +1,3 @@ -// https://github.com/dmfilipenko/timezones.json/blob/master/timezones.json -export const ALL_TIMEZONES = [ - { - value: 'Dateline Standard Time', - abbr: 'DST', - offset: -12, - isdst: false, - text: '(UTC-12:00) International Date Line West', - utc: ['Etc/GMT+12'], - }, - { - value: 'UTC-11', - abbr: 'U', - offset: -11, - isdst: false, - text: '(UTC-11:00) Coordinated Universal Time-11', - utc: ['Etc/GMT+11', 'Pacific/Midway', 'Pacific/Niue', 'Pacific/Pago_Pago'], - }, - { - value: 'Hawaiian Standard Time', - abbr: 'HST', - offset: -10, - isdst: false, - text: '(UTC-10:00) Hawaii', - utc: [ - 'Etc/GMT+10', - 'Pacific/Honolulu', - 'Pacific/Johnston', - 'Pacific/Rarotonga', - 'Pacific/Tahiti', - ], - }, - { - value: 'Alaskan Standard Time', - abbr: 'AKDT', - offset: -8, - isdst: true, - text: '(UTC-09:00) Alaska', - utc: [ - 'America/Anchorage', - 'America/Juneau', - 'America/Nome', - 'America/Sitka', - 'America/Yakutat', - ], - }, - { - value: 'Pacific Standard Time (Mexico)', - abbr: 'PDT', - offset: -7, - isdst: true, - text: '(UTC-08:00) Baja California', - utc: ['America/Santa_Isabel'], - }, - { - value: 'Pacific Daylight Time', - abbr: 'PDT', - offset: -7, - isdst: true, - text: '(UTC-07:00) Pacific Daylight Time (US & Canada)', - utc: ['America/Los_Angeles', 'America/Tijuana', 'America/Vancouver'], - }, - { - value: 'Pacific Standard Time', - abbr: 'PST', - offset: -8, - isdst: false, - text: '(UTC-08:00) Pacific Standard Time (US & Canada)', - utc: ['America/Los_Angeles', 'America/Tijuana', 'America/Vancouver', 'PST8PDT'], - }, - { - value: 'US Mountain Standard Time', - abbr: 'UMST', - offset: -7, - isdst: false, - text: '(UTC-07:00) Arizona', - utc: [ - 'America/Creston', - 'America/Dawson', - 'America/Dawson_Creek', - 'America/Hermosillo', - 'America/Phoenix', - 'America/Whitehorse', - 'Etc/GMT+7', - ], - }, - { - value: 'Mountain Standard Time (Mexico)', - abbr: 'MDT', - offset: -6, - isdst: true, - text: '(UTC-07:00) Chihuahua, La Paz, Mazatlan', - utc: ['America/Chihuahua', 'America/Mazatlan'], - }, - { - value: 'Mountain Standard Time', - abbr: 'MDT', - offset: -6, - isdst: true, - text: '(UTC-07:00) Mountain Time (US & Canada)', - utc: [ - 'America/Boise', - 'America/Cambridge_Bay', - 'America/Denver', - 'America/Edmonton', - 'America/Inuvik', - 'America/Ojinaga', - 'America/Yellowknife', - 'MST7MDT', - ], - }, - { - value: 'Central America Standard Time', - abbr: 'CAST', - offset: -6, - isdst: false, - text: '(UTC-06:00) Central America', - utc: [ - 'America/Belize', - 'America/Costa_Rica', - 'America/El_Salvador', - 'America/Guatemala', - 'America/Managua', - 'America/Tegucigalpa', - 'Etc/GMT+6', - 'Pacific/Galapagos', - ], - }, - { - value: 'Central Standard Time', - abbr: 'CDT', - offset: -5, - isdst: true, - text: '(UTC-06:00) Central Time (US & Canada)', - utc: [ - 'America/Chicago', - 'America/Indiana/Knox', - 'America/Indiana/Tell_City', - 'America/Matamoros', - 'America/Menominee', - 'America/North_Dakota/Beulah', - 'America/North_Dakota/Center', - 'America/North_Dakota/New_Salem', - 'America/Rainy_River', - 'America/Rankin_Inlet', - 'America/Resolute', - 'America/Winnipeg', - 'CST6CDT', - ], - }, - { - value: 'Central Standard Time (Mexico)', - abbr: 'CDT', - offset: -5, - isdst: true, - text: '(UTC-06:00) Guadalajara, Mexico City, Monterrey', - utc: [ - 'America/Bahia_Banderas', - 'America/Cancun', - 'America/Merida', - 'America/Mexico_City', - 'America/Monterrey', - ], - }, - { - value: 'Canada Central Standard Time', - abbr: 'CCST', - offset: -6, - isdst: false, - text: '(UTC-06:00) Saskatchewan', - utc: ['America/Regina', 'America/Swift_Current'], - }, - { - value: 'SA Pacific Standard Time', - abbr: 'SPST', - offset: -5, - isdst: false, - text: '(UTC-05:00) Bogota, Lima, Quito', - utc: [ - 'America/Bogota', - 'America/Cayman', - 'America/Coral_Harbour', - 'America/Eirunepe', - 'America/Guayaquil', - 'America/Jamaica', - 'America/Lima', - 'America/Panama', - 'America/Rio_Branco', - 'Etc/GMT+5', - ], - }, - { - value: 'Eastern Standard Time', - abbr: 'EST', - offset: -5, - isdst: false, - text: '(UTC-05:00) Eastern Time (US & Canada)', - utc: [ - 'America/Detroit', - 'America/Havana', - 'America/Indiana/Petersburg', - 'America/Indiana/Vincennes', - 'America/Indiana/Winamac', - 'America/Iqaluit', - 'America/Kentucky/Monticello', - 'America/Louisville', - 'America/Montreal', - 'America/Nassau', - 'America/New_York', - 'America/Nipigon', - 'America/Pangnirtung', - 'America/Port-au-Prince', - 'America/Thunder_Bay', - 'America/Toronto', - ], - }, - { - value: 'Eastern Daylight Time', - abbr: 'EDT', - offset: -4, - isdst: true, - text: '(UTC-04:00) Eastern Daylight Time (US & Canada)', - utc: [ - 'America/Detroit', - 'America/Havana', - 'America/Indiana/Petersburg', - 'America/Indiana/Vincennes', - 'America/Indiana/Winamac', - 'America/Iqaluit', - 'America/Kentucky/Monticello', - 'America/Louisville', - 'America/Montreal', - 'America/Nassau', - 'America/New_York', - 'America/Nipigon', - 'America/Pangnirtung', - 'America/Port-au-Prince', - 'America/Thunder_Bay', - 'America/Toronto', - ], - }, - { - value: 'US Eastern Standard Time', - abbr: 'UEDT', - offset: -5, - isdst: false, - text: '(UTC-05:00) Indiana (East)', - utc: ['America/Indiana/Marengo', 'America/Indiana/Vevay', 'America/Indianapolis'], - }, - { - value: 'Venezuela Standard Time', - abbr: 'VST', - offset: -4.5, - isdst: false, - text: '(UTC-04:30) Caracas', - utc: ['America/Caracas'], - }, - { - value: 'Paraguay Standard Time', - abbr: 'PYT', - offset: -4, - isdst: false, - text: '(UTC-04:00) Asuncion', - utc: ['America/Asuncion'], - }, - { - value: 'Atlantic Standard Time', - abbr: 'ADT', - offset: -3, - isdst: true, - text: '(UTC-04:00) Atlantic Time (Canada)', - utc: [ - 'America/Glace_Bay', - 'America/Goose_Bay', - 'America/Halifax', - 'America/Moncton', - 'America/Thule', - 'Atlantic/Bermuda', - ], - }, - { - value: 'Central Brazilian Standard Time', - abbr: 'CBST', - offset: -4, - isdst: false, - text: '(UTC-04:00) Cuiaba', - utc: ['America/Campo_Grande', 'America/Cuiaba'], - }, - { - value: 'SA Western Standard Time', - abbr: 'SWST', - offset: -4, - isdst: false, - text: '(UTC-04:00) Georgetown, La Paz, Manaus, San Juan', - utc: [ - 'America/Anguilla', - 'America/Antigua', - 'America/Aruba', - 'America/Barbados', - 'America/Blanc-Sablon', - 'America/Boa_Vista', - 'America/Curacao', - 'America/Dominica', - 'America/Grand_Turk', - 'America/Grenada', - 'America/Guadeloupe', - 'America/Guyana', - 'America/Kralendijk', - 'America/La_Paz', - 'America/Lower_Princes', - 'America/Manaus', - 'America/Marigot', - 'America/Martinique', - 'America/Montserrat', - 'America/Port_of_Spain', - 'America/Porto_Velho', - 'America/Puerto_Rico', - 'America/Santo_Domingo', - 'America/St_Barthelemy', - 'America/St_Kitts', - 'America/St_Lucia', - 'America/St_Thomas', - 'America/St_Vincent', - 'America/Tortola', - 'Etc/GMT+4', - ], - }, - { - value: 'Pacific SA Standard Time', - abbr: 'PSST', - offset: -4, - isdst: false, - text: '(UTC-04:00) Santiago', - utc: ['America/Santiago', 'Antarctica/Palmer'], - }, - { - value: 'Newfoundland Standard Time', - abbr: 'NDT', - offset: -2.5, - isdst: true, - text: '(UTC-03:30) Newfoundland', - utc: ['America/St_Johns'], - }, - { - value: 'E. South America Standard Time', - abbr: 'ESAST', - offset: -3, - isdst: false, - text: '(UTC-03:00) Brasilia', - utc: ['America/Sao_Paulo'], - }, - { - value: 'Argentina Standard Time', - abbr: 'AST', - offset: -3, - isdst: false, - text: '(UTC-03:00) Buenos Aires', - utc: [ - 'America/Argentina/La_Rioja', - 'America/Argentina/Rio_Gallegos', - 'America/Argentina/Salta', - 'America/Argentina/San_Juan', - 'America/Argentina/San_Luis', - 'America/Argentina/Tucuman', - 'America/Argentina/Ushuaia', - 'America/Buenos_Aires', - 'America/Catamarca', - 'America/Cordoba', - 'America/Jujuy', - 'America/Mendoza', - ], - }, - { - value: 'SA Eastern Standard Time', - abbr: 'SEST', - offset: -3, - isdst: false, - text: '(UTC-03:00) Cayenne, Fortaleza', - utc: [ - 'America/Araguaina', - 'America/Belem', - 'America/Cayenne', - 'America/Fortaleza', - 'America/Maceio', - 'America/Paramaribo', - 'America/Recife', - 'America/Santarem', - 'Antarctica/Rothera', - 'Atlantic/Stanley', - 'Etc/GMT+3', - ], - }, - { - value: 'Greenland Standard Time', - abbr: 'GDT', - offset: -3, - isdst: true, - text: '(UTC-03:00) Greenland', - utc: ['America/Godthab'], - }, - { - value: 'Montevideo Standard Time', - abbr: 'MST', - offset: -3, - isdst: false, - text: '(UTC-03:00) Montevideo', - utc: ['America/Montevideo'], - }, - { - value: 'Bahia Standard Time', - abbr: 'BST', - offset: -3, - isdst: false, - text: '(UTC-03:00) Salvador', - utc: ['America/Bahia'], - }, - { - value: 'UTC-02', - abbr: 'U', - offset: -2, - isdst: false, - text: '(UTC-02:00) Coordinated Universal Time-02', - utc: ['America/Noronha', 'Atlantic/South_Georgia', 'Etc/GMT+2'], - }, - { - value: 'Azores Standard Time', - abbr: 'ADT', - offset: 0, - isdst: true, - text: '(UTC-01:00) Azores', - utc: ['America/Scoresbysund', 'Atlantic/Azores'], - }, - { - value: 'Cape Verde Standard Time', - abbr: 'CVST', - offset: -1, - isdst: false, - text: '(UTC-01:00) Cape Verde Is.', - utc: ['Atlantic/Cape_Verde', 'Etc/GMT+1'], - }, - { - value: 'Morocco Standard Time', - abbr: 'MDT', - offset: 1, - isdst: true, - text: '(UTC) Casablanca', - utc: ['Africa/Casablanca', 'Africa/El_Aaiun'], - }, - { - value: 'UTC', - abbr: 'UTC', - offset: 0, - isdst: false, - text: '(UTC) Coordinated Universal Time', - utc: ['America/Danmarkshavn', 'Etc/GMT'], - }, - { - value: 'British Summer Time', - abbr: 'BST', - offset: 1, - isdst: true, - text: '(UTC+01:00) Edinburgh, London', - utc: ['Europe/Isle_of_Man', 'Europe/Guernsey', 'Europe/Jersey', 'Europe/London'], - }, - { - value: 'GMT Standard Time', - abbr: 'GDT', - offset: 1, - isdst: true, - text: '(UTC) Dublin, Lisbon', - utc: [ - 'Atlantic/Canary', - 'Atlantic/Faeroe', - 'Atlantic/Madeira', - 'Europe/Dublin', - 'Europe/Lisbon', - ], - }, - { - value: 'Greenwich Standard Time', - abbr: 'GST', - offset: 0, - isdst: false, - text: '(UTC) Monrovia, Reykjavik', - utc: [ - 'Africa/Abidjan', - 'Africa/Accra', - 'Africa/Bamako', - 'Africa/Banjul', - 'Africa/Bissau', - 'Africa/Conakry', - 'Africa/Dakar', - 'Africa/Freetown', - 'Africa/Lome', - 'Africa/Monrovia', - 'Africa/Nouakchott', - 'Africa/Ouagadougou', - 'Africa/Sao_Tome', - 'Atlantic/Reykjavik', - 'Atlantic/St_Helena', - ], - }, - { - value: 'W. Europe Standard Time', - abbr: 'WEDT', - offset: 2, - isdst: true, - text: '(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna', - utc: [ - 'Arctic/Longyearbyen', - 'Europe/Amsterdam', - 'Europe/Andorra', - 'Europe/Berlin', - 'Europe/Busingen', - 'Europe/Gibraltar', - 'Europe/Luxembourg', - 'Europe/Malta', - 'Europe/Monaco', - 'Europe/Oslo', - 'Europe/Rome', - 'Europe/San_Marino', - 'Europe/Stockholm', - 'Europe/Vaduz', - 'Europe/Vatican', - 'Europe/Vienna', - 'Europe/Zurich', - ], - }, - { - value: 'Central Europe Standard Time', - abbr: 'CEDT', - offset: 2, - isdst: true, - text: '(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague', - utc: [ - 'Europe/Belgrade', - 'Europe/Bratislava', - 'Europe/Budapest', - 'Europe/Ljubljana', - 'Europe/Podgorica', - 'Europe/Prague', - 'Europe/Tirane', - ], - }, - { - value: 'Romance Standard Time', - abbr: 'RDT', - offset: 2, - isdst: true, - text: '(UTC+01:00) Brussels, Copenhagen, Madrid, Paris', - utc: ['Africa/Ceuta', 'Europe/Brussels', 'Europe/Copenhagen', 'Europe/Madrid', 'Europe/Paris'], - }, - { - value: 'Central European Standard Time', - abbr: 'CEDT', - offset: 2, - isdst: true, - text: '(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb', - utc: ['Europe/Sarajevo', 'Europe/Skopje', 'Europe/Warsaw', 'Europe/Zagreb'], - }, - { - value: 'W. Central Africa Standard Time', - abbr: 'WCAST', - offset: 1, - isdst: false, - text: '(UTC+01:00) West Central Africa', - utc: [ - 'Africa/Algiers', - 'Africa/Bangui', - 'Africa/Brazzaville', - 'Africa/Douala', - 'Africa/Kinshasa', - 'Africa/Lagos', - 'Africa/Libreville', - 'Africa/Luanda', - 'Africa/Malabo', - 'Africa/Ndjamena', - 'Africa/Niamey', - 'Africa/Porto-Novo', - 'Africa/Tunis', - 'Etc/GMT-1', - ], - }, - { - value: 'Namibia Standard Time', - abbr: 'NST', - offset: 1, - isdst: false, - text: '(UTC+01:00) Windhoek', - utc: ['Africa/Windhoek'], - }, - { - value: 'GTB Standard Time', - abbr: 'GDT', - offset: 3, - isdst: true, - text: '(UTC+02:00) Athens, Bucharest', - utc: ['Asia/Nicosia', 'Europe/Athens', 'Europe/Bucharest', 'Europe/Chisinau'], - }, - { - value: 'Middle East Standard Time', - abbr: 'MEDT', - offset: 3, - isdst: true, - text: '(UTC+02:00) Beirut', - utc: ['Asia/Beirut'], - }, - { - value: 'Egypt Standard Time', - abbr: 'EST', - offset: 2, - isdst: false, - text: '(UTC+02:00) Cairo', - utc: ['Africa/Cairo'], - }, - { - value: 'Syria Standard Time', - abbr: 'SDT', - offset: 3, - isdst: true, - text: '(UTC+02:00) Damascus', - utc: ['Asia/Damascus'], - }, - { - value: 'E. Europe Standard Time', - abbr: 'EEDT', - offset: 3, - isdst: true, - text: '(UTC+02:00) E. Europe', - utc: [ - 'Asia/Nicosia', - 'Europe/Athens', - 'Europe/Bucharest', - 'Europe/Chisinau', - 'Europe/Helsinki', - 'Europe/Kiev', - 'Europe/Mariehamn', - 'Europe/Nicosia', - 'Europe/Riga', - 'Europe/Sofia', - 'Europe/Tallinn', - 'Europe/Uzhgorod', - 'Europe/Vilnius', - 'Europe/Zaporozhye', - ], - }, - { - value: 'South Africa Standard Time', - abbr: 'SAST', - offset: 2, - isdst: false, - text: '(UTC+02:00) Harare, Pretoria', - utc: [ - 'Africa/Blantyre', - 'Africa/Bujumbura', - 'Africa/Gaborone', - 'Africa/Harare', - 'Africa/Johannesburg', - 'Africa/Kigali', - 'Africa/Lubumbashi', - 'Africa/Lusaka', - 'Africa/Maputo', - 'Africa/Maseru', - 'Africa/Mbabane', - 'Etc/GMT-2', - ], - }, - { - value: 'FLE Standard Time', - abbr: 'FDT', - offset: 3, - isdst: true, - text: '(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius', - utc: [ - 'Europe/Helsinki', - 'Europe/Kiev', - 'Europe/Mariehamn', - 'Europe/Riga', - 'Europe/Sofia', - 'Europe/Tallinn', - 'Europe/Uzhgorod', - 'Europe/Vilnius', - 'Europe/Zaporozhye', - ], - }, - { - value: 'Turkey Standard Time', - abbr: 'TDT', - offset: 3, - isdst: false, - text: '(UTC+03:00) Istanbul', - utc: ['Europe/Istanbul'], - }, - { - value: 'Israel Standard Time', - abbr: 'JDT', - offset: 3, - isdst: true, - text: '(UTC+02:00) Jerusalem', - utc: ['Asia/Jerusalem'], - }, - { - value: 'Libya Standard Time', - abbr: 'LST', - offset: 2, - isdst: false, - text: '(UTC+02:00) Tripoli', - utc: ['Africa/Tripoli'], - }, - { - value: 'Jordan Standard Time', - abbr: 'JST', - offset: 3, - isdst: false, - text: '(UTC+03:00) Amman', - utc: ['Asia/Amman'], - }, - { - value: 'Arabic Standard Time', - abbr: 'AST', - offset: 3, - isdst: false, - text: '(UTC+03:00) Baghdad', - utc: ['Asia/Baghdad'], - }, - { - value: 'Kaliningrad Standard Time', - abbr: 'KST', - offset: 3, - isdst: false, - text: '(UTC+02:00) Kaliningrad', - utc: ['Europe/Kaliningrad'], - }, - { - value: 'Arab Standard Time', - abbr: 'AST', - offset: 3, - isdst: false, - text: '(UTC+03:00) Kuwait, Riyadh', - utc: ['Asia/Aden', 'Asia/Bahrain', 'Asia/Kuwait', 'Asia/Qatar', 'Asia/Riyadh'], - }, - { - value: 'E. Africa Standard Time', - abbr: 'EAST', - offset: 3, - isdst: false, - text: '(UTC+03:00) Nairobi', - utc: [ - 'Africa/Addis_Ababa', - 'Africa/Asmera', - 'Africa/Dar_es_Salaam', - 'Africa/Djibouti', - 'Africa/Juba', - 'Africa/Kampala', - 'Africa/Khartoum', - 'Africa/Mogadishu', - 'Africa/Nairobi', - 'Antarctica/Syowa', - 'Etc/GMT-3', - 'Indian/Antananarivo', - 'Indian/Comoro', - 'Indian/Mayotte', - ], - }, - { - value: 'Moscow Standard Time', - abbr: 'MSK', - offset: 3, - isdst: false, - text: '(UTC+03:00) Moscow, St. Petersburg, Volgograd, Minsk', - utc: ['Europe/Kirov', 'Europe/Moscow', 'Europe/Simferopol', 'Europe/Volgograd', 'Europe/Minsk'], - }, - { - value: 'Samara Time', - abbr: 'SAMT', - offset: 4, - isdst: false, - text: '(UTC+04:00) Samara, Ulyanovsk, Saratov', - utc: ['Europe/Astrakhan', 'Europe/Samara', 'Europe/Ulyanovsk'], - }, - { - value: 'Iran Standard Time', - abbr: 'IDT', - offset: 4.5, - isdst: true, - text: '(UTC+03:30) Tehran', - utc: ['Asia/Tehran'], - }, - { - value: 'Arabian Standard Time', - abbr: 'AST', - offset: 4, - isdst: false, - text: '(UTC+04:00) Abu Dhabi, Muscat', - utc: ['Asia/Dubai', 'Asia/Muscat', 'Etc/GMT-4'], - }, - { - value: 'Azerbaijan Standard Time', - abbr: 'ADT', - offset: 5, - isdst: true, - text: '(UTC+04:00) Baku', - utc: ['Asia/Baku'], - }, - { - value: 'Mauritius Standard Time', - abbr: 'MST', - offset: 4, - isdst: false, - text: '(UTC+04:00) Port Louis', - utc: ['Indian/Mahe', 'Indian/Mauritius', 'Indian/Reunion'], - }, - { - value: 'Georgian Standard Time', - abbr: 'GET', - offset: 4, - isdst: false, - text: '(UTC+04:00) Tbilisi', - utc: ['Asia/Tbilisi'], - }, - { - value: 'Caucasus Standard Time', - abbr: 'CST', - offset: 4, - isdst: false, - text: '(UTC+04:00) Yerevan', - utc: ['Asia/Yerevan'], - }, - { - value: 'Afghanistan Standard Time', - abbr: 'AST', - offset: 4.5, - isdst: false, - text: '(UTC+04:30) Kabul', - utc: ['Asia/Kabul'], - }, - { - value: 'West Asia Standard Time', - abbr: 'WAST', - offset: 5, - isdst: false, - text: '(UTC+05:00) Ashgabat, Tashkent', - utc: [ - 'Antarctica/Mawson', - 'Asia/Aqtau', - 'Asia/Aqtobe', - 'Asia/Ashgabat', - 'Asia/Dushanbe', - 'Asia/Oral', - 'Asia/Samarkand', - 'Asia/Tashkent', - 'Etc/GMT-5', - 'Indian/Kerguelen', - 'Indian/Maldives', - ], - }, - { - value: 'Yekaterinburg Time', - abbr: 'YEKT', - offset: 5, - isdst: false, - text: '(UTC+05:00) Yekaterinburg', - utc: ['Asia/Yekaterinburg'], - }, - { - value: 'Pakistan Standard Time', - abbr: 'PKT', - offset: 5, - isdst: false, - text: '(UTC+05:00) Islamabad, Karachi', - utc: ['Asia/Karachi'], - }, - { - value: 'India Standard Time', - abbr: 'IST', - offset: 5.5, - isdst: false, - text: '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi', - utc: ['Asia/Kolkata', 'Asia/Calcutta'], - }, - { - value: 'Sri Lanka Standard Time', - abbr: 'SLST', - offset: 5.5, - isdst: false, - text: '(UTC+05:30) Sri Jayawardenepura', - utc: ['Asia/Colombo'], - }, - { - value: 'Nepal Standard Time', - abbr: 'NST', - offset: 5.75, - isdst: false, - text: '(UTC+05:45) Kathmandu', - utc: ['Asia/Kathmandu'], - }, - { - value: 'Central Asia Standard Time', - abbr: 'CAST', - offset: 6, - isdst: false, - text: '(UTC+06:00) Nur-Sultan (Astana)', - utc: [ - 'Antarctica/Vostok', - 'Asia/Almaty', - 'Asia/Bishkek', - 'Asia/Qyzylorda', - 'Asia/Urumqi', - 'Etc/GMT-6', - 'Indian/Chagos', - ], - }, - { - value: 'Bangladesh Standard Time', - abbr: 'BST', - offset: 6, - isdst: false, - text: '(UTC+06:00) Dhaka', - utc: ['Asia/Dhaka', 'Asia/Thimphu'], - }, - { - value: 'Myanmar Standard Time', - abbr: 'MST', - offset: 6.5, - isdst: false, - text: '(UTC+06:30) Yangon (Rangoon)', - utc: ['Asia/Rangoon', 'Indian/Cocos'], - }, - { - value: 'SE Asia Standard Time', - abbr: 'SAST', - offset: 7, - isdst: false, - text: '(UTC+07:00) Bangkok, Hanoi, Jakarta', - utc: [ - 'Antarctica/Davis', - 'Asia/Bangkok', - 'Asia/Hovd', - 'Asia/Jakarta', - 'Asia/Phnom_Penh', - 'Asia/Pontianak', - 'Asia/Saigon', - 'Asia/Vientiane', - 'Etc/GMT-7', - 'Indian/Christmas', - ], - }, - { - value: 'N. Central Asia Standard Time', - abbr: 'NCAST', - offset: 7, - isdst: false, - text: '(UTC+07:00) Novosibirsk', - utc: ['Asia/Novokuznetsk', 'Asia/Novosibirsk', 'Asia/Omsk'], - }, - { - value: 'China Standard Time', - abbr: 'CST', - offset: 8, - isdst: false, - text: '(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi', - utc: ['Asia/Hong_Kong', 'Asia/Macau', 'Asia/Shanghai'], - }, - { - value: 'North Asia Standard Time', - abbr: 'NAST', - offset: 8, - isdst: false, - text: '(UTC+08:00) Krasnoyarsk', - utc: ['Asia/Krasnoyarsk'], - }, - { - value: 'Singapore Standard Time', - abbr: 'MPST', - offset: 8, - isdst: false, - text: '(UTC+08:00) Kuala Lumpur, Singapore', - utc: [ - 'Asia/Brunei', - 'Asia/Kuala_Lumpur', - 'Asia/Kuching', - 'Asia/Makassar', - 'Asia/Manila', - 'Asia/Singapore', - 'Etc/GMT-8', - ], - }, - { - value: 'W. Australia Standard Time', - abbr: 'WAST', - offset: 8, - isdst: false, - text: '(UTC+08:00) Perth', - utc: ['Antarctica/Casey', 'Australia/Perth'], - }, - { - value: 'Taipei Standard Time', - abbr: 'TST', - offset: 8, - isdst: false, - text: '(UTC+08:00) Taipei', - utc: ['Asia/Taipei'], - }, - { - value: 'Ulaanbaatar Standard Time', - abbr: 'UST', - offset: 8, - isdst: false, - text: '(UTC+08:00) Ulaanbaatar', - utc: ['Asia/Choibalsan', 'Asia/Ulaanbaatar'], - }, - { - value: 'North Asia East Standard Time', - abbr: 'NAEST', - offset: 8, - isdst: false, - text: '(UTC+08:00) Irkutsk', - utc: ['Asia/Irkutsk'], - }, - { - value: 'Japan Standard Time', - abbr: 'JST', - offset: 9, - isdst: false, - text: '(UTC+09:00) Osaka, Sapporo, Tokyo', - utc: ['Asia/Dili', 'Asia/Jayapura', 'Asia/Tokyo', 'Etc/GMT-9', 'Pacific/Palau'], - }, - { - value: 'Korea Standard Time', - abbr: 'KST', - offset: 9, - isdst: false, - text: '(UTC+09:00) Seoul', - utc: ['Asia/Pyongyang', 'Asia/Seoul'], - }, - { - value: 'Cen. Australia Standard Time', - abbr: 'CAST', - offset: 9.5, - isdst: false, - text: '(UTC+09:30) Adelaide', - utc: ['Australia/Adelaide', 'Australia/Broken_Hill'], - }, - { - value: 'AUS Central Standard Time', - abbr: 'ACST', - offset: 9.5, - isdst: false, - text: '(UTC+09:30) Darwin', - utc: ['Australia/Darwin'], - }, - { - value: 'E. Australia Standard Time', - abbr: 'EAST', - offset: 10, - isdst: false, - text: '(UTC+10:00) Brisbane', - utc: ['Australia/Brisbane', 'Australia/Lindeman'], - }, - { - value: 'AUS Eastern Standard Time', - abbr: 'AEST', - offset: 10, - isdst: false, - text: '(UTC+10:00) Canberra, Melbourne, Sydney', - utc: ['Australia/Melbourne', 'Australia/Sydney'], - }, - { - value: 'West Pacific Standard Time', - abbr: 'WPST', - offset: 10, - isdst: false, - text: '(UTC+10:00) Guam, Port Moresby', - utc: [ - 'Antarctica/DumontDUrville', - 'Etc/GMT-10', - 'Pacific/Guam', - 'Pacific/Port_Moresby', - 'Pacific/Saipan', - 'Pacific/Truk', - ], - }, - { - value: 'Tasmania Standard Time', - abbr: 'TST', - offset: 10, - isdst: false, - text: '(UTC+10:00) Hobart', - utc: ['Australia/Currie', 'Australia/Hobart'], - }, - { - value: 'Yakutsk Standard Time', - abbr: 'YST', - offset: 9, - isdst: false, - text: '(UTC+09:00) Yakutsk', - utc: ['Asia/Chita', 'Asia/Khandyga', 'Asia/Yakutsk'], - }, - { - value: 'Central Pacific Standard Time', - abbr: 'CPST', - offset: 11, - isdst: false, - text: '(UTC+11:00) Solomon Is., New Caledonia', - utc: [ - 'Antarctica/Macquarie', - 'Etc/GMT-11', - 'Pacific/Efate', - 'Pacific/Guadalcanal', - 'Pacific/Kosrae', - 'Pacific/Noumea', - 'Pacific/Ponape', - ], - }, - { - value: 'Vladivostok Standard Time', - abbr: 'VST', - offset: 11, - isdst: false, - text: '(UTC+11:00) Vladivostok', - utc: ['Asia/Sakhalin', 'Asia/Ust-Nera', 'Asia/Vladivostok'], - }, - { - value: 'New Zealand Standard Time', - abbr: 'NZST', - offset: 12, - isdst: false, - text: '(UTC+12:00) Auckland, Wellington', - utc: ['Antarctica/McMurdo', 'Pacific/Auckland'], - }, - { - value: 'UTC+12', - abbr: 'U', - offset: 12, - isdst: false, - text: '(UTC+12:00) Coordinated Universal Time+12', - utc: [ - 'Etc/GMT-12', - 'Pacific/Funafuti', - 'Pacific/Kwajalein', - 'Pacific/Majuro', - 'Pacific/Nauru', - 'Pacific/Tarawa', - 'Pacific/Wake', - 'Pacific/Wallis', - ], - }, - { - value: 'Fiji Standard Time', - abbr: 'FST', - offset: 12, - isdst: false, - text: '(UTC+12:00) Fiji', - utc: ['Pacific/Fiji'], - }, - { - value: 'Magadan Standard Time', - abbr: 'MST', - offset: 12, - isdst: false, - text: '(UTC+12:00) Magadan', - utc: ['Asia/Anadyr', 'Asia/Kamchatka', 'Asia/Magadan', 'Asia/Srednekolymsk'], - }, - { - value: 'Kamchatka Standard Time', - abbr: 'KDT', - offset: 13, - isdst: true, - text: '(UTC+12:00) Petropavlovsk-Kamchatsky - Old', - utc: ['Asia/Kamchatka'], - }, - { - value: 'Tonga Standard Time', - abbr: 'TST', - offset: 13, - isdst: false, - text: "(UTC+13:00) Nuku'alofa", - utc: ['Etc/GMT-13', 'Pacific/Enderbury', 'Pacific/Fakaofo', 'Pacific/Tongatapu'], - }, - { - value: 'Samoa Standard Time', - abbr: 'SST', - offset: 13, - isdst: false, - text: '(UTC+13:00) Samoa', - utc: ['Pacific/Apia'], - }, -] +// Re-export from the shared location. The full timezone catalog now lives in +// `lib/constants/timezones.ts` so it can be consumed outside of PITR. +export { ALL_TIMEZONES } from '@/lib/constants/timezones' diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx index 509fa74f134..e3512b5ead0 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx @@ -1,4 +1,3 @@ -import { format } from 'date-fns' import { User } from 'lucide-react' import { cn } from 'ui' @@ -9,6 +8,27 @@ import { getLevelLabel } from './UnifiedLogs.utils' import { LEVELS } from '@/components/ui/DataTable/DataTable.constants' import { DataTableFilterField, Option } from '@/components/ui/DataTable/DataTable.types' import { getLevelColor } from '@/components/ui/DataTable/DataTable.utils' +import { useFormatDateTime } from '@/lib/datetime' + +const DateCell = (props: { date: ColumnSchema['date'] }) => { + const formatDateTime = useFormatDateTime() + const month = formatDateTime(props.date, 'MMM') + const day = formatDateTime(props.date, 'DD') + const year = formatDateTime(props.date, 'YYYY') + const time = formatDateTime(props.date, 'HH:mm:ss') + + return ( +
+ {month} + · + {day} + · + {year} + · + {time} +
+ ) +} // instead of filterFields, maybe just 'fields' with a filterDisabled prop? // that way, we could have 'message' or 'headers' field with label and value as well as type! @@ -127,25 +147,7 @@ export const sheetFields = [ id: 'date', label: 'Date', type: 'timerange', - component: (props) => { - const date = new Date(props.date) - const month = format(date, 'LLL') - const day = format(date, 'dd') - const year = format(date, 'y') - const time = format(date, 'HH:mm:ss') - - return ( -
- {month} - · - {day} - · - {year} - · - {time} -
- ) - }, + component: DateCell, skeletonClassName: 'w-36', }, { diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx index 599bd1d8fb1..d31d90dec39 100644 --- a/apps/studio/components/interfaces/UserDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown.tsx @@ -1,3 +1,4 @@ +import { useFlag } from 'common' import { FlaskConical, Loader2, ScrollText, Settings } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' @@ -19,6 +20,7 @@ import { import { ButtonTooltip } from '../ui/ButtonTooltip' import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext' +import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown' import { ProfileImage } from '@/components/ui/ProfileImage' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { IS_PLATFORM } from '@/lib/constants' @@ -37,6 +39,7 @@ export function UserDropdown({ const { theme, setTheme } = useTheme() const appStateSnapshot = useAppStateSnapshot() const profileShowEmailEnabled = useIsFeatureEnabled('profile:show_email') + const timezonePickerEnabled = useFlag('timezonePicker') const { username, avatarUrl, primaryEmail, isLoading } = useProfileNameAndPicture() const { toggleFeaturePreviewModal } = useFeaturePreviewModal() @@ -144,6 +147,14 @@ export function UserDropdown({ ))} + {timezonePickerEnabled && ( + <> + + + + + + )} {IS_PLATFORM && ( <> diff --git a/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx b/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx new file mode 100644 index 00000000000..f6e7cf989be --- /dev/null +++ b/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx @@ -0,0 +1,113 @@ +import { CheckIcon } from 'lucide-react' +import { useMemo, useState } from 'react' +import { + cn, + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + ScrollArea, +} from 'ui' + +import { findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones' +import { useTimezone } from '@/lib/datetime' +import { guessLocalTimezone } from '@/lib/dayjs' +import { useTrack } from '@/lib/telemetry/track' + +const AUTO_OPTION_VALUE = '__auto__' + +export const TimezoneDropdown = () => { + const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone() + const track = useTrack() + const [open, setOpen] = useState(false) + + // The "Auto detect" row always advertises the browser's own timezone, even + // when the user is currently overriding it with a manual pick. + const browserTimezone = useMemo(() => guessLocalTimezone(), []) + + const triggerLabel = useMemo(() => { + return findTimezoneByIana(timezone)?.text ?? timezone + }, [timezone]) + + const handleSelect = (nextStored: string) => { + setTimezone(nextStored) + const resolvedNext = nextStored || guessLocalTimezone() + track('timezone_picker_clicked', { + previousTimezone: timezone, + nextTimezone: resolvedNext, + isAutoDetected: nextStored === '', + source: 'user_dropdown', + }) + setOpen(false) + } + + return ( + + +
+ Timezone + + {isAutoDetected ? `Auto (${timezone})` : triggerLabel} + +
+
+ + + + + + No timezones found + + + handleSelect('')} + > +
+ Auto detect + {browserTimezone} +
+ +
+ {TIMEZONES_BY_IANA.map((entry) => { + const ianaName = entry.utc[0] + const isSelected = !isAutoDetected && storedTimezone === ianaName + return ( + handleSelect(ianaName)} + > + {entry.text} + + + ) + })} +
+
+
+
+
+
+
+ ) +} diff --git a/apps/studio/lib/constants/timezones.test.ts b/apps/studio/lib/constants/timezones.test.ts new file mode 100644 index 00000000000..69bc7ea7ca8 --- /dev/null +++ b/apps/studio/lib/constants/timezones.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { ALL_TIMEZONES, findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones' + +describe('TIMEZONES_BY_IANA', () => { + it('produces one row per primary IANA name', () => { + const ianas = TIMEZONES_BY_IANA.map((entry) => entry.utc[0]) + expect(new Set(ianas).size).toBe(ianas.length) + }) + + it('prefers the standard-time row when multiple catalog rows share a primary IANA', () => { + // ALL_TIMEZONES has both PDT (isdst: true) and PST (isdst: false) pointing + // at America/Los_Angeles. The deduped view should pick the standard one + // so the picker label doesn't flip on DST changes. + const collisions = ALL_TIMEZONES.filter((entry) => entry.utc[0] === 'America/Los_Angeles') + expect(collisions.length).toBeGreaterThan(1) + + const winner = TIMEZONES_BY_IANA.find((entry) => entry.utc[0] === 'America/Los_Angeles') + expect(winner).toBeDefined() + expect(winner!.isdst).toBe(false) + }) + + it('preserves entries that have no collision', () => { + // Most rows have a unique primary IANA. The UTC catalog row's primary is + // 'America/Danmarkshavn' and survives the dedupe pass unchanged. + const utcRow = TIMEZONES_BY_IANA.find((entry) => entry.value === 'UTC') + expect(utcRow?.text).toContain('Coordinated Universal Time') + expect(utcRow?.utc[0]).toBe('America/Danmarkshavn') + }) +}) + +describe('findTimezoneByIana', () => { + it('matches the entry by its primary IANA name', () => { + const entry = findTimezoneByIana('America/Danmarkshavn') + expect(entry?.text).toContain('Coordinated Universal Time') + }) + + it('matches the entry by any of its secondary IANA names', () => { + // 'Asia/Tokyo' is one of the IANA aliases on the JST row whose primary + // IANA is 'Asia/Dili'. Lookup must walk the full alias list. + const entry = findTimezoneByIana('Asia/Tokyo') + expect(entry?.utc).toContain('Asia/Tokyo') + }) + + it('returns undefined for an unknown IANA name', () => { + expect(findTimezoneByIana('Not/A/Real_Zone')).toBeUndefined() + }) +}) diff --git a/apps/studio/lib/constants/timezones.ts b/apps/studio/lib/constants/timezones.ts new file mode 100644 index 00000000000..218b45719e0 --- /dev/null +++ b/apps/studio/lib/constants/timezones.ts @@ -0,0 +1,1221 @@ +// https://github.com/dmfilipenko/timezones.json/blob/master/timezones.json +export interface Timezone { + value: string + abbr: string + offset: number + isdst: boolean + text: string + utc: string[] +} + +export const ALL_TIMEZONES: Timezone[] = [ + { + value: 'Dateline Standard Time', + abbr: 'DST', + offset: -12, + isdst: false, + text: '(UTC-12:00) International Date Line West', + utc: ['Etc/GMT+12'], + }, + { + value: 'UTC-11', + abbr: 'U', + offset: -11, + isdst: false, + text: '(UTC-11:00) Coordinated Universal Time-11', + utc: ['Etc/GMT+11', 'Pacific/Midway', 'Pacific/Niue', 'Pacific/Pago_Pago'], + }, + { + value: 'Hawaiian Standard Time', + abbr: 'HST', + offset: -10, + isdst: false, + text: '(UTC-10:00) Hawaii', + utc: [ + 'Etc/GMT+10', + 'Pacific/Honolulu', + 'Pacific/Johnston', + 'Pacific/Rarotonga', + 'Pacific/Tahiti', + ], + }, + { + value: 'Alaskan Standard Time', + abbr: 'AKDT', + offset: -8, + isdst: true, + text: '(UTC-09:00) Alaska', + utc: [ + 'America/Anchorage', + 'America/Juneau', + 'America/Nome', + 'America/Sitka', + 'America/Yakutat', + ], + }, + { + value: 'Pacific Standard Time (Mexico)', + abbr: 'PDT', + offset: -7, + isdst: true, + text: '(UTC-08:00) Baja California', + utc: ['America/Santa_Isabel'], + }, + { + value: 'Pacific Daylight Time', + abbr: 'PDT', + offset: -7, + isdst: true, + text: '(UTC-07:00) Pacific Daylight Time (US & Canada)', + utc: ['America/Los_Angeles', 'America/Tijuana', 'America/Vancouver'], + }, + { + value: 'Pacific Standard Time', + abbr: 'PST', + offset: -8, + isdst: false, + text: '(UTC-08:00) Pacific Standard Time (US & Canada)', + utc: ['America/Los_Angeles', 'America/Tijuana', 'America/Vancouver', 'PST8PDT'], + }, + { + value: 'US Mountain Standard Time', + abbr: 'UMST', + offset: -7, + isdst: false, + text: '(UTC-07:00) Arizona', + utc: [ + 'America/Creston', + 'America/Dawson', + 'America/Dawson_Creek', + 'America/Hermosillo', + 'America/Phoenix', + 'America/Whitehorse', + 'Etc/GMT+7', + ], + }, + { + value: 'Mountain Standard Time (Mexico)', + abbr: 'MDT', + offset: -6, + isdst: true, + text: '(UTC-07:00) Chihuahua, La Paz, Mazatlan', + utc: ['America/Chihuahua', 'America/Mazatlan'], + }, + { + value: 'Mountain Standard Time', + abbr: 'MDT', + offset: -6, + isdst: true, + text: '(UTC-07:00) Mountain Time (US & Canada)', + utc: [ + 'America/Boise', + 'America/Cambridge_Bay', + 'America/Denver', + 'America/Edmonton', + 'America/Inuvik', + 'America/Ojinaga', + 'America/Yellowknife', + 'MST7MDT', + ], + }, + { + value: 'Central America Standard Time', + abbr: 'CAST', + offset: -6, + isdst: false, + text: '(UTC-06:00) Central America', + utc: [ + 'America/Belize', + 'America/Costa_Rica', + 'America/El_Salvador', + 'America/Guatemala', + 'America/Managua', + 'America/Tegucigalpa', + 'Etc/GMT+6', + 'Pacific/Galapagos', + ], + }, + { + value: 'Central Standard Time', + abbr: 'CDT', + offset: -5, + isdst: true, + text: '(UTC-06:00) Central Time (US & Canada)', + utc: [ + 'America/Chicago', + 'America/Indiana/Knox', + 'America/Indiana/Tell_City', + 'America/Matamoros', + 'America/Menominee', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Rainy_River', + 'America/Rankin_Inlet', + 'America/Resolute', + 'America/Winnipeg', + 'CST6CDT', + ], + }, + { + value: 'Central Standard Time (Mexico)', + abbr: 'CDT', + offset: -5, + isdst: true, + text: '(UTC-06:00) Guadalajara, Mexico City, Monterrey', + utc: [ + 'America/Bahia_Banderas', + 'America/Cancun', + 'America/Merida', + 'America/Mexico_City', + 'America/Monterrey', + ], + }, + { + value: 'Canada Central Standard Time', + abbr: 'CCST', + offset: -6, + isdst: false, + text: '(UTC-06:00) Saskatchewan', + utc: ['America/Regina', 'America/Swift_Current'], + }, + { + value: 'SA Pacific Standard Time', + abbr: 'SPST', + offset: -5, + isdst: false, + text: '(UTC-05:00) Bogota, Lima, Quito', + utc: [ + 'America/Bogota', + 'America/Cayman', + 'America/Coral_Harbour', + 'America/Eirunepe', + 'America/Guayaquil', + 'America/Jamaica', + 'America/Lima', + 'America/Panama', + 'America/Rio_Branco', + 'Etc/GMT+5', + ], + }, + { + value: 'Eastern Standard Time', + abbr: 'EST', + offset: -5, + isdst: false, + text: '(UTC-05:00) Eastern Time (US & Canada)', + utc: [ + 'America/Detroit', + 'America/Havana', + 'America/Indiana/Petersburg', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Iqaluit', + 'America/Kentucky/Monticello', + 'America/Louisville', + 'America/Montreal', + 'America/Nassau', + 'America/New_York', + 'America/Nipigon', + 'America/Pangnirtung', + 'America/Port-au-Prince', + 'America/Thunder_Bay', + 'America/Toronto', + ], + }, + { + value: 'Eastern Daylight Time', + abbr: 'EDT', + offset: -4, + isdst: true, + text: '(UTC-04:00) Eastern Daylight Time (US & Canada)', + utc: [ + 'America/Detroit', + 'America/Havana', + 'America/Indiana/Petersburg', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Iqaluit', + 'America/Kentucky/Monticello', + 'America/Louisville', + 'America/Montreal', + 'America/Nassau', + 'America/New_York', + 'America/Nipigon', + 'America/Pangnirtung', + 'America/Port-au-Prince', + 'America/Thunder_Bay', + 'America/Toronto', + ], + }, + { + value: 'US Eastern Standard Time', + abbr: 'UEDT', + offset: -5, + isdst: false, + text: '(UTC-05:00) Indiana (East)', + utc: ['America/Indiana/Marengo', 'America/Indiana/Vevay', 'America/Indianapolis'], + }, + { + value: 'Venezuela Standard Time', + abbr: 'VST', + offset: -4.5, + isdst: false, + text: '(UTC-04:30) Caracas', + utc: ['America/Caracas'], + }, + { + value: 'Paraguay Standard Time', + abbr: 'PYT', + offset: -4, + isdst: false, + text: '(UTC-04:00) Asuncion', + utc: ['America/Asuncion'], + }, + { + value: 'Atlantic Standard Time', + abbr: 'ADT', + offset: -3, + isdst: true, + text: '(UTC-04:00) Atlantic Time (Canada)', + utc: [ + 'America/Glace_Bay', + 'America/Goose_Bay', + 'America/Halifax', + 'America/Moncton', + 'America/Thule', + 'Atlantic/Bermuda', + ], + }, + { + value: 'Central Brazilian Standard Time', + abbr: 'CBST', + offset: -4, + isdst: false, + text: '(UTC-04:00) Cuiaba', + utc: ['America/Campo_Grande', 'America/Cuiaba'], + }, + { + value: 'SA Western Standard Time', + abbr: 'SWST', + offset: -4, + isdst: false, + text: '(UTC-04:00) Georgetown, La Paz, Manaus, San Juan', + utc: [ + 'America/Anguilla', + 'America/Antigua', + 'America/Aruba', + 'America/Barbados', + 'America/Blanc-Sablon', + 'America/Boa_Vista', + 'America/Curacao', + 'America/Dominica', + 'America/Grand_Turk', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Guyana', + 'America/Kralendijk', + 'America/La_Paz', + 'America/Lower_Princes', + 'America/Manaus', + 'America/Marigot', + 'America/Martinique', + 'America/Montserrat', + 'America/Port_of_Spain', + 'America/Porto_Velho', + 'America/Puerto_Rico', + 'America/Santo_Domingo', + 'America/St_Barthelemy', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Tortola', + 'Etc/GMT+4', + ], + }, + { + value: 'Pacific SA Standard Time', + abbr: 'PSST', + offset: -4, + isdst: false, + text: '(UTC-04:00) Santiago', + utc: ['America/Santiago', 'Antarctica/Palmer'], + }, + { + value: 'Newfoundland Standard Time', + abbr: 'NDT', + offset: -2.5, + isdst: true, + text: '(UTC-03:30) Newfoundland', + utc: ['America/St_Johns'], + }, + { + value: 'E. South America Standard Time', + abbr: 'ESAST', + offset: -3, + isdst: false, + text: '(UTC-03:00) Brasilia', + utc: ['America/Sao_Paulo'], + }, + { + value: 'Argentina Standard Time', + abbr: 'AST', + offset: -3, + isdst: false, + text: '(UTC-03:00) Buenos Aires', + utc: [ + 'America/Argentina/La_Rioja', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia', + 'America/Buenos_Aires', + 'America/Catamarca', + 'America/Cordoba', + 'America/Jujuy', + 'America/Mendoza', + ], + }, + { + value: 'SA Eastern Standard Time', + abbr: 'SEST', + offset: -3, + isdst: false, + text: '(UTC-03:00) Cayenne, Fortaleza', + utc: [ + 'America/Araguaina', + 'America/Belem', + 'America/Cayenne', + 'America/Fortaleza', + 'America/Maceio', + 'America/Paramaribo', + 'America/Recife', + 'America/Santarem', + 'Antarctica/Rothera', + 'Atlantic/Stanley', + 'Etc/GMT+3', + ], + }, + { + value: 'Greenland Standard Time', + abbr: 'GDT', + offset: -3, + isdst: true, + text: '(UTC-03:00) Greenland', + utc: ['America/Godthab'], + }, + { + value: 'Montevideo Standard Time', + abbr: 'MST', + offset: -3, + isdst: false, + text: '(UTC-03:00) Montevideo', + utc: ['America/Montevideo'], + }, + { + value: 'Bahia Standard Time', + abbr: 'BST', + offset: -3, + isdst: false, + text: '(UTC-03:00) Salvador', + utc: ['America/Bahia'], + }, + { + value: 'UTC-02', + abbr: 'U', + offset: -2, + isdst: false, + text: '(UTC-02:00) Coordinated Universal Time-02', + utc: ['America/Noronha', 'Atlantic/South_Georgia', 'Etc/GMT+2'], + }, + { + value: 'Azores Standard Time', + abbr: 'ADT', + offset: 0, + isdst: true, + text: '(UTC-01:00) Azores', + utc: ['America/Scoresbysund', 'Atlantic/Azores'], + }, + { + value: 'Cape Verde Standard Time', + abbr: 'CVST', + offset: -1, + isdst: false, + text: '(UTC-01:00) Cape Verde Is.', + utc: ['Atlantic/Cape_Verde', 'Etc/GMT+1'], + }, + { + value: 'Morocco Standard Time', + abbr: 'MDT', + offset: 1, + isdst: true, + text: '(UTC) Casablanca', + utc: ['Africa/Casablanca', 'Africa/El_Aaiun'], + }, + { + value: 'UTC', + abbr: 'UTC', + offset: 0, + isdst: false, + text: '(UTC) Coordinated Universal Time', + utc: ['America/Danmarkshavn', 'Etc/GMT'], + }, + { + value: 'British Summer Time', + abbr: 'BST', + offset: 1, + isdst: true, + text: '(UTC+01:00) Edinburgh, London', + utc: ['Europe/Isle_of_Man', 'Europe/Guernsey', 'Europe/Jersey', 'Europe/London'], + }, + { + value: 'GMT Standard Time', + abbr: 'GDT', + offset: 1, + isdst: true, + text: '(UTC) Dublin, Lisbon', + utc: [ + 'Atlantic/Canary', + 'Atlantic/Faeroe', + 'Atlantic/Madeira', + 'Europe/Dublin', + 'Europe/Lisbon', + ], + }, + { + value: 'Greenwich Standard Time', + abbr: 'GST', + offset: 0, + isdst: false, + text: '(UTC) Monrovia, Reykjavik', + utc: [ + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Bamako', + 'Africa/Banjul', + 'Africa/Bissau', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Freetown', + 'Africa/Lome', + 'Africa/Monrovia', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Sao_Tome', + 'Atlantic/Reykjavik', + 'Atlantic/St_Helena', + ], + }, + { + value: 'W. Europe Standard Time', + abbr: 'WEDT', + offset: 2, + isdst: true, + text: '(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna', + utc: [ + 'Arctic/Longyearbyen', + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Berlin', + 'Europe/Busingen', + 'Europe/Gibraltar', + 'Europe/Luxembourg', + 'Europe/Malta', + 'Europe/Monaco', + 'Europe/Oslo', + 'Europe/Rome', + 'Europe/San_Marino', + 'Europe/Stockholm', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Zurich', + ], + }, + { + value: 'Central Europe Standard Time', + abbr: 'CEDT', + offset: 2, + isdst: true, + text: '(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague', + utc: [ + 'Europe/Belgrade', + 'Europe/Bratislava', + 'Europe/Budapest', + 'Europe/Ljubljana', + 'Europe/Podgorica', + 'Europe/Prague', + 'Europe/Tirane', + ], + }, + { + value: 'Romance Standard Time', + abbr: 'RDT', + offset: 2, + isdst: true, + text: '(UTC+01:00) Brussels, Copenhagen, Madrid, Paris', + utc: ['Africa/Ceuta', 'Europe/Brussels', 'Europe/Copenhagen', 'Europe/Madrid', 'Europe/Paris'], + }, + { + value: 'Central European Standard Time', + abbr: 'CEDT', + offset: 2, + isdst: true, + text: '(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb', + utc: ['Europe/Sarajevo', 'Europe/Skopje', 'Europe/Warsaw', 'Europe/Zagreb'], + }, + { + value: 'W. Central Africa Standard Time', + abbr: 'WCAST', + offset: 1, + isdst: false, + text: '(UTC+01:00) West Central Africa', + utc: [ + 'Africa/Algiers', + 'Africa/Bangui', + 'Africa/Brazzaville', + 'Africa/Douala', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Luanda', + 'Africa/Malabo', + 'Africa/Ndjamena', + 'Africa/Niamey', + 'Africa/Porto-Novo', + 'Africa/Tunis', + 'Etc/GMT-1', + ], + }, + { + value: 'Namibia Standard Time', + abbr: 'NST', + offset: 1, + isdst: false, + text: '(UTC+01:00) Windhoek', + utc: ['Africa/Windhoek'], + }, + { + value: 'GTB Standard Time', + abbr: 'GDT', + offset: 3, + isdst: true, + text: '(UTC+02:00) Athens, Bucharest', + utc: ['Asia/Nicosia', 'Europe/Athens', 'Europe/Bucharest', 'Europe/Chisinau'], + }, + { + value: 'Middle East Standard Time', + abbr: 'MEDT', + offset: 3, + isdst: true, + text: '(UTC+02:00) Beirut', + utc: ['Asia/Beirut'], + }, + { + value: 'Egypt Standard Time', + abbr: 'EST', + offset: 2, + isdst: false, + text: '(UTC+02:00) Cairo', + utc: ['Africa/Cairo'], + }, + { + value: 'Syria Standard Time', + abbr: 'SDT', + offset: 3, + isdst: true, + text: '(UTC+02:00) Damascus', + utc: ['Asia/Damascus'], + }, + { + value: 'E. Europe Standard Time', + abbr: 'EEDT', + offset: 3, + isdst: true, + text: '(UTC+02:00) E. Europe', + utc: [ + 'Asia/Nicosia', + 'Europe/Athens', + 'Europe/Bucharest', + 'Europe/Chisinau', + 'Europe/Helsinki', + 'Europe/Kiev', + 'Europe/Mariehamn', + 'Europe/Nicosia', + 'Europe/Riga', + 'Europe/Sofia', + 'Europe/Tallinn', + 'Europe/Uzhgorod', + 'Europe/Vilnius', + 'Europe/Zaporozhye', + ], + }, + { + value: 'South Africa Standard Time', + abbr: 'SAST', + offset: 2, + isdst: false, + text: '(UTC+02:00) Harare, Pretoria', + utc: [ + 'Africa/Blantyre', + 'Africa/Bujumbura', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Kigali', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Etc/GMT-2', + ], + }, + { + value: 'FLE Standard Time', + abbr: 'FDT', + offset: 3, + isdst: true, + text: '(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius', + utc: [ + 'Europe/Helsinki', + 'Europe/Kiev', + 'Europe/Mariehamn', + 'Europe/Riga', + 'Europe/Sofia', + 'Europe/Tallinn', + 'Europe/Uzhgorod', + 'Europe/Vilnius', + 'Europe/Zaporozhye', + ], + }, + { + value: 'Turkey Standard Time', + abbr: 'TDT', + offset: 3, + isdst: false, + text: '(UTC+03:00) Istanbul', + utc: ['Europe/Istanbul'], + }, + { + value: 'Israel Standard Time', + abbr: 'JDT', + offset: 3, + isdst: true, + text: '(UTC+02:00) Jerusalem', + utc: ['Asia/Jerusalem'], + }, + { + value: 'Libya Standard Time', + abbr: 'LST', + offset: 2, + isdst: false, + text: '(UTC+02:00) Tripoli', + utc: ['Africa/Tripoli'], + }, + { + value: 'Jordan Standard Time', + abbr: 'JST', + offset: 3, + isdst: false, + text: '(UTC+03:00) Amman', + utc: ['Asia/Amman'], + }, + { + value: 'Arabic Standard Time', + abbr: 'AST', + offset: 3, + isdst: false, + text: '(UTC+03:00) Baghdad', + utc: ['Asia/Baghdad'], + }, + { + value: 'Kaliningrad Standard Time', + abbr: 'KST', + offset: 3, + isdst: false, + text: '(UTC+02:00) Kaliningrad', + utc: ['Europe/Kaliningrad'], + }, + { + value: 'Arab Standard Time', + abbr: 'AST', + offset: 3, + isdst: false, + text: '(UTC+03:00) Kuwait, Riyadh', + utc: ['Asia/Aden', 'Asia/Bahrain', 'Asia/Kuwait', 'Asia/Qatar', 'Asia/Riyadh'], + }, + { + value: 'E. Africa Standard Time', + abbr: 'EAST', + offset: 3, + isdst: false, + text: '(UTC+03:00) Nairobi', + utc: [ + 'Africa/Addis_Ababa', + 'Africa/Asmera', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Juba', + 'Africa/Kampala', + 'Africa/Khartoum', + 'Africa/Mogadishu', + 'Africa/Nairobi', + 'Antarctica/Syowa', + 'Etc/GMT-3', + 'Indian/Antananarivo', + 'Indian/Comoro', + 'Indian/Mayotte', + ], + }, + { + value: 'Moscow Standard Time', + abbr: 'MSK', + offset: 3, + isdst: false, + text: '(UTC+03:00) Moscow, St. Petersburg, Volgograd, Minsk', + utc: ['Europe/Kirov', 'Europe/Moscow', 'Europe/Simferopol', 'Europe/Volgograd', 'Europe/Minsk'], + }, + { + value: 'Samara Time', + abbr: 'SAMT', + offset: 4, + isdst: false, + text: '(UTC+04:00) Samara, Ulyanovsk, Saratov', + utc: ['Europe/Astrakhan', 'Europe/Samara', 'Europe/Ulyanovsk'], + }, + { + value: 'Iran Standard Time', + abbr: 'IDT', + offset: 4.5, + isdst: true, + text: '(UTC+03:30) Tehran', + utc: ['Asia/Tehran'], + }, + { + value: 'Arabian Standard Time', + abbr: 'AST', + offset: 4, + isdst: false, + text: '(UTC+04:00) Abu Dhabi, Muscat', + utc: ['Asia/Dubai', 'Asia/Muscat', 'Etc/GMT-4'], + }, + { + value: 'Azerbaijan Standard Time', + abbr: 'ADT', + offset: 5, + isdst: true, + text: '(UTC+04:00) Baku', + utc: ['Asia/Baku'], + }, + { + value: 'Mauritius Standard Time', + abbr: 'MST', + offset: 4, + isdst: false, + text: '(UTC+04:00) Port Louis', + utc: ['Indian/Mahe', 'Indian/Mauritius', 'Indian/Reunion'], + }, + { + value: 'Georgian Standard Time', + abbr: 'GET', + offset: 4, + isdst: false, + text: '(UTC+04:00) Tbilisi', + utc: ['Asia/Tbilisi'], + }, + { + value: 'Caucasus Standard Time', + abbr: 'CST', + offset: 4, + isdst: false, + text: '(UTC+04:00) Yerevan', + utc: ['Asia/Yerevan'], + }, + { + value: 'Afghanistan Standard Time', + abbr: 'AST', + offset: 4.5, + isdst: false, + text: '(UTC+04:30) Kabul', + utc: ['Asia/Kabul'], + }, + { + value: 'West Asia Standard Time', + abbr: 'WAST', + offset: 5, + isdst: false, + text: '(UTC+05:00) Ashgabat, Tashkent', + utc: [ + 'Antarctica/Mawson', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Ashgabat', + 'Asia/Dushanbe', + 'Asia/Oral', + 'Asia/Samarkand', + 'Asia/Tashkent', + 'Etc/GMT-5', + 'Indian/Kerguelen', + 'Indian/Maldives', + ], + }, + { + value: 'Yekaterinburg Time', + abbr: 'YEKT', + offset: 5, + isdst: false, + text: '(UTC+05:00) Yekaterinburg', + utc: ['Asia/Yekaterinburg'], + }, + { + value: 'Pakistan Standard Time', + abbr: 'PKT', + offset: 5, + isdst: false, + text: '(UTC+05:00) Islamabad, Karachi', + utc: ['Asia/Karachi'], + }, + { + value: 'India Standard Time', + abbr: 'IST', + offset: 5.5, + isdst: false, + text: '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi', + utc: ['Asia/Kolkata', 'Asia/Calcutta'], + }, + { + value: 'Sri Lanka Standard Time', + abbr: 'SLST', + offset: 5.5, + isdst: false, + text: '(UTC+05:30) Sri Jayawardenepura', + utc: ['Asia/Colombo'], + }, + { + value: 'Nepal Standard Time', + abbr: 'NST', + offset: 5.75, + isdst: false, + text: '(UTC+05:45) Kathmandu', + utc: ['Asia/Kathmandu'], + }, + { + value: 'Central Asia Standard Time', + abbr: 'CAST', + offset: 6, + isdst: false, + text: '(UTC+06:00) Nur-Sultan (Astana)', + utc: [ + 'Antarctica/Vostok', + 'Asia/Almaty', + 'Asia/Bishkek', + 'Asia/Qyzylorda', + 'Asia/Urumqi', + 'Etc/GMT-6', + 'Indian/Chagos', + ], + }, + { + value: 'Bangladesh Standard Time', + abbr: 'BST', + offset: 6, + isdst: false, + text: '(UTC+06:00) Dhaka', + utc: ['Asia/Dhaka', 'Asia/Thimphu'], + }, + { + value: 'Myanmar Standard Time', + abbr: 'MST', + offset: 6.5, + isdst: false, + text: '(UTC+06:30) Yangon (Rangoon)', + utc: ['Asia/Rangoon', 'Indian/Cocos'], + }, + { + value: 'SE Asia Standard Time', + abbr: 'SAST', + offset: 7, + isdst: false, + text: '(UTC+07:00) Bangkok, Hanoi, Jakarta', + utc: [ + 'Antarctica/Davis', + 'Asia/Bangkok', + 'Asia/Hovd', + 'Asia/Jakarta', + 'Asia/Phnom_Penh', + 'Asia/Pontianak', + 'Asia/Saigon', + 'Asia/Vientiane', + 'Etc/GMT-7', + 'Indian/Christmas', + ], + }, + { + value: 'N. Central Asia Standard Time', + abbr: 'NCAST', + offset: 7, + isdst: false, + text: '(UTC+07:00) Novosibirsk', + utc: ['Asia/Novokuznetsk', 'Asia/Novosibirsk', 'Asia/Omsk'], + }, + { + value: 'China Standard Time', + abbr: 'CST', + offset: 8, + isdst: false, + text: '(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi', + utc: ['Asia/Hong_Kong', 'Asia/Macau', 'Asia/Shanghai'], + }, + { + value: 'North Asia Standard Time', + abbr: 'NAST', + offset: 8, + isdst: false, + text: '(UTC+08:00) Krasnoyarsk', + utc: ['Asia/Krasnoyarsk'], + }, + { + value: 'Singapore Standard Time', + abbr: 'MPST', + offset: 8, + isdst: false, + text: '(UTC+08:00) Kuala Lumpur, Singapore', + utc: [ + 'Asia/Brunei', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Asia/Makassar', + 'Asia/Manila', + 'Asia/Singapore', + 'Etc/GMT-8', + ], + }, + { + value: 'W. Australia Standard Time', + abbr: 'WAST', + offset: 8, + isdst: false, + text: '(UTC+08:00) Perth', + utc: ['Antarctica/Casey', 'Australia/Perth'], + }, + { + value: 'Taipei Standard Time', + abbr: 'TST', + offset: 8, + isdst: false, + text: '(UTC+08:00) Taipei', + utc: ['Asia/Taipei'], + }, + { + value: 'Ulaanbaatar Standard Time', + abbr: 'UST', + offset: 8, + isdst: false, + text: '(UTC+08:00) Ulaanbaatar', + utc: ['Asia/Choibalsan', 'Asia/Ulaanbaatar'], + }, + { + value: 'North Asia East Standard Time', + abbr: 'NAEST', + offset: 8, + isdst: false, + text: '(UTC+08:00) Irkutsk', + utc: ['Asia/Irkutsk'], + }, + { + value: 'Japan Standard Time', + abbr: 'JST', + offset: 9, + isdst: false, + text: '(UTC+09:00) Osaka, Sapporo, Tokyo', + utc: ['Asia/Dili', 'Asia/Jayapura', 'Asia/Tokyo', 'Etc/GMT-9', 'Pacific/Palau'], + }, + { + value: 'Korea Standard Time', + abbr: 'KST', + offset: 9, + isdst: false, + text: '(UTC+09:00) Seoul', + utc: ['Asia/Pyongyang', 'Asia/Seoul'], + }, + { + value: 'Cen. Australia Standard Time', + abbr: 'CAST', + offset: 9.5, + isdst: false, + text: '(UTC+09:30) Adelaide', + utc: ['Australia/Adelaide', 'Australia/Broken_Hill'], + }, + { + value: 'AUS Central Standard Time', + abbr: 'ACST', + offset: 9.5, + isdst: false, + text: '(UTC+09:30) Darwin', + utc: ['Australia/Darwin'], + }, + { + value: 'E. Australia Standard Time', + abbr: 'EAST', + offset: 10, + isdst: false, + text: '(UTC+10:00) Brisbane', + utc: ['Australia/Brisbane', 'Australia/Lindeman'], + }, + { + value: 'AUS Eastern Standard Time', + abbr: 'AEST', + offset: 10, + isdst: false, + text: '(UTC+10:00) Canberra, Melbourne, Sydney', + utc: ['Australia/Melbourne', 'Australia/Sydney'], + }, + { + value: 'West Pacific Standard Time', + abbr: 'WPST', + offset: 10, + isdst: false, + text: '(UTC+10:00) Guam, Port Moresby', + utc: [ + 'Antarctica/DumontDUrville', + 'Etc/GMT-10', + 'Pacific/Guam', + 'Pacific/Port_Moresby', + 'Pacific/Saipan', + 'Pacific/Truk', + ], + }, + { + value: 'Tasmania Standard Time', + abbr: 'TST', + offset: 10, + isdst: false, + text: '(UTC+10:00) Hobart', + utc: ['Australia/Currie', 'Australia/Hobart'], + }, + { + value: 'Yakutsk Standard Time', + abbr: 'YST', + offset: 9, + isdst: false, + text: '(UTC+09:00) Yakutsk', + utc: ['Asia/Chita', 'Asia/Khandyga', 'Asia/Yakutsk'], + }, + { + value: 'Central Pacific Standard Time', + abbr: 'CPST', + offset: 11, + isdst: false, + text: '(UTC+11:00) Solomon Is., New Caledonia', + utc: [ + 'Antarctica/Macquarie', + 'Etc/GMT-11', + 'Pacific/Efate', + 'Pacific/Guadalcanal', + 'Pacific/Kosrae', + 'Pacific/Noumea', + 'Pacific/Ponape', + ], + }, + { + value: 'Vladivostok Standard Time', + abbr: 'VST', + offset: 11, + isdst: false, + text: '(UTC+11:00) Vladivostok', + utc: ['Asia/Sakhalin', 'Asia/Ust-Nera', 'Asia/Vladivostok'], + }, + { + value: 'New Zealand Standard Time', + abbr: 'NZST', + offset: 12, + isdst: false, + text: '(UTC+12:00) Auckland, Wellington', + utc: ['Antarctica/McMurdo', 'Pacific/Auckland'], + }, + { + value: 'UTC+12', + abbr: 'U', + offset: 12, + isdst: false, + text: '(UTC+12:00) Coordinated Universal Time+12', + utc: [ + 'Etc/GMT-12', + 'Pacific/Funafuti', + 'Pacific/Kwajalein', + 'Pacific/Majuro', + 'Pacific/Nauru', + 'Pacific/Tarawa', + 'Pacific/Wake', + 'Pacific/Wallis', + ], + }, + { + value: 'Fiji Standard Time', + abbr: 'FST', + offset: 12, + isdst: false, + text: '(UTC+12:00) Fiji', + utc: ['Pacific/Fiji'], + }, + { + value: 'Magadan Standard Time', + abbr: 'MST', + offset: 12, + isdst: false, + text: '(UTC+12:00) Magadan', + utc: ['Asia/Anadyr', 'Asia/Kamchatka', 'Asia/Magadan', 'Asia/Srednekolymsk'], + }, + { + value: 'Kamchatka Standard Time', + abbr: 'KDT', + offset: 13, + isdst: true, + text: '(UTC+12:00) Petropavlovsk-Kamchatsky - Old', + utc: ['Asia/Kamchatka'], + }, + { + value: 'Tonga Standard Time', + abbr: 'TST', + offset: 13, + isdst: false, + text: "(UTC+13:00) Nuku'alofa", + utc: ['Etc/GMT-13', 'Pacific/Enderbury', 'Pacific/Fakaofo', 'Pacific/Tongatapu'], + }, + { + value: 'Samoa Standard Time', + abbr: 'SST', + offset: 13, + isdst: false, + text: '(UTC+13:00) Samoa', + utc: ['Pacific/Apia'], + }, +] + +/** + * Deduplicated view of `ALL_TIMEZONES`, keyed by the entry's primary IANA + * name (`utc[0]`). Several catalog rows share an IANA target — e.g. PDT and + * PST both point to `America/Los_Angeles` — which would render duplicate UI + * rows that all store the same value when picked. We prefer the standard-time + * (`isdst: false`) row when both are present. + */ +export const TIMEZONES_BY_IANA: Timezone[] = (() => { + const seen = new Map() + for (const entry of ALL_TIMEZONES) { + const key = entry.utc[0] + if (!key) continue + const existing = seen.get(key) + if (!existing || (existing.isdst && !entry.isdst)) { + seen.set(key, entry) + } + } + return Array.from(seen.values()) +})() + +/** Look up the catalog entry that owns the given IANA name (any of `utc[]`). */ +export const findTimezoneByIana = (iana: string): Timezone | undefined => + ALL_TIMEZONES.find((entry) => entry.utc.includes(iana)) diff --git a/apps/studio/lib/datetime.test.ts b/apps/studio/lib/datetime.test.ts new file mode 100644 index 00000000000..aea878c0fd9 --- /dev/null +++ b/apps/studio/lib/datetime.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +import { + formatDate, + formatDateTime, + formatFromNow, + formatTime, + resolveTimezone, + toTimezone, +} from '@/lib/datetime' + +// Fixed reference points so the assertions don't depend on the host machine's +// timezone or current wall-clock time. +const SUMMER_UTC = '2025-06-15T12:34:56Z' +const WINTER_UTC = '2025-01-15T12:34:56Z' +// 16-digit unix microseconds equivalent of SUMMER_UTC. +const SUMMER_UNIX_MICRO = '1749990896000000' + +describe('formatDateTime', () => { + it('renders a UTC instant in Asia/Tokyo (UTC+9, no DST)', () => { + expect(formatDateTime(SUMMER_UTC, { tz: 'Asia/Tokyo' })).toBe('15 Jun 2025 21:34:56') + }) + + it('renders a UTC instant in America/Los_Angeles during DST (UTC-7)', () => { + expect(formatDateTime(SUMMER_UTC, { tz: 'America/Los_Angeles' })).toBe('15 Jun 2025 05:34:56') + }) + + it('renders a UTC instant in America/Los_Angeles outside DST (UTC-8)', () => { + expect(formatDateTime(WINTER_UTC, { tz: 'America/Los_Angeles' })).toBe('15 Jan 2025 04:34:56') + }) + + it('flips the wall-clock day when crossing date boundaries', () => { + const lateUtc = '2025-06-15T22:00:00Z' + expect(formatDateTime(lateUtc, { tz: 'Asia/Tokyo', format: 'YYYY-MM-DD' })).toBe('2025-06-16') + expect(formatDateTime(lateUtc, { tz: 'Pacific/Honolulu', format: 'YYYY-MM-DD' })).toBe( + '2025-06-15' + ) + }) + + it('respects an explicit format string', () => { + expect(formatDateTime(SUMMER_UTC, { tz: 'UTC', format: 'YYYY-MM-DDTHH:mm:ssZ' })).toBe( + '2025-06-15T12:34:56+00:00' + ) + }) + + it('accepts unix microsecond timestamps', () => { + expect(formatDateTime(SUMMER_UNIX_MICRO, { tz: 'UTC' })).toBe('15 Jun 2025 12:34:56') + }) + + it('accepts Date instances', () => { + expect(formatDateTime(new Date(SUMMER_UTC), { tz: 'UTC' })).toBe('15 Jun 2025 12:34:56') + }) +}) + +describe('DST transitions in Europe/Berlin', () => { + it('shows +01:00 before the spring transition', () => { + expect(formatDateTime('2025-03-30T00:00:00Z', { tz: 'Europe/Berlin', format: 'HH:mm Z' })).toBe( + '01:00 +01:00' + ) + }) + + it('shows +02:00 after the spring transition', () => { + expect(formatDateTime('2025-03-30T02:00:00Z', { tz: 'Europe/Berlin', format: 'HH:mm Z' })).toBe( + '04:00 +02:00' + ) + }) +}) + +describe('formatDate / formatTime', () => { + it('formatDate uses the date-only default', () => { + expect(formatDate(SUMMER_UTC, { tz: 'UTC' })).toBe('15 Jun 2025') + }) + + it('formatTime uses the time-only default', () => { + expect(formatTime(SUMMER_UTC, { tz: 'UTC' })).toBe('12:34:56') + }) +}) + +describe('resolveTimezone', () => { + it('returns the input when it is a valid IANA name', () => { + expect(resolveTimezone('Asia/Tokyo')).toBe('Asia/Tokyo') + }) + + it('falls back to the guessed local timezone when input is empty', () => { + expect(resolveTimezone('')).toBeTruthy() + expect(resolveTimezone(null)).toBeTruthy() + expect(resolveTimezone(undefined)).toBeTruthy() + }) + + it('falls back when the input is not a real IANA zone', () => { + // Should not throw, should resolve to something we can format with. + const tz = resolveTimezone('Not/A/Real_Zone') + expect(() => formatDateTime(SUMMER_UTC, { tz })).not.toThrow() + }) +}) + +describe('toTimezone', () => { + it('returns a Dayjs pinned to the given timezone for chained calls', () => { + const d = toTimezone(SUMMER_UTC, 'Asia/Tokyo') + expect(d.format('HH:mm')).toBe('21:34') + expect(d.hour()).toBe(21) + }) +}) + +describe('formatFromNow', () => { + beforeAll(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-06-15T13:34:56Z')) + }) + + afterAll(() => { + vi.useRealTimers() + }) + + it('renders ISO inputs as a relative duration', () => { + expect(formatFromNow(SUMMER_UTC)).toBe('an hour ago') + }) + + it('accepts unix microsecond inputs', () => { + expect(formatFromNow(SUMMER_UNIX_MICRO)).toBe('an hour ago') + }) +}) diff --git a/apps/studio/lib/datetime.tsx b/apps/studio/lib/datetime.tsx new file mode 100644 index 00000000000..3310ed743cc --- /dev/null +++ b/apps/studio/lib/datetime.tsx @@ -0,0 +1,185 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import dayjs, { type Dayjs } from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import timezone from 'dayjs/plugin/timezone' +import utc from 'dayjs/plugin/utc' +import { createContext, useCallback, useContext, useEffect, useMemo, type ReactNode } from 'react' + +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { guessLocalTimezone } from '@/lib/dayjs' + +// dayjs.extend is idempotent. Extending here removes the implicit dependency +// on _app.tsx running first (e.g. Storybook, isolated scripts). +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(relativeTime) + +export type DateInput = string | number | Date | Dayjs + +const isUnixMicro = (value: string | number): boolean => { + const digits = String(value).length + const isNum = !Number.isNaN(Number(value)) + return isNum && digits === 16 +} + +const unixMicroToIso = (value: string | number): string => + dayjs.unix(Number(value) / 1_000_000).toISOString() + +const normalize = (input: DateInput): Dayjs => { + if (dayjs.isDayjs(input)) return input + if (input instanceof Date) return dayjs(input) + if ((typeof input === 'string' || typeof input === 'number') && isUnixMicro(input)) { + return dayjs.utc(unixMicroToIso(input)) + } + return dayjs.utc(input) +} + +const isValidTimezone = (tz: string): boolean => { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }) + return true + } catch { + return false + } +} + +/** + * Resolve a user-supplied timezone to a valid IANA name. Falls back to the + * browser's guessed timezone, then UTC. Pass `undefined`/empty string to opt + * into the guessed default. + */ +export const resolveTimezone = (tz: string | undefined | null): string => { + if (tz && isValidTimezone(tz)) return tz + return guessLocalTimezone() +} + +const DEFAULT_DATETIME_FORMAT = 'DD MMM YYYY HH:mm:ss' +const DEFAULT_DATE_FORMAT = 'DD MMM YYYY' +const DEFAULT_TIME_FORMAT = 'HH:mm:ss' + +interface FormatOptions { + /** IANA timezone (e.g. 'Asia/Tokyo'). Falls back to guessed local. */ + tz?: string + /** dayjs format string. */ + format?: string +} + +export const formatDateTime = (input: DateInput, opts: FormatOptions = {}): string => + normalize(input) + .tz(resolveTimezone(opts.tz)) + .format(opts.format ?? DEFAULT_DATETIME_FORMAT) + +export const formatDate = (input: DateInput, opts: FormatOptions = {}): string => + normalize(input) + .tz(resolveTimezone(opts.tz)) + .format(opts.format ?? DEFAULT_DATE_FORMAT) + +export const formatTime = (input: DateInput, opts: FormatOptions = {}): string => + normalize(input) + .tz(resolveTimezone(opts.tz)) + .format(opts.format ?? DEFAULT_TIME_FORMAT) + +/** Returns a humanised relative time, e.g. "3 minutes ago". */ +export const formatFromNow = (input: DateInput): string => normalize(input).fromNow() + +/** Returns the input as a Dayjs instance pinned to the given timezone. */ +export const toTimezone = (input: DateInput, tz?: string): Dayjs => + normalize(input).tz(resolveTimezone(tz)) + +interface TimezoneContextValue { + /** The resolved IANA timezone currently in use. Always valid. */ + timezone: string + /** The user's stored preference. Empty string means "use guessed local". */ + storedTimezone: string + /** Update the stored preference. Pass an empty string to clear (use guessed). */ + setTimezone: (tz: string) => void + /** Whether the current selection is the auto-detected default. */ + isAutoDetected: boolean +} + +const TimezoneContext = createContext(undefined) + +export const TimezoneProvider = ({ children }: { children: ReactNode }) => { + const [storedTimezone, setStoredTimezone] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.UI_TIMEZONE, + '' + ) + + const timezone = useMemo(() => resolveTimezone(storedTimezone), [storedTimezone]) + + // Apply the selected timezone as the dayjs default so anything calling + // `dayjs.tz()` or `.tz()` without an argument picks it up. Bare `dayjs()` + // calls are unaffected by design — those continue to render in the host + // browser's timezone until they're intentionally migrated to the wrappers + // below. + useEffect(() => { + dayjs.tz.setDefault(timezone) + }, [timezone]) + + const setTimezone = useCallback( + (tz: string) => { + setStoredTimezone(tz) + }, + [setStoredTimezone] + ) + + const value = useMemo( + () => ({ + timezone, + storedTimezone, + setTimezone, + isAutoDetected: !storedTimezone, + }), + [timezone, storedTimezone, setTimezone] + ) + + return {children} +} + +// Stable fallback so callers outside the provider (e.g. unit tests, isolated +// stories) don't get a fresh object identity every render. +const NO_OP_SET_TIMEZONE = () => {} + +export const useTimezone = (): TimezoneContextValue => { + const ctx = useContext(TimezoneContext) + return useMemo( + () => + ctx ?? { + timezone: guessLocalTimezone(), + storedTimezone: '', + setTimezone: NO_OP_SET_TIMEZONE, + isAutoDetected: true, + }, + [ctx] + ) +} + +/** Returns a memoised `(input, format?) => string` bound to the active timezone. */ +export const useFormatDateTime = () => { + const { timezone } = useTimezone() + return useCallback( + (input: DateInput, format?: string) => formatDateTime(input, { tz: timezone, format }), + [timezone] + ) +} + +export const useFormatDate = () => { + const { timezone } = useTimezone() + return useCallback( + (input: DateInput, format?: string) => formatDate(input, { tz: timezone, format }), + [timezone] + ) +} + +export const useFormatTime = () => { + const { timezone } = useTimezone() + return useCallback( + (input: DateInput, format?: string) => formatTime(input, { tz: timezone, format }), + [timezone] + ) +} + +export const useToTimezone = () => { + const { timezone } = useTimezone() + return useCallback((input: DateInput) => toTimezone(input, timezone), [timezone]) +} diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index d352a24054f..c764c35b254 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -39,6 +39,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { ErrorInfo, useCallback, type ComponentProps } from 'react' import { ErrorBoundary } from 'react-error-boundary' import { TooltipProvider } from 'ui' +import { TimestampInfoProvider } from 'ui-patterns' import { StudioCommandMenu } from '@/components/interfaces/App/CommandMenu' import { StudioCommandProvider as CommandProvider } from '@/components/interfaces/App/CommandMenu/StudioCommandProvider' @@ -57,6 +58,7 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { AuthProvider } from '@/lib/auth' import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants' +import { TimezoneProvider, useTimezone } from '@/lib/datetime' import { ProfileProvider } from '@/lib/profile' import { Telemetry } from '@/lib/telemetry' import { Toaster } from '@/lib/toaster' @@ -96,6 +98,11 @@ const FeatureFlagProviderWithOrgContext = ({ ) } +const TimestampInfoTimezoneBridge = ({ children }: { children: React.ReactNode }) => { + const { timezone } = useTimezone() + return {children} +} + loader.config({ // [Joshen] Attempt for offline support/bypass ISP issues is to store the assets required for monaco // locally. We're however, only storing the assets which we need (based on what the network tab loads @@ -159,58 +166,62 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { getConfigCatFlags={getConfigCatFlags} > - - {appTitle ?? 'Supabase'} - - - - {/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */} -