diff --git a/frontend/src/app/(event)/[event-code]/page-client.tsx b/frontend/src/app/(event)/[event-code]/page-client.tsx index a9bbf1362..ceea2b485 100644 --- a/frontend/src/app/(event)/[event-code]/page-client.tsx +++ b/frontend/src/app/(event)/[event-code]/page-client.tsx @@ -8,7 +8,9 @@ import KebabMenu from "@/components/kebab-menu"; import { EventInformation } from "@/core/event/types"; import EmptyButton from "@/features/button/components/empty"; import LinkButton from "@/features/button/components/link"; +import GridPageDaysSelector from "@/features/event/components/selectors/grid-page-days"; import ScheduleGrid from "@/features/event/grid/grid"; +import useGridPageDays from "@/features/event/grid/lib/use-page-days"; import AttendeesPanel from "@/features/event/results/attendees/desktop-panel"; import AttendeesDrawer from "@/features/event/results/attendees/mobile-drawer"; import { getResultBanners } from "@/features/event/results/components/banners"; @@ -64,6 +66,14 @@ function EventResults({ eventData }: { eventData: EventInformation }) { setTimezone(newTZ.toString()); }; + /* GRID PAGE DAYS */ + const { + gridPageDays, + gridPageDaysOptions, + usingMaxGridPageDays, + setGridPageDays, + } = useGridPageDays(); + /* MOBILE DRAWER SPACING */ const [drawerSnap, setDrawerSnap] = useState(0.22); const getSpacerHeight = () => { @@ -119,6 +129,18 @@ function EventResults({ eventData }: { eventData: EventInformation }) { /> ); + const daysPerPageSelector = ( +
+

Days per page

+ +
+ ); + return (
@@ -152,9 +174,12 @@ function EventResults({ eventData }: { eventData: EventInformation }) { availabilities={filteredAvailabilities} numParticipants={gridNumParticipants} timeslots={timeslots} + pageDays={gridPageDays} + useCompactHeader={usingMaxGridPageDays} />
+ {daysPerPageSelector}
+ {daysPerPageSelector} >({}); // VISITED LAST PAGE STATE - const [maxVisitedPage, setMaxVisitedPage] = useState(0); - const [numPages, setNumPages] = useState(1); - const visitedLastPage = maxVisitedPage >= numPages - 1; + const [visitedLastPage, setVisitedLastPage] = useState(false); const [confirmationOpen, setConfirmationOpen] = useState(false); const dialogResolver = useRef<((confirmed: boolean) => void) | null>(null); @@ -249,6 +257,28 @@ export default function ClientPage({ /> ); + const displaySettings = ( +
+
+

Days per page

+ +
+
+

Displaying event in

+ +
+
+ ); + return (
@@ -280,14 +310,7 @@ export default function ClientPage({ setSaveDefaultName={setSaveDefaultName} /> -
- Displaying event in - -
+ {displaySettings}
{/* Right Panel */} @@ -299,21 +322,15 @@ export default function ClientPage({ userAvailability={userAvailability} timeslots={timeslots} onPageUpdate={(index, pages) => { - setNumPages(pages); - if (index > maxVisitedPage) { - setMaxVisitedPage(index); + if (index >= pages - 1) { + setVisitedLastPage(true); } }} + pageDays={gridPageDays} + useCompactHeader={usingMaxGridPageDays} /> -
- Displaying event in - -
+
{displaySettings}
{/* This z-index is necessary to avoid the time column overlapping */} diff --git a/frontend/src/features/event/components/selectors/grid-page-days.tsx b/frontend/src/features/event/components/selectors/grid-page-days.tsx new file mode 100644 index 000000000..1a2f71c66 --- /dev/null +++ b/frontend/src/features/event/components/selectors/grid-page-days.tsx @@ -0,0 +1,27 @@ +import Selector from "@/features/selector/components/selector"; + +export default function GridPageDaysSelector({ + id, + value, + options, + onChange, +}: { + id: string; + value: number; + options: number[]; + onChange: (value: number) => void; +}) { + return ( + ({ + value: option, + label: `${option} days`, + }))} + onChange={onChange} + dialogTitle="Select Days per Page" + dialogDescription="Select the number of days to display per page" + /> + ); +} diff --git a/frontend/src/features/event/editor/editor.tsx b/frontend/src/features/event/editor/editor.tsx index 69d8af854..26cf72571 100644 --- a/frontend/src/features/event/editor/editor.tsx +++ b/frontend/src/features/event/editor/editor.tsx @@ -13,6 +13,7 @@ import checkUnselectedRange from "@/core/event/lib/unselected-range"; import { EventInformation } from "@/core/event/types"; import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; +import GridPageDaysSelector from "@/features/event/components/selectors/grid-page-days"; import TimeSelector from "@/features/event/components/selectors/time"; import AdvancedOptions from "@/features/event/editor/advanced-options"; import { MAX_TITLE_LENGTH } from "@/features/event/editor/constants"; @@ -20,6 +21,7 @@ import DateRangeSelection from "@/features/event/editor/date-range/selector"; import { EventEditorType } from "@/features/event/editor/types"; import { validateEventData } from "@/features/event/editor/validate-data"; import { ScheduleGrid } from "@/features/event/grid"; +import useGridPageDays from "@/features/event/grid/lib/use-page-days"; import HeaderSpacer from "@/features/header/components/header-spacer"; import FormSelectorField from "@/features/selector/components/selector-field"; import { RateLimitBanner } from "@/features/system-feedback"; @@ -57,6 +59,13 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { setEndTime, } = useEventContext(); const { title, customCode, eventRange, timeslots } = state; + const { + gridPageDays, + gridPageDaysOptions, + usingMaxGridPageDays, + setGridPageDays, + } = useGridPageDays(); + const [gridDisplayed, setGridDisplayed] = useState(false); const router = useRouter(); const [mobileTab, setMobileTab] = useState("details"); @@ -111,9 +120,21 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { unselectedRange={checkUnselectedRange(eventRange)} timezone={eventRange.timezone} timeslots={timeslots} + pageDays={gridPageDays} + useCompactHeader={usingMaxGridPageDays} + setGridDisplayed={setGridDisplayed} /> ); + const previewText = ( +

+ This is a preview!{" "} + {type === "edit" + ? "Manage availability from the results page." + : "You'll add your availability later."} +

+ ); + return (
@@ -190,8 +211,22 @@ function EventEditorContent({ type, initialData }: EventEditorProps) {
-
-
{grid}
+
+
{grid}
+ {gridDisplayed && ( +
+ {previewText} +
+

Days per page:

+ +
+
+ )}
@@ -199,11 +234,25 @@ function EventEditorContent({ type, initialData }: EventEditorProps) {
{grid} + {gridDisplayed && ( +
+ {previewText} +
+

Days per page

+ +
+
+ )}
{/* This z-index is necessary to avoid the time column overlapping */} diff --git a/frontend/src/features/event/grid/grid.tsx b/frontend/src/features/event/grid/grid.tsx index 207dd9f02..73246c3b2 100644 --- a/frontend/src/features/event/grid/grid.tsx +++ b/frontend/src/features/event/grid/grid.tsx @@ -23,9 +23,13 @@ interface ScheduleGridProps { mode: "paint" | "view" | "preview"; timeslots: Date[]; timezone: string; + pageDays: number; + useCompactHeader: boolean; isWeekdayEvent?: boolean; + // for "preview" mode unselectedRange?: boolean; + setGridDisplayed?: (displayed: boolean) => void; // for "view" mode availabilities?: ResultsAvailabilityMap; @@ -61,9 +65,12 @@ const variants = { export default function ScheduleGrid({ timeslots, timezone, + pageDays, + useCompactHeader, mode = "preview", isWeekdayEvent = false, unselectedRange = false, + setGridDisplayed = () => {}, availabilities = {}, numParticipants = 0, hoveredSlot, @@ -82,17 +89,23 @@ export default function ScheduleGrid({ direction, paginate, error, - } = useGridinfo(timeslots, timezone, isMobile ? 4 : 7, onPageUpdate); + } = useGridinfo(timeslots, timezone, pageDays, onPageUpdate); // Initial onPageUpdate callback to report pagination info to parent // Also triggers if the user changes between mobile and desktop layouts const reportedTotalPages = useRef(null); useEffect(() => { + let effectiveCurrentPage = currentPage; + if (currentPage >= totalPages) { + // If out of bounds, go to the last page + effectiveCurrentPage = totalPages - 1; + paginate(totalPages - 1 - currentPage); + } if (reportedTotalPages.current !== totalPages) { - onPageUpdate(currentPage, totalPages); + onPageUpdate(effectiveCurrentPage, totalPages); reportedTotalPages.current = totalPages; } - }, [onPageUpdate, currentPage, totalPages]); + }, [onPageUpdate, currentPage, totalPages, paginate]); const hasPrevPage = currentPage > 0; const hasNextPage = currentPage < totalPages - 1; @@ -114,6 +127,11 @@ export default function ScheduleGrid({ return () => resizeObserver.disconnect(); }); + const isGridDisplayed = !unselectedRange && !error; + useEffect(() => { + setGridDisplayed(isGridDisplayed); + }, [isGridDisplayed, setGridDisplayed]); + if (unselectedRange) return ( { - if ( - (newDirection === -1 && currentPage > 0) || - (newDirection === 1 && currentPage < view.totalPages - 1) - ) { - setCurrentPage([currentPage + newDirection, newDirection]); - onPaginate(currentPage + newDirection, view.totalPages); - } - }; + const paginate = useCallback( + (newDirection: number) => { + if ( + (newDirection < 0 && currentPage > 0) || + (newDirection > 0 && currentPage < view.totalPages - 1) + ) { + setCurrentPage([currentPage + newDirection, newDirection]); + onPaginate(currentPage + newDirection, view.totalPages); + } + }, + [currentPage, view.totalPages, onPaginate], + ); return { ...view, currentPage, direction, paginate }; } diff --git a/frontend/src/features/event/grid/lib/use-page-days.ts b/frontend/src/features/event/grid/lib/use-page-days.ts new file mode 100644 index 000000000..f1926250a --- /dev/null +++ b/frontend/src/features/event/grid/lib/use-page-days.ts @@ -0,0 +1,66 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { + GRID_PAGE_DAYS_DESKTOP_KEY, + GRID_PAGE_DAYS_DESKTOP_OPTIONS, + GRID_PAGE_DAYS_MOBILE_KEY, + GRID_PAGE_DAYS_MOBILE_OPTIONS, +} from "@/features/event/grid/lib/constants"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; + +export default function useGridPageDays() { + const isMobile = useCheckMobile(); + + const gridPageDaysOptions: number[] = isMobile + ? GRID_PAGE_DAYS_MOBILE_OPTIONS + : GRID_PAGE_DAYS_DESKTOP_OPTIONS; + + const localStorageKey = isMobile + ? GRID_PAGE_DAYS_MOBILE_KEY + : GRID_PAGE_DAYS_DESKTOP_KEY; + + const validateValue = useCallback( + (value: string | null) => { + if (!value) return gridPageDaysOptions[0]; + const parsedValue = parseInt(value); + return gridPageDaysOptions.includes(parsedValue) + ? parsedValue + : gridPageDaysOptions[0]; + }, + [gridPageDaysOptions], + ); + + const [gridPageDays, setGridPageDaysState] = useState( + gridPageDaysOptions[0], + ); + + useEffect(() => { + try { + const storedValue = localStorage.getItem(localStorageKey); + setGridPageDaysState(validateValue(storedValue)); + } catch { + setGridPageDaysState(gridPageDaysOptions[0]); + } + }, [localStorageKey, isMobile, gridPageDaysOptions, validateValue]); + + const setGridPageDays = (value: number) => { + try { + localStorage.setItem(localStorageKey, value.toString()); + } catch { + // Ignore error + } + setGridPageDaysState(value); + }; + + const usingMaxGridPageDays = + gridPageDays === Math.max(...gridPageDaysOptions); + + return { + gridPageDays, + gridPageDaysOptions, + usingMaxGridPageDays, + setGridPageDays, + }; +} diff --git a/frontend/src/features/event/grid/schedule-header.tsx b/frontend/src/features/event/grid/schedule-header.tsx index 7dc16c865..3f699b95f 100644 --- a/frontend/src/features/event/grid/schedule-header.tsx +++ b/frontend/src/features/event/grid/schedule-header.tsx @@ -14,6 +14,7 @@ import { cn } from "@/lib/utils/classname"; interface ScheduleHeaderProps { preview?: boolean; visibleDays: { dayKey: string; dayDisplay: string }[]; + compact: boolean; currentPage: number; totalPages: number; scrollbarPresent?: boolean; @@ -38,9 +39,31 @@ const variants = { }), }; +const MONTH_TO_NUM: Record = { + Jan: 1, + Feb: 2, + Mar: 3, + Apr: 4, + May: 5, + Jun: 6, + Jul: 7, + Aug: 8, + Sep: 9, + Oct: 10, + Nov: 11, + Dec: 12, +} as const; +const condenseMonth = (month: string) => { + return MONTH_TO_NUM[month] ?? month; +}; +const condenseWeekday = (weekday: string) => { + return weekday === "Thu" ? "R" : weekday === "Sun" ? "U" : weekday[0]; +}; + export default function ScheduleHeader({ preview = false, visibleDays, + compact, currentPage, totalPages, scrollbarPresent = false, @@ -54,7 +77,8 @@ export default function ScheduleHeader({ return (
-
{isWeekdayEvent ? weekday.toUpperCase() : weekday}
+
+ + {condenseWeekday(weekday)} + + + {isWeekdayEvent ? weekday.toUpperCase() : weekday} + +
{!isWeekdayEvent && ( -
- {month} {day.replace(/^0+/, "")} +
+ + {condenseMonth(month)}/ + + + {month} + + {day.replace(/^0+/, "")}
)}