mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user