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",
]) 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<TimePeriod, string> = {
today: t`today`,
@@ -144,7 +144,7 @@ export default function DashboardScreen() {
<View className="flex-row gap-3">
<StatsCard
label={t`In Library`}
value={stats.data?.librarySize}
value={libraryStats.data?.size}
icon={IconBooks}
color="text-status-watchlist"
tintColor={watchlistColor}
@@ -152,7 +152,7 @@ export default function DashboardScreen() {
/>
<StatsCard
label={t`Completed`}
value={stats.data?.completed}
value={libraryStats.data?.completed}
icon={IconCheck}
color="text-status-completed"
tintColor={completedColor}
@@ -246,9 +246,12 @@ export default function LibraryScreen() {
enabled: true,
});
const { quickAdd } = useTitleActions();
const handleQuickAdd = useCallback((id: string) => 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) ?? [],
@@ -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 }) => (
+4 -4
View File
@@ -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(
+3 -10
View File
@@ -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" && (
@@ -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 }) => (
@@ -131,7 +131,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
<Link.MenuAction
title={t`Add to Watchlist`}
icon="bookmark"
onPress={() => titleActions.quickAdd(item.id, item.title)}
onPress={() => titleActions.addToWatchlist(item.id, item.title)}
/>
</Link.Menu>
</Link>
+1 -19
View File
@@ -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<WatchInput>;
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,
+2 -2
View File
@@ -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`)