PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / trunk
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses vtrunk
4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 4.2.1 All 138 releases
learnpress / assets / src / js / admin / statistics / tab-users.js

tab-users.js in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses trunk, at assets/src/js/admin/statistics/tab-users.js

413 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Users tab module.
3 *
4 * Fetches the `dashboard` payload and renders KPIs, registered-users chart,
5 * 5-step funnel (incl. failed), Top Students and Top Courses by Students
6 * tables, popups and CSV export.
7 *
8 * @since 4.4.2
9 * @version 1.0.0
10 */
11
12 import * as lpUtils from 'lpAssetsJsPath/utils.js';
13 import { LP_STATS_FILTER_CHANGED, LP_STATS_EXPORT_CSV } from './state.js';
14 import { lpStatsFetch, getStatsConfig, getStatsI18n } from './api.js';
15 import { renderKpi } from './kpi.js';
16 import { renderLineChart } from './chart.js';
17 import { renderDataTable } from './data-table.js';
18 import { lpStatsReportModal } from './report-modal.js';
19 import { exportCsv, buildCsvFilename } from './csv.js';
20
21 const sprintfLite = ( template, value ) =>
22 String( template ).replace( /%[ds]/, String( value ) ).replace( /%%/g, '%' );
23
24 export class LpStatsTabUsers {
25 static selectors = {
26 elContainer: '.lp-stats-tab-users',
27 elChartCanvas: '#user-chart-content',
28 elFunnelStep: '.lp-stats-funnel__step',
29 elTableTopStudents: '.lp-stats-table-top-students',
30 elTableCoursesByStudents: '.lp-stats-table-courses-by-students',
31 elBtnViewAllStudents: '.lp-stats-view-all-students',
32 elBtnViewAllCourses: '.lp-stats-view-all-courses-by-students',
33 elSkeleton: '.lp-skeleton-animation',
34 };
35
36 static kpiCards = {
37 users_activated: '.lp-kpi-users-activated',
38 students: '.lp-kpi-students',
39 instructors: '.lp-kpi-instructors',
40 not_started: '.lp-kpi-not-started',
41 in_progress: '.lp-kpi-in-progress',
42 finished: '.lp-kpi-finished',
43 };
44
45 constructor() {
46 this.elContainer = null;
47 this.isRequesting = false;
48 this.pendingReload = false;
49 this.tables = {};
50 }
51
52 init() {
53 this.elContainer = document.querySelector(
54 LpStatsTabUsers.selectors.elContainer
55 );
56 if ( ! this.elContainer ) {
57 return;
58 }
59
60 this.events();
61 this.loadData();
62 }
63
64 events() {
65 if ( LpStatsTabUsers._loadedEvents ) {
66 return;
67 }
68 LpStatsTabUsers._loadedEvents = this;
69
70 lpUtils.eventHandlers( 'click', [
71 {
72 selector: LpStatsTabUsers.selectors.elBtnViewAllStudents,
73 class: this,
74 callBack: this.viewAllStudents.name,
75 },
76 {
77 selector: LpStatsTabUsers.selectors.elBtnViewAllCourses,
78 class: this,
79 callBack: this.viewAllCoursesByStudents.name,
80 },
81 ] );
82
83 document.addEventListener( LP_STATS_FILTER_CHANGED, () => this.loadData() );
84 document.addEventListener( LP_STATS_EXPORT_CSV, () => this.exportTables() );
85 }
86
87 toggleSkeletons( show ) {
88 this.elContainer
89 .querySelectorAll( LpStatsTabUsers.selectors.elSkeleton )
90 .forEach( ( el ) => {
91 el.style.display = show ? 'block' : 'none';
92 } );
93 }
94
95 loadData() {
96 if ( this.isRequesting ) {
97 this.pendingReload = true;
98 return;
99 }
100 this.isRequesting = true;
101
102 lpStatsFetch(
103 'user-statistics',
104 {},
105 {
106 before: () => this.toggleSkeletons( true ),
107 success: ( response ) => this.render( response.data ),
108 error: ( err ) => {
109 console.error( 'LP Statistics users:', err );
110 this.render( null );
111 },
112 completed: () => {
113 this.toggleSkeletons( false );
114 this.isRequesting = false;
115 if ( this.pendingReload ) {
116 this.pendingReload = false;
117 this.loadData();
118 }
119 },
120 }
121 );
122 }
123
124 render( data ) {
125 if ( ! data?.dashboard ) {
126 console.error( 'LP Statistics users: dashboard payload missing.' );
127 data = { chart_data: {}, dashboard: {} };
128 }
129
130 const dashboard = data.dashboard || {};
131
132 this.renderKpis( dashboard.kpis || {} );
133 this.renderChart( data.chart_data || {} );
134 this.renderFunnel( dashboard.funnel || {} );
135 this.renderTables( dashboard );
136 }
137
138 renderKpis( kpis ) {
139 Object.entries( LpStatsTabUsers.kpiCards ).forEach( ( [ key, selector ] ) => {
140 const elCard = this.elContainer.querySelector( selector );
141 const payload = { ...( kpis[ key ] || {} ) };
142
143 if ( 'users_activated' === key ) {
144 payload.subline = sprintfLite(
145 getStatsI18n( 'newThisPeriod', '+%d this period' ),
146 payload.new_in_period ?? 0
147 );
148 }
149 if ( 'students' === key ) {
150 payload.subline = sprintfLite(
151 getStatsI18n( 'activeInPeriod', '%d active in this period' ),
152 payload.active_in_period ?? 0
153 );
154 }
155 if ( 'instructors' === key ) {
156 payload.subline = `${ payload.active_in_period ?? 0 }/${
157 payload.value ?? 0
158 } ${ getStatsI18n( 'activeThisPeriod', 'active this period' ) }`;
159 }
160 if ( 'not_started' === key ) {
161 payload.subline = getStatsI18n( 'afterEnrollment', 'After enrollment' );
162 }
163 if ( 'in_progress' === key ) {
164 payload.subline = getStatsI18n( 'currentLearners', 'Current learners' );
165 }
166 if ( 'finished' === key && null != payload.completion_rate ) {
167 payload.subline = sprintfLite(
168 getStatsI18n( 'completionRateSub', '%s%% completion rate' ),
169 payload.completion_rate
170 );
171 }
172
173 renderKpi( elCard, payload );
174 } );
175 }
176
177 renderChart( chartData ) {
178 renderLineChart(
179 LpStatsTabUsers.selectors.elChartCanvas,
180 {
181 labels: chartData.labels || [],
182 datasets: [
183 {
184 label:
185 chartData.line_label ||
186 getStatsI18n( 'registeredUsers', 'Registered users' ),
187 data: chartData.data || [],
188 yAxisID: 'y',
189 },
190 ],
191 xLabel: chartData.x_label || '',
192 granularity: chartData.granularity || '',
193 },
194 { yCurrency: false }
195 );
196 }
197
198 renderFunnel( funnel ) {
199 const steps = [ 'registered', 'enrolled', 'started', 'completed', 'failed' ];
200 let previous = null;
201
202 steps.forEach( ( step ) => {
203 const elStep = this.elContainer.querySelector(
204 `${ LpStatsTabUsers.selectors.elFunnelStep }[data-step="${ step }"]`
205 );
206 if ( ! elStep ) {
207 return;
208 }
209
210 const count = Number( funnel[ step ] ?? 0 );
211 // 'failed' is an annotation on 'started', not the next narrowing step.
212 let base = previous;
213 if ( 'failed' === step ) {
214 base = Number( funnel.started ?? 0 );
215 } else if ( null === previous ) {
216 base = count;
217 }
218 const width = base > 0 ? Math.min( 100, ( count / base ) * 100 ) : 0;
219
220 elStep.querySelector( '.lp-stats-funnel__count' ).textContent =
221 String( count );
222 elStep.querySelector( '.lp-stats-funnel__bar' ).style.width = `${ width }%`;
223
224 if ( 'failed' !== step ) {
225 previous = count;
226 }
227 } );
228 }
229
230 completionBadge( rate ) {
231 if ( null == rate ) {
232 return '';
233 }
234 const { completionBadge = {} } = getStatsConfig();
235 const green = completionBadge.green ?? 60;
236 const yellow = completionBadge.yellow ?? 40;
237
238 if ( rate >= green ) {
239 return 'green';
240 }
241 return rate >= yellow ? 'yellow' : 'red';
242 }
243
244 studentStatusLabel( slug ) {
245 const labels = {
246 active: getStatsI18n( 'statusActive', 'Active' ),
247 at_risk: getStatsI18n( 'statusAtRisk', 'At risk' ),
248 idle: getStatsI18n( 'statusIdle', 'Idle' ),
249 };
250
251 return labels[ slug ] || String( slug || '' );
252 }
253
254 studentStatusBadge( slug ) {
255 const badges = {
256 active: 'green',
257 at_risk: 'yellow',
258 idle: 'grey',
259 };
260
261 return badges[ slug ] || '';
262 }
263
264 formatLastActive( value ) {
265 if ( ! value ) {
266 return '';
267 }
268
269 const date = new Date( String( value ).replace( ' ', 'T' ) );
270 if ( isNaN( date.getTime() ) ) {
271 return '';
272 }
273
274 try {
275 const days = Math.round( ( date.getTime() - Date.now() ) / 86400000 );
276 return new Intl.RelativeTimeFormat( undefined, { numeric: 'auto' } ).format(
277 days,
278 'day'
279 );
280 } catch {
281 return date.toLocaleDateString();
282 }
283 }
284
285 /**
286 * @param {boolean} withScore avg_score column only when the payload carries scores.
287 */
288 topStudentsColumns( withScore = true ) {
289 const columns = [
290 { key: 'name', label: getStatsI18n( 'student', 'Student' ) },
291 { key: 'enrolled', label: getStatsI18n( 'enrolled', 'Enrolled' ) },
292 { key: 'completed', label: getStatsI18n( 'completedLabel', 'Completed' ) },
293 ];
294
295 if ( withScore ) {
296 columns.push( {
297 key: 'avg_score',
298 label: getStatsI18n( 'avgScore', 'Quiz pass rate' ),
299 format: ( value ) => ( null == value ? '' : `${ value }%` ),
300 } );
301 }
302
303 columns.push(
304 {
305 key: 'last_active',
306 label: getStatsI18n( 'lastActive', 'Last active' ),
307 format: ( value ) => this.formatLastActive( value ),
308 csv: ( row ) => row.last_active || '',
309 },
310 {
311 key: 'status',
312 label: getStatsI18n( 'status', 'Status' ),
313 format: ( value ) => this.studentStatusLabel( value ),
314 badge: ( row ) => this.studentStatusBadge( row.status ),
315 csv: ( row ) => row.status,
316 }
317 );
318
319 return columns;
320 }
321
322 coursesByStudentsColumns() {
323 return [
324 { key: 'name', label: getStatsI18n( 'course', 'Course' ) },
325 { key: 'enrolled', label: getStatsI18n( 'enrolled', 'Enrolled' ) },
326 { key: 'started', label: getStatsI18n( 'startedLabel', 'Started' ) },
327 { key: 'completed', label: getStatsI18n( 'completedLabel', 'Completed' ) },
328 {
329 key: 'completion_rate',
330 label: getStatsI18n( 'completion', 'Completion' ),
331 format: ( value ) => ( null == value ? '' : `${ value }%` ),
332 badge: ( row ) => this.completionBadge( row.completion_rate ),
333 },
334 { key: 'active_7d', label: getStatsI18n( 'activeLast7dShort', 'Active 7d' ) },
335 ];
336 }
337
338 /**
339 * avg_score is null when quiz data is unavailable — hide the whole column.
340 *
341 * @param {Array} rows
342 */
343 hasScores( rows = [] ) {
344 return rows.some( ( row ) => null != row.avg_score );
345 }
346
347 renderTables( dashboard ) {
348 const students = dashboard.top_students || [];
349 this.tables[ 'top-students' ] = renderDataTable(
350 this.elContainer.querySelector(
351 LpStatsTabUsers.selectors.elTableTopStudents
352 ),
353 this.topStudentsColumns( this.hasScores( students ) ),
354 students
355 );
356
357 this.tables[ 'courses-by-students' ] = renderDataTable(
358 this.elContainer.querySelector(
359 LpStatsTabUsers.selectors.elTableCoursesByStudents
360 ),
361 this.coursesByStudentsColumns(),
362 dashboard.top_courses_by_students || []
363 );
364 }
365
366 viewAllStudents( args ) {
367 const btn = args.target.closest(
368 LpStatsTabUsers.selectors.elBtnViewAllStudents
369 );
370 if ( ! btn || ! this.elContainer.contains( btn ) ) {
371 return;
372 }
373
374 lpStatsReportModal.open( {
375 report: 'top_students',
376 title: getStatsI18n( 'topStudents', 'Top students' ),
377 tableId: 'top-students',
378 } );
379 }
380
381 viewAllCoursesByStudents( args ) {
382 const btn = args.target.closest(
383 LpStatsTabUsers.selectors.elBtnViewAllCourses
384 );
385 if ( ! btn || ! this.elContainer.contains( btn ) ) {
386 return;
387 }
388
389 lpStatsReportModal.open( {
390 report: 'courses_by_students',
391 title: getStatsI18n(
392 'topCoursesByStudents',
393 'Top courses by students'
394 ),
395 tableId: 'courses-by-students',
396 } );
397 }
398
399 exportTables() {
400 Object.entries( this.tables ).forEach( ( [ tableId, handle ] ) => {
401 if ( handle && handle.rows.length ) {
402 exportCsv(
403 buildCsvFilename( 'users', tableId ),
404 handle.columns,
405 handle.rows
406 );
407 }
408 } );
409 }
410 }
411
412 export const lpStatsTabUsers = new LpStatsTabUsers();
413