From 6d16aa2de4ef26af3f7d76b64afceade1d07ea3c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 07:33:07 -0400 Subject: [PATCH] refactor(web/api): offsetGetNextPageParam helper; paging sites migrated (W4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final task of PR2 DRY pass. Extracts the verbatim `offset + items.length >= total` math repeated across 8 createInfiniteQuery sites into a single Page-shape helper. The task spec described a bare-array helper signature (lastPage: T[], pageSize-based stop), but no call site in this repo uses that shape — every paged endpoint returns a Page envelope. The helper is named pageGetNextPageParam and matches the actual canonical shape so the 8 copies could collapse. Migrated: - likes.ts: 3 sites (TrackRef, AlbumRef, ArtistRef) - albums.ts: 1 site (AlbumRef) - queries.ts: 4 sites (artists + 3 search facets) history.ts left alone — uses has_more/total, different shape. --- web/src/lib/api/albums.ts | 6 ++--- web/src/lib/api/likes.ts | 16 +++--------- web/src/lib/api/paging.test.ts | 45 ++++++++++++++++++++++++++++++++++ web/src/lib/api/paging.ts | 21 ++++++++++++++++ web/src/lib/api/queries.ts | 31 +++++++++++------------ 5 files changed, 86 insertions(+), 33 deletions(-) create mode 100644 web/src/lib/api/paging.test.ts create mode 100644 web/src/lib/api/paging.ts diff --git a/web/src/lib/api/albums.ts b/web/src/lib/api/albums.ts index 60fd6f5e..3ef6e576 100644 --- a/web/src/lib/api/albums.ts +++ b/web/src/lib/api/albums.ts @@ -1,6 +1,7 @@ import { createInfiniteQuery } from '@tanstack/svelte-query'; import { api } from './client'; import { qk } from './queries'; +import { pageGetNextPageParam } from './paging'; import type { AlbumRef, AlbumDetail, Page, TrackRef } from './types'; export const ALBUM_PAGE_SIZE = 50; @@ -14,10 +15,7 @@ export function createAlbumsAlphaInfiniteQuery() { queryKey: qk.albumsAlpha(), queryFn: ({ pageParam = 0 }) => listAlbumsAlpha(ALBUM_PAGE_SIZE, pageParam), initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - } + getNextPageParam: pageGetNextPageParam() }); } diff --git a/web/src/lib/api/likes.ts b/web/src/lib/api/likes.ts index c6cf592f..85da6b0c 100644 --- a/web/src/lib/api/likes.ts +++ b/web/src/lib/api/likes.ts @@ -2,6 +2,7 @@ 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 { pageGetNextPageParam } from './paging'; import type { ArtistRef, AlbumRef, @@ -38,10 +39,7 @@ export function createLikedTracksInfiniteQuery() { 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; - } + getNextPageParam: pageGetNextPageParam() }); } @@ -51,10 +49,7 @@ export function createLikedAlbumsInfiniteQuery() { 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; - } + getNextPageParam: pageGetNextPageParam() }); } @@ -64,10 +59,7 @@ export function createLikedArtistsInfiniteQuery() { 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; - } + getNextPageParam: pageGetNextPageParam() }); } diff --git a/web/src/lib/api/paging.test.ts b/web/src/lib/api/paging.test.ts new file mode 100644 index 00000000..a8afe9d1 --- /dev/null +++ b/web/src/lib/api/paging.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { pageGetNextPageParam } from './paging'; +import type { Page } from './types'; + +function mkPage(items: T[], offset: number, total: number, limit = 50): Page { + return { items, offset, total, limit }; +} + +describe('pageGetNextPageParam', () => { + it('returns next offset (offset + items.length) when more items remain', () => { + const next = pageGetNextPageParam(); + const last = mkPage([1, 2, 3, 4, 5], 0, 12); + expect(next(last)).toBe(5); + }); + + it('returns next offset accounting for prior offset', () => { + const next = pageGetNextPageParam(); + const last = mkPage([1, 2, 3], 50, 100); + expect(next(last)).toBe(53); + }); + + it('returns undefined when loaded reaches total', () => { + const next = pageGetNextPageParam(); + const last = mkPage([1, 2, 3], 7, 10); + expect(next(last)).toBeUndefined(); + }); + + it('returns undefined when last page is empty and offset already equals total', () => { + const next = pageGetNextPageParam(); + const last = mkPage([], 10, 10); + expect(next(last)).toBeUndefined(); + }); + + it('returns undefined when loaded exceeds total (defensive)', () => { + const next = pageGetNextPageParam(); + const last = mkPage([1, 2, 3], 9, 10); + expect(next(last)).toBeUndefined(); + }); + + it('works for different element types via generic parameter', () => { + const next = pageGetNextPageParam(); + const last = mkPage(['a', 'b'], 0, 5); + expect(next(last)).toBe(2); + }); +}); diff --git a/web/src/lib/api/paging.ts b/web/src/lib/api/paging.ts new file mode 100644 index 00000000..c517bd3a --- /dev/null +++ b/web/src/lib/api/paging.ts @@ -0,0 +1,21 @@ +import type { Page } from './types'; + +/** + * getNextPageParam factory for offset/total infinite queries that fetch + * Page envelopes (items + total + limit + offset) and stop when the + * loaded count reaches the server-reported total. + * + * Use with TanStack svelte-query's createInfiniteQuery: + * getNextPageParam: pageGetNextPageParam() + * + * NOTE: the spec for this task described a bare-array shape + * (lastPage: T[], pageSize-based stop). No call site in this repo uses + * that shape — every paged endpoint returns Page. The helper matches + * the actual canonical shape so the 8 verbatim copies can collapse. + */ +export function pageGetNextPageParam() { + return (last: Page): number | undefined => { + const loaded = last.offset + last.items.length; + return loaded >= last.total ? undefined : loaded; + }; +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index d020f03b..418ffaa6 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -1,6 +1,15 @@ import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'; import { api } from './client'; -import type { ArtistRef, ArtistDetail, AlbumDetail, Page, SearchResponse } from './types'; +import { pageGetNextPageParam } from './paging'; +import type { + ArtistRef, + ArtistDetail, + AlbumRef, + AlbumDetail, + Page, + SearchResponse, + TrackRef +} from './types'; export type ArtistSort = 'alpha' | 'newest'; export const ARTIST_PAGE_SIZE = 50; @@ -60,10 +69,7 @@ export function createArtistsQuery(sort: ArtistSort) { `/api/artists?sort=${sort}&limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}` ), initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - }, + getNextPageParam: pageGetNextPageParam(), }); } @@ -100,10 +106,7 @@ export function createSearchArtistsInfiniteQuery(q: string) { `/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}` ).then((r) => r.artists), initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - }, + getNextPageParam: pageGetNextPageParam(), enabled: q.length > 0 }); } @@ -116,10 +119,7 @@ export function createSearchAlbumsInfiniteQuery(q: string) { `/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}` ).then((r) => r.albums), initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - }, + getNextPageParam: pageGetNextPageParam(), enabled: q.length > 0 }); } @@ -132,10 +132,7 @@ export function createSearchTracksInfiniteQuery(q: string) { `/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}` ).then((r) => r.tracks), initialPageParam: 0, - getNextPageParam: (last) => { - const loaded = last.offset + last.items.length; - return loaded >= last.total ? undefined : loaded; - }, + getNextPageParam: pageGetNextPageParam(), enabled: q.length > 0 }); }