diff --git a/src/components/rides/CoasterStatistics.tsx b/src/components/rides/CoasterStatistics.tsx new file mode 100644 index 00000000..db0a2073 --- /dev/null +++ b/src/components/rides/CoasterStatistics.tsx @@ -0,0 +1,68 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { RideCoasterStat } from "@/types/database"; +import { TrendingUp } from "lucide-react"; + +interface CoasterStatisticsProps { + statistics?: RideCoasterStat[]; +} + +export const CoasterStatistics = ({ statistics }: CoasterStatisticsProps) => { + if (!statistics || statistics.length === 0) { + return null; + } + + // Group stats by category + const groupedStats = statistics.reduce((acc, stat) => { + const category = stat.category || 'General'; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(stat); + return acc; + }, {} as Record); + + return ( + + + + + Coaster Statistics + + + +
+ {Object.entries(groupedStats).map(([category, stats]) => ( +
+

+ {category} +

+
+ {stats + .sort((a, b) => a.display_order - b.display_order) + .map((stat) => ( +
+
+ {stat.stat_name} + {stat.description && ( + + {stat.description} + + )} +
+ + {stat.stat_value.toLocaleString()} + {stat.unit && ` ${stat.unit}`} + +
+ ))} +
+
+ ))} +
+
+
+ ); +}; diff --git a/src/components/rides/FormerNames.tsx b/src/components/rides/FormerNames.tsx index 062bc1e5..60f6a1bf 100644 --- a/src/components/rides/FormerNames.tsx +++ b/src/components/rides/FormerNames.tsx @@ -1,6 +1,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { History } from 'lucide-react'; +import { RideNameHistory } from '@/types/database'; +import { format } from 'date-fns'; interface FormerName { name: string; @@ -9,14 +11,41 @@ interface FormerName { } interface FormerNamesProps { - formerNames: FormerName[]; + formerNames?: FormerName[]; + nameHistory?: RideNameHistory[]; // New relational data currentName: string; } -export function FormerNames({ formerNames, currentName }: FormerNamesProps) { - if (!formerNames || formerNames.length === 0) { +export function FormerNames({ formerNames, nameHistory, currentName }: FormerNamesProps) { + // Use new relational data if available, fallback to legacy JSON + const names = nameHistory || formerNames; + + if (!names || names.length === 0) { return null; } + + // Normalize data structure + const normalizedNames = names.map((item) => { + if ('former_name' in item) { + // New relational format (RideNameHistory) + return { + name: item.former_name, + date_changed: item.date_changed, + reason: item.reason, + from_year: item.from_year, + to_year: item.to_year, + order: item.order_index, + }; + } else { + // Legacy JSON format (FormerName) + return { + name: item.name, + from_year: item.from_year, + to_year: item.to_year, + order: 0, + }; + } + }).sort((a, b) => a.order - b.order); return ( @@ -37,21 +66,29 @@ export function FormerNames({ formerNames, currentName }: FormerNamesProps) { Current - {formerNames.map((former, index) => ( + {normalizedNames.map((former, index) => (
{former.name}
- {(former.from_year || former.to_year) && ( -
- {former.from_year && former.to_year - ? `${former.from_year} - ${former.to_year}` - : former.from_year - ? `Since ${former.from_year}` - : `Until ${former.to_year}` - } -
- )} +
+ {(former.from_year || former.to_year) && ( +
+ {former.from_year && former.to_year + ? `${former.from_year} - ${former.to_year}` + : former.from_year + ? `Since ${former.from_year}` + : `Until ${former.to_year}` + } +
+ )} + {former.date_changed && ( +
Changed: {format(new Date(former.date_changed), 'MMM d, yyyy')}
+ )} + {former.reason && ( +
{former.reason}
+ )} +
Former
diff --git a/src/components/rides/TechnicalSpecifications.tsx b/src/components/rides/TechnicalSpecifications.tsx new file mode 100644 index 00000000..5322064a --- /dev/null +++ b/src/components/rides/TechnicalSpecifications.tsx @@ -0,0 +1,61 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { RideTechnicalSpec } from "@/types/database"; +import { Wrench } from "lucide-react"; + +interface TechnicalSpecificationsProps { + specifications?: RideTechnicalSpec[]; +} + +export const TechnicalSpecifications = ({ specifications }: TechnicalSpecificationsProps) => { + if (!specifications || specifications.length === 0) { + return null; + } + + // Group specs by category + const groupedSpecs = specifications.reduce((acc, spec) => { + const category = spec.category || 'General'; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(spec); + return acc; + }, {} as Record); + + return ( + + + + + Technical Specifications + + + +
+ {Object.entries(groupedSpecs).map(([category, specs]) => ( +
+

+ {category} +

+
+ {specs + .sort((a, b) => a.display_order - b.display_order) + .map((spec) => ( +
+ {spec.spec_name} + + {spec.spec_value} + {spec.unit && ` ${spec.unit}`} + +
+ ))} +
+
+ ))} +
+
+
+ ); +}; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index d772c47d..066eef57 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -1103,6 +1103,50 @@ export type Database = { }, ] } + ride_coaster_statistics: { + Row: { + category: string | null + created_at: string + description: string | null + display_order: number | null + id: string + ride_id: string + stat_name: string + stat_value: number + unit: string | null + } + Insert: { + category?: string | null + created_at?: string + description?: string | null + display_order?: number | null + id?: string + ride_id: string + stat_name: string + stat_value: number + unit?: string | null + } + Update: { + category?: string | null + created_at?: string + description?: string | null + display_order?: number | null + id?: string + ride_id?: string + stat_name?: string + stat_value?: number + unit?: string | null + } + Relationships: [ + { + foreignKeyName: "ride_coaster_statistics_ride_id_fkey" + columns: ["ride_id"] + isOneToOne: false + referencedRelation: "rides" + referencedColumns: ["id"] + }, + ] + } ride_coaster_stats: { Row: { category: string | null @@ -1238,6 +1282,50 @@ export type Database = { }, ] } + ride_model_technical_specifications: { + Row: { + category: string | null + created_at: string + display_order: number | null + id: string + ride_model_id: string + spec_name: string + spec_type: string + spec_value: string + unit: string | null + } + Insert: { + category?: string | null + created_at?: string + display_order?: number | null + id?: string + ride_model_id: string + spec_name: string + spec_type: string + spec_value: string + unit?: string | null + } + Update: { + category?: string | null + created_at?: string + display_order?: number | null + id?: string + ride_model_id?: string + spec_name?: string + spec_type?: string + spec_value?: string + unit?: string | null + } + Relationships: [ + { + foreignKeyName: "ride_model_technical_specifications_ride_model_id_fkey" + columns: ["ride_model_id"] + isOneToOne: false + referencedRelation: "ride_models" + referencedColumns: ["id"] + }, + ] + } ride_models: { Row: { banner_image_id: string | null @@ -1297,6 +1385,50 @@ export type Database = { }, ] } + ride_name_history: { + Row: { + created_at: string + date_changed: string | null + former_name: string + from_year: number | null + id: string + order_index: number | null + reason: string | null + ride_id: string + to_year: number | null + } + Insert: { + created_at?: string + date_changed?: string | null + former_name: string + from_year?: number | null + id?: string + order_index?: number | null + reason?: string | null + ride_id: string + to_year?: number | null + } + Update: { + created_at?: string + date_changed?: string | null + former_name?: string + from_year?: number | null + id?: string + order_index?: number | null + reason?: string | null + ride_id?: string + to_year?: number | null + } + Relationships: [ + { + foreignKeyName: "ride_name_history_ride_id_fkey" + columns: ["ride_id"] + isOneToOne: false + referencedRelation: "rides" + referencedColumns: ["id"] + }, + ] + } ride_submissions: { Row: { age_requirement: number | null @@ -1416,6 +1548,50 @@ export type Database = { }, ] } + ride_technical_specifications: { + Row: { + category: string | null + created_at: string + display_order: number | null + id: string + ride_id: string + spec_name: string + spec_type: string + spec_value: string + unit: string | null + } + Insert: { + category?: string | null + created_at?: string + display_order?: number | null + id?: string + ride_id: string + spec_name: string + spec_type: string + spec_value: string + unit?: string | null + } + Update: { + category?: string | null + created_at?: string + display_order?: number | null + id?: string + ride_id?: string + spec_name?: string + spec_type?: string + spec_value?: string + unit?: string | null + } + Relationships: [ + { + foreignKeyName: "ride_technical_specifications_ride_id_fkey" + columns: ["ride_id"] + isOneToOne: false + referencedRelation: "rides" + referencedColumns: ["id"] + }, + ] + } ride_technical_specs: { Row: { category: string | null @@ -2006,6 +2182,10 @@ export type Database = { } Returns: undefined } + migrate_ride_technical_data: { + Args: Record + Returns: undefined + } update_company_ratings: { Args: { target_company_id: string } Returns: undefined diff --git a/src/types/database.ts b/src/types/database.ts index bff5819a..3094ba70 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -52,6 +52,54 @@ export interface Park { updated_at: string; } +export interface RideTechnicalSpec { + id: string; + ride_id: string; + spec_name: string; + spec_value: string; + spec_type: 'string' | 'number' | 'boolean' | 'date'; + category?: string; + unit?: string; + display_order: number; + created_at: string; +} + +export interface RideCoasterStat { + id: string; + ride_id: string; + stat_name: string; + stat_value: number; + unit?: string; + category?: string; + description?: string; + display_order: number; + created_at: string; +} + +export interface RideNameHistory { + id: string; + ride_id: string; + former_name: string; + date_changed?: string; + reason?: string; + from_year?: number; + to_year?: number; + order_index: number; + created_at: string; +} + +export interface RideModelTechnicalSpec { + id: string; + ride_model_id: string; + spec_name: string; + spec_value: string; + spec_type: 'string' | 'number' | 'boolean' | 'date'; + category?: string; + unit?: string; + display_order: number; + created_at: string; +} + export interface RideModel { id: string; name: string; @@ -60,7 +108,8 @@ export interface RideModel { category: 'roller_coaster' | 'flat_ride' | 'water_ride' | 'dark_ride' | 'kiddie_ride' | 'transportation'; ride_type: string; description?: string; - technical_specs?: any; + technical_specs?: any; // Legacy JSON field + technical_specifications?: RideModelTechnicalSpec[]; // New relational data } export interface Ride { @@ -85,8 +134,13 @@ export interface Ride { max_height_meters?: number; length_meters?: number; inversions?: number; - coaster_stats?: any; - technical_specs?: any; + coaster_stats?: any; // Legacy JSON field + technical_specs?: any; // Legacy JSON field + former_names?: any; // Legacy JSON field + // New relational data + technical_specifications?: RideTechnicalSpec[]; + coaster_statistics?: RideCoasterStat[]; + name_history?: RideNameHistory[]; average_rating: number; review_count: number; image_url?: string; @@ -100,7 +154,6 @@ export interface Ride { intensity_level?: string; drop_height_meters?: number; max_g_force?: number; - former_names?: any; } export interface Profile { diff --git a/supabase/migrations/20251002011323_5fc31acd-a77e-4f08-ae28-19b51db147cf.sql b/supabase/migrations/20251002011323_5fc31acd-a77e-4f08-ae28-19b51db147cf.sql new file mode 100644 index 00000000..07d73b4a --- /dev/null +++ b/supabase/migrations/20251002011323_5fc31acd-a77e-4f08-ae28-19b51db147cf.sql @@ -0,0 +1,265 @@ +-- Phase 3A: Create Production Relational Tables and Data Migration + +-- 1. Ride Technical Specifications +CREATE TABLE ride_technical_specifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ride_id UUID NOT NULL REFERENCES rides(id) ON DELETE CASCADE, + spec_name TEXT NOT NULL, + spec_value TEXT NOT NULL, + spec_type TEXT NOT NULL CHECK (spec_type IN ('string', 'number', 'boolean', 'date')), + category TEXT, + unit TEXT, + display_order INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT ride_technical_specifications_unique_spec UNIQUE(ride_id, spec_name) +); + +CREATE INDEX idx_ride_technical_specifications_ride_id ON ride_technical_specifications(ride_id); +CREATE INDEX idx_ride_technical_specifications_category ON ride_technical_specifications(category); + +-- 2. Ride Coaster Statistics +CREATE TABLE ride_coaster_statistics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ride_id UUID NOT NULL REFERENCES rides(id) ON DELETE CASCADE, + stat_name TEXT NOT NULL, + stat_value NUMERIC NOT NULL, + unit TEXT, + category TEXT, + description TEXT, + display_order INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT ride_coaster_statistics_unique_stat UNIQUE(ride_id, stat_name) +); + +CREATE INDEX idx_ride_coaster_statistics_ride_id ON ride_coaster_statistics(ride_id); +CREATE INDEX idx_ride_coaster_statistics_category ON ride_coaster_statistics(category); + +-- 3. Ride Name History +CREATE TABLE ride_name_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ride_id UUID NOT NULL REFERENCES rides(id) ON DELETE CASCADE, + former_name TEXT NOT NULL, + date_changed DATE, + reason TEXT, + from_year INTEGER, + to_year INTEGER, + order_index INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_ride_name_history_ride_id ON ride_name_history(ride_id); +CREATE INDEX idx_ride_name_history_date_changed ON ride_name_history(date_changed); + +-- 4. Ride Model Technical Specifications +CREATE TABLE ride_model_technical_specifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ride_model_id UUID NOT NULL REFERENCES ride_models(id) ON DELETE CASCADE, + spec_name TEXT NOT NULL, + spec_value TEXT NOT NULL, + spec_type TEXT NOT NULL CHECK (spec_type IN ('string', 'number', 'boolean', 'date')), + category TEXT, + unit TEXT, + display_order INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT ride_model_technical_specifications_unique_spec UNIQUE(ride_model_id, spec_name) +); + +CREATE INDEX idx_ride_model_technical_specifications_ride_model_id ON ride_model_technical_specifications(ride_model_id); +CREATE INDEX idx_ride_model_technical_specifications_category ON ride_model_technical_specifications(category); + +-- Enable RLS on all tables +ALTER TABLE ride_technical_specifications ENABLE ROW LEVEL SECURITY; +ALTER TABLE ride_coaster_statistics ENABLE ROW LEVEL SECURITY; +ALTER TABLE ride_name_history ENABLE ROW LEVEL SECURITY; +ALTER TABLE ride_model_technical_specifications ENABLE ROW LEVEL SECURITY; + +-- RLS Policies for ride_technical_specifications +CREATE POLICY "Public read access to ride technical specifications" + ON ride_technical_specifications FOR SELECT + USING (true); + +CREATE POLICY "Moderators can manage ride technical specifications" + ON ride_technical_specifications FOR ALL + USING (is_moderator(auth.uid())); + +-- RLS Policies for ride_coaster_statistics +CREATE POLICY "Public read access to ride coaster statistics" + ON ride_coaster_statistics FOR SELECT + USING (true); + +CREATE POLICY "Moderators can manage ride coaster statistics" + ON ride_coaster_statistics FOR ALL + USING (is_moderator(auth.uid())); + +-- RLS Policies for ride_name_history +CREATE POLICY "Public read access to ride name history" + ON ride_name_history FOR SELECT + USING (true); + +CREATE POLICY "Moderators can manage ride name history" + ON ride_name_history FOR ALL + USING (is_moderator(auth.uid())); + +-- RLS Policies for ride_model_technical_specifications +CREATE POLICY "Public read access to ride model technical specifications" + ON ride_model_technical_specifications FOR SELECT + USING (true); + +CREATE POLICY "Moderators can manage ride model technical specifications" + ON ride_model_technical_specifications FOR ALL + USING (is_moderator(auth.uid())); + +-- Data Migration Function: Migrate existing JSON data to relational tables +CREATE OR REPLACE FUNCTION migrate_ride_technical_data() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + ride_record RECORD; + spec_key TEXT; + spec_value TEXT; + stat_key TEXT; + stat_value NUMERIC; + former_name_item JSONB; + spec_order INTEGER; + stat_order INTEGER; + name_order INTEGER; +BEGIN + -- Migrate technical_specs from rides + FOR ride_record IN + SELECT id, technical_specs + FROM rides + WHERE technical_specs IS NOT NULL AND technical_specs != 'null'::jsonb + LOOP + spec_order := 0; + FOR spec_key, spec_value IN + SELECT key, value::text + FROM jsonb_each_text(ride_record.technical_specs) + LOOP + INSERT INTO ride_technical_specifications ( + ride_id, + spec_name, + spec_value, + spec_type, + display_order + ) + VALUES ( + ride_record.id, + spec_key, + spec_value, + CASE + WHEN spec_value ~ '^[0-9]+\.?[0-9]*$' THEN 'number' + WHEN spec_value IN ('true', 'false') THEN 'boolean' + ELSE 'string' + END, + spec_order + ) + ON CONFLICT (ride_id, spec_name) DO NOTHING; + + spec_order := spec_order + 1; + END LOOP; + END LOOP; + + -- Migrate coaster_stats from rides + FOR ride_record IN + SELECT id, coaster_stats + FROM rides + WHERE coaster_stats IS NOT NULL AND coaster_stats != 'null'::jsonb + LOOP + stat_order := 0; + FOR stat_key, stat_value IN + SELECT key, (value::text)::numeric + FROM jsonb_each(ride_record.coaster_stats) + WHERE value::text ~ '^[0-9]+\.?[0-9]*$' + LOOP + INSERT INTO ride_coaster_statistics ( + ride_id, + stat_name, + stat_value, + display_order + ) + VALUES ( + ride_record.id, + stat_key, + stat_value, + stat_order + ) + ON CONFLICT (ride_id, stat_name) DO NOTHING; + + stat_order := stat_order + 1; + END LOOP; + END LOOP; + + -- Migrate former_names from rides + FOR ride_record IN + SELECT id, former_names + FROM rides + WHERE former_names IS NOT NULL AND former_names != 'null'::jsonb AND jsonb_array_length(former_names) > 0 + LOOP + name_order := 0; + FOR former_name_item IN + SELECT value + FROM jsonb_array_elements(ride_record.former_names) + LOOP + INSERT INTO ride_name_history ( + ride_id, + former_name, + date_changed, + reason, + order_index + ) + VALUES ( + ride_record.id, + former_name_item->>'name', + (former_name_item->>'date')::date, + former_name_item->>'reason', + name_order + ); + + name_order := name_order + 1; + END LOOP; + END LOOP; + + -- Migrate technical_specs from ride_models + FOR ride_record IN + SELECT id, technical_specs + FROM ride_models + WHERE technical_specs IS NOT NULL AND technical_specs != 'null'::jsonb + LOOP + spec_order := 0; + FOR spec_key, spec_value IN + SELECT key, value::text + FROM jsonb_each_text(ride_record.technical_specs) + LOOP + INSERT INTO ride_model_technical_specifications ( + ride_model_id, + spec_name, + spec_value, + spec_type, + display_order + ) + VALUES ( + ride_record.id, + spec_key, + spec_value, + CASE + WHEN spec_value ~ '^[0-9]+\.?[0-9]*$' THEN 'number' + WHEN spec_value IN ('true', 'false') THEN 'boolean' + ELSE 'string' + END, + spec_order + ) + ON CONFLICT (ride_model_id, spec_name) DO NOTHING; + + spec_order := spec_order + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Data migration completed successfully'; +END; +$$; + +-- Execute the migration +SELECT migrate_ride_technical_data(); \ No newline at end of file