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
This commit is contained in:
parent
474e9c6176
commit
e4d5024e83
|
|
@ -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*))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `<tr>
|
||||
<td>${countryToFlag(country)} ${country}</td>
|
||||
const isExpanded = expandedCountries.has(country);
|
||||
const arrow = isExpanded ? '▼' : '▶';
|
||||
return `<tr class="country-row" data-country="${country}" style="cursor: pointer;" onclick="toggleCountryCities('${country}')">
|
||||
<td><span class="expand-arrow">${arrow}</span> ${countryToFlag(country)} ${country}</td>
|
||||
<td>${listeners}</td>
|
||||
<td>${minutes}</td>
|
||||
</tr>
|
||||
<tr class="city-rows" id="cities-${country}" style="display: ${isExpanded ? 'table-row' : 'none'};">
|
||||
<td colspan="3" style="padding: 0;"><div class="city-container" id="city-container-${country}"></div></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
// Re-fetch cities for expanded countries
|
||||
expandedCountries.forEach(country => fetchCities(country));
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
|
||||
}
|
||||
|
|
@ -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 = '<div style="padding: 5px 20px; color: #888;">Loading cities...</div>';
|
||||
|
||||
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 = '<table style="width: 100%; margin-left: 20px;">' +
|
||||
data.cities.map(city => `
|
||||
<tr style="background: rgba(0,255,0,0.05);">
|
||||
<td style="padding: 3px 10px;">└ ${city.city}</td>
|
||||
<td style="padding: 3px 10px;">${city.listeners}</td>
|
||||
<td style="padding: 3px 10px;">${city.minutes}</td>
|
||||
</tr>
|
||||
`).join('') + '</table>';
|
||||
} else {
|
||||
container.innerHTML = '<div style="padding: 5px 20px; color: #888;">No city data</div>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching cities:', error);
|
||||
container.innerHTML = '<div style="padding: 5px 20px; color: #ff6666;">Error loading cities</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-refresh stats every 30 seconds
|
||||
setInterval(refreshListenerStats, 30000);
|
||||
setInterval(refreshGeoStats, 60000);
|
||||
|
|
|
|||
Loading…
Reference in New Issue