diff --git a/package-lock.json b/package-lock.json index 8ae195a8..bc0194fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -190,11 +190,6 @@ "node": ">=6.9.0" } }, - "node_modules/@date-fns/tz": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", - "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==" - }, "node_modules/@emnapi/core": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", @@ -6859,6 +6854,12 @@ "react": ">=16.8.0" } }, + "node_modules/react-day-picker/node_modules/@date-fns/tz": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", + "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==", + "license": "MIT" + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", diff --git a/src/app/(event)/[event-code]/edit/page.tsx b/src/app/(event)/[event-code]/edit/page.tsx index 8ffb0024..fd65dda7 100644 --- a/src/app/(event)/[event-code]/edit/page.tsx +++ b/src/app/(event)/[event-code]/edit/page.tsx @@ -12,12 +12,17 @@ export default async function Page({ params }: EventCodePageProps) { } const eventData = await fetchEventDetails(eventCode); - const { eventName, eventRange } = processEventData(eventData); + const { eventName, eventRange, timeslots } = processEventData(eventData); return ( ); } diff --git a/src/app/(event)/[event-code]/page-client.tsx b/src/app/(event)/[event-code]/page-client.tsx index f08d490a..e7446021 100644 --- a/src/app/(event)/[event-code]/page-client.tsx +++ b/src/app/(event)/[event-code]/page-client.tsx @@ -19,17 +19,20 @@ export default function ClientPage({ eventCode, eventName, eventRange, + timeslots, initialAvailabilityData, + isCreator, }: { eventCode: string; eventName: string; eventRange: EventRange; + timeslots: Date[]; initialAvailabilityData: AvailabilityDataResponse; + isCreator: boolean; }) { /* PARTICIPANT INFO */ const participated: boolean = initialAvailabilityData.user_display_name != null; - const isCreator: boolean = initialAvailabilityData.is_creator || false; const participants: string[] = initialAvailabilityData.participants || []; const availabilities: ResultsAvailabilityMap = initialAvailabilityData.availability || {}; @@ -95,6 +98,7 @@ export default function ClientPage({ setHoveredSlot={handleHoveredSlot} availabilities={availabilities} numParticipants={participants.length} + timeslots={timeslots} />
diff --git a/src/app/(event)/[event-code]/page.tsx b/src/app/(event)/[event-code]/page.tsx index 9eb8cbff..70401c1d 100644 --- a/src/app/(event)/[event-code]/page.tsx +++ b/src/app/(event)/[event-code]/page.tsx @@ -5,6 +5,7 @@ import { fetchAvailabilityData } from "@/features/event/availability/fetch-data" import { EventCodePageProps } from "@/features/event/code-page-props"; import { fetchEventDetails } from "@/features/event/editor/fetch-data"; import { getAuthCookieString } from "@/lib/utils/api/cookie-utils"; +import { processAvailabilityData } from "@/lib/utils/api/process-availability-data"; import { processEventData } from "@/lib/utils/api/process-event-data"; export default async function Page({ params }: EventCodePageProps) { @@ -15,20 +16,24 @@ export default async function Page({ params }: EventCodePageProps) { notFound(); } - const [initialEventData, availabilityData] = await Promise.all([ + const [initialEventData, initialAvailabilityData] = await Promise.all([ fetchEventDetails(eventCode, authCookies), fetchAvailabilityData(eventCode, authCookies), ]); - // Process the data here, on the server! - const { eventName, eventRange } = processEventData(initialEventData); + const { eventName, eventRange, timeslots, isCreator } = + processEventData(initialEventData); + + const availabilityData = processAvailabilityData(initialAvailabilityData); return ( ); } diff --git a/src/app/(event)/[event-code]/painting/page-client.tsx b/src/app/(event)/[event-code]/painting/page-client.tsx index a3de0091..2a3ec400 100644 --- a/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/src/app/(event)/[event-code]/painting/page-client.tsx @@ -9,7 +9,6 @@ import RateLimitBanner from "@/components/banner/rate-limit"; import HeaderSpacer from "@/components/header-spacer"; import MobileFooterTray from "@/components/mobile-footer-tray"; import { useAvailability } from "@/core/availability/use-availability"; -import { convertAvailabilityToGrid } from "@/core/availability/utils"; import { EventRange } from "@/core/event/types"; import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; @@ -26,11 +25,13 @@ export default function ClientPage({ eventCode, eventName, eventRange, + timeslots, initialData, }: { eventCode: string; eventName: string; eventRange: EventRange; + timeslots: Date[]; initialData: SelfAvailabilityResponse | null; }) { const router = useRouter(); @@ -93,15 +94,10 @@ export default function ClientPage({ return false; } - const availabilityGrid = convertAvailabilityToGrid( - userAvailability, - eventRange, - ); - const payload = { event_code: eventCode, display_name: displayName, - availability: availabilityGrid, + availability: Array.from(userAvailability), time_zone: timeZone, }; @@ -139,14 +135,18 @@ export default function ClientPage({ const cancelButton = ( ); const submitButton = ( @@ -227,6 +227,7 @@ export default function ClientPage({ timezone={timeZone} onToggleSlot={toggleSlot} userAvailability={userAvailability} + timeslots={timeslots} />
diff --git a/src/app/(event)/[event-code]/painting/page.tsx b/src/app/(event)/[event-code]/painting/page.tsx index b5db5551..91d1df2b 100644 --- a/src/app/(event)/[event-code]/painting/page.tsx +++ b/src/app/(event)/[event-code]/painting/page.tsx @@ -19,13 +19,14 @@ export default async function Page({ params }: EventCodePageProps) { fetchEventDetails(eventCode), fetchSelfAvailability(eventCode, authCookies), ]); - const { eventName, eventRange } = processEventData(eventData); + const { eventName, eventRange, timeslots } = processEventData(eventData); return ( ); diff --git a/src/core/availability/use-availability.ts b/src/core/availability/use-availability.ts index 9c1f8134..8bc67f21 100644 --- a/src/core/availability/use-availability.ts +++ b/src/core/availability/use-availability.ts @@ -6,13 +6,14 @@ import { } from "@/core/availability/reducers/reducer"; import { createUserAvailability } from "@/core/availability/utils"; import { SelfAvailabilityResponse } from "@/features/event/availability/fetch-data"; +import { formatDateTime } from "@/lib/utils/date-time-format"; export function useAvailability(initialData: SelfAvailabilityResponse | null) { const initialTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const isoStrings = []; if (initialData && initialData.available_dates) { for (const dateStr of initialData.available_dates) { - isoStrings.push(new Date(dateStr).toISOString()); + isoStrings.push(formatDateTime(dateStr)); } } diff --git a/src/core/availability/utils.ts b/src/core/availability/utils.ts index 9e908158..081b3408 100644 --- a/src/core/availability/utils.ts +++ b/src/core/availability/utils.ts @@ -1,15 +1,4 @@ -import { eachDayOfInterval, parseISO } from "date-fns"; - import { AvailabilitySet } from "@/core/availability/types"; -import { - EventRange, - SpecificDateRange, - WeekdayRange, -} from "@/core/event/types"; -import { - getAbsoluteDateRangeInUTC, - getSelectedWeekdaysInTimezone, -} from "@/features/event/grid/lib/expand-event-range"; // Creates an empty UserAvailability object export const createEmptyUserAvailability = (): AvailabilitySet => { @@ -45,71 +34,6 @@ export function isSlotSelected( return availability.has(timeSlot.toISOString()); } -// converts set to grid for api -export function convertAvailabilityToGrid( - availability: AvailabilitySet, - eventRange: EventRange, -): boolean[][] { - if (eventRange.type === "specific") { - return convertAvailabilityToGridForSpecificRange(availability, eventRange); - } else { - return convertAvailabilityToGridForWeekdayRange(availability, eventRange); - } -} - -function convertAvailabilityToGridForSpecificRange( - availability: AvailabilitySet, - eventRange: SpecificDateRange, -): boolean[][] { - const { eventStartUTC, eventEndUTC } = getAbsoluteDateRangeInUTC(eventRange); - const startTime = eventStartUTC.getHours(); - const endTime = - eventEndUTC.getMinutes() === 59 - ? eventEndUTC.getHours() + 1 - : eventEndUTC.getHours(); - - const days = eachDayOfInterval({ - start: parseISO(eventStartUTC.toISOString()), - end: parseISO(eventEndUTC.toISOString()), - }); - - const grid: boolean[][] = days.map((day) => { - const daySlots: boolean[] = []; - - for (let hour = startTime; hour < endTime; hour++) { - for (let minute = 0; minute < 60; minute += 15) { - const slot = new Date(day); - slot.setHours(hour, minute); - daySlots.push(isSlotSelected(availability, slot)); - } - } - - return daySlots; - }); - return grid; -} - -function convertAvailabilityToGridForWeekdayRange( - availability: AvailabilitySet, - eventRange: WeekdayRange, -): boolean[][] { - const selectedDays = getSelectedWeekdaysInTimezone(eventRange); - - const grid: boolean[][] = selectedDays.map((day) => { - const daySlots: boolean[] = []; - const { slotTimeUTC, dayEndUTC } = day; - - while (slotTimeUTC < dayEndUTC) { - daySlots.push(isSlotSelected(availability, slotTimeUTC)); - slotTimeUTC.setUTCMinutes(slotTimeUTC.getUTCMinutes() + 15); - } - - return daySlots; - }); - - return grid; -} - function sortDateRange(start: Date, end: Date): [Date, Date] { // given a start date and end date, it separately sorts the time and date components // and returns two new dates, such that the first has both the earlier date and time diff --git a/src/core/event/lib/default-range.ts b/src/core/event/lib/default-range.ts new file mode 100644 index 00000000..7b662ac7 --- /dev/null +++ b/src/core/event/lib/default-range.ts @@ -0,0 +1,22 @@ +import { SpecificDateRange, WeekdayRange } from "@/core/event/types"; + +const defaultTimeRange = { from: "09:00", to: "17:00" }; + +export const DEFAULT_RANGE_SPECIFIC: SpecificDateRange = { + type: "specific" as const, + duration: 60, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + dateRange: { + from: new Date().toISOString(), + to: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), + }, + timeRange: defaultTimeRange, +}; + +export const DEFAULT_RANGE_WEEKDAY: WeekdayRange = { + type: "weekday" as const, + duration: 30, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + weekdays: { Sun: 0, Mon: 1, Tue: 1, Wed: 1, Thu: 0, Fri: 0, Sat: 0 }, + timeRange: defaultTimeRange, +}; diff --git a/src/core/event/lib/expand-event-range.ts b/src/core/event/lib/expand-event-range.ts new file mode 100644 index 00000000..ce635fa3 --- /dev/null +++ b/src/core/event/lib/expand-event-range.ts @@ -0,0 +1,158 @@ +import { + addDays, + addMinutes, + eachDayOfInterval, + isBefore, + parseISO, +} from "date-fns"; +import { fromZonedTime } from "date-fns-tz"; + +import { + EventRange, + SpecificDateRange, + WeekdayRange, +} from "@/core/event/types"; +import { checkDateRange } from "@/features/event/editor/validate-data"; + +/* EXPAND EVENT RANGE UTILITIES */ + +/** + * Generates 15-minute slots between two absolute UTC times. + * range: [start, end) + */ +function generateSlotsBetween(startUTC: Date, endUTC: Date): Date[] { + const slots: Date[] = []; + let current = startUTC; + + while (isBefore(current, endUTC)) { + slots.push(new Date(current)); + current = addMinutes(current, 15); + } + return slots; +} + +/** + * Constructs the absolute Start and End UTC times for a specific "calendar day" + * in the target timezone, given the hour constraints. + */ +function getDailyBoundariesInUTC( + dateIsoStr: string, // "YYYY-MM-DD" + timezone: string, + timeRange: { from: string; to: string }, +) { + // Construct ISO strings for the target timezone + const startStr = `${dateIsoStr}T${timeRange.from}:00`; + const startUTC = fromZonedTime(startStr, timezone); + + let endUTC: Date; + if (timeRange.to === "00:00" || timeRange.to === "24:00") { + const dateObj = parseISO(dateIsoStr); + const nextDay = addDays(dateObj, 1); + const nextDayStr = nextDay.toISOString().split("T")[0]; + endUTC = fromZonedTime(`${nextDayStr}T00:00:00`, timezone); + } else { + const endStr = `${dateIsoStr}T${timeRange.to}:00`; + endUTC = fromZonedTime(endStr, timezone); + } + + return { startUTC, endUTC }; +} + +/** + * Expands a high-level EventRange into a concrete list of UTC time slots, + * generated based on the event's timezone constraints. + */ +export function expandEventRange(range: EventRange): Date[] { + if (range.type === "specific") { + return generateSlotsForSpecificRange(range); + } else { + return generateSlotsForWeekdayRange(range); + } +} + +function generateSlotsForSpecificRange(range: SpecificDateRange): Date[] { + if (!range.dateRange.from || !range.dateRange.to) { + return []; + } + + // Validate Duration + const startDateStr = range.dateRange.from.split("T")[0]; + const endDateStr = range.dateRange.to.split("T")[0]; + + const { startUTC: eventStartUTC } = getDailyBoundariesInUTC( + startDateStr, + range.timezone, + range.timeRange, + ); + const { endUTC: eventEndUTC } = getDailyBoundariesInUTC( + endDateStr, + range.timezone, + range.timeRange, + ); + + if (checkDateRange(eventStartUTC, eventEndUTC)) { + return []; + } + + // Generate Slots + const slots: Date[] = []; + const days = eachDayOfInterval({ + start: parseISO(startDateStr), + end: parseISO(endDateStr), + }); + + for (const day of days) { + const dayStr = day.toISOString().split("T")[0]; + + const { startUTC, endUTC } = getDailyBoundariesInUTC( + dayStr, + range.timezone, + range.timeRange, + ); + + slots.push(...generateSlotsBetween(startUTC, endUTC)); + } + + return slots; +} + +function generateSlotsForWeekdayRange(range: WeekdayRange): Date[] { + if (range.type !== "weekday") return []; + + const slots: Date[] = []; + const dayIndexMap: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + }; + + // generic reference week starting on a Sunday + const referenceStart = new Date("2012-01-01T00:00:00"); + + for (let i = 0; i < 7; i++) { + // current day in the reference week + const currentDay = addDays(referenceStart, i); + const currentDayIndex = currentDay.getDay(); + const dayName = Object.keys(dayIndexMap).find( + (key) => dayIndexMap[key] === currentDayIndex, + ); + + if (dayName && range.weekdays[dayName as keyof typeof range.weekdays]) { + const dayStr = currentDay.toISOString().split("T")[0]; + + const { startUTC, endUTC } = getDailyBoundariesInUTC( + dayStr, + range.timezone, + range.timeRange, + ); + + slots.push(...generateSlotsBetween(startUTC, endUTC)); + } + } + + return slots; +} diff --git a/src/core/event/reducers/info-reducer.ts b/src/core/event/reducers/info-reducer.ts index 67fd8493..71769f9e 100644 --- a/src/core/event/reducers/info-reducer.ts +++ b/src/core/event/reducers/info-reducer.ts @@ -1,3 +1,5 @@ +import { DEFAULT_RANGE_SPECIFIC } from "@/core/event/lib/default-range"; +import { expandEventRange } from "@/core/event/lib/expand-event-range"; import { EventRangeReducer, EventRangeAction, @@ -29,27 +31,23 @@ export function EventInfoReducer( return { title: "", customCode: "", - eventRange: { - type: "specific", - duration: 60, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - dateRange: { - from: new Date().toISOString(), - to: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), - }, - timeRange: { - from: 9, - to: 17, - }, - }, + eventRange: DEFAULT_RANGE_SPECIFIC, + timeslots: expandEventRange(DEFAULT_RANGE_SPECIFIC), }; default: + const newEventRange = EventRangeReducer( + state.eventRange, + action as EventRangeAction, + ); + + if (newEventRange === state.eventRange) { + return state; + } + return { ...state, - eventRange: EventRangeReducer( - state.eventRange, - action as EventRangeAction, - ), + eventRange: newEventRange, + timeslots: expandEventRange(newEventRange), }; } } diff --git a/src/core/event/reducers/range-reducer.ts b/src/core/event/reducers/range-reducer.ts index e749d4d0..216a5d8e 100644 --- a/src/core/event/reducers/range-reducer.ts +++ b/src/core/event/reducers/range-reducer.ts @@ -1,11 +1,15 @@ +import { + DEFAULT_RANGE_SPECIFIC, + DEFAULT_RANGE_WEEKDAY, +} from "@/core/event/lib/default-range"; import { EventRange, WeekdayMap } from "@/core/event/types"; export type EventRangeAction = | { type: "SET_RANGE_INFO"; payload: EventRange } | { type: "SET_RANGE_TYPE"; payload: "specific" | "weekday" } | { type: "SET_DATE_RANGE"; payload: { from: string; to: string } } - | { type: "SET_START_TIME"; payload: number } - | { type: "SET_END_TIME"; payload: number } + | { type: "SET_START_TIME"; payload: string } + | { type: "SET_END_TIME"; payload: string } | { type: "SET_WEEKDAYS"; payload: { weekdays: Partial> }; @@ -118,24 +122,9 @@ export function EventRangeReducer( case "RESET": { if (state.type === "specific") { - return { - type: "specific", - duration: 30, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - dateRange: { - from: new Date().toISOString(), - to: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), - }, - timeRange: { from: 9, to: 17 }, - }; + return DEFAULT_RANGE_SPECIFIC; } else { - return { - type: "weekday", - duration: 30, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - weekdays: { Sun: 0, Mon: 1, Tue: 1, Wed: 1, Thu: 0, Fri: 0, Sat: 0 }, - timeRange: { from: 9, to: 17 }, - }; + return DEFAULT_RANGE_WEEKDAY; } } diff --git a/src/core/event/types.ts b/src/core/event/types.ts index 43d45ac1..6943496a 100644 --- a/src/core/event/types.ts +++ b/src/core/event/types.ts @@ -4,6 +4,7 @@ export type EventInformation = { title: string; customCode: string; eventRange: EventRange; + timeslots: Date[]; }; // discriminated union for event ranges - this is your single source of truth @@ -41,8 +42,8 @@ export type SpecificDateRange = { to: string; }; timeRange: { - from: number; // hour in 24h format, e.g., 9 for 9:00 AM - to: number; // hour in 24h format, e.g., 17 for 5:00 PM + from: string; + to: string; }; }; @@ -52,8 +53,8 @@ export type WeekdayRange = { timezone: string; weekdays: WeekdayMap; timeRange: { - from: number; // hour in 24h format, e.g., 9 for 9:00 AM - to: number; // hour in 24h format, e.g., 17 for 5:00 PM + from: string; + to: string; }; }; diff --git a/src/core/event/use-event-info.ts b/src/core/event/use-event-info.ts index 8cb16d42..c3c10638 100644 --- a/src/core/event/use-event-info.ts +++ b/src/core/event/use-event-info.ts @@ -2,33 +2,25 @@ import { useMemo, useReducer, useCallback } from "react"; import { DateRange } from "react-day-picker"; +import { DEFAULT_RANGE_SPECIFIC } from "@/core/event/lib/default-range"; +import { expandEventRange } from "@/core/event/lib/expand-event-range"; import { EventInfoReducer } from "@/core/event/reducers/info-reducer"; import { EventInformation, EventRange, WeekdayMap } from "@/core/event/types"; -import { checkInvalidDateRangeLength } from "@/features/event/editor/validate-data"; +import { + checkDateRange, + checkTimeRange, +} from "@/features/event/editor/validate-data"; import { useFormErrors } from "@/lib/hooks/use-form-errors"; import { MESSAGES } from "@/lib/messages"; -const checkTimeRange = (from: number, to: number): boolean => { - return to > from; -}; - function createInitialState(initialData?: EventInformation): EventInformation { return { title: initialData?.title || "", customCode: initialData?.customCode || "", - eventRange: initialData?.eventRange || { - type: "specific", - duration: 0, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - dateRange: { - from: new Date().toISOString(), - to: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), - }, - timeRange: { - from: 9, - to: 17, - }, - }, + eventRange: initialData?.eventRange || DEFAULT_RANGE_SPECIFIC, + timeslots: + initialData?.timeslots || + expandEventRange(initialData?.eventRange || DEFAULT_RANGE_SPECIFIC), }; } @@ -85,7 +77,7 @@ export function useEventInfo(initialData?: EventInformation) { }, []); const setStartTime = useCallback( - (time: number) => { + (time: string) => { if (checkTimeRange(time, state.eventRange.timeRange.to)) { handleError("timeRange", ""); } else handleError("timeRange", MESSAGES.ERROR_EVENT_RANGE_INVALID); @@ -96,7 +88,7 @@ export function useEventInfo(initialData?: EventInformation) { ); const setEndTime = useCallback( - (time: number) => { + (time: string) => { if (checkTimeRange(state.eventRange.timeRange.from, time)) { handleError("timeRange", ""); } else { @@ -109,8 +101,8 @@ export function useEventInfo(initialData?: EventInformation) { ); const setDateRange = useCallback( - (dateRange: DateRange | undefined) => { - if (checkInvalidDateRangeLength(dateRange)) { + (dateRange: DateRange) => { + if (checkDateRange(dateRange.from, dateRange.to)) { handleError("dateRange", MESSAGES.ERROR_EVENT_RANGE_TOO_LONG); } else { handleError("dateRange", ""); diff --git a/src/features/dashboard/components/date-range-row.tsx b/src/features/dashboard/components/date-range-row.tsx index 9bb3eb00..7beb6c6a 100644 --- a/src/features/dashboard/components/date-range-row.tsx +++ b/src/features/dashboard/components/date-range-row.tsx @@ -1,3 +1,5 @@ +import { formatDateRange } from "@/lib/utils/date-time-format"; + type DateRangeRowProps = { startDate: string; endDate: string; @@ -11,21 +13,7 @@ export default function DateRangeRow({
- {formatDates(startDate, endDate)} + {formatDateRange(startDate, endDate)}
); } - -function formatDates(startDate: string, endDate: string): string { - const start = new Date(startDate); - const end = new Date(endDate); - if (start.getUTCMonth() === end.getUTCMonth()) { - if (start.getUTCDate() === end.getUTCDate()) { - return `${start.toLocaleString("en-US", { month: "long" })} ${start.getUTCDate()}`; - } else { - return `${start.toLocaleString("en-US", { month: "long" })} ${start.getUTCDate()} - ${end.getUTCDate()}`; - } - } else { - return `${start.toLocaleString("en-US", { month: "long" })} ${start.getUTCDate()} - ${end.toLocaleString("en-US", { month: "long" })} ${end.getUTCDate()}`; - } -} diff --git a/src/features/dashboard/components/event.tsx b/src/features/dashboard/components/event.tsx index a122730d..32cae0a2 100644 --- a/src/features/dashboard/components/event.tsx +++ b/src/features/dashboard/components/event.tsx @@ -1,4 +1,4 @@ -import { MouseEvent } from "react"; +import { MouseEvent, useMemo } from "react"; import { ClockIcon, Pencil1Icon } from "@radix-ui/react-icons"; import Link from "next/link"; @@ -7,18 +7,17 @@ import { useRouter } from "next/navigation"; import DashboardCopyButton from "@/features/dashboard/components/copy-button"; import DateRangeRow from "@/features/dashboard/components/date-range-row"; import WeekdayRow from "@/features/dashboard/components/weekday-row"; +import { formatTimeRange, getLocalDetails } from "@/lib/utils/date-time-format"; export type DashboardEventProps = { myEvent: boolean; code: string; title: string; type: "specific" | "weekday"; - startHour: number; - endHour: number; - startDate?: string; - endDate?: string; - startWeekday?: number; - endWeekday?: number; + startTime: string; + endTime: string; + startDate: string; + endDate: string; }; export default function DashboardEvent({ @@ -26,12 +25,7 @@ export default function DashboardEvent({ code, title, type, - startHour, - endHour, - startDate, - endDate, - startWeekday, - endWeekday, + ...dateTimeProps }: DashboardEventProps) { const router = useRouter(); @@ -40,6 +34,16 @@ export default function DashboardEvent({ router.push(`/${code}/edit`); } + // Memoized local start and end details + const start = useMemo( + () => getLocalDetails(dateTimeProps.startTime, dateTimeProps.startDate), + [dateTimeProps.startTime, dateTimeProps.startDate], + ); + const end = useMemo( + () => getLocalDetails(dateTimeProps.endTime, dateTimeProps.endDate), + [dateTimeProps.endTime, dateTimeProps.endDate], + ); + return (
@@ -49,15 +53,15 @@ export default function DashboardEvent({
{code}
{type === "specific" && ( - + )} {type === "weekday" && ( - + )}
- {formatTimeRange(startHour, endHour)} + {formatTimeRange(start.time, end.time)}
@@ -77,19 +81,3 @@ export default function DashboardEvent({ ); } - -function formatHour(hour: number): string { - if (hour === 0 || hour === 24) { - return "12am"; - } - const period = hour >= 12 ? "pm" : "am"; - const adjustedHour = hour % 12 === 0 ? 12 : hour % 12; - return `${adjustedHour}${period}`; -} - -function formatTimeRange(startHour: number, endHour: number): string { - if (startHour === 0 && endHour === 24) { - return "All day"; - } - return `${formatHour(startHour)} - ${formatHour(endHour)}`; -} diff --git a/src/features/dashboard/fetch-data.ts b/src/features/dashboard/fetch-data.ts index 28834412..ba657a5b 100644 --- a/src/features/dashboard/fetch-data.ts +++ b/src/features/dashboard/fetch-data.ts @@ -3,14 +3,12 @@ import handleErrorResponse from "@/lib/utils/api/handle-api-error"; export type DashboardEventResponse = { title: string; duration?: number; - start_hour: number; - end_hour: number; + start_time: string; + end_time: string; time_zone: string; event_type: "Date" | "Week"; start_date?: string; end_date?: string; - start_weekday?: number; - end_weekday?: number; event_code: string; }; diff --git a/src/features/event/availability/fetch-data.ts b/src/features/event/availability/fetch-data.ts index e3d0a1c6..0b66cb0b 100644 --- a/src/features/event/availability/fetch-data.ts +++ b/src/features/event/availability/fetch-data.ts @@ -1,7 +1,6 @@ import handleErrorResponse from "@/lib/utils/api/handle-api-error"; export type AvailabilityDataResponse = { - is_creator: boolean; user_display_name: string | null; participants: string[]; availability: Record; diff --git a/src/features/event/editor/advanced-options.tsx b/src/features/event/editor/advanced-options.tsx index fb5fb459..1bc78d75 100644 --- a/src/features/event/editor/advanced-options.tsx +++ b/src/features/event/editor/advanced-options.tsx @@ -26,7 +26,7 @@ export default function AdvancedOptions(props: AdvancedOptionsProps) {
- + diff --git a/src/features/event/editor/date-range/popover.tsx b/src/features/event/editor/date-range/popover.tsx index 706d8492..5c01fdb6 100644 --- a/src/features/event/editor/date-range/popover.tsx +++ b/src/features/event/editor/date-range/popover.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import * as Popover from "@radix-ui/react-popover"; import { useEventContext } from "@/core/event/context"; @@ -12,11 +14,16 @@ export default function DateRangePopover({ endDate, }: SpecificDateRangeDisplayProps) { const { errors, setDateRange } = useEventContext(); + const [open, setOpen] = useState(false); return ( - + - + diff --git a/src/features/event/editor/date-range/specific-date-display.tsx b/src/features/event/editor/date-range/specific-date-display.tsx index 683c18fb..201905c2 100644 --- a/src/features/event/editor/date-range/specific-date-display.tsx +++ b/src/features/event/editor/date-range/specific-date-display.tsx @@ -1,13 +1,17 @@ import { format } from "date-fns"; +import { cn } from "@/lib/utils/classname"; + type SpecificDateRangeDisplayProps = { startDate: Date; endDate: Date; + open?: boolean; }; export default function SpecificDateRangeDisplay({ startDate, endDate, + open = false, }: SpecificDateRangeDisplayProps) { const displayFrom = startDate ? format(startDate, "EEE MMMM d, yyyy") : ""; const displayTo = endDate ? format(endDate, "EEE MMMM d, yyyy") : ""; @@ -20,7 +24,10 @@ export default function SpecificDateRangeDisplay({ {/* Start Date */}

FROM

- + {displayFrom}
@@ -30,7 +37,10 @@ export default function SpecificDateRangeDisplay({ {/* End Date */}

UNTIL

- + {displayTo}
diff --git a/src/features/event/editor/editor.tsx b/src/features/event/editor/editor.tsx index 73780da1..729df8da 100644 --- a/src/features/event/editor/editor.tsx +++ b/src/features/event/editor/editor.tsx @@ -14,14 +14,13 @@ import { EventProvider, useEventContext } from "@/core/event/context"; import { EventInformation } from "@/core/event/types"; import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; -import TimeSelector from "@/features/event/components/selectors/time"; import AdvancedOptions from "@/features/event/editor/advanced-options"; import DateRangeSelection from "@/features/event/editor/date-range/selector"; +import TimeRangeSelection from "@/features/event/editor/time-range/selector"; import { EventEditorType } from "@/features/event/editor/types"; import { validateEventData } from "@/features/event/editor/validate-data"; import ScheduleGrid from "@/features/event/grid/grid"; import GridPreviewDialog from "@/features/event/grid/preview-dialog"; -import FormSelectorField from "@/features/selector/components/selector-field"; import submitEvent from "@/lib/utils/api/submit-event"; import { cn } from "@/lib/utils/classname"; @@ -47,15 +46,13 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { const { state, setTitle, - setStartTime, - setEndTime, errors, handleError, clearAllErrors, handleGenericError, batchHandleErrors, } = useEventContext(); - const { title, customCode, eventRange } = state; + const { title, customCode, eventRange, timeslots } = state; const router = useRouter(); const [mobileTab, setMobileTab] = useState("details"); @@ -72,7 +69,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { } const success = await submitEvent( - { title, code: customCode, eventRange }, + { title, code: customCode, eventRange, timeslots }, type, eventRange.type, (code: string) => router.push(`/${code}`), @@ -146,7 +143,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { className={cn( "w-full grid-cols-1 gap-y-2", mobileTab === "preview" ? "hidden md:grid" : "grid", - "md:grow md:grid-cols-[auto_1fr] md:grid-rows-[auto_repeat(7,minmax(0,25px))_1fr_25px] md:gap-x-4 md:gap-y-2", + "md:grow md:grid-cols-[auto_1fr] md:grid-rows-[auto_repeat(8,minmax(0,25px))_1fr_25px] md:gap-x-4 md:gap-y-2", )} > @@ -157,30 +154,16 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { Possible Times {errors.timeRange && }

-
- - - - - - - +
+
-
+
- +
@@ -195,6 +178,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { eventRange={eventRange} disableSelect={true} timezone={eventRange.timezone} + timeslots={timeslots} />
diff --git a/src/features/event/editor/fetch-data.ts b/src/features/event/editor/fetch-data.ts index d56c8873..6025a843 100644 --- a/src/features/event/editor/fetch-data.ts +++ b/src/features/event/editor/fetch-data.ts @@ -3,14 +3,14 @@ import handleErrorResponse from "@/lib/utils/api/handle-api-error"; export type EventDetailsResponse = { title: string; duration?: number; - start_hour: number; - end_hour: number; time_zone: string; + timeslots: string[]; + is_creator: boolean; event_type: "Date" | "Week"; start_date?: string; end_date?: string; - start_weekday?: number; - end_weekday?: number; + start_time: string; + end_time: string; }; export async function fetchEventDetails( diff --git a/src/features/event/editor/time-range/selector.tsx b/src/features/event/editor/time-range/selector.tsx index bb71d30f..a8537adb 100644 --- a/src/features/event/editor/time-range/selector.tsx +++ b/src/features/event/editor/time-range/selector.tsx @@ -1,63 +1,87 @@ -"use client"; - import { useState } from "react"; -import { - TimePickerRoot, - TimePickerWheel, - TimePickerSeparator, -} from "@poursha98/react-ios-time-picker"; - -export default function TimeRangeSelection() { - const [time, setTime] = useState("02:30 PM"); - - const wheelStyle = { - root: { display: "flex", width: "fit-content", padding: "0 8px" }, - item: { fontSize: "16px" }, - overlayTop: { - background: - "linear-gradient(to bottom, color-mix(in srgb, var(--color-background), transparent 20%) 5%, transparent)", - }, - overlayBottom: { - background: - "linear-gradient(to top, color-mix(in srgb, var(--color-background), transparent 20%) 5%, transparent)", - }, - }; +import * as Collapsible from "@radix-ui/react-collapsible"; + +import { useEventContext } from "@/core/event/context"; +import TimePicker from "@/features/event/editor/time-range/time-picker"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; +import { cn } from "@/lib/utils/classname"; +import { convert24To12 } from "@/lib/utils/date-time-format"; + +export default function TimeRangeSelection({}) { + const { state, setStartTime, setEndTime } = useEventContext(); + const { from: startTime, to: endTime } = state.eventRange.timeRange; + + const [open, setOpen] = useState(""); + + return ( +
+ setOpen(isOpen ? "start-time" : "")} + /> + setOpen(isOpen ? "end-time" : "")} + /> +
+ ); +} + +function TimeCollapsible({ + id, + label, + time, + onChange, + open, + setOpen, +}: { + id: string; + label: string; + time: string; + onChange: (newTime: string) => void; + open?: boolean; + setOpen?: (open: boolean) => void; +}) { + const isMobile = useCheckMobile(); + const time12 = convert24To12(time); return ( - -
-
- -
- - - : - - - + + +
+

+ {label} +

+
+ {time12} +
-
- + + + + + + ); } diff --git a/src/features/event/editor/time-range/time-picker.tsx b/src/features/event/editor/time-range/time-picker.tsx new file mode 100644 index 00000000..b010b19c --- /dev/null +++ b/src/features/event/editor/time-range/time-picker.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; + +import { + TimePickerRoot, + TimePickerWheel, + TimePickerSeparator, +} from "@poursha98/react-ios-time-picker"; + +import { convert12To24, convert24To12 } from "@/lib/utils/date-time-format"; + +type TimePickerProps = { + time: string; + onTimeChange: (newTime: string) => void; + + visibleCount?: number; + fontSize?: number; +}; + +export default function TimePicker({ + time, + onTimeChange, + visibleCount = 3, + fontSize = 16, +}: TimePickerProps) { + const [pickerValue, setPickerValue] = useState(convert24To12(time)); + + // Sync internal state if the external prop changes + useEffect(() => { + setPickerValue(convert24To12(time)); + }, [time]); + + const handleChange = (newTime12: string) => { + setPickerValue(newTime12); + + const time24 = convert12To24(newTime12); + onTimeChange(time24); + }; + + const wheelStyle = { + root: { display: "flex", width: "fit-content", padding: "0 8px" }, + item: { fontSize: `${fontSize}px` }, + overlayTop: { + background: + "linear-gradient(to bottom, color-mix(in srgb, var(--color-background), transparent 20%) 5%, transparent)", + }, + overlayBottom: { + background: + "linear-gradient(to top, color-mix(in srgb, var(--color-background), transparent 20%) 5%, transparent)", + }, + }; + + return ( + +
+
+ +
+ + + : + + + +
+
+ + ); +} diff --git a/src/features/event/editor/validate-data.ts b/src/features/event/editor/validate-data.ts index c759060e..8847ae56 100644 --- a/src/features/event/editor/validate-data.ts +++ b/src/features/event/editor/validate-data.ts @@ -1,11 +1,11 @@ -import { DateRange } from "react-day-picker"; - import { EventInformation, WeekdayRange } from "@/core/event/types"; import { findRangeFromWeekdayMap } from "@/core/event/weekday-utils"; import { EventEditorType } from "@/features/event/editor/types"; -import { isDurationExceedingMax } from "@/features/event/max-event-duration"; import { MESSAGES } from "@/lib/messages"; +export const MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1000; +export const MAX_DURATION = "30 days"; + export const MAX_TITLE_LENGTH = 50; export async function validateEventData( @@ -30,7 +30,7 @@ export async function validateEventData( // check if the date range is more than 30 days const fromDate = new Date(eventRange.dateRange.from); const toDate = new Date(eventRange.dateRange.to); - if (isDurationExceedingMax(fromDate, toDate)) { + if (checkDateRange(fromDate, toDate)) { errors.dateRange = MESSAGES.ERROR_EVENT_RANGE_TOO_LONG; } } @@ -46,19 +46,31 @@ export async function validateEventData( } // Validate time range - if (eventRange.timeRange.from >= eventRange.timeRange.to) { + if (!checkTimeRange(eventRange.timeRange.from, eventRange.timeRange.to)) { errors.timeRange = MESSAGES.ERROR_EVENT_RANGE_INVALID; } return errors; } -export function checkInvalidDateRangeLength( - range: DateRange | undefined, +export function checkDateRange( + start: Date | undefined, + end: Date | undefined, ): boolean { - if (range?.from && range?.to) { - const diffTime = range.to.getTime() - range.from.getTime(); - return diffTime > 30 * 24 * 60 * 60 * 1000; // more than 30 days + if (start && end) { + const diffTime = end.getTime() - start.getTime(); + return diffTime > MAX_DURATION_MS; } return false; } + +export function checkTimeRange(startTime: string, endTime: string): boolean { + if (endTime === "00:00") return true; + + const [startHour, startMinute] = startTime.split(":").map(Number); + const [endHour, endMinute] = endTime.split(":").map(Number); + + if (endHour > startHour) return true; + if (endHour === startHour && endMinute > startMinute) return true; + return false; +} diff --git a/src/features/event/grid/grid.tsx b/src/features/event/grid/grid.tsx index 1134152d..43b1b078 100644 --- a/src/features/event/grid/grid.tsx +++ b/src/features/event/grid/grid.tsx @@ -21,6 +21,7 @@ import useCheckMobile from "@/lib/hooks/use-check-mobile"; interface ScheduleGridProps { mode: "paint" | "view" | "preview"; eventRange: EventRange; + timeslots: Date[]; timezone: string; disableSelect?: boolean; @@ -38,6 +39,7 @@ interface ScheduleGridProps { export default function ScheduleGrid({ eventRange, + timeslots, timezone, mode = "preview", availabilities = {}, @@ -51,6 +53,7 @@ export default function ScheduleGrid({ const { timeBlocks, dayGroupedSlots, numDays, error } = useGenerateTimeSlots( eventRange, + timeslots, timezone, ); diff --git a/src/features/event/grid/lib/expand-event-range.ts b/src/features/event/grid/lib/expand-event-range.ts deleted file mode 100644 index 54fdaba4..00000000 --- a/src/features/event/grid/lib/expand-event-range.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { getHours, getMinutes } from "date-fns"; -import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; - -import { - EventRange, - SpecificDateRange, - WeekdayRange, - WeekdayTimeRange, - WeekdayMap, -} from "@/core/event/types"; -import { isDurationExceedingMax } from "@/features/event/max-event-duration"; - -/* EXPAND EVENT RANGE UTILITIES */ - -function getTimeStrings(timeRange: { from: number; to: number }) { - const fromHour = String(timeRange.from).padStart(2, "0"); - const toHour = - timeRange.to === 24 ? "23:59" : String(timeRange.to).padStart(2, "0"); - return { fromHour, toHour }; -} - -export function getAbsoluteDateRangeInUTC(eventRange: SpecificDateRange): { - eventStartUTC: Date; - eventEndUTC: Date; -} { - const startDateString = eventRange.dateRange.from.split("T")[0]; - const endDateString = eventRange.dateRange.to.split("T")[0]; - const { fromHour: startTimeString, toHour: endTimeString } = getTimeStrings( - eventRange.timeRange, - ); - const eventStartUTC = fromZonedTime( - `${startDateString}T${startTimeString}`, - eventRange.timezone, - ); - const eventEndUTC = fromZonedTime( - `${endDateString}T${endTimeString}`, - eventRange.timezone, - ); - - return { eventStartUTC, eventEndUTC }; -} - -export function getSelectedWeekdaysInTimezone( - range: WeekdayRange, -): WeekdayTimeRange[] { - const dayNameToIndex: { [key: string]: number } = { - Sun: 0, - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, - }; - - const selectedDayIndexes = new Set(); - for (const dayName in range.weekdays) { - if (range.weekdays[dayName as keyof WeekdayMap] === 1) { - selectedDayIndexes.add(dayNameToIndex[dayName]); - } - } - - if (selectedDayIndexes.size === 0) { - return []; - } - - // 01/01/2012 is a sunday - const startOfWeekInViewerTz = fromZonedTime( - "2012-01-01T00:00:00", - range.timezone, - ); - - const { fromHour: startTimeString, toHour: endTimeString } = getTimeStrings( - range.timeRange, - ); - - const selectedDatesUTC: WeekdayTimeRange[] = []; - for (let i = 0; i < 7; i++) { - const currentDay = new Date(startOfWeekInViewerTz); - currentDay.setDate(startOfWeekInViewerTz.getDate() + i); - if (selectedDayIndexes.has(currentDay.getDay())) { - const dateString = formatInTimeZone( - currentDay, - range.timezone, - "yyyy-MM-dd", - ); - - const slotTimeUTC = fromZonedTime( - `${dateString}T${startTimeString}`, - range.timezone, - ); - const dayEndUTC = fromZonedTime( - `${dateString}T${endTimeString}`, - range.timezone, - ); - - selectedDatesUTC.push({ slotTimeUTC, dayEndUTC }); - } - } - - return selectedDatesUTC; -} - -/** - * expands a high-level EventRange into a concrete list of days and time slots - * for the user's local timezone - */ -export function expandEventRange(range: EventRange): Date[] { - if (range.type === "specific") { - return generateSlotsForSpecificRange(range); - } else { - return generateSlotsForWeekdayRange(range); - } -} - -function generateSlotsForSpecificRange(range: SpecificDateRange): Date[] { - const slots: Date[] = []; - if (!range.dateRange.from || !range.dateRange.to) { - return []; - } - - // Get the absolute start and end times in UTC - const { eventStartUTC, eventEndUTC } = getAbsoluteDateRangeInUTC(range); - - // If event is longer than 30 days, return empty array - if (isDurationExceedingMax(eventStartUTC, eventEndUTC)) { - return []; - } - - // Get the valid time range for any given day in UTC - const validStartHour = range.timeRange.from; - const validEndHour = range.timeRange.to; - - const currentUTC = new Date(eventStartUTC); - - while (currentUTC <= eventEndUTC) { - const currentHour = getHours(currentUTC); - const currentMinute = getMinutes(currentUTC); - - const isAfterStartTime = - currentHour > validStartHour || - (currentHour === validStartHour && currentMinute >= 0); - - const isBeforeEndTime = - currentHour < validEndHour || - (currentHour === validEndHour && currentMinute < 0); - - if (isAfterStartTime && isBeforeEndTime) { - slots.push(new Date(currentUTC)); - } - - currentUTC.setUTCMinutes(currentUTC.getUTCMinutes() + 15); - } - - return slots; -} - -function generateSlotsForWeekdayRange(range: WeekdayRange): Date[] { - const slots: Date[] = []; - if (range.type !== "weekday") { - return []; - } - - const selectedDays = getSelectedWeekdaysInTimezone(range); - if (selectedDays.length === 0) { - return []; - } - - for (const day of selectedDays) { - const { slotTimeUTC, dayEndUTC } = day; - - while (slotTimeUTC < dayEndUTC) { - slots.push(new Date(slotTimeUTC)); - slotTimeUTC.setUTCMinutes(slotTimeUTC.getUTCMinutes() + 15); - } - } - - return slots; -} diff --git a/src/features/event/grid/lib/timeslot-utils.ts b/src/features/event/grid/lib/timeslot-utils.ts new file mode 100644 index 00000000..84debdcb --- /dev/null +++ b/src/features/event/grid/lib/timeslot-utils.ts @@ -0,0 +1,48 @@ +import { toZonedTime } from "date-fns-tz"; + +/* + * Gets the grid coordinates (row and column) for a given + * timeslot within the schedule grid. + */ +export function getGridCoordinates( + slot: Date, + visibleDayKeys: string[], + userTimezone: string, + startHour: number, +): { row: number; column: number } | null { + const localSlot = toZonedTime(slot, userTimezone); + const dayKey = localSlot.toLocaleDateString("en-CA"); + const dayIndex = visibleDayKeys.indexOf(dayKey); + + if (dayIndex === -1) { + return null; + } + + const hours = localSlot.getHours(); + const minutes = localSlot.getMinutes(); + const row = (hours - startHour) * 4 + Math.floor(minutes / 15) + 1; // +1 for 1-based index + const column = dayIndex + 1; // +1 for 1-based index + + return { row, column }; +} + +/* + * Determines the base CSS classes for a timeslot cell + * based on its grid row and total number of quarter hours. + */ +export function getBaseCellClasses( + gridRow: number, + numQuarterHours: number, +): string[] { + const cellClasses: string[] = []; + if (gridRow < numQuarterHours) { + cellClasses.push("border-b"); + + if (gridRow % 4 === 0) { + cellClasses.push("border-solid border-gray-400"); + } else { + cellClasses.push("border-dashed border-gray-400"); + } + } + return cellClasses; +} diff --git a/src/features/event/grid/lib/use-generate-timeslots.ts b/src/features/event/grid/lib/use-generate-timeslots.ts index a69d8eac..c9d6b0b1 100644 --- a/src/features/event/grid/lib/use-generate-timeslots.ts +++ b/src/features/event/grid/lib/use-generate-timeslots.ts @@ -4,15 +4,14 @@ import { differenceInCalendarDays } from "date-fns"; import { toZonedTime } from "date-fns-tz"; import { EventRange } from "@/core/event/types"; -import { expandEventRange } from "@/features/event/grid/lib/expand-event-range"; export default function useGenerateTimeSlots( eventRange: EventRange, + timeslots: Date[], timezone: string, ) { return useMemo(() => { - const daySlots = expandEventRange(eventRange); - if (daySlots.length === 0) { + if (timeslots.length === 0) { return { timeBlocks: [], dayGroupedSlots: [], @@ -22,8 +21,8 @@ export default function useGenerateTimeSlots( }; } - const localStartTime = toZonedTime(daySlots[0], timezone); - const localEndTime = toZonedTime(daySlots[daySlots.length - 1], timezone); + const localStartTime = toZonedTime(timeslots[0], timezone); + const localEndTime = toZonedTime(timeslots[timeslots.length - 1], timezone); const localStartHour = localStartTime.getHours(); const localEndHour = localEndTime.getHours(); @@ -42,7 +41,7 @@ export default function useGenerateTimeSlots( } const dayGroupedSlots = Array.from( - daySlots + timeslots .reduce((daysMap, slot) => { const zonedDate = toZonedTime(slot, timezone); const dayKey = zonedDate.toLocaleDateString("en-CA"); @@ -78,5 +77,5 @@ export default function useGenerateTimeSlots( const numDays = differenceInCalendarDays(localEndTime, localStartTime) + 1; return { timeBlocks, dayGroupedSlots, numDays, numHours, error: null }; - }, [eventRange, timezone]); + }, [eventRange, timezone, timeslots]); } diff --git a/src/features/event/grid/preview-dialog.tsx b/src/features/event/grid/preview-dialog.tsx index 5e75f20e..198e0449 100644 --- a/src/features/event/grid/preview-dialog.tsx +++ b/src/features/event/grid/preview-dialog.tsx @@ -9,13 +9,16 @@ import { EventRange } from "@/core/event/types"; import TimeZoneSelector from "@/features/event/components/selectors/timezone"; import ScheduleGrid from "@/features/event/grid/grid"; import { cn } from "@/lib/utils/classname"; +import { findTimezoneLabel } from "@/lib/utils/date-time-format"; interface GridPreviewDialogProps { eventRange: EventRange; + timeslots: Date[]; } export default function GridPreviewDialog({ eventRange, + timeslots, }: GridPreviewDialogProps) { const [isOpen, setIsOpen] = useState(false); const [timezone, setTimezone] = useState(eventRange.timezone); @@ -89,6 +92,7 @@ export default function GridPreviewDialog({ eventRange={eventRange} disableSelect timezone={timezone} + timeslots={timeslots} />
@@ -119,6 +123,7 @@ export default function GridPreviewDialog({ eventRange={eventRange} disableSelect={true} timezone={eventRange.timezone} + timeslots={timeslots} /> )} diff --git a/src/features/event/grid/timeblocks/interactive.tsx b/src/features/event/grid/timeblocks/interactive.tsx index 3bfafbb7..271bab5c 100644 --- a/src/features/event/grid/timeblocks/interactive.tsx +++ b/src/features/event/grid/timeblocks/interactive.tsx @@ -1,6 +1,8 @@ -import { toZonedTime } from "date-fns-tz"; - import { AvailabilitySet } from "@/core/availability/types"; +import { + getGridCoordinates, + getBaseCellClasses, +} from "@/features/event/grid/lib/timeslot-utils"; import useScheduleDrag from "@/features/event/grid/lib/use-schedule-drag"; import TimeSlot from "@/features/event/grid/time-slot"; import BaseTimeBlock from "@/features/event/grid/timeblocks/base"; @@ -40,29 +42,21 @@ export default function InteractiveTimeBlock({ > {timeslots.map((timeslot, timeslotIdx) => { const slotIso = timeslot.toISOString(); - const localSlot = toZonedTime(timeslot, userTimezone); - - const currentDayKey = localSlot.toLocaleDateString("en-CA"); - const dayIndex = visibleDayKeys.indexOf(currentDayKey); - if (dayIndex === -1) return null; - const gridColumn = dayIndex + 1; - const gridRow = - (localSlot.getHours() - startHour) * 4 + - Math.floor(localSlot.getMinutes() / 15) + - 1; + const coords = getGridCoordinates( + timeslot, + visibleDayKeys, + userTimezone, + startHour, + ); + if (!coords) return null; + const { row: gridRow, column: gridColumn } = coords; // borders - const cellClasses: string[] = []; - if (gridRow < numQuarterHours) { - cellClasses.push("border-b"); - - if (gridRow % 4 === 0) { - cellClasses.push("border-solid border-gray-400"); - } else { - cellClasses.push("border-dashed border-gray-400"); - } - } + const cellClasses: string[] = getBaseCellClasses( + gridRow, + numQuarterHours, + ); const isSelected = availability.has(slotIso); const isToggling = diff --git a/src/features/event/grid/timeblocks/preview.tsx b/src/features/event/grid/timeblocks/preview.tsx index fbf50e18..7bbdc6c3 100644 --- a/src/features/event/grid/timeblocks/preview.tsx +++ b/src/features/event/grid/timeblocks/preview.tsx @@ -1,5 +1,7 @@ -import { toZonedTime } from "date-fns-tz"; - +import { + getGridCoordinates, + getBaseCellClasses, +} from "@/features/event/grid/lib/timeslot-utils"; import TimeSlot from "@/features/event/grid/time-slot"; import BaseTimeBlock from "@/features/event/grid/timeblocks/base"; @@ -32,29 +34,21 @@ export default function PreviewTimeBlock({ > {timeslots.map((timeslot, timeslotIdx) => { const slotIso = timeslot.toISOString(); - const localSlot = toZonedTime(timeslot, userTimezone); - - const currentDayKey = localSlot.toLocaleDateString("en-CA"); - const dayIndex = visibleDayKeys.indexOf(currentDayKey); - if (dayIndex === -1) return null; - const gridColumn = dayIndex + 1; - const gridRow = - (localSlot.getHours() - startHour) * 4 + - Math.floor(localSlot.getMinutes() / 15) + - 1; + const coords = getGridCoordinates( + timeslot, + visibleDayKeys, + userTimezone, + startHour, + ); + if (!coords) return null; + const { row: gridRow, column: gridColumn } = coords; // borders - const cellClasses: string[] = []; - if (gridRow < numQuarterHours) { - cellClasses.push("border-b"); - - if (gridRow % 4 === 0) { - cellClasses.push("border-solid border-gray-400"); - } else { - cellClasses.push("border-dashed border-gray-400"); - } - } + const cellClasses: string[] = getBaseCellClasses( + gridRow, + numQuarterHours, + ); return ( {timeslots.map((timeslot, timeslotIdx) => { - const timeslotIso = format(timeslot, "yyyy-MM-dd'T'HH:mm:ss"); + const timeslotIso = timeslot.toISOString(); - const localSlot = toZonedTime(timeslot, userTimezone); - const localSlotIso = formatInTimeZone( + const coords = getGridCoordinates( timeslot, + visibleDayKeys, userTimezone, - "yyyy-MM-dd'T'HH:mm:ss", + startHour, ); - - const currentDayKey = localSlot.toLocaleDateString("en-CA"); - const dayIndex = visibleDayKeys.indexOf(currentDayKey); - if (dayIndex === -1) return null; - - const gridColumn = dayIndex + 1; - const gridRow = - (localSlot.getHours() - startHour) * 4 + - Math.floor(localSlot.getMinutes() / 15) + - 1; + if (!coords) return null; + const { row: gridRow, column: gridColumn } = coords; // borders - const cellClasses: string[] = ["cursor-default"]; - if (gridRow < numQuarterHours) { - cellClasses.push("border-b"); - - if (gridRow % 4 === 0) { - cellClasses.push("border-solid border-gray-400"); - } else { - cellClasses.push("border-dashed border-gray-400"); - } - } + const cellClasses: string[] = getBaseCellClasses( + gridRow, + numQuarterHours, + ); + cellClasses.push("cursor-default"); const matchCount = availabilities[timeslotIso]?.length > 0 @@ -91,7 +80,7 @@ export default function ResultsTimeBlock({ return ( p.type === "timeZoneName")?.value || ""; - - // Try to normalize to GMT+/-HH:MM - const match = offset.match(/GMT([+-]\d{1,2})(?::(\d{2}))?/); - const hours = match?.[1] ?? "0"; - const minutes = match?.[2] ?? "00"; - const fullOffset = `GMT${hours}:${minutes}`; - - const city = tz.split("/").slice(-1)[0].replaceAll("_", " "); - return `${city} (${fullOffset})`; - } catch { - return tz; - } -} +import { + findTimezoneLabel, + formatDateRange, + formatTimeRange, +} from "@/lib/utils/date-time-format"; export default function EventInfoDrawer({ eventRange, @@ -74,7 +50,7 @@ export function EventInfo({ eventRange }: { eventRange: EventRange }) { original event's timezone{" "} which is{" "} - {formatLabel(eventRange.timezone)} + {findTimezoneLabel(eventRange.timezone)}

@@ -82,8 +58,10 @@ export function EventInfo({ eventRange }: { eventRange: EventRange }) {
{eventRange.type === "specific" ? ( - {formatDate(eventRange.dateRange.from, "EEE, MMMM d")} {" - "} - {formatDate(eventRange.dateRange.to, "EEE, MMMM d")} + {formatDateRange( + eventRange.dateRange.from, + eventRange.dateRange.to, + )} ) : ( @@ -95,9 +73,7 @@ export function EventInfo({ eventRange }: { eventRange: EventRange }) { )} - {eventRange.timeRange.from === 0 && eventRange.timeRange.to === 24 - ? "Anytime" - : `${formatTime(eventRange.timeRange.from, "hh:mm a")} - ${formatTime(eventRange.timeRange.to, "hh:mm a")}`} + {formatTimeRange(eventRange.timeRange.from, eventRange.timeRange.to)} {eventRange.duration > 0 && ( @@ -124,15 +100,3 @@ function InfoRow({
); } - -// Helper functions to format date and time -function formatDate(date: string, fmt: string): string { - const parsedDate = parse(date, "yyyy-MM-dd", new Date()); - return format(parsedDate, fmt); -} - -function formatTime(hour: number, fmt: string): string { - const date = new Date(); - date.setHours(hour, 0, 0, 0); - return format(date, fmt); -} diff --git a/src/features/event/max-event-duration.ts b/src/features/event/max-event-duration.ts deleted file mode 100644 index 55e011e8..00000000 --- a/src/features/event/max-event-duration.ts +++ /dev/null @@ -1,7 +0,0 @@ -// 30 days in milliseconds -export const MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1000; -export const MAX_DURATION = "30 days"; - -export function isDurationExceedingMax(start: Date, end: Date): boolean { - return end.getTime() - start.getTime() > MAX_DURATION_MS; -} diff --git a/src/features/selector/components/drawer.tsx b/src/features/selector/components/drawer.tsx index 761973e3..e7c6dc27 100644 --- a/src/features/selector/components/drawer.tsx +++ b/src/features/selector/components/drawer.tsx @@ -46,6 +46,7 @@ export default function SelectorDrawer({ className={cn( "inline-flex items-center rounded-2xl text-start hover:cursor-pointer focus:outline-none", "bg-accent/15 hover:bg-accent/25 active:bg-accent/40 text-accent px-3 py-1", + open && "ring-accent ring-1", )} aria-label={`Select ${dialogTitle}`} > diff --git a/src/lib/messages.ts b/src/lib/messages.ts index f06060d3..1ba298d7 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -1,5 +1,7 @@ -import { MAX_TITLE_LENGTH } from "@/features/event/editor/validate-data"; -import { MAX_DURATION } from "@/features/event/max-event-duration"; +import { + MAX_TITLE_LENGTH, + MAX_DURATION, +} from "@/features/event/editor/validate-data"; export const MESSAGES = { // generic errors diff --git a/src/lib/utils/api/process-availability-data.ts b/src/lib/utils/api/process-availability-data.ts new file mode 100644 index 00000000..ef5ecd62 --- /dev/null +++ b/src/lib/utils/api/process-availability-data.ts @@ -0,0 +1,20 @@ +import { AvailabilityDataResponse } from "@/features/event/availability/fetch-data"; +import { formatDateTime } from "@/lib/utils/date-time-format"; + +export function processAvailabilityData( + availabilityData: AvailabilityDataResponse, +): AvailabilityDataResponse { + const availabilities = availabilityData.availability || {}; + + // convert all keys to ISO strings + const convertedAvailabilities: Record = {}; + for (const [key, value] of Object.entries(availabilities)) { + const isoKey = formatDateTime(key); + convertedAvailabilities[isoKey] = value; + } + + return { + ...availabilityData, + availability: convertedAvailabilities, + }; +} diff --git a/src/lib/utils/api/process-dashboard-data.ts b/src/lib/utils/api/process-dashboard-data.ts index e83dc56f..a7b3ef20 100644 --- a/src/lib/utils/api/process-dashboard-data.ts +++ b/src/lib/utils/api/process-dashboard-data.ts @@ -9,31 +9,17 @@ function processSingleEvent( myEvent: boolean, eventData: DashboardEventResponse, ): DashboardEventProps { - if (eventData.event_type === "Date") { - const data: DashboardEventProps = { - myEvent: myEvent, - code: eventData.event_code, - title: eventData.title, - type: "specific", - startHour: eventData.start_hour, - endHour: eventData.end_hour, - startDate: eventData.start_date, - endDate: eventData.end_date, - }; - return data; - } else { - const data: DashboardEventProps = { - myEvent: myEvent, - code: eventData.event_code, - title: eventData.title, - type: "weekday", - startHour: eventData.start_hour, - endHour: eventData.end_hour, - startWeekday: eventData.start_weekday, - endWeekday: eventData.end_weekday, - }; - return data; - } + const data: DashboardEventProps = { + myEvent: myEvent, + code: eventData.event_code, + title: eventData.title, + type: "specific", + startTime: eventData.start_time, + endTime: eventData.end_time, + startDate: eventData.start_date!, + endDate: eventData.end_date!, + }; + return data; } export function processDashboardData( diff --git a/src/lib/utils/api/process-event-data.ts b/src/lib/utils/api/process-event-data.ts index 5b0473fe..59b0f6eb 100644 --- a/src/lib/utils/api/process-event-data.ts +++ b/src/lib/utils/api/process-event-data.ts @@ -1,44 +1,62 @@ import { EventRange } from "@/core/event/types"; import { generateWeekdayMap } from "@/core/event/weekday-utils"; import { EventDetailsResponse } from "@/features/event/editor/fetch-data"; +import { + getZonedDetails, + parseIsoDateTime, +} from "@/lib/utils/date-time-format"; export function processEventData(eventData: EventDetailsResponse): { eventName: string; eventRange: EventRange; + timeslots: Date[]; + isCreator: boolean; } { const eventName: string = eventData.title; + const timeslots: Date[] = eventData.timeslots.map((ts) => { + return parseIsoDateTime(ts); + }); let eventRange: EventRange; + const start = getZonedDetails( + eventData.start_time, + eventData.start_date!, + eventData.time_zone, + ); + + const end = getZonedDetails( + eventData.end_time, + eventData.end_date!, + eventData.time_zone, + ); + if (eventData.event_type === "Date") { eventRange = { type: "specific", duration: eventData.duration || 0, timezone: eventData.time_zone, dateRange: { - from: eventData.start_date!, - to: eventData.end_date!, + from: start.date, + to: end.date, }, timeRange: { - from: eventData.start_hour, - to: eventData.end_hour, + from: start.time, + to: end.time, }, }; } else { - const weekdays = generateWeekdayMap( - eventData.start_weekday!, - eventData.end_weekday!, - ); + const weekdays = generateWeekdayMap(start.weekday, end.weekday); eventRange = { type: "weekday", duration: eventData.duration || 0, timezone: eventData.time_zone, weekdays: weekdays, timeRange: { - from: eventData.start_hour, - to: eventData.end_hour, + from: start.time, + to: end.time, }, }; } - return { eventName, eventRange }; + return { eventName, eventRange, timeslots, isCreator: eventData.is_creator }; } diff --git a/src/lib/utils/api/submit-event.ts b/src/lib/utils/api/submit-event.ts index adb0f509..ea7c80f2 100644 --- a/src/lib/utils/api/submit-event.ts +++ b/src/lib/utils/api/submit-event.ts @@ -1,9 +1,4 @@ -import { - EventRange, - SpecificDateRange, - WeekdayRange, -} from "@/core/event/types"; -import { findRangeFromWeekdayMap } from "@/core/event/weekday-utils"; +import { EventRange } from "@/core/event/types"; import { EventEditorType } from "@/features/event/editor/types"; import { MESSAGES } from "@/lib/messages"; import { formatApiError } from "@/lib/utils/api/handle-api-error"; @@ -12,29 +7,18 @@ export type EventSubmitData = { title: string; code: string; eventRange: EventRange; + timeslots: Date[]; }; type EventSubmitJsonBody = { title: string; duration?: number; time_zone: string; - start_hour: number; - end_hour: number; - start_date?: string; - end_date?: string; - start_weekday?: number; - end_weekday?: number; + timeslots: string[]; custom_code?: string; event_code?: string; }; -const formatDate = (date: Date): string => { - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, "0"); // Months are 0-indexed - const day = String(date.getUTCDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -}; - export default async function submitEvent( data: EventSubmitData, type: EventEditorType, @@ -43,51 +27,26 @@ export default async function submitEvent( handleError: (field: string, message: string) => void, ): Promise { let apiRoute = ""; - let jsonBody: EventSubmitJsonBody; if (eventType === "specific") { apiRoute = type === "new" ? "/api/event/date-create/" : "/api/event/date-edit/"; - - jsonBody = { - title: data.title, - time_zone: data.eventRange.timezone, - start_date: formatDate( - new Date((data.eventRange as SpecificDateRange).dateRange.from), - ), - end_date: formatDate( - new Date((data.eventRange as SpecificDateRange).dateRange.to), - ), - start_hour: data.eventRange.timeRange.from, - end_hour: data.eventRange.timeRange.to, - }; } else { apiRoute = type === "new" ? "/api/event/week-create/" : "/api/event/week-edit/"; + } - const weekdayRange = findRangeFromWeekdayMap( - (data.eventRange as WeekdayRange).weekdays, - ); - - const dayNameToIndex: { [key: string]: number } = { - Sun: 0, - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, - }; - jsonBody = { - title: data.title, - time_zone: data.eventRange.timezone, - start_weekday: dayNameToIndex[weekdayRange.startDay!], - end_weekday: dayNameToIndex[weekdayRange.endDay!], - start_hour: data.eventRange.timeRange.from, - end_hour: data.eventRange.timeRange.to, - }; + if (data.timeslots.length === 0) { + handleError("toast", "No valid timeslots generated for this range."); + return false; } + const jsonBody: EventSubmitJsonBody = { + title: data.title, + time_zone: data.eventRange.timezone, + timeslots: data.timeslots.map((d) => d.toISOString()), + }; + // only include duration if set if (data.eventRange.duration && data.eventRange.duration > 0) { jsonBody.duration = data.eventRange.duration; diff --git a/src/lib/utils/date-time-format.ts b/src/lib/utils/date-time-format.ts new file mode 100644 index 00000000..37b01f02 --- /dev/null +++ b/src/lib/utils/date-time-format.ts @@ -0,0 +1,138 @@ +import { format, parse, parseISO } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; + +/* TIMEZONE UTILS */ + +// expects a timezone value (e.g., "America/New_York") and returns +// its full label (e.g., "Eastern Daylight Time") +export function findTimezoneLabel(tzValue: string): string { + return formatInTimeZone(new Date(), tzValue, "zzzz"); +} + +// expects UTC time and date strings +// returns an object with time, date, and weekday number localized to the +// event's timezone. +// This is used to convert stored UTC times to the event's timezone and can be +// used on both the server and client side. +export function getZonedDetails( + utcTime: string, + utcDate: string, + timezone: string, +): { time: string; date: string; weekday: number } { + const utcIso = `${utcDate}T${utcTime}Z`; + const dateObj = new Date(utcIso); + + return { + time: formatInTimeZone(dateObj, timezone, "HH:mm"), // "09:00" + date: formatInTimeZone(dateObj, timezone, "yyyy-MM-dd"), // "2025-11-01" + weekday: parseInt(formatInTimeZone(dateObj, timezone, "i")) % 7, // 0-6 (Sun-Sat) + }; +} + +// expects UTC time and date strings +// returns an object with time, date, and weekday number in the local timezone. +// This is used to convert stored UTC times to the user's local timezone +// and is intended for client-side use only since it relies on the browser's timezone. +// If used on the server side, it will default to the server's timezone. +export function getLocalDetails( + utcTime: string, + utcDate: string, +): { time: string; date: string; weekday: number } { + const utcIsoString = `${utcDate}T${utcTime}Z`; + const dateObj = parseISO(utcIsoString); + + return { + time: format(dateObj, "HH:mm"), + date: format(dateObj, "yyyy-MM-dd"), + weekday: dateObj.getDay(), + }; +} + +/* + * DATETIME CONVERSION UTILS + * from python datetime string (ISO 8601) to Date object. + * + * Both function expect a datetime string without timezone information + * (e.g., "2024-01-15T10:30:00") and appends "Z" to interpret + * it as UTC, returning as a string or Date object respectively. + */ + +// return Date object +export function parseIsoDateTime(slotIso: string): Date { + return parseISO(slotIso + "Z"); +} + +// return ISO string +export function formatDateTime(timeslot: string): string { + return parseIsoDateTime(timeslot).toISOString(); +} + +/* DATE UTILS */ + +// expects two date strings in "YYYY-MM-DD" format +// returns a formatted date range string. +// If both dates are the same, return a single date. If both dates are +// in the same month, omit the month from the 'to' date. Otherwise, the +// full range is shown. +export function formatDateRange(fromDate: string, toDate: string): string { + const dateFormat = "MMMM d"; + const fromFormatted = formatDate(fromDate, dateFormat); + const toFormatted = formatDate(toDate, dateFormat); + + if (fromDate === toDate) { + return fromFormatted; + } else if (fromDate.slice(0, 7) === toDate.slice(0, 7)) { + const fromDay = parse(fromDate, "yyyy-MM-dd", new Date()).getDate(); + const toDay = parse(toDate, "yyyy-MM-dd", new Date()).getDate(); + const monthStr = formatDate(fromDate, "MMMM"); + return `${monthStr} ${fromDay}-${toDay}`; + } + return `${fromFormatted} - ${toFormatted}`; +} + +// expects a date string in "YYYY-MM-DD" format and a format string +// returns the formatted date string +export function formatDate(date: string, fmt: string): string { + const parsedDate = parse(date, "yyyy-MM-dd", new Date()); + return format(parsedDate, fmt); +} + +/* TIME UTILS */ + +// expects two time strings in "HH:mm" format +// returns a formatted time range string. +// If the time range is the full day (00:00 - 24:00), it returns "All day". +export function formatTimeRange(startTime: string, endTime: string): string { + if (!startTime || !endTime) return ""; + + if (startTime === "00:00" && endTime === "24:00") { + return "All day"; + } + + return `${formatTime(startTime)} - ${formatTime(endTime)}`; +} + +// expects a time string in "HH:mm" format +// returns the time formatted in "h:mm aaa" format (e.g., "2:30 PM") +export function formatTime(time: string): string { + const parsedDate = parse(time, "HH:mm", new Date()); + return format(parsedDate, "h:mm aaa"); +} + +// expects a time string in "HH:mm" (24-hour) format +// returns the time converted to "hh:mm AM/PM" (12-hour) format +export function convert24To12(time24: string): string { + if (!time24) return ""; + + const date = parse(time24, "HH:mm", new Date()); + return format(date, "hh:mm a"); +} + +// expects a time string in "hh:mm AM/PM" (12-hour) format +// returns the time converted to "HH:mm" (24-hour) format +export function convert12To24(time12: string): string { + if (!time12) return ""; + + const date = parse(time12, "hh:mm a", new Date()); + return format(date, "HH:mm"); +}