diff --git a/src/Pages/Events/Calendar/CalendarHeader.js b/src/Pages/Events/Calendar/CalendarHeader.js index e76a7f7df..f947ee641 100644 --- a/src/Pages/Events/Calendar/CalendarHeader.js +++ b/src/Pages/Events/Calendar/CalendarHeader.js @@ -1,17 +1,17 @@ import { Link } from 'react-router-dom'; -import { ChevronLeft, ChevronRight, PlusIcon } from '../EventIcons'; -import { MONTHS, YEAR_RANGE } from './calendarConstants'; +import { ChevronDown, ChevronLeft, ChevronRight, PlusIcon } from '../EventIcons'; +import { VIEW_MODES } from './calendarConstants'; export function CalendarHeader({ - month, - year, - monthEventCount, + view, + onViewChange, + title, + eventCount, + countLabel, canCreateEvent, - onMonthChange, - onYearChange, onTodayClick, - onPreviousMonth, - onNextMonth, + onPrevious, + onNext, }) { return (
@@ -19,32 +19,20 @@ export function CalendarHeader({
-
- -
- +
+ +
-
+
{canCreateEvent && ( )} + + {title} + + +
+ + {selectedEvents.length > 0 ? ( +
+ {selectedEvents.map((event, index) => ( + + ))} +
+ ) : ( +

No events scheduled for this date.

+ )} +
+
+ )} + +
+ {MONTHS.map((monthName, monthIndex) => { + const monthDates = miniMonthMatrix(year, monthIndex); + + return ( +
{ + monthRefs.current[monthIndex] = node; + }} + className="min-w-0 rounded-lg border border-slate-700/50 bg-slate-900/20 p-2" + > + + +
+ {DAYS.map((day) => ( + {day} + ))} +
+ +
+ {monthDates.map((date, index) => { + if (!date) { + return +
+ ); + })} +
+ + ); +} diff --git a/src/Pages/Events/Calendar/calendarConstants.js b/src/Pages/Events/Calendar/calendarConstants.js index a3be0f6c6..d497fe6d6 100644 --- a/src/Pages/Events/Calendar/calendarConstants.js +++ b/src/Pages/Events/Calendar/calendarConstants.js @@ -5,5 +5,9 @@ export const MONTHS = [ 'July', 'August', 'September', 'October', 'November', 'December', ]; -const currentYear = new Date().getFullYear(); -export const YEAR_RANGE = Array.from({ length: 10 }, (_, i) => currentYear + i); +export const VIEW_MODES = [ + { label: 'Day', value: 'day' }, + { label: 'Week', value: 'week' }, + { label: 'Month', value: 'month' }, + { label: 'Year', value: 'year' }, +]; diff --git a/src/Pages/Events/Calendar/calendarUtils.js b/src/Pages/Events/Calendar/calendarUtils.js index 57b9361cf..568eea24e 100644 --- a/src/Pages/Events/Calendar/calendarUtils.js +++ b/src/Pages/Events/Calendar/calendarUtils.js @@ -1,5 +1,6 @@ import { membershipState } from '../../../Enums'; import { toDateKey } from '../eventUtils'; +import { MONTHS } from './calendarConstants'; export function eventDateKey(event) { if (!event.date) return null; @@ -228,3 +229,144 @@ export function canUserManageEvent(event, user) { return false; } + +export function visibleRange(view, cursor) { + const year = cursor.getFullYear(); + const month = cursor.getMonth(); + const day = cursor.getDate(); + let start; + let end; + + switch (view) { + case 'day': + start = new Date(year, month, day); + end = new Date(year, month, day); + break; + case 'week': + start = new Date(year, month, day - cursor.getDay()); + end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 6); + break; + case 'year': + start = new Date(year, 0, 1); + end = new Date(year, 11, 31); + break; + default: + start = new Date(year, month, 1); + end = new Date(year, month + 1, 0); + } + + return { + startDate: toDateKey(start), + endDate: toDateKey(end), + }; +} + +export function stepCursor(view, cursor, direction) { + const year = cursor.getFullYear(); + const month = cursor.getMonth(); + const day = cursor.getDate(); + + switch (view) { + case 'day': + return new Date(year, month, day + direction); + case 'week': + return new Date(year, month, day + (7 * direction)); + case 'year': + return new Date(year + direction, month, 1); + default: + return new Date(year, month + direction, 1); + } +} + +export function viewTitle(view, cursor) { + if (view === 'day') return formatDate(toDateKey(cursor)); + if (view === 'year') return String(cursor.getFullYear()); + if (view === 'week') { + const weekStart = new Date( + cursor.getFullYear(), + cursor.getMonth(), + cursor.getDate() - cursor.getDay(), + ); + const weekEnd = new Date( + weekStart.getFullYear(), + weekStart.getMonth(), + weekStart.getDate() + 6, + ); + + if (weekStart.getFullYear() !== weekEnd.getFullYear()) { + return `${MONTHS[weekStart.getMonth()]} ${weekStart.getFullYear()} - ${MONTHS[weekEnd.getMonth()]} ${weekEnd.getFullYear()}`; + } + if (weekStart.getMonth() !== weekEnd.getMonth()) { + return `${MONTHS[weekStart.getMonth()]} - ${MONTHS[weekEnd.getMonth()]} ${weekEnd.getFullYear()}`; + } + return `${MONTHS[weekStart.getMonth()]} ${weekStart.getFullYear()}`; + } + return `${MONTHS[cursor.getMonth()]} ${cursor.getFullYear()}`; +} + +export function countLabel(view) { + switch (view) { + case 'day': + return 'today'; + case 'week': + return 'this week'; + case 'year': + return 'this year'; + default: + return 'this month'; + } +} + +export function bucketEventsByHour(events) { + const allDayEvents = []; + const eventsByHour = Array.from({ length: 24 }, () => []); + + events.forEach((event) => { + const validTime = /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(event?.time || '')); + const timeValue = toTimeSortValue(event); + + if (!validTime || !Number.isFinite(timeValue)) { + allDayEvents.push(event); + return; + } + + eventsByHour[Math.floor(timeValue / 60)].push(event); + }); + + return { allDayEvents, eventsByHour }; +} + +export function miniMonthMatrix(year, month) { + const firstDayOffset = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const totalCells = Math.ceil((firstDayOffset + daysInMonth) / 7) * 7; + return Array.from({ length: totalCells }, (_, index) => { + const day = index - firstDayOffset + 1; + return day >= 1 && day <= daysInMonth + ? new Date(year, month, day) + : null; + }); +} + +export function calendarSearchParams(search, cursor, view) { + const params = new URLSearchParams(search); + const month = cursor.getMonth(); + const year = cursor.getFullYear(); + + params.set('month', month); + params.set('year', year); + + if (view === 'month') { + params.delete('view'); + } else { + params.set('view', view); + } + + if (view === 'day' || view === 'week') { + params.set('day', cursor.getDate()); + } else { + params.delete('day'); + } + + return params; +} diff --git a/src/Pages/Events/EventIcons.js b/src/Pages/Events/EventIcons.js index 1b4e43adb..12c8d318f 100644 --- a/src/Pages/Events/EventIcons.js +++ b/src/Pages/Events/EventIcons.js @@ -14,6 +14,14 @@ export function ChevronRight() { ); } +export function ChevronDown() { + return ( + + ); +} + export function CalendarIcon() { return (
@@ -171,6 +198,9 @@ export default function EventsPage() { canCreateEvent={isAdminView} cursor={cursor} setCursor={setCursor} + view={view} + onViewChange={setView} + onYearMonthSelect={handleYearMonthSelect} /> )}
diff --git a/test/frontend/CalendarViewModes.test.js b/test/frontend/CalendarViewModes.test.js new file mode 100644 index 000000000..ee1ed1997 --- /dev/null +++ b/test/frontend/CalendarViewModes.test.js @@ -0,0 +1,166 @@ +import { expect } from 'chai'; + +import { + bucketEventsByHour, + calendarSearchParams, + countLabel, + miniMonthMatrix, + stepCursor, + viewTitle, + visibleRange, +} from '../../src/Pages/Events/Calendar/calendarUtils'; + +function dateParts(date) { + return [date.getFullYear(), date.getMonth(), date.getDate()]; +} + +describe('Calendar view mode helpers', () => { + describe('visibleRange', () => { + it('returns inclusive day, week, month, and year ranges', () => { + expect(visibleRange('day', new Date(2026, 7, 27))).to.deep.equal({ + startDate: '2026-08-27', + endDate: '2026-08-27', + }); + expect(visibleRange('week', new Date(2026, 8, 2))).to.deep.equal({ + startDate: '2026-08-30', + endDate: '2026-09-05', + }); + expect(visibleRange('month', new Date(2028, 1, 14))).to.deep.equal({ + startDate: '2028-02-01', + endDate: '2028-02-29', + }); + expect(visibleRange('year', new Date(2026, 7, 27))).to.deep.equal({ + startDate: '2026-01-01', + endDate: '2026-12-31', + }); + }); + }); + + describe('stepCursor', () => { + it('steps day and week views across month boundaries', () => { + expect(dateParts(stepCursor('day', new Date(2026, 7, 31), 1))) + .to.deep.equal([2026, 8, 1]); + expect(dateParts(stepCursor('day', new Date(2026, 7, 1), -1))) + .to.deep.equal([2026, 6, 31]); + expect(dateParts(stepCursor('week', new Date(2026, 7, 30), 1))) + .to.deep.equal([2026, 8, 6]); + expect(dateParts(stepCursor('week', new Date(2026, 7, 30), -1))) + .to.deep.equal([2026, 7, 23]); + }); + + it('anchors month and year steps to day one without mutating the cursor', () => { + const monthCursor = new Date(2026, 0, 31); + const yearCursor = new Date(2028, 1, 29); + + expect(dateParts(stepCursor('month', monthCursor, 1))) + .to.deep.equal([2026, 1, 1]); + expect(dateParts(stepCursor('year', yearCursor, 1))) + .to.deep.equal([2029, 1, 1]); + expect(dateParts(monthCursor)).to.deep.equal([2026, 0, 31]); + expect(dateParts(yearCursor)).to.deep.equal([2028, 1, 29]); + }); + }); + + describe('view labels', () => { + it('formats titles and count labels for all four views', () => { + const cursor = new Date(2026, 7, 27); + const crossMonthWeekCursor = new Date(2026, 8, 2); + const crossYearWeekCursor = new Date(2026, 11, 30); + + expect(viewTitle('day', cursor)).to.equal('Thursday, August 27, 2026'); + expect(viewTitle('week', cursor)).to.equal('August 2026'); + expect(viewTitle('week', crossMonthWeekCursor)).to.equal('August - September 2026'); + expect(viewTitle('week', crossYearWeekCursor)).to.equal('December 2026 - January 2027'); + expect(viewTitle('month', cursor)).to.equal('August 2026'); + expect(viewTitle('year', cursor)).to.equal('2026'); + expect(countLabel('day')).to.equal('today'); + expect(countLabel('week')).to.equal('this week'); + expect(countLabel('month')).to.equal('this month'); + expect(countLabel('year')).to.equal('this year'); + }); + }); + + describe('bucketEventsByHour', () => { + it('keeps timed events ordered and moves invalid times to all day', () => { + const midnight = { name: 'Midnight', time: '00:00' }; + const firstMorning = { name: 'Z first', time: '09:30' }; + const secondMorning = { name: 'A second', time: '09:30' }; + const late = { name: 'Late', time: '23:59' }; + const untimed = { name: 'Untimed' }; + const malformed = { name: 'Malformed', time: 'morning' }; + const outOfRange = { name: 'Out of range', time: '24:00' }; + const badMinute = { name: 'Bad minute', time: '12:99' }; + const missingMinute = { name: 'Missing minute', time: '09:' }; + const result = bucketEventsByHour([ + midnight, + firstMorning, + secondMorning, + late, + untimed, + malformed, + outOfRange, + badMinute, + missingMinute, + ]); + + expect(result.eventsByHour).to.have.lengthOf(24); + expect(new Set(result.eventsByHour).size).to.equal(24); + expect(result.eventsByHour[0]).to.deep.equal([midnight]); + expect(result.eventsByHour[9]).to.deep.equal([firstMorning, secondMorning]); + expect(result.eventsByHour[23]).to.deep.equal([late]); + expect(result.allDayEvents) + .to.deep.equal([untimed, malformed, outOfRange, badMinute, missingMinute]); + }); + }); + + describe('miniMonthMatrix', () => { + it('builds a Sunday-start leap-February matrix with complete weeks', () => { + const matrix = miniMonthMatrix(2028, 1); + const dates = matrix.filter(Boolean); + + expect(matrix).to.have.lengthOf(35); + expect(matrix[0]).to.equal(null); + expect(matrix[1]).to.equal(null); + expect(dateParts(matrix[2])).to.deep.equal([2028, 1, 1]); + expect(dateParts(matrix[30])).to.deep.equal([2028, 1, 29]); + expect(matrix.slice(31)).to.deep.equal([null, null, null, null]); + expect(dates).to.have.lengthOf(29); + }); + }); + + describe('calendarSearchParams', () => { + const cursor = new Date(2026, 7, 27); + + it('keeps Month URLs free of view and day parameters', () => { + const params = calendarSearchParams( + '?month=7&year=2026&view=day&day=12', + cursor, + 'month', + ); + + expect(params.toString()).to.equal('month=7&year=2026'); + expect(params.has('view')).to.equal(false); + expect(params.has('day')).to.equal(false); + }); + + it('writes day only for Day and Week while preserving unrelated parameters', () => { + const dayParams = calendarSearchParams('?filter=mine', cursor, 'day'); + const weekParams = calendarSearchParams('?filter=mine', cursor, 'week'); + const yearParams = calendarSearchParams('?filter=mine&day=12', cursor, 'year'); + + expect(dayParams.get('month')).to.equal('7'); + expect(dayParams.get('year')).to.equal('2026'); + expect(dayParams.get('view')).to.equal('day'); + expect(dayParams.get('day')).to.equal('27'); + expect(dayParams.get('filter')).to.equal('mine'); + expect(weekParams.get('month')).to.equal('7'); + expect(weekParams.get('year')).to.equal('2026'); + expect(weekParams.get('view')).to.equal('week'); + expect(weekParams.get('day')).to.equal('27'); + expect(weekParams.get('filter')).to.equal('mine'); + expect(yearParams.get('view')).to.equal('year'); + expect(yearParams.has('day')).to.equal(false); + expect(yearParams.get('filter')).to.equal('mine'); + }); + }); +});