Skip to content

Commit 8fea0fa

Browse files
authored
Merge pull request #4105 from Northeastern-Electric-Racing/#4029-guest-dr-page
#4029 guest events page
2 parents cf66571 + 8ababca commit 8fea0fa

11 files changed

Lines changed: 287 additions & 8 deletions

File tree

src/backend/src/controllers/calendar.controllers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,4 +596,18 @@ export default class CalendarController {
596596
next(error);
597597
}
598598
}
599+
600+
static async getAllEventsPaginated(req: Request, res: Response, next: NextFunction) {
601+
try {
602+
const { cursor, pageSize } = req.body;
603+
const paginatedEvents = await CalendarService.getAllEventsPaginated(
604+
req.organization,
605+
cursor ? new Date(cursor) : undefined,
606+
pageSize ? parseInt(pageSize) : undefined
607+
);
608+
res.status(200).json(paginatedEvents);
609+
} catch (error: unknown) {
610+
next(error);
611+
}
612+
}
599613
}

src/backend/src/routes/calendar.routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,5 +297,6 @@ calendarRouter.post(
297297
);
298298

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

301302
export default calendarRouter;

src/backend/src/services/calendar.services.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {
1515
Machinery,
1616
ScheduleSlot,
1717
notGuest,
18-
isSameDay
18+
isSameDay,
19+
EventInstance
1920
} from 'shared';
2021
import { getCalendarQueryArgs } from '../prisma-query-args/calendar.query-args.js';
2122
import { getEventTypeQueryArgs } from '../prisma-query-args/event-type.query-args.js';
@@ -2749,4 +2750,56 @@ export default class CalendarService {
27492750
});
27502751
return eventTypes.map(eventTypeTransformer);
27512752
}
2753+
2754+
/**
2755+
* Gets all the events paginated, ordered by start time and grouped by date
2756+
* @param organization the org the user is currently in
2757+
* @param cursor the start time of the last event on the prev page
2758+
* @param pageSize the number of events to return per page
2759+
* @returns
2760+
*/
2761+
static async getAllEventsPaginated(
2762+
organization: Organization,
2763+
cursor?: Date,
2764+
pageSize: number = 25
2765+
): Promise<{ instances: EventInstance[]; nextCursor: Date | null }> {
2766+
const now = new Date();
2767+
2768+
const slots = await prisma.schedule_Slot.findMany({
2769+
where: {
2770+
startTime: {
2771+
lt: cursor ?? now
2772+
},
2773+
event: {
2774+
dateDeleted: null,
2775+
status: Event_Status.SCHEDULED,
2776+
eventType: {
2777+
organizationId: organization.organizationId
2778+
}
2779+
}
2780+
},
2781+
include: {
2782+
event: getEventQueryArgs(organization.organizationId)
2783+
},
2784+
orderBy: { startTime: 'desc' },
2785+
take: pageSize
2786+
});
2787+
2788+
const nextCursor = slots.length === pageSize ? slots[slots.length - 1].startTime : null;
2789+
2790+
const instances: EventInstance[] = slots.map((slot) => {
2791+
const { scheduledTimes, ...eventWithoutSlots } = eventTransformer(slot.event);
2792+
return {
2793+
...eventWithoutSlots,
2794+
scheduleSlotId: slot.scheduleSlotId,
2795+
startTime: slot.startTime,
2796+
endTime: slot.endTime,
2797+
allDay: slot.allDay,
2798+
recurring: slot.event.scheduledTimes.length > 1,
2799+
totalScheduledSlots: slot.event.scheduledTimes.length
2800+
};
2801+
});
2802+
2803+
return { instances, nextCursor };
2804+
}
27522805
}

