Add dynamic period selector to dashboard stats cards

Movies and Episodes cards now have an interactive dropdown to switch
between Today, This Week, This Month, and This Year. Extracts reusable
getWatchCount helper in discovery service and adds /api/stats route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 20:42:41 -05:00
co-authored by Claude Opus 4.6
parent fe5d2596c9
commit 1b7f64d327
3 changed files with 272 additions and 98 deletions
@@ -2,84 +2,199 @@
import {
IconCheck,
IconChevronDown,
IconLibrary,
IconMovie,
IconPlayerPlay,
} from "@tabler/icons-react";
import { motion } from "motion/react";
import type { DashboardStats } from "@/lib/services/discovery";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { DashboardStats, TimePeriod } from "@/lib/services/discovery";
const statDefs = [
{
key: "moviesThisMonth" as const,
label: "Movies This Month",
icon: IconMovie,
color: "text-primary",
bgColor: "bg-primary/10",
},
{
key: "episodesThisWeek" as const,
label: "Episodes This Week",
icon: IconPlayerPlay,
color: "text-status-watching",
bgColor: "bg-status-watching/10",
},
{
key: "librarySize" as const,
label: "In Library",
icon: IconLibrary,
color: "text-status-watchlist",
bgColor: "bg-status-watchlist/10",
},
{
key: "completed" as const,
label: "Completed",
icon: IconCheck,
color: "text-status-completed",
bgColor: "bg-status-completed/10",
},
];
const periodLabels: Record<TimePeriod, string> = {
today: "Today",
this_week: "This Week",
this_month: "This Month",
this_year: "This Year",
};
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{statDefs.map((def, i) => {
const Icon = def.icon;
const value = stats[def.key];
const periods: TimePeriod[] = ["today", "this_week", "this_month", "this_year"];
interface StatCardProps {
icon: React.ComponentType<{ className?: string }>;
color: string;
bgColor: string;
value: number;
index: number;
label: React.ReactNode;
loading?: boolean;
}
function StatCard({
icon: Icon,
color,
bgColor,
value,
index,
label,
loading,
}: StatCardProps) {
return (
<motion.div
key={def.key}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 24,
delay: i * 0.08,
delay: index * 0.08,
}}
className="rounded-xl border border-border/30 bg-card/50 p-4"
>
<div className="flex items-center gap-2">
<div
className={`flex h-6 w-6 items-center justify-center rounded-md ${def.bgColor}`}
className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}
>
<Icon className={`size-[13px] ${def.color}`} />
<Icon className={`size-[13px] ${color}`} />
</div>
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{def.label}
{label}
</span>
</div>
<motion.p
className={`mt-2 font-display text-2xl tabular-nums tracking-tight ${def.color}`}
className={`mt-2 font-display text-2xl tabular-nums tracking-tight ${color} transition-opacity ${loading ? "opacity-40" : ""}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.08 + 0.2 }}
animate={{ opacity: loading ? 0.4 : 1 }}
transition={{ delay: index * 0.08 + 0.2 }}
>
{value}
</motion.p>
</motion.div>
);
})}
}
function PeriodSelector({
noun,
period,
onPeriodChange,
}: {
noun: string;
period: TimePeriod;
onPeriodChange: (period: TimePeriod) => void;
}) {
return (
<span className="inline-flex items-baseline gap-1">
{noun}{" "}
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-0.5 border-b border-dotted border-muted-foreground/50 uppercase text-foreground/80 transition-colors hover:text-foreground">
{periodLabels[period]}
<IconChevronDown className="size-2.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={period}
onValueChange={(v) => onPeriodChange(v as TimePeriod)}
>
{periods.map((p) => (
<DropdownMenuRadioItem key={p} value={p}>
{periodLabels[p]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</span>
);
}
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
const [movieCount, setMovieCount] = useState(stats.moviesThisMonth);
const [episodeCount, setEpisodeCount] = useState(stats.episodesThisWeek);
const [movieLoading, setMovieLoading] = useState(false);
const [episodeLoading, setEpisodeLoading] = useState(false);
async function fetchCount(
type: "movies" | "episodes",
period: TimePeriod,
): Promise<number> {
const res = await fetch(`/api/stats?type=${type}&period=${period}`);
const data = await res.json();
return data.count;
}
async function handleMoviePeriodChange(period: TimePeriod) {
setMoviePeriod(period);
setMovieLoading(true);
const count = await fetchCount("movies", period);
setMovieCount(count);
setMovieLoading(false);
}
async function handleEpisodePeriodChange(period: TimePeriod) {
setEpisodePeriod(period);
setEpisodeLoading(true);
const count = await fetchCount("episodes", period);
setEpisodeCount(count);
setEpisodeLoading(false);
}
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
icon={IconMovie}
color="text-primary"
bgColor="bg-primary/10"
value={movieCount}
index={0}
loading={movieLoading}
label={
<PeriodSelector
noun="Movies"
period={moviePeriod}
onPeriodChange={handleMoviePeriodChange}
/>
}
/>
<StatCard
icon={IconPlayerPlay}
color="text-status-watching"
bgColor="bg-status-watching/10"
value={episodeCount}
index={1}
loading={episodeLoading}
label={
<PeriodSelector
noun="Episodes"
period={episodePeriod}
onPeriodChange={handleEpisodePeriodChange}
/>
}
/>
<StatCard
icon={IconLibrary}
color="text-status-watchlist"
bgColor="bg-status-watchlist/10"
value={stats.librarySize}
index={2}
label="In Library"
/>
<StatCard
icon={IconCheck}
color="text-status-completed"
bgColor="bg-status-completed/10"
value={stats.completed}
index={3}
label="Completed"
/>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { getWatchCount, type TimePeriod } from "@/lib/services/discovery";
const validTypes = ["movies", "episodes"] as const;
const validPeriods: TimePeriod[] = [
"today",
"this_week",
"this_month",
"this_year",
];
export async function GET(request: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const type = searchParams.get("type");
const period = searchParams.get("period");
if (
!type ||
!period ||
!validTypes.includes(type as (typeof validTypes)[number]) ||
!validPeriods.includes(period as TimePeriod)
) {
return NextResponse.json({ error: "Invalid parameters" }, { status: 400 });
}
const count = getWatchCount(
session.user.id,
type as "movies" | "episodes",
period as TimePeriod,
);
return NextResponse.json({ count });
}
+50 -30
View File
@@ -13,6 +13,52 @@ import {
} from "@/lib/db/schema";
import { tmdbImageUrl } from "@/lib/tmdb/image";
export type TimePeriod = "today" | "this_week" | "this_month" | "this_year";
export function periodStartTimestamp(period: TimePeriod): number {
const now = new Date();
let start: Date;
switch (period) {
case "today":
start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case "this_week": {
const dayOfWeek = now.getDay();
start = new Date(now);
start.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
start.setHours(0, 0, 0, 0);
break;
}
case "this_month":
start = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case "this_year":
start = new Date(now.getFullYear(), 0, 1);
break;
}
return Math.floor(start.getTime() / 1000);
}
export function getWatchCount(
userId: string,
table: "movies" | "episodes",
period: TimePeriod,
): number {
const timestamp = periodStartTimestamp(period);
const watchTable = table === "movies" ? userMovieWatches : userEpisodeWatches;
const [row] = db
.select({ count: sql<number>`count(*)` })
.from(watchTable)
.where(
and(
eq(watchTable.userId, userId),
sql`${watchTable.watchedAt} >= ${timestamp}`,
),
)
.all();
return row?.count ?? 0;
}
export interface DashboardStats {
moviesThisMonth: number;
episodesThisWeek: number;
@@ -21,34 +67,8 @@ export interface DashboardStats {
}
export function getUserStats(userId: string): DashboardStats {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const dayOfWeek = now.getDay();
const weekStart = new Date(now);
weekStart.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
weekStart.setHours(0, 0, 0, 0);
const [moviesThisMonth] = db
.select({ count: sql<number>`count(*)` })
.from(userMovieWatches)
.where(
and(
eq(userMovieWatches.userId, userId),
sql`${userMovieWatches.watchedAt} >= ${Math.floor(monthStart.getTime() / 1000)}`,
),
)
.all();
const [episodesThisWeek] = db
.select({ count: sql<number>`count(*)` })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
sql`${userEpisodeWatches.watchedAt} >= ${Math.floor(weekStart.getTime() / 1000)}`,
),
)
.all();
const moviesThisMonth = getWatchCount(userId, "movies", "this_month");
const episodesThisWeek = getWatchCount(userId, "episodes", "this_week");
const [librarySizeRow] = db
.select({ count: sql<number>`count(*)` })
@@ -68,8 +88,8 @@ export function getUserStats(userId: string): DashboardStats {
.all();
return {
moviesThisMonth: moviesThisMonth?.count ?? 0,
episodesThisWeek: episodesThisWeek?.count ?? 0,
moviesThisMonth,
episodesThisWeek,
librarySize: librarySizeRow?.count ?? 0,
completed: completedCount?.count ?? 0,
};