From 3857eb0e81cab43b02216ef7dc022f0e4ed8e69c Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Sat, 14 Mar 2026 12:38:46 +0100 Subject: [PATCH 01/12] feat: created separate controller and view for student reports --- app/controllers/student_reports_controller.rb | 11 +++++++ app/views/student_reports/show.html.erb | 32 +++++++++++++++++++ app/views/students/show.html.erb | 1 + config/routes.rb | 1 + 4 files changed, 45 insertions(+) create mode 100644 app/controllers/student_reports_controller.rb create mode 100644 app/views/student_reports/show.html.erb diff --git a/app/controllers/student_reports_controller.rb b/app/controllers/student_reports_controller.rb new file mode 100644 index 000000000..ae4654fad --- /dev/null +++ b/app/controllers/student_reports_controller.rb @@ -0,0 +1,11 @@ +class StudentReportsController < HtmlController + include CollectionHelper + + skip_after_action :verify_policy_scoped + layout 'print' + + def show + @student = Student.find params[:id] + authorize @student + end +end diff --git a/app/views/student_reports/show.html.erb b/app/views/student_reports/show.html.erb new file mode 100644 index 000000000..89df73d07 --- /dev/null +++ b/app/views/student_reports/show.html.erb @@ -0,0 +1,32 @@ +<% + @components = [ + { id: 'students', name: 'Student Enrollments' }, + { id: 'enrollmentTimelines', name: 'Enrollment Timelines' }, + { id: 'pointAverages', name: 'Points Averages' }, + { id: 'groupHistory', name: 'Group History' }, + { id: 'groupHistory30', name: 'Group History (Last 30 Lessons)' }, + { id: 'groupAttendance', name: 'Attendance' }, + { id: 'averagePerformanceStudents', name: 'Student Average Performance' } + ] +%> + +
+
+ <%= render CommonComponents::Card.new(title: 'Report Controls') do |card| %> + <% card.with_card_content do %> +
+ <% @components.each do |c| %> + <%= render CommonComponents::ToggleComponent.new(id: "#{c[:id]}Toggle", text: c[:name]) %> + <% end %> +
+ <% end %> + <% end %> +
+ +

Student Report - <%=@student.proper_name%>

