From f0f9ff6e82bc38a8877c99564a0fd7f91de7a7e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 00:35:06 +0000 Subject: [PATCH] Make the availability picker usable on mobile The availability grid was built for a 700px-wide desktop modal and was barely usable on a phone: the table forced horizontal scrolling, the percentage-based row heights collapsed to ~18px tall slots outside the fixed-height modal, the two grid controls were squeezed onto one line, and the fixed CANCEL/SAVE bar sat on top of the last row of slots. Every style change is gated behind the existing max-width:480px mobile check, so the desktop rendering is unchanged. - Fit the week to the viewport on phones instead of scrolling sideways: narrower time column, abbreviated day labels, smaller type, and a fixed 36px row height so slots stay tappable. - Stack the "Fill from busy times" / "Invert Availability" controls above the grid on narrow screens. - Swap the slot's mousedown/mouseup for pointerdown/pointerup so a tap registers directly rather than through emulated mouse events; drag to paint stays mouse-only, so touch scrolling over the grid is unaffected. - Add day-header and time-label toggles (fill or clear a whole column or row) as the touch replacement for click-and-drag. Available on desktop too, surfaced by the cursor and tooltip. - Give the mobile action bar a border, a safe-area inset, and matching bottom padding on the grid so it stops covering slots, and drop the 30px page title for the long "Update your availability for..." header. - Apply the same fit-to-width treatment to the read-only availability view. Also folds the repeated "write the map, then recompute the displayed week" block into a single commitAvailabilities helper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015jsJ4vqxNeHzFTLe8s5KeE --- .../Components/EventAvailabilityPage.tsx | 29 +++- .../CalendarPage/Components/EventTimeSlot.tsx | 18 ++- .../Availability/AvailabilityEditModal.tsx | 31 ++-- .../Availability/EditAvailability.tsx | 151 ++++++++++++++---- .../Availability/SingleAvailabilityView.tsx | 57 +++++-- 5 files changed, 215 insertions(+), 71 deletions(-) diff --git a/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx index 3d2d50b909..f61672158d 100644 --- a/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx @@ -265,7 +265,15 @@ export const EventAvailabilityPage: React.FC = () => { + setEditAvailabilityOpen(true)} disabled={!isUserMember}> Edit My Availability @@ -273,9 +281,18 @@ export const EventAvailabilityPage: React.FC = () => { } > - + {/* Left side - Availability Grid */} - + { {/* Date/Time display */} @@ -334,7 +351,7 @@ export const EventAvailabilityPage: React.FC = () => { } return ( - Hover over a time slot to see availability + {isMobile ? 'Tap a time slot to see availability' : 'Hover over a time slot to see availability'} ); })()} diff --git a/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx b/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx index d1ad5f2e08..d1a3ae69f1 100644 --- a/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx @@ -6,9 +6,9 @@ interface EventTimeSlotProps { selected?: boolean; allRequiredAvailable?: boolean; busy?: boolean; - onMouseDown?: (e: React.MouseEvent) => void; + onPointerDown?: (e: React.PointerEvent) => void; onMouseEnter?: (e: React.MouseEvent) => void; - onMouseUp?: () => void; + onPointerUp?: () => void; } const EventTimeSlot: React.FC = ({ @@ -17,9 +17,9 @@ const EventTimeSlot: React.FC = ({ selected = false, allRequiredAvailable = false, busy = false, - onMouseDown, + onPointerDown, onMouseEnter, - onMouseUp + onPointerUp }) => { const getBorderColor = () => { if (selected) return '#ffff8c'; @@ -35,13 +35,17 @@ const EventTimeSlot: React.FC = ({ return ( = ({ if (isMobile && open) { return ( - - + + {/* the page title styling is far too large for this header sentence on a phone */} + + {header} + + {/* leaves room for the fixed action bar below, which would otherwise cover the last row of slots */} + + + = ({ bottom: 0, left: 0, right: 0, - p: 2, + px: 2, + pt: 2, + pb: 'calc(16px + env(safe-area-inset-bottom))', display: 'flex', gap: 2, + zIndex: 1100, // MUI's app bar layer - keeps the action bar above the grid + borderTop: '1px solid', + borderColor: 'divider', backgroundColor: 'background.paper' }} > diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx index 4b85935a6b..3c3b9db4ca 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx @@ -26,6 +26,16 @@ import { useCurrentUser, useUserBusyTimes } from '../../../../hooks/users.hooks' import { busySlotsByDay, isSlotBusy } from '../../../../utils/ics.utils'; import { useToast } from '../../../../hooks/toasts.hooks'; +// Row height on phones - percentage heights collapse to nothing outside a fixed-height modal, +// and slots need to stay big enough to tap accurately. +const TOUCH_SLOT_HEIGHT = 36; + +// The two grid controls only fit side by side on a phone at a smaller size +const mobileButton = { fontSize: 12, px: 1 }; + +// The day and time labels give up as much width as they can spare so the week fits a phone screen +const narrowLabelColumn = { width: 46, px: 0.25 }; + interface EditAvailabilityProps { editedAvailabilities: Map; setEditedAvailabilities: (val: Map) => void; @@ -43,6 +53,7 @@ const EditAvailability: React.FC = ({ }) => { const currentUser = useCurrentUser(); const toast = useToast(); + const isMobile = useMediaQuery('(max-width:480px)'); const [currentlyDisplayedAvailabilities, setCurrentlyDisplayedAvailabilities] = useState(() => { const availabilities = Array.from(editedAvailabilities.values()); if (availabilities.length === 0) { @@ -75,10 +86,11 @@ const EditAvailability: React.FC = ({ const busyByDay = busySlotsByDay(busyTimes ?? []); - const handleMouseDown = (event: any, availability: Availability, selectedTime: number) => { + // pointerdown rather than mousedown so a tap registers on touch without relying on emulated mouse events + const handlePointerDown = (event: React.PointerEvent, availability: Availability, selectedTime: number) => { event.preventDefault(); toggleTimeSlot(availability, selectedTime); - setIsDragging(true); + setIsDragging(event.pointerType === 'mouse'); }; const increaseDateRange = () => { @@ -116,17 +128,27 @@ const EditAvailability: React.FC = ({ toggleTimeSlot(availability, selectedTime); }; - const handleMouseUp = () => { + const handlePointerUp = () => { setIsDragging(false); }; useEffect(() => { - window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); return () => { - window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); }; }, []); + const commitAvailabilities = () => { + setEditedAvailabilities(editedAvailabilities); + const currentStartDate = currentlyDisplayedAvailabilities[0]?.dateSet ?? initialDate; + setCurrentlyDisplayedAvailabilities( + getMostRecentAvailabilities(Array.from(editedAvailabilities.values()), currentStartDate) + ); + }; + const invertAvailabilities = () => { currentlyDisplayedAvailabilities.forEach((availability) => enumToArray(REVIEW_TIMES).forEach((_time, timeIndex) => toggleTimeSlot(availability, timeIndex)) @@ -145,11 +167,7 @@ const EditAvailability: React.FC = ({ editedAvailabilities.set(availability.dateSet.getTime(), availability); }); - setEditedAvailabilities(editedAvailabilities); - const currentStartDate = currentlyDisplayedAvailabilities[0]?.dateSet ?? initialDate; - setCurrentlyDisplayedAvailabilities( - getMostRecentAvailabilities(Array.from(editedAvailabilities.values()), currentStartDate) - ); + commitAvailabilities(); toast.success( busyCount > 0 @@ -164,12 +182,32 @@ const EditAvailability: React.FC = ({ : availability.availability.push(selectedTime); editedAvailabilities.set(availability.dateSet.getTime(), availability); - setEditedAvailabilities(editedAvailabilities); + commitAvailabilities(); + }; - const currentStartDate = currentlyDisplayedAvailabilities[0]?.dateSet ?? initialDate; - setCurrentlyDisplayedAvailabilities( - getMostRecentAvailabilities(Array.from(editedAvailabilities.values()), currentStartDate) + // Tapping a day header fills or clears that whole column - the touch replacement for click-and-drag + const toggleDay = (availability: Availability) => { + const allSlots = enumToArray(REVIEW_TIMES).map((_time, timeIndex) => timeIndex); + const isFullyAvailable = allSlots.every((slot) => availability.availability.includes(slot)); + + availability.availability = isFullyAvailable ? [] : allSlots; + editedAvailabilities.set(availability.dateSet.getTime(), availability); + commitAvailabilities(); + }; + + // Tapping a time label fills or clears that hour across every displayed day + const toggleTime = (selectedTime: number) => { + const isFullyAvailable = currentlyDisplayedAvailabilities.every((availability) => + availability.availability.includes(selectedTime) ); + + currentlyDisplayedAvailabilities.forEach((availability) => { + const withoutSelectedTime = availability.availability.filter((slot) => slot !== selectedTime); + availability.availability = isFullyAvailable ? withoutSelectedTime : [...withoutSelectedTime, selectedTime]; + editedAvailabilities.set(availability.dateSet.getTime(), availability); + }); + + commitAvailabilities(); }; const stickyLeft = { @@ -179,13 +217,18 @@ const EditAvailability: React.FC = ({ bgcolor: 'background.paper' }; - const isMobile = useMediaQuery('(max-width:480px)'); - return ( - - + + - + Available times in {isInverted ? ( white @@ -195,15 +238,27 @@ const EditAvailability: React.FC = ({ .    All times are in local time, {yourTimeZoneInitials()}.{' '} - Hatched slots are busy on your imported calendar or Finishline events. Use "Fill from busy times" to pre-fill, - then adjust any slots manually. + {isMobile + ? 'Tap a slot to toggle it, or tap a day or time label to fill that whole row or column. Hatched slots are busy on your calendar.' + : 'Hatched slots are busy on your imported calendar or Finishline events. Use "Fill from busy times" to pre-fill, then adjust any slots manually.'} - + {busyTimesIsFetching ? 'Filling out...' : 'Fill from busy times'} - + Invert Availability @@ -215,7 +270,7 @@ const EditAvailability: React.FC = ({ overflowY: 'auto', maxWidth: '100%', maxHeight: '100%', - scrollSnapType: 'x mandatory', + scrollSnapType: isMobile ? 'none' : 'x mandatory', flex: 1 }} > @@ -223,31 +278,42 @@ const EditAvailability: React.FC = ({ stickyHeader size="small" sx={{ - height: '100%', + height: isMobile ? 'auto' : '100%', tableLayout: 'fixed', '& .MuiTableCell-head': { bgcolor: 'background.paper', - px: 0.5, + px: isMobile ? 0.25 : 0.5, py: 0.5 }, '& .MuiTableCell-body': { px: 0, py: 0, - height: `calc((100% - 50px) / 12)` + height: isMobile ? TOUCH_SLOT_HEIGHT : `calc((100% - 50px) / 12)` }, '& .MuiTableCell-root': { borderRight: '1px solid', borderColor: 'divider' }, - minWidth: 700 + // on phones the week has to fit the viewport - there is nowhere to scroll sideways to + minWidth: isMobile ? 0 : 700 }} > - + {currentlyDisplayedAvailabilities.map((availability, idx) => ( - - + toggleDay(availability)} + title="Fill or clear this whole day" + sx={{ scrollSnapAlign: 'start', cursor: 'pointer', userSelect: 'none' }} + > + {!isMobile && getDayOfWeek(availability.dateSet)} {isMobile && getDayOfWeek(availability.dateSet).slice(0, 3)}
@@ -261,8 +327,23 @@ const EditAvailability: React.FC = ({ {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( - - + toggleTime(timeIndex)} + title="Fill or clear this time across every day" + sx={{ + ...stickyLeft, + ...(isMobile && narrowLabelColumn), + zIndex: 1, + cursor: 'pointer', + userSelect: 'none', + scrollSnapAlign: 'start' + }} + > + {reviewTimesInCurrentTimeZone(time)} @@ -274,9 +355,9 @@ const EditAvailability: React.FC = ({ backgroundColor={isAvailable ? HeatmapColors[3] : HeatmapColors[0]} selected={false} busy={isSlotBusy(busyByDay, availability.dateSet, timeIndex)} - onMouseDown={(e) => handleMouseDown(e, availability, timeIndex)} + onPointerDown={(e) => handlePointerDown(e, availability, timeIndex)} onMouseEnter={(e) => handleMouseEnter(e, availability, timeIndex)} - onMouseUp={handleMouseUp} + onPointerUp={handlePointerUp} /> ); diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx index 11910e2d1b..79c59018df 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx @@ -1,4 +1,14 @@ -import { Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + useMediaQuery +} from '@mui/material'; import { addDaysToDate, Availability, getDayOfWeek, getMostRecentAvailabilities } from 'shared'; import { datePipe } from '../../../../utils/pipes'; import { useState, useEffect } from 'react'; @@ -14,6 +24,9 @@ import EventTimeSlot from '../../../CalendarPage/Components/EventTimeSlot'; import { useCurrentUser, useUserBusyTimes } from '../../../../hooks/users.hooks'; import { busySlotsByDay, isSlotBusy } from '../../../../utils/ics.utils'; +// The day and time labels give up as much width as they can spare so the week fits a phone screen +const narrowLabelColumn = { width: 46, px: 0.25 }; + interface SingleAvailabilityViewProps { totalAvailability: Availability[]; initialDate?: Date; @@ -21,6 +34,7 @@ interface SingleAvailabilityViewProps { const SingleAvailabilityView: React.FC = ({ totalAvailability, initialDate }) => { const currentUser = useCurrentUser(); + const isMobile = useMediaQuery('(max-width:480px)'); const [startDate, setStartDate] = useState(initialDate || new Date()); useEffect(() => { @@ -56,8 +70,10 @@ const SingleAvailabilityView: React.FC = ({ totalAv }; return ( - - All times are in local time, {yourTimeZoneInitials()}. + + + All times are in local time, {yourTimeZoneInitials()}. + Hatched slots are busy on your imported calendar or Finishline events. Edit your availability and use "Fill from busy times" to pull in any changes. @@ -65,9 +81,9 @@ const SingleAvailabilityView: React.FC = ({ totalAv @@ -75,32 +91,41 @@ const SingleAvailabilityView: React.FC = ({ totalAv stickyHeader size="small" sx={{ - height: '100%', + height: isMobile ? 'auto' : '100%', tableLayout: 'fixed', '& .MuiTableCell-head': { bgcolor: 'background.paper', - px: 0.5, + px: isMobile ? 0.25 : 0.5, py: 0.5 }, '& .MuiTableCell-body': { px: 0, py: 0, - height: `calc((100% - 50px) / 12)` + height: isMobile ? 30 : `calc((100% - 50px) / 12)` }, '& .MuiTableCell-root': { borderRight: '1px solid', borderColor: 'divider' }, - minWidth: 700 + minWidth: isMobile ? 0 : 700 }} > - + {selectedTimes.map((availability, idx) => ( - - {getDayOfWeek(availability.dateSet) + ' ' + datePipe(availability.dateSet)} + + {isMobile + ? getDayOfWeek(availability.dateSet).slice(0, 3) + : getDayOfWeek(availability.dateSet) + ' ' + datePipe(availability.dateSet)} + {isMobile &&
} + {isMobile && datePipe(availability.dateSet, false)}
))} @@ -109,8 +134,12 @@ const SingleAvailabilityView: React.FC = ({ totalAv {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( - - + + {reviewTimesInCurrentTimeZone(time)}