From 947964482f13c9197726fcd7520db149c1f53ada Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Tue, 11 Nov 2025 17:11:11 +0000
Subject: [PATCH] Enhance admin stats dashboard
Add data quality metrics, growth trends visualization, entity comparison views, and automated health checks to the AdminDatabaseStats dashboard, including new TS types, hooks, UI components, and integrated tabbed layout.
---
.../admin/database-stats/ComparisonTable.tsx | 107 +++++
.../database-stats/DataQualityOverview.tsx | 124 ++++++
.../DatabaseHealthDashboard.tsx | 159 +++++++
.../EntityComparisonDashboard.tsx | 136 ++++++
.../database-stats/GrowthTrendsChart.tsx | 204 +++++++++
.../admin/database-stats/HealthIssueCard.tsx | 110 +++++
src/hooks/useDatabaseHealthCheck.ts | 21 +
src/hooks/useEntityComparisons.ts | 21 +
src/hooks/useGrowthTrends.ts | 24 ++
src/integrations/supabase/types.ts | 14 +
src/lib/queryKeys.ts | 8 +
src/pages/AdminDatabaseStats.tsx | 244 +++++++----
src/types/database-analytics.ts | 88 ++++
...6_c102113c-c8a8-477a-9559-f2934da4ef07.sql | 407 ++++++++++++++++++
14 files changed, 1579 insertions(+), 88 deletions(-)
create mode 100644 src/components/admin/database-stats/ComparisonTable.tsx
create mode 100644 src/components/admin/database-stats/DataQualityOverview.tsx
create mode 100644 src/components/admin/database-stats/DatabaseHealthDashboard.tsx
create mode 100644 src/components/admin/database-stats/EntityComparisonDashboard.tsx
create mode 100644 src/components/admin/database-stats/GrowthTrendsChart.tsx
create mode 100644 src/components/admin/database-stats/HealthIssueCard.tsx
create mode 100644 src/hooks/useDatabaseHealthCheck.ts
create mode 100644 src/hooks/useEntityComparisons.ts
create mode 100644 src/hooks/useGrowthTrends.ts
create mode 100644 src/types/database-analytics.ts
create mode 100644 supabase/migrations/20251111170746_c102113c-c8a8-477a-9559-f2934da4ef07.sql
diff --git a/src/components/admin/database-stats/ComparisonTable.tsx b/src/components/admin/database-stats/ComparisonTable.tsx
new file mode 100644
index 00000000..82406490
--- /dev/null
+++ b/src/components/admin/database-stats/ComparisonTable.tsx
@@ -0,0 +1,107 @@
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Progress } from '@/components/ui/progress';
+import { Link } from 'react-router-dom';
+import { ExternalLink } from 'lucide-react';
+
+interface Column {
+ key: string;
+ label: string;
+ numeric?: boolean;
+ linkBase?: string;
+}
+
+interface ComparisonTableProps {
+ title: string;
+ data: any[];
+ columns: Column[];
+ slugKey: string;
+ parkSlugKey?: string;
+}
+
+export function ComparisonTable({ title, data, columns, slugKey, parkSlugKey }: ComparisonTableProps) {
+ if (!data || data.length === 0) {
+ return (
+
+ No data available
+
+ );
+ }
+
+ // Find the max value for each numeric column (for progress bars)
+ const maxValues: Record = {};
+ columns.forEach(col => {
+ if (col.numeric) {
+ maxValues[col.key] = Math.max(...data.map(row => row[col.key] || 0));
+ }
+ });
+
+ return (
+
+
{title}
+
+
+
+
+ Rank
+ {columns.map(col => (
+
+ {col.label}
+
+ ))}
+
+
+
+ {data.map((row, index) => {
+ const slug = row[slugKey];
+ const parkSlug = parkSlugKey ? row[parkSlugKey] : null;
+
+ return (
+
+
+ #{index + 1}
+
+ {columns.map(col => {
+ const value = row[col.key];
+ const isFirst = col === columns[0];
+
+ if (isFirst && col.linkBase && slug) {
+ const linkPath = parkSlug
+ ? `${col.linkBase}/${parkSlug}/rides/${slug}`
+ : `${col.linkBase}/${slug}`;
+
+ return (
+
+
+ {value}
+
+
+
+ );
+ }
+
+ if (col.numeric) {
+ const percentage = (value / maxValues[col.key]) * 100;
+ return (
+
+
+
+ );
+ }
+
+ return {value};
+ })}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/database-stats/DataQualityOverview.tsx b/src/components/admin/database-stats/DataQualityOverview.tsx
new file mode 100644
index 00000000..a80e2bbb
--- /dev/null
+++ b/src/components/admin/database-stats/DataQualityOverview.tsx
@@ -0,0 +1,124 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Progress } from '@/components/ui/progress';
+import { Link } from 'react-router-dom';
+import { ArrowRight, CheckCircle2, AlertCircle } from 'lucide-react';
+import { useDataCompleteness } from '@/hooks/useDataCompleteness';
+
+export function DataQualityOverview() {
+ const { data, isLoading } = useDataCompleteness();
+
+ if (isLoading || !data) {
+ return (
+
+
+ Data Quality
+ Loading completeness metrics...
+
+
+
+
+
+ );
+ }
+
+ const { summary } = data;
+ const avgScore = Math.round(summary.avg_completeness_score);
+
+ const getScoreColor = (score: number) => {
+ if (score >= 80) return 'text-green-600';
+ if (score >= 60) return 'text-blue-600';
+ if (score >= 40) return 'text-yellow-600';
+ return 'text-red-600';
+ };
+
+ const getProgressColor = (score: number) => {
+ if (score >= 80) return 'bg-green-600';
+ if (score >= 60) return 'bg-blue-600';
+ if (score >= 40) return 'bg-yellow-600';
+ return 'bg-red-600';
+ };
+
+ return (
+
+
+
+
+ Data Quality
+ Overall completeness metrics across all entities
+
+
+ View Details
+
+
+
+
+ {/* Average Score */}
+
+
+ Average Completeness
+
+ {avgScore}%
+
+
+
+
+
+ {/* Quick Stats Grid */}
+
+
+
+
+ 100% Complete
+
+
{summary.entities_100_complete}
+
+ {((summary.entities_100_complete / summary.total_entities) * 100).toFixed(1)}% of total
+
+
+
+
+
+
{summary.entities_below_50}
+
+ {((summary.entities_below_50 / summary.total_entities) * 100).toFixed(1)}% need attention
+
+
+
+
+ {/* By Entity Type */}
+
+
By Entity Type
+
+ {[
+ { label: 'Parks', value: summary.by_entity_type.parks, total: summary.total_entities },
+ { label: 'Rides', value: summary.by_entity_type.rides, total: summary.total_entities },
+ { label: 'Companies', value: summary.by_entity_type.companies, total: summary.total_entities },
+ { label: 'Models', value: summary.by_entity_type.ride_models, total: summary.total_entities },
+ ].map((item) => (
+
+
{item.label}
+
+
{item.value}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/database-stats/DatabaseHealthDashboard.tsx b/src/components/admin/database-stats/DatabaseHealthDashboard.tsx
new file mode 100644
index 00000000..3d7170fb
--- /dev/null
+++ b/src/components/admin/database-stats/DatabaseHealthDashboard.tsx
@@ -0,0 +1,159 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { useDatabaseHealthCheck } from '@/hooks/useDatabaseHealthCheck';
+import { AlertCircle, AlertTriangle, Info, CheckCircle2 } from 'lucide-react';
+import { Progress } from '@/components/ui/progress';
+import { HealthIssueCard } from './HealthIssueCard';
+import { Accordion } from '@/components/ui/accordion';
+
+export function DatabaseHealthDashboard() {
+ const { data, isLoading } = useDatabaseHealthCheck();
+
+ if (isLoading || !data) {
+ return (
+
+
+ Database Health
+ Loading health checks...
+
+
+
+
+
+ );
+ }
+
+ const { overall_score, critical_issues, warning_issues, info_issues, issues } = data;
+
+ const getScoreColor = (score: number) => {
+ if (score >= 80) return 'text-green-600';
+ if (score >= 60) return 'text-yellow-600';
+ if (score >= 40) return 'text-orange-600';
+ return 'text-red-600';
+ };
+
+ const getScoreBackground = (score: number) => {
+ if (score >= 80) return 'bg-green-600';
+ if (score >= 60) return 'bg-yellow-600';
+ if (score >= 40) return 'bg-orange-600';
+ return 'bg-red-600';
+ };
+
+ const criticalIssues = issues.filter(i => i.severity === 'critical');
+ const warningIssues = issues.filter(i => i.severity === 'warning');
+ const infoIssues = issues.filter(i => i.severity === 'info');
+
+ return (
+
+
+ Database Health
+ Automated health checks and data quality issues
+
+
+ {/* Overall Health Score */}
+
+
+
Overall Health Score
+
+ {overall_score}
+
+
Out of 100
+
+
+
+
+
+
Critical Issues:
+
{critical_issues}
+
+
+
+
Warnings:
+
{warning_issues}
+
+
+
+ Info:
+ {info_issues}
+
+
+
+
+ {/* Progress Bar */}
+
+
+ Database Health
+ {overall_score}%
+
+
+
+
+ {/* Issues List */}
+ {issues.length === 0 ? (
+
+
+
All Systems Healthy!
+
+ No database health issues detected at this time.
+
+
+ ) : (
+
+ {/* Critical Issues */}
+ {criticalIssues.length > 0 && (
+
+
+
+ Critical Issues ({criticalIssues.length})
+
+
+ {criticalIssues.map((issue, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Warnings */}
+ {warningIssues.length > 0 && (
+
+
+
+ Warnings ({warningIssues.length})
+
+
+ {warningIssues.map((issue, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Info */}
+ {infoIssues.length > 0 && (
+
+
+
+ Information ({infoIssues.length})
+
+
+ {infoIssues.map((issue, index) => (
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/admin/database-stats/EntityComparisonDashboard.tsx b/src/components/admin/database-stats/EntityComparisonDashboard.tsx
new file mode 100644
index 00000000..3bc35792
--- /dev/null
+++ b/src/components/admin/database-stats/EntityComparisonDashboard.tsx
@@ -0,0 +1,136 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { useEntityComparisons } from '@/hooks/useEntityComparisons';
+import { ComparisonTable } from './ComparisonTable';
+import { Building2, Factory, Users, Pencil, Image as ImageIcon } from 'lucide-react';
+
+export function EntityComparisonDashboard() {
+ const { data, isLoading } = useEntityComparisons();
+
+ if (isLoading || !data) {
+ return (
+
+
+ Entity Comparisons
+ Loading comparison data...
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ Entity Comparisons
+ Top entities by content volume
+
+
+
+
+
+
+ Parks
+
+
+
+ Manufacturers
+
+
+
+ Operators
+
+
+
+ Designers
+
+
+
+ Photos
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/database-stats/GrowthTrendsChart.tsx b/src/components/admin/database-stats/GrowthTrendsChart.tsx
new file mode 100644
index 00000000..6cdb864c
--- /dev/null
+++ b/src/components/admin/database-stats/GrowthTrendsChart.tsx
@@ -0,0 +1,204 @@
+import { useState } from 'react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { useGrowthTrends } from '@/hooks/useGrowthTrends';
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
+import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
+import type { GranularityType } from '@/types/database-analytics';
+import { format } from 'date-fns';
+
+const chartConfig = {
+ parks_added: {
+ label: "Parks",
+ color: "hsl(var(--chart-1))",
+ },
+ rides_added: {
+ label: "Rides",
+ color: "hsl(var(--chart-2))",
+ },
+ companies_added: {
+ label: "Companies",
+ color: "hsl(var(--chart-3))",
+ },
+ ride_models_added: {
+ label: "Models",
+ color: "hsl(var(--chart-4))",
+ },
+ photos_added: {
+ label: "Photos",
+ color: "hsl(var(--chart-5))",
+ },
+} as const;
+
+export function GrowthTrendsChart() {
+ const [timeRange, setTimeRange] = useState(90);
+ const [granularity, setGranularity] = useState('daily');
+ const [activeLines, setActiveLines] = useState({
+ parks_added: true,
+ rides_added: true,
+ companies_added: true,
+ ride_models_added: true,
+ photos_added: true,
+ });
+
+ const { data, isLoading } = useGrowthTrends(timeRange, granularity);
+
+ const toggleLine = (key: keyof typeof activeLines) => {
+ setActiveLines(prev => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ if (isLoading) {
+ return (
+
+
+ Growth Trends
+ Loading growth data...
+
+
+
+
+
+ );
+ }
+
+ const formattedData = data?.map(point => ({
+ ...point,
+ date: format(new Date(point.period), granularity === 'daily' ? 'MMM dd' : granularity === 'weekly' ? 'MMM dd' : 'MMM yyyy'),
+ })) || [];
+
+ return (
+
+
+
+
+ Growth Trends
+ Entity additions over time
+
+
+
+ {/* Time Range Controls */}
+
+ {[
+ { label: '7D', days: 7 },
+ { label: '30D', days: 30 },
+ { label: '90D', days: 90 },
+ { label: '1Y', days: 365 },
+ ].map(({ label, days }) => (
+
+ ))}
+
+
+ {/* Granularity Controls */}
+
+ {(['daily', 'weekly', 'monthly'] as GranularityType[]).map((g) => (
+
+ ))}
+
+
+
+
+
+
+ {/* Entity Type Toggles */}
+
+ {Object.entries(chartConfig).map(([key, config]) => (
+
+ ))}
+
+
+ {/* Chart */}
+
+
+
+
+
+
+ } />
+
+
+ {activeLines.parks_added && (
+
+ )}
+ {activeLines.rides_added && (
+
+ )}
+ {activeLines.companies_added && (
+
+ )}
+ {activeLines.ride_models_added && (
+
+ )}
+ {activeLines.photos_added && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/database-stats/HealthIssueCard.tsx b/src/components/admin/database-stats/HealthIssueCard.tsx
new file mode 100644
index 00000000..1c322eb7
--- /dev/null
+++ b/src/components/admin/database-stats/HealthIssueCard.tsx
@@ -0,0 +1,110 @@
+import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import type { HealthIssue } from '@/types/database-analytics';
+import { AlertCircle, AlertTriangle, Info, Lightbulb } from 'lucide-react';
+
+interface HealthIssueCardProps {
+ issue: HealthIssue;
+}
+
+export function HealthIssueCard({ issue }: HealthIssueCardProps) {
+ const getSeverityIcon = () => {
+ switch (issue.severity) {
+ case 'critical':
+ return ;
+ case 'warning':
+ return ;
+ case 'info':
+ return ;
+ }
+ };
+
+ const getSeverityColor = () => {
+ switch (issue.severity) {
+ case 'critical':
+ return 'border-red-600 bg-red-50 dark:bg-red-950/20';
+ case 'warning':
+ return 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950/20';
+ case 'info':
+ return 'border-blue-600 bg-blue-50 dark:bg-blue-950/20';
+ }
+ };
+
+ const getSeverityBadgeVariant = () => {
+ switch (issue.severity) {
+ case 'critical':
+ return 'destructive';
+ case 'warning':
+ return 'default';
+ case 'info':
+ return 'secondary';
+ }
+ };
+
+ return (
+
+
+
+
+ {getSeverityIcon()}
+
+
{issue.description}
+
+ {issue.category.replace(/_/g, ' ')}
+
+
+
+
+ {issue.count} {issue.count === 1 ? 'entity' : 'entities'}
+
+
+
+
+
+ {/* Suggested Action */}
+
+
+
+
Suggested Action
+
{issue.suggested_action}
+
+
+
+ {/* Entity IDs (first 10) */}
+ {issue.entity_ids && issue.entity_ids.length > 0 && (
+
+
+ Affected Entities ({issue.entity_ids.length})
+
+
+ {issue.entity_ids.slice(0, 10).map((id) => (
+
+ {id.substring(0, 8)}...
+
+ ))}
+ {issue.entity_ids.length > 10 && (
+
+ +{issue.entity_ids.length - 10} more
+
+ )}
+
+
+ )}
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/useDatabaseHealthCheck.ts b/src/hooks/useDatabaseHealthCheck.ts
new file mode 100644
index 00000000..6cabf4e7
--- /dev/null
+++ b/src/hooks/useDatabaseHealthCheck.ts
@@ -0,0 +1,21 @@
+import { useQuery } from '@tanstack/react-query';
+import { supabase } from '@/integrations/supabase/client';
+import { queryKeys } from '@/lib/queryKeys';
+import type { DatabaseHealthData } from '@/types/database-analytics';
+
+export function useDatabaseHealthCheck() {
+ return useQuery({
+ queryKey: queryKeys.analytics.databaseHealth(),
+ queryFn: async () => {
+ const { data, error } = await supabase.rpc('check_database_health');
+
+ if (error) {
+ throw error;
+ }
+
+ return data as unknown as DatabaseHealthData;
+ },
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ refetchInterval: 2 * 60 * 1000, // Auto-refetch every 2 minutes (health is important!)
+ });
+}
diff --git a/src/hooks/useEntityComparisons.ts b/src/hooks/useEntityComparisons.ts
new file mode 100644
index 00000000..91fa32a8
--- /dev/null
+++ b/src/hooks/useEntityComparisons.ts
@@ -0,0 +1,21 @@
+import { useQuery } from '@tanstack/react-query';
+import { supabase } from '@/integrations/supabase/client';
+import { queryKeys } from '@/lib/queryKeys';
+import type { EntityComparisons } from '@/types/database-analytics';
+
+export function useEntityComparisons() {
+ return useQuery({
+ queryKey: queryKeys.analytics.entityComparisons(),
+ queryFn: async () => {
+ const { data, error } = await supabase.rpc('get_entity_comparisons');
+
+ if (error) {
+ throw error;
+ }
+
+ return data as unknown as EntityComparisons;
+ },
+ staleTime: 15 * 60 * 1000, // 15 minutes
+ refetchInterval: 10 * 60 * 1000, // Auto-refetch every 10 minutes
+ });
+}
diff --git a/src/hooks/useGrowthTrends.ts b/src/hooks/useGrowthTrends.ts
new file mode 100644
index 00000000..f96d13ed
--- /dev/null
+++ b/src/hooks/useGrowthTrends.ts
@@ -0,0 +1,24 @@
+import { useQuery } from '@tanstack/react-query';
+import { supabase } from '@/integrations/supabase/client';
+import { queryKeys } from '@/lib/queryKeys';
+import type { GrowthTrendDataPoint, GranularityType } from '@/types/database-analytics';
+
+export function useGrowthTrends(daysBack: number = 90, granularity: GranularityType = 'daily') {
+ return useQuery({
+ queryKey: queryKeys.analytics.growthTrends(daysBack, granularity),
+ queryFn: async () => {
+ const { data, error } = await supabase.rpc('get_entity_growth_trends', {
+ days_back: daysBack,
+ granularity: granularity,
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return data as GrowthTrendDataPoint[];
+ },
+ staleTime: 10 * 60 * 1000, // 10 minutes
+ refetchInterval: 5 * 60 * 1000, // Auto-refetch every 5 minutes
+ });
+}
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index 9a68e0cb..a7a7afbb 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -6673,6 +6673,7 @@ export type Database = {
Returns: boolean
}
cancel_user_email_change: { Args: { _user_id: string }; Returns: boolean }
+ check_database_health: { Args: never; Returns: Json }
check_rate_limit: {
Args: {
p_action: string
@@ -6831,6 +6832,19 @@ export type Database = {
get_current_user_id: { Args: never; Returns: string }
get_database_statistics: { Args: never; Returns: Json }
get_email_change_status: { Args: never; Returns: Json }
+ get_entity_comparisons: { Args: never; Returns: Json }
+ get_entity_growth_trends: {
+ Args: { days_back?: number; granularity?: string }
+ Returns: {
+ companies_added: number
+ parks_added: number
+ period: string
+ photos_added: number
+ ride_models_added: number
+ rides_added: number
+ total_added: number
+ }[]
+ }
get_filtered_profile: {
Args: { _profile_user_id: string; _viewer_id?: string }
Returns: Json
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index cc040640..a0e9526e 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -104,4 +104,12 @@ export const queryKeys = {
databaseStats: () => ['admin', 'database-stats'] as const,
recentAdditions: (limit: number) => ['admin', 'recent-additions', limit] as const,
},
+
+ // Analytics queries
+ analytics: {
+ all: ['analytics'] as const,
+ growthTrends: (days: number, granularity: string) => ['analytics', 'growth-trends', days, granularity] as const,
+ entityComparisons: () => ['analytics', 'entity-comparisons'] as const,
+ databaseHealth: () => ['analytics', 'database-health'] as const,
+ },
} as const;
diff --git a/src/pages/AdminDatabaseStats.tsx b/src/pages/AdminDatabaseStats.tsx
index 765efe27..b2165aa6 100644
--- a/src/pages/AdminDatabaseStats.tsx
+++ b/src/pages/AdminDatabaseStats.tsx
@@ -1,11 +1,16 @@
-import { Building2, Bike, Factory, Users, FileText, TrendingUp, Box, MapPin, Calendar, Image as ImageIcon } from 'lucide-react';
+import { Building2, Bike, Factory, Users, FileText, TrendingUp, Box, Image as ImageIcon, Activity, BarChart3, Shield } from 'lucide-react';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { useAdminGuard } from '@/hooks/useAdminGuard';
import { DatabaseStatsCard } from '@/components/admin/database-stats/DatabaseStatsCard';
import { RecentAdditionsTable } from '@/components/admin/database-stats/RecentAdditionsTable';
+import { DataQualityOverview } from '@/components/admin/database-stats/DataQualityOverview';
+import { GrowthTrendsChart } from '@/components/admin/database-stats/GrowthTrendsChart';
+import { EntityComparisonDashboard } from '@/components/admin/database-stats/EntityComparisonDashboard';
+import { DatabaseHealthDashboard } from '@/components/admin/database-stats/DatabaseHealthDashboard';
import { useAdminDatabaseStats } from '@/hooks/useAdminDatabaseStats';
import { useRecentAdditions } from '@/hooks/useRecentAdditions';
import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AlertCircle } from 'lucide-react';
export default function AdminDatabaseStats() {
@@ -58,103 +63,166 @@ export default function AdminDatabaseStats() {
Database Statistics
- Complete overview of database content and activity
+ Comprehensive analytics, quality metrics, and health monitoring
- {/* Stats Grid */}
-
-
+
+
+
+
+ Overview
+
+
+
+ Growth Trends
+
+
+
+ Comparisons
+
+
+
+ Data Quality
+
+
+
+ Health Checks
+
+
-
+ {/* Overview Tab */}
+
+ {/* Stats Grid */}
+
+
-
+
-
+
-
+
-
+
-
-
+
- {/* Recent Additions Table */}
-
+
+
+
+ {/* Data Quality Overview */}
+
+
+ {/* Recent Additions Table */}
+
+
+
+ {/* Growth Trends Tab */}
+
+
+
+
+ {/* Entity Comparisons Tab */}
+
+
+
+
+ {/* Data Quality Tab */}
+
+
+
+
Full Data Completeness Dashboard
+
+ For detailed analysis of data completeness by entity, missing fields, and improvement opportunities.
+
+
+ View Full Dashboard →
+
+
+
+
+ {/* Database Health Tab */}
+
+
+
+
);
diff --git a/src/types/database-analytics.ts b/src/types/database-analytics.ts
new file mode 100644
index 00000000..7704e433
--- /dev/null
+++ b/src/types/database-analytics.ts
@@ -0,0 +1,88 @@
+/**
+ * Database Analytics Types
+ * For growth trends, entity comparisons, and health checks
+ */
+
+// Growth Trends
+export interface GrowthTrendDataPoint {
+ period: string;
+ parks_added: number;
+ rides_added: number;
+ companies_added: number;
+ ride_models_added: number;
+ photos_added: number;
+ total_added: number;
+}
+
+export type GranularityType = 'daily' | 'weekly' | 'monthly';
+
+// Entity Comparisons
+export interface TopParkByRides {
+ park_name: string;
+ park_slug: string;
+ ride_count: number;
+ photo_count: number;
+}
+
+export interface TopManufacturer {
+ manufacturer_name: string;
+ slug: string;
+ ride_count: number;
+ model_count: number;
+}
+
+export interface TopOperator {
+ operator_name: string;
+ slug: string;
+ park_count: number;
+ ride_count: number;
+}
+
+export interface TopDesigner {
+ designer_name: string;
+ slug: string;
+ ride_count: number;
+}
+
+export interface TopParkByPhotos {
+ park_name: string;
+ park_slug: string;
+ photo_count: number;
+}
+
+export interface TopRideByPhotos {
+ ride_name: string;
+ ride_slug: string;
+ park_slug: string;
+ photo_count: number;
+}
+
+export interface EntityComparisons {
+ top_parks_by_rides: TopParkByRides[];
+ top_manufacturers: TopManufacturer[];
+ top_operators: TopOperator[];
+ top_designers: TopDesigner[];
+ top_parks_by_photos: TopParkByPhotos[];
+ top_rides_by_photos: TopRideByPhotos[];
+}
+
+// Database Health
+export type HealthSeverity = 'critical' | 'warning' | 'info';
+
+export interface HealthIssue {
+ severity: HealthSeverity;
+ category: string;
+ count: number;
+ entity_ids: string[];
+ description: string;
+ suggested_action: string;
+}
+
+export interface DatabaseHealthData {
+ overall_score: number;
+ critical_issues: number;
+ warning_issues: number;
+ info_issues: number;
+ issues: HealthIssue[];
+ checked_at: string;
+}
diff --git a/supabase/migrations/20251111170746_c102113c-c8a8-477a-9559-f2934da4ef07.sql b/supabase/migrations/20251111170746_c102113c-c8a8-477a-9559-f2934da4ef07.sql
new file mode 100644
index 00000000..d296f016
--- /dev/null
+++ b/supabase/migrations/20251111170746_c102113c-c8a8-477a-9559-f2934da4ef07.sql
@@ -0,0 +1,407 @@
+-- Enhanced Database Analytics Functions
+
+-- 1. Get Entity Growth Trends (time-series data)
+CREATE OR REPLACE FUNCTION get_entity_growth_trends(
+ days_back integer DEFAULT 90,
+ granularity text DEFAULT 'daily'
+)
+RETURNS TABLE (
+ period text,
+ parks_added integer,
+ rides_added integer,
+ companies_added integer,
+ ride_models_added integer,
+ photos_added integer,
+ total_added integer
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ interval_format text;
+ trunc_format text;
+BEGIN
+ -- Set format based on granularity
+ CASE granularity
+ WHEN 'daily' THEN
+ interval_format := '1 day';
+ trunc_format := 'day';
+ WHEN 'weekly' THEN
+ interval_format := '1 week';
+ trunc_format := 'week';
+ WHEN 'monthly' THEN
+ interval_format := '1 month';
+ trunc_format := 'month';
+ ELSE
+ interval_format := '1 day';
+ trunc_format := 'day';
+ END CASE;
+
+ RETURN QUERY
+ WITH date_series AS (
+ SELECT generate_series(
+ date_trunc(trunc_format, CURRENT_DATE - (days_back || ' days')::interval),
+ date_trunc(trunc_format, CURRENT_DATE),
+ interval_format::interval
+ )::date AS period_date
+ ),
+ parks_data AS (
+ SELECT
+ date_trunc(trunc_format, created_at)::date AS period_date,
+ COUNT(*)::integer AS count
+ FROM parks
+ WHERE created_at >= CURRENT_DATE - (days_back || ' days')::interval
+ GROUP BY date_trunc(trunc_format, created_at)::date
+ ),
+ rides_data AS (
+ SELECT
+ date_trunc(trunc_format, created_at)::date AS period_date,
+ COUNT(*)::integer AS count
+ FROM rides
+ WHERE created_at >= CURRENT_DATE - (days_back || ' days')::interval
+ GROUP BY date_trunc(trunc_format, created_at)::date
+ ),
+ companies_data AS (
+ SELECT
+ date_trunc(trunc_format, created_at)::date AS period_date,
+ COUNT(*)::integer AS count
+ FROM companies
+ WHERE created_at >= CURRENT_DATE - (days_back || ' days')::interval
+ GROUP BY date_trunc(trunc_format, created_at)::date
+ ),
+ models_data AS (
+ SELECT
+ date_trunc(trunc_format, created_at)::date AS period_date,
+ COUNT(*)::integer AS count
+ FROM ride_models
+ WHERE created_at >= CURRENT_DATE - (days_back || ' days')::interval
+ GROUP BY date_trunc(trunc_format, created_at)::date
+ ),
+ photos_data AS (
+ SELECT
+ date_trunc(trunc_format, created_at)::date AS period_date,
+ COUNT(*)::integer AS count
+ FROM entity_photos
+ WHERE created_at >= CURRENT_DATE - (days_back || ' days')::interval
+ GROUP BY date_trunc(trunc_format, created_at)::date
+ )
+ SELECT
+ ds.period_date::text,
+ COALESCE(p.count, 0),
+ COALESCE(r.count, 0),
+ COALESCE(c.count, 0),
+ COALESCE(m.count, 0),
+ COALESCE(ph.count, 0),
+ COALESCE(p.count, 0) + COALESCE(r.count, 0) + COALESCE(c.count, 0) + COALESCE(m.count, 0) + COALESCE(ph.count, 0)
+ FROM date_series ds
+ LEFT JOIN parks_data p ON ds.period_date = p.period_date
+ LEFT JOIN rides_data r ON ds.period_date = r.period_date
+ LEFT JOIN companies_data c ON ds.period_date = c.period_date
+ LEFT JOIN models_data m ON ds.period_date = m.period_date
+ LEFT JOIN photos_data ph ON ds.period_date = ph.period_date
+ ORDER BY ds.period_date;
+END;
+$$;
+
+-- 2. Get Entity Comparisons (rankings)
+CREATE OR REPLACE FUNCTION get_entity_comparisons()
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ result jsonb;
+BEGIN
+ SELECT jsonb_build_object(
+ 'top_parks_by_rides', (
+ SELECT jsonb_agg(row_to_json(t))
+ FROM (
+ SELECT
+ p.name AS park_name,
+ p.slug AS park_slug,
+ COUNT(DISTINCT r.id)::integer AS ride_count,
+ COUNT(DISTINCT ep.id)::integer AS photo_count
+ FROM parks p
+ LEFT JOIN rides r ON r.park_id = p.id
+ LEFT JOIN entity_photos ep ON ep.entity_id = p.id AND ep.entity_type = 'park'
+ GROUP BY p.id, p.name, p.slug
+ ORDER BY COUNT(DISTINCT r.id) DESC
+ LIMIT 20
+ ) t
+ ),
+ 'top_manufacturers', (
+ SELECT jsonb_agg(row_to_json(t))
+ FROM (
+ SELECT
+ c.name AS manufacturer_name,
+ c.slug,
+ COUNT(DISTINCT r.id)::integer AS ride_count,
+ COUNT(DISTINCT rm.id)::integer AS model_count
+ FROM companies c
+ LEFT JOIN rides r ON r.manufacturer_id = c.id
+ LEFT JOIN ride_models rm ON rm.manufacturer_id = c.id
+ WHERE c.is_manufacturer = true
+ GROUP BY c.id, c.name, c.slug
+ ORDER BY COUNT(DISTINCT r.id) DESC
+ LIMIT 20
+ ) t
+ ),
+ 'top_operators', (
+ SELECT jsonb_agg(row_to_json(t))
+ FROM (
+ SELECT
+ c.name AS operator_name,
+ c.slug,
+ COUNT(DISTINCT p.id)::integer AS park_count,
+ COUNT(DISTINCT r.id)::integer AS ride_count
+ FROM companies c
+ LEFT JOIN parks p ON p.operator_id = c.id
+ LEFT JOIN rides r ON r.park_id = p.id
+ WHERE c.is_operator = true
+ GROUP BY c.id, c.name, c.slug
+ ORDER BY COUNT(DISTINCT p.id) DESC
+ LIMIT 20
+ ) t
+ ),
+ 'top_designers', (
+ SELECT jsonb_agg(row_to_json(t))
+ FROM (
+ SELECT
+ c.name AS designer_name,
+ c.slug,
+ COUNT(DISTINCT r.id)::integer AS ride_count
+ FROM companies c
+ LEFT JOIN rides r ON r.designer_id = c.id
+ WHERE c.is_designer = true
+ GROUP BY c.id, c.name, c.slug
+ ORDER BY COUNT(DISTINCT r.id) DESC
+ LIMIT 20
+ ) t
+ ),
+ 'top_parks_by_photos', (
+ SELECT jsonb_agg(row_to_json(t))
+ FROM (
+ SELECT
+ p.name AS park_name,
+ p.slug AS park_slug,
+ COUNT(DISTINCT ep.id)::integer AS photo_count
+ FROM parks p
+ LEFT JOIN entity_photos ep ON ep.entity_id = p.id AND ep.entity_type = 'park'
+ GROUP BY p.id, p.name, p.slug
+ ORDER BY COUNT(DISTINCT ep.id) DESC
+ LIMIT 20
+ ) t
+ ),
+ 'top_rides_by_photos', (
+ SELECT jsonb_agg(row_to_json(t))
+ FROM (
+ SELECT
+ r.name AS ride_name,
+ r.slug AS ride_slug,
+ p.slug AS park_slug,
+ COUNT(DISTINCT ep.id)::integer AS photo_count
+ FROM rides r
+ LEFT JOIN parks p ON r.park_id = p.id
+ LEFT JOIN entity_photos ep ON ep.entity_id = r.id AND ep.entity_type = 'ride'
+ GROUP BY r.id, r.name, r.slug, p.slug
+ ORDER BY COUNT(DISTINCT ep.id) DESC
+ LIMIT 20
+ ) t
+ )
+ ) INTO result;
+
+ RETURN result;
+END;
+$$;
+
+-- 3. Check Database Health (automated health checks)
+CREATE OR REPLACE FUNCTION check_database_health()
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ issues jsonb := '[]'::jsonb;
+ critical_count integer := 0;
+ warning_count integer := 0;
+ info_count integer := 0;
+ overall_score integer;
+ issue jsonb;
+BEGIN
+ -- CRITICAL: Parks missing critical fields
+ SELECT jsonb_build_object(
+ 'severity', 'critical',
+ 'category', 'missing_critical_fields',
+ 'count', COUNT(*),
+ 'entity_ids', jsonb_agg(id),
+ 'description', 'Parks missing status, park_type, or location',
+ 'suggested_action', 'Add missing critical information to these parks'
+ ) INTO issue
+ FROM parks
+ WHERE status IS NULL OR park_type IS NULL OR location_id IS NULL;
+
+ IF (issue->>'count')::integer > 0 THEN
+ issues := issues || jsonb_build_array(issue);
+ critical_count := critical_count + (issue->>'count')::integer;
+ END IF;
+
+ -- CRITICAL: Rides missing critical fields
+ SELECT jsonb_build_object(
+ 'severity', 'critical',
+ 'category', 'missing_critical_fields',
+ 'count', COUNT(*),
+ 'entity_ids', jsonb_agg(id),
+ 'description', 'Rides missing park, category, or status',
+ 'suggested_action', 'Add missing critical information to these rides'
+ ) INTO issue
+ FROM rides
+ WHERE park_id IS NULL OR category IS NULL OR status IS NULL;
+
+ IF (issue->>'count')::integer > 0 THEN
+ issues := issues || jsonb_build_array(issue);
+ critical_count := critical_count + (issue->>'count')::integer;
+ END IF;
+
+ -- CRITICAL: Data integrity - opening after closing
+ SELECT jsonb_build_object(
+ 'severity', 'critical',
+ 'category', 'data_integrity',
+ 'count', COUNT(*),
+ 'entity_ids', jsonb_agg(id),
+ 'description', 'Parks with opening date after closing date',
+ 'suggested_action', 'Fix date ranges for these parks'
+ ) INTO issue
+ FROM parks
+ WHERE opening_date IS NOT NULL
+ AND closing_date IS NOT NULL
+ AND opening_date > closing_date;
+
+ IF (issue->>'count')::integer > 0 THEN
+ issues := issues || jsonb_build_array(issue);
+ critical_count := critical_count + (issue->>'count')::integer;
+ END IF;
+
+ -- WARNING: Outdated entities
+ SELECT jsonb_build_object(
+ 'severity', 'warning',
+ 'category', 'outdated_information',
+ 'count', COUNT(*),
+ 'entity_ids', jsonb_agg(id),
+ 'description', 'Parks not updated in 365+ days',
+ 'suggested_action', 'Review and update these parks with current information'
+ ) INTO issue
+ FROM parks
+ WHERE updated_at < CURRENT_DATE - INTERVAL '365 days';
+
+ IF (issue->>'count')::integer > 0 THEN
+ issues := issues || jsonb_build_array(issue);
+ warning_count := warning_count + (issue->>'count')::integer;
+ END IF;
+
+ -- WARNING: Missing descriptions
+ SELECT jsonb_build_object(
+ 'severity', 'warning',
+ 'category', 'missing_descriptions',
+ 'count', COUNT(*),
+ 'entity_ids', jsonb_agg(id),
+ 'description', 'Parks without descriptions or with very short ones',
+ 'suggested_action', 'Add detailed descriptions to improve content quality'
+ ) INTO issue
+ FROM parks
+ WHERE description IS NULL OR LENGTH(description) < 50;
+
+ IF (issue->>'count')::integer > 0 THEN
+ issues := issues || jsonb_build_array(issue);
+ warning_count := warning_count + (issue->>'count')::integer;
+ END IF;
+
+ -- WARNING: Missing images
+ SELECT jsonb_build_object(
+ 'severity', 'warning',
+ 'category', 'missing_images',
+ 'count', COUNT(*),
+ 'entity_ids', jsonb_agg(p.id),
+ 'description', 'Parks without banner or card images',
+ 'suggested_action', 'Upload images to improve visual appeal'
+ ) INTO issue
+ FROM parks p
+ LEFT JOIN entity_photos ep ON ep.entity_id = p.id AND ep.entity_type = 'park'
+ WHERE ep.id IS NULL;
+
+ IF (issue->>'count')::integer > 0 THEN
+ issues := issues || jsonb_build_array(issue);
+ warning_count := warning_count + (issue->>'count')::integer;
+ END IF;
+
+ -- WARNING: Historical entities without closing dates
+ SELECT jsonb_build_object(
+ 'severity', 'warning',
+ 'category', 'incomplete_historical_data',
+ 'count', COUNT(*),
+ 'entity_ids', jsonb_agg(id),
+ 'description', 'Historical parks without closing dates',
+ 'suggested_action', 'Add closing dates for proper historical tracking'
+ ) INTO issue
+ FROM parks
+ WHERE status = 'closed' AND closing_date IS NULL;
+
+ IF (issue->>'count')::integer > 0 THEN
+ issues := issues || jsonb_build_array(issue);
+ warning_count := warning_count + (issue->>'count')::integer;
+ END IF;
+
+ -- INFO: Low content parks
+ SELECT jsonb_build_object(
+ 'severity', 'info',
+ 'category', 'content_gaps',
+ 'count', COUNT(*),
+ 'entity_ids', jsonb_agg(p.id),
+ 'description', 'Parks with fewer than 3 rides documented',
+ 'suggested_action', 'Consider adding more rides to improve park coverage'
+ ) INTO issue
+ FROM parks p
+ LEFT JOIN rides r ON r.park_id = p.id
+ GROUP BY p.id
+ HAVING COUNT(r.id) < 3 AND COUNT(r.id) > 0;
+
+ IF (issue->>'count')::integer > 0 THEN
+ issues := issues || jsonb_build_array(issue);
+ info_count := info_count + (issue->>'count')::integer;
+ END IF;
+
+ -- INFO: Manufacturers with low content
+ SELECT jsonb_build_object(
+ 'severity', 'info',
+ 'category', 'content_gaps',
+ 'count', COUNT(*),
+ 'entity_ids', jsonb_agg(c.id),
+ 'description', 'Manufacturers with fewer than 5 rides documented',
+ 'suggested_action', 'Consider documenting more rides from these manufacturers'
+ ) INTO issue
+ FROM companies c
+ LEFT JOIN rides r ON r.manufacturer_id = c.id
+ WHERE c.is_manufacturer = true
+ GROUP BY c.id
+ HAVING COUNT(r.id) < 5 AND COUNT(r.id) > 0;
+
+ IF (issue->>'count')::integer > 0 THEN
+ issues := issues || jsonb_build_array(issue);
+ info_count := info_count + (issue->>'count')::integer;
+ END IF;
+
+ -- Calculate overall health score (0-100)
+ overall_score := GREATEST(0, 100 - (critical_count * 5) - (warning_count * 2) - (info_count * 1));
+
+ RETURN jsonb_build_object(
+ 'overall_score', overall_score,
+ 'critical_issues', critical_count,
+ 'warning_issues', warning_count,
+ 'info_issues', info_count,
+ 'issues', issues,
+ 'checked_at', NOW()
+ );
+END;
+$$;
\ No newline at end of file