refactor: consolidate watchlist states (#18)

- Rename `watchlist` → `in_watchlist` and `in_progress` → `watching` across the full stack (DB migration, Drizzle schema, core services, API contract, web, native)
- Add a new `caught_up` status for TV shows where all aired episodes are watched but the show is still airing
- Add `titles.watchAll` procedure and `markAllWatched` action to mark every episode of a TV show as watched in one step; replace the old "Mark as Watching" / "Mark as Completed" context-menu actions with "Mark All Watched" (TV) and "Mark as Watched" (movie)
- Extract `display-status.ts` in `@sofa/api` to share status → display label/color logic between web and native
- Add `getDisplayStatusesByTitleIds` to `@sofa/core/tracking` and wire it into the dashboard library feed so clients receive resolved display statuses
- Show a destructive Alert confirmation before removing a title from the library (native)
- Remove "Mark as Completed" from the continue-watching card context menu
- Update i18n catalogs for all 6 locales (de, en, es, fr, it, pt)
This commit is contained in:
2026-03-20 15:31:31 -04:00
committed by GitHub
parent 4b4d35ce31
commit 5c70d29fac
49 changed files with 4889 additions and 1309 deletions
@@ -10,7 +10,7 @@ interface TitleGridItem {
releaseDate?: string | null;
firstAirDate?: string | null;
voteAverage?: number | null;
userStatus?: "watchlist" | "in_progress" | "completed" | null;
userStatus?: "in_watchlist" | "watching" | "caught_up" | "completed" | null;
}
export function TitleGridSectionSkeleton() {
@@ -24,7 +24,7 @@ interface TitleRowItem {
voteAverage: number | null;
}
type TitleStatus = "watchlist" | "in_progress" | "completed";
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
interface FilterableTitleRowProps {
heading: string;
@@ -19,7 +19,7 @@ interface TitleRowProps {
heading: string;
icon: React.ReactNode;
items: TitleRowItem[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
userStatuses?: Record<string, "in_watchlist" | "watching" | "caught_up" | "completed">;
episodeProgress?: Record<string, { watched: number; total: number }>;
onEndReached?: () => void;
hasNextPage?: boolean;
@@ -18,7 +18,7 @@ type Sort = "newest" | "rating";
interface FilmographyGridProps {
credits: PersonCredit[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
userStatuses?: Record<string, "in_watchlist" | "watching" | "caught_up" | "completed">;
}
export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps) {
@@ -53,7 +53,7 @@ export function PersonDetailClient({ id }: { id: string }) {
() =>
Object.assign({}, ...(data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
string,
"watchlist" | "in_progress" | "completed"
"in_watchlist" | "watching" | "caught_up" | "completed"
>,
[data?.pages],
);
+9 -4
View File
@@ -32,7 +32,7 @@ export function TitleCardSkeleton() {
);
}
type TitleStatus = "watchlist" | "in_progress" | "completed";
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
interface TiltStyles {
imageStyle: MotionStyle;
@@ -59,16 +59,21 @@ export interface TitleCardProps extends CardInnerProps {
function useStatusConfig() {
const { t } = useLingui();
return {
watchlist: {
in_watchlist: {
icon: IconBookmarkFilled,
label: t`On Watchlist`,
badgeClass: "bg-status-watching/90 text-white",
},
in_progress: {
watching: {
icon: IconPlayerPlayFilled,
label: t`Watching`,
badgeClass: "bg-status-watching/90 text-white",
},
caught_up: {
icon: IconCircleCheckFilled,
label: t`Caught Up`,
badgeClass: "bg-status-watching/90 text-white",
},
completed: {
icon: IconCircleCheckFilled,
label: t`Completed`,
@@ -91,7 +96,7 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => setAddedStatus("watchlist"),
onSuccess: () => setAddedStatus("in_watchlist"),
}),
);
+121 -58
View File
@@ -1,6 +1,24 @@
import { useLingui } from "@lingui/react/macro";
import { IconCheck, IconPlayerPlayFilled, IconPlus, IconX } from "@tabler/icons-react";
import { Trans, useLingui } from "@lingui/react/macro";
import {
IconBookmarkFilled,
IconCheck,
IconPlayerPlayFilled,
IconPlus,
IconX,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface StatusButtonProps {
currentStatus: string | null;
@@ -9,75 +27,120 @@ interface StatusButtonProps {
export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
const { t } = useLingui();
const [confirmOpen, setConfirmOpen] = useState(false);
const watchingStyle = {
label: t`Watching`,
icon: IconPlayerPlayFilled,
class: "text-status-watching",
bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
borderClass: "ring-status-watching/20",
const completedStyle = {
class: "text-status-completed",
bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
borderClass: "ring-status-completed/20",
};
const statusConfig = {
watchlist: watchingStyle,
in_progress: watchingStyle,
in_watchlist: {
label: t`In Watchlist`,
icon: IconBookmarkFilled,
class: "text-primary",
bgClass: "bg-primary/10 hover:bg-primary/15",
borderClass: "ring-primary/20",
},
watching: {
label: t`Watching`,
icon: IconPlayerPlayFilled,
class: "text-status-watching",
bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
borderClass: "ring-status-watching/20",
},
caught_up: {
label: t`Caught Up`,
icon: IconCheck,
...completedStyle,
},
completed: {
label: t`Completed`,
icon: IconCheck,
class: "text-status-completed",
bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
borderClass: "ring-status-completed/20",
...completedStyle,
},
} as const;
const config = statusConfig[currentStatus as keyof typeof statusConfig] ?? null;
return (
<AnimatePresence mode="wait" initial={false}>
{!config ? (
<motion.button
key="add"
type="button"
onClick={() => onChange("watchlist")}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="bg-primary/10 text-primary ring-primary/20 hover:bg-primary/15 hover:ring-primary/30 inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97]"
>
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
{t`Watchlist`}
</motion.button>
) : (
<motion.button
key="status"
type="button"
onClick={() => onChange(null)}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
title={t`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:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
>
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
<config.icon
aria-hidden={true}
className="size-3.5 transition-opacity group-hover:opacity-0"
/>
<IconX
aria-hidden={true}
className="text-destructive size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
/>
</span>
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
<span className="transition-opacity group-hover:opacity-0">{config.label}</span>
<span className="opacity-0 transition-opacity group-hover:opacity-100">
{t`Remove`}
<>
<AnimatePresence mode="wait" initial={false}>
{!config ? (
<motion.button
key="add"
type="button"
onClick={() => onChange("watchlist")}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="bg-primary/10 text-primary ring-primary/20 hover:bg-primary/15 hover:ring-primary/30 inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97]"
>
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
{t`Watchlist`}
</motion.button>
) : (
<motion.button
key="status"
type="button"
onClick={() => setConfirmOpen(true)}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
title={t`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:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
>
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
<config.icon
aria-hidden={true}
className="size-3.5 transition-opacity group-hover:opacity-0"
/>
<IconX
aria-hidden={true}
className="text-destructive size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
/>
</span>
</span>
</motion.button>
)}
</AnimatePresence>
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
<span className="transition-opacity group-hover:opacity-0">{config.label}</span>
<span className="opacity-0 transition-opacity group-hover:opacity-100">
{t`Remove`}
</span>
</span>
</motion.button>
)}
</AnimatePresence>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Remove from library?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This title will be removed from your library. Your watch history and ratings will be
kept.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onChange(null);
setConfirmOpen(false);
}}
>
<Trans>Remove</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -10,7 +10,7 @@ import type { Season } from "@sofa/api/schemas";
import { useTitleContext } from "./title-context";
type UserInfo = {
status: "watchlist" | "in_progress" | "completed" | null;
status: "in_watchlist" | "watching" | "caught_up" | "completed" | null;
rating: number | null;
episodeWatches: string[];
};
@@ -57,17 +57,14 @@ export function useTitleActions() {
for (const id of episodeIds) newWatchSet.add(id);
const newWatches = [...newWatchSet];
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
const allWatched = allEpIds.every((id) => newWatchSet.has(id));
setUserInfo((old) => ({
...old,
episodeWatches: newWatches,
status: allWatched ? "completed" : old.status,
}));
try {
await batchWatchMutation.mutateAsync({ episodeIds });
await queryClient.invalidateQueries({ queryKey: userInfoKey });
toast.success(
t`Caught up — marked ${episodeIds.length} ${plural(episodeIds.length, { one: "episode", other: "episodes" })} as watched`,
);
@@ -80,7 +77,7 @@ export function useTitleActions() {
toast.error(t`Failed to catch up`);
}
},
[getUserInfo, setUserInfo, seasons, batchWatchMutation, t],
[getUserInfo, setUserInfo, batchWatchMutation, queryClient, userInfoKey, t],
);
const handleStatusChange = useCallback(
@@ -88,12 +85,12 @@ export function useTitleActions() {
const prevStatus = getUserInfo().status;
setUserInfo((old) => ({
...old,
status: status === "watchlist" ? "in_progress" : (status as UserInfo["status"]),
status: status ? "in_watchlist" : null,
}));
try {
await updateStatusMutation.mutateAsync({
id: titleId,
status: status ? "in_progress" : null,
status: status ? "watchlist" : null,
});
toast.success(status ? t`Added to watchlist` : t`Removed from library`);
} catch {
@@ -148,7 +145,8 @@ export function useTitleActions() {
setUserInfo((old) => ({
...old,
episodeWatches: old.episodeWatches.filter((id) => id !== episodeId),
status: old.status === "completed" ? "in_progress" : old.status,
status:
old.status === "completed" || old.status === "caught_up" ? "watching" : old.status,
}));
try {
@@ -173,7 +171,7 @@ export function useTitleActions() {
setUserInfo((old) => ({
...old,
episodeWatches: newWatches,
status: old.status === null || old.status === "watchlist" ? "in_progress" : old.status,
status: old.status === null || old.status === "in_watchlist" ? "watching" : old.status,
}));
try {
@@ -243,21 +241,15 @@ export function useTitleActions() {
for (const ep of unwatched) newWatchSet.add(ep.id);
const newWatches = [...newWatchSet];
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
const allWatched = allEpIds.every((id) => newWatchSet.has(id));
setUserInfo((old) => ({
...old,
episodeWatches: newWatches,
status: allWatched
? "completed"
: old.status === null || old.status === "watchlist"
? "in_progress"
: old.status,
status: old.status === null || old.status === "in_watchlist" ? "watching" : old.status,
}));
try {
await watchSeasonMutation.mutateAsync({ id: season.id });
await queryClient.invalidateQueries({ queryKey: userInfoKey });
const currentWatchSet = new Set(getUserInfo().episodeWatches);
const previousUnwatched: string[] = [];
@@ -294,7 +286,7 @@ export function useTitleActions() {
toast.error(t`Failed to mark some episodes`);
}
},
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation, t],
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation, queryClient, userInfoKey, t],
);
const handleUnmarkSeason = useCallback(
@@ -305,7 +297,7 @@ export function useTitleActions() {
setUserInfo((old) => ({
...old,
episodeWatches: old.episodeWatches.filter((id) => !seasonEpIds.has(id)),
status: old.status === "completed" ? "in_progress" : old.status,
status: old.status === "completed" || old.status === "caught_up" ? "watching" : old.status,
}));
try {
@@ -330,10 +322,12 @@ export function useTitleActions() {
setUserInfo((old) => ({
...old,
episodeWatches: allEpIds,
status: "completed",
status: old.status ?? "watching",
}));
try {
await watchAllMutation.mutateAsync({ id: titleId });
// Refresh to get server-derived display status (caught_up / completed)
await queryClient.invalidateQueries({ queryKey: userInfoKey });
toast.success(t`Marked all episodes as watched`);
} catch {
setUserInfo((old) => ({
@@ -343,7 +337,7 @@ export function useTitleActions() {
}));
toast.error(t`Failed to mark all episodes as watched`);
}
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation, t]);
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation, queryClient, userInfoKey, t]);
return {
handleStatusChange,