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",
|
"--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 }) => (
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+4
-6
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user