mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(player-stats): local listening history tab with heatmap and summaries (#849)
* feat(player-stats): local listening history tab with heatmap and summaries Record play sessions in library.sqlite when the library index is enabled, add Rust read APIs and Tauri commands for year/day aggregates, and ship the Player stats UI with session clustering, event-driven live refresh, and a notice when some servers are excluded from indexing. * test(player-stats): split play_session repo and expand test coverage Move the repository into play_session/ (completion, cluster, integration tests), add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh hooks on the frontend per spec v0.3. * docs(release): CHANGELOG and credits for player stats (PR #849) * fix(player-stats): satisfy tsc and clippy CI gates Use InternetRadioStation field names in the radio skip test and replace manual month/day range checks with RangeInclusive::contains.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
libraryGetPlayerStatsHeatmap,
|
||||
libraryGetPlayerStatsYearBounds,
|
||||
libraryGetPlayerStatsYearSummary,
|
||||
type PlaySessionYearBounds,
|
||||
type PlaySessionYearSummary,
|
||||
} from '../../api/library';
|
||||
import { usePlayerStatsLiveRefresh } from '../../hooks/usePlayerStatsLiveRefresh';
|
||||
import PlayerStatsHeatmap from './PlayerStatsHeatmap';
|
||||
import PlayerStatsPartialIndexNotice from './PlayerStatsPartialIndexNotice';
|
||||
import PlayerStatsRecentDays from './PlayerStatsRecentDays';
|
||||
import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration';
|
||||
|
||||
const currentCalendarYear = () => new Date().getFullYear();
|
||||
|
||||
export default function PlayerStatisticsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [year, setYear] = useState(currentCalendarYear);
|
||||
const [yearBounds, setYearBounds] = useState<PlaySessionYearBounds | null>(null);
|
||||
const [summary, setSummary] = useState<PlaySessionYearSummary | null>(null);
|
||||
const [dayCounts, setDayCounts] = useState<Map<string, number>>(new Map());
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recentRefreshKey, setRecentRefreshKey] = useState(0);
|
||||
const [liveRefreshKey, setLiveRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setSelectedDate(null);
|
||||
Promise.all([
|
||||
libraryGetPlayerStatsYearSummary(year),
|
||||
libraryGetPlayerStatsHeatmap(year),
|
||||
libraryGetPlayerStatsYearBounds(),
|
||||
])
|
||||
.then(([s, heat, bounds]) => {
|
||||
if (cancelled) return;
|
||||
setSummary(s);
|
||||
setDayCounts(new Map(heat.map(h => [h.date, h.trackPlayCount])));
|
||||
setYearBounds(bounds);
|
||||
setLoading(false);
|
||||
setRecentRefreshKey(k => k + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSummary(null);
|
||||
setDayCounts(new Map());
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [year]);
|
||||
|
||||
const refreshLive = useCallback(async () => {
|
||||
try {
|
||||
const [s, heat] = await Promise.all([
|
||||
libraryGetPlayerStatsYearSummary(year),
|
||||
libraryGetPlayerStatsHeatmap(year),
|
||||
]);
|
||||
setSummary(s);
|
||||
setDayCounts(new Map(heat.map(h => [h.date, h.trackPlayCount])));
|
||||
setLiveRefreshKey(k => k + 1);
|
||||
} catch {
|
||||
/* ignore transient read errors during live refresh */
|
||||
}
|
||||
}, [year]);
|
||||
|
||||
usePlayerStatsLiveRefresh(refreshLive);
|
||||
|
||||
const empty = !loading && (summary?.trackPlayCount ?? 0) === 0;
|
||||
const calYear = currentCalendarYear();
|
||||
const maxNavYear = yearBounds?.maxYear != null
|
||||
? Math.min(calYear, yearBounds.maxYear)
|
||||
: calYear;
|
||||
const canGoPrev = !loading && yearBounds?.minYear != null && year > yearBounds.minYear;
|
||||
const canGoNext = !loading && year < maxNavYear;
|
||||
|
||||
return (
|
||||
<div className="stats-page">
|
||||
<PlayerStatsPartialIndexNotice />
|
||||
<div className="player-stats-year-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => canGoPrev && setYear(y => y - 1)}
|
||||
disabled={!canGoPrev}
|
||||
aria-label={t('statistics.playerYearPrev')}
|
||||
aria-disabled={!canGoPrev}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>{year}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => canGoNext && setYear(y => y + 1)}
|
||||
disabled={!canGoNext}
|
||||
aria-label={t('statistics.playerYearNext')}
|
||||
aria-disabled={!canGoNext}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-center"><div className="spinner" /></div>
|
||||
) : (
|
||||
<>
|
||||
{!empty && summary && (
|
||||
<div className="stats-overview" style={{ marginBottom: '1.25rem' }}>
|
||||
<div className="stats-card">
|
||||
<span className="stats-card-value">{formatPlayerStatsListeningTotal(summary.totalListenedSec)}</span>
|
||||
<span className="stats-card-label">{t('statistics.playerSummaryTime')}</span>
|
||||
</div>
|
||||
<div className="stats-card">
|
||||
<span className="stats-card-value">{summary.sessionCount.toLocaleString()}</span>
|
||||
<span className="stats-card-label">{t('statistics.playerSummarySessions')}</span>
|
||||
</div>
|
||||
<div className="stats-card">
|
||||
<span className="stats-card-value">{summary.trackPlayCount.toLocaleString()}</span>
|
||||
<span className="stats-card-label">{t('statistics.playerSummaryTracks')}</span>
|
||||
</div>
|
||||
<div className="stats-card">
|
||||
<span className="stats-card-value">{summary.uniqueTrackCount.toLocaleString()}</span>
|
||||
<span className="stats-card-label">{t('statistics.playerSummaryUniqueTracks')}</span>
|
||||
</div>
|
||||
<div className="stats-card">
|
||||
<span className="stats-card-value">{summary.listeningDayCount.toLocaleString()}</span>
|
||||
<span className="stats-card-label">{t('statistics.playerSummaryDays')}</span>
|
||||
</div>
|
||||
<div className="stats-card">
|
||||
<span className="stats-card-value">{summary.fullCount} / {summary.partialCount}</span>
|
||||
<span className="stats-card-label">{t('statistics.playerSummaryCompletion')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{empty && (
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>{t('statistics.playerEmpty')}</p>
|
||||
)}
|
||||
|
||||
<PlayerStatsHeatmap
|
||||
year={year}
|
||||
dayCounts={dayCounts}
|
||||
selectedDate={selectedDate}
|
||||
onSelectDate={setSelectedDate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<PlayerStatsRecentDays
|
||||
heatmapSelectedDate={selectedDate}
|
||||
refreshKey={recentRefreshKey}
|
||||
liveRefreshKey={liveRefreshKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user