mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 23:55:43 -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 { 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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) ?? [];
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user