feat: inline liker avatar stack + shared AvatarStack primitive (#1302)
Replaces the plain "N likes" text next to tracks with an overlapping
strip of the 3 most recent liker avatars (+N if more) — matching the
existing supporter-row pattern on artist pages. Both sites now render
the same `AvatarStack` presentational component; only the data flow
differs (liker avatars are maintained in our artists DB via jetstream;
supporter avatars come from atprotofans via the /artists/batch
enrichment already in place).
Backend
- new `get_top_likers(db, track_ids, limit=3)` aggregation utility
using `ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY created_at
DESC)`, filtered to `rn <= limit`. Postgres 15+ pushes the limit
into the window aggregate (Run Condition), so work short-circuits
per partition. EXPLAIN ANALYZE on production (308 likes, 20-track
page): ~1ms execution, all in shared buffer cache.
- `TrackResponse.top_likers: list[LikerPreview]` added; threaded
through every list endpoint that already batches aggregations
(for_you, tracks listing, tracks /top, tracks /me, tracks /me/broken,
albums listing, users/{handle}/likes, tracks/tags, tracks/shares,
lists/hydration, liked tracks list) plus single-track endpoints
(playback /by-uri, mutations update, mutations restore-record).
- queue and jams serializers continue to skip aggregations per their
existing comments — they pass no `top_likers`, and the field
defaults to `[]`, which the frontend renders as the plain count
(pre-existing behavior).
- `LikerPreview` lives in `utilities/aggregations.py` rather than
`schemas.py` to avoid a circular import (schemas.py imports from
aggregations.py for `CopyrightInfo`).
- tests in `test_aggregations.py`: default limit, custom limit,
ordering by most-recent-first, empty track list, and the
JOIN-on-Artist filter behavior (likers without an artist row are
omitted, matching the existing `GET /tracks/{id}/likes` semantics).
Frontend
- `AvatarStack.svelte` — new purely-presentational component. Props:
`users`, `total`, `maxVisible`, `size`, `borderColor`, `moreHref`,
`onMoreClick`, `avatarHref`, `onAvatarClick`, `ariaLabel`, `class`.
Handles 0-N users, renders +overflow tile as link OR button
depending on the surface, supports fallback initials when
`avatar_url` is null.
- `UserPreview` type added to `types.ts`; matches backend
`LikerPreview` and the atprotofans-derived `Supporter` shape.
- `Track.top_likers?: UserPreview[]` added.
- wired into `TrackItem`, `TrackCard`, and `track/[id]/+page.svelte` —
the existing wrapper keeps the hover-tooltip (desktop) and
bottom-sheet (mobile) behavior on the whole strip; clicking an
individual avatar is intentionally a no-op so the detail sheet is
the canonical "see all likers" path.
- wired into `u/[handle]/+page.svelte` supporter row, replacing the
hand-rolled `.supporter-circle` markup and CSS (~65 lines deleted).
Avatars here DO link to `/u/{handle}` per existing UX; +N links
out to the atprotofans supporter page in a new tab.
- sizing is mobile-first: 20px avatars on mobile tracks, 22px on
desktop tracks, 18px in track cards, 28px/32px on the supporter
row.
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
authored by