Skip to content
Merged
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
14 changes: 14 additions & 0 deletions src/backend/src/controllers/calendar.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,4 +596,18 @@ export default class CalendarController {
next(error);
}
}

static async getAllEventsPaginated(req: Request, res: Response, next: NextFunction) {
try {
const { cursor, pageSize } = req.body;
const paginatedEvents = await CalendarService.getAllEventsPaginated(
req.organization,
cursor ? new Date(cursor) : undefined,
pageSize ? parseInt(pageSize) : undefined
);
res.status(200).json(paginatedEvents);
} catch (error: unknown) {
next(error);
}
}
}
1 change: 1 addition & 0 deletions src/backend/src/routes/calendar.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,5 +297,6 @@ calendarRouter.post(
);

calendarRouter.get('/calendars', CalendarController.getAllCalendars);
calendarRouter.post('/events-paginated', CalendarController.getAllEventsPaginated);

export default calendarRouter;
55 changes: 54 additions & 1 deletion src/backend/src/services/calendar.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
Machinery,
ScheduleSlot,
notGuest,
isSameDay
isSameDay,
EventInstance
} from 'shared';
import { getCalendarQueryArgs } from '../prisma-query-args/calendar.query-args.js';
import { getEventTypeQueryArgs } from '../prisma-query-args/event-type.query-args.js';
Expand Down Expand Up @@ -2749,4 +2750,56 @@ export default class CalendarService {
});
return eventTypes.map(eventTypeTransformer);
}

/**
* Gets all the events paginated, ordered by start time and grouped by date
* @param organization the org the user is currently in
* @param cursor the start time of the last event on the prev page
* @param pageSize the number of events to return per page
* @returns
*/
static async getAllEventsPaginated(
organization: Organization,
cursor?: Date,
pageSize: number = 25
): Promise<{ instances: EventInstance[]; nextCursor: Date | null }> {
const now = new Date();

const slots = await prisma.schedule_Slot.findMany({
where: {
startTime: {
lt: cursor ?? now
},
event: {
dateDeleted: null,
status: Event_Status.SCHEDULED,
eventType: {
organizationId: organization.organizationId
}
}
},
include: {
event: getEventQueryArgs(organization.organizationId)
},
orderBy: { startTime: 'desc' },
take: pageSize
});

const nextCursor = slots.length === pageSize ? slots[slots.length - 1].startTime : null;

const instances: EventInstance[] = slots.map((slot) => {
const { scheduledTimes, ...eventWithoutSlots } = eventTransformer(slot.event);
return {
...eventWithoutSlots,
scheduleSlotId: slot.scheduleSlotId,
startTime: slot.startTime,
endTime: slot.endTime,
allDay: slot.allDay,
recurring: slot.event.scheduledTimes.length > 1,
totalScheduledSlots: slot.event.scheduledTimes.length
};
});

return { instances, nextCursor };
}
}
19 changes: 18 additions & 1 deletion src/frontend/src/apis/calendar.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
EventTypeCreateArgs,
Calendar,
FilterArgs,
ScheduleSlot
ScheduleSlot,
EventInstance
} from 'shared';
import { eventTransformer, eventWithMembersTransformer } from './transformers/calendar.transformer';
import { EditEventArgs, EditScheduleSlotArgs, EventCreateArgs } from '../hooks/calendar.hooks';
Expand Down Expand Up @@ -266,3 +267,19 @@ export const scheduleEvent = async (eventId: string, payload: { startTime: Date;
transformResponse: (data) => eventTransformer(JSON.parse(data))
});
};

export const getAllEventsPaginated = (cursor?: Date, pageSize?: number) => {
return axios.post<{ instances: EventInstance[]; nextCursor: Date | null }>(
apiUrls.calendarEventsPaginated(),
{ cursor, pageSize },
{
transformResponse: (data) => {
const parsed = JSON.parse(data);
return {
instances: parsed.instances,
nextCursor: parsed.nextCursor ? new Date(parsed.nextCursor) : null
};
}
}
);
};
2 changes: 2 additions & 0 deletions src/frontend/src/app/AppAuthenticated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { useCurrentOrganization } from '../hooks/organizations.hooks';
import Statistics from '../pages/StatisticsPage/Statistics';
import RetrospectiveGanttChartPage from '../pages/RetrospectivePage/Retrospective';
import Calendar from '../pages/CalendarPage/Calendar';
import GuestEventPage from '../pages/GuestEventPage/GuestEventPage';

