Add episode progress bar to title cards for in-progress TV shows

Shows a subtle progress bar at the bottom of title cards indicating
watched/total episodes, with a tooltip for exact counts. Uses a single
efficient SQL query with JOINs to batch-fetch progress for all visible
titles on the Explore page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 10:22:05 -05:00
co-authored by Claude Opus 4.6
parent 1b0cad1c83
commit cd63d4f92a
7 changed files with 140 additions and 8 deletions
@@ -13,8 +13,10 @@ import {
} from "@/components/ui/carousel";
import {
defaultItemsAtom,
genreEpisodeProgressLoadable,
genreResultsLoadable,
genreUserStatusesLoadable,
initialEpisodeProgressAtom,
initialUserStatusesAtom,
mediaTypeAtom,
selectedGenreAtom,
@@ -41,6 +43,7 @@ interface FilterableTitleRowProps {
defaultItems: TitleRowItem[];
genres: Genre[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
episodeProgress?: Record<string, { watched: number; total: number }>;
}
const staggerContainer = {
@@ -65,12 +68,14 @@ export function FilterableTitleRow({
defaultItems,
genres,
userStatuses,
episodeProgress,
}: FilterableTitleRowProps) {
const [store] = useState(() => {
const s = createStore();
s.set(mediaTypeAtom, mediaType);
s.set(defaultItemsAtom, defaultItems);
s.set(initialUserStatusesAtom, userStatuses ?? {});
s.set(initialEpisodeProgressAtom, episodeProgress ?? {});
return s;
});
@@ -95,6 +100,8 @@ function FilterableTitleRowInner({
const genreResults = useAtomValue(genreResultsLoadable);
const initialStatuses = useAtomValue(initialUserStatusesAtom);
const genreStatuses = useAtomValue(genreUserStatusesLoadable);
const initialProgress = useAtomValue(initialEpisodeProgressAtom);
const genreProgress = useAtomValue(genreEpisodeProgressLoadable);
const loading = selectedGenre !== null && genreResults.state === "loading";
const items =
@@ -111,6 +118,13 @@ function FilterableTitleRowInner({
? genreStatuses.data
: initialStatuses;
const episodeProgress =
selectedGenre === null
? initialProgress
: genreProgress.state === "hasData" && genreProgress.data !== null
? genreProgress.data
: initialProgress;
function toggleGenre(genreId: number) {
setSelectedGenre(selectedGenre === genreId ? null : genreId);
}
@@ -196,6 +210,9 @@ function FilterableTitleRowInner({
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
showQuickAdd
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`]
}
/>
</motion.div>
</CarouselItem>
+15 -6
View File
@@ -1,7 +1,10 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { getUserStatusesByTmdbIds } from "@/lib/services/tracking";
import {
getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds,
} from "@/lib/services/tracking";
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { FilterableTitleRow } from "./filterable-title-row";
@@ -49,9 +52,10 @@ export default async function ExplorePage() {
const popularMovieItems = mapResults(popularMovies.results, "movie");
const popularTvItems = mapResults(popularTv.results, "tv");
// Fetch user statuses for all visible TMDB IDs
// Fetch user statuses and episode progress for all visible TMDB IDs
let userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> =
{};
let episodeProgress: Record<string, { watched: number; total: number }> = {};
const session = await auth.api.getSession({ headers: await headers() });
if (session) {
const allItems = [
@@ -59,10 +63,12 @@ export default async function ExplorePage() {
...popularMovieItems,
...popularTvItems,
];
userStatuses = getUserStatusesByTmdbIds(
session.user.id,
allItems.map((i) => ({ tmdbId: i.tmdbId, type: i.type })),
);
const tmdbLookups = allItems.map((i) => ({
tmdbId: i.tmdbId,
type: i.type,
}));
userStatuses = getUserStatusesByTmdbIds(session.user.id, tmdbLookups);
episodeProgress = getEpisodeProgressByTmdbIds(session.user.id, tmdbLookups);
}
const heroTitle = trending.results.find(
@@ -88,6 +94,7 @@ export default async function ExplorePage() {
icon={<IconFlame className="size-5 text-primary" />}
items={trendingItems.slice(0, 20)}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
<FilterableTitleRow
@@ -97,6 +104,7 @@ export default async function ExplorePage() {
defaultItems={popularMovieItems.slice(0, 20)}
genres={movieGenres.genres}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
<FilterableTitleRow
@@ -106,6 +114,7 @@ export default async function ExplorePage() {
defaultItems={popularTvItems.slice(0, 20)}
genres={tvGenres.genres}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
</div>
);
+5
View File
@@ -23,6 +23,7 @@ interface TitleRowProps {
icon: React.ReactNode;
items: TitleRowItem[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
episodeProgress?: Record<string, { watched: number; total: number }>;
}
const staggerContainer = {
@@ -45,6 +46,7 @@ export function TitleRow({
icon,
items,
userStatuses,
episodeProgress,
}: TitleRowProps) {
if (items.length === 0) return null;
@@ -81,6 +83,9 @@ export function TitleRow({
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
showQuickAdd
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress?.[`${item.tmdbId}-${item.type}`]
}
/>
</motion.div>
</CarouselItem>