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,
|
searchArtists: (q: string) => ['searchArtists', { q }] as const,
|
||||||
searchAlbums: (q: string) => ['searchAlbums', { q }] as const,
|
searchAlbums: (q: string) => ['searchAlbums', { q }] as const,
|
||||||
searchTracks: (q: string) => ['searchTracks', { 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) {
|
export function createArtistsQuery(sort: ArtistSort) {
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export type RadioResponse = {
|
|||||||
tracks: TrackRef[];
|
tracks: TrackRef[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LikedIdsResponse = {
|
||||||
|
track_ids: string[];
|
||||||
|
album_ids: string[];
|
||||||
|
artist_ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type EventRequest =
|
export type EventRequest =
|
||||||
| { type: 'play_started'; track_id: string; client_id?: string }
|
| { type: 'play_started'; track_id: string; client_id?: string }
|
||||||
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number }
|
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number }
|
||||||
|
|||||||
Reference in New Issue
Block a user