diff --git a/apps/native/src/app/(tabs)/(home)/index.tsx b/apps/native/src/app/(tabs)/(home)/index.tsx index 58f09c0..391b8bb 100644 --- a/apps/native/src/app/(tabs)/(home)/index.tsx +++ b/apps/native/src/app/(tabs)/(home)/index.tsx @@ -51,14 +51,14 @@ export default function DashboardScreen() { "--color-status-completed", ]) as [string, string, string, string]; - const stats = useQuery(orpc.tracking.stats.queryOptions()); + const libraryStats = useQuery(orpc.library.stats.queryOptions()); const movieHistory = useQuery( - orpc.tracking.history.queryOptions({ + orpc.tracking.stats.queryOptions({ input: { type: "movie", period: moviePeriod }, }), ); const episodeHistory = useQuery( - orpc.tracking.history.queryOptions({ + orpc.tracking.stats.queryOptions({ input: { type: "episode", period: episodePeriod }, }), ); @@ -67,7 +67,7 @@ export default function DashboardScreen() { const recommendations = useQuery(orpc.discover.recommendations.queryOptions()); const isRefreshing = - stats.isRefetching || + libraryStats.isRefetching || continueWatching.isRefetching || library.isRefetching || movieHistory.isRefetching || @@ -82,8 +82,8 @@ export default function DashboardScreen() { const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0; const hasRecommendations = (recommendations.data?.items?.length ?? 0) > 0; - const movieCount = movieHistory.data?.count ?? stats.data?.moviesThisMonth; - const episodeCount = episodeHistory.data?.count ?? stats.data?.episodesThisWeek; + const movieCount = movieHistory.data?.count; + const episodeCount = episodeHistory.data?.count; const periodLabels: Record = { today: t`today`, @@ -144,7 +144,7 @@ export default function DashboardScreen() { quickAdd.mutate({ id }), [quickAdd]); - const addingId = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null; + const { updateStatus } = useTitleActions(); + const handleQuickAdd = useCallback( + (id: string) => updateStatus.mutate({ id, status: "watchlist" }), + [updateStatus], + ); + const addingId = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null; const allItems = useMemo( () => libraryQuery.data?.pages.flatMap((page) => page.items) ?? [], diff --git a/apps/native/src/app/(tabs)/(search)/index.tsx b/apps/native/src/app/(tabs)/(search)/index.tsx index 72e8202..1e8a265 100644 --- a/apps/native/src/app/(tabs)/(search)/index.tsx +++ b/apps/native/src/app/(tabs)/(search)/index.tsx @@ -32,13 +32,13 @@ export default function SearchScreen() { }), }); - const { quickAdd: quickAddMutation } = useTitleActions(); + const { updateStatus } = useTitleActions(); const handleQuickAdd = useCallback( (id: string) => { - quickAddMutation.mutate({ id }); + updateStatus.mutate({ id, status: "watchlist" }); }, - [quickAddMutation], + [updateStatus], ); // Memoize mapped results to maintain stable references @@ -57,7 +57,7 @@ export default function SearchScreen() { [searchResults.data?.pages], ); - const addingId = quickAddMutation.isPending ? (quickAddMutation.variables?.id ?? null) : null; + const addingId = updateStatus.isPending ? (updateStatus.variables?.id ?? null) : null; const renderItem = useCallback( ({ item }: { item: SearchResultItem }) => ( diff --git a/apps/native/src/app/person/[id].tsx b/apps/native/src/app/person/[id].tsx index 0f56f12..4ddd649 100644 --- a/apps/native/src/app/person/[id].tsx +++ b/apps/native/src/app/person/[id].tsx @@ -62,12 +62,12 @@ export default function PersonDetailScreen() { const mutedForeground = useCSSVariable("--color-muted-foreground") as string; const primaryColor = useCSSVariable("--color-primary") as string; - const { quickAdd } = useTitleActions(); + const { updateStatus } = useTitleActions(); const handleQuickAdd = useCallback( - (titleId: string) => quickAdd.mutate({ id: titleId }), - [quickAdd], + (titleId: string) => updateStatus.mutate({ id: titleId, status: "watchlist" }), + [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 } = useInfiniteQuery( diff --git a/apps/native/src/app/title/[id].tsx b/apps/native/src/app/title/[id].tsx index 85911fd..739e500 100644 --- a/apps/native/src/app/title/[id].tsx +++ b/apps/native/src/app/title/[id].tsx @@ -108,12 +108,7 @@ export default function TitleDetailScreen() { const userInfo = useQuery(orpc.tracking.userInfo.queryOptions({ input: { id } })); const recommendations = useQuery(orpc.titles.similar.queryOptions({ input: { id } })); - const { - updateStatus, - updateRating, - watchMovie, - quickAdd: quickAddMutation, - } = useTitleActions({ + const { updateStatus, updateRating, watchMovie } = useTitleActions({ toasts: { watchMovie: () => { const name = title?.title; @@ -413,14 +408,12 @@ export default function TitleDetailScreen() { currentStatus={userInfo.data?.status ?? null} onStatusChange={(status) => { if (status === "in_watchlist") { - quickAddMutation.mutate({ id }); + updateStatus.mutate({ id, status: "watchlist" }); } else { updateStatus.mutate({ id, status: null }); } }} - isPending={ - updateStatus.isPending || quickAddMutation.isPending || watchMovie.isPending - } + isPending={updateStatus.isPending || watchMovie.isPending} /> {title.type === "movie" && ( diff --git a/apps/native/src/components/dashboard/horizontal-poster-row.tsx b/apps/native/src/components/dashboard/horizontal-poster-row.tsx index 1756b7d..b85e22c 100644 --- a/apps/native/src/components/dashboard/horizontal-poster-row.tsx +++ b/apps/native/src/components/dashboard/horizontal-poster-row.tsx @@ -29,9 +29,12 @@ export function HorizontalPosterRow({ items: PosterRowItem[]; isLoading?: boolean; }) { - const { quickAdd } = useTitleActions(); - const handleQuickAdd = useCallback((id: string) => quickAdd.mutate({ id }), [quickAdd]); - const addingKey = quickAdd.isPending ? (quickAdd.variables?.id ?? null) : null; + const { updateStatus } = useTitleActions(); + const handleQuickAdd = useCallback( + (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 renderItem = useCallback( ({ item }: { item: PosterRowItem }) => ( diff --git a/apps/native/src/components/explore/hero-banner.tsx b/apps/native/src/components/explore/hero-banner.tsx index ddad660..0cb45e2 100644 --- a/apps/native/src/components/explore/hero-banner.tsx +++ b/apps/native/src/components/explore/hero-banner.tsx @@ -131,7 +131,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) { titleActions.quickAdd(item.id, item.title)} + onPress={() => titleActions.addToWatchlist(item.id, item.title)} /> diff --git a/apps/native/src/hooks/use-title-actions.ts b/apps/native/src/hooks/use-title-actions.ts index 9ea203a..8f78f9c 100644 --- a/apps/native/src/hooks/use-title-actions.ts +++ b/apps/native/src/hooks/use-title-actions.ts @@ -3,7 +3,6 @@ import { useLingui } from "@lingui/react/macro"; import { useMutation } from "@tanstack/react-query"; import { orpc } from "@/lib/orpc"; -import { queryClient } from "@/lib/query-client"; import { invalidateTitleQueries } from "@/lib/title-actions"; import { toast } from "@/lib/toast"; @@ -25,7 +24,6 @@ interface WatchInput { interface UseTitleActionsOptions { toasts?: { - quickAdd?: ToastOverride<{ id: string }>; updateStatus?: ToastOverride<{ id: string; status: string | null }>; watchMovie?: ToastOverride; updateRating?: ToastOverride<{ id: string; stars: number }>; @@ -43,20 +41,6 @@ export function useTitleActions(options?: UseTitleActionsOptions) { const { t } = useLingui(); 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( orpc.tracking.updateStatus.mutationOptions({ onSuccess: (_data, input) => { @@ -92,8 +76,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) { ? t`Rated ${plural(stars, { one: "# star", other: "# stars" })}` : t`Rating removed`; toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input)); - // Rating only invalidates title queries, not tracking - queryClient.invalidateQueries({ queryKey: orpc.titles.key() }); + invalidateTitleQueries(); }, onError: () => toast.error(t`Failed to update rating`), }), @@ -130,7 +113,6 @@ export function useTitleActions(options?: UseTitleActionsOptions) { ); return { - quickAdd, updateStatus, watchMovie, updateRating, diff --git a/apps/native/src/lib/title-actions.ts b/apps/native/src/lib/title-actions.ts index 9572661..6360e09 100644 --- a/apps/native/src/lib/title-actions.ts +++ b/apps/native/src/lib/title-actions.ts @@ -27,9 +27,9 @@ export function invalidateTitleQueries() { * Each method calls the RPC, shows a toast, and invalidates the relevant queries. */ export const titleActions = { - async quickAdd(id: string, titleName?: string) { + async addToWatchlist(id: string, titleName?: string) { try { - await client.tracking.quickAdd({ id }); + await client.tracking.updateStatus({ id, status: "watchlist" }); toast.success( titleName ? i18n._(msg`Added "${titleName}" to watchlist`) diff --git a/apps/server/src/orpc/procedures/library.ts b/apps/server/src/orpc/procedures/library.ts index b8a2afb..6acb403 100644 --- a/apps/server/src/orpc/procedures/library.ts +++ b/apps/server/src/orpc/procedures/library.ts @@ -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 { 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) }; }); +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 }) => { const feed = getContinueWatchingFeed(context.user.id); const items = feed.map((item) => ({ diff --git a/apps/server/src/orpc/procedures/tracking.ts b/apps/server/src/orpc/procedures/tracking.ts index 4e10d23..af9273b 100644 --- a/apps/server/src/orpc/procedures/tracking.ts +++ b/apps/server/src/orpc/procedures/tracking.ts @@ -1,8 +1,5 @@ -import { ORPCError } from "@orpc/server"; - -import { AppErrorCode } from "@sofa/api/errors"; 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 { getDisplayStatusesByTitleIds, @@ -14,7 +11,6 @@ import { quickAddTitle, rateTitleStars, removeTitleStatus, - setTitleStatus, unwatchEpisode, unwatchMovie, unwatchSeason, @@ -32,6 +28,8 @@ const watchHistoryTypeMap = { movie: "movies", episode: "episodes" } as const; // ─── 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[]) { switch (scope) { 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); }); -export const updateStatus = os.tracking.updateStatus.use(authed).handler(({ input, context }) => { - if (input.status === null) { - removeTitleStatus(context.user.id, input.id); - } else { - setTitleStatus(context.user.id, input.id, input.status); - } -}); +export const updateStatus = os.tracking.updateStatus + .use(authed) + .handler(async ({ input, context }) => { + if (input.status === null) { + 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 }) => { 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 }; }); -export const quickAdd = os.tracking.quickAdd.use(authed).handler(async ({ 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 }) => { +export const stats = os.tracking.stats.use(authed).handler(({ input, context }) => { const coreType = watchHistoryTypeMap[input.type]; const count = getWatchCount(context.user.id, coreType, input.period); - const watchHistory = getWatchHistory(context.user.id, coreType, input.period); - return { count, history: watchHistory }; + const history = getWatchHistory(context.user.id, coreType, input.period); + return { count, history }; }); diff --git a/apps/server/src/orpc/router.ts b/apps/server/src/orpc/router.ts index 67b9b7b..df6be8a 100644 --- a/apps/server/src/orpc/router.ts +++ b/apps/server/src/orpc/router.ts @@ -20,13 +20,12 @@ export const implementedRouter = { updateStatus: tracking.updateStatus, rate: tracking.rate, userInfo: tracking.userInfo, - quickAdd: tracking.quickAdd, stats: tracking.stats, - history: tracking.history, }, library: { list: library.list, genres: library.genres, + stats: library.stats, continueWatching: library.continueWatching, upcoming: library.upcoming, }, diff --git a/apps/web/src/components/dashboard/stats-display.tsx b/apps/web/src/components/dashboard/stats-display.tsx index ea9c809..2b304bb 100644 --- a/apps/web/src/components/dashboard/stats-display.tsx +++ b/apps/web/src/components/dashboard/stats-display.tsx @@ -12,7 +12,7 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; 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"; @@ -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 [moviePeriod, setMoviePeriod] = useState("this_month"); const [episodePeriod, setEpisodePeriod] = useState("this_week"); - const { data: movieStats } = useQuery( - orpc.tracking.history.queryOptions({ + const { data: moviePeriodStats } = useQuery( + orpc.tracking.stats.queryOptions({ input: { type: "movie", period: moviePeriod }, }), ); - const { data: episodeStats } = useQuery( - orpc.tracking.history.queryOptions({ + const { data: episodePeriodStats } = useQuery( + orpc.tracking.stats.queryOptions({ input: { type: "episode", period: episodePeriod }, }), ); - const movieCount = movieStats?.count ?? stats.moviesThisMonth; - const movieHistory = movieStats?.history; + const movieCount = moviePeriodStats?.count ?? movieStats.count; + const movieHistory = moviePeriodStats?.history ?? movieStats.history; - const episodeCount = episodeStats?.count ?? stats.episodesThisWeek; - const episodeHistory = episodeStats?.history; + const episodeCount = episodePeriodStats?.count ?? episodeStats.count; + const episodeHistory = episodePeriodStats?.history ?? episodeStats.history; return (
@@ -206,7 +217,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) { icon={IconBooks} color="text-status-watchlist" bgColor="bg-status-watchlist/10" - value={stats.librarySize} + value={libraryStats.size} index={2} label={t`In Library`} /> @@ -214,7 +225,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) { icon={IconCheck} color="text-status-completed" bgColor="bg-status-completed/10" - value={stats.completed} + value={libraryStats.completed} index={3} label={t`Completed`} /> diff --git a/apps/web/src/components/dashboard/stats-section.tsx b/apps/web/src/components/dashboard/stats-section.tsx index 2aecf3b..fa2840e 100644 --- a/apps/web/src/components/dashboard/stats-section.tsx +++ b/apps/web/src/components/dashboard/stats-section.tsx @@ -8,20 +8,34 @@ import { orpc } from "@/lib/orpc/client"; import { StatsDisplay, StatsSectionSkeleton } from "./stats-display"; 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 ; - if (!stats) return null; + if (!movieStats || !episodeStats || !libraryStats) return null; const isEmpty = - stats.moviesThisMonth === 0 && - stats.episodesThisWeek === 0 && - stats.librarySize === 0 && - stats.completed === 0; + movieStats.count === 0 && + episodeStats.count === 0 && + libraryStats.size === 0 && + libraryStats.completed === 0; return ( <> - + {isEmpty && (
diff --git a/apps/web/src/components/title-card.tsx b/apps/web/src/components/title-card.tsx index 0ffd575..1868131 100644 --- a/apps/web/src/components/title-card.tsx +++ b/apps/web/src/components/title-card.tsx @@ -88,8 +88,8 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat const statusConfig = useStatusConfig(); const [optimisticStatus, setOptimisticStatus] = useState(null); - const quickAddMutation = useMutation( - orpc.tracking.quickAdd.mutationOptions({ + const addToWatchlistMutation = useMutation( + orpc.tracking.updateStatus.mutationOptions({ onSuccess: () => setOptimisticStatus("in_watchlist"), }), ); @@ -101,8 +101,8 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat function handleClick(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); - if (quickAddMutation.isPending || isAdded) return; - quickAddMutation.mutate({ id }); + if (addToWatchlistMutation.isPending || isAdded) return; + addToWatchlistMutation.mutate({ id, status: "watchlist" }); } 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" render={