PHP Classes

File: templates/admin/activity_stats.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/admin/activity_stats.twig   Download  
File: templates/admin/activity_stats.twig
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 29,659 bytes
 

Contents

Class file image Download
?{% extends "base.twig" %} {% block title %}{{ t('ui.admin.activity_stats.page_title') }}{% endblock %} {% block content %} <div class="container-fluid"> <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> <h1 class="h2">{{ t('ui.admin.activity_stats.heading') }}</h1> <div class="d-flex align-items-center gap-2"> <label class="me-1 mb-0 text-muted small">{{ t('ui.admin.activity_stats.period') }}:</label> <select id="period-select" class="form-select form-select-sm" style="width:auto"> <option value="7d">{{ t('ui.admin.activity_stats.period_7d') }}</option> <option value="30d" selected>{{ t('ui.admin.activity_stats.period_30d') }}</option> <option value="90d">{{ t('ui.admin.activity_stats.period_90d') }}</option> <option value="all">{{ t('ui.admin.activity_stats.period_all') }}</option> </select> <div class="form-check mb-0 ms-2"> <input class="form-check-input" type="checkbox" id="exclude-admins"> <label class="form-check-label text-muted small" for="exclude-admins">{{ t('ui.admin.activity_stats.exclude_admins') }}</label> </div> <a href="/admin" class="btn btn-sm btn-outline-secondary"> <i class="fas fa-arrow-left me-1"></i>{{ t('ui.admin.activity_stats.dashboard') }} </a> </div> </div> <div id="loading-indicator" class="text-center py-5"> <div class="spinner-border text-primary" role="status"> <span class="visually-hidden">{{ t('ui.common.loading') }}</span> </div> <p class="mt-2 text-muted">{{ t('ui.admin.activity_stats.loading_activity_data') }}</p> </div> <div id="stats-content" style="display:none"> <!-- Summary Cards --> <div class="row mb-4" id="summary-cards"></div> <!-- Tabs --> <ul class="nav nav-tabs mb-3" id="stats-tabs" role="tablist"> <li class="nav-item" role="presentation"> <button class="nav-link active" id="tab-overview" data-bs-toggle="tab" data-bs-target="#pane-overview" type="button">{{ t('ui.admin.activity_stats.tab_overview') }}</button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-areas" data-bs-toggle="tab" data-bs-target="#pane-areas" type="button">{{ t('ui.admin.activity_stats.tab_popular_areas') }}</button> </li> {% if interests_enabled %} <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-interests" data-bs-toggle="tab" data-bs-target="#pane-interests" type="button">{{ t('ui.admin.activity_stats.tab_interests') }}</button> </li> {% endif %} <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-doors" data-bs-toggle="tab" data-bs-target="#pane-doors" type="button">{{ t('ui.admin.activity_stats.tab_doors') }}</button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-files" data-bs-toggle="tab" data-bs-target="#pane-files" type="button">{{ t('ui.admin.activity_stats.tab_file_activity') }}</button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-nodelist" data-bs-toggle="tab" data-bs-target="#pane-nodelist" type="button">{{ t('ui.admin.activity_stats.tab_nodelist') }}</button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-users" data-bs-toggle="tab" data-bs-target="#pane-users" type="button">{{ t('ui.admin.activity_stats.tab_top_users') }}</button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-hourly" data-bs-toggle="tab" data-bs-target="#pane-hourly" type="button">{{ t('ui.admin.activity_stats.tab_hourly') }}</button> </li> </ul> <div class="tab-content" id="stats-tab-content"> <!-- Overview Tab --> <div class="tab-pane fade show active" id="pane-overview" role="tabpanel"> <div class="row"> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.activity_by_category') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.category') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.events') }}</th><th style="width:40%"></th></tr></thead> <tbody id="category-rows"></tbody> </table> </div> </div> </div> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.daily_activity_last_30') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0" id="daily-table"> <thead><tr><th>{{ t('ui.admin.activity_stats.date') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.events') }}</th><th style="width:40%"></th></tr></thead> <tbody id="daily-rows"></tbody> </table> </div> </div> </div> </div> </div> <!-- Popular Areas Tab --> <div class="tab-pane fade" id="pane-areas" role="tabpanel"> <div class="row"> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.most_viewed_echoareas') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.area') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.views') }}</th><th style="width:35%"></th></tr></thead> <tbody id="echoarea-views-rows"></tbody> </table> </div> </div> </div> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.most_active_echoareas') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.area') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.posts') }}</th><th style="width:35%"></th></tr></thead> <tbody id="echoarea-posts-rows"></tbody> </table> </div> </div> </div> </div> </div> <!-- Doors Tab --> <div class="tab-pane fade" id="pane-doors" role="tabpanel"> <div class="row"> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.most_played_webdoors') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.game') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.sessions') }}</th><th style="width:35%"></th></tr></thead> <tbody id="webdoor-rows"></tbody> </table> </div> </div> </div> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.most_played_dos_doors') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.door') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.sessions') }}</th><th style="width:35%"></th></tr></thead> <tbody id="dosdoor-rows"></tbody> </table> </div> </div> </div> </div> </div> <!-- File Activity Tab --> <div class="tab-pane fade" id="pane-files" role="tabpanel"> <div class="row"> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.top_downloaded_files') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.file') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.downloads') }}</th><th style="width:35%"></th></tr></thead> <tbody id="file-download-rows"></tbody> </table> </div> </div> </div> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.most_browsed_file_areas') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.area') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.views') }}</th><th style="width:35%"></th></tr></thead> <tbody id="filearea-rows"></tbody> </table> </div> </div> </div> </div> </div> <!-- Nodelist Tab --> <div class="tab-pane fade" id="pane-nodelist" role="tabpanel"> <div class="row"> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.most_searched_nodelist_queries') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.query') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.searches') }}</th><th style="width:35%"></th></tr></thead> <tbody id="nodelist-search-rows"></tbody> </table> </div> </div> </div> <div class="col-lg-6 mb-4"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.most_viewed_nodes') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.node') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.views') }}</th><th style="width:35%"></th></tr></thead> <tbody id="node-view-rows"></tbody> </table> </div> </div> </div> </div> </div> <!-- Top Users Tab --> <div class="tab-pane fade" id="pane-users" role="tabpanel"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.most_active_users') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.user') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.events') }}</th><th style="width:35%"></th></tr></thead> <tbody id="top-users-rows"></tbody> </table> </div> </div> </div> <!-- Hourly Distribution Tab --> <div class="tab-pane fade" id="pane-hourly" role="tabpanel"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.activity_by_hour_of_day') }} (<span id="hourly-tz-label">{{ t('ui.admin.activity_stats.utc', {}, locale, ['common']) }}</span>)</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.hour') }} (<span class="hourly-tz-col">{{ t('ui.admin.activity_stats.utc', {}, locale, ['common']) }}</span>)</th><th class="text-end">{{ t('ui.admin.activity_stats.events') }}</th><th style="width:50%"></th></tr></thead> <tbody id="hourly-rows"></tbody> </table> </div> </div> </div> {% if interests_enabled %} <!-- Popular Interests Tab --> <div class="tab-pane fade" id="pane-interests" role="tabpanel"> <div class="card shadow-sm"> <div class="card-header"><strong>{{ t('ui.admin.activity_stats.popular_interests') }}</strong></div> <div class="card-body p-0"> <table class="table table-sm table-hover mb-0"> <thead><tr><th>{{ t('ui.admin.activity_stats.interest') }}</th><th class="text-end">{{ t('ui.admin.activity_stats.subscribers') }}</th><th style="width:35%"></th></tr></thead> <tbody id="interest-rows"></tbody> </table> </div> </div> </div> {% endif %} </div><!-- /.tab-content --> </div><!-- /#stats-content --> </div> <script> (function() { const userTimezone = {{ user_timezone|json_encode(15)|raw }}; function uiT(key, fallback, params = {}) { if (window.t) { return window.t(key, params, fallback); } return fallback; } function formatDate(dateStr) { const parts = dateStr.split('-'); const localDate = new Date(parts[0], parts[1] - 1, parts[2]); return localDate.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }); } function getTzAbbr(timezone) { try { return new Intl.DateTimeFormat(undefined, { timeZone: timezone, timeZoneName: 'short' }) .formatToParts(new Date()) .find(p => p.type === 'timeZoneName')?.value || timezone; } catch (e) { return timezone; } } function escapeHtml(str) { if (!str) return '&mdash;'; return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } function apiError(payload, fallback) { if (window.getApiErrorMessage) { return window.getApiErrorMessage(payload, fallback); } return payload && payload.error ? payload.error : fallback; } function progressBar(value, max, cls) { const pct = max > 0 ? Math.round((value / max) * 100) : 0; return `<div class="progress" style="height:14px"><div class="progress-bar ${cls || ''}" style="width:${pct}%"></div></div>`; } function renderRows(tbodyId, items, nameFn, valueFn, barCls) { const tbody = document.getElementById(tbodyId); if (!tbody) return; if (!items || items.length === 0) { tbody.innerHTML = `<tr><td colspan="3" class="text-muted text-center py-3">${uiT('ui.admin.activity_stats.no_data_for_period', 'No data for this period')}</td></tr>`; return; } const maxVal = Math.max(...items.map(r => parseInt(valueFn(r)) || 0)); tbody.innerHTML = items.map(r => { const val = parseInt(valueFn(r)) || 0; return `<tr> <td class="text-truncate" style="max-width:180px">${escapeHtml(nameFn(r))}</td> <td class="text-end fw-semibold">${val.toLocaleString()}</td> <td>${progressBar(val, maxVal, barCls)}</td> </tr>`; }).join(''); } function summaryCard(label, count, icon, cls, bg, sub) { const subHtml = sub ? `<div class="text-muted" style="font-size:0.7rem;line-height:1.4">${sub}</div>` : ''; return `<div class="col-xl-2 col-md-4 col-sm-6 mb-4"> <div class="card shadow-sm h-100"> <div class="card-body d-flex align-items-center gap-3"> <div class="rounded-circle p-3 ${bg} bg-opacity-10 flex-shrink-0"> <i class="fas ${icon} fa-lg ${cls}"></i> </div> <div> <div class="text-muted small text-uppercase">${label}</div> <div class="h4 mb-0 fw-bold">${count.toLocaleString()}</div> ${subHtml} </div> </div> </div> </div>`; } function renderSummaryCards(data) { const summary = data.summary || {}; const byCategory = summary.by_category || {}; const byType = summary.by_type || {}; const total = summary.total || 0; const echoViews = byType.echomail_views || 0; const echoSends = byType.echomail_sends || 0; const netReads = byType.netmail_reads || 0; const netSends = byType.netmail_sends || 0; document.getElementById('summary-cards').innerHTML = summaryCard(uiT('ui.admin.activity_stats.echomail', 'Echomail'), echoViews + echoSends, 'fa-comments', 'text-primary', 'bg-primary', uiT('ui.admin.activity_stats.views_sent', 'Views: {views} · Sent: {sent}', { views: echoViews.toLocaleString(), sent: echoSends.toLocaleString() })) + summaryCard(uiT('ui.admin.activity_stats.netmail', 'Netmail'), netReads + netSends, 'fa-envelope', 'text-secondary', 'bg-secondary', uiT('ui.admin.activity_stats.read_sent', 'Read: {read} · Sent: {sent}', { read: netReads.toLocaleString(), sent: netSends.toLocaleString() })) + summaryCard(uiT('ui.admin.activity_stats.files', 'Files'), byCategory.file || 0, 'fa-folder-open', 'text-success', 'bg-success') + summaryCard(uiT('ui.admin.activity_stats.door_plays', 'Door Plays'), byCategory.door || 0, 'fa-gamepad', 'text-warning', 'bg-warning') + summaryCard(uiT('ui.admin.activity_stats.logins', 'Logins'), byCategory.auth || 0, 'fa-sign-in-alt', 'text-info', 'bg-info') + summaryCard(uiT('ui.admin.activity_stats.total', 'Total'), total, 'fa-chart-bar', 'text-secondary', 'bg-secondary'); } function renderOverview(data) { const byCategory = (data.summary || {}).by_category || {}; const byType = (data.summary || {}).by_type || {}; const loginBySource = (data.summary || {}).login_by_source || {}; const echoViews = byType.echomail_views || 0; const echoSends = byType.echomail_sends || 0; const netReads = byType.netmail_reads || 0; const netSends = byType.netmail_sends || 0; const allVals = [ echoViews + echoSends, echoViews, echoSends, netReads + netSends, netReads, netSends, byCategory.file || 0, byCategory.door || 0, byCategory.nodelist || 0, byCategory.chat || 0, byCategory.auth || 0, ...Object.values(loginBySource), ]; const maxCat = Math.max(...allVals); function groupRow(label, val) { return `<tr class="fw-semibold"> <td>${label}</td> <td class="text-end">${val.toLocaleString()}</td> <td>${progressBar(val, maxCat, 'bg-primary')}</td> </tr>`; } function subRow(label, val, barCls) { return `<tr class="text-muted" style="font-size:0.875rem"> <td class="ps-4"><i class="fas fa-angle-right me-1 opacity-50"></i>${label}</td> <td class="text-end">${val.toLocaleString()}</td> <td>${progressBar(val, maxCat, barCls)}</td> </tr>`; } function plainRow(label, val) { return `<tr> <td>${label}</td> <td class="text-end fw-semibold">${val.toLocaleString()}</td> <td>${progressBar(val, maxCat, 'bg-primary')}</td> </tr>`; } const catTbody = document.getElementById('category-rows'); catTbody.innerHTML = groupRow(uiT('ui.admin.activity_stats.echomail', 'Echomail'), echoViews + echoSends) + subRow(uiT('ui.admin.activity_stats.area_views', 'Area Views'), echoViews, 'bg-primary') + subRow(uiT('ui.admin.activity_stats.sent', 'Sent'), echoSends, 'bg-success') + groupRow(uiT('ui.admin.activity_stats.netmail', 'Netmail'), netReads + netSends) + subRow(uiT('ui.admin.activity_stats.read', 'Read'), netReads, 'bg-secondary') + subRow(uiT('ui.admin.activity_stats.sent', 'Sent'), netSends, 'bg-success') + plainRow(uiT('ui.admin.activity_stats.files', 'Files'), byCategory.file || 0) + plainRow(uiT('ui.admin.activity_stats.doors', 'Doors'), byCategory.door || 0) + plainRow(uiT('ui.admin.activity_stats.nodelist', 'Nodelist'), byCategory.nodelist || 0) + plainRow(uiT('ui.admin.activity_stats.chat', 'Chat'), byCategory.chat || 0) + groupRow(uiT('ui.admin.activity_stats.auth', 'Auth'), byCategory.auth || 0) + Object.entries(loginBySource).map(([src, cnt]) => subRow(escapeHtml(src.charAt(0).toUpperCase() + src.slice(1)), cnt, 'bg-info') ).join(''); const daily = data.daily || []; const dailyTbody = document.getElementById('daily-rows'); if (daily.length === 0) { dailyTbody.innerHTML = `<tr><td colspan="3" class="text-muted text-center py-3">${uiT('ui.admin.activity_stats.no_data', 'No data')}</td></tr>`; } else { const maxDay = Math.max(...daily.map(d => d.count || 0)); dailyTbody.innerHTML = daily.slice().reverse().map(d => `<tr> <td>${escapeHtml(formatDate(d.date))}</td> <td class="text-end fw-semibold">${(d.count || 0).toLocaleString()}</td> <td>${progressBar(d.count || 0, maxDay, 'bg-info')}</td> </tr>` ).join(''); } } function renderAreas(data) { const areas = data.popular_echoareas || []; renderRows('echoarea-views-rows', areas, r => r.name, r => r.views, 'bg-primary'); renderRows('echoarea-posts-rows', areas.slice().sort((a, b) => (b.posts || 0) - (a.posts || 0)), r => r.name, r => r.posts, 'bg-success'); } function renderDoors(data) { renderRows('webdoor-rows', data.popular_webdoors || [], r => r.name, r => r.count, 'bg-warning'); renderRows('dosdoor-rows', data.popular_dosdoors || [], r => r.name, r => r.count, 'bg-danger'); } function renderFiles(data) { renderRows('file-download-rows', data.top_files || [], r => r.name, r => r.count, 'bg-success'); renderRows('filearea-rows', data.top_fileareas || [], r => r.area_name, r => r.count, 'bg-info'); } function renderNodelist(data) { renderRows('nodelist-search-rows', data.top_nodelist_searches || [], r => r.name, r => r.count, 'bg-secondary'); renderRows('node-view-rows', data.top_nodes || [], r => r.name, r => r.count, 'bg-primary'); } function renderUsers(data) { renderRows('top-users-rows', data.top_users || [], r => r.username || uiT('ui.admin.activity_stats.anonymous', '(anon)'), r => r.count, 'bg-primary'); } function renderInterests(data) { const tbody = document.getElementById('interest-rows'); if (!tbody) return; const interests = data.popular_interests || []; if (interests.length === 0) { tbody.innerHTML = `<tr><td colspan="3" class="text-muted text-center py-3">${uiT('ui.admin.activity_stats.no_data_for_period', 'No data for this period')}</td></tr>`; return; } const maxVal = Math.max(...interests.map(r => r.subscribers || 0)); tbody.innerHTML = interests.map(r => { const val = r.subscribers || 0; const icon = escapeHtml(r.icon || 'fa-layer-group'); const color = escapeHtml(r.color || '#6c757d'); const name = escapeHtml(r.name || ''); return `<tr> <td class="text-truncate" style="max-width:220px"> <i class="fas ${icon} me-2" style="color:${color}"></i>${name} </td> <td class="text-end fw-semibold">${val.toLocaleString()}</td> <td>${progressBar(val, maxVal, 'bg-primary')}</td> </tr>`; }).join(''); } function renderHourly(data) { const hourly = data.hourly || []; const tbody = document.getElementById('hourly-rows'); const tzAbbr = getTzAbbr(data.timezone || userTimezone); document.getElementById('hourly-tz-label').textContent = tzAbbr; document.querySelectorAll('.hourly-tz-col').forEach(el => el.textContent = tzAbbr); if (hourly.length === 0) { tbody.innerHTML = `<tr><td colspan="3" class="text-muted text-center py-3">${uiT('ui.admin.activity_stats.no_data', 'No data')}</td></tr>`; return; } const maxH = Math.max(...hourly.map(h => h.count || 0)); tbody.innerHTML = hourly.map(h => { const label = String(h.hour).padStart(2, '0') + ':00'; return `<tr> <td>${label}</td> <td class="text-end fw-semibold">${(h.count || 0).toLocaleString()}</td> <td>${progressBar(h.count || 0, maxH, 'bg-primary')}</td> </tr>`; }).join(''); } function renderAll(data) { renderSummaryCards(data); renderOverview(data); renderAreas(data); renderDoors(data); renderFiles(data); renderNodelist(data); renderUsers(data); renderHourly(data); renderInterests(data); } function loadStats() { const period = document.getElementById('period-select').value; const excludeAdmins = document.getElementById('exclude-admins').checked ? '1' : '0'; document.getElementById('loading-indicator').style.display = ''; document.getElementById('stats-content').style.display = 'none'; $.getJSON('/admin/api/activity-stats?period=' + encodeURIComponent(period) + '&exclude_admins=' + excludeAdmins + '&timezone=' + encodeURIComponent(userTimezone)) .done(function(data) { renderAll(data); document.getElementById('loading-indicator').style.display = 'none'; document.getElementById('stats-content').style.display = ''; }) .fail(function(xhr) { document.getElementById('loading-indicator').innerHTML = `<div class="alert alert-danger">${uiT('ui.admin.activity_stats.failed_load_statistics_prefix', 'Failed to load statistics. ')} ` + escapeHtml(apiError(xhr.responseJSON, '')) + '</div>'; }); } document.getElementById('period-select').addEventListener('change', loadStats); document.getElementById('exclude-admins').addEventListener('change', loadStats); $(document).ready(function() { loadI18nNamespaces(['common', 'errors']).then(function() { loadStats(); }); }); })(); </script> {% endblock %}