+
+ + +<%= javascript_include_tag "new_charts", onload: 'window.onChartsLoad && window.onChartsLoad()' %> \ No newline at end of file diff --git a/app/views/students/show.html.erb b/app/views/students/show.html.erb index c3b82ec93..c7d5a9c78 100644 --- a/app/views/students/show.html.erb +++ b/app/views/students/show.html.erb @@ -4,6 +4,7 @@ {title: CommonComponents::TagLabel.new(label: @student.proper_name, img_src: 'student.svg'), href: '' }, ], buttons: [ (CommonComponents::ButtonComponent.new(label: t(:edit_student), href: edit_student_path(@student)) if policy(@student).edit?), + (CommonComponents::ButtonComponent.new(label: t(:generate_report), href: reports_student_path(@student)) if policy(@student).show?), (CommonComponents::FormButtonComponent.new(label: t(:restore_student), path: undelete_student_path(@student), method: :post) if policy(@student).destroy? && @student.deleted_at), (CommonComponents::DangerousFormButtonComponent.new(label: t(:delete_student), path: student_path(@student), method: :delete) if policy(@student).destroy? && !@student.deleted_at) ].compact) do |h| diff --git a/config/routes.rb b/config/routes.rb index 1bf9eb714..16999c0da 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,7 @@ resource :reports, only: %i[show] do resources :groups, controller: :group_reports, only: %i[show] + resources :students, controller: :student_reports, only: %i[show] end resources :lessons, only: %i[index new create show edit update] do resources :students, controller: :student_lessons, only: %i[show update] From 6607d2903ca892d60051db29912aa1ab4973abd2 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Sun, 15 Mar 2026 12:29:16 +0100 Subject: [PATCH 02/12] feat: created student average history and performance change sections --- app/assets/javascripts/new_charts.js | 142 ++++++++++++++++++ app/controllers/student_reports_controller.rb | 31 +++- app/lib/sql.rb | 92 ++++++++++++ app/views/student_reports/show.html.erb | 126 +++++++++++++++- config/locales/en.yml | 1 + 5 files changed, 384 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/new_charts.js b/app/assets/javascripts/new_charts.js index df80fbfb9..e97742240 100644 --- a/app/assets/javascripts/new_charts.js +++ b/app/assets/javascripts/new_charts.js @@ -196,6 +196,50 @@ function buildDatasetsForGroups(groups) { return datasets } +function buildDatasetsForStudentReportByGroups(groupedData) { + const source = groupedData ? groupedData : {} + const datasets = [] + + Object.entries(source).forEach(([groupId, rows], idx) => { + const color = colorForIndex(idx) + const firstRow = rows[0] || {} + + const points = rows + .map((row) => { + const x = new Date(row.lesson_date).getTime() + const y = row.average_mark + + return { + x, + y, + date: row.lesson_date, + lesson_url: row.lesson_url, + group_id: row.group_id, + group_name: row.group_name + } + }) + + if (!points.length) return + + datasets.push({ + label: firstRow.group_name || `Group ${groupId}`, + groupKey: groupId, + type: "line", + data: points, + parsing: false, + borderColor: color, + backgroundColor: color, + borderWidth: 3, + tension: 0.15, + spanGaps: false, + pointRadius: 3, + pointHoverRadius: 5 + }) + }) + + return datasets +} + // ---------- Group Analytics Chart && Average performance per Group by Lesson chart --------- function displayAveragePerformancePerGroupByLessonChart(containerId, seriesJson, opts = {}) { if(!chartJsPresent()) return @@ -1233,4 +1277,102 @@ function displayMarkAveragesChart(containerId, data, opts = {}) { }, plugins: [whiteBackgroundPlugin()] }) +} + +// ---------- Student report chart: performance by lesson split by group ---------- +function displayStudentReportPerformanceByGroupChart(containerId, groupedData, opts = {}) { + if (!chartJsPresent()) return + + const canvas = ensureCanvasIsPresent(containerId, { heightPx: opts.heightPx || 500 }) + if (!canvas) return + + destroyIfExists(canvas) + + const datasets = buildDatasetsForStudentReportByGroups(groupedData) + + new Chart(canvas.getContext("2d"), { + data: { datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + clip: false, + plugins: { + legend: { + display: true, + labels: { + padding: 10, + font: { size: 12 } + } + }, + tooltip: { + backgroundColor: "#ffffff", + titleColor: "#111827", + bodyColor: "#374151", + borderColor: "#D1D5DB", + borderWidth: 2, + cornerRadius: 3, + padding: 12, + titleFont: { + size: 14, + weight: "600" + }, + bodyFont: { + size: 13 + }, + bodySpacing: 3, + callbacks: { + title: function(context) { + return context[0].dataset.label + }, + label: function(context) { + return [ + `Date: ${toUSdateFormat(context.parsed.x)}`, + `Average: ${context.parsed.y}` + ] + } + } + }, + whiteBackground: { + color: "white" + } + }, + scales: { + x: { + type: "linear", + title: { + display: true, + text: opts.xTitle || "Lesson date" + }, + ticks: { + callback: (value) => toUSdateFormat(Number(value)) + } + }, + y: { + title: { + display: true, + text: opts.yTitle || "Performance" + }, + min: 1, + max: 7, + ticks: { + precision: 0 + } + } + }, + onClick: function(event, elements) { + if (!elements?.length) return + + const el = elements[0] + const point = this.data.datasets[el.datasetIndex].data[el.index] + + if (point?.lesson_url) window.open(point.lesson_url, "_blank") + }, + onHover: function(event, elements) { + const c = event.native?.target || event.chart?.canvas + if (!c) return + c.style.cursor = elements?.length ? "pointer" : "default" + } + }, + plugins: [whiteBackgroundPlugin()] + }) } \ No newline at end of file diff --git a/app/controllers/student_reports_controller.rb b/app/controllers/student_reports_controller.rb index ae4654fad..099769f80 100644 --- a/app/controllers/student_reports_controller.rb +++ b/app/controllers/student_reports_controller.rb @@ -5,7 +5,36 @@ class StudentReportsController < HtmlController layout 'print' def show - @student = Student.find params[:id] + @student = Student.find(params[:id]) authorize @student + + summaries = StudentLessonSummary.where(student_id: @student.id, deleted_at: nil) + groups_by_id = Group.where(id: summaries.filter_map(&:group_id).uniq).index_by(&:id) + lessons_by_id = Lesson.where(id: summaries.filter_map(&:lesson_id).uniq).index_by(&:id) + + @student_summaries = StudentLessonSummary.where(student_id: @student.id, deleted_at: nil).where.not(average_mark: nil).order(:lesson_date) + .map { |s| to_lesson_summary(s, groups_by_id, lessons_by_id) } + .group_by { |s| s[:group_id] } + @performance_changes = fetch_performance_changes_by_subject(@student.id) + end + + private + + def fetch_performance_changes_by_subject(student_id) + sql = Sql.performance_change_summary_with_middle_by_subject_query([student_id]) + ActiveRecord::Base.connection.exec_query(sql).to_a.map(&:symbolize_keys) + end + + def to_lesson_summary(summary, groups_by_id, lessons_by_id) + group = groups_by_id[summary.group_id] + lesson = lessons_by_id[summary.lesson_id] + + { + group_name: group.group_name, + group_id: summary.group_id, + average_mark: summary.average_mark, + lesson_date: summary.lesson_date, + lesson_url: lesson ? lesson_url(lesson) : nil + } end end diff --git a/app/lib/sql.rb b/app/lib/sql.rb index 804d1d743..68a2e703d 100644 --- a/app/lib/sql.rb +++ b/app/lib/sql.rb @@ -99,4 +99,96 @@ def self.performance_change_query_with_dates(student_ids) ORDER BY diff; SQL end + + def self.performance_change_summary_with_middle_by_subject_query(student_ids) + <<~SQL.squish + WITH lesson_averages AS ( + SELECT + s.id AS student_id, + sub.id AS subject_id, + sub.subject_name AS subject_name, + l.id AS lesson_id, + l.date AS lesson_date, + AVG(g.mark) AS average_mark + FROM students s + JOIN grades g ON s.id = g.student_id + JOIN lessons l ON l.id = g.lesson_id + JOIN subjects sub ON sub.id = l.subject_id + WHERE s.id IN (#{student_ids.join(', ')}) + AND g.deleted_at IS NULL + AND s.deleted_at IS NULL + AND l.deleted_at IS NULL + AND sub.deleted_at IS NULL + GROUP BY s.id, sub.id, sub.subject_name, l.id, l.date + ), + ranked_lessons AS ( + SELECT + lesson_averages.*, + COUNT(*) OVER (PARTITION BY student_id, subject_id) AS lesson_count, + ROW_NUMBER() OVER ( + PARTITION BY student_id, subject_id + ORDER BY lesson_date ASC, lesson_id ASC + ) AS row_num + FROM lesson_averages + ), + first_lessons AS ( + SELECT + student_id, + subject_id, + subject_name, + lesson_date AS first_date, + average_mark AS first_avg, + lesson_count + FROM ranked_lessons + WHERE row_num = 1 + AND lesson_count >= 10 + ), + middle_lessons AS ( + SELECT + student_id, + subject_id, + lesson_date AS middle_date, + average_mark AS middle_avg + FROM ranked_lessons + WHERE row_num = ((lesson_count + 1) / 2) + AND lesson_count >= 10 + ), + last_lessons AS ( + SELECT + student_id, + subject_id, + lesson_date AS last_date, + average_mark AS last_avg + FROM ranked_lessons + WHERE row_num = lesson_count + AND lesson_count >= 10 + ) + SELECT + first_lessons.student_id, + first_lessons.subject_id, + first_lessons.subject_name, + first_lessons.lesson_count, + + first_lessons.first_date, + ROUND(first_lessons.first_avg::numeric, 2) AS first_avg, + + middle_lessons.middle_date, + ROUND(middle_lessons.middle_avg::numeric, 2) AS middle_avg, + + last_lessons.last_date, + ROUND(last_lessons.last_avg::numeric, 2) AS last_avg, + + ROUND((middle_lessons.middle_avg - first_lessons.first_avg)::numeric, 2) AS first_to_middle_diff, + ROUND((last_lessons.last_avg - middle_lessons.middle_avg)::numeric, 2) AS middle_to_last_diff, + ROUND((last_lessons.last_avg - first_lessons.first_avg)::numeric, 2) AS overall_diff + FROM first_lessons + JOIN middle_lessons + ON middle_lessons.student_id = first_lessons.student_id + AND middle_lessons.subject_id = first_lessons.subject_id + JOIN last_lessons + ON last_lessons.student_id = first_lessons.student_id + AND last_lessons.subject_id = first_lessons.subject_id + ORDER BY first_lessons.subject_name + SQL + end end diff --git a/app/views/student_reports/show.html.erb b/app/views/student_reports/show.html.erb index 89df73d07..3ca530506 100644 --- a/app/views/student_reports/show.html.erb +++ b/app/views/student_reports/show.html.erb @@ -1,12 +1,6 @@ <% @components = [ - { id: 'students', name: 'Student Enrollments' }, - { id: 'enrollmentTimelines', name: 'Enrollment Timelines' }, - { id: 'pointAverages', name: 'Points Averages' }, - { id: 'groupHistory', name: 'Group History' }, - { id: 'groupHistory30', name: 'Group History (Last 30 Lessons)' }, - { id: 'groupAttendance', name: 'Attendance' }, - { id: 'averagePerformanceStudents', name: 'Student Average Performance' } + ] %> @@ -24,9 +18,127 @@

Student Report - <%=@student.proper_name%>

+
+
+ <%= render CommonComponents::Card.new(title: t(:student_averages).capitalize) do |card| %> + <% card.with_card_content do %> +
+ <% end %> + <% end %> +
+
+ +
+ <%= render CommonComponents::Card.new(title: t(:performance_change).capitalize) do |card| %> + <% card.with_card_content do %> + <% @performance_changes.each do |performance_change| %> + <% overall_diff = performance_change[:overall_diff].to_f %> + <% first_to_middle_diff = performance_change[:first_to_middle_diff].to_f %> + <% middle_to_last_diff = performance_change[:middle_to_last_diff].to_f %> + + <% overall_positive = overall_diff >= 0 %> + <% overall_bg_class = overall_positive ? "bg-green-50 border-green-200" : "bg-red-50 border-red-200" %> + <% overall_text_class = overall_positive ? "text-green-700" : "text-red-700" %> + <% overall_arrow = overall_positive ? "▲" : "▼" %> + +
+
+
+ +
+

+ <%= performance_change[:subject_name] %> Performance Summary +

+ +
+ + <%= overall_arrow %> + <%= overall_diff >= 0 ? "+" : "" %><%= performance_change[:overall_diff] %> + + +
+

+ Overall change across the program +

+

+ Based on <%= performance_change[:lesson_count] %> lessons +

+
+
+
+ +
+

Progress Path

