A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.

show source per day

Changed files
+165 -111
frontend
src
src
+7 -1
frontend/src/components/LinkList.tsx
··· 85 85 const baseUrl = window.location.origin 86 86 navigator.clipboard.writeText(`${baseUrl}/${shortCode}`) 87 87 toast({ 88 - description: "Link copied to clipboard", 88 + description: ( 89 + <> 90 + Link copied to clipboard 91 + <br /> 92 + You can add ?source=TextHere to the end of the link to track the source of clicks 93 + </> 94 + ), 89 95 }) 90 96 } 91 97
+148 -104
frontend/src/components/StatisticsModal.tsx
··· 1 1 import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 2 2 import { 3 - LineChart, 4 - Line, 5 - XAxis, 6 - YAxis, 7 - CartesianGrid, 8 - Tooltip, 9 - ResponsiveContainer, 3 + LineChart, 4 + Line, 5 + XAxis, 6 + YAxis, 7 + CartesianGrid, 8 + Tooltip, 9 + ResponsiveContainer, 10 10 } from "recharts"; 11 11 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 12 - import { toast } from "@/hooks/use-toast" 12 + import { toast } from "@/hooks/use-toast"; 13 13 import { useState, useEffect } from "react"; 14 14 15 - import { getLinkClickStats, getLinkSourceStats } from '../api/client'; 16 - import { ClickStats, SourceStats } from '../types/api'; 15 + import { getLinkClickStats, getLinkSourceStats } from "../api/client"; 16 + import { ClickStats, SourceStats } from "../types/api"; 17 17 18 18 interface StatisticsModalProps { 19 - isOpen: boolean; 20 - onClose: () => void; 21 - linkId: number; 19 + isOpen: boolean; 20 + onClose: () => void; 21 + linkId: number; 22 22 } 23 + 24 + interface EnhancedClickStats extends ClickStats { 25 + sources?: { source: string; count: number }[]; 26 + } 27 + 28 + const CustomTooltip = ({ 29 + active, 30 + payload, 31 + label, 32 + }: { 33 + active?: boolean; 34 + payload?: any[]; 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 + }; 23 60 24 61 export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) { 25 - const [clicksOverTime, setClicksOverTime] = useState<ClickStats[]>([]); 26 - const [sourcesData, setSourcesData] = useState<SourceStats[]>([]); 27 - const [loading, setLoading] = useState(true); 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 + ]); 28 75 29 - useEffect(() => { 30 - if (isOpen && linkId) { 31 - const fetchData = async () => { 32 - try { 33 - setLoading(true); 34 - const [clicksData, sourcesData] = await Promise.all([ 35 - getLinkClickStats(linkId), 36 - getLinkSourceStats(linkId), 37 - ]); 38 - setClicksOverTime(clicksData); 39 - setSourcesData(sourcesData); 40 - } catch (error: any) { 41 - console.error("Failed to fetch statistics:", error); 42 - toast({ 43 - variant: "destructive", 44 - title: "Error", 45 - description: error.response?.data || "Failed to load statistics", 46 - }); 47 - } finally { 48 - setLoading(false); 49 - } 50 - }; 51 - 52 - fetchData(); 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: any) { 85 + console.error("Failed to fetch statistics:", error); 86 + toast({ 87 + variant: "destructive", 88 + title: "Error", 89 + description: error.response?.data || "Failed to load statistics", 90 + }); 91 + } finally { 92 + setLoading(false); 53 93 } 54 - }, [isOpen, linkId]); 94 + }; 55 95 56 - return ( 57 - <Dialog open={isOpen} onOpenChange={onClose}> 58 - <DialogContent className="max-w-3xl"> 59 - <DialogHeader> 60 - <DialogTitle>Link Statistics</DialogTitle> 61 - </DialogHeader> 96 + fetchData(); 97 + } 98 + }, [isOpen, linkId]); 62 99 63 - {loading ? ( 64 - <div className="flex items-center justify-center h-64">Loading...</div> 65 - ) : ( 66 - <div className="grid gap-4"> 67 - <Card> 68 - <CardHeader> 69 - <CardTitle>Clicks Over Time</CardTitle> 70 - </CardHeader> 71 - <CardContent> 72 - <div className="h-[300px]"> 73 - <ResponsiveContainer width="100%" height="100%"> 74 - <LineChart data={clicksOverTime}> 75 - <CartesianGrid strokeDasharray="3 3" /> 76 - <XAxis dataKey="date" /> 77 - <YAxis /> 78 - <Tooltip /> 79 - <Line 80 - type="monotone" 81 - dataKey="clicks" 82 - stroke="#8884d8" 83 - strokeWidth={2} 84 - /> 85 - </LineChart> 86 - </ResponsiveContainer> 87 - </div> 88 - </CardContent> 89 - </Card> 100 + return ( 101 + <Dialog open={isOpen} onOpenChange={onClose}> 102 + <DialogContent className="max-w-3xl"> 103 + <DialogHeader> 104 + <DialogTitle>Link Statistics</DialogTitle> 105 + </DialogHeader> 90 106 91 - <Card> 92 - <CardHeader> 93 - <CardTitle>Top Sources</CardTitle> 94 - </CardHeader> 95 - <CardContent> 96 - <ul className="space-y-2"> 97 - {sourcesData.map((source, index) => ( 98 - <li 99 - key={source.source} 100 - className="flex items-center justify-between py-2 border-b last:border-0" 101 - > 102 - <span className="text-sm"> 103 - <span className="font-medium text-muted-foreground mr-2"> 104 - {index + 1}. 105 - </span> 106 - {source.source} 107 - </span> 108 - <span className="text-sm font-medium"> 109 - {source.count} clicks 110 - </span> 111 - </li> 112 - ))} 113 - </ul> 114 - </CardContent> 115 - </Card> 116 - </div> 117 - )} 118 - </DialogContent> 119 - </Dialog> 120 - ); 107 + {loading ? ( 108 + <div className="flex items-center justify-center h-64">Loading...</div> 109 + ) : ( 110 + <div className="grid gap-4"> 111 + <Card> 112 + <CardHeader> 113 + <CardTitle>Clicks Over Time</CardTitle> 114 + </CardHeader> 115 + <CardContent> 116 + <div className="h-[300px]"> 117 + <ResponsiveContainer width="100%" height="100%"> 118 + <LineChart data={clicksOverTime}> 119 + <CartesianGrid strokeDasharray="3 3" /> 120 + <XAxis dataKey="date" /> 121 + <YAxis /> 122 + <Tooltip content={<CustomTooltip />} /> 123 + <Line 124 + type="monotone" 125 + dataKey="clicks" 126 + stroke="#8884d8" 127 + strokeWidth={2} 128 + /> 129 + </LineChart> 130 + </ResponsiveContainer> 131 + </div> 132 + </CardContent> 133 + </Card> 134 + 135 + <Card> 136 + <CardHeader> 137 + <CardTitle>Top Sources</CardTitle> 138 + </CardHeader> 139 + <CardContent> 140 + <ul className="space-y-2"> 141 + {sourcesData.map((source, index) => ( 142 + <li 143 + key={source.source} 144 + className="flex items-center justify-between py-2 border-b last:border-0" 145 + > 146 + <span className="text-sm"> 147 + <span className="font-medium text-muted-foreground mr-2"> 148 + {index + 1}. 149 + </span> 150 + {source.source} 151 + </span> 152 + <span className="text-sm font-medium"> 153 + {source.count} clicks 154 + </span> 155 + </li> 156 + ))} 157 + </ul> 158 + </CardContent> 159 + </Card> 160 + </div> 161 + )} 162 + </DialogContent> 163 + </Dialog> 164 + ); 121 165 }
+1
frontend/src/types/api.ts
··· 32 32 } 33 33 34 34 export interface SourceStats { 35 + date: string; 35 36 source: string; 36 37 count: number; 37 38 }
+8 -6
src/handlers.rs
··· 643 643 sqlx::query_as::<_, SourceStats>( 644 644 r#" 645 645 SELECT 646 + DATE(created_at)::text as date, 646 647 query_source as source, 647 648 COUNT(*)::bigint as count 648 649 FROM clicks 649 650 WHERE link_id = $1 650 651 AND query_source IS NOT NULL 651 652 AND query_source != '' 652 - GROUP BY query_source 653 - ORDER BY COUNT(*) DESC 654 - LIMIT 10 653 + GROUP BY DATE(created_at), query_source 654 + ORDER BY DATE(created_at) ASC, COUNT(*) DESC 655 + LIMIT 300 655 656 "#, 656 657 ) 657 658 .bind(link_id) ··· 662 663 sqlx::query_as::<_, SourceStats>( 663 664 r#" 664 665 SELECT 666 + DATE(created_at) as date, 665 667 query_source as source, 666 668 COUNT(*) as count 667 669 FROM clicks 668 670 WHERE link_id = ? 669 671 AND query_source IS NOT NULL 670 672 AND query_source != '' 671 - GROUP BY query_source 672 - ORDER BY COUNT(*) DESC 673 - LIMIT 10 673 + GROUP BY DATE(created_at), query_source 674 + ORDER BY DATE(created_at) ASC, COUNT(*) DESC 675 + LIMIT 300 674 676 "#, 675 677 ) 676 678 .bind(link_id)
+1
src/models.rs
··· 150 150 151 151 #[derive(sqlx::FromRow, Serialize)] 152 152 pub struct SourceStats { 153 + pub date: String, 153 154 pub source: String, 154 155 pub count: i64, 155 156 }