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 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 16:46:39 -04:00
parent c7f4adbcc3
commit dd2b9318a0
4 changed files with 198 additions and 0 deletions
+72
View File
@@ -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<typeof vi.fn>).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');
});
});
+116
View File
@@ -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<EntityKind, 'tracks' | 'albums' | 'artists'> = {
track: 'tracks',
album: 'albums',
artist: 'artists'
};
const SET_KEY_BY_KIND: Record<EntityKind, keyof LikedIdsResponse> = {
track: 'track_ids',
album: 'album_ids',
artist: 'artist_ids'
};
export function createLikedIdsQuery() {
return createQuery({
queryKey: qk.likedIds(),
queryFn: () => api.get<LikedIdsResponse>('/api/likes/ids'),
staleTime: 60_000
});
}
export function createLikedTracksInfiniteQuery() {
return createInfiniteQuery({
queryKey: qk.likedTracks(),
queryFn: ({ pageParam = 0 }) =>
api.get<Page<TrackRef>>(`/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<Page<AlbumRef>>(`/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<Page<ArtistRef>>(`/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<void> {
const setKey = SET_KEY_BY_KIND[kind];
const previous = client.getQueryData<LikedIdsResponse>(qk.likedIds());
if (previous) {
client.setQueryData<LikedIdsResponse>(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<void> {
const setKey = SET_KEY_BY_KIND[kind];
const previous = client.getQueryData<LikedIdsResponse>(qk.likedIds());
if (previous) {
client.setQueryData<LikedIdsResponse>(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;
}
}
+4
View File
@@ -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) {
+6
View File
@@ -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 }