Add sparkline charts to stats cards, use rolling periods, upgrade recharts to v3

Background sparkline area charts on Movies and Episodes cards show watch
activity distribution over the selected period. Periods now use rolling
windows (past 24h/7d/30d/365d) instead of calendar boundaries. Bumped
stats grid breakpoint to lg. Upgraded recharts 2.15.4 → 3.7.0 and fixed
chart.tsx types for v3 (TooltipContentProps, LegendPayload).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 21:13:43 -05:00
co-authored by Claude Opus 4.6
parent 1b7f64d327
commit 36afbcb8c0
7 changed files with 218 additions and 66 deletions
@@ -0,0 +1,46 @@
"use client";
import { useId } from "react";
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
interface SparklineProps {
data: Array<{ bucket: string; count: number }>;
color: string;
}
export function Sparkline({ data, color }: SparklineProps) {
const uniqueId = useId();
const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`;
if (!data.some((d) => d.count > 0)) return null;
return (
<div
className={`pointer-events-none absolute inset-0 overflow-hidden ${color}`}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity={0.08} />
<stop offset="100%" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis domain={[0, "auto"]} hide />
<Area
type="monotone"
dataKey="count"
stroke="currentColor"
strokeWidth={1}
strokeOpacity={0.15}
fill={`url(#${gradientId})`}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
@@ -8,7 +8,7 @@ import {
IconPlayerPlay,
} from "@tabler/icons-react";
import { motion } from "motion/react";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,7 +16,12 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { DashboardStats, TimePeriod } from "@/lib/services/discovery";
import type {
DashboardStats,
HistoryBucket,
TimePeriod,
} from "@/lib/services/discovery";
import { Sparkline } from "./sparkline";
const periodLabels: Record<TimePeriod, string> = {
today: "Today",
@@ -35,6 +40,7 @@ interface StatCardProps {
index: number;
label: React.ReactNode;
loading?: boolean;
sparklineData?: HistoryBucket[];
}
function StatCard({
@@ -45,6 +51,7 @@ function StatCard({
index,
label,
loading,
sparklineData,
}: StatCardProps) {
return (
<motion.div
@@ -56,9 +63,10 @@ function StatCard({
damping: 24,
delay: index * 0.08,
}}
className="rounded-xl border border-border/30 bg-card/50 p-4"
className="relative overflow-hidden rounded-xl border border-border/30 bg-card/50 p-4"
>
<div className="flex items-center gap-2">
{sparklineData && <Sparkline data={sparklineData} color={color} />}
<div className="relative z-10 flex items-center gap-2">
<div
className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}
>
@@ -69,7 +77,7 @@ function StatCard({
</span>
</div>
<motion.p
className={`mt-2 font-display text-2xl tabular-nums tracking-tight ${color} transition-opacity ${loading ? "opacity-40" : ""}`}
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} transition-opacity ${loading ? "opacity-40" : ""}`}
initial={{ opacity: 0 }}
animate={{ opacity: loading ? 0.4 : 1 }}
transition={{ delay: index * 0.08 + 0.2 }}
@@ -114,6 +122,16 @@ function PeriodSelector({
);
}
async function fetchStats(
type: "movies" | "episodes",
period: TimePeriod,
): Promise<{ count: number; history: HistoryBucket[] }> {
const res = await fetch(
`/api/stats?type=${type}&period=${period}&history=true`,
);
return res.json();
}
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
@@ -121,34 +139,36 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
const [episodeCount, setEpisodeCount] = useState(stats.episodesThisWeek);
const [movieLoading, setMovieLoading] = useState(false);
const [episodeLoading, setEpisodeLoading] = useState(false);
const [movieHistory, setMovieHistory] = useState<HistoryBucket[]>();
const [episodeHistory, setEpisodeHistory] = useState<HistoryBucket[]>();
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;
}
useEffect(() => {
fetchStats("movies", "this_month").then((d) => setMovieHistory(d.history));
fetchStats("episodes", "this_week").then((d) =>
setEpisodeHistory(d.history),
);
}, []);
async function handleMoviePeriodChange(period: TimePeriod) {
setMoviePeriod(period);
setMovieLoading(true);
const count = await fetchCount("movies", period);
setMovieCount(count);
const data = await fetchStats("movies", period);
setMovieCount(data.count);
setMovieHistory(data.history);
setMovieLoading(false);
}
async function handleEpisodePeriodChange(period: TimePeriod) {
setEpisodePeriod(period);
setEpisodeLoading(true);
const count = await fetchCount("episodes", period);
setEpisodeCount(count);
const data = await fetchStats("episodes", period);
setEpisodeCount(data.count);
setEpisodeHistory(data.history);
setEpisodeLoading(false);
}
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
icon={IconMovie}
color="text-primary"
@@ -156,6 +176,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
value={movieCount}
index={0}
loading={movieLoading}
sparklineData={movieHistory}
label={
<PeriodSelector
noun="Movies"
@@ -171,6 +192,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
value={episodeCount}
index={1}
loading={episodeLoading}
sparklineData={episodeHistory}
label={
<PeriodSelector
noun="Episodes"