Skip to content

Commit 900620d

Browse files
feat(stats): rework statistics dashboard to Last.fm-inspired layout
- Two-column layout: ranked artist list (left), plays-over-time bar chart (right) - Date range dropdown with calendar icon and 6 presets (7d/30d/90d/180d/365d/all) - Theme-aware bars using hsl(var(--primary)) with text embedded inside - Hidden scrollbar with gradient overlays for scroll indication - Non-selectable text (select-none) across the stats view - Plays-over-time groups by day (7d/30d), month (90d/180d/365d), or year (all) - Backend: add Last90Days, Last180Days, Last365Days date range variants Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ce96885 commit 900620d

4 files changed

Lines changed: 258 additions & 129 deletions

File tree

app/frontend/js/components/stats-view.js

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,47 @@
33
*
44
* Listening statistics dashboard with artist rankings, genre breakdown,
55
* plays-over-time chart, and album art chart grid generator.
6+
* Layout inspired by Last.fm's library statistics page.
67
*/
78

89
import { stats } from '../api/stats.js';
910
import { library } from '../api/library.js';
1011

12+
const DATE_RANGE_OPTIONS = [
13+
{ value: 'Last7Days', label: 'Last 7 days' },
14+
{ value: 'Last30Days', label: 'Last 30 days' },
15+
{ value: 'Last90Days', label: 'Last 90 days' },
16+
{ value: 'Last180Days', label: 'Last 180 days' },
17+
{ value: 'Last365Days', label: 'Last 365 days' },
18+
{ value: 'AllTime', label: 'All time' },
19+
];
20+
21+
const MONTH_NAMES = [
22+
'Jan',
23+
'Feb',
24+
'Mar',
25+
'Apr',
26+
'May',
27+
'Jun',
28+
'Jul',
29+
'Aug',
30+
'Sep',
31+
'Oct',
32+
'Nov',
33+
'Dec',
34+
];
35+
1136
export function createStatsView(Alpine) {
1237
Alpine.data('statsView', () => ({
1338
dateRange: 'AllTime',
39+
dateDropdownOpen: false,
40+
dateRangeOptions: DATE_RANGE_OPTIONS,
1441
loading: false,
1542

43+
// Scroll state for gradient overlays
44+
_artistsScrollTop: 0,
45+
_artistsCanScrollMore: false,
46+
1647
overview: null,
1748
topArtists: [],
1849
genres: [],
@@ -49,6 +80,9 @@ export function createStatsView(Alpine) {
4980
this.playsOverTime = playsOverTime;
5081

5182
this.loadArtistArtwork(topArtists);
83+
84+
// Update scroll state after DOM renders
85+
this.$nextTick(() => this._updateArtistsScrollState());
5286
} catch (error) {
5387
console.error('[stats] Failed to load stats:', error);
5488
Alpine.store('ui').toast('Failed to load statistics', 'error');
@@ -72,8 +106,28 @@ export function createStatsView(Alpine) {
72106
}
73107
},
74108

75-
async onDateRangeChange() {
76-
await this.loadStats();
109+
_onArtistsScroll(event) {
110+
const el = event.target;
111+
this._artistsScrollTop = el.scrollTop;
112+
this._artistsCanScrollMore = el.scrollTop + el.clientHeight < el.scrollHeight - 10;
113+
},
114+
115+
_updateArtistsScrollState() {
116+
const panel = this.$refs.artistsPanel;
117+
if (!panel) return;
118+
this._artistsScrollTop = panel.scrollTop;
119+
this._artistsCanScrollMore = panel.scrollHeight > panel.clientHeight + 10;
120+
},
121+
122+
dateRangeLabel() {
123+
const opt = DATE_RANGE_OPTIONS.find((o) => o.value === this.dateRange);
124+
return opt ? opt.label : this.dateRange;
125+
},
126+
127+
selectDateRange(value) {
128+
this.dateRange = value;
129+
this.dateDropdownOpen = false;
130+
this.loadStats();
77131
},
78132

79133
maxArtistPlays() {
@@ -96,6 +150,23 @@ export function createStatsView(Alpine) {
96150
return `width: ${Math.max(pct, 2)}%`;
97151
},
98152

153+
/**
154+
* Format time period labels for display.
155+
* "2024" -> "2024", "2024-03" -> "Mar 2024", "2024-03-15" -> "15 Mar"
156+
*/
157+
formatTimeLabel(label) {
158+
if (!label) return '';
159+
const parts = label.split('-');
160+
if (parts.length === 1) return label; // Year only
161+
if (parts.length === 2) {
162+
const monthIdx = parseInt(parts[1], 10) - 1;
163+
return `${MONTH_NAMES[monthIdx]} ${parts[0]}`;
164+
}
165+
// Full date: show "15 Mar"
166+
const monthIdx = parseInt(parts[1], 10) - 1;
167+
return `${parseInt(parts[2], 10)} ${MONTH_NAMES[monthIdx]}`;
168+
},
169+
99170
formatDuration(seconds) {
100171
if (!seconds || seconds <= 0) return '0m';
101172
const days = Math.floor(seconds / 86400);

0 commit comments

Comments
 (0)