diff --git a/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb b/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb
index 9cebbbb..e9ce3cd 100644
--- a/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb
+++ b/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb
@@ -19,19 +19,34 @@ def show
student = course.students.find_by(id: user_id)
if student.nil?
- render json: { "error": "User ID #{user_id} not a student in course ID #{course_id}" }, status: :not_found
- return
- end
- calendar_data = BackupMetadatum
- .where(
- course: course.okpy_endpoint,
- assignment: assignment.okpy_endpoint,
- student_email: student.email
- )
- .group("date(created)")
- .count
+ query = BackupMetadatum
+ .where(course: course.okpy_endpoint, assignment: assignment.okpy_endpoint)
+ .joins("INNER JOIN users ON users.email = backup_metadata.student_email")
+ .group("DATE(backup_metadata.created)", "users.email", "users.first_name", "users.last_name")
+ .order("DATE(backup_metadata.created)", "users.first_name", "users.last_name")
+ .count
+
+ date_counts = Hash.new(0)
+ calendar_data = query.map do |(date, email, first_name, last_name), count|
+ local_index = date_counts[date]
+ date_counts[date] += 1
+ [ date, count, "#{first_name} #{last_name}", local_index ]
+ end
- render json: calendar_data, status: :ok
+ render json: calendar_data, status: :ok
+
+ else
+ calendar_data = BackupMetadatum
+ .where(
+ course: course.okpy_endpoint,
+ assignment: assignment.okpy_endpoint,
+ student_email: student.email
+ )
+ .group("date(created)")
+ .count
+
+ render json: calendar_data, status: :ok
+ end
end
end
diff --git a/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupCalendarChart.jsx b/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupCalendarChart.jsx
index 24f3b4d..d3282de 100644
--- a/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupCalendarChart.jsx
+++ b/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupCalendarChart.jsx
@@ -82,8 +82,8 @@ const BackupCalendarChart = () => {
cellSize: [40, "auto"],
yearLabel: { show: false },
dayLabel: {
- firstDay: 1,
- nameMap: "en",
+ firstDay: 0,
+ nameMap: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
},
monthLabel: {
position: "start", // Places month names to the left of the grid
diff --git a/src/snapshots-app/client/bundles/components/submission/tabs/summary/MultiStudentCalendar.jsx b/src/snapshots-app/client/bundles/components/submission/tabs/summary/MultiStudentCalendar.jsx
new file mode 100644
index 0000000..c256647
--- /dev/null
+++ b/src/snapshots-app/client/bundles/components/submission/tabs/summary/MultiStudentCalendar.jsx
@@ -0,0 +1,133 @@
+import React, { useEffect, useState } from "react";
+import ReactECharts from "echarts-for-react";
+import { useParams } from "react-router";
+import { CircularProgress } from "@mui/material";
+
+const MultiStudentCalendar = () => {
+ const routeParams = useParams();
+ const [calendarData, setCalendarData] = useState([]);
+
+ useEffect(() => {
+ fetch(
+ `/api/problem_calendar/${routeParams.courseId}/${routeParams.assignmentId}`,
+ {
+ method: "GET",
+ },
+ )
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then((responseData) => {
+ setCalendarData(responseData);
+ });
+ }, [routeParams]);
+
+ const releaseDate = "2025-10-10";
+ // TODO highlight checkpoint dates (not sure if this is possible with apache echarts in a non-janky way)
+ const checkpointOneDueDate = "2025-10-16";
+ const checkpointTwoDueDate = "2025-10-21";
+ const dueDate = "2025-10-23";
+
+ const option = {
+ title: {
+ text: "Project Worksession Heatmap (Class-Wide)",
+ left: "center",
+ },
+ tooltip: {
+ trigger: "item",
+ formatter: (params) => {
+ const [date, count, name] = params.data;
+ return `${name}
${date}: ${count} backups`;
+ },
+ },
+ // This controls the color of the dots based on the 'count' (index 1)
+ visualMap: {
+ min: 0,
+ max: Math.max(...calendarData.map((val) => val[1])),
+ calculable: true,
+ orient: "vertical",
+ right: "5%",
+ top: "center",
+ dimension: 1, // Point to the 'count' value in the data array
+ inRange: {
+ color: ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"],
+ },
+ },
+ calendar: {
+ orient: "vertical",
+ top: 100,
+ bottom: 40,
+ left: 80,
+ right: 150,
+ cellSize: ["auto", "auto"], // larger for jitter grid
+ range: [releaseDate, dueDate],
+ itemStyle: {
+ borderWidth: 0.5,
+ borderColor: "#ccc",
+ },
+ yearLabel: { show: false },
+ dayLabel: {
+ firstDay: 0,
+ nameMap: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
+ },
+ monthLabel: {
+ position: "start", // Places month names to the left of the grid
+ margin: 20,
+ },
+ },
+
+ series: [
+ {
+ type: "custom",
+ coordinateSystem: "calendar",
+ data: calendarData,
+
+ renderItem: (params, api) => {
+ const cellPoint = api.coord(api.value(0));
+ const studentIndex = api.value(3); // index of student for this day
+
+ // Define a grid inside the cell (e.g., 5x5 grid)
+ const columns = 5;
+ const spacing = 8; // Pixels between dots
+
+ const row = Math.floor(studentIndex / columns);
+ const col = studentIndex % columns;
+
+ // Center the grid within the cell
+ const startX = cellPoint[0] - (columns * spacing) / 2;
+ const startY = cellPoint[1] - (columns * spacing) / 2;
+
+ return {
+ type: "circle",
+ shape: {
+ cx: startX + col * spacing,
+ cy: startY + row * spacing,
+ r: 3, // Smaller radius for high density
+ },
+ style: api.style(),
+ };
+ },
+ },
+ ],
+ };
+
+ return (
+ <>
+ {calendarData.length === 0 ? (
+