Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Pages/Events/Calendar/CalendarCell.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function CalendarCell({ date, isCurrentMonth, isToday, events, onSelectEv
return (
<div
className={[
'min-h-[106px] sm:min-h-[124px] border-b border-r border-slate-700/60 p-1.5 transition-colors duration-150',
'min-h-[106px] sm:min-h-0 sm:h-full border-b border-r border-slate-700/60 p-1.5 transition-colors duration-150',
isCurrentMonth ? '' : 'opacity-30',
isToday ? 'bg-cyan-500/[0.12]' : 'hover:bg-slate-700/35',
].join(' ')}
Expand Down
44 changes: 34 additions & 10 deletions src/Pages/Events/Calendar/CalendarGrid.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
import { DayLabels } from './DayLabels';
import { CalendarCell } from './CalendarCell';

export function CalendarGrid({ cells, onSelectEvent, isAdminView }) {
function chunkWeeks(cells) {
const weeks = [];
for (let i = 0; i < cells.length; i += 7) {
weeks.push(cells.slice(i, i + 7));
}
return weeks;
}

export function CalendarGrid({
cells,
onSelectEvent,
isAdminView,
firstVisibleWeekIndex = 0,
}) {
const weeks = chunkWeeks(cells);
const visibleWeeks = weeks.slice(firstVisibleWeekIndex);

return (
<div className="hidden sm:block">
<div className="hidden sm:flex sm:flex-col sm:flex-1 sm:min-h-0">
<DayLabels />

<div className="grid grid-cols-7 [&>*:nth-child(7n)]:border-r-0">
{cells.map((cell) => (
<CalendarCell
key={cell.key}
{...cell}
onSelectEvent={onSelectEvent}
isAdminView={isAdminView}
/>
<div className="flex min-h-0 flex-1 flex-col">
{visibleWeeks.map((week, weekIndex) => (
<div
key={firstVisibleWeekIndex + weekIndex}
data-week-index={firstVisibleWeekIndex + weekIndex}
className="grid min-h-0 flex-1 grid-cols-7 [&>*:nth-child(7n)]:border-r-0"
>
{week.map((cell) => (
<CalendarCell
key={cell.key}
{...cell}
onSelectEvent={onSelectEvent}
isAdminView={isAdminView}
/>
))}
</div>
))}
</div>
</div>
Expand Down
54 changes: 48 additions & 6 deletions src/Pages/Events/Calendar/CalendarView.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = {};
Expand All @@ -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));
}
Expand Down Expand Up @@ -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 && (
Expand All @@ -89,17 +124,23 @@ export default function CalendarView({
/>
)}

<div className="flex h-full max-h-full flex-col overflow-hidden rounded-xl border border-slate-500/50 bg-slate-800/85 shadow-[0_0_0_1px_rgba(148,163,184,0.05)] sm:h-auto sm:max-h-none">
<div className="flex h-full min-h-0 max-h-full flex-col overflow-hidden rounded-xl border border-slate-500/50 bg-slate-800/85 shadow-[0_0_0_1px_rgba(148,163,184,0.05)]">
<CalendarHeader
month={month}
year={year}
monthEventCount={monthEventCount}
canCreateEvent={canCreateEvent}
onMonthChange={handleMonthChange}
onYearChange={handleYearChange}
onTodayClick={() => 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));
}}
/>

<div className="flex-1 min-h-0 overflow-y-auto sm:hidden">
Expand All @@ -114,6 +155,7 @@ export default function CalendarView({
cells={cells}
onSelectEvent={setSelectedEvent}
isAdminView={isAdminView}
firstVisibleWeekIndex={firstVisibleWeekIndex}
/>

<CalendarLegend isAdminView={isAdminView} />
Expand Down
13 changes: 13 additions & 0 deletions src/Pages/Events/Calendar/calendarUtils.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
102 changes: 58 additions & 44 deletions src/Pages/Events/Events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,57 +102,17 @@ 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));
const pageContainerClass = isAdminView
? '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(() => {
Expand Down Expand Up @@ -171,6 +184,7 @@ export default function EventsPage() {
canCreateEvent={isAdminView}
cursor={cursor}
setCursor={setCursor}
scrollToTodayWeekOnMount={scrollToTodayWeekOnMount}
/>
)}
</div>
Expand Down
51 changes: 51 additions & 0 deletions test/frontend/calendarUtils.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading