diff --git a/apps/web/src/components/create-poll.tsx b/apps/web/src/components/create-poll.tsx index c0108d949..9bcd85817 100644 --- a/apps/web/src/components/create-poll.tsx +++ b/apps/web/src/components/create-poll.tsx @@ -4,7 +4,6 @@ import React from "react"; import { usePostHog } from "@/utils/posthog"; -import { encodeDateOption } from "../utils/date-time-utils"; import { trpc } from "../utils/trpc"; import { Button } from "./button"; import { @@ -102,7 +101,6 @@ const Page: React.FunctionComponent = () => { await createPoll.mutateAsync({ title: title, - type: "date", location: formData?.eventDetails?.location, description: formData?.eventDetails?.description, user: session.user.isGuest @@ -112,7 +110,10 @@ const Page: React.FunctionComponent = () => { } : undefined, timeZone: formData?.options?.timeZone, - options: required(formData?.options?.options).map(encodeDateOption), + options: required(formData?.options?.options).map((option) => ({ + startDate: option.type === "date" ? option.date : option.start, + endDate: option.type === "timeSlot" ? option.end : undefined, + })), }); } }; diff --git a/apps/web/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx b/apps/web/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx index e1deaa906..a3fd5733f 100644 --- a/apps/web/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx +++ b/apps/web/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx @@ -265,16 +265,11 @@ const MonthCalendar: React.FunctionComponent = ({ { - let newEnd = dayjs(newStart).add( + const newEnd = dayjs(newStart).add( duration, "minutes", ); - if (!newEnd.isSame(newStart, "day")) { - newEnd = newEnd - .set("hour", 23) - .set("minute", 45); - } // replace enter with updated start time onChange([ ...options.slice(0, index), @@ -293,9 +288,7 @@ const MonthCalendar: React.FunctionComponent = ({ /> { onChange([ ...options.slice(0, index), @@ -330,7 +323,15 @@ const MonthCalendar: React.FunctionComponent = ({ const lastOption = expectTimeOption( optionsForDay[optionsForDay.length - 1].option, ); - const startTime = lastOption.end; + + const startTime = dayjs(lastOption.end).isSame( + lastOption.start, + "day", + ) + ? // if the end time of the previous option is on the same day as the start time, use the end time + lastOption.end + : // otherwise use the start time + lastOption.start; onChange([ ...options, diff --git a/apps/web/src/components/forms/poll-options-form/month-calendar/time-picker.tsx b/apps/web/src/components/forms/poll-options-form/month-calendar/time-picker.tsx index 3957cdf90..afe77496c 100644 --- a/apps/web/src/components/forms/poll-options-form/month-calendar/time-picker.tsx +++ b/apps/web/src/components/forms/poll-options-form/month-calendar/time-picker.tsx @@ -9,6 +9,7 @@ import { Listbox } from "@headlessui/react"; import clsx from "clsx"; import * as React from "react"; +import { getDuration } from "@/utils/date-time-utils"; import { stopPropagation } from "@/utils/stop-propagation"; import { useDayjs } from "../../../../utils/dayjs"; @@ -17,7 +18,7 @@ import { styleMenuItem } from "../../../menu-styles"; export interface TimePickerProps { value: Date; - startFrom?: Date; + after?: Date; className?: string; onChange?: (value: Date) => void; } @@ -26,10 +27,11 @@ const TimePicker: React.FunctionComponent = ({ value, onChange, className, - startFrom, + after, }) => { const { dayjs } = useDayjs(); const { reference, floating, x, y, strategy, refs } = useFloating({ + placement: "bottom-start", strategy: "fixed", middleware: [ offset(5), @@ -38,7 +40,7 @@ const TimePicker: React.FunctionComponent = ({ apply: ({ rects }) => { if (refs.floating.current) { Object.assign(refs.floating.current.style, { - width: `${rects.reference.width}px`, + minWidth: `${rects.reference.width}px`, }); } }, @@ -47,15 +49,11 @@ const TimePicker: React.FunctionComponent = ({ }); const renderOptions = () => { - const startFromDate = startFrom - ? dayjs(startFrom) - : dayjs(value).startOf("day"); + const startFromDate = after ? dayjs(after) : dayjs(value).startOf("day"); const options: React.ReactNode[] = []; - const startMinute = - startFromDate.get("hour") * 60 + startFromDate.get("minute"); - const intervals = Math.floor((1440 - startMinute) / 15); - for (let i = 0; i < intervals; i++) { + + for (let i = 1; i <= 96; i++) { const optionValue = startFromDate.add(i * 15, "minutes"); options.push( = ({ className={styleMenuItem} value={optionValue.format("YYYY-MM-DDTHH:mm:ss")} > - {optionValue.format("LT")} +
+ {optionValue.format("LT")} + {after ? ( + + {getDuration(dayjs(after), optionValue)} + + ) : null} +
, ); } diff --git a/apps/web/src/components/poll/manage-poll.tsx b/apps/web/src/components/poll/manage-poll.tsx index d133ba720..0513cbd22 100644 --- a/apps/web/src/components/poll/manage-poll.tsx +++ b/apps/web/src/components/poll/manage-poll.tsx @@ -1,4 +1,5 @@ import { Placement } from "@floating-ui/react-dom-interactions"; +import dayjs from "dayjs"; import { Trans, useTranslation } from "next-i18next"; import * as React from "react"; @@ -23,6 +24,15 @@ import { useUpdatePollMutation } from "./mutations"; const PollOptionsForm = React.lazy(() => import("../forms/poll-options-form")); +const convertOptionToString = (option: { start: Date; duration: number }) => { + const start = dayjs(option.start); + return option.duration === 0 + ? start.format("YYYY-MM-DD") + : `${start.format("YYYY-MM-DDTHH:mm:ss")}/${start + .add(option.duration, "minute") + .format("YYYY-MM-DDTHH:mm:ss")}`; +}; + const ManagePoll: React.FunctionComponent<{ placement?: Placement; }> = ({ placement }) => { @@ -67,18 +77,20 @@ const ManagePoll: React.FunctionComponent<{ name="pollOptions" title={poll.title} defaultValues={{ - navigationDate: poll.options[0].value.split("/")[0], + navigationDate: poll.options[0].start.toString(), options: poll.options.map((option) => { - const [start, end] = option.value.split("/"); - return end + const start = dayjs(option.start); + return option.duration > 0 ? { type: "timeSlot", - start, - end, + start: start.format("YYYY-MM-DDTHH:mm:ss"), + end: start + .add(option.duration, "minute") + .format("YYYY-MM-DDTHH:mm:ss"), } : { type: "date", - date: start, + date: start.format("YYYY-MM-DD"), }; }), timeZone: poll.timeZone ?? "", @@ -86,12 +98,14 @@ const ManagePoll: React.FunctionComponent<{ onSubmit={(data) => { const encodedOptions = data.options.map(encodeDateOption); const optionsToDelete = poll.options.filter((option) => { - return !encodedOptions.includes(option.value); + return !encodedOptions.includes(convertOptionToString(option)); }); const optionsToAdd = encodedOptions.filter( (encodedOption) => - !poll.options.find((o) => o.value === encodedOption), + !poll.options.find( + (o) => convertOptionToString(o) === encodedOption, + ), ); const onOk = () => { diff --git a/apps/web/src/pages/api/house-keeping.ts b/apps/web/src/pages/api/house-keeping.ts index 8b4e096b1..8f1b6a73b 100644 --- a/apps/web/src/pages/api/house-keeping.ts +++ b/apps/web/src/pages/api/house-keeping.ts @@ -2,8 +2,6 @@ import { Prisma, prisma } from "@rallly/database"; import dayjs from "dayjs"; import { NextApiRequest, NextApiResponse } from "next"; -import { parseValue } from "../../utils/date-time-utils"; - /** * DANGER: This endpoint will permanently delete polls. */ @@ -25,32 +23,26 @@ export default async function handler( } // get polls that have not been accessed for over 30 days - const inactivePolls = await prisma.$queryRaw< - Array<{ id: string; max: string }> - >` - SELECT polls.id, MAX(options.value) FROM polls - JOIN options ON options.poll_id = polls.id - WHERE touched_at <= ${dayjs().add(-30, "days").toDate()} AND deleted = false - GROUP BY polls.id; - `; + const thirtyDaysAgo = dayjs().subtract(30, "days").toDate(); - const pollsToSoftDelete: string[] = []; - - // keep polls that have options that are in the future - inactivePolls.forEach(({ id, max: value }) => { - const parsedValue = parseValue(value); - const date = - parsedValue.type === "date" ? parsedValue.date : parsedValue.end; - - if (dayjs(date).isBefore(dayjs())) { - pollsToSoftDelete.push(id); - } + const oldPollsWithAllPastOptions = await prisma.poll.findMany({ + select: { + id: true, + }, + where: { + touchedAt: { lte: thirtyDaysAgo }, + options: { + every: { + start: { lt: new Date() }, + }, + }, + }, }); const softDeletedPolls = await prisma.poll.deleteMany({ where: { id: { - in: pollsToSoftDelete, + in: oldPollsWithAllPastOptions.map(({ id }) => id), }, }, }); diff --git a/apps/web/src/server/routers/polls.ts b/apps/web/src/server/routers/polls.ts index 24fec8b4e..205e47b82 100644 --- a/apps/web/src/server/routers/polls.ts +++ b/apps/web/src/server/routers/polls.ts @@ -1,6 +1,7 @@ import { prisma } from "@rallly/database"; import { sendEmail } from "@rallly/emails"; import { TRPCError } from "@trpc/server"; +import dayjs from "dayjs"; import { z } from "zod"; import { absoluteUrl } from "../../utils/absolute-url"; @@ -10,46 +11,6 @@ import { comments } from "./polls/comments"; import { demo } from "./polls/demo"; import { participants } from "./polls/participants"; -const defaultSelectFields: { - id: true; - timeZone: true; - title: true; - location: true; - description: true; - createdAt: true; - adminUrlId: true; - participantUrlId: true; - closed: true; - legacy: true; - demo: true; - options: { - orderBy: { - value: "asc"; - }; - }; - user: true; - deleted: true; -} = { - id: true, - timeZone: true, - title: true, - location: true, - description: true, - createdAt: true, - adminUrlId: true, - participantUrlId: true, - closed: true, - legacy: true, - demo: true, - options: { - orderBy: { - value: "asc", - }, - }, - user: true, - deleted: true, -}; - const getPollIdFromAdminUrlId = async (urlId: string) => { const res = await prisma.poll.findUnique({ select: { @@ -72,7 +33,6 @@ export const polls = router({ .input( z.object({ title: z.string(), - type: z.literal("date"), timeZone: z.string().optional(), location: z.string().optional(), description: z.string().optional(), @@ -82,7 +42,12 @@ export const polls = router({ email: z.string(), }) .optional(), - options: z.string().array(), + options: z + .object({ + startDate: z.string(), + endDate: z.string().optional(), + }) + .array(), demo: z.boolean().optional(), }), ) @@ -120,7 +85,6 @@ export const polls = router({ data: { id: await nanoid(), title: input.title, - type: input.type, timeZone: input.timeZone, location: input.location, description: input.description, @@ -137,8 +101,14 @@ export const polls = router({ : undefined, options: { createMany: { - data: input.options.map((value) => ({ - value, + data: input.options.map((option) => ({ + start: new Date(option.startDate), + duration: option.endDate + ? dayjs(option.endDate).diff( + dayjs(option.startDate), + "minute", + ) + : 0, })), }, }, @@ -193,15 +163,26 @@ export const polls = router({ if (input.optionsToAdd && input.optionsToAdd.length > 0) { await prisma.option.createMany({ - data: input.optionsToAdd.map((optionValue) => ({ - value: optionValue, - pollId, - })), + data: input.optionsToAdd.map((optionValue) => { + const [start, end] = optionValue.split("/"); + if (end) { + return { + start: new Date(start), + duration: dayjs(end).diff(dayjs(start), "minute"), + pollId, + }; + } else { + return { + start: new Date(start.substring(0, 10) + "T00:00:00"), + pollId, + }; + } + }), }); } await prisma.poll.update({ - select: defaultSelectFields, + select: { id: true }, where: { id: pollId, }, @@ -303,7 +284,24 @@ export const polls = router({ .query(async ({ input }) => { const res = await prisma.poll.findUnique({ select: { - ...defaultSelectFields, + id: true, + timeZone: true, + title: true, + location: true, + description: true, + createdAt: true, + adminUrlId: true, + participantUrlId: true, + closed: true, + legacy: true, + demo: true, + options: { + orderBy: { + start: "asc", + }, + }, + user: true, + deleted: true, watchers: { select: { userId: true, @@ -333,7 +331,32 @@ export const polls = router({ ) .query(async ({ input, ctx }) => { const res = await prisma.poll.findUnique({ - select: { userId: true, ...defaultSelectFields }, + select: { + id: true, + timeZone: true, + title: true, + location: true, + description: true, + createdAt: true, + adminUrlId: true, + participantUrlId: true, + closed: true, + legacy: true, + demo: true, + options: { + orderBy: { + start: "asc", + }, + }, + user: true, + userId: true, + deleted: true, + watchers: { + select: { + userId: true, + }, + }, + }, where: { participantUrlId: input.urlId, }, diff --git a/apps/web/src/server/routers/polls/demo.ts b/apps/web/src/server/routers/polls/demo.ts index 87dc81813..27775cd18 100644 --- a/apps/web/src/server/routers/polls/demo.ts +++ b/apps/web/src/server/routers/polls/demo.ts @@ -30,10 +30,10 @@ export const demo = router({ const adminUrlId = await nanoid(); const demoUser = { name: "John Example", email: "noreply@rallly.co" }; - const options: Array<{ value: string; id: string }> = []; + const options: Array<{ start: Date; id: string }> = []; for (let i = 0; i < optionValues.length; i++) { - options.push({ id: await nanoid(), value: optionValues[i] }); + options.push({ id: await nanoid(), start: new Date(optionValues[i]) }); } const participants: Array<{ @@ -74,7 +74,6 @@ export const demo = router({ data: { id: await nanoid(), title: "Lunch Meeting", - type: "date", location: "Starbucks, 901 New York Avenue", description: `Hey everyone, please choose the dates when you are available to meet for our monthly get together. Looking forward to see you all!`, demo: true, diff --git a/apps/web/src/utils/date-time-utils.ts b/apps/web/src/utils/date-time-utils.ts index c1af872b4..4b27388e3 100644 --- a/apps/web/src/utils/date-time-utils.ts +++ b/apps/web/src/utils/date-time-utils.ts @@ -65,7 +65,9 @@ export const decodeOptions = ( ): | { pollType: "date"; options: ParsedDateOption[] } | { pollType: "timeSlot"; options: ParsedTimeSlotOption[] } => { - const pollType = isTimeSlot(options[0].value) ? "timeSlot" : "date"; + const pollType = options.some(({ duration }) => duration > 0) + ? "timeSlot" + : "date"; if (pollType === "timeSlot") { return { @@ -83,12 +85,7 @@ export const decodeOptions = ( }; const parseDateOption = (option: Option): ParsedDateOption => { - const dateString = - option.value.indexOf("T") === -1 - ? // we add the time because otherwise Date will assume UTC time which might change the day for some time zones - option.value + "T00:00:00" - : option.value; - const date = dayjs(dateString); + const date = dayjs(option.start); return { type: "date", optionId: option.id, @@ -104,16 +101,12 @@ const parseTimeSlotOption = ( timeZone: string | null, targetTimeZone: string, ): ParsedTimeSlotOption => { - const [start, end] = option.value.split("/"); - const startDate = timeZone && targetTimeZone - ? dayjs(start).tz(timeZone, true).tz(targetTimeZone) - : dayjs(start); - const endDate = - timeZone && targetTimeZone - ? dayjs(end).tz(timeZone, true).tz(targetTimeZone) - : dayjs(end); + ? dayjs(option.start).tz(timeZone, true).tz(targetTimeZone) + : dayjs(option.start); + + const endDate = startDate.add(option.duration, "minute"); return { type: "timeSlot", diff --git a/apps/web/tests/house-keeping.spec.ts b/apps/web/tests/house-keeping.spec.ts index 36f6cb4d7..455fb7e58 100644 --- a/apps/web/tests/house-keeping.spec.ts +++ b/apps/web/tests/house-keeping.spec.ts @@ -176,7 +176,6 @@ const seedData = async () => { { title: "Active Poll", id: "active-poll", - type: "date", userId: "user1", participantUrlId: "p1", adminUrlId: "a1", @@ -185,7 +184,6 @@ const seedData = async () => { { title: "Deleted poll", id: "deleted-poll-6d", - type: "date", userId: "user1", deleted: true, deletedAt: dayjs().add(-6, "days").toDate(), @@ -196,7 +194,6 @@ const seedData = async () => { { title: "Deleted poll 7d", id: "deleted-poll-7d", - type: "date", userId: "user1", deleted: true, deletedAt: dayjs().add(-7, "days").toDate(), @@ -207,7 +204,6 @@ const seedData = async () => { { title: "Still active", id: "still-active-poll", - type: "date", userId: "user1", touchedAt: dayjs().add(-29, "days").toDate(), participantUrlId: "p4", @@ -217,7 +213,6 @@ const seedData = async () => { { title: "Inactive poll", id: "inactive-poll", - type: "date", userId: "user1", touchedAt: dayjs().add(-30, "days").toDate(), participantUrlId: "p5", @@ -228,7 +223,6 @@ const seedData = async () => { demo: true, title: "Demo poll", id: "demo-poll-new", - type: "date", userId: "user1", createdAt: new Date(), participantUrlId: "p6", @@ -238,7 +232,6 @@ const seedData = async () => { demo: true, title: "Old demo poll", id: "demo-poll-old", - type: "date", userId: "user1", createdAt: dayjs().add(-2, "days").toDate(), participantUrlId: "p7", @@ -247,7 +240,6 @@ const seedData = async () => { { title: "Inactive poll with future option", id: "inactive-poll-future-option", - type: "date", userId: "user1", touchedAt: dayjs().add(-30, "days").toDate(), participantUrlId: "p8", @@ -260,32 +252,27 @@ const seedData = async () => { data: [ { id: "option-1", - value: "2022-02-22", + start: new Date("2022-02-22T00:00:00"), pollId: "deleted-poll-7d", }, { id: "option-2", - value: "2022-02-23", + start: new Date("2022-02-23T00:00:00"), pollId: "deleted-poll-7d", }, { id: "option-3", - value: "2022-02-24", + start: new Date("2022-02-24T00:00:00"), pollId: "deleted-poll-7d", }, { id: "option-4", - value: `${dayjs() - .add(10, "days") - .format("YYYY-MM-DDTHH:mm:ss")}/${dayjs() - .add(10, "days") - .add(1, "hour") - .format("YYYY-MM-DDTHH:mm:ss")}`, + start: dayjs().add(10, "days").toDate(), pollId: "inactive-poll-future-option", }, { id: "option-5", - value: dayjs().add(-1, "days").format("YYYY-MM-DD"), + start: dayjs().add(-1, "days").toDate(), pollId: "inactive-poll", }, ], diff --git a/packages/database/prisma/migrations/20230329173551_options_refactor/migration.sql b/packages/database/prisma/migrations/20230329173551_options_refactor/migration.sql new file mode 100644 index 000000000..a728fddbe --- /dev/null +++ b/packages/database/prisma/migrations/20230329173551_options_refactor/migration.sql @@ -0,0 +1,35 @@ +-- AlterTable +ALTER TABLE "options" +ADD COLUMN "duration_minutes" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "start" TIMESTAMP(0); + +-- AlterTable +ALTER TABLE "polls" DROP COLUMN "type"; + +-- DropEnum +DROP TYPE "poll_type"; + +-- Reformat option value into new columns +UPDATE "options" +SET "start" = CASE + WHEN POSITION('/' IN "value") = 0 THEN (value || 'T00:00:00')::TIMESTAMP WITHOUT TIME ZONE + ELSE SPLIT_PART("value", '/', 1)::TIMESTAMP WITHOUT TIME ZONE + END, + "duration_minutes" = CASE + WHEN POSITION('/' IN "value") = 0 THEN 0 + ELSE + LEAST(EXTRACT(EPOCH FROM (split_part("value", '/', 2)::timestamp - split_part("value", '/', 1)::timestamp)) / 60, 1440) + END; + +-- Fix cases where we have a negative duration due to the end time being in the past +-- eg. Some polls have value 2023-03-29T23:00:00/2023-03-29T01:00:00 +UPDATE "options" +SET "duration_minutes" = "duration_minutes" + 1440 +WHERE "duration_minutes" < 0; + +-- Set start date to be not null now that we have all the data and drop the old value column +ALTER TABLE "options" +DROP COLUMN "value", +DROP COLUMN "updated_at", +ALTER COLUMN "start" SET NOT NULL; + diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index c0426d29c..0029b69fe 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -22,19 +22,12 @@ model User { @@map("users") } -enum PollType { - date - - @@map("poll_type") -} - model Poll { id String @id @unique @map("id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deadline DateTime? title String - type PollType description String? location String? user User? @relation(fields: [userId], references: [id]) @@ -88,11 +81,11 @@ model Participant { model Option { id String @id @default(cuid()) - value String + start DateTime @db.Timestamp(0) + duration Int @default(0) @map("duration_minutes") pollId String @map("poll_id") poll Poll @relation(fields: [pollId], references: [id]) createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime? @updatedAt @map("updated_at") @@index([pollId], type: Hash) @@map("options") diff --git a/packages/database/prisma/seed.ts b/packages/database/prisma/seed.ts index bce95ca7f..6638708f3 100644 --- a/packages/database/prisma/seed.ts +++ b/packages/database/prisma/seed.ts @@ -1,5 +1,5 @@ import { faker } from "@faker-js/faker"; -import { PrismaClient, VoteType } from "@prisma/client"; +import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); @@ -11,14 +11,16 @@ async function main() { // Create some users const user = await prisma.user.create({ data: { - name: faker.name.fullName(), - email: faker.internet.email(), + name: "Dev User", + email: "dev@rallly.co", }, }); // Create some polls const polls = await Promise.all( - Array.from({ length: 20 }).map(async () => { + Array.from({ length: 20 }).map(async (_, i) => { + // create some polls with no duration (all day) and some with a random duration. + const duration = i % 5 === 0 ? 15 * randInt(8) : 0; const poll = await prisma.poll.create({ include: { participants: true, @@ -30,7 +32,6 @@ async function main() { description: faker.lorem.paragraph(), location: faker.address.streetAddress(), deadline: faker.date.future(), - type: "date", user: { connect: { id: user.id, @@ -46,7 +47,8 @@ async function main() { ) // .map((date) => { return { - value: date.toISOString().substring(0, 10), + start: date, + duration, }; }), }, @@ -73,7 +75,7 @@ async function main() { data: poll.options.map((option) => { const randomNumber = randInt(100); const vote = - randomNumber > 90 ? "ifNeedBe" : randomNumber > 50 ? "yes" : "no"; + randomNumber > 95 ? "ifNeedBe" : randomNumber > 50 ? "yes" : "no"; return { participantId: participant.id, pollId: poll.id,