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.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-11 17:11:11 +00:00
parent f036776dce
commit 947964482f
14 changed files with 1579 additions and 88 deletions

View File

@@ -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 (
<div className="text-center py-8 text-muted-foreground">
No data available
</div>
);
}
// Find the max value for each numeric column (for progress bars)
const maxValues: Record<string, number> = {};
columns.forEach(col => {
if (col.numeric) {
maxValues[col.key] = Math.max(...data.map(row => row[col.key] || 0));
}
});
return (
<div className="space-y-2">
<h3 className="text-lg font-semibold">{title}</h3>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">Rank</TableHead>
{columns.map(col => (
<TableHead key={col.key} className={col.numeric ? 'text-right' : ''}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => {
const slug = row[slugKey];
const parkSlug = parkSlugKey ? row[parkSlugKey] : null;
return (
<TableRow key={index}>
<TableCell className="font-medium text-muted-foreground">
#{index + 1}
</TableCell>
{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 (
<TableCell key={col.key}>
<Link
to={linkPath}
className="flex items-center gap-2 hover:text-primary transition-colors"
>
{value}
<ExternalLink className="h-3 w-3" />
</Link>
</TableCell>
);
}
if (col.numeric) {
const percentage = (value / maxValues[col.key]) * 100;
return (
<TableCell key={col.key} className="text-right">
<div className="flex items-center justify-end gap-2">
<span className="font-semibold min-w-12">{value}</span>
<Progress value={percentage} className="h-2 w-24" />
</div>
</TableCell>
);
}
return <TableCell key={col.key}>{value}</TableCell>;
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Data Quality</CardTitle>
<CardDescription>Loading completeness metrics...</CardDescription>
</CardHeader>
<CardContent>
<div className="animate-pulse space-y-4">
<div className="h-20 bg-muted rounded" />
<div className="h-20 bg-muted rounded" />
</div>
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Data Quality</CardTitle>
<CardDescription>Overall completeness metrics across all entities</CardDescription>
</div>
<Link
to="/admin/data-completeness"
className="text-sm text-primary hover:text-primary/80 flex items-center gap-1"
>
View Details <ArrowRight className="h-4 w-4" />
</Link>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Average Score */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Average Completeness</span>
<span className={`text-3xl font-bold ${getScoreColor(avgScore)}`}>
{avgScore}%
</span>
</div>
<div className="relative">
<Progress value={avgScore} className="h-3" />
<div
className={`absolute inset-0 rounded-full ${getProgressColor(avgScore)} transition-all`}
style={{ width: `${avgScore}%` }}
/>
</div>
</div>
{/* Quick Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">100% Complete</span>
</div>
<div className="text-2xl font-bold">{summary.entities_100_complete}</div>
<div className="text-xs text-muted-foreground">
{((summary.entities_100_complete / summary.total_entities) * 100).toFixed(1)}% of total
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600" />
<span className="text-sm font-medium">Below 50%</span>
</div>
<div className="text-2xl font-bold">{summary.entities_below_50}</div>
<div className="text-xs text-muted-foreground">
{((summary.entities_below_50 / summary.total_entities) * 100).toFixed(1)}% need attention
</div>
</div>
</div>
{/* By Entity Type */}
<div className="space-y-3">
<h4 className="text-sm font-medium">By Entity Type</h4>
<div className="space-y-2">
{[
{ 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) => (
<div key={item.label} className="flex items-center gap-2">
<span className="text-xs w-20">{item.label}</span>
<Progress value={(item.value / item.total) * 100} className="h-2 flex-1" />
<span className="text-xs text-muted-foreground w-12 text-right">{item.value}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Database Health</CardTitle>
<CardDescription>Loading health checks...</CardDescription>
</CardHeader>
<CardContent>
<div className="animate-pulse space-y-4">
<div className="h-32 bg-muted rounded" />
<div className="h-64 bg-muted rounded" />
</div>
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader>
<CardTitle>Database Health</CardTitle>
<CardDescription>Automated health checks and data quality issues</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Overall Health Score */}
<div className="flex items-center justify-between p-6 border rounded-lg bg-card">
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Overall Health Score</h3>
<div className={`text-6xl font-bold ${getScoreColor(overall_score)}`}>
{overall_score}
</div>
<p className="text-sm text-muted-foreground">Out of 100</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-red-600" />
<span className="text-sm font-medium">Critical Issues:</span>
<span className="text-lg font-bold">{critical_issues}</span>
</div>
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600" />
<span className="text-sm font-medium">Warnings:</span>
<span className="text-lg font-bold">{warning_issues}</span>
</div>
<div className="flex items-center gap-3">
<Info className="h-5 w-5 text-blue-600" />
<span className="text-sm font-medium">Info:</span>
<span className="text-lg font-bold">{info_issues}</span>
</div>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Database Health</span>
<span className={getScoreColor(overall_score)}>{overall_score}%</span>
</div>
<div className="relative">
<Progress value={overall_score} className="h-3" />
<div
className={`absolute inset-0 rounded-full ${getScoreBackground(overall_score)} transition-all`}
style={{ width: `${overall_score}%` }}
/>
</div>
</div>
{/* Issues List */}
{issues.length === 0 ? (
<div className="text-center py-12">
<CheckCircle2 className="h-16 w-16 text-green-600 mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">All Systems Healthy!</h3>
<p className="text-muted-foreground">
No database health issues detected at this time.
</p>
</div>
) : (
<div className="space-y-4">
{/* Critical Issues */}
{criticalIssues.length > 0 && (
<div className="space-y-2">
<h3 className="text-lg font-semibold text-red-600 flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
Critical Issues ({criticalIssues.length})
</h3>
<Accordion type="multiple" className="space-y-2">
{criticalIssues.map((issue, index) => (
<HealthIssueCard key={index} issue={issue} />
))}
</Accordion>
</div>
)}
{/* Warnings */}
{warningIssues.length > 0 && (
<div className="space-y-2">
<h3 className="text-lg font-semibold text-yellow-600 flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Warnings ({warningIssues.length})
</h3>
<Accordion type="multiple" className="space-y-2">
{warningIssues.map((issue, index) => (
<HealthIssueCard key={index} issue={issue} />
))}
</Accordion>
</div>
)}
{/* Info */}
{infoIssues.length > 0 && (
<div className="space-y-2">
<h3 className="text-lg font-semibold text-blue-600 flex items-center gap-2">
<Info className="h-5 w-5" />
Information ({infoIssues.length})
</h3>
<Accordion type="multiple" className="space-y-2">
{infoIssues.map((issue, index) => (
<HealthIssueCard key={index} issue={issue} />
))}
</Accordion>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Entity Comparisons</CardTitle>
<CardDescription>Loading comparison data...</CardDescription>
</CardHeader>
<CardContent>
<div className="animate-pulse space-y-4">
<div className="h-64 bg-muted rounded" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Entity Comparisons</CardTitle>
<CardDescription>Top entities by content volume</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="parks-rides" className="space-y-4">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="parks-rides">
<Building2 className="h-4 w-4 mr-2" />
Parks
</TabsTrigger>
<TabsTrigger value="manufacturers">
<Factory className="h-4 w-4 mr-2" />
Manufacturers
</TabsTrigger>
<TabsTrigger value="operators">
<Users className="h-4 w-4 mr-2" />
Operators
</TabsTrigger>
<TabsTrigger value="designers">
<Pencil className="h-4 w-4 mr-2" />
Designers
</TabsTrigger>
<TabsTrigger value="photos">
<ImageIcon className="h-4 w-4 mr-2" />
Photos
</TabsTrigger>
</TabsList>
<TabsContent value="parks-rides" className="space-y-4">
<ComparisonTable
title="Top Parks by Ride Count"
data={data.top_parks_by_rides}
columns={[
{ key: 'park_name', label: 'Park Name', linkBase: '/parks' },
{ key: 'ride_count', label: 'Rides', numeric: true },
{ key: 'photo_count', label: 'Photos', numeric: true },
]}
slugKey="park_slug"
/>
</TabsContent>
<TabsContent value="manufacturers" className="space-y-4">
<ComparisonTable
title="Top Manufacturers"
data={data.top_manufacturers}
columns={[
{ key: 'manufacturer_name', label: 'Manufacturer', linkBase: '/manufacturers' },
{ key: 'ride_count', label: 'Rides', numeric: true },
{ key: 'model_count', label: 'Models', numeric: true },
]}
slugKey="slug"
/>
</TabsContent>
<TabsContent value="operators" className="space-y-4">
<ComparisonTable
title="Top Operators"
data={data.top_operators}
columns={[
{ key: 'operator_name', label: 'Operator', linkBase: '/operators' },
{ key: 'park_count', label: 'Parks', numeric: true },
{ key: 'ride_count', label: 'Total Rides', numeric: true },
]}
slugKey="slug"
/>
</TabsContent>
<TabsContent value="designers" className="space-y-4">
<ComparisonTable
title="Top Designers"
data={data.top_designers}
columns={[
{ key: 'designer_name', label: 'Designer', linkBase: '/designers' },
{ key: 'ride_count', label: 'Rides', numeric: true },
]}
slugKey="slug"
/>
</TabsContent>
<TabsContent value="photos" className="space-y-4">
<div className="space-y-6">
<ComparisonTable
title="Top Parks by Photo Count"
data={data.top_parks_by_photos}
columns={[
{ key: 'park_name', label: 'Park Name', linkBase: '/parks' },
{ key: 'photo_count', label: 'Photos', numeric: true },
]}
slugKey="park_slug"
/>
<ComparisonTable
title="Top Rides by Photo Count"
data={data.top_rides_by_photos}
columns={[
{ key: 'ride_name', label: 'Ride Name', linkBase: '/parks' },
{ key: 'photo_count', label: 'Photos', numeric: true },
]}
slugKey="ride_slug"
parkSlugKey="park_slug"
/>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View File

@@ -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<number>(90);
const [granularity, setGranularity] = useState<GranularityType>('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 (
<Card>
<CardHeader>
<CardTitle>Growth Trends</CardTitle>
<CardDescription>Loading growth data...</CardDescription>
</CardHeader>
<CardContent>
<div className="h-80 bg-muted rounded animate-pulse" />
</CardContent>
</Card>
);
}
const formattedData = data?.map(point => ({
...point,
date: format(new Date(point.period), granularity === 'daily' ? 'MMM dd' : granularity === 'weekly' ? 'MMM dd' : 'MMM yyyy'),
})) || [];
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<CardTitle>Growth Trends</CardTitle>
<CardDescription>Entity additions over time</CardDescription>
</div>
<div className="flex gap-2 flex-wrap">
{/* Time Range Controls */}
<div className="flex gap-1">
{[
{ label: '7D', days: 7 },
{ label: '30D', days: 30 },
{ label: '90D', days: 90 },
{ label: '1Y', days: 365 },
].map(({ label, days }) => (
<Button
key={label}
variant={timeRange === days ? 'default' : 'outline'}
size="sm"
onClick={() => setTimeRange(days)}
>
{label}
</Button>
))}
</div>
{/* Granularity Controls */}
<div className="flex gap-1">
{(['daily', 'weekly', 'monthly'] as GranularityType[]).map((g) => (
<Button
key={g}
variant={granularity === g ? 'default' : 'outline'}
size="sm"
onClick={() => setGranularity(g)}
className="capitalize"
>
{g}
</Button>
))}
</div>
</div>
</div>
</CardHeader>
<CardContent>
{/* Entity Type Toggles */}
<div className="flex gap-2 mb-4 flex-wrap">
{Object.entries(chartConfig).map(([key, config]) => (
<Button
key={key}
variant={activeLines[key as keyof typeof activeLines] ? 'default' : 'outline'}
size="sm"
onClick={() => toggleLine(key as keyof typeof activeLines)}
>
{config.label}
</Button>
))}
</div>
{/* Chart */}
<ChartContainer config={chartConfig} className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={formattedData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
className="text-xs"
tick={{ fill: 'hsl(var(--muted-foreground))' }}
/>
<YAxis
className="text-xs"
tick={{ fill: 'hsl(var(--muted-foreground))' }}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
{activeLines.parks_added && (
<Line
type="monotone"
dataKey="parks_added"
stroke={chartConfig.parks_added.color}
strokeWidth={2}
dot={false}
name={chartConfig.parks_added.label}
/>
)}
{activeLines.rides_added && (
<Line
type="monotone"
dataKey="rides_added"
stroke={chartConfig.rides_added.color}
strokeWidth={2}
dot={false}
name={chartConfig.rides_added.label}
/>
)}
{activeLines.companies_added && (
<Line
type="monotone"
dataKey="companies_added"
stroke={chartConfig.companies_added.color}
strokeWidth={2}
dot={false}
name={chartConfig.companies_added.label}
/>
)}
{activeLines.ride_models_added && (
<Line
type="monotone"
dataKey="ride_models_added"
stroke={chartConfig.ride_models_added.color}
strokeWidth={2}
dot={false}
name={chartConfig.ride_models_added.label}
/>
)}
{activeLines.photos_added && (
<Line
type="monotone"
dataKey="photos_added"
stroke={chartConfig.photos_added.color}
strokeWidth={2}
dot={false}
name={chartConfig.photos_added.label}
/>
)}
</LineChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -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 <AlertCircle className="h-4 w-4 text-red-600" />;
case 'warning':
return <AlertTriangle className="h-4 w-4 text-yellow-600" />;
case 'info':
return <Info className="h-4 w-4 text-blue-600" />;
}
};
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 (
<AccordionItem
value={`issue-${issue.category}-${issue.count}`}
className={`border rounded-lg ${getSeverityColor()}`}
>
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center justify-between w-full pr-4">
<div className="flex items-center gap-3">
{getSeverityIcon()}
<div className="text-left">
<div className="font-semibold">{issue.description}</div>
<div className="text-sm text-muted-foreground capitalize">
{issue.category.replace(/_/g, ' ')}
</div>
</div>
</div>
<Badge variant={getSeverityBadgeVariant()}>
{issue.count} {issue.count === 1 ? 'entity' : 'entities'}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 space-y-4">
{/* Suggested Action */}
<div className="flex items-start gap-2 p-3 bg-background rounded border">
<Lightbulb className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<div className="text-sm font-medium">Suggested Action</div>
<div className="text-sm text-muted-foreground">{issue.suggested_action}</div>
</div>
</div>
{/* Entity IDs (first 10) */}
{issue.entity_ids && issue.entity_ids.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium">
Affected Entities ({issue.entity_ids.length})
</div>
<div className="flex flex-wrap gap-2">
{issue.entity_ids.slice(0, 10).map((id) => (
<Badge key={id} variant="outline" className="font-mono text-xs">
{id.substring(0, 8)}...
</Badge>
))}
{issue.entity_ids.length > 10 && (
<Badge variant="secondary" className="text-xs">
+{issue.entity_ids.length - 10} more
</Badge>
)}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
<Button size="sm" variant="default">
View Entities
</Button>
<Button size="sm" variant="outline">
Export List
</Button>
</div>
</AccordionContent>
</AccordionItem>
);
}

View File

@@ -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!)
});
}

View File

@@ -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
});
}

View File

@@ -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
});
}