interface AppAuthenticatedProps {
userId: string;
Expand Down Expand Up @@ -130,6 +131,7 @@ const AppAuthenticated: React.FC<AppAuthenticatedProps> = ({ userId, userRole })
<Route path={routes.STATISTICS} component={Statistics} />
<Route path={routes.HOME} component={Home} />
<Route path={routes.RETROSPECTIVE} component={RetrospectiveGanttChartPage} />
<Route path={routes.EVENTS} component={GuestEventPage} />
<Redirect from={routes.BASE} to={routes.HOME} />
<Route path="*" component={PageNotFound} />
</Switch>
Expand Down
19 changes: 17 additions & 2 deletions src/frontend/src/hooks/calendar.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
FilterArgs,
ScheduleSlotCreateArgs,
EventWithMembers,
ScheduleSlot
ScheduleSlot,
EventInstance
} from 'shared';
import {
getAllShops,
Expand Down Expand Up @@ -48,7 +49,8 @@ import {
getSingleEventWithMembers,
previewScheduleSlotRecurringEdits,
postDeleteScheduleSlot,
scheduleEvent
scheduleEvent,
getAllEventsPaginated
} from '../apis/calendar.api';
import { useCurrentUser } from './users.hooks';
import { PDFDocument } from 'pdf-lib';
Expand Down Expand Up @@ -664,3 +666,16 @@ export const combinePdfsAndDownload = async (blobData: Blob[], filename: string)
const pdfBlob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' });
saveAs(pdfBlob, filename);
};

/**
* Custom hook to get all events in a paginated manner, sorted by scheduled date ascending.
*/
export const useAllEventsPaginated = (cursor?: Date, pageSize?: number) => {
return useQuery<{ instances: EventInstance[]; nextCursor: Date | null }, Error>(
['events', 'paginated', cursor, pageSize],
async () => {
const { data } = await getAllEventsPaginated(cursor, pageSize);
return data;
}
);
};
8 changes: 4 additions & 4 deletions src/frontend/src/layouts/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import styles from '../../stylesheets/layouts/sidebar/sidebar.module.css';
import { Typography, Box, IconButton, Divider } from '@mui/material';
import HomeIcon from '@mui/icons-material/Home';
import AlignHorizontalLeftIcon from '@mui/icons-material/AlignHorizontalLeft';
import RateReviewIcon from '@mui/icons-material/RateReview';
import DashboardIcon from '@mui/icons-material/Dashboard';
// To be uncommented after guest sponsors page is developed
// import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism';
Expand Down Expand Up @@ -37,6 +36,7 @@ import QueryStatsIcon from '@mui/icons-material/QueryStats';
import CurrencyExchangeIcon from '@mui/icons-material/CurrencyExchange';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import { useState } from 'react';
import { CalendarIcon } from '@mui/x-date-pickers';

interface SidebarProps {
drawerOpen: boolean;
Expand Down Expand Up @@ -101,9 +101,9 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid
route: routes.CHANGE_REQUESTS
},
{
name: 'Design Review',
icon: <RateReviewIcon />,
route: routes.CALENDAR
name: 'Events',
icon: <CalendarIcon />,
route: routes.EVENTS
}
]
},
Expand Down
80 changes: 80 additions & 0 deletions src/frontend/src/pages/GuestEventPage/GuestEventCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { alpha, Box, Card, CardContent, Chip, Stack, Typography, useTheme } from '@mui/material';
import { EventInstance, formatEventDate, formatEventTime } from 'shared';
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';

interface GuestEventCardProps {
event: EventInstance;
}

const GuestEventCard: React.FC<GuestEventCardProps> = ({ event }) => {
const theme = useTheme();

const displayDate = new Date(event.startTime);
const formattedDate = formatEventDate(displayDate);
const formattedTime = formatEventTime(displayDate);

const wbsLabels = event.workPackages.map(
(wp) =>
`${wp.wbsElement.carNumber}.${wp.wbsElement.projectNumber}.${wp.wbsElement.workPackageNumber} - ${wp.wbsElement.name}`
);

const title = wbsLabels.length > 0 ? wbsLabels[0] : event.title;
const extraWbs = wbsLabels.slice(1);

return (
<Card
variant="outlined"
sx={{
width: '100%',
background: theme.palette.background.paper,
borderRadius: 2
}}
>
<CardContent>
<Stack gap={1}>
<Box>
<Typography fontWeight="bold" variant="h6" sx={{ wordBreak: 'break-word' }}>
{title}
</Typography>
{extraWbs.map((label) => (
<Typography key={label} variant="body2" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{label}
</Typography>
))}
</Box>

<Stack direction="row" flexWrap="wrap" gap={1}>
<Stack direction="row" spacing={0.5} alignItems="center">
<ScheduleOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary', flexShrink: 0 }} />
<Typography variant="body2" color="text.secondary" noWrap>
{formattedDate} @ {formattedTime}
</Typography>
</Stack>
</Stack>

{event.teams.length > 0 && (
<Stack direction="row" flexWrap="wrap" gap={0.5}>
{event.teams.map((team) => (
<Chip
key={team.teamId}
label={team.teamName}
size="small"
variant="filled"
sx={{ bgcolor: alpha(theme.palette.primary.main, 0.45), color: theme.palette.primary.light }}
/>
))}
</Stack>
)}

{event.description && (
<Typography variant="body2" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{event.description}
</Typography>
)}
</Stack>
</CardContent>
</Card>
);
};

