From d90e49e05f2e6d2d797ddf5b549879e06ea26d25 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:41:18 -0500 Subject: [PATCH 01/32] simplify expand event range logic --- .../event/grid/lib/expand-event-range.ts | 144 +++++++++++++----- 1 file changed, 107 insertions(+), 37 deletions(-) diff --git a/src/features/event/grid/lib/expand-event-range.ts b/src/features/event/grid/lib/expand-event-range.ts index 54fdaba..5b80f8d 100644 --- a/src/features/event/grid/lib/expand-event-range.ts +++ b/src/features/event/grid/lib/expand-event-range.ts @@ -1,4 +1,10 @@ -import { getHours, getMinutes } from "date-fns"; +import { + addDays, + addMinutes, + eachDayOfInterval, + isBefore, + parseISO, +} from "date-fns"; import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; import { @@ -12,6 +18,48 @@ import { isDurationExceedingMax } from "@/features/event/max-event-duration"; /* EXPAND EVENT RANGE UTILITIES */ +/** + * Helper: 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; +} + +/** + * Helper: 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: number; to: number }, +) { + // Construct ISO strings for the target timezone + const startStr = `${dateIsoStr}T${String(timeRange.from).padStart(2, "0")}:00:00`; + const startUTC = fromZonedTime(startStr, timezone); + + let endUTC: Date; + if (timeRange.to === 24) { + 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${String(timeRange.to).padStart(2, "0")}:00:00`; + endUTC = fromZonedTime(endStr, timezone); + } + + return { startUTC, endUTC }; +} + function getTimeStrings(timeRange: { from: number; to: number }) { const fromHour = String(timeRange.from).padStart(2, "0"); const toHour = @@ -114,64 +162,86 @@ export function expandEventRange(range: EventRange): Date[] { } 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); + // 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 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); + // Generate Slots + const slots: Date[] = []; + const days = eachDayOfInterval({ + start: parseISO(startDateStr), + end: parseISO(endDateStr), + }); - const isBeforeEndTime = - currentHour < validEndHour || - (currentHour === validEndHour && currentMinute < 0); + for (const day of days) { + const dayStr = day.toISOString().split("T")[0]; - if (isAfterStartTime && isBeforeEndTime) { - slots.push(new Date(currentUTC)); - } + const { startUTC, endUTC } = getDailyBoundariesInUTC( + dayStr, + range.timezone, + range.timeRange, + ); - currentUTC.setUTCMinutes(currentUTC.getUTCMinutes() + 15); + slots.push(...generateSlotsBetween(startUTC, endUTC)); } return slots; } function generateSlotsForWeekdayRange(range: WeekdayRange): Date[] { + if (range.type !== "weekday") return []; + const slots: Date[] = []; - if (range.type !== "weekday") { - return []; - } + const dayIndexMap: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + }; - const selectedDays = getSelectedWeekdaysInTimezone(range); - if (selectedDays.length === 0) { - return []; - } + // generic reference week starting on a Sunday + const referenceStart = new Date("2012-01-01T00:00:00"); - for (const day of selectedDays) { - const { slotTimeUTC, dayEndUTC } = day; + 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, + ); - while (slotTimeUTC < dayEndUTC) { - slots.push(new Date(slotTimeUTC)); - slotTimeUTC.setUTCMinutes(slotTimeUTC.getUTCMinutes() + 15); + slots.push(...generateSlotsBetween(startUTC, endUTC)); } } From 984c6f6fede18e9598967fba1a48dcd5413546d5 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:59:10 -0500 Subject: [PATCH 02/32] return day slots at once --- src/features/event/grid/grid.tsx | 10 ++--- .../event/grid/lib/expand-event-range.ts | 41 +++++++++++++---- .../event/grid/lib/use-generate-timeslots.ts | 44 +++---------------- 3 files changed, 44 insertions(+), 51 deletions(-) diff --git a/src/features/event/grid/grid.tsx b/src/features/event/grid/grid.tsx index 1134152..c00a52f 100644 --- a/src/features/event/grid/grid.tsx +++ b/src/features/event/grid/grid.tsx @@ -49,7 +49,7 @@ export default function ScheduleGrid({ }: ScheduleGridProps) { const isMobile = useCheckMobile(); - const { timeBlocks, dayGroupedSlots, numDays, error } = useGenerateTimeSlots( + const { timeBlocks, daySlots, numDays, error } = useGenerateTimeSlots( eventRange, timezone, ); @@ -61,13 +61,13 @@ export default function ScheduleGrid({ const startIndex = currentPage * maxDaysVisible; const endIndex = Math.min(startIndex + maxDaysVisible, numDays); - const visibleDays = dayGroupedSlots.slice(startIndex, endIndex); - const visibleTimeSlots = visibleDays.flatMap((day) => day.timeslots); - - if (numDays <= 0 || numDays > 30) + if (!daySlots || numDays <= 0 || numDays > 30) return ; if (error) return ; + const visibleDays = daySlots.slice(startIndex, endIndex); + const visibleTimeSlots = visibleDays.flatMap((day) => day.timeslots); + return (
= { Sun: 0, Mon: 1, @@ -241,9 +253,20 @@ function generateSlotsForWeekdayRange(range: WeekdayRange): Date[] { range.timeRange, ); - slots.push(...generateSlotsBetween(startUTC, endUTC)); + const slots = generateSlotsBetween(startUTC, endUTC); + + // get other metadata + const dayKey = dayStr; + const dayLabel = formatInTimeZone(currentDay, range.timezone, "EEE"); + + daySlots.push({ + date: currentDay, + dayKey, + dayLabel, + timeslots: slots, + }); } } - return slots; + return daySlots; } diff --git a/src/features/event/grid/lib/use-generate-timeslots.ts b/src/features/event/grid/lib/use-generate-timeslots.ts index a69d8ea..1c028e9 100644 --- a/src/features/event/grid/lib/use-generate-timeslots.ts +++ b/src/features/event/grid/lib/use-generate-timeslots.ts @@ -22,14 +22,18 @@ export default function useGenerateTimeSlots( }; } - const localStartTime = toZonedTime(daySlots[0], timezone); - const localEndTime = toZonedTime(daySlots[daySlots.length - 1], timezone); + const localStartTime = toZonedTime(daySlots[0].timeslots[0], timezone); + const localEndTime = toZonedTime( + daySlots[daySlots.length - 1].timeslots.slice(-1)[0], + timezone, + ); const localStartHour = localStartTime.getHours(); const localEndHour = localEndTime.getHours(); const timeBlocks = []; let numHours = 0; + // Handle overnight ranges if (localEndHour < localStartHour) { timeBlocks.push({ startHour: 0, endHour: localEndHour }); @@ -41,42 +45,8 @@ export default function useGenerateTimeSlots( numHours += localEndHour - localStartHour; } - const dayGroupedSlots = Array.from( - daySlots - .reduce((daysMap, slot) => { - const zonedDate = toZonedTime(slot, timezone); - const dayKey = zonedDate.toLocaleDateString("en-CA"); - - if (!daysMap.has(dayKey)) { - let dayLabel = ""; - if (eventRange.type === "weekday") { - dayLabel = zonedDate - .toLocaleDateString("en-US", { - weekday: "short", - }) - .toUpperCase() as keyof typeof eventRange.weekdays; - } else { - dayLabel = zonedDate.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - }); - } - daysMap.set(dayKey, { - dayKey, - dayLabel, - date: zonedDate, - timeslots: [], - }); - } - daysMap.get(dayKey)!.timeslots.push(slot); - return daysMap; - }, new Map()) - .values(), - ); - const numDays = differenceInCalendarDays(localEndTime, localStartTime) + 1; - return { timeBlocks, dayGroupedSlots, numDays, numHours, error: null }; + return { timeBlocks, daySlots, numDays, numHours, error: null }; }, [eventRange, timezone]); } From 04bbfe66242f05545b1bbfdce9d514e708de034a Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:07:32 -0500 Subject: [PATCH 03/32] Revert "return day slots at once" This reverts commit 984c6f6fede18e9598967fba1a48dcd5413546d5. --- src/features/event/grid/grid.tsx | 10 ++--- .../event/grid/lib/expand-event-range.ts | 41 ++++------------- .../event/grid/lib/use-generate-timeslots.ts | 44 ++++++++++++++++--- 3 files changed, 51 insertions(+), 44 deletions(-) diff --git a/src/features/event/grid/grid.tsx b/src/features/event/grid/grid.tsx index c00a52f..1134152 100644 --- a/src/features/event/grid/grid.tsx +++ b/src/features/event/grid/grid.tsx @@ -49,7 +49,7 @@ export default function ScheduleGrid({ }: ScheduleGridProps) { const isMobile = useCheckMobile(); - const { timeBlocks, daySlots, numDays, error } = useGenerateTimeSlots( + const { timeBlocks, dayGroupedSlots, numDays, error } = useGenerateTimeSlots( eventRange, timezone, ); @@ -61,13 +61,13 @@ export default function ScheduleGrid({ const startIndex = currentPage * maxDaysVisible; const endIndex = Math.min(startIndex + maxDaysVisible, numDays); - if (!daySlots || numDays <= 0 || numDays > 30) + const visibleDays = dayGroupedSlots.slice(startIndex, endIndex); + const visibleTimeSlots = visibleDays.flatMap((day) => day.timeslots); + + if (numDays <= 0 || numDays > 30) return ; if (error) return ; - const visibleDays = daySlots.slice(startIndex, endIndex); - const visibleTimeSlots = visibleDays.flatMap((day) => day.timeslots); - return (
= { Sun: 0, Mon: 1, @@ -253,20 +241,9 @@ function generateSlotsForWeekdayRange(range: WeekdayRange): DaySlot[] { range.timeRange, ); - const slots = generateSlotsBetween(startUTC, endUTC); - - // get other metadata - const dayKey = dayStr; - const dayLabel = formatInTimeZone(currentDay, range.timezone, "EEE"); - - daySlots.push({ - date: currentDay, - dayKey, - dayLabel, - timeslots: slots, - }); + slots.push(...generateSlotsBetween(startUTC, endUTC)); } } - return daySlots; + return slots; } diff --git a/src/features/event/grid/lib/use-generate-timeslots.ts b/src/features/event/grid/lib/use-generate-timeslots.ts index 1c028e9..a69d8ea 100644 --- a/src/features/event/grid/lib/use-generate-timeslots.ts +++ b/src/features/event/grid/lib/use-generate-timeslots.ts @@ -22,18 +22,14 @@ export default function useGenerateTimeSlots( }; } - const localStartTime = toZonedTime(daySlots[0].timeslots[0], timezone); - const localEndTime = toZonedTime( - daySlots[daySlots.length - 1].timeslots.slice(-1)[0], - timezone, - ); + const localStartTime = toZonedTime(daySlots[0], timezone); + const localEndTime = toZonedTime(daySlots[daySlots.length - 1], timezone); const localStartHour = localStartTime.getHours(); const localEndHour = localEndTime.getHours(); const timeBlocks = []; let numHours = 0; - // Handle overnight ranges if (localEndHour < localStartHour) { timeBlocks.push({ startHour: 0, endHour: localEndHour }); @@ -45,8 +41,42 @@ export default function useGenerateTimeSlots( numHours += localEndHour - localStartHour; } + const dayGroupedSlots = Array.from( + daySlots + .reduce((daysMap, slot) => { + const zonedDate = toZonedTime(slot, timezone); + const dayKey = zonedDate.toLocaleDateString("en-CA"); + + if (!daysMap.has(dayKey)) { + let dayLabel = ""; + if (eventRange.type === "weekday") { + dayLabel = zonedDate + .toLocaleDateString("en-US", { + weekday: "short", + }) + .toUpperCase() as keyof typeof eventRange.weekdays; + } else { + dayLabel = zonedDate.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + } + daysMap.set(dayKey, { + dayKey, + dayLabel, + date: zonedDate, + timeslots: [], + }); + } + daysMap.get(dayKey)!.timeslots.push(slot); + return daysMap; + }, new Map()) + .values(), + ); + const numDays = differenceInCalendarDays(localEndTime, localStartTime) + 1; - return { timeBlocks, daySlots, numDays, numHours, error: null }; + return { timeBlocks, dayGroupedSlots, numDays, numHours, error: null }; }, [eventRange, timezone]); } From dc4cadfe45313615aab37a923777d54c66cfcaf5 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:29:22 -0500 Subject: [PATCH 04/32] add timeslots to reducer state --- src/core/availability/utils.ts | 8 +-- .../event}/lib/expand-event-range.ts | 0 src/core/event/reducers/info-reducer.ts | 42 +++++++----- src/core/event/types.ts | 1 + src/core/event/use-event-info.ts | 29 ++++---- src/features/event/editor/editor.tsx | 7 +- src/features/event/grid/grid.tsx | 3 + .../event/grid/lib/use-generate-timeslots.ts | 13 ++-- src/features/event/grid/preview-dialog.tsx | 4 ++ src/lib/utils/api/submit-event.ts | 67 ++++--------------- 10 files changed, 76 insertions(+), 98 deletions(-) rename src/{features/event/grid => core/event}/lib/expand-event-range.ts (100%) diff --git a/src/core/availability/utils.ts b/src/core/availability/utils.ts index 9e90815..fa0478b 100644 --- a/src/core/availability/utils.ts +++ b/src/core/availability/utils.ts @@ -1,15 +1,15 @@ import { eachDayOfInterval, parseISO } from "date-fns"; import { AvailabilitySet } from "@/core/availability/types"; +import { + getAbsoluteDateRangeInUTC, + getSelectedWeekdaysInTimezone, +} from "@/core/event/lib/expand-event-range"; 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 => { diff --git a/src/features/event/grid/lib/expand-event-range.ts b/src/core/event/lib/expand-event-range.ts similarity index 100% rename from src/features/event/grid/lib/expand-event-range.ts rename to src/core/event/lib/expand-event-range.ts diff --git a/src/core/event/reducers/info-reducer.ts b/src/core/event/reducers/info-reducer.ts index 4cf1656..d9c7eae 100644 --- a/src/core/event/reducers/info-reducer.ts +++ b/src/core/event/reducers/info-reducer.ts @@ -1,3 +1,4 @@ +import { expandEventRange } from "@/core/event/lib/expand-event-range"; import { EventRangeReducer, EventRangeAction, @@ -26,30 +27,37 @@ export function EventInfoReducer( customCode: action.payload, }; case "RESET": + const defaultRange = { + type: "specific" as const, + duration: 60, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + dateRange: { + from: new Date().toISOString(), + to: new Date().toISOString(), + }, + timeRange: { from: 9, to: 17 }, + }; + return { title: "", customCode: "", - eventRange: { - type: "specific", - duration: 60, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - dateRange: { - from: new Date().toISOString(), - to: new Date().toISOString(), - }, - timeRange: { - from: 9, - to: 17, - }, - }, + eventRange: defaultRange, + timeslots: expandEventRange(defaultRange), }; 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/types.ts b/src/core/event/types.ts index 43d45ac..5b9b90c 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 diff --git a/src/core/event/use-event-info.ts b/src/core/event/use-event-info.ts index 9982bc5..17e2d96 100644 --- a/src/core/event/use-event-info.ts +++ b/src/core/event/use-event-info.ts @@ -2,6 +2,7 @@ import { useMemo, useReducer, useCallback } from "react"; import { DateRange } from "react-day-picker"; +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"; @@ -13,22 +14,24 @@ const checkTimeRange = (from: number, to: number): boolean => { }; function createInitialState(initialData?: EventInformation): EventInformation { + const defaultRange = { + type: "specific" as const, + duration: 60, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + dateRange: { + from: new Date().toISOString(), + to: new Date().toISOString(), + }, + timeRange: { from: 9, to: 17 }, + }; + 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().toISOString(), - }, - timeRange: { - from: 9, - to: 17, - }, - }, + eventRange: initialData?.eventRange || defaultRange, + timeslots: + initialData?.timeslots || + expandEventRange(initialData?.eventRange || defaultRange), }; } diff --git a/src/features/event/editor/editor.tsx b/src/features/event/editor/editor.tsx index 73780da..795838b 100644 --- a/src/features/event/editor/editor.tsx +++ b/src/features/event/editor/editor.tsx @@ -55,7 +55,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { handleGenericError, batchHandleErrors, } = useEventContext(); - const { title, customCode, eventRange } = state; + const { title, customCode, eventRange, timeslots } = state; const router = useRouter(); const [mobileTab, setMobileTab] = useState("details"); @@ -72,7 +72,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}`), @@ -180,7 +180,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) {
- +
@@ -195,6 +195,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { eventRange={eventRange} disableSelect={true} timezone={eventRange.timezone} + timeslots={timeslots} />
diff --git a/src/features/event/grid/grid.tsx b/src/features/event/grid/grid.tsx index 1134152..43b1b07 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/use-generate-timeslots.ts b/src/features/event/grid/lib/use-generate-timeslots.ts index a69d8ea..c9d6b0b 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 5e75f20..e837729 100644 --- a/src/features/event/grid/preview-dialog.tsx +++ b/src/features/event/grid/preview-dialog.tsx @@ -12,10 +12,12 @@ import { cn } from "@/lib/utils/classname"; 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 +91,7 @@ export default function GridPreviewDialog({ eventRange={eventRange} disableSelect timezone={timezone} + timeslots={timeslots} />
- {formatTimeRange(startHour, endHour)} + {formatTimeRange(startTime, endTime)}
@@ -77,19 +78,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/event/editor/editor.tsx b/src/features/event/editor/editor.tsx index 795838b..8374091 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,8 +46,6 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { const { state, setTitle, - setStartTime, - setEndTime, errors, handleError, clearAllErrors, @@ -158,21 +155,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { {errors.timeRange && }

- - - - - - - +
diff --git a/src/features/event/editor/time-range/selector.tsx b/src/features/event/editor/time-range/selector.tsx index bb71d30..0827fe6 100644 --- a/src/features/event/editor/time-range/selector.tsx +++ b/src/features/event/editor/time-range/selector.tsx @@ -1,63 +1,38 @@ -"use client"; +import { useEventContext } from "@/core/event/context"; +import TimePicker from "@/features/event/editor/time-range/time-picker"; +import FormSelectorField from "@/features/selector/components/selector-field"; +import { cn } from "@/lib/utils/classname"; -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)", - }, - }; +export default function TimeRangeSelection({}) { + const { state, setStartTime, setEndTime } = useEventContext(); + const { from: startTime, to: endTime } = state.eventRange.timeRange; return ( - -
-
- -
- - - : - - - -
-
- +
+ + + + + + + + +
); } 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 0000000..7e0a925 --- /dev/null +++ b/src/features/event/editor/time-range/time-picker.tsx @@ -0,0 +1,86 @@ +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; +}; + +export default function TimePicker({ + time, + onTimeChange, + visibleCount = 3, +}: 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: "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)", + }, + }; + + return ( + +
+
+ +
+ + + : + + + +
+
+ + ); +} diff --git a/src/features/event/grid/preview-dialog.tsx b/src/features/event/grid/preview-dialog.tsx index e837729..198e044 100644 --- a/src/features/event/grid/preview-dialog.tsx +++ b/src/features/event/grid/preview-dialog.tsx @@ -9,6 +9,7 @@ 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; @@ -110,7 +111,7 @@ export default function GridPreviewDialog({
diff --git a/src/features/event/info-drawer.tsx b/src/features/event/info-drawer.tsx index 12be9c2..3c5e5d9 100644 --- a/src/features/event/info-drawer.tsx +++ b/src/features/event/info-drawer.tsx @@ -2,37 +2,13 @@ import * as Dialog from "@radix-ui/react-dialog"; import { InfoCircledIcon } from "@radix-ui/react-icons"; -import { format } from "date-fns/format"; -import { parse } from "date-fns/parse"; import { EventRange } from "@/core/event/types"; - -function formatLabel(tz: string): string { - try { - const now = new Date(); - const offset = - new Intl.DateTimeFormat("en-US", { - timeZone: tz, - hour12: false, - hour: "2-digit", - minute: "2-digit", - timeZoneName: "shortOffset", - }) - .formatToParts(now) - .find((p) => 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/lib/utils/api/process-dashboard-data.ts b/src/lib/utils/api/process-dashboard-data.ts index 5118e32..17739a5 100644 --- a/src/lib/utils/api/process-dashboard-data.ts +++ b/src/lib/utils/api/process-dashboard-data.ts @@ -5,18 +5,12 @@ import { DashboardResponse, } from "@/features/dashboard/fetch-data"; -const timeToHour = (timeStr: string): number => { - if (!timeStr) return 0; - const [hours, minutes] = timeStr.split(":").map(Number); - return hours + minutes / 60; -}; - function processSingleEvent( myEvent: boolean, eventData: DashboardEventResponse, ): DashboardEventProps { - const startHour = timeToHour(eventData.start_time); - const endHour = timeToHour(eventData.end_time); + const startTime = eventData.start_time.substring(0, 5); + const endTime = eventData.end_time.substring(0, 5); if (eventData.event_type === "Date") { const data: DashboardEventProps = { @@ -24,8 +18,8 @@ function processSingleEvent( code: eventData.event_code, title: eventData.title, type: "specific", - startHour: startHour, - endHour: endHour, + startTime: startTime, + endTime: endTime, startDate: eventData.start_date, endDate: eventData.end_date, }; @@ -39,8 +33,8 @@ function processSingleEvent( code: eventData.event_code, title: eventData.title, type: "weekday", - startHour: startHour, - endHour: endHour, + startTime: startTime, + endTime: endTime, startWeekday: startWeekday, endWeekday: endWeekday, }; diff --git a/src/lib/utils/api/process-event-data.ts b/src/lib/utils/api/process-event-data.ts index be053ce..5eb8f94 100644 --- a/src/lib/utils/api/process-event-data.ts +++ b/src/lib/utils/api/process-event-data.ts @@ -4,12 +4,6 @@ import { EventRange } from "@/core/event/types"; import { generateWeekdayMap } from "@/core/event/weekday-utils"; import { EventDetailsResponse } from "@/features/event/editor/fetch-data"; -const timeToHour = (timeStr: string): number => { - if (!timeStr) return 0; - const [hours, minutes] = timeStr.split(":").map(Number); - return hours + minutes / 60; -}; - export function processEventData(eventData: EventDetailsResponse): { eventName: string; eventRange: EventRange; @@ -21,8 +15,8 @@ export function processEventData(eventData: EventDetailsResponse): { }); let eventRange: EventRange; - const startHour = timeToHour(eventData.start_time); - const endHour = timeToHour(eventData.end_time); + const startTime = eventData.start_time.substring(0, 5); + const endTime = eventData.end_time.substring(0, 5); if (eventData.event_type === "Date") { eventRange = { @@ -34,8 +28,8 @@ export function processEventData(eventData: EventDetailsResponse): { to: eventData.end_date!, }, timeRange: { - from: startHour, - to: endHour, + from: startTime, + to: endTime, }, }; } else { @@ -50,8 +44,8 @@ export function processEventData(eventData: EventDetailsResponse): { timezone: eventData.time_zone, weekdays: weekdays, timeRange: { - from: startHour, - to: endHour, + from: startTime, + to: endTime, }, }; } diff --git a/src/lib/utils/date-time-format.ts b/src/lib/utils/date-time-format.ts new file mode 100644 index 0000000..f0fc2a2 --- /dev/null +++ b/src/lib/utils/date-time-format.ts @@ -0,0 +1,62 @@ +import { format, parse } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; + +/* TIMEZONE UTILS */ + +export function findTimezoneLabel(tzValue: string): string { + return formatInTimeZone(new Date(), tzValue, "zzzz"); +} + +/* DATE UTILS */ + +export function formatDateRange(fromDate: string, toDate: string): string { + const format = "MMMM d"; + const fromFormatted = formatDate(fromDate, format); + const toFormatted = formatDate(toDate, format); + + 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}`; +} + +export function formatDate(date: string, fmt: string): string { + const parsedDate = parse(date, "yyyy-MM-dd", new Date()); + return format(parsedDate, fmt); +} + +/* TIME UTILS */ + +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)}`; +} + +export function formatTime(time: string): string { + const parsedDate = parse(time, "HH:mm", new Date()); + return format(parsedDate, "h:mm aaa"); +} + +export function convert24To12(time24: string): string { + if (!time24) return ""; + + const date = parse(time24, "HH:mm", new Date()); + return format(date, "hh:mm a"); +} + +export function convert12To24(time12: string): string { + if (!time12) return ""; + + const date = parse(time12, "hh:mm a", new Date()); + return format(date, "HH:mm"); +} From 8fe32e7ff6e64c31a0fc40733665f5869596952d Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:53:03 -0500 Subject: [PATCH 10/32] formatApiTime --- src/lib/utils/api/process-dashboard-data.ts | 5 +++-- src/lib/utils/api/process-event-data.ts | 5 +++-- src/lib/utils/date-time-format.ts | 7 +++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/lib/utils/api/process-dashboard-data.ts b/src/lib/utils/api/process-dashboard-data.ts index 17739a5..c973538 100644 --- a/src/lib/utils/api/process-dashboard-data.ts +++ b/src/lib/utils/api/process-dashboard-data.ts @@ -4,13 +4,14 @@ import { DashboardEventResponse, DashboardResponse, } from "@/features/dashboard/fetch-data"; +import { formatApiTime } from "@/lib/utils/date-time-format"; function processSingleEvent( myEvent: boolean, eventData: DashboardEventResponse, ): DashboardEventProps { - const startTime = eventData.start_time.substring(0, 5); - const endTime = eventData.end_time.substring(0, 5); + const startTime = formatApiTime(eventData.start_time, eventData.time_zone); + const endTime = formatApiTime(eventData.end_time, eventData.time_zone); if (eventData.event_type === "Date") { const data: DashboardEventProps = { diff --git a/src/lib/utils/api/process-event-data.ts b/src/lib/utils/api/process-event-data.ts index 5eb8f94..6cb00e6 100644 --- a/src/lib/utils/api/process-event-data.ts +++ b/src/lib/utils/api/process-event-data.ts @@ -3,6 +3,7 @@ import { parseISO } from "date-fns"; import { EventRange } from "@/core/event/types"; import { generateWeekdayMap } from "@/core/event/weekday-utils"; import { EventDetailsResponse } from "@/features/event/editor/fetch-data"; +import { formatApiTime } from "@/lib/utils/date-time-format"; export function processEventData(eventData: EventDetailsResponse): { eventName: string; @@ -15,8 +16,8 @@ export function processEventData(eventData: EventDetailsResponse): { }); let eventRange: EventRange; - const startTime = eventData.start_time.substring(0, 5); - const endTime = eventData.end_time.substring(0, 5); + const startTime = formatApiTime(eventData.start_time, eventData.time_zone); + const endTime = formatApiTime(eventData.end_time, eventData.time_zone); if (eventData.event_type === "Date") { eventRange = { diff --git a/src/lib/utils/date-time-format.ts b/src/lib/utils/date-time-format.ts index f0fc2a2..5212cac 100644 --- a/src/lib/utils/date-time-format.ts +++ b/src/lib/utils/date-time-format.ts @@ -32,6 +32,13 @@ export function formatDate(date: string, fmt: string): string { /* TIME UTILS */ +export function formatApiTime(apiTime: string, eventTimezone: string): string { + const todayDate = format(new Date(), "yyyy-MM-dd"); + const isoString = `${todayDate}T${apiTime}Z`; + const localDate = new Date(isoString); + return formatInTimeZone(localDate, eventTimezone, "HH:mm"); +} + export function formatTimeRange(startTime: string, endTime: string): string { if (!startTime || !endTime) return ""; From f17f5b1b666873b398fa054b1351c67a9ac4bdc6 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:01:50 -0500 Subject: [PATCH 11/32] add the creator --- src/app/(event)/[event-code]/edit/page.tsx | 9 +++++++-- src/app/(event)/[event-code]/page-client.tsx | 3 ++- src/app/(event)/[event-code]/page.tsx | 3 ++- src/features/event/availability/fetch-data.ts | 1 - src/features/event/editor/fetch-data.ts | 1 + src/lib/utils/api/process-event-data.ts | 3 ++- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app/(event)/[event-code]/edit/page.tsx b/src/app/(event)/[event-code]/edit/page.tsx index 8ffb002..fd65dda 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 58b0f94..dc1f3e6 100644 --- a/src/app/(event)/[event-code]/page-client.tsx +++ b/src/app/(event)/[event-code]/page-client.tsx @@ -20,12 +20,14 @@ export default function ClientPage({ eventRange, timeslots, initialAvailabilityData, + isCreator, }: { eventCode: string; eventName: string; eventRange: EventRange; timeslots: Date[]; initialAvailabilityData: AvailabilityDataResponse; + isCreator: boolean; }) { const [timezone, setTimezone] = useState( Intl.DateTimeFormat().resolvedOptions().timeZone, @@ -38,7 +40,6 @@ export default function ClientPage({ 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 || {}; diff --git a/src/app/(event)/[event-code]/page.tsx b/src/app/(event)/[event-code]/page.tsx index d2f37f6..1a243a8 100644 --- a/src/app/(event)/[event-code]/page.tsx +++ b/src/app/(event)/[event-code]/page.tsx @@ -20,7 +20,7 @@ export default async function Page({ params }: EventCodePageProps) { fetchAvailabilityData(eventCode, authCookies), ]); - const { eventName, eventRange, timeslots } = + const { eventName, eventRange, timeslots, isCreator } = processEventData(initialEventData); return ( @@ -30,6 +30,7 @@ export default async function Page({ params }: EventCodePageProps) { eventRange={eventRange} timeslots={timeslots} initialAvailabilityData={availabilityData} + isCreator={isCreator} /> ); } diff --git a/src/features/event/availability/fetch-data.ts b/src/features/event/availability/fetch-data.ts index e3d0a1c..0b66cb0 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/fetch-data.ts b/src/features/event/editor/fetch-data.ts index e874628..6025a84 100644 --- a/src/features/event/editor/fetch-data.ts +++ b/src/features/event/editor/fetch-data.ts @@ -5,6 +5,7 @@ export type EventDetailsResponse = { duration?: number; time_zone: string; timeslots: string[]; + is_creator: boolean; event_type: "Date" | "Week"; start_date?: string; end_date?: string; diff --git a/src/lib/utils/api/process-event-data.ts b/src/lib/utils/api/process-event-data.ts index 6cb00e6..a0b1697 100644 --- a/src/lib/utils/api/process-event-data.ts +++ b/src/lib/utils/api/process-event-data.ts @@ -9,6 +9,7 @@ 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) => { @@ -51,5 +52,5 @@ export function processEventData(eventData: EventDetailsResponse): { }; } - return { eventName, eventRange, timeslots }; + return { eventName, eventRange, timeslots, isCreator: eventData.is_creator }; } From fb8cdf19d73adff9c35c0652a2ca3e93e1ec4467 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:46:50 -0500 Subject: [PATCH 12/32] implement ios picker --- .../event/editor/advanced-options.tsx | 2 +- .../event/editor/date-range/popover.tsx | 11 ++- .../date-range/specific-date-display.tsx | 13 ++- src/features/event/editor/editor.tsx | 6 +- .../event/editor/time-range/selector.tsx | 99 ++++++++++++++----- .../event/editor/time-range/time-picker.tsx | 4 +- 6 files changed, 101 insertions(+), 34 deletions(-) diff --git a/src/features/event/editor/advanced-options.tsx b/src/features/event/editor/advanced-options.tsx index fb5fb45..1bc78d7 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/specific-date-display.tsx b/src/features/event/editor/date-range/specific-date-display.tsx index 683c18f..d44969c 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,16 @@ +import { cn } from "@poursha98/react-ios-time-picker"; import { format } from "date-fns"; 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 +23,10 @@ export default function SpecificDateRangeDisplay({ {/* Start Date */}

FROM

- + {displayFrom}
@@ -30,7 +36,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 8374091..729df8d 100644 --- a/src/features/event/editor/editor.tsx +++ b/src/features/event/editor/editor.tsx @@ -143,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", )} > @@ -154,11 +154,11 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { Possible Times {errors.timeRange && }

-
+
-
+
diff --git a/src/features/event/editor/time-range/selector.tsx b/src/features/event/editor/time-range/selector.tsx index 0827fe6..a8537ad 100644 --- a/src/features/event/editor/time-range/selector.tsx +++ b/src/features/event/editor/time-range/selector.tsx @@ -1,38 +1,87 @@ +import { useState } from "react"; + +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 FormSelectorField from "@/features/selector/components/selector-field"; +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 index 7e0a925..b010b19 100644 --- a/src/features/event/editor/time-range/time-picker.tsx +++ b/src/features/event/editor/time-range/time-picker.tsx @@ -13,12 +13,14 @@ type TimePickerProps = { 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)); @@ -36,7 +38,7 @@ export default function TimePicker({ const wheelStyle = { root: { display: "flex", width: "fit-content", padding: "0 8px" }, - item: { fontSize: "16px" }, + item: { fontSize: `${fontSize}px` }, overlayTop: { background: "linear-gradient(to bottom, color-mix(in srgb, var(--color-background), transparent 20%) 5%, transparent)", From 1de4a896f4215bfcb73f6c323333a4f70d8d2812 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:32:53 -0500 Subject: [PATCH 13/32] add disabled state for dropdown --- src/features/selector/components/dropdown.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/selector/components/dropdown.tsx b/src/features/selector/components/dropdown.tsx index 7981daa..ffa2392 100644 --- a/src/features/selector/components/dropdown.tsx +++ b/src/features/selector/components/dropdown.tsx @@ -29,6 +29,8 @@ export default function Dropdown({ "text-accent inline-flex items-center rounded-2xl text-start hover:cursor-pointer focus:outline-none", disabled && "text-foreground/50 cursor-not-allowed", "bg-accent/15 hover:bg-accent/25 active:bg-accent/40 text-accent px-3 py-1", + disabled && + "cursor-not-allowed bg-gray-200/50 text-gray-500 hover:cursor-not-allowed hover:bg-gray-200/50 active:bg-gray-200/50", className, )} aria-label="Custom select" From 62f3c5e33bc5d3951b111d279962a4a50e6fef62 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:36:49 -0500 Subject: [PATCH 14/32] Update page-client.tsx --- src/app/(event)/[event-code]/painting/page-client.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/(event)/[event-code]/painting/page-client.tsx b/src/app/(event)/[event-code]/painting/page-client.tsx index aecfa5c..66fd4f3 100644 --- a/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/src/app/(event)/[event-code]/painting/page-client.tsx @@ -135,14 +135,18 @@ export default function ClientPage({ const cancelButton = ( ); const submitButton = ( From 0c3f964f48e2eaee63c8f2800ddf4fdb2fc92d2e Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:43:18 -0500 Subject: [PATCH 15/32] three day range --- src/core/event/lib/default-range.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/event/lib/default-range.ts b/src/core/event/lib/default-range.ts index c3eb38b..7b662ac 100644 --- a/src/core/event/lib/default-range.ts +++ b/src/core/event/lib/default-range.ts @@ -8,7 +8,7 @@ export const DEFAULT_RANGE_SPECIFIC: SpecificDateRange = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, dateRange: { from: new Date().toISOString(), - to: new Date().toISOString(), + to: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), }, timeRange: defaultTimeRange, }; @@ -17,6 +17,6 @@ export const DEFAULT_RANGE_WEEKDAY: WeekdayRange = { type: "weekday" as const, duration: 30, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - weekdays: { Sun: 0, Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0 }, + weekdays: { Sun: 0, Mon: 1, Tue: 1, Wed: 1, Thu: 0, Fri: 0, Sat: 0 }, timeRange: defaultTimeRange, }; From 62d8618776f309c5ababff22f74ea51d3bec9458 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:18:03 -0500 Subject: [PATCH 16/32] add ring to selectors --- src/features/event/editor/date-range/drawer.tsx | 6 +++++- src/features/selector/components/drawer.tsx | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/features/event/editor/date-range/drawer.tsx b/src/features/event/editor/date-range/drawer.tsx index 0071704..97b7018 100644 --- a/src/features/event/editor/date-range/drawer.tsx +++ b/src/features/event/editor/date-range/drawer.tsx @@ -28,7 +28,11 @@ export default function DateRangeDrawer({ return ( - + diff --git a/src/features/selector/components/drawer.tsx b/src/features/selector/components/drawer.tsx index 761973e..e7c6dc2 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}`} > From 86e5dd451b55c06948d1aca954036b0f16085adc Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:12:03 -0500 Subject: [PATCH 17/32] Update src/core/event/lib/expand-event-range.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/core/event/lib/expand-event-range.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/event/lib/expand-event-range.ts b/src/core/event/lib/expand-event-range.ts index 23910f7..25866b7 100644 --- a/src/core/event/lib/expand-event-range.ts +++ b/src/core/event/lib/expand-event-range.ts @@ -59,8 +59,8 @@ function getDailyBoundariesInUTC( } /** - * expands a high-level EventRange into a concrete list of days and time slots - * for the user's local timezone + * 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") { From c704935abd4571cf37572537fde1decf3a774a7e Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:53:49 -0500 Subject: [PATCH 18/32] Update src/features/event/editor/date-range/specific-date-display.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/features/event/editor/date-range/specific-date-display.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d44969c..d76a291 100644 --- a/src/features/event/editor/date-range/specific-date-display.tsx +++ b/src/features/event/editor/date-range/specific-date-display.tsx @@ -1,4 +1,4 @@ -import { cn } from "@poursha98/react-ios-time-picker"; +import { cn } from "@/lib/utils/classname"; import { format } from "date-fns"; type SpecificDateRangeDisplayProps = { From 16f0db0e8fb2ea34650c9a20198cac2173f79a3d Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:55:24 -0500 Subject: [PATCH 19/32] centralize time and date range validation --- src/core/event/lib/expand-event-range.ts | 4 +-- src/core/event/use-event-info.ts | 18 ++++-------- src/features/event/editor/validate-data.ts | 32 +++++++++++++++------- src/features/event/max-event-duration.ts | 7 ----- src/lib/messages.ts | 6 ++-- src/lib/utils/date-time-format.ts | 6 ++-- 6 files changed, 37 insertions(+), 36 deletions(-) delete mode 100644 src/features/event/max-event-duration.ts diff --git a/src/core/event/lib/expand-event-range.ts b/src/core/event/lib/expand-event-range.ts index 23910f7..a88f8c7 100644 --- a/src/core/event/lib/expand-event-range.ts +++ b/src/core/event/lib/expand-event-range.ts @@ -12,7 +12,7 @@ import { SpecificDateRange, WeekdayRange, } from "@/core/event/types"; -import { isDurationExceedingMax } from "@/features/event/max-event-duration"; +import { checkDateRange } from "@/features/event/editor/validate-data"; /* EXPAND EVENT RANGE UTILITIES */ @@ -90,7 +90,7 @@ function generateSlotsForSpecificRange(range: SpecificDateRange): Date[] { range.timeRange, ); - if (isDurationExceedingMax(eventStartUTC, eventEndUTC)) { + if (checkDateRange(eventStartUTC, eventEndUTC)) { return []; } diff --git a/src/core/event/use-event-info.ts b/src/core/event/use-event-info.ts index fcaae89..c3c1063 100644 --- a/src/core/event/use-event-info.ts +++ b/src/core/event/use-event-info.ts @@ -6,19 +6,13 @@ 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 = (startTime: string, endTime: string): boolean => { - 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; -}; - function createInitialState(initialData?: EventInformation): EventInformation { return { title: initialData?.title || "", @@ -107,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/event/editor/validate-data.ts b/src/features/event/editor/validate-data.ts index c759060..8847ae5 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/max-event-duration.ts b/src/features/event/max-event-duration.ts deleted file mode 100644 index 55e011e..0000000 --- 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/lib/messages.ts b/src/lib/messages.ts index f06060d..1ba298d 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/date-time-format.ts b/src/lib/utils/date-time-format.ts index 5212cac..3447bbc 100644 --- a/src/lib/utils/date-time-format.ts +++ b/src/lib/utils/date-time-format.ts @@ -10,9 +10,9 @@ export function findTimezoneLabel(tzValue: string): string { /* DATE UTILS */ export function formatDateRange(fromDate: string, toDate: string): string { - const format = "MMMM d"; - const fromFormatted = formatDate(fromDate, format); - const toFormatted = formatDate(toDate, format); + const dateFormat = "MMMM d"; + const fromFormatted = formatDate(fromDate, dateFormat); + const toFormatted = formatDate(toDate, dateFormat); if (fromDate === toDate) { return fromFormatted; From 3d67479f29fdc123e7f356c5eb56f80a59f9a301 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:01:29 -0500 Subject: [PATCH 20/32] universal datetime handling --- src/app/(event)/[event-code]/page.tsx | 5 ++++- src/core/availability/use-availability.ts | 5 ++--- .../date-range/specific-date-display.tsx | 3 ++- .../event/grid/timeblocks/results.tsx | 2 +- .../utils/api/process-availability-data.ts | 20 ++++++++++++++++++ src/lib/utils/api/process-event-data.ts | 6 ++---- src/lib/utils/date-time-format.ts | 21 ++++++++++++++++--- 7 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 src/lib/utils/api/process-availability-data.ts diff --git a/src/app/(event)/[event-code]/page.tsx b/src/app/(event)/[event-code]/page.tsx index 1a243a8..70401c1 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,7 +16,7 @@ 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), ]); @@ -23,6 +24,8 @@ export default async function Page({ params }: EventCodePageProps) { const { eventName, eventRange, timeslots, isCreator } = processEventData(initialEventData); + const availabilityData = processAvailabilityData(initialAvailabilityData); + return ( {timeslots.map((timeslot, timeslotIdx) => { - const timeslotIso = timeslot.toISOString().split(".")[0]; + const timeslotIso = timeslot.toISOString(); const coords = getGridCoordinates( timeslot, 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 0000000..ef5ecd6 --- /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-event-data.ts b/src/lib/utils/api/process-event-data.ts index a0b1697..2f8b57f 100644 --- a/src/lib/utils/api/process-event-data.ts +++ b/src/lib/utils/api/process-event-data.ts @@ -1,9 +1,7 @@ -import { parseISO } from "date-fns"; - import { EventRange } from "@/core/event/types"; import { generateWeekdayMap } from "@/core/event/weekday-utils"; import { EventDetailsResponse } from "@/features/event/editor/fetch-data"; -import { formatApiTime } from "@/lib/utils/date-time-format"; +import { formatApiTime, DateTimeToDate } from "@/lib/utils/date-time-format"; export function processEventData(eventData: EventDetailsResponse): { eventName: string; @@ -13,7 +11,7 @@ export function processEventData(eventData: EventDetailsResponse): { } { const eventName: string = eventData.title; const timeslots: Date[] = eventData.timeslots.map((ts) => { - return parseISO(ts + "Z"); + return DateTimeToDate(ts); }); let eventRange: EventRange; diff --git a/src/lib/utils/date-time-format.ts b/src/lib/utils/date-time-format.ts index 3447bbc..12b4be4 100644 --- a/src/lib/utils/date-time-format.ts +++ b/src/lib/utils/date-time-format.ts @@ -1,4 +1,4 @@ -import { format, parse } from "date-fns"; +import { format, parse, parseISO } from "date-fns"; import { formatInTimeZone } from "date-fns-tz"; /* TIMEZONE UTILS */ @@ -7,6 +7,18 @@ export function findTimezoneLabel(tzValue: string): string { return formatInTimeZone(new Date(), tzValue, "zzzz"); } +/* DATETIME CONVERSION UTILS + * from python datetime string (ISO 8601) to Date object + */ + +export function formatDateTime(timeslot: string): string { + return DateTimeToDate(timeslot).toISOString(); +} + +export function DateTimeToDate(slotIso: string): Date { + return parseISO(slotIso + "Z"); +} + /* DATE UTILS */ export function formatDateRange(fromDate: string, toDate: string): string { @@ -32,10 +44,13 @@ export function formatDate(date: string, fmt: string): string { /* TIME UTILS */ +/* + * Converts an API time string (in UTC) to the event's local time string. + */ export function formatApiTime(apiTime: string, eventTimezone: string): string { const todayDate = format(new Date(), "yyyy-MM-dd"); - const isoString = `${todayDate}T${apiTime}Z`; - const localDate = new Date(isoString); + const UTC_isoString = `${todayDate}T${apiTime}Z`; + const localDate = new Date(UTC_isoString); return formatInTimeZone(localDate, eventTimezone, "HH:mm"); } From 4741e581876743c7824e6b221cf542fdb8839057 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:18:31 -0500 Subject: [PATCH 21/32] add comments to date-time-format.ts --- src/lib/utils/api/process-event-data.ts | 4 +-- src/lib/utils/date-time-format.ts | 44 ++++++++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/lib/utils/api/process-event-data.ts b/src/lib/utils/api/process-event-data.ts index 2f8b57f..00ea10d 100644 --- a/src/lib/utils/api/process-event-data.ts +++ b/src/lib/utils/api/process-event-data.ts @@ -1,7 +1,7 @@ import { EventRange } from "@/core/event/types"; import { generateWeekdayMap } from "@/core/event/weekday-utils"; import { EventDetailsResponse } from "@/features/event/editor/fetch-data"; -import { formatApiTime, DateTimeToDate } from "@/lib/utils/date-time-format"; +import { formatApiTime, parseIsoDateTime } from "@/lib/utils/date-time-format"; export function processEventData(eventData: EventDetailsResponse): { eventName: string; @@ -11,7 +11,7 @@ export function processEventData(eventData: EventDetailsResponse): { } { const eventName: string = eventData.title; const timeslots: Date[] = eventData.timeslots.map((ts) => { - return DateTimeToDate(ts); + return parseIsoDateTime(ts); }); let eventRange: EventRange; diff --git a/src/lib/utils/date-time-format.ts b/src/lib/utils/date-time-format.ts index 12b4be4..fc0c3fe 100644 --- a/src/lib/utils/date-time-format.ts +++ b/src/lib/utils/date-time-format.ts @@ -3,24 +3,38 @@ 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"); } -/* DATETIME CONVERSION UTILS - * from python datetime string (ISO 8601) to Date object +/* + * 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. */ -export function formatDateTime(timeslot: string): string { - return DateTimeToDate(timeslot).toISOString(); +// return Date object +export function parseIsoDateTime(slotIso: string): Date { + return parseISO(slotIso + "Z"); } -export function DateTimeToDate(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, it the +// full range is shown. export function formatDateRange(fromDate: string, toDate: string): string { const dateFormat = "MMMM d"; const fromFormatted = formatDate(fromDate, dateFormat); @@ -37,6 +51,8 @@ export function formatDateRange(fromDate: string, toDate: string): string { 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); @@ -44,9 +60,10 @@ export function formatDate(date: string, fmt: string): string { /* TIME UTILS */ -/* - * Converts an API time string (in UTC) to the event's local time string. - */ +// expects a time string from the API in "HH:mm" format in UTC +// and an event timezone (e.g., "America/New_York") +// returns the time convered to and formatted in the event's timezone +// in "HH:mm" format export function formatApiTime(apiTime: string, eventTimezone: string): string { const todayDate = format(new Date(), "yyyy-MM-dd"); const UTC_isoString = `${todayDate}T${apiTime}Z`; @@ -54,6 +71,9 @@ export function formatApiTime(apiTime: string, eventTimezone: string): string { return formatInTimeZone(localDate, eventTimezone, "HH:mm"); } +// 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 ""; @@ -64,11 +84,15 @@ export function formatTimeRange(startTime: string, endTime: string): string { 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 ""; @@ -76,6 +100,8 @@ export function convert24To12(time24: string): string { 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 ""; From ed45c95dbf7075476a2884555d7ab119ff59f3e5 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Sat, 10 Jan 2026 19:05:37 -0500 Subject: [PATCH 22/32] Update src/lib/utils/date-time-format.ts Co-authored-by: Jack Zgombic <69125339+jzgom067@users.noreply.github.com> --- src/lib/utils/date-time-format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/date-time-format.ts b/src/lib/utils/date-time-format.ts index fc0c3fe..f986bfe 100644 --- a/src/lib/utils/date-time-format.ts +++ b/src/lib/utils/date-time-format.ts @@ -33,7 +33,7 @@ export function formatDateTime(timeslot: string): string { // 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, it the +// 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"; From 23b77e5cc0e4c9826c5bfd7b16d78746feecdf33 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:00:51 -0500 Subject: [PATCH 23/32] display local details for dashboard information --- src/features/dashboard/components/event.tsx | 33 +++++++------- src/lib/utils/api/process-dashboard-data.ts | 43 +++++------------- src/lib/utils/api/process-event-data.ts | 36 +++++++++------ src/lib/utils/date-time-format.ts | 50 ++++++++++++++++----- 4 files changed, 90 insertions(+), 72 deletions(-) diff --git a/src/features/dashboard/components/event.tsx b/src/features/dashboard/components/event.tsx index b357271..32cae0a 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,7 +7,7 @@ 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 } from "@/lib/utils/date-time-format"; +import { formatTimeRange, getLocalDetails } from "@/lib/utils/date-time-format"; export type DashboardEventProps = { myEvent: boolean; @@ -16,10 +16,8 @@ export type DashboardEventProps = { type: "specific" | "weekday"; startTime: string; endTime: string; - startDate?: string; - endDate?: string; - startWeekday?: number; - endWeekday?: number; + startDate: string; + endDate: string; }; export default function DashboardEvent({ @@ -27,12 +25,7 @@ export default function DashboardEvent({ code, title, type, - startTime, - endTime, - startDate, - endDate, - startWeekday, - endWeekday, + ...dateTimeProps }: DashboardEventProps) { const router = useRouter(); @@ -41,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 (
@@ -50,15 +53,15 @@ export default function DashboardEvent({
{code}
{type === "specific" && ( - + )} {type === "weekday" && ( - + )}
- {formatTimeRange(startTime, endTime)} + {formatTimeRange(start.time, end.time)}
diff --git a/src/lib/utils/api/process-dashboard-data.ts b/src/lib/utils/api/process-dashboard-data.ts index c973538..a7b3ef2 100644 --- a/src/lib/utils/api/process-dashboard-data.ts +++ b/src/lib/utils/api/process-dashboard-data.ts @@ -4,43 +4,22 @@ import { DashboardEventResponse, DashboardResponse, } from "@/features/dashboard/fetch-data"; -import { formatApiTime } from "@/lib/utils/date-time-format"; function processSingleEvent( myEvent: boolean, eventData: DashboardEventResponse, ): DashboardEventProps { - const startTime = formatApiTime(eventData.start_time, eventData.time_zone); - const endTime = formatApiTime(eventData.end_time, eventData.time_zone); - - if (eventData.event_type === "Date") { - const data: DashboardEventProps = { - myEvent: myEvent, - code: eventData.event_code, - title: eventData.title, - type: "specific", - startTime: startTime, - endTime: endTime, - startDate: eventData.start_date, - endDate: eventData.end_date, - }; - return data; - } else { - const startWeekday = new Date(eventData.start_date!).getUTCDay(); - const endWeekday = new Date(eventData.end_date!).getUTCDay(); - - const data: DashboardEventProps = { - myEvent: myEvent, - code: eventData.event_code, - title: eventData.title, - type: "weekday", - startTime: startTime, - endTime: endTime, - startWeekday: startWeekday, - endWeekday: endWeekday, - }; - 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 00ea10d..59b0f6e 100644 --- a/src/lib/utils/api/process-event-data.ts +++ b/src/lib/utils/api/process-event-data.ts @@ -1,7 +1,10 @@ import { EventRange } from "@/core/event/types"; import { generateWeekdayMap } from "@/core/event/weekday-utils"; import { EventDetailsResponse } from "@/features/event/editor/fetch-data"; -import { formatApiTime, parseIsoDateTime } from "@/lib/utils/date-time-format"; +import { + getZonedDetails, + parseIsoDateTime, +} from "@/lib/utils/date-time-format"; export function processEventData(eventData: EventDetailsResponse): { eventName: string; @@ -15,8 +18,17 @@ export function processEventData(eventData: EventDetailsResponse): { }); let eventRange: EventRange; - const startTime = formatApiTime(eventData.start_time, eventData.time_zone); - const endTime = formatApiTime(eventData.end_time, eventData.time_zone); + 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 = { @@ -24,28 +36,24 @@ export function processEventData(eventData: EventDetailsResponse): { 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: startTime, - to: endTime, + from: start.time, + to: end.time, }, }; } else { - const startDayIndex = new Date(eventData.start_date!).getUTCDay(); - const endDayIndex = new Date(eventData.end_date!).getUTCDay(); - - const weekdays = generateWeekdayMap(startDayIndex, endDayIndex); - + const weekdays = generateWeekdayMap(start.weekday, end.weekday); eventRange = { type: "weekday", duration: eventData.duration || 0, timezone: eventData.time_zone, weekdays: weekdays, timeRange: { - from: startTime, - to: endTime, + from: start.time, + to: end.time, }, }; } diff --git a/src/lib/utils/date-time-format.ts b/src/lib/utils/date-time-format.ts index f986bfe..37b01f0 100644 --- a/src/lib/utils/date-time-format.ts +++ b/src/lib/utils/date-time-format.ts @@ -9,6 +9,45 @@ 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. @@ -60,17 +99,6 @@ export function formatDate(date: string, fmt: string): string { /* TIME UTILS */ -// expects a time string from the API in "HH:mm" format in UTC -// and an event timezone (e.g., "America/New_York") -// returns the time convered to and formatted in the event's timezone -// in "HH:mm" format -export function formatApiTime(apiTime: string, eventTimezone: string): string { - const todayDate = format(new Date(), "yyyy-MM-dd"); - const UTC_isoString = `${todayDate}T${apiTime}Z`; - const localDate = new Date(UTC_isoString); - return formatInTimeZone(localDate, eventTimezone, "HH:mm"); -} - // 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". From c8e1a3dbe89b0f3c1645d454521b0e2fb99fb2af Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:47:39 -0500 Subject: [PATCH 24/32] create universal timezone translation --- src/app/(event)/[event-code]/page-client.tsx | 4 +- .../[event-code]/painting/page-client.tsx | 4 +- src/features/dashboard/components/event.tsx | 17 ++- src/features/event/info-drawer.tsx | 125 +++++++++++------- src/lib/utils/api/process-dashboard-data.ts | 2 +- src/lib/utils/api/process-event-data.ts | 22 +-- src/lib/utils/date-time-format.ts | 79 +++++------ 7 files changed, 152 insertions(+), 101 deletions(-) diff --git a/src/app/(event)/[event-code]/page-client.tsx b/src/app/(event)/[event-code]/page-client.tsx index e744602..b691712 100644 --- a/src/app/(event)/[event-code]/page-client.tsx +++ b/src/app/(event)/[event-code]/page-client.tsx @@ -67,7 +67,7 @@ export default function ClientPage({

{eventName}

- +
{isCreator && ( @@ -143,7 +143,7 @@ export default function ClientPage({
- +
diff --git a/src/app/(event)/[event-code]/painting/page-client.tsx b/src/app/(event)/[event-code]/painting/page-client.tsx index 2a3ec40..5d7ec3f 100644 --- a/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/src/app/(event)/[event-code]/painting/page-client.tsx @@ -165,7 +165,7 @@ export default function ClientPage({

{eventName}

- +
{cancelButton} @@ -205,7 +205,7 @@ export default function ClientPage({ {/* Desktop-only Event Info */}
- +
diff --git a/src/features/dashboard/components/event.tsx b/src/features/dashboard/components/event.tsx index 32cae0a..6da39a5 100644 --- a/src/features/dashboard/components/event.tsx +++ b/src/features/dashboard/components/event.tsx @@ -7,7 +7,10 @@ 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"; +import { + formatTimeRange, + getTimezoneDetails, +} from "@/lib/utils/date-time-format"; export type DashboardEventProps = { myEvent: boolean; @@ -36,11 +39,19 @@ export default function DashboardEvent({ // Memoized local start and end details const start = useMemo( - () => getLocalDetails(dateTimeProps.startTime, dateTimeProps.startDate), + () => + getTimezoneDetails({ + time: dateTimeProps.startTime, + date: dateTimeProps.startDate, + }), [dateTimeProps.startTime, dateTimeProps.startDate], ); const end = useMemo( - () => getLocalDetails(dateTimeProps.endTime, dateTimeProps.endDate), + () => + getTimezoneDetails({ + time: dateTimeProps.endTime, + date: dateTimeProps.endDate, + }), [dateTimeProps.endTime, dateTimeProps.endDate], ); diff --git a/src/features/event/info-drawer.tsx b/src/features/event/info-drawer.tsx index 3c5e5d9..7db2316 100644 --- a/src/features/event/info-drawer.tsx +++ b/src/features/event/info-drawer.tsx @@ -1,20 +1,29 @@ "use client"; +import { useMemo } from "react"; + import * as Dialog from "@radix-ui/react-dialog"; import { InfoCircledIcon } from "@radix-ui/react-icons"; +import { addDays } from "date-fns"; +import { format } from "date-fns-tz/format"; -import { EventRange } from "@/core/event/types"; +import { EventRange, days } from "@/core/event/types"; +import WeekdayRow from "@/features/dashboard/components/weekday-row"; import { - findTimezoneLabel, formatDateRange, formatTimeRange, + getTimezoneDetails, } from "@/lib/utils/date-time-format"; +type EventInfoProps = { + eventRange: EventRange; + timezone: string; +}; + export default function EventInfoDrawer({ eventRange, -}: { - eventRange: EventRange; -}) { + timezone, +}: EventInfoProps) { return ( @@ -33,55 +42,81 @@ export default function EventInfoDrawer({ aria-hidden className="sticky mx-auto mb-8 h-1.5 w-12 flex-shrink-0 rounded-full bg-gray-300" /> - + ); } -export function EventInfo({ eventRange }: { eventRange: EventRange }) { +export function EventInfo({ eventRange, timezone }: EventInfoProps) { + const startTime = eventRange.timeRange.from; + const endTime = eventRange.timeRange.to; + + let startDate, endDate; + if (eventRange.type === "specific") { + startDate = eventRange.dateRange.from; + endDate = eventRange.dateRange.to; + } else { + const activeDays = days + .map((day, i) => (eventRange.weekdays[day] === 1 ? i : -1)) + .filter((i) => i !== -1); + + if (activeDays.length > 0) { + const referenceStart = new Date("2012-01-01T00:00:00"); + startDate = format(addDays(referenceStart, activeDays[0]), "yyyy-MM-dd"); + endDate = format( + addDays(referenceStart, activeDays[activeDays.length - 1]), + "yyyy-MM-dd", + ); + } + } + + const start = useMemo( + () => + getTimezoneDetails({ + time: startTime, + date: startDate!, + fromTZ: eventRange.timezone, + toTZ: timezone, + }), + [startTime, startDate, eventRange.timezone, timezone], + ); + + const end = useMemo( + () => + getTimezoneDetails({ + time: endTime, + date: endDate!, + fromTZ: eventRange.timezone, + toTZ: timezone, + }), + [endTime, endDate, eventRange.timezone, timezone], + ); + return ( -
-
-

Event Details

-

- Please note that these details are presented in respect to the{" "} - original event's timezone{" "} - which is{" "} - - {findTimezoneLabel(eventRange.timezone)} - -

-
- -
- {eventRange.type === "specific" ? ( - - {formatDateRange( - eventRange.dateRange.from, - eventRange.dateRange.to, - )} - - ) : ( - - {Object.entries(eventRange.weekdays) - .filter(([, val]) => val === 1) - .map(([day]) => day) - .join(", ")} - - )} - - - {formatTimeRange(eventRange.timeRange.from, eventRange.timeRange.to)} +
+

Event Details

+ + {eventRange.type === "specific" ? ( + + {formatDateRange(start.date, end.date)} + + ) : ( + + + )} - {eventRange.duration > 0 && ( - - {eventRange.duration} minutes - - )} -
+ + {formatTimeRange(start.time, end.time)} + + + {eventRange.duration > 0 && ( + + {eventRange.duration} minutes + + )}
); } diff --git a/src/lib/utils/api/process-dashboard-data.ts b/src/lib/utils/api/process-dashboard-data.ts index a7b3ef2..281f91e 100644 --- a/src/lib/utils/api/process-dashboard-data.ts +++ b/src/lib/utils/api/process-dashboard-data.ts @@ -13,7 +13,7 @@ function processSingleEvent( myEvent: myEvent, code: eventData.event_code, title: eventData.title, - type: "specific", + type: eventData.event_type === "Date" ? "specific" : "weekday", startTime: eventData.start_time, endTime: eventData.end_time, startDate: eventData.start_date!, diff --git a/src/lib/utils/api/process-event-data.ts b/src/lib/utils/api/process-event-data.ts index 59b0f6e..3ad4ee8 100644 --- a/src/lib/utils/api/process-event-data.ts +++ b/src/lib/utils/api/process-event-data.ts @@ -2,7 +2,7 @@ import { EventRange } from "@/core/event/types"; import { generateWeekdayMap } from "@/core/event/weekday-utils"; import { EventDetailsResponse } from "@/features/event/editor/fetch-data"; import { - getZonedDetails, + getTimezoneDetails, parseIsoDateTime, } from "@/lib/utils/date-time-format"; @@ -18,17 +18,17 @@ export function processEventData(eventData: EventDetailsResponse): { }); let eventRange: EventRange; - const start = getZonedDetails( - eventData.start_time, - eventData.start_date!, - eventData.time_zone, - ); + const start = getTimezoneDetails({ + time: eventData.start_time, + date: eventData.start_date!, + toTZ: eventData.time_zone, + }); - const end = getZonedDetails( - eventData.end_time, - eventData.end_date!, - eventData.time_zone, - ); + const end = getTimezoneDetails({ + time: eventData.end_time, + date: eventData.end_date!, + toTZ: eventData.time_zone, + }); if (eventData.event_type === "Date") { eventRange = { diff --git a/src/lib/utils/date-time-format.ts b/src/lib/utils/date-time-format.ts index 37b01f0..532a579 100644 --- a/src/lib/utils/date-time-format.ts +++ b/src/lib/utils/date-time-format.ts @@ -1,5 +1,5 @@ import { format, parse, parseISO } from "date-fns"; -import { formatInTimeZone } from "date-fns-tz"; +import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; /* TIMEZONE UTILS */ @@ -9,43 +9,48 @@ 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 time and date strings along with optional source and target timezones +// Returns an object with time, date, and weekday number converted between timezones +// If there are no timezones provided, it assumes inputs are in UTC and returns them +// formatted in the local timezone. +type TimezoneDetailsInput = { + time: string; + date: string; + fromTZ?: string; + toTZ?: string; +}; +export function getTimezoneDetails({ + time, + date, + fromTZ, + toTZ, +}: TimezoneDetailsInput): { time: string; date: string; weekday: number } { + let dateObj: Date; + + if (fromTZ) { + const tzIso = `${date}T${time}`; + dateObj = fromZonedTime(tzIso, fromTZ); + } else { + const utcIsoString = `${date}T${time}Z`; + dateObj = parseISO(utcIsoString); + } -// 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(), - }; + if (toTZ) { + const convertedTime = formatInTimeZone(dateObj, toTZ, "HH:mm"); + const convertedDate = formatInTimeZone(dateObj, toTZ, "yyyy-MM-dd"); + const convertedWeekday = parseInt(formatInTimeZone(dateObj, toTZ, "i")) % 7; // 0-6 (Sun-Sat) + return { + time: convertedTime, + date: convertedDate, + weekday: convertedWeekday, + }; + } else { + return { + time: format(dateObj, "HH:mm"), + date: format(dateObj, "yyyy-MM-dd"), + weekday: dateObj.getDay(), + }; + } } /* From 87b8ad9ee2858fc77f333f2de671fe95d96f3fa0 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:16:28 -0500 Subject: [PATCH 25/32] all eventRange dates in event time --- src/core/event/lib/default-range.ts | 7 +- src/core/event/lib/expand-event-range.ts | 5 +- src/core/event/reducers/range-reducer.ts | 65 +++++++++++++++++-- src/core/event/use-event-info.ts | 10 +-- .../event/editor/date-range/selector.tsx | 11 ++-- .../event/editor/time-range/time-picker.tsx | 46 +++++++++++-- 6 files changed, 119 insertions(+), 25 deletions(-) diff --git a/src/core/event/lib/default-range.ts b/src/core/event/lib/default-range.ts index 7b662ac..528d2c9 100644 --- a/src/core/event/lib/default-range.ts +++ b/src/core/event/lib/default-range.ts @@ -1,3 +1,5 @@ +import { format } from "date-fns"; + import { SpecificDateRange, WeekdayRange } from "@/core/event/types"; const defaultTimeRange = { from: "09:00", to: "17:00" }; @@ -7,9 +9,10 @@ export const DEFAULT_RANGE_SPECIFIC: SpecificDateRange = { duration: 60, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, dateRange: { - from: new Date().toISOString(), - to: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), + from: format(new Date(), "yyyy-MM-dd"), + to: format(new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), "yyyy-MM-dd"), }, + timeRange: defaultTimeRange, }; diff --git a/src/core/event/lib/expand-event-range.ts b/src/core/event/lib/expand-event-range.ts index ce635fa..bc17db8 100644 --- a/src/core/event/lib/expand-event-range.ts +++ b/src/core/event/lib/expand-event-range.ts @@ -4,6 +4,7 @@ import { eachDayOfInterval, isBefore, parseISO, + format, } from "date-fns"; import { fromZonedTime } from "date-fns-tz"; @@ -102,7 +103,7 @@ function generateSlotsForSpecificRange(range: SpecificDateRange): Date[] { }); for (const day of days) { - const dayStr = day.toISOString().split("T")[0]; + const dayStr = format(day, "yyyy-MM-dd"); const { startUTC, endUTC } = getDailyBoundariesInUTC( dayStr, @@ -142,7 +143,7 @@ function generateSlotsForWeekdayRange(range: WeekdayRange): Date[] { ); if (dayName && range.weekdays[dayName as keyof typeof range.weekdays]) { - const dayStr = currentDay.toISOString().split("T")[0]; + const dayStr = format(currentDay, "yyyy-MM-dd"); const { startUTC, endUTC } = getDailyBoundariesInUTC( dayStr, diff --git a/src/core/event/reducers/range-reducer.ts b/src/core/event/reducers/range-reducer.ts index 216a5d8..04d7b99 100644 --- a/src/core/event/reducers/range-reducer.ts +++ b/src/core/event/reducers/range-reducer.ts @@ -1,3 +1,6 @@ +// import { format, addDays, differenceInCalendarDays, parseISO } from "date-fns"; +// import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; + import { DEFAULT_RANGE_SPECIFIC, DEFAULT_RANGE_WEEKDAY, @@ -44,16 +47,13 @@ export function EventRangeReducer( return { ...baseEvent, type: "specific", - dateRange: { - from: new Date().toISOString(), - to: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), - }, + dateRange: DEFAULT_RANGE_SPECIFIC.dateRange, }; } else { return { ...baseEvent, type: "weekday", - weekdays: { Sun: 0, Mon: 1, Tue: 1, Wed: 1, Thu: 0, Fri: 0, Sat: 0 }, + weekdays: DEFAULT_RANGE_WEEKDAY.weekdays, }; } } @@ -114,9 +114,62 @@ export function EventRangeReducer( } case "SET_TIMEZONE": { + const newTz = action.payload; + + // Logic: If Specific Date, ensure the Start Time is not in the past in the new TZ. + // If it is, shift the dates forward so they are valid. + // if (state.type === "specific") { + // const { dateRange, timeRange } = state; + // const now = new Date(); + + // // 1. Interpret existing "Wall Time" in the New Timezone + // // e.g., "2025-01-11 09:00" interpreted as Shanghai Time + // const wallStartIso = `${dateRange.from}T${timeRange.from}`; + // const startInNewZone = fromZonedTime(wallStartIso, newTz); + + // // 2. Check if that time has already passed + // if (startInNewZone < now) { + // // 3. Find "Today" and "Tomorrow" in the new timezone + // const todayInNewZoneStr = formatInTimeZone(now, newTz, "yyyy-MM-dd"); + + // // Check if we can still make it "Today" (is Now < 9am Today in New Zone?) + // const potentialStartToday = fromZonedTime( + // `${todayInNewZoneStr}T${timeRange.from}`, + // newTz, + // ); + + // let newStartDateStr = todayInNewZoneStr; + + // if (potentialStartToday < now) { + // // 9am Today has passed. Move to Tomorrow. + // const tomorrow = addDays(parseISO(todayInNewZoneStr), 1); + // newStartDateStr = format(tomorrow, "yyyy-MM-dd"); + // } + + // // 4. Calculate existing duration to preserve it + // const oldStart = parseISO(dateRange.from); + // const oldEnd = parseISO(dateRange.to); + // const daysLength = differenceInCalendarDays(oldEnd, oldStart); + + // // 5. Construct new range + // const newStart = parseISO(newStartDateStr); + // const newEnd = addDays(newStart, daysLength); + + // return { + // ...state, + // timezone: newTz, + // dateRange: { + // from: format(newStart, "yyyy-MM-dd"), + // to: format(newEnd, "yyyy-MM-dd"), + // }, + // }; + // } + // } + + // Default behavior: just update the string return { ...state, - timezone: action.payload, + timezone: newTz, }; } diff --git a/src/core/event/use-event-info.ts b/src/core/event/use-event-info.ts index c3c1063..b4f5901 100644 --- a/src/core/event/use-event-info.ts +++ b/src/core/event/use-event-info.ts @@ -1,5 +1,6 @@ import { useMemo, useReducer, useCallback } from "react"; +import { format } from "date-fns"; import { DateRange } from "react-day-picker"; import { DEFAULT_RANGE_SPECIFIC } from "@/core/event/lib/default-range"; @@ -101,16 +102,17 @@ export function useEventInfo(initialData?: EventInformation) { ); const setDateRange = useCallback( - (dateRange: DateRange) => { - if (checkDateRange(dateRange.from, dateRange.to)) { + (dateRange: DateRange | undefined) => { + if (checkDateRange(dateRange?.from, dateRange?.to)) { handleError("dateRange", MESSAGES.ERROR_EVENT_RANGE_TOO_LONG); } else { handleError("dateRange", ""); } if (dateRange?.from && dateRange?.to) { - const from = dateRange.from.toISOString(); - const to = dateRange.to.toISOString(); + const from = format(dateRange.from, "yyyy-MM-dd"); + const to = format(dateRange.to, "yyyy-MM-dd"); + dispatch({ type: "SET_DATE_RANGE", payload: { from, to }, diff --git a/src/features/event/editor/date-range/selector.tsx b/src/features/event/editor/date-range/selector.tsx index 7b7aa5a..7cb88d5 100644 --- a/src/features/event/editor/date-range/selector.tsx +++ b/src/features/event/editor/date-range/selector.tsx @@ -1,5 +1,5 @@ import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; -import { fromZonedTime } from "date-fns-tz"; +import { parseISO } from "date-fns"; import Switch from "@/components/switch"; import { useEventContext } from "@/core/event/context"; @@ -71,12 +71,9 @@ function SpecificDateRangeDisplay({ }) { const isMobile = useCheckMobile(); - const earliestDate = new Date(eventRange.dateRange.from); - const startDate = fromZonedTime( - eventRange.dateRange.from, - eventRange.timezone, - ); - const endDate = fromZonedTime(eventRange.dateRange.to, eventRange.timezone); + const earliestDate = parseISO(eventRange.dateRange.from); + const startDate = parseISO(eventRange.dateRange.from); + const endDate = parseISO(eventRange.dateRange.to); if (isMobile) { return ( diff --git a/src/features/event/editor/time-range/time-picker.tsx b/src/features/event/editor/time-range/time-picker.tsx index b010b19..674a839 100644 --- a/src/features/event/editor/time-range/time-picker.tsx +++ b/src/features/event/editor/time-range/time-picker.tsx @@ -11,7 +11,6 @@ import { convert12To24, convert24To12 } from "@/lib/utils/date-time-format"; type TimePickerProps = { time: string; onTimeChange: (newTime: string) => void; - visibleCount?: number; fontSize?: number; }; @@ -22,6 +21,7 @@ export default function TimePicker({ visibleCount = 3, fontSize = 16, }: TimePickerProps) { + // pickerValue acts as our "previous" state during changes const [pickerValue, setPickerValue] = useState(convert24To12(time)); // Sync internal state if the external prop changes @@ -30,10 +30,48 @@ export default function TimePicker({ }, [time]); const handleChange = (newTime12: string) => { - setPickerValue(newTime12); + const [oldHourStr, oldRest] = pickerValue.split(":"); + const oldHour = parseInt(oldHourStr, 10); + const oldPeriod = oldRest.split(" ")[1]; + + const [newHourStr, newRest] = newTime12.split(":"); + const newHour = parseInt(newHourStr, 10); + const newMinute = newRest.split(" ")[0]; + const newPeriod = newRest.split(" ")[1]; + + let finalTime = newTime12; + + // Only run auto-flip logic if the user didn't manually change the period + if (oldPeriod === newPeriod && oldHour !== newHour) { + // 1. Normalize hours to 0-11 range (12 becomes 0) for easier math + const o = oldHour === 12 ? 0 : oldHour; + const n = newHour === 12 ? 0 : newHour; + + // 2. Calculate circular distance (how many steps forward?) + // Examples: 10->12 is diff 2. 1->11 is diff 10. + const diff = (n - o + 12) % 12; + + // 3. Determine if we crossed the 11->12 (or 12->11) boundary + let crossedBoundary = false; + + if (diff > 0 && diff <= 6) { + // Forward movement (e.g., 10 -> 12) + // If we moved forward but the number got smaller (e.g., 11 -> 0), we wrapped. + if (n < o) crossedBoundary = true; + } else if (diff > 6) { + // Backward movement (e.g., 1 -> 11) + // If we moved backward but the number got bigger (e.g., 0 -> 11), we wrapped. + if (n > o) crossedBoundary = true; + } + + if (crossedBoundary) { + const toggledPeriod = newPeriod === "AM" ? "PM" : "AM"; + finalTime = `${newHourStr}:${newMinute} ${toggledPeriod}`; + } + } - const time24 = convert12To24(newTime12); - onTimeChange(time24); + setPickerValue(finalTime); + onTimeChange(convert12To24(finalTime)); }; const wheelStyle = { From 447351d6135c200cb6f730ed1a8424df51130627 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:17:46 -0500 Subject: [PATCH 26/32] reset default duration to 0 --- src/core/event/lib/default-range.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/event/lib/default-range.ts b/src/core/event/lib/default-range.ts index 528d2c9..762bc63 100644 --- a/src/core/event/lib/default-range.ts +++ b/src/core/event/lib/default-range.ts @@ -6,7 +6,7 @@ const defaultTimeRange = { from: "09:00", to: "17:00" }; export const DEFAULT_RANGE_SPECIFIC: SpecificDateRange = { type: "specific" as const, - duration: 60, + duration: 0, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, dateRange: { from: format(new Date(), "yyyy-MM-dd"), @@ -18,7 +18,7 @@ export const DEFAULT_RANGE_SPECIFIC: SpecificDateRange = { export const DEFAULT_RANGE_WEEKDAY: WeekdayRange = { type: "weekday" as const, - duration: 30, + duration: 0, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, weekdays: { Sun: 0, Mon: 1, Tue: 1, Wed: 1, Thu: 0, Fri: 0, Sat: 0 }, timeRange: defaultTimeRange, From 61ededce98670b0d2925d6d0c8d3e9c79fa2a73e Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:31:51 -0500 Subject: [PATCH 27/32] re-instate time dropdown --- .../event/components/selectors/time.tsx | 13 ++++++++---- src/features/event/editor/editor.tsx | 21 +++++++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/features/event/components/selectors/time.tsx b/src/features/event/components/selectors/time.tsx index 8d0c4f1..b144a7a 100644 --- a/src/features/event/components/selectors/time.tsx +++ b/src/features/event/components/selectors/time.tsx @@ -1,9 +1,10 @@ import Selector from "@/features/selector/components/selector"; +import { convert12To24 } from "@/lib/utils/date-time-format"; type TimeSelectorProps = { id: string; - onChange: (time: number) => void; - value: number; + onChange: (time: string) => void; + value: string; }; export default function TimeSelector({ @@ -14,10 +15,14 @@ export default function TimeSelector({ const options = Array.from({ length: 24 }, (_, i) => { const hour = i % 12 === 0 ? 12 : i % 12; const period = i < 12 ? "am" : "pm"; - return { label: `${hour}:00 ${period}`, value: i }; + + const label = `${hour}:00 ${period}`; + const value = convert12To24(label); + + return { label, value }; }); - options.push({ label: "12:00 am", value: 24 }); + options.push({ label: "12:00 am", value: "24:00" }); return ( }

- + + + + + + +
From 58effb1d7101277925788c992fa84c2888744625 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:09:42 -0500 Subject: [PATCH 28/32] fix aria and alignment errors --- src/features/event/components/selectors/time.tsx | 1 - src/features/event/components/selectors/timezone.tsx | 1 + src/features/event/editor/date-range/drawer.tsx | 4 ++++ src/features/selector/components/drawer.tsx | 11 ++++++++--- src/features/selector/components/selector.tsx | 2 ++ src/features/selector/types.ts | 1 + 6 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/features/event/components/selectors/time.tsx b/src/features/event/components/selectors/time.tsx index b144a7a..95f785a 100644 --- a/src/features/event/components/selectors/time.tsx +++ b/src/features/event/components/selectors/time.tsx @@ -32,7 +32,6 @@ export default function TimeSelector({ options={options} dialogTitle="Select Time" dialogDescription="Select a time from the list" - className="h-fit w-fit" /> ); } diff --git a/src/features/event/components/selectors/timezone.tsx b/src/features/event/components/selectors/timezone.tsx index 59265a1..75438db 100644 --- a/src/features/event/components/selectors/timezone.tsx +++ b/src/features/event/components/selectors/timezone.tsx @@ -34,6 +34,7 @@ export default function TimeZoneSelector({ dialogTitle="Select Timezone" dialogDescription="Select a timezone from the list" className={className} + textStart /> ); } diff --git a/src/features/event/editor/date-range/drawer.tsx b/src/features/event/editor/date-range/drawer.tsx index 97b7018..03345b4 100644 --- a/src/features/event/editor/date-range/drawer.tsx +++ b/src/features/event/editor/date-range/drawer.tsx @@ -67,6 +67,10 @@ export default function DateRangeDrawer({ )} + + + Select a date range using the calendar below +
({ onChange, dialogTitle, dialogDescription, + textStart = false, }: SelectorProps) { const [open, setOpen] = useState(false); - const selectedItemRef = useRef(null); + const selectedItemRef = useRef(null); const selectLabel = options.find((opt) => opt.value === value)?.label || ""; @@ -58,7 +59,6 @@ export default function SelectorDrawer({
({ {dialogTitle} + + + {dialogDescription || "Select an option from the list below"} +
@@ -83,6 +87,7 @@ export default function SelectorDrawer({ const isSelected = option.value === value; return (