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
···
const baseUrl = window.location.origin
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
toast({
-
description: "Link copied to clipboard",
+
description: (
+
<>
+
Link copied to clipboard
+
<br />
+
You can add ?source=TextHere to the end of the link to track the source of clicks
+
</>
+
),
})
}
+148 -104
frontend/src/components/StatisticsModal.tsx
···
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
-
LineChart,
-
Line,
-
XAxis,
-
YAxis,
-
CartesianGrid,
-
Tooltip,
-
ResponsiveContainer,
+
LineChart,
+
Line,
+
XAxis,
+
YAxis,
+
CartesianGrid,
+
Tooltip,
+
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-
import { toast } from "@/hooks/use-toast"
+
import { toast } from "@/hooks/use-toast";
import { useState, useEffect } from "react";
-
import { getLinkClickStats, getLinkSourceStats } from '../api/client';
-
import { ClickStats, SourceStats } from '../types/api';
+
import { getLinkClickStats, getLinkSourceStats } from "../api/client";
+
import { ClickStats, SourceStats } from "../types/api";
interface StatisticsModalProps {
-
isOpen: boolean;
-
onClose: () => void;
-
linkId: number;
+
isOpen: boolean;
+
onClose: () => void;
+
linkId: number;
}
+
+
interface EnhancedClickStats extends ClickStats {
+
sources?: { source: string; count: number }[];
+
}
+
+
const CustomTooltip = ({
+
active,
+
payload,
+
label,
+
}: {
+
active?: boolean;
+
payload?: any[];
+
label?: string;
+
}) => {
+
if (active && payload && payload.length > 0) {
+
const data = payload[0].payload;
+
return (
+
<div className="bg-background text-foreground p-4 rounded-lg shadow-lg border">
+
<p className="font-medium">{label}</p>
+
<p className="text-sm">Clicks: {data.clicks}</p>
+
{data.sources && data.sources.length > 0 && (
+
<div className="mt-2">
+
<p className="font-medium text-sm">Sources:</p>
+
<ul className="text-sm">
+
{data.sources.map((source: { source: string; count: number }) => (
+
<li key={source.source}>
+
{source.source}: {source.count}
+
</li>
+
))}
+
</ul>
+
</div>
+
)}
+
</div>
+
);
+
}
+
return null;
+
};
export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
-
const [clicksOverTime, setClicksOverTime] = useState<ClickStats[]>([]);
-
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
-
const [loading, setLoading] = useState(true);
+
const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]);
+
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
+
const [loading, setLoading] = useState(true);
+
+
useEffect(() => {
+
if (isOpen && linkId) {
+
const fetchData = async () => {
+
try {
+
setLoading(true);
+
const [clicksData, sourcesData] = await Promise.all([
+
getLinkClickStats(linkId),
+
getLinkSourceStats(linkId),
+
]);
-
useEffect(() => {
-
if (isOpen && linkId) {
-
const fetchData = async () => {
-
try {
-
setLoading(true);
-
const [clicksData, sourcesData] = await Promise.all([
-
getLinkClickStats(linkId),
-
getLinkSourceStats(linkId),
-
]);
-
setClicksOverTime(clicksData);
-
setSourcesData(sourcesData);
-
} catch (error: any) {
-
console.error("Failed to fetch statistics:", error);
-
toast({
-
variant: "destructive",
-
title: "Error",
-
description: error.response?.data || "Failed to load statistics",
-
});
-
} finally {
-
setLoading(false);
-
}
-
};
-
-
fetchData();
+
// Enhance clicks data with source information
+
const enhancedClicksData = clicksData.map((clickData) => ({
+
...clickData,
+
sources: sourcesData.filter((source) => source.date === clickData.date),
+
}));
+
+
setClicksOverTime(enhancedClicksData);
+
setSourcesData(sourcesData);
+
} catch (error: any) {
+
console.error("Failed to fetch statistics:", error);
+
toast({
+
variant: "destructive",
+
title: "Error",
+
description: error.response?.data || "Failed to load statistics",
+
});
+
} finally {
+
setLoading(false);
}
-
}, [isOpen, linkId]);
+
};
-
return (
-
<Dialog open={isOpen} onOpenChange={onClose}>
-
<DialogContent className="max-w-3xl">
-
<DialogHeader>
-
<DialogTitle>Link Statistics</DialogTitle>
-
</DialogHeader>
+
fetchData();
+
}
+
}, [isOpen, linkId]);
-
{loading ? (
-
<div className="flex items-center justify-center h-64">Loading...</div>
-
) : (
-
<div className="grid gap-4">
-
<Card>
-
<CardHeader>
-
<CardTitle>Clicks Over Time</CardTitle>
-
</CardHeader>
-
<CardContent>
-
<div className="h-[300px]">
-
<ResponsiveContainer width="100%" height="100%">
-
<LineChart data={clicksOverTime}>
-
<CartesianGrid strokeDasharray="3 3" />
-
<XAxis dataKey="date" />
-
<YAxis />
-
<Tooltip />
-
<Line
-
type="monotone"
-
dataKey="clicks"
-
stroke="#8884d8"
-
strokeWidth={2}
-
/>
-
</LineChart>
-
</ResponsiveContainer>
-
</div>
-
</CardContent>
-
</Card>
+
return (
+
<Dialog open={isOpen} onOpenChange={onClose}>
+
<DialogContent className="max-w-3xl">
+
<DialogHeader>
+
<DialogTitle>Link Statistics</DialogTitle>
+
</DialogHeader>
-
<Card>
-
<CardHeader>
-
<CardTitle>Top Sources</CardTitle>
-
</CardHeader>
-
<CardContent>
-
<ul className="space-y-2">
-
{sourcesData.map((source, index) => (
-
<li
-
key={source.source}
-
className="flex items-center justify-between py-2 border-b last:border-0"
-
>
-
<span className="text-sm">
-
<span className="font-medium text-muted-foreground mr-2">
-
{index + 1}.
-
</span>
-
{source.source}
-
</span>
-
<span className="text-sm font-medium">
-
{source.count} clicks
-
</span>
-
</li>
-
))}
-
</ul>
-
</CardContent>
-
</Card>
-
</div>
-
)}
-
</DialogContent>
-
</Dialog>
-
);
+
{loading ? (
+
<div className="flex items-center justify-center h-64">Loading...</div>
+
) : (
+
<div className="grid gap-4">
+
<Card>
+
<CardHeader>
+
<CardTitle>Clicks Over Time</CardTitle>
+
</CardHeader>
+
<CardContent>
+
<div className="h-[300px]">
+
<ResponsiveContainer width="100%" height="100%">
+
<LineChart data={clicksOverTime}>
+
<CartesianGrid strokeDasharray="3 3" />
+
<XAxis dataKey="date" />
+
<YAxis />
+
<Tooltip content={<CustomTooltip />} />
+
<Line
+
type="monotone"
+
dataKey="clicks"
+
stroke="#8884d8"
+
strokeWidth={2}
+
/>
+
</LineChart>
+
</ResponsiveContainer>
+
</div>
+
</CardContent>
+
</Card>
+
+
<Card>
+
<CardHeader>
+
<CardTitle>Top Sources</CardTitle>
+
</CardHeader>
+
<CardContent>
+
<ul className="space-y-2">
+
{sourcesData.map((source, index) => (
+
<li
+
key={source.source}
+
className="flex items-center justify-between py-2 border-b last:border-0"
+
>
+
<span className="text-sm">
+
<span className="font-medium text-muted-foreground mr-2">
+
{index + 1}.
+
</span>
+
{source.source}
+
</span>
+
<span className="text-sm font-medium">
+
{source.count} clicks
+
</span>
+
</li>
+
))}
+
</ul>
+
</CardContent>
+
</Card>
+
</div>
+
)}
+
</DialogContent>
+
</Dialog>
+
);
}
+1
frontend/src/types/api.ts
···
}
export interface SourceStats {
+
date: string;
source: string;
count: number;
}
+8 -6
src/handlers.rs
···
sqlx::query_as::<_, SourceStats>(
r#"
SELECT
+
DATE(created_at)::text as date,
query_source as source,
COUNT(*)::bigint as count
FROM clicks
WHERE link_id = $1
AND query_source IS NOT NULL
AND query_source != ''
-
GROUP BY query_source
-
ORDER BY COUNT(*) DESC
-
LIMIT 10
+
GROUP BY DATE(created_at), query_source
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
+
LIMIT 300
"#,
)
.bind(link_id)
···
sqlx::query_as::<_, SourceStats>(
r#"
SELECT
+
DATE(created_at) as date,
query_source as source,
COUNT(*) as count
FROM clicks
WHERE link_id = ?
AND query_source IS NOT NULL
AND query_source != ''
-
GROUP BY query_source
-
ORDER BY COUNT(*) DESC
-
LIMIT 10
+
GROUP BY DATE(created_at), query_source
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
+
LIMIT 300
"#,
)
.bind(link_id)
+1
src/models.rs
···
#[derive(sqlx::FromRow, Serialize)]
pub struct SourceStats {
+
pub date: String,
pub source: String,
pub count: i64,
}