🌐 Better way to store times (#1037)

This commit is contained in:
Luke Vella 2024-02-29 16:36:21 +05:30 committed by GitHub
parent 7b996aa24f
commit 08729168d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 150 additions and 29 deletions

View file

@ -117,7 +117,7 @@ const Page = () => {
} }
})} })}
> >
<PollOptionsForm> <PollOptionsForm disableTimeZoneChange={true}>
<CardFooter className="justify-between"> <CardFooter className="justify-between">
<Button asChild> <Button asChild>
<Link href={pollLink}> <Link href={pollLink}>

View file

@ -133,7 +133,7 @@ const Discussion: React.FunctionComponent = () => {
)} )}
</div> </div>
</div> </div>
<div className="w-fit whitespace-pre-wrap pl-8 leading-relaxed"> <div className="ml-0.5 w-fit whitespace-pre-wrap pl-8 text-sm leading-relaxed">
<TruncatedLinkify>{comment.content}</TruncatedLinkify> <TruncatedLinkify>{comment.content}</TruncatedLinkify>
</div> </div>
</div> </div>
@ -191,7 +191,7 @@ const Discussion: React.FunctionComponent = () => {
</form> </form>
) : ( ) : (
<button <button
className="border-input text-muted-foreground flex w-full rounded border bg-transparent px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1" className="border-input text-muted-foreground flex w-full rounded border bg-transparent px-3 py-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
onClick={() => setIsWriting(true)} onClick={() => setIsWriting(true)}
> >
<Trans <Trans

View file

@ -1,3 +1,4 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@rallly/ui/card"; import { Card, CardDescription, CardHeader, CardTitle } from "@rallly/ui/card";
import { CommandDialog } from "@rallly/ui/command"; import { CommandDialog } from "@rallly/ui/command";
@ -28,7 +29,10 @@ export type PollOptionsData = {
options: DateTimeOption[]; options: DateTimeOption[];
}; };
const PollOptionsForm = ({ children }: React.PropsWithChildren) => { const PollOptionsForm = ({
children,
disableTimeZoneChange,
}: React.PropsWithChildren<{ disableTimeZoneChange?: boolean }>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = useFormContext<NewEventData>(); const form = useFormContext<NewEventData>();
@ -199,10 +203,15 @@ const PollOptionsForm = ({ children }: React.PropsWithChildren) => {
control={form.control} control={form.control}
name="timeZone" name="timeZone"
render={({ field }) => ( render={({ field }) => (
<div className="grid items-center justify-between gap-2.5 border-t bg-gray-50 p-4 md:flex"> <div
className={cn(
"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"> <div className="flex h-9 items-center gap-x-2.5 p-2">
<Switch <Switch
id="timeZone" id="timeZone"
disabled={disableTimeZoneChange}
checked={!!field.value} checked={!!field.value}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (checked) { if (checked) {
@ -233,6 +242,7 @@ const PollOptionsForm = ({ children }: React.PropsWithChildren) => {
{field.value ? ( {field.value ? (
<div> <div>
<Button <Button
disabled={disableTimeZoneChange}
onClick={() => { onClick={() => {
showTimeZoneCommandModal(true); showTimeZoneCommandModal(true);
}} }}

View file

@ -30,7 +30,7 @@ const NameInput: React.ForwardRefRenderFunction<
<input <input
ref={ref} ref={ref}
className={cn( className={cn(
"input", "input text-sm",
{ {
"pl-9": value || defaultValue, "pl-9": value || defaultValue,
"ring-destructive ring-1": error, "ring-destructive ring-1": error,

View file

@ -1,11 +1,14 @@
import { Participant, VoteType } from "@rallly/database"; import { Participant, VoteType } from "@rallly/database";
import dayjs from "dayjs";
import { keyBy } from "lodash"; import { keyBy } from "lodash";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import React from "react"; import React from "react";
import { useUser } from "@/components/user-provider";
import { import {
decodeOptions, decodeOptions,
getDuration,
ParsedDateOption, ParsedDateOption,
ParsedTimeSlotOption, ParsedTimeSlotOption,
} from "@/utils/date-time-utils"; } from "@/utils/date-time-utils";
@ -141,7 +144,7 @@ export const PollContextProvider: React.FunctionComponent<{
); );
}; };
const OptionsContext = React.createContext< type OptionsContextValue =
| { | {
pollType: "date"; pollType: "date";
options: ParsedDateOption[]; options: ParsedDateOption[];
@ -149,8 +152,9 @@ const OptionsContext = React.createContext<
| { | {
pollType: "timeSlot"; pollType: "timeSlot";
options: ParsedTimeSlotOption[]; options: ParsedTimeSlotOption[];
} };
>({
const OptionsContext = React.createContext<OptionsContextValue>({
pollType: "date", pollType: "date",
options: [], options: [],
}); });
@ -160,17 +164,89 @@ export const useOptions = () => {
return context; return context;
}; };
function createOptionsContextValue(
pollOptions: { id: string; startTime: Date; duration: number }[],
targetTimeZone: string,
sourceTimeZone: string | null,
): OptionsContextValue {
if (pollOptions[0].duration > 0) {
return {
pollType: "timeSlot",
options: pollOptions.map((option) => {
function adjustTimeZone(date: Date) {
if (sourceTimeZone) {
return dayjs(date).tz(targetTimeZone);
}
return dayjs(date).utc();
}
const localStartTime = adjustTimeZone(option.startTime);
// for some reason, dayjs requires us to do timezone conversion at the end
const localEndTime = adjustTimeZone(
dayjs(option.startTime).add(option.duration, "minute").toDate(),
);
return {
optionId: option.id,
type: "timeSlot",
startTime: localStartTime.format("LT"),
endTime: localEndTime.format("LT"),
duration: getDuration(localStartTime, localEndTime),
month: localStartTime.format("MMM"),
day: localStartTime.format("D"),
dow: localStartTime.format("ddd"),
year: localStartTime.format("YYYY"),
} satisfies ParsedTimeSlotOption;
}),
};
} else {
return {
pollType: "date",
options: pollOptions.map((option) => {
const localTime = sourceTimeZone
? dayjs(option.startTime).tz(targetTimeZone)
: dayjs(option.startTime).utc();
return {
optionId: option.id,
type: "date",
month: localTime.format("MMM"),
day: localTime.format("D"),
dow: localTime.format("ddd"),
year: localTime.format("YYYY"),
} satisfies ParsedDateOption;
}),
};
}
}
export const OptionsProvider = (props: React.PropsWithChildren) => { export const OptionsProvider = (props: React.PropsWithChildren) => {
const { poll } = usePoll(); const { poll } = usePoll();
const { timeZone: targetTimeZone, timeFormat } = useDayjs(); const { timeZone: targetTimeZone, timeFormat } = useDayjs();
const parsedDateOptions = decodeOptions( const { isInternalUser } = useUser();
poll.options,
poll.timeZone, const options = React.useMemo(() => {
targetTimeZone, let res: OptionsContextValue;
timeFormat, if (isInternalUser) {
); res = createOptionsContextValue(
poll.options,
targetTimeZone,
poll.timeZone,
);
} else {
// @deprecated - Stop using this method and drop the start column from the database in favor of startTime
res = decodeOptions(
poll.options,
poll.timeZone,
targetTimeZone,
timeFormat,
);
}
return res;
}, [isInternalUser, poll.options, poll.timeZone, targetTimeZone, timeFormat]);
return ( return (
<OptionsContext.Provider value={parsedDateOptions}> <OptionsContext.Provider value={options}>
{props.children} {props.children}
</OptionsContext.Provider> </OptionsContext.Provider>
); );

View file

@ -123,7 +123,7 @@ const DesktopPoll: React.FunctionComponent = () => {
<div className="flex h-14 shrink-0 items-center justify-between rounded-t-md border-b bg-gradient-to-b from-gray-50 to-gray-100/50 px-4 py-3"> <div className="flex h-14 shrink-0 items-center justify-between rounded-t-md border-b bg-gradient-to-b from-gray-50 to-gray-100/50 px-4 py-3">
<div> <div>
{mode !== "view" ? ( {mode !== "view" ? (
<div> <p className="text-sm">
<Trans <Trans
t={t} t={t}
i18nKey="saveInstruction" i18nKey="saveInstruction"
@ -132,7 +132,7 @@ const DesktopPoll: React.FunctionComponent = () => {
}} }}
components={{ b: <strong /> }} components={{ b: <strong /> }}
/> />
</div> </p>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users2Icon className="size-5 shrink-0" /> <Users2Icon className="size-5 shrink-0" />

View file

@ -96,7 +96,9 @@ const UserAvatar: React.FunctionComponent<UserAvaterProps> = ({
)} )}
> >
<UserAvatarInner {...forwardedProps} /> <UserAvatarInner {...forwardedProps} />
<div className="min-w-0 truncate font-medium">{forwardedProps.name}</div> <div className="min-w-0 truncate text-sm font-medium">
{forwardedProps.name}
</div>
{isYou ? <Badge>{t("you")}</Badge> : null} {isYou ? <Badge>{t("you")}</Badge> : null}
</div> </div>
); );

View file

@ -27,7 +27,8 @@ export const UserContext = React.createContext<{
} | null>(null); } | null>(null);
export const useUser = () => { export const useUser = () => {
return useRequiredContext(UserContext, "UserContext"); const value = useRequiredContext(UserContext, "UserContext");
return { ...value, isInternalUser: value.user.email?.endsWith("@rallly.co") };
}; };
export const useAuthenticatedUser = () => { export const useAuthenticatedUser = () => {

View file

@ -29,7 +29,7 @@ export default async function handler(
SELECT poll_id SELECT poll_id
FROM options FROM options
WHERE poll_id = p.id WHERE poll_id = p.id
AND start > NOW() AND start_time > NOW()
) )
AND user_id NOT IN ( AND user_id NOT IN (
SELECT id SELECT id

View file

@ -49,7 +49,6 @@ export const encodeDateOption = (option: DateTimeOption) => {
export interface ParsedDateOption { export interface ParsedDateOption {
type: "date"; type: "date";
date: Date;
optionId: string; optionId: string;
day: string; day: string;
dow: string; dow: string;
@ -60,7 +59,6 @@ export interface ParsedDateOption {
export interface ParsedTimeSlotOption { export interface ParsedTimeSlotOption {
type: "timeSlot"; type: "timeSlot";
optionId: string; optionId: string;
date: Date;
day: string; day: string;
dow: string; dow: string;
month: string; month: string;
@ -119,7 +117,6 @@ export const parseDateOption = (option: Option): ParsedDateOption => {
const date = dayjs(option.start).utc(); const date = dayjs(option.start).utc();
return { return {
type: "date", type: "date",
date: date.toDate(),
optionId: option.id, optionId: option.id,
day: date.format("D"), day: date.format("D"),
dow: date.format("ddd"), dow: date.format("ddd"),
@ -153,8 +150,6 @@ export const parseTimeSlotOption = (
return { return {
type: "timeSlot", type: "timeSlot",
optionId: option.id, optionId: option.id,
date: startDate.toDate(),
startTime: startDate.format(timeFormat === "hours12" ? "h:mm A" : "HH:mm"), startTime: startDate.format(timeFormat === "hours12" ? "h:mm A" : "HH:mm"),
endTime: endDate.format(timeFormat === "hours12" ? "h:mm A" : "HH:mm"), endTime: endDate.format(timeFormat === "hours12" ? "h:mm A" : "HH:mm"),
day: startDate.format("D"), day: startDate.format("D"),

View file

@ -5,7 +5,7 @@ export type GetPollApiResponse = {
title: string; title: string;
location: string | null; location: string | null;
description: string | null; description: string | null;
options: { id: string; start: Date; duration: number }[]; options: { id: string; start: Date; startTime: Date; duration: number }[];
user: User | null; user: User | null;
timeZone: string | null; timeZone: string | null;
adminUrlId: string; adminUrlId: string;

View file

@ -100,7 +100,10 @@ export const polls = router({
options: { options: {
createMany: { createMany: {
data: input.options.map((option) => ({ data: input.options.map((option) => ({
start: new Date(`${option.startDate}Z`), start: dayjs(option.startDate).utc(true).toDate(),
startTime: input.timeZone
? dayjs(option.startDate).tz(input.timeZone, true).toDate()
: dayjs(option.startDate).utc(true).toDate(),
duration: option.endDate duration: option.endDate
? dayjs(option.endDate).diff( ? dayjs(option.endDate).diff(
dayjs(option.startDate), dayjs(option.startDate),
@ -191,12 +194,16 @@ export const polls = router({
if (end) { if (end) {
return { return {
start: new Date(`${start}Z`), start: new Date(`${start}Z`),
startTime: input.timeZone
? dayjs(start).tz(input.timeZone, true).toDate()
: dayjs(start).utc(true).toDate(),
duration: dayjs(end).diff(dayjs(start), "minute"), duration: dayjs(end).diff(dayjs(start), "minute"),
pollId, pollId,
}; };
} else { } else {
return { return {
start: new Date(start.substring(0, 10) + "T00:00:00Z"), start: new Date(start.substring(0, 10) + "T00:00:00Z"),
startTime: dayjs(start).utc(true).toDate(),
pollId, pollId,
}; };
} }
@ -363,6 +370,7 @@ export const polls = router({
select: { select: {
id: true, id: true,
start: true, start: true,
startTime: true,
duration: true, duration: true,
}, },
orderBy: { orderBy: {
@ -851,6 +859,7 @@ export const polls = router({
options: { options: {
select: { select: {
start: true, start: true,
startTime: true,
duration: true, duration: true,
}, },
}, },

View file

@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "options" ADD COLUMN "start_time" TIMESTAMP(0);
-- migration.sql
DO
$do$
DECLARE
poll_record RECORD;
BEGIN
FOR poll_record IN SELECT id, "time_zone" FROM polls
LOOP
IF poll_record."time_zone" IS NULL OR poll_record."time_zone" = '' THEN
UPDATE options
SET "start_time" = "start"
WHERE "poll_id" = poll_record.id;
ELSE
UPDATE options
SET "start_time" = ("start"::TIMESTAMP WITHOUT TIME ZONE) AT TIME ZONE poll_record.time_zone
WHERE "poll_id" = poll_record.id;
END IF;
END LOOP;
END
$do$;
-- Make start_time not null
ALTER TABLE "options" ALTER COLUMN "start_time" SET NOT NULL;

View file

@ -196,7 +196,8 @@ model Participant {
model Option { model Option {
id String @id @default(cuid()) id String @id @default(cuid())
start DateTime @db.Timestamp(0) start DateTime @db.Timestamp(0) // @deprecated - use startTime
startTime DateTime @db.Timestamp(0) @map("start_time")
duration Int @default(0) @map("duration_minutes") duration Int @default(0) @map("duration_minutes")
pollId String @map("poll_id") pollId String @map("poll_id")
poll Poll @relation(fields: [pollId], references: [id]) poll Poll @relation(fields: [pollId], references: [id])