diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml deleted file mode 100644 index fd7e416e..00000000 --- a/.github/workflows/workflow.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Format the code - -on: - push: - -jobs: - format: - runs-on: ubuntu-latest - name: Format Files - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: "20" - - name: Prettier - run: npx prettier --write **/*.{js,ts,tsx,json,md} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: stefanzweifel/git-auto-commit-action@v4 - if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} - with: - commit_message: "Auto-formatted the code using Prettier" diff --git a/course-matrix/backend/__tests__/timetablesController.test.ts b/course-matrix/backend/__tests__/timetablesController.test.ts index e9b49fcf..852c360e 100644 --- a/course-matrix/backend/__tests__/timetablesController.test.ts +++ b/course-matrix/backend/__tests__/timetablesController.test.ts @@ -320,27 +320,6 @@ describe("PUT /api/timetables/:id", () => { beforeEach(() => { jest.clearAllMocks(); }); - test("should return error code 400 and message 'New timetable title or semester or updated favorite status is required when updating a timetable' if request body is empty", async () => { - // Make sure the test user is authenticated - const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; - const timetableData = {}; - - // Mock authHandler to simulate the user being logged in - ( - authHandler as jest.MockedFunction - ).mockImplementationOnce(mockAuthHandler(user_id)); - - const response = await request(app) - .put("/api/timetables/1") - .send(timetableData); - - // Check that the `update` method was called - expect(response.statusCode).toBe(400); - expect(response.body).toEqual({ - error: - "New timetable title or semester or updated favorite status or email notifications enabled is required when updating a timetable", - }); - }); test("should update the timetable successfully", async () => { // Make sure the test user is authenticated @@ -361,7 +340,7 @@ describe("PUT /api/timetables/:id", () => { // Check that the `update` method was called expect(response.statusCode).toBe(200); - expect(response.body).toEqual({ + expect(response.body).toMatchObject({ timetable_title: "Updated Title", semester: "Spring 2025", }); diff --git a/course-matrix/backend/src/constants/availableFunctions.ts b/course-matrix/backend/src/constants/availableFunctions.ts index b0ef0c96..fe3df416 100644 --- a/course-matrix/backend/src/constants/availableFunctions.ts +++ b/course-matrix/backend/src/constants/availableFunctions.ts @@ -41,7 +41,6 @@ export const availableFunctions: AvailableFunctions = { try { // Retrieve user_id const user_id = (req as any).user.id; - // Retrieve user timetable item based on user_id let timeTableQuery = supabase .schema("timetable") @@ -63,7 +62,11 @@ export const availableFunctions: AvailableFunctions = { }; } - return { status: 200, data: timetableData }; + return { + status: 200, + timetableCount: timetableData.length, + data: timetableData, + }; } catch (error) { console.log(error); return { status: 400, error: error }; @@ -113,6 +116,7 @@ export const availableFunctions: AvailableFunctions = { } let updateData: any = {}; + updateData.updated_at = new Date().toISOString(); if (timetable_title) updateData.timetable_title = timetable_title; if (semester) updateData.semester = semester; @@ -198,6 +202,9 @@ export const availableFunctions: AvailableFunctions = { try { // Extract event details and course information from the request const { name, semester, courses, restrictions } = args; + // Get user id from session authentication to insert in the user_id col + const user_id = (req as any).user.id; + if (name.length > 50) { return { status: 400, @@ -205,6 +212,23 @@ export const availableFunctions: AvailableFunctions = { }; } + // Timetables cannot exceed the size of 25. + const { count: timetable_count, error: timetableCountError } = + await supabase + .schema("timetable") + .from("timetables") + .select("*", { count: "exact", head: true }) + .eq("user_id", user_id); + + console.log(timetable_count); + + if ((timetable_count ?? 0) >= 25) { + return { + status: 400, + error: "You have exceeded the limit of 25 timetables", + }; + } + const courseOfferingsList: OfferingList[] = []; const validCourseOfferingsList: GroupedOfferingList[] = []; const maxdays = getMaxDays(restrictions); @@ -277,9 +301,6 @@ export const availableFunctions: AvailableFunctions = { // ------ CREATE FLOW ------ - // Get user id from session authentication to insert in the user_id col - const user_id = (req as any).user.id; - // Retrieve timetable title const schedule = trim(validSchedules)[0]; if (!name || !semester) { diff --git a/course-matrix/backend/src/controllers/aiController.ts b/course-matrix/backend/src/controllers/aiController.ts index 00573b27..8fe7a2da 100644 --- a/course-matrix/backend/src/controllers/aiController.ts +++ b/course-matrix/backend/src/controllers/aiController.ts @@ -244,7 +244,6 @@ export const chat = asyncHandler(async (req: Request, res: Response) => { - Allowing natural language queries about courses, offerings, and academic programs - Providing personalized recommendations based on degree requirements and course availability - Creating, reading, updating, and deleting user timetables based on natural language - ## Your Capabilities - Create new timetables based on provided courses and restrictions - Update timetable names and semesters @@ -266,11 +265,12 @@ export const chat = asyncHandler(async (req: Request, res: Response) => { }/dashboard/timetable?edit=[[TIMETABLE_ID]] , where TIMETABLE_ID is the id of the respective timetable. - If the user provides a course code of length 6 like CSCA08, then assume they mean CSCA08H3 (H3 appended) - If the user wants to create a timetable: - 1. First call getCourses to get course information on the requested courses, - 2. If the user provided a semester, then call getOfferings with the provided courses and semester to ensure the courses are actually offered in the semester. + 1. First call getTimetables to refetch most recent info on timetable names + timetableCount. DO NOT assume timetable names and # of timetables is same since last query. + 2. Then call getCourses to get course information on the requested courses, + 3. If the user provided a semester, then call getOfferings with the provided courses and semester to ensure the courses are actually offered in the semester. a) If a course is NOT returned by getOFferings, then list it under "Excluded courses" with "reason: not offered in [provided semester]" b) If no courses have offerings, then do not generate the timetable. - 3. Lastly, call generateTimetable with the provided information. + 4. Lastly, call generateTimetable with the provided information. - Do not make up fake courses or offerings. - If a user asks about a course that you do not know of, acknowledge this. - You can only edit title of the timetable, nothing else. If a user tries to edit something else, acknowledge this limitation. @@ -280,12 +280,14 @@ export const chat = asyncHandler(async (req: Request, res: Response) => { - After a deletion has been cancelled, /timetable confirm will do nothing. If the user wants to delete again after cancelling, they must specify so. - Do not create multiple timetables for a single user query. Each user query can create at most 1 timetable - If you try to update or create a timetable but you get an error saying a timetable with the same name already exists, then ask the user to rename + - It is possible for users to delete timetables / update them manually. For this reason, always refetch getTimtables before creation, to get the latest names and timetable count. Do not assume the timetables are the same since the last query. + - If a user asks for the timetable count, always refetch getTimetables. Assume this count could have changed between user queries. `, messages, tools: { getTimetables: tool({ description: - "Get all the timetables of the currently logged in user.", + "Get all the timetables of the currently logged in user AND the number of timetables of the currently logged in user", parameters: z.object({}), execute: async (args) => { return await availableFunctions.getTimetables(args, req); diff --git a/course-matrix/backend/src/controllers/timetablesController.ts b/course-matrix/backend/src/controllers/timetablesController.ts index 7b1e99d2..2a3cfeca 100644 --- a/course-matrix/backend/src/controllers/timetablesController.ts +++ b/course-matrix/backend/src/controllers/timetablesController.ts @@ -31,6 +31,21 @@ export default { .status(400) .json({ error: "Timetable Title cannot be over 50 characters long" }); } + // Timetables cannot exceed the size of 25. + const { count: timetable_count, error: timetableCountError } = + await supabase + .schema("timetable") + .from("timetables") + .select("*", { count: "exact", head: true }) + .eq("user_id", user_id); + + console.log(timetable_count); + + if ((timetable_count ?? 0) >= 25) { + return res + .status(400) + .json({ error: "You have exceeded the limit of 25 timetables" }); + } // Check if a timetable with the same title already exist for this user const { data: existingTimetable, error: existingTimetableError } = @@ -163,17 +178,6 @@ export default { favorite, email_notifications_enabled, } = req.body; - if ( - !timetable_title && - !semester && - favorite === undefined && - email_notifications_enabled === undefined - ) { - return res.status(400).json({ - error: - "New timetable title or semester or updated favorite status or email notifications enabled is required when updating a timetable", - }); - } // Timetables cannot be longer than 50 characters. if (timetable_title && timetable_title.length > 50) { @@ -184,7 +188,6 @@ export default { //Retrieve the authenticated user const user_id = (req as any).user.id; - //Retrieve users allowed to access the timetable const { data: timetableUserData, error: timetableUserError } = await supabase @@ -194,7 +197,6 @@ export default { .eq("user_id", user_id) .eq("id", id) .maybeSingle(); - if (timetableUserError) return res.status(400).json({ error: timetableUserError.message }); @@ -202,7 +204,6 @@ export default { if (!timetableUserData || timetableUserData.length === 0) { return res.status(404).json({ error: "Calendar id not found" }); } - // Check if another timetable with the same title already exist for this user const { data: existingTimetable, error: existingTimetableError } = await supabase @@ -213,7 +214,6 @@ export default { .eq("timetable_title", timetable_title) .neq("id", id) .maybeSingle(); - if (existingTimetableError) { return res.status(400).json({ error: existingTimetableError.message }); } @@ -223,8 +223,8 @@ export default { .status(400) .json({ error: "Another timetable with this title already exists" }); } - let updateData: any = {}; + updateData.updated_at = new Date().toISOString(); if (timetable_title) updateData.timetable_title = timetable_title; if (semester) updateData.semester = semester; if (favorite !== undefined) updateData.favorite = favorite; @@ -240,13 +240,11 @@ export default { .eq("id", id) .select() .single(); - const { data: timetableData, error: timetableError } = await updateTimetableQuery; if (timetableError) return res.status(400).json({ error: timetableError.message }); - // If no records were updated due to non-existence timetable or it doesn't belong to the user. if (!timetableData || timetableData.length === 0) { return res.status(404).json({ @@ -255,6 +253,7 @@ export default { } return res.status(200).json(timetableData); } catch (error) { + console.error(error); return res.status(500).send({ error }); } }), diff --git a/course-matrix/frontend/__tests__/convertTimestampToLocaleTime.test.ts b/course-matrix/frontend/__tests__/convertTimestampToLocaleTime.test.ts new file mode 100644 index 00000000..ff80f9bb --- /dev/null +++ b/course-matrix/frontend/__tests__/convertTimestampToLocaleTime.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, test } from "@jest/globals"; + +import { convertTimestampToLocaleTime } from "../src/utils/convert-timestamp-to-locale-time"; + +describe("convertTimestampToLocaleTime", () => { + test("should convert a valid timestamp string to a locale time string", () => { + const timestamp = "2025-03-28T12:00:00Z"; + const result = convertTimestampToLocaleTime(timestamp); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); // Ensures it returns a non-empty string + }); + + test("should convert a valid numeric timestamp to a locale time string", () => { + const timestamp = 1711622400000; // Equivalent to 2025-03-28T12:00:00Z + // in milliseconds + const result = convertTimestampToLocaleTime(timestamp); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + test("convert to locale time date is different", () => { + const timestamp = "2025-03-28 02:33:02.589Z"; + const result = convertTimestampToLocaleTime(timestamp); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + test("should return 'Invalid Date' for an invalid timestamp", () => { + const timestamp = "invalid"; + const result = convertTimestampToLocaleTime(timestamp); + expect(result).toBe("Invalid Date"); + }); +}); diff --git a/course-matrix/frontend/index.html b/course-matrix/frontend/index.html index 7ce8c4c3..f1256cda 100644 --- a/course-matrix/frontend/index.html +++ b/course-matrix/frontend/index.html @@ -2,7 +2,7 @@ - + Course Matrix diff --git a/course-matrix/frontend/package-lock.json b/course-matrix/frontend/package-lock.json index c9ab7e61..2cc0b991 100644 --- a/course-matrix/frontend/package-lock.json +++ b/course-matrix/frontend/package-lock.json @@ -25,6 +25,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@reduxjs/toolkit": "^2.5.1", "@schedule-x/drag-and-drop": "^2.21.1", @@ -2828,6 +2829,39 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz", + "integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", diff --git a/course-matrix/frontend/package.json b/course-matrix/frontend/package.json index 367abe76..a7944a0b 100644 --- a/course-matrix/frontend/package.json +++ b/course-matrix/frontend/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@reduxjs/toolkit": "^2.5.1", "@schedule-x/drag-and-drop": "^2.21.1", diff --git a/course-matrix/frontend/public/img/course-matrix-logo.png b/course-matrix/frontend/public/img/course-matrix-logo.png new file mode 100644 index 00000000..6f41280f Binary files /dev/null and b/course-matrix/frontend/public/img/course-matrix-logo.png differ diff --git a/course-matrix/frontend/public/img/grey-avatar.png b/course-matrix/frontend/public/img/grey-avatar.png new file mode 100644 index 00000000..6ffe1296 Binary files /dev/null and b/course-matrix/frontend/public/img/grey-avatar.png differ diff --git a/course-matrix/frontend/src/App.tsx b/course-matrix/frontend/src/App.tsx index 60b97279..1b58ce9f 100644 --- a/course-matrix/frontend/src/App.tsx +++ b/course-matrix/frontend/src/App.tsx @@ -6,6 +6,7 @@ import SignupPage from "./pages/Signup/SignUpPage"; import AuthRoute from "./components/auth-route"; import SignupSuccessfulPage from "./pages/Signup/SignupSuccessfulPage"; import LoginRoute from "./components/login-route"; +import { Toaster } from "./components/ui/toaster"; /** * App Component @@ -44,6 +45,7 @@ function App() { element={} /> + ); } diff --git a/course-matrix/frontend/src/components/UserMenu.tsx b/course-matrix/frontend/src/components/UserMenu.tsx index 619ef1bb..fd9750a3 100644 --- a/course-matrix/frontend/src/components/UserMenu.tsx +++ b/course-matrix/frontend/src/components/UserMenu.tsx @@ -59,7 +59,11 @@ import { useEffect, useState, useRef } from "react"; * @returns {JSX.Element} The rendered user menu dropdown with account options. */ -export function UserMenu() { +interface UserMenuProps { + setOpen: (open: boolean) => void; +} + +export function UserMenu({ setOpen }: UserMenuProps) { const dispatch = useDispatch(); const [logout] = useLogoutMutation(); const navigate = useNavigate(); @@ -100,24 +104,6 @@ export function UserMenu() { } }; - const handleUsernameUpdate = async () => { - try { - const username = usernameRef?.current?.value; - if (!username || !username.trim()) { - return; - } - user_metadata.user.user_metadata.username = - usernameRef.current?.value.trimEnd(); - localStorage.setItem("userInfo", JSON.stringify(user_metadata)); - await usernameUpdate({ - userId: userId, - username: user_metadata.user.user_metadata.username, - }); - } catch (err) { - console.error("Update username failed: ", err); - } - }; - return ( @@ -125,7 +111,7 @@ export function UserMenu() { {username} {/* Avatar Image is the profile picture of the user. The default avatar is used as a placeholder for now. */} - + {/* Avatar Fallback is the initials of the user. Avatar Fallback will be used if Avatar Image fails to load */} {initials} @@ -138,52 +124,10 @@ export function UserMenu() { {user_metadata?.user?.user_metadata?.email}

- e.preventDefault()}> - - - - - - - Edit Account - - Edit your account details. - - - - {/* Disable this email input box for now until we have the backend for accounts set up */} - { - if (e.key === " ") { - e.stopPropagation(); // Allows space input - } - }} - /> - - {/* Disable this email input box for now until we have the backend for accounts set up */} - - - {/* Disable this password input box for now until we have the backend for accounts set up */} - - - - - - - - - - - + + + + + + + + + + ); +}; + +export default EditAccountDialog; diff --git a/course-matrix/frontend/src/pages/Home/EmailNotificationSettings.tsx b/course-matrix/frontend/src/pages/Home/EmailNotificationSettings.tsx index 6214575d..cf52be96 100644 --- a/course-matrix/frontend/src/pages/Home/EmailNotificationSettings.tsx +++ b/course-matrix/frontend/src/pages/Home/EmailNotificationSettings.tsx @@ -14,6 +14,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/hooks/use-toast"; import { TimetableModel } from "@/models/models"; import { useEffect, useState } from "react"; @@ -26,6 +27,7 @@ export const EmailNotificationSettings = ({ }: EmailNotificationSettingsProps) => { const { data, isLoading, refetch } = useGetTimetableQuery(timetableId); const [updateTimetable] = useUpdateTimetableMutation(); + const { toast } = useToast(); const [toggled, setToggled] = useState(false); const handleCancel = () => { @@ -47,6 +49,9 @@ export const EmailNotificationSettings = ({ id: timetableId, email_notifications_enabled: toggled, }).unwrap(); + toast({ + description: `Email notifications have been ${toggled ? "enabled" : "disabled"}.`, + }); refetch(); } catch (error) { console.error("Failed to update timetable:", error); diff --git a/course-matrix/frontend/src/pages/Home/Home.tsx b/course-matrix/frontend/src/pages/Home/Home.tsx index 7604f634..ba29e039 100644 --- a/course-matrix/frontend/src/pages/Home/Home.tsx +++ b/course-matrix/frontend/src/pages/Home/Home.tsx @@ -11,11 +11,11 @@ import TimetableCard from "./TimetableCard"; import TimetableCreateNewButton from "./TimetableCreateNewButton"; import { useGetTimetablesQuery } from "../../api/timetableApiSlice"; import { TimetableCompareButton } from "./TimetableCompareButton"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import TimetableErrorDialog from "../TimetableBuilder/TimetableErrorDialog"; import { useGetTimetablesSharedWithMeQuery } from "@/api/sharedApiSlice"; -import SharedCalendar from "../TimetableBuilder/SharedCalendar"; -import { useGetUsernameFromUserIdQuery } from "@/api/authApiSlice"; +import ViewCalendar from "../TimetableBuilder/ViewCalendar"; +import { sortTimetablesComparator } from "@/utils/calendar-utils"; export interface Timetable { id: number; @@ -35,14 +35,6 @@ export interface TimetableShare { timetables: Timetable[]; } -function sortingFunction(a: Timetable, b: Timetable) { - if (a.favorite == b.favorite) - return b?.updated_at.localeCompare(a?.updated_at); - if (a.favorite) return -1; - if (b.favorite) return 1; - return 0; -} - /** * Home component that displays the user's timetables and provides options to create or compare timetables. * @returns {JSX.Element} The rendered component. @@ -71,13 +63,24 @@ const Home = () => { const isLoading = myTimetablesDataLoading || sharedWithmeDataLoading; const myOwningTimetables = [...(myTimetablesData ?? [])].sort( - sortingFunction, + sortTimetablesComparator, ); const sharedWithMeTimetables = [...(sharedWithMeData ?? [])] .flatMap((share) => share.timetables) - .sort(sortingFunction); + .sort(sortTimetablesComparator); + const allTimetables = [...myOwningTimetables, ...sharedWithMeTimetables] + .map((timetable, index) => ({ + ...timetable, + isShared: index >= myOwningTimetables.length, + })) + .sort(sortTimetablesComparator); const [errorMessage, setErrorMessage] = useState(null); + const [count, setCount] = useState(0); + + useEffect(() => { + if (myTimetablesData !== undefined) setCount(myTimetablesData.length); + }, [myTimetablesData]); const [activeTab, setActiveTab] = useState("Mine"); const [selectedSharedTimetable, setSelectedSharedTimetable] = @@ -88,13 +91,6 @@ const Home = () => { const selectedSharedTimetableOwnerId = selectedSharedTimetable?.user_id ?? ""; const selectSharedTimetableSemester = selectedSharedTimetable?.semester ?? ""; - // Get the selected shared timetable owner's username - const { data: usernameData } = useGetUsernameFromUserIdQuery( - selectedSharedTimetableOwnerId, - { skip: selectedSharedTimetableId === -1 }, - ); - const ownerUsername = usernameData ?? ""; - return (
@@ -103,26 +99,33 @@ const Home = () => { onOpenChange={() => setSelectedSharedTimetable(null)} > - - + - - - +

My Timetables

- + + +

= 25 + ? "text-sm font-bold text-red-500" + : "text-sm font-normal text-black" + }`} + > + {" "} + (No. Timetables: {count}/25) +

{
- +

-
+
{isLoading ? (

Loading...

) : activeTab === "Mine" ? ( diff --git a/course-matrix/frontend/src/pages/Home/TimetableCard.tsx b/course-matrix/frontend/src/pages/Home/TimetableCard.tsx index 4b215b54..8a5618f0 100644 --- a/course-matrix/frontend/src/pages/Home/TimetableCard.tsx +++ b/course-matrix/frontend/src/pages/Home/TimetableCard.tsx @@ -19,6 +19,18 @@ import { } from "@/api/timetableApiSlice"; import { Link } from "react-router-dom"; import { TimetableModel } from "@/models/models"; +import { SemesterIcon } from "@/components/semester-icon"; +import { convertTimestampToLocaleTime } from "../../utils/convert-timestamp-to-locale-time"; + +const semesterToBgColor = (semester: string) => { + if (semester.startsWith("Fall")) { + return "bg-red-100"; + } else if (semester.startsWith("Winter")) { + return "bg-blue-100"; + } else { + return "bg-yellow-100"; + } +}; interface TimetableCardProps { refetchMyTimetables: () => void; @@ -52,8 +64,9 @@ const TimetableCard = ({ setSelectedSharedTimetable, favorite, }: TimetableCardProps) => { - const [updateTimetable] = useUpdateTimetableMutation(); + /// small blurred version + const [updateTimetable] = useUpdateTimetableMutation(); const timetableId = timetable.id; const user_metadata = JSON.parse(localStorage.getItem("userInfo") ?? "{}"); @@ -65,20 +78,13 @@ const TimetableCard = ({ ? (usernameData ?? "John Doe") : loggedInUsername; - const lastEditedDateArray = lastEditedDate - .toISOString() - .split("T")[0] - .split("-"); - const lastEditedYear = lastEditedDateArray[0]; - const lastEditedMonth = lastEditedDateArray[1]; - const lastEditedDay = lastEditedDateArray[2]; - const lastEditedDateTimestamp = - lastEditedMonth + "/" + lastEditedDay + "/" + lastEditedYear; - const [timetableCardTitle, setTimetableCardTitle] = useState(title); const [isEditingTitle, setIsEditingTitle] = useState(false); - const { data } = useGetTimetableQuery(timetableId); + const { data, refetch } = useGetTimetableQuery(timetableId); const [toggled, setToggled] = useState(favorite); + const [lastEdited, setLastEdited] = useState( + convertTimestampToLocaleTime(lastEditedDate.toISOString()).split(",")[0], + ); const handleSave = async () => { try { @@ -92,6 +98,7 @@ const TimetableCard = ({ setErrorMessage(errorData?.error ?? "Unknown error occurred"); return; } + refetch(); }; useEffect(() => { @@ -100,6 +107,11 @@ const TimetableCard = ({ if (val !== undefined) { setToggled(val); } + setLastEdited( + convertTimestampToLocaleTime( + (data as TimetableModel[])[0]?.updated_at, + ).split(",")[0], + ); } }, [data]); @@ -120,14 +132,18 @@ const TimetableCard = ({ }; return isShared ? ( - + - Timetable default image setSelectedSharedTimetable(timetable)} - /> +
+
+ +
+
+
-
Last edited {lastEditedDateTimestamp}
+
+ Last edited{" "} + { + convertTimestampToLocaleTime(lastEditedDate.toISOString()).split( + ",", + )[0] + } +
+
Owned by: {ownerUsername}
) : ( - + - Timetable default image +
+
+ +
+
@@ -183,20 +212,19 @@ const TimetableCard = ({ onChange={(e) => setTimetableCardTitle(e.target.value)} /> -
- handleFavourite()} - /> -
{!isEditingTitle && ( <> + handleFavourite()} + /> - + + +
+
+ {" "} + {updateErrorMessage}{" "} +
)} diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx index c37d134f..dd873cd0 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx @@ -102,7 +102,7 @@ const CreateCustomSetting = ({ const val = form ?.getValues("restrictions") .some((r) => r.type === "Days Off"); - console.log(val); + // console.log(val); return val; }; @@ -110,7 +110,7 @@ const CreateCustomSetting = ({ const val = form ?.getValues("restrictions") .some((r) => r.type === "Max Gap"); - console.log(val); + // console.log(val); return val; }; @@ -255,6 +255,36 @@ const CreateCustomSetting = ({ }} /> ))} + { + return ( + + + { + return checked + ? field.onChange( + daysOfWeek.map( + (item) => item.id, + ), + ) + : field.onChange([]); + }} + /> + + + All Days + + + ); + }} + />
diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/GeneratedCalendars.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/GeneratedCalendars.tsx index 01e97258..4b9d392c 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/GeneratedCalendars.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/GeneratedCalendars.tsx @@ -16,7 +16,7 @@ import { } from "@/utils/semester-utils"; import { courseEventStyles } from "@/constants/calendarConstants"; import { createEventModalPlugin } from "@schedule-x/event-modal"; -import React, { useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useGetOfferingEventsQuery } from "@/api/offeringsApiSlice"; import { Button } from "@/components/ui/button"; import { @@ -107,7 +107,7 @@ export const GeneratedCalendars = React.memo( const { data: courseEventsData, isLoading } = useGetOfferingEventsQuery({ offering_ids: currentTimetableOfferings - .map((offering) => offering.id) + ?.map((offering) => offering.id) .join(","), semester_start_date: semesterStartDate, semester_end_date: semesterEndDate, @@ -200,6 +200,10 @@ export const GeneratedCalendars = React.memo( isResponsive: false, }); + useEffect(() => { + setCurrentTimetableIndex(0); + }, [generatedTimetables]); + return ( <>
diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/OfferingInfo.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/OfferingInfo.tsx index 1b42a9f0..2c513ff9 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/OfferingInfo.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/OfferingInfo.tsx @@ -245,7 +245,7 @@ const OfferingInfo = ({ course, semester, form }: OfferingInfoProps) => { {section} @@ -257,7 +257,7 @@ const OfferingInfo = ({ course, semester, form }: OfferingInfoProps) => { return ( {`${offering?.day}, ${offering?.start} - ${offering?.end}`} ); })} @@ -309,7 +309,7 @@ const OfferingInfo = ({ course, semester, form }: OfferingInfoProps) => { {section} @@ -321,7 +321,7 @@ const OfferingInfo = ({ course, semester, form }: OfferingInfoProps) => { return ( {`${offering?.day}, ${offering?.start} - ${offering?.end}`} ); })} @@ -375,7 +375,7 @@ const OfferingInfo = ({ course, semester, form }: OfferingInfoProps) => { {section} @@ -387,7 +387,7 @@ const OfferingInfo = ({ course, semester, form }: OfferingInfoProps) => { return ( {`${offering?.day}, ${offering?.start} - ${offering?.end}`} ); })} diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx index a1b1b711..67cfb502 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx @@ -362,13 +362,15 @@ const TimetableBuilder = () => { - + {isEditingTimetable && ( + + )} {isEditingTimetable && ( { - setIsChoosingSectionsManually(checked === true) - } + onCheckedChange={(checked) => { + setIsChoosingSectionsManually(checked === true); + if (checked) { + form.setValue("restrictions", []); + } + }} />
- -
-
-

Custom Settings

-

- Add additional restrictions to your timetable to - personalize it to your needs. -

-
- -
- -
- ( - -

- Enabled Restrictions: {enabledRestrictions.length} + {(!isChoosingSectionsManually || isEditingTimetable) && ( + <> +

+
+

Custom Settings

+

+ {!isEditingTimetable + ? "Add additional restrictions to your timetable to personalize it to your needs." + : "This timetable was created with the following restrictions:"}

- - - )} - /> -
- {enabledRestrictions && - enabledRestrictions.map((restric, index) => ( -
- {restric.type.startsWith("Restrict") ? ( -

- {restric.type}:{" "} - {restric.startTime - ? formatTime(restric.startTime) - : ""}{" "} - {restric.type === "Restrict Between" ? " - " : ""}{" "} - {restric.endTime - ? formatTime(restric.endTime) - : ""}{" "} - {restric.days?.join(" ")} -

- ) : restric.type.startsWith("Days") ? ( -

- {restric.type}: At least{" "} - {restric.numDays} days off -

- ) : ( -

- {restric.type}: {restric.maxGap}{" "} - hours +

+ +
+ +
+ ( + +

+ Enabled Restrictions: {enabledRestrictions.length}

- )} + +
+ )} + /> +
+ {enabledRestrictions && + enabledRestrictions.map((restric, index) => ( +
+ {restric.type.startsWith("Restrict") ? ( +

+ {restric.type}:{" "} + {restric.startTime + ? formatTime(restric.startTime) + : ""}{" "} + {restric.type === "Restrict Between" + ? " - " + : ""}{" "} + {restric.endTime + ? formatTime(restric.endTime) + : ""}{" "} + {restric.days?.join(" ")} +

+ ) : restric.type.startsWith("Days") ? ( +

+ {restric.type}: At least{" "} + {restric.numDays} days off +

+ ) : ( +

+ {restric.type}:{" "} + {restric.maxGap} hours +

+ )} - handleRemoveRestriction(index)} - /> -
- ))} -
-
+ handleRemoveRestriction(index)} + /> +
+ ))} +
+
+ + )} {!isChoosingSectionsManually && (
diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/ViewCalendar.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/ViewCalendar.tsx new file mode 100644 index 00000000..495b08cf --- /dev/null +++ b/course-matrix/frontend/src/pages/TimetableBuilder/ViewCalendar.tsx @@ -0,0 +1,165 @@ +import { ScheduleXCalendar } from "@schedule-x/react"; +import { + createCalendar, + createViewDay, + createViewMonthAgenda, + createViewMonthGrid, + createViewWeek, + viewWeek, +} from "@schedule-x/calendar"; +// import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop"; +import { createEventModalPlugin } from "@schedule-x/event-modal"; +import "@schedule-x/theme-default/dist/index.css"; +import React from "react"; +import { useGetSharedEventsQuery } from "@/api/eventsApiSlice"; +import { Event, TimetableEvents } from "@/utils/type-utils"; +import { + getSemesterStartAndEndDates, + getSemesterStartAndEndDatesPlusOneWeek, +} from "@/utils/semester-utils"; +import { courseEventStyles } from "@/constants/calendarConstants"; +import { parseEvent } from "@/utils/calendar-utils"; +import { useGetUsernameFromUserIdQuery } from "@/api/authApiSlice"; +import { Spinner } from "@/components/ui/spinner"; +import { SemesterIcon } from "@/components/semester-icon"; + +interface ViewCalendarProps { + user_id: string; + calendar_id: number; + timetable_title: string; + semester: string; + show_fancy_header: boolean; +} + +const ViewCalendar = React.memo( + ({ user_id, calendar_id, timetable_title, semester, show_fancy_header }) => { + const { data: usernameData } = useGetUsernameFromUserIdQuery(user_id, { + skip: calendar_id === -1, + }); + const username = usernameData + ? usernameData.trim().length > 0 + ? usernameData + : "John Doe" + : "John Doe"; + + const semesterStartDate = getSemesterStartAndEndDates(semester).start; + const { start: semesterStartDatePlusOneWeek, end: semesterEndDate } = + getSemesterStartAndEndDatesPlusOneWeek(semester); + + const { data: sharedEventsData, isLoading: isSharedEventsLoading } = + useGetSharedEventsQuery( + { user_id, calendar_id }, + { skip: !user_id || !calendar_id }, + ) as { + data: TimetableEvents; + isLoading: boolean; + }; + const sharedEvents = sharedEventsData as TimetableEvents; + + const isLoading = isSharedEventsLoading; + + const courseEvents: Event[] = sharedEvents?.courseEvents ?? []; + const userEvents: Event[] = sharedEvents?.userEvents ?? []; + + const courses = [ + ...new Set( + courseEvents.map((event) => event.event_name.split("-")[0].trim()), + ), + ]; + const courseToMeetingSectionMap = new Map(); + courseEvents.forEach((event) => { + const course = event.event_name.split("-")[0].trim(); + const meetingSection = event.event_name.split("-")[1].trim(); + if (courseToMeetingSectionMap.has(course)) { + const meetingSections = courseToMeetingSectionMap.get(course); + if (meetingSections) { + courseToMeetingSectionMap.set(course, [ + ...new Set([...meetingSections, meetingSection]), + ]); + } + } else { + courseToMeetingSectionMap.set(course, [meetingSection]); + } + }); + + let index = 1; + const courseEventsParsed = courseEvents.map((event) => + parseEvent(index++, event, "courseEvent"), + ); + const userEventsParsed = userEvents.map((event) => + parseEvent(index++, event, "userEvent"), + ); + + const calendar = createCalendar({ + views: [ + createViewDay(), + createViewWeek(), + createViewMonthGrid(), + createViewMonthAgenda(), + ], + firstDayOfWeek: 0, + selectedDate: semesterStartDatePlusOneWeek, + minDate: semesterStartDate, + maxDate: semesterEndDate, + defaultView: viewWeek.name, + events: [...courseEventsParsed, ...userEventsParsed], + calendars: { + courseEvent: courseEventStyles, + }, + plugins: [createEventModalPlugin()], + weekOptions: { + gridHeight: 600, + }, + dayBoundaries: { + start: "06:00", + end: "21:00", + }, + isResponsive: false, + }); + + return isLoading ? ( + + ) : ( +
+ {!show_fancy_header && ( +

+
+ + {timetable_title} +
+

+ )} + {show_fancy_header && ( + <> +

+ You are viewing{" "} + {username ?? "John Doe"}'s{" "} + timetable named{" "} + {timetable_title} for{" "} + {semester} +

+
+ Courses:{" "} + {courses.length === 0 && ( + + This timetable has no courses + + )} + {courses.map((course) => ( + + {course}{" "} + + ({(courseToMeetingSectionMap.get(course) ?? []).join(", ")}) + + + ))} +
+ + )} + +
+ ); + }, +); + +export default ViewCalendar; diff --git a/course-matrix/frontend/src/utils/calendar-utils.ts b/course-matrix/frontend/src/utils/calendar-utils.ts index cdf73cd9..d26be723 100644 --- a/course-matrix/frontend/src/utils/calendar-utils.ts +++ b/course-matrix/frontend/src/utils/calendar-utils.ts @@ -1,4 +1,4 @@ -import { Event } from "@/utils/type-utils"; +import { Event, Timetable } from "@/utils/type-utils"; export function parseEvent(id: number, event: Event, calendarId: string) { return { @@ -19,3 +19,11 @@ export function parseEvent(id: number, event: Event, calendarId: string) { calendarId: calendarId, }; } + +export function sortTimetablesComparator(a: Timetable, b: Timetable) { + if (a.favorite == b.favorite) + return b?.updated_at.localeCompare(a?.updated_at); + if (a.favorite) return -1; + if (b.favorite) return 1; + return 0; +} diff --git a/course-matrix/frontend/src/utils/convert-timestamp-to-locale-time.ts b/course-matrix/frontend/src/utils/convert-timestamp-to-locale-time.ts new file mode 100644 index 00000000..35867568 --- /dev/null +++ b/course-matrix/frontend/src/utils/convert-timestamp-to-locale-time.ts @@ -0,0 +1,7 @@ +export function convertTimestampToLocaleTime( + timestampz: string | number, +): string { + const date = new Date(timestampz); + + return date.toLocaleString(); // Uses system's default locale +} diff --git a/course-matrix/frontend/tailwind.config.js b/course-matrix/frontend/tailwind.config.js index 5ea93599..bdab53a9 100644 --- a/course-matrix/frontend/tailwind.config.js +++ b/course-matrix/frontend/tailwind.config.js @@ -78,10 +78,15 @@ export default { height: "0", }, }, + "fade-in": { + "0%": { opacity: "0" }, + "100%": { opacity: "1" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + "fade-in": "fade-in 0.4s ease-in-out", }, }, },