mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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 }) => (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<TimePeriod>("this_month");
|
||||
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("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 (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
@@ -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`}
|
||||
/>
|
||||
|
||||
@@ -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 <StatsSectionSkeleton />;
|
||||
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 (
|
||||
<>
|
||||
<StatsDisplay stats={stats} />
|
||||
<StatsDisplay
|
||||
movieStats={movieStats}
|
||||
episodeStats={episodeStats}
|
||||
libraryStats={libraryStats}
|
||||
/>
|
||||
{isEmpty && (
|
||||
<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">
|
||||
|
||||
@@ -88,8 +88,8 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
|
||||
const statusConfig = useStatusConfig();
|
||||
const [optimisticStatus, setOptimisticStatus] = useState<TitleStatus | null>(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={<button type="button" />}
|
||||
>
|
||||
{!quickAddMutation.isPending && <IconPlus className="size-4" />}
|
||||
{quickAddMutation.isPending && <IconLoader className="size-4 animate-spin" />}
|
||||
{!addToWatchlistMutation.isPending && <IconPlus className="size-4" />}
|
||||
{addToWatchlistMutation.isPending && <IconLoader className="size-4 animate-spin" />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t`Add to Watchlist`}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -17,7 +17,13 @@ export const Route = createFileRoute("/_app/dashboard")({
|
||||
staleTime: 30_000,
|
||||
loader: async ({ context }) => {
|
||||
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.discover.recommendations.queryOptions()),
|
||||
context.queryClient.ensureQueryData(
|
||||
|
||||
+4
-6
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Get watch history
|
||||
title: Get library statistics
|
||||
full: true
|
||||
_openapi:
|
||||
method: GET
|
||||
@@ -7,13 +7,11 @@ _openapi:
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: >-
|
||||
Fetch the user's watch counts grouped by time period. Useful for
|
||||
rendering activity charts.
|
||||
- content: 'Fetch aggregate library counts: total titles and completed titles.'
|
||||
---
|
||||
|
||||
{/* 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: []
|
||||
contents:
|
||||
- content: >-
|
||||
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.
|
||||
---
|
||||
|
||||
{/* 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"}]} />
|
||||
+43
-188
@@ -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": {
|
||||
"get": {
|
||||
"operationId": "tracking.stats",
|
||||
"summary": "Get watch statistics",
|
||||
"description": "Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles.",
|
||||
"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.",
|
||||
"description": "Fetch the user's watch counts grouped by time period. Useful for rendering activity charts and dashboard counters.",
|
||||
"tags": [
|
||||
"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": {
|
||||
"get": {
|
||||
"operationId": "library.continueWatching",
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ContinueWatchingOutput,
|
||||
CreateImportJobInput,
|
||||
CreateIntegrationInput,
|
||||
DashboardStatsOutput,
|
||||
DiscoverInput,
|
||||
DiscoverOutput,
|
||||
DiscoverRecommendationsOutput,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
LibraryGenresOutput,
|
||||
LibraryListInput,
|
||||
LibraryListOutput,
|
||||
LibraryStatsOutput,
|
||||
MediaTypeParam,
|
||||
PageParam,
|
||||
PaginatedInput,
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
PublicInfoOutput,
|
||||
PurgeImageCacheOutput,
|
||||
PurgeMetadataCacheOutput,
|
||||
QuickAddOutput,
|
||||
RestoreBackupInput,
|
||||
SearchInput,
|
||||
SearchOutput,
|
||||
@@ -166,24 +165,6 @@ export const contract = {
|
||||
})
|
||||
.input(IdParam)
|
||||
.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
|
||||
.route({
|
||||
method: "GET",
|
||||
@@ -191,18 +172,7 @@ export const contract = {
|
||||
tags: ["Tracking"],
|
||||
summary: "Get watch statistics",
|
||||
description:
|
||||
"Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles.",
|
||||
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.",
|
||||
"Fetch the user's watch counts grouped by time period. Useful for rendering activity charts and dashboard counters.",
|
||||
successDescription: "Watch counts bucketed by time period",
|
||||
})
|
||||
.input(WatchHistoryInput)
|
||||
@@ -234,6 +204,16 @@ export const contract = {
|
||||
successDescription: "Genres present in the library",
|
||||
})
|
||||
.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
|
||||
.route({
|
||||
method: "GET",
|
||||
|
||||
@@ -537,14 +537,12 @@ export const PersonDetailOutput = z
|
||||
|
||||
// ─── Dashboard outputs ─────────────────────────────────────────
|
||||
|
||||
export const DashboardStatsOutput = z
|
||||
export const LibraryStatsOutput = z
|
||||
.object({
|
||||
moviesThisMonth: z.number().describe("Movies watched in the current calendar month"),
|
||||
episodesThisWeek: z.number().describe("Episodes watched in the current calendar week"),
|
||||
librarySize: z.number().describe("Total titles in the user's library"),
|
||||
size: z.number().describe("Total titles in the user's library"),
|
||||
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
|
||||
.object({
|
||||
@@ -1000,17 +998,6 @@ export const PurgeImageCacheOutput = z
|
||||
})
|
||||
.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 ───────────────────────────────────────────
|
||||
|
||||
export const PublicInfoOutput = z
|
||||
@@ -1225,7 +1212,7 @@ export type BackupInfo = z.infer<typeof BackupSchema>;
|
||||
export type CastMember = z.infer<typeof CastMemberSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
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 HistoryBucket = z.infer<typeof HistoryBucketSchema>;
|
||||
export type PersonCredit = z.infer<typeof PersonCreditSchema>;
|
||||
|
||||
@@ -163,7 +163,7 @@ export function unwatchMovie(userId: string, titleId: string) {
|
||||
deleteMovieWatches(userId, titleId);
|
||||
|
||||
const existing = getTitleStatus(userId, titleId);
|
||||
if (existing?.status === "completed") {
|
||||
if (existing && existing.status !== "watchlist") {
|
||||
setTitleStatus(userId, titleId, "watchlist");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user