diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17b51e81b..028deaa21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,8 @@
## Unreleased
+- Updated database engine versions in pulumi
+- Modified current Group analytics tab to use new charting logic
+- Added UI component for multiselect capabilities
+- Added new charting library using vendored `chart.js` and `regression.js`
- Fixed student lesson summaries showing empty on report screen
## 0.39.0
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index 5cc1b40ac..c4dd3227e 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -3,6 +3,7 @@
//= link tailwind.css
//= link application.js
//= link charts.js
+//= link new_charts.js
//= link analytics.js
//= link pikaday.js
//= link frappe-gantt.css
diff --git a/app/assets/images/checkmark.svg b/app/assets/images/checkmark.svg
new file mode 100644
index 000000000..93e35e7b7
--- /dev/null
+++ b/app/assets/images/checkmark.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/javascripts/new_charts.js b/app/assets/javascripts/new_charts.js
new file mode 100644
index 000000000..93bfe2e8c
--- /dev/null
+++ b/app/assets/javascripts/new_charts.js
@@ -0,0 +1,260 @@
+//= require chartjs.js
+//= require regression.js
+
+function whiteBackgroundPlugin() {
+ return {
+ id: "whiteBackground",
+ beforeDraw: (chart, args, options) => {
+ const { ctx, canvas } = chart
+
+ ctx.save()
+ ctx.globalCompositeOperation = "destination-over"
+ ctx.fillStyle = (options && options.color) ? options.color : "white"
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
+ ctx.restore()
+ }
+ }
+}
+
+function polynomialLineForGroup(group, order = 4) {
+ if (!window.regression) {
+ console.error("regression-js not found (window.regression undefined).")
+ 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))
+ .sort((a, b) => a.x - b.x)
+
+ if (pts.length < order + 1) return null
+
+ const xs = pts.map(p => p.x)
+ const minX = Math.min(...xs)
+ const maxX = Math.max(...xs)
+ 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]
+ curve.push({ x: toX(t), y })
+ }
+
+ return curve
+}
+
+function colorForIndex(i) {
+ const colors = ["#7cb5ec", "#434348", "#90ed7d", "#f7a35c", "#8085e9", "#f15c80", "#e4d354", "#2b908f", "#f45b5b", "#91e8e1"]
+
+ return colors[i % colors.length]
+}
+
+function withAlpha(hex, a) {
+ // accepts "#RRGGBB"
+ const r = parseInt(hex.slice(1, 3), 16)
+ const g = parseInt(hex.slice(3, 5), 16)
+ const b = parseInt(hex.slice(5, 7), 16)
+
+ return `rgba(${r}, ${g}, ${b}, ${a})`
+}
+
+function buildDatasetsForGroups(groups) {
+ const datasets = []
+
+ groups.forEach((g, idx) => {
+ const color = colorForIndex(idx)
+
+ // 1) scatter points
+ datasets.push({
+ label: g.name,
+ type: "scatter",
+ groupKey: g.id,
+ data: (g.data || []).map(p => ({
+ x: p.x,
+ y: p.y,
+ lesson_url: p.lesson_url,
+ date: p.date
+ })),
+ pointRadius: 3,
+ backgroundColor: color,
+ borderColor: color
+ })
+
+ // 2) polynomial regression line for that group
+ const curve = polynomialLineForGroup(g, 4)
+ if (curve) {
+ datasets.push({
+ label: `${g.name} - Regression`,
+ groupKey: g.id,
+ type: "line",
+ data: curve,
+ pointRadius: 0,
+ borderWidth: 3,
+ borderColor: withAlpha(color, .75),
+ borderDash: [5, 5],
+ tension: 0,
+ hiddenFromLegend: true
+ })
+ }
+ })
+
+ return datasets
+}
+
+function displayAveragePerformancePerGroupByLesson(groups) {
+ const canvas = document.getElementById("groups-performance-chart")
+ if (!canvas) return
+ if (!window.Chart) {
+ console.error("Chart.js not found (window.Chart undefined).")
+ return
+ }
+
+ const datasets = buildDatasetsForGroups(groups)
+
+ if (canvas.__chart) canvas.__chart.destroy()
+
+ canvas.__chart = new Chart(canvas.getContext("2d"), {
+ data: { datasets },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ clip: false,
+ animation: {
+ duration: 1000,
+ easing: "easeOutQuart",
+ delay: (ctx) => {
+ if (ctx.type !== "data") return 0
+ const ds = ctx.chart.data.datasets[ctx.datasetIndex]
+ if (ds.type === "line") return 0
+ return ctx.dataIndex * 12
+ }
+ },
+ plugins: {
+ legend: {
+ display: true,
+ labels:
+ {
+ padding: 30,
+ font: {
+ size: 15
+ },
+ filter: (legendItem, chartData) => {
+ const dataSet = chartData.datasets[legendItem.datasetIndex]
+
+ return !dataSet.hiddenFromLegend
+ }
+ },
+ onClick: (e, legendItem, legend) => {
+ const chart = legend.chart
+ const clickedDataset = chart.data.datasets[legendItem.datasetIndex]
+
+ // Determine new hidden state: toggle based on the scatter dataset state
+ const scatterMeta = chart.getDatasetMeta(legendItem.datasetIndex)
+ const nextHidden = !scatterMeta.hidden
+
+ // Apply to all datasets with same groupKey (scatter + regression)
+ chart.data.datasets.forEach((dataset, i) => {
+ if (dataset.groupKey === clickedDataset.groupKey) {
+ chart.getDatasetMeta(i).hidden = nextHidden
+ }
+ })
+
+ chart.update()
+ }
+ },
+ 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){
+ if(context[0].dataset.type === 'line') return null
+ return context[0].dataset.label
+ },
+
+ label: function(context) {
+ if (context.dataset.type === "line") return null
+
+ const raw = context.raw || {}
+ const x = context.parsed.x
+ const y = context.parsed.y
+ const parts = [`Lesson Number: ${x}`, `Average: ${y}`]
+
+ if (raw.date) {
+ parts.push(`Date: ${raw.date}`)
+ }
+
+ return parts
+ }
+ }
+ },
+ whiteBackground: {
+ color: 'white'
+ }
+ },
+ scales: {
+ x: {
+ min: 1,
+ title: { display: true, text: "Nr. of lessons" },
+ ticks: { precision: 0 }
+ },
+ y: {
+ title: { display: true, text: "Performance" },
+ min: 1,
+ max: 7,
+ ticks: { precision: 0 }
+ }
+ },
+ onClick: function (event, elements) {
+ if (!elements || elements.length === 0) return
+ const el = elements[0]
+ const ds = this.data.datasets[el.datasetIndex]
+
+ // ignore regression line clicks
+ if (ds.type === "line") return
+
+ const point = ds.data[el.index]
+ if (point && point.lesson_url) window.open(point.lesson_url, "_blank")
+ },
+ onHover: function (event, elements) {
+ const canvas = event.native?.target || event.chart?.canvas
+ if (!canvas) return
+
+ if (elements.length > 0) {
+ const el = elements[0]
+ const ds = this.data.datasets[el.datasetIndex]
+
+ canvas.style.cursor = ds.type === "scatter" ? "pointer" : "default"
+ } else {
+ canvas.style.cursor = "default"
+ }
+ }
+ },
+ plugins: [whiteBackgroundPlugin()]
+ })
+}
\ No newline at end of file
diff --git a/app/components/common_components/multiselect_component.rb b/app/components/common_components/multiselect_component.rb
new file mode 100644
index 000000000..de21deabd
--- /dev/null
+++ b/app/components/common_components/multiselect_component.rb
@@ -0,0 +1,37 @@
+class CommonComponents::MultiselectComponent < ViewComponent::Base
+ erb_template <<~ERB
+
+
+
+ <% @selected_values&.each do |value| %>
+
+ <% end %>
+
+
+
+
+ <% @options.each do |option| %>
+
+ <%= option[:label] %>
+ <%= helpers.inline_svg_tag("checkmark.svg", class: "w-4 h-4 text-green-600 checkmark hidden") %>
+
+ <% end %>
+
+
+ ERB
+
+ def initialize(label:, target:, options:, selected_values: nil)
+ @label = label
+ @target = target
+ @options = options
+ @selected_values = selected_values
+ end
+end
diff --git a/app/components/datepicker.rb b/app/components/datepicker.rb
index d57c1036d..070c50f30 100644
--- a/app/components/datepicker.rb
+++ b/app/components/datepicker.rb
@@ -7,7 +7,7 @@ class Datepicker < ViewComponent::Base
<%= @form.text_field @target, data: { 'datepicker-target' => 'picker' },
class: @custom_class || 'rounded-md border-purple-500 text-sm focus:border-green-600 focus:outline-hidden focus:ring-green-600', autocomplete: 'disabled' %>
<% elsif @custom_name %>
- <%= text_field_tag @custom_name, @date, data: { 'datepicker-target' => 'picker' },
+ <%= text_field_tag @custom_name, @date, data: { 'datepicker-target' => 'picker', 'action' => 'change->datepicker#onChange' },
class: @custom_class || 'rounded-md border-purple-500 text-sm focus:border-green-600 focus:outline-hidden focus:ring-green-600', autocomplete: 'disabled' %>
<% else %>
<%= input_field %>
diff --git a/app/controllers/analytics/group_controller.rb b/app/controllers/analytics/group_controller.rb
index fd00929be..e8171041d 100644
--- a/app/controllers/analytics/group_controller.rb
+++ b/app/controllers/analytics/group_controller.rb
@@ -1,44 +1,52 @@
module Analytics
class GroupController < AnalyticsController
def index
- # figure 8: Average performance per group by days in program
- # Rebecca requested a Trellis per Group
- @group_series = performance_per_group
+ @selected_group_ids = params[:group_ids]
+ @from = params[:from_date] || Date.new(Date.current.year, 1, 1)
+ @to = params[:to_date] || Date.current
+ @group_series = performance_per_group_by_lesson
end
- # rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
- def performance_per_group
- groups_lesson_summaries = if selected_param_present_but_not_all?(@selected_group_id)
- GroupLessonSummary.joins(:group).where(group_id: @selected_group_id, groups: { deleted_at: nil })
- elsif selected_param_present_but_not_all?(@selected_chapter_id)
- GroupLessonSummary.joins(:group).where(groups: { deleted_at: nil }).where(chapter_id: @selected_chapter_id)
- else
- GroupLessonSummary.joins(:group).where(groups: { deleted_at: nil }).joins(:chapter).where(chapters: { organization_id: @selected_organization_id })
- end
+ def performance_per_group_by_lesson
+ selected_groups = if @selected_group_ids.present?
+ Group.where(id: @selected_group_ids, deleted_at: nil)
+ elsif selected_param_present_but_not_all?(@selected_chapter_id)
+ Group.where(chapter_id: @selected_chapter_id, deleted_at: nil)
+ else
+ Group.joins(:chapter).where(chapters: { organization_id: @selected_organization_id }).where(deleted_at: nil)
+ end
+ groups = Array(selected_groups)
+ conn = ActiveRecord::Base.connection.raw_connection
- groups_lesson_summaries
- .group_by(&:group_id)
- .map do |group_id, summaries|
- group_series = []
- group_series << {
- name: summaries[0].group_chapter_name,
- data: summaries.map.with_index { |summary, i| { x: i + 1, y: summary.average_mark, date: summary.lesson_date, lesson_url: lesson_path(summary.lesson_id), grade_count: summary.grade_count } },
- regression: summaries.length > 1,
- color: get_color(0),
- regressionSettings: {
- type: 'polynomial',
- order: 4,
- color: get_color(0),
- name: "#{t(:group)} #{summaries[0].group_chapter_name} - Regression",
- lineWidth: 1
- }
- }
- { group: "#{t(:group)} #{summaries[0].group_chapter_name}", series: group_series, group_id: }
- end
- .sort_by { |e| e[:group_id] }
+ groups.map do |group|
+ sql = Sql.average_mark_for_group_lessons
+ params = [
+ group.id,
+ @from,
+ @to
+ ]
+ result = conn.exec_params(sql, params).values
+
+ {
+ id: group.id,
+ name: "#{t(:group)} #{group.group_chapter_name}",
+ data: format_point_data(result)
+ }
+ end
end
- # rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
+
+ def format_point_data(data)
+ data.map do |e|
+ {
+ # Increment 'No. of Lessons' to start from 1
+ x: e[0] + 1,
+ y: e[1],
+ lesson_url: lesson_path(e[2]),
+ date: e[3]
+ }
+ end
+ end
end
end
diff --git a/app/javascript/controllers/analytics_filter_controller.js b/app/javascript/controllers/analytics_filter_controller.js
index bb58abe74..f170d65d5 100644
--- a/app/javascript/controllers/analytics_filter_controller.js
+++ b/app/javascript/controllers/analytics_filter_controller.js
@@ -11,17 +11,50 @@ function createOption(label, value) {
}
export default class extends Controller {
- static targets = ['select', 'anchor']
+ static targets = ['select', 'anchor', 'multiselect', 'date']
connect() {
+ this.allowedIdsByName = {}
this.updateFilter()
}
updateFilter() {
+ // filter dropdowns
this.updateDropdown(this.selectTargets[0], JSON.parse(this.selectTargets[0].dataset.resources))
- this.anchorTarget.href = this.selectTargets.reduce((acc, e) => {
- return acc + e.getAttribute('data-name') + '=' + this.toId(e.value) + '&'
- }, '?')
+
+ // filter multiselect dropdown
+ this.updateMultiselectOptions()
+
+ // update anchor link
+ this.updateAnchor()
+ }
+
+ updateAnchor() {
+ const params = new URLSearchParams()
+
+ // add select params
+ this.selectTargets.forEach(e => {
+ const value = this.toId(e.value)
+ params.set(e.getAttribute('data-name'), value)
+ })
+
+ // add multiselect params (hidden inputs)
+ this.multiselectTargets.forEach(wrapper => {
+ const inputs = wrapper.querySelectorAll('input[type="hidden"]')
+ inputs.forEach(i => {
+ if (i.value !== '') params.append(i.name, i.value)
+ })
+ })
+
+ // add date params
+ this.dateTargets.forEach(wrapper => {
+ const inputs = wrapper.querySelectorAll('input')
+ inputs.forEach(i => {
+ if (i.value !== '') params.append(i.name, i.value)
+ })
+ })
+
+ this.anchorTarget.href = `?${params.toString()}`
}
toId(value) {
@@ -46,6 +79,9 @@ export default class extends Controller {
return v.id;
});
+ // store allowed ids for this select to use in multiselect component
+ this.allowedIdsByName[dropdown.dataset.name] = filteredIds
+
if (valueExists) {
dropdown.value = currentValue;
}
@@ -54,8 +90,43 @@ 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)
+ }
})
}
}
+
+ updateMultiselectOptions() {
+ // possible select dropdowns are for: organization_id, chapter_id
+ const orgSelect = this.selectTargets.find(t => t.dataset.name === "organization_id")
+ const chapterSelect = this.selectTargets.find(t => t.dataset.name === "chapter_id")
+ const orgId = this.toId(orgSelect?.value) // "" when All
+ const chapterId = this.toId(chapterSelect?.value) // "" when All
+
+ let allowedChapterIds = []
+
+ if (chapterId) {
+ // specific chapter selected
+ allowedChapterIds = [chapterId]
+ } else if (orgId) {
+ // specific org selected, chapter = All => use the allowed chapters computed by updateDropdown
+ allowedChapterIds = (this.allowedIdsByName["chapter_id"] || []).map(String)
+ } else {
+ // org = All and chapter = All => no filtering, show all groups
+ allowedChapterIds = []
+ }
+
+ this.multiselectTargets.forEach(wrapper => {
+ const el = wrapper.querySelector('[data-controller~="multiselect"]')
+
+ if (!el) {
+ return
+ }
+
+ const controller = this.application.getControllerForElementAndIdentifier(el, "multiselect")
+ controller?.filterOptions(allowedChapterIds)
+ })
+ }
}
diff --git a/app/javascript/controllers/charts_download_controller.js b/app/javascript/controllers/charts_download_controller.js
new file mode 100644
index 000000000..54370617f
--- /dev/null
+++ b/app/javascript/controllers/charts_download_controller.js
@@ -0,0 +1,33 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="charts_download"
+export default class extends Controller {
+ static targets = ["canvas"]
+
+ download() {
+ const canvas = this.canvasTarget
+ const chart = canvas.__chart
+
+ // ensure latest draw (and background plugin) has run
+ if (chart) {
+ chart.update("none")
+ }
+
+ // Best quality + correct filename: use toBlob
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ return
+ }
+
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+
+ a.href = url
+ a.download = "group-performance-chart.png"
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ URL.revokeObjectURL(url)
+ }, "image/png")
+ }
+}
diff --git a/app/javascript/controllers/datepicker_controller.js b/app/javascript/controllers/datepicker_controller.js
index 16cd87949..6c4f25459 100644
--- a/app/javascript/controllers/datepicker_controller.js
+++ b/app/javascript/controllers/datepicker_controller.js
@@ -14,6 +14,10 @@ export default class extends Controller {
this.anchorTarget.href = url.href
}
+ onChange() {
+ this.element.dispatchEvent(new CustomEvent("datepicker:change", { bubbles: true }))
+ }
+
connect() {
const picker = new Pikaday({
field: this.pickerTarget,
diff --git a/app/javascript/controllers/multiselect_controller.js b/app/javascript/controllers/multiselect_controller.js
new file mode 100644
index 000000000..3d0a86460
--- /dev/null
+++ b/app/javascript/controllers/multiselect_controller.js
@@ -0,0 +1,129 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="multiselect"
+export default class extends Controller {
+ static targets = ["menu", "option", "hiddenField", "label"]
+ static values = { label: String, selectName: String }
+
+ connect() {
+ // Collect initial values from hidden inputs
+ this.selected = [...this.hiddenFieldTarget.querySelectorAll("input")].map(input => input.value).filter(v => v !== "")
+ this.applyInitialSelection()
+ this.updateLabel()
+
+ // Close menu if anything outside is clicked
+ this.outsideClick = this.handleClickOutside.bind(this)
+ document.addEventListener("click", this.outsideClick)
+
+ // Notify other controllers of readyness
+ this.element.dispatchEvent(new CustomEvent("multiselect:ready", { bubbles: true }))
+ }
+
+ disconnect() {
+ document.removeEventListener("click", this.outsideClick)
+ }
+
+ handleClickOutside(event) {
+ if (!this.element.contains(event.target)) {
+ this.menuTarget.classList.add("hidden")
+ }
+ }
+
+ applyInitialSelection() {
+ this.optionTargets.forEach(opt => {
+ const id = opt.dataset.value
+
+ if (this.selected.includes(id)) {
+ opt.classList.add("text-green-600")
+ opt.querySelector(".checkmark").classList.remove("hidden")
+ }
+ })
+ }
+
+ toggleMenu(event) {
+ event.stopPropagation()
+ this.menuTarget.classList.toggle("hidden")
+ }
+
+ toggleOption(event) {
+ const item = event.currentTarget
+ const id = item.dataset.value
+
+ if (this.selected.includes(id)) {
+ this.selected = this.selected.filter(s => s !== id)
+ item.classList.remove("text-green-600")
+ item.querySelector(".checkmark").classList.add("hidden")
+ } else {
+ this.selected.push(id)
+ item.classList.add("text-green-600")
+ item.querySelector(".checkmark").classList.remove("hidden")
+ }
+
+ this.rebuildHiddenInputs()
+ this.updateLabel()
+
+ // notify other controllers listening for option toggles
+ this.element.dispatchEvent(new CustomEvent("multiselect:change", { bubbles: true, detail: { name: this.selectNameValue, selected: this.selected } }))
+ }
+
+ rebuildHiddenInputs() {
+ this.hiddenFieldTarget.innerHTML = ""
+
+ if(this.selected.length ===0){
+ const blank = document.createElement("input")
+ blank.type = "hidden"
+ blank.name = this.selectNameValue
+ blank.value = ''
+ this.hiddenFieldTarget.appendChild(blank)
+ return
+ }
+
+ this.selected.forEach(id => {
+ const input = document.createElement("input")
+ input.type = "hidden"
+ input.name = this.selectNameValue
+ input.value = id
+ this.hiddenFieldTarget.appendChild(input)
+ })
+ }
+
+ updateLabel() {
+ if (this.selected.length === 0) {
+ this.labelTarget.textContent = this.labelValue
+ } else {
+ this.labelTarget.textContent = `${this.selected.length} selected`
+ }
+ }
+
+ // callable method for filtering options by external id values
+ filterOptions(parentIds) {
+ const parents = (parentIds || []).map(String)
+
+ // If no filter list provided, show everything
+ const filterActive = parents.length > 0
+
+ let selectionChanged = false
+
+ this.optionTargets.forEach(opt => {
+ const id = opt.dataset.value
+ const dependId = opt.dataset.dependId ? String(opt.dataset.dependId) : ""
+ const visible = !filterActive || (dependId && parents.includes(dependId))
+
+ opt.classList.toggle("hidden", !visible)
+
+ // If it became invalid, auto-deselect it
+ if (!visible && this.selected.includes(id)) {
+ this.selected = this.selected.filter(s => s !== id)
+ opt.classList.remove("text-green-600")
+ opt.querySelector(".checkmark").classList.add("hidden")
+ selectionChanged = true
+ }
+ })
+
+ if (selectionChanged) {
+ this.rebuildHiddenInputs()
+ this.updateLabel()
+ this.element.dispatchEvent(new CustomEvent("multiselect:change", { bubbles: true }))
+ }
+ }
+}
diff --git a/app/lib/sql.rb b/app/lib/sql.rb
index fe1ded86c..1f5c043c9 100644
--- a/app/lib/sql.rb
+++ b/app/lib/sql.rb
@@ -73,4 +73,22 @@ def self.average_mark_in_group_lessons(group)
group by l.id;
SQL
end
+
+ def self.average_mark_for_group_lessons
+ <<~SQL.squish
+ SELECT
+ row_number() OVER (ORDER BY l.date) - 1 AS idx,
+ round(avg(g.mark), 2)::FLOAT AS avg_mark,
+ l.id AS lesson_id,
+ l.date AS lesson_date
+ FROM lessons l
+ JOIN grades g ON g.lesson_id = l.id
+ WHERE l.group_id = $1
+ AND g.deleted_at IS NULL
+ AND ($2::date IS NULL OR l.date >= $2::date)
+ AND ($3::date IS NULL OR l.date <= $3::date)
+ GROUP BY l.id, l.date
+ ORDER BY l.date;
+ SQL
+ end
end
diff --git a/app/views/analytics/group/index.html.erb b/app/views/analytics/group/index.html.erb
index 8956ff9a4..f779778ec 100644
--- a/app/views/analytics/group/index.html.erb
+++ b/app/views/analytics/group/index.html.erb
@@ -3,7 +3,7 @@
<%= render LayoutComponents::HeaderComponent.new(current_user: current_user, tabs: [
{ title: t(:general_analytics).capitalize, href: general_analytics_url },
{ title: t(:subject_analytics).capitalize, href: subject_analytics_url },
- { title: t(:group_analytics).capitalize, href: '' }
+ { title: t(:group_analytics).capitalize, href: '' },
]) do |h| %>
<% h.with_left do %>
<% end %>
@@ -20,108 +20,47 @@
<%= select_tag :chapter_select, options_from_collection_for_select(@available_chapters, :id, :chapter_name, @selected_chapter_id), :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-analytics-filter-target' => 'select', 'data-action' => 'analytics-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, @selected_group_id), :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-analytics-filter-target' => 'select', 'data-action' => 'analytics-filter#updateFilter', 'data-name' => 'group_id', 'data-resources' => @available_groups.map { |g| { id: g.id, label: g.group_name, depend_id: g.chapter_id } }.uniq.to_json %>
+ <%= label_tag :chapter_label, "Group", class: 'block text-sm font-medium text-gray-700' %>
+
+ <%= render CommonComponents::MultiselectComponent.new(label: 'All', target: 'group_ids[]', options: @available_groups.map { |g| { id: g.id, label: g.group_name, depend_id: g.chapter_id } }, selected_values: @selected_group_ids) %>
+
+
+
+ <%= label_tag :from_label, "From", class: 'block text-sm font-medium text-gray-700' %>
+
+ <%= render Datepicker.new(date: @from, target: 'from', custom_name: "from_date", custom_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') %>
+
+
+
+ <%= label_tag :to_label, "To", class: 'block text-sm font-medium text-gray-700' %>
+
+ <%= render Datepicker.new(date: @to, target: 'to', custom_name: "to_date", custom_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') %>
+
- <%= render CommonComponents::ButtonComponent.new(label: t(:filter), options: { 'data-analytics-filter-target' => 'anchor' })%>
+ <%= render CommonComponents::ButtonComponent.new(label: t(:filter), options: { 'data-analytics-filter-target' => 'anchor', 'data-turbo-prefetch' => 'false' })%>
-
- <% @group_series.each_slice(2) do |d1, d2| %>
-
- <% if d2 %>
-
- <% end %>
- <% end %>
+
+
+
+
+<%= javascript_include_tag "new_charts", onload: 'drawGroupPerformanceCharts()' %>
\ No newline at end of file
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c59d8d12e..fc1813d41 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -319,6 +319,7 @@ en:
general_analytics: General Analytics
group_analytics: Group Analytics
subject_analytics: Subject Analytics
+ new_analytics: New Analytics
actions: Actions
close_sidebar: Close Sidebar
open_sidebar: Open sidebar
diff --git a/deploy/pulumi/Pulumi.production.yaml b/deploy/pulumi/Pulumi.production.yaml
index abf8e98c3..38b4a8132 100644
--- a/deploy/pulumi/Pulumi.production.yaml
+++ b/deploy/pulumi/Pulumi.production.yaml
@@ -24,7 +24,7 @@ config:
secure: v1:FNnVQd178R1dJFy9:vr048i9b3k1G1iFO9M16BiGx1Y5Z5vhpqWavEritMQCePxD2yBjQ1N3wlyY=
mindleaps-tracker:new_relic_license_key:
secure: v1:x8CtZmFJWL/gaVqV:pNLs2MUfScjfpqR3SuXaR3NnAXDDcaot9cYjTq1g5N7QDP9H+GoaaUZbQ/JJhMt/zkG/PnlkepI=
- mindleaps-tracker:postgres_version: "13"
+ mindleaps-tracker:postgres_version: "16.9"
mindleaps-tracker:publicSSH:
secure: v1:SBwkRSH68ZFg2qUc:NnnrMKco1iXb5zbjJp1YSesfm/AnGTu5R+Gpc0Nh4BSlljcoDgd9ZBROBFqrBaDJrH4Nwc1FOVUaGBEpeVoY2S8HhI0M/EGpRXk8VhI4PKX88prTOA0dwavH0Xhx+BhECpr0O9d5q64lDVPgQ1DJSMSFPt2wZP3/xqADGsRytFCvxTJhXP47N2ORZc8dQql8LgFmROJ7KtexJwTzf5RqfulOUKrfsoAmBdzbAJS45BIQmllWA1m67QaqUccJucRro8ZCacyOWIKuaG9hynj1GsKWJcnX2Zt4V1dzrMbTh4emY6brORYn0/AAqpmEcb9RBmEq5p0AhxbNVyGFEvC94Q6E/UtBuq3/VQoSlf8mKHHyVKgFdLUY1DChSatk46Mmt1Czjjwt34kwdqrbBkTykQVkMovlWBRnwlJacrqglkybwpRJOv71uMW8z4fNr3UMa0Pl40tAAc2L55a+ljQrsyPvgE8v5N1EyUt7LGXQSdLf7LJVDrQFoaY/U2K5CnqKdxrLkk7cnBa3pZHyN1Ax5uzzM+qoKmbnkFrOyjoaWn29fnixwbL09y3HkjoAqSDbQHYskfutevnIEZbDYP1gb/02WR8WKApkpQCnoKzO5DJMoE2lKFCGnx7PHoyemqybkUPPlbGz+h70zTFuvWMF0K85wIDA8SdET8+TMa3kv8VqbLjdiKvNPoMdrwH718CFlO/BNUgNyeT0y67+uueSL9NgNuFY5E4OcLo0gBUIM8FIwiqU0QrO4cRr1oM8Rf5BET8/EgOb5c/NOOknaR6u922C931apxrHmDY26xDeL/NGF3jSnir0qa45xzPd/Egfty2dkbmJnafZd5+5qTVDOM0CaQ7d7yd6JCYkvqq65vzlWVI0jGU3fkR1JBrntw+UeVo36PRDLoCSnUDdN49hhcBwODyc8SWYHp6N9TFfyfRGuphuSfbFWjn9NW8j7Asn4VSbDh7lV5D5llmxDuJ1USpqS5lE34UYyIbd4Ey4XqoEfy/kbtrf6cE=
mindleaps-tracker:rds_db_name: tracker
diff --git a/deploy/pulumi/Pulumi.staging.yaml b/deploy/pulumi/Pulumi.staging.yaml
index b3dc1ea83..dce69efcf 100644
--- a/deploy/pulumi/Pulumi.staging.yaml
+++ b/deploy/pulumi/Pulumi.staging.yaml
@@ -25,7 +25,7 @@ config:
secure: v1:3qMp4mpQLKxSWo1S:m0H7o7gkU+0QJ+L+dg6oKjRjWQ9trZyQYCzTRLcmM72HZA4Uc+Pi7HM=
mindleaps-tracker:new_relic_license_key:
secure: v1:CAcePbjqAKCgIR/g:0WKT2teuj9SU41fvWepRSUapxf4v352KVq7wihVepIE2dZPOo8ZToqlJXjJtPXoKqgI6Tbj6TsM=
- mindleaps-tracker:postgres_version: "13"
+ mindleaps-tracker:postgres_version: "16.9"
mindleaps-tracker:publicSSH:
secure: v1:OIVF9TWOT9SLWACt:0Sgk2xuryeDBAM6BMYtEMYmeZms0KhAmo10e6CZi8h4Hfy6t9WE7y5IWYlOXnbQjjy9/LBhW0aJwLx5k/RH2Eu0PIXjzLVBUqCXj2rlOeZhcRItkjROS7nOOtxNMaL5N5zPWnk7+nW0n2iA9XGk1XH+P3ywxigBjKqzQUK+eQVmGEepBJUp2T4ESA2aEkGgYEcOOIqer9RZY1bcUoUIOytwq3uDe8TVm4tBU5FTo0vItlZhqlxQYWG2OZOifR48Y2BTQdPLGlY5e64xlthvznQgc5j78Fy0uBvD8TW6MGmgExpCMJioqEd174Mn4vC6f5ZX87D6+TaXj0q0PKnld96vo15La2h/wUOyigaZ/mTaGPyaUZLqH3CFkE/BI4i5oR4THiaIPxbFPWVplAc3ul1gwFnh2IsbHBWTmRdTo09ZigKNvBkG/DsZ6idX/p8JgeVj7ssbBCG4PDvyGfWqGy44BCD1l1r0I4vMeNU5BxV5sABj7Zcd0CIMRPQBRzJT8gwKLLC10HG5qD7eOoeALTC0R7KL/8NosjFTvADNiILSLekVQ/2U7p/JAtGO9o9AH2xwo4MoAA4KznLJktSuEuUI8km8XB3zQVFi9OVQ0Ez/iKlvZ7iEg4ncpTkrQ51y+r/JF17OyhT0TElAN5WEuP5oXrS8qyvwWyGLEsWPRajWJIp31vWFMgb5pUlkyjQKLWVcNHj3LTO79Ch6laiOTRlUqbVkWNFJQbjdeDDK72wuFRHo6rHL/tCYsqMsyEv7W9n5Ze55DfHQ6HJlWyMyTBs3Awmi4SRtkCAHosW4FOQd17DUEMGE87Z6xyX7lXh/SWgl3ImNge2AbmyS9vFlLIInB+5uWCVykskuR5kvMpZ/+8gB6nH//GOE5OYenTdjv65J4dkrXYa3gzvA94JxVKQvV9L9EmaF5Xq13jF/XWEy0P3BX+xRsL/1zrmUkROXyU6q1UlVB1x6foKjUAeWrU1jjXaeILjd7sxqlAJ6EPqkxId41mjv41EE=
mindleaps-tracker:rds_db_name: tracker
diff --git a/spec/components/multiselect_component_spec.rb b/spec/components/multiselect_component_spec.rb
new file mode 100644
index 000000000..b6f3619f7
--- /dev/null
+++ b/spec/components/multiselect_component_spec.rb
@@ -0,0 +1,32 @@
+RSpec.describe CommonComponents::MultiselectComponent, type: :component do
+ before :each do
+ @label = 'Select Items'
+ @options = [{ id: 1, label: 'First Item' }, { id: 2, label: 'Second Item' }, { id: 3, label: 'Third Item' }]
+ @target = 'some_target'
+ end
+
+ it 'has the correct label' do
+ render_inline(CommonComponents::MultiselectComponent.new(label: @label, target: @target, options: @options))
+ expect(page).to have_css('button', text: @label, visible: true)
+ end
+
+ it 'contains all options' do
+ render_inline(CommonComponents::MultiselectComponent.new(label: @label, target: @target, options: @options))
+ rendered_options = page.find_all('div[data-multiselect-target="option"] > span', visible: false)
+
+ @options.each_with_index do |option, index|
+ expect(rendered_options[index].text).to eq option[:label]
+ end
+ end
+
+ it 'renders inputs for already selected options' do
+ selected_values = [@options[0][:id], @options[1][:id]]
+ render_inline(CommonComponents::MultiselectComponent.new(label: @label, target: @target, options: @options, selected_values: selected_values))
+
+ hidden_inputs = page.find_all('div[data-multiselect-target="hiddenField"] > input', visible: false)
+
+ selected_values.each_with_index do |value, index|
+ expect(hidden_inputs[index].value).to eq value.to_s
+ end
+ end
+end
diff --git a/spec/features/analytics_features_spec.rb b/spec/features/analytics_features_spec.rb
index 64ed89a66..b3ae29348 100644
--- a/spec/features/analytics_features_spec.rb
+++ b/spec/features/analytics_features_spec.rb
@@ -37,19 +37,7 @@
click_link 'Group analytics'
select @organization.organization_name, from: 'organization_select'
click_link 'Filter'
- expect(page).to have_content(@group.group_chapter_name)
- end
-
- it 'does not display deleted group analytics', js: true do
- visit '/'
- click_link 'Analytics'
- click_link 'Group analytics'
-
- select @organization.organization_name, from: 'organization_select'
- click_link 'Filter'
-
- expect(page).to have_content(@group.group_chapter_name)
- expect(page).not_to have_content(@deleted_group.group_chapter_name)
+ expect(page).to have_content('Download PNG')
end
end
end
diff --git a/spec/features/student_features_spec.rb b/spec/features/student_features_spec.rb
index aa6ae9ad3..fd1599691 100644
--- a/spec/features/student_features_spec.rb
+++ b/spec/features/student_features_spec.rb
@@ -163,5 +163,6 @@
def add_and_select_group(chapter_group_name, group_index)
click_button 'Add Group'
select chapter_group_name, from: "student_enrollments_attributes_#{group_index}_group_id"
- fill_in "student_enrollments_attributes_#{group_index}_active_since", with: Time.zone.now.to_date.to_s
+ find("input#student_enrollments_attributes_#{group_index}_active_since").click
+ find('td.is-today').click
end
diff --git a/vendor/assets/javascripts/chartjs.js b/vendor/assets/javascripts/chartjs.js
new file mode 100644
index 000000000..ba7b499f5
--- /dev/null
+++ b/vendor/assets/javascripts/chartjs.js
@@ -0,0 +1,14 @@
+/*!
+ * Chart.js v4.5.1
+ * https://www.chartjs.org
+ * (c) 2025 Chart.js Contributors
+ * Released under the MIT License
+ */
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a