diff --git a/apps/web/package.json b/apps/web/package.json index 3dc47963c..05ff634e6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,9 @@ "lint:tsc": "tsc --noEmit", "i18n:scan": "i18next-scanner --config i18next-scanner.config.js", "prettier": "prettier --write ./src", - "test": "playwright test", + "test:e2e": "playwright test", + "test:unit": "vitest run", + "test": "yarn test:unit && yarn test:e2e", "test:codegen": "playwright codegen http://localhost:3000", "docker:start": "./scripts/docker-start.sh" }, @@ -40,7 +42,6 @@ "accept-language-parser": "^1.5.0", "autoprefixer": "^10.4.13", "class-variance-authority": "^0.6.0", - "cmdk": "^0.2.0", "color-hash": "^2.0.2", "cookie": "^0.5.0", "crypto": "^1.0.1", @@ -88,6 +89,7 @@ "@types/smoothscroll-polyfill": "^0.3.1", "cheerio": "^1.0.0-rc.12", "cross-env": "^7.0.3", + "vitest": "^1.3.1", "i18next-scanner": "^4.2.0", "i18next-scanner-typescript": "^1.1.1", "smtp-tester": "^2.0.1", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 2389bbaa3..36e6bc582 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -23,6 +23,7 @@ const config: PlaywrightTestConfig = { permissions: ["clipboard-read"], trace: "retain-on-failure", }, + testDir: "./tests", webServer: { command: `NODE_ENV=test yarn start --port ${PORT}`, url: baseURL, diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index b991a71c5..30d18c2d8 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -102,7 +102,6 @@ "timeZoneSelect__defaultValue": "Select time zone…", "timeZoneSelect__noOption": "No option found", "timeZoneSelect__inputPlaceholder": "Search…", - "timeZonePicker__ignore": "Ignore time zone", "poweredByRallly": "Powered by {name}", "participants": "Participants", "language": "Language", @@ -240,5 +239,7 @@ "inviteParticipantLinkInfo": "Anyone with this link will be able to vote on your poll.", "accountNotLinkedTitle": "Your account cannot be linked to an existing user", "accountNotLinkedDescription": "A user with this email already exists. Please log in using the original method.", - "or": "Or" + "or": "Or", + "autoTimeZone": "Automatic Time Zone Conversion", + "autoTimeZoneHelp": "Enable this setting to automatically adjust event times to each participant's local time zone." } diff --git a/apps/web/src/components/clock.tsx b/apps/web/src/components/clock.tsx index a305a7475..102d92099 100644 --- a/apps/web/src/components/clock.tsx +++ b/apps/web/src/components/clock.tsx @@ -22,7 +22,7 @@ import { usePreferences } from "@/contexts/preferences"; import { useDayjs } from "@/utils/dayjs"; export const TimePreferences = () => { - const { preferences, updatePreferences } = usePreferences(); + const { updatePreferences } = usePreferences(); const { timeFormat, timeZone } = useDayjs(); return ( @@ -32,7 +32,7 @@ export const TimePreferences = () => { { updatePreferences({ timeZone: newTimeZone }); }} @@ -43,7 +43,7 @@ export const TimePreferences = () => { { updatePreferences({ timeFormat: newTimeFormat }); }} @@ -75,17 +75,15 @@ export const Clock = ({ className }: { className?: string }) => { export const TimesShownIn = () => { const { timeZone } = useDayjs(); - const timeZoneDisplayFormat = soft(timeZone)[0]; - const now = spacetime.now(timeZone); - const standard = timeZoneDisplayFormat.standard.name; - const dst = timeZoneDisplayFormat.daylight?.name; - const timeZoneName = now.isDST() ? dst : standard; return ( ); diff --git a/apps/web/src/components/date-card.tsx b/apps/web/src/components/date-card.tsx index 12c79caaa..53ff588e0 100644 --- a/apps/web/src/components/date-card.tsx +++ b/apps/web/src/components/date-card.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import * as React from "react"; export interface DateCardProps { - annotation?: React.ReactNode; day: string; dow?: string; month: string; @@ -10,32 +9,22 @@ export interface DateCardProps { } const DateCard: React.FunctionComponent = ({ - annotation, className, day, - dow, month, }) => { return (
- {annotation ? ( -
{annotation}
- ) : null} -
- {dow ? ( -
- {dow} -
- ) : null} -
{day}
-
- {month} -
+
+ {month} +
+
+ {day}
); diff --git a/apps/web/src/components/forms/poll-details-form.tsx b/apps/web/src/components/forms/poll-details-form.tsx index 7b58d39de..93fb05782 100644 --- a/apps/web/src/components/forms/poll-details-form.tsx +++ b/apps/web/src/components/forms/poll-details-form.tsx @@ -53,7 +53,9 @@ export const PollDetailsForm = () => {
- {t("location")} + + {t("location")} + diff --git a/apps/web/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx b/apps/web/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx index 533a54948..bdd9a32f9 100644 --- a/apps/web/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx +++ b/apps/web/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx @@ -254,7 +254,7 @@ const MonthCalendar: React.FunctionComponent = ({
-
+
{isTimedEvent ? (
{Object.keys(optionsByDay) @@ -264,13 +264,11 @@ const MonthCalendar: React.FunctionComponent = ({ return (
-
- -
+
{optionsForDay.map(({ option, index }) => { if (option.type === "date") { @@ -447,7 +445,7 @@ const MonthCalendar: React.FunctionComponent = ({ })}
) : datepicker.selection.length ? ( -
+
{datepicker.selection .sort((a, b) => a.getTime() - b.getTime()) .map((selectedDate, i) => { @@ -455,18 +453,18 @@ const MonthCalendar: React.FunctionComponent = ({ { - // TODO (Luke Vella) [2022-03-19]: Find cleaner way to manage this state - // Quite tedious right now to remove a single element - onChange( - removeAllOptionsForDay(options, selectedDate), - ); - }} - /> - } + // annotation={ + // { + // // TODO (Luke Vella) [2022-03-19]: Find cleaner way to manage this state + // // Quite tedious right now to remove a single element + // onChange( + // removeAllOptionsForDay(options, selectedDate), + // ); + // }} + // /> + // } /> ); })} diff --git a/apps/web/src/components/forms/poll-options-form/poll-options-form.tsx b/apps/web/src/components/forms/poll-options-form/poll-options-form.tsx index 75a04244d..ab7e4bfbd 100644 --- a/apps/web/src/components/forms/poll-options-form/poll-options-form.tsx +++ b/apps/web/src/components/forms/poll-options-form/poll-options-form.tsx @@ -1,12 +1,17 @@ +import { Button } from "@rallly/ui/button"; import { Card, CardDescription, CardHeader, CardTitle } from "@rallly/ui/card"; +import { CommandDialog } from "@rallly/ui/command"; import { FormField, FormMessage } from "@rallly/ui/form"; +import { Label } from "@rallly/ui/label"; +import { Switch } from "@rallly/ui/switch"; import { Tabs, TabsList, TabsTrigger } from "@rallly/ui/tabs"; -import { CalendarIcon, TableIcon } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip"; +import { CalendarIcon, GlobeIcon, InfoIcon, TableIcon } from "lucide-react"; import { Trans, useTranslation } from "next-i18next"; import * as React from "react"; import { useFormContext } from "react-hook-form"; -import TimeZonePicker from "@/components/time-zone-picker"; +import { TimeZoneCommand } from "@/components/time-zone-picker/time-zone-select"; import { getBrowserTimeZone } from "../../../utils/date-time-utils"; import { useModal } from "../../modal"; @@ -27,6 +32,8 @@ const PollOptionsForm = ({ children }: React.PropsWithChildren) => { const { t } = useTranslation(); const form = useFormContext(); + const [isTimeZoneCommandModalOpen, showTimeZoneCommandModal] = + React.useState(false); const { watch, setValue, formState } = form; const views = React.useMemo(() => { @@ -96,114 +103,165 @@ const PollOptionsForm = ({ children }: React.PropsWithChildren) => { const navigationDate = new Date(watchNavigationDate ?? Date.now()); return ( - - -
-
- - Calendar - - - - Select potential dates for your event - - -
-
- ( - - - - - - - - - - - - - )} - /> -
-
-
- {dateOrTimeRangeModal} -
- { - return options.length > 0 - ? true - : t("calendarHelp", { - defaultValue: - "You can't create a poll without any options. Add at least one option to continue.", - }); - }, - }} - render={({ field }) => ( + <> + + +
- { - setValue("navigationDate", date.toISOString()); - }} - onChange={(options) => { - field.onChange(options); - if ( - length === 0 || - options.every((option) => option.type === "date") - ) { - // unset the timeZone if we only have date option - setValue("timeZone", ""); - } - if ( - options.length > 0 && - !formState.touchedFields.timeZone && - options.every((option) => option.type === "timeSlot") - ) { - // set timeZone if we are adding time ranges and we haven't touched the timeZone field - setValue("timeZone", getBrowserTimeZone()); - } - }} - duration={watchDuration} - onChangeDuration={(duration) => { - setValue("duration", duration); - }} - /> - {formState.errors.options ? ( -
- -
- ) : null} + + Calendar + + + + Select potential dates for your event + +
- )} - /> -
-
- ( - { - setValue("timeZone", timeZone, { shouldTouch: true }); - }} - disabled={datesOnly} - /> - )} - /> -
- {children} -
+
+ ( + + + + + + + + + + + + + )} + /> +
+
+ + {dateOrTimeRangeModal} +
+ { + return options.length > 0 + ? true + : t("calendarHelp", { + defaultValue: + "You can't create a poll without any options. Add at least one option to continue.", + }); + }, + }} + render={({ field }) => ( +
+ { + setValue("navigationDate", date.toISOString()); + }} + onChange={(options) => { + field.onChange(options); + if ( + length === 0 || + options.every((option) => option.type === "date") + ) { + // unset the timeZone if we only have date option + setValue("timeZone", ""); + } + if ( + options.length > 0 && + !formState.touchedFields.timeZone && + options.every((option) => option.type === "timeSlot") + ) { + // set timeZone if we are adding time ranges and we haven't touched the timeZone field + setValue("timeZone", getBrowserTimeZone()); + } + }} + duration={watchDuration} + onChangeDuration={(duration) => { + setValue("duration", duration); + }} + /> + {formState.errors.options ? ( +
+ +
+ ) : null} +
+ )} + /> +
+ {!datesOnly ? ( + ( +
+
+ { + if (checked) { + field.onChange(getBrowserTimeZone()); + } else { + field.onChange(""); + } + }} + /> + + + + + + + + + +
+ {field.value ? ( +
+ + + { + field.onChange(newValue); + showTimeZoneCommandModal(false); + }} + /> + +
+ ) : null} +
+ )} + /> + ) : null} + {children} +
+ ); }; diff --git a/apps/web/src/components/settings/date-time-preferences.tsx b/apps/web/src/components/settings/date-time-preferences.tsx index 67bcecb44..f46f0b097 100644 --- a/apps/web/src/components/settings/date-time-preferences.tsx +++ b/apps/web/src/components/settings/date-time-preferences.tsx @@ -33,9 +33,9 @@ const DateTimePreferencesForm = () => { const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { - timeFormat: preferences.timeFormat ?? timeFormat, - weekStart: preferences.weekStart ?? weekStart, - timeZone: preferences.timeZone ?? timeZone, + timeFormat, + weekStart, + timeZone, }, }); diff --git a/apps/web/src/components/time-zone-picker/index.ts b/apps/web/src/components/time-zone-picker/index.ts deleted file mode 100644 index c172fb01a..000000000 --- a/apps/web/src/components/time-zone-picker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./time-zone-picker"; diff --git a/apps/web/src/components/time-zone-picker/time-zone-picker.tsx b/apps/web/src/components/time-zone-picker/time-zone-picker.tsx deleted file mode 100644 index ba3f2cb59..000000000 --- a/apps/web/src/components/time-zone-picker/time-zone-picker.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { - flip, - FloatingPortal, - offset, - size, - useFloating, -} from "@floating-ui/react-dom-interactions"; -import { Combobox } from "@headlessui/react"; -import clsx from "clsx"; -import { ChevronDownIcon } from "lucide-react"; -import { useTranslation } from "next-i18next"; -import React from "react"; -import spacetime from "spacetime"; -import soft from "timezone-soft"; - -import { styleMenuItem } from "../menu-styles"; -import timeZones from "./time-zones.json"; - -interface TimeZoneOption { - value: string; - label: string; - offset: number; -} - -const useTimeZones = () => { - const options = React.useMemo(() => { - return Object.entries(timeZones) - .reduce((selectOptions, zone) => { - const now = spacetime.now(zone[0]); - const tz = now.timezone(); - - let label = ""; - - const min = tz.current.offset * 60; - const hr = - `${(min / 60) ^ 0}:` + (min % 60 === 0 ? "00" : Math.abs(min % 60)); - const prefix = `(GMT${hr.includes("-") ? hr : `+${hr}`}) ${zone[1]}`; - - label = prefix; - - selectOptions.push({ - value: tz.name, - label: label, - offset: tz.current.offset, - }); - - return selectOptions; - }, []) - .sort((a: TimeZoneOption, b: TimeZoneOption) => a.offset - b.offset); - }, []); - - const findFuzzyTz = React.useCallback( - (zone: string): TimeZoneOption => { - let currentTime = spacetime.now("GMT"); - try { - currentTime = spacetime.now(zone); - } catch (err) { - throw new Error(`Invalid time zone: zone`); - } - return options - .filter( - (tz: TimeZoneOption) => - tz.offset === currentTime.timezone().current.offset, - ) - .map((tz: TimeZoneOption) => { - let score = 0; - if ( - currentTime.timezones[tz.value.toLowerCase()] && - !!currentTime.timezones[tz.value.toLowerCase()].dst === - currentTime.timezone().hasDst - ) { - if ( - tz.value - .toLowerCase() - .indexOf( - currentTime.tz.substring(currentTime.tz.indexOf("/") + 1), - ) !== -1 - ) { - score += 8; - } - if ( - tz.label - .toLowerCase() - .indexOf( - currentTime.tz.substring(currentTime.tz.indexOf("/") + 1), - ) !== -1 - ) { - score += 4; - } - if ( - tz.value - .toLowerCase() - .indexOf( - currentTime.tz.substring(0, currentTime.tz.indexOf("/")), - ) - ) { - score += 2; - } - score += 1; - } else if (tz.value === "GMT") { - score += 1; - } - return { tz, score }; - }) - .sort((a, b) => b.score - a.score) - .map(({ tz }) => tz)[0]; - }, - [options], - ); - - return React.useMemo( - () => ({ - options, - findFuzzyTz, - }), - [findFuzzyTz, options], - ); -}; - -const TimeZonePicker: React.FunctionComponent<{ - value: string; - onChange: (tz: string) => void; - onBlur?: () => void; - className?: string; - style?: React.CSSProperties; - disabled?: boolean; -}> = ({ value, onChange, onBlur, className, style, disabled }) => { - const { t } = useTranslation(); - const { options, findFuzzyTz } = useTimeZones(); - - const { reference, floating, x, y, strategy, refs } = useFloating({ - strategy: "fixed", - middleware: [ - offset(5), - flip(), - size({ - apply: ({ rects }) => { - if (refs.floating.current) { - Object.assign(refs.floating.current.style, { - width: `${rects.reference.width}px`, - }); - } - }, - }), - ], - }); - - const timeZoneOptions = React.useMemo( - () => [ - { - value: "", - label: t("timeZonePicker__ignore", { - defaultValue: "Ignore time zone", - }), - offset: 0, - }, - ...options, - ], - [options, t], - ); - - const selectedTimeZone = React.useMemo( - () => - value - ? timeZoneOptions.find( - (timeZoneOption) => timeZoneOption.value === value, - ) ?? findFuzzyTz(value) - : timeZoneOptions[0], - [findFuzzyTz, timeZoneOptions, value], - ); - - const [query, setQuery] = React.useState(""); - - const filteredTimeZones = React.useMemo(() => { - return query - ? timeZoneOptions.filter((tz) => { - if (tz.label.toLowerCase().includes(query.toLowerCase())) { - return true; - } - const tzStrings = soft(query); - return tzStrings.some((tzString) => tzString.iana === tz.value); - }) - : timeZoneOptions; - }, [timeZoneOptions, query]); - - return ( - { - setQuery(""); - onChange(newTimeZone.value); - }} - disabled={disabled} - > -
- {/* Remove generic params once Combobox.Input can infer the types */} - - className="input w-full pr-8" - displayValue={() => ""} - onChange={(e) => { - setQuery(e.target.value); - }} - onBlur={onBlur} - /> - - - {!query ? selectedTimeZone.label : null} - - - - - - - - {filteredTimeZones.map((timeZone) => ( - - {timeZone.label} - - ))} - - -
-
- ); -}; - -export default TimeZonePicker; diff --git a/apps/web/src/components/time-zone-picker/time-zone-select.tsx b/apps/web/src/components/time-zone-picker/time-zone-select.tsx index d798f227e..e95e31348 100644 --- a/apps/web/src/components/time-zone-picker/time-zone-select.tsx +++ b/apps/web/src/components/time-zone-picker/time-zone-select.tsx @@ -1,3 +1,5 @@ +"use client"; + import { SelectProps } from "@radix-ui/react-select"; import { cn } from "@rallly/ui"; import { @@ -13,18 +15,9 @@ import dayjs from "dayjs"; import { CheckIcon, ChevronDownIcon, GlobeIcon } from "lucide-react"; import { useTranslation } from "next-i18next"; import React from "react"; -import spacetime from "spacetime"; import { Trans } from "@/components/trans"; - -import timeZones from "./time-zones.json"; - -const options = Object.entries(timeZones).map(([value, label]) => ({ - value, - label, -})); - -export { timeZones }; +import { groupedTimeZones } from "@/utils/grouped-time-zone"; interface TimeZoneCommandProps { value?: string; @@ -40,107 +33,46 @@ export const TimeZoneCommand = ({ onSelect, value }: TimeZoneCommandProps) => { defaultValue: "Search…", })} /> - + - - {options.map((option) => { - const min = dayjs().tz(option.value).utcOffset(); - const hr = - `${(min / 60) ^ 0}:` + - (min % 60 === 0 ? "00" : Math.abs(min % 60)); - const offset = `GMT${hr.includes("-") ? hr : `+${hr}`}`; - return ( - onSelect?.(option.value)} - className="flex min-w-0 gap-x-2.5" - > - - {option.label} - - {offset} - - - ); - })} - + {Object.entries(groupedTimeZones).map(([region, timeZones]) => ( + + {timeZones.map(({ timezone, city }) => { + return ( + onSelect?.(timezone)} + className="flex min-w-0 gap-x-2.5" + > + + {city} + + {dayjs().tz(timezone).format("LT")} + + + ); + })} + + ))} ); }; -const findFuzzyTz = (zone: string) => { - let currentTime = spacetime.now("GMT"); - try { - currentTime = spacetime.now(zone); - } catch (err) { - return; - } - - const currentOffset = dayjs().tz(zone).utcOffset(); - - return options - .filter((tz) => { - const offset = dayjs().tz(tz.value).utcOffset(); - return offset === currentOffset; - }) - .map((tz) => { - let score = 0; - if ( - currentTime.timezones[tz.value.toLowerCase()] && - !!currentTime.timezones[tz.value.toLowerCase()].dst === - currentTime.timezone().hasDst - ) { - if ( - tz.value - .toLowerCase() - .indexOf( - currentTime.tz.substring(currentTime.tz.indexOf("/") + 1), - ) !== -1 - ) { - score += 8; - } - if ( - tz.label - .toLowerCase() - .indexOf( - currentTime.tz.substring(currentTime.tz.indexOf("/") + 1), - ) !== -1 - ) { - score += 4; - } - if ( - tz.value - .toLowerCase() - .indexOf(currentTime.tz.substring(0, currentTime.tz.indexOf("/"))) - ) { - score += 2; - } - score += 1; - } else if (tz.value === "GMT") { - score += 1; - } - return { tz, score }; - }) - .sort((a, b) => b.score - a.score) - .map(({ tz }) => tz)[0]; -}; - export const TimeZoneSelect = React.forwardRef( ({ value, onValueChange, disabled }, ref) => { const [open, setOpen] = React.useState(false); const popoverContentId = "timeZoneSelect__popoverContent"; - const fuzzyValue = value ? findFuzzyTz(value) : undefined; return ( @@ -156,8 +88,8 @@ export const TimeZoneSelect = React.forwardRef( > - {fuzzyValue ? ( - fuzzyValue.label + {value ? ( + value.replaceAll("_", " ") ) : ( ( /> )} - + } token = { ...token, ...session }; } - if (trigger === "signIn" && user) { token.locale = user.locale; token.timeFormat = user.timeFormat; token.timeZone = user.timeZone; token.weekStart = user.weekStart; } - return token; }, async session({ session, token }) { diff --git a/apps/web/src/utils/date-time-utils.ts b/apps/web/src/utils/date-time-utils.ts index fed810d0f..b201842aa 100644 --- a/apps/web/src/utils/date-time-utils.ts +++ b/apps/web/src/utils/date-time-utils.ts @@ -1,5 +1,8 @@ import { TimeFormat } from "@rallly/database"; import dayjs from "dayjs"; +import soft from "timezone-soft"; + +import { supportedTimeZones } from "@/utils/supported-time-zones"; import { DateTimeOption, @@ -8,8 +11,35 @@ import { type Option = { id: string; start: Date; duration: number }; -export const getBrowserTimeZone = () => - Intl.DateTimeFormat().resolvedOptions().timeZone; +export function parseIanaTimezone(timezone: string): { + region: string; + city: string; +} { + const firstSlash = timezone.indexOf("/"); + const region = timezone.substring(0, firstSlash); + const city = timezone.substring(firstSlash + 1).replaceAll("_", " "); + + return { region, city }; +} + +export function getBrowserTimeZone() { + const res = soft(Intl.DateTimeFormat().resolvedOptions().timeZone)[0]; + return resolveGeographicTimeZone(res.iana); +} + +export function resolveGeographicTimeZone(timezone: string) { + const tz = supportedTimeZones.find((tz) => tz === timezone); + + if (!tz) { + // find nearest timezone with the same offset + const offset = dayjs().tz(timezone).utcOffset(); + return supportedTimeZones.find((tz) => { + return dayjs().tz(tz, true).utcOffset() === offset; + })!; + } + + return tz; +} export const encodeDateOption = (option: DateTimeOption) => { return option.type === "timeSlot" diff --git a/apps/web/src/utils/date-time-utilts.test.ts b/apps/web/src/utils/date-time-utilts.test.ts new file mode 100644 index 000000000..a9d3a69b4 --- /dev/null +++ b/apps/web/src/utils/date-time-utilts.test.ts @@ -0,0 +1,38 @@ +import dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import { describe, expect, it } from "vitest"; + +import { supportedTimeZones } from "@/utils/supported-time-zones"; + +import { resolveGeographicTimeZone } from "./date-time-utils"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +describe("resolveGeographicTimezone", () => { + it("should return same timezone when given a geographic timezone", () => { + const browserTimeZone = resolveGeographicTimeZone("Europe/London"); + + // Assert that the browser time zone is one of the supported time zones + expect(browserTimeZone).toBe("Europe/London"); + }); + it("should return a supported timezone when given a fixed offset timezone", () => { + const browserTimeZone = resolveGeographicTimeZone("Etc/GMT-1"); + + // Assert that the browser time zone is one of the supported time zones + expect(supportedTimeZones.includes(browserTimeZone)).toBe(true); + }); + it("should return a supported timezone when given GMT", () => { + const browserTimeZone = resolveGeographicTimeZone("GMT"); + + // Assert that the browser time zone is one of the supported time zones + expect(supportedTimeZones.includes(browserTimeZone)).toBe(true); + }); + it("should return a supported timezone when given UTC", () => { + const browserTimeZone = resolveGeographicTimeZone("UTC"); + + // Assert that the browser time zone is one of the supported time zones + expect(supportedTimeZones.includes(browserTimeZone)).toBe(true); + }); +}); diff --git a/apps/web/src/utils/dayjs.tsx b/apps/web/src/utils/dayjs.tsx index 2523af91d..18489144f 100644 --- a/apps/web/src/utils/dayjs.tsx +++ b/apps/web/src/utils/dayjs.tsx @@ -16,7 +16,10 @@ import * as React from "react"; import { useAsync } from "react-use"; import { usePreferences } from "@/contexts/preferences"; -import { getBrowserTimeZone } from "@/utils/date-time-utils"; +import { + getBrowserTimeZone, + resolveGeographicTimeZone, +} from "@/utils/date-time-utils"; import { useRequiredContext } from "../components/use-required-context"; @@ -205,7 +208,13 @@ export const DayjsProvider: React.FunctionComponent<{ return await dayjsLocales[l].import(); }, [l]); - const preferredTimeZone = config?.timeZone ?? getBrowserTimeZone(); + const preferredTimeZone = React.useMemo( + () => + config?.timeZone + ? resolveGeographicTimeZone(config?.timeZone) + : getBrowserTimeZone(), + [config?.timeZone], + ); const adjustTimeZone = React.useCallback( (date: dayjs.ConfigType, keepLocalTime = false) => { diff --git a/apps/web/src/utils/grouped-time-zone.ts b/apps/web/src/utils/grouped-time-zone.ts new file mode 100644 index 000000000..ec788a7ca --- /dev/null +++ b/apps/web/src/utils/grouped-time-zone.ts @@ -0,0 +1,14 @@ +import { parseIanaTimezone } from "@/utils/date-time-utils"; +import { supportedTimeZones } from "@/utils/supported-time-zones"; + +export const groupedTimeZones = supportedTimeZones.reduce( + (acc, tz) => { + const { region, city } = parseIanaTimezone(tz); + if (!acc[region]) { + acc[region] = []; + } + acc[region].push({ timezone: tz, city }); + return acc; + }, + {} as Record, +); diff --git a/apps/web/src/utils/supported-time-zones.ts b/apps/web/src/utils/supported-time-zones.ts new file mode 100644 index 000000000..be40352da --- /dev/null +++ b/apps/web/src/utils/supported-time-zones.ts @@ -0,0 +1,430 @@ +export const supportedTimeZones = [ + "Africa/Abidjan", + "Africa/Accra", + "Africa/Addis_Ababa", + "Africa/Algiers", + "Africa/Asmera", + "Africa/Bamako", + "Africa/Bangui", + "Africa/Banjul", + "Africa/Bissau", + "Africa/Blantyre", + "Africa/Brazzaville", + "Africa/Bujumbura", + "Africa/Cairo", + "Africa/Casablanca", + "Africa/Ceuta", + "Africa/Conakry", + "Africa/Dakar", + "Africa/Dar_es_Salaam", + "Africa/Djibouti", + "Africa/Douala", + "Africa/El_Aaiun", + "Africa/Freetown", + "Africa/Gaborone", + "Africa/Harare", + "Africa/Johannesburg", + "Africa/Juba", + "Africa/Kampala", + "Africa/Khartoum", + "Africa/Kigali", + "Africa/Kinshasa", + "Africa/Lagos", + "Africa/Libreville", + "Africa/Lome", + "Africa/Luanda", + "Africa/Lubumbashi", + "Africa/Lusaka", + "Africa/Malabo", + "Africa/Maputo", + "Africa/Maseru", + "Africa/Mbabane", + "Africa/Mogadishu", + "Africa/Monrovia", + "Africa/Nairobi", + "Africa/Ndjamena", + "Africa/Niamey", + "Africa/Nouakchott", + "Africa/Ouagadougou", + "Africa/Porto-Novo", + "Africa/Sao_Tome", + "Africa/Tripoli", + "Africa/Tunis", + "Africa/Windhoek", + "America/Adak", + "America/Anchorage", + "America/Anguilla", + "America/Antigua", + "America/Araguaina", + "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/Aruba", + "America/Asuncion", + "America/Bahia", + "America/Bahia_Banderas", + "America/Barbados", + "America/Belem", + "America/Belize", + "America/Blanc-Sablon", + "America/Boa_Vista", + "America/Bogota", + "America/Boise", + "America/Buenos_Aires", + "America/Cambridge_Bay", + "America/Campo_Grande", + "America/Cancun", + "America/Caracas", + "America/Catamarca", + "America/Cayenne", + "America/Cayman", + "America/Chicago", + "America/Chihuahua", + "America/Ciudad_Juarez", + "America/Coral_Harbour", + "America/Cordoba", + "America/Costa_Rica", + "America/Creston", + "America/Cuiaba", + "America/Curacao", + "America/Danmarkshavn", + "America/Dawson", + "America/Dawson_Creek", + "America/Denver", + "America/Detroit", + "America/Dominica", + "America/Edmonton", + "America/Eirunepe", + "America/El_Salvador", + "America/Fort_Nelson", + "America/Fortaleza", + "America/Glace_Bay", + "America/Godthab", + "America/Goose_Bay", + "America/Grand_Turk", + "America/Grenada", + "America/Guadeloupe", + "America/Guatemala", + "America/Guayaquil", + "America/Guyana", + "America/Halifax", + "America/Havana", + "America/Hermosillo", + "America/Indiana/Knox", + "America/Indiana/Marengo", + "America/Indiana/Petersburg", + "America/Indiana/Tell_City", + "America/Indiana/Vevay", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Indianapolis", + "America/Inuvik", + "America/Iqaluit", + "America/Jamaica", + "America/Jujuy", + "America/Juneau", + "America/Kentucky/Monticello", + "America/Kralendijk", + "America/La_Paz", + "America/Lima", + "America/Los_Angeles", + "America/Louisville", + "America/Lower_Princes", + "America/Maceio", + "America/Managua", + "America/Manaus", + "America/Marigot", + "America/Martinique", + "America/Matamoros", + "America/Mazatlan", + "America/Mendoza", + "America/Menominee", + "America/Merida", + "America/Metlakatla", + "America/Mexico_City", + "America/Miquelon", + "America/Moncton", + "America/Monterrey", + "America/Montevideo", + "America/Montserrat", + "America/Nassau", + "America/New_York", + "America/Nipigon", + "America/Nome", + "America/Noronha", + "America/North_Dakota/Beulah", + "America/North_Dakota/Center", + "America/North_Dakota/New_Salem", + "America/Ojinaga", + "America/Panama", + "America/Pangnirtung", + "America/Paramaribo", + "America/Phoenix", + "America/Port-au-Prince", + "America/Port_of_Spain", + "America/Porto_Velho", + "America/Puerto_Rico", + "America/Punta_Arenas", + "America/Rainy_River", + "America/Rankin_Inlet", + "America/Recife", + "America/Regina", + "America/Resolute", + "America/Rio_Branco", + "America/Santa_Isabel", + "America/Santarem", + "America/Santiago", + "America/Santo_Domingo", + "America/Sao_Paulo", + "America/Scoresbysund", + "America/Sitka", + "America/St_Barthelemy", + "America/St_Johns", + "America/St_Kitts", + "America/St_Lucia", + "America/St_Thomas", + "America/St_Vincent", + "America/Swift_Current", + "America/Tegucigalpa", + "America/Thule", + "America/Thunder_Bay", + "America/Tijuana", + "America/Toronto", + "America/Tortola", + "America/Vancouver", + "America/Whitehorse", + "America/Winnipeg", + "America/Yakutat", + "America/Yellowknife", + "Antarctica/Casey", + "Antarctica/Davis", + "Antarctica/DumontDUrville", + "Antarctica/Macquarie", + "Antarctica/Mawson", + "Antarctica/McMurdo", + "Antarctica/Palmer", + "Antarctica/Rothera", + "Antarctica/Syowa", + "Antarctica/Troll", + "Antarctica/Vostok", + "Arctic/Longyearbyen", + "Asia/Aden", + "Asia/Almaty", + "Asia/Amman", + "Asia/Anadyr", + "Asia/Aqtau", + "Asia/Aqtobe", + "Asia/Ashgabat", + "Asia/Atyrau", + "Asia/Baghdad", + "Asia/Bahrain", + "Asia/Baku", + "Asia/Bangkok", + "Asia/Barnaul", + "Asia/Beirut", + "Asia/Bishkek", + "Asia/Brunei", + "Asia/Calcutta", + "Asia/Chita", + "Asia/Choibalsan", + "Asia/Colombo", + "Asia/Damascus", + "Asia/Dhaka", + "Asia/Dili", + "Asia/Dubai", + "Asia/Dushanbe", + "Asia/Famagusta", + "Asia/Gaza", + "Asia/Hebron", + "Asia/Hong_Kong", + "Asia/Hovd", + "Asia/Irkutsk", + "Asia/Jakarta", + "Asia/Jayapura", + "Asia/Jerusalem", + "Asia/Kabul", + "Asia/Kamchatka", + "Asia/Karachi", + "Asia/Katmandu", + "Asia/Khandyga", + "Asia/Krasnoyarsk", + "Asia/Kuala_Lumpur", + "Asia/Kuching", + "Asia/Kuwait", + "Asia/Macau", + "Asia/Magadan", + "Asia/Makassar", + "Asia/Manila", + "Asia/Muscat", + "Asia/Nicosia", + "Asia/Novokuznetsk", + "Asia/Novosibirsk", + "Asia/Omsk", + "Asia/Oral", + "Asia/Phnom_Penh", + "Asia/Pontianak", + "Asia/Pyongyang", + "Asia/Qatar", + "Asia/Qostanay", + "Asia/Qyzylorda", + "Asia/Rangoon", + "Asia/Riyadh", + "Asia/Saigon", + "Asia/Sakhalin", + "Asia/Samarkand", + "Asia/Seoul", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Srednekolymsk", + "Asia/Taipei", + "Asia/Tashkent", + "Asia/Tbilisi", + "Asia/Tehran", + "Asia/Thimphu", + "Asia/Tokyo", + "Asia/Tomsk", + "Asia/Ulaanbaatar", + "Asia/Urumqi", + "Asia/Ust-Nera", + "Asia/Vientiane", + "Asia/Vladivostok", + "Asia/Yakutsk", + "Asia/Yekaterinburg", + "Asia/Yerevan", + "Atlantic/Azores", + "Atlantic/Bermuda", + "Atlantic/Canary", + "Atlantic/Cape_Verde", + "Atlantic/Faeroe", + "Atlantic/Madeira", + "Atlantic/Reykjavik", + "Atlantic/South_Georgia", + "Atlantic/St_Helena", + "Atlantic/Stanley", + "Australia/Adelaide", + "Australia/Brisbane", + "Australia/Broken_Hill", + "Australia/Currie", + "Australia/Darwin", + "Australia/Eucla", + "Australia/Hobart", + "Australia/Lindeman", + "Australia/Lord_Howe", + "Australia/Melbourne", + "Australia/Perth", + "Australia/Sydney", + "Europe/Amsterdam", + "Europe/Andorra", + "Europe/Astrakhan", + "Europe/Athens", + "Europe/Belgrade", + "Europe/Berlin", + "Europe/Bratislava", + "Europe/Brussels", + "Europe/Bucharest", + "Europe/Budapest", + "Europe/Busingen", + "Europe/Chisinau", + "Europe/Copenhagen", + "Europe/Dublin", + "Europe/Gibraltar", + "Europe/Guernsey", + "Europe/Helsinki", + "Europe/Isle_of_Man", + "Europe/Istanbul", + "Europe/Jersey", + "Europe/Kaliningrad", + "Europe/Kiev", + "Europe/Kirov", + "Europe/Lisbon", + "Europe/Ljubljana", + "Europe/London", + "Europe/Luxembourg", + "Europe/Madrid", + "Europe/Malta", + "Europe/Mariehamn", + "Europe/Minsk", + "Europe/Monaco", + "Europe/Moscow", + "Europe/Oslo", + "Europe/Paris", + "Europe/Podgorica", + "Europe/Prague", + "Europe/Riga", + "Europe/Rome", + "Europe/Samara", + "Europe/San_Marino", + "Europe/Sarajevo", + "Europe/Saratov", + "Europe/Simferopol", + "Europe/Skopje", + "Europe/Sofia", + "Europe/Stockholm", + "Europe/Tallinn", + "Europe/Tirane", + "Europe/Ulyanovsk", + "Europe/Uzhgorod", + "Europe/Vaduz", + "Europe/Vatican", + "Europe/Vienna", + "Europe/Vilnius", + "Europe/Volgograd", + "Europe/Warsaw", + "Europe/Zagreb", + "Europe/Zaporozhye", + "Europe/Zurich", + "Indian/Antananarivo", + "Indian/Chagos", + "Indian/Christmas", + "Indian/Cocos", + "Indian/Comoro", + "Indian/Kerguelen", + "Indian/Mahe", + "Indian/Maldives", + "Indian/Mauritius", + "Indian/Mayotte", + "Indian/Reunion", + "Pacific/Apia", + "Pacific/Auckland", + "Pacific/Bougainville", + "Pacific/Chatham", + "Pacific/Easter", + "Pacific/Efate", + "Pacific/Enderbury", + "Pacific/Fakaofo", + "Pacific/Fiji", + "Pacific/Funafuti", + "Pacific/Galapagos", + "Pacific/Gambier", + "Pacific/Guadalcanal", + "Pacific/Guam", + "Pacific/Honolulu", + "Pacific/Johnston", + "Pacific/Kiritimati", + "Pacific/Kosrae", + "Pacific/Kwajalein", + "Pacific/Majuro", + "Pacific/Marquesas", + "Pacific/Midway", + "Pacific/Nauru", + "Pacific/Niue", + "Pacific/Norfolk", + "Pacific/Noumea", + "Pacific/Pago_Pago", + "Pacific/Palau", + "Pacific/Pitcairn", + "Pacific/Ponape", + "Pacific/Port_Moresby", + "Pacific/Rarotonga", + "Pacific/Saipan", + "Pacific/Tahiti", + "Pacific/Tarawa", + "Pacific/Tongatapu", + "Pacific/Truk", + "Pacific/Wake", + "Pacific/Wallis", +] as const; diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 000000000..f42999196 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,14 @@ +/// +import path from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/*.spec.ts"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, +}); diff --git a/package.json b/package.json index c10c88759..513b6c6d6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "db:setup": "run-s db:deploy db:generate db:seed", "dx": "run-s db:up db:setup", "test": "turbo build:test && turbo test", + "test:unit": "turbo test:unit", "lint": "turbo lint", "i18n:scan": "turbo i18n:scan", "lint:tsc": "turbo lint:tsc", @@ -49,7 +50,8 @@ "npm-run-all": "^4.1.5", "prettier": "^3.2.4", "prettier-plugin-tailwindcss": "^0.5.11", - "turbo": "^1.10.7" + "turbo": "^1.10.7", + "vitest": "^1.3.1" }, "engines": { "node": ">=18.17.0" diff --git a/packages/database/prisma/migrations/20240221084400_unset_invalid_timezones/migration.sql b/packages/database/prisma/migrations/20240221084400_unset_invalid_timezones/migration.sql new file mode 100644 index 000000000..b4a3bae0a --- /dev/null +++ b/packages/database/prisma/migrations/20240221084400_unset_invalid_timezones/migration.sql @@ -0,0 +1,2 @@ +-- Unset non-geographic time zones +UPDATE users SET time_zone = NULL WHERE time_zone NOT LIKE '%/%' OR time_zone LIKE 'Etc/%'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 941c768e1..9343d1bef 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,6 +30,7 @@ "@rallly/tailwind-config": "*", "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", + "cmdk": "^0.2.1", "tailwind-merge": "^1.12.0" }, "devDependencies": { diff --git a/packages/ui/src/card.tsx b/packages/ui/src/card.tsx index 087659975..f69048445 100644 --- a/packages/ui/src/card.tsx +++ b/packages/ui/src/card.tsx @@ -23,10 +23,7 @@ const CardHeader = React.forwardRef< >(({ className, ...props }, ref) => (
)); @@ -39,7 +36,7 @@ const CardTitle = React.forwardRef<

>(({ className, ...props }, ref) => ( -
+
)); CardContent.displayName = "CardContent"; @@ -74,7 +71,7 @@ const CardFooter = React.forwardRef<
{ return ( - + {children} diff --git a/packages/ui/src/input.tsx b/packages/ui/src/input.tsx index 69536db62..9efe1e140 100644 --- a/packages/ui/src/input.tsx +++ b/packages/ui/src/input.tsx @@ -19,7 +19,7 @@ const inputVariants = cva( variants: { size: { sm: "h-6 text-sm px-1", - md: "h-9 text-base px-2", + md: "h-9 text-sm px-2", lg: "h-12 text-lg px-3", }, }, diff --git a/packages/ui/src/textarea.tsx b/packages/ui/src/textarea.tsx index ca1bdb3e7..3047c5742 100644 --- a/packages/ui/src/textarea.tsx +++ b/packages/ui/src/textarea.tsx @@ -9,7 +9,7 @@ const Textarea = React.forwardRef( return (