refactor(stats): co-locate statistics feature into features/stats

This commit is contained in:
Psychotoxical
2026-06-29 22:51:54 +02:00
parent 431d65b587
commit 236acc33dd
19 changed files with 54 additions and 47 deletions
@@ -0,0 +1,145 @@
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,
StatisticsOverviewData,
SubsonicAlbum,
SubsonicArtist,
SubsonicGenre,
SubsonicSong,
} from '@/api/subsonicTypes';
/** Cache TTL for statistics page aggregates — same 7-minute window as
* the rating prefetch cache in subsonicRatings.ts. */
const STATS_CACHE_TTL = 7 * 60 * 1000;
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
function statisticsPageCacheKey(prefix: string): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
const folderPart = folder === 'all' ? 'all' : folder;
return `${prefix}:${activeServerId}:${folderPart}`;
}
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
/**
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
* 7 minutes.
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
*/
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
const key = statisticsPageCacheKey('statsAgg');
if (key) {
const hit = statisticsAggregatesCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
const pageSize = 500;
const capped = false;
let offset = 0;
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
for (;;) {
try {
const albums = await nextPage;
for (const a of albums) {
playtimeSec += a.duration ?? 0;
albumsCounted += 1;
const sc = a.songCount ?? 0;
songsCounted += sc;
const tags = genreTagsFor(a);
const labels = tags.length > 0 ? tags : [''];
for (const label of labels) {
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
}
g.songCount += sc;
g.albumCount += 1;
}
}
if (albums.length < pageSize) break;
offset += pageSize;
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
} catch {
break;
}
}
const genres: SubsonicGenre[] = [...genreAgg.entries()]
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
.sort((a, b) => b.songCount - a.songCount);
const result: StatisticsLibraryAggregates = {
playtimeSec,
albumsCounted,
songsCounted,
capped,
genres,
};
if (key) {
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
/** Recent / frequent / highest album strips + artist count for Statistics. */
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
const key = statisticsPageCacheKey('statsOverview');
if (key) {
const hit = statisticsOverviewCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const [recent, frequent, highest, artists] = await Promise.all([
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
getArtists().catch(() => [] as SubsonicArtist[]),
]);
const result: StatisticsOverviewData = {
recent,
frequent,
highest,
artistCount: artists.length,
};
if (key) {
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
/** Format (suffix) histogram from a random sample for Statistics. */
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
const key = statisticsPageCacheKey('statsFormat');
if (key) {
const hit = statisticsFormatCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const rows = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
if (key) {
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
@@ -0,0 +1,182 @@
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 '@/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();
export default function PlayerStatisticsPanel() {
const { t } = useTranslation();
const recordingEnabled = usePlayerStatsRecordingEnabled();
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(() => {
if (!recordingEnabled) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(false);
setSummary(null);
setDayCounts(new Map());
setSelectedDate(null);
return;
}
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, recordingEnabled]);
const refreshLive = useCallback(async () => {
if (!recordingEnabled) return;
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, recordingEnabled]);
usePlayerStatsLiveRefresh(refreshLive);
if (!recordingEnabled) {
return (
<div className="stats-page">
<PlayerStatsIndexRequiredNotice />
</div>
);
}
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">
<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 '@/features/stats/utils/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,25 @@
import { Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export default function PlayerStatsIndexRequiredNotice() {
const { t } = useTranslation();
const navigate = useNavigate();
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.playerIndexRequired')}
{' '}
<button
type="button"
className="player-stats-partial-index-link"
onClick={() => navigate('/settings', { state: { tab: 'servers' } })}
>
{t('statistics.playerPartialIndexSettings')}
</button>
</span>
</div>
);
}
@@ -0,0 +1,205 @@
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 '@/features/stats/utils/formatPlayerStatsDay';
import PlayerStatsDayTracks from '@/features/stats/components/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);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
detailsRef.current = details;
const expandedRef = useRef(expandedDates);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
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;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
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;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
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 { useOfflineBrowseContext } from '@/hooks/useOfflineBrowseContext';
import { usePlayerStatsRecordingEnabled } from '@/features/stats/hooks/usePlayerStatsRecordingEnabled';
export default function StatisticsTabBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const offlineBrowseActive = useOfflineBrowseContext().active;
const playerStatsEnabled = usePlayerStatsRecordingEnabled();
const isPlayer = location.pathname === '/player-stats';
const showServerTab = !offlineBrowseActive;
return (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
{showServerTab && (
<button
type="button"
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
onClick={() => navigate('/statistics')}
>
{t('statistics.tabServer')}
</button>
)}
{playerStatsEnabled && (
<button
type="button"
className={`btn btn-sm ${isPlayer || offlineBrowseActive ? 'btn-primary' : 'btn-surface'}`}
onClick={() => navigate('/player-stats')}
>
{t('statistics.tabPlayer')}
</button>
)}
</div>
);
}
@@ -0,0 +1,313 @@
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 {
exportAlbumCardBlob,
renderAlbumCardCanvas,
ExportFormat,
ExportGridSize,
} from '@/utils/export/exportAlbumCard';
interface Props {
open: boolean;
/** Pre-loaded albums (e.g. from the statistics page). The modal will fetch
* more on open if this list is shorter than 25 (max grid 5×5). */
albums: SubsonicAlbum[];
/** Footer-right meta string, e.g. "Most Played" or a date. */
meta?: string;
onClose: () => void;
}
const MAX_NEEDED = 25; // 5 × 5 grid
const FORMATS: { key: ExportFormat; ratioBox: { w: number; h: number } }[] = [
{ key: 'story', ratioBox: { w: 36, h: 64 } },
{ key: 'square', ratioBox: { w: 50, h: 50 } },
{ key: 'twitter', ratioBox: { w: 64, h: 36 } },
];
const GRID_SIZES: ExportGridSize[] = [3, 4, 5];
export default function StatsExportModal({ open, albums, meta, onClose }: Props) {
const { t } = useTranslation();
const [format, setFormat] = useState<ExportFormat>('square');
const [gridSize, setGridSize] = useState<ExportGridSize>(3);
const [saving, setSaving] = useState(false);
const [topUpAlbums, setTopUpAlbums] = useState<SubsonicAlbum[] | null>(null);
const previewRef = useRef<HTMLDivElement | null>(null);
const previewSeqRef = useRef(0);
const effectiveAlbums = topUpAlbums ?? albums;
const required = gridSize * gridSize;
const enoughAlbums = effectiveAlbums.length >= required;
// On open: if the caller-provided list is shorter than the largest grid,
// fetch up to 25 in the background so the user can pick 4×4 / 5×5 even
// when the entry surface only loaded a few albums.
useEffect(() => {
if (!open) return;
if (albums.length >= MAX_NEEDED) {
// React Compiler set-state-in-effect rule: local state synced with the already-available `albums` prop when the modal opens (the async top-up below is skipped).
// eslint-disable-next-line react-hooks/set-state-in-effect
setTopUpAlbums(albums);
return;
}
setTopUpAlbums(null);
let cancelled = false;
(async () => {
try {
const more = await getAlbumList('frequent', MAX_NEEDED, 0);
if (cancelled) return;
setTopUpAlbums(more.length > albums.length ? more : albums);
} catch {
if (!cancelled) setTopUpAlbums(albums);
}
})();
return () => { cancelled = true; };
}, [open, albums]);
const title = t('statistics.exportFooterLabel');
// Live preview: re-renders on format / gridSize / albums changes.
useEffect(() => {
if (!open) return;
if (!enoughAlbums) return;
const host = previewRef.current;
if (!host) return;
const seq = ++previewSeqRef.current;
let cancelled = false;
(async () => {
try {
const canvas = await renderAlbumCardCanvas({
albums: effectiveAlbums,
format,
gridSize,
title,
meta,
preview: true,
});
if (cancelled || seq !== previewSeqRef.current) return;
// Replace any previous preview canvas.
host.replaceChildren(canvas);
canvas.style.width = '100%';
canvas.style.height = 'auto';
canvas.style.display = 'block';
canvas.style.borderRadius = '12px';
canvas.style.boxShadow = '0 8px 24px rgba(0,0,0,0.35)';
} catch (e) {
if (!cancelled && seq === previewSeqRef.current) {
host.textContent = String(e);
}
}
})();
return () => {
cancelled = true;
};
}, [open, format, gridSize, effectiveAlbums, enoughAlbums, title, meta]);
// Esc-to-close.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const onSave = async () => {
if (saving || !enoughAlbums) return;
setSaving(true);
try {
const blob = await exportAlbumCardBlob({ albums: effectiveAlbums, format, gridSize, title, meta });
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
const suggested = `psysonic-top-albums-${gridSize}x${gridSize}-${format}-${stamp}.png`;
const path = await save({
title: t('statistics.exportSave'),
defaultPath: suggested,
filters: [{ name: 'PNG', extensions: ['png'] }],
});
if (!path) {
setSaving(false);
return;
}
const buf = new Uint8Array(await blob.arrayBuffer());
await writeFile(path, buf);
showToast(t('statistics.exportSaved'), 2400, 'info');
onClose();
} catch (err) {
console.error('[stats-export] save failed', err);
showToast(t('statistics.exportSaveFailed'), 3200, 'error');
} finally {
setSaving(false);
}
};
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '720px', width: 'min(720px, 92vw)' }}
>
<button className="modal-close" onClick={onClose} aria-label={t('statistics.exportCancel')}>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.25rem', fontFamily: 'var(--font-display)' }}>
{t('statistics.exportTitle')}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem', fontSize: '0.875rem' }}>
{t('statistics.exportSubtitle')}
</p>
{/* Format */}
<div style={{ marginBottom: '0.875rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('statistics.exportFormat')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{FORMATS.map(f => {
const active = format === f.key;
return (
<button
key={f.key}
type="button"
onClick={() => setFormat(f.key)}
className="btn btn-surface"
style={{
padding: '0.5rem 0.75rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
}}
>
<span style={{
display: 'inline-block',
width: f.ratioBox.w * 0.4,
height: f.ratioBox.h * 0.4,
background: active ? 'var(--accent)' : 'var(--text-muted)',
opacity: active ? 0.9 : 0.5,
borderRadius: 2,
}} />
{t(`statistics.exportFormat${f.key[0].toUpperCase()}${f.key.slice(1)}`)}
</button>
);
})}
</div>
</div>
{/* Grid */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('statistics.exportGrid')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{GRID_SIZES.map(n => {
const active = gridSize === n;
return (
<button
key={n}
type="button"
onClick={() => setGridSize(n)}
className="btn btn-surface"
style={{
padding: '0.5rem 0.875rem',
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
}}
>
{t('statistics.exportGridLabel', { n })}
</button>
);
})}
</div>
</div>
{/* Preview */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('statistics.exportPreview')}
</div>
<PreviewFrame format={format}>
{!enoughAlbums ? (
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
color: 'var(--text-muted)',
fontSize: '0.875rem',
padding: '1rem',
}}>
{t('statistics.exportNotEnough', { count: required, n: gridSize })}
</div>
) : (
<div ref={previewRef} style={{ width: '100%' }} />
)}
</PreviewFrame>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button className="btn btn-ghost" onClick={onClose} disabled={saving}>
{t('statistics.exportCancel')}
</button>
<button
className="btn btn-primary"
onClick={onSave}
disabled={!enoughAlbums || saving}
>
{saving ? t('statistics.exportSaving') : t('statistics.exportSave')}
</button>
</div>
</div>
</div>,
document.body,
);
}
const PreviewFrame = ({ format, children }: { format: ExportFormat; children: React.ReactNode }) => {
// Aspect-aware bounds: cap BOTH dimensions so the preview always fits inside
// the modal at any ratio. The earlier version capped only `maxHeight`, so
// Square (1:1) tried to span the full modal width — the 1:1 canvas then
// overflowed `maxHeight: 52vh` and the bottom rows were clipped by
// `overflow: hidden` with no way to scroll them into view.
const { aspect, maxWidth } = useMemo(() => {
if (format === 'story') return { aspect: '9 / 16', maxWidth: 'min(320px, calc(52vh * 9 / 16))' };
if (format === 'square') return { aspect: '1 / 1', maxWidth: '52vh' };
return { aspect: '16 / 9', maxWidth: undefined as string | undefined };
}, [format]);
return (
<div style={{
position: 'relative',
width: '100%',
aspectRatio: aspect,
margin: '0 auto',
maxWidth,
maxHeight: '52vh',
background: 'var(--glass-bg)',
borderRadius: 12,
border: '1px solid var(--glass-border)',
overflow: 'hidden',
}}>
{children}
</div>
);
};
@@ -0,0 +1,39 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
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', () => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'visible',
});
const onRefresh = vi.fn();
renderHook(() => usePlayerStatsLiveRefresh(onRefresh));
emitPlaySessionRecorded({ serverId: 's1', trackId: 't1', startedAtMs: 1 });
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it('does not refresh on record while the tab is hidden', () => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'hidden',
});
const onRefresh = vi.fn();
renderHook(() => usePlayerStatsLiveRefresh(onRefresh));
emitPlaySessionRecorded({ serverId: 's1', trackId: 't1', startedAtMs: 1 });
expect(onRefresh).not.toHaveBeenCalled();
});
it('refreshes when the tab becomes visible again', () => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'visible',
});
const onRefresh = vi.fn();
renderHook(() => usePlayerStatsLiveRefresh(onRefresh));
document.dispatchEvent(new Event('visibilitychange'));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react';
import { onPlaySessionRecorded } from '@/store/playSessionRecorded';
/** Refresh player stats when a listen is persisted or the tab becomes visible again. */
export function usePlayerStatsLiveRefresh(onRefresh: () => void) {
const onRefreshRef = useRef(onRefresh);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
onRefreshRef.current = onRefresh;
useEffect(() => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
onRefreshRef.current();
}
};
const unsubRecorded = onPlaySessionRecorded(() => {
refreshIfVisible();
});
const onVisibility = () => {
if (document.visibilityState === 'visible') {
onRefreshRef.current();
}
};
document.addEventListener('visibilitychange', onVisibility);
return () => {
unsubRecorded();
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
}
@@ -0,0 +1,10 @@
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 {
const servers = useAuthStore(s => s.servers);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const indexedServerIds = useLibraryIndexStore(s => s.indexedServerIds);
return masterEnabled && indexedServerIds(servers.map(s => s.id)).length > 0;
}
+7
View File
@@ -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';
+445
View File
@@ -0,0 +1,445 @@
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 '@/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 { useLocation } from 'react-router-dom';
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 {
const diff = Math.floor(Date.now() / 1000) - timestamp;
if (diff < 60) return t('statistics.lfmJustNow');
if (diff < 3600) return t('statistics.lfmMinutesAgo', { n: Math.floor(diff / 60) });
if (diff < 86400) return t('statistics.lfmHoursAgo', { n: Math.floor(diff / 3600) });
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
}
const PERIODS: { key: StatsPeriod; label: string }[] = [
{ key: '7day', label: 'lfmPeriod7day' },
{ key: '1month', label: 'lfmPeriod1month' },
{ key: '3month', label: 'lfmPeriod3month' },
{ key: '6month', label: 'lfmPeriod6month' },
{ key: '12month', label: 'lfmPeriod12month' },
{ key: 'overall', label: 'lfmPeriodOverall' },
];
export default function Statistics() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const isPlayerStats = location.pathname === '/player-stats';
const offlineBrowseActive = useOfflineBrowseContext().active;
const playerStatsEnabled = usePlayerStatsRecordingEnabled();
const enrichmentPrimaryId = useAuthStore(s => s.enrichmentPrimaryId);
const enrichmentLabel = useEnrichmentPrimaryLabel() ?? '';
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
const [artistCount, setArtistCount] = useState<number | null>(null);
const [totalSongs, setTotalSongs] = useState<number | null>(null);
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [loading, setLoading] = useState(true);
const [totalPlaytime, setTotalPlaytime] = useState<number | null>(null);
const [playtimeCapped, setPlaytimeCapped] = useState(false);
const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null);
const [formatSampleSize, setFormatSampleSize] = useState(0);
const [exportOpen, setExportOpen] = useState(false);
// Enrichment-primary listening stats. The `lfm*` local names and the
// `statistics.lfm*` i18n keys are the original (pre-framework) identifiers,
// kept as-is: the user-facing copy is provider-neutral ({{provider}}), and the
// keys share the `lfmPeriod`/`lfmPeriod7day` prefix so a blanket rename is
// unsafe. Internal-only; not a framework-boundary concern.
const [lfmPeriod, setLfmPeriod] = useState<StatsPeriod>('1month');
const [lfmTopArtists, setLfmTopArtists] = useState<TopItem[]>([]);
const [lfmTopAlbums, setLfmTopAlbums] = useState<TopItem[]>([]);
const [lfmTopTracks, setLfmTopTracks] = useState<TopItem[]>([]);
const [lfmLoading, setLfmLoading] = useState(false);
const [lfmRecentTracks, setLfmRecentTracks] = useState<RecentTrack[]>([]);
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
useEffect(() => {
if (offlineBrowseActive && playerStatsEnabled && !isPlayerStats) {
navigate('/player-stats', { replace: true });
}
}, [offlineBrowseActive, playerStatsEnabled, isPlayerStats, navigate]);
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(false);
return;
}
fetchStatisticsOverview()
.then(d => {
setRecent(d.recent);
setFrequent(d.frequent);
setHighest(d.highest);
setArtistCount(d.artistCount);
setLoading(false);
})
.catch(() => setLoading(false));
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) return;
let cancelled = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTotalPlaytime(null);
setTotalAlbums(null);
setTotalSongs(null);
setPlaytimeCapped(false);
setGenres([]);
(async () => {
try {
const agg = await fetchStatisticsLibraryAggregates();
if (cancelled) return;
setTotalPlaytime(agg.playtimeSec);
setTotalAlbums(agg.albumsCounted);
setTotalSongs(agg.songsCounted);
setPlaytimeCapped(agg.capped);
setGenres(agg.genres);
} catch {
if (!cancelled) {
setTotalPlaytime(0);
setTotalAlbums(0);
setTotalSongs(0);
setPlaytimeCapped(false);
setGenres([]);
}
}
})();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) return;
let cancelled = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setFormatData(null);
setFormatSampleSize(0);
fetchStatisticsFormatSample()
.then(s => {
if (cancelled) return;
setFormatData(s.rows);
setFormatSampleSize(s.sampleSize);
})
.catch(() => {});
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) return;
if (enrichmentPrimaryId === null) return;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLfmRecentLoading(true);
getMusicNetworkRuntime().getRecentTracks(20)
.then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); })
.catch(() => setLfmRecentLoading(false));
}, [enrichmentPrimaryId, offlineBrowseActive, isPlayerStats]);
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) return;
if (enrichmentPrimaryId === null) return;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLfmLoading(true);
Promise.all([
getMusicNetworkRuntime().getTopItems(lfmPeriod, 'artists', 10),
getMusicNetworkRuntime().getTopItems(lfmPeriod, 'albums', 10),
getMusicNetworkRuntime().getTopItems(lfmPeriod, 'tracks', 10),
]).then(([artists, albums, tracks]) => {
setLfmTopArtists(artists);
setLfmTopAlbums(albums);
setLfmTopTracks(tracks);
setLfmLoading(false);
}).catch(() => setLfmLoading(false));
}, [lfmPeriod, enrichmentPrimaryId, offlineBrowseActive, isPlayerStats]);
const loadMore = async (
type: 'frequent' | 'highest',
currentList: SubsonicAlbum[],
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
) => {
try {
const more = await getAlbumList(type, 12, currentList.length);
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
} catch (e) {
console.error('Failed to load more', e);
}
};
const playtimeDisplay = totalPlaytime === null
? t('statistics.computing')
: (playtimeCapped ? '≥ ' : '') + formatHumanHoursMinutes(totalPlaytime);
const countDisplay = (n: number | null) =>
n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString();
const stats = [
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—', tooltip: t('statistics.statArtistsTooltip') },
{ label: t('statistics.statAlbums'), value: countDisplay(totalAlbums) },
{ label: t('statistics.statSongs'), value: countDisplay(totalSongs) },
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
];
const topGenres = genres.slice(0, 10);
const maxGenreSongs = topGenres[0]?.songCount ?? 1;
return (
<div className="content-body animate-fade-in">
<h1 className="page-title">{t('statistics.title')}</h1>
<StatisticsTabBar />
{isPlayerStats ? (
<PlayerStatisticsPanel />
) : loading ? (
<div className="loading-center"><div className="spinner" /></div>
) : (
<div className="stats-page">
<div className="stats-overview">
{stats.map(s => (
<div key={s.label} className="stats-card">
<span className="stats-card-value">{s.value}</span>
<span className="stats-card-label" data-tooltip={s.tooltip} data-tooltip-wrap="true">{s.label}</span>
</div>
))}
</div>
{/* Genre Insights + Format Distribution */}
{(topGenres.length > 0 || formatData) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
{topGenres.length > 0 && (
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
{t('statistics.genreInsights')}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{topGenres.map(g => (
<div key={g.value || '__genre_unknown__'}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
{g.value.trim() ? g.value : t('statistics.decadeUnknown')}
</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
{g.songCount.toLocaleString()}
</span>
</div>
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${(g.songCount / maxGenreSongs) * 100}%`,
background: 'var(--accent)',
opacity: 0.7,
borderRadius: '2px',
transition: 'width 0.4s ease',
}} />
</div>
</div>
))}
</div>
</div>
)}
{formatData && (
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '0.25rem' }}>
{t('statistics.formatDistribution')}
</h3>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{t('statistics.formatSample', { n: formatSampleSize.toLocaleString() })}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{formatData.map(f => {
const pct = formatSampleSize > 0 ? Math.round((f.count / formatSampleSize) * 100) : 0;
return (
<div key={f.format}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 600, fontFamily: 'monospace' }}>{f.format}</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{pct}%</span>
</div>
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${pct}%`,
background: 'var(--accent)',
opacity: 0.6,
borderRadius: '2px',
transition: 'width 0.4s ease',
}} />
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{recent.length > 0 && (
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
)}
<AlbumRow
title={t('statistics.mostPlayed')}
albums={frequent}
onLoadMore={() => loadMore('frequent', frequent, setFrequent)}
moreText={t('statistics.loadMore')}
headerExtra={frequent.length >= 9 ? (
<button
type="button"
className="nav-btn"
onClick={() => setExportOpen(true)}
data-tooltip={t('statistics.exportTitle')}
aria-label={t('statistics.exportTitle')}
>
<Share2 size={18} />
</button>
) : undefined}
/>
<AlbumRow
title={t('statistics.highestRated')}
albums={highest}
onLoadMore={() => loadMore('highest', highest, setHighest)}
moreText={t('statistics.loadMore')}
showRating
/>
{/* Music Network Stats */}
{enrichmentPrimaryId !== null && (
<section style={{ marginTop: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>{t('statistics.lfmTitle', { provider: enrichmentLabel })}</h2>
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
{PERIODS.map(p => (
<button
key={p.key}
className={`btn btn-sm ${lfmPeriod === p.key ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setLfmPeriod(p.key)}
style={{ padding: '0.25rem 0.625rem', fontSize: '0.75rem' }}
>
{t(`statistics.${p.label}`)}
</button>
))}
</div>
</div>
{lfmLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
{([
{ label: t('statistics.lfmTopArtists'), items: lfmTopArtists.map(a => ({ primary: a.name, secondary: null, playcount: a.playcount })) },
{ label: t('statistics.lfmTopAlbums'), items: lfmTopAlbums.map(a => ({ primary: a.name, secondary: a.artist ?? null, playcount: a.playcount })) },
{ label: t('statistics.lfmTopTracks'), items: lfmTopTracks.map(tr => ({ primary: tr.name, secondary: tr.artist ?? null, playcount: tr.playcount })) },
] as { label: string; items: { primary: string; secondary: string | null; playcount: string }[] }[]).map(col => {
const max = Math.max(...col.items.map(it => Number(it.playcount)), 1);
return (
<div key={col.label} style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
{col.label}
</h3>
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
{col.items.map((it, i) => (
<li key={`${it.primary}-${i}`}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.625rem', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
{i + 1}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.primary}</div>
{it.secondary && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.secondary}</div>
)}
</div>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0 }}>{Number(it.playcount).toLocaleString()}</span>
</div>
<div style={{ height: '2px', borderRadius: '1px', background: 'var(--glass-border)', overflow: 'hidden', marginLeft: '2.125rem' }}>
<div style={{ height: '100%', width: `${(Number(it.playcount) / max) * 100}%`, background: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 0.8 : 0.3, borderRadius: '1px', transition: 'width 0.4s ease' }} />
</div>
</li>
))}
</ol>
</div>
);
})}
</div>
)}
</section>
)}
{/* Recent Scrobbles */}
{enrichmentPrimaryId !== null && (
<section style={{ marginTop: '2rem' }}>
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('statistics.lfmRecentTracks')}</h2>
{lfmRecentLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{lfmRecentTracks.slice(0, 3).map((track, i) => (
<div key={`${track.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 0.75rem', borderRadius: '8px', background: track.nowPlaying ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', border: track.nowPlaying ? '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' : '1px solid transparent' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
{track.nowPlaying && (
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--bg-app)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{track.artist}{track.album ? ` · ${track.album}` : ''}
</div>
</div>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0 }}>
{track.nowPlaying ? '' : track.timestamp ? relativeTime(track.timestamp, t) : ''}
</span>
</div>
))}
</div>
)}
</section>
)}
</div>
)}
{!isPlayerStats && (
<StatsExportModal
open={exportOpen}
albums={frequent}
onClose={() => setExportOpen(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { formatPlayerStatsDayLabel } from '@/features/stats/utils/formatPlayerStatsDay';
const t = (key: string) => {
if (key === 'statistics.playerDayToday') return 'Today';
if (key === 'statistics.playerDayYesterday') return 'Yesterday';
return key;
};
describe('formatPlayerStatsDayLabel', () => {
it('labels today and yesterday', () => {
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, '0');
const d = String(today.getDate()).padStart(2, '0');
expect(formatPlayerStatsDayLabel(`${y}-${m}-${d}`, t, 'en')).toBe('Today');
const yest = new Date(today);
yest.setDate(yest.getDate() - 1);
const ym = String(yest.getMonth() + 1).padStart(2, '0');
const yd = String(yest.getDate()).padStart(2, '0');
expect(formatPlayerStatsDayLabel(`${yest.getFullYear()}-${ym}-${yd}`, t, 'en')).toBe('Yesterday');
});
});
@@ -0,0 +1,41 @@
export const PLAYER_STATS_RECENT_DAYS_LIMIT = 30;
export function localTodayIso(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function parseLocalDate(dateIso: string): Date {
const [y, m, d] = dateIso.split('-').map(Number);
return new Date(y, m - 1, d);
}
function startOfLocalDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/** Human-readable day header for recent-days accordion. */
export function formatPlayerStatsDayLabel(
dateIso: string,
t: (key: string, opts?: Record<string, unknown>) => string,
locale?: string,
): string {
const date = parseLocalDate(dateIso);
const today = startOfLocalDay(new Date());
const day = startOfLocalDay(date);
const diffDays = Math.round((today.getTime() - day.getTime()) / 86_400_000);
if (diffDays === 0) return t('statistics.playerDayToday');
if (diffDays === 1) return t('statistics.playerDayYesterday');
const fmt = new Intl.DateTimeFormat(locale, {
weekday: 'long',
month: 'short',
day: 'numeric',
year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined,
});
return fmt.format(date);
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { heatmapCellMetrics } from '@/features/stats/utils/heatmapLevels';
describe('heatmapCellMetrics', () => {
it('shrinks cells to fit narrow containers', () => {
const m = heatmapCellMetrics(400, 53);
expect(m.cell).toBeLessThan(14);
expect(m.cell).toBeGreaterThanOrEqual(4);
const total = m.labelW + m.bodyGap + 53 * m.cell + 52 * m.gap;
expect(total).toBeLessThanOrEqual(400 + 1);
});
it('caps at 14px on wide containers', () => {
const m = heatmapCellMetrics(1200, 53);
expect(m.cell).toBe(14);
});
});
+110
View File
@@ -0,0 +1,110 @@
/** GitHub-style intensity bucket from track play count vs year max. */
export function heatmapLevel(count: number, maxCount: number): 0 | 1 | 2 | 3 | 4 {
if (count <= 0 || maxCount <= 0) return 0;
const ratio = count / maxCount;
if (ratio >= 0.75) return 4;
if (ratio >= 0.5) return 3;
if (ratio >= 0.25) return 2;
return 1;
}
export const HEATMAP_LEVEL_COUNT = 5;
export type HeatmapCell = { date: string; count: number };
export function yearDayKeys(year: number): string[] {
const out: string[] = [];
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
out.push(`${y}-${m}-${day}`);
}
return out;
}
/** Week columns (Sun→Sat rows) with leading/trailing padding cells. */
export function heatmapWeekColumns(year: number, dayCounts: Map<string, number>): {
weeks: HeatmapCell[][];
maxCount: number;
} {
const days = yearDayKeys(year);
const maxCount = Math.max(0, ...days.map(d => dayCounts.get(d) ?? 0));
const firstDow = new Date(year, 0, 1).getDay();
const cells: HeatmapCell[] = [];
for (let i = 0; i < firstDow; i++) {
cells.push({ date: '', count: 0 });
}
for (const date of days) {
cells.push({ date, count: dayCounts.get(date) ?? 0 });
}
while (cells.length % 7 !== 0) {
cells.push({ date: '', count: 0 });
}
const weeks: HeatmapCell[][] = [];
for (let i = 0; i < cells.length; i += 7) {
weeks.push(cells.slice(i, i + 7));
}
return { weeks, maxCount };
}
/** Month labels aligned to week columns (first day of each month). */
export function heatmapMonthLabels(
year: number,
locale?: string,
): { columnIndex: number; label: string }[] {
const fmt = new Intl.DateTimeFormat(locale, { month: 'short' });
const firstDow = new Date(year, 0, 1).getDay();
const labels: { columnIndex: number; label: string }[] = [];
let lastMonth = -1;
const yearStart = new Date(year, 0, 1).getTime();
const end = new Date(year, 11, 31);
for (let d = new Date(year, 0, 1); d <= end; d.setDate(d.getDate() + 1)) {
const month = d.getMonth();
if (month === lastMonth) continue;
const dayOfYear = Math.floor((d.getTime() - yearStart) / 86_400_000);
labels.push({
columnIndex: Math.floor((firstDow + dayOfYear) / 7),
label: fmt.format(new Date(year, month, 1)),
});
lastMonth = month;
}
return labels;
}
/** Weekday row labels (SunSat); empty string hides a row label like GitHub. */
export function heatmapWeekdayLabels(locale?: string): string[] {
const fmt = new Intl.DateTimeFormat(locale, { weekday: 'narrow' });
// Jan 4 2026 is Sunday — anchor week for stable weekday order.
const anchor = new Date(2026, 0, 4);
return Array.from({ length: 7 }, (_, i) => {
if (i % 2 === 0) return '';
const d = new Date(anchor);
d.setDate(anchor.getDate() + i);
return fmt.format(d);
});
}
const HEATMAP_LABEL_W = 24;
const HEATMAP_BODY_GAP = 8;
const HEATMAP_CELL_MIN = 4;
const HEATMAP_CELL_MAX = 14;
/** Fit square cells to the heatmap container width (week columns only). */
export function heatmapCellMetrics(
containerWidth: number,
weekCount: number,
): { cell: number; gap: number; labelW: number; bodyGap: number } {
const gap = containerWidth > 640 ? 3 : 2;
const available = containerWidth - HEATMAP_LABEL_W - HEATMAP_BODY_GAP;
if (available <= 0 || weekCount <= 0) {
return { cell: HEATMAP_CELL_MIN, gap, labelW: HEATMAP_LABEL_W, bodyGap: HEATMAP_BODY_GAP };
}
const cell = Math.min(
HEATMAP_CELL_MAX,
Math.max(HEATMAP_CELL_MIN, (available - (weekCount - 1) * gap) / weekCount),
);
return { cell, gap, labelW: HEATMAP_LABEL_W, bodyGap: HEATMAP_BODY_GAP };
}