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 @@