A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.
1import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
2import {
3 LineChart,
4 Line,
5 XAxis,
6 YAxis,
7 CartesianGrid,
8 Tooltip,
9 ResponsiveContainer,
10} from "recharts";
11import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
12import { toast } from "@/hooks/use-toast";
13import { useState, useEffect, useMemo } from "react";
14
15import { getLinkClickStats, getLinkSourceStats } from "../api/client";
16import { ClickStats, SourceStats } from "../types/api";
17
18interface StatisticsModalProps {
19 isOpen: boolean;
20 onClose: () => void;
21 linkId: number;
22}
23
24interface EnhancedClickStats extends ClickStats {
25 sources?: { source: string; count: number }[];
26}
27
28const CustomTooltip = ({
29 active,
30 payload,
31 label,
32}: {
33 active?: boolean;
34 payload?: { value: number; payload: EnhancedClickStats }[];
35 label?: string;
36}) => {
37 if (active && payload && payload.length > 0) {
38 const data = payload[0].payload;
39 return (
40 <div className="bg-background text-foreground p-4 rounded-lg shadow-lg border">
41 <p className="font-medium">{label}</p>
42 <p className="text-sm">Clicks: {data.clicks}</p>
43 {data.sources && data.sources.length > 0 && (
44 <div className="mt-2">
45 <p className="font-medium text-sm">Sources:</p>
46 <ul className="text-sm">
47 {data.sources.map((source: { source: string; count: number }) => (
48 <li key={source.source}>
49 {source.source}: {source.count}
50 </li>
51 ))}
52 </ul>
53 </div>
54 )}
55 </div>
56 );
57 }
58 return null;
59};
60
61export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
62 const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]);
63 const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
64 const [loading, setLoading] = useState(true);
65
66 useEffect(() => {
67 if (isOpen && linkId) {
68 const fetchData = async () => {
69 try {
70 setLoading(true);
71 const [clicksData, sourcesData] = await Promise.all([
72 getLinkClickStats(linkId),
73 getLinkSourceStats(linkId),
74 ]);
75
76 // Enhance clicks data with source information
77 const enhancedClicksData = clicksData.map((clickData) => ({
78 ...clickData,
79 sources: sourcesData.filter((source) => source.date === clickData.date),
80 }));
81
82 setClicksOverTime(enhancedClicksData);
83 setSourcesData(sourcesData);
84 } catch (error: unknown) {
85 console.error("Failed to fetch statistics:", error);
86 toast({
87 variant: "destructive",
88 title: "Error",
89 description: error instanceof Error ? error.message : "Failed to load statistics",
90 });
91 } finally {
92 setLoading(false);
93 }
94 };
95
96 fetchData();
97 }
98 }, [isOpen, linkId]);
99
100 const aggregatedSources = useMemo(() => {
101 const sourceMap = sourcesData.reduce<Record<string, number>>(
102 (acc, { source, count }) => ({
103 ...acc,
104 [source]: (acc[source] || 0) + count
105 }),
106 {}
107 );
108
109 return Object.entries(sourceMap)
110 .map(([source, count]) => ({ source, count }))
111 .sort((a, b) => b.count - a.count);
112 }, [sourcesData]);
113
114 return (
115 <Dialog open={isOpen} onOpenChange={onClose}>
116 <DialogContent className="max-w-3xl">
117 <DialogHeader>
118 <DialogTitle>Link Statistics</DialogTitle>
119 </DialogHeader>
120
121 {loading ? (
122 <div className="flex items-center justify-center h-64">Loading...</div>
123 ) : (
124 <div className="grid gap-4">
125 <Card>
126 <CardHeader>
127 <CardTitle>Clicks Over Time</CardTitle>
128 </CardHeader>
129 <CardContent>
130 <div className="h-[300px]">
131 <ResponsiveContainer width="100%" height="100%">
132 <LineChart data={clicksOverTime}>
133 <CartesianGrid strokeDasharray="3 3" />
134 <XAxis dataKey="date" />
135 <YAxis />
136 <Tooltip content={<CustomTooltip />} />
137 <Line
138 type="monotone"
139 dataKey="clicks"
140 stroke="#8884d8"
141 strokeWidth={2}
142 />
143 </LineChart>
144 </ResponsiveContainer>
145 </div>
146 </CardContent>
147 </Card>
148
149 <Card>
150 <CardHeader>
151 <CardTitle>Top Sources</CardTitle>
152 </CardHeader>
153 <CardContent>
154 <ul className="space-y-2">
155 {aggregatedSources.map((source, index) => (
156 <li
157 key={source.source}
158 className="flex items-center justify-between py-2 border-b last:border-0"
159 >
160 <span className="text-sm">
161 <span className="font-medium text-muted-foreground mr-2">
162 {index + 1}.
163 </span>
164 {source.source}
165 </span>
166 <span className="text-sm font-medium">{source.count} clicks</span>
167 </li>
168 ))}
169 </ul>
170 </CardContent>
171 </Card>
172 </div>
173 )}
174 </DialogContent>
175 </Dialog>
176 );
177}