From cd63d4f92a03673b651bcc78fc69e016471d20bd Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 5 Mar 2026 10:22:05 -0500 Subject: [PATCH] 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 --- app/(pages)/explore/filterable-title-row.tsx | 17 ++++++++ app/(pages)/explore/page.tsx | 21 +++++++--- app/(pages)/explore/title-row.tsx | 5 +++ components/title-card.tsx | 30 +++++++++++++ lib/actions/watchlist.ts | 9 ++++ lib/atoms/filterable-row.ts | 22 +++++++++- lib/services/tracking.ts | 44 +++++++++++++++++++- 7 files changed, 140 insertions(+), 8 deletions(-) diff --git a/app/(pages)/explore/filterable-title-row.tsx b/app/(pages)/explore/filterable-title-row.tsx index 0f73ace..18e9f48 100644 --- a/app/(pages)/explore/filterable-title-row.tsx +++ b/app/(pages)/explore/filterable-title-row.tsx @@ -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; + episodeProgress?: Record; } 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}`] + } /> diff --git a/app/(pages)/explore/page.tsx b/app/(pages)/explore/page.tsx index 9181960..2745894 100644 --- a/app/(pages)/explore/page.tsx +++ b/app/(pages)/explore/page.tsx @@ -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 = {}; + let episodeProgress: Record = {}; 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={} items={trendingItems.slice(0, 20)} userStatuses={userStatuses} + episodeProgress={episodeProgress} /> ); diff --git a/app/(pages)/explore/title-row.tsx b/app/(pages)/explore/title-row.tsx index 29a8795..957322d 100644 --- a/app/(pages)/explore/title-row.tsx +++ b/app/(pages)/explore/title-row.tsx @@ -23,6 +23,7 @@ interface TitleRowProps { icon: React.ReactNode; items: TitleRowItem[]; userStatuses?: Record; + episodeProgress?: Record; } 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}`] + } /> diff --git a/components/title-card.tsx b/components/title-card.tsx index 8d1a092..d4ec346 100644 --- a/components/title-card.tsx +++ b/components/title-card.tsx @@ -36,6 +36,7 @@ interface TitleCardProps { onImport?: () => void; showQuickAdd?: boolean; userStatus?: TitleStatus | null; + episodeProgress?: { watched: number; total: number } | null; } 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 ( + + } + > +
+ + + {watched}/{total} episodes + + + ); +} + export function TitleCard({ id, tmdbId, @@ -154,6 +175,7 @@ export function TitleCard({ onImport, showQuickAdd, userStatus, + episodeProgress, }: TitleCardProps) { const year = releaseDate?.slice(0, 4); const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv; @@ -209,6 +231,14 @@ export function TitleCard({ )}
+ + {/* Episode progress bar */} + {episodeProgress && episodeProgress.watched > 0 && ( + + )} ); diff --git a/lib/actions/watchlist.ts b/lib/actions/watchlist.ts index eaf6551..567aaa3 100644 --- a/lib/actions/watchlist.ts +++ b/lib/actions/watchlist.ts @@ -7,6 +7,7 @@ import { db } from "@/lib/db/client"; import { userTitleStatus } from "@/lib/db/schema"; import { importTitle } from "@/lib/services/metadata"; import { + getEpisodeProgressByTmdbIds, getUserStatusesByTmdbIds, setTitleStatus, } from "@/lib/services/tracking"; @@ -19,6 +20,14 @@ export async function fetchUserStatuses( return getUserStatusesByTmdbIds(session.user.id, tmdbIds); } +export async function fetchEpisodeProgress( + tmdbIds: { tmdbId: number; type: string }[], +): Promise> { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return {}; + return getEpisodeProgressByTmdbIds(session.user.id, tmdbIds); +} + export async function quickAddToWatchlist( tmdbId: number, type: "movie" | "tv", diff --git a/lib/atoms/filterable-row.ts b/lib/atoms/filterable-row.ts index 10ceb16..fb2beda 100644 --- a/lib/atoms/filterable-row.ts +++ b/lib/atoms/filterable-row.ts @@ -1,6 +1,9 @@ import { atom } from "jotai"; import { loadable } from "jotai/utils"; -import { fetchUserStatuses } from "@/lib/actions/watchlist"; +import { + fetchEpisodeProgress, + fetchUserStatuses, +} from "@/lib/actions/watchlist"; type TitleStatus = "watchlist" | "in_progress" | "completed"; @@ -17,6 +20,9 @@ export const selectedGenreAtom = atom(null); export const mediaTypeAtom = atom<"movie" | "tv">("movie"); export const defaultItemsAtom = atom([]); export const initialUserStatusesAtom = atom>({}); +export const initialEpisodeProgressAtom = atom< + Record +>({}); const genreResultsAsyncAtom = atom(async (get) => { const genre = get(selectedGenreAtom); @@ -42,3 +48,17 @@ const genreUserStatusesAsyncAtom = atom(async (get) => { }); 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, +); diff --git a/lib/services/tracking.ts b/lib/services/tracking.ts index 919ddb4..6c20e86 100644 --- a/lib/services/tracking.ts +++ b/lib/services/tracking.ts @@ -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 { episodes, @@ -330,6 +330,48 @@ export function getUserStatusesByTmdbIds( return result; } +export function getEpisodeProgressByTmdbIds( + userId: string, + tmdbIds: { tmdbId: number; type: string }[], +): Record { + 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`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 = {}; + 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) { const status = db .select()