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,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>
);
}
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import type { PlaySessionDayDetail } from '../../api/library';
import { formatPlayerStatsListenedSec } from '../../utils/format/formatHumanDuration';
type Props = {
detail: PlaySessionDayDetail;
};
export default function PlayerStatsDayTracks({ detail }: Props) {
const { t } = useTranslation();
return (
<ul className="player-stats-day-tracks">
{detail.tracks.map(tr => (
<li key={`${tr.serverId}:${tr.trackId}:${tr.startedAtMs}`}>
<div className="player-stats-day-track-title">{tr.title}</div>
<div className="player-stats-day-track-meta">
{tr.artist ?? '—'}
{' · '}
{formatPlayerStatsListenedSec(tr.listenedSec)}
{' · '}
{tr.completion === 'full'
? t('statistics.completionFull')
: t('statistics.completionPartial')}
</div>
</li>
))}
</ul>
);
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import {
HEATMAP_LEVEL_COUNT,
heatmapCellMetrics,
heatmapLevel,
heatmapMonthLabels,
heatmapWeekColumns,
heatmapWeekdayLabels,
type HeatmapCell,
} from '../../utils/playerStats/heatmapLevels';
const LEVEL_OPACITY = [0.14, 0.32, 0.52, 0.72, 1] as const;
type Props = {
year: number;
dayCounts: Map<string, number>;
selectedDate: string | null;
onSelectDate: (date: string) => void;
};
function cellClass(cell: HeatmapCell, level: number, active: boolean): string {
const parts = ['player-heatmap-cell'];
if (!cell.date) parts.push('player-heatmap-cell--pad');
else if (cell.count <= 0) parts.push('player-heatmap-cell--empty');
else parts.push(`player-heatmap-cell--l${level}`);
if (active) parts.push('player-heatmap-cell--selected');
return parts.join(' ');
}
function cellStyle(cell: HeatmapCell, level: number): CSSProperties | undefined {
if (!cell.date || cell.count <= 0) return undefined;
return {
background: `color-mix(in srgb, var(--accent) ${Math.round(LEVEL_OPACITY[level] * 100)}%, transparent)`,
};
}
export default function PlayerStatsHeatmap({ year, dayCounts, selectedDate, onSelectDate }: Props) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language;
const wrapRef = useRef<HTMLDivElement>(null);
const [layoutWidth, setLayoutWidth] = useState(0);
const { weeks, maxCount } = useMemo(
() => heatmapWeekColumns(year, dayCounts),
[year, dayCounts],
);
const monthLabels = useMemo(() => heatmapMonthLabels(year, locale), [year, locale]);
const weekdayLabels = useMemo(() => heatmapWeekdayLabels(locale), [locale]);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const measure = () => setLayoutWidth(el.clientWidth);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [weeks.length]);
const { cell, gap, labelW, bodyGap } = useMemo(
() => heatmapCellMetrics(layoutWidth, weeks.length),
[layoutWidth, weeks.length],
);
const heatmapVars = {
'--hm-weeks': weeks.length,
'--hm-cell': `${cell}px`,
'--hm-gap': `${gap}px`,
'--hm-label-w': `${labelW}px`,
'--hm-body-gap': `${bodyGap}px`,
'--hm-pitch': `${cell + gap}px`,
'--hm-grid-w': `${weeks.length * (cell + gap) - gap}px`,
} as CSSProperties;
return (
<div ref={wrapRef} className="player-heatmap-wrap">
<div className="player-heatmap" style={heatmapVars}>
<div className="player-heatmap-months" aria-hidden="true">
{monthLabels.map(m => (
<span
key={`${m.columnIndex}-${m.label}`}
className="player-heatmap-month"
style={{ '--column-index': m.columnIndex } as CSSProperties}
>
{m.label}
</span>
))}
</div>
<div className="player-heatmap-body">
<div className="player-heatmap-weekdays" aria-hidden="true">
{weekdayLabels.map((label, i) => (
<span key={i}>{label}</span>
))}
</div>
<div className="player-heatmap-columns">
{weeks.map((week, wi) => (
<div key={wi} className="player-heatmap-col">
{week.map((cellItem, di) => {
if (!cellItem.date) {
return <span key={di} className="player-heatmap-cell player-heatmap-cell--pad" />;
}
const level = heatmapLevel(cellItem.count, maxCount);
const active = selectedDate === cellItem.date;
return (
<button
key={cellItem.date}
type="button"
className={cellClass(cellItem, level, active)}
style={cellStyle(cellItem, level)}
title={`${cellItem.date}: ${t('statistics.playerDayTrackPlays', { count: cellItem.count })}`}
aria-label={`${cellItem.date}: ${t('statistics.playerDayTrackPlays', { count: cellItem.count })}`}
aria-pressed={active}
onClick={() => onSelectDate(cellItem.date)}
/>
);
})}
</div>
))}
</div>
</div>
<div className="player-heatmap-legend" aria-hidden="true">
<span>{t('statistics.playerHeatmapLess')}</span>
{Array.from({ length: HEATMAP_LEVEL_COUNT }, (_, level) => (
<span
key={level}
className={`player-heatmap-cell player-heatmap-cell--sample${level === 0 ? ' player-heatmap-cell--empty' : ''}`}
style={level === 0 ? undefined : {
background: `color-mix(in srgb, var(--accent) ${Math.round(LEVEL_OPACITY[level as 0 | 1 | 2 | 3 | 4] * 100)}%, transparent)`,
}}
/>
))}
<span>{t('statistics.playerHeatmapMore')}</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { Info } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
export default function PlayerStatsPartialIndexNotice() {
const { t } = useTranslation();
const navigate = useNavigate();
const servers = useAuthStore(s => s.servers);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer);
const excludedCount = useMemo(
() => servers.filter(s => syncExcludedByServer[s.id] === true).length,
[servers, syncExcludedByServer],
);
if (!masterEnabled || excludedCount === 0 || servers.length <= 1) {
return null;
}
return (
<div className="settings-hint settings-hint-info player-stats-partial-index-notice" role="status">
<Info size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<span>
{t('statistics.playerPartialIndexNotice')}
{' '}
<button
type="button"
className="player-stats-partial-index-link"
onClick={() => navigate('/settings', { state: { tab: 'library' } })}
>
{t('statistics.playerPartialIndexSettings')}
</button>
</span>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
export default function StatisticsTabBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const servers = useAuthStore(s => s.servers);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer);
const showPlayerTab =
masterEnabled && servers.some(s => syncExcludedByServer[s.id] !== true);
if (!showPlayerTab) return null;
const isPlayer = location.pathname === '/player-stats';
return (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
<button
type="button"
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
onClick={() => navigate('/statistics')}
>
{t('statistics.tabServer')}
</button>
<button
type="button"
className={`btn btn-sm ${isPlayer ? 'btn-primary' : 'btn-surface'}`}
onClick={() => navigate('/player-stats')}
>
{t('statistics.tabPlayer')}
</button>
</div>
);
}