show source per day

This commit is contained in:
Wavering Ana 2025-01-31 20:25:44 -05:00
parent d1202a03fa
commit 26e0a4f92f
5 changed files with 165 additions and 111 deletions

View file

@ -85,7 +85,13 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
const baseUrl = window.location.origin const baseUrl = window.location.origin
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`) navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
toast({ 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
</>
),
}) })
} }

View file

@ -1,121 +1,165 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { import {
LineChart, LineChart,
Line, Line,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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 { useState, useEffect } from "react";
import { getLinkClickStats, getLinkSourceStats } from '../api/client'; import { getLinkClickStats, getLinkSourceStats } from "../api/client";
import { ClickStats, SourceStats } from '../types/api'; import { ClickStats, SourceStats } from "../types/api";
interface StatisticsModalProps { interface StatisticsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
linkId: number; 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) { export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
const [clicksOverTime, setClicksOverTime] = useState<ClickStats[]>([]); const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]);
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]); const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (isOpen && linkId) { if (isOpen && linkId) {
const fetchData = async () => { const fetchData = async () => {
try { try {
setLoading(true); setLoading(true);
const [clicksData, sourcesData] = await Promise.all([ const [clicksData, sourcesData] = await Promise.all([
getLinkClickStats(linkId), getLinkClickStats(linkId),
getLinkSourceStats(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 ( fetchData();
<Dialog open={isOpen} onOpenChange={onClose}> }
<DialogContent className="max-w-3xl"> }, [isOpen, linkId]);
<DialogHeader>
<DialogTitle>Link Statistics</DialogTitle>
</DialogHeader>
{loading ? ( return (
<div className="flex items-center justify-center h-64">Loading...</div> <Dialog open={isOpen} onOpenChange={onClose}>
) : ( <DialogContent className="max-w-3xl">
<div className="grid gap-4"> <DialogHeader>
<Card> <DialogTitle>Link Statistics</DialogTitle>
<CardHeader> </DialogHeader>
<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>
<Card> {loading ? (
<CardHeader> <div className="flex items-center justify-center h-64">Loading...</div>
<CardTitle>Top Sources</CardTitle> ) : (
</CardHeader> <div className="grid gap-4">
<CardContent> <Card>
<ul className="space-y-2"> <CardHeader>
{sourcesData.map((source, index) => ( <CardTitle>Clicks Over Time</CardTitle>
<li </CardHeader>
key={source.source} <CardContent>
className="flex items-center justify-between py-2 border-b last:border-0" <div className="h-[300px]">
> <ResponsiveContainer width="100%" height="100%">
<span className="text-sm"> <LineChart data={clicksOverTime}>
<span className="font-medium text-muted-foreground mr-2"> <CartesianGrid strokeDasharray="3 3" />
{index + 1}. <XAxis dataKey="date" />
</span> <YAxis />
{source.source} <Tooltip content={<CustomTooltip />} />
</span> <Line
<span className="text-sm font-medium"> type="monotone"
{source.count} clicks dataKey="clicks"
</span> stroke="#8884d8"
</li> strokeWidth={2}
))} />
</ul> </LineChart>
</CardContent> </ResponsiveContainer>
</Card> </div>
</div> </CardContent>
)} </Card>
</DialogContent>
</Dialog> <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>
);
} }

View file

@ -32,6 +32,7 @@ export interface ClickStats {
} }
export interface SourceStats { export interface SourceStats {
date: string;
source: string; source: string;
count: number; count: number;
} }

View file

@ -643,15 +643,16 @@ pub async fn get_link_sources(
sqlx::query_as::<_, SourceStats>( sqlx::query_as::<_, SourceStats>(
r#" r#"
SELECT SELECT
DATE(created_at)::text as date,
query_source as source, query_source as source,
COUNT(*)::bigint as count COUNT(*)::bigint as count
FROM clicks FROM clicks
WHERE link_id = $1 WHERE link_id = $1
AND query_source IS NOT NULL AND query_source IS NOT NULL
AND query_source != '' AND query_source != ''
GROUP BY query_source GROUP BY DATE(created_at), query_source
ORDER BY COUNT(*) DESC ORDER BY DATE(created_at) ASC, COUNT(*) DESC
LIMIT 10 LIMIT 300
"#, "#,
) )
.bind(link_id) .bind(link_id)
@ -662,15 +663,16 @@ pub async fn get_link_sources(
sqlx::query_as::<_, SourceStats>( sqlx::query_as::<_, SourceStats>(
r#" r#"
SELECT SELECT
DATE(created_at) as date,
query_source as source, query_source as source,
COUNT(*) as count COUNT(*) as count
FROM clicks FROM clicks
WHERE link_id = ? WHERE link_id = ?
AND query_source IS NOT NULL AND query_source IS NOT NULL
AND query_source != '' AND query_source != ''
GROUP BY query_source GROUP BY DATE(created_at), query_source
ORDER BY COUNT(*) DESC ORDER BY DATE(created_at) ASC, COUNT(*) DESC
LIMIT 10 LIMIT 300
"#, "#,
) )
.bind(link_id) .bind(link_id)

View file

@ -150,6 +150,7 @@ pub struct ClickStats {
#[derive(sqlx::FromRow, Serialize)] #[derive(sqlx::FromRow, Serialize)]
pub struct SourceStats { pub struct SourceStats {
pub date: String,
pub source: String, pub source: String,
pub count: i64, pub count: i64,
} }