Updated clock preferences (#789)

This commit is contained in:
Luke Vella 2023-07-26 17:12:44 +01:00 committed by GitHub
parent e5b58c6a21
commit 8ec075b80f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 246 additions and 158 deletions

View file

@ -154,9 +154,7 @@
"noTransfer": "No, take me home",
"share": "Share",
"timeShownIn": "Times shown in {timeZone}",
"timeShownInLocalTime": "Times shown in local time",
"editDetailsDescription": "Change the details of your event.",
"editOptionsDescription": "Change the options available in your poll.",
"finalizeDescription": "Select a final date for your event.",
"notificationsGuestTooltip": "Create an account or login to turn get notifications",
"planFree": "Free",
@ -213,5 +211,7 @@
"hideScoresDescription": "Only show scores until after a participant has voted",
"disableComments": "Disable comments",
"disableCommentsDescription": "Remove the option to leave a comment on the poll",
"planCustomizablePollSettings": "Customizable poll settings"
"planCustomizablePollSettings": "Customizable poll settings",
"clockPreferences": "Clock Preferences",
"clockPreferencesDescription": "Set your preferred time zone and time format."
}

View file

@ -0,0 +1,146 @@
import { trpc } from "@rallly/backend";
import { GlobeIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@rallly/ui/dialog";
import { Label } from "@rallly/ui/label";
import dayjs from "dayjs";
import React from "react";
import { useInterval } from "react-use";
import spacetime from "spacetime";
import soft from "timezone-soft";
import { TimeFormatPicker } from "@/components/time-format-picker";
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
import { Trans } from "@/components/trans";
import { useDayjs } from "@/utils/dayjs";
export const TimePreferences = () => {
const { timeZone, timeFormat } = useDayjs();
const queryClient = trpc.useContext();
const { data } = trpc.userPreferences.get.useQuery();
const updatePreferences = trpc.userPreferences.update.useMutation({
onMutate: (newPreferences) => {
queryClient.userPreferences.get.setData(undefined, (oldPreferences) => {
if (!oldPreferences) {
return null;
}
return {
...oldPreferences,
timeFormat: newPreferences.timeFormat ?? oldPreferences?.timeFormat,
timeZone: newPreferences.timeZone ?? oldPreferences?.timeZone ?? null,
weekStart: newPreferences.weekStart ?? oldPreferences?.weekStart,
};
});
},
onSuccess: () => {
queryClient.userPreferences.get.invalidate();
},
});
if (data === undefined) {
return null;
}
return (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
<Trans i18nKey="timeZone" />
</Label>
<TimeZoneSelect
value={timeZone}
onValueChange={(newTimeZone) => {
updatePreferences.mutate({
timeZone: newTimeZone,
});
}}
/>
</div>
<div className="grid gap-2">
<Label>
<Trans i18nKey="timeFormat" />
</Label>
<TimeFormatPicker
value={timeFormat}
onChange={(newTimeFormat) => {
updatePreferences.mutate({
timeFormat: newTimeFormat,
});
}}
/>
</div>
</div>
);
};
export const Clock = ({ className }: { className?: string }) => {
const { timeZone, timeFormat } = useDayjs();
const timeZoneDisplayFormat = soft(timeZone)[0];
const now = spacetime.now(timeZone);
const standardAbbrev = timeZoneDisplayFormat.standard.abbr;
const dstAbbrev = timeZoneDisplayFormat.daylight?.abbr;
const abbrev = now.isDST() ? dstAbbrev : standardAbbrev;
const [time, setTime] = React.useState(new Date());
useInterval(() => {
setTime(new Date());
}, 1000);
return (
<span
key={timeFormat}
className={cn("inline-block font-medium tabular-nums", className)}
>{`${dayjs(time).tz(timeZone).format("LT")} ${abbrev}`}</span>
);
};
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="h-4 w-4" />
<Trans i18nKey="timeShownIn" values={{ timeZone: timeZoneName }} />
</button>
</ClockPreferences>
);
};
export const ClockPreferences = ({ children }: React.PropsWithChildren) => {
return (
<Dialog modal={false}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
<Trans i18nKey="clockPreferences" defaults="Clock Preferences" />
</DialogTitle>
<DialogDescription>
<Trans
i18nKey="clockPreferencesDescription"
defaults="Set your preferred time zone and time format."
/>
</DialogDescription>
</DialogHeader>
<div className="bg-muted grid h-24 items-center justify-center rounded-md text-2xl font-bold">
<Clock />
</div>
<TimePreferences />
</DialogContent>
</Dialog>
);
};

View file

