Skip to content
Open
1 change: 1 addition & 0 deletions app/assets/config/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/assets/images/checkmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
260 changes: 260 additions & 0 deletions app/assets/javascripts/new_charts.js
Original file line number Diff line number Diff line change
@@ -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()]
})
}
37 changes: 37 additions & 0 deletions app/components/common_components/multiselect_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class CommonComponents::MultiselectComponent < ViewComponent::Base
erb_template <<~ERB
<div data-controller="multiselect" data-multiselect-label-value="<%= @label %>" data-multiselect-select-name-value="<%= @target %>" class="col-span-6 lg:col-span-2 relative">
<!-- Hidden inputs for each selected item from the menu-->
<div data-multiselect-target="hiddenField">
<% @selected_values&.each do |value| %>
<input type="hidden" name="<%= @target %>" value="<%= value %>">
<% end %>
</div>
<button type="button" data-action="click->multiselect#toggleMenu"
class="w-full min-w-36 border border-gray-300 p-2 rounded-md bg-white flex justify-between items-center text-md sm:text-sm font-medium focus:ring-indigo-500 focus:border-indigo-500">
<span data-multiselect-target="label" > <%= @label %> </span>
<%= helpers.inline_svg_tag("arrow_down.svg", class: "w-5 h-5 fill-gray-500") %>
</button>
<!-- Dropdown Menu -->
<div class="absolute p-1 w-full border rounded-md bg-white hidden z-10 max-h-80 overflow-y-auto" data-multiselect-target="menu" >
<% @options.each do |option| %>
<div class="hover:bg-gray-100 cursor-pointer flex justify-between p-2 rounded-md flex justify-between items-center text-md sm:text-sm font-medium"
data-value="<%= option[:id] %>"
data-depend-id="<%= option[:depend_id] %>"
data-action="click->multiselect#toggleOption"
data-multiselect-target="option">
<span><%= option[:label] %></span>
<%= helpers.inline_svg_tag("checkmark.svg", class: "w-4 h-4 text-green-600 checkmark hidden") %>
</div>
<% end %>
</div>
</div>
ERB

def initialize(label:, target:, options:, selected_values: nil)
@label = label
@target = target
@options = options
@selected_values = selected_values
end
end
43 changes: 43 additions & 0 deletions app/controllers/analytics/new_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module Analytics
class NewController < AnalyticsController
def index
@selected_group_ids = params[:group_ids]
@group_series = performance_per_group_by_lesson
end

# rubocop:disable Metrics/MethodLength
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.map do |group|
result = conn.exec(Sql.average_mark_in_group_lessons(group)).values
{
id: group.id,
name: "#{t(:group)} #{group.group_chapter_name}",
data: format_point_data(result)
}
end
end
# 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
Loading