diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index 8798e80440e..3be75536b2f 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -17,6 +17,7 @@ from .views import urlpatterns as view_urls from .webhook import urlpatterns as webhook_urls from .workspace import urlpatterns as workspace_urls +from .timezone import urlpatterns as timezone_urls urlpatterns = [ *analytic_urls, @@ -38,4 +39,5 @@ *workspace_urls, *api_urls, *webhook_urls, + *timezone_urls, ] diff --git a/apiserver/plane/app/urls/timezone.py b/apiserver/plane/app/urls/timezone.py new file mode 100644 index 00000000000..ff14d029f2e --- /dev/null +++ b/apiserver/plane/app/urls/timezone.py @@ -0,0 +1,8 @@ +from django.urls import path + +from plane.app.views import TimezoneEndpoint + +urlpatterns = [ + # timezone endpoint + path("timezones/", TimezoneEndpoint.as_view(), name="timezone-list") +] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 581a1065d96..845cc813074 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -204,3 +204,5 @@ from .notification.base import MarkAllReadNotificationViewSet from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint + +from .timezone.base import TimezoneEndpoint diff --git a/apiserver/plane/app/views/timezone/base.py b/apiserver/plane/app/views/timezone/base.py new file mode 100644 index 00000000000..77c87704736 --- /dev/null +++ b/apiserver/plane/app/views/timezone/base.py @@ -0,0 +1,247 @@ +# Python imports +import pytz +from datetime import datetime + +# Django imports +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView + +# Module imports +from plane.authentication.rate_limit import AuthenticationThrottle + + +class TimezoneEndpoint(APIView): + permission_classes = [AllowAny] + + throttle_classes = [AuthenticationThrottle] + + @method_decorator(cache_page(60 * 60 * 24)) + def get(self, request): + timezone_mapping = { + "-1100": [ + ("Midway Island", "Pacific/Midway"), + ("American Samoa", "Pacific/Pago_Pago"), + ], + "-1000": [ + ("Hawaii", "Pacific/Honolulu"), + ("Aleutian Islands", "America/Adak"), + ], + "-0930": [("Marquesas Islands", "Pacific/Marquesas")], + "-0900": [ + ("Alaska", "America/Anchorage"), + ("Gambier Islands", "Pacific/Gambier"), + ], + "-0800": [ + ("Pacific Time (US and Canada)", "America/Los_Angeles"), + ("Baja California", "America/Tijuana"), + ], + "-0700": [ + ("Mountain Time (US and Canada)", "America/Denver"), + ("Arizona", "America/Phoenix"), + ("Chihuahua, Mazatlan", "America/Chihuahua"), + ], + "-0600": [ + ("Central Time (US and Canada)", "America/Chicago"), + ("Saskatchewan", "America/Regina"), + ("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"), + ("Tegucigalpa, Honduras", "America/Tegucigalpa"), + ("Costa Rica", "America/Costa_Rica"), + ], + "-0500": [ + ("Eastern Time (US and Canada)", "America/New_York"), + ("Lima", "America/Lima"), + ("Bogota", "America/Bogota"), + ("Quito", "America/Guayaquil"), + ("Chetumal", "America/Cancun"), + ], + "-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")], + "-0400": [ + ("Atlantic Time (Canada)", "America/Halifax"), + ("Caracas", "America/Caracas"), + ("Santiago", "America/Santiago"), + ("La Paz", "America/La_Paz"), + ("Manaus", "America/Manaus"), + ("Georgetown", "America/Guyana"), + ("Bermuda", "Atlantic/Bermuda"), + ], + "-0330": [("Newfoundland Time (Canada)", "America/St_Johns")], + "-0300": [ + ("Buenos Aires", "America/Argentina/Buenos_Aires"), + ("Brasilia", "America/Sao_Paulo"), + ("Greenland", "America/Godthab"), + ("Montevideo", "America/Montevideo"), + ("Falkland Islands", "Atlantic/Stanley"), + ], + "-0200": [ + ( + "South Georgia and the South Sandwich Islands", + "Atlantic/South_Georgia", + ) + ], + "-0100": [ + ("Azores", "Atlantic/Azores"), + ("Cape Verde Islands", "Atlantic/Cape_Verde"), + ], + "+0000": [ + ("Dublin", "Europe/Dublin"), + ("Reykjavik", "Atlantic/Reykjavik"), + ("Lisbon", "Europe/Lisbon"), + ("Monrovia", "Africa/Monrovia"), + ("Casablanca", "Africa/Casablanca"), + ], + "+0100": [ + ("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"), + ("West Central Africa", "Africa/Lagos"), + ("Algiers", "Africa/Algiers"), + ("Lagos", "Africa/Lagos"), + ("Tunis", "Africa/Tunis"), + ], + "+0200": [ + ("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"), + ("Athens", "Europe/Athens"), + ("Jerusalem", "Asia/Jerusalem"), + ("Johannesburg", "Africa/Johannesburg"), + ("Harare, Pretoria", "Africa/Harare"), + ], + "+0300": [ + ("Moscow Time", "Europe/Moscow"), + ("Baghdad", "Asia/Baghdad"), + ("Nairobi", "Africa/Nairobi"), + ("Kuwait, Riyadh", "Asia/Riyadh"), + ], + "+0330": [("Tehran", "Asia/Tehran")], + "+0400": [ + ("Abu Dhabi", "Asia/Dubai"), + ("Baku", "Asia/Baku"), + ("Yerevan", "Asia/Yerevan"), + ("Astrakhan", "Europe/Astrakhan"), + ("Tbilisi", "Asia/Tbilisi"), + ("Mauritius", "Indian/Mauritius"), + ], + "+0500": [ + ("Islamabad", "Asia/Karachi"), + ("Karachi", "Asia/Karachi"), + ("Tashkent", "Asia/Tashkent"), + ("Yekaterinburg", "Asia/Yekaterinburg"), + ("Maldives", "Indian/Maldives"), + ("Chagos", "Indian/Chagos"), + ], + "+0530": [ + ("Chennai", "Asia/Kolkata"), + ("Kolkata", "Asia/Kolkata"), + ("Mumbai", "Asia/Kolkata"), + ("New Delhi", "Asia/Kolkata"), + ("Sri Jayawardenepura", "Asia/Colombo"), + ], + "+0545": [("Kathmandu", "Asia/Kathmandu")], + "+0600": [ + ("Dhaka", "Asia/Dhaka"), + ("Almaty", "Asia/Almaty"), + ("Bishkek", "Asia/Bishkek"), + ("Thimphu", "Asia/Thimphu"), + ], + "+0630": [ + ("Yangon (Rangoon)", "Asia/Yangon"), + ("Cocos Islands", "Indian/Cocos"), + ], + "+0700": [ + ("Bangkok", "Asia/Bangkok"), + ("Hanoi", "Asia/Ho_Chi_Minh"), + ("Jakarta", "Asia/Jakarta"), + ("Novosibirsk", "Asia/Novosibirsk"), + ("Krasnoyarsk", "Asia/Krasnoyarsk"), + ], + "+0800": [ + ("Beijing", "Asia/Shanghai"), + ("Singapore", "Asia/Singapore"), + ("Perth", "Australia/Perth"), + ("Hong Kong", "Asia/Hong_Kong"), + ("Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Palau", "Pacific/Palau"), + ], + "+0845": [("Eucla", "Australia/Eucla")], + "+0900": [ + ("Tokyo", "Asia/Tokyo"), + ("Seoul", "Asia/Seoul"), + ("Yakutsk", "Asia/Yakutsk"), + ], + "+0930": [ + ("Adelaide", "Australia/Adelaide"), + ("Darwin", "Australia/Darwin"), + ], + "+1000": [ + ("Sydney", "Australia/Sydney"), + ("Brisbane", "Australia/Brisbane"), + ("Guam", "Pacific/Guam"), + ("Vladivostok", "Asia/Vladivostok"), + ("Tahiti", "Pacific/Tahiti"), + ], + "+1030": [("Lord Howe Island", "Australia/Lord_Howe")], + "+1100": [ + ("Solomon Islands", "Pacific/Guadalcanal"), + ("Magadan", "Asia/Magadan"), + ("Norfolk Island", "Pacific/Norfolk"), + ("Bougainville Island", "Pacific/Bougainville"), + ("Chokurdakh", "Asia/Srednekolymsk"), + ], + "+1200": [ + ("Auckland", "Pacific/Auckland"), + ("Wellington", "Pacific/Auckland"), + ("Fiji Islands", "Pacific/Fiji"), + ("Anadyr", "Asia/Anadyr"), + ], + "+1245": [("Chatham Islands", "Pacific/Chatham")], + "+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")], + "+1400": [("Kiritimati Island", "Pacific/Kiritimati")], + } + + timezone_list = [] + now = datetime.now() + + # Process timezone mapping + for offset, locations in timezone_mapping.items(): + sign = "-" if offset.startswith("-") else "+" + hours = offset[1:3] + minutes = offset[3:] if len(offset) > 3 else "00" + + for friendly_name, tz_identifier in locations: + try: + tz = pytz.timezone(tz_identifier) + current_offset = now.astimezone(tz).strftime("%z") + + # converting and formatting UTC offset to GMT offset + current_utc_offset = now.astimezone(tz).utcoffset() + total_seconds = int(current_utc_offset.total_seconds()) + hours_offset = total_seconds // 3600 + minutes_offset = abs(total_seconds % 3600) // 60 + gmt_offset = ( + f"GMT{'+' if hours_offset >= 0 else '-'}" + f"{abs(hours_offset):02}:{minutes_offset:02}" + ) + + timezone_value = { + "offset": int(current_offset), + "utc_offset": f"UTC{sign}{hours}:{minutes}", + "gmt_offset": gmt_offset, + "value": tz_identifier, + "label": f"{friendly_name}", + } + + timezone_list.append(timezone_value) + except pytz.exceptions.UnknownTimeZoneError: + continue + + # Sort by offset and then by label + timezone_list.sort(key=lambda x: (x["offset"], x["label"])) + + # Remove offset from final output + for tz in timezone_list: + del tz["offset"] + + return Response({"timezones": timezone_list}, status=status.HTTP_200_OK) diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 9c66c629a78..9f2a6e066e0 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -33,3 +33,4 @@ export * from "./favorite"; export * from "./file"; export * from "./workspace-draft-issues/base"; export * from "./command-palette"; +export * from "./timezone"; diff --git a/packages/types/src/timezone.d.ts b/packages/types/src/timezone.d.ts new file mode 100644 index 00000000000..b4df123a306 --- /dev/null +++ b/packages/types/src/timezone.d.ts @@ -0,0 +1,8 @@ +export type TTimezoneObject = { + utc_offset: string; + gmt_offset: string; + label: string; + value: string; +}; + +export type TTimezones = { timezones: TTimezoneObject[] }; diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index 1dd9702a36b..d451042d9d6 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -20,9 +20,9 @@ import { import { DeactivateAccountModal } from "@/components/account"; import { LogoSpinner } from "@/components/common"; import { ImagePickerPopover, UserImageUploadModal, PageHead } from "@/components/core"; +import { TimezoneSelect } from "@/components/global"; import { ProfileSettingContentWrapper } from "@/components/profile"; // constants -import { TIME_ZONES, TTimezone } from "@/constants/timezones"; import { USER_ROLES } from "@/constants/workspace"; // helpers import { getFileURL } from "@/helpers/file.helper"; @@ -120,22 +120,6 @@ const ProfileSettingsPage = observer(() => { }); }; - const getTimeZoneLabel = (timezone: TTimezone | undefined) => { - if (!timezone) return undefined; - return ( -