From a940e61d75fccf3e2f4507f754f82488c3301889 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Tue, 3 Mar 2026 16:14:21 -0500 Subject: [PATCH] Derive TV show status from episode watch data instead of manual selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status (watchlist/watching/completed) for TV shows is now automatically determined by episode progress rather than requiring manual selection from a dropdown. The only manual action is adding/removing from watchlist — watching and completed states flow from episode data. - Decouple markAllEpisodesWatched from setTitleStatus and export it - Replace status dropdown with toggle button + read-only status badge - Add "Mark All Watched" button with confirmation dialog for TV shows - Fix optimistic transitions (watchlist → watching on first episode) - Simplify W keyboard shortcut to toggle watchlist on/off - Add optimistic completion check when marking entire seasons Co-Authored-By: Claude Opus 4.6 --- .../titles/[id]/_components/actions.ts | 16 +- .../titles/[id]/_components/title-actions.tsx | 66 ++++++- .../title-interaction-provider.tsx | 68 ++++--- .../_components/title-keyboard-shortcuts.tsx | 17 +- components/status-button.tsx | 173 +++++++----------- lib/services/tracking.ts | 9 +- 6 files changed, 191 insertions(+), 158 deletions(-) diff --git a/app/(pages)/titles/[id]/_components/actions.ts b/app/(pages)/titles/[id]/_components/actions.ts index c2e9685..3af8949 100644 --- a/app/(pages)/titles/[id]/_components/actions.ts +++ b/app/(pages)/titles/[id]/_components/actions.ts @@ -8,6 +8,7 @@ import { episodes } from "@/lib/db/schema"; import { logEpisodeWatch, logMovieWatch, + markAllEpisodesWatched, rateTitleStars, removeTitleStatus, setTitleStatus, @@ -23,20 +24,21 @@ async function getSessionUserId() { export async function updateTitleStatus( titleId: string, - status: string | null, + status: "watchlist" | null, ) { const userId = await getSessionUserId(); - if (status === null || status === undefined) { + if (status === null) { await removeTitleStatus(userId, titleId); } else { - await setTitleStatus( - userId, - titleId, - status as "watchlist" | "in_progress" | "completed", - ); + await setTitleStatus(userId, titleId, status); } } +export async function markAllWatchedAction(titleId: string) { + const userId = await getSessionUserId(); + await markAllEpisodesWatched(userId, titleId); +} + export async function updateTitleRating(titleId: string, ratingStars: number) { const userId = await getSessionUserId(); if (ratingStars < 0 || ratingStars > 5) throw new Error("Invalid rating"); diff --git a/app/(pages)/titles/[id]/_components/title-actions.tsx b/app/(pages)/titles/[id]/_components/title-actions.tsx index b7c6f68..af61635 100644 --- a/app/(pages)/titles/[id]/_components/title-actions.tsx +++ b/app/(pages)/titles/[id]/_components/title-actions.tsx @@ -1,8 +1,20 @@ "use client"; -import { IconPlayerPlay } from "@tabler/icons-react"; +import { IconChecks, IconPlayerPlay } from "@tabler/icons-react"; +import { useState } from "react"; import { StarRating } from "@/components/star-rating"; import { StatusButton } from "@/components/status-button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { useTitleInteraction } from "./title-interaction-provider"; export function TitleActions() { @@ -13,6 +25,7 @@ export function TitleActions() { handleStatusChange, handleRating, handleWatchMovie, + handleMarkAllWatched, } = useTitleInteraction(); return ( @@ -27,14 +40,55 @@ export function TitleActions() { onClick={handleWatchMovie} className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all active:scale-[0.97] hover:shadow-md hover:shadow-primary/20" > - + Mark Watched )} -
- Rate: - -
+ {titleType === "tv" && userStatus && userStatus !== "completed" && ( + + )} + + ); } + +function MarkAllWatchedButton({ onConfirm }: { onConfirm: () => void }) { + const [open, setOpen] = useState(false); + + return ( + + + + Mark All Watched + + } + /> + + + Mark all episodes as watched? + + This will mark every episode of this show as watched. You can undo + this later by unmarking individual seasons. + + + + Cancel + { + onConfirm(); + setOpen(false); + }} + > + Mark All Watched + + + + + ); +} diff --git a/app/(pages)/titles/[id]/_components/title-interaction-provider.tsx b/app/(pages)/titles/[id]/_components/title-interaction-provider.tsx index ad6967f..63149a7 100644 --- a/app/(pages)/titles/[id]/_components/title-interaction-provider.tsx +++ b/app/(pages)/titles/[id]/_components/title-interaction-provider.tsx @@ -10,6 +10,7 @@ import { import { toast } from "sonner"; import type { Season } from "@/lib/types/title"; import { + markAllWatchedAction, unwatchEpisodeAction, unwatchSeasonAction, updateTitleRating, @@ -38,6 +39,7 @@ interface TitleInteractionState { ) => void; handleMarkSeason: (season: Season) => void; handleUnmarkSeason: (season: Season) => void; + handleMarkAllWatched: () => void; watchingEp: string | null; } @@ -81,30 +83,23 @@ export function TitleInteractionProvider({ const handleStatusChange = useCallback( async (status: string | null) => { const prev = userStatus; - const prevWatches = episodeWatches; setUserStatus(status); - if (status === "completed" && titleType === "tv") { - const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id)); - setEpisodeWatches(allEpIds); - } try { - await updateTitleStatus(titleId, status); - const label = + await updateTitleStatus( + titleId, + status === "watchlist" ? "watchlist" : null, + ); + toast.success( status === "watchlist" ? "Added to watchlist" - : status === "in_progress" - ? "Marked as watching" - : status === "completed" - ? "Marked as completed" - : "Removed from list"; - toast.success(label); + : "Removed from library", + ); } catch { setUserStatus(prev); - setEpisodeWatches(prevWatches); toast.error("Failed to update status"); } }, - [titleId, titleType, userStatus, episodeWatches, seasons], + [titleId, userStatus], ); const handleRating = useCallback( @@ -162,7 +157,9 @@ export function TitleInteractionProvider({ setEpisodeWatches((w) => w.includes(episodeId) ? w : [...w, episodeId], ); - setUserStatus((s) => s ?? "in_progress"); + setUserStatus((s) => + s === null || s === "watchlist" ? "in_progress" : s, + ); try { await watchEpisode(episodeId); toast.success(`Watched S${seasonNum} E${epNum}`); @@ -183,11 +180,20 @@ export function TitleInteractionProvider({ ); if (unwatched.length === 0) return; - setEpisodeWatches((w) => { - const set = new Set(w); - for (const ep of unwatched) set.add(ep.id); - return [...set]; - }); + const newWatchSet = new Set(episodeWatches); + for (const ep of unwatched) newWatchSet.add(ep.id); + const newWatches = [...newWatchSet]; + setEpisodeWatches(newWatches); + + // Optimistically check if all episodes are now watched + const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id)); + if (allEpIds.every((id) => newWatchSet.has(id))) { + setUserStatus("completed"); + } else { + setUserStatus((s) => + s === null || s === "watchlist" ? "in_progress" : s, + ); + } try { await watchSeason(season.id); @@ -198,7 +204,7 @@ export function TitleInteractionProvider({ toast.error("Failed to mark some episodes"); } }, - [episodeWatches], + [episodeWatches, seasons], ); const handleUnmarkSeason = useCallback(async (season: Season) => { @@ -217,6 +223,22 @@ export function TitleInteractionProvider({ } }, []); + const handleMarkAllWatched = useCallback(async () => { + const prevStatus = userStatus; + const prevWatches = episodeWatches; + const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id)); + setEpisodeWatches(allEpIds); + setUserStatus("completed"); + try { + await markAllWatchedAction(titleId); + toast.success("Marked all episodes as watched"); + } catch { + setUserStatus(prevStatus); + setEpisodeWatches(prevWatches); + toast.error("Failed to mark all episodes as watched"); + } + }, [titleId, userStatus, episodeWatches, seasons]); + const value = useMemo( () => ({ titleId, @@ -232,6 +254,7 @@ export function TitleInteractionProvider({ handleWatchEpisode, handleMarkSeason, handleUnmarkSeason, + handleMarkAllWatched, watchingEp, }), [ @@ -248,6 +271,7 @@ export function TitleInteractionProvider({ handleWatchEpisode, handleMarkSeason, handleUnmarkSeason, + handleMarkAllWatched, watchingEp, ], ); diff --git a/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx b/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx index 1a8ea75..e87116a 100644 --- a/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx +++ b/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx @@ -4,12 +4,9 @@ import type { Hotkey } from "@tanstack/react-hotkeys"; import { useHotkey } from "@tanstack/react-hotkeys"; import { useAtomValue } from "jotai"; import { useRouter } from "next/navigation"; -import { useMemo } from "react"; import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette"; import { useTitleInteraction } from "./title-interaction-provider"; -const statusCycle = ["watchlist", "in_progress", "completed"] as const; - export function TitleKeyboardShortcuts() { const router = useRouter(); const { @@ -23,16 +20,10 @@ export function TitleKeyboardShortcuts() { const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom); const enabled = !commandPaletteOpen; - const nextStatus = useMemo(() => { - const currentIdx = statusCycle.indexOf( - userStatus as (typeof statusCycle)[number], - ); - return currentIdx === statusCycle.length - 1 - ? null - : statusCycle[currentIdx + 1]; - }, [userStatus]); - - useHotkey("W", () => handleStatusChange(nextStatus), { enabled }); + // W: toggle watchlist (add if not in library, remove if in library) + useHotkey("W", () => handleStatusChange(userStatus ? null : "watchlist"), { + enabled, + }); useHotkey( "M", () => { diff --git a/components/status-button.tsx b/components/status-button.tsx index ad4536f..6bc85d9 100644 --- a/components/status-button.tsx +++ b/components/status-button.tsx @@ -1,38 +1,37 @@ "use client"; import { - IconBookmark, + IconBookmarkFilled, IconCheck, - IconPlayerPlay, + IconPlayerPlayFilled, IconPlus, IconX, } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useRef, useState } from "react"; -const statuses = [ - { - value: "watchlist", +const statusConfig = { + watchlist: { label: "Watchlist", - icon: IconBookmark, - colorClass: - "border-status-watchlist/30 bg-status-watchlist/10 text-status-watchlist hover:bg-status-watchlist/15", + icon: IconBookmarkFilled, + class: "text-status-watchlist", + bgClass: "bg-status-watchlist/10 hover:bg-status-watchlist/15", + borderClass: "ring-status-watchlist/20", }, - { - value: "in_progress", + in_progress: { label: "Watching", - icon: IconPlayerPlay, - colorClass: - "border-status-watching/30 bg-status-watching/10 text-status-watching hover:bg-status-watching/15", + icon: IconPlayerPlayFilled, + class: "text-status-watching", + bgClass: "bg-status-watching/10 hover:bg-status-watching/15", + borderClass: "ring-status-watching/20", }, - { - value: "completed", + completed: { label: "Completed", icon: IconCheck, - colorClass: - "border-status-completed/30 bg-status-completed/10 text-status-completed hover:bg-status-completed/15", + class: "text-status-completed", + bgClass: "bg-status-completed/10 hover:bg-status-completed/15", + borderClass: "ring-status-completed/20", }, -] as const; +} as const; interface StatusButtonProps { currentStatus: string | null; @@ -40,93 +39,57 @@ interface StatusButtonProps { } export function StatusButton({ currentStatus, onChange }: StatusButtonProps) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, []); - - const current = statuses.find((s) => s.value === currentStatus); - const CurrentIcon = current?.icon ?? IconPlus; + const config = + statusConfig[currentStatus as keyof typeof statusConfig] ?? null; return ( -
- - - - {open && ( - - {statuses.map((s) => { - const Icon = s.icon; - return ( - - ); - })} - {currentStatus && ( - <> -
- - - )} - - )} - -
+ + {!config ? ( + onChange("watchlist")} + initial={{ opacity: 0, y: 4 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -4 }} + transition={{ duration: 0.15 }} + className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 text-sm font-medium text-primary ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]" + > + + Watchlist + + ) : ( + onChange(null)} + initial={{ opacity: 0, y: 4 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -4 }} + transition={{ duration: 0.15 }} + title="Remove from library" + className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:ring-destructive/30 hover:bg-destructive/10 hover:text-destructive`} + > + + + + + + + {config.label} + + + Remove + + + + )} + ); } diff --git a/lib/services/tracking.ts b/lib/services/tracking.ts index 93a3e03..ba9dd97 100644 --- a/lib/services/tracking.ts +++ b/lib/services/tracking.ts @@ -14,6 +14,7 @@ export async function setTitleStatus( userId: string, titleId: string, status: "watchlist" | "in_progress" | "completed", + // biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers source: "manual" | "import" | "plex" | "jellyfin" = "manual", ) { const now = new Date(); @@ -25,10 +26,6 @@ export async function setTitleStatus( set: { status, updatedAt: now }, }) .run(); - - if (status === "completed") { - await markAllEpisodesWatched(userId, titleId, source); - } } export async function removeTitleStatus(userId: string, titleId: string) { @@ -119,7 +116,7 @@ export async function logEpisodeWatch( await checkAllEpisodesWatched(userId, titleId); } -async function markAllEpisodesWatched( +export async function markAllEpisodesWatched( userId: string, titleId: string, source: "manual" | "import" | "plex" | "jellyfin" = "manual", @@ -169,6 +166,8 @@ async function markAllEpisodesWatched( } } } + + await setTitleStatus(userId, titleId, "completed", source); } async function checkAllEpisodesWatched(userId: string, titleId: string) {