@@ -198,9 +182,6 @@ export function SongStats() {
Today
-
@@ -244,7 +225,12 @@ export function SongStats() {
{song.playCount.toLocaleString()}
-
{formatDate(song.lastPlayed)} |
+
+ {formatRelative(song.lastPlayed)}
+ |
))}
{pageRows.length === 0 && (
diff --git a/src/UI/App/src/pages/UserStats.tsx b/src/UI/App/src/pages/UserStats.tsx
index f38de6a..0de5312 100644
--- a/src/UI/App/src/pages/UserStats.tsx
+++ b/src/UI/App/src/pages/UserStats.tsx
@@ -1,13 +1,6 @@
import { Fragment, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
-import {
- PieChart,
- Pie,
- Cell,
- ResponsiveContainer,
- Legend,
- Tooltip,
-} from 'recharts';
+import { PieChart, Pie, ResponsiveContainer, Legend, Tooltip } from 'recharts';
import { loadUsers } from '../services/user-service';
import { cleanTitle } from '../services/song-stats-service';
import { LoadingSpinner } from '../components/LoadingSpinner';
@@ -15,7 +8,7 @@ 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';
+import { formatDate, formatRelative } from '../utils/time';
const PAGE_SIZE = 10;
@@ -35,10 +28,6 @@ const COLORS = [
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;
}
@@ -59,6 +48,7 @@ export function UserStats() {
} = useQuery({
queryKey: ['userStats'],
queryFn: loadUsers,
+ refetchInterval: 60_000,
});
if (isLoading) return
;
@@ -111,9 +101,10 @@ export function UserStats() {
const chartData = [...filtered]
.sort((a, b) => b.totalPlays - a.totalPlays)
.slice(0, 8)
- .map((user) => ({
+ .map((user, index) => ({
name: user.username,
value: user.totalPlays,
+ fill: COLORS[index % COLORS.length],
}));
function handleSort(key: SortKey) {
@@ -126,28 +117,6 @@ export function UserStats() {
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 (
<>
@@ -198,9 +167,6 @@ export function UserStats() {
setPage(1);
}}
/>
-
@@ -280,8 +246,11 @@ export function UserStats() {
{formatDate(user.memberSince)}
|
-
- {formatDate(user.lastPlayed)}
+ |
+ {formatRelative(user.lastPlayed)}
|
{expandedUser === user.username && (
@@ -361,15 +330,8 @@ export function UserStats() {
outerRadius={isMobile ? 85 : 120}
dataKey="value"
paddingAngle={2}
- >
- {chartData.map((_entry, index) => (
- |
- ))}
-
+ stroke="none"
+ />
rootRoute,
path: '/',
- beforeLoad: () => {
- throw redirect({ to: '/users' });
- },
+ component: Overview,
});
const songsRoute = createRoute({
@@ -56,7 +55,7 @@ const catchAllRoute = createRoute({
getParentRoute: () => rootRoute,
path: '$',
beforeLoad: () => {
- throw redirect({ to: '/songs' });
+ throw redirect({ to: '/' });
},
});
diff --git a/src/UI/App/src/services/api.ts b/src/UI/App/src/services/api.ts
index 1b098b1..e2049c5 100644
--- a/src/UI/App/src/services/api.ts
+++ b/src/UI/App/src/services/api.ts
@@ -1 +1,4 @@
-export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
+// The production build uses a relative path because the Worker serves the SPA
+// and the API from the same origin (works for local docker and deployment).
+// The dev server overrides this via .env.development to reach localhost:5000.
+export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
diff --git a/src/UI/App/src/utils/csv.ts b/src/UI/App/src/utils/csv.ts
deleted file mode 100644
index c5dc8bf..0000000
--- a/src/UI/App/src/utils/csv.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-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);
-}
diff --git a/src/UI/App/src/utils/time.ts b/src/UI/App/src/utils/time.ts
new file mode 100644
index 0000000..a6f148e
--- /dev/null
+++ b/src/UI/App/src/utils/time.ts
@@ -0,0 +1,17 @@
+export function formatDate(value?: Date | string | null): string {
+ return value ? String(value).split('T')[0] : '';
+}
+
+export function formatRelative(value?: Date | string | null): string {
+ if (!value) return '';
+ const time = new Date(value).getTime();
+ if (Number.isNaN(time)) return '';
+ const minutes = Math.floor((Date.now() - time) / 60000);
+ if (minutes < 1) return 'just now';
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d ago`;
+ return formatDate(value);
+}