From e1ff87dc03bb7e998a78b0a1c82a68b809e36912 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Fri, 2 Jan 2026 11:53:38 +0000 Subject: [PATCH] Show opt-in counts by feature --- apps/admin/src/pages/DashboardPage.tsx | 61 +++++++++++++++++++ ...000002_add_feature_interest_counts_rpc.sql | 29 +++++++++ 2 files changed, 90 insertions(+) create mode 100644 apps/supabase/migrations/20260102000002_add_feature_interest_counts_rpc.sql diff --git a/apps/admin/src/pages/DashboardPage.tsx b/apps/admin/src/pages/DashboardPage.tsx index bc99103..99d3a4f 100644 --- a/apps/admin/src/pages/DashboardPage.tsx +++ b/apps/admin/src/pages/DashboardPage.tsx @@ -26,6 +26,12 @@ type RecentFeatureInterest = { }; }; +type FeatureInterestCountsRow = { + feature_name: string; + opt_in_count: number; + opt_in_count_last_7_days: number; +}; + function formatFeatureName(featureName: string) { return featureName .split('_') @@ -45,6 +51,7 @@ export function DashboardPage() { }); const [messageStats, setMessageStats] = useState<{ date: string; count: number }[]>([]); const [featureInterestStats, setFeatureInterestStats] = useState([]); + const [featureInterestCounts, setFeatureInterestCounts] = useState([]); const [recentFeatureInterests, setRecentFeatureInterests] = useState([]); const [loading, setLoading] = useState(true); @@ -95,6 +102,19 @@ export function DashboardPage() { .limit(10), ]); + if (canViewUsers) { + const { data: countsData, error: countsError } = await supabase.rpc('get_feature_interest_counts'); + + if (countsError) { + console.error('Failed to fetch feature interest counts:', countsError); + setFeatureInterestCounts([]); + } else { + setFeatureInterestCounts((countsData ?? []) as FeatureInterestCountsRow[]); + } + } else { + setFeatureInterestCounts([]); + } + // Process messages for chart const messageDaysMap = new Map(); for (let i = 0; i < 7; i++) { @@ -483,6 +503,47 @@ export function DashboardPage() { )} +
+
+

Opt-ins by feature

+

All time • last 7d

+
+ + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, idx) => ( +
+ +
+ + +
+
+ ))} +
+ ) : featureInterestCounts.length === 0 ? ( +

No opt-ins yet.

+ ) : ( +
+ {featureInterestCounts.slice(0, 12).map((row) => ( +
+

+ {formatFeatureName(row.feature_name)} +

+
+ + {row.opt_in_count} + + + {row.opt_in_count_last_7_days} 7d + +
+
+ ))} +
+ )} +
+

Recent opt-ins

diff --git a/apps/supabase/migrations/20260102000002_add_feature_interest_counts_rpc.sql b/apps/supabase/migrations/20260102000002_add_feature_interest_counts_rpc.sql new file mode 100644 index 0000000..c7fcba7 --- /dev/null +++ b/apps/supabase/migrations/20260102000002_add_feature_interest_counts_rpc.sql @@ -0,0 +1,29 @@ +-- Expose per-feature opt-in counts for admins + +CREATE OR REPLACE FUNCTION public.get_feature_interest_counts() +RETURNS TABLE ( + feature_name TEXT, + opt_in_count BIGINT, + opt_in_count_last_7_days BIGINT +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +BEGIN + IF NOT has_permission('view_users') THEN + RAISE EXCEPTION 'Access denied'; + END IF; + + RETURN QUERY + SELECT + fi.feature_name, + COUNT(*)::BIGINT AS opt_in_count, + COUNT(*) FILTER (WHERE fi.created_at >= now() - interval '7 days')::BIGINT AS opt_in_count_last_7_days + FROM public.feature_interests fi + GROUP BY fi.feature_name + ORDER BY opt_in_count DESC, fi.feature_name ASC; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_feature_interest_counts() TO authenticated;