🌐 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">
<Button asChild>
<Link href={pollLink}>

View file

@ -133,7 +133,7 @@ const Discussion: React.FunctionComponent = () => {
)}
</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>
</div>
</div>
@ -191,7 +191,7 @@ const Discussion: React.FunctionComponent = () => {
</form>
) : (
<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)}
>
<Trans

View file

@ -1,3 +1,4 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@rallly/ui/card";
import { CommandDialog } from "@rallly/ui/command";
@ -28,7 +29,10 @@ export type PollOptionsData = {
options: DateTimeOption[];
};
const PollOptionsForm = ({ children }: React.PropsWithChildren) => {
const PollOptionsForm = ({
children,
disableTimeZoneChange,
}: React.PropsWithChildren<{ disableTimeZoneChange?: boolean }>) => {
const { t } = useTranslation();
const form = useFormContext<NewEventData>();
@ -199,10 +203,15 @@ const PollOptionsForm = ({ children }: React.PropsWithChildren) => {
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={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">
<Switch
id="timeZone"
disabled={disableTimeZoneChange}
checked={!!field.value}
onCheckedChange={(checked) => {
if (checked) {
@ -233,6 +242,7 @@ const PollOptionsForm = ({ children }: React.PropsWithChildren) => {
{field.value ? (
<div>
<Button
disabled={disableTimeZoneChange}
onClick={() => {
showTimeZoneCommandModal(true);
}}

View file

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

View file

@ -1,11 +1,14 @@
import { Participant, VoteType } from "@rallly/database";
import dayjs from "dayjs";
import { keyBy } from "lodash";
import { TrashIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import React from "react";
import { useUser } from "@/components/user-provider";
import {
decodeOptions,
getDuration,
ParsedDateOption,
ParsedTimeSlotOption,
} from "@/utils/date-time-utils";
@ -141,7 +144,7 @@ export const PollContextProvider: React.FunctionComponent<{
);
};
const OptionsContext = React.createContext<
type OptionsContextValue =
| {
pollType: "date";
options: ParsedDateOption[];
@ -149,8 +152,9 @@ const OptionsContext = React.createContext<
| {
pollType: "timeSlot";
options: ParsedTimeSlotOption[];
}
>({
};
const OptionsContext = React.createContext<OptionsContextValue>({
pollType: "date",
options: [],
});
@ -160,17 +164,89 @@ export const useOptions = () => {
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) => {
const { poll } = usePoll();
const { timeZone: targetTimeZone, timeFormat } = useDayjs();
const parsedDateOptions = decodeOptions(
poll.options,
poll.timeZone,
targetTimeZone,
timeFormat,
);
const { isInternalUser } = useUser();
const options = React.useMemo(() => {
let res: OptionsContextValue;
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 (
<OptionsContext.Provider value={parsedDateOptions}>
<OptionsContext.Provider value={options}>
{props.children}
</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>
{mode !== "view" ? (
<div>
<p className="text-sm">
<Trans
t={t}
i18nKey="saveInstruction"
@ -132,7 +132,7 @@ const DesktopPoll: React.FunctionComponent = () => {
}}
components={{ b: <strong /> }}
/>
</div>
</p>
) : (
<div className="flex items-center gap-2">
<Users2Icon className="size-5 shrink-0" />

View file

@ -96,7 +96,9 @@ const UserAvatar: React.FunctionComponent<UserAvaterProps> = ({
)}
>
<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}
</div>
);

View file

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

View file

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

View file

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

View file

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

View file

@ -100,7 +100,10 @@ export const polls = router({
options: {
createMany: {
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
? dayjs(option.endDate).diff(
dayjs(option.startDate),
@ -191,12 +194,16 @@ export const polls = router({
if (end) {
return {
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"),
pollId,
};
} else {
return {
start: new Date(start.substring(0, 10) + "T00:00:00Z"),
startTime: dayjs(start).utc(true).toDate(),
pollId,
};
}
@ -363,6 +370,7 @@ export const polls = router({
select: {
id: true,
start: true,
startTime: true,
duration: true,
},
orderBy: {
@ -851,6 +859,7 @@ export const polls = router({
options: {
select: {
start: true,
startTime: 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 {
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")
pollId String @map("poll_id")
poll Poll @relation(fields: [pollId], references: [id])