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"; } from "@/components/ui/carousel";
import { import {
defaultItemsAtom, defaultItemsAtom,
genreEpisodeProgressLoadable,
genreResultsLoadable, genreResultsLoadable,
genreUserStatusesLoadable, genreUserStatusesLoadable,
initialEpisodeProgressAtom,
initialUserStatusesAtom, initialUserStatusesAtom,
mediaTypeAtom, mediaTypeAtom,
selectedGenreAtom, selectedGenreAtom,
@@ -41,6 +43,7 @@ interface FilterableTitleRowProps {
defaultItems: TitleRowItem[]; defaultItems: TitleRowItem[];
genres: Genre[]; genres: Genre[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">; userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
episodeProgress?: Record<string, { watched: number; total: number }>;
} }
const staggerContainer = { const staggerContainer = {
@@ -65,12 +68,14 @@ export function FilterableTitleRow({
defaultItems, defaultItems,
genres, genres,
userStatuses, userStatuses,
episodeProgress,
}: FilterableTitleRowProps) { }: FilterableTitleRowProps) {
const [store] = useState(() => { const [store] = useState(() => {
const s = createStore(); const s = createStore();
s.set(mediaTypeAtom, mediaType); s.set(mediaTypeAtom, mediaType);
s.set(defaultItemsAtom, defaultItems); s.set(defaultItemsAtom, defaultItems);
s.set(initialUserStatusesAtom, userStatuses ?? {}); s.set(initialUserStatusesAtom, userStatuses ?? {});
s.set(initialEpisodeProgressAtom, episodeProgress ?? {});
return s; return s;
}); });
@@ -95,6 +100,8 @@ function FilterableTitleRowInner({
const genreResults = useAtomValue(genreResultsLoadable); const genreResults = useAtomValue(genreResultsLoadable);
const initialStatuses = useAtomValue(initialUserStatusesAtom); const initialStatuses = useAtomValue(initialUserStatusesAtom);
const genreStatuses = useAtomValue(genreUserStatusesLoadable); const genreStatuses = useAtomValue(genreUserStatusesLoadable);
const initialProgress = useAtomValue(initialEpisodeProgressAtom);
const genreProgress = useAtomValue(genreEpisodeProgressLoadable);
const loading = selectedGenre !== null && genreResults.state === "loading"; const loading = selectedGenre !== null && genreResults.state === "loading";
const items = const items =
@@ -111,6 +118,13 @@ function FilterableTitleRowInner({
? genreStatuses.data ? genreStatuses.data
: initialStatuses; : initialStatuses;
const episodeProgress =
selectedGenre === null
? initialProgress
: genreProgress.state === "hasData" && genreProgress.data !== null
? genreProgress.data
: initialProgress;
function toggleGenre(genreId: number) { function toggleGenre(genreId: number) {
setSelectedGenre(selectedGenre === genreId ? null : genreId); setSelectedGenre(selectedGenre === genreId ? null : genreId);
} }
@@ -196,6 +210,9 @@ function FilterableTitleRowInner({
href={`/titles/tmdb-${item.tmdbId}-${item.type}`} href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
showQuickAdd showQuickAdd
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]} userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`]
}
/> />
</motion.div> </motion.div>
</CarouselItem> </CarouselItem>
+15 -6
View File
@@ -1,7 +1,10 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react"; import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; 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 { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { tmdbImageUrl } from "@/lib/tmdb/image";
import { FilterableTitleRow } from "./filterable-title-row"; import { FilterableTitleRow } from "./filterable-title-row";
@@ -49,9 +52,10 @@ export default async function ExplorePage() {
const popularMovieItems = mapResults(popularMovies.results, "movie"); const popularMovieItems = mapResults(popularMovies.results, "movie");
const popularTvItems = mapResults(popularTv.results, "tv"); 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 userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> =
{}; {};
let episodeProgress: Record<string, { watched: number; total: number }> = {};
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (session) { if (session) {
const allItems = [ const allItems = [
@@ -59,10 +63,12 @@ export default async function ExplorePage() {
...popularMovieItems, ...popularMovieItems,
...popularTvItems, ...popularTvItems,
]; ];
userStatuses = getUserStatusesByTmdbIds( const tmdbLookups = allItems.map((i) => ({
session.user.id, tmdbId: i.tmdbId,
allItems.map((i) => ({ tmdbId: i.tmdbId, type: i.type })), type: i.type,
); }));
userStatuses = getUserStatusesByTmdbIds(session.user.id, tmdbLookups);
episodeProgress = getEpisodeProgressByTmdbIds(session.user.id, tmdbLookups);
} }
const heroTitle = trending.results.find( const heroTitle = trending.results.find(
@@ -88,6 +94,7 @@ export default async function ExplorePage() {
icon={<IconFlame className="size-5 text-primary" />} icon={<IconFlame className="size-5 text-primary" />}
items={trendingItems.slice(0, 20)} items={trendingItems.slice(0, 20)}
userStatuses={userStatuses} userStatuses={userStatuses}
episodeProgress={episodeProgress}
/> />
<FilterableTitleRow <FilterableTitleRow
@@ -97,6 +104,7 @@ export default async function ExplorePage() {
defaultItems={popularMovieItems.slice(0, 20)} defaultItems={popularMovieItems.slice(0, 20)}
genres={movieGenres.genres} genres={movieGenres.genres}
userStatuses={userStatuses} userStatuses={userStatuses}
episodeProgress={episodeProgress}
/> />
<FilterableTitleRow <FilterableTitleRow
@@ -106,6 +114,7 @@ export default async function ExplorePage() {
defaultItems={popularTvItems.slice(0, 20)} defaultItems={popularTvItems.slice(0, 20)}
genres={tvGenres.genres} genres={tvGenres.genres}
userStatuses={userStatuses} userStatuses={userStatuses}
episodeProgress={episodeProgress}
/> />
</div> </div>
); );
+5
View File
@@ -23,6 +23,7 @@ interface TitleRowProps {
icon: React.ReactNode; icon: React.ReactNode;
items: TitleRowItem[]; items: TitleRowItem[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">; userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
episodeProgress?: Record<string, { watched: number; total: number }>;
} }
const staggerContainer = { const staggerContainer = {
@@ -45,6 +46,7 @@ export function TitleRow({
icon, icon,
items, items,
userStatuses, userStatuses,
episodeProgress,
}: TitleRowProps) { }: TitleRowProps) {
if (items.length === 0) return null; if (items.length === 0) return null;
@@ -81,6 +83,9 @@ export function TitleRow({
href={`/titles/tmdb-${item.tmdbId}-${item.type}`} href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
showQuickAdd showQuickAdd
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]} userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress?.[`${item.tmdbId}-${item.type}`]
}
/> />
</motion.div> </motion.div>
</CarouselItem> </CarouselItem>
+30
View File
@@ -36,6 +36,7 @@ interface TitleCardProps {
onImport?: () => void; onImport?: () => void;
showQuickAdd?: boolean; showQuickAdd?: boolean;
userStatus?: TitleStatus | null; userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null;
} }
type QuickAddState = "idle" | "loading" | "added"; type QuickAddState = "idle" | "loading" | "added";
@@ -142,6 +143,26 @@ function StatusBadge({ status }: { status: TitleStatus }) {
); );
} }
function ProgressBar({ watched, total }: { watched: number; total: number }) {
const pct = total > 0 ? (watched / total) * 100 : 0;
return (
<Tooltip>
<TooltipTrigger
className="absolute bottom-0 left-0 right-0 z-10 h-1 bg-white/10 cursor-default"
render={<div />}
>
<div
className="h-full bg-status-watching transition-all duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</TooltipTrigger>
<TooltipContent side="top">
{watched}/{total} episodes
</TooltipContent>
</Tooltip>
);
}
export function TitleCard({ export function TitleCard({
id, id,
tmdbId, tmdbId,
@@ -154,6 +175,7 @@ export function TitleCard({
onImport, onImport,
showQuickAdd, showQuickAdd,
userStatus, userStatus,
episodeProgress,
}: TitleCardProps) { }: TitleCardProps) {
const year = releaseDate?.slice(0, 4); const year = releaseDate?.slice(0, 4);
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv; const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
@@ -209,6 +231,14 @@ export function TitleCard({
)} )}
</div> </div>
</div> </div>
{/* Episode progress bar */}
{episodeProgress && episodeProgress.watched > 0 && (
<ProgressBar
watched={episodeProgress.watched}
total={episodeProgress.total}
/>
)}
</motion.div> </motion.div>
); );
+9
View File
@@ -7,6 +7,7 @@ import { db } from "@/lib/db/client";
import { userTitleStatus } from "@/lib/db/schema"; import { userTitleStatus } from "@/lib/db/schema";
import { importTitle } from "@/lib/services/metadata"; import { importTitle } from "@/lib/services/metadata";
import { import {
getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds, getUserStatusesByTmdbIds,
setTitleStatus, setTitleStatus,
} from "@/lib/services/tracking"; } from "@/lib/services/tracking";
@@ -19,6 +20,14 @@ export async function fetchUserStatuses(
return getUserStatusesByTmdbIds(session.user.id, tmdbIds); return getUserStatusesByTmdbIds(session.user.id, tmdbIds);
} }
export async function fetchEpisodeProgress(
tmdbIds: { tmdbId: number; type: string }[],
): Promise<Record<string, { watched: number; total: number }>> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return {};
return getEpisodeProgressByTmdbIds(session.user.id, tmdbIds);
}
export async function quickAddToWatchlist( export async function quickAddToWatchlist(
tmdbId: number, tmdbId: number,
type: "movie" | "tv", type: "movie" | "tv",
+21 -1
View File
@@ -1,6 +1,9 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { loadable } from "jotai/utils"; import { loadable } from "jotai/utils";
import { fetchUserStatuses } from "@/lib/actions/watchlist"; import {
fetchEpisodeProgress,
fetchUserStatuses,
} from "@/lib/actions/watchlist";
type TitleStatus = "watchlist" | "in_progress" | "completed"; type TitleStatus = "watchlist" | "in_progress" | "completed";
@@ -17,6 +20,9 @@ export const selectedGenreAtom = atom<number | null>(null);
export const mediaTypeAtom = atom<"movie" | "tv">("movie"); export const mediaTypeAtom = atom<"movie" | "tv">("movie");
export const defaultItemsAtom = atom<TitleRowItem[]>([]); export const defaultItemsAtom = atom<TitleRowItem[]>([]);
export const initialUserStatusesAtom = atom<Record<string, TitleStatus>>({}); export const initialUserStatusesAtom = atom<Record<string, TitleStatus>>({});
export const initialEpisodeProgressAtom = atom<
Record<string, { watched: number; total: number }>
>({});
const genreResultsAsyncAtom = atom(async (get) => { const genreResultsAsyncAtom = atom(async (get) => {
const genre = get(selectedGenreAtom); const genre = get(selectedGenreAtom);
@@ -42,3 +48,17 @@ const genreUserStatusesAsyncAtom = atom(async (get) => {
}); });
export const genreUserStatusesLoadable = loadable(genreUserStatusesAsyncAtom); export const genreUserStatusesLoadable = loadable(genreUserStatusesAsyncAtom);
const genreEpisodeProgressAsyncAtom = atom(async (get) => {
const genre = get(selectedGenreAtom);
if (genre === null) return null;
const genreResults = await get(genreResultsAsyncAtom);
if (!genreResults || genreResults.length === 0) return {};
return fetchEpisodeProgress(
genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type })),
);
});
export const genreEpisodeProgressLoadable = loadable(
genreEpisodeProgressAsyncAtom,
);
+43 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray } from "drizzle-orm"; import { and, count, eq, inArray, sql } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { import {
episodes, episodes,
@@ -330,6 +330,48 @@ export function getUserStatusesByTmdbIds(
return result; return result;
} }
export function getEpisodeProgressByTmdbIds(
userId: string,
tmdbIds: { tmdbId: number; type: string }[],
): Record<string, { watched: number; total: number }> {
const tvIds = tmdbIds.filter((t) => t.type === "tv").map((t) => t.tmdbId);
if (tvIds.length === 0) return {};
const rows = db
.select({
tmdbId: titles.tmdbId,
totalEpisodes: count(episodes.id),
watchedEpisodes:
sql<number>`sum(case when ${userEpisodeWatches.id} is not null then 1 else 0 end)`.as(
"watchedEpisodes",
),
})
.from(titles)
.innerJoin(seasons, eq(seasons.titleId, titles.id))
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
.leftJoin(
userEpisodeWatches,
and(
eq(userEpisodeWatches.episodeId, episodes.id),
eq(userEpisodeWatches.userId, userId),
),
)
.where(and(inArray(titles.tmdbId, tvIds), eq(titles.type, "tv")))
.groupBy(titles.tmdbId)
.all();
const result: Record<string, { watched: number; total: number }> = {};
for (const row of rows) {
if (row.watchedEpisodes > 0) {
result[`${row.tmdbId}-tv`] = {
watched: row.watchedEpisodes,
total: row.totalEpisodes,
};
}
}
return result;
}
export function getUserTitleInfo(userId: string, titleId: string) { export function getUserTitleInfo(userId: string, titleId: string) {
const status = db const status = db
.select() .select()