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 diff --git a/app/assets/javascripts/new_charts.js b/app/assets/javascripts/new_charts.js index df80fbfb9..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 }) } @@ -196,6 +195,97 @@ 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 +} + +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 @@ -674,12 +764,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 @@ -1233,4 +1323,175 @@ 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()] + }) +} + +// ---------- 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/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/controllers/student_reports_controller.rb b/app/controllers/student_reports_controller.rb new file mode 100644 index 000000000..cf9a1ed73 --- /dev/null +++ b/app/controllers/student_reports_controller.rb @@ -0,0 +1,123 @@ +class StudentReportsController < HtmlController + include CollectionHelper + + skip_after_action :verify_policy_scoped + layout 'print' + + def show + @student = Student.find(params[:id]) + authorize @student + + @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 + @performance_per_skill = performance_per_skill_single_student + 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 + + # rubocop:disable Metrics/MethodLength + 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] } + sorted_skills = rows.sort_by { |row| -row[:average] } + + { + strongest: strongest, + weakest: weakest, + skills: sorted_skills + } + end + end + # rubocop:enable Metrics/MethodLength + + 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 #{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 + + # 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/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/lib/sql.rb b/app/lib/sql.rb index 804d1d743..553976db6 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/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/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/layouts/print.html.erb b/app/views/layouts/print.html.erb index e22f8f6cb..171a6246c 100644 --- a/app/views/layouts/print.html.erb +++ b/app/views/layouts/print.html.erb @@ -14,92 +14,81 @@ <% end %> - -
-
- <%= yield %> +
+
+ <%= yield %> +
-
- + \ 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/app/views/student_reports/show.html.erb b/app/views/student_reports/show.html.erb new file mode 100644 index 000000000..b02fc40b5 --- /dev/null +++ b/app/views/student_reports/show.html.erb @@ -0,0 +1,210 @@ +<% + @components = [ + { id: 'studentAverages', name: 'Student Averages' }, + { id: 'studentSkillProgress', name: 'Student Skill Progress' }, + { id: 'performanceChanges', name: 'Performance Changes' }, + { id: 'skillAverages', name: 'Skill Averages' }, + ] +%> + +
+
+ <%= render CommonComponents::Card.new(title: t(: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%>

+ +
+ <%= render CommonComponents::Card.new(title: t(:student_averages)) do |card| %> + <% card.with_card_content do %> +
+ <% end %> + <% 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| %> + <% 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 %> +
+ <% end %> + <% 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| %> + + <% 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 f6b22f86e..7c073fcad 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), 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 54fc97e78..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 @@ -217,6 +218,8 @@ 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_skill_progress: Student Skill Progress student_grades: Student Grades student_graded: Student graded. student_graded_text: Student %{student} graded. @@ -283,6 +286,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 @@ -317,6 +321,9 @@ 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 group_analytics: Group Analytics 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] 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