mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 22:55:42 -04:00
fix(discover): map TV date sorts, clamp TMDB page counts, and pause infinite scroll after a failed page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
12 files changed
+120
-11
No files matched your search
@@ -0,0 +1,15 @@
|
||||
/** TMDB rejects page > 500 (and our PageParam caps at 500). */
|
||||
export const TMDB_MAX_PAGE = 500;
|
||||
|
||||
export function clampTotalPages(totalPages: number | undefined): number {
|
||||
return Math.min(totalPages ?? 1, TMDB_MAX_PAGE);
|
||||
}
|
||||
|
||||
/** TMDB's /discover/tv sorts by first_air_date where /discover/movie uses primary_release_date. */
|
||||
export function tmdbSortBy(type: "movie" | "tv", sortBy: string | undefined): string {
|
||||
const value = sortBy ?? "popularity.desc";
|
||||
if (type === "tv" && value.startsWith("primary_release_date.")) {
|
||||
return value.replace("primary_release_date.", "first_air_date.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
import { clampTotalPages, tmdbSortBy } from "./discover-utils";
|
||||
|
||||
function requireTmdb() {
|
||||
if (!isTmdbConfigured()) {
|
||||
@@ -115,7 +116,7 @@ export const trending = os.discover.trending.use(authed).handler(async ({ input,
|
||||
userStatuses,
|
||||
episodeProgress,
|
||||
page: (data as { page?: number }).page ?? input.page,
|
||||
totalPages: (data as { total_pages?: number }).total_pages ?? 1,
|
||||
totalPages: clampTotalPages((data as { total_pages?: number }).total_pages),
|
||||
totalResults: (data as { total_results?: number }).total_results ?? 0,
|
||||
};
|
||||
});
|
||||
@@ -162,7 +163,7 @@ export const popular = os.discover.popular.use(authed).handler(async ({ input, c
|
||||
userStatuses,
|
||||
episodeProgress,
|
||||
page: data.page ?? input.page,
|
||||
totalPages: data.total_pages ?? 1,
|
||||
totalPages: clampTotalPages(data.total_pages),
|
||||
totalResults: data.total_results ?? 0,
|
||||
};
|
||||
});
|
||||
@@ -214,7 +215,7 @@ export const search = os.discover.search.use(authed).handler(async ({ input }) =
|
||||
}),
|
||||
),
|
||||
page: personResults.page ?? input.page,
|
||||
totalPages: personResults.total_pages ?? 1,
|
||||
totalPages: clampTotalPages(personResults.total_pages),
|
||||
totalResults: personResults.total_results ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -319,7 +320,7 @@ export const search = os.discover.search.use(authed).handler(async ({ input }) =
|
||||
return {
|
||||
results,
|
||||
page: raw.page ?? input.page,
|
||||
totalPages: raw.total_pages ?? 1,
|
||||
totalPages: clampTotalPages(raw.total_pages),
|
||||
totalResults: raw.total_results ?? 0,
|
||||
};
|
||||
});
|
||||
@@ -330,7 +331,7 @@ export const browse = os.discover.browse.use(authed).handler(async ({ input, con
|
||||
requireTmdb();
|
||||
|
||||
const params: Record<string, string> = {
|
||||
sort_by: input.sortBy ?? "popularity.desc",
|
||||
sort_by: tmdbSortBy(input.type, input.sortBy),
|
||||
"vote_count.gte": "50",
|
||||
};
|
||||
if (input.genreId) params.with_genres = String(input.genreId);
|
||||
@@ -397,7 +398,7 @@ export const browse = os.discover.browse.use(authed).handler(async ({ input, con
|
||||
userStatuses,
|
||||
episodeProgress,
|
||||
page: results.page ?? input.page,
|
||||
totalPages: results.total_pages ?? 1,
|
||||
totalPages: clampTotalPages(results.total_pages),
|
||||
totalResults: results.total_results ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { clampTotalPages, tmdbSortBy } from "../src/orpc/procedures/discover-utils";
|
||||
|
||||
describe("clampTotalPages", () => {
|
||||
test("defaults to 1 when undefined", () => {
|
||||
expect(clampTotalPages(undefined)).toBe(1);
|
||||
});
|
||||
|
||||
test("passes through values under the cap", () => {
|
||||
expect(clampTotalPages(42)).toBe(42);
|
||||
});
|
||||
|
||||
test("caps at 500", () => {
|
||||
expect(clampTotalPages(1234)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tmdbSortBy", () => {
|
||||
test("maps TV date sorts to first_air_date", () => {
|
||||
expect(tmdbSortBy("tv", "primary_release_date.desc")).toBe("first_air_date.desc");
|
||||
expect(tmdbSortBy("tv", "primary_release_date.asc")).toBe("first_air_date.asc");
|
||||
});
|
||||
|
||||
test("leaves movie date sorts unchanged", () => {
|
||||
expect(tmdbSortBy("movie", "primary_release_date.desc")).toBe("primary_release_date.desc");
|
||||
});
|
||||
|
||||
test("defaults to popularity.desc", () => {
|
||||
expect(tmdbSortBy("tv", undefined)).toBe("popularity.desc");
|
||||
});
|
||||
|
||||
test("leaves other TV sorts unchanged", () => {
|
||||
expect(tmdbSortBy("tv", "vote_average.desc")).toBe("vote_average.desc");
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,8 @@ export function DiscoverSection() {
|
||||
const { data: genreData } = useQuery(orpc.discover.genres.queryOptions({ input: { type } }));
|
||||
const { data: providerData } = useQuery(orpc.discover.platforms.queryOptions());
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery(
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError, isPending } =
|
||||
useInfiniteQuery(
|
||||
orpc.discover.browse.infiniteOptions({
|
||||
input: (pageParam: number) => ({
|
||||
type,
|
||||
@@ -95,6 +96,7 @@ export function DiscoverSection() {
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
|
||||
@@ -55,6 +55,7 @@ export function FilterableTitleRow({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
} = useInfiniteQuery(
|
||||
orpc.discover.browse.infiniteOptions({
|
||||
input:
|
||||
@@ -96,6 +97,7 @@ export function FilterableTitleRow({
|
||||
fetchNextPage,
|
||||
hasNextPage: selectedGenre !== null && hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
rootRef: scrollRef,
|
||||
rootMargin: "0px 400px 0px 0px",
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ interface TitleRowProps {
|
||||
onEndReached?: () => void;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
isFetchNextPageError?: boolean;
|
||||
}
|
||||
|
||||
export function TitleRow({
|
||||
@@ -37,12 +38,14 @@ export function TitleRow({
|
||||
onEndReached,
|
||||
hasNextPage = false,
|
||||
isFetchingNextPage = false,
|
||||
isFetchNextPageError = false,
|
||||
}: TitleRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useInfiniteScroll({
|
||||
fetchNextPage: onEndReached ?? noop,
|
||||
hasNextPage: !!onEndReached && hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
rootRef: scrollRef,
|
||||
rootMargin: "0px 400px 0px 0px",
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ export function PersonDetailSkeleton() {
|
||||
}
|
||||
|
||||
export function PersonDetailClient({ id }: { id: string }) {
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
|
||||
useInfiniteQuery(
|
||||
orpc.people.get.infiniteOptions({
|
||||
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
|
||||
initialPageParam: 1,
|
||||
@@ -45,6 +46,7 @@ export function PersonDetailClient({ id }: { id: string }) {
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
});
|
||||
|
||||
const person = data?.pages[0]?.person;
|
||||
|
||||
@@ -10,11 +10,13 @@ function HorizontalRow({
|
||||
fetchNextPage,
|
||||
hasNextPage = true,
|
||||
isFetchingNextPage = false,
|
||||
isFetchNextPageError = false,
|
||||
rootMargin = "0px",
|
||||
}: {
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
isFetchNextPageError?: boolean;
|
||||
rootMargin?: string;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -22,6 +24,7 @@ function HorizontalRow({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
rootRef: scrollRef,
|
||||
rootMargin,
|
||||
});
|
||||
@@ -123,4 +126,34 @@ describe("useInfiniteScroll with rootRef", () => {
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not refetch after a failed page until the sentinel leaves and re-enters view", async () => {
|
||||
const fetchNextPage = vi.fn<() => void>();
|
||||
const host = mount(<HorizontalRow fetchNextPage={fetchNextPage} />);
|
||||
const scroller = host.querySelector<HTMLElement>("[data-testid=scroller]");
|
||||
expect(scroller).not.toBeNull();
|
||||
|
||||
await nextFrames();
|
||||
scroller!.scrollLeft = scroller!.scrollWidth;
|
||||
await vi.waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The fetch starts, then fails; the sentinel is still on screen.
|
||||
act(() => {
|
||||
root?.render(<HorizontalRow fetchNextPage={fetchNextPage} isFetchingNextPage />);
|
||||
});
|
||||
await nextFrames();
|
||||
act(() => {
|
||||
root?.render(<HorizontalRow fetchNextPage={fetchNextPage} isFetchNextPageError />);
|
||||
});
|
||||
await nextFrames(5);
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Scroll away, then back to the end: now it may fetch again.
|
||||
scroller!.scrollLeft = 0;
|
||||
await nextFrames(5);
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
scroller!.scrollLeft = scroller!.scrollWidth;
|
||||
await vi.waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,15 @@ export function useInfiniteScroll({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError = false,
|
||||
rootRef,
|
||||
rootMargin = "200px",
|
||||
}: {
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
/** When the last next-page fetch failed, don't auto-refetch until the sentinel re-enters view. */
|
||||
isFetchNextPageError?: boolean;
|
||||
/** Scroll container to observe within. Defaults to the document viewport. */
|
||||
rootRef?: React.RefObject<HTMLElement | null>;
|
||||
rootMargin?: string;
|
||||
@@ -20,8 +23,14 @@ export function useInfiniteScroll({
|
||||
const el = sentinelRef.current;
|
||||
if (!el || !hasNextPage) return;
|
||||
|
||||
let initialCallback = true;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// After a failed page, don't refetch just because the observer was re-created while the
|
||||
// sentinel is still on screen — wait for the user to scroll it out of and back into view.
|
||||
const skip = initialCallback && isFetchNextPageError;
|
||||
initialCallback = false;
|
||||
if (skip) return;
|
||||
if (entry.isIntersecting && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
@@ -31,7 +40,7 @@ export function useInfiniteScroll({
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, rootRef, rootMargin]);
|
||||
}, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage, rootRef, rootMargin]);
|
||||
|
||||
return sentinelRef;
|
||||
}
|
||||
@@ -79,6 +79,7 @@ function ExplorePage() {
|
||||
fetchNextPage: fetchNextTrending,
|
||||
hasNextPage: hasNextTrending,
|
||||
isFetchingNextPage: isFetchingNextTrending,
|
||||
isFetchNextPageError: isTrendingNextPageError,
|
||||
} = useInfiniteQuery(
|
||||
orpc.discover.trending.infiniteOptions({
|
||||
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
|
||||
@@ -147,6 +148,7 @@ function ExplorePage() {
|
||||
onEndReached={fetchNextTrending}
|
||||
hasNextPage={hasNextTrending}
|
||||
isFetchingNextPage={isFetchingNextTrending}
|
||||
isFetchNextPageError={isTrendingNextPageError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -147,7 +147,8 @@ function LibraryPage() {
|
||||
return input;
|
||||
}, [search]);
|
||||
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
|
||||
useInfiniteQuery(
|
||||
orpc.library.list.infiniteOptions({
|
||||
input: (pageParam: number) => ({ ...queryInput, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
@@ -160,6 +161,7 @@ function LibraryPage() {
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
});
|
||||
|
||||
const items = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
@@ -68,7 +68,8 @@ function UpcomingPage() {
|
||||
const typeFilter = search.type ?? "all";
|
||||
const statusFilter = search.status ?? "all";
|
||||
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
|
||||
useInfiniteQuery(
|
||||
orpc.library.upcoming.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
days: 90,
|
||||
@@ -87,6 +88,7 @@ function UpcomingPage() {
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
});
|
||||
|
||||
function setTypeFilter(value: string) {
|
||||
|
||||
Reference in new issue
Block a user