mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 02:06:34 +02:00
🌐 Better way to store times (#1037)
This commit is contained in:
parent
7b996aa24f
commit
08729168d2
14 changed files with 150 additions and 29 deletions
|
@ -117,7 +117,7 @@ const Page = () => {
|
|||
}
|
||||
})}
|
||||
>
|
||||
<PollOptionsForm>
|
||||
<PollOptionsForm disableTimeZoneChange={true}>
|
||||
<CardFooter className="justify-between">
|
||||
<Button asChild>
|
||||
<Link href={pollLink}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -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])
|
||||
|
|
Loading…
Add table
Reference in a new issue