+
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={() => {
+ const day = view === 'day' || view === 'week' ? today.getDate() : 1;
+ setCursor(new Date(today.getFullYear(), today.getMonth(), day));
+ }}
+ onPrevious={() => setCursor(stepCursor(view, cursor, -1))}
+ onNext={() => setCursor(stepCursor(view, cursor, 1))}
/>
-
-
-
+ ) : view === 'year' ? (
+
+ ) : (
+ <>
+
+
+
-
+
+ >
+ )}
diff --git a/src/Pages/Events/Calendar/TimeGrid.js b/src/Pages/Events/Calendar/TimeGrid.js
new file mode 100644
index 000000000..6f4ee6dfe
--- /dev/null
+++ b/src/Pages/Events/Calendar/TimeGrid.js
@@ -0,0 +1,157 @@
+import { useEffect, useRef } from 'react';
+import { bucketEventsByHour, formatTime } from './calendarUtils';
+import { DAYS } from './calendarConstants';
+import { EventRow } from './EventRow';
+
+const START_HOUR = 8;
+
+export function TimeGrid({ days, onSelectEvent, isAdminView }) {
+ const scrollContainerRef = useRef(null);
+ const startHourRef = useRef(null);
+ const isWeekView = days.length === 7;
+ const day = days[0] || { key: '', isToday: false, events: [] };
+ const dayBuckets = days.map(({ events }) => bucketEventsByHour(events));
+ const { allDayEvents, eventsByHour } = dayBuckets[0];
+
+ useEffect(() => {
+ if (scrollContainerRef.current && startHourRef.current) {
+ scrollContainerRef.current.scrollTop = startHourRef.current.offsetTop;
+ }
+ }, [day.key]);
+
+ if (isWeekView) {
+ return (
+
+
+
+
+ {days.map((weekDay, index) => (
+
+ {DAYS[index]}
+
+ {weekDay.date.getDate()}
+
+
+ ))}
+
+
+
+
+ All day
+
+ {days.map((weekDay, index) => (
+
+ {dayBuckets[index].allDayEvents.map((event, eventIndex) => (
+ onSelectEvent(event)}
+ isAdminView={isAdminView}
+ />
+ ))}
+
+ ))}
+
+
+
+ {Array.from({ length: 24 }, (_, hour) => (
+
+
+ {formatTime(`${String(hour).padStart(2, '0')}:00`)}
+
+ {days.map((weekDay, index) => (
+
+ {dayBuckets[index].eventsByHour[hour].map((event, eventIndex) => (
+
+ ))}
+
+ ))}
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ All day
+
+
+ {allDayEvents.map((event, index) => (
+ onSelectEvent(event)}
+ isAdminView={isAdminView}
+ />
+ ))}
+
+
+
+
+ {eventsByHour.map((hourEvents, hour) => (
+
+
+ {formatTime(`${String(hour).padStart(2, '0')}:00`)}
+
+
+ {hourEvents.map((event, index) => (
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/Pages/Events/Calendar/YearView.js b/src/Pages/Events/Calendar/YearView.js
new file mode 100644
index 000000000..8746d4bf3
--- /dev/null
+++ b/src/Pages/Events/Calendar/YearView.js
@@ -0,0 +1,160 @@
+import { useEffect, useRef, useState } from 'react';
+import { toDateKey } from '../eventUtils';
+import { DAYS, MONTHS } from './calendarConstants';
+import { miniMonthMatrix } from './calendarUtils';
+import { EventRow } from './EventRow';
+
+export function YearView({
+ year,
+ activeMonth,
+ eventsByDate,
+ onSelectEvent,
+ onSelectMonth,
+ isAdminView,
+}) {
+ const todayKey = toDateKey(new Date());
+ const [selectedDate, setSelectedDate] = useState(null);
+ const monthRefs = useRef([]);
+ const selectedDateKey = selectedDate ? toDateKey(selectedDate) : null;
+ const selectedEvents = selectedDateKey ? eventsByDate[selectedDateKey] || [] : [];
+
+ useEffect(() => {
+ setSelectedDate(null);
+ }, [year]);
+
+ useEffect(() => {
+ monthRefs.current[activeMonth]?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ }, [activeMonth, year]);
+
+ function handleYearContentClick(event) {
+ if (
+ event.target.closest('button')
+ || event.target.closest('[data-year-date-preview]')
+ ) return;
+ setSelectedDate(null);
+ }
+
+ return (
+
+ {selectedDate && (
+
+
+
+
+ {MONTHS[selectedDate.getMonth()]} {selectedDate.getDate()}, {year}
+
+
+
+
+ {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
;
+ }
+
+ const dateKey = toDateKey(date);
+ const isToday = dateKey === todayKey;
+ const isSelected = dateKey === selectedDateKey;
+ const hasEvents = Boolean(eventsByDate[dateKey]?.length);
+
+ 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 (