Use only geographic time zones (#1033)

This commit is contained in:
Luke Vella 2024-02-24 09:12:05 +08:00 committed by GitHub
parent 39a22acaa7
commit 27dda65ca5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1416 additions and 618 deletions

View file

@ -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",

View file

@ -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,

View file

@ -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."
}

View file

@ -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>
);

View file

@ -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>
);

View file

@ -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>

View file

@ -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),
// );
// }}
// />
// }
/>
);
})}

View file

@ -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>
</>
);
};

View file

@ -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,
},
});

View file

@ -1 +0,0 @@
export { default } from "./time-zone-picker";

View file

@ -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;

View file

@ -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

View file

@ -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"
}

View file

@ -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 }) {

View file

@ -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"

View 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);
});
});

View file

@ -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) => {

View 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 }[]>,
);

View 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
View 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"),
},
},
});

View file

@ -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"

View file

@ -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/%';

View file

@ -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": {

View file

@ -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}

View file

@ -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>

View file

@ -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",
},
},

View file

@ -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,

View file

@ -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}

View file

@ -24,6 +24,9 @@
"env": ["CI"],
"dependsOn": ["^build:test"]
},
"test:unit": {
"cache": true
},
"db:generate": {
"dependsOn": ["^db:generate"]
},

1
vitest.workspace.ts Normal file
View file

@ -0,0 +1 @@
export default ["packages/*/vitest.config.ts", "apps/*/vitest.config.ts"];

631
yarn.lock

File diff suppressed because it is too large Load diff