diff --git a/src/Pages/Events/Calendar/CalendarCell.js b/src/Pages/Events/Calendar/CalendarCell.js index f2c45e345..ec4d87c68 100644 --- a/src/Pages/Events/Calendar/CalendarCell.js +++ b/src/Pages/Events/Calendar/CalendarCell.js @@ -10,7 +10,7 @@ export function CalendarCell({ date, isCurrentMonth, isToday, events, onSelectEv return (
+
-
- {cells.map((cell) => ( - +
+ {visibleWeeks.map((week, weekIndex) => ( +
+ {week.map((cell) => ( + + ))} +
))}
diff --git a/src/Pages/Events/Calendar/CalendarView.js b/src/Pages/Events/Calendar/CalendarView.js index 6e4e54d71..5cf83b8fa 100644 --- a/src/Pages/Events/Calendar/CalendarView.js +++ b/src/Pages/Events/Calendar/CalendarView.js @@ -1,6 +1,6 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useRef, useLayoutEffect } from 'react'; import { toDateKey } from '../eventUtils'; -import { eventDateKey, sortEventsForDay } from './calendarUtils'; +import { eventDateKey, sortEventsForDay, getTodayWeekRowIndex } from './calendarUtils'; import { EventPopup } from './EventPopup'; import { CalendarHeader } from './CalendarHeader'; import { CalendarGrid } from './CalendarGrid'; @@ -14,9 +14,13 @@ export default function CalendarView({ canCreateEvent = false, cursor, setCursor, + scrollToTodayWeekOnMount = false, }) { const today = useMemo(() => new Date(), []); const [selectedEvent, setSelectedEvent] = useState(null); + const shouldScrollToTodayWeekRef = useRef(scrollToTodayWeekOnMount); + const [scrollToTodayWeekRequest, setScrollToTodayWeekRequest] = useState(0); + const [firstVisibleWeekIndex, setFirstVisibleWeekIndex] = useState(0); const eventsByDate = useMemo(() => { const map = {}; @@ -35,12 +39,18 @@ export default function CalendarView({ const year = cursor.getFullYear(); const month = cursor.getMonth(); + function showFullMonth() { + setFirstVisibleWeekIndex(0); + } + function handleMonthChange(e) { + showFullMonth(); const nextMonth = Number(e.target.value); setCursor(new Date(year, nextMonth, 1)); } function handleYearChange(e) { + showFullMonth(); const nextYear = Number(e.target.value); setCursor(new Date(nextYear, month, 1)); } @@ -78,6 +88,31 @@ export default function CalendarView({ ); }, [cells]); + const isViewingCurrentMonth = + year === today.getFullYear() && month === today.getMonth(); + + useLayoutEffect(() => { + if (!shouldScrollToTodayWeekRef.current || !isViewingCurrentMonth) { + return; + } + + setFirstVisibleWeekIndex(getTodayWeekRowIndex(cells)); + shouldScrollToTodayWeekRef.current = false; + }, [ + isViewingCurrentMonth, + year, + month, + todayKey, + cells.length, + scrollToTodayWeekRequest, + ]); + + function handleTodayClick() { + shouldScrollToTodayWeekRef.current = true; + setScrollToTodayWeekRequest((n) => n + 1); + setCursor(new Date(today.getFullYear(), today.getMonth(), 1)); + } + return ( <> {selectedEvent && ( @@ -89,7 +124,7 @@ export default function CalendarView({ /> )} -
+
setCursor(new Date(today.getFullYear(), today.getMonth(), 1))} - onPreviousMonth={() => setCursor(new Date(year, month - 1, 1))} - onNextMonth={() => setCursor(new Date(year, month + 1, 1))} + onTodayClick={handleTodayClick} + onPreviousMonth={() => { + showFullMonth(); + setCursor(new Date(year, month - 1, 1)); + }} + onNextMonth={() => { + showFullMonth(); + setCursor(new Date(year, month + 1, 1)); + }} />
@@ -114,6 +155,7 @@ export default function CalendarView({ cells={cells} onSelectEvent={setSelectedEvent} isAdminView={isAdminView} + firstVisibleWeekIndex={firstVisibleWeekIndex} /> diff --git a/src/Pages/Events/Calendar/calendarUtils.js b/src/Pages/Events/Calendar/calendarUtils.js index 57b9361cf..00894c7f7 100644 --- a/src/Pages/Events/Calendar/calendarUtils.js +++ b/src/Pages/Events/Calendar/calendarUtils.js @@ -1,6 +1,19 @@ import { membershipState } from '../../../Enums'; import { toDateKey } from '../eventUtils'; +export function getWeekRowIndex(dayOfMonth, firstDayOfMonth) { + return Math.floor((dayOfMonth - 1 + firstDayOfMonth) / 7); +} + +/** Week row (0-based) in the month grid that contains today's cell. */ +export function getTodayWeekRowIndex(cells) { + const todayCellIndex = cells.findIndex((cell) => cell.isToday); + if (todayCellIndex < 0) { + return 0; + } + return Math.floor(todayCellIndex / 7); +} + export function eventDateKey(event) { if (!event.date) return null; if (/^\d{4}-\d{2}-\d{2}$/.test(event.date)) return event.date; diff --git a/src/Pages/Events/Events.js b/src/Pages/Events/Events.js index 1a7a08bc1..6fbf35b2b 100644 --- a/src/Pages/Events/Events.js +++ b/src/Pages/Events/Events.js @@ -8,6 +8,59 @@ import { toDateKey } from './eventUtils'; const EVENTS_CALENDAR_CURSOR_KEY = 'scevents-calendar-cursor'; +function getInitialCalendarState(search) { + const params = new URLSearchParams(search); + const monthParam = params.get('month'); + const yearParam = params.get('year'); + + const month = Number(monthParam); + const year = Number(yearParam); + + if ( + monthParam !== null && + yearParam !== null && + Number.isInteger(month) && + month >= 0 && + month <= 11 && + Number.isInteger(year) + ) { + return { + cursor: new Date(year, month, 1), + scrollToTodayWeekOnMount: false, + }; + } + + const savedCursor = window.localStorage.getItem(EVENTS_CALENDAR_CURSOR_KEY); + + if (savedCursor) { + try { + const parsedCursor = JSON.parse(savedCursor); + const savedMonth = Number(parsedCursor.month); + const savedYear = Number(parsedCursor.year); + + if ( + Number.isInteger(savedMonth) && + savedMonth >= 0 && + savedMonth <= 11 && + Number.isInteger(savedYear) + ) { + return { + cursor: new Date(savedYear, savedMonth, 1), + scrollToTodayWeekOnMount: false, + }; + } + } catch { + window.localStorage.removeItem(EVENTS_CALENDAR_CURSOR_KEY); + } + } + + const today = new Date(); + return { + cursor: new Date(today.getFullYear(), today.getMonth(), 1), + scrollToTodayWeekOnMount: true, + }; +} + function canUserSeeEvent(event, user) { const userId = user?._id != null ? String(user._id) : ''; const userAccess = user?.accessLevel ?? membershipState.NON_MEMBER; @@ -49,49 +102,9 @@ export default function EventsPage() { const location = useLocation(); const history = useHistory(); - const [cursor, setCursor] = useState(() => { - const params = new URLSearchParams(location.search); - const monthParam = params.get('month'); - const yearParam = params.get('year'); - - const month = Number(monthParam); - const year = Number(yearParam); - - if ( - monthParam !== null && - yearParam !== null && - Number.isInteger(month) && - month >= 0 && - month <= 11 && - Number.isInteger(year) - ) { - return new Date(year, month, 1); - } - - const savedCursor = window.localStorage.getItem(EVENTS_CALENDAR_CURSOR_KEY); - - if (savedCursor) { - try { - const parsedCursor = JSON.parse(savedCursor); - const savedMonth = Number(parsedCursor.month); - const savedYear = Number(parsedCursor.year); - - if ( - Number.isInteger(savedMonth) && - savedMonth >= 0 && - savedMonth <= 11 && - Number.isInteger(savedYear) - ) { - return new Date(savedYear, savedMonth, 1); - } - } catch { - window.localStorage.removeItem(EVENTS_CALENDAR_CURSOR_KEY); - } - } - - const today = new Date(); - return new Date(today.getFullYear(), today.getMonth(), 1); - }); + const [initialCalendarState] = useState(() => getInitialCalendarState(location.search)); + const [cursor, setCursor] = useState(() => initialCalendarState.cursor); + const scrollToTodayWeekOnMount = initialCalendarState.scrollToTodayWeekOnMount; const isAdminView = user?.accessLevel >= membershipState.OFFICER; const visibleEvents = events.filter((event) => canUserSeeEvent(event, user)); @@ -99,7 +112,7 @@ export default function EventsPage() { ? 'relative h-dvh overflow-hidden bg-gradient-to-r from-gray-800 to-gray-600 text-white' : 'relative h-[calc(100dvh-4rem)] overflow-hidden bg-gradient-to-r from-gray-800 to-gray-600 text-white'; const calendarContainerClass = isAdminView - ? 'relative h-full w-full overflow-hidden px-3 py-4 sm:px-4 sm:py-5 lg:px-5' + ? 'relative flex h-full min-h-0 w-full flex-col overflow-hidden px-3 py-4 sm:px-4 sm:py-5 lg:px-5' : 'relative mx-auto h-full max-w-[120rem] overflow-y-auto px-4 py-6 sm:px-6 sm:py-8 lg:px-10'; useEffect(() => { @@ -171,6 +184,7 @@ export default function EventsPage() { canCreateEvent={isAdminView} cursor={cursor} setCursor={setCursor} + scrollToTodayWeekOnMount={scrollToTodayWeekOnMount} /> )}
diff --git a/test/frontend/calendarUtils.test.js b/test/frontend/calendarUtils.test.js new file mode 100644 index 000000000..2220028f6 --- /dev/null +++ b/test/frontend/calendarUtils.test.js @@ -0,0 +1,51 @@ +import { expect } from 'chai'; +import { getWeekRowIndex, getTodayWeekRowIndex } from '../../src/Pages/Events/Calendar/calendarUtils'; +import { toDateKey } from '../../src/Pages/Events/eventUtils'; + +describe('getWeekRowIndex', () => { + // May 2026 starts on Friday (firstDayOfMonth === 5) + const may2026FirstDay = new Date(2026, 4, 1).getDay(); + + it('returns 0 for the first day of May 2026', () => { + expect(getWeekRowIndex(1, may2026FirstDay)).to.equal(0); + }); + + it('returns 5 for May 31, 2026 (6th week row)', () => { + expect(getWeekRowIndex(31, may2026FirstDay)).to.equal(5); + }); +}); + +describe('getTodayWeekRowIndex', () => { + function buildMay2026Cells(todayKey) { + const year = 2026; + const month = 4; + const firstDayOfMonth = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const totalCells = Math.ceil((firstDayOfMonth + daysInMonth) / 7) * 7; + + return Array.from({ length: totalCells }, (_, i) => { + const dayOffset = i - firstDayOfMonth; + const date = new Date(year, month, dayOffset + 1); + const key = toDateKey(date); + return { + key, + isToday: key === todayKey, + }; + }); + } + + it('returns 5 when today is May 31, 2026', () => { + const cells = buildMay2026Cells('2026-05-31'); + expect(getTodayWeekRowIndex(cells)).to.equal(5); + }); + + it('returns 5 when today is June 1, 2026 in the May grid', () => { + const cells = buildMay2026Cells('2026-06-01'); + expect(getTodayWeekRowIndex(cells)).to.equal(5); + }); + + it('returns 0 when today is not in the grid', () => { + const cells = buildMay2026Cells('2026-07-04'); + expect(getTodayWeekRowIndex(cells)).to.equal(0); + }); +});