diff --git a/frontend/src/components/LinkList.tsx b/frontend/src/components/LinkList.tsx
index 1911889..6f768cf 100644
--- a/frontend/src/components/LinkList.tsx
+++ b/frontend/src/components/LinkList.tsx
@@ -85,7 +85,13 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
const baseUrl = window.location.origin
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
toast({
- description: "Link copied to clipboard",
+ description: (
+ <>
+ Link copied to clipboard
+
+ You can add ?source=TextHere to the end of the link to track the source of clicks
+ >
+ ),
})
}
diff --git a/frontend/src/components/StatisticsModal.tsx b/frontend/src/components/StatisticsModal.tsx
index 1df2bc4..db936dd 100644
--- a/frontend/src/components/StatisticsModal.tsx
+++ b/frontend/src/components/StatisticsModal.tsx
@@ -1,121 +1,165 @@
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 (
+
+
{label}
+
Clicks: {data.clicks}
+ {data.sources && data.sources.length > 0 && (
+
+
Sources:
+
+ {data.sources.map((source: { source: string; count: number }) => (
+ -
+ {source.source}: {source.count}
+
+ ))}
+
+
+ )}
+
+ );
+ }
+ return null;
+};
+
export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
- const [clicksOverTime, setClicksOverTime] = useState([]);
- const [sourcesData, setSourcesData] = useState([]);
- const [loading, setLoading] = useState(true);
+ const [clicksOverTime, setClicksOverTime] = useState([]);
+ const [sourcesData, setSourcesData] = useState([]);
+ 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),
- ]);
- 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();
+ useEffect(() => {
+ if (isOpen && linkId) {
+ const fetchData = async () => {
+ try {
+ setLoading(true);
+ const [clicksData, sourcesData] = await Promise.all([
+ getLinkClickStats(linkId),
+ getLinkSourceStats(linkId),
+ ]);
+
+ // 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 (
-
- );
+ {loading ? (
+ Loading...
+ ) : (
+
+
+
+ Clicks Over Time
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+ Top Sources
+
+
+
+ {sourcesData.map((source, index) => (
+ -
+
+
+ {index + 1}.
+
+ {source.source}
+
+
+ {source.count} clicks
+
+
+ ))}
+
+
+
+
+ )}
+
+
+ );
}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 2d30756..a2b5322 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -32,6 +32,7 @@ export interface ClickStats {
}
export interface SourceStats {
+ date: string;
source: string;
count: number;
}
diff --git a/src/handlers.rs b/src/handlers.rs
index 7d505c3..6f0a488 100644
--- a/src/handlers.rs
+++ b/src/handlers.rs
@@ -643,15 +643,16 @@ pub async fn get_link_sources(
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)
@@ -662,15 +663,16 @@ pub async fn get_link_sources(
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)
diff --git a/src/models.rs b/src/models.rs
index 9e97dcf..1af84da 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -150,6 +150,7 @@ pub struct ClickStats {
#[derive(sqlx::FromRow, Serialize)]
pub struct SourceStats {
+ pub date: String,
pub source: String,
pub count: i64,
}