🏗️ Update how we store date values

This also fixes a few bugs:
- some values had end times that were before the start times
- some values has end times a couple of days in the future

It’s not entirely clear how users were able to set these values but this update fixes these values and helps avoid similar issues in the future.
This commit is contained in:
Luke Vella 2023-03-30 14:10:23 +01:00
parent 8a9159c322
commit 51c5016656
12 changed files with 203 additions and 158 deletions

View file

@ -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,
})),
});
}
};

View file

@ -265,16 +265,11 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
<TimePicker
value={startDate}
onChange={(newStart) => {
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<DateTimePickerProps> = ({
/>
<TimePicker
value={new Date(option.end)}
startFrom={dayjs(startDate)
.add(15, "minutes")
.toDate()}
after={startDate}
onChange={(newEnd) => {
onChange([
...options.slice(0, index),
@ -330,7 +323,15 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
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,

View file

@ -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<TimePickerProps> = ({
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<TimePickerProps> = ({
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<TimePickerProps> = ({
});
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(
<Listbox.Option
@ -63,7 +61,14 @@ const TimePicker: React.FunctionComponent<TimePickerProps> = ({
className={styleMenuItem}
value={optionValue.format("YYYY-MM-DDTHH:mm:ss")}
>
{optionValue.format("LT")}
<div className="flex items-center gap-2">
<span>{optionValue.format("LT")}</span>
{after ? (
<span className="text-sm text-slate-500">
{getDuration(dayjs(after), optionValue)}
</span>
) : null}
</div>
</Listbox.Option>,
);
}

View file

@ -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 = () => {

View file

@ -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),
},
},
});

View file

@ -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,
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,
},

View file

@ -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,

View file

@ -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",

View file

@ -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",
},
],

View file

@ -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;

View file

@ -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")

View file

@ -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,