mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(stats): co-locate statistics feature into features/stats
This commit is contained in:
@@ -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 effect’s 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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (Sun–Sat); 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user