Skip to content

Commit 3b88879

Browse files
committed
Merge branch 'main' into add-graphs-summary
2 parents f27a855 + 96bd029 commit 3b88879

15 files changed

Lines changed: 787 additions & 160 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
class Api::ProblemCalendarController < ApplicationController
2+
def show
3+
# Validate params
4+
course_id = params[:course_id].to_i
5+
assignment_id = params[:assignment_id].to_i
6+
user_id = params[:user_id].to_i
7+
8+
course = Course.find_by(id: course_id)
9+
if course.nil?
10+
render json: { "error": "Course ID #{course_id} not found" }, status: :not_found
11+
return
12+
end
13+
14+
assignment = course.assignments.find_by(id: assignment_id)
15+
if assignment.nil?
16+
render json: { "error": "Assignment ID #{assignment_id} not found within course ID #{course_id}" }, status: :not_found
17+
return
18+
end
19+
20+
student = course.students.find_by(id: user_id)
21+
if student.nil?
22+
render json: { "error": "User ID #{user_id} not a student in course ID #{course_id}" }, status: :not_found
23+
return
24+
end
25+
26+
# TODO error if student doesn't have any backups for this assignment and course
27+
28+
calendar_data = BackupMetadatum
29+
.where(
30+
course: course.okpy_endpoint,
31+
assignment: assignment.okpy_endpoint,
32+
student_email: student.email
33+
)
34+
.group("date(created)")
35+
.count
36+
37+
render json: calendar_data, status: :ok
38+
end
39+
end
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
require "active_support/time"
2+
3+
# TODO unit tests
4+
class Api::ProblemTimelineController < ApplicationController
5+
def show
6+
# Validate params
7+
course_id = params[:course_id].to_i
8+
assignment_id = params[:assignment_id].to_i
9+
user_id = params[:user_id].to_i
10+
11+
course = Course.find_by(id: course_id)
12+
if course.nil?
13+
render json: { "error": "Course ID #{course_id} not found" }, status: :not_found
14+
return
15+
end
16+
17+
assignment = course.assignments.find_by(id: assignment_id)
18+
if assignment.nil?
19+
render json: { "error": "Assignment ID #{assignment_id} not found within course ID #{course_id}" }, status: :not_found
20+
return
21+
end
22+
23+
student = course.students.find_by(id: user_id)
24+
if student.nil?
25+
render json: { "error": "User ID #{user_id} not a student in course ID #{course_id}" }, status: :not_found
26+
return
27+
end
28+
29+
# TODO error if student doesn't have any backups for this assignment and course
30+
31+
problem_name_to_index = AssignmentProblem.where(assignment_id: assignment.id)
32+
.pluck(:display_name, :problem_index)
33+
.to_h
34+
35+
backups = BackupMetadatum
36+
.where(
37+
course: course.okpy_endpoint,
38+
assignment: assignment.okpy_endpoint,
39+
student_email: student.email
40+
)
41+
.order(:created)
42+
43+
processed_backups = process_backups(backups, problem_name_to_index)
44+
sessions = get_sessions(processed_backups)
45+
46+
render json: sessions, status: :ok
47+
end
48+
49+
private
50+
51+
SESSION_TIME_GAP_THRESHOLD = 15.minutes
52+
53+
# TODO move this logic into frontend since it's about styling
54+
# color blind color palette from https://davidmathlogic.com/colorblind/#%23D81B60-%231E88E5-%23FFC107-%23004D40
55+
PINK = "#D81B60"
56+
BLUE = "#1E88E5"
57+
YELLOW = "#FFC107"
58+
DARK_GREEN = "#004D40"
59+
60+
def get_grading_backup_status(grading_message_question)
61+
# TODO separate label into backup type (unlock vs. correctness) and status (passed boolean), then format label in frontend
62+
if grading_message_question.failed == 0
63+
{ label: "Correctness Tests Passed", color: DARK_GREEN }
64+
else
65+
{ label: "Correctness Tests Failed", color: PINK }
66+
end
67+
end
68+
69+
# This works assuming that the unlock_message_cases are all belonging to the same problem
70+
def get_unlocking_backup_status(unlock_message_cases)
71+
if unlock_message_cases.map { |umc| umc.correct }.all?
72+
{ label: "Unlocking Tests Passed", color: BLUE }
73+
else
74+
{ label: "Unlocking Tests Failed", color: YELLOW }
75+
end
76+
end
77+
78+
def process_backups(backups, problem_name_to_index)
79+
# returns an array of hashes where each hash represents relevant data from a single backup
80+
# hash has: timestamp, label, color, index, problem_name
81+
result = []
82+
83+
backups.each_with_index do |backup, index|
84+
if backup.grading_location.present?
85+
backup.grading_message_questions.each do |gmq|
86+
status = get_grading_backup_status(gmq)
87+
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)
88+
end
89+
end
90+
91+
if backup.unlock_location.present?
92+
# annoyingly, unlock.json doesn't have question display name so we fetch it from analytics.json
93+
backup_problem_names = backup.analytics_message.question_display_names
94+
umc_grouped_by_problem_name = backup_problem_names.map { |name| [ name, [] ] }.to_h
95+
backup.unlock_message_cases.each do |umc|
96+
# TODO dangerously (?) assume that unlock message cases
97+
# will always match exactly one of the backup problem names
98+
backup_problem_names.each do |name|
99+
if umc.case_id.start_with?(name)
100+
umc_grouped_by_problem_name[name] << umc
101+
break
102+
end
103+
end
104+
end
105+
106+
umc_grouped_by_problem_name.each do |problem_name, unlock_message_cases|
107+
status = get_unlocking_backup_status(unlock_message_cases)
108+
result << { timestamp: Time.iso8601(backup.created), index: index, problem_name: problem_name, problem_index: problem_name_to_index[problem_name] }.merge(status)
109+
end
110+
end
111+
end
112+
113+
result
114+
end
115+
116+
def get_sessions(processed_backups)
117+
if processed_backups.empty?
118+
return []
119+
end
120+
121+
# A session is defined as a series of backups where:
122+
# 1. All problems worked on are the same within the session, AND
123+
# 2. All labels (and therefore colors) are the same within the session, AND
124+
# 3. Consecutive timestamps are <= SESSION_TIME_GAP_THRESHOLD
125+
126+
result = []
127+
# TODO only convert to camel case at the end for consistency. generally fix naming conventions in all files...
128+
curr_session =
129+
{
130+
startTime: processed_backups[0][:timestamp],
131+
endTime: processed_backups[0][:timestamp],
132+
label: processed_backups[0][:label],
133+
color: processed_backups[0][:color],
134+
startIndex: processed_backups[0][:index],
135+
endIndex: processed_backups[0][:index] + 1,
136+
problemName: processed_backups[0][:problem_name],
137+
problemIndex: processed_backups[0][:problem_index]
138+
}
139+
140+
processed_backups.each_cons(2) do |a, b|
141+
problems_differ = a[:problem_index] != b[:problem_index]
142+
has_time_gap = b[:timestamp] - a[:timestamp] > SESSION_TIME_GAP_THRESHOLD
143+
labels_differ = a[:label] != b[:label]
144+
145+
if problems_differ or has_time_gap or labels_differ
146+
result << curr_session
147+
curr_session =
148+
{
149+
startTime: b[:timestamp],
150+
endTime: b[:timestamp],
151+
label: b[:label],
152+
color: b[:color],
153+
startIndex: b[:index],
154+
endIndex: b[:index] + 1,
155+
problemName: b[:problem_name],
156+
problemIndex: b[:problem_index]
157+
}
158+
else
159+
curr_session[:endTime] = b[:timestamp]
160+
curr_session[:endIndex] = b[:index] + 1
161+
end
162+
end
163+
164+
result << curr_session
165+
result
166+
end
167+
end
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
module Api::ProblemCalendarHelper
2+
end
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
module Api::ProblemTimelineHelper
2+
end
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class BackupMetadatum < ApplicationRecord
2-
has_one :analytics_message
3-
has_many :grading_message_questions
4-
has_many :unlock_message_cases
2+
has_one :analytics_message, foreign_key: :backup_id
3+
has_many :grading_message_questions, foreign_key: :backup_id
4+
has_many :unlock_message_cases, foreign_key: :backup_id
55
end
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import React, { useMemo, useState, useEffect } from "react";
2+
import { useParams } from "react-router";
3+
import ReactECharts from "echarts-for-react";
4+
import * as echarts from "echarts";
5+
import { CircularProgress } from "@mui/material";
6+
7+
// TODO don't hardcode release, checkpoint 1, checkpoint 2, due date
8+
// const highlightedDates = ["2025-10-27", "2025-11-05", "2025-11-14", "2025-11-24", "2025-11-25"];
9+
10+
// TODO rename charts for consistency with titles in frontend
11+
const BackupCalendarChart = () => {
12+
const routeParams = useParams();
13+
const [rawCalendarData, setRawCalendarData] = useState([]);
14+
15+
useEffect(() => {
16+
fetch(
17+
`/api/problem_calendar/${routeParams.courseId}/${routeParams.assignmentId}/${routeParams.studentId}`,
18+
{
19+
method: "GET",
20+
},
21+
)
22+
.then((response) => {
23+
if (!response.ok) {
24+
throw new Error(`HTTP error! Status: ${response.status}`);
25+
}
26+
return response.json();
27+
})
28+
.then((responseData) => {
29+
setRawCalendarData(responseData);
30+
});
31+
}, [routeParams]);
32+
33+
const startDate = useMemo(() => {
34+
const keys = Object.keys(rawCalendarData);
35+
if (keys.length === 0) return null;
36+
return Math.min(...keys.map((date) => new Date(date).getTime()));
37+
}, [rawCalendarData]);
38+
39+
const endDate = useMemo(() => {
40+
const keys = Object.keys(rawCalendarData);
41+
if (keys.length === 0) return null;
42+
return Math.max(...keys.map((date) => new Date(date).getTime()));
43+
}, [rawCalendarData]);
44+
45+
// generate dummy data
46+
const calendarData = useMemo(() => {
47+
const data = [];
48+
const start = new Date(startDate);
49+
const end = new Date(endDate);
50+
51+
for (
52+
let time = start.getTime();
53+
time <= end.getTime();
54+
time += 24 * 60 * 60 * 1000
55+
) {
56+
const dateString = echarts.time.format(
57+
new Date(time),
58+
"{yyyy}-{MM}-{dd}",
59+
false,
60+
);
61+
const count = rawCalendarData[dateString] || 0;
62+
data.push([dateString, count]);
63+
}
64+
return data;
65+
}, [rawCalendarData, startDate, endDate]);
66+
67+
const option = useMemo(
68+
() => ({
69+
title: { text: "Project Worksession Heatmap", left: "center" },
70+
tooltip: {
71+
formatter: (params) => `${params.value[0]}: ${params.value[1]} backups`,
72+
},
73+
visualMap: {
74+
min: 0,
75+
max: Math.max(...calendarData.map((val) => val[1])),
76+
calculable: true,
77+
orient: "vertical",
78+
right: "5%",
79+
top: "center",
80+
// Standard GitHub-style greens
81+
inRange: {
82+
color: ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"],
83+
},
84+
},
85+
calendar: {
86+
top: 100,
87+
bottom: 40,
88+
left: 80,
89+
right: 150,
90+
orient: "vertical",
91+
range: [startDate, endDate],
92+
cellSize: [40, "auto"],
93+
yearLabel: { show: false },
94+
dayLabel: {
95+
firstDay: 1,
96+
nameMap: "en",
97+
},
98+
monthLabel: {
99+
position: "start", // Places month names to the left of the grid
100+
margin: 20,
101+
},
102+
itemStyle: {
103+
borderWidth: 2,
104+
borderColor: "#fff",
105+
},
106+
},
107+
series: [
108+
{
109+
type: "heatmap",
110+
coordinateSystem: "calendar",
111+
data: calendarData,
112+
},
113+
],
114+
}),
115+
[calendarData, startDate, endDate],
116+
);
117+
118+
// Adjust container height dynamically based on the range
119+
const calculateHeight = () => {
120+
const months =
121+
new Date(endDate).getMonth() - new Date(startDate).getMonth() + 1;
122+
return Math.max(months * 180, 400) + "px";
123+
};
124+
125+
return (
126+
<>
127+
{calendarData.length === 0 || startDate === null || endDate === null ? (
128+
<CircularProgress />
129+
) : (
130+
<div style={{ height: calculateHeight(), width: "100%" }}>
131+
<ReactECharts
132+
option={option}
133+
style={{ height: "100%" }}
134+
notMerge={true}
135+
/>
136+
</div>
137+
)}
138+
</>
139+
);
140+
};
141+
142+
export default BackupCalendarChart;

0 commit comments

Comments
 (0)