import { Trans, useLingui } from "@lingui/react/macro";
import { IconCalendarEvent } from "@tabler/icons-react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { UpcomingRow } from "@/components/dashboard/upcoming-item";
import { RouteError } from "@/components/route-error";
import { Skeleton } from "@/components/ui/skeleton";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
import { groupByDateBucket } from "@sofa/i18n/date-buckets";
const upcomingSearchSchema = z.object({
view: z.enum(["upcoming", "recent"]).optional().catch(undefined),
type: z.enum(["all", "movie", "tv"]).optional().catch(undefined),
status: z.enum(["all", "watching", "watchlist"]).optional().catch(undefined),
});
export const Route = createFileRoute("/_app/upcoming")({
validateSearch: zodValidator(upcomingSearchSchema),
staleTime: 30_000,
loader: async ({ context }) => {
await context.queryClient.ensureInfiniteQueryData(
orpc.library.upcoming.infiniteOptions({
input: (pageParam: string | undefined) => ({
days: 90,
limit: 20,
cursor: pageParam,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
}),
);
},
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
pendingComponent: UpcomingSkeleton,
errorComponent: RouteError,
component: UpcomingPage,
});
function UpcomingSkeleton() {
return (
{Array.from({ length: 5 }, (_, i) => (
))}
);
}
function UpcomingPage() {
const { t } = useLingui();
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const view = search.view ?? "upcoming";
const isRecent = view === "recent";
const typeFilter = search.type ?? "all";
const statusFilter = search.status ?? "all";
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
useInfiniteQuery(
orpc.library.upcoming.infiniteOptions({
input: (pageParam: string | undefined) => ({
days: 90,
limit: 20,
cursor: pageParam,
direction: isRecent ? "recent" : undefined,
mediaType: !isRecent && typeFilter !== "all" ? (typeFilter as "movie" | "tv") : undefined,
statusFilter:
!isRecent && statusFilter !== "all"
? [statusFilter as "watching" | "watchlist"]
: undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
}),
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
});
function setView(value: "upcoming" | "recent") {
void navigate({
search: (prev) => ({
...prev,
view: value === "upcoming" ? undefined : value,
}),
replace: true,
});
}
function setTypeFilter(value: string) {
void navigate({
search: (prev) => ({
...prev,
type: value === "all" ? undefined : (value as "movie" | "tv"),
}),
replace: true,
});
}
function setStatusFilter(value: string) {
void navigate({
search: (prev) => ({
...prev,
status: value === "all" ? undefined : (value as "watching" | "watchlist"),
}),
replace: true,
});
}
const filterToggles = (
{
const next = values.find((v) => v !== view);
if (next === "upcoming" || next === "recent") setView(next);
}}
variant="outline"
size="sm"
>
{t`Upcoming`}
{t`Recently aired`}
{!isRecent && (
{
const next = values.find((v) => v !== typeFilter) ?? "all";
setTypeFilter(next);
}}
variant="outline"
size="sm"
>
{t`All`}
{t`Movies`}
{t`TV Shows`}
{
const next = values.find((v) => v !== statusFilter) ?? "all";
setStatusFilter(next);
}}
variant="outline"
size="sm"
>
{t`All`}
{t`Watching`}
{t`Watchlist`}
)}
);
if (isPending) return ;
const allItems = data?.pages.flatMap((p) => p.items) ?? [];
if (allItems.length === 0) {
return (
{filterToggles}
{isRecent ? (
You're all caught up on the last 90 days.
) : (
No upcoming episodes or releases in the next 90 days.
)}
);
}
const buckets = groupByDateBucket(allItems, { past: isRecent });
return (
{filterToggles}
{buckets.map((bucket) => (
{bucket.label}
{bucket.items.map((item, i) => (
))}
))}
{isFetchingNextPage &&
}
);
}
function UpcomingHeader({ view }: { view: "upcoming" | "recent" }) {
const { t } = useLingui();
return (
{view === "recent" ? t`Recently aired` : t`Upcoming`}
{view === "recent" ? (
Unwatched episodes from the last 90 days.
) : (
Episodes and movies coming up in the next 90 days.
)}
);
}