From e6a8b516dbc06aae11b3e1eea73e909ad9bc5035 Mon Sep 17 00:00:00 2001 From: Coder-soft Date: Wed, 21 Oct 2026 17:35:51 +0500 Subject: [PATCH 1/2] feat(analytics): add Supabase signups to the admin dashboard The admin dashboard only reported first-party visitor traffic, where "new users" means first-time visitors. Add a real signup series sourced from auth.users.created_at (guests excluded): - analytics_overview() now returns totals.signups/confirmedSignups and a dense per-bucket signupBuckets series (zeros included, not gaps) - dashboard gets a Signups stat card, a "New signups" chart and a Signups column in the breakdown table - visitor cards relabelled (New/Returning visitors) so they are not confused with accounts --- src/lib/analytics.ts | 12 ++ src/pages/Admin.tsx | 2 +- src/pages/Analytics.tsx | 120 +++++++++++-- .../20261021000000_analytics_signups.sql | 167 ++++++++++++++++++ 4 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 supabase/migrations/20261021000000_analytics_signups.sql diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index d32eeef4..8d806c16 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -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; @@ -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 }[]; } diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index f5cb20d2..5549c59d 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -66,7 +66,7 @@ const Admin = () => { - Traffic Analytics + Site Analytics diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index fa964a69..41dda766 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -13,6 +13,7 @@ import { IconUserCheck, IconUserPlus, IconUsers, + IconUsersGroup, } from "@tabler/icons-react"; import { Navigate } from "react-router-dom"; import { useAuth } from "@/hooks/useAuth"; @@ -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 }, @@ -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; }) => ( + {hint ?

{hint}

: null}
@@ -107,8 +115,8 @@ const StatCard = ({ const DashboardSkeleton = () => (
-
- {Array.from({ length: 4 }).map((_, index) => ( +
+ {Array.from({ length: 5 }).map((_, index) => ( ))}
@@ -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(); + 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 ; } @@ -189,16 +250,49 @@ export const AnalyticsDashboard = () => { ) : ( data && (
-
- - - - +
+ + + + +
- New vs returning + New signups + + Accounts created per {granularity} (UTC) — Supabase auth, excludes guests + + + + + + + + + } /> + + + + + + + + + New vs returning visitors Distinct visitors per {granularity} (UTC) @@ -345,6 +439,7 @@ export const AnalyticsDashboard = () => { Period + Signups New Returning Unique @@ -352,16 +447,17 @@ export const AnalyticsDashboard = () => { - {buckets.length === 0 ? ( + {breakdownRows.length === 0 ? ( - + No data for this range yet. ) : ( - [...buckets].reverse().map((point) => ( + [...breakdownRows].reverse().map((point) => ( {point.label} + {point.signups} {point.newUsers} {point.returningUsers} {point.uniques} @@ -398,7 +494,7 @@ const Analytics = () => (

- Traffic Analytics + Site Analytics

diff --git a/supabase/migrations/20261021000000_analytics_signups.sql b/supabase/migrations/20261021000000_analytics_signups.sql new file mode 100644 index 00000000..5fd6a26f --- /dev/null +++ b/supabase/migrations/20261021000000_analytics_signups.sql @@ -0,0 +1,167 @@ +-- Add Supabase auth signups to the admin analytics payload. +-- +-- The original analytics_overview only reported first-party visitor traffic +-- (analytics_events), where "new users" means "first-time visitors". Those +-- numbers are browser/session based and intentionally do not require an +-- account, so they can never answer "how many people actually signed up?". +-- +-- This adds a real signup series sourced from auth.users.created_at, both as +-- range totals (signups / confirmedSignups) and as a dense per-bucket series +-- (signupBuckets) so days without a signup show a real zero instead of a gap. +-- Anonymous (guest) accounts are excluded. Function is security definer and +-- only granted to service_role, matching the existing traffic function. + +create or replace function public.analytics_overview( + p_from timestamptz, + p_to timestamptz, + p_granularity text default 'day', + p_top_limit integer default 10 +) returns jsonb +language sql +security definer +set search_path = public +as $$ + with params as ( + select + case + when p_granularity = 'month' then 'month' + when p_granularity = 'week' then 'week' + else 'day' + end::text as gran + ), + filtered as ( + select * + from public.analytics_events + where created_at >= p_from and created_at < p_to + ), + totals as ( + select + count(distinct visitor_key) filter (where is_new) as new_users, + count(distinct visitor_key) filter (where not is_new) as returning_users, + count(distinct visitor_key) as total_users, + count(*) as visits + from filtered + ), + buckets as ( + select + date_trunc( + case + when p_granularity = 'month' then 'month' + when p_granularity = 'week' then 'week' + else 'day' + end, + created_at + ) as bucket, + count(distinct visitor_key) filter (where is_new) as new_users, + count(distinct visitor_key) filter (where not is_new) as returning_users, + count(distinct visitor_key) as uniques, + count(*) as visits + from filtered + group by 1 + order by 1 + ), + pages as ( + select path, count(*) as views + from filtered + where path is not null and path <> '' + group by path + order by views desc, path + limit greatest(p_top_limit, 1) + ), + referrers as ( + select referrer, count(*) as views + from filtered + where referrer is not null and referrer <> '' + group by referrer + order by views desc, referrer + limit greatest(p_top_limit, 1) + ), + signups as ( + select created_at, email_confirmed_at + from auth.users + where created_at >= p_from + and created_at < p_to + and not is_anonymous + ), + signup_totals as ( + select + count(*) as signups, + count(*) filter (where email_confirmed_at is not null) as confirmed + from signups + ), + signup_counts as ( + select + date_trunc((select gran from params), created_at) as bucket, + count(*) as signups, + count(*) filter (where email_confirmed_at is not null) as confirmed + from signups + group by 1 + ), + signup_series as ( + select generate_series( + date_trunc((select gran from params), p_from), + date_trunc((select gran from params), p_to), + case (select gran from params) + when 'month' then interval '1 month' + when 'week' then interval '1 week' + else interval '1 day' + end + ) as bucket + ), + signup_buckets as ( + select + s.bucket, + coalesce(c.signups, 0) as signups, + coalesce(c.confirmed, 0) as confirmed + from signup_series s + left join signup_counts c on c.bucket = s.bucket + order by s.bucket + ) + select jsonb_build_object( + 'from', p_from, + 'to', p_to, + 'granularity', (select gran from params), + 'totals', jsonb_build_object( + 'newUsers', totals.new_users, + 'returningUsers', totals.returning_users, + 'totalUsers', totals.total_users, + 'visits', totals.visits, + 'signups', signup_totals.signups, + 'confirmedSignups', signup_totals.confirmed + ), + 'buckets', coalesce( + (select jsonb_agg( + jsonb_build_object( + 'bucket', b.bucket, + 'newUsers', b.new_users, + 'returningUsers', b.returning_users, + 'uniques', b.uniques, + 'visits', b.visits + ) order by b.bucket + ) from buckets b), + '[]'::jsonb + ), + 'signupBuckets', coalesce( + (select jsonb_agg( + jsonb_build_object( + 'bucket', sb.bucket, + 'signups', sb.signups, + 'confirmed', sb.confirmed + ) order by sb.bucket + ) from signup_buckets sb), + '[]'::jsonb + ), + 'topPages', coalesce( + (select jsonb_agg(jsonb_build_object('path', p.path, 'views', p.views) order by p.views desc, p.path) from pages p), + '[]'::jsonb + ), + 'topReferrers', coalesce( + (select jsonb_agg(jsonb_build_object('referrer', r.referrer, 'views', r.views) order by r.views desc, r.referrer) from referrers r), + '[]'::jsonb + ) + ) + from totals, signup_totals; +$$; + +revoke all on function public.analytics_overview(timestamptz, timestamptz, text, integer) from public, anon, authenticated; +grant execute on function public.analytics_overview(timestamptz, timestamptz, text, integer) to service_role; From e04ad7746f1707f50bbaef1e3e09d0cd50575ab7 Mon Sep 17 00:00:00 2001 From: Coder-soft Date: Wed, 21 Oct 2026 18:01:02 +0500 Subject: [PATCH 2/2] fix(dev): drop stale Cloudflare analytics proxy from the Vite config /api/stats and /api/track were still proxied to analytics.codersoft.xyz, the self-hosted Worker from 6b08678 that the Supabase migration (20261009_site_analytics) replaced. The Worker only serves /stats and /track now, so the dev dashboard got a 404 on /api/stats instead of hitting api/stats.js via the local Express adapter. Removing both entries lets them fall through to the generic /api -> localhost:3000 proxy, which implements them. --- vite.config.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index f400a659..55a0fca1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,16 +23,6 @@ export default defineConfig(() => { secure: true, rewrite: (path) => path.replace(/^\/api\/assets-gw/, ''), }, - '/api/stats': { - target: 'https://analytics.codersoft.xyz', - changeOrigin: true, - secure: true, - }, - '/api/track': { - target: 'https://analytics.codersoft.xyz', - changeOrigin: true, - secure: true, - }, '/api': { target: 'http://localhost:3000', changeOrigin: true,