Skip to content

Commit c866938

Browse files
authored
Merge branch 'develop' into kl/scrum-168-image-enhancements
2 parents dba5926 + 7d65b17 commit c866938

File tree

9 files changed

+124
-74
lines changed

9 files changed

+124
-74
lines changed

course-matrix/backend/__tests__/timetablesController.test.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -320,27 +320,6 @@ describe("PUT /api/timetables/:id", () => {
320320
beforeEach(() => {
321321
jest.clearAllMocks();
322322
});
323-
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 () => {
324-
// Make sure the test user is authenticated
325-
const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c";
326-
const timetableData = {};
327-
328-
// Mock authHandler to simulate the user being logged in
329-
(
330-
authHandler as jest.MockedFunction<typeof authHandler>
331-
).mockImplementationOnce(mockAuthHandler(user_id));
332-
333-
const response = await request(app)
334-
.put("/api/timetables/1")
335-
.send(timetableData);
336-
337-
// Check that the `update` method was called
338-
expect(response.statusCode).toBe(400);
339-
expect(response.body).toEqual({
340-
error:
341-
"New timetable title or semester or updated favorite status or email notifications enabled is required when updating a timetable",
342-
});
343-
});
344323

345324
test("should update the timetable successfully", async () => {
346325
// Make sure the test user is authenticated
@@ -361,7 +340,7 @@ describe("PUT /api/timetables/:id", () => {
361340

362341
// Check that the `update` method was called
363342
expect(response.statusCode).toBe(200);
364-
expect(response.body).toEqual({
343+
expect(response.body).toMatchObject({
365344
timetable_title: "Updated Title",
366345
semester: "Spring 2025",
367346
});

course-matrix/backend/src/constants/availableFunctions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export const availableFunctions: AvailableFunctions = {
116116
}
117117

118118
let updateData: any = {};
119+
updateData.updated_at = new Date().toISOString();
119120
if (timetable_title) updateData.timetable_title = timetable_title;
120121
if (semester) updateData.semester = semester;
121122

course-matrix/backend/src/controllers/timetablesController.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -179,17 +179,6 @@ export default {
179179
favorite,
180180
email_notifications_enabled,
181181
} = req.body;
182-
if (
183-
!timetable_title &&
184-
!semester &&
185-
favorite === undefined &&
186-
email_notifications_enabled === undefined
187-
) {
188-
return res.status(400).json({
189-
error:
190-
"New timetable title or semester or updated favorite status or email notifications enabled is required when updating a timetable",
191-
});
192-
}
193182

194183
// Timetables cannot be longer than 50 characters.
195184
if (timetable_title && timetable_title.length > 50) {
@@ -236,6 +225,7 @@ export default {
236225
.json({ error: "Another timetable with this title already exists" });
237226
}
238227
let updateData: any = {};
228+
updateData.updated_at = new Date().toISOString();
239229
if (timetable_title) updateData.timetable_title = timetable_title;
240230
if (semester) updateData.semester = semester;
241231
if (favorite !== undefined) updateData.favorite = favorite;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it, test } from "@jest/globals";
2+
3+
import { convertTimestampToLocaleTime } from "../src/utils/convert-timestamp-to-locale-time";
4+
5+
describe("convertTimestampToLocaleTime", () => {
6+
test("should convert a valid timestamp string to a locale time string", () => {
7+
const timestamp = "2025-03-28T12:00:00Z";
8+
const result = convertTimestampToLocaleTime(timestamp);
9+
expect(typeof result).toBe("string");
10+
expect(result.length).toBeGreaterThan(0); // Ensures it returns a non-empty string
11+
});
12+
13+
test("should convert a valid numeric timestamp to a locale time string", () => {
14+
const timestamp = 1711622400000; // Equivalent to 2025-03-28T12:00:00Z
15+
// in milliseconds
16+
const result = convertTimestampToLocaleTime(timestamp);
17+
expect(typeof result).toBe("string");
18+
expect(result.length).toBeGreaterThan(0);
19+
});
20+
21+
test("convert to locale time date is different", () => {
22+
const timestamp = "2025-03-28 02:33:02.589Z";
23+
const result = convertTimestampToLocaleTime(timestamp);
24+
expect(typeof result).toBe("string");
25+
expect(result.length).toBeGreaterThan(0);
26+
});
27+
28+
test("should return 'Invalid Date' for an invalid timestamp", () => {
29+
const timestamp = "invalid";
30+
const result = convertTimestampToLocaleTime(timestamp);
31+
expect(result).toBe("Invalid Date");
32+
});
33+
});

course-matrix/frontend/src/models/timetable-form.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export const RestrictionSchema = z
106106
})
107107
.refine(
108108
(data) => {
109-
if (data.startTime && data.endTime) {
109+
if (data.type === "Restrict Between" && data.startTime && data.endTime) {
110110
return data.startTime < data.endTime;
111111
}
112112
return true; // Allow if either undefined

course-matrix/frontend/src/pages/Home/TimetableCard.tsx

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Link } from "react-router-dom";
2121
import { TimetableModel } from "@/models/models";
2222
import { ImagePlaceholder } from "@/components/imagePlaceholder";
2323
import { SemesterIcon } from "@/components/semester-icon";
24+
import { convertTimestampToLocaleTime } from "../../utils/convert-timestamp-to-locale-time";
2425

2526
const semesterToBgColor = (semester: string) => {
2627
if (semester.startsWith("Fall")) {
@@ -67,7 +68,6 @@ const TimetableCard = ({
6768
/// small blurred version
6869

6970
const [updateTimetable] = useUpdateTimetableMutation();
70-
7171
const timetableId = timetable.id;
7272

7373
const user_metadata = JSON.parse(localStorage.getItem("userInfo") ?? "{}");
@@ -79,21 +79,14 @@ const TimetableCard = ({
7979
? (usernameData ?? "John Doe")
8080
: loggedInUsername;
8181

82-
const lastEditedDateArray = lastEditedDate
83-
.toISOString()
84-
.split("T")[0]
85-
.split("-");
86-
const lastEditedYear = lastEditedDateArray[0];
87-
const lastEditedMonth = lastEditedDateArray[1];
88-
const lastEditedDay = lastEditedDateArray[2];
89-
const lastEditedDateTimestamp =
90-
lastEditedMonth + "/" + lastEditedDay + "/" + lastEditedYear;
91-
9282
const [timetableCardTitle, setTimetableCardTitle] = useState(title);
9383
const [isEditingTitle, setIsEditingTitle] = useState(false);
94-
const { data } = useGetTimetableQuery(timetableId);
84+
const { data, refetch } = useGetTimetableQuery(timetableId);
9585
const [toggled, setToggled] = useState(favorite);
9686
const [imageLoaded, setImageLoaded] = useState(false);
87+
const [lastEdited, setLastEdited] = useState(
88+
convertTimestampToLocaleTime(lastEditedDate.toISOString()).split(",")[0],
89+
);
9790

9891
const handleSave = async () => {
9992
try {
@@ -107,6 +100,7 @@ const TimetableCard = ({
107100
setErrorMessage(errorData?.error ?? "Unknown error occurred");
108101
return;
109102
}
103+
refetch();
110104
};
111105

112106
useEffect(() => {
@@ -115,6 +109,11 @@ const TimetableCard = ({
115109
if (val !== undefined) {
116110
setToggled(val);
117111
}
112+
setLastEdited(
113+
convertTimestampToLocaleTime(
114+
(data as TimetableModel[])[0]?.updated_at,
115+
).split(",")[0],
116+
);
118117
}
119118
}, [data]);
120119

@@ -175,7 +174,15 @@ const TimetableCard = ({
175174
</CardHeader>
176175
<CardContent className="-mt-3">
177176
<CardDescription className="flex justify-between text-xs">
178-
<div>Last edited {lastEditedDateTimestamp}</div>
177+
<div>
178+
Last edited{" "}
179+
{
180+
convertTimestampToLocaleTime(lastEditedDate.toISOString()).split(
181+
",",
182+
)[0]
183+
}
184+
</div>
185+
179186
<div>Owned by: {ownerUsername}</div>
180187
</CardDescription>
181188
</CardContent>
@@ -249,7 +256,7 @@ const TimetableCard = ({
249256
</CardHeader>
250257
<CardContent className="-mt-3">
251258
<CardDescription className="flex justify-between text-xs">
252-
<div>Last edited {lastEditedDateTimestamp}</div>
259+
<div>Last edited {lastEdited}</div>
253260
<div>Owned by: {ownerUsername}</div>
254261
</CardDescription>
255262
</CardContent>

course-matrix/frontend/src/pages/TimetableBuilder/Calendar.tsx

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { TimetableFormSchema } from "@/models/timetable-form";
2828
import {
2929
useGetTimetablesQuery,
3030
useCreateTimetableMutation,
31+
useUpdateTimetableMutation,
3132
} from "@/api/timetableApiSlice";
3233
import {
3334
useGetRestrictionsQuery,
@@ -41,6 +42,7 @@ import {
4142
useCreateEventMutation,
4243
useGetEventsQuery,
4344
useDeleteEventMutation,
45+
useUpdateEventMutation,
4446
} from "@/api/eventsApiSlice";
4547
import { useGetOfferingsQuery } from "@/api/offeringsApiSlice";
4648
import { useGetOfferingEventsQuery } from "@/api/offeringsApiSlice";
@@ -89,13 +91,16 @@ const Calendar = React.memo<CalendarProps>(
8991
const editingTimetableId = parseInt(queryParams.get("edit") ?? "0");
9092

9193
const [createTimetable] = useCreateTimetableMutation();
94+
const [updateTimetable] = useUpdateTimetableMutation();
9295
const [createEvent] = useCreateEventMutation();
9396
const [deleteEvent] = useDeleteEventMutation();
9497
const [createRestriction] = useCreateRestrictionMutation();
9598
const [deleteRestriction] = useDeleteRestrictionMutation();
9699

97100
const [errorMessage, setErrorMessage] = useState<string | null>(null);
98-
101+
const [updateErrorMessage, setUpdateErrorMessage] = useState<string | null>(
102+
null,
103+
);
99104
const semesterStartDate = getSemesterStartAndEndDates(semester).start;
100105
const { start: semesterStartDatePlusOneWeek, end: semesterEndDate } =
101106
getSemesterStartAndEndDatesPlusOneWeek(semester);
@@ -262,13 +267,20 @@ const Calendar = React.memo<CalendarProps>(
262267
};
263268

264269
const handleUpdate = async () => {
270+
const timetableTitle = timetableTitleRef.current?.value ?? "";
265271
setShowLoadingPage(true);
272+
266273
const offeringIdsToDelete = oldOfferingIds.filter(
267274
(offeringId) => !newOfferingIds.includes(offeringId),
268275
);
269276
const offeringIdsToAdd = newOfferingIds.filter(
270277
(offeringId) => !oldOfferingIds.includes(offeringId),
271278
);
279+
if (offeringIdsToAdd.length === 0 && offeringIdsToDelete.length === 0) {
280+
setUpdateErrorMessage("You have made no changes to the timetable!");
281+
setShowLoadingPage(false);
282+
return;
283+
}
272284
// Delete course events
273285
for (const offeringId of offeringIdsToDelete) {
274286
const { error: deleteError } = await deleteEvent({
@@ -322,12 +334,23 @@ const Calendar = React.memo<CalendarProps>(
322334
console.error(restrictionError);
323335
}
324336
}
337+
338+
try {
339+
await updateTimetable({
340+
id: editingTimetableId,
341+
timetable_title: timetableTitle,
342+
}).unwrap();
343+
} catch (error) {
344+
setUpdateErrorMessage("You have made no changes to the timetable");
345+
setShowLoadingPage(false);
346+
return;
347+
}
325348
navigate("/home");
326349
};
327350

328351
return (
329352
<div>
330-
<h1 className="text-2xl flex flex-row justify-between font-medium tracking-tight mb-8">
353+
<h1 className="text-2xl flex flex-row justify-between font-medium tracking-tight mb-4">
331354
<div>{header}</div>
332355
<TimetableErrorDialog
333356
errorMessage={errorMessage}
@@ -381,29 +404,35 @@ const Calendar = React.memo<CalendarProps>(
381404
</DialogContent>
382405
</Dialog>
383406
) : (
384-
<div className="flex gap-2">
385-
{isChoosingSectionsManually &&
386-
!allOfferingSectionsHaveBeenSelected && (
387-
<p className="text-sm text-red-500 pr-2">
388-
Please select all LEC/TUT/PRA sections for your courses in
389-
order to save your timetable.
390-
</p>
391-
)}
407+
<div>
408+
<div className="flex gap-2">
409+
{isChoosingSectionsManually &&
410+
!allOfferingSectionsHaveBeenSelected && (
411+
<p className="text-sm text-red-500 pr-2">
412+
Please select all LEC/TUT/PRA sections for your courses in
413+
order to save your timetable.
414+
</p>
415+
)}
392416

393-
<Button
394-
size="sm"
395-
variant="outline"
396-
onClick={() => navigate("/home")}
397-
>
398-
Cancel Editing
399-
</Button>
400-
<Button
401-
size="sm"
402-
disabled={!allOfferingSectionsHaveBeenSelected}
403-
onClick={handleUpdate}
404-
>
405-
Update Timetable
406-
</Button>
417+
<Button
418+
size="sm"
419+
variant="outline"
420+
onClick={() => navigate("/home")}
421+
>
422+
Cancel Editing
423+
</Button>
424+
<Button
425+
size="sm"
426+
disabled={!allOfferingSectionsHaveBeenSelected}
427+
onClick={handleUpdate}
428+
>
429+
Update Timetable
430+
</Button>
431+
</div>
432+
<div className="mt-1 text-sm text-red-500 font-bold">
433+
{" "}
434+
{updateErrorMessage}{" "}
435+
</div>
407436
</div>
408437
)}
409438
</h1>

course-matrix/frontend/src/pages/TimetableBuilder/GeneratedCalendars.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from "@/utils/semester-utils";
1717
import { courseEventStyles } from "@/constants/calendarConstants";
1818
import { createEventModalPlugin } from "@schedule-x/event-modal";
19-
import React, { useRef, useState } from "react";
19+
import React, { useEffect, useRef, useState } from "react";
2020
import { useGetOfferingEventsQuery } from "@/api/offeringsApiSlice";
2121
import { Button } from "@/components/ui/button";
2222
import {
@@ -107,7 +107,7 @@ export const GeneratedCalendars = React.memo<GeneratedCalendarsProps>(
107107

108108
const { data: courseEventsData, isLoading } = useGetOfferingEventsQuery({
109109
offering_ids: currentTimetableOfferings
110-
.map((offering) => offering.id)
110+
?.map((offering) => offering.id)
111111
.join(","),
112112
semester_start_date: semesterStartDate,
113113
semester_end_date: semesterEndDate,
@@ -200,6 +200,10 @@ export const GeneratedCalendars = React.memo<GeneratedCalendarsProps>(
200200
isResponsive: false,
201201
});
202202

203+
useEffect(() => {
204+
setCurrentTimetableIndex(0);
205+
}, [generatedTimetables]);
206+
203207
return (
204208
<>
205209
<div>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function convertTimestampToLocaleTime(
2+
timestampz: string | number,
3+
): string {
4+
const date = new Date(timestampz);
5+
6+
return date.toLocaleString(); // Uses system's default locale
7+
}

0 commit comments

Comments
 (0)