mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-01 11:16:32 +02:00
✨ Add scheduled events schema (#1679)
This commit is contained in:
parent
22f32f9314
commit
56bd684c55
35 changed files with 1412 additions and 659 deletions
|
@ -200,10 +200,6 @@
|
||||||
"upcoming": "Upcoming",
|
"upcoming": "Upcoming",
|
||||||
"past": "Past",
|
"past": "Past",
|
||||||
"copyLink": "Copy Link",
|
"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",
|
"createPoll": "Create poll",
|
||||||
"addToCalendar": "Add to Calendar",
|
"addToCalendar": "Add to Calendar",
|
||||||
"microsoft365": "Microsoft 365",
|
"microsoft365": "Microsoft 365",
|
||||||
|
@ -301,7 +297,6 @@
|
||||||
"signUp": "Sign Up",
|
"signUp": "Sign Up",
|
||||||
"upgradeToPro": "Upgrade to Pro",
|
"upgradeToPro": "Upgrade to Pro",
|
||||||
"moreParticipants": "{count} more…",
|
"moreParticipants": "{count} more…",
|
||||||
"noDates": "No dates",
|
|
||||||
"commandMenuNoResults": "No results",
|
"commandMenuNoResults": "No results",
|
||||||
"commandMenu": "Command Menu",
|
"commandMenu": "Command Menu",
|
||||||
"commandMenuDescription": "Select a command",
|
"commandMenuDescription": "Select a command",
|
||||||
|
@ -319,5 +314,19 @@
|
||||||
"searchPollsPlaceholder": "Search polls by title...",
|
"searchPollsPlaceholder": "Search polls by title...",
|
||||||
"poll": "Poll",
|
"poll": "Poll",
|
||||||
"sendFeedbackDesc": "Share your feedback with us.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { CalendarIcon } from "lucide-react";
|
||||||
|
|
||||||
import type { Params } from "@/app/[locale]/types";
|
import type { Params } from "@/app/[locale]/types";
|
||||||
import { EventPageIcon } from "@/app/components/page-icons";
|
import { EventPageIcon } from "@/app/components/page-icons";
|
||||||
import {
|
import {
|
||||||
|
@ -7,13 +9,101 @@ import {
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
} from "@/app/components/page-layout";
|
} 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 { 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 { 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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
|
@ -29,7 +119,39 @@ export default async function Page({ params }: { params: Params }) {
|
||||||
</PageDescription>
|
</PageDescription>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<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>
|
</PageContent>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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} />;
|
|
||||||
}
|
|
|
@ -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} />;
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -6,6 +6,7 @@ import Link from "next/link";
|
||||||
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
||||||
import { getUser } from "@/data/get-user";
|
import { getUser } from "@/data/get-user";
|
||||||
import { CommandMenu } from "@/features/navigation/command-menu";
|
import { CommandMenu } from "@/features/navigation/command-menu";
|
||||||
|
import { TimezoneProvider } from "@/features/timezone/client/context";
|
||||||
|
|
||||||
import { AppSidebar } from "./components/sidebar/app-sidebar";
|
import { AppSidebar } from "./components/sidebar/app-sidebar";
|
||||||
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider";
|
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider";
|
||||||
|
@ -18,6 +19,7 @@ export default async function Layout({
|
||||||
}) {
|
}) {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
return (
|
return (
|
||||||
|
<TimezoneProvider initialTimezone={user.timeZone}>
|
||||||
<AppSidebarProvider>
|
<AppSidebarProvider>
|
||||||
<CommandMenu />
|
<CommandMenu />
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
@ -49,5 +51,6 @@ export default async function Layout({
|
||||||
<ActionBar />
|
<ActionBar />
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</AppSidebarProvider>
|
</AppSidebarProvider>
|
||||||
|
</TimezoneProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,9 +27,9 @@ import {
|
||||||
EmptyStateIcon,
|
EmptyStateIcon,
|
||||||
EmptyStateTitle,
|
EmptyStateTitle,
|
||||||
} from "@/components/empty-state";
|
} from "@/components/empty-state";
|
||||||
import { FormattedDate } from "@/components/formatted-date";
|
|
||||||
import { PayWallDialog } from "@/components/pay-wall-dialog";
|
import { PayWallDialog } from "@/components/pay-wall-dialog";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
import { FormattedDateTime } from "@/features/timezone/client/formatted-date-time";
|
||||||
import { requireUser } from "@/next-auth";
|
import { requireUser } from "@/next-auth";
|
||||||
import { isSelfHosted } from "@/utils/constants";
|
import { isSelfHosted } from "@/utils/constants";
|
||||||
|
|
||||||
|
@ -153,7 +153,10 @@ export default async function Page() {
|
||||||
{subscription.cancelAtPeriodEnd ? (
|
{subscription.cancelAtPeriodEnd ? (
|
||||||
"-"
|
"-"
|
||||||
) : (
|
) : (
|
||||||
<FormattedDate date={subscription.periodEnd} format="short" />
|
<FormattedDateTime
|
||||||
|
date={subscription.periodEnd}
|
||||||
|
format="D MMM YYYY"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</DescriptionDetails>
|
</DescriptionDetails>
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import React from "react";
|
||||||
|
|
||||||
import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
|
import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
|
||||||
import { UserProvider } from "@/components/user-provider";
|
import { UserProvider } from "@/components/user-provider";
|
||||||
import { TimezoneProvider } from "@/features/timezone";
|
import { TimezoneProvider } from "@/features/timezone/client/context";
|
||||||
import { I18nProvider } from "@/i18n/client";
|
import { I18nProvider } from "@/i18n/client";
|
||||||
import { auth } from "@/next-auth";
|
import { auth } from "@/next-auth";
|
||||||
import { TRPCProvider } from "@/trpc/client/provider";
|
import { TRPCProvider } from "@/trpc/client/provider";
|
||||||
|
|
|
@ -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)}</>;
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
10
apps/web/src/features/scheduled-event/schema.ts
Normal file
10
apps/web/src/features/scheduled-event/schema.ts
Normal 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>;
|
33
apps/web/src/features/timezone/client/calendar-date.tsx
Normal file
33
apps/web/src/features/timezone/client/calendar-date.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
55
apps/web/src/features/timezone/client/context.tsx
Normal file
55
apps/web/src/features/timezone/client/context.tsx
Normal 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;
|
||||||
|
};
|
|
@ -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";
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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");
|
||||||
|
};
|
|
@ -1,3 +1,2 @@
|
||||||
export * from "./timezone-context";
|
export * from "./client/context";
|
||||||
export * from "./timezone-display";
|
export * from "./utils";
|
||||||
export * from "./timezone-utils";
|
|
||||||
|
|
|
@ -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;
|
|
||||||
};
|
|
|
@ -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>;
|
|
||||||
}
|
|
|
@ -6,6 +6,7 @@ import { nanoid } from "@rallly/utils/nanoid";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import * as ics from "ics";
|
import * as ics from "ics";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { moderateContent } from "@/features/moderation";
|
import { moderateContent } from "@/features/moderation";
|
||||||
|
@ -253,6 +254,8 @@ export const polls = router({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
|
||||||
return { id: poll.id };
|
return { id: poll.id };
|
||||||
}),
|
}),
|
||||||
update: possiblyPublicProcedure
|
update: possiblyPublicProcedure
|
||||||
|
@ -349,6 +352,7 @@ export const polls = router({
|
||||||
requireParticipantEmail: input.requireParticipantEmail,
|
requireParticipantEmail: input.requireParticipantEmail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
revalidatePath("/", "layout");
|
||||||
}),
|
}),
|
||||||
delete: possiblyPublicProcedure
|
delete: possiblyPublicProcedure
|
||||||
.input(
|
.input(
|
||||||
|
@ -362,6 +366,7 @@ export const polls = router({
|
||||||
where: { id: pollId },
|
where: { id: pollId },
|
||||||
data: { deleted: true, deletedAt: new Date() },
|
data: { deleted: true, deletedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
revalidatePath("/", "layout");
|
||||||
}),
|
}),
|
||||||
// END LEGACY ROUTES
|
// END LEGACY ROUTES
|
||||||
getWatchers: publicProcedure
|
getWatchers: publicProcedure
|
||||||
|
@ -519,9 +524,16 @@ export const polls = router({
|
||||||
},
|
},
|
||||||
participants: {
|
participants: {
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
timeZone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
votes: {
|
votes: {
|
||||||
select: {
|
select: {
|
||||||
optionId: true,
|
optionId: true,
|
||||||
|
@ -579,6 +591,41 @@ export const polls = router({
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: "finalized",
|
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: {
|
event: {
|
||||||
create: {
|
create: {
|
||||||
optionId: input.optionId,
|
optionId: input.optionId,
|
||||||
|
@ -748,6 +795,8 @@ export const polls = router({
|
||||||
days_since_created: dayjs().diff(poll.createdAt, "day"),
|
days_since_created: dayjs().diff(poll.createdAt, "day"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
revalidatePath("/", "layout");
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
reopen: possiblyPublicProcedure
|
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
|
pause: possiblyPublicProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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
|
||||||
|
));
|
|
@ -63,6 +63,8 @@ model User {
|
||||||
paymentMethods PaymentMethod[]
|
paymentMethods PaymentMethod[]
|
||||||
subscription Subscription? @relation("UserToSubscription")
|
subscription Subscription? @relation("UserToSubscription")
|
||||||
pollViews PollView[]
|
pollViews PollView[]
|
||||||
|
scheduledEvents ScheduledEvent[]
|
||||||
|
scheduledEventInvites ScheduledEventInvite[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
@ -155,6 +157,7 @@ model Poll {
|
||||||
participantUrlId String @unique @map("participant_url_id")
|
participantUrlId String @unique @map("participant_url_id")
|
||||||
adminUrlId String @unique @map("admin_url_id")
|
adminUrlId String @unique @map("admin_url_id")
|
||||||
eventId String? @unique @map("event_id")
|
eventId String? @unique @map("event_id")
|
||||||
|
scheduledEventId String? @map("scheduled_event_id")
|
||||||
hideParticipants Boolean @default(false) @map("hide_participants")
|
hideParticipants Boolean @default(false) @map("hide_participants")
|
||||||
hideScores Boolean @default(false) @map("hide_scores")
|
hideScores Boolean @default(false) @map("hide_scores")
|
||||||
disableComments Boolean @default(false) @map("disable_comments")
|
disableComments Boolean @default(false) @map("disable_comments")
|
||||||
|
@ -162,6 +165,7 @@ model Poll {
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||||
|
scheduledEvent ScheduledEvent? @relation(fields: [scheduledEventId], references: [id], onDelete: SetNull)
|
||||||
options Option[]
|
options Option[]
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
watchers Watcher[]
|
watchers Watcher[]
|
||||||
|
@ -310,3 +314,79 @@ model VerificationToken {
|
||||||
@@unique([identifier, token])
|
@@unique([identifier, token])
|
||||||
@@map("verification_tokens")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
@ -1,156 +1,17 @@
|
||||||
import { faker } from "@faker-js/faker";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { PrismaClient, VoteType } from "@prisma/client";
|
import { seedPolls } from "./seed/polls";
|
||||||
import dayjs from "dayjs";
|
import { seedScheduledEvents } from "./seed/scheduled-events";
|
||||||
|
import { seedUsers } from "./seed/users";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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() {
|
async function main() {
|
||||||
// Create some users
|
const users = await seedUsers();
|
||||||
// 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 proUser = await prisma.user.create({
|
for (const user of users) {
|
||||||
data: {
|
await seedPolls(user.id);
|
||||||
id: "pro-user",
|
await seedScheduledEvents(user.id);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
119
packages/database/prisma/seed/polls.ts
Normal file
119
packages/database/prisma/seed/polls.ts
Normal 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}`);
|
||||||
|
}
|
144
packages/database/prisma/seed/scheduled-events.ts
Normal file
144
packages/database/prisma/seed/scheduled-events.ts
Normal 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}`);
|
||||||
|
}
|
43
packages/database/prisma/seed/users.ts
Normal file
43
packages/database/prisma/seed/users.ts
Normal 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];
|
||||||
|
}
|
9
packages/database/prisma/seed/utils.ts
Normal file
9
packages/database/prisma/seed/utils.ts
Normal 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;
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue