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}