From a11f64b6364c1504c681fd5148d97d3c3f94056b Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Fri, 12 Dec 2025 20:47:46 +0300 Subject: [PATCH] Add expandable city breakdown in geo stats admin view - Add get-geo-stats-by-city function for city-level queries - Add /api/asteroid/stats/geo/cities endpoint - Add expandable country rows in admin geo stats table - Click country to expand/collapse city breakdown --- asteroid.lisp | 12 +++++++++ listener-stats.lisp | 16 ++++++++++++ template/admin.ctml | 60 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/asteroid.lisp b/asteroid.lisp index 6329be5..405fd43 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -1281,6 +1281,18 @@ ("total_minutes" . ,(third row)))) stats)))))) +(define-api asteroid/stats/geo/cities (country &optional (days "7")) () + "Get city breakdown for a specific country (admin only)" + (require-role :admin) + (let ((stats (get-geo-stats-by-city country (parse-integer days :junk-allowed t)))) + (api-output `(("status" . "success") + ("country" . ,country) + ("cities" . ,(mapcar (lambda (row) + `(("city" . ,(or (first row) "Unknown")) + ("listeners" . ,(second row)) + ("minutes" . ,(third row)))) + stats)))))) + ;; RADIANCE server management functions (defun start-server (&key (port *server-port*)) diff --git a/listener-stats.lisp b/listener-stats.lisp index 833e4ba..da6bc67 100644 --- a/listener-stats.lisp +++ b/listener-stats.lisp @@ -382,6 +382,22 @@ (log:error "Failed to get geo stats: ~a" e) nil))) +(defun get-geo-stats-by-city (country-code &optional (days 7)) + "Get city breakdown for a specific country for the last N days" + (handler-case + (with-db + (postmodern:query + (format nil "SELECT city, SUM(listener_count) as total_listeners, SUM(listen_minutes) as total_minutes + FROM listener_geo_stats + WHERE date > NOW() - INTERVAL '~a days' + AND country_code = '~a' + GROUP BY city + ORDER BY total_listeners DESC + LIMIT 10" days country-code))) + (error (e) + (log:error "Failed to get city stats for ~a: ~a" country-code e) + nil))) + (defun get-user-listening-stats (user-id) "Get listening statistics for a specific user" (handler-case diff --git a/template/admin.ctml b/template/admin.ctml index 2c078c5..0b8a27d 100644 --- a/template/admin.ctml +++ b/template/admin.ctml @@ -336,6 +336,9 @@ return String.fromCodePoint(...codePoints); } + // Track expanded countries + const expandedCountries = new Set(); + // Fetch and display geo stats function refreshGeoStats() { fetch('/api/asteroid/stats/geo?days=7') @@ -349,12 +352,19 @@ const country = item.country_code || item[0]; const listeners = item.total_listeners || item[1] || 0; const minutes = item.total_minutes || item[2] || 0; - return ` - ${countryToFlag(country)} ${country} + const isExpanded = expandedCountries.has(country); + const arrow = isExpanded ? '▼' : '▶'; + return ` + ${arrow} ${countryToFlag(country)} ${country} ${listeners} ${minutes} + + +
`; }).join(''); + // Re-fetch cities for expanded countries + expandedCountries.forEach(country => fetchCities(country)); } else { tbody.innerHTML = 'No geo data yet'; } @@ -366,6 +376,52 @@ }); } + // Toggle city display for a country + function toggleCountryCities(country) { + const cityRow = document.getElementById(`cities-${country}`); + const countryRow = document.querySelector(`tr[data-country="${country}"]`); + const arrow = countryRow.querySelector('.expand-arrow'); + + if (expandedCountries.has(country)) { + expandedCountries.delete(country); + cityRow.style.display = 'none'; + arrow.textContent = '▶'; + } else { + expandedCountries.add(country); + cityRow.style.display = 'table-row'; + arrow.textContent = '▼'; + fetchCities(country); + } + } + + // Fetch cities for a country + function fetchCities(country) { + const container = document.getElementById(`city-container-${country}`); + container.innerHTML = '
Loading cities...
'; + + fetch(`/api/asteroid/stats/geo/cities?country=${country}&days=7`) + .then(response => response.json()) + .then(result => { + const data = result.data || result; + if (data.status === 'success' && data.cities && data.cities.length > 0) { + container.innerHTML = '' + + data.cities.map(city => ` + + + + + + `).join('') + '
└ ${city.city}${city.listeners}${city.minutes}
'; + } else { + container.innerHTML = '
No city data
'; + } + }) + .catch(error => { + console.error('Error fetching cities:', error); + container.innerHTML = '
Error loading cities
'; + }); + } + // Auto-refresh stats every 30 seconds setInterval(refreshListenerStats, 30000); setInterval(refreshGeoStats, 60000);