Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
167 changes: 167 additions & 0 deletions src/snapshots-app/app/controllers/api/problem_timeline_controller.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/snapshots-app/app/helpers/api/problem_calendar_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module Api::ProblemCalendarHelper
end
2 changes: 2 additions & 0 deletions src/snapshots-app/app/helpers/api/problem_timeline_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module Api::ProblemTimelineHelper
end
6 changes: 3 additions & 3 deletions src/snapshots-app/app/models/backup_metadatum.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 ? (
<CircularProgress />
) : (
<div style={{ height: calculateHeight(), width: "100%" }}>
<ReactECharts
option={option}
style={{ height: "100%" }}
notMerge={true}
/>
</div>
)}
</>
);
};

export default BackupCalendarChart;
Loading
Loading