From 4d8198d113c5f8594804ae04cd69344f31758e4f Mon Sep 17 00:00:00 2001 From: "GitStart-Cal.com" <121884634+gitstart-calcom@users.noreply.github.com> Date: Thu, 2 Mar 2023 20:42:41 +0000 Subject: [PATCH] Filter Timezones by cities (#7118) * Filter Timezones by cities * Update yarn.lock * Removes large endpoint from batching * Adds caching for large response * Updates test snapshots --------- Co-authored-by: gitstart-calcom Co-authored-by: zomars --- .../components/booking/TimezoneDropdown.tsx | 2 +- apps/web/pages/api/trpc/[trpc].ts | 1 + apps/web/test/lib/getTimezone.test.ts | 78 +++++++++++++++++++ package.json | 1 + packages/lib/timezone.ts | 38 +++++++++ packages/trpc/server/routers/viewer.tsx | 2 + .../form/timezone-select/TimezoneSelect.tsx | 28 +++++-- .../components/form/timezone-select/index.ts | 2 +- yarn.lock | 7 ++ 9 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 apps/web/test/lib/getTimezone.test.ts create mode 100644 packages/lib/timezone.ts diff --git a/apps/web/components/booking/TimezoneDropdown.tsx b/apps/web/components/booking/TimezoneDropdown.tsx index 195a9acec7..dcffd1904c 100644 --- a/apps/web/components/booking/TimezoneDropdown.tsx +++ b/apps/web/components/booking/TimezoneDropdown.tsx @@ -17,7 +17,7 @@ export function TimezoneDropdown({ return ( <> -
+
diff --git a/apps/web/pages/api/trpc/[trpc].ts b/apps/web/pages/api/trpc/[trpc].ts index 81a1e4598d..d00d2ebb3e 100644 --- a/apps/web/pages/api/trpc/[trpc].ts +++ b/apps/web/pages/api/trpc/[trpc].ts @@ -65,6 +65,7 @@ export default trpcNext.createNextApiHandler({ "viewer.public.i18n": `no-cache`, // Revalidation time here should be 1 second, per https://github.com/calcom/cal.com/pull/6823#issuecomment-1423215321 "viewer.public.slots.getSchedule": `no-cache`, // FIXME + "viewer.public.cityTimezones": `max-age=${ONE_DAY_IN_SECONDS}, stale-while-revalidate`, } as const; // Find which element above is an exact match for this group of paths diff --git a/apps/web/test/lib/getTimezone.test.ts b/apps/web/test/lib/getTimezone.test.ts new file mode 100644 index 0000000000..841c777a5c --- /dev/null +++ b/apps/web/test/lib/getTimezone.test.ts @@ -0,0 +1,78 @@ +import { expect, it } from "@jest/globals"; + +import { filterByCities, addCitiesToDropdown, handleOptionLabel } from "@calcom/lib/timezone"; + +const cityData = [ + { + city: "San Francisco", + timezone: "America/Argentina/Cordoba", + }, + { + city: "Sao Francisco do Sul", + timezone: "America/Sao_Paulo", + }, + { + city: "San Francisco de Macoris", + timezone: "America/Santo_Domingo", + }, + { + city: "San Francisco Gotera", + timezone: "America/El_Salvador", + }, + { + city: "San Francisco", + timezone: "America/Los_Angeles", + }, +]; + +const option = { + value: "America/Los_Angeles", + label: "(GMT-8:00) San Francisco", + offset: -8, + abbrev: "PST", + altName: "Pacific Standard Time", +}; + +it("should return empty array for an empty string", () => { + expect(filterByCities("", cityData)).toMatchInlineSnapshot(`Array []`); +}); + +it("should filter cities for a valid city name", () => { + expect(filterByCities("San Francisco", cityData)).toMatchInlineSnapshot(` + Array [ + Object { + "city": "San Francisco", + "timezone": "America/Argentina/Cordoba", + }, + Object { + "city": "San Francisco de Macoris", + "timezone": "America/Santo_Domingo", + }, + Object { + "city": "San Francisco Gotera", + "timezone": "America/El_Salvador", + }, + Object { + "city": "San Francisco", + "timezone": "America/Los_Angeles", + }, + ] + `); +}); + +it("should return appropriate timezone(s) for a given city name array", () => { + expect(addCitiesToDropdown(cityData)).toMatchInlineSnapshot(` + Object { + "America/Los_Angeles": "San Francisco", + "America/Sao_Paulo": "Sao Francisco do Sul", + } + `); +}); + +it("should render city name as option label if cityData is not empty", () => { + expect(handleOptionLabel(option, cityData)).toMatchInlineSnapshot(`"San Francisco GMT -8:00"`); +}); + +it("should return timezone as option label if cityData is empty", () => { + expect(handleOptionLabel(option, [])).toMatchInlineSnapshot(`"America/Los_Angeles GMT -8:00"`); +}); diff --git a/package.json b/package.json index 7d83fb89df..d894cb5b64 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "ts-jest": "^28.0.8" }, "dependencies": { + "city-timezones": "^1.2.1", "turbo": "^1.4.3" }, "resolutions": { diff --git a/packages/lib/timezone.ts b/packages/lib/timezone.ts new file mode 100644 index 0000000000..60c9b5a59c --- /dev/null +++ b/packages/lib/timezone.ts @@ -0,0 +1,38 @@ +import type { ITimezoneOption } from "react-timezone-select"; +import { allTimezones } from "react-timezone-select"; + +import type { ICity } from "@calcom/ui/components/form/timezone-select"; + +function findPartialMatch(itemsToSearch: string, searchString: string) { + const searchItems = searchString.split(" "); + return searchItems.every((i) => itemsToSearch.toLowerCase().indexOf(i.toLowerCase()) >= 0); +} + +function findFromCity(searchString: string, data: ICity[]): ICity[] { + if (searchString) { + const cityLookup = data.filter((o) => findPartialMatch(o.city, searchString)); + return cityLookup?.length ? cityLookup : []; + } + return []; +} + +export const filterByCities = (tz: string, data: ICity[]): ICity[] => { + const cityLookup = findFromCity(tz, data); + return cityLookup.map(({ city, timezone }) => ({ city, timezone })); +}; + +export const addCitiesToDropdown = (cities: ICity[]) => { + const cityTimezones = cities?.reduce((acc: { [key: string]: string }, city: ICity) => { + if (Object.keys(allTimezones).includes(city.timezone)) { + acc[city.timezone] = city.city; + } + return acc; + }, {}); + return cityTimezones || {}; +}; + +export const handleOptionLabel = (option: ITimezoneOption, cities: ICity[]) => { + const timezoneValue = option.label.split(")")[0].replace("(", " ").replace("T", "T "); + const cityName = option.label.split(") ")[1]; + return cities.length > 0 ? `${cityName}${timezoneValue}` : `${option.value}${timezoneValue}`; +}; diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index e87ccd1669..640df42d15 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -1,5 +1,6 @@ import type { DestinationCalendar, Prisma } from "@prisma/client"; import { AppCategories, BookingStatus, IdentityProvider } from "@prisma/client"; +import { cityMapping } from "city-timezones"; import _ from "lodash"; import { authenticator } from "otplib"; import z from "zod"; @@ -143,6 +144,7 @@ const publicViewerRouter = router({ }), // REVIEW: This router is part of both the public and private viewer router? slots: slotsRouter, + cityTimezones: publicProcedure.query(() => cityMapping), }); // routes only available to authenticated users diff --git a/packages/ui/components/form/timezone-select/TimezoneSelect.tsx b/packages/ui/components/form/timezone-select/TimezoneSelect.tsx index 40430460dd..9d9a0542d2 100644 --- a/packages/ui/components/form/timezone-select/TimezoneSelect.tsx +++ b/packages/ui/components/form/timezone-select/TimezoneSelect.tsx @@ -1,28 +1,44 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { ITimezoneOption, ITimezone, Props as SelectProps } from "react-timezone-select"; import BaseSelect, { allTimezones } from "react-timezone-select"; +import { filterByCities, addCitiesToDropdown, handleOptionLabel } from "@calcom/lib/timezone"; +import { trpc } from "@calcom/trpc/react"; + import { getReactSelectProps } from "../select"; +export interface ICity { + city: string; + timezone: string; +} + export function TimezoneSelect({ className, components, ...props }: SelectProps) { + const [cities, setCities] = useState([]); + const { data, isLoading } = trpc.viewer.public.cityTimezones.useQuery(undefined, { + trpc: { context: { skipBatch: true } }, + }); + const handleInputChange = (tz: string) => { + if (data) setCities(filterByCities(tz, data)); + }; + const reactSelectProps = useMemo(() => { return getReactSelectProps({ className, components: components || {} }); }, [className, components]); return (

{(option as ITimezoneOption).value}

} - getOptionLabel={(data) => { - const option = data as ITimezoneOption; - const formatedLabel = option.label.split(")")[0].replace("(", " ").replace("T", "T "); - return `${option.value}${formatedLabel}`; - }} + getOptionLabel={(option) => handleOptionLabel(option as ITimezoneOption, cities)} /> ); } diff --git a/packages/ui/components/form/timezone-select/index.ts b/packages/ui/components/form/timezone-select/index.ts index 21a295f9ce..025fb331c0 100644 --- a/packages/ui/components/form/timezone-select/index.ts +++ b/packages/ui/components/form/timezone-select/index.ts @@ -1,2 +1,2 @@ export { TimezoneSelect } from "./TimezoneSelect"; -export type { ITimezone, ITimezoneOption } from "./TimezoneSelect"; +export type { ITimezone, ITimezoneOption, ICity } from "./TimezoneSelect"; diff --git a/yarn.lock b/yarn.lock index d9450e895c..c10da465f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11624,6 +11624,13 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: inherits "^2.0.1" safe-buffer "^5.0.1" +city-timezones@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/city-timezones/-/city-timezones-1.2.1.tgz#7087d1719dd599f1e88ebc2f7fb96e091201d318" + integrity sha512-hruuB611QFoUFMsan7xd9B2VPMrA8XC716O/999WW34kmaJUT1hxKF2W8TSXAWkhSqgvbu70DjcDv7/wpM6vow== + dependencies: + lodash "^4.17.21" + cjs-module-lexer@^1.0.0: version "1.2.2" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40"