Revert "Persist dashboard periods and explore genre filters in URL via nuqs"

This reverts commit 2bb5af042b.
This commit is contained in:
2026-03-08 15:14:03 -04:00
parent 2bb5af042b
commit 5fdc7ebd97
12 changed files with 70 additions and 213 deletions
@@ -6,7 +6,6 @@ import {
IconMovie, IconMovie,
IconPlayerPlay, IconPlayerPlay,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useQueryStates } from "nuqs";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Select, Select,
@@ -21,7 +20,6 @@ import type {
HistoryBucket, HistoryBucket,
TimePeriod, TimePeriod,
} from "@/lib/services/discovery"; } from "@/lib/services/discovery";
import { dashboardSearchParams, periods } from "../search-params";
import { Sparkline } from "./sparkline"; import { Sparkline } from "./sparkline";
const periodLabels: Record<TimePeriod, string> = { const periodLabels: Record<TimePeriod, string> = {
@@ -31,6 +29,8 @@ const periodLabels: Record<TimePeriod, string> = {
this_year: "This Year", this_year: "This Year",
}; };
const periods: TimePeriod[] = ["today", "this_week", "this_month", "this_year"];
interface StatCardProps { interface StatCardProps {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
color: string; color: string;
@@ -120,9 +120,8 @@ function PeriodSelector({
} }
export function StatsDisplay({ stats }: { stats: DashboardStats }) { export function StatsDisplay({ stats }: { stats: DashboardStats }) {
const [{ moviePeriod, episodePeriod }, setPeriods] = useQueryStates( const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
dashboardSearchParams, const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
);
const [movieStats, setMovieStats] = useState<{ const [movieStats, setMovieStats] = useState<{
count: number; count: number;
history: HistoryBucket[]; history: HistoryBucket[];
@@ -140,10 +139,10 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
void getStatsAction("episodes", episodePeriod).then(setEpisodeStats); void getStatsAction("episodes", episodePeriod).then(setEpisodeStats);
}, [episodePeriod]); }, [episodePeriod]);
const movieCount = movieStats?.count ?? stats.movieCount; const movieCount = movieStats?.count ?? stats.moviesThisMonth;
const movieHistory = movieStats?.history; const movieHistory = movieStats?.history;
const episodeCount = episodeStats?.count ?? stats.episodeCount; const episodeCount = episodeStats?.count ?? stats.episodesThisWeek;
const episodeHistory = episodeStats?.history; const episodeHistory = episodeStats?.history;
return ( return (
@@ -159,7 +158,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
<PeriodSelector <PeriodSelector
noun="Movies" noun="Movies"
period={moviePeriod} period={moviePeriod}
onPeriodChange={(p) => setPeriods({ moviePeriod: p })} onPeriodChange={setMoviePeriod}
/> />
} }
/> />
@@ -174,7 +173,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
<PeriodSelector <PeriodSelector
noun="Episodes" noun="Episodes"
period={episodePeriod} period={episodePeriod}
onPeriodChange={(p) => setPeriods({ episodePeriod: p })} onPeriodChange={setEpisodePeriod}
/> />
} }
/> />
@@ -1,22 +1,14 @@
import { IconDeviceTv } from "@tabler/icons-react"; import { IconDeviceTv } from "@tabler/icons-react";
import Link from "next/link"; import Link from "next/link";
import { getUserStats, type TimePeriod } from "@/lib/services/discovery"; import { getUserStats } from "@/lib/services/discovery";
import { StatsDisplay } from "./stats-display"; import { StatsDisplay } from "./stats-display";
export async function StatsSection({ export async function StatsSection({ userId }: { userId: string }) {
userId, const stats = await getUserStats(userId);
moviePeriod,
episodePeriod,
}: {
userId: string;
moviePeriod: TimePeriod;
episodePeriod: TimePeriod;
}) {
const stats = getUserStats(userId, moviePeriod, episodePeriod);
const isEmpty = const isEmpty =
stats.movieCount === 0 && stats.moviesThisMonth === 0 &&
stats.episodeCount === 0 && stats.episodesThisWeek === 0 &&
stats.librarySize === 0 && stats.librarySize === 0 &&
stats.completed === 0; stats.completed === 0;
+2 -15
View File
@@ -1,4 +1,3 @@
import type { SearchParams } from "nuqs/server";
import { Suspense } from "react"; import { Suspense } from "react";
import { import {
ContinueWatchingSectionSkeleton, ContinueWatchingSectionSkeleton,
@@ -11,29 +10,17 @@ import { LibrarySection } from "./_components/library-section";
import { RecommendationsSection } from "./_components/recommendations-section"; import { RecommendationsSection } from "./_components/recommendations-section";
import { StatsSection } from "./_components/stats-section"; import { StatsSection } from "./_components/stats-section";
import { WelcomeHeader } from "./_components/welcome-header"; import { WelcomeHeader } from "./_components/welcome-header";
import { loadDashboardSearchParams } from "./search-params";
export default async function DashboardPage({ export default async function DashboardPage() {
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const session = await getSession(); const session = await getSession();
if (!session) return null; if (!session) return null;
const { moviePeriod, episodePeriod } =
await loadDashboardSearchParams(searchParams);
return ( return (
<div className="space-y-10"> <div className="space-y-10">
<WelcomeHeader name={session.user.name} /> <WelcomeHeader name={session.user.name} />
<Suspense fallback={<StatsSectionSkeleton />}> <Suspense fallback={<StatsSectionSkeleton />}>
<StatsSection <StatsSection userId={session.user.id} />
userId={session.user.id}
moviePeriod={moviePeriod}
episodePeriod={episodePeriod}
/>
</Suspense> </Suspense>
<Suspense fallback={<ContinueWatchingSectionSkeleton />}> <Suspense fallback={<ContinueWatchingSectionSkeleton />}>
-15
View File
@@ -1,15 +0,0 @@
import { createLoader, parseAsStringLiteral } from "nuqs/server";
export const periods = [
"today",
"this_week",
"this_month",
"this_year",
] as const;
export const dashboardSearchParams = {
moviePeriod: parseAsStringLiteral(periods).withDefault("this_month"),
episodePeriod: parseAsStringLiteral(periods).withDefault("this_week"),
};
export const loadDashboardSearchParams = createLoader(dashboardSearchParams);
@@ -1,7 +1,5 @@
"use client"; "use client";
import Link from "next/link";
import { useQueryStates } from "nuqs";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { TitleCardSkeleton } from "@/components/skeletons"; import { TitleCardSkeleton } from "@/components/skeletons";
import { TitleCard } from "@/components/title-card"; import { TitleCard } from "@/components/title-card";
@@ -12,7 +10,6 @@ import {
fetchEpisodeProgress, fetchEpisodeProgress,
fetchUserStatuses, fetchUserStatuses,
} from "@/lib/actions/watchlist"; } from "@/lib/actions/watchlist";
import { exploreSearchParams, serializeExploreParams } from "../search-params";
interface Genre { interface Genre {
id: number; id: number;
@@ -35,7 +32,6 @@ interface FilterableTitleRowProps {
icon: React.ReactNode; icon: React.ReactNode;
mediaType: "movie" | "tv"; mediaType: "movie" | "tv";
defaultItems: TitleRowItem[]; defaultItems: TitleRowItem[];
initialGenreItems: TitleRowItem[] | null;
genres: Genre[]; genres: Genre[];
userStatuses?: Record<string, TitleStatus>; userStatuses?: Record<string, TitleStatus>;
episodeProgress?: Record<string, { watched: number; total: number }>; episodeProgress?: Record<string, { watched: number; total: number }>;
@@ -46,28 +42,18 @@ export function FilterableTitleRow({
icon, icon,
mediaType, mediaType,
defaultItems, defaultItems,
initialGenreItems,
genres, genres,
userStatuses: initialStatuses = {}, userStatuses: initialStatuses = {},
episodeProgress: initialProgress = {}, episodeProgress: initialProgress = {},
}: FilterableTitleRowProps) { }: FilterableTitleRowProps) {
const paramKey = mediaType === "movie" ? "movieGenre" : ("tvGenre" as const); const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const [params, setParams] = useQueryStates(exploreSearchParams, { const [genreResults, setGenreResults] = useState<TitleRowItem[] | null>(null);
history: "push",
});
const selectedGenre = params[paramKey];
const setSelectedGenre = (value: number | null) =>
setParams({ [paramKey]: value });
const [genreResults, setGenreResults] = useState<TitleRowItem[] | null>(
initialGenreItems,
);
const [genreStatuses, setGenreStatuses] = useState< const [genreStatuses, setGenreStatuses] = useState<
Record<string, TitleStatus> Record<string, TitleStatus>
>(initialGenreItems ? initialStatuses : {}); >({});
const [genreProgress, setGenreProgress] = useState< const [genreProgress, setGenreProgress] = useState<
Record<string, { watched: number; total: number }> Record<string, { watched: number; total: number }>
>(initialGenreItems ? initialProgress : {}); >({});
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const items = selectedGenre === null ? defaultItems : (genreResults ?? []); const items = selectedGenre === null ? defaultItems : (genreResults ?? []);
@@ -117,37 +103,26 @@ export function FilterableTitleRow({
{/* Genre chips */} {/* Genre chips */}
<ScrollArea scrollFade hideScrollbar> <ScrollArea scrollFade hideScrollbar>
<div className="flex gap-2"> <div className="flex gap-2">
{genres.map((genre) => { {genres.map((genre) => (
const isSelected = selectedGenre === genre.id; <Button
const href = serializeExploreParams("/explore", { key={genre.id}
...params, variant={selectedGenre === genre.id ? "default" : "outline"}
[paramKey]: isSelected ? null : genre.id, size="sm"
}); onClick={() => toggleGenre(genre.id)}
return ( className={`shrink-0 rounded-full ${
<Button selectedGenre === genre.id
key={genre.id} ? "border-primary bg-primary/10 text-primary hover:bg-primary/20"
render={<Link href={href} prefetch={false} />} : "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
variant={isSelected ? "default" : "outline"} }`}
size="sm" >
onClick={(e) => { {genre.name}
e.preventDefault(); </Button>
toggleGenre(genre.id); ))}
}}
className={`shrink-0 rounded-full ${
isSelected
? "border-primary bg-primary/10 text-primary hover:bg-primary/20"
: "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
}`}
>
{genre.name}
</Button>
);
})}
</div> </div>
</ScrollArea> </ScrollArea>
{/* Loading skeleton */} {/* Loading skeleton */}
{(isPending || (selectedGenre !== null && genreResults === null)) && ( {isPending && (
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0"> <div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div <div
@@ -162,14 +137,11 @@ export function FilterableTitleRow({
)} )}
{/* Empty state */} {/* Empty state */}
{!isPending && {!isPending && selectedGenre !== null && items.length === 0 && (
selectedGenre !== null && <p className="py-8 text-center text-muted-foreground text-sm">
genreResults !== null && No titles found for this genre.
items.length === 0 && ( </p>
<p className="py-8 text-center text-muted-foreground text-sm"> )}
No titles found for this genre.
</p>
)}
{/* Title cards */} {/* Title cards */}
{!isPending && items.length > 0 && ( {!isPending && items.length > 0 && (
+12 -64
View File
@@ -1,21 +1,14 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react"; import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import type { SearchParams } from "nuqs/server";
import { getSession } from "@/lib/auth/session"; import { getSession } from "@/lib/auth/session";
import { import {
getEpisodeProgressByTmdbIds, getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds, getUserStatusesByTmdbIds,
} from "@/lib/services/tracking"; } from "@/lib/services/tracking";
import { import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
discover,
getGenres,
getPopular,
getTrending,
} from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { tmdbImageUrl } from "@/lib/tmdb/image";
import { FilterableTitleRow } from "./_components/filterable-title-row"; import { FilterableTitleRow } from "./_components/filterable-title-row";
import { HeroBanner } from "./_components/hero-banner"; import { HeroBanner } from "./_components/hero-banner";
import { TitleRow } from "./_components/title-row"; import { TitleRow } from "./_components/title-row";
import { loadExploreSearchParams } from "./search-params";
function mapResults( function mapResults(
results: { results: {
@@ -44,33 +37,6 @@ function mapResults(
})); }));
} }
async function discoverByGenreServer(
mediaType: "movie" | "tv",
genreId: number,
) {
const results = await discover(mediaType, {
sort_by: "popularity.desc",
"vote_count.gte": "50",
with_genres: String(genreId),
});
type DiscoverResult = NonNullable<typeof results.results>[number] & {
title?: string;
name?: string;
release_date?: string;
first_air_date?: string;
};
return ((results.results ?? []) as DiscoverResult[])
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
type: mediaType,
title: r.title ?? r.name ?? "",
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
releaseDate: r.release_date ?? r.first_air_date ?? null,
voteAverage: r.vote_average,
}));
}
async function getExploreTmdbData() { async function getExploreTmdbData() {
const [trending, popularMovies, popularTv, movieGenres, tvGenres] = const [trending, popularMovies, popularTv, movieGenres, tvGenres] =
await Promise.all([ await Promise.all([
@@ -97,35 +63,19 @@ async function getExploreTmdbData() {
}; };
} }
export default async function ExplorePage({ export default async function ExplorePage() {
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
// Await session first — its headers() call triggers the dynamic bailout during // Await session first — its headers() call triggers the dynamic bailout during
// PPR static generation, preventing TMDB fetches from firing at build time. // PPR static generation, preventing TMDB fetches from firing at build time.
const session = await getSession(); const session = await getSession();
const [ const {
{ movieGenre, tvGenre }, trending,
{ trendingItems,
trending, popularMovieItems,
trendingItems, popularTvItems,
popularMovieItems, movieGenres,
popularTvItems, tvGenres,
movieGenres, } = await getExploreTmdbData();
tvGenres,
},
] = await Promise.all([
loadExploreSearchParams(searchParams),
getExploreTmdbData(),
]);
// Pre-fetch genre-filtered results server-side when URL has genre params
const [movieGenreItems, tvGenreItems] = await Promise.all([
movieGenre ? discoverByGenreServer("movie", movieGenre) : null,
tvGenre ? discoverByGenreServer("tv", tvGenre) : null,
]);
// Fetch user statuses and episode progress for all visible TMDB IDs // Fetch user statuses and episode progress for all visible TMDB IDs
let userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> = let userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> =
@@ -134,8 +84,8 @@ export default async function ExplorePage({
if (session) { if (session) {
const allItems = [ const allItems = [
...trendingItems, ...trendingItems,
...(movieGenreItems ?? popularMovieItems), ...popularMovieItems,
...(tvGenreItems ?? popularTvItems), ...popularTvItems,
]; ];
const tmdbLookups = allItems.map((i) => ({ const tmdbLookups = allItems.map((i) => ({
tmdbId: i.tmdbId, tmdbId: i.tmdbId,
@@ -183,7 +133,6 @@ export default async function ExplorePage({
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />} icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
mediaType="movie" mediaType="movie"
defaultItems={popularMovieItems.slice(0, 20)} defaultItems={popularMovieItems.slice(0, 20)}
initialGenreItems={movieGenreItems?.slice(0, 20) ?? null}
genres={movieGenres} genres={movieGenres}
userStatuses={userStatuses} userStatuses={userStatuses}
episodeProgress={episodeProgress} episodeProgress={episodeProgress}
@@ -196,7 +145,6 @@ export default async function ExplorePage({
} }
mediaType="tv" mediaType="tv"
defaultItems={popularTvItems.slice(0, 20)} defaultItems={popularTvItems.slice(0, 20)}
initialGenreItems={tvGenreItems?.slice(0, 20) ?? null}
genres={tvGenres} genres={tvGenres}
userStatuses={userStatuses} userStatuses={userStatuses}
episodeProgress={episodeProgress} episodeProgress={episodeProgress}
-9
View File
@@ -1,9 +0,0 @@
import { createLoader, createSerializer, parseAsInteger } from "nuqs/server";
export const exploreSearchParams = {
movieGenre: parseAsInteger,
tvGenre: parseAsInteger,
};
export const loadExploreSearchParams = createLoader(exploreSearchParams);
export const serializeExploreParams = createSerializer(exploreSearchParams);
+6 -9
View File
@@ -2,7 +2,6 @@ import { Provider as StoreProvider } from "jotai";
import { MotionConfig } from "motion/react"; import { MotionConfig } from "motion/react";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google"; import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@@ -55,14 +54,12 @@ export default function RootLayout({
> >
Skip to main content Skip to main content
</a> </a>
<NuqsAdapter> <StoreProvider>
<StoreProvider> <MotionConfig reducedMotion="user">
<MotionConfig reducedMotion="user"> <TooltipProvider>{children}</TooltipProvider>
<TooltipProvider>{children}</TooltipProvider> <Toaster position="bottom-right" />
<Toaster position="bottom-right" /> </MotionConfig>
</MotionConfig> </StoreProvider>
</StoreProvider>
</NuqsAdapter>
</body> </body>
</html> </html>
); );
+1 -10
View File
@@ -22,7 +22,6 @@
"motion": "12.35.1", "motion": "12.35.1",
"next": "16.1.6", "next": "16.1.6",
"node-vibrant": "4.0.4", "node-vibrant": "4.0.4",
"nuqs": "2.8.9",
"openapi-fetch": "0.17.0", "openapi-fetch": "0.17.0",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "9.14.0", "react-day-picker": "9.14.0",
@@ -476,7 +475,7 @@
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -1176,8 +1175,6 @@
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="],
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1536,8 +1533,6 @@
"@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
@@ -1568,8 +1563,6 @@
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@reduxjs/toolkit/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
@@ -1598,8 +1591,6 @@
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
+4 -4
View File
@@ -122,8 +122,8 @@ describe("getUserStats", () => {
insertStatus("user-1", titleId, "in_progress"); insertStatus("user-1", titleId, "in_progress");
const stats = getUserStats("user-1"); const stats = getUserStats("user-1");
expect(stats.movieCount).toBe(2); expect(stats.moviesThisMonth).toBe(2);
expect(stats.episodeCount).toBe(1); expect(stats.episodesThisWeek).toBe(1);
expect(stats.librarySize).toBe(3); expect(stats.librarySize).toBe(3);
expect(stats.completed).toBe(2); expect(stats.completed).toBe(2);
}); });
@@ -131,8 +131,8 @@ describe("getUserStats", () => {
test("returns zeros when no data", () => { test("returns zeros when no data", () => {
insertUser(); insertUser();
const stats = getUserStats("user-1"); const stats = getUserStats("user-1");
expect(stats.movieCount).toBe(0); expect(stats.moviesThisMonth).toBe(0);
expect(stats.episodeCount).toBe(0); expect(stats.episodesThisWeek).toBe(0);
expect(stats.librarySize).toBe(0); expect(stats.librarySize).toBe(0);
expect(stats.completed).toBe(0); expect(stats.completed).toBe(0);
}); });
+7 -11
View File
@@ -144,19 +144,15 @@ export function getWatchHistory(
} }
export interface DashboardStats { export interface DashboardStats {
movieCount: number; moviesThisMonth: number;
episodeCount: number; episodesThisWeek: number;
librarySize: number; librarySize: number;
completed: number; completed: number;
} }
export function getUserStats( export function getUserStats(userId: string): DashboardStats {
userId: string, const moviesThisMonth = getWatchCount(userId, "movies", "this_month");
moviePeriod: TimePeriod = "this_month", const episodesThisWeek = getWatchCount(userId, "episodes", "this_week");
episodePeriod: TimePeriod = "this_week",
): DashboardStats {
const movieCount = getWatchCount(userId, "movies", moviePeriod);
const episodeCount = getWatchCount(userId, "episodes", episodePeriod);
const [statusCounts] = db const [statusCounts] = db
.select({ .select({
@@ -168,8 +164,8 @@ export function getUserStats(
.all(); .all();
return { return {
movieCount, moviesThisMonth,
episodeCount, episodesThisWeek,
librarySize: statusCounts?.librarySize ?? 0, librarySize: statusCounts?.librarySize ?? 0,
completed: statusCounts?.completed ?? 0, completed: statusCounts?.completed ?? 0,
}; };
-1
View File
@@ -36,7 +36,6 @@
"motion": "12.35.1", "motion": "12.35.1",
"next": "16.1.6", "next": "16.1.6",
"node-vibrant": "4.0.4", "node-vibrant": "4.0.4",
"nuqs": "2.8.9",
"openapi-fetch": "0.17.0", "openapi-fetch": "0.17.0",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "9.14.0", "react-day-picker": "9.14.0",