View File

@@ -6673,6 +6673,7 @@ export type Database = {
Returns: boolean Returns: boolean
} }
cancel_user_email_change: { Args: { _user_id: string }; Returns: boolean } cancel_user_email_change: { Args: { _user_id: string }; Returns: boolean }
check_database_health: { Args: never; Returns: Json }
check_rate_limit: { check_rate_limit: {
Args: { Args: {
p_action: string p_action: string
@@ -6831,6 +6832,19 @@ export type Database = {
get_current_user_id: { Args: never; Returns: string } get_current_user_id: { Args: never; Returns: string }
get_database_statistics: { Args: never; Returns: Json } get_database_statistics: { Args: never; Returns: Json }
get_email_change_status: { 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: { get_filtered_profile: {
Args: { _profile_user_id: string; _viewer_id?: string } Args: { _profile_user_id: string; _viewer_id?: string }
Returns: Json Returns: Json

View File

@@ -104,4 +104,12 @@ export const queryKeys = {
databaseStats: () => ['admin', 'database-stats'] as const, databaseStats: () => ['admin', 'database-stats'] as const,
recentAdditions: (limit: number) => ['admin', 'recent-additions', limit] 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; } as const;

View File

@@ -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 { AdminLayout } from '@/components/layout/AdminLayout';
import { useAdminGuard } from '@/hooks/useAdminGuard'; import { useAdminGuard } from '@/hooks/useAdminGuard';
import { DatabaseStatsCard } from '@/components/admin/database-stats/DatabaseStatsCard'; import { DatabaseStatsCard } from '@/components/admin/database-stats/DatabaseStatsCard';
import { RecentAdditionsTable } from '@/components/admin/database-stats/RecentAdditionsTable'; 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 { useAdminDatabaseStats } from '@/hooks/useAdminDatabaseStats';
import { useRecentAdditions } from '@/hooks/useRecentAdditions'; import { useRecentAdditions } from '@/hooks/useRecentAdditions';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
export default function AdminDatabaseStats() { export default function AdminDatabaseStats() {
@@ -58,103 +63,166 @@ export default function AdminDatabaseStats() {
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Database Statistics</h1> <h1 className="text-3xl font-bold tracking-tight">Database Statistics</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Complete overview of database content and activity Comprehensive analytics, quality metrics, and health monitoring
</p> </p>
</div> </div>
{/* Stats Grid */} <Tabs defaultValue="overview" className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <TabsList className="grid w-full grid-cols-5">
<DatabaseStatsCard <TabsTrigger value="overview" className="flex items-center gap-2">
title="Total Entities" <Box className="h-4 w-4" />
icon={Box} Overview
iconClassName="text-blue-500" </TabsTrigger>
stats={[ <TabsTrigger value="growth" className="flex items-center gap-2">
{ label: 'All Entities', value: totalEntities }, <TrendingUp className="h-4 w-4" />
{ label: 'Parks', value: stats?.parks.total || 0 }, Growth Trends
{ label: 'Rides', value: stats?.rides.total || 0 }, </TabsTrigger>
{ label: 'Companies', value: stats?.companies.total || 0 }, <TabsTrigger value="comparisons" className="flex items-center gap-2">
{ label: 'Ride Models', value: stats?.ride_models.total || 0 }, <BarChart3 className="h-4 w-4" />
]} Comparisons
/> </TabsTrigger>
<TabsTrigger value="quality" className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Data Quality
</TabsTrigger>
<TabsTrigger value="health" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Health Checks
</TabsTrigger>
</TabsList>
<DatabaseStatsCard {/* Overview Tab */}
title="Recent Activity" <TabsContent value="overview" className="space-y-6">
icon={TrendingUp} {/* Stats Grid */}
iconClassName="text-green-500" <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
stats={[ <DatabaseStatsCard
{ title="Total Entities"
label: 'Added (7 days)', icon={Box}
value: recentAdditions7d, iconClassName="text-blue-500"
}, stats={[
{ { label: 'All Entities', value: totalEntities },
label: 'Added (30 days)', { label: 'Parks', value: stats?.parks.total || 0 },
value: recentAdditions30d, { label: 'Rides', value: stats?.rides.total || 0 },
}, { label: 'Companies', value: stats?.companies.total || 0 },
]} { label: 'Ride Models', value: stats?.ride_models.total || 0 },
/> ]}
/>
<DatabaseStatsCard <DatabaseStatsCard
title="Parks & Rides" title="Recent Activity"
icon={Building2} icon={TrendingUp}
iconClassName="text-purple-500" iconClassName="text-green-500"
stats={[ stats={[
{ label: 'Active Parks', value: stats?.parks.active || 0 }, {
{ label: 'Historical Parks', value: stats?.parks.historical || 0 }, label: 'Added (7 days)',
{ label: 'Active Rides', value: stats?.rides.active || 0 }, value: recentAdditions7d,
{ label: 'Historical Rides', value: stats?.rides.historical || 0 }, },
]} {
/> label: 'Added (30 days)',
value: recentAdditions30d,
},
]}
/>
<DatabaseStatsCard <DatabaseStatsCard
title="Content" title="Parks & Rides"
icon={ImageIcon} icon={Building2}
iconClassName="text-orange-500" iconClassName="text-purple-500"
stats={[ stats={[
{ label: 'Photos', value: stats?.photos.total || 0 }, { label: 'Active Parks', value: stats?.parks.active || 0 },
{ label: 'Locations', value: stats?.locations.total || 0 }, { label: 'Historical Parks', value: stats?.parks.historical || 0 },
{ label: 'Timeline Events', value: stats?.timeline_events.total || 0 }, { label: 'Active Rides', value: stats?.rides.active || 0 },
]} { label: 'Historical Rides', value: stats?.rides.historical || 0 },
/> ]}
/>
<DatabaseStatsCard <DatabaseStatsCard
title="Companies" title="Content"
icon={Factory} icon={ImageIcon}
iconClassName="text-amber-500" iconClassName="text-orange-500"
stats={[ stats={[
{ label: 'Total', value: stats?.companies.total || 0 }, { label: 'Photos', value: stats?.photos.total || 0 },
{ label: 'Manufacturers', value: stats?.companies.manufacturers || 0 }, { label: 'Locations', value: stats?.locations.total || 0 },
{ label: 'Operators', value: stats?.companies.operators || 0 }, { label: 'Timeline Events', value: stats?.timeline_events.total || 0 },
{ label: 'Designers', value: stats?.companies.designers || 0 }, ]}
]} />
/>
<DatabaseStatsCard <DatabaseStatsCard
title="User Activity" title="Companies"
icon={Users} icon={Factory}
iconClassName="text-teal-500" iconClassName="text-amber-500"
stats={[ stats={[
{ label: 'Total Users', value: stats?.users.total || 0 }, { label: 'Total', value: stats?.companies.total || 0 },
{ label: 'Active (30 days)', value: stats?.users.active_30d || 0 }, { label: 'Manufacturers', value: stats?.companies.manufacturers || 0 },
]} { label: 'Operators', value: stats?.companies.operators || 0 },
/> { label: 'Designers', value: stats?.companies.designers || 0 },
]}
/>
<DatabaseStatsCard <DatabaseStatsCard
title="Submissions" title="User Activity"
icon={FileText} icon={Users}
iconClassName="text-pink-500" iconClassName="text-teal-500"
stats={[ stats={[
{ label: 'Pending', value: stats?.submissions.pending || 0 }, { label: 'Total Users', value: stats?.users.total || 0 },
{ label: 'Approved', value: stats?.submissions.approved || 0 }, { label: 'Active (30 days)', value: stats?.users.active_30d || 0 },
{ label: 'Rejected', value: stats?.submissions.rejected || 0 }, ]}
]} />
/>
</div>
{/* Recent Additions Table */} <DatabaseStatsCard
<RecentAdditionsTable title="Submissions"
additions={recentAdditions || []} icon={FileText}
isLoading={additionsLoading} iconClassName="text-pink-500"
/> stats={[
{ label: 'Pending', value: stats?.submissions.pending || 0 },
{ label: 'Approved', value: stats?.submissions.approved || 0 },
{ label: 'Rejected', value: stats?.submissions.rejected || 0 },
]}
/>
</div>
{/* Data Quality Overview */}
<DataQualityOverview />
{/* Recent Additions Table */}
<RecentAdditionsTable
additions={recentAdditions || []}
isLoading={additionsLoading}
/>
</TabsContent>
{/* Growth Trends Tab */}
<TabsContent value="growth" className="space-y-6">
<GrowthTrendsChart />
</TabsContent>
{/* Entity Comparisons Tab */}
<TabsContent value="comparisons" className="space-y-6">
<EntityComparisonDashboard />
</TabsContent>
{/* Data Quality Tab */}
<TabsContent value="quality" className="space-y-6">
<DataQualityOverview />
<div className="p-6 border rounded-lg bg-muted/50">
<h3 className="text-lg font-semibold mb-2">Full Data Completeness Dashboard</h3>
<p className="text-sm text-muted-foreground mb-4">
For detailed analysis of data completeness by entity, missing fields, and improvement opportunities.
</p>
<a
href="/admin/data-completeness"
className="inline-flex items-center gap-2 text-primary hover:underline"
>
View Full Dashboard
</a>
</div>
</TabsContent>
{/* Database Health Tab */}
<TabsContent value="health" className="space-y-6">
<DatabaseHealthDashboard />
</TabsContent>
</Tabs>
</div> </div>
</AdminLayout> </AdminLayout>
); );

View File

@@ -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;
}

View File

@@ -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;
$$;