src/frontend/src/apis/calendar.api.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
EventTypeCreateArgs,
1212
Calendar,
1313
FilterArgs,
14-
ScheduleSlot
14+
ScheduleSlot,
15+
EventInstance
1516
} from 'shared';
1617
import { eventTransformer, eventWithMembersTransformer } from './transformers/calendar.transformer';
1718
import { EditEventArgs, EditScheduleSlotArgs, EventCreateArgs } from '../hooks/calendar.hooks';
@@ -266,3 +267,19 @@ export const scheduleEvent = async (eventId: string, payload: { startTime: Date;
266267
transformResponse: (data) => eventTransformer(JSON.parse(data))
267268
});
268269
};
270+
271+
export const getAllEventsPaginated = (cursor?: Date, pageSize?: number) => {
272+
return axios.post<{ instances: EventInstance[]; nextCursor: Date | null }>(
273+
apiUrls.calendarEventsPaginated(),
274+
{ cursor, pageSize },
275+
{
276+
transformResponse: (data) => {
277+
const parsed = JSON.parse(data);
278+
return {
279+
instances: parsed.instances,
280+
nextCursor: parsed.nextCursor ? new Date(parsed.nextCursor) : null
281+
};
282+
}
283+
}
284+
);
285+
};

src/frontend/src/app/AppAuthenticated.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { useCurrentOrganization } from '../hooks/organizations.hooks';
3434
import Statistics from '../pages/StatisticsPage/Statistics';
3535
import RetrospectiveGanttChartPage from '../pages/RetrospectivePage/Retrospective';
3636
import Calendar from '../pages/CalendarPage/Calendar';
37+
import GuestEventPage from '../pages/GuestEventPage/GuestEventPage';
3738

3839
interface AppAuthenticatedProps {
3940
userId: string;
@@ -130,6 +131,7 @@ const AppAuthenticated: React.FC<AppAuthenticatedProps> = ({ userId, userRole })
130131
<Route path={routes.STATISTICS} component={Statistics} />
131132
<Route path={routes.HOME} component={Home} />
132133
<Route path={routes.RETROSPECTIVE} component={RetrospectiveGanttChartPage} />
134+
<Route path={routes.EVENTS} component={GuestEventPage} />
133135
<Redirect from={routes.BASE} to={routes.HOME} />
134136
<Route path="*" component={PageNotFound} />
135137
</Switch>

src/frontend/src/hooks/calendar.hooks.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
FilterArgs,
1212
ScheduleSlotCreateArgs,
1313
EventWithMembers,
14-
ScheduleSlot
14+
ScheduleSlot,
15+
EventInstance
1516
} from 'shared';
1617
import {
1718
getAllShops,
@@ -48,7 +49,8 @@ import {
4849
getSingleEventWithMembers,
4950
previewScheduleSlotRecurringEdits,
5051
postDeleteScheduleSlot,
51-
scheduleEvent
52+
scheduleEvent,
53+
getAllEventsPaginated
5254
} from '../apis/calendar.api';
5355
import { useCurrentUser } from './users.hooks';
5456
import { PDFDocument } from 'pdf-lib';
@@ -664,3 +666,16 @@ export const combinePdfsAndDownload = async (blobData: Blob[], filename: string)
664666
const pdfBlob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' });
665667
saveAs(pdfBlob, filename);
666668
};
669+
670+
/**
671+
* Custom hook to get all events in a paginated manner, sorted by scheduled date ascending.
672+
*/
673+
export const useAllEventsPaginated = (cursor?: Date, pageSize?: number) => {
674+
return useQuery<{ instances: EventInstance[]; nextCursor: Date | null }, Error>(
675+
['events', 'paginated', cursor, pageSize],
676+
async () => {
677+
const { data } = await getAllEventsPaginated(cursor, pageSize);
678+
return data;
679+
}
680+
);
681+
};

src/frontend/src/layouts/Sidebar/Sidebar.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import styles from '../../stylesheets/layouts/sidebar/sidebar.module.css';
99
import { Typography, Box, IconButton, Divider } from '@mui/material';
1010
import HomeIcon from '@mui/icons-material/Home';
1111
import AlignHorizontalLeftIcon from '@mui/icons-material/AlignHorizontalLeft';
12-
import RateReviewIcon from '@mui/icons-material/RateReview';
1312
import DashboardIcon from '@mui/icons-material/Dashboard';
1413
// To be uncommented after guest sponsors page is developed
1514
// import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism';
@@ -37,6 +36,7 @@ import QueryStatsIcon from '@mui/icons-material/QueryStats';
3736
import CurrencyExchangeIcon from '@mui/icons-material/CurrencyExchange';
3837
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
3938
import { useState } from 'react';
39+
import { CalendarIcon } from '@mui/x-date-pickers';
4040

