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:
jakeandClaude Opus 5.5 committed 2026-10-05 03:31:06 -04:00
1 parent 7269209d43
commit d6ac2324e7
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;
}
+7 -6
View File
@@ -22,6 +22,7 @@ import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
import { clampTotalPages, tmdbSortBy } from "./discover-utils";
function requireTmdb() { function requireTmdb() {
if (!isTmdbConfigured()) { if (!isTmdbConfigured()) {
@@ -115,7 +116,7 @@ export const trending = os.discover.trending.use(authed).handler(async ({ input,
userStatuses, userStatuses,
episodeProgress, episodeProgress,
page: (data as { page?: number }).page ?? input.page, 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, 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, userStatuses,
episodeProgress, episodeProgress,
page: data.page ?? input.page, page: data.page ?? input.page,
totalPages: data.total_pages ?? 1, totalPages: clampTotalPages(data.total_pages),
totalResults: data.total_results ?? 0, 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, page: personResults.page ?? input.page,
totalPages: personResults.total_pages ?? 1, totalPages: clampTotalPages(personResults.total_pages),
totalResults: personResults.total_results ?? 0, totalResults: personResults.total_results ?? 0,
}; };
} }
@@ -319,7 +320,7 @@ export const search = os.discover.search.use(authed).handler(async ({ input }) =
return { return {
results, results,
page: raw.page ?? input.page, page: raw.page ?? input.page,
totalPages: raw.total_pages ?? 1, totalPages: clampTotalPages(raw.total_pages),
totalResults: raw.total_results ?? 0, totalResults: raw.total_results ?? 0,
}; };
}); });
@@ -330,7 +331,7 @@ export const browse = os.discover.browse.use(authed).handler(async ({ input, con
requireTmdb(); requireTmdb();
const params: Record<string, string> = { const params: Record<string, string> = {
sort_by: input.sortBy ?? "popularity.desc", sort_by: tmdbSortBy(input.type, input.sortBy),
"vote_count.gte": "50", "vote_count.gte": "50",
}; };
if (input.genreId) params.with_genres = String(input.genreId); 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, userStatuses,
episodeProgress, episodeProgress,
page: results.page ?? input.page, page: results.page ?? input.page,
totalPages: results.total_pages ?? 1, totalPages: clampTotalPages(results.total_pages),
totalResults: results.total_results ?? 0, totalResults: results.total_results ?? 0,
}; };
}); });
+36
View File
@@ -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: genreData } = useQuery(orpc.discover.genres.queryOptions({ input: { type } }));
const { data: providerData } = useQuery(orpc.discover.platforms.queryOptions()); 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({ orpc.discover.browse.infiniteOptions({
input: (pageParam: number) => ({ input: (pageParam: number) => ({
type, type,
@@ -95,6 +96,7 @@ export function DiscoverSection() {
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
isFetchNextPageError,
}); });
const items = useMemo(() => { const items = useMemo(() => {
@@ -55,6 +55,7 @@ export function FilterableTitleRow({
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
isFetchNextPageError,
} = useInfiniteQuery( } = useInfiniteQuery(
orpc.discover.browse.infiniteOptions({ orpc.discover.browse.infiniteOptions({
input: input:
@@ -96,6 +97,7 @@ export function FilterableTitleRow({
fetchNextPage, fetchNextPage,
hasNextPage: selectedGenre !== null && hasNextPage, hasNextPage: selectedGenre !== null && hasNextPage,
isFetchingNextPage, isFetchingNextPage,
isFetchNextPageError,
rootRef: scrollRef, rootRef: scrollRef,
rootMargin: "0px 400px 0px 0px", rootMargin: "0px 400px 0px 0px",
}); });
@@ -26,6 +26,7 @@ interface TitleRowProps {
onEndReached?: () => void; onEndReached?: () => void;
hasNextPage?: boolean; hasNextPage?: boolean;
isFetchingNextPage?: boolean; isFetchingNextPage?: boolean;
isFetchNextPageError?: boolean;
} }
export function TitleRow({ export function TitleRow({
@@ -37,12 +38,14 @@ export function TitleRow({
onEndReached, onEndReached,
hasNextPage = false, hasNextPage = false,
isFetchingNextPage = false, isFetchingNextPage = false,
isFetchNextPageError = false,
}: TitleRowProps) { }: TitleRowProps) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useInfiniteScroll({ const sentinelRef = useInfiniteScroll({
fetchNextPage: onEndReached ?? noop, fetchNextPage: onEndReached ?? noop,
hasNextPage: !!onEndReached && hasNextPage, hasNextPage: !!onEndReached && hasNextPage,
isFetchingNextPage, isFetchingNextPage,
isFetchNextPageError,
rootRef: scrollRef, rootRef: scrollRef,
rootMargin: "0px 400px 0px 0px", rootMargin: "0px 400px 0px 0px",
}); });
@@ -32,7 +32,8 @@ export function PersonDetailSkeleton() {
} }
export function PersonDetailClient({ id }: { id: string }) { 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({ orpc.people.get.infiniteOptions({
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }), input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
initialPageParam: 1, initialPageParam: 1,
@@ -45,6 +46,7 @@ export function PersonDetailClient({ id }: { id: string }) {
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
isFetchNextPageError,
}); });
const person = data?.pages[0]?.person; const person = data?.pages[0]?.person;
@@ -10,11 +10,13 @@ function HorizontalRow({
fetchNextPage, fetchNextPage,
hasNextPage = true, hasNextPage = true,
isFetchingNextPage = false, isFetchingNextPage = false,
isFetchNextPageError = false,
rootMargin = "0px", rootMargin = "0px",
}: { }: {
fetchNextPage: () => void; fetchNextPage: () => void;
hasNextPage?: boolean; hasNextPage?: boolean;
isFetchingNextPage?: boolean; isFetchingNextPage?: boolean;
isFetchNextPageError?: boolean;
rootMargin?: string; rootMargin?: string;
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -22,6 +24,7 @@ function HorizontalRow({
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
isFetchNextPageError,
rootRef: scrollRef, rootRef: scrollRef,
rootMargin, rootMargin,
}); });
@@ -123,4 +126,34 @@ describe("useInfiniteScroll with rootRef", () => {
expect(fetchNextPage).not.toHaveBeenCalled(); 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));
});
}); });
+10 -1
View File
@@ -4,12 +4,15 @@ export function useInfiniteScroll({
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
isFetchNextPageError = false,
rootRef, rootRef,
rootMargin = "200px", rootMargin = "200px",
}: { }: {
fetchNextPage: () => void; fetchNextPage: () => void;
hasNextPage: boolean; hasNextPage: boolean;
isFetchingNextPage: 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. */ /** Scroll container to observe within. Defaults to the document viewport. */
rootRef?: React.RefObject<HTMLElement | null>; rootRef?: React.RefObject<HTMLElement | null>;
rootMargin?: string; rootMargin?: string;
@@ -20,8 +23,14 @@ export function useInfiniteScroll({
const el = sentinelRef.current; const el = sentinelRef.current;
if (!el || !hasNextPage) return; if (!el || !hasNextPage) return;
let initialCallback = true;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([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) { if (entry.isIntersecting && !isFetchingNextPage) {
fetchNextPage(); fetchNextPage();
} }
@@ -31,7 +40,7 @@ export function useInfiniteScroll({
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage, rootRef, rootMargin]); }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage, rootRef, rootMargin]);
return sentinelRef; return sentinelRef;
} }
+2
View File
@@ -79,6 +79,7 @@ function ExplorePage() {
fetchNextPage: fetchNextTrending, fetchNextPage: fetchNextTrending,
hasNextPage: hasNextTrending, hasNextPage: hasNextTrending,
isFetchingNextPage: isFetchingNextTrending, isFetchingNextPage: isFetchingNextTrending,
isFetchNextPageError: isTrendingNextPageError,
} = useInfiniteQuery( } = useInfiniteQuery(
orpc.discover.trending.infiniteOptions({ orpc.discover.trending.infiniteOptions({
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }), input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
@@ -147,6 +148,7 @@ function ExplorePage() {
onEndReached={fetchNextTrending} onEndReached={fetchNextTrending}
hasNextPage={hasNextTrending} hasNextPage={hasNextTrending}
isFetchingNextPage={isFetchingNextTrending} isFetchingNextPage={isFetchingNextTrending}
isFetchNextPageError={isTrendingNextPageError}
/> />
</div> </div>
+3 -1
View File
@@ -147,7 +147,8 @@ function LibraryPage() {
return input; return input;
}, [search]); }, [search]);
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery( const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
useInfiniteQuery(
orpc.library.list.infiniteOptions({ orpc.library.list.infiniteOptions({
input: (pageParam: number) => ({ ...queryInput, page: pageParam }), input: (pageParam: number) => ({ ...queryInput, page: pageParam }),
initialPageParam: 1, initialPageParam: 1,
@@ -160,6 +161,7 @@ function LibraryPage() {
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
isFetchNextPageError,
}); });
const items = data?.pages.flatMap((p) => p.items) ?? []; const items = data?.pages.flatMap((p) => p.items) ?? [];
+3 -1
View File
@@ -68,7 +68,8 @@ function UpcomingPage() {
const typeFilter = search.type ?? "all"; const typeFilter = search.type ?? "all";
const statusFilter = search.status ?? "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({ orpc.library.upcoming.infiniteOptions({
input: (pageParam: string | undefined) => ({ input: (pageParam: string | undefined) => ({
days: 90, days: 90,
@@ -87,6 +88,7 @@ function UpcomingPage() {
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
isFetchNextPageError,
}); });
function setTypeFilter(value: string) { function setTypeFilter(value: string) {