fix: address review feedback on API reorganization

- Fix updateRating invalidation in native app — was only invalidating
  title queries, now calls invalidateTitleQueries() to also refresh
  tracking.userInfo (drives the rating UI)
- Make unwatchMovie status revert consistent with unwatchSeries — revert
  any non-watchlist status, not just "completed"
- Add sync invariant comment on handleWatch/handleUnwatch loops
- Remove unused queryClient import in native use-title-actions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:58:26 -04:00
co-authored by Claude Opus 4.6
parent 73e5c96bd8
commit d2ddc0fd34
23 changed files with 188 additions and 386 deletions
+8 -8
View File
@@ -51,14 +51,14 @@ export default function DashboardScreen() {
"--color-status-completed", "--color-status-completed",
]) as [string, string, string, string]; ]) as [string, string, string, string];
const stats = useQuery(orpc.tracking.stats.queryOptions()); const libraryStats = useQuery(orpc.library.stats.queryOptions());
const movieHistory = useQuery( const movieHistory = useQuery(
orpc.tracking.history.queryOptions({ orpc.tracking.stats.queryOptions({
input: { type: "movie", period: moviePeriod }, input: { type: "movie", period: moviePeriod },
}), }),
); );
const episodeHistory = useQuery( const episodeHistory = useQuery(
orpc.tracking.history.queryOptions({ orpc.tracking.stats.queryOptions({
input: { type: "episode", period: episodePeriod }, input: { type: "episode", period: episodePeriod },
}), }),
); );
@@ -67,7 +67,7 @@ export default function DashboardScreen() {
const recommendations = useQuery(orpc.discover.recommendations.queryOptions()); const recommendations = useQuery(orpc.discover.recommendations.queryOptions());
const isRefreshing = const isRefreshing =
stats.isRefetching || libraryStats.isRefetching ||
continueWatching.isRefetching || continueWatching.isRefetching ||
library.isRefetching || library.isRefetching ||
movieHistory.isRefetching || movieHistory.isRefetching ||
@@ -82,8 +82,8 @@ export default function DashboardScreen() {
const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0; const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0;
const hasRecommendations = (recommendations.data?.items?.length ?? 0) > 0; const hasRecommendations = (recommendations.data?.items?.length ?? 0) > 0;
const movieCount = movieHistory.data?.count ?? stats.data?.moviesThisMonth; const movieCount = movieHistory.data?.count;
const episodeCount = episodeHistory.data?.count ?? stats.data?.episodesThisWeek; const episodeCount = episodeHistory.data?.count;
const periodLabels: Record<TimePeriod, string> = { const periodLabels: Record<TimePeriod, string> = {
today: t`today`, today: t`today`,
@@ -144,7 +144,7 @@ export default function DashboardScreen() {
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<StatsCard <StatsCard
label={t`In Library`} label={t`In Library`}
value={stats.data?.librarySize} value={libraryStats.data?.size}
icon={IconBooks} icon={IconBooks}
color="text-status-watchlist" color="text-status-watchlist"
tintColor={watchlistColor} tintColor={watchlistColor}
@@ -152,7 +152,7 @@ export default function DashboardScreen() {
/> />
<StatsCard <StatsCard
label={t`Completed`} label={t`Completed`}
value={stats.data?.completed} value={libraryStats.data?.completed}
icon={IconCheck} icon={IconCheck}
color="text-status-completed" color="text-status-completed"
tintColor={completedColor} tintColor={completedColor}
@@ -246,9 +246,12 @@ export default function LibraryScreen() {
enabled: true, enabled: true,
}); });
const { quickAdd } = useTitleActions(); const { updateStatus } = useTitleActions();
const handleQuickAdd = useCallback((id: string) => quickAdd.mutate({ id }), [quickAdd]); const handleQuickAdd = useCallback(
const addingId = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null; (id: string) => updateStatus.mutate({ id, status: "watchlist" }),
[updateStatus],
);
const addingId = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null;
const allItems = useMemo( const allItems = useMemo(
() => libraryQuery.data?.pages.flatMap((page) => page.items) ?? [], () => libraryQuery.data?.pages.flatMap((page) => page.items) ?? [],
@@ -32,13 +32,13 @@ export default function SearchScreen() {
}), }),
}); });
const { quickAdd: quickAddMutation } = useTitleActions(); const { updateStatus } = useTitleActions();
const handleQuickAdd = useCallback( const handleQuickAdd = useCallback(
(id: string) => { (id: string) => {
quickAddMutation.mutate({ id }); updateStatus.mutate({ id, status: "watchlist" });
}, },
[quickAddMutation], [updateStatus],
); );
// Memoize mapped results to maintain stable references // Memoize mapped results to maintain stable references
@@ -57,7 +57,7 @@ export default function SearchScreen() {
[searchResults.data?.pages], [searchResults.data?.pages],
); );
const addingId = quickAddMutation.isPending ? (quickAddMutation.variables?.id ?? null) : null; const addingId = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null;
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: SearchResultItem }) => ( ({ item }: { item: SearchResultItem }) => (
+4 -4
View File
@@ -62,12 +62,12 @@ export default function PersonDetailScreen() {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string; const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
const { quickAdd } = useTitleActions(); const { updateStatus } = useTitleActions();
const handleQuickAdd = useCallback( const handleQuickAdd = useCallback(
(titleId: string) => quickAdd.mutate({ id: titleId }), (titleId: string) => updateStatus.mutate({ id: titleId, status: "watchlist" }),
[quickAdd], [updateStatus],
); );
const addingKey = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null; const addingKey = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null;
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery( useInfiniteQuery(
+3 -10
View File
@@ -108,12 +108,7 @@ export default function TitleDetailScreen() {
const userInfo = useQuery(orpc.tracking.userInfo.queryOptions({ input: { id } })); const userInfo = useQuery(orpc.tracking.userInfo.queryOptions({ input: { id } }));
const recommendations = useQuery(orpc.titles.similar.queryOptions({ input: { id } })); const recommendations = useQuery(orpc.titles.similar.queryOptions({ input: { id } }));
const { const { updateStatus, updateRating, watchMovie } = useTitleActions({
updateStatus,
updateRating,
watchMovie,
quickAdd: quickAddMutation,
} = useTitleActions({
toasts: { toasts: {
watchMovie: () => { watchMovie: () => {
const name = title?.title; const name = title?.title;
@@ -413,14 +408,12 @@ export default function TitleDetailScreen() {
currentStatus={userInfo.data?.status ?? null} currentStatus={userInfo.data?.status ?? null}
onStatusChange={(status) => { onStatusChange={(status) => {
if (status === "in_watchlist") { if (status === "in_watchlist") {
quickAddMutation.mutate({ id }); updateStatus.mutate({ id, status: "watchlist" });
} else { } else {
updateStatus.mutate({ id, status: null }); updateStatus.mutate({ id, status: null });
} }
}} }}
isPending={ isPending={updateStatus.isPending || watchMovie.isPending}
updateStatus.isPending || quickAddMutation.isPending || watchMovie.isPending
}
/> />
{title.type === "movie" && ( {title.type === "movie" && (
@@ -29,9 +29,12 @@ export function HorizontalPosterRow({
items: PosterRowItem[]; items: PosterRowItem[];
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const { quickAdd } = useTitleActions(); const { updateStatus } = useTitleActions();
const handleQuickAdd = useCallback((id: string) => quickAdd.mutate({ id }), [quickAdd]); const handleQuickAdd = useCallback(
const addingKey = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null; (id: string) => updateStatus.mutate({ id, status: "watchlist" }),
[updateStatus],
);
const addingKey = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null;
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []); const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => ( ({ item }: { item: PosterRowItem }) => (
@@ -131,7 +131,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
<Link.MenuAction <Link.MenuAction
title={t`Add to Watchlist`} title={t`Add to Watchlist`}
icon="bookmark" icon="bookmark"
onPress={() => titleActions.quickAdd(item.id, item.title)} onPress={() => titleActions.addToWatchlist(item.id, item.title)}
/> />
</Link.Menu> </Link.Menu>
</Link> </Link>
+1 -19
View File
@@ -3,7 +3,6 @@ import { useLingui } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { invalidateTitleQueries } from "@/lib/title-actions"; import { invalidateTitleQueries } from "@/lib/title-actions";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
@@ -25,7 +24,6 @@ interface WatchInput {
interface UseTitleActionsOptions { interface UseTitleActionsOptions {
toasts?: { toasts?: {
quickAdd?: ToastOverride<{ id: string }>;
updateStatus?: ToastOverride<{ id: string; status: string | null }>; updateStatus?: ToastOverride<{ id: string; status: string | null }>;
watchMovie?: ToastOverride<WatchInput>; watchMovie?: ToastOverride<WatchInput>;
updateRating?: ToastOverride<{ id: string; stars: number }>; updateRating?: ToastOverride<{ id: string; stars: number }>;
@@ -43,20 +41,6 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
const { t } = useLingui(); const { t } = useLingui();
const toastOverrides = options?.toasts; const toastOverrides = options?.toasts;
const quickAdd = useMutation(
orpc.tracking.quickAdd.mutationOptions({
onSuccess: (_data, input) => {
toast.success(resolveToast(toastOverrides?.quickAdd, t`Added to watchlist`, input));
invalidateTitleQueries();
},
onError: () => {
toast.error(t`Failed to add to watchlist`);
// Refetch so optimistic local status reverts on failure
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
}),
);
const updateStatus = useMutation( const updateStatus = useMutation(
orpc.tracking.updateStatus.mutationOptions({ orpc.tracking.updateStatus.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
@@ -92,8 +76,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
? t`Rated ${plural(stars, { one: "# star", other: "# stars" })}` ? t`Rated ${plural(stars, { one: "# star", other: "# stars" })}`
: t`Rating removed`; : t`Rating removed`;
toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input)); toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input));
// Rating only invalidates title queries, not tracking invalidateTitleQueries();
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
}, },
onError: () => toast.error(t`Failed to update rating`), onError: () => toast.error(t`Failed to update rating`),
}), }),
@@ -130,7 +113,6 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
); );
return { return {
quickAdd,
updateStatus, updateStatus,
watchMovie, watchMovie,
updateRating, updateRating,
+2 -2
View File
@@ -27,9 +27,9 @@ export function invalidateTitleQueries() {
* Each method calls the RPC, shows a toast, and invalidates the relevant queries. * Each method calls the RPC, shows a toast, and invalidates the relevant queries.
*/ */
export const titleActions = { export const titleActions = {
async quickAdd(id: string, titleName?: string) { async addToWatchlist(id: string, titleName?: string) {
try { try {
await client.tracking.quickAdd({ id }); await client.tracking.updateStatus({ id, status: "watchlist" });
toast.success( toast.success(
titleName titleName
? i18n._(msg`Added "${titleName}" to watchlist`) ? i18n._(msg`Added "${titleName}" to watchlist`)
+6 -1
View File
@@ -1,4 +1,4 @@
import { getContinueWatchingFeed, getUpcomingFeed } from "@sofa/core/discovery"; import { getContinueWatchingFeed, getUserStats, getUpcomingFeed } from "@sofa/core/discovery";
import { getFilteredLibraryFeed, getLibraryGenresList } from "@sofa/core/library"; import { getFilteredLibraryFeed, getLibraryGenresList } from "@sofa/core/library";
import { tmdbImageUrl } from "@sofa/tmdb/image"; import { tmdbImageUrl } from "@sofa/tmdb/image";
@@ -47,6 +47,11 @@ export const genres = os.library.genres.use(authed).handler(({ context }) => {
return { genres: getLibraryGenresList(context.user.id) }; return { genres: getLibraryGenresList(context.user.id) };
}); });
export const stats = os.library.stats.use(authed).handler(({ context }) => {
const userStats = getUserStats(context.user.id);
return { size: userStats.librarySize, completed: userStats.completed };
});
export const continueWatching = os.library.continueWatching.use(authed).handler(({ context }) => { export const continueWatching = os.library.continueWatching.use(authed).handler(({ context }) => {
const feed = getContinueWatchingFeed(context.user.id); const feed = getContinueWatchingFeed(context.user.id);
const items = feed.map((item) => ({ const items = feed.map((item) => ({
+22 -36
View File
@@ -1,8 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import type { WatchScopeType } from "@sofa/api/schemas"; import type { WatchScopeType } from "@sofa/api/schemas";
import { getUserStats, getWatchCount, getWatchHistory } from "@sofa/core/discovery"; import { getWatchCount, getWatchHistory } from "@sofa/core/discovery";
import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata"; import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import { import {
getDisplayStatusesByTitleIds, getDisplayStatusesByTitleIds,
@@ -14,7 +11,6 @@ import {
quickAddTitle, quickAddTitle,
rateTitleStars, rateTitleStars,
removeTitleStatus, removeTitleStatus,
setTitleStatus,
unwatchEpisode, unwatchEpisode,
unwatchMovie, unwatchMovie,
unwatchSeason, unwatchSeason,
@@ -32,6 +28,8 @@ const watchHistoryTypeMap = { movie: "movies", episode: "episodes" } as const;
// ─── Watch handlers by scope ────────────────────────────────── // ─── Watch handlers by scope ──────────────────────────────────
// All tracking core functions are synchronous (bun:sqlite). If any become
// async, these loops need to be awaited to surface errors properly.
function handleWatch(userId: string, scope: WatchScopeType, ids: string[]) { function handleWatch(userId: string, scope: WatchScopeType, ids: string[]) {
switch (scope) { switch (scope) {
case "movie": case "movie":
@@ -80,13 +78,22 @@ export const unwatch = os.tracking.unwatch.use(authed).handler(({ input, context
handleUnwatch(context.user.id, input.scope, input.ids); handleUnwatch(context.user.id, input.scope, input.ids);
}); });
export const updateStatus = os.tracking.updateStatus.use(authed).handler(({ input, context }) => { export const updateStatus = os.tracking.updateStatus
if (input.status === null) { .use(authed)
removeTitleStatus(context.user.id, input.id); .handler(async ({ input, context }) => {
} else { if (input.status === null) {
setTitleStatus(context.user.id, input.id, input.status); removeTitleStatus(context.user.id, input.id);
} return;
}); }
// Auto-import from TMDB if the title is a shell (absorbs quickAdd logic)
const result = quickAddTitle(context.user.id, input.id);
if (result && !result.alreadyAdded) {
getOrFetchTitleByTmdbId(result.tmdbId, result.type as "movie" | "tv").catch((err) => {
log.warn(`Failed to import ${result.type} TMDB ${result.tmdbId}:`, err);
});
}
});
export const rate = os.tracking.rate.use(authed).handler(({ input, context }) => { export const rate = os.tracking.rate.use(authed).handler(({ input, context }) => {
rateTitleStars(context.user.id, input.id, input.stars); rateTitleStars(context.user.id, input.id, input.stars);
@@ -100,30 +107,9 @@ export const userInfo = os.tracking.userInfo.use(authed).handler(({ input, conte
return { ...info, status: displayStatuses[input.id] ?? null }; return { ...info, status: displayStatuses[input.id] ?? null };
}); });
export const quickAdd = os.tracking.quickAdd.use(authed).handler(async ({ input, context }) => { export const stats = os.tracking.stats.use(authed).handler(({ input, context }) => {
const result = quickAddTitle(context.user.id, input.id);
if (!result) {
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
}
// Trigger full TMDB import if still a shell (fire-and-forget)
getOrFetchTitleByTmdbId(result.tmdbId, result.type as "movie" | "tv").catch((err) => {
log.warn(`Failed to import ${result.type} TMDB ${result.tmdbId}:`, err);
});
return { id: result.id, alreadyAdded: result.alreadyAdded };
});
export const stats = os.tracking.stats.use(authed).handler(({ context }) => {
return getUserStats(context.user.id);
});
export const history = os.tracking.history.use(authed).handler(({ input, context }) => {
const coreType = watchHistoryTypeMap[input.type]; const coreType = watchHistoryTypeMap[input.type];
const count = getWatchCount(context.user.id, coreType, input.period); const count = getWatchCount(context.user.id, coreType, input.period);
const watchHistory = getWatchHistory(context.user.id, coreType, input.period); const history = getWatchHistory(context.user.id, coreType, input.period);
return { count, history: watchHistory }; return { count, history };
}); });
+1 -2
View File
@@ -20,13 +20,12 @@ export const implementedRouter = {
updateStatus: tracking.updateStatus, updateStatus: tracking.updateStatus,
rate: tracking.rate, rate: tracking.rate,
userInfo: tracking.userInfo, userInfo: tracking.userInfo,
quickAdd: tracking.quickAdd,
stats: tracking.stats, stats: tracking.stats,
history: tracking.history,
}, },
library: { library: {
list: library.list, list: library.list,
genres: library.genres, genres: library.genres,
stats: library.stats,
continueWatching: library.continueWatching, continueWatching: library.continueWatching,
upcoming: library.upcoming, upcoming: library.upcoming,
}, },
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
import type { DashboardStats, HistoryBucket, TimePeriod } from "@sofa/api/schemas"; import type { HistoryBucket, LibraryStats, TimePeriod } from "@sofa/api/schemas";
import { Sparkline } from "./sparkline"; import { Sparkline } from "./sparkline";
@@ -152,27 +152,38 @@ function PeriodSelector({
); );
} }
export function StatsDisplay({ stats }: { stats: DashboardStats }) { interface WatchStats {
count: number;
history: HistoryBucket[];
}
interface StatsDisplayProps {
movieStats: WatchStats;
episodeStats: WatchStats;
libraryStats: LibraryStats;
}
export function StatsDisplay({ movieStats, episodeStats, libraryStats }: StatsDisplayProps) {
const { t } = useLingui(); const { t } = useLingui();
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month"); const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week"); const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
const { data: movieStats } = useQuery( const { data: moviePeriodStats } = useQuery(
orpc.tracking.history.queryOptions({ orpc.tracking.stats.queryOptions({
input: { type: "movie", period: moviePeriod }, input: { type: "movie", period: moviePeriod },
}), }),
); );
const { data: episodeStats } = useQuery( const { data: episodePeriodStats } = useQuery(
orpc.tracking.history.queryOptions({ orpc.tracking.stats.queryOptions({
input: { type: "episode", period: episodePeriod }, input: { type: "episode", period: episodePeriod },
}), }),
); );
const movieCount = movieStats?.count ?? stats.moviesThisMonth; const movieCount = moviePeriodStats?.count ?? movieStats.count;
const movieHistory = movieStats?.history; const movieHistory = moviePeriodStats?.history ?? movieStats.history;
const episodeCount = episodeStats?.count ?? stats.episodesThisWeek; const episodeCount = episodePeriodStats?.count ?? episodeStats.count;
const episodeHistory = episodeStats?.history; const episodeHistory = episodePeriodStats?.history ?? episodeStats.history;
return ( return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
@@ -206,7 +217,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
icon={IconBooks} icon={IconBooks}
color="text-status-watchlist" color="text-status-watchlist"
bgColor="bg-status-watchlist/10" bgColor="bg-status-watchlist/10"
value={stats.librarySize} value={libraryStats.size}
index={2} index={2}
label={t`In Library`} label={t`In Library`}
/> />
@@ -214,7 +225,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
icon={IconCheck} icon={IconCheck}
color="text-status-completed" color="text-status-completed"
bgColor="bg-status-completed/10" bgColor="bg-status-completed/10"
value={stats.completed} value={libraryStats.completed}
index={3} index={3}
label={t`Completed`} label={t`Completed`}
/> />
@@ -8,20 +8,34 @@ import { orpc } from "@/lib/orpc/client";
import { StatsDisplay, StatsSectionSkeleton } from "./stats-display"; import { StatsDisplay, StatsSectionSkeleton } from "./stats-display";
export function StatsSection() { export function StatsSection() {
const { data: stats, isPending } = useQuery(orpc.tracking.stats.queryOptions()); const { data: movieStats, isPending: moviesPending } = useQuery(
orpc.tracking.stats.queryOptions({ input: { type: "movie", period: "this_month" } }),
);
const { data: episodeStats, isPending: episodesPending } = useQuery(
orpc.tracking.stats.queryOptions({ input: { type: "episode", period: "this_week" } }),
);
const { data: libraryStats, isPending: libraryPending } = useQuery(
orpc.library.stats.queryOptions(),
);
const isPending = moviesPending || episodesPending || libraryPending;
if (isPending) return <StatsSectionSkeleton />; if (isPending) return <StatsSectionSkeleton />;
if (!stats) return null; if (!movieStats || !episodeStats || !libraryStats) return null;
const isEmpty = const isEmpty =
stats.moviesThisMonth === 0 && movieStats.count === 0 &&
stats.episodesThisWeek === 0 && episodeStats.count === 0 &&
stats.librarySize === 0 && libraryStats.size === 0 &&
stats.completed === 0; libraryStats.completed === 0;
return ( return (
<> <>
<StatsDisplay stats={stats} /> <StatsDisplay
movieStats={movieStats}
episodeStats={episodeStats}
libraryStats={libraryStats}
/>
{isEmpty && ( {isEmpty && (
<div className="border-border/50 flex flex-col items-center gap-4 rounded-xl border border-dashed py-16 text-center"> <div className="border-border/50 flex flex-col items-center gap-4 rounded-xl border border-dashed py-16 text-center">
<div className="bg-primary/10 rounded-full p-4"> <div className="bg-primary/10 rounded-full p-4">
+6 -6
View File
@@ -88,8 +88,8 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
const statusConfig = useStatusConfig(); const statusConfig = useStatusConfig();
const [optimisticStatus, setOptimisticStatus] = useState<TitleStatus | null>(null); const [optimisticStatus, setOptimisticStatus] = useState<TitleStatus | null>(null);
const quickAddMutation = useMutation( const addToWatchlistMutation = useMutation(
orpc.tracking.quickAdd.mutationOptions({ orpc.tracking.updateStatus.mutationOptions({
onSuccess: () => setOptimisticStatus("in_watchlist"), onSuccess: () => setOptimisticStatus("in_watchlist"),
}), }),
); );
@@ -101,8 +101,8 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
function handleClick(e: React.MouseEvent) { function handleClick(e: React.MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (quickAddMutation.isPending || isAdded) return; if (addToWatchlistMutation.isPending || isAdded) return;
quickAddMutation.mutate({ id }); addToWatchlistMutation.mutate({ id, status: "watchlist" });
} }
if (isAdded && config) { if (isAdded && config) {
@@ -127,8 +127,8 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
className="absolute end-2 top-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 text-white opacity-60 backdrop-blur-sm transition-opacity hover:bg-black/70 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100" className="absolute end-2 top-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 text-white opacity-60 backdrop-blur-sm transition-opacity hover:bg-black/70 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
render={<button type="button" />} render={<button type="button" />}
> >
{!quickAddMutation.isPending && <IconPlus className="size-4" />} {!addToWatchlistMutation.isPending && <IconPlus className="size-4" />}
{quickAddMutation.isPending && <IconLoader className="size-4 animate-spin" />} {addToWatchlistMutation.isPending && <IconLoader className="size-4 animate-spin" />}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom">{t`Add to Watchlist`}</TooltipContent> <TooltipContent side="bottom">{t`Add to Watchlist`}</TooltipContent>
</Tooltip> </Tooltip>
+7 -1
View File
@@ -17,7 +17,13 @@ export const Route = createFileRoute("/_app/dashboard")({
staleTime: 30_000, staleTime: 30_000,
loader: async ({ context }) => { loader: async ({ context }) => {
await Promise.all([ await Promise.all([
context.queryClient.ensureQueryData(orpc.tracking.stats.queryOptions()), context.queryClient.ensureQueryData(
orpc.tracking.stats.queryOptions({ input: { type: "movie", period: "this_month" } }),
),
context.queryClient.ensureQueryData(
orpc.tracking.stats.queryOptions({ input: { type: "episode", period: "this_week" } }),
),
context.queryClient.ensureQueryData(orpc.library.stats.queryOptions()),
context.queryClient.ensureQueryData(orpc.library.continueWatching.queryOptions()), context.queryClient.ensureQueryData(orpc.library.continueWatching.queryOptions()),
context.queryClient.ensureQueryData(orpc.discover.recommendations.queryOptions()), context.queryClient.ensureQueryData(orpc.discover.recommendations.queryOptions()),
context.queryClient.ensureQueryData( context.queryClient.ensureQueryData(
@@ -1,5 +1,5 @@
--- ---
title: Get watch history title: Get library statistics
full: true full: true
_openapi: _openapi:
method: GET method: GET
@@ -7,13 +7,11 @@ _openapi:
structuredData: structuredData:
headings: [] headings: []
contents: contents:
- content: >- - content: 'Fetch aggregate library counts: total titles and completed titles.'
Fetch the user's watch counts grouped by time period. Useful for
rendering activity charts.
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Fetch the user's watch counts grouped by time period. Useful for rendering activity charts. Fetch aggregate library counts: total titles and completed titles.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/tracking/history","method":"get"}]} /> <APIPage document={"./public/openapi.json"} operations={[{"path":"/library/stats","method":"get"}]} />
@@ -1,20 +0,0 @@
---
title: Quick add title to library
full: true
_openapi:
method: POST
toc: []
structuredData:
headings: []
contents:
- content: >-
Add a title to the user's watchlist and trigger a full TMDB import if
needed. If the title already exists in the user's library, returns
alreadyAdded: true.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/tracking/titles/{id}/quick-add","method":"post"}]} />
@@ -8,12 +8,12 @@ _openapi:
headings: [] headings: []
contents: contents:
- content: >- - content: >-
Fetch summary counts for the current user: movies watched this month, Fetch the user's watch counts grouped by time period. Useful for
episodes this week, library size, and completed titles. rendering activity charts and dashboard counters.
--- ---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles. Fetch the user's watch counts grouped by time period. Useful for rendering activity charts and dashboard counters.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/tracking/stats","method":"get"}]} /> <APIPage document={"./public/openapi.json"} operations={[{"path":"/tracking/stats","method":"get"}]} />
+43 -188
View File
@@ -2127,198 +2127,11 @@
] ]
} }
}, },
"/tracking/titles/{id}/quick-add": {
"post": {
"operationId": "tracking.quickAdd",
"summary": "Quick add title to library",
"description": "Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.",
"tags": [
"Tracking"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"minLength": 1,
"description": "Internal UUIDv7 identifier"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Title ID and whether it was already in the library",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Internal title ID"
},
"alreadyAdded": {
"type": "boolean",
"description": "True if the title was already in the user's library"
}
},
"required": [
"id",
"alreadyAdded"
],
"description": "Result of a quick-add operation"
}
}
}
},
"404": {
"description": "404",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"defined": {
"const": true
},
"code": {
"const": "NOT_FOUND"
},
"status": {
"const": 404
},
"message": {
"type": "string",
"default": "Title not found"
},
"data": {
"type": "object",
"properties": {
"code": {
"const": "TITLE_NOT_FOUND"
}
},
"required": [
"code"
]
}
},
"required": [
"defined",
"code",
"status",
"message",
"data"
]
},
{
"type": "object",
"properties": {
"defined": {
"const": false
},
"code": {
"type": "string"
},
"status": {
"type": "number"
},
"message": {
"type": "string"
},
"data": {}
},
"required": [
"defined",
"code",
"status",
"message"
]
}
]
}
}
}
}
},
"security": [
{
"session": []
}
]
}
},
"/tracking/stats": { "/tracking/stats": {
"get": { "get": {
"operationId": "tracking.stats", "operationId": "tracking.stats",
"summary": "Get watch statistics", "summary": "Get watch statistics",
"description": "Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles.", "description": "Fetch the user's watch counts grouped by time period. Useful for rendering activity charts and dashboard counters.",
"tags": [
"Tracking"
],
"responses": {
"200": {
"description": "Aggregate watch statistics",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"moviesThisMonth": {
"type": "number",
"description": "Movies watched in the current calendar month"
},
"episodesThisWeek": {
"type": "number",
"description": "Episodes watched in the current calendar week"
},
"librarySize": {
"type": "number",
"description": "Total titles in the user's library"
},
"completed": {
"type": "number",
"description": "Total titles with completed status"
}
},
"required": [
"moviesThisMonth",
"episodesThisWeek",
"librarySize",
"completed"
],
"description": "Aggregate watch statistics for the dashboard"
}
}
}
}
},
"security": [
{
"session": []
}
]
}
},
"/tracking/history": {
"get": {
"operationId": "tracking.history",
"summary": "Get watch history",
"description": "Fetch the user's watch counts grouped by time period. Useful for rendering activity charts.",
"tags": [ "tags": [
"Tracking" "Tracking"
], ],
@@ -2821,6 +2634,48 @@
] ]
} }
}, },
"/library/stats": {
"get": {
"operationId": "library.stats",
"summary": "Get library statistics",
"description": "Fetch aggregate library counts: total titles and completed titles.",
"tags": [
"Library"
],
"responses": {
"200": {
"description": "Library size and completed count",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"size": {
"type": "number",
"description": "Total titles in the user's library"
},
"completed": {
"type": "number",
"description": "Total titles with completed status"
}
},
"required": [
"size",
"completed"
],
"description": "Aggregate library statistics"
}
}
}
}
},
"security": [
{
"session": []
}
]
}
},
"/library/continue-watching": { "/library/continue-watching": {
"get": { "get": {
"operationId": "library.continueWatching", "operationId": "library.continueWatching",
+12 -32
View File
@@ -11,7 +11,6 @@ import {
ContinueWatchingOutput, ContinueWatchingOutput,
CreateImportJobInput, CreateImportJobInput,
CreateIntegrationInput, CreateIntegrationInput,
DashboardStatsOutput,
DiscoverInput, DiscoverInput,
DiscoverOutput, DiscoverOutput,
DiscoverRecommendationsOutput, DiscoverRecommendationsOutput,
@@ -26,6 +25,7 @@ import {
LibraryGenresOutput, LibraryGenresOutput,
LibraryListInput, LibraryListInput,
LibraryListOutput, LibraryListOutput,
LibraryStatsOutput,
MediaTypeParam, MediaTypeParam,
PageParam, PageParam,
PaginatedInput, PaginatedInput,
@@ -38,7 +38,6 @@ import {
PublicInfoOutput, PublicInfoOutput,
PurgeImageCacheOutput, PurgeImageCacheOutput,
PurgeMetadataCacheOutput, PurgeMetadataCacheOutput,
QuickAddOutput,
RestoreBackupInput, RestoreBackupInput,
SearchInput, SearchInput,
SearchOutput, SearchOutput,
@@ -166,24 +165,6 @@ export const contract = {
}) })
.input(IdParam) .input(IdParam)
.output(UserInfoOutput), .output(UserInfoOutput),
quickAdd: oc
.route({
method: "POST",
path: "/tracking/titles/{id}/quick-add",
tags: ["Tracking"],
summary: "Quick add title to library",
description:
"Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.",
successDescription: "Title ID and whether it was already in the library",
})
.input(IdParam)
.output(QuickAddOutput)
.errors({
NOT_FOUND: {
message: "Title not found",
data: appErrorData(AppErrorCode.TITLE_NOT_FOUND),
},
}),
stats: oc stats: oc
.route({ .route({
method: "GET", method: "GET",
@@ -191,18 +172,7 @@ export const contract = {
tags: ["Tracking"], tags: ["Tracking"],
summary: "Get watch statistics", summary: "Get watch statistics",
description: description:
"Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles.", "Fetch the user's watch counts grouped by time period. Useful for rendering activity charts and dashboard counters.",
successDescription: "Aggregate watch statistics",
})
.output(DashboardStatsOutput),
history: oc
.route({
method: "GET",
path: "/tracking/history",
tags: ["Tracking"],
summary: "Get watch history",
description:
"Fetch the user's watch counts grouped by time period. Useful for rendering activity charts.",
successDescription: "Watch counts bucketed by time period", successDescription: "Watch counts bucketed by time period",
}) })
.input(WatchHistoryInput) .input(WatchHistoryInput)
@@ -234,6 +204,16 @@ export const contract = {
successDescription: "Genres present in the library", successDescription: "Genres present in the library",
}) })
.output(LibraryGenresOutput), .output(LibraryGenresOutput),
stats: oc
.route({
method: "GET",
path: "/library/stats",
tags: ["Library"],
summary: "Get library statistics",
description: "Fetch aggregate library counts: total titles and completed titles.",
successDescription: "Library size and completed count",
})
.output(LibraryStatsOutput),
continueWatching: oc continueWatching: oc
.route({ .route({
method: "GET", method: "GET",
+4 -17
View File
@@ -537,14 +537,12 @@ export const PersonDetailOutput = z
// ─── Dashboard outputs ───────────────────────────────────────── // ─── Dashboard outputs ─────────────────────────────────────────
export const DashboardStatsOutput = z export const LibraryStatsOutput = z
.object({ .object({
moviesThisMonth: z.number().describe("Movies watched in the current calendar month"), size: z.number().describe("Total titles in the user's library"),
episodesThisWeek: z.number().describe("Episodes watched in the current calendar week"),
librarySize: z.number().describe("Total titles in the user's library"),
completed: z.number().describe("Total titles with completed status"), completed: z.number().describe("Total titles with completed status"),
}) })
.meta({ description: "Aggregate watch statistics for the dashboard" }); .meta({ description: "Aggregate library statistics" });
export const ContinueWatchingOutput = z export const ContinueWatchingOutput = z
.object({ .object({
@@ -1000,17 +998,6 @@ export const PurgeImageCacheOutput = z
}) })
.meta({ description: "Result of purging the image cache from disk" }); .meta({ description: "Result of purging the image cache from disk" });
// ─── Quick add output ──────────────────────────────────────────
export const QuickAddOutput = z
.object({
id: z.string().describe("Internal title ID"),
alreadyAdded: z.boolean().describe("True if the title was already in the user's library"),
})
.meta({
description: "Result of a quick-add operation",
});
// ─── System outputs ─────────────────────────────────────────── // ─── System outputs ───────────────────────────────────────────
export const PublicInfoOutput = z export const PublicInfoOutput = z
@@ -1225,7 +1212,7 @@ export type BackupInfo = z.infer<typeof BackupSchema>;
export type CastMember = z.infer<typeof CastMemberSchema>; export type CastMember = z.infer<typeof CastMemberSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>; export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
export type CronJobName = z.infer<typeof cronJobName>; export type CronJobName = z.infer<typeof cronJobName>;
export type DashboardStats = z.infer<typeof DashboardStatsOutput>; export type LibraryStats = z.infer<typeof LibraryStatsOutput>;
export type Episode = z.infer<typeof EpisodeSchema>; export type Episode = z.infer<typeof EpisodeSchema>;
export type HistoryBucket = z.infer<typeof HistoryBucketSchema>; export type HistoryBucket = z.infer<typeof HistoryBucketSchema>;
export type PersonCredit = z.infer<typeof PersonCreditSchema>; export type PersonCredit = z.infer<typeof PersonCreditSchema>;
+1 -1
View File
@@ -163,7 +163,7 @@ export function unwatchMovie(userId: string, titleId: string) {
deleteMovieWatches(userId, titleId); deleteMovieWatches(userId, titleId);
const existing = getTitleStatus(userId, titleId); const existing = getTitleStatus(userId, titleId);
if (existing?.status === "completed") { if (existing && existing.status !== "watchlist") {
setTitleStatus(userId, titleId, "watchlist"); setTitleStatus(userId, titleId, "watchlist");
} }
} }