diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 9dd127eb2..67f1900f9 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -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." } diff --git a/apps/web/src/app/[locale]/(space)/events/event-list.tsx b/apps/web/src/app/[locale]/(space)/events/event-list.tsx deleted file mode 100644 index c22c6496a..000000000 --- a/apps/web/src/app/[locale]/(space)/events/event-list.tsx +++ /dev/null @@ -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 ( -
- -
- ); -} diff --git a/apps/web/src/app/[locale]/(space)/events/events-tabbed-view.tsx b/apps/web/src/app/[locale]/(space)/events/events-tabbed-view.tsx new file mode 100644 index 000000000..65a7cc4e4 --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/events/events-tabbed-view.tsx @@ -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 ( + + + + + + + + + + + {children} + + + ); +} diff --git a/apps/web/src/app/[locale]/(space)/events/page.tsx b/apps/web/src/app/[locale]/(space)/events/page.tsx index de14d0388..4b09e791f 100644 --- a/apps/web/src/app/[locale]/(space)/events/page.tsx +++ b/apps/web/src/app/[locale]/(space)/events/page.tsx @@ -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 ( + + + + + {title} + {description} + + ); +} + +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 ( @@ -29,7 +119,39 @@ export default async function Page({ params }: { params: Params }) { - + +
+ +
+ {scheduledEvents.length === 0 && ( + + )} + {scheduledEvents.length > 0 && ( + + {scheduledEvents.map((event) => ( + + + + ))} + + )} +
+
+
); diff --git a/apps/web/src/app/[locale]/(space)/events/past-events.tsx b/apps/web/src/app/[locale]/(space)/events/past-events.tsx deleted file mode 100644 index b4d67cb0d..000000000 --- a/apps/web/src/app/[locale]/(space)/events/past-events.tsx +++ /dev/null @@ -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 ; - } - - if (data.length === 0) { - return ( - - - - - - - - - - - - ); - } - - return ; -} diff --git a/apps/web/src/app/[locale]/(space)/events/upcoming-events.tsx b/apps/web/src/app/[locale]/(space)/events/upcoming-events.tsx deleted file mode 100644 index 39be20d21..000000000 --- a/apps/web/src/app/[locale]/(space)/events/upcoming-events.tsx +++ /dev/null @@ -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 ; - } - - if (data.length === 0) { - return ( - - - - - - - - - - - - ); - } - - return ; -} diff --git a/apps/web/src/app/[locale]/(space)/events/user-scheduled-events.tsx b/apps/web/src/app/[locale]/(space)/events/user-scheduled-events.tsx deleted file mode 100644 index 04b6bb897..000000000 --- a/apps/web/src/app/[locale]/(space)/events/user-scheduled-events.tsx +++ /dev/null @@ -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 ( -
- { - const params = new URLSearchParams(searchParams); - params.set("period", value); - const newUrl = `?${params.toString()}`; - router.replace(newUrl); - }} - aria-label="Event period" - > - - - - - - - - - -
- {period === "upcoming" && } - {period === "past" && } -
-
- ); -} diff --git a/apps/web/src/app/[locale]/(space)/layout.tsx b/apps/web/src/app/[locale]/(space)/layout.tsx index 1d18cff48..ac55bb1e6 100644 --- a/apps/web/src/app/[locale]/(space)/layout.tsx +++ b/apps/web/src/app/[locale]/(space)/layout.tsx @@ -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,36 +19,38 @@ export default async function Layout({ }) { const user = await getUser(); return ( - - - - - - - - - - - - -
-
{children}
-
- -
-
+ + + + + + + + + + + + + +
+
{children}
+
+ +
+
+
); } diff --git a/apps/web/src/app/[locale]/(space)/settings/billing/page.tsx b/apps/web/src/app/[locale]/(space)/settings/billing/page.tsx index 5fa936089..a4941e133 100644 --- a/apps/web/src/app/[locale]/(space)/settings/billing/page.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/billing/page.tsx @@ -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 ? ( "-" ) : ( - + )} diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx index e61aa7981..29cefcb15 100644 --- a/apps/web/src/app/[locale]/layout.tsx +++ b/apps/web/src/app/[locale]/layout.tsx @@ -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"; diff --git a/apps/web/src/components/formatted-date.tsx b/apps/web/src/components/formatted-date.tsx deleted file mode 100644 index 6bde0531f..000000000 --- a/apps/web/src/components/formatted-date.tsx +++ /dev/null @@ -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)}; -} diff --git a/apps/web/src/components/poll/scheduled-event-display.tsx b/apps/web/src/components/poll/scheduled-event-display.tsx deleted file mode 100644 index 2aad075dc..000000000 --- a/apps/web/src/components/poll/scheduled-event-display.tsx +++ /dev/null @@ -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 ( -
- - {event.duration > 0 ? ( - - ) : ( - - )} - -
- ); - } - - if (!dateOptions?.first || !dateOptions?.last || dateOptions.count === 0) { - return ( - - - - ); - } - - return ( -
- - - -
- ); -}; diff --git a/apps/web/src/features/scheduled-event/api/get-scheduled-events.ts b/apps/web/src/features/scheduled-event/api/get-scheduled-events.ts new file mode 100644 index 000000000..1c77cf2a6 --- /dev/null +++ b/apps/web/src/features/scheduled-event/api/get-scheduled-events.ts @@ -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; +} diff --git a/apps/web/src/features/scheduled-event/components/scheduled-event-list.tsx b/apps/web/src/features/scheduled-event/components/scheduled-event-list.tsx new file mode 100644 index 000000000..6e13cc48e --- /dev/null +++ b/apps/web/src/features/scheduled-event/components/scheduled-event-list.tsx @@ -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 ( +
+
+
+
{title}
+
+ +
+
+
+
+
+ +
+
+ {allDay ? ( + + ) : ( +
+ + - + +
+ )} +
+
+
+
+
+
+ ({ + id: invite.id, + name: invite.inviteeName, + image: invite.inviteeImage ?? undefined, + }))} + max={5} + /> +
+
+
+ ); +} diff --git a/apps/web/src/features/scheduled-event/components/scheduled-event-status-badge.tsx b/apps/web/src/features/scheduled-event/components/scheduled-event-status-badge.tsx new file mode 100644 index 000000000..7c70a4313 --- /dev/null +++ b/apps/web/src/features/scheduled-event/components/scheduled-event-status-badge.tsx @@ -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 ( + + + + ); + case "upcoming": + return null; + case "canceled": + return ( + + + + ); + case "unconfirmed": + return ( + + + + ); + } +} diff --git a/apps/web/src/features/scheduled-event/schema.ts b/apps/web/src/features/scheduled-event/schema.ts new file mode 100644 index 000000000..d6b350264 --- /dev/null +++ b/apps/web/src/features/scheduled-event/schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const statusSchema = z.enum([ + "upcoming", + "unconfirmed", + "past", + "canceled", +]); + +export type Status = z.infer; diff --git a/apps/web/src/features/timezone/client/calendar-date.tsx b/apps/web/src/features/timezone/client/calendar-date.tsx new file mode 100644 index 000000000..dc024539a --- /dev/null +++ b/apps/web/src/features/timezone/client/calendar-date.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/features/timezone/client/context.tsx b/apps/web/src/features/timezone/client/context.tsx new file mode 100644 index 000000000..47a3fa9db --- /dev/null +++ b/apps/web/src/features/timezone/client/context.tsx @@ -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(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 ( + + {children} + + ); +}; + +export const useTimezone = () => { + const context = React.useContext(TimezoneContext); + if (context === null) { + throw new Error("useTimezone must be used within a TimezoneProvider"); + } + return context; +}; diff --git a/apps/web/src/features/timezone/client/formatted-date-time.tsx b/apps/web/src/features/timezone/client/formatted-date-time.tsx new file mode 100644 index 000000000..0fcabdd99 --- /dev/null +++ b/apps/web/src/features/timezone/client/formatted-date-time.tsx @@ -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 { + /** 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 ( + + ); +}); + +FormattedDateTime.displayName = "FormattedDateTime"; diff --git a/apps/web/src/features/timezone/formatted-date-time-server.tsx b/apps/web/src/features/timezone/formatted-date-time-server.tsx new file mode 100644 index 000000000..32c83e323 --- /dev/null +++ b/apps/web/src/features/timezone/formatted-date-time-server.tsx @@ -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, "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 ( + + ); +}; diff --git a/apps/web/src/features/timezone/hooks/use-formatted-date-time.ts b/apps/web/src/features/timezone/hooks/use-formatted-date-time.ts new file mode 100644 index 000000000..3d6041c54 --- /dev/null +++ b/apps/web/src/features/timezone/hooks/use-formatted-date-time.ts @@ -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"); +}; diff --git a/apps/web/src/features/timezone/index.ts b/apps/web/src/features/timezone/index.ts index 84aa6a7ed..512fc980c 100644 --- a/apps/web/src/features/timezone/index.ts +++ b/apps/web/src/features/timezone/index.ts @@ -1,3 +1,2 @@ -export * from "./timezone-context"; -export * from "./timezone-display"; -export * from "./timezone-utils"; +export * from "./client/context"; +export * from "./utils"; diff --git a/apps/web/src/features/timezone/timezone-context.tsx b/apps/web/src/features/timezone/timezone-context.tsx deleted file mode 100644 index a118e8375..000000000 --- a/apps/web/src/features/timezone/timezone-context.tsx +++ /dev/null @@ -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( - 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(() => { - 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 ( - - {children} - - ); -}; - -export const useTimezone = () => { - const context = useContext(TimezoneContext); - if (context === undefined) { - throw new Error("useTimezone must be used within a TimezoneProvider"); - } - return context; -}; diff --git a/apps/web/src/features/timezone/timezone-display.tsx b/apps/web/src/features/timezone/timezone-display.tsx deleted file mode 100644 index d7d35b892..000000000 --- a/apps/web/src/features/timezone/timezone-display.tsx +++ /dev/null @@ -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 {formatDate(date, format)}; -} - -export function TimeDisplay({ date, format = "HH:mm" }: DateDisplayProps) { - const { formatTime } = useTimezone(); - return {formatTime(date, format)}; -} - -export function DateTimeDisplay({ date, format = "LL, LT" }: DateDisplayProps) { - const { formatDateTime } = useTimezone(); - return {formatDateTime(date, format)}; -} - -// Component to display the current timezone -export function CurrentTimezone() { - const { timezone } = useTimezone(); - return {timezone}; -} diff --git a/apps/web/src/features/timezone/timezone-utils.ts b/apps/web/src/features/timezone/utils.ts similarity index 100% rename from apps/web/src/features/timezone/timezone-utils.ts rename to apps/web/src/features/timezone/utils.ts diff --git a/apps/web/src/trpc/routers/polls.ts b/apps/web/src/trpc/routers/polls.ts index 1ad9a6430..f83af8fed 100644 --- a/apps/web/src/trpc/routers/polls.ts +++ b/apps/web/src/trpc/routers/polls.ts @@ -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( diff --git a/packages/database/prisma/migrations/20250415153024_scheduled_events/migration.sql b/packages/database/prisma/migrations/20250415153024_scheduled_events/migration.sql new file mode 100644 index 000000000..06050300d --- /dev/null +++ b/packages/database/prisma/migrations/20250415153024_scheduled_events/migration.sql @@ -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; diff --git a/packages/database/prisma/migrations/20250415155219_migrate_events/migration.sql b/packages/database/prisma/migrations/20250415155219_migrate_events/migration.sql new file mode 100644 index 000000000..1e83493d7 --- /dev/null +++ b/packages/database/prisma/migrations/20250415155219_migrate_events/migration.sql @@ -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; \ No newline at end of file diff --git a/packages/database/prisma/migrations/20250421170921_migrate_invites/migration.sql b/packages/database/prisma/migrations/20250421170921_migrate_invites/migration.sql new file mode 100644 index 000000000..27341e338 --- /dev/null +++ b/packages/database/prisma/migrations/20250421170921_migrate_invites/migration.sql @@ -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 + )); \ No newline at end of file diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 1ba5e2e93..a04fd812f 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -5,8 +5,8 @@ datasource db { } generator client { - provider = "prisma-client-js" - binaryTargets = ["native"] + provider = "prisma-client-js" + binaryTargets = ["native"] previewFeatures = ["relationJoins"] } @@ -38,31 +38,33 @@ model Account { } model User { - id String @id @default(cuid()) - name String - email String @unique() @db.Citext - emailVerified DateTime? @map("email_verified") - image String? - timeZone String? @map("time_zone") - weekStart Int? @map("week_start") - timeFormat TimeFormat? @map("time_format") - locale String? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime? @updatedAt @map("updated_at") - customerId String? @map("customer_id") - banned Boolean @default(false) - bannedAt DateTime? @map("banned_at") - banReason String? @map("ban_reason") + id String @id @default(cuid()) + name String + email String @unique() @db.Citext + emailVerified DateTime? @map("email_verified") + image String? + timeZone String? @map("time_zone") + weekStart Int? @map("week_start") + timeFormat TimeFormat? @map("time_format") + locale String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @updatedAt @map("updated_at") + customerId String? @map("customer_id") + banned Boolean @default(false) + bannedAt DateTime? @map("banned_at") + banReason String? @map("ban_reason") - comments Comment[] - polls Poll[] - watcher Watcher[] - events Event[] - accounts Account[] - participants Participant[] - paymentMethods PaymentMethod[] - subscription Subscription? @relation("UserToSubscription") - pollViews PollView[] + comments Comment[] + polls Poll[] + watcher Watcher[] + events Event[] + accounts Account[] + participants Participant[] + paymentMethods PaymentMethod[] + subscription Subscription? @relation("UserToSubscription") + pollViews PollView[] + scheduledEvents ScheduledEvent[] + scheduledEventInvites ScheduledEventInvite[] @@map("users") } @@ -155,19 +157,21 @@ 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") requireParticipantEmail Boolean @default(false) @map("require_participant_email") - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull) - options Option[] - participants Participant[] - watchers Watcher[] - comments Comment[] - votes Vote[] - views PollView[] + 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[] + comments Comment[] + votes Vote[] + views PollView[] @@index([guestId]) @@map("polls") @@ -293,7 +297,7 @@ model PollView { userAgent String? @map("user_agent") viewedAt DateTime @default(now()) @map("viewed_at") - poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade) + poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@index([pollId], type: Hash) @@ -309,4 +313,80 @@ model VerificationToken { @@unique([identifier, token]) @@map("verification_tokens") -} \ No newline at end of file +} + +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") +} diff --git a/packages/database/prisma/seed.ts b/packages/database/prisma/seed.ts index ffc17435f..e4df37174 100644 --- a/packages/database/prisma/seed.ts +++ b/packages/database/prisma/seed.ts @@ -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() diff --git a/packages/database/prisma/seed/polls.ts b/packages/database/prisma/seed/polls.ts new file mode 100644 index 000000000..b16eb9eb6 --- /dev/null +++ b/packages/database/prisma/seed/polls.ts @@ -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}`); +} diff --git a/packages/database/prisma/seed/scheduled-events.ts b/packages/database/prisma/seed/scheduled-events.ts new file mode 100644 index 000000000..a9cea522d --- /dev/null +++ b/packages/database/prisma/seed/scheduled-events.ts @@ -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.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([ + "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}`); +} diff --git a/packages/database/prisma/seed/users.ts b/packages/database/prisma/seed/users.ts new file mode 100644 index 000000000..184716f35 --- /dev/null +++ b/packages/database/prisma/seed/users.ts @@ -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]; +} diff --git a/packages/database/prisma/seed/utils.ts b/packages/database/prisma/seed/utils.ts new file mode 100644 index 000000000..f52845038 --- /dev/null +++ b/packages/database/prisma/seed/utils.ts @@ -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; +};