From 236acc33dd927519a2487a84b2e97b399540acd9 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:51:54 +0200 Subject: [PATCH] refactor(stats): co-locate statistics feature into features/stats --- src/app/AppRoutes.tsx | 2 +- .../stats}/api/subsonicStatistics.ts | 10 +++---- .../components}/PlayerStatisticsPanel.tsx | 14 +++++----- .../components}/PlayerStatsDayTracks.tsx | 4 +-- .../stats/components}/PlayerStatsHeatmap.tsx | 2 +- .../PlayerStatsIndexRequiredNotice.tsx | 0 .../components}/PlayerStatsRecentDays.tsx | 8 +++--- .../stats/components}/StatisticsTabBar.tsx | 4 +-- .../stats}/components/StatsExportModal.tsx | 8 +++--- .../hooks/usePlayerStatsLiveRefresh.test.ts | 4 +-- .../stats}/hooks/usePlayerStatsLiveRefresh.ts | 2 +- .../hooks/usePlayerStatsRecordingEnabled.ts | 4 +-- src/features/stats/index.ts | 7 +++++ src/{ => features/stats}/pages/Statistics.tsx | 26 +++++++++---------- .../stats/utils}/formatPlayerStatsDay.test.ts | 2 +- .../stats/utils}/formatPlayerStatsDay.ts | 0 .../stats/utils}/heatmapLevels.test.ts | 2 +- .../stats/utils}/heatmapLevels.ts | 0 src/hooks/useOfflineBrowseContext.ts | 2 +- 19 files changed, 54 insertions(+), 47 deletions(-) rename src/{ => features/stats}/api/subsonicStatistics.ts (95%) rename src/{components/statistics => features/stats/components}/PlayerStatisticsPanel.tsx (91%) rename src/{components/statistics => features/stats/components}/PlayerStatsDayTracks.tsx (84%) rename src/{components/statistics => features/stats/components}/PlayerStatsHeatmap.tsx (99%) rename src/{components/statistics => features/stats/components}/PlayerStatsIndexRequiredNotice.tsx (100%) rename src/{components/statistics => features/stats/components}/PlayerStatsRecentDays.tsx (96%) rename src/{components/statistics => features/stats/components}/StatisticsTabBar.tsx (86%) rename src/{ => features/stats}/components/StatsExportModal.tsx (98%) rename src/{ => features/stats}/hooks/usePlayerStatsLiveRefresh.test.ts (89%) rename src/{ => features/stats}/hooks/usePlayerStatsLiveRefresh.ts (93%) rename src/{ => features/stats}/hooks/usePlayerStatsRecordingEnabled.ts (78%) create mode 100644 src/features/stats/index.ts rename src/{ => features/stats}/pages/Statistics.tsx (96%) rename src/{utils/playerStats => features/stats/utils}/formatPlayerStatsDay.test.ts (90%) rename src/{utils/playerStats => features/stats/utils}/formatPlayerStatsDay.ts (100%) rename src/{utils/playerStats => features/stats/utils}/heatmapLevels.test.ts (87%) rename src/{utils/playerStats => features/stats/utils}/heatmapLevels.ts (100%) diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 5dd168a5..0e97378b 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -28,7 +28,7 @@ const Playlists = lazy(() => import('../pages/Playlists')); const PlaylistDetail = lazy(() => import('../pages/PlaylistDetail')); const NowPlayingPage = lazy(() => import('../pages/NowPlaying')); const Settings = lazy(() => import('../pages/Settings')); -const Statistics = lazy(() => import('../pages/Statistics')); +const Statistics = lazy(() => import('@/features/stats/pages/Statistics')); const Help = lazy(() => import('../pages/Help')); const WhatsNew = lazy(() => import('../pages/WhatsNew')); const DeviceSync = lazy(() => import('../pages/DeviceSync')); diff --git a/src/api/subsonicStatistics.ts b/src/features/stats/api/subsonicStatistics.ts similarity index 95% rename from src/api/subsonicStatistics.ts rename to src/features/stats/api/subsonicStatistics.ts index 66cecb2f..bfa2e1d2 100644 --- a/src/api/subsonicStatistics.ts +++ b/src/features/stats/api/subsonicStatistics.ts @@ -1,7 +1,7 @@ -import { useAuthStore } from '../store/authStore'; -import { genreTagsFor } from '../utils/library/genreTags'; -import { getArtists } from './subsonicArtists'; -import { getAlbumList, getRandomSongs } from './subsonicLibrary'; +import { useAuthStore } from '@/store/authStore'; +import { genreTagsFor } from '@/utils/library/genreTags'; +import { getArtists } from '@/api/subsonicArtists'; +import { getAlbumList, getRandomSongs } from '@/api/subsonicLibrary'; import type { StatisticsFormatSample, StatisticsLibraryAggregates, @@ -10,7 +10,7 @@ import type { SubsonicArtist, SubsonicGenre, SubsonicSong, -} from './subsonicTypes'; +} from '@/api/subsonicTypes'; /** Cache TTL for statistics page aggregates — same 7-minute window as * the rating prefetch cache in subsonicRatings.ts. */ diff --git a/src/components/statistics/PlayerStatisticsPanel.tsx b/src/features/stats/components/PlayerStatisticsPanel.tsx similarity index 91% rename from src/components/statistics/PlayerStatisticsPanel.tsx rename to src/features/stats/components/PlayerStatisticsPanel.tsx index 42687998..85949486 100644 --- a/src/components/statistics/PlayerStatisticsPanel.tsx +++ b/src/features/stats/components/PlayerStatisticsPanel.tsx @@ -7,13 +7,13 @@ import { libraryGetPlayerStatsYearSummary, type PlaySessionYearBounds, type PlaySessionYearSummary, -} from '../../api/library'; -import { usePlayerStatsLiveRefresh } from '../../hooks/usePlayerStatsLiveRefresh'; -import { usePlayerStatsRecordingEnabled } from '../../hooks/usePlayerStatsRecordingEnabled'; -import PlayerStatsHeatmap from './PlayerStatsHeatmap'; -import PlayerStatsIndexRequiredNotice from './PlayerStatsIndexRequiredNotice'; -import PlayerStatsRecentDays from './PlayerStatsRecentDays'; -import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration'; +} from '@/api/library'; +import { usePlayerStatsLiveRefresh } from '@/features/stats/hooks/usePlayerStatsLiveRefresh'; +import { usePlayerStatsRecordingEnabled } from '@/features/stats/hooks/usePlayerStatsRecordingEnabled'; +import PlayerStatsHeatmap from '@/features/stats/components/PlayerStatsHeatmap'; +import PlayerStatsIndexRequiredNotice from '@/features/stats/components/PlayerStatsIndexRequiredNotice'; +import PlayerStatsRecentDays from '@/features/stats/components/PlayerStatsRecentDays'; +import { formatPlayerStatsListeningTotal } from '@/utils/format/formatHumanDuration'; const currentCalendarYear = () => new Date().getFullYear(); diff --git a/src/components/statistics/PlayerStatsDayTracks.tsx b/src/features/stats/components/PlayerStatsDayTracks.tsx similarity index 84% rename from src/components/statistics/PlayerStatsDayTracks.tsx rename to src/features/stats/components/PlayerStatsDayTracks.tsx index 207cd9d9..4d4a0527 100644 --- a/src/components/statistics/PlayerStatsDayTracks.tsx +++ b/src/features/stats/components/PlayerStatsDayTracks.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; -import type { PlaySessionDayDetail } from '../../api/library'; -import { formatPlayerStatsListenedSec } from '../../utils/format/formatHumanDuration'; +import type { PlaySessionDayDetail } from '@/api/library'; +import { formatPlayerStatsListenedSec } from '@/utils/format/formatHumanDuration'; type Props = { detail: PlaySessionDayDetail; diff --git a/src/components/statistics/PlayerStatsHeatmap.tsx b/src/features/stats/components/PlayerStatsHeatmap.tsx similarity index 99% rename from src/components/statistics/PlayerStatsHeatmap.tsx rename to src/features/stats/components/PlayerStatsHeatmap.tsx index 52bc15b1..e7ae7c08 100644 --- a/src/components/statistics/PlayerStatsHeatmap.tsx +++ b/src/features/stats/components/PlayerStatsHeatmap.tsx @@ -8,7 +8,7 @@ import { heatmapWeekColumns, heatmapWeekdayLabels, type HeatmapCell, -} from '../../utils/playerStats/heatmapLevels'; +} from '@/features/stats/utils/heatmapLevels'; const LEVEL_OPACITY = [0.14, 0.32, 0.52, 0.72, 1] as const; diff --git a/src/components/statistics/PlayerStatsIndexRequiredNotice.tsx b/src/features/stats/components/PlayerStatsIndexRequiredNotice.tsx similarity index 100% rename from src/components/statistics/PlayerStatsIndexRequiredNotice.tsx rename to src/features/stats/components/PlayerStatsIndexRequiredNotice.tsx diff --git a/src/components/statistics/PlayerStatsRecentDays.tsx b/src/features/stats/components/PlayerStatsRecentDays.tsx similarity index 96% rename from src/components/statistics/PlayerStatsRecentDays.tsx rename to src/features/stats/components/PlayerStatsRecentDays.tsx index 134ae1aa..afba9106 100644 --- a/src/components/statistics/PlayerStatsRecentDays.tsx +++ b/src/features/stats/components/PlayerStatsRecentDays.tsx @@ -6,13 +6,13 @@ import { libraryGetPlayerStatsRecentDays, type PlaySessionDayDetail, type PlaySessionRecentDay, -} from '../../api/library'; -import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration'; +} from '@/api/library'; +import { formatPlayerStatsListeningTotal } from '@/utils/format/formatHumanDuration'; import { formatPlayerStatsDayLabel, PLAYER_STATS_RECENT_DAYS_LIMIT, -} from '../../utils/playerStats/formatPlayerStatsDay'; -import PlayerStatsDayTracks from './PlayerStatsDayTracks'; +} from '@/features/stats/utils/formatPlayerStatsDay'; +import PlayerStatsDayTracks from '@/features/stats/components/PlayerStatsDayTracks'; type Props = { /** Day selected on the heatmap — auto-expand when present. */ diff --git a/src/components/statistics/StatisticsTabBar.tsx b/src/features/stats/components/StatisticsTabBar.tsx similarity index 86% rename from src/components/statistics/StatisticsTabBar.tsx rename to src/features/stats/components/StatisticsTabBar.tsx index afc67ccc..687b3782 100644 --- a/src/components/statistics/StatisticsTabBar.tsx +++ b/src/features/stats/components/StatisticsTabBar.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; -import { useOfflineBrowseContext } from '../../hooks/useOfflineBrowseContext'; -import { usePlayerStatsRecordingEnabled } from '../../hooks/usePlayerStatsRecordingEnabled'; +import { useOfflineBrowseContext } from '@/hooks/useOfflineBrowseContext'; +import { usePlayerStatsRecordingEnabled } from '@/features/stats/hooks/usePlayerStatsRecordingEnabled'; export default function StatisticsTabBar() { const { t } = useTranslation(); diff --git a/src/components/StatsExportModal.tsx b/src/features/stats/components/StatsExportModal.tsx similarity index 98% rename from src/components/StatsExportModal.tsx rename to src/features/stats/components/StatsExportModal.tsx index cf2d1afb..2f02aeea 100644 --- a/src/components/StatsExportModal.tsx +++ b/src/features/stats/components/StatsExportModal.tsx @@ -1,18 +1,18 @@ -import { getAlbumList } from '../api/subsonicLibrary'; -import type { SubsonicAlbum } from '../api/subsonicTypes'; +import { getAlbumList } from '@/api/subsonicLibrary'; +import type { SubsonicAlbum } from '@/api/subsonicTypes'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { X } from 'lucide-react'; import { save } from '@tauri-apps/plugin-dialog'; import { writeFile } from '@tauri-apps/plugin-fs'; -import { showToast } from '../utils/ui/toast'; +import { showToast } from '@/utils/ui/toast'; import { exportAlbumCardBlob, renderAlbumCardCanvas, ExportFormat, ExportGridSize, -} from '../utils/export/exportAlbumCard'; +} from '@/utils/export/exportAlbumCard'; interface Props { open: boolean; diff --git a/src/hooks/usePlayerStatsLiveRefresh.test.ts b/src/features/stats/hooks/usePlayerStatsLiveRefresh.test.ts similarity index 89% rename from src/hooks/usePlayerStatsLiveRefresh.test.ts rename to src/features/stats/hooks/usePlayerStatsLiveRefresh.test.ts index 7476d39f..7ef0d02d 100644 --- a/src/hooks/usePlayerStatsLiveRefresh.test.ts +++ b/src/features/stats/hooks/usePlayerStatsLiveRefresh.test.ts @@ -1,7 +1,7 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { usePlayerStatsLiveRefresh } from './usePlayerStatsLiveRefresh'; -import { emitPlaySessionRecorded } from '../store/playSessionRecorded'; +import { usePlayerStatsLiveRefresh } from '@/features/stats/hooks/usePlayerStatsLiveRefresh'; +import { emitPlaySessionRecorded } from '@/store/playSessionRecorded'; describe('usePlayerStatsLiveRefresh', () => { it('refreshes when a play session is recorded and the tab is visible', () => { diff --git a/src/hooks/usePlayerStatsLiveRefresh.ts b/src/features/stats/hooks/usePlayerStatsLiveRefresh.ts similarity index 93% rename from src/hooks/usePlayerStatsLiveRefresh.ts rename to src/features/stats/hooks/usePlayerStatsLiveRefresh.ts index 801f4c76..32004b92 100644 --- a/src/hooks/usePlayerStatsLiveRefresh.ts +++ b/src/features/stats/hooks/usePlayerStatsLiveRefresh.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { onPlaySessionRecorded } from '../store/playSessionRecorded'; +import { onPlaySessionRecorded } from '@/store/playSessionRecorded'; /** Refresh player stats when a listen is persisted or the tab becomes visible again. */ export function usePlayerStatsLiveRefresh(onRefresh: () => void) { diff --git a/src/hooks/usePlayerStatsRecordingEnabled.ts b/src/features/stats/hooks/usePlayerStatsRecordingEnabled.ts similarity index 78% rename from src/hooks/usePlayerStatsRecordingEnabled.ts rename to src/features/stats/hooks/usePlayerStatsRecordingEnabled.ts index acad0053..96e61863 100644 --- a/src/hooks/usePlayerStatsRecordingEnabled.ts +++ b/src/features/stats/hooks/usePlayerStatsRecordingEnabled.ts @@ -1,5 +1,5 @@ -import { useAuthStore } from '../store/authStore'; -import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { useAuthStore } from '@/store/authStore'; +import { useLibraryIndexStore } from '@/store/libraryIndexStore'; /** True when local play history is recorded (master index on + ≥1 server included). */ export function usePlayerStatsRecordingEnabled(): boolean { diff --git a/src/features/stats/index.ts b/src/features/stats/index.ts new file mode 100644 index 00000000..ea4450d8 --- /dev/null +++ b/src/features/stats/index.ts @@ -0,0 +1,7 @@ +/** + * Statistics feature — listening stats dashboard (player-stats heatmap, recent + * days, library aggregates) and its recording-enabled gate. The `Statistics` + * page is loaded lazily by the router via its deep path, so it is intentionally + * not re-exported here. + */ +export { usePlayerStatsRecordingEnabled } from './hooks/usePlayerStatsRecordingEnabled'; diff --git a/src/pages/Statistics.tsx b/src/features/stats/pages/Statistics.tsx similarity index 96% rename from src/pages/Statistics.tsx rename to src/features/stats/pages/Statistics.tsx index 4dfa9a12..0ae0ea8f 100644 --- a/src/pages/Statistics.tsx +++ b/src/features/stats/pages/Statistics.tsx @@ -1,21 +1,21 @@ -import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchStatisticsOverview } from '../api/subsonicStatistics'; -import { getAlbumList } from '../api/subsonicLibrary'; -import type { SubsonicAlbum, SubsonicGenre } from '../api/subsonicTypes'; +import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchStatisticsOverview } from '@/features/stats/api/subsonicStatistics'; +import { getAlbumList } from '@/api/subsonicLibrary'; +import type { SubsonicAlbum, SubsonicGenre } from '@/api/subsonicTypes'; import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Share2 } from 'lucide-react'; -import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration'; -import AlbumRow from '../components/AlbumRow'; -import StatsExportModal from '../components/StatsExportModal'; -import PlayerStatisticsPanel from '../components/statistics/PlayerStatisticsPanel'; -import StatisticsTabBar from '../components/statistics/StatisticsTabBar'; +import { formatHumanHoursMinutes } from '@/utils/format/formatHumanDuration'; +import AlbumRow from '@/components/AlbumRow'; +import StatsExportModal from '@/features/stats/components/StatsExportModal'; +import PlayerStatisticsPanel from '@/features/stats/components/PlayerStatisticsPanel'; +import StatisticsTabBar from '@/features/stats/components/StatisticsTabBar'; import { useTranslation } from 'react-i18next'; -import { useAuthStore } from '../store/authStore'; +import { useAuthStore } from '@/store/authStore'; import { useLocation } from 'react-router-dom'; -import { getMusicNetworkRuntime, type RecentTrack, type StatsPeriod, type TopItem } from '../music-network'; -import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext'; -import { usePlayerStatsRecordingEnabled } from '../hooks/usePlayerStatsRecordingEnabled'; -import { useEnrichmentPrimaryLabel } from '../hooks/useEnrichmentPrimaryLabel'; +import { getMusicNetworkRuntime, type RecentTrack, type StatsPeriod, type TopItem } from '@/music-network'; +import { useOfflineBrowseContext } from '@/hooks/useOfflineBrowseContext'; +import { usePlayerStatsRecordingEnabled } from '@/features/stats/hooks/usePlayerStatsRecordingEnabled'; +import { useEnrichmentPrimaryLabel } from '@/hooks/useEnrichmentPrimaryLabel'; // eslint-disable-next-line @typescript-eslint/no-explicit-any function relativeTime(timestamp: number, t: (key: string, opts?: any) => string): string { diff --git a/src/utils/playerStats/formatPlayerStatsDay.test.ts b/src/features/stats/utils/formatPlayerStatsDay.test.ts similarity index 90% rename from src/utils/playerStats/formatPlayerStatsDay.test.ts rename to src/features/stats/utils/formatPlayerStatsDay.test.ts index b07bbb86..81b07d17 100644 --- a/src/utils/playerStats/formatPlayerStatsDay.test.ts +++ b/src/features/stats/utils/formatPlayerStatsDay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatPlayerStatsDayLabel } from './formatPlayerStatsDay'; +import { formatPlayerStatsDayLabel } from '@/features/stats/utils/formatPlayerStatsDay'; const t = (key: string) => { if (key === 'statistics.playerDayToday') return 'Today'; diff --git a/src/utils/playerStats/formatPlayerStatsDay.ts b/src/features/stats/utils/formatPlayerStatsDay.ts similarity index 100% rename from src/utils/playerStats/formatPlayerStatsDay.ts rename to src/features/stats/utils/formatPlayerStatsDay.ts diff --git a/src/utils/playerStats/heatmapLevels.test.ts b/src/features/stats/utils/heatmapLevels.test.ts similarity index 87% rename from src/utils/playerStats/heatmapLevels.test.ts rename to src/features/stats/utils/heatmapLevels.test.ts index 000a3509..b7fde6bd 100644 --- a/src/utils/playerStats/heatmapLevels.test.ts +++ b/src/features/stats/utils/heatmapLevels.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { heatmapCellMetrics } from './heatmapLevels'; +import { heatmapCellMetrics } from '@/features/stats/utils/heatmapLevels'; describe('heatmapCellMetrics', () => { it('shrinks cells to fit narrow containers', () => { diff --git a/src/utils/playerStats/heatmapLevels.ts b/src/features/stats/utils/heatmapLevels.ts similarity index 100% rename from src/utils/playerStats/heatmapLevels.ts rename to src/features/stats/utils/heatmapLevels.ts diff --git a/src/hooks/useOfflineBrowseContext.ts b/src/hooks/useOfflineBrowseContext.ts index ad0adb17..cc61ccde 100644 --- a/src/hooks/useOfflineBrowseContext.ts +++ b/src/hooks/useOfflineBrowseContext.ts @@ -1,7 +1,7 @@ import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; import { useConnectionStatus } from './useConnectionStatus'; -import { usePlayerStatsRecordingEnabled } from './usePlayerStatsRecordingEnabled'; +import { usePlayerStatsRecordingEnabled } from '@/features/stats'; import { hasOfflineBrowsingContent } from '../utils/offline/favoritesOfflineBrowse'; import { useOfflineBrowseActive } from '../utils/offline/offlineBrowseMode'; import {