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 ( + +
+ {value} + +
+
+ ); + } + + 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 +
+
+ +
+
+ + Below 50% +
+
{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