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:
cucadmuh
2026-05-22 14:07:38 +03:00
committed by GitHub
parent 7afddf7b84
commit 23f7ba02d6
49 changed files with 3059 additions and 19 deletions
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
libraryGetPlayerStatsDayDetail,
libraryGetPlayerStatsRecentDays,
type PlaySessionDayDetail,
type PlaySessionRecentDay,
} from '../../api/library';
import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration';
import {
formatPlayerStatsDayLabel,
PLAYER_STATS_RECENT_DAYS_LIMIT,
} from '../../utils/playerStats/formatPlayerStatsDay';
import PlayerStatsDayTracks from './PlayerStatsDayTracks';
type Props = {
/** Day selected on the heatmap — auto-expand when present. */
heatmapSelectedDate: string | null;
/** Full reload (year change) — shows section spinner. */
refreshKey: number;
/** Silent poll while listening — updates list and expanded day details. */
liveRefreshKey: number;
};
function formatDayMeta(
summary: PlaySessionRecentDay,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
return [
formatPlayerStatsListeningTotal(summary.totalListenedSec),
t('statistics.playerDaySessions', { count: summary.sessionCount }),
t('statistics.playerDayTrackPlays', { count: summary.trackPlayCount }),
].join(' · ');
}
export default function PlayerStatsRecentDays({
heatmapSelectedDate,
refreshKey,
liveRefreshKey,
}: Props) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language;
const [days, setDays] = useState<PlaySessionRecentDay[]>([]);
const [loading, setLoading] = useState(true);
const [expandedDates, setExpandedDates] = useState<Set<string>>(() => new Set());
const [details, setDetails] = useState<Map<string, PlaySessionDayDetail>>(() => new Map());
const [loadingDates, setLoadingDates] = useState<Set<string>>(() => new Set());
const detailsRef = useRef(details);
detailsRef.current = details;
const expandedRef = useRef(expandedDates);
expandedRef.current = expandedDates;
const loadDetail = useCallback(async (date: string) => {
setLoadingDates(prev => new Set(prev).add(date));
try {
const detail = await libraryGetPlayerStatsDayDetail(date);
setDetails(prev => new Map(prev).set(date, detail));
} catch {
/* ignore */
} finally {
setLoadingDates(prev => {
const next = new Set(prev);
next.delete(date);
return next;
});
}
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
.then(rows => {
if (!cancelled) {
setDays(rows);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setDays([]);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [refreshKey]);
useEffect(() => {
if (liveRefreshKey === 0) return;
let cancelled = false;
libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
.then(rows => {
if (cancelled) return;
setDays(rows);
const refreshDates = new Set(expandedRef.current);
if (heatmapSelectedDate && expandedRef.current.has(heatmapSelectedDate)) {
refreshDates.add(heatmapSelectedDate);
}
setDetails(prev => {
const next = new Map(prev);
for (const date of refreshDates) next.delete(date);
return next;
});
for (const date of refreshDates) {
void loadDetail(date);
}
})
.catch(() => { /* ignore */ });
return () => { cancelled = true; };
}, [liveRefreshKey, heatmapSelectedDate, loadDetail]);
const daySet = useMemo(() => new Set(days.map(d => d.date)), [days]);
const ensureDetail = useCallback(async (date: string) => {
if (detailsRef.current.has(date)) return;
await loadDetail(date);
}, [loadDetail]);
const toggleDate = useCallback((date: string) => {
setExpandedDates(prev => {
const next = new Set(prev);
if (next.has(date)) next.delete(date);
else next.add(date);
return next;
});
void ensureDetail(date);
}, [ensureDetail]);
useEffect(() => {
if (!heatmapSelectedDate) return;
setExpandedDates(prev => new Set(prev).add(heatmapSelectedDate));
void ensureDetail(heatmapSelectedDate);
}, [heatmapSelectedDate, ensureDetail]);
const extraHeatmapDay = heatmapSelectedDate && !daySet.has(heatmapSelectedDate)
? heatmapSelectedDate
: null;
if (loading) {
return (
<section className="player-stats-recent">
<h2 className="section-title">{t('statistics.playerRecentDaysTitle')}</h2>
<div className="loading-center" style={{ minHeight: '4rem' }}>
<div className="spinner" />
</div>
</section>
);
}
if (days.length === 0 && !extraHeatmapDay) return null;
const renderRow = (date: string, summary: PlaySessionRecentDay | null) => {
const open = expandedDates.has(date);
const detail = details.get(date);
const pending = loadingDates.has(date);
const label = formatPlayerStatsDayLabel(date, t, locale);
const meta = summary ? formatDayMeta(summary, t) : null;
return (
<div key={date} className={`player-stats-day-item${open ? ' player-stats-day-item--open' : ''}`}>
<button
type="button"
className="player-stats-day-header"
onClick={() => toggleDate(date)}
aria-expanded={open}
>
<span className="player-stats-day-header-text">
<span className="player-stats-day-label">{label}</span>
{meta && <span className="player-stats-day-summary">{meta}</span>}
</span>
<ChevronDown size={16} className="player-stats-day-chevron" aria-hidden />
</button>
{open && (
<div className="player-stats-day-body">
{pending && !detail && (
<div className="loading-center" style={{ minHeight: '2.5rem' }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
</div>
)}
{detail && <PlayerStatsDayTracks detail={detail} />}
</div>
)}
</div>
);
};
return (
<section className="player-stats-recent">
<h2 className="section-title">{t('statistics.playerRecentDaysTitle')}</h2>
<div className="player-stats-day-list">
{extraHeatmapDay && renderRow(extraHeatmapDay, null)}
{days.map(day => renderRow(day.date, day))}
</div>
</section>
);
}