From 9b71a702c7dedf35ec084dffa05beaa0d76a8f77 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Thu, 19 Dec 2024 20:15:55 +0530 Subject: [PATCH] [WEB-2884] chore: Update timezone list, add new endpoint, and update timezone dropdowns (#6231) * dev: updated timezones list * chore: added rate limiting --- apiserver/plane/app/urls/__init__.py | 2 + apiserver/plane/app/urls/timezone.py | 8 + apiserver/plane/app/views/__init__.py | 2 + apiserver/plane/app/views/timezone/base.py | 247 ++++++++++++++++++ packages/types/src/index.d.ts | 1 + packages/types/src/timezone.d.ts | 8 + web/app/profile/page.tsx | 35 +-- web/core/components/global/index.ts | 2 + .../components/global/timezone-select.tsx | 53 ++++ web/core/components/project/form.tsx | 42 +-- web/core/hooks/use-timezone.tsx | 80 ++++++ web/core/services/timezone.service.ts | 23 ++ 12 files changed, 444 insertions(+), 59 deletions(-) create mode 100644 apiserver/plane/app/urls/timezone.py create mode 100644 apiserver/plane/app/views/timezone/base.py create mode 100644 packages/types/src/timezone.d.ts create mode 100644 web/core/components/global/timezone-select.tsx create mode 100644 web/core/hooks/use-timezone.tsx create mode 100644 web/core/services/timezone.service.ts 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 ( -
- {timezone.gmtOffset} - {timezone.name} -
- ); - }; - - const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ - value: timeZone.value, - query: timeZone.name + " " + timeZone.gmtOffset + " " + timeZone.value, - content: getTimeZoneLabel(timeZone), - })); - if (!currentUser) return (
@@ -379,19 +363,12 @@ const ProfileSettingsPage = observer(() => { control={control} rules={{ required: "Please select a timezone" }} render={({ field: { value, onChange } }) => ( - t.value === value)) ?? value) - : "Select a timezone" - } - options={timeZoneOptions} - onChange={onChange} - buttonClassName={errors.user_timezone ? "border-red-500" : ""} - className="rounded-md border-[0.5px] !border-custom-border-200" - optionsClassName="w-72" - input + onChange={(value: string) => { + onChange(value); + }} + error={Boolean(errors.user_timezone)} /> )} /> diff --git a/web/core/components/global/index.ts b/web/core/components/global/index.ts index 1230b8384fc..bb0ffcec8a1 100644 --- a/web/core/components/global/index.ts +++ b/web/core/components/global/index.ts @@ -1 +1,3 @@ export * from "./product-updates"; + +export * from "./timezone-select"; diff --git a/web/core/components/global/timezone-select.tsx b/web/core/components/global/timezone-select.tsx new file mode 100644 index 00000000000..55b9f5cf186 --- /dev/null +++ b/web/core/components/global/timezone-select.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { CustomSearchSelect } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import useTimezone from "@/hooks/use-timezone"; + +type TTimezoneSelect = { + value: string | undefined; + onChange: (value: string) => void; + error?: boolean; + label?: string; + buttonClassName?: string; + className?: string; + optionsClassName?: string; + disabled?: boolean; +}; + +export const TimezoneSelect: FC = observer((props) => { + // props + const { + value, + onChange, + error = false, + label = "Select a timezone", + buttonClassName = "", + className = "", + optionsClassName = "", + disabled = false, + } = props; + // hooks + const { disabled: isDisabled, timezones, selectedValue } = useTimezone(); + + return ( +
+ +
+ ); +}); diff --git a/web/core/components/project/form.tsx b/web/core/components/project/form.tsx index 855c52aaf7f..8ce776eb666 100644 --- a/web/core/components/project/form.tsx +++ b/web/core/components/project/form.tsx @@ -16,16 +16,15 @@ import { CustomEmojiIconPicker, EmojiIconPickerTypes, Tooltip, - CustomSearchSelect, } from "@plane/ui"; // components import { Logo } from "@/components/common"; import { ImagePickerPopover } from "@/components/core"; +import { TimezoneSelect } from "@/components/global"; // constants import { PROJECT_UPDATED } from "@/constants/event-tracker"; import { NETWORK_CHOICES } from "@/constants/project"; // helpers -import { TTimezone, TIME_ZONES } from "@/constants/timezones"; import { renderFormattedDate } from "@/helpers/date-time.helper"; import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; import { getFileURL } from "@/helpers/file.helper"; @@ -34,6 +33,7 @@ import { useEventTracker, useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // services import { ProjectService } from "@/services/project"; + export interface IProjectDetailsForm { project: IProject; workspaceSlug: string; @@ -68,20 +68,6 @@ export const ProjectDetailsForm: FC = (props) => { }); // derived values const currentNetwork = NETWORK_CHOICES.find((n) => n.key === project?.network); - const getTimeZoneLabel = (timezone: TTimezone | undefined) => { - if (!timezone) return undefined; - return ( -
- {timezone.gmtOffset} - {timezone.name} -
- ); - }; - const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ - value: timeZone.value, - query: timeZone.name + " " + timeZone.gmtOffset + " " + timeZone.value, - content: getTimeZoneLabel(timeZone), - })); const coverImage = watch("cover_image_url"); useEffect(() => { @@ -393,20 +379,16 @@ export const ProjectDetailsForm: FC = (props) => { control={control} rules={{ required: "Please select a timezone" }} render={({ field: { value, onChange } }) => ( - t.value === value)) ?? value) - : "Select a timezone" - } - options={timeZoneOptions} - onChange={onChange} - buttonClassName={errors.timezone ? "border-red-500" : "border-none"} - className="rounded-md border-[0.5px] !border-custom-border-200" - optionsClassName="w-72" - input - /> + <> + { + onChange(value); + }} + error={Boolean(errors.timezone)} + buttonClassName="border-none" + /> + )} /> {errors.timezone && {errors.timezone.message}} diff --git a/web/core/hooks/use-timezone.tsx b/web/core/hooks/use-timezone.tsx new file mode 100644 index 00000000000..983f99beb43 --- /dev/null +++ b/web/core/hooks/use-timezone.tsx @@ -0,0 +1,80 @@ +import useSWR from "swr"; +import { TTimezoneObject } from "@plane/types"; +// services +import timezoneService from "@/services/timezone.service"; + +// group timezones by value +const groupTimezones = (timezones: TTimezoneObject[]): TTimezoneObject[] => { + const groupedMap = timezones.reduce((acc, timezone: TTimezoneObject) => { + const key = timezone.value; + + if (!acc.has(key)) { + acc.set(key, { + utc_offset: timezone.utc_offset, + gmt_offset: timezone.gmt_offset, + value: timezone.value, + label: timezone.label, + }); + } else { + const existing = acc.get(key); + existing.label = `${existing.label}, ${timezone.label}`; + } + + return acc; + }, new Map()); + + return Array.from(groupedMap.values()); +}; + +const useTimezone = () => { + // fetching the timezone from the server + const { + data: timezones, + isLoading: timezoneIsLoading, + error: timezonesError, + } = useSWR("TIMEZONES_LIST", () => timezoneService.fetch(), { + refreshInterval: 0, + }); + + // derived values + const isDisabled = timezoneIsLoading || timezonesError || !timezones; + + const getTimeZoneLabel = (timezone: TTimezoneObject | undefined) => { + if (!timezone) return undefined; + return ( +
+ {timezone.utc_offset} + {timezone.label} +
+ ); + }; + const options = [ + ...groupTimezones(timezones?.timezones || [])?.map((timezone) => ({ + value: timezone.value, + query: `${timezone.value} ${timezone.label}, ${timezone.gmt_offset}, ${timezone.utc_offset}`, + content: getTimeZoneLabel(timezone), + })), + { + value: "UTC", + query: "utc, coordinated universal time", + content: "UTC", + }, + { + value: "Universal", + query: "universal, coordinated universal time", + content: "Universal", + }, + ]; + + const selectedTimezone = (value: string | undefined) => options.find((option) => option.value === value)?.content; + + return { + timezones: options, + isLoading: timezoneIsLoading, + error: timezonesError, + disabled: isDisabled, + selectedValue: selectedTimezone, + }; +}; + +export default useTimezone; diff --git a/web/core/services/timezone.service.ts b/web/core/services/timezone.service.ts new file mode 100644 index 00000000000..d19e7e96495 --- /dev/null +++ b/web/core/services/timezone.service.ts @@ -0,0 +1,23 @@ +import { TTimezones } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// api services +import { APIService } from "@/services/api.service"; + +export class TimezoneService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetch(): Promise { + return this.get(`/api/timezones/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} + +const timezoneService = new TimezoneService(); + +export default timezoneService;