audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: top tracks time-range toggle + artist leaderboard rank (#1228)

* feat: top tracks time-range toggle + artist leaderboard rank

add a cycling period filter to the homepage top tracks section (all time
→ past month → past week → past day) with localStorage persistence, and
show a rank badge (#1–#10) on artist analytics pages with gold/silver/
bronze styling for top 3. leaderboard is cached in Redis (5 min TTL).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: clear leaderboard cache between tests

the leaderboard tests were hitting stale Redis cache from other
analytics tests that ran first. add a fixture to clear the cache
key before and after each test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
6f5a5799 c31c308b

+557 -13
+46
backend/src/backend/api/artists.py
··· 1 1 """artist profile API endpoints.""" 2 2 3 + import json 3 4 import logging 4 5 from datetime import UTC, datetime 5 6 from typing import Annotated 6 7 7 8 from fastapi import APIRouter, Depends, HTTPException 8 9 from pydantic import BaseModel, ConfigDict, field_validator 10 + from redis.exceptions import RedisError 9 11 from sqlalchemy import func, select, text 10 12 from sqlalchemy.ext.asyncio import AsyncSession 11 13 ··· 16 18 upsert_profile_record, 17 19 ) 18 20 from backend.models import Artist, Track, TrackLike, UserPreferences, get_db 21 + from backend.utilities.aggregations import get_top_artists_by_plays 22 + from backend.utilities.redis import get_async_redis_client 19 23 20 24 logger = logging.getLogger(__name__) 21 25 ··· 77 81 total_duration_seconds: int 78 82 top_item: TopItemResponse | None 79 83 top_liked: TopItemResponse | None 84 + rank: int | None = None 80 85 81 86 82 87 # endpoints ··· 280 285 return response 281 286 282 287 288 + LEADERBOARD_CACHE_KEY = "artist_leaderboard:top10" 289 + LEADERBOARD_CACHE_TTL = 300 # 5 minutes 290 + 291 + 292 + async def _get_leaderboard(db: AsyncSession) -> list[tuple[str, int]]: 293 + """get top-10 artist leaderboard, cached in Redis for 5 minutes.""" 294 + try: 295 + redis = get_async_redis_client() 296 + if cached := await redis.get(LEADERBOARD_CACHE_KEY): 297 + return [tuple(row) for row in json.loads(cached)] 298 + except (RuntimeError, RedisError): 299 + pass 300 + 301 + leaderboard = await get_top_artists_by_plays(db, limit=10) 302 + 303 + try: 304 + redis = get_async_redis_client() 305 + await redis.set( 306 + LEADERBOARD_CACHE_KEY, 307 + json.dumps(leaderboard), 308 + ex=LEADERBOARD_CACHE_TTL, 309 + ) 310 + except (RuntimeError, RedisError): 311 + logger.debug("failed to cache artist leaderboard") 312 + 313 + return leaderboard 314 + 315 + 316 + async def _get_artist_rank(db: AsyncSession, artist_did: str) -> int | None: 317 + """return 1-indexed rank if artist is in the top 10, else None.""" 318 + leaderboard = await _get_leaderboard(db) 319 + for i, (did, _plays) in enumerate(leaderboard): 320 + if did == artist_did: 321 + return i + 1 322 + return None 323 + 324 + 283 325 @router.get("/{artist_did}/analytics") 284 326 async def get_artist_analytics( 285 327 artist_did: str, ··· 338 380 play_count=top_liked_row[2], # reuse play_count field for like count 339 381 ) 340 382 383 + # check artist's rank in the top-10 leaderboard (Redis-cached, 5 min TTL) 384 + rank = await _get_artist_rank(db, artist_did) 385 + 341 386 return AnalyticsResponse( 342 387 total_plays=total_plays, 343 388 total_items=total_items, 344 389 total_duration_seconds=total_duration, 345 390 top_item=top_item, 346 391 top_liked=top_liked, 392 + rank=rank, 347 393 ) 348 394 349 395
+16 -3
backend/src/backend/api/tracks/listing.py
··· 2 2 3 3 import asyncio 4 4 import logging 5 - from datetime import datetime 6 - from typing import TYPE_CHECKING, Annotated, cast 5 + from datetime import UTC, datetime, timedelta 6 + from typing import TYPE_CHECKING, Annotated, Literal, cast 7 7 8 8 import logfire 9 9 from botocore.exceptions import ClientError ··· 328 328 return response 329 329 330 330 331 + PERIOD_DELTAS: dict[str, timedelta | None] = { 332 + "all_time": None, 333 + "month": timedelta(days=30), 334 + "week": timedelta(days=7), 335 + "day": timedelta(days=1), 336 + } 337 + 338 + 331 339 @router.get("/top") 332 340 async def list_top_tracks( 333 341 db: Annotated[AsyncSession, Depends(get_db)], 334 342 limit: int = 10, 343 + period: Literal["all_time", "month", "week", "day"] = "all_time", 335 344 session: AuthSession | None = Depends(get_optional_session), 336 345 ) -> list[TrackResponse]: 337 346 """get top tracks by like count (most liked first, at least one like).""" 338 347 limit = max(1, min(limit, 50)) 339 348 349 + since: datetime | None = None 350 + if (delta := PERIOD_DELTAS.get(period)) is not None: 351 + since = datetime.now(UTC) - delta 352 + 340 353 # get top track IDs with like counts in a single query 341 - top_tracks_with_counts = await get_top_tracks_with_counts(db, limit) 354 + top_tracks_with_counts = await get_top_tracks_with_counts(db, limit, since=since) 342 355 if not top_tracks_with_counts: 343 356 return [] 344 357
+31 -4
backend/src/backend/utilities/aggregations.py
··· 3 3 import logging 4 4 from collections import Counter 5 5 from dataclasses import dataclass 6 + from datetime import datetime 6 7 from typing import Any 7 8 8 9 from sqlalchemy import select 9 10 from sqlalchemy.ext.asyncio import AsyncSession 10 11 from sqlalchemy.sql import func 11 12 12 - from backend.models import CopyrightScan, Tag, TrackComment, TrackLike, TrackTag 13 + from backend.models import CopyrightScan, Tag, Track, TrackComment, TrackLike, TrackTag 13 14 14 15 logger = logging.getLogger(__name__) 15 16 ··· 155 156 156 157 157 158 async def get_top_tracks_with_counts( 158 - db: AsyncSession, limit: int = 10 159 + db: AsyncSession, limit: int = 10, since: datetime | None = None 159 160 ) -> list[tuple[int, int]]: 160 161 """get top track IDs with their like counts in a single query. 161 162 ··· 165 166 args: 166 167 db: database session 167 168 limit: max number of tracks to return 169 + since: only count likes created at or after this time (None = all time) 168 170 169 171 returns: 170 172 list of (track_id, like_count) tuples ordered by like count descending 171 173 """ 174 + stmt = select(TrackLike.track_id, func.count(TrackLike.id).label("like_count")) 175 + if since is not None: 176 + stmt = stmt.where(TrackLike.created_at >= since) 172 177 stmt = ( 173 - select(TrackLike.track_id, func.count(TrackLike.id).label("like_count")) 174 - .group_by(TrackLike.track_id) 178 + stmt.group_by(TrackLike.track_id) 175 179 .order_by(func.count(TrackLike.id).desc()) 180 + .limit(limit) 181 + ) 182 + result = await db.execute(stmt) 183 + return [(row[0], row[1]) for row in result.all()] 184 + 185 + 186 + async def get_top_artists_by_plays( 187 + db: AsyncSession, limit: int = 10 188 + ) -> list[tuple[str, int]]: 189 + """get top artists ordered by total play count. 190 + 191 + args: 192 + db: database session 193 + limit: max number of artists to return 194 + 195 + returns: 196 + list of (artist_did, total_plays) tuples ordered by plays descending 197 + """ 198 + stmt = ( 199 + select(Track.artist_did, func.sum(Track.play_count).label("total_plays")) 200 + .where(Track.play_count > 0) 201 + .group_by(Track.artist_did) 202 + .order_by(func.sum(Track.play_count).desc()) 176 203 .limit(limit) 177 204 ) 178 205 result = await db.execute(stmt)
+212
backend/tests/api/test_artist_leaderboard.py
··· 1 + """tests for artist leaderboard rank in analytics endpoint.""" 2 + 3 + from collections.abc import Generator 4 + 5 + import pytest 6 + from fastapi import FastAPI 7 + from httpx import ASGITransport, AsyncClient 8 + from sqlalchemy.ext.asyncio import AsyncSession 9 + 10 + from backend._internal import Session, require_auth 11 + from backend.api.artists import LEADERBOARD_CACHE_KEY 12 + from backend.main import app 13 + from backend.models import Artist, Track, get_db 14 + from backend.utilities.redis import get_async_redis_client 15 + 16 + 17 + class MockSession(Session): 18 + """mock session for auth bypass in tests.""" 19 + 20 + def __init__(self, did: str = "did:test:user123"): 21 + self.did = did 22 + self.handle = "testuser.bsky.social" 23 + self.session_id = "test_session_id" 24 + self.access_token = "test_token" 25 + self.refresh_token = "test_refresh" 26 + self.oauth_session = { 27 + "did": did, 28 + "handle": "testuser.bsky.social", 29 + "pds_url": "https://test.pds", 30 + "authserver_iss": "https://auth.test", 31 + "scope": "atproto transition:generic", 32 + "access_token": "test_token", 33 + "refresh_token": "test_refresh", 34 + "dpop_private_key_pem": "fake_key", 35 + "dpop_authserver_nonce": "", 36 + "dpop_pds_nonce": "", 37 + } 38 + 39 + 40 + @pytest.fixture 41 + async def _clear_leaderboard_cache(): 42 + """clear leaderboard cache to prevent cross-test pollution.""" 43 + try: 44 + redis = get_async_redis_client() 45 + await redis.delete(LEADERBOARD_CACHE_KEY) 46 + except Exception: 47 + pass 48 + yield 49 + try: 50 + redis = get_async_redis_client() 51 + await redis.delete(LEADERBOARD_CACHE_KEY) 52 + except Exception: 53 + pass 54 + 55 + 56 + @pytest.fixture 57 + def test_app( 58 + db_session: AsyncSession, _clear_leaderboard_cache: None 59 + ) -> Generator[FastAPI, None, None]: 60 + """test app with overridden DB session and cleared leaderboard cache.""" 61 + 62 + async def mock_require_auth() -> Session: 63 + return MockSession() 64 + 65 + async def mock_get_db(): 66 + yield db_session 67 + 68 + app.dependency_overrides[require_auth] = mock_require_auth 69 + app.dependency_overrides[get_db] = mock_get_db 70 + 71 + yield app 72 + 73 + app.dependency_overrides.clear() 74 + 75 + 76 + async def test_rank_appears_for_top_artist(test_app: FastAPI, db_session: AsyncSession): 77 + """artist with the most plays gets rank=1 in analytics.""" 78 + top_artist = Artist( 79 + did="did:plc:leader1", 80 + handle="leader.bsky.social", 81 + display_name="Top Leader", 82 + pds_url="https://test.pds", 83 + ) 84 + other_artist = Artist( 85 + did="did:plc:leader2", 86 + handle="other.bsky.social", 87 + display_name="Other Artist", 88 + pds_url="https://test.pds", 89 + ) 90 + db_session.add_all([top_artist, other_artist]) 91 + await db_session.flush() 92 + 93 + db_session.add( 94 + Track( 95 + title="Hit Song", 96 + artist_did=top_artist.did, 97 + file_id="hit1", 98 + file_type="mp3", 99 + play_count=200, 100 + ) 101 + ) 102 + db_session.add( 103 + Track( 104 + title="Decent Song", 105 + artist_did=other_artist.did, 106 + file_id="decent1", 107 + file_type="mp3", 108 + play_count=50, 109 + ) 110 + ) 111 + await db_session.commit() 112 + 113 + async with AsyncClient( 114 + transport=ASGITransport(app=test_app), 115 + base_url="http://test", 116 + ) as client: 117 + response = await client.get(f"/artists/{top_artist.did}/analytics") 118 + 119 + assert response.status_code == 200 120 + assert response.json()["rank"] == 1 121 + 122 + 123 + async def test_rank_ordering_is_correct(test_app: FastAPI, db_session: AsyncSession): 124 + """artists are ranked by total plays descending.""" 125 + artists_data = [ 126 + ("did:plc:r1", "first.bsky.social", "First", 300), 127 + ("did:plc:r2", "second.bsky.social", "Second", 200), 128 + ("did:plc:r3", "third.bsky.social", "Third", 100), 129 + ] 130 + 131 + for did, handle, name, _plays in artists_data: 132 + db_session.add( 133 + Artist( 134 + did=did, 135 + handle=handle, 136 + display_name=name, 137 + pds_url="https://test.pds", 138 + ) 139 + ) 140 + 141 + await db_session.flush() 142 + 143 + for did, _handle, _name, plays in artists_data: 144 + db_session.add( 145 + Track( 146 + title=f"Track by {did}", 147 + artist_did=did, 148 + file_id=f"file_{did}", 149 + file_type="mp3", 150 + play_count=plays, 151 + ) 152 + ) 153 + 154 + await db_session.commit() 155 + 156 + async with AsyncClient( 157 + transport=ASGITransport(app=test_app), 158 + base_url="http://test", 159 + ) as client: 160 + resp = await client.get("/artists/did:plc:r1/analytics") 161 + assert resp.json()["rank"] == 1 162 + 163 + resp = await client.get("/artists/did:plc:r2/analytics") 164 + assert resp.json()["rank"] == 2 165 + 166 + resp = await client.get("/artists/did:plc:r3/analytics") 167 + assert resp.json()["rank"] == 3 168 + 169 + 170 + async def test_rank_is_null_outside_top_10(test_app: FastAPI, db_session: AsyncSession): 171 + """artists outside the top 10 get rank=null.""" 172 + artists = [] 173 + for i in range(11): 174 + artist = Artist( 175 + did=f"did:plc:rank{i}", 176 + handle=f"rank{i}.bsky.social", 177 + display_name=f"Rank {i}", 178 + pds_url="https://test.pds", 179 + ) 180 + db_session.add(artist) 181 + artists.append(artist) 182 + 183 + await db_session.flush() 184 + 185 + for i, artist in enumerate(artists): 186 + db_session.add( 187 + Track( 188 + title=f"Track {i}", 189 + artist_did=artist.did, 190 + file_id=f"rank_file_{i}", 191 + file_type="mp3", 192 + play_count=(11 - i) * 100, 193 + ) 194 + ) 195 + 196 + await db_session.commit() 197 + 198 + async with AsyncClient( 199 + transport=ASGITransport(app=test_app), 200 + base_url="http://test", 201 + ) as client: 202 + # artist 0 (highest plays) should be rank 1 203 + resp = await client.get(f"/artists/{artists[0].did}/analytics") 204 + assert resp.json()["rank"] == 1 205 + 206 + # artist 9 (10th highest) should be rank 10 207 + resp = await client.get(f"/artists/{artists[9].did}/analytics") 208 + assert resp.json()["rank"] == 10 209 + 210 + # artist 10 (11th) should be null 211 + resp = await client.get(f"/artists/{artists[10].did}/analytics") 212 + assert resp.json()["rank"] is None
+146
backend/tests/api/test_top_tracks.py
··· 1 1 """tests for GET /tracks/top endpoint.""" 2 2 3 3 from collections.abc import Generator 4 + from datetime import UTC, datetime, timedelta 4 5 5 6 import pytest 6 7 from fastapi import FastAPI ··· 355 356 356 357 assert "electronic" in tags_by_title["Top Track 0"] 357 358 assert tags_by_title["Top Track 1"] == [] 359 + 360 + 361 + # --- period filtering tests --- 362 + 363 + 364 + @pytest.fixture 365 + async def tracks_with_timed_likes( 366 + db_session: AsyncSession, artist: Artist 367 + ) -> list[Track]: 368 + """create tracks with likes at different times. 369 + 370 + track A: 2 likes (1 recent, 1 old) 371 + track B: 1 like (recent) 372 + track C: 2 likes (both old) 373 + """ 374 + tracks = [] 375 + for label in ("A", "B", "C"): 376 + track = Track( 377 + title=f"Timed Track {label}", 378 + artist_did=artist.did, 379 + file_id=f"timed_{label}", 380 + file_type="mp3", 381 + extra={"duration": 120}, 382 + atproto_record_uri=f"at://did:plc:topartist/fm.plyr.track/timed{label}", 383 + atproto_record_cid=f"bafytimed{label}", 384 + ) 385 + db_session.add(track) 386 + tracks.append(track) 387 + 388 + await db_session.flush() 389 + 390 + now = datetime.now(UTC) 391 + old = now - timedelta(days=60) 392 + recent = now - timedelta(hours=6) 393 + 394 + # track A: 1 recent + 1 old 395 + db_session.add( 396 + TrackLike( 397 + track_id=tracks[0].id, 398 + user_did="did:test:t1", 399 + atproto_like_uri="at://did:test:t1/fm.plyr.like/tA1", 400 + created_at=recent, 401 + ) 402 + ) 403 + db_session.add( 404 + TrackLike( 405 + track_id=tracks[0].id, 406 + user_did="did:test:t2", 407 + atproto_like_uri="at://did:test:t2/fm.plyr.like/tA2", 408 + created_at=old, 409 + ) 410 + ) 411 + 412 + # track B: 1 recent 413 + db_session.add( 414 + TrackLike( 415 + track_id=tracks[1].id, 416 + user_did="did:test:t3", 417 + atproto_like_uri="at://did:test:t3/fm.plyr.like/tB1", 418 + created_at=recent, 419 + ) 420 + ) 421 + 422 + # track C: 2 old 423 + for i in range(2): 424 + db_session.add( 425 + TrackLike( 426 + track_id=tracks[2].id, 427 + user_did=f"did:test:t{i + 10}", 428 + atproto_like_uri=f"at://did:test:t{i + 10}/fm.plyr.like/tC{i}", 429 + created_at=old, 430 + ) 431 + ) 432 + 433 + await db_session.commit() 434 + for track in tracks: 435 + await db_session.refresh(track) 436 + 437 + return tracks 438 + 439 + 440 + async def test_top_tracks_period_default_is_all_time( 441 + unauthenticated_app: FastAPI, 442 + tracks_with_timed_likes: list[Track], 443 + ): 444 + """default period returns all likes regardless of time.""" 445 + async with AsyncClient( 446 + transport=ASGITransport(app=unauthenticated_app), 447 + base_url="http://test", 448 + ) as client: 449 + response = await client.get("/tracks/top") 450 + 451 + assert response.status_code == 200 452 + tracks = response.json() 453 + titles = [t["title"] for t in tracks] 454 + 455 + # all three tracks have at least one like 456 + assert len(titles) == 3 457 + # track A (2 likes) and track C (2 likes) tied, then track B (1 like) 458 + assert "Timed Track A" in titles 459 + assert "Timed Track B" in titles 460 + assert "Timed Track C" in titles 461 + 462 + 463 + async def test_top_tracks_period_month( 464 + unauthenticated_app: FastAPI, 465 + tracks_with_timed_likes: list[Track], 466 + ): 467 + """period=month only counts likes from the past 30 days.""" 468 + async with AsyncClient( 469 + transport=ASGITransport(app=unauthenticated_app), 470 + base_url="http://test", 471 + ) as client: 472 + response = await client.get("/tracks/top?period=month") 473 + 474 + assert response.status_code == 200 475 + tracks = response.json() 476 + titles = [t["title"] for t in tracks] 477 + 478 + # only recent likes count: track A (1 recent), track B (1 recent) 479 + # track C has only old likes → excluded 480 + assert "Timed Track A" in titles 481 + assert "Timed Track B" in titles 482 + assert "Timed Track C" not in titles 483 + 484 + 485 + async def test_top_tracks_period_day( 486 + unauthenticated_app: FastAPI, 487 + tracks_with_timed_likes: list[Track], 488 + ): 489 + """period=day only counts likes from the past 24 hours.""" 490 + async with AsyncClient( 491 + transport=ASGITransport(app=unauthenticated_app), 492 + base_url="http://test", 493 + ) as client: 494 + response = await client.get("/tracks/top?period=day") 495 + 496 + assert response.status_code == 200 497 + tracks = response.json() 498 + titles = [t["title"] for t in tracks] 499 + 500 + # recent likes were 6 hours ago — within "day" window 501 + assert "Timed Track A" in titles 502 + assert "Timed Track B" in titles 503 + assert "Timed Track C" not in titles
+8 -2
frontend/src/lib/tracks.svelte.ts
··· 228 228 } 229 229 } 230 230 231 - export async function fetchTopTracks(limit = 10): Promise<Track[]> { 231 + export async function fetchTopTracks(limit = 10, period = 'all_time'): Promise<Track[]> { 232 232 try { 233 - const response = await fetch(`${API_URL}/tracks/top?limit=${limit}`, { 233 + const url = new URL(`${API_URL}/tracks/top`); 234 + url.searchParams.set('limit', String(limit)); 235 + if (period !== 'all_time') { 236 + url.searchParams.set('period', period); 237 + } 238 + 239 + const response = await fetch(url.toString(), { 234 240 credentials: 'include' 235 241 }); 236 242
+1
frontend/src/lib/types.ts
··· 117 117 total_duration_seconds: number; 118 118 top_item: TopItem | null; 119 119 top_liked: TopItem | null; 120 + rank?: number | null; 120 121 } 121 122 122 123 export interface ArtistAlbumSummary extends AlbumSummary {}
+46 -3
frontend/src/routes/+page.svelte
··· 37 37 let loadingTopTracks = $state(true); 38 38 let hasTopTracks = $derived(topTracks.length > 0); 39 39 40 + // top tracks period toggle 41 + const PERIODS = ['all_time', 'month', 'week', 'day'] as const; 42 + const PERIOD_LABELS: Record<string, string> = { 43 + all_time: 'all time', 44 + month: 'past month', 45 + week: 'past week', 46 + day: 'past day' 47 + }; 48 + let topTracksPeriod = $state( 49 + (typeof window !== 'undefined' && localStorage.getItem('topTracksPeriod')) || 'all_time' 50 + ); 51 + let periodLabel = $derived(PERIOD_LABELS[topTracksPeriod] ?? 'all time'); 52 + 53 + function cyclePeriod() { 54 + const idx = PERIODS.indexOf(topTracksPeriod as typeof PERIODS[number]); 55 + topTracksPeriod = PERIODS[(idx + 1) % PERIODS.length]; 56 + if (typeof window !== 'undefined') { 57 + localStorage.setItem('topTracksPeriod', topTracksPeriod); 58 + } 59 + loadingTopTracks = true; 60 + fetchTopTracks(10, topTracksPeriod).then((result) => { 61 + topTracks = result; 62 + loadingTopTracks = false; 63 + }); 64 + } 65 + 40 66 // network artists (people you follow on bluesky who have music here) 41 67 let networkArtists = $derived(networkArtistsCache.artists); 42 68 let hasNetworkArtists = $derived(networkArtistsCache.hasArtists); ··· 51 77 let sentinelElement = $state<HTMLDivElement | null>(null); 52 78 53 79 onMount(async () => { 54 - const [topResult] = await Promise.all([fetchTopTracks(10), tracksCache.fetch()]); 80 + const [topResult] = await Promise.all([fetchTopTracks(10, topTracksPeriod), tracksCache.fetch()]); 55 81 topTracks = topResult; 56 82 loadingTopTracks = false; 57 83 initialLoad = false; ··· 150 176 {#if loadingTopTracks} 151 177 <section class="top-tracks" transition:fade={{ duration: 200 }}> 152 178 <h2> 153 - top tracks 179 + top tracks <button class="period-toggle" onclick={cyclePeriod}>{periodLabel}</button> 154 180 </h2> 155 181 <div class="loading-container compact"> 156 182 <WaveLoading size="sm" message="loading..." /> ··· 159 185 {:else if hasTopTracks} 160 186 <section class="top-tracks" transition:fade={{ duration: 200 }}> 161 187 <h2> 162 - top tracks 188 + top tracks <button class="period-toggle" onclick={cyclePeriod}>{periodLabel}</button> 163 189 </h2> 164 190 <div class="top-tracks-grid"> 165 191 {#each topTracks as track, i} ··· 284 310 font-weight: 700; 285 311 color: var(--text-primary); 286 312 margin: 0 0 1rem 0; 313 + } 314 + 315 + .period-toggle { 316 + background: transparent; 317 + border: none; 318 + padding: 0; 319 + font: inherit; 320 + font-size: var(--text-base); 321 + font-weight: 400; 322 + color: var(--accent); 323 + cursor: pointer; 324 + transition: opacity 0.15s; 325 + user-select: none; 326 + } 327 + 328 + .period-toggle:hover { 329 + opacity: 0.7; 287 330 } 288 331 289 332 .top-tracks-grid {
+38
frontend/src/routes/u/[handle]/+page.svelte
··· 512 512 <div class="skeleton-bar small"></div> 513 513 </div> 514 514 {:else if analytics} 515 + {#if analytics.rank} 516 + <div class="stat-card rank-card" class:rank-gold={analytics.rank === 1} class:rank-silver={analytics.rank === 2} class:rank-bronze={analytics.rank === 3} transition:fade={{ duration: 200 }}> 517 + <div class="stat-value rank-value">#{analytics.rank}</div> 518 + <div class="stat-label">top artist</div> 519 + </div> 520 + {/if} 515 521 <div class="stat-card" transition:fade={{ duration: 200 }}> 516 522 <div class="stat-value">{analytics.total_plays.toLocaleString()}</div> 517 523 <div class="stat-label">total plays</div> ··· 1030 1036 1031 1037 .stat-card:hover { 1032 1038 border-color: var(--border-emphasis); 1039 + } 1040 + 1041 + .rank-card { 1042 + border-color: var(--accent); 1043 + } 1044 + 1045 + .rank-value { 1046 + font-variant-numeric: tabular-nums; 1047 + } 1048 + 1049 + .rank-gold { 1050 + border-color: #FFD700; 1051 + } 1052 + 1053 + .rank-gold .rank-value { 1054 + color: #FFD700; 1055 + } 1056 + 1057 + .rank-silver { 1058 + border-color: #C0C0C0; 1059 + } 1060 + 1061 + .rank-silver .rank-value { 1062 + color: #C0C0C0; 1063 + } 1064 + 1065 + .rank-bronze { 1066 + border-color: #CD7F32; 1067 + } 1068 + 1069 + .rank-bronze .rank-value { 1070 + color: #CD7F32; 1033 1071 } 1034 1072 1035 1073 .stat-value {
+13 -1
loq.toml
··· 39 39 40 40 [[rules]] 41 41 path = "backend/src/backend/api/tracks/listing.py" 42 - max_lines = 563 42 + max_lines = 576 43 43 44 44 [[rules]] 45 45 path = "backend/src/backend/api/tracks/mutations.py" ··· 232 232 [[rules]] 233 233 path = "backend/tests/api/test_track_deletion.py" 234 234 max_lines = 505 235 + 236 + [[rules]] 237 + path = "backend/tests/api/test_top_tracks.py" 238 + max_lines = 503 239 + 240 + [[rules]] 241 + path = "frontend/src/routes/+page.svelte" 242 + max_lines = 501 243 + 244 + [[rules]] 245 + path = "frontend/src/routes/u/[[]handle[]]/+page.svelte" 246 + max_lines = 1468