From dd2b9318a08e98c7078a5bf49bf804d5f7cba9a8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 26 Apr 2026 16:46:39 -0400 Subject: [PATCH] feat(web): add likes TanStack Query helpers + mutations createLikedIdsQuery is the single source for heart-button state across the app (track_ids/album_ids/artist_ids Sets). likeEntity / unlikeEntity do optimistic setQueryData updates with rollback on error. Per-entity infinite queries back the /library/liked sections. Co-Authored-By: Claude Opus 4.7 --- web/src/lib/api/likes.test.ts | 72 +++++++++++++++++++++ web/src/lib/api/likes.ts | 116 ++++++++++++++++++++++++++++++++++ web/src/lib/api/queries.ts | 4 ++ web/src/lib/api/types.ts | 6 ++ 4 files changed, 198 insertions(+) create mode 100644 web/src/lib/api/likes.test.ts create mode 100644 web/src/lib/api/likes.ts diff --git a/web/src/lib/api/likes.test.ts b/web/src/lib/api/likes.test.ts new file mode 100644 index 00000000..f5dc3b46 --- /dev/null +++ b/web/src/lib/api/likes.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; +import { QueryClient } from '@tanstack/svelte-query'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn().mockResolvedValue(null), + del: vi.fn().mockResolvedValue(null) + } +})); + +import { likeEntity, unlikeEntity } from './likes'; +import { qk } from './queries'; +import { api } from './client'; + +let client: QueryClient; + +beforeEach(() => { + vi.clearAllMocks(); + client = new QueryClient(); + client.setQueryData(qk.likedIds(), { + track_ids: ['t1'], + album_ids: [], + artist_ids: [] + }); +}); + +afterEach(() => client.clear()); + +describe('likes mutations', () => { + test('likeEntity track adds id to track_ids in cache (optimistic)', async () => { + await likeEntity(client, 'track', 't2'); + const cached = client.getQueryData(qk.likedIds()) as + | { track_ids: string[] } | undefined; + expect(cached?.track_ids).toContain('t2'); + expect(api.post).toHaveBeenCalledWith('/api/likes/tracks/t2', undefined); + }); + + test('likeEntity album hits albums endpoint and adds to album_ids', async () => { + await likeEntity(client, 'album', 'al1'); + const cached = client.getQueryData(qk.likedIds()) as + | { album_ids: string[] } | undefined; + expect(cached?.album_ids).toContain('al1'); + expect(api.post).toHaveBeenCalledWith('/api/likes/albums/al1', undefined); + }); + + test('likeEntity artist adds to artist_ids', async () => { + await likeEntity(client, 'artist', 'ar1'); + const cached = client.getQueryData(qk.likedIds()) as + | { artist_ids: string[] } | undefined; + expect(cached?.artist_ids).toContain('ar1'); + expect(api.post).toHaveBeenCalledWith('/api/likes/artists/ar1', undefined); + }); + + test('unlikeEntity removes id from cache', async () => { + await unlikeEntity(client, 'track', 't1'); + const cached = client.getQueryData(qk.likedIds()) as + | { track_ids: string[] } | undefined; + expect(cached?.track_ids).not.toContain('t1'); + expect(api.del).toHaveBeenCalledWith('/api/likes/tracks/t1'); + }); + + test('likeEntity rolls back on error', async () => { + (api.post as ReturnType).mockRejectedValueOnce(new Error('boom')); + try { + await likeEntity(client, 'track', 't2'); + } catch { /* expected */ } + const cached = client.getQueryData(qk.likedIds()) as + | { track_ids: string[] } | undefined; + expect(cached?.track_ids).not.toContain('t2'); + }); +}); diff --git a/web/src/lib/api/likes.ts b/web/src/lib/api/likes.ts new file mode 100644 index 00000000..c6cf592f --- /dev/null +++ b/web/src/lib/api/likes.ts @@ -0,0 +1,116 @@ +import type { QueryClient } from '@tanstack/svelte-query'; +import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk, ARTIST_PAGE_SIZE } from './queries'; +import type { + ArtistRef, + AlbumRef, + TrackRef, + Page, + LikedIdsResponse +} from './types'; + +export type EntityKind = 'track' | 'album' | 'artist'; + +const PATH_BY_KIND: Record = { + track: 'tracks', + album: 'albums', + artist: 'artists' +}; + +const SET_KEY_BY_KIND: Record = { + track: 'track_ids', + album: 'album_ids', + artist: 'artist_ids' +}; + +export function createLikedIdsQuery() { + return createQuery({ + queryKey: qk.likedIds(), + queryFn: () => api.get('/api/likes/ids'), + staleTime: 60_000 + }); +} + +export function createLikedTracksInfiniteQuery() { + return createInfiniteQuery({ + queryKey: qk.likedTracks(), + queryFn: ({ pageParam = 0 }) => + api.get>(`/api/likes/tracks?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`), + initialPageParam: 0, + getNextPageParam: (last) => { + const loaded = last.offset + last.items.length; + return loaded >= last.total ? undefined : loaded; + } + }); +} + +export function createLikedAlbumsInfiniteQuery() { + return createInfiniteQuery({ + queryKey: qk.likedAlbums(), + queryFn: ({ pageParam = 0 }) => + api.get>(`/api/likes/albums?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`), + initialPageParam: 0, + getNextPageParam: (last) => { + const loaded = last.offset + last.items.length; + return loaded >= last.total ? undefined : loaded; + } + }); +} + +export function createLikedArtistsInfiniteQuery() { + return createInfiniteQuery({ + queryKey: qk.likedArtists(), + queryFn: ({ pageParam = 0 }) => + api.get>(`/api/likes/artists?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`), + initialPageParam: 0, + getNextPageParam: (last) => { + const loaded = last.offset + last.items.length; + return loaded >= last.total ? undefined : loaded; + } + }); +} + +// likeEntity does an optimistic insert into the likedIds cache, fires the +// network call, and rolls back on error. Same shape for unlikeEntity. +export async function likeEntity( + client: QueryClient, + kind: EntityKind, + id: string +): Promise { + const setKey = SET_KEY_BY_KIND[kind]; + const previous = client.getQueryData(qk.likedIds()); + if (previous) { + client.setQueryData(qk.likedIds(), { + ...previous, + [setKey]: previous[setKey].includes(id) ? previous[setKey] : [...previous[setKey], id] + }); + } + try { + await api.post(`/api/likes/${PATH_BY_KIND[kind]}/${id}`, undefined); + } catch (err) { + if (previous) client.setQueryData(qk.likedIds(), previous); + throw err; + } +} + +export async function unlikeEntity( + client: QueryClient, + kind: EntityKind, + id: string +): Promise { + const setKey = SET_KEY_BY_KIND[kind]; + const previous = client.getQueryData(qk.likedIds()); + if (previous) { + client.setQueryData(qk.likedIds(), { + ...previous, + [setKey]: previous[setKey].filter((x) => x !== id) + }); + } + try { + await api.del(`/api/likes/${PATH_BY_KIND[kind]}/${id}`); + } catch (err) { + if (previous) client.setQueryData(qk.likedIds(), previous); + throw err; + } +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 8fb531a8..11c8b2a4 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -15,6 +15,10 @@ export const qk = { searchArtists: (q: string) => ['searchArtists', { q }] as const, searchAlbums: (q: string) => ['searchAlbums', { q }] as const, searchTracks: (q: string) => ['searchTracks', { q }] as const, + likedIds: () => ['likedIds'] as const, + likedTracks: () => ['likedTracks'] as const, + likedAlbums: () => ['likedAlbums'] as const, + likedArtists: () => ['likedArtists'] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 4725753b..2d666faa 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -57,6 +57,12 @@ export type RadioResponse = { tracks: TrackRef[]; }; +export type LikedIdsResponse = { + track_ids: string[]; + album_ids: string[]; + artist_ids: string[]; +}; + export type EventRequest = | { type: 'play_started'; track_id: string; client_id?: string } | { type: 'play_ended'; play_event_id: string; duration_played_ms: number }