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