Skip to content

Commit 2df9d74

Browse files
authored
Merge pull request #2530 from MindLeaps/2486-student-reports
2486: Student reports
2 parents d641b3d + a104207 commit 2df9d74

File tree

15 files changed

+926
-114
lines changed

15 files changed

+926
-114
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
## Unreleased
2+
- Added ability to generate student-level reports
23
- Defaulted starting date for analytics to the last lesson's month
34
- Replaced existing charts using the `Chart.js` library
45
- Fixed issue where group reports crashed when students had no summaries

app/assets/javascripts/new_charts.js

Lines changed: 268 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,6 @@ function polynomialLineForGroup(group, order = 4) {
119119
return null
120120
}
121121

122-
// Ensure numeric + sorted
123122
const pts = (group.data || [])
124123
.map(p => ({ x: Number(p.x), y: Number(p.y) }))
125124
.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
@@ -133,20 +132,20 @@ function polynomialLineForGroup(group, order = 4) {
133132
const span = maxX - minX
134133
if (span === 0) return null
135134

136-
// Map x -> t in [-1, 1]
137135
const toT = (x) => ((x - minX) / span) * 2 - 1
138136
const toX = (t) => minX + ((t + 1) / 2) * span
139137

140-
// Fit polynomial on t to avoid instability
141138
const data = pts.map(p => [toT(p.x), p.y])
142139
const result = window.regression.polynomial(data, { order })
143140

144-
// Sample a smooth curve in t-space
145141
const steps = 200
146142
const curve = []
143+
147144
for (let i = 0; i <= steps; i++) {
148145
const t = -1 + (2 * i) / steps
149-
const y = result.predict(t)[1]
146+
const rawY = result.predict(t)[1]
147+
const y = Math.max(1, Math.min(7, rawY))
148+
150149
curve.push({ x: toX(t), y })
151150
}
152151

@@ -196,6 +195,97 @@ function buildDatasetsForGroups(groups) {
196195
return datasets
197196
}
198197

198+
function buildDatasetsForStudentReportByGroups(groupedData) {
199+
const source = groupedData ? groupedData : {}
200+
const datasets = []
201+
202+
Object.entries(source).forEach(([groupId, rows], idx) => {
203+
const color = colorForIndex(idx)
204+
const firstRow = rows[0] || {}
205+
206+
const points = rows
207+
.map((row) => {
208+
const x = new Date(row.lesson_date).getTime()
209+
const y = row.average_mark
210+
211+
return {
212+
x,
213+
y,
214+
date: row.lesson_date,
215+
lesson_url: row.lesson_url,
216+
group_id: row.group_id,
217+
group_name: row.group_name
218+
}
219+
})
220+
221+
if (!points.length) return
222+
223+
datasets.push({
224+
label: firstRow.group_name || `Group ${groupId}`,
225+
groupKey: groupId,
226+
type: "line",
227+
data: points,
228+
parsing: false,
229+
borderColor: color,
230+
backgroundColor: color,
231+
borderWidth: 3,
232+
tension: 0.15,
233+
spanGaps: false,
234+
pointRadius: 3,
235+
pointHoverRadius: 5
236+
})
237+
})
238+
239+
return datasets
240+
}
241+
242+
function buildRegressionOnlyDatasetsForStudentSkills(skillSeriesJson, opts = {}) {
243+
const items = Array.isArray(skillSeriesJson) ? skillSeriesJson : []
244+
const datasets = []
245+
246+
items.forEach((item, idx) => {
247+
const skillName = item?.skill || `Skill ${idx + 1}`
248+
const firstSeries = Array.isArray(item?.series) ? item.series[0] : null
249+
const color = firstSeries?.color || colorForIndex(idx)
250+
251+
const points = (firstSeries?.data || [])
252+
.map((p) => ({
253+
x: p.x,
254+
y: p.y,
255+
lesson_url: p.lesson_url,
256+
date: p.date
257+
}))
258+
.sort((a, b) => a.x - b.x)
259+
260+
if (points.length < 2) return
261+
262+
const group = {
263+
id: skillName,
264+
name: skillName,
265+
data: points
266+
}
267+
268+
const curve = polynomialLineForGroup(group, opts.regressionOrder ?? 4)
269+
if (!curve) return
270+
271+
datasets.push({
272+
label: skillName,
273+
type: "line",
274+
data: curve,
275+
parsing: false,
276+
borderColor: color,
277+
backgroundColor: color,
278+
borderDash: [5, 5],
279+
borderWidth: 3,
280+
tension: 0,
281+
pointRadius: 0,
282+
pointHoverRadius: 0
283+
})
284+
})
285+
286+
return datasets
287+
}
288+
199289
// ---------- Group Analytics Chart && Average performance per Group by Lesson chart ---------
200290
function displayAveragePerformancePerGroupByLessonChart(containerId, seriesJson, opts = {}) {
201291
if(!chartJsPresent()) return
@@ -674,12 +764,12 @@ function displayLessonChart(containerId, lessonId, data) {
674764
},
675765
pointBackgroundColor: (ctx) => {
676766
const raw = ctx.raw
677-
if (!raw) return " #9C27B0"
767+
if (!raw) return "#9C27B0"
678768
return raw.lesson_id === lessonId ? "#4CAF50" : " #9C27B0"
679769
},
680770
pointBorderColor: (ctx) => {
681771
const raw = ctx.raw
682-
if (!raw) return " #9C27B0"
772+
if (!raw) return "#9C27B0"
683773
return raw.lesson_id === lessonId ? "#4CAF50" : " #9C27B0"
684774
},
685775
pointHoverRadius: 5
@@ -1233,4 +1323,175 @@ function displayMarkAveragesChart(containerId, data, opts = {}) {
12331323
},
12341324
plugins: [whiteBackgroundPlugin()]
12351325
})
1326+
}
1327+
1328+
// ---------- Student report chart: performance by lesson split by group ----------
1329+
function displayStudentReportPerformanceByGroupChart(containerId, groupedData, opts = {}) {
1330+
if (!chartJsPresent()) return
1331+
1332+
const canvas = ensureCanvasIsPresent(containerId, { heightPx: opts.heightPx || 500 })
1333+
if (!canvas) return
1334+
1335+
destroyIfExists(canvas)
1336+
1337+
const datasets = buildDatasetsForStudentReportByGroups(groupedData)
1338+
1339+
new Chart(canvas.getContext("2d"), {
1340+
data: { datasets },
1341+
options: {
1342+
responsive: true,
1343+
maintainAspectRatio: false,
1344+
clip: false,
1345+
plugins: {
1346+
legend: {
1347+
display: true,
1348+
labels: {
1349+
padding: 10,
1350+
font: { size: 12 }
1351+
}
1352+
},
1353+
tooltip: {
1354+
backgroundColor: "#ffffff",
1355+
titleColor: "#111827",
1356+
bodyColor: "#374151",
1357+
borderColor: "#D1D5DB",
1358+
borderWidth: 2,
1359+
cornerRadius: 3,
1360+
padding: 12,
1361+
titleFont: {
1362+
size: 14,
1363+
weight: "600"
1364+
},
1365+
bodyFont: {
1366+
size: 13
1367+
},
1368+
bodySpacing: 3,
1369+
callbacks: {
1370+
title: function(context) {
1371+
return context[0].dataset.label
1372+
},
1373+
label: function(context) {
1374+
return [
1375+
`Date: ${toUSdateFormat(context.parsed.x)}`,
1376+
`Average: ${context.parsed.y}`
1377+
]
1378+
}
1379+
}
1380+
},
1381+
whiteBackground: {
1382+
color: "white"
1383+
}
1384+
},
1385+
scales: {
1386+
x: {
1387+
type: "linear",
1388+
title: {
1389+
display: true,
1390+
text: opts.xTitle || "Lesson date"
1391+
},
1392+
ticks: {
1393+
callback: (value) => toUSdateFormat(Number(value))
1394+
}
1395+
},
1396+
y: {
1397+
title: {
1398+
display: true,
1399+
text: opts.yTitle || "Performance"
1400+
},
1401+
min: 1,
1402+
max: 7,
1403+
ticks: {
1404+
precision: 0
1405+
}
1406+
}
1407+
},
1408+
onClick: function(event, elements) {
1409+
if (!elements?.length) return
1410+
1411+
const el = elements[0]
1412+
const point = this.data.datasets[el.datasetIndex].data[el.index]
1413+
1414+
if (point?.lesson_url) window.open(point.lesson_url, "_blank")
1415+
},
1416+
onHover: function(event, elements) {
1417+
const c = event.native?.target || event.chart?.canvas
1418+
if (!c) return
1419+
c.style.cursor = elements?.length ? "pointer" : "default"
1420+
}
1421+
},
1422+
plugins: [whiteBackgroundPlugin()]
1423+
})
1424+
}
1425+
1426+
// ---------- Student report chart: performance by lesson split by skill ----------
1427+
function displayStudentSkillRegressionChart(containerId, skillSeriesJson, opts = {}) {
1428+
if (!chartJsPresent()) return
1429+
1430+
const canvas = ensureCanvasIsPresent(containerId, { heightPx: opts.heightPx || 500 })
1431+
if (!canvas) return
1432+
destroyIfExists(canvas)
1433+
1434+
const datasets = buildRegressionOnlyDatasetsForStudentSkills(skillSeriesJson, opts)
1435+
1436+
new Chart(canvas.getContext("2d"), {
1437+
data: { datasets },
1438+
options: {
1439+
responsive: true,
1440+
maintainAspectRatio: false,
1441+
clip: false,
1442+
plugins: {
1443+
title: {
1444+
display: true,
1445+
text: opts.title || "Student progress by skill",
1446+
font: { size: 20 }
1447+
},
1448+
legend: {
1449+
display: true,
1450+
labels: {
1451+
padding: 10,
1452+
font: { size: 11 },
1453+
boxHeight: 10,
1454+
boxWidth: 10
1455+
}
1456+
},
1457+
tooltip: {
1458+
backgroundColor: "#ffffff",
1459+
titleColor: "#111827",
1460+
bodyColor: "#374151",
1461+
borderColor: "#D1D5DB",
1462+
borderWidth: 2,
1463+
cornerRadius: 3,
1464+
padding: 12,
1465+
callbacks: {
1466+
title: (ctx) => ctx[0]?.dataset?.label || "",
1467+
label: (ctx) => [
1468+
`${opts.xTitle || "Nr. of lessons"}: ${Math.round(ctx.parsed.x)}`,
1469+
`${opts.yTitle || "Performance"}: ${ctx.parsed.y.toFixed(2)}`
1470+
]
1471+
}
1472+
},
1473+
whiteBackground: { color: "white" }
1474+
},
1475+
scales: {
1476+
x: {
1477+
type: "linear",
1478+
min: 1,
1479+
title: { display: true, text: opts.xTitle || "Nr. of lessons" },
1480+
ticks: { precision: 0 }
1481+
},
1482+
y: {
1483+
min: 1,
1484+
max: 7,
1485+
title: { display: true, text: opts.yTitle || "Performance" },
1486+
ticks: { precision: 0 }
1487+
}
1488+
},
1489+
onHover: function (event) {
1490+
const c = event.native?.target || event.chart?.canvas
1491+
if (!c) return
1492+
c.style.cursor = "default"
1493+
}
1494+
},
1495+
plugins: [whiteBackgroundPlugin()]
1496+
})
12361497
}

app/controllers/reports_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ def show
66
@available_organizations = policy_scope Organization.where(deleted_at: nil).order(:organization_name)
77
@available_chapters = policy_scope Chapter.where(deleted_at: nil).order(:chapter_name)
88
@available_groups = policy_scope Group.where(deleted_at: nil).order(:group_name)
9+
@selected_student_id = params[:student_id]
910

1011
authorize Group, :index?
1112
end

0 commit comments

Comments
 (0)