@ -1,23 +1,20 @@
import { ClockIcon, ListIcon, LogInIcon } from "@rallly/icons";
import { ListIcon, LogInIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import clsx from "clsx";
import dayjs from "dayjs";
import { AnimatePresence, m } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
import { Toaster } from "react-hot-toast";
import { useInterval } from "react-use";
import spacetime from "spacetime";
import soft from "timezone-soft";
import { Clock, ClockPreferences } from "@/components/clock";
import { Container } from "@/components/container";
import FeedbackButton from "@/components/feedback";
import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans";
import { UserDropdown } from "@/components/user-dropdown";
import { useDayjs } from "@/utils/dayjs";
import { IconComponent, NextPageWithLayout } from "../../types";
import ModalProvider from "../modal/modal-provider";
@ -28,27 +25,32 @@ const NavMenuItem = ({
target,
label,
icon: Icon,
className,
}: {
icon: IconComponent;
href: string;
target?: string;
label: React.ReactNode;
className?: string;
}) => {
const router = useRouter();
return (
<Link
target={target}
href={href}
className={cn(
"flex items-center gap-2.5 px-2.5 py-1.5 text-sm font-medium",
router.asPath === href
? "text-foreground"
: "text-muted-foreground hover:text-foreground active:bg-gray-200/50",
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
<Button variant="ghost" asChild>
<Link
target={target}
href={href}
className={cn(
"flex items-center gap-2.5 px-2.5 py-1.5 text-sm font-medium",
router.asPath === href
? "text-foreground"
: "text-muted-foreground hover:text-foreground active:bg-gray-200/50",
className,
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
</Button>
);
};
@ -93,28 +95,6 @@ const Logo = () => {
);
};
const Clock = () => {
const { timeZone } = useDayjs();
const timeZoneDisplayFormat = soft(timeZone)[0];
const now = spacetime.now(timeZone);
const standardAbbrev = timeZoneDisplayFormat.standard.abbr;
const dstAbbrev = timeZoneDisplayFormat.daylight?.abbr;
const abbrev = now.isDST() ? dstAbbrev : standardAbbrev;
const [time, setTime] = React.useState(new Date());
useInterval(() => {
setTime(new Date());
}, 1000);
return (
<NavMenuItem
icon={ClockIcon}
href="/settings/preferences"
label={`${dayjs(time).tz(timeZone).format("LT")} ${abbrev}`}
/>
);
};
const MainNav = () => {
return (
<m.div
@ -139,15 +119,20 @@ const MainNav = () => {
</nav>
</div>
<div className="flex items-center gap-x-4">
<nav className="hidden gap-x-2 sm:flex">
<nav className="flex gap-x-2">
<IfGuest>
<NavMenuItem
className="hidden sm:flex"
icon={LogInIcon}
href="/login"
label={<Trans i18nKey="login" defaults="Login" />}
/>
</IfGuest>
<Clock />
<ClockPreferences>
<Button className="text-muted-foreground" variant="ghost">
<Clock />
</Button>
</ClockPreferences>
</nav>
<UserDropdown />
</div>

View file

@ -10,9 +10,9 @@ import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import { useMeasure, useUpdateEffect } from "react-use";
import { TimesShownIn } from "@/components/clock";
import { usePermissions } from "@/contexts/permissions";
import { useRole } from "@/contexts/role";
import { TimePreferences } from "@/contexts/time-preferences";
import { useNewParticipantModal } from "../new-participant-modal";
import {
@ -193,8 +193,8 @@ const Poll: React.FunctionComponent = () => {
</div>
</div>
{poll.options[0].duration !== 0 ? (
<div className="border-b p-3">
<TimePreferences />
<div className="border-b bg-gray-50 p-3">
<TimesShownIn />
</div>
) : null}
<div>

View file

@ -18,8 +18,7 @@ import { DateIcon } from "@/components/date-icon";
import { useParticipants } from "@/components/participants-provider";
import { ConnectedScoreSummary } from "@/components/poll/score-summary";
import { VoteSummaryProgressBar } from "@/components/vote-summary-progress-bar";
import { usePoll } from "@/contexts/poll";
import { useDateFormatter } from "@/contexts/time-preferences";
import { useDateFormatter, usePoll } from "@/contexts/poll";
const formSchema = z.object({
selectedOptionId: z.string(),

View file

@ -18,8 +18,7 @@ import { DateIcon } from "@/components/date-icon";
import { useParticipants } from "@/components/participants-provider";
import { ConnectedScoreSummary } from "@/components/poll/score-summary";
import { VoteSummaryProgressBar } from "@/components/vote-summary-progress-bar";
import { usePoll } from "@/contexts/poll";
import { useDateFormatter } from "@/contexts/time-preferences";
import { useDateFormatter, usePoll } from "@/contexts/poll";
const formSchema = z.object({
selectedOptionId: z.string(),

View file

@ -8,10 +8,10 @@ import { FormProvider, useForm } from "react-hook-form";
import { useUpdateEffect } from "react-use";
import smoothscroll from "smoothscroll-polyfill";
import { TimesShownIn } from "@/components/clock";
import { ParticipantDropdown } from "@/components/participant-dropdown";
import { useOptions, usePoll } from "@/components/poll-context";
import { usePermissions } from "@/contexts/permissions";
import { TimePreferences } from "@/contexts/time-preferences";
import { styleMenuItem } from "../menu-styles";
import { useNewParticipantModal } from "../new-participant-modal";
@ -216,8 +216,8 @@ const MobilePoll: React.FunctionComponent = () => {
</div>
</div>
{poll.options[0].duration !== 0 ? (
<div className="overflow-x-auto border-b bg-gray-50 p-3">
<TimePreferences />
<div className="flex border-b bg-gray-50 p-3">
<TimesShownIn />
</div>
) : null}
<GroupedOptions

View file

@ -1,6 +1,5 @@
import { TimeFormat } from "@rallly/database";
import { cn } from "@rallly/ui";
import React from "react";
import { RadioGroup, RadioGroupItem } from "@rallly/ui/radio-group";
import { Trans } from "@/components/trans";
@ -10,51 +9,28 @@ interface TimeFormatPickerProps {
disabled?: boolean;
}
const RadioButton = (
props: React.PropsWithChildren<{ checked?: boolean; onClick?: () => void }>,
) => {
return (
<button
{...props}
role="radio"
type="button"
aria-checked={props.checked}
className={cn(
props.checked ? "" : "hover:bg-accent",
"text-muted-foreground aria-checked:text-foreground grow rounded-none px-2 font-medium aria-checked:bg-white",
)}
/>
);
};
const TimeFormatPicker = ({
disabled,
value,
onChange,
}: TimeFormatPickerProps) => {
return (
<div
aria-disabled={disabled}
role="radiogroup"
className="inline-flex h-9 divide-x overflow-hidden whitespace-nowrap rounded-md border bg-gray-50 text-sm aria-disabled:pointer-events-none aria-disabled:opacity-50"
>
<RadioButton
onClick={() => {
onChange?.("hours12");
}}
checked={value === "hours12"}
>
<Trans i18nKey={"12h"} defaults={"12h"} />
</RadioButton>
<RadioButton
onClick={() => {
onChange?.("hours24");
}}
checked={value === "hours24"}
>
<Trans i18nKey={"24h"} defaults={"24h"} />
</RadioButton>
</div>
<RadioGroup value={value} onValueChange={onChange} disabled={disabled}>
<div className="grid gap-y-1">
<label className="flex items-center gap-x-2">
<RadioGroupItem value="hours12" />
<span>
<Trans i18nKey="12h" />
</span>
</label>
<label className="flex items-center gap-x-2">
<RadioGroupItem value="hours24" />
<span>
<Trans i18nKey="24h" />
</span>
</label>
</div>
</RadioGroup>
);
};

View file

@ -57,11 +57,11 @@ export const TimeZoneCommand = ({ onSelect, value }: TimeZoneCommandProps) => {
<CommandItem
key={option.value}
onSelect={() => onSelect?.(option.value)}
className="flex min-w-0 gap-x-4"
className="flex min-w-0 gap-x-2.5"
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
"h-4 w-4 shrink-0",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
@ -84,7 +84,7 @@ export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
const popoverContentId = "timeZoneSelect__popoverContent";
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover modal={false} open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild={true}>
<button
ref={ref}
@ -93,10 +93,10 @@ export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
role="combobox"
aria-expanded={open}
aria-controls={popoverContentId}
className="bg-input-background flex h-9 w-full items-center gap-x-1.5 rounded-md border px-2 py-2 text-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
className="bg-input-background flex h-9 w-full min-w-0 items-center gap-x-1.5 rounded-md border px-2 py-2 text-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<GlobeIcon className="h-4 w-4" />
<span className="grow text-left">
<span className="grow truncate text-left">
{value ? (
options.find((option) => option.value === value)?.label
) : (
@ -112,7 +112,7 @@ export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
<PopoverContent
id={popoverContentId}
align="start"
className="min-w-[var(--radix-popover-trigger-width)] bg-white p-0"
className="z-[1000] max-w-[var(--radix-popover-trigger-width)] bg-white p-0"
>
<TimeZoneCommand
value={value}

View file

@ -1,7 +1,10 @@
import { trpc } from "@rallly/backend";
import dayjs, { Dayjs } from "dayjs";
import { useRouter } from "next/router";
import React from "react";
import { useDayjs } from "@/utils/dayjs";
export const usePoll = () => {
const router = useRouter();
@ -20,3 +23,16 @@ export const usePoll = () => {
return pollQuery.data;
};
export const useDateFormatter = () => {
const { timeZone } = usePoll();
const { timeZone: preferredTimeZone } = useDayjs();
return (date: Date | Dayjs) => {
if (timeZone) {
return dayjs(date).utc().tz(timeZone, true).tz(preferredTimeZone);
}
return dayjs(date).utc();
};
};

View file

@ -1,23 +1,16 @@
import { trpc } from "@rallly/backend";
import { GlobeIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@rallly/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@rallly/ui/popover";
import dayjs, { Dayjs } from "dayjs";
import React from "react";
import { TimeFormatPicker } from "@/components/time-format-picker";
import {
TimeZoneCommand,
timeZones,
} from "@/components/time-zone-picker/time-zone-select";
import { Trans } from "@/components/trans";
import { TimesShownIn } from "@/components/clock";
import { TimeZoneCommand } from "@/components/time-zone-picker/time-zone-select";
import { usePoll } from "@/contexts/poll";
import { useDayjs } from "@/utils/dayjs";
export const TimePreferences = () => {
const poll = usePoll();
const { timeZone, timeFormat } = useDayjs();
const { timeZone } = useDayjs();
const queryClient = trpc.useContext();
const [open, setIsOpen] = React.useState(false);
@ -31,9 +24,7 @@ export const TimePreferences = () => {
}
return {
...oldPreferences,
timeFormat: newPreferences.timeFormat ?? oldPreferences?.timeFormat,
timeZone: newPreferences.timeZone ?? oldPreferences?.timeZone ?? null,
weekStart: newPreferences.weekStart ?? oldPreferences?.weekStart,
};
});
},
@ -47,50 +38,23 @@ export const TimePreferences = () => {
}
return (
<div className="flex justify-between gap-x-4 overflow-x-auto sm:overflow-x-visible">
<Dialog open={open} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button icon={GlobeIcon} disabled={!poll.timeZone}>
{poll.timeZone ? (
<Trans
i18nKey="timeShownIn"
defaults="Times shown in {timeZone}"
values={{
timeZone: timeZones[timeZone as keyof typeof timeZones],
}}
/>
) : (
<Trans
i18nKey="timeShownInLocalTime"
defaults="Times shown in local time"
/>
)}
</Button>
</DialogTrigger>
<DialogContent className="p-0">
<TimeZoneCommand
value={timeZone}
onSelect={(value) => {
updatePreferences.mutate({
timeZone: value,
});
setIsOpen(false);
}}
/>
</DialogContent>
</Dialog>
<div>
<TimeFormatPicker
value={timeFormat}
onChange={(newTimeFormat) => {
<Popover open={open} onOpenChange={setIsOpen}>
<PopoverTrigger className="inline-flex items-center gap-x-2 text-sm hover:underline">
<GlobeIcon className="h-4 w-4" />
<TimesShownIn />
</PopoverTrigger>
<PopoverContent align="center" className="p-0">
<TimeZoneCommand
value={timeZone}
onSelect={(value) => {
updatePreferences.mutate({
timeFormat: newTimeFormat,
timeZone: value,
});
setIsOpen(false);
}}
/>
</div>
</div>
</PopoverContent>
</Popover>
);
};
export const useDateFormatter = () => {

View file

@ -18,7 +18,7 @@ const buttonVariants = cva(
"rounded-md px-3.5 py-2.5 hover:shadow-sm active:shadow-none data-[state=open]:shadow-none data-[state=open]:bg-gray-100 active:bg-gray-100 hover:bg-white/50 bg-gray-50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "border-transparent hover:bg-gray-200/50 active:bg-gray-200",
ghost: "border-transparent hover:bg-gray-200 active:bg-gray-300",
link: "underline-offset-4 hover:underline text-primary",
},
size: {

View file

@ -9,6 +9,7 @@ import { cn } from "./lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
const DialogPortal = ({
className,
@ -128,6 +129,7 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,

View file

@ -18,8 +18,9 @@ const PopoverContent = React.forwardRef<
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={12}
className={cn(
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border bg-white p-4 shadow-md outline-none",
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-huge z-50 rounded-md border bg-white p-4 outline-none",
className,
)}
{...props}