Skip to content

Commit 5af90cf

Browse files
committed
feat(analytics): add category popularity chart widget
- Add CategoryPopularityChart.tsx: horizontal bar chart ranking categories by unique users or puzzles solved, with a metric toggle and date range controls matching existing analytics widgets - Bars are re-sorted descending client-side on data/metric change - Add getCategoryPopularity API function and types to analyticsApi.ts for GET /analytics/categories/popularity
1 parent 64de1fa commit 5af90cf

2 files changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// frontend/components/analytics/CategoryPopularityChart.tsx
2+
"use client";
3+
4+
import { useMemo, useState } from "react";
5+
import { useQuery } from "@tanstack/react-query";
6+
import {
7+
ResponsiveContainer,
8+
BarChart,
9+
Bar,
10+
XAxis,
11+
YAxis,
12+
CartesianGrid,
13+
Tooltip,
14+
} from "recharts";
15+
import { CalendarRange, SlidersHorizontal, X } from "lucide-react";
16+
import {
17+
getCategoryPopularity,
18+
type CategoryPopularityDataPoint,
19+
} from "../../lib/api/analyticsApi";
20+
21+
type Metric = "uniqueUsers" | "puzzlesSolved";
22+
23+
const METRICS: { label: string; value: Metric }[] = [
24+
{ label: "Unique Users", value: "uniqueUsers" },
25+
{ label: "Puzzles Solved", value: "puzzlesSolved" },
26+
];
27+
28+
function formatDate(date: Date): string {
29+
return date.toISOString().slice(0, 10);
30+
}
31+
32+
function getDefaultRange() {
33+
const end = new Date();
34+
const start = new Date();
35+
start.setDate(start.getDate() - 29); // last 30 days
36+
return { startDate: formatDate(start), endDate: formatDate(end) };
37+
}
38+
39+
const PRESETS = [
40+
{ label: "7D", days: 7 },
41+
{ label: "30D", days: 30 },
42+
{ label: "90D", days: 90 },
43+
];
44+
45+
export default function CategoryPopularityChart() {
46+
const defaultRange = useMemo(getDefaultRange, []);
47+
const [startDate, setStartDate] = useState(defaultRange.startDate);
48+
const [endDate, setEndDate] = useState(defaultRange.endDate);
49+
const [metric, setMetric] = useState<Metric>("uniqueUsers");
50+
const [filtersOpen, setFiltersOpen] = useState(false);
51+
52+
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
53+
queryKey: ["category-popularity", startDate, endDate],
54+
queryFn: () => getCategoryPopularity({ startDate, endDate }),
55+
});
56+
57+
function applyPreset(days: number) {
58+
const end = new Date();
59+
const start = new Date();
60+
start.setDate(start.getDate() - (days - 1));
61+
setStartDate(formatDate(start));
62+
setEndDate(formatDate(end));
63+
}
64+
65+
const chartData: CategoryPopularityDataPoint[] = useMemo(() => {
66+
const rows = data?.data ?? [];
67+
return [...rows].sort((a, b) => b[metric] - a[metric]);
68+
}, [data, metric]);
69+
70+
const hasData = chartData.length > 0;
71+
const metricLabel = METRICS.find((m) => m.value === metric)?.label ?? "";
72+
73+
const controls = (
74+
<div className="flex flex-wrap items-center gap-2">
75+
{PRESETS.map((preset) => (
76+
<button
77+
key={preset.label}
78+
type="button"
79+
onClick={() => applyPreset(preset.days)}
80+
className="rounded-lg border border-slate-700 px-3 py-1.5 text-xs text-slate-300 transition hover:border-blue-500/60 hover:text-white"
81+
>
82+
{preset.label}
83+
</button>
84+
))}
85+
86+
<div className="flex items-center gap-2 rounded-lg border border-slate-700 px-3 py-1.5">
87+
<CalendarRange className="h-4 w-4 text-slate-400" />
88+
<input
89+
type="date"
90+
value={startDate}
91+
max={endDate}
92+
onChange={(e) => setStartDate(e.target.value)}
93+
className="bg-transparent text-xs text-slate-200 outline-none [color-scheme:dark]"
94+
aria-label="Start date"
95+
/>
96+
<span className="text-slate-500"></span>
97+
<input
98+
type="date"
99+
value={endDate}
100+
min={startDate}
101+
max={formatDate(new Date())}
102+
onChange={(e) => setEndDate(e.target.value)}
103+
className="bg-transparent text-xs text-slate-200 outline-none [color-scheme:dark]"
104+
aria-label="End date"
105+
/>
106+
</div>
107+
</div>
108+
);
109+
110+
return (
111+
<div className="min-w-0 rounded-2xl border border-slate-800 bg-[#101B30] p-4 sm:p-6">
112+
<div className="flex flex-wrap items-center justify-between gap-3">
113+
<div>
114+
<h2 className="text-lg font-semibold text-white">
115+
Category Popularity
116+
</h2>
117+
<p className="text-xs text-slate-400">
118+
Categories ranked by {metricLabel.toLowerCase()}
119+
</p>
120+
</div>
121+
122+
{/* Desktop / wide tablet: controls shown inline */}
123+
<div className="hidden md:block">{controls}</div>
124+
125+
{/* Narrow tablet & mobile: controls collapse behind this toggle */}
126+
<button
127+
type="button"
128+
onClick={() => setFiltersOpen((open) => !open)}
129+
className="flex items-center gap-2 rounded-lg border border-slate-700 px-3 py-1.5 text-xs text-slate-300 hover:border-blue-500/60 hover:text-white md:hidden"
130+
>
131+
{filtersOpen ? (
132+
<X className="h-4 w-4" />
133+
) : (
134+
<SlidersHorizontal className="h-4 w-4" />
135+
)}
136+
Filters
137+
</button>
138+
</div>
139+
140+
{/* Collapsible controls panel, only rendered below md */}
141+
{filtersOpen && (
142+
<div className="mt-3 rounded-xl border border-slate-700 bg-[#0A0F1A]/60 p-3 md:hidden">
143+
{controls}
144+
</div>
145+
)}
146+
147+
<div
148+
role="tablist"
149+
aria-label="Ranking metric"
150+
className="mt-4 inline-flex w-fit rounded-lg border border-slate-700 p-1"
151+
>
152+
{METRICS.map((option) => {
153+
const isActive = option.value === metric;
154+
return (
155+
<button
156+
key={option.value}
157+
type="button"
158+
role="tab"
159+
aria-selected={isActive}
160+
onClick={() => setMetric(option.value)}
161+
className={`rounded-md px-3 py-1.5 text-xs font-medium transition ${
162+
isActive
163+
? "bg-blue-500/20 text-blue-200"
164+
: "text-slate-400 hover:text-white"
165+
}`}
166+
>
167+
{option.label}
168+
</button>
169+
);
170+
})}
171+
</div>
172+
173+
<div
174+
className="mt-6 w-full"
175+
style={{ height: Math.max(256, chartData.length * 40) }}
176+
>
177+
{isLoading && (
178+
<div className="flex h-full items-center justify-center text-sm text-slate-400">
179+
Loading category data...
180+
</div>
181+
)}
182+
183+
{!isLoading && isError && (
184+
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-red-400">
185+
<span>
186+
Couldn&apos;t load category popularity data
187+
{error instanceof Error ? `: ${error.message}` : "."}
188+
</span>
189+
<button
190+
type="button"
191+
onClick={() => refetch()}
192+
className="rounded-lg border border-red-400/40 px-3 py-1.5 text-xs text-red-300 hover:bg-red-400/10"
193+
>
194+
Try again
195+
</button>
196+
</div>
197+
)}
198+
199+
{!isLoading && !isError && !hasData && (
200+
<div className="flex h-full items-center justify-center text-sm text-slate-400">
201+
No category data for this date range yet.
202+
</div>
203+
)}
204+
205+
{!isLoading && !isError && hasData && (
206+
<ResponsiveContainer width="100%" height="100%">
207+
<BarChart
208+
data={chartData}
209+
layout="vertical"
210+
margin={{ top: 8, right: 24, left: 0, bottom: 0 }}
211+
barCategoryGap={12}
212+
>
213+
<CartesianGrid
214+
strokeDasharray="3 3"
215+
stroke="#1e293b"
216+
horizontal={false}
217+
/>
218+
<XAxis
219+
type="number"
220+
stroke="#64748b"
221+
tick={{ fontSize: 12 }}
222+
allowDecimals={false}
223+
/>
224+
<YAxis
225+
type="category"
226+
dataKey="categoryName"
227+
stroke="#64748b"
228+
tick={{ fontSize: 12 }}
229+
width={120}
230+
/>
231+
<Tooltip
232+
cursor={{ fill: "rgba(148, 163, 184, 0.08)" }}
233+
contentStyle={{
234+
background: "#0A0F1A",
235+
border: "1px solid #1e293b",
236+
borderRadius: 8,
237+
color: "#e2e8f0",
238+
}}
239+
formatter={(value) => [value, metricLabel]}
240+
/>
241+
<Bar
242+
dataKey={metric}
243+
name={metricLabel}
244+
fill="#3b82f6"
245+
radius={[0, 4, 4, 0]}
246+
maxBarSize={28}
247+
/>
248+
</BarChart>
249+
</ResponsiveContainer>
250+
)}
251+
252+
{isFetching && !isLoading && (
253+
<div className="mt-2 text-right text-[10px] text-slate-500">
254+
Refreshing…
255+
</div>
256+
)}
257+
</div>
258+
</div>
259+
);
260+
}

frontend/lib/api/analyticsApi.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,34 @@ export async function getRetentionMetrics(
5454
params,
5555
});
5656
return response.data;
57+
}
58+
59+
export interface CategoryPopularityDataPoint {
60+
categoryId: string;
61+
categoryName: string;
62+
uniqueUsers: number;
63+
puzzlesSolved: number;
64+
}
65+
66+
export interface CategoryPopularityResponse {
67+
startDate: string;
68+
endDate: string;
69+
granularity: string;
70+
data: CategoryPopularityDataPoint[];
71+
total: number;
72+
}
73+
74+
export interface CategoryPopularityParams {
75+
startDate: string; // YYYY-MM-DD
76+
endDate: string; // YYYY-MM-DD
77+
}
78+
79+
export async function getCategoryPopularity(
80+
params: CategoryPopularityParams,
81+
): Promise<CategoryPopularityResponse> {
82+
const response = await api.get<CategoryPopularityResponse>(
83+
'/analytics/categories/popularity',
84+
{ params },
85+
);
86+
return response.data;
5787
}

0 commit comments

Comments
 (0)