mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 15:51:12 -05:00
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:
107
src/components/admin/database-stats/ComparisonTable.tsx
Normal file
107
src/components/admin/database-stats/ComparisonTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/components/admin/database-stats/DataQualityOverview.tsx
Normal file
124
src/components/admin/database-stats/DataQualityOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
src/components/admin/database-stats/DatabaseHealthDashboard.tsx
Normal file
159
src/components/admin/database-stats/DatabaseHealthDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
204
src/components/admin/database-stats/GrowthTrendsChart.tsx
Normal file
204
src/components/admin/database-stats/GrowthTrendsChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/admin/database-stats/HealthIssueCard.tsx
Normal file
110
src/components/admin/database-stats/HealthIssueCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/hooks/useDatabaseHealthCheck.ts
Normal file
21
src/hooks/useDatabaseHealthCheck.ts
Normal 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!)
|
||||
});
|
||||
}
|
||||
21
src/hooks/useEntityComparisons.ts
Normal file
21
src/hooks/useEntityComparisons.ts
Normal 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
|
||||
});
|
||||
}
|
||||
24
src/hooks/useGrowthTrends.ts
Normal file
24
src/hooks/useGrowthTrends.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,10 +63,36 @@ export default function AdminDatabaseStats() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Database Statistics</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Complete overview of database content and activity
|
||||
Comprehensive analytics, quality metrics, and health monitoring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="overview" className="flex items-center gap-2">
|
||||
<Box className="h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="growth" className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Growth Trends
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="comparisons" className="flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<DatabaseStatsCard
|
||||
@@ -150,11 +181,48 @@ export default function AdminDatabaseStats() {
|
||||
/>
|
||||
</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>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
88
src/types/database-analytics.ts
Normal file
88
src/types/database-analytics.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user