mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +02:00
✨ Use only geographic time zones (#1033)
This commit is contained in:
parent
39a22acaa7
commit
27dda65ca5
31 changed files with 1416 additions and 618 deletions
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 <a>{name}</a>",
|
||||
"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."
|
||||
}
|
||||
|
|
|
@ -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 = () => {
|
|||
<Trans i18nKey="timeZone" />
|
||||
</Label>
|
||||
<TimeZoneSelect
|
||||
value={preferences.timeZone ?? timeZone}
|
||||
value={timeZone}
|
||||
onValueChange={(newTimeZone) => {
|
||||
updatePreferences({ timeZone: newTimeZone });
|
||||
}}
|
||||
|
@ -43,7 +43,7 @@ export const TimePreferences = () => {
|
|||
<Trans i18nKey="timeFormat" />
|
||||
</Label>
|
||||
<TimeFormatPicker
|
||||
value={preferences.timeFormat ?? timeFormat}
|
||||
value={timeFormat}
|
||||
onChange={(newTimeFormat) => {
|
||||
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 (
|
||||
<ClockPreferences>
|
||||
<button className="inline-flex items-center gap-x-2 text-sm hover:underline">
|
||||
<GlobeIcon className="size-4" />
|
||||
<Trans i18nKey="timeShownIn" values={{ timeZone: timeZoneName }} />
|
||||
<Trans
|
||||
i18nKey="timeShownIn"
|
||||
values={{ timeZone: timeZone.replaceAll("_", " ") }}
|
||||
/>
|
||||
</button>
|
||||
</ClockPreferences>
|
||||
);
|
||||
|
|
|
@ -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<DateCardProps> = ({
|
||||
annotation,
|
||||
className,
|
||||
day,
|
||||
dow,
|
||||
month,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative inline-flex size-14 items-center justify-center rounded-md border border-gray-200 bg-white p-1 text-center",
|
||||
"relative inline-flex size-12 flex-col rounded-md border bg-gray-50 text-center text-slate-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{annotation ? (
|
||||
<div className="absolute -right-3 -top-3 z-10">{annotation}</div>
|
||||
) : null}
|
||||
<div>
|
||||
{dow ? (
|
||||
<div className="-mt-3 bg-white text-center text-xs text-gray-500">
|
||||
{dow}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-center text-xl font-bold leading-none">{day}</div>
|
||||
<div className="text-center text-xs font-semibold uppercase leading-normal text-gray-500">
|
||||
{month}
|
||||
</div>
|
||||
<div className="text-muted-foreground border-b border-gray-200 text-xs font-normal leading-4">
|
||||
{month}
|
||||
</div>
|
||||
<div className="flex grow items-center justify-center rounded-b-md bg-white text-lg font-semibold leading-none tracking-tight">
|
||||
{day}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -53,7 +53,9 @@ export const PollDetailsForm = () => {
|
|||
|
||||
<FormItem>
|
||||
<div>
|
||||
<FormLabel className="inline-block">{t("location")}</FormLabel>
|
||||
<FormLabel className="inline-block" htmlFor="location">
|
||||
{t("location")}
|
||||
</FormLabel>
|
||||
<span className="text-muted-foreground ml-1 text-sm">
|
||||
<Trans i18nKey="optionalLabel" defaults="(Optional)" />
|
||||
</span>
|
||||
|
|
|
@ -254,7 +254,7 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-400px)] min-h-0 grow overflow-auto">
|
||||
<div className="max-h-[calc(100vh-380px)] min-h-0 grow overflow-auto">
|
||||
{isTimedEvent ? (
|
||||
<div className="divide-y">
|
||||
{Object.keys(optionsByDay)
|
||||
|
@ -264,13 +264,11 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
|||
return (
|
||||
<div
|
||||
key={dateString}
|
||||
className="space-y-3 p-3 sm:flex sm:space-x-4 sm:space-y-0 sm:p-4"
|
||||
className="space-y-3 p-3 sm:flex sm:items-start sm:space-x-4 sm:space-y-0 sm:p-4"
|
||||
>
|
||||
<div>
|
||||
<DateCard
|
||||
{...getDateProps(new Date(dateString + "T12:00:00"))}
|
||||
/>
|
||||
</div>
|
||||
<DateCard
|
||||
{...getDateProps(new Date(dateString + "T12:00:00"))}
|
||||
/>
|
||||
<div className="grow space-y-3">
|
||||
{optionsForDay.map(({ option, index }) => {
|
||||
if (option.type === "date") {
|
||||
|
@ -447,7 +445,7 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
|||
})}
|
||||
</div>
|
||||
) : datepicker.selection.length ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,54px)] gap-3 p-3 sm:gap-4 sm:p-4">
|
||||
<div className="flex flex-wrap gap-3 p-3 sm:gap-4 sm:p-4">
|
||||
{datepicker.selection
|
||||
.sort((a, b) => a.getTime() - b.getTime())
|
||||
.map((selectedDate, i) => {
|
||||
|
@ -455,18 +453,18 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
|||
<DateCard
|
||||
key={i}
|
||||
{...getDateProps(selectedDate)}
|
||||
annotation={
|
||||
<CompactButton
|
||||
icon={XIcon}
|
||||
onClick={() => {
|
||||
// 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={
|
||||
// <CompactButton
|
||||
// icon={XIcon}
|
||||
// onClick={() => {
|
||||
// // 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),
|
||||
// );
|
||||
// }}
|
||||
// />
|
||||
// }
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -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<NewEventData>();
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col justify-between gap-4 sm:flex-row">
|
||||
<div>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="calendar">Calendar</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans i18nKey="selectPotentialDates">
|
||||
Select potential dates for your event
|
||||
</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="view"
|
||||
render={({ field }) => (
|
||||
<Tabs value={field.value} onValueChange={field.onChange}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="grow" value="month">
|
||||
<CalendarIcon className="size-4 mr-2" />
|
||||
<Trans i18nKey="monthView" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="grow" value="week">
|
||||
<TableIcon className="size-4 mr-2" />
|
||||
<Trans i18nKey="weekView" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{dateOrTimeRangeModal}
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options"
|
||||
rules={{
|
||||
validate: (options) => {
|
||||
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 }) => (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col justify-between gap-4 sm:flex-row">
|
||||
<div>
|
||||
<selectedView.Component
|
||||
options={field.value}
|
||||
date={navigationDate}
|
||||
onNavigate={(date) => {
|
||||
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 ? (
|
||||
<div className="border-t bg-red-50 p-3 text-center">
|
||||
<FormMessage />
|
||||
</div>
|
||||
) : null}
|
||||
<CardTitle>
|
||||
<Trans i18nKey="calendar">Calendar</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans i18nKey="selectPotentialDates">
|
||||
Select potential dates for your event
|
||||
</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow border-t bg-gray-50 px-5 py-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeZone"
|
||||
render={({ field }) => (
|
||||
<TimeZonePicker
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(timeZone) => {
|
||||
setValue("timeZone", timeZone, { shouldTouch: true });
|
||||
}}
|
||||
disabled={datesOnly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="view"
|
||||
render={({ field }) => (
|
||||
<Tabs value={field.value} onValueChange={field.onChange}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="grow" value="month">
|
||||
<CalendarIcon className="mr-2 size-4" />
|
||||
<Trans i18nKey="monthView" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="grow" value="week">
|
||||
<TableIcon className="mr-2 size-4" />
|
||||
<Trans i18nKey="weekView" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{dateOrTimeRangeModal}
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options"
|
||||
rules={{
|
||||
validate: (options) => {
|
||||
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 }) => (
|
||||
<div>
|
||||
<selectedView.Component
|
||||
options={field.value}
|
||||
date={navigationDate}
|
||||
onNavigate={(date) => {
|
||||
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 ? (
|
||||
<div className="border-t bg-red-50 p-3 text-center">
|
||||
<FormMessage />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{!datesOnly ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeZone"
|
||||
render={({ field }) => (
|
||||
<div className="grid items-center justify-between gap-2.5 border-t bg-gray-50 p-4 md:flex">
|
||||
<div className="flex h-9 items-center gap-x-2.5 p-2">
|
||||
<Switch
|
||||
id="timeZone"
|
||||
checked={!!field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
field.onChange(getBrowserTimeZone());
|
||||
} else {
|
||||
field.onChange("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="timeZone">
|
||||
<Trans
|
||||
i18nKey="autoTimeZone"
|
||||
defaults="Automatic Time Zone Conversion"
|
||||
/>
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="text-muted-foreground size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="w-72">
|
||||
<Trans
|
||||
i18nKey="autoTimeZoneHelp"
|
||||
defaults="Enable this setting to automatically adjust event times to each participant's local time zone."
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{field.value ? (
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
showTimeZoneCommandModal(true);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
<GlobeIcon className="text-muted-foreground size-4" />
|
||||
{field.value}
|
||||
</Button>
|
||||
<CommandDialog
|
||||
open={isTimeZoneCommandModalOpen}
|
||||
onOpenChange={showTimeZoneCommandModal}
|
||||
>
|
||||
<TimeZoneCommand
|
||||
value={field.value}
|
||||
onSelect={(newValue) => {
|
||||
field.onChange(newValue);
|
||||
showTimeZoneCommandModal(false);
|
||||
}}
|
||||
/>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -33,9 +33,9 @@ const DateTimePreferencesForm = () => {
|
|||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
timeFormat: preferences.timeFormat ?? timeFormat,
|
||||
weekStart: preferences.weekStart ?? weekStart,
|
||||
timeZone: preferences.timeZone ?? timeZone,
|
||||
timeFormat,
|
||||
weekStart,
|
||||
timeZone,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./time-zone-picker";
|
|
@ -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<TimeZoneOption[]>((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 (
|
||||
<Combobox
|
||||
value={selectedTimeZone}
|
||||
onChange={(newTimeZone) => {
|
||||
setQuery("");
|
||||
onChange(newTimeZone.value);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div
|
||||
className={clsx("relative", className)}
|
||||
ref={reference}
|
||||
style={style}
|
||||
>
|
||||
{/* Remove generic params once Combobox.Input can infer the types */}
|
||||
<Combobox.Input<"input">
|
||||
className="input w-full pr-8"
|
||||
displayValue={() => ""}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-0 flex h-9 w-full cursor-default items-center px-2 text-left text-sm">
|
||||
<span className="grow truncate">
|
||||
{!query ? selectedTimeZone.label : null}
|
||||
</span>
|
||||
<span className="pointer-events-none flex">
|
||||
<ChevronDownIcon className="size-5" />
|
||||
</span>
|
||||
</Combobox.Button>
|
||||
<FloatingPortal>
|
||||
<Combobox.Options
|
||||
ref={floating}
|
||||
className="z-50 mt-1 max-h-72 overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
style={{
|
||||
position: strategy,
|
||||
left: x ?? "",
|
||||
top: y ?? "",
|
||||
}}
|
||||
>
|
||||
{filteredTimeZones.map((timeZone) => (
|
||||
<Combobox.Option
|
||||
key={timeZone.value}
|
||||
className={styleMenuItem}
|
||||
value={timeZone}
|
||||
>
|
||||
{timeZone.label}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</FloatingPortal>
|
||||
</div>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeZonePicker;
|
|
@ -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…",
|
||||
})}
|
||||
/>
|
||||
<CommandList className="max-h-[300px] max-w-[var(--radix-popover-content-available-width)] overflow-y-auto">
|
||||
<CommandList className="max-h-[300px] w-[var(--radix-popover-trigger-width)] max-w-[var(--radix-popover-content-available-width)] overflow-y-auto">
|
||||
<CommandEmpty>
|
||||
<Trans
|
||||
i18nKey="timeZoneSelect__noOption"
|
||||
defaults="No option found"
|
||||
/>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{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 (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => onSelect?.(option.value)}
|
||||
className="flex min-w-0 gap-x-2.5"
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
value === option.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 grow truncate">{option.label}</span>
|
||||
<span className="whitespace-nowrap rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||
{offset}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
{Object.entries(groupedTimeZones).map(([region, timeZones]) => (
|
||||
<CommandGroup heading={region} key={region}>
|
||||
{timeZones.map(({ timezone, city }) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={timezone}
|
||||
onSelect={() => onSelect?.(timezone)}
|
||||
className="flex min-w-0 gap-x-2.5"
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
value === timezone ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 grow truncate">{city}</span>
|
||||
<span className="whitespace-nowrap rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||
{dayjs().tz(timezone).format("LT")}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
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<HTMLButtonElement, SelectProps>(
|
||||
({ value, onValueChange, disabled }, ref) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const popoverContentId = "timeZoneSelect__popoverContent";
|
||||
const fuzzyValue = value ? findFuzzyTz(value) : undefined;
|
||||
|
||||
return (
|
||||
<Popover modal={false} open={open} onOpenChange={setOpen}>
|
||||
|
@ -156,8 +88,8 @@ export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
|
|||
>
|
||||
<GlobeIcon className="size-4" />
|
||||
<span className="grow truncate text-left">
|
||||
{fuzzyValue ? (
|
||||
fuzzyValue.label
|
||||
{value ? (
|
||||
value.replaceAll("_", " ")
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="timeZoneSelect__defaultValue"
|
||||
|
@ -165,7 +97,7 @@ export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
|
|||
/>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 ml-2 shrink-0 opacity-50" />
|
||||
<ChevronDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
|
|
@ -1,81 +0,0 @@
|
|||
{
|
||||
"Pacific/Midway": "Midway Island, Samoa",
|
||||
"Pacific/Honolulu": "Hawaii",
|
||||
"America/Juneau": "Alaska",
|
||||
"America/Boise": "Mountain Time",
|
||||
"America/Dawson": "Dawson, Yukon",
|
||||
"America/Chihuahua": "Chihuahua, La Paz, Mazatlan",
|
||||
"America/Phoenix": "Arizona",
|
||||
"America/Chicago": "Central Time",
|
||||
"America/Regina": "Saskatchewan",
|
||||
"America/Mexico_City": "Guadalajara, Mexico City, Monterrey",
|
||||
"America/Belize": "Central America",
|
||||
"America/Detroit": "Eastern Time",
|
||||
"America/Bogota": "Bogota, Lima, Quito",
|
||||
"America/Caracas": "Caracas, La Paz",
|
||||
"America/Santiago": "Santiago",
|
||||
"America/St_Johns": "Newfoundland and Labrador",
|
||||
"America/Sao_Paulo": "Brasilia",
|
||||
"America/Tijuana": "Tijuana",
|
||||
"America/Montevideo": "Montevideo",
|
||||
"America/Argentina/Buenos_Aires": "Buenos Aires, Georgetown",
|
||||
"America/Godthab": "Greenland",
|
||||
"America/Los_Angeles": "Pacific Time",
|
||||
"Atlantic/Azores": "Azores",
|
||||
"Atlantic/Cape_Verde": "Cape Verde Islands",
|
||||
"GMT": "UTC",
|
||||
"Europe/London": "Edinburgh, London",
|
||||
"Europe/Dublin": "Dublin",
|
||||
"Europe/Lisbon": "Lisbon",
|
||||
"Africa/Casablanca": "Casablanca, Monrovia",
|
||||
"Atlantic/Canary": "Canary Islands",
|
||||
"Europe/Belgrade": "Belgrade, Bratislava, Budapest, Ljubljana, Prague",
|
||||
"Europe/Sarajevo": "Sarajevo, Skopje, Warsaw, Zagreb",
|
||||
"Europe/Brussels": "Brussels, Copenhagen, Madrid, Paris",
|
||||
"Europe/Amsterdam": "Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
|
||||
"Africa/Algiers": "West Central Africa",
|
||||
"Europe/Bucharest": "Bucharest",
|
||||
"Africa/Cairo": "Cairo",
|
||||
"Europe/Helsinki": "Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius",
|
||||
"Europe/Athens": "Athens, Minsk",
|
||||
"Asia/Jerusalem": "Jerusalem",
|
||||
"Africa/Harare": "Harare, Pretoria",
|
||||
"Europe/Moscow": "Istanbul, Moscow, St. Petersburg, Volgograd",
|
||||
"Asia/Kuwait": "Kuwait, Riyadh",
|
||||
"Africa/Nairobi": "Nairobi",
|
||||
"Asia/Baghdad": "Baghdad",
|
||||
"Asia/Tehran": "Tehran",
|
||||
"Asia/Dubai": "Abu Dhabi, Muscat",
|
||||
"Asia/Baku": "Baku, Tbilisi, Yerevan",
|
||||
"Asia/Kabul": "Kabul",
|
||||
"Asia/Yekaterinburg": "Ekaterinburg",
|
||||
"Asia/Karachi": "Islamabad, Karachi, Tashkent",
|
||||
"Asia/Kolkata": "Chennai, Kolkata, Mumbai, New Delhi",
|
||||
"Asia/Kathmandu": "Kathmandu",
|
||||
"Asia/Dhaka": "Astana, Dhaka",
|
||||
"Asia/Colombo": "Sri Jayawardenepura",
|
||||
"Asia/Almaty": "Almaty, Novosibirsk",
|
||||
"Asia/Rangoon": "Yangon Rangoon",
|
||||
"Asia/Bangkok": "Bangkok, Hanoi, Jakarta",
|
||||
"Asia/Krasnoyarsk": "Krasnoyarsk",
|
||||
"Asia/Shanghai": "Beijing, Chongqing, Hong Kong SAR, Urumqi",
|
||||
"Asia/Kuala_Lumpur": "Kuala Lumpur, Singapore",
|
||||
"Asia/Taipei": "Taipei",
|
||||
"Australia/Perth": "Perth",
|
||||
"Asia/Irkutsk": "Irkutsk, Ulaanbaatar",
|
||||
"Asia/Seoul": "Seoul",
|
||||
"Asia/Tokyo": "Osaka, Sapporo, Tokyo",
|
||||
"Asia/Yakutsk": "Yakutsk",
|
||||
"Australia/Darwin": "Darwin",
|
||||
"Australia/Adelaide": "Adelaide",
|
||||
"Australia/Sydney": "Canberra, Melbourne, Sydney",
|
||||
"Australia/Brisbane": "Brisbane",
|
||||
"Australia/Hobart": "Hobart",
|
||||
"Asia/Vladivostok": "Vladivostok",
|
||||
"Pacific/Guam": "Guam, Port Moresby",
|
||||
"Asia/Magadan": "Magadan, Solomon Islands, New Caledonia",
|
||||
"Asia/Kamchatka": "Kamchatka, Marshall Islands",
|
||||
"Pacific/Fiji": "Fiji Islands",
|
||||
"Pacific/Auckland": "Auckland, Wellington",
|
||||
"Pacific/Tongatapu": "Nuku'alofa"
|
||||
}
|
|
@ -265,14 +265,12 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
|||
}
|
||||
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 }) {
|
||||
|
|
|
@ -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"
|
||||
|
|
38
apps/web/src/utils/date-time-utilts.test.ts
Normal file
38
apps/web/src/utils/date-time-utilts.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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) => {
|
||||
|
|
14
apps/web/src/utils/grouped-time-zone.ts
Normal file
14
apps/web/src/utils/grouped-time-zone.ts
Normal file
|
@ -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<string, { timezone: string; city: string }[]>,
|
||||
);
|
430
apps/web/src/utils/supported-time-zones.ts
Normal file
430
apps/web/src/utils/supported-time-zones.ts
Normal file
|
@ -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;
|
14
apps/web/vitest.config.ts
Normal file
14
apps/web/vitest.config.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/// <reference types="vitest" />
|
||||
import path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ["**/*.spec.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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"
|
||||
|
|
|
@ -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/%';
|
|
@ -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": {
|
||||
|
|
|
@ -23,10 +23,7 @@ const CardHeader = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid border-b border-gray-100 p-3 sm:px-5 sm:py-4",
|
||||
className,
|
||||
)}
|
||||
className={cn("grid border-b border-gray-100 p-3 sm:p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
@ -39,7 +36,7 @@ const CardTitle = React.forwardRef<
|
|||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"font-semibold tracking-tight sm:text-lg sm:leading-tight",
|
||||
"mb-1 font-semibold tracking-tight sm:leading-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
@ -63,7 +60,7 @@ const CardContent = React.forwardRef<
|
|||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-3 sm:px-5 sm:py-4", className)} {...props} />
|
||||
<div ref={ref} className={cn("p-3 sm:p-4", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
|
@ -74,7 +71,7 @@ const CardFooter = React.forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center gap-x-2 rounded-b-md border-t bg-gray-50 p-3 sm:px-5",
|
||||
"flex items-center gap-x-2 rounded-b-md border-t bg-gray-50 p-3 sm:px-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -28,7 +28,7 @@ type CommandDialogProps = DialogProps;
|
|||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
||||
<DialogContent className="shadow-huge w-full max-w-3xl overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
|
|
|
@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground flex min-h-[80px] w-full rounded border bg-transparent px-3 py-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"border-input placeholder:text-muted-foreground flex min-h-[80px] w-full rounded border bg-transparent px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:ring-offset-input-background focus-visible:ring-1 focus-visible:ring-offset-1",
|
||||
"focus-visible:border-primary-400 focus-visible:ring-primary-100",
|
||||
className,
|
||||
|
|
|
@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
|
|||
sideOffset={sideOffset}
|
||||
side={side}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-gray-700 px-2 py-1.5 text-sm text-gray-50 shadow-md",
|
||||
"z-50 overflow-hidden rounded-md bg-gray-800 px-2 py-1.5 text-sm text-gray-50 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -24,6 +24,9 @@
|
|||
"env": ["CI"],
|
||||
"dependsOn": ["^build:test"]
|
||||
},
|
||||
"test:unit": {
|
||||
"cache": true
|
||||
},
|
||||
"db:generate": {
|
||||
"dependsOn": ["^db:generate"]
|
||||
},
|
||||
|
|
1
vitest.workspace.ts
Normal file
1
vitest.workspace.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export default ["packages/*/vitest.config.ts", "apps/*/vitest.config.ts"];
|
Loading…
Add table
Reference in a new issue