diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index cc7afe09e58..29d7af06882 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -108,24 +108,175 @@ def create_question_related_hash end def create_student_live_feedback_hash - count_hash = Course::Assessment::LiveFeedback::Message.joins(:thread). - select('live_feedback_threads.submission_creator_id', - 'live_feedback_threads.submission_question_id'). - where.not(creator_id: User::SYSTEM_USER_ID). - where(live_feedback_threads: { - submission_question_id: @submission_question_id_hash.values, - submission_creator_id: @all_students.pluck(:user_id) - }). - group('live_feedback_threads.submission_creator_id', - 'live_feedback_threads.submission_question_id').count - submission_hash = @submissions.to_h { |submission| [submission.creator_id, submission] } + message_grade_hash = fetch_message_grade_hash + prompt_hash = calculate_prompt_hash(message_grade_hash) + submission_hash = @submissions.index_by(&:creator_id) + + final_grade_hash = Course::Assessment::Answer.where( + submission_id: @submissions.pluck(:id), + current_answer: true + ).to_h { |answer| [[answer.submission_id, answer.question_id], answer&.grade&.to_f || 0] } + @student_live_feedback_hash = @all_students.to_h do |student| submission = submission_hash[student.user_id] - live_feedback_count = get_live_feedback_count(count_hash, submission) - [student, [submission, live_feedback_count]] + live_feedback_data = build_live_feedback_data(submission, final_grade_hash, message_grade_hash, prompt_hash) + + [student, [submission, live_feedback_data]] + end + end + + # Fetches all user Get Help messages grouped by [submission_creator_id, submission_question_id], + # along with `grade_before` and `grade_after` relative to the message timestamps. + # The returned structure looks like: + # { + # [4, 70] => { + # messages: [ + # { created_at: "2025-05-30T05:18:48.623076", content: "Explain the question" } + # ], + # grade_before: 0.0, + # grade_after: 75.0 + # }, + # [4, 72] => { + # messages: [ + # { created_at: "2025-05-30T05:19:38.71754", content: "Where am I wrong?" }, + # { created_at: "2025-05-30T05:19:47.08988", content: "How do I fix this?" }, + # { created_at: "2025-05-30T05:25:04.50411", content: "I am stuck" } + # ], + # grade_before: 10.0, + # grade_after: 10.0 + # }, + # ... + # } + def fetch_message_grade_hash + student_ids = @all_students.pluck(:user_id) + submission_question_ids = @submission_question_id_hash.values + + result = ActiveRecord::Base.connection.execute( + build_message_grade_sql(student_ids, submission_question_ids) + ) + + result.to_h do |row| + key = [row['submission_creator_id'], row['submission_question_id']] + [ + key, + messages: JSON.parse(row['messages_json']), + grade_before: row['grade_before']&.to_f || 0, + grade_after: row['grade_after']&.to_f || 0 + ] + end + end + + def build_message_grade_sql(student_ids, submission_question_ids) + <<-SQL + WITH feedback_messages AS ( + #{feedback_messages_cte(student_ids, submission_question_ids)} + ), + grades_before AS ( + #{grades_before_cte} + ), + grades_after AS ( + #{grades_after_cte} + ) + SELECT + f.submission_creator_id, + f.submission_question_id, + f.messages_json, + gb.grade_before, + ga.grade_after + FROM feedback_messages f + LEFT JOIN grades_before gb ON f.submission_creator_id = gb.submission_creator_id AND f.submission_question_id = gb.submission_question_id + LEFT JOIN grades_after ga ON f.submission_creator_id = ga.submission_creator_id AND f.submission_question_id = ga.submission_question_id + SQL + end + + def feedback_messages_cte(student_ids, submission_question_ids) + <<-SQL + SELECT + lft.submission_creator_id, + lft.submission_question_id, + json_agg(json_build_object( + 'created_at', m.created_at, + 'content', m.content + ) ORDER BY m.created_at) AS messages_json, + MIN(m.created_at) AS first_message_at, + MAX(m.created_at) AS last_message_at + FROM live_feedback_messages m + JOIN live_feedback_threads lft + ON lft.id = m.thread_id + WHERE m.creator_id != #{User::SYSTEM_USER_ID} + AND lft.submission_creator_id = ANY(ARRAY[#{student_ids.join(',')}]) + AND lft.submission_question_id = ANY(ARRAY[#{submission_question_ids.join(',')}]) + GROUP BY lft.submission_creator_id, lft.submission_question_id + SQL + end + + def grades_before_cte + <<-SQL + SELECT DISTINCT ON (a.submission_id, a.question_id) + a.grade AS grade_before, + lft.submission_creator_id, + lft.submission_question_id + FROM feedback_messages f + JOIN live_feedback_threads lft ON lft.submission_creator_id = f.submission_creator_id AND lft.submission_question_id = f.submission_question_id + JOIN course_assessment_submission_questions sq ON sq.id = lft.submission_question_id + JOIN course_assessment_answers a ON a.submission_id = sq.submission_id AND a.question_id = sq.question_id + WHERE a.created_at < f.first_message_at + ORDER BY a.submission_id, a.question_id, a.created_at DESC + SQL + end + + def grades_after_cte + <<-SQL + SELECT DISTINCT ON (a.submission_id, a.question_id) + a.grade AS grade_after, + lft.submission_creator_id, + lft.submission_question_id + FROM feedback_messages f + JOIN live_feedback_threads lft ON lft.submission_creator_id = f.submission_creator_id AND lft.submission_question_id = f.submission_question_id + JOIN course_assessment_submission_questions sq ON sq.id = lft.submission_question_id + JOIN course_assessment_answers a ON a.submission_id = sq.submission_id AND a.question_id = sq.question_id + WHERE a.created_at > f.last_message_at + ORDER BY a.submission_id, a.question_id, a.created_at ASC + SQL + end + + def build_live_feedback_data(submission, final_grade_hash, message_grade_hash, prompt_hash) + @ordered_questions.map do |question_id| + submission_question_id = @submission_question_id_hash[[submission&.id, question_id]] + key = [submission&.creator_id, submission_question_id] + + message_grade_data = message_grade_hash[key] || {} + grade_before = message_grade_data[:grade_before] + grade_after = message_grade_data[:grade_after] + + prompt_data = prompt_hash[key] || { messages_sent: 0, word_count: 0 } + + { + grade: final_grade_hash[[submission&.id, question_id]], + grade_diff: (grade_after && grade_before) ? (grade_after - grade_before).round(2) : 0, + word_count: prompt_data[:word_count], + messages_sent: prompt_data[:messages_sent] + } end end + def calculate_prompt_hash(message_hash) + message_hash.transform_values do |data| + messages = data[:messages] || [] + { + messages_sent: messages.size, + word_count: messages.sum { |m| m['content'].to_s.split(/\s+/).size } + } + end + end + + def fetch_messages_for_question(submission_question_id) + Course::Assessment::LiveFeedback::Message. + joins(:thread). + where(live_feedback_threads: { submission_question_id: submission_question_id }). + order(:created_at) + end + def create_question_order_hash @question_order_hash = @assessment.question_assessments.to_h do |q| [q.question_id, q.weight] @@ -140,11 +291,4 @@ def create_submission_question_id_hash(questions) [[sq.submission_id, sq.question_id], sq.id] end end - - def get_live_feedback_count(count_hash, submission) - @ordered_questions.map do |question_id| - submission_question_id = @submission_question_id_hash[[submission&.id, question_id]] - count_hash[[submission&.creator_id, submission_question_id]] || 0 - end - end end diff --git a/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder b/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder index 9f7d10f80aa..bd8038f714f 100644 --- a/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/live_feedback_statistics.json.jbuilder @@ -1,5 +1,5 @@ # frozen_string_literal: true -json.array! @student_live_feedback_hash.each do |course_user, (submission, live_feedback_count)| +json.array! @student_live_feedback_hash.each do |course_user, (submission, live_feedback_data)| json.partial! 'course_user', course_user: course_user if submission.nil? json.workflowState 'unstarted' @@ -13,6 +13,6 @@ json.array! @student_live_feedback_hash.each do |course_user, (submission, live_ json.name name end - json.liveFeedbackCount live_feedback_count + json.liveFeedbackData live_feedback_data json.questionIds(@question_order_hash.keys.sort_by { |key| @question_order_hash[key] }) end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx index 2fc661c2dec..5b0e260e431 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AssessmentStatisticsPage.tsx @@ -45,7 +45,7 @@ const translations = defineMessages({ }, liveFeedback: { id: 'course.assessment.statistics.liveFeedback', - defaultMessage: 'Live Feedback', + defaultMessage: 'Get Help', }, gradesPerQuestion: { id: 'course.assessment.statistics.gradesPerQuestion', diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx index 1aa8242d286..97abcc99aee 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackStatisticsTable.tsx @@ -1,7 +1,10 @@ -import { FC, ReactNode, useEffect, useState } from 'react'; +import { FC, ReactNode, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Box, Typography } from '@mui/material'; -import { AssessmentLiveFeedbackStatistics } from 'types/course/statistics/assessmentStatistics'; +import { Box, Tooltip, Typography } from '@mui/material'; +import { + AssessmentLiveFeedbackData, + AssessmentLiveFeedbackStatistics, +} from 'types/course/statistics/assessmentStatistics'; import SubmissionWorkflowState from 'course/assessment/submission/components/SubmissionWorkflowState'; import { workflowStates } from 'course/assessment/submission/constants'; @@ -14,12 +17,36 @@ import { getEditSubmissionURL } from 'lib/helpers/url-builders'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import LiveFeedbackMetricSelector from './components/LiveFeedbackMetricsSelector'; import { getClassnameForLiveFeedbackCell } from './classNameUtils'; import LiveFeedbackHistoryContent from './LiveFeedbackHistory'; import { getAssessmentStatistics } from './selectors'; import translations from './translations'; import { getJointGroupsName } from './utils'; +const METRIC_CONFIG = { + grade: { + showTotal: true, + legendLowerLabel: 'legendLowerLabelGrade', + legendUpperLabel: 'legendUpperLabelGrade', + }, + grade_diff: { + showTotal: false, + legendLowerLabel: 'legendLowerLabelGradeDiff', + legendUpperLabel: 'legendUpperLabelGradeDiff', + }, + messages_sent: { + showTotal: true, + legendLowerLabel: 'legendLowerLabelMessagesSent', + legendUpperLabel: 'legendUpperLabelMessagesSent', + }, + word_count: { + showTotal: true, + legendLowerLabel: 'legendLowerLabelWordCount', + legendUpperLabel: 'legendUpperLabelWordCount', + }, +} as const; + interface Props { includePhantom: boolean; liveFeedbackStatistics: AssessmentLiveFeedbackStatistics[]; @@ -37,225 +64,376 @@ const LiveFeedbackStatisticsTable: FC = (props) => { const [parsedStatistics, setParsedStatistics] = useState< AssessmentLiveFeedbackStatistics[] >([]); - const [upperQuartileFeedbackCount, setUpperQuartileFeedbackCount] = + const [upperQuartileMetricValue, setUpperQuartileMetricValue] = useState(0); - const [openLiveFeedbackHistory, setOpenLiveFeedbackHistory] = useState(false); const [liveFeedbackInfo, setLiveFeedbackInfo] = useState({ courseUserId: 0, questionId: 0, questionNumber: 0, }); + const [selectedMetric, setSelectedMetric] = useState({ + value: 'messages_sent', + label: 'Messages Sent', + }); useEffect(() => { - const feedbackCounts = liveFeedbackStatistics - .flatMap((s) => s.liveFeedbackCount ?? []) - .map((c) => c ?? 0) + // Create a deep copy of the statistics to avoid mutating the original data + const processedStats = liveFeedbackStatistics.map((stat) => ({ + ...stat, + liveFeedbackData: stat.liveFeedbackData.map((data) => ({ + ...data, + [selectedMetric.value]: data[selectedMetric.value as keyof typeof data], + })), + })); + + // Calculate quartile value from all non-zero values + const feedbackStats = processedStats + .flatMap((s) => + s.liveFeedbackData.map((d) => { + const val = d[selectedMetric.value as keyof typeof d]; + return typeof val === 'number' ? val : 0; + }), + ) .filter((c) => c !== 0) .sort((a, b) => a - b); + const upperQuartilePercentileIndex = Math.floor( - 0.75 * (feedbackCounts.length - 1), + 0.75 * (feedbackStats.length - 1), ); const upperQuartilePercentileValue = - feedbackCounts[upperQuartilePercentileIndex]; - setUpperQuartileFeedbackCount(upperQuartilePercentileValue); + feedbackStats[upperQuartilePercentileIndex]; + setUpperQuartileMetricValue(upperQuartilePercentileValue); const filteredStats = includePhantom - ? liveFeedbackStatistics - : liveFeedbackStatistics.filter((s) => !s.courseUser.isPhantom); + ? processedStats + : processedStats.filter((s) => !s.courseUser.isPhantom); + + // Only calculate totals if the metric should show them + if ( + METRIC_CONFIG[selectedMetric.value as keyof typeof METRIC_CONFIG] + ?.showTotal + ) { + filteredStats.forEach((stat) => { + stat.totalMetricCount = stat.liveFeedbackData.reduce((sum, data) => { + const value = data[selectedMetric.value as keyof typeof data]; + return sum + (typeof value === 'number' ? value : 0); + }, 0); + }); + } else { + // Clear any existing totals + filteredStats.forEach((stat) => { + stat.totalMetricCount = undefined; + }); + } - filteredStats.forEach((stat) => { - stat.totalFeedbackCount = - stat.liveFeedbackCount?.reduce((sum, count) => sum + (count || 0), 0) ?? - 0; + const sortedStats = filteredStats.sort((a, b) => { + // First sort by phantom status + const phantomDiff = + Number(a.courseUser.isPhantom) - Number(b.courseUser.isPhantom); + if (phantomDiff !== 0) return phantomDiff; + + // Then sort by workflow state + const workflowStateOrder = { + [workflowStates.Published]: 0, + [workflowStates.Graded]: 1, + [workflowStates.Submitted]: 2, + [workflowStates.Attempting]: 3, + [workflowStates.Unstarted]: 4, + }; + const stateA = + workflowStateOrder[a.workflowState ?? workflowStates.Unstarted] ?? 5; + const stateB = + workflowStateOrder[b.workflowState ?? workflowStates.Unstarted] ?? 5; + if (stateA !== stateB) return stateA - stateB; + + // Then sort by total metric count + const feedbackDiff = + (b.totalMetricCount ?? 0) - (a.totalMetricCount ?? 0); + if (feedbackDiff !== 0) return feedbackDiff; + + // Finally sort by name + return a.courseUser.name.localeCompare(b.courseUser.name); }); - setParsedStatistics( - filteredStats.sort((a, b) => { - const phantomDiff = - Number(a.courseUser.isPhantom) - Number(b.courseUser.isPhantom); - if (phantomDiff !== 0) return phantomDiff; + setParsedStatistics(sortedStats); + }, [liveFeedbackStatistics, includePhantom, selectedMetric]); - const feedbackDiff = - (b.totalFeedbackCount ?? 0) - (a.totalFeedbackCount ?? 0); - if (feedbackDiff !== 0) return feedbackDiff; + const renderTooltipContent = ( + liveFeedbackData: AssessmentLiveFeedbackData, + ): ReactNode => ( + + + Grade: {liveFeedbackData.grade ?? '-'} + + + Grade Difference: {liveFeedbackData.grade_diff ?? '-'} + + + Messages Sent: {liveFeedbackData.messages_sent ?? '-'} + + + Word Count: {liveFeedbackData.word_count ?? '-'} + + + ); - return a.courseUser.name.localeCompare(b.courseUser.name); - }), - ); - }, [liveFeedbackStatistics, includePhantom]); + const renderClickableCell = ( + metricValue: number, + classname: string, + courseUserId: number, + questionId: number, + questionNumber: number, + ): JSX.Element => ( +
{ + setOpenLiveFeedbackHistory(true); + setLiveFeedbackInfo({ courseUserId, questionId, questionNumber }); + }} + > + {metricValue} +
+ ); // the case where the live feedback count is null is handled separately inside the column // (refer to the definition of statColumns below) const renderNonNullClickableLiveFeedbackCountCell = ( - count: number, + metricValue: number, courseUserId: number, questionId: number, questionNumber: number, + liveFeedbackData: AssessmentLiveFeedbackData, ): ReactNode => { const classname = getClassnameForLiveFeedbackCell( - count, - upperQuartileFeedbackCount, + metricValue, + upperQuartileMetricValue, ); - if (count === 0) { - return {count}; + + const tooltipContent = renderTooltipContent(liveFeedbackData); + + // If there is no LiveFeedbackHistory, we do not show the clickable cell + if (liveFeedbackData.messages_sent === 0) { + return ( +
+ + {metricValue} + +
+ ); } + return ( -
{ - setOpenLiveFeedbackHistory(true); - setLiveFeedbackInfo({ courseUserId, questionId, questionNumber }); - }} - > - {count} -
+ + {renderClickableCell( + metricValue, + classname, + courseUserId, + questionId, + questionNumber, + )} + ); }; - const statColumns: ColumnTemplate[] = - Array.from({ length: assessment?.questionCount ?? 0 }, (_, index) => { - return { + const columns: ColumnTemplate[] = + useMemo(() => { + const statColumns = Array.from( + { length: assessment?.questionCount ?? 0 }, + (_, index) => { + return { + searchProps: { + getValue: (datum) => + datum.liveFeedbackData[index]?.[ + selectedMetric.value as keyof (typeof datum.liveFeedbackData)[number] + ]?.toString() ?? '', + }, + title: t(translations.questionIndex, { index: index + 1 }), + cell: (datum): ReactNode => { + const metricValue = + datum.liveFeedbackData[index]?.[ + selectedMetric.value as keyof (typeof datum.liveFeedbackData)[number] + ]; + return typeof metricValue === 'number' + ? renderNonNullClickableLiveFeedbackCountCell( + metricValue, + datum.courseUser.id, + datum.questionIds[index], + index + 1, + datum.liveFeedbackData[index], + ) + : null; + }, + sortable: true, + csvDownloadable: true, + className: 'text-right', + sortProps: { + sort: (a, b): number => { + const aValue = + a.liveFeedbackData[index]?.[ + selectedMetric.value as keyof (typeof a.liveFeedbackData)[number] + ] ?? Number.MIN_SAFE_INTEGER; + const bValue = + b.liveFeedbackData[index]?.[ + selectedMetric.value as keyof (typeof b.liveFeedbackData)[number] + ] ?? Number.MIN_SAFE_INTEGER; + return aValue - bValue; + }, + }, + }; + }, + ); + + const baseColumns: ColumnTemplate[] = [ + { + searchProps: { + getValue: (datum) => datum.courseUser.name, + }, + title: t(translations.name), + sortable: true, + searchable: true, + cell: (datum) => ( +
+ + {datum.courseUser.name} + + {datum.courseUser.isPhantom && ( + + )} +
+ ), + csvDownloadable: true, + }, + { + searchProps: { + getValue: (datum) => datum.courseUser.email, + }, + title: t(translations.email), + hidden: true, + cell: (datum) => ( +
+ {datum.courseUser.email} +
+ ), + csvDownloadable: true, + }, + { + of: 'groups', + title: t(translations.group), + sortable: true, + searchable: true, + searchProps: { + getValue: (datum) => getJointGroupsName(datum.groups), + }, + cell: (datum) => getJointGroupsName(datum.groups), + csvDownloadable: true, + }, + { + of: 'workflowState', + title: t(translations.workflowState), + sortable: true, + cell: (datum) => ( + + ), + className: 'center', + }, + ...statColumns, + ]; + + // Always add total column, but make it empty when showTotal is false to prevent UI elements shifting + baseColumns.push({ searchProps: { getValue: (datum) => - datum.liveFeedbackCount?.[index]?.toString() ?? '', + METRIC_CONFIG[selectedMetric.value as keyof typeof METRIC_CONFIG] + ?.showTotal + ? datum.liveFeedbackData + .reduce((sum, data) => { + const value = + data[selectedMetric.value as keyof typeof data]; + return sum + (typeof value === 'number' ? value : 0); + }, 0) + .toString() + : '', }, - title: t(translations.questionIndex, { index: index + 1 }), + title: t(translations.total), cell: (datum): ReactNode => { - return typeof datum.liveFeedbackCount?.[index] === 'number' - ? renderNonNullClickableLiveFeedbackCountCell( - datum.liveFeedbackCount?.[index], - datum.courseUser.id, - datum.questionIds[index], - index + 1, - ) - : null; + if ( + !METRIC_CONFIG[selectedMetric.value as keyof typeof METRIC_CONFIG] + ?.showTotal + ) { + return -; + } + + const totalMetricValue = datum.liveFeedbackData.reduce( + (sum, data) => { + const value = data[selectedMetric.value as keyof typeof data]; + return sum + (typeof value === 'number' ? value : 0); + }, + 0, + ); + + return {totalMetricValue}; }, sortable: true, csvDownloadable: true, className: 'text-right', sortProps: { sort: (a, b): number => { - const aValue = - a.liveFeedbackCount?.[index] ?? Number.MIN_SAFE_INTEGER; - const bValue = - b.liveFeedbackCount?.[index] ?? Number.MIN_SAFE_INTEGER; - - return aValue - bValue; + if ( + !METRIC_CONFIG[selectedMetric.value as keyof typeof METRIC_CONFIG] + ?.showTotal + ) { + return 0; + } + const totalA = a.totalMetricCount ?? 0; + const totalB = b.totalMetricCount ?? 0; + return totalA - totalB; }, }, - }; - }); + }); - const columns: ColumnTemplate[] = [ - { - searchProps: { - getValue: (datum) => datum.courseUser.name, - }, - title: t(translations.name), - sortable: true, - searchable: true, - cell: (datum) => ( -
- - {datum.courseUser.name} - - {datum.courseUser.isPhantom && ( - - )} -
- ), - csvDownloadable: true, - }, - { - searchProps: { - getValue: (datum) => datum.courseUser.email, - }, - title: t(translations.email), - hidden: true, - cell: (datum) => ( -
{datum.courseUser.email}
- ), - csvDownloadable: true, - }, - { - of: 'groups', - title: t(translations.group), - sortable: true, - searchable: true, - searchProps: { - getValue: (datum) => getJointGroupsName(datum.groups), - }, - cell: (datum) => getJointGroupsName(datum.groups), - csvDownloadable: true, - }, - { - of: 'workflowState', - title: t(translations.workflowState), - sortable: true, - cell: (datum) => ( - - ), - className: 'center', - }, - ...statColumns, - { - searchProps: { - getValue: (datum) => - datum.liveFeedbackCount - ? datum.liveFeedbackCount - .reduce((sum, count) => sum + (count || 0), 0) - .toString() - : '', - }, - title: t(translations.total), - cell: (datum): ReactNode => { - const totalFeedbackCount = datum.liveFeedbackCount - ? datum.liveFeedbackCount.reduce( - (sum, count) => sum + (count || 0), - 0, - ) - : null; - return ( -
- {totalFeedbackCount} -
- ); - }, - sortable: true, - csvDownloadable: true, - className: 'text-right', - sortProps: { - sort: (a, b): number => { - const totalA = a.totalFeedbackCount ?? 0; - const totalB = b.totalFeedbackCount ?? 0; - return totalA - totalB; - }, - }, - }, - ]; + return baseColumns; + }, [selectedMetric.value, upperQuartileMetricValue]); return ( <> -
- - {t(translations.legendLowerUsage)} - - { - // The gradient color bar -
- } - - {t(translations.legendHigherusage)} - +
+
+ +
+ +
+ + {t( + translations[ + METRIC_CONFIG[ + selectedMetric.value as keyof typeof METRIC_CONFIG + ].legendLowerLabel + ], + )} + +
+ + {t( + translations[ + METRIC_CONFIG[ + selectedMetric.value as keyof typeof METRIC_CONFIG + ].legendUpperLabel + ], + )} + +
{ const gradientLevel = calculateOneSidedColorGradientLevel( - count, + metricValue, upperQuartile, ); - return `${redBackgroundColorClassName[gradientLevel]} p-[1rem]`; + return `${redBackgroundColorClassName[gradientLevel]} p-1.5`; }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx new file mode 100644 index 00000000000..4b14746518c --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/components/LiveFeedbackMetricsSelector.tsx @@ -0,0 +1,89 @@ +import { FC } from 'react'; +import { + Autocomplete, + Box, + TextField, + Tooltip, + Typography, +} from '@mui/material'; + +import InfoLabel from 'lib/components/core/InfoLabel'; + +interface MetricOption { + value: string; + label: string; +} + +interface Props { + selectedMetric: MetricOption; + setSelectedMetric: (value: MetricOption) => void; +} + +const metricOptions: MetricOption[] = [ + { value: 'grade', label: 'Grade' }, + { value: 'grade_diff', label: 'Grade Difference' }, + { value: 'messages_sent', label: 'Messages Sent' }, + { value: 'word_count', label: 'Word Count' }, +]; + +const metricDescriptions: Record = { + grade: 'The final grade assigned to the student.', + grade_diff: ( + <> + The grade difference between the{' '} + last answer before the first message and the{' '} + first answer after the last message. + + ), + messages_sent: 'The number of messages sent during the session.', + word_count: "Total word count from the user's messages.", +}; + +const LiveFeedbackMetricSelector: FC = ({ + selectedMetric, + setSelectedMetric, +}) => { + const description = + metricDescriptions[selectedMetric?.value] || + 'Select a metric to see its description.'; // Just in case no metric is selected + + return ( + + + + option.label} + isOptionEqualToValue={(option, value) => + option.value === value.value + } + onChange={(_, value) => { + if (value) setSelectedMetric(value); + }} + options={metricOptions} + renderInput={(params) => ( + + )} + value={selectedMetric} + /> + + {description}} + > +
+ +
+
+
+
+ ); +}; + +export default LiveFeedbackMetricSelector; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts index 20d520114fe..52b322d34e4 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/translations.ts @@ -33,21 +33,45 @@ const translations = defineMessages({ id: 'course.assessment.statistics.group', defaultMessage: 'Group', }, - legendHigherusage: { - id: 'course.assessment.statistics.legendHigherusage', - defaultMessage: 'Higher Usage', + legendLowerLabelGrade: { + id: 'course.assessment.statistics.legendLowerLabelGrade', + defaultMessage: 'Lower Grade', + }, + legendUpperLabelGrade: { + id: 'course.assessment.statistics.legendHigherLabelGrade', + defaultMessage: 'Higher Grade', + }, + legendLowerLabelGradeDiff: { + id: 'course.assessment.statistics.legendLowerLabelGradeDiff', + defaultMessage: 'Lower Grade Difference', + }, + legendUpperLabelGradeDiff: { + id: 'course.assessment.statistics.legendHigherLabelGradeDiff', + defaultMessage: 'Higher Grade Difference', }, - legendLowerUsage: { - id: 'course.assessment.statistics.legendLowerUsage', + legendLowerLabelMessagesSent: { + id: 'course.assessment.statistics.legendLowerLabelMessagesSent', defaultMessage: 'Lower Usage', }, + legendUpperLabelMessagesSent: { + id: 'course.assessment.statistics.legendUpperLabelMessagesSent', + defaultMessage: 'Higher Usage', + }, + legendLowerLabelWordCount: { + id: 'course.assessment.statistics.legendLowerLabelWordCount', + defaultMessage: 'Lower Word Count', + }, + legendUpperLabelWordCount: { + id: 'course.assessment.statistics.legendUpperLabelWordCount', + defaultMessage: 'Higher Word Count', + }, liveFeedbackFilename: { id: 'course.assessment.statistics.liveFeedback.filename', - defaultMessage: 'Question-level Live Feedback Statistics for {assessment}', + defaultMessage: 'Question-level Get Help Statistics for {assessment}', }, liveFeedbackHistoryPromptTitle: { id: 'course.assessment.statistics.liveFeedbackHistoryPromptTitle', - defaultMessage: 'Live Feedback History', + defaultMessage: 'Get Help History', }, marksFilename: { id: 'course.assessment.statistics.marks.filename', diff --git a/client/app/bundles/course/assessment/submission/actions/answers/index.js b/client/app/bundles/course/assessment/submission/actions/answers/index.js index d20fe67149c..e08601f23c5 100644 --- a/client/app/bundles/course/assessment/submission/actions/answers/index.js +++ b/client/app/bundles/course/assessment/submission/actions/answers/index.js @@ -145,11 +145,12 @@ export function saveAnswer(answerData, answerId, currentTime, resetField) { return (dispatch, getState) => { dispatch(dispatchUpdateAnswerFlagSavingSize(answerId, savingSize)); + // When the current client version is greater than that in the redux store, // the answer is already stale and no API call is needed. const isAnswerStale = getClientVersionForAnswerId(getState(), answerId) > currentTime; - if (isAnswerStale) return {}; + if (isAnswerStale) return Promise.resolve({}); dispatch({ type: actionTypes.SAVE_ANSWER_REQUEST, @@ -182,6 +183,7 @@ export function saveAnswer(answerData, answerId, currentTime, resetField) { dispatch( dispatchUpdateAnswerFlagSavingStatus(answerId, SAVING_STATUS.Saved), ); + return Promise.resolve({}); }) .catch((e) => { dispatch({ @@ -196,6 +198,7 @@ export function saveAnswer(answerData, answerId, currentTime, resetField) { isStaleAnswer, ), ); + return Promise.reject(e); }); }; } diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx index 01521833fe8..98eede7b92f 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx @@ -152,8 +152,9 @@ const SubmissionForm: FC = (props) => { setStepIndex(assignedStep); if (scrollToRef.current) { - setImmediate(() => - (scrollToRef.current! as HTMLElement).scrollIntoView(), + setTimeout( + () => (scrollToRef.current! as HTMLElement).scrollIntoView(), + 0, ); } } diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 9601c36192a..54d00fa9d6f 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -89,7 +89,14 @@ export interface AssessmentLiveFeedbackStatistics { groups: { name: string }[]; workflowState?: WorkflowState; submissionId?: number; - liveFeedbackCount?: number[]; // Will already be ordered by question - totalFeedbackCount?: number; + liveFeedbackData: AssessmentLiveFeedbackData[]; // Will already be ordered by question questionIds: number[]; + totalMetricCount?: number; +} + +export interface AssessmentLiveFeedbackData { + grade: number; + grade_diff: number; + messages_sent: number; + word_count: number; } diff --git a/client/locales/en.json b/client/locales/en.json index e32456445f0..0b0773a0baa 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -2550,10 +2550,10 @@ "defaultMessage": "Lower Usage" }, "course.assessment.statistics.liveFeedback.filename": { - "defaultMessage": "Question-level Live Feedback Statistics for {assessment}" + "defaultMessage": "Question-level Get Help Statistics for {assessment}" }, "course.assessment.statistics.liveFeedbackHistoryPromptTitle": { - "defaultMessage": "Live Feedback History" + "defaultMessage": "Get Help History" }, "course.assessment.statistics.marks.filename": { "defaultMessage": "Question-level Marks Statistics for {assessment}" diff --git a/spec/controllers/course/statistics/assessment_controller_spec.rb b/spec/controllers/course/statistics/assessment_controller_spec.rb index f4f7b1a4dbf..d8e5c7d5553 100644 --- a/spec/controllers/course/statistics/assessment_controller_spec.rb +++ b/spec/controllers/course/statistics/assessment_controller_spec.rb @@ -214,15 +214,15 @@ expect(first_result).to have_key('workflowState') expect(first_result).to have_key('submissionId') expect(first_result).to have_key('groups') - expect(first_result).to have_key('liveFeedbackCount') + expect(first_result).to have_key('liveFeedbackData') expect(first_result).to have_key('questionIds') # Ensure that the feedback count is correct for the specific questions question_index = first_result['questionIds'].index(question1.id) if first_result['courseUser']['id'] == course_student.id - expect(first_result['liveFeedbackCount'][question_index]).to eq(3) + expect(first_result['liveFeedbackData'][question_index]['messages_sent']).to eq(3) else - expect(first_result['liveFeedbackCount'][question_index]).to eq(0) + expect(first_result['liveFeedbackData'][question_index]['messages_sent']).to eq(0) end end end @@ -247,15 +247,15 @@ expect(first_result).to have_key('workflowState') expect(first_result).to have_key('submissionId') expect(first_result).to have_key('groups') - expect(first_result).to have_key('liveFeedbackCount') + expect(first_result).to have_key('liveFeedbackData') expect(first_result).to have_key('questionIds') # Ensure that the feedback count is correct for the specific questions question_index = first_result['questionIds'].index(question1.id) if first_result['courseUser']['id'] == course_student.id - expect(first_result['liveFeedbackCount'][question_index]).to eq(3) + expect(first_result['liveFeedbackData'][question_index]['messages_sent']).to eq(3) else - expect(first_result['liveFeedbackCount'][question_index]).to eq(0) + expect(first_result['liveFeedbackData'][question_index]['messages_sent']).to eq(0) end end end