From 1e7ddaba9dea352aefd53726e158dd22c5e00b80 Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:31:17 +1100 Subject: [PATCH 01/12] feat: add mutation and route for add event --- client/src/api/timetable/mutations.ts | 11 ++++++ client/src/api/timetable/queries.ts | 11 ++++++ client/src/api/timetable/routes.ts | 54 +++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/client/src/api/timetable/mutations.ts b/client/src/api/timetable/mutations.ts index 7ab6601bd..ee6c174c7 100644 --- a/client/src/api/timetable/mutations.ts +++ b/client/src/api/timetable/mutations.ts @@ -1,6 +1,8 @@ import { QueryClient, useMutation } from '@tanstack/react-query'; import { + addEvent, + AddEventParams, addTimetableCourse, createTimetable, deleteTimetable, @@ -89,3 +91,12 @@ export const useDuplicateTimetable = (queryClient: QueryClient) => variables.onSuccess(data); }, }); + +export const useAddTimetableEvent = (queryClient: QueryClient) => + useMutation({ + mutationFn: ({ event, onSuccess: _ }: { event: AddEventParams; onSuccess: () => void }) => addEvent(event), + onSuccess: async (_data, variables, _context) => { + await queryClient.invalidateQueries({ queryKey: ['timetable', variables.event.timetableId] }); + variables.onSuccess(); + }, + }); diff --git a/client/src/api/timetable/queries.ts b/client/src/api/timetable/queries.ts index 5c8bf4341..b364ee0d4 100644 --- a/client/src/api/timetable/queries.ts +++ b/client/src/api/timetable/queries.ts @@ -31,3 +31,14 @@ export const useTimetableInfoQuery = (timetableId: string) => queryKey: ['timetable', timetableId, 'info'], queryFn: () => getTimetableInfo(timetableId), }).data; + +// export const useTimetableEventQuery = (eventIds: string[]) => { +// const queries = useSuspenseQueries({ +// queries: eventIds.map((id) => ({ +// queryKey: ['timetable', 'event', id], +// queryFn: () => getEventInfo(id), +// })), +// }); + +// return queries.map((query) => query.data); +// }; diff --git a/client/src/api/timetable/routes.ts b/client/src/api/timetable/routes.ts index aa6b47c47..93c7819af 100644 --- a/client/src/api/timetable/routes.ts +++ b/client/src/api/timetable/routes.ts @@ -78,3 +78,57 @@ export const makePrimaryTimetable = async (timetableId: string): Promise = export const duplicateTimetable = async (timetableId: string): Promise => { return (await apiClient.post(`/user/timetables/${timetableId}/duplicate`)).data; }; + +interface TimetableEvent { + id: string; + timetableId: string; + colour: string; + title: string; + location: string | null; + description: string | null; + dayOfWeek: number; + start: number; + end: number; + type: 'CUSTOM' | 'TUTORING'; +} + +export const getEventInfo = async (eventId: string) => { + return (await apiClient.get(`/user/timetables/event/${eventId}`)).data; +}; + +export interface AddEventParams { + timetableId: string; + colour: string; + dayOfWeek: number; // 0 = Monday, 6 = Sunday + start: number; // Mins since midnight + end: number; // Mins since midnight + type: 'CUSTOM' | 'TUTORING'; + title: string; + description?: string; + location?: string; +} +export const addEvent = async ({ + timetableId, + colour, + dayOfWeek, + start, + end, + type, + title, + description, + location, +}: AddEventParams): Promise => { + await apiClient.post(`/user/timetables/event/${timetableId}`, { + event: { + timetableId, + colour, + dayOfWeek, + start, + end, + type, + title, + description, + location, + }, + }); +}; From 28b105a430cd42b8b754569688fb2d6630334c4b Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Wed, 12 Nov 2025 19:59:07 +1100 Subject: [PATCH 02/12] feat: add event form with refactored closing to unmount instead of manually resetting states --- .../controls/customEvents/CustomEvents.tsx | 50 +++-- .../customEvents/CustomEventsForm.tsx | 189 ++++++++++++++++++ client/src/utils/eventHelpers.ts | 30 +++ 3 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 client/src/components/planner/controls/customEvents/CustomEventsForm.tsx create mode 100644 client/src/utils/eventHelpers.ts diff --git a/client/src/components/planner/controls/customEvents/CustomEvents.tsx b/client/src/components/planner/controls/customEvents/CustomEvents.tsx index cb2e68b54..f198d219a 100644 --- a/client/src/components/planner/controls/customEvents/CustomEvents.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEvents.tsx @@ -1,34 +1,50 @@ import { ArrowDropDown, ArrowDropUp } from '@mui/icons-material'; -import { Box } from '@mui/material'; -import { useState } from 'react'; +import { Box, Popover } from '@mui/material'; +import { useMemo, useState } from 'react'; import { StyledControlsButton } from '../../../../styles/ControlStyles'; import { DropdownButton } from '../../../../styles/CustomEventStyles'; +import CustomEventsForm from './CustomEventsForm'; -const CustomEvent: React.FC = () => { +interface CustomEventProps { + timetableId: string; +} + +const CustomEvent = ({ timetableId }: CustomEventProps) => { const [createEventAnchorEl, setCreateEventAnchorEl] = useState(null); - const openCreateEventPopover = Boolean(createEventAnchorEl); - const popoverId = openCreateEventPopover ? 'create-event-popover' : undefined; + const popoverId = useMemo(() => (createEventAnchorEl ? 'create-event-popover' : undefined), [createEventAnchorEl]); - const handleOpen = (event: React.MouseEvent) => { + const handlePopoverOpen = (event: React.MouseEvent) => { setCreateEventAnchorEl(event.currentTarget); }; + const handlePopoverClose = () => { + setCreateEventAnchorEl(null); + }; - // TODO: Implement create event popover return ( - - - CREATE EVENT + + + Create Event - {openCreateEventPopover ? : } + {createEventAnchorEl ? : } + + + ); }; diff --git a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx new file mode 100644 index 000000000..ce47eee87 --- /dev/null +++ b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx @@ -0,0 +1,189 @@ +import { Add, Event, LocationOn, Notes } from '@mui/icons-material'; +import { TabContext } from '@mui/lab'; +import { Box, ListItemIcon, Tab, Tabs, TextField } from '@mui/material'; +import { TimePicker } from '@mui/x-date-pickers'; +import { useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; + +import { useAddTimetableEvent } from '../../../../api/timetable/mutations'; +import { StyledListItem } from '../../../../styles/ControlStyles'; +import { ExecuteButton, StyledList, StyledListItemText, StyledTabPanel } from '../../../../styles/CustomEventStyles'; +import { areValidEventTimes, createDateWithTime } from '../../../../utils/eventHelpers'; +import DropdownOption from '../../timetable/DropdownOption'; + +const initialStartTime = createDateWithTime(9); +const initialEndTime = createDateWithTime(10); +const initialDay = ''; +const daysShort = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + +interface CustomEventsPopoverProps { + handlePopoverClose: () => void; + timetableId: string; +} + +const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPopoverProps) => { + const queryClient = useQueryClient(); + const eventCreateMutation = useAddTimetableEvent(queryClient); + + const [eventType, setEventType] = useState('General'); + const [eventName, setEventName] = useState(''); + const [eventDescription, setEventDescription] = useState(''); + const [eventLocation, setEventLocation] = useState(''); + const [startTime, setStartTime] = useState(createDateWithTime(9)); + const [endTime, setEndTime] = useState(createDateWithTime(10)); + const [eventDays, setEventDays] = useState([]); + const [courseCode, setCourseCode] = useState(''); + const [classCode, setClassCode] = useState(''); + const [classesCodes, setClassesCodes] = useState[]>([]); + const [colorPickerAnchorEl, setColorPickerAnchorEl] = useState(null); + const [isInitialStartTime, setIsInitialStartTime] = useState(false); + const [isInitialEndTime, setIsInitialEndTime] = useState(false); + const [isInitialDay, setIsInitialDay] = useState(false); + + const handleCreateEvent = () => { + // eventCreateMutation.mutate({ timetableId, event: { + + // }); + handlePopoverClose(); + }; + + const handleFormat = (newFormats: string[]) => { + setEventDays(newFormats); + setIsInitialDay(false); + }; + + const handleTabChange = (_: React.SyntheticEvent, newEventType: string) => { + setEventType(newEventType); + }; + + const isEventButtonDisabled = useMemo(() => { + return ( + (eventType === 'General' && (!eventName || eventDays.length === 0)) || + (eventType === 'Tutoring' && (!courseCode || !classCode)) + ); + }, [eventType, eventDays, courseCode, classCode, eventName]); + + return ( + <> + + + + + + + + + + + + + + { + setEventName(e.target.value); + }} + variant="outlined" + fullWidth + required + /> + + + + + + { + setEventDescription(e.target.value); + }} + variant="outlined" + multiline + fullWidth + /> + + + + + + { + setEventLocation(e.target.value); + }} + variant="outlined" + fullWidth + /> + + + + { + if (e) setStartTime(e); + setIsInitialStartTime(false); + }} + /> + + + + { + if (e) setEndTime(e); + setIsInitialEndTime(false); + }} + /> + + + + + {/* */} + + + + {/* */} + + + + Create + + + ); +}; + +export default CustomEventsPopover; diff --git a/client/src/utils/eventHelpers.ts b/client/src/utils/eventHelpers.ts new file mode 100644 index 000000000..b2ea5cb2a --- /dev/null +++ b/client/src/utils/eventHelpers.ts @@ -0,0 +1,30 @@ +/** + * @param start The starting time of the event + * @param end The ending time of the event + * @returns Whether the start and end times represent a valid event + */ +export const areValidEventTimes = (start: Date, end: Date) => { + // Return true if the event ends at midnight + if (end.getHours() + end.getMinutes() / 60 === 0) { + return true; + } else { + return start.getHours() + start.getMinutes() / 60 < end.getHours() + end.getMinutes() / 60; + } +}; + +/** + * @param time The start or end time of the event + * @returns Whether the start and end times represent a valid event + */ +export const createDateWithTime = (time: number) => { + return new Date(2022, 0, 0, time, (time - Math.floor(time)) * 60); +}; + +/** + * @param day The day of the week the event starts on + * @returns An array of the days of the week starting with the given day + */ +export const resizeWeekArray = (day: number) => { + const MondayToSunday: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + return MondayToSunday.slice(day); +}; From 83c25c542d47c61cd8905e560f1b55aa75581a22 Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Wed, 12 Nov 2025 23:19:19 +1100 Subject: [PATCH 03/12] feat: add mutation call for new event added --- client/src/api/timetable/mutations.ts | 3 +- .../customEvents/CustomEventsForm.tsx | 39 +++++++++++-------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/client/src/api/timetable/mutations.ts b/client/src/api/timetable/mutations.ts index ee6c174c7..14409ba2b 100644 --- a/client/src/api/timetable/mutations.ts +++ b/client/src/api/timetable/mutations.ts @@ -94,9 +94,8 @@ export const useDuplicateTimetable = (queryClient: QueryClient) => export const useAddTimetableEvent = (queryClient: QueryClient) => useMutation({ - mutationFn: ({ event, onSuccess: _ }: { event: AddEventParams; onSuccess: () => void }) => addEvent(event), + mutationFn: ({ event }: { event: AddEventParams }) => addEvent(event), onSuccess: async (_data, variables, _context) => { await queryClient.invalidateQueries({ queryKey: ['timetable', variables.event.timetableId] }); - variables.onSuccess(); }, }); diff --git a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx index ce47eee87..c533f9b12 100644 --- a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx @@ -11,9 +11,6 @@ import { ExecuteButton, StyledList, StyledListItemText, StyledTabPanel } from '. import { areValidEventTimes, createDateWithTime } from '../../../../utils/eventHelpers'; import DropdownOption from '../../timetable/DropdownOption'; -const initialStartTime = createDateWithTime(9); -const initialEndTime = createDateWithTime(10); -const initialDay = ''; const daysShort = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; interface CustomEventsPopoverProps { @@ -34,22 +31,32 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo const [eventDays, setEventDays] = useState([]); const [courseCode, setCourseCode] = useState(''); const [classCode, setClassCode] = useState(''); - const [classesCodes, setClassesCodes] = useState[]>([]); - const [colorPickerAnchorEl, setColorPickerAnchorEl] = useState(null); - const [isInitialStartTime, setIsInitialStartTime] = useState(false); - const [isInitialEndTime, setIsInitialEndTime] = useState(false); - const [isInitialDay, setIsInitialDay] = useState(false); + // const [classesCodes, setClassesCodes] = useState[]>([]); + // const [colorPickerAnchorEl, setColorPickerAnchorEl] = useState(null); const handleCreateEvent = () => { - // eventCreateMutation.mutate({ timetableId, event: { - - // }); + for (const day of eventDays) { + const isMidnight = endTime.getHours() + endTime.getMinutes() / 60 === 0; + eventCreateMutation.mutate({ + event: { + timetableId, + colour: '000000', // TODO: Add color picker + dayOfWeek: daysShort.indexOf(day), + start: startTime.getHours() + startTime.getMinutes() / 60, + end: isMidnight ? 24.0 : endTime.getHours() + endTime.getMinutes() / 60, + type: eventType === 'General' ? 'CUSTOM' : 'TUTORING', + title: eventName, + description: eventDescription, + location: eventLocation, + }, + }); + } handlePopoverClose(); }; const handleFormat = (newFormats: string[]) => { + console.log(newFormats); setEventDays(newFormats); - setIsInitialDay(false); }; const handleTabChange = (_: React.SyntheticEvent, newEventType: string) => { @@ -126,28 +133,26 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo { if (e) setStartTime(e); - setIsInitialStartTime(false); }} /> { if (e) setEndTime(e); - setIsInitialEndTime(false); }} /> Date: Thu, 13 Nov 2025 02:54:48 +1100 Subject: [PATCH 04/12] feat: add back colors --- .../planner/controls/ColorPicker.tsx | 114 ++++++++++++++++++ .../components/planner/controls/Controls.tsx | 2 +- .../controls/customEvents/CustomEvents.tsx | 2 +- .../customEvents/CustomEventsForm.tsx | 30 +++-- client/src/utils/colors.ts | 15 +++ 5 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 client/src/components/planner/controls/ColorPicker.tsx diff --git a/client/src/components/planner/controls/ColorPicker.tsx b/client/src/components/planner/controls/ColorPicker.tsx new file mode 100644 index 000000000..0fc3db741 --- /dev/null +++ b/client/src/components/planner/controls/ColorPicker.tsx @@ -0,0 +1,114 @@ +import { Box, Button, ButtonGroup, ListItem, Popover, TextField } from '@mui/material'; +import { Colorful } from '@uiw/react-color'; +import { useMemo, useState } from 'react'; + +import { useGetUserSettingsQuery } from '../../../api/user/queries'; +import { ColorIndicatorBox, StyledButtonContainer } from '../../../styles/ControlStyles'; +import { decodeColor, oklchToHex } from '../../../utils/colors'; + +interface ColorPickerProps { + color: string; + setColor: (color: string) => void; + colorPickerAnchorEl: HTMLElement | null; + handleOpenColorPicker: (event: React.MouseEvent) => void; + handleCloseColorPicker: () => void; + handleSaveNewColor?: () => void; +} + +const ColorPicker: React.FC = ({ + color, + setColor, + colorPickerAnchorEl, + handleOpenColorPicker, + handleCloseColorPicker, + handleSaveNewColor, +}) => { + // Whether the colour picker popover is shown + const openColorPickerPopover = Boolean(colorPickerAnchorEl); + const colorPickerPopoverId = openColorPickerPopover ? 'simple-popover' : undefined; + + const [showCustomColorPicker, setShowCustomColorPicker] = useState(false); + const { preferredTheme } = useGetUserSettingsQuery(); + + const decodedColor = decodeColor(color, preferredTheme); + const textFieldValue = useMemo(() => oklchToHex(decodedColor), [decodedColor]); + + return ( + + + + + + {handleSaveNewColor && ( + + )} + + + + + {/* { + setColor(selectedColor); + }} + onCustomColorSelect={() => { + setShowCustomColorPicker(!showCustomColorPicker); + }} + /> */} + + {showCustomColorPicker && ( + + { + setColor(e.hex); + }} + color={color} + disableAlpha + /> + + )} + + { + let newColor = e.target.value; + if (newColor !== '' && !newColor.startsWith('#')) { + newColor = `#${newColor}`; + } + setColor(newColor); + }} + /> + + + + ); +}; + +export default ColorPicker; diff --git a/client/src/components/planner/controls/Controls.tsx b/client/src/components/planner/controls/Controls.tsx index 31d09156b..6b36e7a67 100644 --- a/client/src/components/planner/controls/Controls.tsx +++ b/client/src/components/planner/controls/Controls.tsx @@ -84,7 +84,7 @@ const Controls: React.FC<{ term: Term; setTerm: (term: Term) => void; timetableI }} > - + diff --git a/client/src/components/planner/controls/customEvents/CustomEvents.tsx b/client/src/components/planner/controls/customEvents/CustomEvents.tsx index f198d219a..ef302203d 100644 --- a/client/src/components/planner/controls/customEvents/CustomEvents.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEvents.tsx @@ -43,7 +43,7 @@ const CustomEvent = ({ timetableId }: CustomEventProps) => { horizontal: 'right', }} > - + ); diff --git a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx index c533f9b12..f195c43bd 100644 --- a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx @@ -10,8 +10,9 @@ import { StyledListItem } from '../../../../styles/ControlStyles'; import { ExecuteButton, StyledList, StyledListItemText, StyledTabPanel } from '../../../../styles/CustomEventStyles'; import { areValidEventTimes, createDateWithTime } from '../../../../utils/eventHelpers'; import DropdownOption from '../../timetable/DropdownOption'; +import ColorPicker from '../ColorPicker'; -const daysShort = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; +const DAYS_SHORT = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; interface CustomEventsPopoverProps { handlePopoverClose: () => void; @@ -29,10 +30,13 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo const [startTime, setStartTime] = useState(createDateWithTime(9)); const [endTime, setEndTime] = useState(createDateWithTime(10)); const [eventDays, setEventDays] = useState([]); + + const [color, setColor] = useState('default-1'); + const [colorPickerAnchorEl, setColorPickerAnchorEl] = useState(null); + const [courseCode, setCourseCode] = useState(''); const [classCode, setClassCode] = useState(''); // const [classesCodes, setClassesCodes] = useState[]>([]); - // const [colorPickerAnchorEl, setColorPickerAnchorEl] = useState(null); const handleCreateEvent = () => { for (const day of eventDays) { @@ -41,7 +45,7 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo event: { timetableId, colour: '000000', // TODO: Add color picker - dayOfWeek: daysShort.indexOf(day), + dayOfWeek: DAYS_SHORT.indexOf(day), start: startTime.getHours() + startTime.getMinutes() / 60, end: isMidnight ? 24.0 : endTime.getHours() + endTime.getMinutes() / 60, type: eventType === 'General' ? 'CUSTOM' : 'TUTORING', @@ -154,7 +158,7 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo optionName="Days" optionState={eventDays} setOptionState={handleFormat} - optionChoices={daysShort} + optionChoices={DAYS_SHORT} multiple={true} noOff /> @@ -169,13 +173,17 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo - {/* */} + { + setColorPickerAnchorEl(e.currentTarget); + }} + handleCloseColorPicker={() => { + setColorPickerAnchorEl(null); + }} + /> { return colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b)); }; + +export const oklchToHex = (oklch: string): string => { + if (!oklch.startsWith('oklch(')) { + return oklch; + } + + const [l, c, h] = oklch + .replace('oklch(', '') + .replace(')', '') + .split(' ') + .map((v) => parseFloat(v.trim())); + return oklch2hex([l, c, h]); +}; From d5f8c976a7a333168d3e9c855a5321cbd4885040 Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Thu, 13 Nov 2025 03:33:20 +1100 Subject: [PATCH 05/12] feat: add back in color options and refactored calculation of color chunks --- .../planner/controls/ColorOptions.tsx | 87 +++++++++++++++++++ .../planner/controls/ColorPicker.tsx | 6 +- 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 client/src/components/planner/controls/ColorOptions.tsx diff --git a/client/src/components/planner/controls/ColorOptions.tsx b/client/src/components/planner/controls/ColorOptions.tsx new file mode 100644 index 000000000..9c3677dda --- /dev/null +++ b/client/src/components/planner/controls/ColorOptions.tsx @@ -0,0 +1,87 @@ +import styled from '@emotion/styled'; +import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; +import { IconButton, List, ListItem, useTheme } from '@mui/material'; +import { useMemo } from 'react'; + +import { useGetUserSettingsQuery } from '../../../api/user/queries'; +import { decodeColor } from '../../../utils/colors'; + +const COLORS = ['default-1', 'default-2', 'default-3', 'default-4', 'default-5', 'default-6', 'default-7', 'default-8']; + +const StyledColorIconButton = styled(IconButton, { + shouldForwardProp: (prop) => prop !== 'border' && prop !== 'bgColor', +})<{ border: string; bgColor: string }>(({ border, bgColor }) => ({ + backgroundColor: bgColor, + width: 40, + height: 40, + '&:hover': { + backgroundColor: bgColor, + border: `2px solid ${border}`, + }, +})); + +interface ColorOptionsProps { + maxDefaultColors?: number; + showCustomColorPicker: boolean; + onSelectColor: (color: string) => void; + onCustomColorSelect: () => void; +} + +const ColorOptions = ({ + maxDefaultColors = 4, // Default to 4 color options + showCustomColorPicker, + onSelectColor, + onCustomColorSelect, +}: ColorOptionsProps) => { + const theme = useTheme(); + const { preferredTheme } = useGetUserSettingsQuery(); + const decodedColors = useMemo(() => COLORS.map((color) => decodeColor(color, preferredTheme)), [preferredTheme]); + + const selectedThemeColorDisplay = useMemo(() => { + const colorItems = []; + for (let i = 0; i < COLORS.length; i += maxDefaultColors) { + const isLastChunk = i === COLORS.length - maxDefaultColors; + colorItems.push( + COLORS.slice(i, isLastChunk ? i + maxDefaultColors - 1 : i + maxDefaultColors).map((color, j) => ( + + { + onSelectColor(color); + }} + /> + + )), + ); + } + colorItems[colorItems.length - 1].push( + + {showCustomColorPicker ? : } + , + ); + + return colorItems.map((item, index) => ( + + {item} + + )); + }, [ + maxDefaultColors, + theme.palette.secondary.main, + theme.palette.secondary.dark, + decodedColors, + onCustomColorSelect, + showCustomColorPicker, + onSelectColor, + ]); + + return {selectedThemeColorDisplay}; +}; + +export default ColorOptions; diff --git a/client/src/components/planner/controls/ColorPicker.tsx b/client/src/components/planner/controls/ColorPicker.tsx index 0fc3db741..89d85aa30 100644 --- a/client/src/components/planner/controls/ColorPicker.tsx +++ b/client/src/components/planner/controls/ColorPicker.tsx @@ -5,6 +5,7 @@ import { useMemo, useState } from 'react'; import { useGetUserSettingsQuery } from '../../../api/user/queries'; import { ColorIndicatorBox, StyledButtonContainer } from '../../../styles/ControlStyles'; import { decodeColor, oklchToHex } from '../../../utils/colors'; +import ColorOptions from './ColorOptions'; interface ColorPickerProps { color: string; @@ -69,8 +70,7 @@ const ColorPicker: React.FC = ({ }} > - {/* { setColor(selectedColor); @@ -78,7 +78,7 @@ const ColorPicker: React.FC = ({ onCustomColorSelect={() => { setShowCustomColorPicker(!showCustomColorPicker); }} - /> */} + /> {showCustomColorPicker && ( From 85999b1db95e9d27434cd416edf7a812e1b30a06 Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Thu, 13 Nov 2025 03:35:14 +1100 Subject: [PATCH 06/12] feat: add color to add event request --- .../planner/controls/customEvents/CustomEventsForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx index f195c43bd..57a9c43cb 100644 --- a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx @@ -44,7 +44,7 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo eventCreateMutation.mutate({ event: { timetableId, - colour: '000000', // TODO: Add color picker + colour: color, dayOfWeek: DAYS_SHORT.indexOf(day), start: startTime.getHours() + startTime.getMinutes() / 60, end: isMidnight ? 24.0 : endTime.getHours() + endTime.getMinutes() / 60, From 36c01546905965e17d1697c5b0caedf4c69d6451 Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Thu, 13 Nov 2025 04:25:17 +1100 Subject: [PATCH 07/12] feat: refactor again to have 1parent-2children architecture using useImperativeHandle hook --- .../customEvents/CustomEventsCustomForm.tsx | 148 +++++++++++++++ .../customEvents/CustomEventsForm.tsx | 171 ++++-------------- .../customEvents/CustomEventsTutoringForm.tsx | 91 ++++++++++ 3 files changed, 272 insertions(+), 138 deletions(-) create mode 100644 client/src/components/planner/controls/customEvents/CustomEventsCustomForm.tsx create mode 100644 client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx diff --git a/client/src/components/planner/controls/customEvents/CustomEventsCustomForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsCustomForm.tsx new file mode 100644 index 000000000..41b55afe0 --- /dev/null +++ b/client/src/components/planner/controls/customEvents/CustomEventsCustomForm.tsx @@ -0,0 +1,148 @@ +import { Event, LocationOn, Notes } from '@mui/icons-material'; +import { ListItemIcon, TextField } from '@mui/material'; +import { TimePicker } from '@mui/x-date-pickers'; +import { useQueryClient } from '@tanstack/react-query'; +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; + +import { useAddTimetableEvent } from '../../../../api/timetable/mutations'; +import { StyledListItem } from '../../../../styles/ControlStyles'; +import { StyledListItemText } from '../../../../styles/CustomEventStyles'; +import { areValidEventTimes, createDateWithTime } from '../../../../utils/eventHelpers'; +import DropdownOption from '../../timetable/DropdownOption'; + +const DAYS_SHORT = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + +interface CustomEventsCustomFormProps { + timetableId: string; + color: string; + setCustomEventFormSatisfied: (satisfied: boolean) => void; +} + +const CustomEventsCustomForm = forwardRef( + ({ timetableId, color, setCustomEventFormSatisfied }: CustomEventsCustomFormProps, ref) => { + const queryClient = useQueryClient(); + const eventCreateMutation = useAddTimetableEvent(queryClient); + + const [eventName, setEventName] = useState(''); + const [eventDescription, setEventDescription] = useState(''); + const [eventLocation, setEventLocation] = useState(''); + const [startTime, setStartTime] = useState(createDateWithTime(9)); + const [endTime, setEndTime] = useState(createDateWithTime(10)); + const [eventDays, setEventDays] = useState([]); + + useEffect(() => { + setCustomEventFormSatisfied(!!eventName && eventDays.length > 0); + }, [eventName, eventDays, setCustomEventFormSatisfied]); + + const handleCreateEvent = () => { + for (const day of eventDays) { + const isMidnight = endTime.getHours() + endTime.getMinutes() / 60 === 0; + eventCreateMutation.mutate({ + event: { + timetableId, + colour: color, + dayOfWeek: DAYS_SHORT.indexOf(day), + start: startTime.getHours() + startTime.getMinutes() / 60, + end: isMidnight ? 24.0 : endTime.getHours() + endTime.getMinutes() / 60, + type: 'CUSTOM', + title: eventName, + description: eventDescription, + location: eventLocation, + }, + }); + } + }; + useImperativeHandle(ref, () => ({ + handleCreateEvent, + })); + + const handleFormat = (newFormats: string[]) => { + setEventDays(newFormats); + }; + + return ( + <> + + + + + { + setEventName(e.target.value); + }} + variant="outlined" + fullWidth + required + /> + + + + + + { + setEventDescription(e.target.value); + }} + variant="outlined" + multiline + fullWidth + /> + + + + + + { + setEventLocation(e.target.value); + }} + variant="outlined" + fullWidth + /> + + + + { + if (e) setStartTime(e); + }} + /> + + + + { + if (e) setEndTime(e); + }} + /> + + + + ); + }, +); + +CustomEventsCustomForm.displayName = 'CustomEventsCustomForm'; +export default CustomEventsCustomForm; diff --git a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx index 57a9c43cb..038d5b4a5 100644 --- a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx @@ -1,18 +1,12 @@ -import { Add, Event, LocationOn, Notes } from '@mui/icons-material'; +import { Add } from '@mui/icons-material'; import { TabContext } from '@mui/lab'; -import { Box, ListItemIcon, Tab, Tabs, TextField } from '@mui/material'; -import { TimePicker } from '@mui/x-date-pickers'; -import { useQueryClient } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; +import { Box, Tab, Tabs } from '@mui/material'; +import { useMemo, useRef, useState } from 'react'; -import { useAddTimetableEvent } from '../../../../api/timetable/mutations'; -import { StyledListItem } from '../../../../styles/ControlStyles'; -import { ExecuteButton, StyledList, StyledListItemText, StyledTabPanel } from '../../../../styles/CustomEventStyles'; -import { areValidEventTimes, createDateWithTime } from '../../../../utils/eventHelpers'; -import DropdownOption from '../../timetable/DropdownOption'; +import { ExecuteButton, StyledList, StyledTabPanel } from '../../../../styles/CustomEventStyles'; import ColorPicker from '../ColorPicker'; - -const DAYS_SHORT = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; +import CustomEventsCustomForm from './CustomEventsCustomForm'; +import CustomEventsTutoringForm from './CustomEventsTutoringForm'; interface CustomEventsPopoverProps { handlePopoverClose: () => void; @@ -20,60 +14,34 @@ interface CustomEventsPopoverProps { } const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPopoverProps) => { - const queryClient = useQueryClient(); - const eventCreateMutation = useAddTimetableEvent(queryClient); + const customEventFormRef = useRef<{ handleCreateEvent: () => void }>(null); + const tutoringEventFormRef = useRef<{ handleCreateEvent: () => void }>(null); const [eventType, setEventType] = useState('General'); - const [eventName, setEventName] = useState(''); - const [eventDescription, setEventDescription] = useState(''); - const [eventLocation, setEventLocation] = useState(''); - const [startTime, setStartTime] = useState(createDateWithTime(9)); - const [endTime, setEndTime] = useState(createDateWithTime(10)); - const [eventDays, setEventDays] = useState([]); - + const [customEventFormSatisfied, setCustomEventFormSatisfied] = useState(false); + const [tutoringEventFormSatisfied, setTutoringEventFormSatisfied] = useState(false); + const buttonDisabled = useMemo( + () => + (eventType === 'General' && !customEventFormSatisfied) || + (eventType === 'Tutoring' && !tutoringEventFormSatisfied), + [eventType, customEventFormSatisfied, tutoringEventFormSatisfied], + ); const [color, setColor] = useState('default-1'); const [colorPickerAnchorEl, setColorPickerAnchorEl] = useState(null); - const [courseCode, setCourseCode] = useState(''); - const [classCode, setClassCode] = useState(''); - // const [classesCodes, setClassesCodes] = useState[]>([]); + const handleTabChange = (_: React.SyntheticEvent, newEventType: string) => { + setEventType(newEventType); + }; const handleCreateEvent = () => { - for (const day of eventDays) { - const isMidnight = endTime.getHours() + endTime.getMinutes() / 60 === 0; - eventCreateMutation.mutate({ - event: { - timetableId, - colour: color, - dayOfWeek: DAYS_SHORT.indexOf(day), - start: startTime.getHours() + startTime.getMinutes() / 60, - end: isMidnight ? 24.0 : endTime.getHours() + endTime.getMinutes() / 60, - type: eventType === 'General' ? 'CUSTOM' : 'TUTORING', - title: eventName, - description: eventDescription, - location: eventLocation, - }, - }); + if (eventType === 'General') { + customEventFormRef.current?.handleCreateEvent(); + } else if (eventType === 'Tutoring') { + tutoringEventFormRef.current?.handleCreateEvent(); } handlePopoverClose(); }; - const handleFormat = (newFormats: string[]) => { - console.log(newFormats); - setEventDays(newFormats); - }; - - const handleTabChange = (_: React.SyntheticEvent, newEventType: string) => { - setEventType(newEventType); - }; - - const isEventButtonDisabled = useMemo(() => { - return ( - (eventType === 'General' && (!eventName || eventDays.length === 0)) || - (eventType === 'Tutoring' && (!courseCode || !classCode)) - ); - }, [eventType, eventDays, courseCode, classCode, eventName]); - return ( <> @@ -85,91 +53,18 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo - - - - - { - setEventName(e.target.value); - }} - variant="outlined" - fullWidth - required - /> - - - - - - { - setEventDescription(e.target.value); - }} - variant="outlined" - multiline - fullWidth - /> - - - - - - { - setEventLocation(e.target.value); - }} - variant="outlined" - fullWidth - /> - - - - { - if (e) setStartTime(e); - }} - /> - - - - { - if (e) setEndTime(e); - }} - /> - - - {/* */} + @@ -189,7 +84,7 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo variant="contained" color="primary" disableElevation - disabled={isEventButtonDisabled} + disabled={buttonDisabled} onClick={handleCreateEvent} > diff --git a/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx new file mode 100644 index 000000000..7c0284085 --- /dev/null +++ b/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx @@ -0,0 +1,91 @@ +import { Class, Event } from '@mui/icons-material'; +import { ListItemIcon } from '@mui/material'; +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; + +import { StyledListItem } from '../../../../styles/ControlStyles'; + +interface CustomEventsTutoringFormProps { + setTutoringEventFormSatisfied: (satisfied: boolean) => void; +} + +const CustomEventsTutoringForm = forwardRef(({ setTutoringEventFormSatisfied }: CustomEventsTutoringFormProps, ref) => { + const [courseCode, setCourseCode] = useState(''); + const [classCode, setClassCode] = useState(''); + const [classesCodes, setClassesCodes] = useState[]>([]); + + useEffect(() => { + setTutoringEventFormSatisfied(!!courseCode && !!classCode); + }, [courseCode, classCode, setTutoringEventFormSatisfied]); + + const handleCreateEvent = () => { + // TODO: implement tutoring event creation + }; + useImperativeHandle(ref, () => ({ + handleCreateEvent, + })); + return ( + <> + + + + + {/* } + fullWidth + autoHighlight + noOptionsText="No Results" + onChange={(_, value) => { + value ? setCourseCode(value.label) : setCourseCode(''); + }} + renderOption={(props, option) => { + return ( +
  • + {option.label} +
  • + ); + }} + isOptionEqualToValue={(option, value) => option.id === value.id && option.label === value.label} + ListboxProps={{ + style: { + maxHeight: '120px', + }, + }} + /> */} +
    + + + + + {/* } + fullWidth + autoHighlight + noOptionsText="No Results" + onChange={(_, value) => { + value ? setClassCode(value.label) : setClassCode(''); + }} + renderOption={(props, option) => { + return ( +
  • + {option.label} +
  • + ); + }} + isOptionEqualToValue={(option, value) => option.id === value.id && option.label === value.label} + ListboxProps={{ + style: { + maxHeight: '120px', + }, + }} + /> */} +
    + + ); +}); + +CustomEventsTutoringForm.displayName = 'CustomEventsTutoringForm'; +export default CustomEventsTutoringForm; From d70fe7058ef8f22397587c6ead4a58a89ef6cd15 Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Thu, 13 Nov 2025 04:55:46 +1100 Subject: [PATCH 08/12] feat: implement course and class time fetch --- .../components/planner/controls/Controls.tsx | 2 +- .../controls/customEvents/CustomEvents.tsx | 10 +- .../customEvents/CustomEventsForm.tsx | 9 +- .../customEvents/CustomEventsTutoringForm.tsx | 165 ++++++++++-------- 4 files changed, 101 insertions(+), 85 deletions(-) diff --git a/client/src/components/planner/controls/Controls.tsx b/client/src/components/planner/controls/Controls.tsx index 6b36e7a67..e5ce40c10 100644 --- a/client/src/components/planner/controls/Controls.tsx +++ b/client/src/components/planner/controls/Controls.tsx @@ -84,7 +84,7 @@ const Controls: React.FC<{ term: Term; setTerm: (term: Term) => void; timetableI }} > - + diff --git a/client/src/components/planner/controls/customEvents/CustomEvents.tsx b/client/src/components/planner/controls/customEvents/CustomEvents.tsx index ef302203d..3b5b7b1b9 100644 --- a/client/src/components/planner/controls/customEvents/CustomEvents.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEvents.tsx @@ -2,15 +2,17 @@ import { ArrowDropDown, ArrowDropUp } from '@mui/icons-material'; import { Box, Popover } from '@mui/material'; import { useMemo, useState } from 'react'; +import { Term } from '../../../../api/times/times'; import { StyledControlsButton } from '../../../../styles/ControlStyles'; import { DropdownButton } from '../../../../styles/CustomEventStyles'; import CustomEventsForm from './CustomEventsForm'; -interface CustomEventProps { +interface CustomEventsProps { + term: Term; timetableId: string; } -const CustomEvent = ({ timetableId }: CustomEventProps) => { +const CustomEvents = ({ term, timetableId }: CustomEventsProps) => { const [createEventAnchorEl, setCreateEventAnchorEl] = useState(null); const popoverId = useMemo(() => (createEventAnchorEl ? 'create-event-popover' : undefined), [createEventAnchorEl]); @@ -43,10 +45,10 @@ const CustomEvent = ({ timetableId }: CustomEventProps) => { horizontal: 'right', }} > - + ); }; -export default CustomEvent; +export default CustomEvents; diff --git a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx index 038d5b4a5..725ed1cad 100644 --- a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx @@ -3,17 +3,19 @@ import { TabContext } from '@mui/lab'; import { Box, Tab, Tabs } from '@mui/material'; import { useMemo, useRef, useState } from 'react'; +import { Term } from '../../../../api/times/times'; import { ExecuteButton, StyledList, StyledTabPanel } from '../../../../styles/CustomEventStyles'; import ColorPicker from '../ColorPicker'; import CustomEventsCustomForm from './CustomEventsCustomForm'; import CustomEventsTutoringForm from './CustomEventsTutoringForm'; -interface CustomEventsPopoverProps { +interface CustomEventsFormProps { + term: Term; handlePopoverClose: () => void; timetableId: string; } -const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPopoverProps) => { +const CustomEventsForm = ({ term, handlePopoverClose, timetableId }: CustomEventsFormProps) => { const customEventFormRef = useRef<{ handleCreateEvent: () => void }>(null); const tutoringEventFormRef = useRef<{ handleCreateEvent: () => void }>(null); @@ -62,6 +64,7 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo @@ -94,4 +97,4 @@ const CustomEventsPopover = ({ handlePopoverClose, timetableId }: CustomEventsPo ); }; -export default CustomEventsPopover; +export default CustomEventsForm; diff --git a/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx index 7c0284085..0c401e6fe 100644 --- a/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx @@ -1,91 +1,102 @@ import { Class, Event } from '@mui/icons-material'; -import { ListItemIcon } from '@mui/material'; +import { Autocomplete, ListItemIcon, TextField } from '@mui/material'; import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; +import { Term, useCourseListQuery, useCoursesClassTimesQuery } from '../../../../api/times/times'; import { StyledListItem } from '../../../../styles/ControlStyles'; interface CustomEventsTutoringFormProps { setTutoringEventFormSatisfied: (satisfied: boolean) => void; + term: Term; } -const CustomEventsTutoringForm = forwardRef(({ setTutoringEventFormSatisfied }: CustomEventsTutoringFormProps, ref) => { - const [courseCode, setCourseCode] = useState(''); - const [classCode, setClassCode] = useState(''); - const [classesCodes, setClassesCodes] = useState[]>([]); +const CustomEventsTutoringForm = forwardRef( + ({ term, setTutoringEventFormSatisfied }: CustomEventsTutoringFormProps, ref) => { + const courseList = useCourseListQuery(term); - useEffect(() => { - setTutoringEventFormSatisfied(!!courseCode && !!classCode); - }, [courseCode, classCode, setTutoringEventFormSatisfied]); + const [courseCode, setCourseCode] = useState(''); + const [classCode, setClassCode] = useState<{ day: string; time: string } | null>(null); + const classList = useCoursesClassTimesQuery(courseCode ? [courseCode] : [], term.year, term.term); - const handleCreateEvent = () => { - // TODO: implement tutoring event creation - }; - useImperativeHandle(ref, () => ({ - handleCreateEvent, - })); - return ( - <> - - - - - {/* } - fullWidth - autoHighlight - noOptionsText="No Results" - onChange={(_, value) => { - value ? setCourseCode(value.label) : setCourseCode(''); - }} - renderOption={(props, option) => { - return ( -
  • - {option.label} -
  • - ); - }} - isOptionEqualToValue={(option, value) => option.id === value.id && option.label === value.label} - ListboxProps={{ - style: { - maxHeight: '120px', - }, - }} - /> */} -
    - - - - - {/* } - fullWidth - autoHighlight - noOptionsText="No Results" - onChange={(_, value) => { - value ? setClassCode(value.label) : setClassCode(''); - }} - renderOption={(props, option) => { - return ( -
  • - {option.label} -
  • - ); - }} - isOptionEqualToValue={(option, value) => option.id === value.id && option.label === value.label} - ListboxProps={{ - style: { - maxHeight: '120px', - }, - }} - /> */} -
    - - ); -}); + useEffect(() => { + setTutoringEventFormSatisfied(!!courseCode && !!classCode); + }, [courseCode, classCode, setTutoringEventFormSatisfied]); + + const handleCreateEvent = () => { + // TODO: implement tutoring event creation + }; + useImperativeHandle(ref, () => ({ + handleCreateEvent, + })); + return ( + <> + + + + + } + fullWidth + autoHighlight + noOptionsText="No Results" + onChange={(_, value) => { + console.log(value); + setCourseCode(value ? value.course_code : ''); + }} + renderOption={(props, option) => { + return ( +
  • + {option.course_code} +
  • + ); + }} + getOptionLabel={(option) => (typeof option === 'string' ? option : option.course_code)} + isOptionEqualToValue={(option, value) => + option.course_id === value.course_id && option.course_code === value.course_code + } + ListboxProps={{ + style: { + maxHeight: '120px', + }, + }} + /> +
    + + + + + 0 ? classList[0].times : []} + renderInput={(params) => } + fullWidth + autoHighlight + noOptionsText="No Results" + onChange={(_, value) => { + setClassCode(value); + }} + renderOption={(props, option) => { + const classTime = `${option.day} ${option.time}`; + return ( +
  • + {classTime} +
  • + ); + }} + isOptionEqualToValue={(option, value) => option.day === value.day && option.time === value.time} + ListboxProps={{ + style: { + maxHeight: '120px', + }, + }} + /> +
    + + ); + }, +); CustomEventsTutoringForm.displayName = 'CustomEventsTutoringForm'; export default CustomEventsTutoringForm; From 31e9376881ca4cc551f9afa5de7fd4b18f874485 Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:52:05 +1100 Subject: [PATCH 09/12] feat: add back in tutoring form with proper gql queries + have gql query work now --- client/src/api/times/times.ts | 45 ++++++- .../customEvents/CustomEventsForm.tsx | 2 + .../customEvents/CustomEventsTutoringForm.tsx | 112 ++++++++++++++---- 3 files changed, 133 insertions(+), 26 deletions(-) diff --git a/client/src/api/times/times.ts b/client/src/api/times/times.ts index 678b6bd09..5bed15b78 100644 --- a/client/src/api/times/times.ts +++ b/client/src/api/times/times.ts @@ -156,7 +156,7 @@ export const COURSES_CLASS_TIMES_QUERY: CoursesClassTimesQueryType = gql` classes(where: { course_id: { _in: $courseIds }, year: { _eq: $year }, term: { _eq: $term } }) { times { day - start_time + time } } } @@ -176,7 +176,50 @@ export const useCoursesClassTimesQuery = (courseIds: string[], year: number, ter times: { day: string; time: string; + location: string; }[]; + class_id: string; + section: string; + activity: string; + }[]; + + return data.classes; +}; + +export const COURSES_CLASS_TIMES_DETAILED_QUERY: CoursesClassTimesQueryType = gql` + query GetCoursesClassTimes($courseIds: [String!]!, $year: Int!, $term: String!) { + classes(where: { course_id: { _in: $courseIds }, year: { _eq: $year }, term: { _eq: $term } }) { + times { + day + time + location + } + section + class_id + activity + } + } +`; + +export const useCoursesClassTimesDetailedQuery = (courseIds: string[], year: number, term: string) => { + const skip = courseIds.length === 0; + + const { data } = useSuspenseQuery(COURSES_CLASS_TIMES_DETAILED_QUERY, { + variables: { courseIds, year, term }, + skip, + }); + + // Data should only be undefined if skipped + if (skip || data === undefined) + return [] as { + times: { + day: string; + time: string; + location: string; + }[]; + class_id: string; + section: string; + activity: string; }[]; return data.classes; diff --git a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx index 725ed1cad..2e0b5e249 100644 --- a/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEventsForm.tsx @@ -64,6 +64,8 @@ const CustomEventsForm = ({ term, handlePopoverClose, timetableId }: CustomEvent
    void; term: Term; + timetableId: string; + color: string; } const CustomEventsTutoringForm = forwardRef( - ({ term, setTutoringEventFormSatisfied }: CustomEventsTutoringFormProps, ref) => { + ({ term, setTutoringEventFormSatisfied, timetableId, color }: CustomEventsTutoringFormProps, ref) => { + const [selectedCourseId, setSelectedCourseId] = useState(''); + const [selectedClass, setSelectedClass] = useState<{ + day: string; + startTime: string; + endTime: string; + classId: string; + section: string; + activity: string; + location: string; + } | null>(null); + + const courseSelectionRef = useRef(null); + const courseList = useCourseListQuery(term); + const classList = useCoursesClassTimesQuery(selectedCourseId ? [selectedCourseId] : [], term.year, term.term); + const queryClient = useQueryClient(); + const eventCreateMutation = useAddTimetableEvent(queryClient); - const [courseCode, setCourseCode] = useState(''); - const [classCode, setClassCode] = useState<{ day: string; time: string } | null>(null); - const classList = useCoursesClassTimesQuery(courseCode ? [courseCode] : [], term.year, term.term); + const classListOptions = useMemo( + () => + classList + .filter((cls) => cls.times.length > 0 && TUTORING_ACTIVITY_TYPES.includes(cls.activity)) + .map(({ times: cls, class_id, section, activity }) => ({ + day: cls[0].day, + startTime: cls[0].time.split('-')[0].trim(), + endTime: cls[cls.length - 1].time.split('-')[1].trim(), + classId: class_id, + section, + activity, + location: cls[0].location, + })), + [classList], + ); useEffect(() => { - setTutoringEventFormSatisfied(!!courseCode && !!classCode); - }, [courseCode, classCode, setTutoringEventFormSatisfied]); + setTutoringEventFormSatisfied(!!selectedCourseId && !!selectedClass); + }, [selectedCourseId, selectedClass, setTutoringEventFormSatisfied]); - const handleCreateEvent = () => { - // TODO: implement tutoring event creation - }; + const handleCreateEvent = useCallback(() => { + if (!selectedClass) return; + const startTimeHours = parseInt(selectedClass.startTime.split(':')[0], 10); + const startTimeMinutes = parseInt(selectedClass.startTime.split(':')[1], 10); + const endTimeHours = parseInt(selectedClass.endTime.split(':')[0], 10); + const endTimeMinutes = parseInt(selectedClass.endTime.split(':')[1], 10); + + const startTimeVal = startTimeHours + startTimeMinutes / 60; + const endTimeVal = endTimeHours + endTimeMinutes / 60; + + const isMidnight = endTimeHours + endTimeMinutes / 60 === 0; + eventCreateMutation.mutate({ + event: { + timetableId, + colour: color, + dayOfWeek: DAYS_SHORT.indexOf(selectedClass.day), + start: startTimeVal, + end: isMidnight ? 24.0 : endTimeVal, + type: 'TUTORING', + title: `${courseSelectionRef.current?.value ?? ''} - ${selectedClass.activity}`, + description: selectedClass.section, + location: selectedClass.location, + }, + }); + }, [selectedClass, timetableId, color, eventCreateMutation]); useImperativeHandle(ref, () => ({ handleCreateEvent, })); + return ( <> @@ -42,8 +100,7 @@ const CustomEventsTutoringForm = forwardRef( autoHighlight noOptionsText="No Results" onChange={(_, value) => { - console.log(value); - setCourseCode(value ? value.course_code : ''); + setSelectedCourseId(value ? value.course_id : ''); }} renderOption={(props, option) => { return ( @@ -52,13 +109,15 @@ const CustomEventsTutoringForm = forwardRef( ); }} - getOptionLabel={(option) => (typeof option === 'string' ? option : option.course_code)} + getOptionLabel={(option) => option.course_code} isOptionEqualToValue={(option, value) => option.course_id === value.course_id && option.course_code === value.course_code } - ListboxProps={{ - style: { - maxHeight: '120px', + slotProps={{ + listbox: { + sx: { + maxHeight: '120px', + }, }, }} /> @@ -69,26 +128,29 @@ const CustomEventsTutoringForm = forwardRef( 0 ? classList[0].times : []} + options={classListOptions} + ref={courseSelectionRef} renderInput={(params) => } fullWidth autoHighlight noOptionsText="No Results" onChange={(_, value) => { - setClassCode(value); + setSelectedClass(value); }} renderOption={(props, option) => { - const classTime = `${option.day} ${option.time}`; return ( -
  • - {classTime} +
  • + {option.section}
  • ); }} - isOptionEqualToValue={(option, value) => option.day === value.day && option.time === value.time} - ListboxProps={{ - style: { - maxHeight: '120px', + getOptionLabel={(option) => option.section} + isOptionEqualToValue={(option, value) => option.classId === value.classId} + slotProps={{ + listbox: { + sx: { + maxHeight: '120px', + }, }, }} /> From f97caac8e83ff6de3a9315b9f6a17446e5d5118a Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:05:52 +1100 Subject: [PATCH 10/12] fix: make a separate hook for the tutoring information fetch to not interfere with optimal fetch used in CourseSelect --- client/src/api/times/times.ts | 18 +++++++----------- .../customEvents/CustomEventsTutoringForm.tsx | 4 ++-- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/client/src/api/times/times.ts b/client/src/api/times/times.ts index 5bed15b78..8f9d21cd5 100644 --- a/client/src/api/times/times.ts +++ b/client/src/api/times/times.ts @@ -176,19 +176,15 @@ export const useCoursesClassTimesQuery = (courseIds: string[], year: number, ter times: { day: string; time: string; - location: string; }[]; - class_id: string; - section: string; - activity: string; }[]; return data.classes; }; -export const COURSES_CLASS_TIMES_DETAILED_QUERY: CoursesClassTimesQueryType = gql` - query GetCoursesClassTimes($courseIds: [String!]!, $year: Int!, $term: String!) { - classes(where: { course_id: { _in: $courseIds }, year: { _eq: $year }, term: { _eq: $term } }) { +export const COURSE_CLASS_TIMES_DETAILED_QUERY: CoursesClassTimesQueryType = gql` + query GetCoursesClassTimes($courseId: String!, $year: Int!, $term: String!) { + classes(where: { course_id: { _eq: $courseId }, year: { _eq: $year }, term: { _eq: $term } }) { times { day time @@ -201,11 +197,11 @@ export const COURSES_CLASS_TIMES_DETAILED_QUERY: CoursesClassTimesQueryType = gq } `; -export const useCoursesClassTimesDetailedQuery = (courseIds: string[], year: number, term: string) => { - const skip = courseIds.length === 0; +export const useCourseClassTimesDetailedQuery = (courseId: string, year: number, term: string) => { + const skip = courseId.length === 0; - const { data } = useSuspenseQuery(COURSES_CLASS_TIMES_DETAILED_QUERY, { - variables: { courseIds, year, term }, + const { data } = useSuspenseQuery(COURSE_CLASS_TIMES_DETAILED_QUERY, { + variables: { courseId, year, term }, skip, }); diff --git a/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx b/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx index 44e4b671b..8044ab54c 100644 --- a/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx +++ b/client/src/components/planner/controls/customEvents/CustomEventsTutoringForm.tsx @@ -3,7 +3,7 @@ import { Autocomplete, ListItemIcon, TextField } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import { Term, useCourseListQuery, useCoursesClassTimesQuery } from '../../../../api/times/times'; +import { Term, useCourseClassTimesDetailedQuery, useCourseListQuery } from '../../../../api/times/times'; import { useAddTimetableEvent } from '../../../../api/timetable/mutations'; import { StyledListItem } from '../../../../styles/ControlStyles'; @@ -33,7 +33,7 @@ const CustomEventsTutoringForm = forwardRef( const courseSelectionRef = useRef(null); const courseList = useCourseListQuery(term); - const classList = useCoursesClassTimesQuery(selectedCourseId ? [selectedCourseId] : [], term.year, term.term); + const classList = useCourseClassTimesDetailedQuery(selectedCourseId, term.year, term.term); const queryClient = useQueryClient(); const eventCreateMutation = useAddTimetableEvent(queryClient); From b227b528f0cf9e6d2a64444374995c1ddd8d3d16 Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:17:09 +1100 Subject: [PATCH 11/12] feat: add event ids returned from get timetable route --- server/src/timetable/timetable.service.ts | 6 ++++++ server/src/timetable/types.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/server/src/timetable/timetable.service.ts b/server/src/timetable/timetable.service.ts index 6356d8947..a6844bcbf 100644 --- a/server/src/timetable/timetable.service.ts +++ b/server/src/timetable/timetable.service.ts @@ -316,12 +316,18 @@ export class TimetableService { where: { id: timetableId }, }); + const events = await this.prisma.event.findMany({ + select: { id: true }, + where: { timetableId }, + }); + return { id: data.id, name: data.name, year: data.year, term: data.term, primary: data.primary, + events: events.map((event) => event.id), }; } diff --git a/server/src/timetable/types.ts b/server/src/timetable/types.ts index b88c93dbb..4ea3d0c31 100644 --- a/server/src/timetable/types.ts +++ b/server/src/timetable/types.ts @@ -16,6 +16,7 @@ export class UserTimetable { year: number; term: string; primary: boolean; + events: string[]; } export enum Term { From 8e049b250942ef3b5f21c3b5169b223367c289e4 Mon Sep 17 00:00:00 2001 From: Mark Tran <29350857+marktran2@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:58:19 +1100 Subject: [PATCH 12/12] fix: build error due to typing of gql query --- client/src/api/times/times.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/client/src/api/times/times.ts b/client/src/api/times/times.ts index 8f9d21cd5..1e8247640 100644 --- a/client/src/api/times/times.ts +++ b/client/src/api/times/times.ts @@ -182,8 +182,24 @@ export const useCoursesClassTimesQuery = (courseIds: string[], year: number, ter return data.classes; }; -export const COURSE_CLASS_TIMES_DETAILED_QUERY: CoursesClassTimesQueryType = gql` - query GetCoursesClassTimes($courseId: String!, $year: Int!, $term: String!) { +type CourseClassTimesDetailedQueryType = TypedDocumentNode< + { + classes: { + times: { + day: string; + time: string; + location: string; + }[]; + section: string; + class_id: string; + activity: string; + }[]; + }, + { courseId: string; year: number; term: string } +>; + +export const COURSE_CLASS_TIMES_DETAILED_QUERY: CourseClassTimesDetailedQueryType = gql` + query GetCoursesClassTimesDetailed($courseId: String!, $year: Int!, $term: String!) { classes(where: { course_id: { _eq: $courseId }, year: { _eq: $year }, term: { _eq: $term } }) { times { day