From aba4e5a4d4a6190b90c812be1ee133865b21bf1c Mon Sep 17 00:00:00 2001 From: Standley Gury Date: Wed, 15 Jul 2026 23:07:27 +0800 Subject: [PATCH] add statistics endpoint for today's top songs and implement pagination and sorting in song and user stats - Added new API endpoint `/api/statistics-today` to fetch top songs for the current day. - Introduced `Pagination` component for paginating song and user statistics. - Implemented `SortableTh` component for sortable table headers in song and user stats. - Enhanced `SongStats` and `UserStats` pages to support searching, sorting, and exporting to CSV. - Updated styles for pagination, toolbar, and responsive design adjustments. - Refactored data fetching logic to accommodate new statistics endpoint and improved data handling. --- .claude/settings.local.json | 7 +- src/UI/Api/ControllerExtensions.cs | 5 + src/UI/App/src/components/Pagination.tsx | 47 +++ src/UI/App/src/components/SortableTh.tsx | 34 ++ src/UI/App/src/hooks/useIsMobile.ts | 17 + src/UI/App/src/index.css | 292 ++++++++++++- src/UI/App/src/layouts/DashboardLayout.tsx | 16 +- src/UI/App/src/pages/RadioAdmin.tsx | 5 +- src/UI/App/src/pages/SongStats.tsx | 341 ++++++++++++---- src/UI/App/src/pages/UserStats.tsx | 382 ++++++++++++------ src/UI/App/src/router.tsx | 2 +- src/UI/App/src/services/song-stats-service.ts | 13 + src/UI/App/src/utils/csv.ts | 23 ++ 13 files changed, 970 insertions(+), 214 deletions(-) create mode 100644 src/UI/App/src/components/Pagination.tsx create mode 100644 src/UI/App/src/components/SortableTh.tsx create mode 100644 src/UI/App/src/hooks/useIsMobile.ts create mode 100644 src/UI/App/src/utils/csv.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4d7cf3d..025c0c4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,12 @@ "allow": [ "WebFetch(domain:github.com)", "WebSearch", - "WebFetch(domain:support.discord.com)" + "WebFetch(domain:support.discord.com)", + "Skill(dataviz)", + "Bash(node scripts/validate_palette.js \"#ff6b6b,#4ecdc4,#45b7d1,#f9ca24,#f0932b,#eb4d4b,#6ab04c,#9c88ff\" --mode dark)", + "PowerShell(node \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\bundled-skills\\\\2.1.206\\\\086a8e9e37ae6507cf6e474311e79744\\\\dataviz\\\\scripts\\\\validate_palette.js\" \"#ff6b6b,#4ecdc4,#45b7d1,#f9ca24,#f0932b,#eb4d4b,#6ab04c,#9c88ff\" --mode dark)", + "PowerShell(node \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\bundled-skills\\\\2.1.206\\\\086a8e9e37ae6507cf6e474311e79744\\\\dataviz\\\\scripts\\\\validate_palette.js\" \"#3987e5,#199e70,#c98500,#008300,#9085e9,#e66767,#d55181,#d95926\" --mode dark --surface \"#366472\")", + "PowerShell(node \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\G--personal-Discord-Music-Bot\\\\42ca7a0b-f5a7-4a2e-a708-2f026c81c580\\\\scratchpad\\\\mock-api.js\")" ] } } diff --git a/src/UI/Api/ControllerExtensions.cs b/src/UI/Api/ControllerExtensions.cs index ebf5d70..0451fac 100644 --- a/src/UI/Api/ControllerExtensions.cs +++ b/src/UI/Api/ControllerExtensions.cs @@ -17,6 +17,11 @@ public void AddApiController() await statisticsService.GetAllSongsAsync()) .WithName("GetStatisticsAll"); + app.MapGet("/api/statistics-today", + async (IStatisticsService statisticsService, int? limit) => + await statisticsService.GetTopSongsAsync(true, limit ?? 100)) + .WithName("GetStatisticsToday"); + app.MapGet("/api/users", async (IUserService userService) => await userService.GetAllUsersAsync()) .WithName("GetAllUsers"); diff --git a/src/UI/App/src/components/Pagination.tsx b/src/UI/App/src/components/Pagination.tsx new file mode 100644 index 0000000..b02de99 --- /dev/null +++ b/src/UI/App/src/components/Pagination.tsx @@ -0,0 +1,47 @@ +interface PaginationProps { + page: number; + pageCount: number; + totalItems: number; + pageSize: number; + onPageChange: (page: number) => void; +} + +export function Pagination({ + page, + pageCount, + totalItems, + pageSize, + onPageChange, +}: PaginationProps) { + if (pageCount <= 1) return null; + + const start = (page - 1) * pageSize + 1; + const end = Math.min(page * pageSize, totalItems); + + return ( +
+ + Showing {start}-{end} of {totalItems} + +
+ + + {page} / {pageCount} + + +
+
+ ); +} diff --git a/src/UI/App/src/components/SortableTh.tsx b/src/UI/App/src/components/SortableTh.tsx new file mode 100644 index 0000000..2a08b8b --- /dev/null +++ b/src/UI/App/src/components/SortableTh.tsx @@ -0,0 +1,34 @@ +interface SortableThProps { + label: string; + active: boolean; + direction: 'asc' | 'desc'; + onSort: () => void; + className?: string; +} + +export function SortableTh({ + label, + active, + direction, + onSort, + className, +}: SortableThProps) { + return ( + + + {label} + + + ); +} diff --git a/src/UI/App/src/hooks/useIsMobile.ts b/src/UI/App/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..bbd2fa4 --- /dev/null +++ b/src/UI/App/src/hooks/useIsMobile.ts @@ -0,0 +1,17 @@ +import { useSyncExternalStore } from 'react'; + +const MOBILE_QUERY = '(max-width: 768px)'; + +function subscribe(callback: () => void) { + const mql = window.matchMedia(MOBILE_QUERY); + mql.addEventListener('change', callback); + return () => mql.removeEventListener('change', callback); +} + +function getSnapshot() { + return window.matchMedia(MOBILE_QUERY).matches; +} + +export function useIsMobile(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/src/UI/App/src/index.css b/src/UI/App/src/index.css index 0eb74f3..f8f2a52 100644 --- a/src/UI/App/src/index.css +++ b/src/UI/App/src/index.css @@ -77,6 +77,7 @@ body { .stats-grid { display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; } @@ -102,6 +103,16 @@ body { .stat-value { font-size: 2rem; font-weight: 700; + overflow-wrap: anywhere; +} + +.stat-song-title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; + font-size: 1.2rem; } .stat-label { @@ -163,11 +174,54 @@ body { backdrop-filter: blur(20px); } +.table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + .table { width: 100%; border-collapse: collapse; } +.table th.sortable { + cursor: pointer; + user-select: none; +} + +.table th.sortable:hover { + color: rgba(255, 255, 255, 1); +} + +.sort-header { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.sort-arrow { + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid rgba(255, 255, 255, 0.25); +} + +.sort-arrow.active { + border-top-color: rgba(255, 255, 255, 0.9); +} + +.sort-arrow.active.asc { + border-top: none; + border-bottom: 5px solid rgba(255, 255, 255, 0.9); +} + +.empty-state { + text-align: center; + color: rgba(255, 255, 255, 0.6); + padding: 2rem 1rem; +} + .table th, .table td { text-align: left; @@ -218,6 +272,69 @@ body { width: 100%; } +/* ===== Toolbar (search / filters / export) ===== */ + +.toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.search-input { + flex: 1; + min-width: 180px; + max-width: 340px; + border-radius: 50px; + padding: 0.6rem 1.1rem; +} + +.export-button { + padding: 0.6rem 1.25rem; + font-size: 0.85rem; + margin-left: auto; +} + +/* ===== Pagination ===== */ + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1.5rem; +} + +.pagination-info { + color: rgba(255, 255, 255, 0.6); + font-size: 0.85rem; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.pagination-button { + padding: 0.5rem 1.1rem; + font-size: 0.85rem; +} + +.pagination-button:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.pagination-page { + color: rgba(255, 255, 255, 0.8); + font-size: 0.85rem; + font-variant-numeric: tabular-nums; +} + .loading { display: flex; justify-content: center; @@ -373,7 +490,7 @@ body { margin: 0 auto; display: flex; align-items: center; - justify-content: space-between; + gap: 1rem; } .logo { @@ -400,6 +517,13 @@ body { display: flex; gap: 0.5rem; align-items: center; + margin-left: auto; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.5rem; } .nav-button { @@ -787,6 +911,35 @@ body { /* ===== Responsive ===== */ +/* Tablet */ +@media (max-width: 1024px) { + .main { + padding: 1.5rem; + } + + .header-container { + padding: 1rem 1.5rem; + } + + .nav-button { + padding: 0.6rem 1rem; + font-size: 0.85rem; + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .content-card { + padding: 1.5rem; + } + + .table .hide-md { + display: none; + } +} + +/* Mobile */ @media (max-width: 768px) { .header { flex-direction: column; @@ -794,40 +947,96 @@ body { align-items: stretch; } - .toggle-button { + .title { + font-size: 1.5rem; + } + + .view-toggle { width: 100%; } - .stats-grid { - grid-template-columns: 1fr; + .toggle-button { + flex: 1; + padding: 0.6rem 0.5rem; } - .table { - font-size: 0.9rem; + .header-container { + padding: 0.75rem 1rem; } - .table td { - padding: 0.75rem 0.5rem; + .header-content { + flex-wrap: wrap; + row-gap: 0.75rem; } - .header-container { - padding: 1rem; + .logo { + font-size: 1.2rem; } - .header-content { - flex-direction: column; - gap: 1rem; + .header-actions { + margin-left: auto; + padding: 0 0.25rem; } .nav { - flex-wrap: wrap; - justify-content: center; + order: 3; + width: 100%; + margin-left: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .nav::-webkit-scrollbar { + display: none; + } + + .nav-button { + flex: 1; + white-space: nowrap; + padding: 0.6rem 0.9rem; } .main { padding: 1rem; } + .toolbar .view-toggle { + width: auto; + } + + .search-input { + max-width: none; + flex-basis: 100%; + } + + .export-button { + margin-left: 0; + } + + .content-card { + padding: 1rem; + min-height: 300px; + } + + .chart-container { + height: 320px; + } + + .table { + font-size: 0.9rem; + min-width: 0; + } + + .table th, + .table td { + padding: 0.75rem 0.5rem; + } + + .table .hide-sm { + display: none; + } + .user-info { gap: 0.5rem; } @@ -838,12 +1047,65 @@ body { font-size: 1rem; } + .recent-song-date { + width: 5.2rem; + font-size: 0.75rem; + } + + .recent-song-item { + padding: 0.55rem 0.65rem; + } + .radio-grid { grid-template-columns: 1fr; } + .radio-actions { + flex-wrap: wrap; + } + + .action-button { + flex: 1; + padding: 0.65rem 1rem; + } + .modal-content { margin: 1rem; width: calc(100% - 2rem); + max-height: 90dvh; + padding: 1.25rem; + } + + .form-actions { + flex-direction: column-reverse; + } + + .form-button { + width: 100%; + } +} + +/* Small phones */ +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 1.25rem; + } + + .stat-card { + padding: 1rem; + } + + .stat-value { + font-size: 1.4rem; + } + + .stat-song-title { + font-size: 1rem; + } + + .stat-label { + font-size: 0.8rem; } } diff --git a/src/UI/App/src/layouts/DashboardLayout.tsx b/src/UI/App/src/layouts/DashboardLayout.tsx index 3fe5f16..1d6f7eb 100644 --- a/src/UI/App/src/layouts/DashboardLayout.tsx +++ b/src/UI/App/src/layouts/DashboardLayout.tsx @@ -29,18 +29,18 @@ export function DashboardLayout() { Rytho Dashboard +
{!isAuthenticated ? (
)} - +
diff --git a/src/UI/App/src/pages/RadioAdmin.tsx b/src/UI/App/src/pages/RadioAdmin.tsx index 260225b..50427fb 100644 --- a/src/UI/App/src/pages/RadioAdmin.tsx +++ b/src/UI/App/src/pages/RadioAdmin.tsx @@ -147,10 +147,7 @@ export function RadioAdmin() { -
+

{totalStations}

Total Stations

diff --git a/src/UI/App/src/pages/SongStats.tsx b/src/UI/App/src/pages/SongStats.tsx index e53905a..7740157 100644 --- a/src/UI/App/src/pages/SongStats.tsx +++ b/src/UI/App/src/pages/SongStats.tsx @@ -9,20 +9,50 @@ import { ResponsiveContainer, Tooltip, } from 'recharts'; -import { loadSongStats, cleanTitle } from '../services/song-stats-service'; +import { + loadSongStats, + loadTopSongsToday, + cleanTitle, +} from '../services/song-stats-service'; import { LoadingSpinner } from '../components/LoadingSpinner'; import { AppError } from '../components/AppError'; +import { Pagination } from '../components/Pagination'; +import { SortableTh } from '../components/SortableTh'; +import { useIsMobile } from '../hooks/useIsMobile'; +import { exportCsv } from '../utils/csv'; + +const PAGE_SIZE = 10; +// Validated single-hue mark color: 3.09:1 contrast on the dashboard surface. +const BAR_COLOR = '#86b6ef'; + +type SortKey = 'playCount' | 'lastPlayed'; +type TimeRange = 'all' | 'today'; + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max - 3)}...` : text; +} + +function formatDate(value?: Date | string | null): string { + return value ? String(value).split('T')[0] : ''; +} export function SongStats() { const [viewMode, setViewMode] = useState<'table' | 'chart'>('table'); + const [timeRange, setTimeRange] = useState('all'); + const [search, setSearch] = useState(''); + const [sortKey, setSortKey] = useState('playCount'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const [page, setPage] = useState(1); + const isMobile = useIsMobile(); const { data: songs, isLoading, error, } = useQuery({ - queryKey: ['songStats'], - queryFn: loadSongStats, + queryKey: ['songStats', timeRange], + queryFn: () => + timeRange === 'today' ? loadTopSongsToday() : loadSongStats(), }); if (isLoading) return ; @@ -30,13 +60,69 @@ export function SongStats() { if (!songs) return null; const totalPlays = songs.reduce((sum, song) => sum + song.playCount, 0); - const avgPlays = totalPlays > 0 ? Math.round(totalPlays / songs.length) : 0; - const topSong = songs[0]; + const avgPlays = songs.length > 0 ? Math.round(totalPlays / songs.length) : 0; + const topSong = songs.reduce( + (max, song) => (song.playCount > (max?.playCount ?? 0) ? song : max), + songs[0], + ); + + const query = search.trim().toLowerCase(); + const filtered = query + ? songs.filter((song) => + `${cleanTitle(song.title)} ${song.artist ?? ''}` + .toLowerCase() + .includes(query), + ) + : songs; + + const sorted = [...filtered].sort((a, b) => { + const delta = + sortKey === 'playCount' + ? a.playCount - b.playCount + : new Date(a.lastPlayed).getTime() - new Date(b.lastPlayed).getTime(); + return sortDir === 'asc' ? delta : -delta; + }); + + const pageCount = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); + const safePage = Math.min(page, pageCount); + const pageRows = sorted.slice( + (safePage - 1) * PAGE_SIZE, + safePage * PAGE_SIZE, + ); - const chartData = songs.slice(0, 10).map((song) => ({ - name: `${cleanTitle(song.title)} ${song.artist ? `- ${song.artist}` : ''}`, - playCount: song.playCount, - })); + const chartData = [...filtered] + .sort((a, b) => b.playCount - a.playCount) + .slice(0, 10) + .map((song) => ({ + name: truncate( + `${cleanTitle(song.title)}${song.artist ? ` - ${song.artist}` : ''}`, + isMobile ? 22 : 28, + ), + playCount: song.playCount, + })); + + function handleSort(key: SortKey) { + if (sortKey === key) { + setSortDir(sortDir === 'desc' ? 'asc' : 'desc'); + } else { + setSortKey(key); + setSortDir('desc'); + } + setPage(1); + } + + function handleExport() { + exportCsv( + `song-stats-${timeRange}.csv`, + ['Title', 'Artist', 'Play Count', 'Last Played'], + sorted.map((song) => [ + cleanTitle(song.title), + song.artist ?? '', + song.playCount, + formatDate(song.lastPlayed), + ]), + ); + } return ( <> @@ -58,13 +144,12 @@ export function SongStats() {
-
+

{totalPlays.toLocaleString()}

-

Total Plays

+

+ {timeRange === 'today' ? 'Plays Today' : 'Total Plays'} +

{songs.length}

@@ -82,67 +167,181 @@ export function SongStats() {
-
+
+ { + setSearch(e.target.value); + setPage(1); + }} + /> +
+ + +
+ +
+ +
{viewMode === 'table' ? ( - - - - - - - - - - {songs.slice(0, 10).map((song, index) => ( - - - - - - ))} - -
RankSongPlays
{index + 1} -
-
{cleanTitle(song.title)}
-
{song.artist}
-
-
- - {song.playCount.toLocaleString()} - -
+ <> +
+ + + + + + handleSort('playCount')} + /> + handleSort('lastPlayed')} + className="hide-sm" + /> + + + + {pageRows.map((song, index) => ( + + + + + + + ))} + {pageRows.length === 0 && ( + + + + )} + +
RankSong
{(safePage - 1) * PAGE_SIZE + index + 1} +
+
+ {cleanTitle(song.title)} +
+
{song.artist}
+
+
+ + {song.playCount.toLocaleString()} + + {formatDate(song.lastPlayed)}
+ {timeRange === 'today' + ? 'No songs played today.' + : 'No songs match your search.'} +
+
+ + ) : (
- - - - - - - + {isMobile ? ( + + + + + + + + ) : ( + + + + + + + + )}
)} diff --git a/src/UI/App/src/pages/UserStats.tsx b/src/UI/App/src/pages/UserStats.tsx index bd648fa..f38de6a 100644 --- a/src/UI/App/src/pages/UserStats.tsx +++ b/src/UI/App/src/pages/UserStats.tsx @@ -12,21 +12,45 @@ import { loadUsers } from '../services/user-service'; import { cleanTitle } from '../services/song-stats-service'; import { LoadingSpinner } from '../components/LoadingSpinner'; import { AppError } from '../components/AppError'; +import { Pagination } from '../components/Pagination'; +import { SortableTh } from '../components/SortableTh'; +import { useIsMobile } from '../hooks/useIsMobile'; +import { exportCsv } from '../utils/csv'; +const PAGE_SIZE = 10; + +// CVD-validated categorical palette in fixed slot order (see dataviz palette). +// Sub-3:1 contrast on the glass surface is relieved by the legend, tooltip, +// and the table view toggle. const COLORS = [ - '#ff6b6b', - '#4ecdc4', - '#45b7d1', - '#f9ca24', - '#f0932b', - '#eb4d4b', - '#6ab04c', - '#9c88ff', + '#3987e5', + '#199e70', + '#c98500', + '#008300', + '#9085e9', + '#e66767', + '#d55181', + '#d95926', ]; +type SortKey = 'totalPlays' | 'uniqueSongs' | 'lastPlayed' | 'memberSince'; + +function formatDate(value?: Date | string | null): string { + return value ? String(value).split('T')[0] : ''; +} + +function toTime(value?: Date | string | null): number { + return value ? new Date(value).getTime() : 0; +} + export function UserStats() { const [viewMode, setViewMode] = useState<'table' | 'chart'>('table'); const [expandedUser, setExpandedUser] = useState(null); + const [search, setSearch] = useState(''); + const [sortKey, setSortKey] = useState('totalPlays'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const [page, setPage] = useState(1); + const isMobile = useIsMobile(); const { data: userStats, @@ -49,10 +73,80 @@ export function UserStats() { userStats[0], ); - const chartData = userStats.slice(0, 8).map((user) => ({ - name: user.username, - value: user.totalPlays, - })); + const query = search.trim().toLowerCase(); + const filtered = query + ? userStats.filter((user) => + `${user.username} ${user.displayName ?? ''}` + .toLowerCase() + .includes(query), + ) + : userStats; + + const sorted = [...filtered].sort((a, b) => { + let delta: number; + switch (sortKey) { + case 'totalPlays': + delta = a.totalPlays - b.totalPlays; + break; + case 'uniqueSongs': + delta = a.uniqueSongs - b.uniqueSongs; + break; + case 'lastPlayed': + delta = toTime(a.lastPlayed) - toTime(b.lastPlayed); + break; + case 'memberSince': + delta = toTime(a.memberSince) - toTime(b.memberSince); + break; + } + return sortDir === 'asc' ? delta : -delta; + }); + + const pageCount = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); + const safePage = Math.min(page, pageCount); + const pageRows = sorted.slice( + (safePage - 1) * PAGE_SIZE, + safePage * PAGE_SIZE, + ); + + const chartData = [...filtered] + .sort((a, b) => b.totalPlays - a.totalPlays) + .slice(0, 8) + .map((user) => ({ + name: user.username, + value: user.totalPlays, + })); + + function handleSort(key: SortKey) { + if (sortKey === key) { + setSortDir(sortDir === 'desc' ? 'asc' : 'desc'); + } else { + setSortKey(key); + setSortDir('desc'); + } + setPage(1); + } + + function handleExport() { + exportCsv( + 'user-stats.csv', + [ + 'Username', + 'Display Name', + 'Total Plays', + 'Unique Songs', + 'Member Since', + 'Last Played', + ], + sorted.map((user) => [ + user.username, + user.displayName ?? '', + user.totalPlays, + user.uniqueSongs, + formatDate(user.memberSince), + formatDate(user.lastPlayed), + ]), + ); + } return ( <> @@ -74,10 +168,7 @@ export function UserStats() {
-
+

{totalUsers}

Active Users

@@ -91,112 +182,173 @@ export function UserStats() {

Average Plays per User

-

{topUser?.username || 'N/A'}

+

{topUser?.username || 'N/A'}

Top User

-
+
+ { + setSearch(e.target.value); + setPage(1); + }} + /> + +
+ +
{viewMode === 'table' ? ( - - - - - - - - - - - - - {userStats.slice(0, 10).map((user, index) => ( - - - setExpandedUser( - expandedUser === user.username ? null : user.username, - ) - } - style={{ cursor: 'pointer' }} - title="Click to see recently played songs" - > - - - - - - + <> +
+
RankUserTotal PlaysUnique SongsMember SinceLast Played
{index + 1} -
-
- {user.username.slice(0, 2)} -
-
-
{user.username}
-
- {user.displayName} -
-
-
-
- {user.totalPlays} - - {user.uniqueSongs} - {String(user.memberSince).split('T')[0]} - {user.lastPlayed - ? String(user.lastPlayed).split('T')[0] - : ''} -
+ + + + + handleSort('totalPlays')} + /> + handleSort('uniqueSongs')} + className="hide-sm" + /> + handleSort('memberSince')} + className="hide-md" + /> + handleSort('lastPlayed')} + className="hide-sm" + /> - {expandedUser === user.username && ( - - + + {pageRows.map((user, index) => ( + + + setExpandedUser( + expandedUser === user.username + ? null + : user.username, + ) + } + style={{ cursor: 'pointer' }} + title="Click to see recently played songs" + > + + + + + + + + {expandedUser === user.username && ( + + + + )} + + ))} + {pageRows.length === 0 && ( + + )} - - ))} - -
RankUser
- {user.recentSongs.length === 0 ? ( - - No songs played yet. - - ) : ( -
-
- Recently Played - Plays / Last Played +
{(safePage - 1) * PAGE_SIZE + index + 1} +
+
+ {user.username.slice(0, 2)}
-
- {user.recentSongs.map((song, songIndex) => { - const title = cleanTitle(song.title); - return ( -
- - {songIndex + 1} - - - {title} - - - {song.totalPlays} - - - {song.playedAt.split('T')[0]} - -
- ); - })} +
+
{user.username}
+
+ {user.displayName} +
- )} +
+ {user.totalPlays} + + + {user.uniqueSongs} + + + {formatDate(user.memberSince)} + + {formatDate(user.lastPlayed)} +
+ {user.recentSongs.length === 0 ? ( + + No songs played yet. + + ) : ( +
+
+ Recently Played + Plays / Last Played +
+
+ {user.recentSongs.map((song, songIndex) => { + const title = cleanTitle(song.title); + return ( +
+ + {songIndex + 1} + + + {title} + + + {song.totalPlays} + + + {song.playedAt.split('T')[0]} + +
+ ); + })} +
+
+ )} +
+ No users match your search.
+ + +
+ + ) : (
@@ -205,8 +357,8 @@ export function UserStats() { data={chartData} cx="50%" cy="50%" - innerRadius={60} - outerRadius={120} + innerRadius={isMobile ? 45 : 60} + outerRadius={isMobile ? 85 : 120} dataKey="value" paddingAngle={2} > @@ -227,9 +379,9 @@ export function UserStats() { }} /> ( {value} diff --git a/src/UI/App/src/router.tsx b/src/UI/App/src/router.tsx index 3c676e8..1d6848d 100644 --- a/src/UI/App/src/router.tsx +++ b/src/UI/App/src/router.tsx @@ -18,7 +18,7 @@ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', beforeLoad: () => { - throw redirect({ to: '/songs' }); + throw redirect({ to: '/users' }); }, }); diff --git a/src/UI/App/src/services/song-stats-service.ts b/src/UI/App/src/services/song-stats-service.ts index 364798a..cedc750 100644 --- a/src/UI/App/src/services/song-stats-service.ts +++ b/src/UI/App/src/services/song-stats-service.ts @@ -14,6 +14,19 @@ export async function loadSongStats(): Promise { return await response.json(); } +export async function loadTopSongsToday(): Promise { + const response = await fetch(`${API_BASE_URL}/statistics-today`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.json(); +} + export function cleanTitle(title?: string | null): string { if (!title) return ''; const normalized = removeFancyUnicode(title); diff --git a/src/UI/App/src/utils/csv.ts b/src/UI/App/src/utils/csv.ts new file mode 100644 index 0000000..c5dc8bf --- /dev/null +++ b/src/UI/App/src/utils/csv.ts @@ -0,0 +1,23 @@ +type CsvValue = string | number | null | undefined; + +function escapeCell(value: CsvValue): string { + const text = value == null ? '' : String(value); + return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +export function exportCsv( + filename: string, + header: string[], + rows: CsvValue[][], +): void { + const lines = [header, ...rows].map((row) => row.map(escapeCell).join(',')); + const blob = new Blob([lines.join('\r\n')], { + type: 'text/csv;charset=utf-8;', + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +}