mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 08:51:16 -05:00
feat: Implement Phase 4 cleanup and polish
This commit is contained in:
158
src/hooks/profile/useProfileActivity.ts
Normal file
158
src/hooks/profile/useProfileActivity.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Profile Activity Hook
|
||||
*
|
||||
* Fetches user activity feed with privacy checks and batch optimization.
|
||||
* Eliminates N+1 queries for photo submissions.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useProfileActivity(
|
||||
userId: string | undefined,
|
||||
isOwnProfile: boolean,
|
||||
isModerator: boolean
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.profile.activity(userId || '', isOwnProfile, isModerator),
|
||||
queryFn: async () => {
|
||||
if (!userId) return [];
|
||||
|
||||
// Check privacy settings first
|
||||
const { data: preferences } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('privacy_settings')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
const privacySettings = preferences?.privacy_settings as { activity_visibility?: string } | null;
|
||||
const activityVisibility = privacySettings?.activity_visibility || 'public';
|
||||
|
||||
if (activityVisibility !== 'public' && !isOwnProfile && !isModerator) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch all activity types in parallel
|
||||
const [reviews, credits, submissions, rankings] = await Promise.all([
|
||||
// Reviews query
|
||||
supabase.from('reviews')
|
||||
.select('id, rating, title, created_at, moderation_status, park_id, ride_id, parks(name, slug), rides(name, slug, parks(name, slug))')
|
||||
.eq('user_id', userId)
|
||||
.eq('moderation_status', isOwnProfile || isModerator ? undefined : 'approved')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
.then(res => res.data || []),
|
||||
|
||||
// Credits query
|
||||
supabase.from('user_ride_credits')
|
||||
.select('id, ride_count, first_ride_date, created_at, rides(name, slug, parks(name, slug))')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
.then(res => res.data || []),
|
||||
|
||||
// Submissions query
|
||||
supabase.from('content_submissions')
|
||||
.select('id, submission_type, content, status, created_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', isOwnProfile || isModerator ? undefined : 'approved')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
.then(res => res.data || []),
|
||||
|
||||
// Rankings query
|
||||
supabase.from('user_top_lists')
|
||||
.select('id, title, description, list_type, created_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_public', isOwnProfile ? undefined : true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
.then(res => res.data || [])
|
||||
]);
|
||||
|
||||
// Enrich photo submissions in batch
|
||||
const photoSubmissions = submissions.filter(s => s.submission_type === 'photo');
|
||||
const photoSubmissionIds = photoSubmissions.map(s => s.id);
|
||||
|
||||
if (photoSubmissionIds.length > 0) {
|
||||
// Batch fetch photo submission data
|
||||
const { data: photoSubs } = await supabase
|
||||
.from('photo_submissions')
|
||||
.select('id, submission_id, entity_type, entity_id')
|
||||
.in('submission_id', photoSubmissionIds);
|
||||
|
||||
if (photoSubs) {
|
||||
// Batch fetch photo items
|
||||
const photoSubIds = photoSubs.map(ps => ps.id);
|
||||
const { data: photoItems } = await supabase
|
||||
.from('photo_submission_items')
|
||||
.select('photo_submission_id, cloudflare_image_url')
|
||||
.in('photo_submission_id', photoSubIds)
|
||||
.order('order_index', { ascending: true });
|
||||
|
||||
// Group entity IDs by type for batch fetching
|
||||
const parkIds = photoSubs.filter(ps => ps.entity_type === 'park').map(ps => ps.entity_id);
|
||||
const rideIds = photoSubs.filter(ps => ps.entity_type === 'ride').map(ps => ps.entity_id);
|
||||
|
||||
// Batch fetch entities
|
||||
const [parks, rides] = await Promise.all([
|
||||
parkIds.length ? supabase.from('parks').select('id, name, slug').in('id', parkIds).then(r => r.data || []) : [],
|
||||
rideIds.length ? supabase.from('rides').select('id, name, slug, parks!inner(name, slug)').in('id', rideIds).then(r => r.data || []) : []
|
||||
]);
|
||||
|
||||
// Create lookup maps
|
||||
const photoSubMap = new Map(photoSubs.map(ps => [ps.submission_id, ps]));
|
||||
const photoItemsMap = new Map<string, any[]>();
|
||||
photoItems?.forEach(item => {
|
||||
if (!photoItemsMap.has(item.photo_submission_id)) {
|
||||
photoItemsMap.set(item.photo_submission_id, []);
|
||||
}
|
||||
photoItemsMap.get(item.photo_submission_id)!.push(item);
|
||||
});
|
||||
const entityMap = new Map<string, any>([
|
||||
...parks.map((p: any) => [p.id, p] as [string, any]),
|
||||
...rides.map((r: any) => [r.id, r] as [string, any])
|
||||
]);
|
||||
|
||||
// Enrich submissions
|
||||
photoSubmissions.forEach((sub: any) => {
|
||||
const photoSub = photoSubMap.get(sub.id);
|
||||
if (photoSub) {
|
||||
const items = photoItemsMap.get(photoSub.id) || [];
|
||||
sub.photo_count = items.length;
|
||||
sub.photo_preview = items[0]?.cloudflare_image_url;
|
||||
sub.entity_type = photoSub.entity_type;
|
||||
sub.entity_id = photoSub.entity_id;
|
||||
|
||||
const entity = entityMap.get(photoSub.entity_id);
|
||||
if (entity) {
|
||||
sub.content = {
|
||||
...(typeof sub.content === 'object' ? sub.content : {}),
|
||||
entity_name: entity.name,
|
||||
entity_slug: entity.slug,
|
||||
...(entity.parks && { park_name: entity.parks.name, park_slug: entity.parks.slug })
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Combine and sort
|
||||
const combined = [
|
||||
...reviews.map(r => ({ ...r, type: 'review' as const })),
|
||||
...credits.map(c => ({ ...c, type: 'credit' as const })),
|
||||
...submissions.map(s => ({ ...s, type: 'submission' as const })),
|
||||
...rankings.map(r => ({ ...r, type: 'ranking' as const }))
|
||||
].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 15);
|
||||
|
||||
return combined;
|
||||
},
|
||||
enabled: !!userId,
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes - activity updates frequently
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
37
src/hooks/profile/useProfileStats.ts
Normal file
37
src/hooks/profile/useProfileStats.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Profile Stats Hook
|
||||
*
|
||||
* Fetches calculated user statistics (rides, coasters, parks).
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useProfileStats(userId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.profile.stats(userId || ''),
|
||||
queryFn: async () => {
|
||||
if (!userId) return { rideCount: 0, coasterCount: 0, parkCount: 0 };
|
||||
|
||||
const { data: ridesData } = await supabase
|
||||
.from('user_ride_credits')
|
||||
.select('ride_count, rides!inner(category, park_id)')
|
||||
.eq('user_id', userId);
|
||||
|
||||
const totalRides = ridesData?.reduce((sum, credit) => sum + (credit.ride_count || 0), 0) || 0;
|
||||
const coasterRides = ridesData?.filter(credit => credit.rides?.category === 'roller_coaster') || [];
|
||||
const uniqueCoasters = new Set(coasterRides.map(credit => credit.rides));
|
||||
const coasterCount = uniqueCoasters.size;
|
||||
const parkRides = ridesData?.map(credit => credit.rides?.park_id).filter(Boolean) || [];
|
||||
const uniqueParks = new Set(parkRides);
|
||||
const parkCount = uniqueParks.size;
|
||||
|
||||
return { rideCount: totalRides, coasterCount, parkCount };
|
||||
},
|
||||
enabled: !!userId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 15 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user