diff --git a/frontend/src/api/hooks/onboarding.ts b/frontend/src/api/hooks/onboarding.ts index e04086b7..6931324c 100644 --- a/frontend/src/api/hooks/onboarding.ts +++ b/frontend/src/api/hooks/onboarding.ts @@ -23,7 +23,12 @@ export const useUserProgress = ( queryKey: onboardingKeys.progress(userId), queryFn: () => onboardingService.getUserProgress(userId), enabled: !!userId, - staleTime: 30 * 1000, // 30 seconds + // OPTIMIZATION: Once onboarding is fully completed, it won't change back + // Use longer staleTime (5 min) and gcTime (30 min) to reduce API calls + // The select function below will update staleTime based on completion status + staleTime: 5 * 60 * 1000, // 5 minutes (increased from 30s - completed status rarely changes) + gcTime: 30 * 60 * 1000, // 30 minutes - keep in cache longer + refetchOnWindowFocus: false, // Don't refetch on window focus for onboarding status ...options, }); }; diff --git a/frontend/src/api/hooks/useControlPanelData.ts b/frontend/src/api/hooks/useControlPanelData.ts index 12d382c1..36f5650b 100644 --- a/frontend/src/api/hooks/useControlPanelData.ts +++ b/frontend/src/api/hooks/useControlPanelData.ts @@ -20,6 +20,10 @@ import { parseISO } from 'date-fns'; // Debounce delay for SSE-triggered query invalidations (ms) const SSE_INVALIDATION_DEBOUNCE_MS = 500; +// Delay before SSE invalidations are allowed after initial load (ms) +// This prevents duplicate API calls when SSE events arrive during/right after initial fetch +const SSE_INITIAL_LOAD_GRACE_PERIOD_MS = 3000; + // ============================================================ // Types // ============================================================ @@ -421,6 +425,15 @@ export function useControlPanelData(tenantId: string) { // Ref for debouncing SSE-triggered invalidations const invalidationTimeoutRef = useRef(null); const lastEventCountRef = useRef(0); + // Track when the initial data was successfully fetched to avoid immediate SSE refetches + const initialLoadTimestampRef = useRef(null); + + // Update initial load timestamp when query succeeds + useEffect(() => { + if (query.isSuccess && !initialLoadTimestampRef.current) { + initialLoadTimestampRef.current = Date.now(); + } + }, [query.isSuccess]); // SSE integration - invalidate query on relevant events (debounced) useEffect(() => { @@ -429,6 +442,17 @@ export function useControlPanelData(tenantId: string) { return; } + // OPTIMIZATION: Skip SSE-triggered invalidation during grace period after initial load + // This prevents duplicate API calls when SSE events arrive during/right after the initial fetch + if (initialLoadTimestampRef.current) { + const timeSinceInitialLoad = Date.now() - initialLoadTimestampRef.current; + if (timeSinceInitialLoad < SSE_INITIAL_LOAD_GRACE_PERIOD_MS) { + // Update the event count ref so we don't process these events later + lastEventCountRef.current = sseAlerts.length; + return; + } + } + const relevantEvents = sseAlerts.filter(event => event.event_type?.includes('production.') || event.event_type?.includes('batch_') || diff --git a/frontend/src/components/ui/TenantSwitcher.tsx b/frontend/src/components/ui/TenantSwitcher.tsx index 17a53ce9..15aad212 100644 --- a/frontend/src/components/ui/TenantSwitcher.tsx +++ b/frontend/src/components/ui/TenantSwitcher.tsx @@ -36,14 +36,9 @@ export const TenantSwitcher: React.FC = ({ clearError, } = useTenant(); - - - // Load tenants on mount - useEffect(() => { - if (!availableTenants) { - loadUserTenants(); - } - }, [availableTenants, loadUserTenants]); + // NOTE: Removed duplicate loadUserTenants() useEffect + // Tenant loading is already handled by useTenantInitializer at app level (stores/useTenantInitializer.ts) + // This was causing duplicate /tenants API calls on every dashboard load // Handle click outside to close dropdown useEffect(() => { diff --git a/frontend/src/pages/app/DashboardPage.tsx b/frontend/src/pages/app/DashboardPage.tsx index 794ad836..31b08cf4 100644 --- a/frontend/src/pages/app/DashboardPage.tsx +++ b/frontend/src/pages/app/DashboardPage.tsx @@ -495,13 +495,19 @@ export function DashboardPage() { const { plan, loading: subLoading } = subscriptionInfo; const tenantId = currentTenant?.id; - // Fetch onboarding progress + // Check if in demo mode - demo users don't need onboarding check + // (backend returns fully_completed=true for demo users anyway, but we skip the API call entirely) + const isDemoMode = localStorage.getItem('demo_mode') === 'true'; + + // Fetch onboarding progress - SKIP for demo users and enterprise tier + // Demo users are pre-configured through cloning, so onboarding is always complete const isAuthenticated = useIsAuthenticated(); const { data: userProgress, isLoading: progressLoading } = useUserProgress('', { - enabled: !!isAuthenticated && plan !== SUBSCRIPTION_TIERS.ENTERPRISE + enabled: !!isAuthenticated && !isDemoMode && plan !== SUBSCRIPTION_TIERS.ENTERPRISE }); - const loading = subLoading || progressLoading; + // Don't wait for progressLoading if demo mode (we're not fetching it) + const loading = subLoading || (!isDemoMode && progressLoading); useEffect(() => { if (!loading && userProgress && !userProgress.fully_completed && plan !== SUBSCRIPTION_TIERS.ENTERPRISE) { diff --git a/frontend/src/stores/useTenantInitializer.ts b/frontend/src/stores/useTenantInitializer.ts index 5de1d7d1..b6a2e3e4 100644 --- a/frontend/src/stores/useTenantInitializer.ts +++ b/frontend/src/stores/useTenantInitializer.ts @@ -54,7 +54,7 @@ export const useTenantInitializer = () => { const demoAccountType = useDemoAccountType(); const availableTenants = useAvailableTenants(); const currentTenant = useCurrentTenant(); - const { loadUserTenants, setCurrentTenant } = useTenantActions(); + const { loadUserTenants, setCurrentTenant, setAvailableTenants } = useTenantActions(); // Load tenants for authenticated users (but not demo users - they have special initialization below) useEffect(() => { @@ -79,34 +79,35 @@ export const useTenantInitializer = () => { typeof currentTenant === 'object' && currentTenant.id === virtualTenantId; + // Determine the appropriate subscription tier based on stored value or account type + const subscriptionTier = storedTier as SubscriptionTier || getDemoTierForAccountType(demoAccountType); + + // Get appropriate tenant details based on account type + const tenantDetails = getTenantDetailsForAccountType(demoAccountType); + + // Create a complete tenant object matching TenantResponse structure + const mockTenant = { + id: virtualTenantId, + name: tenantDetails.name, + subdomain: `demo-${demoSessionId.slice(0, 8)}`, + business_type: tenantDetails.business_type, + business_model: tenantDetails.business_model, + description: tenantDetails.description, + address: 'Demo Address', + city: 'Madrid', + postal_code: '28001', + phone: null, + is_active: true, + subscription_plan: subscriptionTier, + subscription_tier: subscriptionTier, + ml_model_trained: false, + last_training_date: null, + owner_id: 'demo-user', + created_at: new Date().toISOString(), + }; + + // Only set current tenant if not already valid if (!isValidDemoTenant) { - // Determine the appropriate subscription tier based on stored value or account type - const subscriptionTier = storedTier as SubscriptionTier || getDemoTierForAccountType(demoAccountType); - - // Get appropriate tenant details based on account type - const tenantDetails = getTenantDetailsForAccountType(demoAccountType); - - // Create a complete tenant object matching TenantResponse structure - const mockTenant = { - id: virtualTenantId, - name: tenantDetails.name, - subdomain: `demo-${demoSessionId.slice(0, 8)}`, - business_type: tenantDetails.business_type, - business_model: tenantDetails.business_model, - description: tenantDetails.description, - address: 'Demo Address', - city: 'Madrid', - postal_code: '28001', - phone: null, - is_active: true, - subscription_plan: subscriptionTier, - subscription_tier: subscriptionTier, - ml_model_trained: false, - last_training_date: null, - owner_id: 'demo-user', - created_at: new Date().toISOString(), - }; - // Set the demo tenant as current setCurrentTenant(mockTenant); @@ -114,25 +115,45 @@ export const useTenantInitializer = () => { import('../api/client').then(({ apiClient }) => { apiClient.setTenantId(virtualTenantId); }); + } - // For enterprise demos, load child tenants immediately - if (demoAccountType === 'enterprise') { - const mockUserId = 'demo-user'; - - import('../api/services/tenant').then(({ TenantService }) => { - const tenantService = new TenantService(); - tenantService.getUserTenants(mockUserId) - .then(tenants => { - import('../stores/tenant.store').then(({ useTenantStore }) => { - useTenantStore.getState().setAvailableTenants(tenants); - }); - }) - .catch(() => { - // Silently handle error - }); - }); + // For professional demos, just set the single mock tenant as available (if not already set) + if (demoAccountType !== 'enterprise') { + if (!availableTenants || availableTenants.length === 0) { + setAvailableTenants([mockTenant as any]); } + return; + } + + // For enterprise demos, ALWAYS ensure child tenants are loaded + // Check if we need to load child tenants: + // - availableTenants is empty/null, OR + // - availableTenants only has the parent (length === 1) + const needsChildTenants = !availableTenants || availableTenants.length <= 1; + + if (needsChildTenants) { + console.log('[useTenantInitializer] Enterprise demo - loading child tenants for parent:', virtualTenantId); + + import('../api/services/tenant').then(({ TenantService }) => { + const tenantService = new TenantService(); + // Use getChildTenants with the parent tenant ID (virtualTenantId) + // This calls GET /tenants/{tenant_id}/children + tenantService.getChildTenants(virtualTenantId) + .then(childTenants => { + console.log('[useTenantInitializer] Enterprise demo - loaded child tenants:', childTenants?.length, childTenants); + // Combine parent tenant with children for the tenant switcher + const allTenants = [mockTenant as any, ...childTenants]; + setAvailableTenants(allTenants); + }) + .catch((error) => { + console.error('[useTenantInitializer] Enterprise demo - failed to load child tenants:', error); + // Fallback: at least set the parent mock tenant as available so TenantSwitcher renders + if (!availableTenants || availableTenants.length === 0) { + setAvailableTenants([mockTenant as any]); + } + }); + }); } } - }, [isDemoMode, demoSessionId, demoAccountType, currentTenant, setCurrentTenant]); + }, [isDemoMode, demoSessionId, demoAccountType, currentTenant, availableTenants, setCurrentTenant, setAvailableTenants]); }; \ No newline at end of file