mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-06 09:59:00 +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
|
@ -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 "./timezone-display";
|
||||
export * from "./timezone-utils";
|
||||
export * from "./client/context";
|
||||
export * from "./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>;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue