From d4626cf891423d0c5a253bb9f17905ff3fe45226 Mon Sep 17 00:00:00 2001 From: ethancha0 Date: Fri, 5 Jun 2026 10:50:44 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E2=9C=A8=20working?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/availability/availability.tsx | 64 ++- .../availability/mobile-group-responses.tsx | 18 + .../mobile-room-recommendations.tsx | 36 ++ .../availability/room-recommendations.tsx | 372 +++++++++--------- 4 files changed, 302 insertions(+), 188 deletions(-) create mode 100644 src/components/availability/mobile-room-recommendations.tsx diff --git a/src/components/availability/availability.tsx b/src/components/availability/availability.tsx index 6617dd134..395edec06 100644 --- a/src/components/availability/availability.tsx +++ b/src/components/availability/availability.tsx @@ -11,6 +11,7 @@ import { GroupResponses } from "@/components/availability/group-responses"; import { AvailabilityHeader } from "@/components/availability/header/availability-header"; import { PersonalAvailability } from "@/components/availability/personal-availability"; import { + deduplicateRooms, groupRawRoomsByKey, RoomRecommendationSettings, } from "@/components/availability/room-recommendations"; @@ -45,6 +46,7 @@ import { useAvailabilityStore } from "@/store/useAvailabilityStore"; import { MobilePersonalAvailabilitySidebar } from "../nav/mobile-personal-availability"; import { PersonalAvailabilitySidebar } from "../nav/personal-availability-sidebar"; import { MobileGroupResponses } from "./mobile-group-responses"; +import { MobileRoomRecommendations } from "./mobile-room-recommendations"; import { MobileScheduleSettings } from "./mobile-schedule-settings"; const LG_UP_MEDIA = "(min-width: 1024px)"; @@ -239,6 +241,7 @@ export function Availability({ } = useRoomRecommendations(availabilityDates); const [selectedRoomIds, setSelectedRoomIds] = useState([]); + const [isMobileRoomsDrawerOpen, setIsMobileRoomsDrawerOpen] = useState(false); const showRoomPreviews = availabilityView === "group" || availabilityView === "schedule"; @@ -254,6 +257,17 @@ export function Availability({ [studyRooms], ); + const mobileRoomCount = useMemo( + () => deduplicateRooms(studyRooms).length, + [studyRooms], + ); + + useEffect(() => { + if (availabilityView !== "group") { + setIsMobileRoomsDrawerOpen(false); + } + }, [availabilityView]); + const handleImportSlotsFromMeeting = useCallback( ({ meetingAvailabilities, @@ -430,6 +444,7 @@ export function Availability({ }, [router, returnToPath, setAvailabilityView, user]); const handleMobileOpenAttendees = useCallback(() => { + setIsMobileRoomsDrawerOpen(false); setIsMobileDrawerOpen(true); }, [setIsMobileDrawerOpen]); @@ -437,6 +452,15 @@ export function Availability({ setAvailabilityView("schedule"); }, [setAvailabilityView]); + const handleMobileOpenRooms = useCallback(() => { + setIsMobileDrawerOpen(false); + setIsMobileRoomsDrawerOpen(true); + }, [setIsMobileDrawerOpen]); + + const handleCloseMobileRooms = useCallback(() => { + setIsMobileRoomsDrawerOpen(false); + }, []); + return ( ) : ( - + <> + + + )} diff --git a/src/components/availability/mobile-group-responses.tsx b/src/components/availability/mobile-group-responses.tsx index 339e8636f..e970a4c98 100644 --- a/src/components/availability/mobile-group-responses.tsx +++ b/src/components/availability/mobile-group-responses.tsx @@ -1,6 +1,7 @@ "use client"; import { + Apartment, CalendarMonthOutlined, EditCalendarOutlined, PeopleAltOutlined, @@ -20,8 +21,11 @@ export interface MobileGroupResponsesProps { isOwner: boolean; respondedMembersCount: number; pendingMembersCount: number; + roomCount: number; + hasSearchedRooms: boolean; onAddAvailability: () => void; onOpenAttendees: () => void; + onOpenRooms: () => void; onSchedule: () => void; } @@ -29,14 +33,28 @@ export function MobileGroupResponses({ isOwner, respondedMembersCount, pendingMembersCount, + roomCount, + hasSearchedRooms, onAddAvailability, onOpenAttendees, + onOpenRooms, onSchedule, }: MobileGroupResponsesProps) { + const roomsLabel = hasSearchedRooms ? `${roomCount} Rooms` : "Rooms"; + return (
+ + + + + + {errorMessage && ( + + {errorMessage} + + )} + + + +
+ + + +
+
+ + Meeting Length + + onFiltersChange({ ...filters, lengths: [] })} + /> +
+ +
+ + Capacity + + onFiltersChange({ ...filters, capacities: [] })} + /> +
+ +
+ + Buildings + + onFiltersChange({ ...filters, buildings: [] })} + /> +
+
+
+
+ + + +
+ {roomState.status === "results" && ( +
+
+ Room Results + {selectedBookingUrl && ( +
+ +
+ )} +
+ + Click a chip to pin a room on the calendar. Hover to preview + without pinning. + + + {effectiveSelectedRoomIds.length > 1 && ( + +
+ You can only book one room at a time +
+ )} +
+ )} + + {roomState.status === "empty" && ( + + {rawRooms.length === 0 + ? "No available study rooms for the selected times." + : "No rooms match your current filters."} + + )} + + {roomState.status === "results" && ( +
+ {roomState.rooms.map((room) => { + const isSelected = effectiveSelectedRoomIds.includes(room.id); + const label = [ + formatRoomChipLabel(room.location, room.label), + room.capacity != null ? `· ${room.capacity} cap` : null, + room.techEnhanced ? "· Tech" : null, + ] + .filter(Boolean) + .join(" "); + + return ( + handleToggleRoom(room)} + onMouseEnter={() => handleRoomChipMouseEnter(room)} + onMouseLeave={handleRoomChipMouseLeave} + sx={{ maxWidth: "100%" }} + /> + ); + })} +
+ )} +
+
+ ); + + if (isSheet) { + return ( +
+
+ Room Recommendations + + Auto-generate the rooms that are most compatible with the Attendee + Responder results. + +
+ {settingsContent} +
+ ); + } + return (
@@ -388,182 +581,7 @@ export function RoomRecommendationSettings({ -
- - - {errorMessage && ( - - {errorMessage} - - )} - - - -
- - - -
-
- - Meeting Length - - - onFiltersChange({ ...filters, lengths: [] }) - } - /> -
- -
- - Capacity - - - onFiltersChange({ ...filters, capacities: [] }) - } - /> -
- -
- - Buildings - - - onFiltersChange({ ...filters, buildings: [] }) - } - /> -
-
-
-
- - - -
- {roomState.status === "results" && ( -
-
- Room Results - {selectedBookingUrl && ( -
- -
- )} -
- - Click a chip to pin a room on the calendar. Hover to preview - without pinning. - - - {effectiveSelectedRoomIds.length > 1 && ( - -
- You can only book one room at a time -
- )} -
- )} - - {roomState.status === "empty" && ( - - {rawRooms.length === 0 - ? "No available study rooms for the selected times." - : "No rooms match your current filters."} - - )} - - {roomState.status === "results" && ( -
- {roomState.rooms.map((room) => { - const isSelected = effectiveSelectedRoomIds.includes( - room.id, - ); - const label = [ - formatRoomChipLabel(room.location, room.label), - room.capacity != null ? `· ${room.capacity} cap` : null, - room.techEnhanced ? "· Tech" : null, - ] - .filter(Boolean) - .join(" "); - - return ( - handleToggleRoom(room)} - onMouseEnter={() => handleRoomChipMouseEnter(room)} - onMouseLeave={handleRoomChipMouseLeave} - sx={{ maxWidth: "100%" }} - /> - ); - })} -
- )} -
-
+ {settingsContent}
From e1064d4e63466649cff5e6da75b16c3a5c292286 Mon Sep 17 00:00:00 2001 From: ethancha0 Date: Fri, 5 Jun 2026 18:50:16 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E2=9C=A8=20horizontal=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/availability/availability.tsx | 12 + .../availability/mobile-room-wheel-picker.tsx | 251 ++++++++++++++++++ .../availability/room-recommendations.tsx | 2 +- 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/components/availability/mobile-room-wheel-picker.tsx diff --git a/src/components/availability/availability.tsx b/src/components/availability/availability.tsx index 395edec06..bdfb82138 100644 --- a/src/components/availability/availability.tsx +++ b/src/components/availability/availability.tsx @@ -47,6 +47,7 @@ import { MobilePersonalAvailabilitySidebar } from "../nav/mobile-personal-availa import { PersonalAvailabilitySidebar } from "../nav/personal-availability-sidebar"; import { MobileGroupResponses } from "./mobile-group-responses"; import { MobileRoomRecommendations } from "./mobile-room-recommendations"; +import { MobileRoomWheelPicker } from "./mobile-room-wheel-picker"; import { MobileScheduleSettings } from "./mobile-schedule-settings"; const LG_UP_MEDIA = "(min-width: 1024px)"; @@ -607,6 +608,17 @@ export function Availability({ /> ) : ( <> + {hasSearchedRooms && + studyRooms.length > 0 && + !isMobileRoomsDrawerOpen && ( + + )} ; + filters: RoomFilters; + selectedRoomIds: string[]; + onSelectedRoomIdsChange: (ids: string[]) => void; +} + +export function MobileRoomWheelPicker({ + rawRooms, + rawRoomsByKey, + filters, + selectedRoomIds, + onSelectedRoomIdsChange, +}: MobileRoomWheelPickerProps) { + const { setHoveredRoom } = useStudyRoomHover(); + const [activeIndex, setActiveIndex] = useState(0); + const chipRefs = useRef<(HTMLDivElement | null)[]>([]); + + const { + lengths: selectedLengths, + capacities: selectedCapacities, + buildings: selectedBuildings, + } = filters; + + const rooms = useMemo(() => { + const all = deduplicateRooms(rawRooms); + return all.filter((room) => { + if (selectedCapacities.length > 0) { + if (room.capacity == null) return false; + const matchesCapacity = selectedCapacities.some((range) => { + if (range === "13+") + return room.capacity != null && room.capacity >= 13; + const [min, max] = range.split("-").map(Number); + return ( + room.capacity != null && + room.capacity >= min && + room.capacity <= max + ); + }); + if (!matchesCapacity) return false; + } + + if (selectedBuildings.length > 0) { + const matchesBuilding = selectedBuildings.some((b) => + room.location.includes(b), + ); + if (!matchesBuilding) return false; + } + + if (selectedLengths.length > 0 && room.durations.length > 0) { + const matchesLength = room.durations.some((d) => + selectedLengths.includes(d), + ); + if (!matchesLength) return false; + } + + return true; + }); + }, [rawRooms, selectedCapacities, selectedBuildings, selectedLengths]); + + // Clamp activeIndex when room list shrinks + useEffect(() => { + if (rooms.length === 0) return; + setActiveIndex((prev) => Math.min(prev, rooms.length - 1)); + }, [rooms.length]); + + // Drive the heatmap preview whenever the active room changes + useEffect(() => { + if (rooms.length === 0) { + setHoveredRoom(null); + return; + } + const activeRoom = rooms[activeIndex]; + if (!activeRoom) return; + + setHoveredRoom(rawRoomsByKey.get(activeRoom.id) ?? null); + + chipRefs.current[activeIndex]?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + }, [activeIndex, rooms, rawRoomsByKey, setHoveredRoom]); + + // Clear preview when the picker unmounts + useEffect(() => { + return () => { + setHoveredRoom(null); + }; + }, [setHoveredRoom]); + + const handlePrev = useCallback(() => { + setActiveIndex((i) => Math.max(0, i - 1)); + }, []); + + const handleNext = useCallback(() => { + setActiveIndex((i) => Math.min(rooms.length - 1, i + 1)); + }, [rooms.length]); + + const handleChipClick = useCallback( + (index: number, roomId: string) => { + if (index !== activeIndex) { + // Navigate to this room, which triggers the hover preview + setActiveIndex(index); + return; + } + // Toggle pin on the currently-active room + const next = selectedRoomIds.includes(roomId) + ? selectedRoomIds.filter((id) => id !== roomId) + : [...selectedRoomIds, roomId]; + onSelectedRoomIdsChange(next); + }, + [activeIndex, selectedRoomIds, onSelectedRoomIdsChange], + ); + + const activeRoom = rooms[activeIndex]; + const activeRoomIsPinned = + activeRoom != null && selectedRoomIds.includes(activeRoom.id); + const activeBookingUrl = useMemo(() => { + if (!activeRoomIsPinned || !activeRoom) return null; + return getRoomBookingUrl(rawRoomsByKey.get(activeRoom.id), filters.lengths); + }, [activeRoomIsPinned, activeRoom, rawRoomsByKey, filters.lengths]); + + if (rooms.length === 0) return null; + + return ( +
+ +
+ + + + + {/* Scrollable chip track */} +
+ {rooms.map((room, i) => { + const chipLabel = [ + formatRoomChipLabel(room.location, room.label), + room.capacity != null ? `· ${room.capacity} cap` : null, + room.techEnhanced ? "· Tech" : null, + ] + .filter(Boolean) + .join(" "); + const isActive = i === activeIndex; + const isSelected = selectedRoomIds.includes(room.id); + + return ( +
{ + chipRefs.current[i] = el; + }} + style={{ scrollSnapAlign: "center", flexShrink: 0 }} + > + handleChipClick(i, room.id)} + size="small" + sx={isActive ? { fontWeight: 600 } : { opacity: 0.6 }} + /> +
+ ); + })} +
+ + + + +
+ + {activeBookingUrl && ( +
+ +
+ )} + + + {activeIndex + 1} / {rooms.length} + {" · "} + {activeRoomIsPinned ? "Pinned" : "Tap to pin"} + +
+
+ ); +} diff --git a/src/components/availability/room-recommendations.tsx b/src/components/availability/room-recommendations.tsx index 4b3e66edb..fa3f3233e 100644 --- a/src/components/availability/room-recommendations.tsx +++ b/src/components/availability/room-recommendations.tsx @@ -48,7 +48,7 @@ function dedupeKey(name: string, location: string): string { return `${stripRoomDurationSuffix(name)}|${location}`; } -function getRoomBookingUrl( +export function getRoomBookingUrl( variants: StudyRoomApiEntry[] | undefined, preferredLengths: MeetingLength[] = [], ): string | null {