Add scheduled events schema (#1679)

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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