+ +
+ Start + Middle + Latest +
+ +
+
+
+ <%= performance_change[:first_avg] %> +
+

<%= performance_change[:first_date] %>

+
+ +
+
+
+ +
+
+ <%= performance_change[:middle_avg] %> +
+

<%= performance_change[:middle_date] %>

+
+ +
+
+
+ +
+
+ <%= performance_change[:last_avg] %> +
+

<%= performance_change[:last_date] %>

+
+
+ +
+ + <%= first_to_middle_diff >= 0 ? '▲' : '▼' %> + <%= first_to_middle_diff >= 0 ? '+' : '' %><%= performance_change[:first_to_middle_diff] %> + + + + <%= middle_to_last_diff >= 0 ? '▲' : '▼' %> + <%= middle_to_last_diff >= 0 ? '+' : '' %><%= performance_change[:middle_to_last_diff] %> + +
+
+ +
+
+
+ <% end %> + <% end %> + <% end %> +
<%= javascript_include_tag "new_charts", onload: 'window.onChartsLoad && window.onChartsLoad()' %> \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 54fc97e78..a21b5500c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -217,6 +217,7 @@ en: invalid_token: Invalid Google id_token invalid_user: User does not exist in MindLeaps student_average_marks: Student Average Marks + student_averages: Student Averages student_grades: Student Grades student_graded: Student graded. student_graded_text: Student %{student} graded. From 03e93c87d758a81179b95d1867bb06a159ec5382 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Mon, 16 Mar 2026 18:10:23 +0100 Subject: [PATCH 03/12] feat: added skill averages section --- app/controllers/student_reports_controller.rb | 53 ++++++++++--- app/views/group_reports/show.html.erb | 2 +- app/views/student_reports/show.html.erb | 76 +++++++++++++++++-- app/views/students/show.html.erb | 2 +- config/locales/en.yml | 2 + 5 files changed, 116 insertions(+), 19 deletions(-) diff --git a/app/controllers/student_reports_controller.rb b/app/controllers/student_reports_controller.rb index 099769f80..4e21f501a 100644 --- a/app/controllers/student_reports_controller.rb +++ b/app/controllers/student_reports_controller.rb @@ -8,14 +8,10 @@ def show @student = Student.find(params[:id]) authorize @student - summaries = StudentLessonSummary.where(student_id: @student.id, deleted_at: nil) - groups_by_id = Group.where(id: summaries.filter_map(&:group_id).uniq).index_by(&:id) - lessons_by_id = Lesson.where(id: summaries.filter_map(&:lesson_id).uniq).index_by(&:id) - - @student_summaries = StudentLessonSummary.where(student_id: @student.id, deleted_at: nil).where.not(average_mark: nil).order(:lesson_date) - .map { |s| to_lesson_summary(s, groups_by_id, lessons_by_id) } - .group_by { |s| s[:group_id] } + @summaries = StudentLessonSummary.where(student_id: @student.id, deleted_at: nil) + @grouped_student_summaries = group_summaries @performance_changes = fetch_performance_changes_by_subject(@student.id) + @student_skill_averages = skill_average_summaries end private @@ -25,16 +21,55 @@ def fetch_performance_changes_by_subject(student_id) ActiveRecord::Base.connection.exec_query(sql).to_a.map(&:symbolize_keys) end + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/AbcSize + def skill_average_summaries + grouped = {} + + StudentAverage.where(student_id: @student.id).load.each do |average| + subject_name = average[:subject_name].to_s + grouped[subject_name] ||= [] + grouped[subject_name] << { skill: average[:skill_name], average: average[:average_mark].to_f } + end + + grouped.transform_values do |rows| + strongest = rows.max_by { |row| row[:average] } + weakest = rows.min_by { |row| row[:average] } + + { + strongest: strongest, + weakest: weakest, + other_skills: rows.reject do |row| + row[:skill] == strongest[:skill] || row[:skill] == weakest[:skill] + end + } + end + end + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/AbcSize + + def group_summaries + groups_by_id = Group.where(id: @summaries.filter_map(&:group_id).uniq).index_by(&:id) + lessons_by_id = Lesson.where(id: @summaries.filter_map(&:lesson_id).uniq).index_by(&:id) + + @summaries.where.not(average_mark: nil) + .order(:lesson_date) + .map { |s| to_lesson_summary(s, groups_by_id, lessons_by_id) } + .group_by { |s| s[:group_id] } + end + def to_lesson_summary(summary, groups_by_id, lessons_by_id) group = groups_by_id[summary.group_id] lesson = lessons_by_id[summary.lesson_id] { - group_name: group.group_name, + group_name: group&.group_name || "Group #{summary.group_id}", group_id: summary.group_id, average_mark: summary.average_mark, lesson_date: summary.lesson_date, lesson_url: lesson ? lesson_url(lesson) : nil } end -end +end \ No newline at end of file diff --git a/app/views/group_reports/show.html.erb b/app/views/group_reports/show.html.erb index 68decf2e2..c24a90c5e 100644 --- a/app/views/group_reports/show.html.erb +++ b/app/views/group_reports/show.html.erb @@ -12,7 +12,7 @@
- <%= render CommonComponents::Card.new(title: 'Report Controls') do |card| %> + <%= render CommonComponents::Card.new(title: t(:report_controls)) do |card| %> <% card.with_card_content do %>
<% @components.each do |c| %> diff --git a/app/views/student_reports/show.html.erb b/app/views/student_reports/show.html.erb index 3ca530506..129591c7e 100644 --- a/app/views/student_reports/show.html.erb +++ b/app/views/student_reports/show.html.erb @@ -6,7 +6,7 @@
- <%= render CommonComponents::Card.new(title: 'Report Controls') do |card| %> + <%= render CommonComponents::Card.new(title: t(:report_controls)) do |card| %> <% card.with_card_content do %>
<% @components.each do |c| %> @@ -20,7 +20,7 @@

Student Report - <%=@student.proper_name%>

- <%= render CommonComponents::Card.new(title: t(:student_averages).capitalize) do |card| %> + <%= render CommonComponents::Card.new(title: t(:student_averages)) do |card| %> <% card.with_card_content do %>
<% end %> @@ -29,7 +29,7 @@
- <%= render CommonComponents::Card.new(title: t(:performance_change).capitalize) do |card| %> + <%= render CommonComponents::Card.new(title: t(:performance_change)) do |card| %> <% card.with_card_content do %> <% @performance_changes.each do |performance_change| %> <% overall_diff = performance_change[:overall_diff].to_f %> @@ -127,18 +127,78 @@ <% end %> <% end %>
+ +
+ <%= render CommonComponents::Card.new(title: t(:skill_averages)) do |card| %> + <% card.with_card_content do %> + <% @student_skill_averages.each do |subject_name, summary| %> +
+
+
+
+

+ <%= subject_name %> Skill Averages +

+
+ +
+
+

+ Strongest Skill +

+

+ <%= summary[:strongest][:skill] %> +

+

+ <%= summary[:strongest][:average] %> +

+
+ +
+

+ Lowest Rated Skill +

+

+ <%= summary[:weakest][:skill] %> +

+

+ <%= summary[:weakest][:average] %> +

+
+
+ + <% if summary[:other_skills].any? %> +
+
+ <% summary[:other_skills].each do |row| %> +
+

+ <%= row[:skill] %> +

+

+ <%= row[:average] %> +