export default GuestEventCard;
93 changes: 93 additions & 0 deletions src/frontend/src/pages/GuestEventPage/GuestEventPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { Box, Button } from '@mui/material';
import { Collapse, IconButton, Stack, Typography, useTheme } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import LoadingIndicator from '../../components/LoadingIndicator';
import ErrorPage from '../ErrorPage';
import GuestEventCard from './GuestEventCard';
import { EventInstance, formatEventDate } from 'shared';
import { useAllEventsPaginated } from '../../hooks/calendar.hooks';

const groupInstancesByDate = (instances: EventInstance[]): [string, EventInstance[]][] => {
const groups = new Map<string, { date: Date; instances: EventInstance[] }>();
for (const instance of instances) {
const date = new Date(instance.startTime);
const key = formatEventDate(date);
if (!groups.has(key)) groups.set(key, { date, instances: [] });
groups.get(key)!.instances.push(instance);
}
return Array.from(groups.entries())
.sort(([, a], [, b]) => b.date.getTime() - a.date.getTime())
.map(([key, { instances }]) => [key, instances]);
};

interface DateGroupProps {
date: string;
instances: EventInstance[];
}

const DateGroup: React.FC<DateGroupProps> = ({ date, instances }) => {
const theme = useTheme();
const [open, setOpen] = useState(true);

return (
<Box>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ cursor: 'pointer', borderBottom: `1px solid ${theme.palette.divider}`, pb: 0.5, mb: 1 }}
onClick={() => setOpen((prev) => !prev)}
>
<Typography variant="h6" fontWeight="bold">
{date}
</Typography>
<IconButton size="small">{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}</IconButton>
</Stack>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{instances.map((instance) => (
<GuestEventCard key={`${instance.eventId}-${instance.scheduleSlotId}`} event={instance} />
))}
</Box>
</Collapse>
</Box>
);
};

const GuestEventPage: React.FC = () => {
const [cursor, setCursor] = useState<Date | undefined>(undefined);
const [allInstances, setAllInstances] = useState<EventInstance[]>([]);
const { data, isLoading, isError, error } = useAllEventsPaginated(cursor);

useEffect(() => {
if (data?.instances) {
setAllInstances((prev) => {
const existingKeys = new Set(prev.map((i) => `${i.eventId}-${i.scheduleSlotId}`));
const newInstances = data.instances.filter((i) => !existingKeys.has(`${i.eventId}-${i.scheduleSlotId}`));
return newInstances.length > 0 ? [...prev, ...newInstances] : prev;
});
}
}, [data]);

if (isLoading && allInstances.length === 0) return <LoadingIndicator />;
if (isError) return <ErrorPage message={error.message} />;

const groups = groupInstancesByDate(allInstances);

return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, p: 2 }}>
{groups.map(([date, instances]) => (
<DateGroup key={date} date={date} instances={instances} />
))}
{data?.nextCursor && (
<Button variant="outlined" onClick={() => setCursor(data.nextCursor!)} disabled={isLoading}>
{isLoading ? <LoadingIndicator /> : 'Load More'}
</Button>
)}
</Box>
);
};

export default GuestEventPage;
2 changes: 2 additions & 0 deletions src/frontend/src/utils/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const PROJECT_TEMPLATE_EDIT = PROJECT_TEMPLATES + '/edit';

/**************** Design Review Calendar ****************/
const CALENDAR = `/calendar`;
const EVENTS = '/events';

/**************** Organizations ****************/
const ORGANIZATIONS = `/organizations`;
Expand Down Expand Up @@ -135,6 +136,7 @@ export const routes = {
PROJECT_TEMPLATE_EDIT,

CALENDAR,
EVENTS,

ORGANIZATIONS,

Expand Down
Loading
Loading