diff --git a/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb b/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb
new file mode 100644
index 0000000..a6f3737
--- /dev/null
+++ b/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb
@@ -0,0 +1,39 @@
+class Api::ProblemCalendarController < ApplicationController
+ def show
+ # Validate params
+ course_id = params[:course_id].to_i
+ assignment_id = params[:assignment_id].to_i
+ user_id = params[:user_id].to_i
+
+ course = Course.find_by(id: course_id)
+ if course.nil?
+ render json: { "error": "Course ID #{course_id} not found" }, status: :not_found
+ return
+ end
+
+ assignment = course.assignments.find_by(id: assignment_id)
+ if assignment.nil?
+ render json: { "error": "Assignment ID #{assignment_id} not found within course ID #{course_id}" }, status: :not_found
+ return
+ end
+
+ 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
+
+ # TODO error if student doesn't have any backups for this assignment and course
+
+ 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
diff --git a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb
new file mode 100644
index 0000000..8dc7b0e
--- /dev/null
+++ b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb
@@ -0,0 +1,167 @@
+require "active_support/time"
+
+# TODO unit tests
+class Api::ProblemTimelineController < ApplicationController
+ def show
+ # Validate params
+ course_id = params[:course_id].to_i
+ assignment_id = params[:assignment_id].to_i
+ user_id = params[:user_id].to_i
+
+ course = Course.find_by(id: course_id)
+ if course.nil?
+ render json: { "error": "Course ID #{course_id} not found" }, status: :not_found
+ return
+ end
+
+ assignment = course.assignments.find_by(id: assignment_id)
+ if assignment.nil?
+ render json: { "error": "Assignment ID #{assignment_id} not found within course ID #{course_id}" }, status: :not_found
+ return
+ end
+
+ 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
+
+ # TODO error if student doesn't have any backups for this assignment and course
+
+ problem_name_to_index = AssignmentProblem.where(assignment_id: assignment.id)
+ .pluck(:display_name, :problem_index)
+ .to_h
+
+ backups = BackupMetadatum
+ .where(
+ course: course.okpy_endpoint,
+ assignment: assignment.okpy_endpoint,
+ student_email: student.email
+ )
+ .order(:created)
+
+ processed_backups = process_backups(backups, problem_name_to_index)
+ sessions = get_sessions(processed_backups)
+
+ render json: sessions, status: :ok
+ end
+
+ private
+
+ SESSION_TIME_GAP_THRESHOLD = 15.minutes
+
+ # TODO move this logic into frontend since it's about styling
+ # color blind color palette from https://davidmathlogic.com/colorblind/#%23D81B60-%231E88E5-%23FFC107-%23004D40
+ PINK = "#D81B60"
+ BLUE = "#1E88E5"
+ YELLOW = "#FFC107"
+ DARK_GREEN = "#004D40"
+
+ def get_grading_backup_status(grading_message_question)
+ # TODO separate label into backup type (unlock vs. correctness) and status (passed boolean), then format label in frontend
+ if grading_message_question.failed == 0
+ { label: "Correctness Tests Passed", color: DARK_GREEN }
+ else
+ { label: "Correctness Tests Failed", color: PINK }
+ end
+ end
+
+ # This works assuming that the unlock_message_cases are all belonging to the same problem
+ def get_unlocking_backup_status(unlock_message_cases)
+ if unlock_message_cases.map { |umc| umc.correct }.all?
+ { label: "Unlocking Tests Passed", color: BLUE }
+ else
+ { label: "Unlocking Tests Failed", color: YELLOW }
+ end
+ end
+
+ def process_backups(backups, problem_name_to_index)
+ # returns an array of hashes where each hash represents relevant data from a single backup
+ # hash has: timestamp, label, color, index, problem_name
+ result = []
+
+ backups.each_with_index do |backup, index|
+ if backup.grading_location.present?
+ backup.grading_message_questions.each do |gmq|
+ status = get_grading_backup_status(gmq)
+ result << { timestamp: Time.iso8601(backup.created), index: index, problem_name: gmq.question_display_name, problem_index: problem_name_to_index[gmq.question_display_name] }.merge(status)
+ end
+ end
+
+ if backup.unlock_location.present?
+ # annoyingly, unlock.json doesn't have question display name so we fetch it from analytics.json
+ backup_problem_names = backup.analytics_message.question_display_names
+ umc_grouped_by_problem_name = backup_problem_names.map { |name| [ name, [] ] }.to_h
+ backup.unlock_message_cases.each do |umc|
+ # TODO dangerously (?) assume that unlock message cases
+ # will always match exactly one of the backup problem names
+ backup_problem_names.each do |name|
+ if umc.case_id.start_with?(name)
+ umc_grouped_by_problem_name[name] << umc
+ break
+ end
+ end
+ end
+
+ umc_grouped_by_problem_name.each do |problem_name, unlock_message_cases|
+ status = get_unlocking_backup_status(unlock_message_cases)
+ result << { timestamp: Time.iso8601(backup.created), index: index, problem_name: problem_name, problem_index: problem_name_to_index[problem_name] }.merge(status)
+ end
+ end
+ end
+
+ result
+ end
+
+ def get_sessions(processed_backups)
+ if processed_backups.empty?
+ return []
+ end
+
+ # A session is defined as a series of backups where:
+ # 1. All problems worked on are the same within the session, AND
+ # 2. All labels (and therefore colors) are the same within the session, AND
+ # 3. Consecutive timestamps are <= SESSION_TIME_GAP_THRESHOLD
+
+ result = []
+ # TODO only convert to camel case at the end for consistency. generally fix naming conventions in all files...
+ curr_session =
+ {
+ startTime: processed_backups[0][:timestamp],
+ endTime: processed_backups[0][:timestamp],
+ label: processed_backups[0][:label],
+ color: processed_backups[0][:color],
+ startIndex: processed_backups[0][:index],
+ endIndex: processed_backups[0][:index] + 1,
+ problemName: processed_backups[0][:problem_name],
+ problemIndex: processed_backups[0][:problem_index]
+ }
+
+ processed_backups.each_cons(2) do |a, b|
+ problems_differ = a[:problem_index] != b[:problem_index]
+ has_time_gap = b[:timestamp] - a[:timestamp] > SESSION_TIME_GAP_THRESHOLD
+ labels_differ = a[:label] != b[:label]
+
+ if problems_differ or has_time_gap or labels_differ
+ result << curr_session
+ curr_session =
+ {
+ startTime: b[:timestamp],
+ endTime: b[:timestamp],
+ label: b[:label],
+ color: b[:color],
+ startIndex: b[:index],
+ endIndex: b[:index] + 1,
+ problemName: b[:problem_name],
+ problemIndex: b[:problem_index]
+ }
+ else
+ curr_session[:endTime] = b[:timestamp]
+ curr_session[:endIndex] = b[:index] + 1
+ end
+ end
+
+ result << curr_session
+ result
+ end
+end
diff --git a/src/snapshots-app/app/helpers/api/problem_calendar_helper.rb b/src/snapshots-app/app/helpers/api/problem_calendar_helper.rb
new file mode 100644
index 0000000..a48ace6
--- /dev/null
+++ b/src/snapshots-app/app/helpers/api/problem_calendar_helper.rb
@@ -0,0 +1,2 @@
+module Api::ProblemCalendarHelper
+end
diff --git a/src/snapshots-app/app/helpers/api/problem_timeline_helper.rb b/src/snapshots-app/app/helpers/api/problem_timeline_helper.rb
new file mode 100644
index 0000000..d4b4c8d
--- /dev/null
+++ b/src/snapshots-app/app/helpers/api/problem_timeline_helper.rb
@@ -0,0 +1,2 @@
+module Api::ProblemTimelineHelper
+end
diff --git a/src/snapshots-app/app/models/backup_metadatum.rb b/src/snapshots-app/app/models/backup_metadatum.rb
index 564e569..773f78a 100644
--- a/src/snapshots-app/app/models/backup_metadatum.rb
+++ b/src/snapshots-app/app/models/backup_metadatum.rb
@@ -1,5 +1,5 @@
class BackupMetadatum < ApplicationRecord
- has_one :analytics_message
- has_many :grading_message_questions
- has_many :unlock_message_cases
+ has_one :analytics_message, foreign_key: :backup_id
+ has_many :grading_message_questions, foreign_key: :backup_id
+ has_many :unlock_message_cases, foreign_key: :backup_id
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
new file mode 100644
index 0000000..459cfcb
--- /dev/null
+++ b/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupCalendarChart.jsx
@@ -0,0 +1,142 @@
+import React, { useMemo, useState, useEffect } from "react";
+import { useParams } from "react-router";
+import ReactECharts from "echarts-for-react";
+import * as echarts from "echarts";
+import { CircularProgress } from "@mui/material";
+
+// TODO don't hardcode release, checkpoint 1, checkpoint 2, due date
+// const highlightedDates = ["2025-10-27", "2025-11-05", "2025-11-14", "2025-11-24", "2025-11-25"];
+
+// TODO rename charts for consistency with titles in frontend
+const BackupCalendarChart = () => {
+ const routeParams = useParams();
+ const [rawCalendarData, setRawCalendarData] = useState([]);
+
+ useEffect(() => {
+ fetch(
+ `/api/problem_calendar/${routeParams.courseId}/${routeParams.assignmentId}/${routeParams.studentId}`,
+ {
+ method: "GET",
+ },
+ )
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then((responseData) => {
+ setRawCalendarData(responseData);
+ });
+ }, [routeParams]);
+
+ const startDate = useMemo(() => {
+ const keys = Object.keys(rawCalendarData);
+ if (keys.length === 0) return null;
+ return Math.min(...keys.map((date) => new Date(date).getTime()));
+ }, [rawCalendarData]);
+
+ const endDate = useMemo(() => {
+ const keys = Object.keys(rawCalendarData);
+ if (keys.length === 0) return null;
+ return Math.max(...keys.map((date) => new Date(date).getTime()));
+ }, [rawCalendarData]);
+
+ // generate dummy data
+ const calendarData = useMemo(() => {
+ const data = [];
+ const start = new Date(startDate);
+ const end = new Date(endDate);
+
+ for (
+ let time = start.getTime();
+ time <= end.getTime();
+ time += 24 * 60 * 60 * 1000
+ ) {
+ const dateString = echarts.time.format(
+ new Date(time),
+ "{yyyy}-{MM}-{dd}",
+ false,
+ );
+ const count = rawCalendarData[dateString] || 0;
+ data.push([dateString, count]);
+ }
+ return data;
+ }, [rawCalendarData, startDate, endDate]);
+
+ const option = useMemo(
+ () => ({
+ title: { text: "Project Worksession Heatmap", left: "center" },
+ tooltip: {
+ formatter: (params) => `${params.value[0]}: ${params.value[1]} backups`,
+ },
+ visualMap: {
+ min: 0,
+ max: Math.max(...calendarData.map((val) => val[1])),
+ calculable: true,
+ orient: "vertical",
+ right: "5%",
+ top: "center",
+ // Standard GitHub-style greens
+ inRange: {
+ color: ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"],
+ },
+ },
+ calendar: {
+ top: 100,
+ bottom: 40,
+ left: 80,
+ right: 150,
+ orient: "vertical",
+ range: [startDate, endDate],
+ cellSize: [40, "auto"],
+ yearLabel: { show: false },
+ dayLabel: {
+ firstDay: 1,
+ nameMap: "en",
+ },
+ monthLabel: {
+ position: "start", // Places month names to the left of the grid
+ margin: 20,
+ },
+ itemStyle: {
+ borderWidth: 2,
+ borderColor: "#fff",
+ },
+ },
+ series: [
+ {
+ type: "heatmap",
+ coordinateSystem: "calendar",
+ data: calendarData,
+ },
+ ],
+ }),
+ [calendarData, startDate, endDate],
+ );
+
+ // Adjust container height dynamically based on the range
+ const calculateHeight = () => {
+ const months =
+ new Date(endDate).getMonth() - new Date(startDate).getMonth() + 1;
+ return Math.max(months * 180, 400) + "px";
+ };
+
+ return (
+ <>
+ {calendarData.length === 0 || startDate === null || endDate === null ? (
+
+ ) : (
+
+
+
+ )}
+ >
+ );
+};
+
+export default BackupCalendarChart;
diff --git a/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupGanttPlot.jsx b/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupGanttPlot.jsx
new file mode 100644
index 0000000..f9b0a91
--- /dev/null
+++ b/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupGanttPlot.jsx
@@ -0,0 +1,217 @@
+import React, { useState, useMemo, useEffect } from "react";
+
+import { useParams } from "react-router";
+
+import ReactECharts from "echarts-for-react";
+import * as echarts from "echarts";
+
+// TODO API endpoint to get problems
+const PROBLEMS = [
+ "Problem 0",
+ "Problem 1",
+ "Problem 2",
+ "Problem 3",
+ "Problem 4",
+ "Problem 5",
+ "Problem 6",
+ "Problem 7",
+ "Problem 8a",
+ "Problem 8b",
+ "Problem 8c",
+ "Problem 9",
+ "Problem 10",
+ "Problem 11",
+ "Problem 12",
+];
+
+const PINK = "#D81B60";
+const BLUE = "#1E88E5";
+const YELLOW = "#FFC107";
+const DARK_GREEN = "#004D40";
+
+const CATEGORIES = [
+ { name: "Correctness Tests Passed", color: DARK_GREEN },
+ { name: "Correctness Tests Failed", color: PINK },
+ { name: "Unlocking Tests Passed", color: BLUE },
+ { name: "Unlocking Tests Failed", color: YELLOW },
+];
+
+// TODO make this more DRY and name it better
+// TODO fetch problem names and problem timeline once
+// TODO add comments explaining options
+// TODO legend instead of label
+// TODO button to auto jump to zoom
+const BackupGanttPlot = () => {
+ const [timelineData, setTimelineData] = useState([]);
+ const routeParams = useParams();
+
+ useEffect(() => {
+ fetch(
+ `/api/problem_timeline/${routeParams.courseId}/${routeParams.assignmentId}/${routeParams.studentId}`,
+ {
+ method: "GET",
+ },
+ )
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then((responseData) => {
+ setTimelineData(responseData);
+ });
+ }, [routeParams]);
+
+ const option = useMemo(
+ () => ({
+ legend: {
+ data: CATEGORIES.map((c) => c.name),
+ bottom: 40, // Place it below the chart
+ selectedMode: false,
+ itemGap: 40,
+ },
+ tooltip: {
+ formatter: (params) => {
+ const start = params.value[1];
+ const end = params.value[2];
+ const diff = end - start;
+ const numBackups = params.value[4] - params.value[3];
+
+ // Calculate time units
+ const seconds = Math.floor((diff / 1000) % 60);
+ const minutes = Math.floor((diff / (1000 * 60)) % 60);
+ const hours = Math.floor(diff / (1000 * 60 * 60));
+
+ // Build the duration string
+ let durationStr = "";
+ if (hours > 0) durationStr += `${hours}h `;
+ if (minutes > 0 || hours > 0) durationStr += `${minutes}m `;
+ durationStr += `${seconds}s`;
+
+ const startTimeStr = echarts.time.format(
+ start,
+ "{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",
+ false,
+ );
+ const endTimeStr = echarts.time.format(
+ end,
+ "{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",
+ false,
+ );
+
+ // TODO turn this into jsx instead of string?
+ return `
+ Duration: ${durationStr}
+ # of backups: ${numBackups}
+ Start: ${startTimeStr}
+ End: ${endTimeStr}
+ `;
+ },
+ },
+ title: { text: "Problem Timeline", left: "center" },
+ // Enable zooming and panning for high-frequency data
+ dataZoom: [
+ {
+ type: "slider",
+ filterMode: "weakFilter",
+ showDataShadow: false,
+ bottom: 10,
+ },
+ ],
+ grid: {
+ top: 80, // Space for the title and top X-axis
+ left: 100, // Space for problem labels
+ right: 50, // Padding on the right
+ bottom: 80, // This creates the gap where the legend and slider live
+ },
+ xAxis: {
+ type: "value",
+ name: "Backup Index",
+ position: "top",
+ splitLine: { show: true },
+ min: "dataMin",
+ max: "dataMax",
+ },
+ yAxis: {
+ data: PROBLEMS,
+ inverse: true,
+ splitLine: { show: true },
+ axisLabel: {
+ interval: 0, // Force show ALL problems
+ },
+ },
+ // TODO(stretch): add another series to compare the current student with the average gantt plot of all other students
+ series: [
+ {
+ type: "custom",
+ renderItem: (params, api) => {
+ const categoryIndex = api.value(0);
+ const start = api.coord([api.value(3), categoryIndex]);
+ const end = api.coord([api.value(4), categoryIndex]);
+ const height = api.size([0, 1])[1] * 0.6; // Bar height is 60% of row height
+
+ const rectShape = echarts.graphic.clipRectByRect(
+ {
+ x: start[0],
+ y: start[1] - height / 2,
+ // enforce min width of 5 pixels so that graph doesn't look blank
+ // width: Math.max(end[0] - start[0], 5),
+ width: end[0] - start[0],
+ height: height,
+ },
+ {
+ x: params.coordSys.x,
+ y: params.coordSys.y,
+ width: params.coordSys.width,
+ height: params.coordSys.height,
+ },
+ );
+
+ return (
+ rectShape && {
+ type: "rect",
+ transition: ["shape"],
+ shape: rectShape,
+ style: {
+ fill: api.visual("color"), // Gets the color from the itemStyle in your data
+ opacity: 0.8,
+ },
+ }
+ );
+ },
+ itemStyle: { opacity: 0.8 },
+ encode: { x: [3, 4], y: 0 },
+ // Map to ECharts internal format
+ data: timelineData.map((item) => ({
+ name: item.label, // This must match CATEGORIES names for interactivity
+ value: [
+ item.problemIndex,
+ new Date(item.startTime).getTime(),
+ new Date(item.endTime).getTime(),
+ item.startIndex,
+ item.endIndex,
+ ],
+ itemStyle: { color: item.color },
+ })),
+ },
+ ...CATEGORIES.map((cat) => ({
+ name: cat.name,
+ type: "bar", // Can be anything, bar works well
+ itemStyle: { color: cat.color },
+
+ // data: [], // Empty so it doesn't render bars
+ })),
+ ],
+ }),
+ [timelineData],
+ );
+
+ return (
+
+
+
+ );
+};
+
+export default BackupGanttPlot;
diff --git a/src/snapshots-app/client/bundles/components/submission/tabs/summary/ProblemGanttPlot.jsx b/src/snapshots-app/client/bundles/components/submission/tabs/summary/ProblemGanttPlot.jsx
index 39f5a6d..c22ecb0 100644
--- a/src/snapshots-app/client/bundles/components/submission/tabs/summary/ProblemGanttPlot.jsx
+++ b/src/snapshots-app/client/bundles/components/submission/tabs/summary/ProblemGanttPlot.jsx
@@ -1,166 +1,197 @@
-import React from "react";
+import React, { useState, useMemo, useEffect } from "react";
+
+import { useParams } from "react-router";
import ReactECharts from "echarts-for-react";
import * as echarts from "echarts";
-const ProblemGanttPlot = () => {
- const problems = [
- "Problem 1",
- "Problem 2",
- "Problem 3",
- "Problem 4",
- "Problem 5",
- "Problem 6",
- "Problem 7",
- "Problem 8a",
- "Problem 8b",
- "Problem 8c",
- "Problem 9",
- "Problem 10",
- "Problem 11",
- "Problem 12",
- ];
+// TODO delete this if unused or figure out a better way to present the data
- const timelineData = [
- {
- problemIndex: 0,
- startTime: "2026-04-14T10:00:00Z",
- endTime: "2026-04-14T10:00:15Z",
- label: "Session 1",
- },
- {
- problemIndex: 0,
- startTime: "2026-04-14T10:00:25Z",
- endTime: "2026-04-14T10:00:45Z",
- label: "Session 2",
- },
- {
- problemIndex: 1,
- startTime: "2026-04-14T10:00:05Z",
- endTime: "2026-04-14T10:00:30Z",
- label: "Session 3",
- },
- {
- problemIndex: 7,
- startTime: "2026-04-14T10:01:00Z",
- endTime: "2026-04-14T10:01:20Z",
- label: "Session 4",
- },
- {
- problemIndex: 8,
- startTime: "2026-04-14T10:01:05Z",
- endTime: "2026-04-14T10:01:40Z",
- label: "Session 5",
- },
- {
- problemIndex: 9,
- startTime: "2026-04-14T10:01:15Z",
- endTime: "2026-04-14T10:01:55Z",
- label: "Session 6",
- },
- ];
+// TODO API endpoint to get problems
+const PROBLEMS = [
+ "Problem 0",
+ "Problem 1",
+ "Problem 2",
+ "Problem 3",
+ "Problem 4",
+ "Problem 5",
+ "Problem 6",
+ "Problem 7",
+ "Problem 8a",
+ "Problem 8b",
+ "Problem 8c",
+ "Problem 9",
+ "Problem 10",
+ "Problem 11",
+ "Problem 12",
+];
- // Map to ECharts internal format
- const data = timelineData.map((item) => ({
- name: item.label,
- value: [
- item.problemIndex,
- new Date(item.startTime).getTime(),
- new Date(item.endTime).getTime(),
- ],
- itemStyle: { color: "#5470c6" },
- }));
+const ProblemGanttPlot = () => {
+ const [timelineData, setTimelineData] = useState([]);
+ const routeParams = useParams();
- const option = {
- tooltip: {
- formatter: (params) => {
- // value[1] is start, value[2] is end
- const start = echarts.time.format(
- params.value[1],
- "{HH}:{mm}:{ss}",
- false,
- );
- const end = echarts.time.format(
- params.value[2],
- "{HH}:{mm}:{ss}",
- false,
- );
- return `${params.name}
${start} - ${end}`;
- },
- },
- title: { text: "Problem Timeline", left: "center" },
- // Enable zooming and panning for high-frequency data
- dataZoom: [
+ useEffect(() => {
+ fetch(
+ `/api/problem_timeline/${routeParams.courseId}/${routeParams.assignmentId}/${routeParams.studentId}`,
{
- type: "slider",
- filterMode: "weakFilter",
- showDataShadow: false,
- bottom: 10,
- },
- ],
- grid: {
- top: 80, // Space for the title and top X-axis
- left: 100, // Space for problem labels
- right: 50, // Padding on the right
- bottom: 80, // This creates the gap where the slider lives
- },
- xAxis: {
- type: "time",
- position: "top",
- splitLine: { show: true },
- axisLabel: { formatter: "{HH}:{mm}:{ss}" },
- },
- yAxis: {
- data: problems,
- splitLine: { show: true },
- axisLabel: {
- interval: 0, // Force show ALL problems
+ method: "GET",
},
- },
- series: [
- {
- type: "custom",
- renderItem: (params, api) => {
- const categoryIndex = api.value(0);
- const start = api.coord([api.value(1), categoryIndex]);
- const end = api.coord([api.value(2), categoryIndex]);
- const height = api.size([0, 1])[1] * 0.6; // Bar height is 60% of row height
+ )
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then((responseData) => {
+ setTimelineData(responseData);
+ });
+ }, [routeParams]);
- const rectShape = echarts.graphic.clipRectByRect(
- {
- x: start[0],
- y: start[1] - height / 2,
- width: end[0] - start[0],
- height: height,
- },
- {
- x: params.coordSys.x,
- y: params.coordSys.y,
- width: params.coordSys.width,
- height: params.coordSys.height,
- },
- );
+ const option = useMemo(
+ () => ({
+ tooltip: {
+ formatter: (params) => {
+ const start = params.value[1];
+ const end = params.value[2];
+ const diff = end - start;
+ const numBackups = params.value[3];
- return (
- rectShape && {
- type: "rect",
- transition: ["shape"],
- shape: rectShape,
- style: {
- fill: api.visual("color"), // Gets the color from the itemStyle in your data
- opacity: 0.8,
- },
- }
+ // Calculate time units
+ const seconds = Math.floor((diff / 1000) % 60);
+ const minutes = Math.floor((diff / (1000 * 60)) % 60);
+ const hours = Math.floor(diff / (1000 * 60 * 60));
+
+ // Build the duration string
+ let durationStr = "";
+ if (hours > 0) durationStr += `${hours}h `;
+ if (minutes > 0 || hours > 0) durationStr += `${minutes}m `;
+ durationStr += `${seconds}s`;
+
+ const startTimeStr = echarts.time.format(
+ start,
+ "{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",
+ false,
+ );
+ const endTimeStr = echarts.time.format(
+ end,
+ "{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",
+ false,
);
+
+ // TODO turn this into jsx instead of string?
+ return `
+
+ ${params.name}
+
+ Duration: ${durationStr}
+ # of backups: ${numBackups}
+ Start: ${startTimeStr}
+ End: ${endTimeStr}
+ `;
},
- itemStyle: { opacity: 0.8 },
- encode: { x: [1, 2], y: 0 },
- data: data,
},
- ],
- };
+ title: { text: "Problem Timeline", left: "center" },
+ // Enable zooming and panning for high-frequency data
+ dataZoom: [
+ {
+ type: "slider",
+ filterMode: "weakFilter",
+ showDataShadow: false,
+ bottom: 10,
+ },
+ ],
+ grid: {
+ top: 80, // Space for the title and top X-axis
+ left: 100, // Space for problem labels
+ right: 50, // Padding on the right
+ bottom: 80, // This creates the gap where the slider lives
+ },
+ xAxis: {
+ type: "time",
+ position: "top",
+ splitLine: { show: true },
+ axisLabel: {
+ hideOverlap: true,
+ formatter: {
+ day: "{MM}-{dd}",
+ hour: "{HH}:{mm}",
+ minute: "{HH}:{mm}",
+ second: "{HH}:{mm}:{ss}",
+ none: "{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",
+ },
+ },
+ },
+ yAxis: {
+ data: PROBLEMS,
+ inverse: true,
+ splitLine: { show: true },
+ axisLabel: {
+ interval: 0, // Force show ALL problems
+ },
+ },
+ // TODO(stretch): add another series to compare the current student with the average gantt plot of all other students
+ series: [
+ {
+ type: "custom",
+ renderItem: (params, api) => {
+ const categoryIndex = api.value(0);
+ const start = api.coord([api.value(1), categoryIndex]);
+ const end = api.coord([api.value(2), categoryIndex]);
+ const height = api.size([0, 1])[1] * 0.6; // Bar height is 60% of row height
+
+ const rectShape = echarts.graphic.clipRectByRect(
+ {
+ x: start[0],
+ y: start[1] - height / 2,
+ // enforce min width of 5 pixels so that graph doesn't look blank
+ width: Math.max(end[0] - start[0], 5),
+ height: height,
+ },
+ {
+ x: params.coordSys.x,
+ y: params.coordSys.y,
+ width: params.coordSys.width,
+ height: params.coordSys.height,
+ },
+ );
+
+ return (
+ rectShape && {
+ type: "rect",
+ transition: ["shape"],
+ shape: rectShape,
+ style: {
+ fill: api.visual("color"), // Gets the color from the itemStyle in your data
+ opacity: 0.8,
+ },
+ }
+ );
+ },
+ itemStyle: { opacity: 0.8 },
+ encode: { x: [1, 2], y: 0 },
+ // Map to ECharts internal format
+ data: timelineData.map((item) => ({
+ name: item.label,
+ value: [
+ item.problemIndex,
+ new Date(item.startTime).getTime(),
+ new Date(item.endTime).getTime(),
+ item.endIndex - item.startIndex, // number of backups
+ ],
+ itemStyle: { color: item.color },
+ })),
+ },
+ ],
+ }),
+ [timelineData],
+ );
- return ;
+ return (
+
+
+
+ );
};
export default ProblemGanttPlot;
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 7a27e46..e849696 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
@@ -23,10 +23,12 @@ import {
import { useParams } from "react-router";
import StatisticsDashboard from "./StatisticsDashboard";
-import ProblemGanttPlot from "./ProblemGanttPlot";
+// import ProblemGanttPlot from "./ProblemGanttPlot";
// import ProblemTimeline from "./ProblemTimeline";
// import GanttPlot from "./GanttPlot";
import InfoTooltip from "../../../common/InfoTooltip";
+import BackupGanttPlot from "./BackupGanttPlot";
+import BackupCalendarChart from "./BackupCalendarChart";
// TODO: move graphs from Submission Layout into here
// TODO: lines added/removed rich git diff chart like encourse
@@ -263,7 +265,11 @@ function SummaryTab({}) {
-
+ {/* */}
+
+
+
+
>
) : (
diff --git a/src/snapshots-app/config/routes.rb b/src/snapshots-app/config/routes.rb
index 2adf320..61553f6 100644
--- a/src/snapshots-app/config/routes.rb
+++ b/src/snapshots-app/config/routes.rb
@@ -20,6 +20,8 @@
get "lint_errors", to: "lint_errors#show"
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"
namespace :debugging, defaults: { format: :json } do
get "autograder_spam/:course_id/:assignment_id/:user_id", to: "autograder_spam#show"
diff --git a/src/snapshots-app/db/migrate/20260414193627_add_problem_index_to_assignment_problems.rb b/src/snapshots-app/db/migrate/20260414193627_add_problem_index_to_assignment_problems.rb
new file mode 100644
index 0000000..e7fc6bc
--- /dev/null
+++ b/src/snapshots-app/db/migrate/20260414193627_add_problem_index_to_assignment_problems.rb
@@ -0,0 +1,5 @@
+class AddProblemIndexToAssignmentProblems < ActiveRecord::Migration[8.0]
+ def change
+ add_column :assignment_problems, :problem_index, :integer
+ end
+end
diff --git a/src/snapshots-app/db/schema.rb b/src/snapshots-app/db/schema.rb
index aefb48d..efc125d 100644
--- a/src/snapshots-app/db/schema.rb
+++ b/src/snapshots-app/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_11_18_234456) do
+ActiveRecord::Schema[8.0].define(version: 2026_04_14_193627) do
create_table "analytics_messages", primary_key: "backup_id", id: :string, force: :cascade do |t|
t.boolean "unlock", null: false
t.json "question_cli_names"
@@ -27,6 +27,7 @@
create_table "assignment_problems", force: :cascade do |t|
t.integer "assignment_id", null: false
t.string "display_name", null: false
+ t.integer "problem_index"
t.index ["assignment_id"], name: "index_assignment_problems_on_assignment_id"
end
diff --git a/src/snapshots-app/db/seeds.rb b/src/snapshots-app/db/seeds.rb
index 090f99a..a964019 100644
--- a/src/snapshots-app/db/seeds.rb
+++ b/src/snapshots-app/db/seeds.rb
@@ -112,6 +112,7 @@
okpy_endpoint: "ants",
files: [ "ants.py" ],
problems: [
+ "Problem 0", # only unlocking tests
"Problem 1",
"Problem 2",
"Problem 3",
@@ -211,8 +212,8 @@ def create_fake_student(hash)
AssignmentFile.create!(assignment_id: assignment_record.id, file_name: file_name)
end
- assignment[:problems].each do |problem_name|
- AssignmentProblem.create!(assignment_id: assignment_record.id, display_name: problem_name)
+ assignment[:problems].each_with_index do |problem_name, index|
+ AssignmentProblem.create!(assignment_id: assignment_record.id, display_name: problem_name, problem_index: index)
end
end
end
diff --git a/src/snapshots-app/test/controllers/api/problem_calendar_controller_test.rb b/src/snapshots-app/test/controllers/api/problem_calendar_controller_test.rb
new file mode 100644
index 0000000..8581667
--- /dev/null
+++ b/src/snapshots-app/test/controllers/api/problem_calendar_controller_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class Api::ProblemCalendarControllerTest < ActionDispatch::IntegrationTest
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/src/snapshots-app/test/controllers/api/problem_timeline_controller_test.rb b/src/snapshots-app/test/controllers/api/problem_timeline_controller_test.rb
new file mode 100644
index 0000000..d124223
--- /dev/null
+++ b/src/snapshots-app/test/controllers/api/problem_timeline_controller_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class Api::ProblemTimelineControllerTest < ActionDispatch::IntegrationTest
+ # test "the truth" do
+ # assert true
+ # end
+end