4141
interface SidebarProps {
4242
drawerOpen: boolean;
@@ -101,9 +101,9 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid
101101
route: routes.CHANGE_REQUESTS
102102
},
103103
{
104-
name: 'Design Review',
105-
icon: <RateReviewIcon />,
106-
route: routes.CALENDAR
104+
name: 'Events',
105+
icon: <CalendarIcon />,
106+
route: routes.EVENTS
107107
}
108108
]
109109
},
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { alpha, Box, Card, CardContent, Chip, Stack, Typography, useTheme } from '@mui/material';
2+
import { EventInstance, formatEventDate, formatEventTime } from 'shared';
3+
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
4+
5+
interface GuestEventCardProps {
6+
event: EventInstance;
7+
}
8+
9+
const GuestEventCard: React.FC<GuestEventCardProps> = ({ event }) => {
10+
const theme = useTheme();
11+
12+
const displayDate = new Date(event.startTime);
13+
const formattedDate = formatEventDate(displayDate);
14+
const formattedTime = formatEventTime(displayDate);
15+
16+
const wbsLabels = event.workPackages.map(
17+
(wp) =>
18+
`${wp.wbsElement.carNumber}.${wp.wbsElement.projectNumber}.${wp.wbsElement.workPackageNumber} - ${wp.wbsElement.name}`
19+
);
20+
21+
const title = wbsLabels.length > 0 ? wbsLabels[0] : event.title;
22+
const extraWbs = wbsLabels.slice(1);
23+
24+
return (
25+
<Card
26+
variant="outlined"
27+
sx={{
28+
width: '100%',
29+
background: theme.palette.background.paper,
30+
borderRadius: 2
31+
}}
32+
>
33+
<CardContent>
34+
<Stack gap={1}>
35+
<Box>
36+
<Typography fontWeight="bold" variant="h6" sx={{ wordBreak: 'break-word' }}>
37+
{title}
38+
</Typography>
39+
{extraWbs.map((label) => (
40+
<Typography key={label} variant="body2" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
41+
{label}
42+
</Typography>
43+
))}
44+
</Box>
45+
46+
<Stack direction="row" flexWrap="wrap" gap={1}>
47+
<Stack direction="row" spacing={0.5} alignItems="center">
48+
<ScheduleOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary', flexShrink: 0 }} />
49+
<Typography variant="body2" color="text.secondary" noWrap>
50+
{formattedDate} @ {formattedTime}
51+
</Typography>
52+
</Stack>
53+
</Stack>
54+
55+
{event.teams.length > 0 && (
56+
<Stack direction="row" flexWrap="wrap" gap={0.5}>
57+
{event.teams.map((team) => (
58+
<Chip
59+
key={team.teamId}
60+
label={team.teamName}
61+
size="small"
62+
variant="filled"
63+
sx={{ bgcolor: alpha(theme.palette.primary.main, 0.45), color: theme.palette.primary.light }}
64+
/>
65+
))}
66+
</Stack>
67+
)}
68+
69+
{event.description && (
70+
<Typography variant="body2" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
71+
{event.description}
72+
</Typography>
73+
)}
74+
</Stack>
75+
</CardContent>
76+
</Card>
77+
);
78+
};
79+
80+
export default GuestEventCard;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useEffect, useState } from 'react';
2+
import { Box, Button } from '@mui/material';
3+
import { Collapse, IconButton, Stack, Typography, useTheme } from '@mui/material';
4+
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
5+
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
6+
import LoadingIndicator from '../../components/LoadingIndicator';
7+
import ErrorPage from '../ErrorPage';
8+
import GuestEventCard from './GuestEventCard';
9+
import { EventInstance, formatEventDate } from 'shared';
10+
import { useAllEventsPaginated } from '../../hooks/calendar.hooks';
11+
12+
const groupInstancesByDate = (instances: EventInstance[]): [string, EventInstance[]][] => {
13+
const groups = new Map<string, { date: Date; instances: EventInstance[] }>();
14+
for (const instance of instances) {
15+
const date = new Date(instance.startTime);
16+
const key = formatEventDate(date);
17+
if (!groups.has(key)) groups.set(key, { date, instances: [] });
18+
groups.get(key)!.instances.push(instance);
19+
}
20+
return Array.from(groups.entries())
21+
.sort(([, a], [, b]) => b.date.getTime() - a.date.getTime())
22+
.map(([key, { instances }]) => [key, instances]);
23+
};
24+
25+
interface DateGroupProps {
26+
date: string;
27+
instances: EventInstance[];
28+
}
29+
30+
const DateGroup: React.FC<DateGroupProps> = ({ date, instances }) => {
31+
const theme = useTheme();
32+
const [open, setOpen] = useState(true);
33+
34+
return (
35+
<Box>
36+
<Stack
37+
direction="row"
38+
alignItems="center"
39+
justifyContent="space-between"
40+
sx={{ cursor: 'pointer', borderBottom: `1px solid ${theme.palette.divider}`, pb: 0.5, mb: 1 }}
41+
onClick={() => setOpen((prev) => !prev)}
42+
>
43+
<Typography variant="h6" fontWeight="bold">
44+
{date}
45+
</Typography>
46+
<IconButton size="small">{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}</IconButton>
47+
</Stack>
48+
<Collapse in={open} timeout="auto" unmountOnExit>
49+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
50+
{instances.map((instance) => (
51+
<GuestEventCard key={`${instance.eventId}-${instance.scheduleSlotId}`} event={instance} />
52+
))}
53+
</Box>
54+
</Collapse>
55+
</Box>
56+
);
57+
};
58+
59+
const GuestEventPage: React.FC = () => {
60+
const [cursor, setCursor] = useState<Date | undefined>(undefined);
61+
const [allInstances, setAllInstances] = useState<EventInstance[]>([]);
62+
const { data, isLoading, isError, error } = useAllEventsPaginated(cursor);
63+
64+
useEffect(() => {
65+
if (data?.instances) {
66+
setAllInstances((prev) => {
67+
const existingKeys = new Set(prev.map((i) => `${i.eventId}-${i.scheduleSlotId}`));
68+
const newInstances = data.instances.filter((i) => !existingKeys.has(`${i.eventId}-${i.scheduleSlotId}`));
69+
return newInstances.length > 0 ? [...prev, ...newInstances] : prev;
70+
});
71+
}
72+
}, [data]);
73+
74+
if (isLoading && allInstances.length === 0) return <LoadingIndicator />;
75+
if (isError) return <ErrorPage message={error.message} />;
76+
77+
const groups = groupInstancesByDate(allInstances);
78+
79+
return (
80+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, p: 2 }}>
81+
{groups.map(([date, instances]) => (
82+
<DateGroup key={date} date={date} instances={instances} />
83+
))}
84+
{data?.nextCursor && (
85+
<Button variant="outlined" onClick={() => setCursor(data.nextCursor!)} disabled={isLoading}>
86+
{isLoading ? <LoadingIndicator /> : 'Load More'}
87+
</Button>
88+
)}
89+
</Box>
90+
);
91+
};
92+
93+
export default GuestEventPage;

src/frontend/src/utils/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const PROJECT_TEMPLATE_EDIT = PROJECT_TEMPLATES + '/edit';
6666

6767
/**************** Design Review Calendar ****************/
6868
const CALENDAR = `/calendar`;
69+
const EVENTS = '/events';
6970

7071
/**************** Organizations ****************/
7172
const ORGANIZATIONS = `/organizations`;
@@ -135,6 +136,7 @@ export const routes = {
135136
PROJECT_TEMPLATE_EDIT,
136137

137138
CALENDAR,
139+
EVENTS,
138140

139141
ORGANIZATIONS,
140142

0 commit comments

Comments
 (0)