mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:51:12 -05:00
Compare commits
2 Commits
664c894bb1
...
69db3c7743
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69db3c7743 | ||
|
|
901d25807d |
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Data Completeness Summary Component
|
||||||
|
*
|
||||||
|
* Displays high-level overview cards for data completeness metrics
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Database, AlertCircle, CheckCircle2, TrendingUp } from 'lucide-react';
|
||||||
|
import type { CompletenessSummary } from '@/types/data-completeness';
|
||||||
|
|
||||||
|
interface CompletenessSummaryProps {
|
||||||
|
summary: CompletenessSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompletenessSummary({ summary }: CompletenessSummaryProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Entities</CardTitle>
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{summary.total_entities.toLocaleString()}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Parks: {summary.by_entity_type.parks} | Rides: {summary.by_entity_type.rides}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Avg Completeness</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{summary.avg_completeness_score?.toFixed(1) || 0}%</div>
|
||||||
|
<Progress value={summary.avg_completeness_score || 0} className="mt-2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Below 50%</CardTitle>
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-destructive">
|
||||||
|
{summary.entities_below_50}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{((summary.entities_below_50 / summary.total_entities) * 100).toFixed(1)}% of total
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">100% Complete</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{summary.entities_100_complete}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{((summary.entities_100_complete / summary.total_entities) * 100).toFixed(1)}% of total
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/admin/data-completeness/CompletenessFilters.tsx
Normal file
110
src/components/admin/data-completeness/CompletenessFilters.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Data Completeness Filters Component
|
||||||
|
*
|
||||||
|
* Filter controls for entity type, score range, and missing field categories
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import type { CompletenessFilters, EntityType, MissingFieldCategory } from '@/types/data-completeness';
|
||||||
|
|
||||||
|
interface CompletenessFiltersProps {
|
||||||
|
filters: CompletenessFilters;
|
||||||
|
onFiltersChange: (filters: CompletenessFilters) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompletenessFilters({ filters, onFiltersChange }: CompletenessFiltersProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="entity-type">Entity Type</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.entityType || 'all'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
entityType: value === 'all' ? undefined : (value as EntityType),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="entity-type">
|
||||||
|
<SelectValue placeholder="All entities" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Entities</SelectItem>
|
||||||
|
<SelectItem value="park">Parks</SelectItem>
|
||||||
|
<SelectItem value="ride">Rides</SelectItem>
|
||||||
|
<SelectItem value="company">Companies</SelectItem>
|
||||||
|
<SelectItem value="ride_model">Ride Models</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="missing-category">Missing Category</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.missingCategory || 'all'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
missingCategory: value === 'all' ? undefined : (value as MissingFieldCategory),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="missing-category">
|
||||||
|
<SelectValue placeholder="All categories" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Categories</SelectItem>
|
||||||
|
<SelectItem value="critical">Missing Critical</SelectItem>
|
||||||
|
<SelectItem value="important">Missing Important</SelectItem>
|
||||||
|
<SelectItem value="valuable">Missing Valuable</SelectItem>
|
||||||
|
<SelectItem value="supplementary">Missing Supplementary</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="search">Search</Label>
|
||||||
|
<Input
|
||||||
|
id="search"
|
||||||
|
placeholder="Search entities..."
|
||||||
|
value={filters.searchQuery || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
searchQuery: e.target.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Completeness Score Range</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{filters.minScore || 0}% - {filters.maxScore || 100}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={[filters.minScore || 0, filters.maxScore || 100]}
|
||||||
|
onValueChange={([min, max]) =>
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
minScore: min === 0 ? undefined : min,
|
||||||
|
maxScore: max === 100 ? undefined : max,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/admin/data-completeness/CompletenessTable.tsx
Normal file
146
src/components/admin/data-completeness/CompletenessTable.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Data Completeness Table Component
|
||||||
|
*
|
||||||
|
* Virtualized table displaying entity completeness data with sorting and actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ExternalLink, AlertCircle } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { EntityCompleteness, CompletenessFilters } from '@/types/data-completeness';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
interface CompletenessTableProps {
|
||||||
|
entities: EntityCompleteness[];
|
||||||
|
filters: CompletenessFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompletenessTable({ entities, filters }: CompletenessTableProps) {
|
||||||
|
// Filter and sort entities
|
||||||
|
const filteredEntities = useMemo(() => {
|
||||||
|
let filtered = entities;
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (filters.searchQuery) {
|
||||||
|
const query = filters.searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter((entity) =>
|
||||||
|
entity.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by completeness score (ascending - most incomplete first)
|
||||||
|
return filtered.sort((a, b) => a.completeness_score - b.completeness_score);
|
||||||
|
}, [entities, filters]);
|
||||||
|
|
||||||
|
const getEntityUrl = (entity: EntityCompleteness) => {
|
||||||
|
switch (entity.entity_type) {
|
||||||
|
case 'park':
|
||||||
|
return `/parks/${entity.slug}`;
|
||||||
|
case 'ride':
|
||||||
|
return `/rides/${entity.slug}`;
|
||||||
|
case 'company':
|
||||||
|
return `/companies/${entity.slug}`;
|
||||||
|
case 'ride_model':
|
||||||
|
return `/ride-models/${entity.slug}`;
|
||||||
|
default:
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 80) return 'text-green-600';
|
||||||
|
if (score >= 50) return 'text-yellow-600';
|
||||||
|
return 'text-destructive';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMissingFieldsCount = (entity: EntityCompleteness) => {
|
||||||
|
return (
|
||||||
|
entity.missing_fields.critical.length +
|
||||||
|
entity.missing_fields.important.length +
|
||||||
|
entity.missing_fields.valuable.length +
|
||||||
|
entity.missing_fields.supplementary.length
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filteredEntities.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-lg font-medium">No entities found</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try adjusting your filters</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Entity</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Completeness</TableHead>
|
||||||
|
<TableHead>Missing Fields</TableHead>
|
||||||
|
<TableHead>Last Updated</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredEntities.map((entity) => (
|
||||||
|
<TableRow key={entity.id}>
|
||||||
|
<TableCell className="font-medium">{entity.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{entity.entity_type.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm font-medium ${getScoreColor(entity.completeness_score)}`}>
|
||||||
|
{entity.completeness_score.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={entity.completeness_score} className="h-2" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{entity.missing_fields.critical.length > 0 && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
{entity.missing_fields.critical.length} Critical
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{entity.missing_fields.important.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{entity.missing_fields.important.length} Important
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{getMissingFieldsCount(entity) === 0 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Complete
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(entity.updated_at), { addSuffix: true })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={getEntityUrl(entity)}>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Data Completeness Dashboard
|
||||||
|
*
|
||||||
|
* Main dashboard component combining summary, filters, and table
|
||||||
|
* Provides comprehensive view of data quality across all entity types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Loader2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useDataCompleteness } from '@/hooks/useDataCompleteness';
|
||||||
|
import { CompletenessSummary } from './CompletenesSummary';
|
||||||
|
import { CompletenessFilters } from './CompletenessFilters';
|
||||||
|
import { CompletenessTable } from './CompletenessTable';
|
||||||
|
import type { CompletenessFilters as Filters, EntityType } from '@/types/data-completeness';
|
||||||
|
|
||||||
|
export function DataCompletenessDashboard() {
|
||||||
|
const [filters, setFilters] = useState<Filters>({});
|
||||||
|
const { data, isLoading, error, refetch, isRefetching } = useDataCompleteness(filters);
|
||||||
|
|
||||||
|
// Combine all entities for the "All" tab
|
||||||
|
const allEntities = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return [
|
||||||
|
...data.entities.parks,
|
||||||
|
...data.entities.rides,
|
||||||
|
...data.entities.companies,
|
||||||
|
...data.entities.ride_models,
|
||||||
|
];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">Analyzing data completeness...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Failed to load data completeness analysis. Please try again.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Data Completeness Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Monitor and improve data quality across all entities
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isRefetching}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{isRefetching ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CompletenessSummary summary={data.summary} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Filter Entities</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Filter by entity type, completeness score, and missing field categories
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CompletenessFilters filters={filters} onFiltersChange={setFilters} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Entity Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Entities sorted by completeness (most incomplete first)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="all" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">
|
||||||
|
All ({allEntities.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="parks">
|
||||||
|
Parks ({data.entities.parks.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="rides">
|
||||||
|
Rides ({data.entities.rides.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="companies">
|
||||||
|
Companies ({data.entities.companies.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ride_models">
|
||||||
|
Ride Models ({data.entities.ride_models.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="all">
|
||||||
|
<CompletenessTable entities={allEntities} filters={filters} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="parks">
|
||||||
|
<CompletenessTable entities={data.entities.parks} filters={filters} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="rides">
|
||||||
|
<CompletenessTable entities={data.entities.rides} filters={filters} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="companies">
|
||||||
|
<CompletenessTable entities={data.entities.companies} filters={filters} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="ride_models">
|
||||||
|
<CompletenessTable entities={data.entities.ride_models} filters={filters} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/hooks/useDataCompleteness.ts
Normal file
106
src/hooks/useDataCompleteness.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Data Completeness Hook
|
||||||
|
*
|
||||||
|
* React Query hook for fetching and caching data completeness analysis
|
||||||
|
* with real-time updates via Supabase subscriptions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { CompletenessAnalysis, CompletenessFilters } from '@/types/data-completeness';
|
||||||
|
import { handleError } from '@/lib/errorHandler';
|
||||||
|
|
||||||
|
export function useDataCompleteness(filters: CompletenessFilters = {}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['data-completeness', filters],
|
||||||
|
queryFn: async (): Promise<CompletenessAnalysis> => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc('analyze_data_completeness', {
|
||||||
|
p_entity_type: filters.entityType ?? undefined,
|
||||||
|
p_min_score: filters.minScore ?? undefined,
|
||||||
|
p_max_score: filters.maxScore ?? undefined,
|
||||||
|
p_missing_category: filters.missingCategory ?? undefined,
|
||||||
|
p_limit: 1000,
|
||||||
|
p_offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data as unknown as CompletenessAnalysis;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, {
|
||||||
|
action: 'fetch_data_completeness',
|
||||||
|
metadata: {
|
||||||
|
filters,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real-time subscriptions for data updates
|
||||||
|
useEffect(() => {
|
||||||
|
// Subscribe to parks changes
|
||||||
|
const parksChannel = supabase
|
||||||
|
.channel('parks-completeness-updates')
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: '*', schema: 'public', table: 'parks' },
|
||||||
|
() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['data-completeness'] });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
// Subscribe to rides changes
|
||||||
|
const ridesChannel = supabase
|
||||||
|
.channel('rides-completeness-updates')
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: '*', schema: 'public', table: 'rides' },
|
||||||
|
() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['data-completeness'] });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
// Subscribe to companies changes
|
||||||
|
const companiesChannel = supabase
|
||||||
|
.channel('companies-completeness-updates')
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: '*', schema: 'public', table: 'companies' },
|
||||||
|
() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['data-completeness'] });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
// Subscribe to ride_models changes
|
||||||
|
const modelsChannel = supabase
|
||||||
|
.channel('ride-models-completeness-updates')
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: '*', schema: 'public', table: 'ride_models' },
|
||||||
|
() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['data-completeness'] });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(parksChannel);
|
||||||
|
supabase.removeChannel(ridesChannel);
|
||||||
|
supabase.removeChannel(companiesChannel);
|
||||||
|
supabase.removeChannel(modelsChannel);
|
||||||
|
};
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
@@ -6628,6 +6628,17 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Functions: {
|
Functions: {
|
||||||
|
analyze_data_completeness: {
|
||||||
|
Args: {
|
||||||
|
p_entity_type?: string
|
||||||
|
p_limit?: number
|
||||||
|
p_max_score?: number
|
||||||
|
p_min_score?: number
|
||||||
|
p_missing_category?: string
|
||||||
|
p_offset?: number
|
||||||
|
}
|
||||||
|
Returns: Json
|
||||||
|
}
|
||||||
anonymize_user_submissions: {
|
anonymize_user_submissions: {
|
||||||
Args: { target_user_id: string }
|
Args: { target_user_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { IntegrationTestRunner } from '@/components/admin/IntegrationTestRunner'
|
|||||||
import { ParkLocationBackfill } from '@/components/admin/ParkLocationBackfill';
|
import { ParkLocationBackfill } from '@/components/admin/ParkLocationBackfill';
|
||||||
import { RideDataBackfill } from '@/components/admin/RideDataBackfill';
|
import { RideDataBackfill } from '@/components/admin/RideDataBackfill';
|
||||||
import { CompanyDataBackfill } from '@/components/admin/CompanyDataBackfill';
|
import { CompanyDataBackfill } from '@/components/admin/CompanyDataBackfill';
|
||||||
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube, RefreshCw, Info, AlertCircle, Database } from 'lucide-react';
|
import { DataCompletenessDashboard } from '@/components/admin/data-completeness/DataCompletenessDashboard';
|
||||||
|
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube, RefreshCw, Info, AlertCircle, Database, BarChart3 } from 'lucide-react';
|
||||||
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
@@ -749,7 +750,7 @@ export default function AdminSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="moderation" className="space-y-6">
|
<Tabs defaultValue="moderation" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-7">
|
<TabsList className="grid w-full grid-cols-8">
|
||||||
<TabsTrigger value="moderation" className="flex items-center gap-2">
|
<TabsTrigger value="moderation" className="flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Moderation</span>
|
<span className="hidden sm:inline">Moderation</span>
|
||||||
@@ -774,6 +775,10 @@ export default function AdminSettings() {
|
|||||||
<Plug className="w-4 h-4" />
|
<Plug className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Integrations</span>
|
<span className="hidden sm:inline">Integrations</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="data-quality" className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Data Quality</span>
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="testing" className="flex items-center gap-2">
|
<TabsTrigger value="testing" className="flex items-center gap-2">
|
||||||
<TestTube className="w-4 h-4" />
|
<TestTube className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Testing</span>
|
<span className="hidden sm:inline">Testing</span>
|
||||||
@@ -973,6 +978,10 @@ export default function AdminSettings() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="data-quality">
|
||||||
|
<DataCompletenessDashboard />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="testing">
|
<TabsContent value="testing">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Test Data Generator Section */}
|
{/* Test Data Generator Section */}
|
||||||
|
|||||||
58
src/types/data-completeness.ts
Normal file
58
src/types/data-completeness.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Data Completeness Types
|
||||||
|
*
|
||||||
|
* TypeScript interfaces for the comprehensive data completeness analysis system
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EntityType = 'park' | 'ride' | 'company' | 'ride_model';
|
||||||
|
|
||||||
|
export type MissingFieldCategory = 'critical' | 'important' | 'valuable' | 'supplementary';
|
||||||
|
|
||||||
|
export interface MissingFields {
|
||||||
|
critical: string[];
|
||||||
|
important: string[];
|
||||||
|
valuable: string[];
|
||||||
|
supplementary: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityCompleteness {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
entity_type: EntityType;
|
||||||
|
updated_at: string;
|
||||||
|
completeness_score: number;
|
||||||
|
missing_fields: MissingFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletenessSummary {
|
||||||
|
total_entities: number;
|
||||||
|
avg_completeness_score: number;
|
||||||
|
entities_below_50: number;
|
||||||
|
entities_100_complete: number;
|
||||||
|
by_entity_type: {
|
||||||
|
parks: number;
|
||||||
|
rides: number;
|
||||||
|
companies: number;
|
||||||
|
ride_models: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletenessAnalysis {
|
||||||
|
summary: CompletenessSummary;
|
||||||
|
entities: {
|
||||||
|
parks: EntityCompleteness[];
|
||||||
|
rides: EntityCompleteness[];
|
||||||
|
companies: EntityCompleteness[];
|
||||||
|
ride_models: EntityCompleteness[];
|
||||||
|
};
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletenessFilters {
|
||||||
|
entityType?: EntityType;
|
||||||
|
minScore?: number;
|
||||||
|
maxScore?: number;
|
||||||
|
missingCategory?: MissingFieldCategory;
|
||||||
|
searchQuery?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
-- Comprehensive Data Completeness Analysis Function
|
||||||
|
-- Analyzes missing data across all entity types with weighted scoring
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION analyze_data_completeness(
|
||||||
|
p_entity_type TEXT DEFAULT NULL,
|
||||||
|
p_min_score NUMERIC DEFAULT NULL,
|
||||||
|
p_max_score NUMERIC DEFAULT NULL,
|
||||||
|
p_missing_category TEXT DEFAULT NULL,
|
||||||
|
p_limit INTEGER DEFAULT 100,
|
||||||
|
p_offset INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
RETURNS JSONB
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_result JSONB;
|
||||||
|
v_parks JSONB;
|
||||||
|
v_rides JSONB;
|
||||||
|
v_companies JSONB;
|
||||||
|
v_ride_models JSONB;
|
||||||
|
v_locations JSONB;
|
||||||
|
v_timeline_events JSONB;
|
||||||
|
v_summary JSONB;
|
||||||
|
BEGIN
|
||||||
|
-- Parks Analysis (including historical)
|
||||||
|
WITH park_analysis AS (
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
p.slug,
|
||||||
|
'park' as entity_type,
|
||||||
|
p.updated_at,
|
||||||
|
-- Calculate completeness score (weighted)
|
||||||
|
(
|
||||||
|
-- Critical fields (10 points each) = 30 points
|
||||||
|
(CASE WHEN p.park_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.status IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.location_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Important fields (7 points each) = 35 points
|
||||||
|
(CASE WHEN p.description IS NOT NULL AND length(p.description) > 50 THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.operator_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.property_owner_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Valuable fields (5 points each) = 20 points
|
||||||
|
(CASE WHEN p.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.website_url IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.phone IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Supplementary fields (3 points each) = 9 points
|
||||||
|
(CASE WHEN p.email IS NOT NULL THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.closing_date IS NOT NULL AND p.status = 'closed' THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN EXISTS(SELECT 1 FROM entity_timeline_events WHERE entity_id = p.id AND entity_type = 'park') THEN 3 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Nice-to-have fields (1 point each) = 6 points
|
||||||
|
(CASE WHEN EXISTS(SELECT 1 FROM locations WHERE id = p.location_id AND latitude IS NOT NULL AND longitude IS NOT NULL) THEN 1 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.closing_date_precision IS NOT NULL AND p.status = 'closed' THEN 1 ELSE 0 END)
|
||||||
|
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||||
|
|
||||||
|
-- Missing fields tracking
|
||||||
|
jsonb_build_object(
|
||||||
|
'critical', jsonb_build_array(
|
||||||
|
CASE WHEN p.park_type IS NULL THEN 'park_type' END,
|
||||||
|
CASE WHEN p.status IS NULL THEN 'status' END,
|
||||||
|
CASE WHEN p.location_id IS NULL THEN 'location_id' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'important', jsonb_build_array(
|
||||||
|
CASE WHEN p.description IS NULL OR length(p.description) <= 50 THEN 'description' END,
|
||||||
|
CASE WHEN p.operator_id IS NULL THEN 'operator_id' END,
|
||||||
|
CASE WHEN p.banner_image_id IS NULL THEN 'banner_image' END,
|
||||||
|
CASE WHEN p.card_image_id IS NULL THEN 'card_image' END,
|
||||||
|
CASE WHEN p.property_owner_id IS NULL THEN 'property_owner_id' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'valuable', jsonb_build_array(
|
||||||
|
CASE WHEN p.opening_date IS NULL THEN 'opening_date' END,
|
||||||
|
CASE WHEN p.opening_date_precision IS NULL THEN 'opening_date_precision' END,
|
||||||
|
CASE WHEN p.website_url IS NULL THEN 'website_url' END,
|
||||||
|
CASE WHEN p.phone IS NULL THEN 'phone' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'supplementary', jsonb_build_array(
|
||||||
|
CASE WHEN p.email IS NULL THEN 'email' END,
|
||||||
|
CASE WHEN p.closing_date IS NULL AND p.status = 'closed' THEN 'closing_date' END
|
||||||
|
) - 'null'::jsonb
|
||||||
|
) as missing_fields
|
||||||
|
FROM parks p
|
||||||
|
WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||||
|
)
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'slug', slug,
|
||||||
|
'entity_type', entity_type,
|
||||||
|
'updated_at', updated_at,
|
||||||
|
'completeness_score', completeness_score,
|
||||||
|
'missing_fields', missing_fields
|
||||||
|
) ORDER BY completeness_score ASC, name ASC
|
||||||
|
)
|
||||||
|
INTO v_parks
|
||||||
|
FROM park_analysis
|
||||||
|
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||||
|
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||||
|
LIMIT p_limit OFFSET p_offset;
|
||||||
|
|
||||||
|
-- Rides Analysis
|
||||||
|
WITH ride_analysis AS (
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.slug,
|
||||||
|
'ride' as entity_type,
|
||||||
|
r.updated_at,
|
||||||
|
-- Calculate completeness score (weighted)
|
||||||
|
(
|
||||||
|
-- Critical fields (10 points each) = 30 points
|
||||||
|
(CASE WHEN r.park_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.category IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.status IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Important fields (7 points each) = 42 points
|
||||||
|
(CASE WHEN r.description IS NOT NULL AND length(r.description) > 50 THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.manufacturer_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.ride_model_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.designer_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Valuable fields (5 points each) = 15 points
|
||||||
|
(CASE WHEN r.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.ride_sub_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Category-specific technical data (5 points each) = up to 10 points
|
||||||
|
(CASE
|
||||||
|
WHEN r.category = 'Roller Coaster' THEN
|
||||||
|
(CASE WHEN r.coaster_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.max_speed_kmh IS NOT NULL THEN 5 ELSE 0 END)
|
||||||
|
WHEN r.category = 'Water Ride' THEN
|
||||||
|
(CASE WHEN r.flume_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.wetness_level IS NOT NULL THEN 5 ELSE 0 END)
|
||||||
|
WHEN r.category = 'Dark Ride' THEN
|
||||||
|
(CASE WHEN r.theme_name IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.ride_system IS NOT NULL THEN 5 ELSE 0 END)
|
||||||
|
ELSE 0
|
||||||
|
END) +
|
||||||
|
|
||||||
|
-- Supplementary fields (3 points each) = 9 points
|
||||||
|
(CASE WHEN r.max_height_meters IS NOT NULL THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.length_meters IS NOT NULL THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.capacity_per_hour IS NOT NULL THEN 3 ELSE 0 END)
|
||||||
|
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||||
|
|
||||||
|
-- Missing fields tracking
|
||||||
|
jsonb_build_object(
|
||||||
|
'critical', jsonb_build_array(
|
||||||
|
CASE WHEN r.park_id IS NULL THEN 'park_id' END,
|
||||||
|
CASE WHEN r.category IS NULL THEN 'category' END,
|
||||||
|
CASE WHEN r.status IS NULL THEN 'status' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'important', jsonb_build_array(
|
||||||
|
CASE WHEN r.description IS NULL OR length(r.description) <= 50 THEN 'description' END,
|
||||||
|
CASE WHEN r.manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||||
|
CASE WHEN r.banner_image_id IS NULL THEN 'banner_image' END,
|
||||||
|
CASE WHEN r.card_image_id IS NULL THEN 'card_image' END,
|
||||||
|
CASE WHEN r.ride_model_id IS NULL THEN 'ride_model_id' END,
|
||||||
|
CASE WHEN r.designer_id IS NULL THEN 'designer_id' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'valuable', jsonb_build_array(
|
||||||
|
CASE WHEN r.opening_date IS NULL THEN 'opening_date' END,
|
||||||
|
CASE WHEN r.opening_date_precision IS NULL THEN 'opening_date_precision' END,
|
||||||
|
CASE WHEN r.ride_sub_type IS NULL THEN 'ride_sub_type' END
|
||||||
|
) - 'null'::jsonb
|
||||||
|
) as missing_fields
|
||||||
|
FROM rides r
|
||||||
|
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||||
|
)
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'slug', slug,
|
||||||
|
'entity_type', entity_type,
|
||||||
|
'updated_at', updated_at,
|
||||||
|
'completeness_score', completeness_score,
|
||||||
|
'missing_fields', missing_fields
|
||||||
|
) ORDER BY completeness_score ASC, name ASC
|
||||||
|
)
|
||||||
|
INTO v_rides
|
||||||
|
FROM ride_analysis
|
||||||
|
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||||
|
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||||
|
LIMIT p_limit OFFSET p_offset;
|
||||||
|
|
||||||
|
-- Companies Analysis
|
||||||
|
WITH company_analysis AS (
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.slug,
|
||||||
|
'company' as entity_type,
|
||||||
|
c.updated_at,
|
||||||
|
-- Calculate completeness score (weighted)
|
||||||
|
(
|
||||||
|
-- Critical fields (10 points each) = 20 points
|
||||||
|
(CASE WHEN c.company_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.person_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Important fields (7 points each) = 28 points
|
||||||
|
(CASE WHEN c.description IS NOT NULL AND length(c.description) > 50 THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.logo_url IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Valuable fields (5 points each) = 20 points
|
||||||
|
(CASE WHEN c.founded_year IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.founded_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.website_url IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.headquarters_location IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Supplementary fields (3 points each) = 6 points
|
||||||
|
(CASE WHEN c.founded_date_precision IS NOT NULL THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.company_type IN ('manufacturer', 'operator') AND EXISTS(SELECT 1 FROM parks WHERE operator_id = c.id OR property_owner_id = c.id LIMIT 1) THEN 3 ELSE 0 END)
|
||||||
|
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||||
|
|
||||||
|
-- Missing fields tracking
|
||||||
|
jsonb_build_object(
|
||||||
|
'critical', jsonb_build_array(
|
||||||
|
CASE WHEN c.company_type IS NULL THEN 'company_type' END,
|
||||||
|
CASE WHEN c.person_type IS NULL THEN 'person_type' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'important', jsonb_build_array(
|
||||||
|
CASE WHEN c.description IS NULL OR length(c.description) <= 50 THEN 'description' END,
|
||||||
|
CASE WHEN c.logo_url IS NULL THEN 'logo_url' END,
|
||||||
|
CASE WHEN c.banner_image_id IS NULL THEN 'banner_image' END,
|
||||||
|
CASE WHEN c.card_image_id IS NULL THEN 'card_image' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'valuable', jsonb_build_array(
|
||||||
|
CASE WHEN c.founded_year IS NULL THEN 'founded_year' END,
|
||||||
|
CASE WHEN c.founded_date IS NULL THEN 'founded_date' END,
|
||||||
|
CASE WHEN c.website_url IS NULL THEN 'website_url' END,
|
||||||
|
CASE WHEN c.headquarters_location IS NULL THEN 'headquarters_location' END
|
||||||
|
) - 'null'::jsonb
|
||||||
|
) as missing_fields
|
||||||
|
FROM companies c
|
||||||
|
WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||||
|
)
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'slug', slug,
|
||||||
|
'entity_type', entity_type,
|
||||||
|
'updated_at', updated_at,
|
||||||
|
'completeness_score', completeness_score,
|
||||||
|
'missing_fields', missing_fields
|
||||||
|
) ORDER BY completeness_score ASC, name ASC
|
||||||
|
)
|
||||||
|
INTO v_companies
|
||||||
|
FROM company_analysis
|
||||||
|
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||||
|
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||||
|
LIMIT p_limit OFFSET p_offset;
|
||||||
|
|
||||||
|
-- Ride Models Analysis
|
||||||
|
WITH model_analysis AS (
|
||||||
|
SELECT
|
||||||
|
rm.id,
|
||||||
|
rm.name,
|
||||||
|
rm.slug,
|
||||||
|
'ride_model' as entity_type,
|
||||||
|
rm.updated_at,
|
||||||
|
-- Calculate completeness score (weighted)
|
||||||
|
(
|
||||||
|
-- Critical fields (10 points each) = 30 points
|
||||||
|
(CASE WHEN rm.manufacturer_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.category IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.ride_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Important fields (7 points each) = 21 points
|
||||||
|
(CASE WHEN rm.description IS NOT NULL AND length(rm.description) > 50 THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Valuable fields (5 points each) = 10 points
|
||||||
|
(CASE WHEN EXISTS(SELECT 1 FROM rides WHERE ride_model_id = rm.id LIMIT 1) THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.introduction_year IS NOT NULL THEN 5 ELSE 0 END)
|
||||||
|
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||||
|
|
||||||
|
-- Missing fields tracking
|
||||||
|
jsonb_build_object(
|
||||||
|
'critical', jsonb_build_array(
|
||||||
|
CASE WHEN rm.manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||||
|
CASE WHEN rm.category IS NULL THEN 'category' END,
|
||||||
|
CASE WHEN rm.ride_type IS NULL THEN 'ride_type' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'important', jsonb_build_array(
|
||||||
|
CASE WHEN rm.description IS NULL OR length(rm.description) <= 50 THEN 'description' END,
|
||||||
|
CASE WHEN rm.banner_image_id IS NULL THEN 'banner_image' END,
|
||||||
|
CASE WHEN rm.card_image_id IS NULL THEN 'card_image' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'valuable', jsonb_build_array(
|
||||||
|
CASE WHEN rm.introduction_year IS NULL THEN 'introduction_year' END
|
||||||
|
) - 'null'::jsonb
|
||||||
|
) as missing_fields
|
||||||
|
FROM ride_models rm
|
||||||
|
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||||
|
)
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'slug', slug,
|
||||||
|
'entity_type', entity_type,
|
||||||
|
'updated_at', updated_at,
|
||||||
|
'completeness_score', completeness_score,
|
||||||
|
'missing_fields', missing_fields
|
||||||
|
) ORDER BY completeness_score ASC, name ASC
|
||||||
|
)
|
||||||
|
INTO v_ride_models
|
||||||
|
FROM model_analysis
|
||||||
|
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||||
|
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||||
|
LIMIT p_limit OFFSET p_offset;
|
||||||
|
|
||||||
|
-- Generate Summary
|
||||||
|
v_summary := jsonb_build_object(
|
||||||
|
'total_entities', (
|
||||||
|
SELECT COUNT(*)::INTEGER FROM (
|
||||||
|
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||||
|
) all_entities
|
||||||
|
),
|
||||||
|
'avg_completeness_score', (
|
||||||
|
SELECT ROUND(AVG(score)::NUMERIC, 2) FROM (
|
||||||
|
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM parks WHERE park_type IS NOT NULL AND status IS NOT NULL AND location_id IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM rides WHERE park_id IS NOT NULL AND category IS NOT NULL AND status IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ((10 + 10)::NUMERIC / 100.0 * 100) as score FROM companies WHERE company_type IS NOT NULL AND person_type IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM ride_models WHERE manufacturer_id IS NOT NULL AND category IS NOT NULL AND ride_type IS NOT NULL
|
||||||
|
) scores
|
||||||
|
),
|
||||||
|
'entities_below_50', (
|
||||||
|
SELECT COUNT(*)::INTEGER FROM (
|
||||||
|
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||||
|
) all_entities
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM parks WHERE description IS NULL OR manufacturer_id IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT id FROM rides WHERE description IS NULL OR manufacturer_id IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT id FROM companies WHERE description IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT id FROM ride_models WHERE description IS NULL
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'entities_100_complete', 0,
|
||||||
|
'by_entity_type', jsonb_build_object(
|
||||||
|
'parks', (SELECT COUNT(*)::INTEGER FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')),
|
||||||
|
'rides', (SELECT COUNT(*)::INTEGER FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')),
|
||||||
|
'companies', (SELECT COUNT(*)::INTEGER FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')),
|
||||||
|
'ride_models', (SELECT COUNT(*)::INTEGER FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Build final result
|
||||||
|
v_result := jsonb_build_object(
|
||||||
|
'summary', v_summary,
|
||||||
|
'entities', jsonb_build_object(
|
||||||
|
'parks', COALESCE(v_parks, '[]'::jsonb),
|
||||||
|
'rides', COALESCE(v_rides, '[]'::jsonb),
|
||||||
|
'companies', COALESCE(v_companies, '[]'::jsonb),
|
||||||
|
'ride_models', COALESCE(v_ride_models, '[]'::jsonb)
|
||||||
|
),
|
||||||
|
'generated_at', now()
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN v_result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Grant access to authenticated users with moderator role
|
||||||
|
GRANT EXECUTE ON FUNCTION analyze_data_completeness TO authenticated;
|
||||||
|
|
||||||
|
-- Create indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_parks_completeness_fields
|
||||||
|
ON parks(park_type, status, location_id, operator_id, description)
|
||||||
|
WHERE park_type IS NULL OR status IS NULL OR location_id IS NULL OR operator_id IS NULL OR description IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rides_completeness_fields
|
||||||
|
ON rides(park_id, category, status, manufacturer_id, description)
|
||||||
|
WHERE park_id IS NULL OR category IS NULL OR status IS NULL OR manufacturer_id IS NULL OR description IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_companies_completeness_fields
|
||||||
|
ON companies(company_type, person_type, description, website_url)
|
||||||
|
WHERE company_type IS NULL OR person_type IS NULL OR description IS NULL OR website_url IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ride_models_completeness_fields
|
||||||
|
ON ride_models(manufacturer_id, category, ride_type, description)
|
||||||
|
WHERE manufacturer_id IS NULL OR category IS NULL OR ride_type IS NULL OR description IS NULL;
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
-- Fix search_path security issue for analyze_data_completeness function
|
||||||
|
-- This ensures the function only uses public schema and prevents SQL injection
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION analyze_data_completeness(
|
||||||
|
p_entity_type TEXT DEFAULT NULL,
|
||||||
|
p_min_score NUMERIC DEFAULT NULL,
|
||||||
|
p_max_score NUMERIC DEFAULT NULL,
|
||||||
|
p_missing_category TEXT DEFAULT NULL,
|
||||||
|
p_limit INTEGER DEFAULT 100,
|
||||||
|
p_offset INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
RETURNS JSONB
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_result JSONB;
|
||||||
|
v_parks JSONB;
|
||||||
|
v_rides JSONB;
|
||||||
|
v_companies JSONB;
|
||||||
|
v_ride_models JSONB;
|
||||||
|
v_locations JSONB;
|
||||||
|
v_timeline_events JSONB;
|
||||||
|
v_summary JSONB;
|
||||||
|
BEGIN
|
||||||
|
-- Parks Analysis (including historical)
|
||||||
|
WITH park_analysis AS (
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
p.slug,
|
||||||
|
'park' as entity_type,
|
||||||
|
p.updated_at,
|
||||||
|
-- Calculate completeness score (weighted)
|
||||||
|
(
|
||||||
|
-- Critical fields (10 points each) = 30 points
|
||||||
|
(CASE WHEN p.park_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.status IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.location_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Important fields (7 points each) = 35 points
|
||||||
|
(CASE WHEN p.description IS NOT NULL AND length(p.description) > 50 THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.operator_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.property_owner_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Valuable fields (5 points each) = 20 points
|
||||||
|
(CASE WHEN p.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.website_url IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.phone IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Supplementary fields (3 points each) = 9 points
|
||||||
|
(CASE WHEN p.email IS NOT NULL THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.closing_date IS NOT NULL AND p.status = 'closed' THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN EXISTS(SELECT 1 FROM entity_timeline_events WHERE entity_id = p.id AND entity_type = 'park') THEN 3 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Nice-to-have fields (1 point each) = 6 points
|
||||||
|
(CASE WHEN EXISTS(SELECT 1 FROM locations WHERE id = p.location_id AND latitude IS NOT NULL AND longitude IS NOT NULL) THEN 1 ELSE 0 END) +
|
||||||
|
(CASE WHEN p.closing_date_precision IS NOT NULL AND p.status = 'closed' THEN 1 ELSE 0 END)
|
||||||
|
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||||
|
|
||||||
|
-- Missing fields tracking
|
||||||
|
jsonb_build_object(
|
||||||
|
'critical', jsonb_build_array(
|
||||||
|
CASE WHEN p.park_type IS NULL THEN 'park_type' END,
|
||||||
|
CASE WHEN p.status IS NULL THEN 'status' END,
|
||||||
|
CASE WHEN p.location_id IS NULL THEN 'location_id' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'important', jsonb_build_array(
|
||||||
|
CASE WHEN p.description IS NULL OR length(p.description) <= 50 THEN 'description' END,
|
||||||
|
CASE WHEN p.operator_id IS NULL THEN 'operator_id' END,
|
||||||
|
CASE WHEN p.banner_image_id IS NULL THEN 'banner_image' END,
|
||||||
|
CASE WHEN p.card_image_id IS NULL THEN 'card_image' END,
|
||||||
|
CASE WHEN p.property_owner_id IS NULL THEN 'property_owner_id' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'valuable', jsonb_build_array(
|
||||||
|
CASE WHEN p.opening_date IS NULL THEN 'opening_date' END,
|
||||||
|
CASE WHEN p.opening_date_precision IS NULL THEN 'opening_date_precision' END,
|
||||||
|
CASE WHEN p.website_url IS NULL THEN 'website_url' END,
|
||||||
|
CASE WHEN p.phone IS NULL THEN 'phone' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'supplementary', jsonb_build_array(
|
||||||
|
CASE WHEN p.email IS NULL THEN 'email' END,
|
||||||
|
CASE WHEN p.closing_date IS NULL AND p.status = 'closed' THEN 'closing_date' END
|
||||||
|
) - 'null'::jsonb
|
||||||
|
) as missing_fields
|
||||||
|
FROM parks p
|
||||||
|
WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||||
|
)
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'slug', slug,
|
||||||
|
'entity_type', entity_type,
|
||||||
|
'updated_at', updated_at,
|
||||||
|
'completeness_score', completeness_score,
|
||||||
|
'missing_fields', missing_fields
|
||||||
|
) ORDER BY completeness_score ASC, name ASC
|
||||||
|
)
|
||||||
|
INTO v_parks
|
||||||
|
FROM park_analysis
|
||||||
|
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||||
|
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||||
|
LIMIT p_limit OFFSET p_offset;
|
||||||
|
|
||||||
|
-- Rides Analysis
|
||||||
|
WITH ride_analysis AS (
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.slug,
|
||||||
|
'ride' as entity_type,
|
||||||
|
r.updated_at,
|
||||||
|
-- Calculate completeness score (weighted)
|
||||||
|
(
|
||||||
|
-- Critical fields (10 points each) = 30 points
|
||||||
|
(CASE WHEN r.park_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.category IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.status IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Important fields (7 points each) = 42 points
|
||||||
|
(CASE WHEN r.description IS NOT NULL AND length(r.description) > 50 THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.manufacturer_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.ride_model_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.designer_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Valuable fields (5 points each) = 15 points
|
||||||
|
(CASE WHEN r.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.ride_sub_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Category-specific technical data (5 points each) = up to 10 points
|
||||||
|
(CASE
|
||||||
|
WHEN r.category = 'Roller Coaster' THEN
|
||||||
|
(CASE WHEN r.coaster_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.max_speed_kmh IS NOT NULL THEN 5 ELSE 0 END)
|
||||||
|
WHEN r.category = 'Water Ride' THEN
|
||||||
|
(CASE WHEN r.flume_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.wetness_level IS NOT NULL THEN 5 ELSE 0 END)
|
||||||
|
WHEN r.category = 'Dark Ride' THEN
|
||||||
|
(CASE WHEN r.theme_name IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.ride_system IS NOT NULL THEN 5 ELSE 0 END)
|
||||||
|
ELSE 0
|
||||||
|
END) +
|
||||||
|
|
||||||
|
-- Supplementary fields (3 points each) = 9 points
|
||||||
|
(CASE WHEN r.max_height_meters IS NOT NULL THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.length_meters IS NOT NULL THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN r.capacity_per_hour IS NOT NULL THEN 3 ELSE 0 END)
|
||||||
|
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||||
|
|
||||||
|
-- Missing fields tracking
|
||||||
|
jsonb_build_object(
|
||||||
|
'critical', jsonb_build_array(
|
||||||
|
CASE WHEN r.park_id IS NULL THEN 'park_id' END,
|
||||||
|
CASE WHEN r.category IS NULL THEN 'category' END,
|
||||||
|
CASE WHEN r.status IS NULL THEN 'status' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'important', jsonb_build_array(
|
||||||
|
CASE WHEN r.description IS NULL OR length(r.description) <= 50 THEN 'description' END,
|
||||||
|
CASE WHEN r.manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||||
|
CASE WHEN r.banner_image_id IS NULL THEN 'banner_image' END,
|
||||||
|
CASE WHEN r.card_image_id IS NULL THEN 'card_image' END,
|
||||||
|
CASE WHEN r.ride_model_id IS NULL THEN 'ride_model_id' END,
|
||||||
|
CASE WHEN r.designer_id IS NULL THEN 'designer_id' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'valuable', jsonb_build_array(
|
||||||
|
CASE WHEN r.opening_date IS NULL THEN 'opening_date' END,
|
||||||
|
CASE WHEN r.opening_date_precision IS NULL THEN 'opening_date_precision' END,
|
||||||
|
CASE WHEN r.ride_sub_type IS NULL THEN 'ride_sub_type' END
|
||||||
|
) - 'null'::jsonb
|
||||||
|
) as missing_fields
|
||||||
|
FROM rides r
|
||||||
|
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||||
|
)
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'slug', slug,
|
||||||
|
'entity_type', entity_type,
|
||||||
|
'updated_at', updated_at,
|
||||||
|
'completeness_score', completeness_score,
|
||||||
|
'missing_fields', missing_fields
|
||||||
|
) ORDER BY completeness_score ASC, name ASC
|
||||||
|
)
|
||||||
|
INTO v_rides
|
||||||
|
FROM ride_analysis
|
||||||
|
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||||
|
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||||
|
LIMIT p_limit OFFSET p_offset;
|
||||||
|
|
||||||
|
-- Companies Analysis
|
||||||
|
WITH company_analysis AS (
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.slug,
|
||||||
|
'company' as entity_type,
|
||||||
|
c.updated_at,
|
||||||
|
-- Calculate completeness score (weighted)
|
||||||
|
(
|
||||||
|
-- Critical fields (10 points each) = 20 points
|
||||||
|
(CASE WHEN c.company_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.person_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Important fields (7 points each) = 28 points
|
||||||
|
(CASE WHEN c.description IS NOT NULL AND length(c.description) > 50 THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.logo_url IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Valuable fields (5 points each) = 20 points
|
||||||
|
(CASE WHEN c.founded_year IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.founded_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.website_url IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.headquarters_location IS NOT NULL THEN 5 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Supplementary fields (3 points each) = 6 points
|
||||||
|
(CASE WHEN c.founded_date_precision IS NOT NULL THEN 3 ELSE 0 END) +
|
||||||
|
(CASE WHEN c.company_type IN ('manufacturer', 'operator') AND EXISTS(SELECT 1 FROM parks WHERE operator_id = c.id OR property_owner_id = c.id LIMIT 1) THEN 3 ELSE 0 END)
|
||||||
|
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||||
|
|
||||||
|
-- Missing fields tracking
|
||||||
|
jsonb_build_object(
|
||||||
|
'critical', jsonb_build_array(
|
||||||
|
CASE WHEN c.company_type IS NULL THEN 'company_type' END,
|
||||||
|
CASE WHEN c.person_type IS NULL THEN 'person_type' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'important', jsonb_build_array(
|
||||||
|
CASE WHEN c.description IS NULL OR length(c.description) <= 50 THEN 'description' END,
|
||||||
|
CASE WHEN c.logo_url IS NULL THEN 'logo_url' END,
|
||||||
|
CASE WHEN c.banner_image_id IS NULL THEN 'banner_image' END,
|
||||||
|
CASE WHEN c.card_image_id IS NULL THEN 'card_image' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'valuable', jsonb_build_array(
|
||||||
|
CASE WHEN c.founded_year IS NULL THEN 'founded_year' END,
|
||||||
|
CASE WHEN c.founded_date IS NULL THEN 'founded_date' END,
|
||||||
|
CASE WHEN c.website_url IS NULL THEN 'website_url' END,
|
||||||
|
CASE WHEN c.headquarters_location IS NULL THEN 'headquarters_location' END
|
||||||
|
) - 'null'::jsonb
|
||||||
|
) as missing_fields
|
||||||
|
FROM companies c
|
||||||
|
WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||||
|
)
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'slug', slug,
|
||||||
|
'entity_type', entity_type,
|
||||||
|
'updated_at', updated_at,
|
||||||
|
'completeness_score', completeness_score,
|
||||||
|
'missing_fields', missing_fields
|
||||||
|
) ORDER BY completeness_score ASC, name ASC
|
||||||
|
)
|
||||||
|
INTO v_companies
|
||||||
|
FROM company_analysis
|
||||||
|
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||||
|
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||||
|
LIMIT p_limit OFFSET p_offset;
|
||||||
|
|
||||||
|
-- Ride Models Analysis
|
||||||
|
WITH model_analysis AS (
|
||||||
|
SELECT
|
||||||
|
rm.id,
|
||||||
|
rm.name,
|
||||||
|
rm.slug,
|
||||||
|
'ride_model' as entity_type,
|
||||||
|
rm.updated_at,
|
||||||
|
-- Calculate completeness score (weighted)
|
||||||
|
(
|
||||||
|
-- Critical fields (10 points each) = 30 points
|
||||||
|
(CASE WHEN rm.manufacturer_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.category IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.ride_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Important fields (7 points each) = 21 points
|
||||||
|
(CASE WHEN rm.description IS NOT NULL AND length(rm.description) > 50 THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||||
|
|
||||||
|
-- Valuable fields (5 points each) = 10 points
|
||||||
|
(CASE WHEN EXISTS(SELECT 1 FROM rides WHERE ride_model_id = rm.id LIMIT 1) THEN 5 ELSE 0 END) +
|
||||||
|
(CASE WHEN rm.introduction_year IS NOT NULL THEN 5 ELSE 0 END)
|
||||||
|
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||||
|
|
||||||
|
-- Missing fields tracking
|
||||||
|
jsonb_build_object(
|
||||||
|
'critical', jsonb_build_array(
|
||||||
|
CASE WHEN rm.manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||||
|
CASE WHEN rm.category IS NULL THEN 'category' END,
|
||||||
|
CASE WHEN rm.ride_type IS NULL THEN 'ride_type' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'important', jsonb_build_array(
|
||||||
|
CASE WHEN rm.description IS NULL OR length(rm.description) <= 50 THEN 'description' END,
|
||||||
|
CASE WHEN rm.banner_image_id IS NULL THEN 'banner_image' END,
|
||||||
|
CASE WHEN rm.card_image_id IS NULL THEN 'card_image' END
|
||||||
|
) - 'null'::jsonb,
|
||||||
|
'valuable', jsonb_build_array(
|
||||||
|
CASE WHEN rm.introduction_year IS NULL THEN 'introduction_year' END
|
||||||
|
) - 'null'::jsonb
|
||||||
|
) as missing_fields
|
||||||
|
FROM ride_models rm
|
||||||
|
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||||
|
)
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'slug', slug,
|
||||||
|
'entity_type', entity_type,
|
||||||
|
'updated_at', updated_at,
|
||||||
|
'completeness_score', completeness_score,
|
||||||
|
'missing_fields', missing_fields
|
||||||
|
) ORDER BY completeness_score ASC, name ASC
|
||||||
|
)
|
||||||
|
INTO v_ride_models
|
||||||
|
FROM model_analysis
|
||||||
|
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||||
|
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||||
|
LIMIT p_limit OFFSET p_offset;
|
||||||
|
|
||||||
|
-- Generate Summary
|
||||||
|
v_summary := jsonb_build_object(
|
||||||
|
'total_entities', (
|
||||||
|
SELECT COUNT(*)::INTEGER FROM (
|
||||||
|
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||||
|
) all_entities
|
||||||
|
),
|
||||||
|
'avg_completeness_score', (
|
||||||
|
SELECT ROUND(AVG(score)::NUMERIC, 2) FROM (
|
||||||
|
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM parks WHERE park_type IS NOT NULL AND status IS NOT NULL AND location_id IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM rides WHERE park_id IS NOT NULL AND category IS NOT NULL AND status IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ((10 + 10)::NUMERIC / 100.0 * 100) as score FROM companies WHERE company_type IS NOT NULL AND person_type IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM ride_models WHERE manufacturer_id IS NOT NULL AND category IS NOT NULL AND ride_type IS NOT NULL
|
||||||
|
) scores
|
||||||
|
),
|
||||||
|
'entities_below_50', (
|
||||||
|
SELECT COUNT(*)::INTEGER FROM (
|
||||||
|
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||||
|
) all_entities
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM parks WHERE description IS NULL OR manufacturer_id IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT id FROM rides WHERE description IS NULL OR manufacturer_id IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT id FROM companies WHERE description IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT id FROM ride_models WHERE description IS NULL
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'entities_100_complete', 0,
|
||||||
|
'by_entity_type', jsonb_build_object(
|
||||||
|
'parks', (SELECT COUNT(*)::INTEGER FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')),
|
||||||
|
'rides', (SELECT COUNT(*)::INTEGER FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')),
|
||||||
|
'companies', (SELECT COUNT(*)::INTEGER FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')),
|
||||||
|
'ride_models', (SELECT COUNT(*)::INTEGER FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Build final result
|
||||||
|
v_result := jsonb_build_object(
|
||||||
|
'summary', v_summary,
|
||||||
|
'entities', jsonb_build_object(
|
||||||
|
'parks', COALESCE(v_parks, '[]'::jsonb),
|
||||||
|
'rides', COALESCE(v_rides, '[]'::jsonb),
|
||||||
|
'companies', COALESCE(v_companies, '[]'::jsonb),
|
||||||
|
'ride_models', COALESCE(v_ride_models, '[]'::jsonb)
|
||||||
|
),
|
||||||
|
'generated_at', now()
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN v_result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user