Derive TV show status from episode watch data instead of manual selection

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 16:14:21 -05:00
co-authored by Claude Opus 4.6
parent adc0431c7f
commit a940e61d75
6 changed files with 191 additions and 158 deletions
@@ -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");
@@ -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"
>
<IconPlayerPlay size={15} />
<IconPlayerPlay size={14} />
Mark Watched
</button>
)}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Rate:</span>
<StarRating value={userRating ?? 0} onChange={handleRating} />
</div>
{titleType === "tv" && userStatus && userStatus !== "completed" && (
<MarkAllWatchedButton onConfirm={handleMarkAllWatched} />
)}
<span className="mx-0.5 h-4 w-px bg-border/50" />
<StarRating value={userRating ?? 0} onChange={handleRating} />
</div>
);
}
function MarkAllWatchedButton({ onConfirm }: { onConfirm: () => void }) {
const [open, setOpen] = useState(false);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:scale-[0.97]"
>
<IconChecks size={14} />
Mark All Watched
</button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark all episodes as watched?</AlertDialogTitle>
<AlertDialogDescription>
This will mark every episode of this show as watched. You can undo
this later by unmarking individual seasons.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onConfirm();
setOpen(false);
}}
>
Mark All Watched
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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,
],
);
@@ -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",
() => {