From 973edf79f0c334c1eef97a09825aeab3a06b723a Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Tue, 14 Apr 2026 12:23:26 -0700 Subject: [PATCH 01/13] rails generate ProblemTimeline controller --- .../app/controllers/api/problem_timeline_controller.rb | 2 ++ .../app/helpers/api/problem_timeline_helper.rb | 2 ++ .../controllers/api/problem_timeline_controller_test.rb | 7 +++++++ 3 files changed, 11 insertions(+) create mode 100644 src/snapshots-app/app/controllers/api/problem_timeline_controller.rb create mode 100644 src/snapshots-app/app/helpers/api/problem_timeline_helper.rb create mode 100644 src/snapshots-app/test/controllers/api/problem_timeline_controller_test.rb 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..d5f40a7 --- /dev/null +++ b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb @@ -0,0 +1,2 @@ +class Api::ProblemTimelineController < ApplicationController +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/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 From 495b33b62f07d283587a266c4f25d6a287460904 Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Tue, 14 Apr 2026 16:50:55 -0700 Subject: [PATCH 02/13] Add problem_index column to assignment_problems table; add Problem 0 to list of problems in seeds.rb --- ...0260414193627_add_problem_index_to_assignment_problems.rb | 5 +++++ src/snapshots-app/db/schema.rb | 3 ++- src/snapshots-app/db/seeds.rb | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 src/snapshots-app/db/migrate/20260414193627_add_problem_index_to_assignment_problems.rb 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 From fcf37206ef9bf58b52035668ce8c5dc0468c2c36 Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Tue, 14 Apr 2026 16:51:04 -0700 Subject: [PATCH 03/13] Add problem timeline to routes.rb --- src/snapshots-app/config/routes.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/src/snapshots-app/config/routes.rb b/src/snapshots-app/config/routes.rb index 2adf320..38c2676 100644 --- a/src/snapshots-app/config/routes.rb +++ b/src/snapshots-app/config/routes.rb @@ -20,6 +20,7 @@ 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" namespace :debugging, defaults: { format: :json } do get "autograder_spam/:course_id/:assignment_id/:user_id", to: "autograder_spam#show" From 71f09abca25ea6626d0886d9f7a22d389bc7494c Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Tue, 14 Apr 2026 16:51:26 -0700 Subject: [PATCH 04/13] Specify foreign key for backup metadatum associations with analytics, grading, and unlock messages --- src/snapshots-app/app/models/backup_metadatum.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 5920428361b439b7095a705f4ef912037420bec2 Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Tue, 14 Apr 2026 16:51:36 -0700 Subject: [PATCH 05/13] Implement problem timeline controller --- .../api/problem_timeline_controller.rb | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb index d5f40a7..5aaa2db 100644 --- a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb +++ b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb @@ -1,2 +1,179 @@ +require 'active_support/time' + class Api::ProblemTimelineController < ApplicationController + SESSION_TIME_GAP_THRESHOLD = 15.minutes + + # 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) + 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 get_problem_to_backups(backups, problem_names) + # problem name to array of hashes where each hash represents relevant data from a single backup + # hash has: timestamp, label, color + result = problem_names.map { |name| [name, []] }.to_h + + backups.each do |backup| + Rails.logger.info("backup #{backup}") + if backup.grading_location.present? + backup.grading_message_questions.each do |gmq| + status = get_grading_backup_status(gmq) + result[gmq.question_display_name] << { :timestamp => Time.iso8601(backup.created) }.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 |name, unlock_message_cases| + status = get_unlocking_backup_status(unlock_message_cases) + result[name] << { :timestamp => Time.iso8601(backup.created) }.merge(status) + end + end + end + + result + end + + # This works assuming the array contains backups for the same problem + def get_sessions(backups) + # A session is defined as a series of backups where: + # 1. Consecutive timestamps are <= SESSION_TIME_GAP_THRESHOLD, and + # 2. All labels (and therefore colors) are the same within the session + + result = [] + curr_session = + { + startTime: backups[0][:timestamp], + endTime: backups[0][:timestamp], + label: backups[0][:label], + color: backups[0][:color], + numBackups: 1, + } + + backups.each_cons(2) do |a, b| + has_time_gap = b[:timestamp] - a[:timestamp] > SESSION_TIME_GAP_THRESHOLD + labels_differ = a[:label] != b[:label] + + if has_time_gap or labels_differ + result << curr_session + curr_session = + { + startTime: b[:timestamp], + endTime: b[:timestamp], + label: b[:label], + color: b[:color], + numBackups: 1, + } + else + curr_session[:endTime] = b[:timestamp] + curr_session[:numBackups] += 1 + end + end + + result << curr_session + result + end + + def get_problem_to_sessions(problem_to_backups) + # then have helper function to turn each array into sessions (use threshold) + # compute startTime and endTime, color + problem_to_backups.map { |name, backups| [name, get_sessions(backups)] }.to_h + end + + def get_timeline_data(problem_to_sessions, problem_name_to_index) + # then flatten all arrays and assign problemIndex using problem_name_to_index hash + # also combine label with numBackups into single label + result = [] + + problem_to_sessions.map do |problem_name, sessions| + problem_index = problem_name_to_index[problem_name] + sessions.each do |session| + result << { + problemIndex: problem_index, + startTime: session[:startTime], + endTime: session[:endTime], + label: "#{session[:label]} (#{session[:numBackups]} backup#{session[:numBackups] > 1 ? 's' : ''})", + color: session[:color] + } + end + end + + result + end + + 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) + + problem_to_backups = get_problem_to_backups(backups, problem_name_to_index.keys) + problem_to_sessions = get_problem_to_sessions(problem_to_backups) + timeline_data = get_timeline_data(problem_to_sessions, problem_name_to_index) + + render json: timeline_data, status: :ok + end end From 61517c09726b8f41944712a27b94a66d95432b3c Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Tue, 14 Apr 2026 23:07:08 -0700 Subject: [PATCH 06/13] Add numBackups as separate JSON field; fix error when problem has no backups --- .../app/controllers/api/problem_timeline_controller.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb index 5aaa2db..b2710f4 100644 --- a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb +++ b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb @@ -67,6 +67,10 @@ def get_problem_to_backups(backups, problem_names) # This works assuming the array contains backups for the same problem def get_sessions(backups) + if backups.empty? + return [] + end + # A session is defined as a series of backups where: # 1. Consecutive timestamps are <= SESSION_TIME_GAP_THRESHOLD, and # 2. All labels (and therefore colors) are the same within the session @@ -113,7 +117,6 @@ def get_problem_to_sessions(problem_to_backups) def get_timeline_data(problem_to_sessions, problem_name_to_index) # then flatten all arrays and assign problemIndex using problem_name_to_index hash - # also combine label with numBackups into single label result = [] problem_to_sessions.map do |problem_name, sessions| @@ -123,7 +126,8 @@ def get_timeline_data(problem_to_sessions, problem_name_to_index) problemIndex: problem_index, startTime: session[:startTime], endTime: session[:endTime], - label: "#{session[:label]} (#{session[:numBackups]} backup#{session[:numBackups] > 1 ? 's' : ''})", + label: session[:label], + numBackups: session[:numBackups], color: session[:color] } end From 37b7bd633a3f66e04e31852f4455eb6de6a255b9 Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Tue, 14 Apr 2026 23:07:50 -0700 Subject: [PATCH 07/13] Fetch problem timeline in frontend; misc Gantt plot updates --- .../tabs/summary/ProblemGanttPlot.jsx | 318 ++++++++++-------- 1 file changed, 171 insertions(+), 147 deletions(-) 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..f680ba5 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,164 +1,188 @@ -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"; +// TODO API endpoint to get problems +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", +]; + 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", - ]; + 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( + () => ({ + tooltip: { + formatter: (params) => { + const start = params.value[1]; + const end = params.value[2]; + const diff = end - start; + const numBackups = params.value[3]; - 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", - }, - ]; + // 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)); - // 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" }, - })); + // Build the duration string + let durationStr = ""; + if (hours > 0) durationStr += `${hours}h `; + if (minutes > 0 || hours > 0) durationStr += `${minutes}m `; + durationStr += `${seconds}s`; - 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}`; + 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} + `; + }, }, - }, - title: { text: "Problem Timeline", left: "center" }, - // Enable zooming and panning for high-frequency data - dataZoom: [ - { - type: "slider", - filterMode: "weakFilter", - showDataShadow: false, - bottom: 10, + 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 }, - ], - 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 + 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}", + }, + }, }, - }, - 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, - width: end[0] - start[0], - height: height, - }, - { - x: params.coordSys.x, - y: params.coordSys.y, - width: params.coordSys.width, - height: params.coordSys.height, - }, - ); + 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 - 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, + 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.numBackups, + ], + itemStyle: { color: item.color }, + })), }, - itemStyle: { opacity: 0.8 }, - encode: { x: [1, 2], y: 0 }, - data: data, - }, - ], - }; + ], + }), + [timelineData], + ); return ; }; From 3e570f2b1f10521d3b0d85902bed6a936a1d3370 Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Thu, 16 Apr 2026 08:10:45 -0700 Subject: [PATCH 08/13] WIP add gantt plot by backup index instead of absolute time because data is too sparse --- .../api/problem_timeline_controller.rb | 112 +++++----- .../tabs/summary/BackupGanttPlot.jsx | 195 ++++++++++++++++++ .../tabs/summary/ProblemGanttPlot.jsx | 9 +- .../submission/tabs/summary/SummaryTab.jsx | 3 + 4 files changed, 264 insertions(+), 55 deletions(-) create mode 100644 src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupGanttPlot.jsx diff --git a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb index b2710f4..3b346f4 100644 --- a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb +++ b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb @@ -1,6 +1,54 @@ 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) + + problem_to_backups = get_problem_to_backups(backups, problem_name_to_index.keys) + problem_to_sessions = get_problem_to_sessions(problem_to_backups) + timeline_data = get_timeline_data(problem_to_sessions, problem_name_to_index) + + render json: timeline_data, status: :ok + end + + private + SESSION_TIME_GAP_THRESHOLD = 15.minutes # color blind color palette from https://davidmathlogic.com/colorblind/#%23D81B60-%231E88E5-%23FFC107-%23004D40 @@ -31,12 +79,11 @@ def get_problem_to_backups(backups, problem_names) # hash has: timestamp, label, color result = problem_names.map { |name| [name, []] }.to_h - backups.each do |backup| - Rails.logger.info("backup #{backup}") + 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[gmq.question_display_name] << { :timestamp => Time.iso8601(backup.created) }.merge(status) + result[gmq.question_display_name] << { :timestamp => Time.iso8601(backup.created), :index => index }.merge(status) end end @@ -57,7 +104,7 @@ def get_problem_to_backups(backups, problem_names) umc_grouped_by_problem_name.each do |name, unlock_message_cases| status = get_unlocking_backup_status(unlock_message_cases) - result[name] << { :timestamp => Time.iso8601(backup.created) }.merge(status) + result[name] << { :timestamp => Time.iso8601(backup.created), :index => index }.merge(status) end end end @@ -76,13 +123,15 @@ def get_sessions(backups) # 2. All labels (and therefore colors) are the same within the session result = [] + # TODO redo sessions computation -- do not group by problem first bc then leads to weird overlap curr_session = { startTime: backups[0][:timestamp], endTime: backups[0][:timestamp], label: backups[0][:label], color: backups[0][:color], - numBackups: 1, + startIndex: backups[0][:index], + endIndex: backups[0][:index] + 1, } backups.each_cons(2) do |a, b| @@ -97,11 +146,12 @@ def get_sessions(backups) endTime: b[:timestamp], label: b[:label], color: b[:color], - numBackups: 1, + startIndex: b[:index], + endIndex: b[:index] + 1 } else curr_session[:endTime] = b[:timestamp] - curr_session[:numBackups] += 1 + curr_session[:endIndex] = b[:index] + 1 end end @@ -127,7 +177,8 @@ def get_timeline_data(problem_to_sessions, problem_name_to_index) startTime: session[:startTime], endTime: session[:endTime], label: session[:label], - numBackups: session[:numBackups], + startIndex: session[:startIndex], + endIndex: session[:endIndex], color: session[:color] } end @@ -135,49 +186,4 @@ def get_timeline_data(problem_to_sessions, problem_name_to_index) result end - - 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) - - problem_to_backups = get_problem_to_backups(backups, problem_name_to_index.keys) - problem_to_sessions = get_problem_to_sessions(problem_to_backups) - timeline_data = get_timeline_data(problem_to_sessions, problem_name_to_index) - - render json: timeline_data, status: :ok - end end 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..1f10528 --- /dev/null +++ b/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupGanttPlot.jsx @@ -0,0 +1,195 @@ +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", +]; + +// 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( + () => ({ + 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 ` +
+ ${params.name} +
+ 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 slider lives + }, + 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, + value: [ + item.problemIndex, + new Date(item.startTime).getTime(), + new Date(item.endTime).getTime(), + item.startIndex, + item.endIndex, + ], + itemStyle: { color: item.color }, + })), + }, + ], + }), + [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 f680ba5..3070f13 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 @@ -7,6 +7,7 @@ import * as echarts from "echarts"; // TODO API endpoint to get problems const PROBLEMS = [ + "Problem 0", "Problem 1", "Problem 2", "Problem 3", @@ -174,7 +175,7 @@ const ProblemGanttPlot = () => { item.problemIndex, new Date(item.startTime).getTime(), new Date(item.endTime).getTime(), - item.numBackups, + item.endIndex - item.startIndex, // number of backups ], itemStyle: { color: item.color }, })), @@ -184,7 +185,11 @@ const ProblemGanttPlot = () => { [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..b97c9f0 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 ProblemGanttPlot from "./ProblemGanttPlot"; // import ProblemTimeline from "./ProblemTimeline"; // import GanttPlot from "./GanttPlot"; import InfoTooltip from "../../../common/InfoTooltip"; +import BackupGanttPlot from "./BackupGanttPlot"; // TODO: move graphs from Submission Layout into here // TODO: lines added/removed rich git diff chart like encourse @@ -264,6 +265,8 @@ function SummaryTab({}) { + + ) : ( From 438494600192465d6f871cffbf72e39a55cbb335 Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Thu, 16 Apr 2026 08:46:55 -0700 Subject: [PATCH 09/13] Fix the way sessions are computed so that backup gantt plot works --- .../api/problem_timeline_controller.rb | 100 +++++++----------- .../tabs/summary/BackupGanttPlot.jsx | 32 +++++- .../submission/tabs/summary/SummaryTab.jsx | 2 +- 3 files changed, 67 insertions(+), 67 deletions(-) diff --git a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb index 3b346f4..6f96eb4 100644 --- a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb +++ b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb @@ -1,4 +1,4 @@ -require 'active_support/time' +require "active_support/time" # TODO unit tests class Api::ProblemTimelineController < ApplicationController @@ -26,7 +26,7 @@ def show return end - # TODO error if student doesn't have any backups for this assignment and course + # 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) @@ -40,17 +40,17 @@ def show ) .order(:created) - problem_to_backups = get_problem_to_backups(backups, problem_name_to_index.keys) - problem_to_sessions = get_problem_to_sessions(problem_to_backups) - timeline_data = get_timeline_data(problem_to_sessions, problem_name_to_index) + processed_backups = process_backups(backups, problem_name_to_index) + sessions = get_sessions(processed_backups) - render json: timeline_data, status: :ok + 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" @@ -58,39 +58,40 @@ def show 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 } + { label: "Correctness Tests Passed", color: DARK_GREEN } else - { :label => "Correctness Tests Failed", :color => PINK } + { 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 } + { label: "Unlocking Tests Passed", color: BLUE } else - { :label => "Unlocking Tests Failed", :color => YELLOW } + { label: "Unlocking Tests Failed", color: YELLOW } end end - def get_problem_to_backups(backups, problem_names) - # problem name to array of hashes where each hash represents relevant data from a single backup - # hash has: timestamp, label, color - result = problem_names.map { |name| [name, []] }.to_h + 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[gmq.question_display_name] << { :timestamp => Time.iso8601(backup.created), :index => index }.merge(status) + 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 + 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 @@ -102,9 +103,9 @@ def get_problem_to_backups(backups, problem_names) end end - umc_grouped_by_problem_name.each do |name, unlock_message_cases| + umc_grouped_by_problem_name.each do |problem_name, unlock_message_cases| status = get_unlocking_backup_status(unlock_message_cases) - result[name] << { :timestamp => Time.iso8601(backup.created), :index => index }.merge(status) + 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 @@ -112,33 +113,36 @@ def get_problem_to_backups(backups, problem_names) result end - # This works assuming the array contains backups for the same problem - def get_sessions(backups) - if backups.empty? + def get_sessions(processed_backups) + if processed_backups.empty? return [] end # A session is defined as a series of backups where: - # 1. Consecutive timestamps are <= SESSION_TIME_GAP_THRESHOLD, and - # 2. All labels (and therefore colors) are the same within the session + # 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 redo sessions computation -- do not group by problem first bc then leads to weird overlap + # TODO only convert to camel case at the end for consistency. generally fix naming conventions in all files... curr_session = { - startTime: backups[0][:timestamp], - endTime: backups[0][:timestamp], - label: backups[0][:label], - color: backups[0][:color], - startIndex: backups[0][:index], - endIndex: backups[0][:index] + 1, + 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], } - backups.each_cons(2) do |a, b| + 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 has_time_gap or labels_differ + if problems_differ or has_time_gap or labels_differ result << curr_session curr_session = { @@ -147,7 +151,9 @@ def get_sessions(backups) label: b[:label], color: b[:color], startIndex: b[:index], - endIndex: b[:index] + 1 + endIndex: b[:index] + 1, + problemName: b[:problem_name], + problemIndex: b[:problem_index] } else curr_session[:endTime] = b[:timestamp] @@ -158,32 +164,4 @@ def get_sessions(backups) result << curr_session result end - - def get_problem_to_sessions(problem_to_backups) - # then have helper function to turn each array into sessions (use threshold) - # compute startTime and endTime, color - problem_to_backups.map { |name, backups| [name, get_sessions(backups)] }.to_h - end - - def get_timeline_data(problem_to_sessions, problem_name_to_index) - # then flatten all arrays and assign problemIndex using problem_name_to_index hash - result = [] - - problem_to_sessions.map do |problem_name, sessions| - problem_index = problem_name_to_index[problem_name] - sessions.each do |session| - result << { - problemIndex: problem_index, - startTime: session[:startTime], - endTime: session[:endTime], - label: session[:label], - startIndex: session[:startIndex], - endIndex: session[:endIndex], - color: session[:color] - } - end - end - - result - end end 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 index 1f10528..f9b0a91 100644 --- a/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupGanttPlot.jsx +++ b/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupGanttPlot.jsx @@ -24,6 +24,18 @@ const PROBLEMS = [ "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 @@ -53,6 +65,12 @@ const BackupGanttPlot = () => { 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]; @@ -84,9 +102,6 @@ const BackupGanttPlot = () => { // TODO turn this into jsx instead of string? return ` -
- ${params.name} -
Duration: ${durationStr}
# of backups: ${numBackups}
Start: ${startTimeStr}
@@ -108,7 +123,7 @@ const BackupGanttPlot = () => { 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 + bottom: 80, // This creates the gap where the legend and slider live }, xAxis: { type: "value", @@ -169,7 +184,7 @@ const BackupGanttPlot = () => { encode: { x: [3, 4], y: 0 }, // Map to ECharts internal format data: timelineData.map((item) => ({ - name: item.label, + name: item.label, // This must match CATEGORIES names for interactivity value: [ item.problemIndex, new Date(item.startTime).getTime(), @@ -180,6 +195,13 @@ const BackupGanttPlot = () => { 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], 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 b97c9f0..b38f7ec 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 @@ -264,7 +264,7 @@ function SummaryTab({}) { - + {/* */} From 19a7ff8bb4fd7a627fa44975b6eddc8a7c19eacd Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Thu, 16 Apr 2026 09:03:50 -0700 Subject: [PATCH 10/13] Add dummy backup calendar chart --- .../tabs/summary/BackupCalendarChart.jsx | 100 ++++++++++++++++++ .../tabs/summary/ProblemGanttPlot.jsx | 2 + .../submission/tabs/summary/SummaryTab.jsx | 5 +- 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupCalendarChart.jsx 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..24692a7 --- /dev/null +++ b/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupCalendarChart.jsx @@ -0,0 +1,100 @@ +import React, { useMemo } from "react"; +import ReactECharts from "echarts-for-react"; +import * as echarts from "echarts"; + +const BackupCalendarChart = ({ + startDate = "2026-11-01", + endDate = "2026-11-30", +}) => { + // 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 = Math.random() > 0.5 ? Math.floor(Math.random() * 20) : 0; + data.push([dateString, count]); + } + return data; + }, [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 ( +
+ +
+ ); +}; + +export default BackupCalendarChart; 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 3070f13..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 @@ -5,6 +5,8 @@ import { useParams } from "react-router"; import ReactECharts from "echarts-for-react"; import * as echarts from "echarts"; +// TODO delete this if unused or figure out a better way to present the data + // TODO API endpoint to get problems const PROBLEMS = [ "Problem 0", 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 b38f7ec..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,11 +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 @@ -266,6 +267,8 @@ function SummaryTab({}) { {/* */} + + ) : ( From 47cc8528cb7a2de06b31e65932afe377a5be2a7e Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Thu, 16 Apr 2026 09:05:04 -0700 Subject: [PATCH 11/13] Add problem calendar route and empty controller files --- .../app/controllers/api/problem_calendar_controller.rb | 2 ++ .../app/helpers/api/problem_calendar_helper.rb | 2 ++ src/snapshots-app/config/routes.rb | 1 + .../controllers/api/problem_calendar_controller_test.rb | 7 +++++++ 4 files changed, 12 insertions(+) create mode 100644 src/snapshots-app/app/controllers/api/problem_calendar_controller.rb create mode 100644 src/snapshots-app/app/helpers/api/problem_calendar_helper.rb create mode 100644 src/snapshots-app/test/controllers/api/problem_calendar_controller_test.rb 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..6e68f76 --- /dev/null +++ b/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb @@ -0,0 +1,2 @@ +class Api::ProblemCalendarController < ApplicationController +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/config/routes.rb b/src/snapshots-app/config/routes.rb index 38c2676..61553f6 100644 --- a/src/snapshots-app/config/routes.rb +++ b/src/snapshots-app/config/routes.rb @@ -21,6 +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" 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/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 From 3a4468049f5c1e9905952b47826b4a6240024348 Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Thu, 16 Apr 2026 09:32:27 -0700 Subject: [PATCH 12/13] Implement problem calendar controller, fetch it in BackupCalendarChart --- .../api/problem_calendar_controller.rb | 37 ++++++++++ .../tabs/summary/BackupCalendarChart.jsx | 70 +++++++++++++++---- 2 files changed, 93 insertions(+), 14 deletions(-) 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 6e68f76..97926fc 100644 --- a/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb +++ b/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb @@ -1,2 +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/client/bundles/components/submission/tabs/summary/BackupCalendarChart.jsx b/src/snapshots-app/client/bundles/components/submission/tabs/summary/BackupCalendarChart.jsx index 24692a7..459cfcb 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 @@ -1,11 +1,47 @@ -import React, { useMemo } from "react"; +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]); -const BackupCalendarChart = ({ - startDate = "2026-11-01", - endDate = "2026-11-30", -}) => { // generate dummy data const calendarData = useMemo(() => { const data = []; @@ -22,11 +58,11 @@ const BackupCalendarChart = ({ "{yyyy}-{MM}-{dd}", false, ); - const count = Math.random() > 0.5 ? Math.floor(Math.random() * 20) : 0; + const count = rawCalendarData[dateString] || 0; data.push([dateString, count]); } return data; - }, [startDate, endDate]); + }, [rawCalendarData, startDate, endDate]); const option = useMemo( () => ({ @@ -87,13 +123,19 @@ const BackupCalendarChart = ({ }; return ( -
- -
+ <> + {calendarData.length === 0 || startDate === null || endDate === null ? ( + + ) : ( +
+ +
+ )} + ); }; From 274f492bc1e0687605dccc2a66bae55441460b82 Mon Sep 17 00:00:00 2001 From: Rebecca Dang Date: Thu, 16 Apr 2026 09:35:41 -0700 Subject: [PATCH 13/13] Run rubocop --- .../app/controllers/api/problem_calendar_controller.rb | 2 +- .../app/controllers/api/problem_timeline_controller.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 97926fc..a6f3737 100644 --- a/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb +++ b/src/snapshots-app/app/controllers/api/problem_calendar_controller.rb @@ -23,7 +23,7 @@ def show return end - # TODO error if student doesn't have any backups for this assignment and course + # TODO error if student doesn't have any backups for this assignment and course calendar_data = BackupMetadatum .where( diff --git a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb index 6f96eb4..8dc7b0e 100644 --- a/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb +++ b/src/snapshots-app/app/controllers/api/problem_timeline_controller.rb @@ -134,7 +134,7 @@ def get_sessions(processed_backups) startIndex: processed_backups[0][:index], endIndex: processed_backups[0][:index] + 1, problemName: processed_backups[0][:problem_name], - problemIndex: processed_backups[0][:problem_index], + problemIndex: processed_backups[0][:problem_index] } processed_backups.each_cons(2) do |a, b|