Add scheduled events schema (#1679)

This commit is contained in:
Luke Vella 2025-04-22 14:28:15 +01:00 committed by GitHub
parent 22f32f9314
commit 56bd684c55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1412 additions and 659 deletions

View file

@ -200,10 +200,6 @@
"upcoming": "Upcoming",
"past": "Past",
"copyLink": "Copy Link",
"upcomingEventsEmptyStateTitle": "No Upcoming Events",
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
"pastEventsEmptyStateTitle": "No Past Events",
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
"createPoll": "Create poll",
"addToCalendar": "Add to Calendar",
"microsoft365": "Microsoft 365",
@ -301,7 +297,6 @@
"signUp": "Sign Up",
"upgradeToPro": "Upgrade to Pro",
"moreParticipants": "{count} more…",
"noDates": "No dates",
"commandMenuNoResults": "No results",
"commandMenu": "Command Menu",
"commandMenuDescription": "Select a command",
@ -319,5 +314,19 @@
"searchPollsPlaceholder": "Search polls by title...",
"poll": "Poll",
"sendFeedbackDesc": "Share your feedback with us.",
"sendFeedbackSuccess": "Thank you for your feedback!"
"sendFeedbackSuccess": "Thank you for your feedback!",
"searchEventsPlaceholder": "Search events by title...",
"canceled": "Canceled",
"tomorrow": "Tomorrow",
"yesterday": "Yesterday",
"lastWeek": "Last Week",
"unconfirmed": "Unconfirmed",
"upcomingEventsEmptyStateTitle": "No Upcoming Events",
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
"pastEventsEmptyStateTitle": "No Past Events",
"pastEventsEmptyStateDescription": "Past events will show up here.",
"unconfirmedEventsEmptyStateTitle": "No Unconfirmed Events",
"unconfirmedEventsEmptyStateDescription": "Unconfirmed events will show up here.",
"canceledEventsEmptyStateTitle": "No Canceled Events",
"canceledEventsEmptyStateDescription": "Canceled events will show up here."
}

View file

