-
- {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);
+ });
+});