mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-26 04:37: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">
|
<CardFooter className="justify-between">
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href={pollLink}>
|
<Link href={pollLink}>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 = () => {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 {
|
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])
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue