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