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 ? ( + + ) : ( +
+ +
+ )} + + ); +}; + +export default MultiStudentCalendar; diff --git a/src/snapshots-app/client/bundles/components/submission/tabs/summary/SummaryTab.jsx b/src/snapshots-app/client/bundles/components/submission/tabs/summary/SummaryTab.jsx index 1c54f95..39fdd27 100644 --- a/src/snapshots-app/client/bundles/components/submission/tabs/summary/SummaryTab.jsx +++ b/src/snapshots-app/client/bundles/components/submission/tabs/summary/SummaryTab.jsx @@ -27,6 +27,7 @@ import StatisticsDashboard from "./StatisticsDashboard"; import InfoTooltip from "../../../common/InfoTooltip"; import BackupGanttPlot from "./BackupGanttPlot"; import BackupCalendarChart from "./BackupCalendarChart"; +import MultiStudentCalendar from "./MultiStudentCalendar"; const SCORE_HISTOGRAM_OPTIONS = { histogram: { @@ -288,6 +289,7 @@ function SummaryTab({}) { )} + diff --git a/src/snapshots-app/config/routes.rb b/src/snapshots-app/config/routes.rb index caca2c2..ea75360 100644 --- a/src/snapshots-app/config/routes.rb +++ b/src/snapshots-app/config/routes.rb @@ -21,7 +21,7 @@ get "backup_file_metadata/:course_id/:assignment_id/:user_id", to: "backup_file_metadata#show" get "summary_statistics/:course_id/:assignment_id/:user_id", to: "summary_statistics#show" get "problem_timeline/:course_id/:assignment_id/:user_id", to: "problem_timeline#show" - get "problem_calendar/:course_id/:assignment_id/:user_id", to: "problem_calendar#show" + get "problem_calendar/:course_id/:assignment_id(/:user_id)", to: "problem_calendar#show" namespace :debugging, defaults: { format: :json } do get "autograder_spam/:course_id/:assignment_id/:user_id", to: "autograder_spam#show"