Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ export interface StatBucket {
visits: number;
}

export interface SignupBucket {
bucket: string;
signups: number;
confirmed: number;
}

export interface AnalyticsStats {
from: string;
to: string;
Expand All @@ -108,8 +114,14 @@ export interface AnalyticsStats {
returningUsers: number;
totalUsers: number;
visits: number;
/** Accounts created in Supabase (auth.users) during the range. */
signups: number;
/** Of those, how many have a confirmed email. */
confirmedSignups: number;
};
buckets: StatBucket[];
/** Dense series (one entry per bucket, zeros included) of Supabase signups. */
signupBuckets: SignupBucket[];
topPages: { path: string; views: number }[];
topReferrers: { referrer: string; views: number }[];
}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const Admin = () => {
<TabsList className="grid w-full max-w-2xl grid-cols-3 mb-8">
<TabsTrigger value="analytics" className="gap-2">
<IconChartAreaLine className="h-4 w-4" />
Traffic Analytics
Site Analytics
</TabsTrigger>
<TabsTrigger value="blogs" className="gap-2">
<IconFileText className="h-4 w-4" />
Expand Down
120 changes: 108 additions & 12 deletions src/pages/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
IconUserCheck,
IconUserPlus,
IconUsers,
IconUsersGroup,
} from "@tabler/icons-react";
import { Navigate } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
Expand All @@ -37,6 +38,10 @@ const visitsConfig = {
visits: { label: "Visits", color: "hsl(var(--chart-4))" },
} satisfies ChartConfig;

const signupsConfig = {
signups: { label: "Signups", color: "hsl(var(--chart-5))" },
} satisfies ChartConfig;

const RANGES = [
{ label: "7d", value: 7 },
{ label: "30d", value: 30 },
Expand Down Expand Up @@ -78,12 +83,14 @@ const StatCard = ({
value,
icon: Icon,
accent,
hint,
index,
}: {
label: string;
value: number;
icon: typeof IconUsers;
accent: string;
hint?: string;
index: number;
}) => (
<motion.div
Expand All @@ -100,15 +107,16 @@ const StatCard = ({
<div className="font-mono text-3xl font-semibold">
<CountUp value={value} />
</div>
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
</CardContent>
</Card>
</motion.div>
);

const DashboardSkeleton = () => (
<div className="flex flex-col gap-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-[116px] rounded-lg" />
))}
</div>
Expand Down Expand Up @@ -141,6 +149,59 @@ export const AnalyticsDashboard = () => {
[data, granularity],
);

const signupBuckets = useMemo(
() =>
(data?.signupBuckets ?? []).map((point) => ({
...point,
label: formatBucket(granularity, point.bucket),
})),
[data, granularity],
);

// Traffic buckets only exist for periods with a visit, while signups come
// back as a dense series, so the breakdown table merges both on the bucket.
const breakdownRows = useMemo(() => {
type Row = {
bucket: string;
label: string;
signups: number;
newUsers: number;
returningUsers: number;
uniques: number;
visits: number;
};
const merged = new Map<string, Row>();
const row = (bucket: string, label: string): Row => {
const existing = merged.get(bucket);
if (existing) return existing;
const created: Row = {
bucket,
label,
signups: 0,
newUsers: 0,
returningUsers: 0,
uniques: 0,
visits: 0,
};
merged.set(bucket, created);
return created;
};

for (const point of buckets) {
Object.assign(row(point.bucket, point.label), {
newUsers: point.newUsers,
returningUsers: point.returningUsers,
uniques: point.uniques,
visits: point.visits,
});
}
for (const point of signupBuckets) {
row(point.bucket, point.label).signups = point.signups;
}

return [...merged.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
}, [buckets, signupBuckets]);

if (loading) {
return <DashboardSkeleton />;
}
Expand Down Expand Up @@ -189,16 +250,49 @@ export const AnalyticsDashboard = () => {
) : (
data && (
<div className="flex flex-col gap-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="New users" value={data.totals.newUsers} icon={IconUserPlus} accent="text-chart-1" index={0} />
<StatCard label="Returning users" value={data.totals.returningUsers} icon={IconUserCheck} accent="text-chart-2" index={1} />
<StatCard label="Unique visitors" value={data.totals.totalUsers} icon={IconUsers} accent="text-chart-3" index={2} />
<StatCard label="Visits" value={data.totals.visits} icon={IconEye} accent="text-chart-4" index={3} />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<StatCard
label="Signups"
value={data.totals.signups ?? 0}
icon={IconUserPlus}
accent="text-chart-5"
hint={`${(data.totals.confirmedSignups ?? 0).toLocaleString()} confirmed`}
index={0}
/>
<StatCard label="New visitors" value={data.totals.newUsers} icon={IconUsers} accent="text-chart-1" index={1} />
<StatCard label="Returning visitors" value={data.totals.returningUsers} icon={IconUserCheck} accent="text-chart-2" index={2} />
<StatCard label="Unique visitors" value={data.totals.totalUsers} icon={IconUsersGroup} accent="text-chart-3" index={3} />
<StatCard label="Visits" value={data.totals.visits} icon={IconEye} accent="text-chart-4" index={4} />
</div>

<Card>
<CardHeader>
<CardTitle>New vs returning</CardTitle>
<CardTitle>New signups</CardTitle>
<CardDescription>
Accounts created per {granularity} (UTC) — Supabase auth, excludes guests
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={signupsConfig} className="aspect-auto h-[260px] w-full">
<BarChart data={signupBuckets} margin={{ left: 0, right: 12, top: 8, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
<YAxis allowDecimals={false} tickLine={false} axisLine={false} width={28} />
<ChartTooltip content={<ChartTooltipContent indicator="dot" />} />
<Bar
dataKey="signups"
fill="var(--color-signups)"
radius={[4, 4, 0, 0]}
animationDuration={900}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>New vs returning visitors</CardTitle>
<CardDescription>Distinct visitors per {granularity} (UTC)</CardDescription>
</CardHeader>
<CardContent>
Expand Down Expand Up @@ -345,23 +439,25 @@ export const AnalyticsDashboard = () => {
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">Signups</TableHead>
<TableHead className="text-right">New</TableHead>
<TableHead className="text-right">Returning</TableHead>
<TableHead className="text-right">Unique</TableHead>
<TableHead className="text-right">Visits</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{buckets.length === 0 ? (
{breakdownRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-6 text-center text-muted-foreground">
<TableCell colSpan={6} className="py-6 text-center text-muted-foreground">
No data for this range yet.
</TableCell>
</TableRow>
) : (
[...buckets].reverse().map((point) => (
[...breakdownRows].reverse().map((point) => (
<TableRow key={point.bucket}>
<TableCell className="font-mono">{point.label}</TableCell>
<TableCell className="text-right font-semibold text-chart-5">{point.signups}</TableCell>
<TableCell className="text-right">{point.newUsers}</TableCell>
<TableCell className="text-right">{point.returningUsers}</TableCell>
<TableCell className="text-right">{point.uniques}</TableCell>
Expand Down Expand Up @@ -398,7 +494,7 @@ const Analytics = () => (
<div className="mb-8 flex items-center gap-3">
<IconChartAreaLine className="size-8 text-primary" />
<h1 className="font-heading text-4xl md:text-5xl">
Traffic <span className="text-primary">Analytics</span>
Site <span className="text-primary">Analytics</span>
</h1>
</div>

Expand Down
Loading
Loading