@ -1,74 +0,0 @@
"use client";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import dayjs from "dayjs";
import { Trans } from "@/components/trans";
import { generateGradient } from "@/utils/color-hash";
import { useDayjs } from "@/utils/dayjs";
import type { ScheduledEvent } from "./types";
export function EventList({ data }: { data: ScheduledEvent[] }) {
const table = useReactTable({
data,
columns: [],
getCoreRowModel: getCoreRowModel(),
});
const { adjustTimeZone } = useDayjs();
return (
<div className="rounded-lg border">
<ul className="divide-y divide-gray-100">
{table.getRowModel().rows.map((row) => {
const start = adjustTimeZone(
row.original.start,
!row.original.timeZone,
);
const end = adjustTimeZone(
dayjs(row.original.start).add(row.original.duration, "minutes"),
!row.original.timeZone,
);
return (
<li key={row.id} className="p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-8">
<div className="flex shrink-0 justify-between gap-1 sm:w-24 sm:flex-col sm:text-right">
<time dateTime={start.toISOString()} className="text-sm">
{start.format("ddd, D MMM")}
</time>
<time
dateTime={start.toISOString()}
className="text-muted-foreground text-sm"
>
{start.format("YYYY")}
</time>
</div>
<div className="min-w-0">
<div className="flex items-center gap-x-2">
<span
className="h-4 w-1 shrink-0 rounded-full"
style={{
background: generateGradient(row.original.id),
}}
></span>
<h2 className="truncate text-sm font-medium">
{row.original.title}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{row.original.duration === 0 ? (
<Trans i18nKey="allDay" />
) : (
<span>{`${start.format("LT")} - ${end.format("LT")}`}</span>
)}
</p>
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
import { useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { Trans } from "@/components/trans";
export function EventsTabbedView({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
const name = "status";
const router = useRouter();
const handleTabChange = React.useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams);
params.set(name, value);
params.delete("page");
const newUrl = `?${params.toString()}`;
router.replace(newUrl, { scroll: false });
},
[name, router, searchParams],
);
const value = searchParams.get(name) ?? "upcoming";
return (
<Tabs value={value} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="upcoming">
<Trans i18nKey="upcoming" defaults="Upcoming" />
</TabsTrigger>
<TabsTrigger value="past">
<Trans i18nKey="past" defaults="Past" />
</TabsTrigger>
</TabsList>
<TabsContent tabIndex={-1} value={value} key={value}>
{children}
</TabsContent>
</Tabs>
);
}

View file

@ -1,3 +1,5 @@
import { CalendarIcon } from "lucide-react";
import type { Params } from "@/app/[locale]/types";
import { EventPageIcon } from "@/app/components/page-icons";
import {
@ -7,13 +9,101 @@ import {
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { SearchInput } from "@/app/components/search-input";
import {
EmptyState,
EmptyStateDescription,
EmptyStateIcon,
EmptyStateTitle,
} from "@/components/empty-state";
import { StackedList, StackedListItem } from "@/components/stacked-list";
import { Trans } from "@/components/trans";
import { getScheduledEvents } from "@/features/scheduled-event/api/get-scheduled-events";
import { ScheduledEventListItem } from "@/features/scheduled-event/components/scheduled-event-list";
import type { Status } from "@/features/scheduled-event/schema";
import { statusSchema } from "@/features/scheduled-event/schema";
import { getTranslation } from "@/i18n/server";
import { requireUser } from "@/next-auth";
import { UserScheduledEvents } from "./user-scheduled-events";
import { EventsTabbedView } from "./events-tabbed-view";
async function loadData({
status,
search,
}: {
status: Status;
search?: string;
}) {
const { userId } = await requireUser();
const scheduledEvents = await getScheduledEvents({
userId,
status,
search,
});
return scheduledEvents;
}
async function ScheduledEventEmptyState({ status }: { status: Status }) {
const { t } = await getTranslation();
const contentByStatus = {
upcoming: {
title: t("upcomingEventsEmptyStateTitle", {
defaultValue: "No Upcoming Events",
}),
description: t("upcomingEventsEmptyStateDescription", {
defaultValue: "When you schedule events, they will appear here.",
}),
},
past: {
title: t("pastEventsEmptyStateTitle", {
defaultValue: "No Past Events",
}),
description: t("pastEventsEmptyStateDescription", {
defaultValue: "Past events will show up here.",
}),
},
unconfirmed: {
title: t("unconfirmedEventsEmptyStateTitle", {
defaultValue: "No Unconfirmed Events",
}),
description: t("unconfirmedEventsEmptyStateDescription", {
defaultValue: "Unconfirmed events will show up here.",
}),
},
canceled: {
title: t("canceledEventsEmptyStateTitle", {
defaultValue: "No Canceled Events",
}),
description: t("canceledEventsEmptyStateDescription", {
defaultValue: "Canceled events will show up here.",
}),
},
};
const { title, description } = contentByStatus[status];
return (
<EmptyState className="h-96">
<EmptyStateIcon>
<CalendarIcon />
</EmptyStateIcon>
<EmptyStateTitle>{title}</EmptyStateTitle>
<EmptyStateDescription>{description}</EmptyStateDescription>
</EmptyState>
);
}
export default async function Page({
params,
searchParams,
}: {
params: Params;
searchParams?: { status: string; q?: string };
}) {
const { t } = await getTranslation(params.locale);
const status = statusSchema.catch("upcoming").parse(searchParams?.status);
const scheduledEvents = await loadData({ status, search: searchParams?.q });
export default async function Page({ params }: { params: Params }) {
await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
@ -29,7 +119,39 @@ export default async function Page({ params }: { params: Params }) {
</PageDescription>
</PageHeader>
<PageContent>
<UserScheduledEvents />
<EventsTabbedView>
<div className="space-y-4">
<SearchInput
placeholder={t("searchEventsPlaceholder", {
defaultValue: "Search events by title...",
})}
/>
<div className="space-y-6">
{scheduledEvents.length === 0 && (
<ScheduledEventEmptyState status={status} />
)}
{scheduledEvents.length > 0 && (
<StackedList>
{scheduledEvents.map((event) => (
<StackedListItem key={event.id}>
<ScheduledEventListItem
eventId={event.id}
key={event.id}
floating={event.timeZone === null}
title={event.title}
start={event.start}
end={event.end}
status={event.status}
allDay={event.allDay}
invites={event.invites}
/>
</StackedListItem>
))}
</StackedList>
)}
</div>
</div>
</EventsTabbedView>
</PageContent>
</PageContainer>
);

View file

@ -1,48 +0,0 @@
"use client";
import { CalendarPlusIcon } from "lucide-react";
import {
EmptyState,
EmptyStateDescription,
EmptyStateIcon,
EmptyStateTitle,
} from "@/components/empty-state";
import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans";
import { trpc } from "@/trpc/client";
import { EventList } from "./event-list";
export function PastEvents() {
const { data } = trpc.scheduledEvents.list.useQuery({
period: "past",
});
if (!data) {
return <Spinner />;
}
if (data.length === 0) {
return (
<EmptyState className="h-96">
<EmptyStateIcon>
<CalendarPlusIcon />
</EmptyStateIcon>
<EmptyStateTitle>
<Trans
i18nKey="pastEventsEmptyStateTitle"
defaults="No Past Events"
/>
</EmptyStateTitle>
<EmptyStateDescription>
<Trans
i18nKey="pastEventsEmptyStateDescription"
defaults="When you schedule events, they will appear here."
/>
</EmptyStateDescription>
</EmptyState>
);
}
return <EventList data={data} />;
}

View file

@ -1,46 +0,0 @@
"use client";
import { CalendarPlusIcon } from "lucide-react";
import {
EmptyState,
EmptyStateDescription,
EmptyStateIcon,
EmptyStateTitle,
} from "@/components/empty-state";
import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans";
import { trpc } from "@/trpc/client";
import { EventList } from "./event-list";
export function UpcomingEvents() {
const { data } = trpc.scheduledEvents.list.useQuery({ period: "upcoming" });
if (!data) {
return <Spinner />;
}
if (data.length === 0) {
return (
<EmptyState className="h-96">
<EmptyStateIcon>
<CalendarPlusIcon />
</EmptyStateIcon>
<EmptyStateTitle>
<Trans
i18nKey="upcomingEventsEmptyStateTitle"
defaults="No Upcoming Events"
/>
</EmptyStateTitle>
<EmptyStateDescription>
<Trans
i18nKey="upcomingEventsEmptyStateDescription"
defaults="When you schedule events, they will appear here."
/>
</EmptyStateDescription>
</EmptyState>
);
}
return <EventList data={data} />;
}

View file

@ -1,46 +0,0 @@
"use client";
import { Tabs, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { Trans } from "@/components/trans";
import { PastEvents } from "./past-events";
import { UpcomingEvents } from "./upcoming-events";
const eventPeriodSchema = z.enum(["upcoming", "past"]).catch("upcoming");
export function UserScheduledEvents() {
const searchParams = useSearchParams();
const period = eventPeriodSchema.parse(searchParams?.get("period"));
const router = useRouter();
return (
<div className="space-y-4">
<Tabs
value={searchParams.get("period") ?? "upcoming"}
onValueChange={(value) => {
const params = new URLSearchParams(searchParams);
params.set("period", value);
const newUrl = `?${params.toString()}`;
router.replace(newUrl);
}}
aria-label="Event period"
>
<TabsList>
<TabsTrigger value="upcoming">
<Trans i18nKey="upcoming" defaults="Upcoming" />
</TabsTrigger>
<TabsTrigger value="past">
<Trans i18nKey="past" defaults="Past" />
</TabsTrigger>
</TabsList>
</Tabs>
<div>
{period === "upcoming" && <UpcomingEvents />}
{period === "past" && <PastEvents />}
</div>
</div>
);
}

View file

@ -6,6 +6,7 @@ import Link from "next/link";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { getUser } from "@/data/get-user";
import { CommandMenu } from "@/features/navigation/command-menu";
import { TimezoneProvider } from "@/features/timezone/client/context";
import { AppSidebar } from "./components/sidebar/app-sidebar";
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider";
@ -18,6 +19,7 @@ export default async function Layout({
}) {
const user = await getUser();
return (
<TimezoneProvider initialTimezone={user.timeZone}>
<AppSidebarProvider>
<CommandMenu />
<AppSidebar />
@ -49,5 +51,6 @@ export default async function Layout({
<ActionBar />
</SidebarInset>
</AppSidebarProvider>
</TimezoneProvider>
);
}

View file

@ -27,9 +27,9 @@ import {
EmptyStateIcon,
EmptyStateTitle,
} from "@/components/empty-state";
import { FormattedDate } from "@/components/formatted-date";
import { PayWallDialog } from "@/components/pay-wall-dialog";
import { Trans } from "@/components/trans";
import { FormattedDateTime } from "@/features/timezone/client/formatted-date-time";
import { requireUser } from "@/next-auth";
import { isSelfHosted } from "@/utils/constants";
@ -153,7 +153,10 @@ export default async function Page() {
{subscription.cancelAtPeriodEnd ? (
"-"
) : (
<FormattedDate date={subscription.periodEnd} format="short" />
<FormattedDateTime
date={subscription.periodEnd}
format="D MMM YYYY"
/>
)}
</DescriptionDetails>

View file

@ -13,7 +13,7 @@ import React from "react";
import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
import { UserProvider } from "@/components/user-provider";
import { TimezoneProvider } from "@/features/timezone";
import { TimezoneProvider } from "@/features/timezone/client/context";
import { I18nProvider } from "@/i18n/client";
import { auth } from "@/next-auth";
import { TRPCProvider } from "@/trpc/client/provider";

View file

@ -1,22 +0,0 @@
"use client";
import dayjs from "dayjs";
const formatMap = {
short: "D MMM YYYY",
};
type Format = keyof typeof formatMap | string;
export function FormattedDate({
date,
format,
}: {
date: Date;
format: Format;
}) {
// If format is a key in formatMap, use the predefined format, otherwise use the format string directly
const formatString =
format in formatMap ? formatMap[format as keyof typeof formatMap] : format;
return <>{dayjs(date).format(formatString)}</>;
}

View file

@ -1,57 +0,0 @@
"use client";
import dayjs from "dayjs";
import { Trans } from "@/components/trans";
import { DateDisplay, DateTimeDisplay } from "@/features/timezone";
interface ScheduledEventDisplayProps {
event?: {
start: Date;
duration: number;
};
dateOptions?: {
first?: Date;
last?: Date;
count: number;
};
}
export const ScheduledEventDisplay = ({
event,
dateOptions,
}: ScheduledEventDisplayProps) => {
if (event) {
return (
<div className="flex items-center gap-2 whitespace-nowrap text-sm">
<span className="text-muted-foreground">
{event.duration > 0 ? (
<DateTimeDisplay date={dayjs(event.start)} />
) : (
<DateDisplay date={dayjs(event.start)} />
)}
</span>
</div>
);
}
if (!dateOptions?.first || !dateOptions?.last || dateOptions.count === 0) {
return (
<span className="text-muted-foreground">
<Trans i18nKey="noDates" defaults="No dates" />
</span>
);
}
return (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">
<Trans
i18nKey="optionCount"
defaults="{count, plural, one {# option} other {# options}}"
values={{ count: dateOptions.count }}
/>
</span>
</div>
);
};

View file

@ -0,0 +1,80 @@
import { prisma } from "@rallly/database";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { Status } from "../schema";
dayjs.extend(utc);
dayjs.extend(timezone);
const mapStatus = {
upcoming: "confirmed",
unconfirmed: "unconfirmed",
past: undefined,
canceled: "canceled",
} as const;
export async function getScheduledEvents({
userId,
status,
search,
}: {
userId: string;
status: Status;
search?: string;
}) {
const now = new Date();
const rawEvents = await prisma.scheduledEvent.findMany({
where: {
userId,
deletedAt: null,
...(status != "past" && { start: { gte: now } }),
...(status === "past" && { start: { lt: now } }),
...(search && { title: { contains: search, mode: "insensitive" } }),
status: mapStatus[status],
},
orderBy: {
start: status === "past" ? "desc" : "asc",
},
select: {
id: true,
title: true,
description: true,
location: true,
start: true,
end: true,
allDay: true,
timeZone: true,
status: true,
invites: {
select: {
id: true,
inviteeName: true,
user: {
select: {
image: true,
},
},
},
},
},
});
const events = rawEvents.map((event) => ({
...event,
status:
event.status === "confirmed"
? // If the event is confirmed, it's either past or upcoming
((event.start < now ? "past" : "upcoming") as Status)
: event.status,
invites: event.invites.map((invite) => ({
id: invite.id,
inviteeName: invite.inviteeName,
inviteeImage: invite.user?.image ?? undefined,
})),
}));
return events;
}

View file

@ -0,0 +1,82 @@
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
import { StackedList } from "@/components/stacked-list";
import { Trans } from "@/components/trans";
import { ScheduledEventStatusBadge } from "@/features/scheduled-event/components/scheduled-event-status-badge";
import type { Status } from "@/features/scheduled-event/schema";
import { FormattedDateTime } from "@/features/timezone/client/formatted-date-time";
export const ScheduledEventList = StackedList;
export function ScheduledEventListItem({
title,
start,
end,
status,
allDay,
invites,
floating: isFloating,
}: {
eventId: string;
title: string;
start: Date;
end: Date;
status: Status;
allDay: boolean;
invites: { id: string; inviteeName: string; inviteeImage?: string }[];
floating: boolean;
}) {
return (
<div className="flex w-full gap-6">
<div className="flex flex-1 flex-col gap-y-1 lg:flex-row-reverse lg:justify-end lg:gap-x-4">
<div className="flex items-center gap-4 text-sm">
<div>{title}</div>
<div>
<ScheduledEventStatusBadge status={status} />
</div>
</div>
<div className="flex items-center whitespace-nowrap text-sm lg:min-w-40">
<div>
<div>
<FormattedDateTime
date={start}
floating={isFloating}
format="LL"
/>
</div>
<div className="text-muted-foreground mt-1">
{allDay ? (
<Trans i18nKey="allDay" defaults="All day" />
) : (
<div className="flex items-center gap-x-1">
<FormattedDateTime
date={start}
floating={isFloating}
format="LT"
/>
<span>-</span>
<FormattedDateTime
date={end}
floating={isFloating}
format="LT"
/>
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:block">
<ParticipantAvatarBar
participants={invites.map((invite) => ({
id: invite.id,
name: invite.inviteeName,
image: invite.inviteeImage ?? undefined,
}))}
max={5}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,29 @@
import { Badge } from "@rallly/ui/badge";
import { Trans } from "@/components/trans";
import type { Status } from "@/features/scheduled-event/schema";
export function ScheduledEventStatusBadge({ status }: { status: Status }) {
switch (status) {
case "past":
return (
<Badge>
<Trans i18nKey="past" defaults="Past" />
</Badge>
);
case "upcoming":
return null;
case "canceled":
return (
<Badge>
<Trans i18nKey="canceled" defaults="Canceled" />
</Badge>
);
case "unconfirmed":
return (
<Badge>
<Trans i18nKey="unconfirmed" defaults="Unconfirmed" />
</Badge>
);
}
}

View file

@ -0,0 +1,10 @@
import { z } from "zod";
export const statusSchema = z.enum([
"upcoming",
"unconfirmed",
"past",
"canceled",
]);
export type Status = z.infer<typeof statusSchema>;

View file

@ -0,0 +1,33 @@
"use client";
import dayjs from "dayjs";
import calendar from "dayjs/plugin/calendar";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import { useTimezone } from "@/features/timezone/client/context";
import { useTranslation } from "@/i18n/client";
dayjs.extend(calendar);
dayjs.extend(localizedFormat);
dayjs.extend(timezone);
export function CalendarDate({ date }: { date: string }) {
const { timezone } = useTimezone();
const { t } = useTranslation();
return (
<time dateTime={dayjs(date).toISOString()}>
{dayjs(date)
.tz(timezone)
.calendar(null, {
sameDay: `[${t("today", { defaultValue: "Today" })}]`,
nextDay: `[${t("tomorrow", { defaultValue: "Tomorrow" })}]`,
nextWeek: "dddd",
lastDay: `[${t("yesterday", { defaultValue: "Yesterday" })}]`,
lastWeek: `[${t("lastWeek", { defaultValue: "Last Week" })}]`,
sameElse: "DD MMM YYYY",
})}
</time>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import dayjs from "dayjs";
import * as React from "react";
import { getBrowserTimeZone } from "@/utils/date-time-utils";
interface TimezoneContextProps {
timezone: string;
setTimezone: (timezone: string) => void;
}
const TimezoneContext = React.createContext<TimezoneContextProps | null>(null);
interface TimezoneProviderProps {
children: React.ReactNode;
initialTimezone?: string;
}
export const TimezoneProvider = ({
children,
initialTimezone,
}: TimezoneProviderProps) => {
const [timezone, setTimezone] = React.useState(() => {
if (initialTimezone) {
try {
dayjs().tz(initialTimezone);
return initialTimezone;
} catch (error) {
console.warn(error);
}
}
return getBrowserTimeZone();
});
const value = React.useMemo(
() => ({ timezone, setTimezone }),
[timezone, setTimezone],
);
return (
<TimezoneContext.Provider value={value}>
{children}
</TimezoneContext.Provider>
);
};
export const useTimezone = () => {
const context = React.useContext(TimezoneContext);
if (context === null) {
throw new Error("useTimezone must be used within a TimezoneProvider");
}
return context;
};

View file

@ -0,0 +1,46 @@
"use client";
import type { ConfigType } from "dayjs";
import dayjs from "dayjs";
import * as React from "react";
import { useFormattedDateTime } from "@/features/timezone/hooks/use-formatted-date-time";
interface FormattedDateTimeProps extends React.HTMLAttributes<HTMLSpanElement> {
/** The date/time to format (Accepts Date, ISO string, Unix timestamp, Dayjs object). */
date: ConfigType | null | undefined;
/** A dayjs format string (e.g., "YYYY-MM-DD HH:mm", "h:mm A"). Defaults to a locale-aware format. */
format?: string;
/** If true, formats the time without applying the context timezone. Defaults to false. */
floating?: boolean;
/** Optional locale string (e.g., "en-US", "fr-FR"). Defaults to browser/system locale. */
locale?: string;
}
/**
* Component to render a formatted date/time string based on the current timezone context.
*
* Uses the `useFormattedDateTime` hook internally.
*/
export const FormattedDateTime = React.forwardRef<
HTMLTimeElement,
FormattedDateTimeProps
>(({ date, format, floating, locale, ...props }, ref) => {
const formattedDate = useFormattedDateTime(date, {
format,
floating,
locale,
});
return (
<time
dateTime={date ? dayjs(date).toISOString() : new Date().toISOString()}
ref={ref}
{...props}
>
{formattedDate}
</time>
);
});
FormattedDateTime.displayName = "FormattedDateTime";

View file

@ -0,0 +1,75 @@
import { cn } from "@rallly/ui";
import type { ConfigType } from "dayjs";
import dayjs from "dayjs";
import * as React from "react";
interface FormattedDateTimeServerProps
extends Omit<React.HTMLAttributes<HTMLTimeElement>, "dateTime"> {
/** The date/time to format (Accepts Date, ISO string, Unix timestamp, Dayjs object). */
date: ConfigType | null | undefined;
/** The IANA timezone string to use for formatting. Required for server component. */
timezone: string;
/** A dayjs format string (e.g., "YYYY-MM-DD HH:mm", "h:mm A"). Defaults to a locale-aware format. */
format?: string;
/** If true, formats the time without applying the context timezone. Defaults to false. */
floating?: boolean;
/** Optional locale string (e.g., "en-US", "fr-FR"). Defaults to browser/system locale if applicable on server, otherwise server default. */
locale?: string;
}
/**
* Server Component to render a formatted date/time string based on a provided timezone.
*
* Does NOT use React Context.
*/
export const FormattedDateTimeServer = ({
date,
timezone,
format,
floating = false,
locale,
className,
...props
}: FormattedDateTimeServerProps) => {
if (!date) {
return null; // Return null for invalid dates
}
let dayjsInstance = dayjs(date);
// Apply locale if provided
if (locale) {
dayjsInstance = dayjsInstance.locale(locale);
}
// Apply timezone unless floating is true
if (!floating) {
try {
dayjsInstance = dayjsInstance.tz(timezone);
} catch (error) {
console.warn(
`FormattedDateTimeServer: Invalid timezone provided: "${timezone}". Falling back.`,
error,
);
// Fallback or default behavior if timezone is invalid
// Might default to UTC or system time depending on Dayjs config
}
} else {
// Standardize floating times to UTC before formatting without tz
dayjsInstance = dayjsInstance.utc();
}
// Determine the format string
const defaultFormat = floating ? "LT" : "LLLL"; // LT for floating, LLLL for timezone-aware
const formatString = format ?? defaultFormat;
const formattedDate = dayjsInstance.format(formatString);
// Provide machine-readable dateTime attribute, usually in ISO format
const machineReadableDate = dayjs(date).toISOString();
return (
<time dateTime={machineReadableDate} className={cn(className)} {...props}>
{formattedDate}
</time>
);
};

View file

@ -0,0 +1,52 @@
import type { ConfigType } from "dayjs";
import dayjs from "dayjs";
import { useTimezone } from "@/features/timezone/client/context";
interface UseFormattedDateTimeOptions {
/** A dayjs format string (e.g., "YYYY-MM-DD HH:mm", "h:mm A"). Defaults to a locale-aware format. */
format?: string;
/** If true, formats the time without applying the context timezone. Defaults to false. */
floating?: boolean;
/** Optional locale string (e.g., "en-US", "fr-FR"). Defaults to browser/system locale. */
locale?: string;
}
/**
* Hook to format a date/time value based on the current timezone context.
*
* @param date The date/time to format (Accepts Date, ISO string, Unix timestamp, Dayjs object).
* @param options Formatting options including format string, floating flag, and locale.
* @returns The formatted date/time string.
*/
export const useFormattedDateTime = (
date: ConfigType | null | undefined,
options: UseFormattedDateTimeOptions = {},
): string => {
const { timezone } = useTimezone();
const { format, floating = false, locale } = options;
if (!date) {
return "";
}
let dayjsInstance = dayjs(date);
// Apply locale if provided
if (locale) {
dayjsInstance = dayjsInstance.locale(locale);
}
// Apply timezone unless floating is true
if (!floating) {
dayjsInstance = dayjsInstance.tz(timezone);
}
// For floating times, we might still want to ensure consistency,
// especially if the input could be a Z-suffixed ISO string.
// Converting to UTC first standardizes it before formatting without tz.
else {
dayjsInstance = dayjsInstance.utc();
}
return dayjsInstance.format(format ?? "LLLL");
};

View file

@ -1,3 +1,2 @@
export * from "./timezone-context";
export * from "./timezone-display";
export * from "./timezone-utils";
export * from "./client/context";
export * from "./utils";

View file

@ -1,104 +0,0 @@
"use client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { createContext, useContext, useEffect, useState } from "react";
// Initialize dayjs plugins
dayjs.extend(utc);
dayjs.extend(timezone);
// Default to browser timezone if not specified
const getBrowserTimezone = () => {
if (typeof window !== "undefined") {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return "UTC"; // Default to UTC for server-side rendering
};
type TimezoneContextType = {
timezone: string;
setTimezone: (timezone: string) => void;
formatDate: (date: string | Date | dayjs.Dayjs, format?: string) => string;
formatTime: (date: string | Date | dayjs.Dayjs, format?: string) => string;
formatDateTime: (
date: string | Date | dayjs.Dayjs,
format?: string,
) => string;
};
const TimezoneContext = createContext<TimezoneContextType | undefined>(
undefined,
);
export const TimezoneProvider = ({
initialTimezone,
children,
}: {
initialTimezone?: string;
children: React.ReactNode;
}) => {
// Initialize with browser timezone, but allow user preference to override
const [timezone, setTimezone] = useState<string>(() => {
if (initialTimezone) {
return initialTimezone;
}
// Try to get from localStorage first (user preference)
if (typeof window !== "undefined") {
const savedTimezone = localStorage.getItem("userTimezone");
if (savedTimezone) {
return savedTimezone;
}
}
return getBrowserTimezone();
});
// Save timezone preference to localStorage when it changes
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("userTimezone", timezone);
}
}, [timezone]);
// Format functions that automatically use the current timezone
const formatDate = (
date: string | Date | dayjs.Dayjs,
format = "YYYY-MM-DD",
) => {
return dayjs(date).tz(timezone).format(format);
};
const formatTime = (date: string | Date | dayjs.Dayjs, format = "HH:mm") => {
return dayjs(date).tz(timezone).format(format);
};
const formatDateTime = (
date: string | Date | dayjs.Dayjs,
format = "YYYY-MM-DD HH:mm",
) => {
return dayjs(date).tz(timezone).format(format);
};
const value = {
timezone,
setTimezone,
formatDate,
formatTime,
formatDateTime,
};
return (
<TimezoneContext.Provider value={value}>
{children}
</TimezoneContext.Provider>
);
};
export const useTimezone = () => {
const context = useContext(TimezoneContext);
if (context === undefined) {
throw new Error("useTimezone must be used within a TimezoneProvider");
}
return context;
};

View file

@ -1,31 +0,0 @@
"use client";
import type dayjs from "dayjs";
import { useTimezone } from "./timezone-context";
type DateDisplayProps = {
date: string | Date | dayjs.Dayjs;
format?: string;
};
export function DateDisplay({ date, format = "LL" }: DateDisplayProps) {
const { formatDate } = useTimezone();
return <span>{formatDate(date, format)}</span>;
}
export function TimeDisplay({ date, format = "HH:mm" }: DateDisplayProps) {
const { formatTime } = useTimezone();
return <span>{formatTime(date, format)}</span>;
}
export function DateTimeDisplay({ date, format = "LL, LT" }: DateDisplayProps) {
const { formatDateTime } = useTimezone();
return <span>{formatDateTime(date, format)}</span>;
}
// Component to display the current timezone
export function CurrentTimezone() {
const { timezone } = useTimezone();
return <span>{timezone}</span>;
}

View file

@ -6,6 +6,7 @@ import { nanoid } from "@rallly/utils/nanoid";
import { TRPCError } from "@trpc/server";
import dayjs from "dayjs";
import * as ics from "ics";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { moderateContent } from "@/features/moderation";
@ -253,6 +254,8 @@ export const polls = router({
}
}
revalidatePath("/", "layout");
return { id: poll.id };
}),
update: possiblyPublicProcedure
@ -349,6 +352,7 @@ export const polls = router({
requireParticipantEmail: input.requireParticipantEmail,
},
});
revalidatePath("/", "layout");
}),
delete: possiblyPublicProcedure
.input(
@ -362,6 +366,7 @@ export const polls = router({
where: { id: pollId },
data: { deleted: true, deletedAt: new Date() },
});
revalidatePath("/", "layout");
}),
// END LEGACY ROUTES
getWatchers: publicProcedure
@ -519,9 +524,16 @@ export const polls = router({
},
participants: {
select: {
id: true,
name: true,
email: true,
locale: true,
user: {
select: {
email: true,
timeZone: true,
},
},
votes: {
select: {
optionId: true,
@ -579,6 +591,41 @@ export const polls = router({
},
data: {
status: "finalized",
scheduledEvent: {
create: {
id: input.pollId,
start: eventStart.toDate(),
end: eventStart.add(option.duration, "minute").toDate(),
title: poll.title,
location: poll.location,
timeZone: poll.timeZone,
userId: ctx.user.id,
allDay: option.duration === 0,
status: "confirmed",
invites: {
createMany: {
data: poll.participants
.filter((p) => p.email || p.user?.email) // Filter out participants without email
.map((p) => ({
inviteeName: p.name,
inviteeEmail:
p.user?.email ?? p.email ?? `${p.id}@rallly.co`,
inviteeTimeZone: p.user?.timeZone ?? poll.timeZone, // We should track participant's timezone
status: (
{
yes: "accepted",
ifNeedBe: "tentative",
no: "declined",
} as const
)[
p.votes.find((v) => v.optionId === input.optionId)
?.type ?? "no"
],
})),
},
},
},
},
event: {
create: {
optionId: input.optionId,
@ -748,6 +795,8 @@ export const polls = router({
days_since_created: dayjs().diff(poll.createdAt, "day"),
},
});
revalidatePath("/", "layout");
}
}),
reopen: possiblyPublicProcedure
@ -774,7 +823,16 @@ export const polls = router({
},
});
}
if (poll.scheduledEventId) {
await prisma.scheduledEvent.delete({
where: {
id: poll.scheduledEventId,
},
});
}
});
revalidatePath("/", "layout");
}),
pause: possiblyPublicProcedure
.input(

View file

@ -0,0 +1,87 @@
-- CreateEnum
CREATE TYPE "scheduled_event_status" AS ENUM ('confirmed', 'canceled', 'unconfirmed');
-- CreateEnum
CREATE TYPE "scheduled_event_invite_status" AS ENUM ('pending', 'accepted', 'declined', 'tentative');
-- CreateTable
CREATE TABLE "scheduled_events" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"location" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"status" "scheduled_event_status" NOT NULL DEFAULT 'confirmed',
"time_zone" TEXT,
"start" TIMESTAMP(3) NOT NULL,
"end" TIMESTAMP(3) NOT NULL,
"all_day" BOOLEAN NOT NULL DEFAULT false,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "scheduled_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rescheduled_event_dates" (
"id" TEXT NOT NULL,
"scheduled_event_id" TEXT NOT NULL,
"start" TIMESTAMP(3) NOT NULL,
"end" TIMESTAMP(3) NOT NULL,
"all_day" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rescheduled_event_dates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "scheduled_event_invites" (
"id" TEXT NOT NULL,
"scheduled_event_id" TEXT NOT NULL,
"invitee_name" TEXT NOT NULL,
"invitee_email" TEXT NOT NULL,
"invitee_id" TEXT,
"invitee_time_zone" TEXT,
"status" "scheduled_event_invite_status" NOT NULL DEFAULT 'pending',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "scheduled_event_invites_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "rescheduled_event_dates_scheduled_event_id_idx" ON "rescheduled_event_dates"("scheduled_event_id");
-- CreateIndex
CREATE INDEX "scheduled_event_invites_scheduled_event_id_idx" ON "scheduled_event_invites"("scheduled_event_id");
-- CreateIndex
CREATE INDEX "scheduled_event_invites_invitee_id_idx" ON "scheduled_event_invites"("invitee_id");
-- CreateIndex
CREATE INDEX "scheduled_event_invites_invitee_email_idx" ON "scheduled_event_invites"("invitee_email");
-- CreateIndex
CREATE UNIQUE INDEX "scheduled_event_invites_scheduled_event_id_invitee_email_key" ON "scheduled_event_invites"("scheduled_event_id", "invitee_email");
-- CreateIndex
CREATE UNIQUE INDEX "scheduled_event_invites_scheduled_event_id_invitee_id_key" ON "scheduled_event_invites"("scheduled_event_id", "invitee_id");
-- AddForeignKey
ALTER TABLE "scheduled_events" ADD CONSTRAINT "scheduled_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rescheduled_event_dates" ADD CONSTRAINT "rescheduled_event_dates_scheduled_event_id_fkey" FOREIGN KEY ("scheduled_event_id") REFERENCES "scheduled_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduled_event_invites" ADD CONSTRAINT "scheduled_event_invites_scheduled_event_id_fkey" FOREIGN KEY ("scheduled_event_id") REFERENCES "scheduled_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduled_event_invites" ADD CONSTRAINT "scheduled_event_invites_invitee_id_fkey" FOREIGN KEY ("invitee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "polls" ADD COLUMN "scheduled_event_id" TEXT;
-- AddForeignKey
ALTER TABLE "polls" ADD CONSTRAINT "polls_scheduled_event_id_fkey" FOREIGN KEY ("scheduled_event_id") REFERENCES "scheduled_events"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,52 @@
-- Step 1: Insert data from Event into ScheduledEvent
-- Reuse Event ID for ScheduledEvent ID
-- Calculate 'end': For all-day (duration 0), end is start + 1 day. Otherwise, calculate based on duration.
-- Set 'all_day' based on 'duration_minutes'
-- Fetch 'location' and 'time_zone' from the related Poll using event_id
-- Set defaults for other fields
INSERT INTO "scheduled_events" (
"id",
"user_id",
"title",
"description",
"location",
"created_at",
"updated_at",
"status",
"time_zone",
"start",
"end",
"all_day",
"deleted_at"
)
SELECT
e."id", -- Reuse Event ID
e."user_id",
e."title",
NULL, -- Default description
p."location", -- Get location from the related Poll
e."created_at",
NOW(), -- Set updated_at to current time
'confirmed'::"scheduled_event_status", -- Default status 'confirmed'
p."time_zone", -- Get timeZone from the related Poll
e."start",
-- Calculate 'end': If duration is 0 (all-day), set end to start + 1 day. Otherwise, calculate based on duration.
CASE
WHEN e."duration_minutes" = 0 THEN e."start" + interval '1 day'
ELSE e."start" + (e."duration_minutes" * interval '1 minute')
END,
-- Set 'all_day': TRUE if duration is 0, FALSE otherwise
CASE
WHEN e."duration_minutes" = 0 THEN TRUE
ELSE FALSE
END,
NULL -- Default deletedAt to NULL
FROM
"events" e
LEFT JOIN "polls" p ON e."id" = p."event_id";
-- Step 2: Update the polls table to link to the new scheduled_event_id
-- Set scheduled_event_id = event_id where event_id was previously set
-- Only update if the corresponding ScheduledEvent was successfully created in Step 1
UPDATE "polls" p
SET "scheduled_event_id" = p."event_id"
WHERE p."event_id" IS NOT NULL;

View file

@ -0,0 +1,88 @@
-- migrate_event_votes_to_invites.sql V7
-- Migrate participants with emails from polls linked to events with a selected winning option (event.optionId)
-- into scheduled_event_invites for the corresponding scheduled_event (poll.scheduled_event_id).
-- Map the participant's vote on the winning option to the invite status.
-- Uses CTE with ROW_NUMBER() to handle potential duplicates based on email *and* user_id per scheduled event, preferring the most recent participant.
-- Uses NOT EXISTS in WHERE clause to avoid inserting invites if they already exist from other sources.
-- Reuses the participant's unique ID (pt.id) as the invite ID for this migration.
-- Excludes participants with NULL or empty string emails.
WITH PotentialInvites AS (
SELECT
pt.id as participant_id, -- Keep original participant ID for reuse
p.scheduled_event_id,
pt.name as invitee_name,
pt.email as invitee_email,
pt.user_id as invitee_id,
u.time_zone as invitee_time_zone,
v.type as vote_type,
pt.created_at as participant_created_at,
-- Assign row number partitioned by event and email, preferring most recent participant
ROW_NUMBER() OVER(PARTITION BY p.scheduled_event_id, pt.email ORDER BY pt.created_at DESC) as rn_email,
-- Assign row number partitioned by event and user_id (if not null), preferring most recent participant
CASE
WHEN pt.user_id IS NOT NULL THEN ROW_NUMBER() OVER(PARTITION BY p.scheduled_event_id, pt.user_id ORDER BY pt.created_at DESC)
ELSE NULL
END as rn_user
FROM
events e
JOIN
polls p ON e.id = p.event_id
JOIN
participants pt ON p.id = pt.poll_id
LEFT JOIN
votes v ON pt.id = v.participant_id AND e.option_id = v.option_id
LEFT JOIN
users u ON pt.user_id = u.id
WHERE
e.option_id IS NOT NULL
AND p.scheduled_event_id IS NOT NULL
AND pt.email IS NOT NULL
AND pt.email != ''
AND pt.deleted = false
)
INSERT INTO scheduled_event_invites (
id,
scheduled_event_id,
invitee_name,
invitee_email,
invitee_id,
invitee_time_zone,
status,
created_at,
updated_at
)
SELECT
pi.participant_id as id, -- Reuse participant's unique CUID as invite ID
pi.scheduled_event_id,
pi.invitee_name,
pi.invitee_email,
pi.invitee_id,
pi.invitee_time_zone,
CASE pi.vote_type
WHEN 'yes' THEN 'accepted'::scheduled_event_invite_status
WHEN 'ifNeedBe' THEN 'tentative'::scheduled_event_invite_status
WHEN 'no' THEN 'declined'::scheduled_event_invite_status
ELSE 'pending'::scheduled_event_invite_status
END as status,
NOW() as created_at,
NOW() as updated_at
FROM
PotentialInvites pi
WHERE
pi.rn_email = 1 -- Only take the first row for each email/event combo
AND (pi.invitee_id IS NULL OR pi.rn_user = 1) -- Only take the first row for each user_id/event combo (if user_id exists)
-- Check for existing invite by email for the same scheduled event
AND NOT EXISTS (
SELECT 1
FROM scheduled_event_invites sei
WHERE sei.scheduled_event_id = pi.scheduled_event_id
AND sei.invitee_email = pi.invitee_email
)
-- Check for existing invite by user ID for the same scheduled event (only if participant has a user ID)
AND (pi.invitee_id IS NULL OR NOT EXISTS (
SELECT 1
FROM scheduled_event_invites sei
WHERE sei.scheduled_event_id = pi.scheduled_event_id
AND sei.invitee_id = pi.invitee_id
));

View file

@ -63,6 +63,8 @@ model User {
paymentMethods PaymentMethod[]
subscription Subscription? @relation("UserToSubscription")
pollViews PollView[]
scheduledEvents ScheduledEvent[]
scheduledEventInvites ScheduledEventInvite[]
@@map("users")
}
@ -155,6 +157,7 @@ model Poll {
participantUrlId String @unique @map("participant_url_id")
adminUrlId String @unique @map("admin_url_id")
eventId String? @unique @map("event_id")
scheduledEventId String? @map("scheduled_event_id")
hideParticipants Boolean @default(false) @map("hide_participants")
hideScores Boolean @default(false) @map("hide_scores")
disableComments Boolean @default(false) @map("disable_comments")
@ -162,6 +165,7 @@ model Poll {
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
scheduledEvent ScheduledEvent? @relation(fields: [scheduledEventId], references: [id], onDelete: SetNull)
options Option[]
participants Participant[]
watchers Watcher[]
@ -310,3 +314,79 @@ model VerificationToken {
@@unique([identifier, token])
@@map("verification_tokens")
}
enum ScheduledEventStatus {
confirmed
canceled
unconfirmed
@@map("scheduled_event_status")
}
enum ScheduledEventInviteStatus {
pending
accepted
declined
tentative
@@map("scheduled_event_invite_status")
}
model ScheduledEvent {
id String @id @default(cuid())
userId String @map("user_id")
title String
description String?
location String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
status ScheduledEventStatus @default(confirmed)
timeZone String? @map("time_zone")
start DateTime
end DateTime
allDay Boolean @default(false) @map("all_day")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rescheduledDates RescheduledEventDate[]
invites ScheduledEventInvite[]
polls Poll[]
@@map("scheduled_events")
}
model RescheduledEventDate {
id String @id @default(cuid())
scheduledEventId String @map("scheduled_event_id")
start DateTime @map("start")
end DateTime @map("end")
allDay Boolean @default(false) @map("all_day")
createdAt DateTime @default(now()) @map("created_at")
scheduledEvent ScheduledEvent @relation(fields: [scheduledEventId], references: [id], onDelete: Cascade)
@@index([scheduledEventId])
@@map("rescheduled_event_dates")
}
model ScheduledEventInvite {
id String @id @default(cuid())
scheduledEventId String @map("scheduled_event_id")
inviteeName String @map("invitee_name")
inviteeEmail String @map("invitee_email")
inviteeId String? @map("invitee_id")
inviteeTimeZone String? @map("invitee_time_zone")
status ScheduledEventInviteStatus @default(pending)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
scheduledEvent ScheduledEvent @relation(fields: [scheduledEventId], references: [id], onDelete: Cascade)
user User? @relation(fields: [inviteeId], references: [id], onDelete: SetNull) // Optional relation to User model
@@unique([scheduledEventId, inviteeEmail])
@@unique([scheduledEventId, inviteeId])
@@index([scheduledEventId])
@@index([inviteeId])
@@index([inviteeEmail])
@@map("scheduled_event_invites")
}

View file

@ -1,156 +1,17 @@
import { faker } from "@faker-js/faker";
import { PrismaClient, VoteType } from "@prisma/client";
import dayjs from "dayjs";
import { PrismaClient } from "@prisma/client";
import { seedPolls } from "./seed/polls";
import { seedScheduledEvents } from "./seed/scheduled-events";
import { seedUsers } from "./seed/users";
const prisma = new PrismaClient();
const randInt = (max = 1, floor = 0) => {
return Math.round(Math.random() * max) + floor;
};
function generateTitle() {
const titleTemplates = [
() => `${faker.company.catchPhrase()} Meeting`,
() => `${faker.commerce.department()} Team Sync`,
() => `Q${faker.datatype.number({ min: 1, max: 4 })} Planning`,
() => `${faker.name.jobArea()} Workshop`,
() => `Project ${faker.word.adjective()} Update`,
() => `${faker.company.bsBuzz()} Strategy Session`,
() => faker.company.catchPhrase(),
() => `${faker.name.jobType()} Interview`,
() => `${faker.commerce.productAdjective()} Product Review`,
() => `Team ${faker.word.verb()} Day`,
];
return faker.helpers.arrayElement(titleTemplates)();
}
async function createPollForUser(userId: string) {
const duration = 60 * randInt(8);
let cursor = dayjs().add(randInt(30), "day").second(0).minute(0);
const numberOfOptions = randInt(5, 2); // Reduced for realism
const poll = await prisma.poll.create({
include: {
participants: true,
options: true,
},
data: {
id: faker.random.alpha(10),
title: generateTitle(),
description: generateDescription(),
location: faker.address.streetAddress(),
deadline: faker.date.future(),
user: {
connect: {
id: userId,
},
},
status: faker.helpers.arrayElement(["live", "paused", "finalized"]),
timeZone: duration !== 0 ? "Europe/London" : undefined,
options: {
create: Array.from({ length: numberOfOptions }).map(() => {
const startTime = cursor.toDate();
cursor = cursor.add(randInt(72, 1), "hour");
return {
startTime,
duration,
};
}),
},
participants: {
create: Array.from({ length: Math.round(Math.random() * 10) }).map(
() => ({
name: faker.name.fullName(),
email: faker.internet.email(),
}),
),
},
adminUrlId: faker.random.alpha(10),
participantUrlId: faker.random.alpha(10),
},
});
// Generate vote data for all participants and options
const voteData = poll.participants.flatMap((participant) =>
poll.options.map((option) => ({
id: faker.random.alpha(10),
optionId: option.id,
participantId: participant.id,
pollId: poll.id,
type: faker.helpers.arrayElement(["yes", "no", "ifNeedBe"]) as VoteType,
})),
);
// Create all votes in a single query
await prisma.vote.createMany({
data: voteData,
});
return poll;
}
// Function to generate realistic descriptions
function generateDescription() {
const descriptions = [
"Discuss the quarterly results and strategize for the upcoming quarter. Please come prepared with your reports.",
"Team meeting to align on project goals and timelines. Bring your ideas and feedback.",
"An informal catch-up to discuss ongoing projects and any roadblocks. Open to all team members.",
"Monthly review of our marketing strategies and performance metrics. Let's brainstorm new ideas.",
"A brief meeting to go over the new software updates and how they will impact our workflow.",
"Discussion on the upcoming product launch and marketing strategies. Your input is valuable!",
"Weekly sync to check in on project progress and address any concerns. Please be on time.",
"A brainstorming session for the new campaign. All creative minds are welcome!",
"Review of the last sprint and planning for the next one. Let's ensure we're on track.",
"An open forum for team members to share updates and challenges. Everyone is encouraged to speak up.",
];
// Randomly select a description
return faker.helpers.arrayElement(descriptions);
}
async function main() {
// Create some users
// Create some users and polls
const freeUser = await prisma.user.create({
data: {
id: "free-user",
name: "Dev User",
email: "dev@rallly.co",
timeZone: "America/New_York",
},
});
const users = await seedUsers();
const proUser = await prisma.user.create({
data: {
id: "pro-user",
name: "Pro User",
email: "dev+pro@rallly.co",
subscription: {
create: {
id: "sub_123",
currency: "usd",
amount: 700,
interval: "month",
status: "active",
active: true,
priceId: "price_123",
periodStart: new Date(),
periodEnd: dayjs().add(1, "month").toDate(),
},
},
},
});
await Promise.all(
[freeUser, proUser].map(async (user) => {
Array.from({ length: 20 }).forEach(async () => {
await createPollForUser(user.id);
});
console.info(`✓ Added ${user.email}`);
}),
);
console.info(`✓ Added polls for ${freeUser.email}`);
for (const user of users) {
await seedPolls(user.id);
await seedScheduledEvents(user.id);
}
}
main()

View file

@ -0,0 +1,119 @@
import { faker } from "@faker-js/faker";
import type { User } from "@prisma/client";
import { VoteType } from "@prisma/client";
import dayjs from "dayjs";
import { prisma } from "@rallly/database";
import { randInt } from "./utils";
function generateTitle() {
const titleTemplates = [
() => `${faker.company.catchPhrase()} Meeting`,
() => `${faker.commerce.department()} Team Sync`,
() => `Q${faker.datatype.number({ min: 1, max: 4 })} Planning`,
() => `${faker.name.jobArea()} Workshop`,
() => `Project ${faker.word.adjective()} Update`,
() => `${faker.company.bsBuzz()} Strategy Session`,
() => faker.company.catchPhrase(),
() => `${faker.name.jobType()} Interview`,
() => `${faker.commerce.productAdjective()} Product Review`,
() => `Team ${faker.word.verb()} Day`,
];
return faker.helpers.arrayElement(titleTemplates)();
}
// Function to generate realistic descriptions
function generateDescription() {
const descriptions = [
"Discuss the quarterly results and strategize for the upcoming quarter. Please come prepared with your reports.",
"Team meeting to align on project goals and timelines. Bring your ideas and feedback.",
"An informal catch-up to discuss ongoing projects and any roadblocks. Open to all team members.",
"Monthly review of our marketing strategies and performance metrics. Let's brainstorm new ideas.",
"A brief meeting to go over the new software updates and how they will impact our workflow.",
"Discussion on the upcoming product launch and marketing strategies. Your input is valuable!",
"Weekly sync to check in on project progress and address any concerns. Please be on time.",
"A brainstorming session for the new campaign. All creative minds are welcome!",
"Review of the last sprint and planning for the next one. Let's ensure we're on track.",
"An open forum for team members to share updates and challenges. Everyone is encouraged to speak up.",
];
// Randomly select a description
return faker.helpers.arrayElement(descriptions);
}
async function createPollForUser(userId: string) {
const duration = 60 * randInt(8);
let cursor = dayjs().add(randInt(30), "day").second(0).minute(0);
const numberOfOptions = randInt(5, 2); // Reduced for realism
const poll = await prisma.poll.create({
include: {
participants: true,
options: true,
},
data: {
id: faker.random.alpha(10),
title: generateTitle(),
description: generateDescription(),
location: faker.address.streetAddress(),
deadline: faker.date.future(),
user: {
connect: {
id: userId,
},
},
status: faker.helpers.arrayElement(["live", "paused", "finalized"]),
timeZone: duration !== 0 ? "Europe/London" : undefined,
options: {
create: Array.from({ length: numberOfOptions }).map(() => {
const startTime = cursor.toDate();
cursor = cursor.add(randInt(72, 1), "hour");
return {
startTime,
duration,
};
}),
},
participants: {
create: Array.from({ length: randInt(10) }).map(() => ({
name: faker.name.fullName(),
email: faker.internet.email(),
})),
},
adminUrlId: faker.random.alpha(10),
participantUrlId: faker.random.alpha(10),
},
});
// Generate vote data for all participants and options
const voteData = poll.participants.flatMap((participant) =>
poll.options.map((option) => ({
id: faker.random.alpha(10),
optionId: option.id,
participantId: participant.id,
pollId: poll.id,
type: faker.helpers.arrayElement(["yes", "no", "ifNeedBe"]) as VoteType,
})),
);
// Create all votes in a single query
if (voteData.length > 0) {
await prisma.vote.createMany({
data: voteData,
});
}
return poll;
}
export async function seedPolls(userId: string) {
console.info("Seeding polls...");
const pollPromises = Array.from({ length: 20 }).map(() =>
createPollForUser(userId),
);
await Promise.all(pollPromises);
console.info(`✓ Seeded polls for ${userId}`);
}

View file

@ -0,0 +1,144 @@
import { ScheduledEventInviteStatus } from "@prisma/client";
import { Prisma, ScheduledEventStatus } from "@prisma/client"; // Ensure Prisma is imported
import dayjs from "dayjs";
import { faker } from "@faker-js/faker";
import { prisma } from "@rallly/database";
import { randInt } from "./utils";
// Realistic event titles and descriptions
function generateEventDetails() {
const titles = [
"Team Sync Meeting",
"Product Strategy Session",
"Design Review",
"Engineering Standup",
"Client Check-in Call",
"Marketing Campaign Kickoff",
"Sales Pipeline Review",
"HR Training Workshop",
"Finance Budget Planning",
"All Hands Company Update",
"Sprint Retrospective",
"User Research Debrief",
"Technical Deep Dive",
"Content Calendar Planning",
"Partnership Discussion",
];
const descriptions = [
"Discussing project updates and blockers.",
"Aligning on the product roadmap for the next quarter.",
"Gathering feedback on the latest UI mockups.",
"Quick daily updates from the engineering team.",
"Reviewing progress and addressing client concerns.",
"Launching the new social media campaign.",
"Analyzing the current sales funnel and opportunities.",
"Mandatory compliance training session.",
"Meeting to finalize budget decisions across different departments.",
"Sharing company performance and upcoming goals.",
"Reflecting on the past sprint, celebrating successes, and identifying areas for improvement.",
"Sharing key insights gathered from recent user research sessions.",
"Exploring the architecture of the new microservice.",
"Planning blog posts and social media content for the month.",
"Exploring potential collaboration opportunities with external partners.",
"Team building activity to foster collaboration.",
"Workshop on improving presentation skills.",
"Onboarding session for new hires.",
"Reviewing customer feedback and planning improvements.",
"Brainstorming session for new feature ideas.",
];
return {
title: faker.helpers.arrayElement(titles),
description: faker.helpers.arrayElement(descriptions),
};
}
async function createScheduledEventForUser(userId: string) {
const { title, description } = generateEventDetails();
const isAllDay = Math.random() < 0.3; // ~30% chance of being all-day
let startTime: Date;
let endTime: Date | null;
if (isAllDay) {
const startDate = dayjs(
faker.datatype.boolean() ? faker.date.past(1) : faker.date.soon(30),
)
.startOf("day")
.toDate();
startTime = startDate;
// Decide if it's a multi-day event
const isMultiDay = Math.random() < 0.2; // ~20% chance of multi-day
if (isMultiDay) {
const durationDays = faker.datatype.number({ min: 1, max: 3 });
// End date is the start of the day *after* the last full day
endTime = dayjs(startDate)
.add(durationDays + 1, "day")
.toDate();
} else {
// Single all-day event ends at the start of the next day
endTime = dayjs(startDate).add(1, "day").toDate();
}
} else {
// Generate times for non-all-day events
startTime = dayjs(
faker.datatype.boolean() ? faker.date.past(1) : faker.date.soon(30),
)
.second(faker.helpers.arrayElement([0, 15, 30, 45])) // Add some variance
.minute(faker.helpers.arrayElement([0, 15, 30, 45]))
.hour(faker.datatype.number({ min: 8, max: 20 })) // Wider range for hours
.toDate();
const durationMinutes = faker.helpers.arrayElement([30, 60, 90, 120, 180]); // Longer durations possible
endTime = dayjs(startTime).add(durationMinutes, "minute").toDate();
}
// Use only valid statuses from the schema
const status = faker.helpers.arrayElement<ScheduledEventStatus>([
ScheduledEventStatus.confirmed,
ScheduledEventStatus.canceled,
]);
const timeZone = faker.address.timeZone();
const data: Prisma.ScheduledEventCreateInput = {
title,
description,
start: startTime, // Use correct model field name 'start'
end: endTime, // Use correct model field name 'end'
timeZone,
status, // Assign the randomly selected valid status
user: { connect: { id: userId } }, // Connect to existing user
allDay: isAllDay,
location: faker.datatype.boolean()
? faker.address.streetAddress()
: undefined,
// Add invites (optional, example below)
invites: {
create: Array.from({ length: randInt(5, 0) }).map(() => ({
inviteeEmail: faker.internet.email(),
inviteeName: faker.name.fullName(),
inviteeTimeZone: faker.address.timeZone(),
status: faker.helpers.arrayElement<ScheduledEventInviteStatus>([
"accepted",
"declined",
"tentative",
"pending",
]),
})),
},
};
await prisma.scheduledEvent.create({ data });
}
export async function seedScheduledEvents(userId: string) {
console.info("Seeding scheduled events...");
const eventPromises = Array.from({ length: 15 }).map((_, i) =>
createScheduledEventForUser(userId),
);
await Promise.all(eventPromises);
console.info(`✓ Seeded scheduled events for ${userId}`);
}

View file

@ -0,0 +1,43 @@
import dayjs from "dayjs";
import { prisma } from "@rallly/database";
export async function seedUsers() {
console.info("Seeding users...");
const freeUser = await prisma.user.upsert({
where: { email: "dev@rallly.co" },
update: {},
create: {
id: "free-user",
name: "Dev User",
email: "dev@rallly.co",
timeZone: "America/New_York",
},
});
const proUser = await prisma.user.upsert({
where: { email: "dev+pro@rallly.co" },
update: {},
create: {
id: "pro-user",
name: "Pro User",
email: "dev+pro@rallly.co",
subscription: {
create: {
id: "sub_123",
currency: "usd",
amount: 700,
interval: "month",
status: "active",
active: true,
priceId: "price_123",
periodStart: new Date(),
periodEnd: dayjs().add(1, "month").toDate(),
},
},
},
});
console.info(`✓ Seeded user ${freeUser.email}`);
console.info(`✓ Seeded user ${proUser.email}`);
return [freeUser, proUser];
}

View file

@ -0,0 +1,9 @@
/**
* Generates a random integer between floor and max (inclusive).
* @param max The maximum value.
* @param floor The minimum value (default: 0).
* @returns A random integer.
*/
export const randInt = (max = 1, floor = 0): number => {
return Math.round(Math.random() * max) + floor;
};