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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user