+
+ <% end %> +
+
+ <% end %> +
+
+
+ <% end %> + <% end %> + <% end %> +
<%= javascript_include_tag "new_charts", onload: 'window.onChartsLoad && window.onChartsLoad()' %> \ No newline at end of file diff --git a/app/views/students/show.html.erb b/app/views/students/show.html.erb index c7d5a9c78..49baf2004 100644 --- a/app/views/students/show.html.erb +++ b/app/views/students/show.html.erb @@ -4,7 +4,7 @@ {title: CommonComponents::TagLabel.new(label: @student.proper_name, img_src: 'student.svg'), href: '' }, ], buttons: [ (CommonComponents::ButtonComponent.new(label: t(:edit_student), href: edit_student_path(@student)) if policy(@student).edit?), - (CommonComponents::ButtonComponent.new(label: t(:generate_report), href: reports_student_path(@student)) if policy(@student).show?), + (CommonComponents::ButtonComponent.new(label: t(:generate_report), href: reports_student_path(@student), options: { target: '_blank' }) if policy(@student).show?), (CommonComponents::FormButtonComponent.new(label: t(:restore_student), path: undelete_student_path(@student), method: :post) if policy(@student).destroy? && @student.deleted_at), (CommonComponents::DangerousFormButtonComponent.new(label: t(:delete_student), path: student_path(@student), method: :delete) if policy(@student).destroy? && !@student.deleted_at) ].compact) do |h| diff --git a/config/locales/en.yml b/config/locales/en.yml index a21b5500c..bf9679c7a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -284,6 +284,7 @@ en: average: Average average_grade: Average Grade average_mark_for_skill: Average Mark for Skill + skill_averages: Skill Averages mark_percentages: Mark Percentages graded_of_skills: Graded / Skills grade_count: Grade Count @@ -318,6 +319,7 @@ en: total: Total reports: Reports generate_report: Generate Report + report_controls: Report Controls analytics: Analytics general_analytics: General Analytics group_analytics: Group Analytics From add7b5e0a79c61fae14530a9d5beebc00035b04d Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Tue, 17 Mar 2026 17:27:30 +0100 Subject: [PATCH 04/12] chore: rubocop, tidy up some things --- app/assets/javascripts/new_charts.js | 4 +- app/lib/sql.rb | 166 +++++++++++++-------------- app/views/layouts/print.html.erb | 128 ++++++++++----------- 3 files changed, 144 insertions(+), 154 deletions(-) diff --git a/app/assets/javascripts/new_charts.js b/app/assets/javascripts/new_charts.js index e97742240..8c3d5ea1b 100644 --- a/app/assets/javascripts/new_charts.js +++ b/app/assets/javascripts/new_charts.js @@ -718,12 +718,12 @@ function displayLessonChart(containerId, lessonId, data) { }, pointBackgroundColor: (ctx) => { const raw = ctx.raw - if (!raw) return " #9C27B0" + if (!raw) return "#9C27B0" return raw.lesson_id === lessonId ? "#4CAF50" : " #9C27B0" }, pointBorderColor: (ctx) => { const raw = ctx.raw - if (!raw) return " #9C27B0" + if (!raw) return "#9C27B0" return raw.lesson_id === lessonId ? "#4CAF50" : " #9C27B0" }, pointHoverRadius: 5 diff --git a/app/lib/sql.rb b/app/lib/sql.rb index 68a2e703d..553976db6 100644 --- a/app/lib/sql.rb +++ b/app/lib/sql.rb @@ -102,93 +102,93 @@ def self.performance_change_query_with_dates(student_ids) def self.performance_change_summary_with_middle_by_subject_query(student_ids) <<~SQL.squish - WITH lesson_averages AS ( - SELECT - s.id AS student_id, - sub.id AS subject_id, - sub.subject_name AS subject_name, - l.id AS lesson_id, - l.date AS lesson_date, - AVG(g.mark) AS average_mark - FROM students s - JOIN grades g ON s.id = g.student_id - JOIN lessons l ON l.id = g.lesson_id - JOIN subjects sub ON sub.id = l.subject_id - WHERE s.id IN (#{student_ids.join(', ')}) - AND g.deleted_at IS NULL - AND s.deleted_at IS NULL - AND l.deleted_at IS NULL - AND sub.deleted_at IS NULL - GROUP BY s.id, sub.id, sub.subject_name, l.id, l.date - ), - ranked_lessons AS ( - SELECT - lesson_averages.*, - COUNT(*) OVER (PARTITION BY student_id, subject_id) AS lesson_count, - ROW_NUMBER() OVER ( - PARTITION BY student_id, subject_id - ORDER BY lesson_date ASC, lesson_id ASC - ) AS row_num - FROM lesson_averages - ), - first_lessons AS ( - SELECT - student_id, - subject_id, - subject_name, - lesson_date AS first_date, - average_mark AS first_avg, - lesson_count - FROM ranked_lessons - WHERE row_num = 1 - AND lesson_count >= 10 - ), - middle_lessons AS ( - SELECT - student_id, - subject_id, - lesson_date AS middle_date, - average_mark AS middle_avg - FROM ranked_lessons - WHERE row_num = ((lesson_count + 1) / 2) - AND lesson_count >= 10 - ), - last_lessons AS ( + WITH lesson_averages AS ( + SELECT + s.id AS student_id, + sub.id AS subject_id, + sub.subject_name AS subject_name, + l.id AS lesson_id, + l.date AS lesson_date, + AVG(g.mark) AS average_mark + FROM students s + JOIN grades g ON s.id = g.student_id + JOIN lessons l ON l.id = g.lesson_id + JOIN subjects sub ON sub.id = l.subject_id + WHERE s.id IN (#{student_ids.join(', ')}) + AND g.deleted_at IS NULL + AND s.deleted_at IS NULL + AND l.deleted_at IS NULL + AND sub.deleted_at IS NULL + GROUP BY s.id, sub.id, sub.subject_name, l.id, l.date + ), + ranked_lessons AS ( + SELECT + lesson_averages.*, + COUNT(*) OVER (PARTITION BY student_id, subject_id) AS lesson_count, + ROW_NUMBER() OVER ( + PARTITION BY student_id, subject_id + ORDER BY lesson_date ASC, lesson_id ASC + ) AS row_num + FROM lesson_averages + ), + first_lessons AS ( + SELECT + student_id, + subject_id, + subject_name, + lesson_date AS first_date, + average_mark AS first_avg, + lesson_count + FROM ranked_lessons + WHERE row_num = 1 + AND lesson_count >= 10 + ), + middle_lessons AS ( + SELECT + student_id, + subject_id, + lesson_date AS middle_date, + average_mark AS middle_avg + FROM ranked_lessons + WHERE row_num = ((lesson_count + 1) / 2) + AND lesson_count >= 10 + ), + last_lessons AS ( + SELECT + student_id, + subject_id, + lesson_date AS last_date, + average_mark AS last_avg + FROM ranked_lessons + WHERE row_num = lesson_count + AND lesson_count >= 10 + ) SELECT - student_id, - subject_id, - lesson_date AS last_date, - average_mark AS last_avg - FROM ranked_lessons - WHERE row_num = lesson_count - AND lesson_count >= 10 - ) - SELECT - first_lessons.student_id, - first_lessons.subject_id, - first_lessons.subject_name, - first_lessons.lesson_count, + first_lessons.student_id, + first_lessons.subject_id, + first_lessons.subject_name, + first_lessons.lesson_count, - first_lessons.first_date, - ROUND(first_lessons.first_avg::numeric, 2) AS first_avg, + first_lessons.first_date, + ROUND(first_lessons.first_avg::numeric, 2) AS first_avg, - middle_lessons.middle_date, - ROUND(middle_lessons.middle_avg::numeric, 2) AS middle_avg, + middle_lessons.middle_date, + ROUND(middle_lessons.middle_avg::numeric, 2) AS middle_avg, - last_lessons.last_date, - ROUND(last_lessons.last_avg::numeric, 2) AS last_avg, + last_lessons.last_date, + ROUND(last_lessons.last_avg::numeric, 2) AS last_avg, - ROUND((middle_lessons.middle_avg - first_lessons.first_avg)::numeric, 2) AS first_to_middle_diff, - ROUND((last_lessons.last_avg - middle_lessons.middle_avg)::numeric, 2) AS middle_to_last_diff, - ROUND((last_lessons.last_avg - first_lessons.first_avg)::numeric, 2) AS overall_diff - FROM first_lessons - JOIN middle_lessons - ON middle_lessons.student_id = first_lessons.student_id - AND middle_lessons.subject_id = first_lessons.subject_id - JOIN last_lessons - ON last_lessons.student_id = first_lessons.student_id - AND last_lessons.subject_id = first_lessons.subject_id - ORDER BY first_lessons.subject_name - SQL + ROUND((middle_lessons.middle_avg - first_lessons.first_avg)::numeric, 2) AS first_to_middle_diff, + ROUND((last_lessons.last_avg - middle_lessons.middle_avg)::numeric, 2) AS middle_to_last_diff, + ROUND((last_lessons.last_avg - first_lessons.first_avg)::numeric, 2) AS overall_diff + FROM first_lessons + JOIN middle_lessons + ON middle_lessons.student_id = first_lessons.student_id + AND middle_lessons.subject_id = first_lessons.subject_id + JOIN last_lessons + ON last_lessons.student_id = first_lessons.student_id + AND last_lessons.subject_id = first_lessons.subject_id + ORDER BY first_lessons.subject_name + SQL end end diff --git a/app/views/layouts/print.html.erb b/app/views/layouts/print.html.erb index e22f8f6cb..ffb5e26b7 100644 --- a/app/views/layouts/print.html.erb +++ b/app/views/layouts/print.html.erb @@ -14,92 +14,82 @@ <% end %> - -
-
- <%= yield %> +
+
+ <%= yield %> +
-
- + \ No newline at end of file From 4da7b2aad27347459b3de9d510bb5bd5b1aa8531 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Tue, 17 Mar 2026 17:27:57 +0100 Subject: [PATCH 05/12] feat: modified performance change and skill averages layout --- app/controllers/student_reports_controller.rb | 14 +- app/views/student_reports/show.html.erb | 268 +++++++++--------- 2 files changed, 135 insertions(+), 147 deletions(-) diff --git a/app/controllers/student_reports_controller.rb b/app/controllers/student_reports_controller.rb index 4e21f501a..6c12b4392 100644 --- a/app/controllers/student_reports_controller.rb +++ b/app/controllers/student_reports_controller.rb @@ -22,33 +22,31 @@ def fetch_performance_changes_by_subject(student_id) end # rubocop:disable Metrics/MethodLength - # rubocop:disable Metrics/CyclomaticComplexity - # rubocop:disable Metrics/AbcSize def skill_average_summaries grouped = {} StudentAverage.where(student_id: @student.id).load.each do |average| subject_name = average[:subject_name].to_s grouped[subject_name] ||= [] - grouped[subject_name] << { skill: average[:skill_name], average: average[:average_mark].to_f } + grouped[subject_name] << { + skill: average[:skill_name], + average: average[:average_mark].to_f + } end grouped.transform_values do |rows| strongest = rows.max_by { |row| row[:average] } weakest = rows.min_by { |row| row[:average] } + sorted_skills = rows.sort_by { |row| -row[:average] } { strongest: strongest, weakest: weakest, - other_skills: rows.reject do |row| - row[:skill] == strongest[:skill] || row[:skill] == weakest[:skill] - end + skills: sorted_skills } end end # rubocop:enable Metrics/MethodLength - # rubocop:enable Metrics/CyclomaticComplexity - # rubocop:enable Metrics/AbcSize def group_summaries groups_by_id = Group.where(id: @summaries.filter_map(&:group_id).uniq).index_by(&:id) diff --git a/app/views/student_reports/show.html.erb b/app/views/student_reports/show.html.erb index 129591c7e..1b8786edb 100644 --- a/app/views/student_reports/show.html.erb +++ b/app/views/student_reports/show.html.erb @@ -1,6 +1,8 @@ <% @components = [ - + { id: 'studentAverages', name: 'Student Averages' }, + { id: 'performanceChanges', name: 'Performance Changes' }, + { id: 'skillAverages', name: 'Skill Averages' }, ] %> @@ -18,183 +20,171 @@

Student Report - <%=@student.proper_name%>

-
-
- <%= render CommonComponents::Card.new(title: t(:student_averages)) do |card| %> - <% card.with_card_content do %> -
- <% end %> - <% end %> -
-
-
- <%= render CommonComponents::Card.new(title: t(:performance_change)) do |card| %> +
+ <%= render CommonComponents::Card.new(title: t(:student_averages)) do |card| %> <% card.with_card_content do %> - <% @performance_changes.each do |performance_change| %> - <% overall_diff = performance_change[:overall_diff].to_f %> - <% first_to_middle_diff = performance_change[:first_to_middle_diff].to_f %> - <% middle_to_last_diff = performance_change[:middle_to_last_diff].to_f %> - - <% overall_positive = overall_diff >= 0 %> - <% overall_bg_class = overall_positive ? "bg-green-50 border-green-200" : "bg-red-50 border-red-200" %> - <% overall_text_class = overall_positive ? "text-green-700" : "text-red-700" %> - <% overall_arrow = overall_positive ? "▲" : "▼" %> - -
-
-
- -
-

- <%= performance_change[:subject_name] %> Performance Summary -

- -
- - <%= overall_arrow %> - <%= overall_diff >= 0 ? "+" : "" %><%= performance_change[:overall_diff] %> - +
+ <% end %> + <% end %> +
-
-

- Overall change across the program -

-

- Based on <%= performance_change[:lesson_count] %> lessons -

-
+ <% unless @performance_changes.empty? %> +
+ <%= render CommonComponents::Card.new(title: t(:performance_change)) do |card| %> + <% card.with_card_content do %> +
+ <% @performance_changes.each do |performance_change| %> + <% first_to_middle_diff = performance_change[:first_to_middle_diff].to_f %> + <% middle_to_last_diff = performance_change[:middle_to_last_diff].to_f %> + <% overall_diff = performance_change[:overall_diff].to_f %> + +
+
+ <% end %> -
+
<%= render CommonComponents::Card.new(title: t(:skill_averages)) do |card| %> <% card.with_card_content do %> - <% @student_skill_averages.each do |subject_name, summary| %> -
-
-
-
-

- <%= subject_name %> Skill Averages -

-
+
+ <% @student_skill_averages.each do |subject_name, summary| %> + - <% end %> + <% end %> +
<% end %> <% end %>
<%= javascript_include_tag "new_charts", onload: 'window.onChartsLoad && window.onChartsLoad()' %> \ No newline at end of file From 3f121439685f06af77310c639f48f3655403d006 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Fri, 3 Apr 2026 11:38:13 +0200 Subject: [PATCH 08/12] remove unnecessary option --- app/views/student_reports/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/student_reports/show.html.erb b/app/views/student_reports/show.html.erb index cab150aee..1b8786edb 100644 --- a/app/views/student_reports/show.html.erb +++ b/app/views/student_reports/show.html.erb @@ -188,7 +188,7 @@ window.onChartsLoad = function () { let groupedSummaries = <%= @grouped_student_summaries.to_json.html_safe %>; - displayStudentReportPerformanceByGroupChart && displayStudentReportPerformanceByGroupChart("#student-history-chart", groupedSummaries, { title: "Student performance by lesson and group", heightPx: 100 }) + displayStudentReportPerformanceByGroupChart && displayStudentReportPerformanceByGroupChart("#student-history-chart", groupedSummaries, { title: "Student performance by lesson and group" }) } <%= javascript_include_tag "new_charts", onload: 'window.onChartsLoad && window.onChartsLoad()' %> \ No newline at end of file From 1136297d79455ad1e856d5362d00f33df34e4068 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Fri, 3 Apr 2026 16:51:00 +0200 Subject: [PATCH 09/12] feat: added multiple skill progress chart to student report --- app/assets/javascripts/new_charts.js | 129 +++++++++++++++++- app/controllers/student_reports_controller.rb | 50 +++++++ app/views/student_reports/show.html.erb | 15 ++ config/locales/en.yml | 1 + 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/new_charts.js b/app/assets/javascripts/new_charts.js index 8c3d5ea1b..b9efdf466 100644 --- a/app/assets/javascripts/new_charts.js +++ b/app/assets/javascripts/new_charts.js @@ -119,7 +119,6 @@ function polynomialLineForGroup(group, order = 4) { return null } - // Ensure numeric + sorted const pts = (group.data || []) .map(p => ({ x: Number(p.x), y: Number(p.y) })) .filter(p => Number.isFinite(p.x) && Number.isFinite(p.y)) @@ -133,20 +132,20 @@ function polynomialLineForGroup(group, order = 4) { const span = maxX - minX if (span === 0) return null - // Map x -> t in [-1, 1] const toT = (x) => ((x - minX) / span) * 2 - 1 const toX = (t) => minX + ((t + 1) / 2) * span - // Fit polynomial on t to avoid instability const data = pts.map(p => [toT(p.x), p.y]) const result = window.regression.polynomial(data, { order }) - // Sample a smooth curve in t-space const steps = 200 const curve = [] + for (let i = 0; i <= steps; i++) { const t = -1 + (2 * i) / steps - const y = result.predict(t)[1] + const rawY = result.predict(t)[1] + const y = Math.max(1, Math.min(7, rawY)) + curve.push({ x: toX(t), y }) } @@ -240,6 +239,53 @@ function buildDatasetsForStudentReportByGroups(groupedData) { return datasets } +function buildRegressionOnlyDatasetsForStudentSkills(skillSeriesJson, opts = {}) { + const items = Array.isArray(skillSeriesJson) ? skillSeriesJson : [] + const datasets = [] + + items.forEach((item, idx) => { + const skillName = item?.skill || `Skill ${idx + 1}` + const firstSeries = Array.isArray(item?.series) ? item.series[0] : null + const color = firstSeries?.color || colorForIndex(idx) + + const points = (firstSeries?.data || []) + .map((p) => ({ + x: p.x, + y: p.y, + lesson_url: p.lesson_url, + date: p.date + })) + .sort((a, b) => a.x - b.x) + + if (points.length < 2) return + + const group = { + id: skillName, + name: skillName, + data: points + } + + const curve = polynomialLineForGroup(group, opts.regressionOrder ?? 4) + if (!curve) return + + datasets.push({ + label: skillName, + type: "line", + data: curve, + parsing: false, + borderColor: color, + backgroundColor: color, + borderDash: [5, 5], + borderWidth: 3, + tension: 0, + pointRadius: 0, + pointHoverRadius: 0 + }) + }) + + return datasets +} + // ---------- Group Analytics Chart && Average performance per Group by Lesson chart --------- function displayAveragePerformancePerGroupByLessonChart(containerId, seriesJson, opts = {}) { if(!chartJsPresent()) return @@ -1375,4 +1421,77 @@ function displayStudentReportPerformanceByGroupChart(containerId, groupedData, o }, plugins: [whiteBackgroundPlugin()] }) +} + +// ---------- Student report chart: performance by lesson split by skill ---------- +function displayStudentSkillRegressionChart(containerId, skillSeriesJson, opts = {}) { + if (!chartJsPresent()) return + + const canvas = ensureCanvasIsPresent(containerId, { heightPx: opts.heightPx || 500 }) + if (!canvas) return + destroyIfExists(canvas) + + const datasets = buildRegressionOnlyDatasetsForStudentSkills(skillSeriesJson, opts) + + new Chart(canvas.getContext("2d"), { + data: { datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + clip: false, + plugins: { + title: { + display: true, + text: opts.title || "Student progress by skill", + font: { size: 20 } + }, + legend: { + display: true, + labels: { + padding: 10, + font: { size: 11 }, + boxHeight: 10, + boxWidth: 10 + } + }, + tooltip: { + backgroundColor: "#ffffff", + titleColor: "#111827", + bodyColor: "#374151", + borderColor: "#D1D5DB", + borderWidth: 2, + cornerRadius: 3, + padding: 12, + callbacks: { + title: (ctx) => ctx[0]?.dataset?.label || "", + label: (ctx) => [ + `${opts.xTitle || "Nr. of lessons"}: ${Math.round(ctx.parsed.x)}`, + `${opts.yTitle || "Performance"}: ${ctx.parsed.y.toFixed(2)}` + ] + } + }, + whiteBackground: { color: "white" } + }, + scales: { + x: { + type: "linear", + min: 1, + title: { display: true, text: opts.xTitle || "Nr. of lessons" }, + ticks: { precision: 0 } + }, + y: { + min: 1, + max: 7, + title: { display: true, text: opts.yTitle || "Performance" }, + ticks: { precision: 0 } + } + }, + onHover: function (event) { + const c = event.native?.target || event.chart?.canvas + if (!c) return + c.style.cursor = "default" + } + }, + plugins: [whiteBackgroundPlugin()] + }) } \ No newline at end of file diff --git a/app/controllers/student_reports_controller.rb b/app/controllers/student_reports_controller.rb index 57bb9c543..cf9a1ed73 100644 --- a/app/controllers/student_reports_controller.rb +++ b/app/controllers/student_reports_controller.rb @@ -12,6 +12,7 @@ def show @grouped_student_summaries = group_summaries @performance_changes = fetch_performance_changes_by_subject(@student.id) @student_skill_averages = skill_average_summaries + @performance_per_skill = performance_per_skill_single_student end private @@ -70,4 +71,53 @@ def to_lesson_summary(summary, groups_by_id, lessons_by_id) lesson_url: lesson ? lesson_url(lesson) : nil } end + + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize + def performance_per_skill_single_student + conn = ActiveRecord::Base.connection.raw_connection + student_name = @student.proper_name + + sql = Sql.performance_per_skill_in_lessons_per_student_query_with_dates([@student.id]) + rows = conn.exec_params(sql, [nil, nil]).values + + grouped = rows.each_with_object({}) do |row, acc| + lesson_index = row[0].to_i + 1 + average_mark = row[1].to_f + lesson_id = row[2] + lesson_date = row[3] + skill_name = row[4] + + acc[skill_name] ||= [] + acc[skill_name] << { + x: lesson_index, + y: average_mark, + lesson_url: lesson_path(lesson_id), + date: lesson_date + } + end + + grouped.map.with_index do |(skill_name, data), index| + { + skill: skill_name, + series: [ + { + name: student_name, + data: data, + color: get_color(index) + } + ] + } + end + end + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/AbcSize + + def colors + %w[#7cb5ec #434348 #90ed7d #f7a35c #8085e9 #f15c80 #e4d354 #2b908f #f45b5b #91e8e1] + end + + def get_color(index) + colors[index % colors.length] + end end diff --git a/app/views/student_reports/show.html.erb b/app/views/student_reports/show.html.erb index 1b8786edb..189e45d57 100644 --- a/app/views/student_reports/show.html.erb +++ b/app/views/student_reports/show.html.erb @@ -29,6 +29,14 @@ <% end %>
+
+ <%= render CommonComponents::Card.new(title: t(:student_skill_progress)) do |card| %> + <% card.with_card_content do %> +
+ <% end %> + <% end %> +
+ <% unless @performance_changes.empty? %>
<%= render CommonComponents::Card.new(title: t(:performance_change)) do |card| %> @@ -187,8 +195,15 @@ <%= javascript_include_tag "new_charts", onload: 'window.onChartsLoad && window.onChartsLoad()' %> \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index bf9679c7a..e178c7f11 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -218,6 +218,7 @@ en: invalid_user: User does not exist in MindLeaps student_average_marks: Student Average Marks student_averages: Student Averages + student_skill_progress: Student Skill Progress student_grades: Student Grades student_graded: Student graded. student_graded_text: Student %{student} graded. From cf955bc465b4ece1d2887eb9e8f30c3022141b04 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Sat, 4 Apr 2026 21:24:49 +0200 Subject: [PATCH 10/12] chore: add simple spec and correct section Ids --- app/views/student_reports/show.html.erb | 3 ++- spec/features/reports_features_spec.rb | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/views/student_reports/show.html.erb b/app/views/student_reports/show.html.erb index 189e45d57..b02fc40b5 100644 --- a/app/views/student_reports/show.html.erb +++ b/app/views/student_reports/show.html.erb @@ -1,6 +1,7 @@ <% @components = [ { id: 'studentAverages', name: 'Student Averages' }, + { id: 'studentSkillProgress', name: 'Student Skill Progress' }, { id: 'performanceChanges', name: 'Performance Changes' }, { id: 'skillAverages', name: 'Skill Averages' }, ] @@ -29,7 +30,7 @@ <% end %>
-
+
<%= render CommonComponents::Card.new(title: t(:student_skill_progress)) do |card| %> <% card.with_card_content do %>
diff --git a/spec/features/reports_features_spec.rb b/spec/features/reports_features_spec.rb index f9090eb7e..9ba08cd4a 100644 --- a/spec/features/reports_features_spec.rb +++ b/spec/features/reports_features_spec.rb @@ -13,8 +13,8 @@ @chapter = create :chapter, chapter_name: 'Report Chapter', organization: @organization @group = create :group, chapter: @chapter, group_name: 'Report Group' @empty_group = create :group, chapter: @chapter, group_name: 'Empty Report Group' - @first_student = create :graded_student, organization: @group, groups: [@group], subject: @first_subject, grades: { 'First Skill' => [1, 2, 3] } - @second_student = create :graded_student, organization: @group, groups: [@group], subject: @second_subject, grades: { 'Second Skill' => [1, 2, 3] } + @first_student = create :graded_student, organization: @organization, groups: [@group], subject: @first_subject, grades: { 'First Skill' => [1, 2, 3, 4, 5, 6, 7, 3, 5, 6, 1, 7] } + @second_student = create :graded_student, organization: @organization, groups: [@group], subject: @second_subject, grades: { 'Second Skill' => [1, 2, 3] } end it 'displays group and student averages', js: true, skip: 'Fails because we cannot close an open print preview window' do @@ -41,5 +41,15 @@ expect(page).to have_content('There are no students in this group') expect(page).to have_content('There is no data for this group yet') end + + it 'displays a generated student report', js: true do + visit "reports/students/#{@first_student.id}" + expect(page).to have_content("Student Report - #{@first_student.proper_name}") + + %w[studentAverages studentSkillProgress performanceChanges skillAverages].each do |s| + section = find("##{s}") + expect(section).to be_visible + end + end end end From 1b331fada81c5851f0fbcefe77c2da1e162fa740 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Mon, 6 Apr 2026 12:23:31 +0200 Subject: [PATCH 11/12] feat: added ability to generate student reports from the reports page --- app/controllers/reports_controller.rb | 1 + .../controllers/reports_filter_controller.js | 89 +++++++++++++++---- .../students/_student_select.html.erb | 8 +- app/views/reports/show.html.erb | 89 +++++++++++++++---- config/locales/en.yml | 3 + 5 files changed, 155 insertions(+), 35 deletions(-) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index ec4758528..8b8c83ec7 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -6,6 +6,7 @@ def show @available_organizations = policy_scope Organization.where(deleted_at: nil).order(:organization_name) @available_chapters = policy_scope Chapter.where(deleted_at: nil).order(:chapter_name) @available_groups = policy_scope Group.where(deleted_at: nil).order(:group_name) + @selected_student_id = params[:student_id] authorize Group, :index? end diff --git a/app/javascript/controllers/reports_filter_controller.js b/app/javascript/controllers/reports_filter_controller.js index 0e4443d8d..81f1fabac 100644 --- a/app/javascript/controllers/reports_filter_controller.js +++ b/app/javascript/controllers/reports_filter_controller.js @@ -9,10 +9,12 @@ function createOption(label, value) { return opt; } + export default class extends Controller { - static targets = ['select', 'anchor'] + static targets = ['select', 'anchor', 'studentAnchor'] static values = { - path: String + path: String, + studentPath: String } connect() { @@ -26,13 +28,35 @@ export default class extends Controller { return value } - updateFilter() { + updateFilter(event) { this.updateDropdown(this.selectTargets[0], JSON.parse(this.selectTargets[0].dataset.resources)) - const replaceValue = this.toId(this.selectTargets[this.selectTargets.length - 1].value) - this.anchorTarget.href = this.pathValue.replace("placeholder", replaceValue) - if(this.selectTargets[1].value.length === 0 || this.selectTargets[2].value.length === 0) this.disableAnchor() - else this.enableAnchor() + const changedName = event?.target?.dataset?.name + if (changedName === 'organization_id' || changedName === 'chapter_id' || changedName === 'group_id') { + this.refreshStudents() + } + + this.updateAnchors() + } + + updateAnchor() { + this.updateAnchors() + } + + updateAnchors() { + const groupSelect = this.selectTargets.find(t => t.dataset.name === 'group_id') + const studentSelect = this.selectTargets.find(t => t.dataset.name === 'student_id') + + const groupId = this.toId(groupSelect?.value) + const studentId = this.toId(studentSelect?.value) + + this.anchorTarget.href = groupId ? this.pathValue.replace("placeholder", groupId) : "#" + groupId ? this.enableAnchor(this.anchorTarget) : this.disableAnchor(this.anchorTarget) + + if (this.hasStudentAnchorTarget) { + this.studentAnchorTarget.href = studentId ? this.studentPathValue.replace("placeholder", studentId) : "#" + studentId ? this.enableAnchor(this.studentAnchorTarget) : this.disableAnchor(this.studentAnchorTarget) + } } updateDropdown(dropdown, values, parentIds) { @@ -59,20 +83,51 @@ export default class extends Controller { if (dependents) { dependents.forEach(dependentSelectName => { const targetSelect = this.selectTargets.find(t => t.dataset.name === dependentSelectName); - this.updateDropdown(targetSelect, JSON.parse(targetSelect.dataset.resources), valueExists ? [currentValue] : filteredIds) + if (targetSelect) { + this.updateDropdown( + targetSelect, + JSON.parse(targetSelect.dataset.resources), + valueExists ? [currentValue] : filteredIds + ) + } }) } } - disableAnchor() { - this.anchorTarget.style.pointerEvents="none" - this.anchorTarget.style.cursor='default' - this.anchorTarget.style.opacity = '0.5' + refreshStudents() { + const frame = document.getElementById('student_select_frame') + if (!frame) return + + const groupSelect = this.selectTargets.find(t => t.dataset.name === 'group_id') + const groupId = this.toId(groupSelect?.value) + + if (!groupId) { + frame.src = frame.dataset.srcBase + frame.reload() + this.updateAnchors() + return + } + + const params = new URLSearchParams() + params.append('group_ids[]', groupId) + + const studentSelect = this.selectTargets.find(t => t.dataset.name === 'student_id') + const currentStudentId = this.toId(studentSelect?.value) + if (currentStudentId) params.set('student_id', currentStudentId) + + frame.src = `${frame.dataset.srcBase}?${params.toString()}` + frame.reload() + } + + disableAnchor(anchor) { + anchor.style.pointerEvents = "none" + anchor.style.cursor = "default" + anchor.style.opacity = "0.5" } - enableAnchor() { - this.anchorTarget.style.pointerEvents='' - this.anchorTarget.style.cursor='pointer' - this.anchorTarget.style.opacity = '1' + enableAnchor(anchor) { + anchor.style.pointerEvents = "" + anchor.style.cursor = "pointer" + anchor.style.opacity = "1" } -} +} \ No newline at end of file diff --git a/app/views/analytics/students/_student_select.html.erb b/app/views/analytics/students/_student_select.html.erb index 8b82f307b..0c2ab74c1 100644 --- a/app/views/analytics/students/_student_select.html.erb +++ b/app/views/analytics/students/_student_select.html.erb @@ -1,9 +1,11 @@ -<% placeholder = disabled ? t(:select_groups_to_load_students) : t(:all) %> +<% placeholder = disabled ? t(:select_groups_to_load_students) : t(:select_student) %> + <%= select_tag :student_id, options_for_select([[placeholder, ""]] + students.map { |s| [s.proper_name, s.id] }, selected_student_id), disabled: disabled, class: "disabled:bg-gray-300 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", "data-analytics-filter-target" => "select", + "data-reports-filter-target" => "select", "data-name" => "student_id", - "data-action" => "analytics-filter#updateAnchor" -%> + "data-action" => "change->analytics-filter#updateAnchor change->reports-filter#updateAnchors" +%> \ No newline at end of file diff --git a/app/views/reports/show.html.erb b/app/views/reports/show.html.erb index f632c33d3..af378bf7a 100644 --- a/app/views/reports/show.html.erb +++ b/app/views/reports/show.html.erb @@ -1,22 +1,81 @@ <% content_for :title, t(:reports).capitalize %> -
-
-
-
- <%= label_tag :organization_label, "Organization", class: 'block text-sm font-medium text-gray-700' %> - <%= select_tag :organization_select, options_from_collection_for_select(@available_organizations, :id, :organization_name), :class => 'mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md', :prompt => 'All', 'data-reports-filter-target' => 'select', 'data-action' => 'reports-filter#updateFilter', 'data-name' => 'organization_id', 'data-dependents' => ['chapter_id'].to_json, 'data-resources' => @available_organizations.map { |o| { id: o.id, label: o.organization_name} }.to_json %> + +
+
+
+
+ <%= label_tag :organization_label, "Organization", class: "block text-sm font-medium text-gray-700" %> + <%= select_tag :organization_select, + options_from_collection_for_select(@available_organizations, :id, :organization_name), + class: "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + prompt: "All", + "data-reports-filter-target" => "select", + "data-action" => "reports-filter#updateFilter", + "data-name" => "organization_id", + "data-dependents" => ["chapter_id"].to_json, + "data-resources" => @available_organizations.map { |o| { id: o.id, label: o.organization_name } }.to_json %>
-
- <%= label_tag :chapter_label, "Chapter", class: 'block text-sm font-medium text-gray-700' %> - <%= select_tag :chapter_select, options_from_collection_for_select(@available_chapters, :id, :chapter_name), :class => 'mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md', :prompt => 'All', 'data-reports-filter-target' => 'select', 'data-action' => 'reports-filter#updateFilter', 'data-name' => 'chapter_id', 'data-dependents' => ['group_id'].to_json, 'data-resources' => @available_chapters.map { |c| { id: c.id, label: c.chapter_name, depend_id: c.organization_id } }.to_json %> + +
+ <%= label_tag :chapter_label, "Chapter", class: "block text-sm font-medium text-gray-700" %> + <%= select_tag :chapter_select, + options_from_collection_for_select(@available_chapters, :id, :chapter_name), + class: "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + prompt: "All", + "data-reports-filter-target" => "select", + "data-action" => "reports-filter#updateFilter", + "data-name" => "chapter_id", + "data-dependents" => ["group_id"].to_json, + "data-resources" => @available_chapters.map { |c| { id: c.id, label: c.chapter_name, depend_id: c.organization_id } }.to_json %>
-
- <%= label_tag :group_label, "Group", class: 'block text-sm font-medium text-gray-700' %> - <%= select_tag :group_select, options_from_collection_for_select(@available_groups, :id, :group_name), :class => 'mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md', 'data-reports-filter-target' => 'select', 'data-action' => 'reports-filter#updateFilter', 'data-name' => 'group_id', 'data-resources' => @available_groups.map { |g| { id: g.id, label: g.group_name, depend_id: g.chapter_id } }.to_json %> + +
+ <%= label_tag :group_label, "Group", class: "block text-sm font-medium text-gray-700" %> + <%= select_tag :group_select, + options_from_collection_for_select(@available_groups, :id, :group_name), + class: "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + "data-reports-filter-target" => "select", + "data-action" => "reports-filter#updateFilter", + "data-name" => "group_id", + "data-resources" => @available_groups.map { |g| { id: g.id, label: g.group_name, depend_id: g.chapter_id } }.to_json %>
-
- <%= render CommonComponents::ButtonComponent.new(label: t(:generate_report), options: { 'data-reports-filter-target' => 'anchor', 'target' => '_blank' })%> + +
+ <%= label_tag :student_label, "Student", class: "block text-sm font-medium text-gray-700" %> + <%= turbo_frame_tag "student_select_frame", + data: { src_base: analytics_student_select_path }, + "data-action" => "turbo:frame-load->reports-filter#updateAnchors" do %> + <%= render "analytics/students/student_select", + students: [], + selected_student_id: nil, + disabled: true %> + <% end %> +
+ +
+ <%= render CommonComponents::ButtonComponent.new( + label: t(:generate_group_report), + options: { + "data-reports-filter-target" => "anchor", + "target" => "_blank", + "class" => "whitespace-nowrap" + } + ) %> + + <%= render CommonComponents::ButtonComponent.new( + label: t(:generate_student_report), + options: { + "data-reports-filter-target" => "studentAnchor", + "target" => "_blank", + "class" => "whitespace-nowrap" + } + ) %>
-
+
\ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index e178c7f11..cf91ffb69 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -147,6 +147,7 @@ en: unable_to_update_student: Unable to update student all: All select_groups_to_load_students: Select groups to load students + select_student: Select Student cannot_change_organization_existing_student: Cannot change the organization of an existing student number_of_students: Number of Students number_of_groups: Number of Groups @@ -320,6 +321,8 @@ en: total: Total reports: Reports generate_report: Generate Report + generate_group_report: Generate Group Report + generate_student_report: Generate Student Report report_controls: Report Controls analytics: Analytics general_analytics: General Analytics From a10420712fd0148973cb0b94493ad3a9b206e081 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Mon, 6 Apr 2026 12:36:53 +0200 Subject: [PATCH 12/12] chroe: add to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d59aa68..236316038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## Unreleased +- Added ability to generate student-level reports - Defaulted starting date for analytics to the last lesson's month - Replaced existing charts using the `Chart.js` library - Fixed issue where group reports crashed when students had no summaries