REFACTOR external service and improve websocket training

This commit is contained in:
Urtzi Alfaro
2025-10-09 14:11:02 +02:00
parent 7c72f83c51
commit 3c689b4f98
111 changed files with 13289 additions and 2374 deletions

View File

@@ -13,13 +13,8 @@ import type {
TrainingJobResponse,
TrainingJobStatus,
SingleProductTrainingRequest,
ActiveModelResponse,
ModelMetricsResponse,
TrainedModelResponse,
TenantStatistics,
ModelPerformanceResponse,
ModelsQueryParams,
PaginatedResponse,
} from '../types/training';
// Query Keys Factory
@@ -30,10 +25,10 @@ export const trainingKeys = {
status: (tenantId: string, jobId: string) =>
[...trainingKeys.jobs.all(), 'status', tenantId, jobId] as const,
},
models: {
models: {
all: () => [...trainingKeys.all, 'models'] as const,
lists: () => [...trainingKeys.models.all(), 'list'] as const,
list: (tenantId: string, params?: ModelsQueryParams) =>
list: (tenantId: string, params?: any) =>
[...trainingKeys.models.lists(), tenantId, params] as const,
details: () => [...trainingKeys.models.all(), 'detail'] as const,
detail: (tenantId: string, modelId: string) =>
@@ -67,7 +62,7 @@ export const useTrainingJobStatus = (
jobId: !!jobId,
isWebSocketConnected,
queryEnabled: isEnabled
});
});
return useQuery<TrainingJobStatus, ApiError>({
queryKey: trainingKeys.jobs.status(tenantId, jobId),
@@ -76,14 +71,8 @@ export const useTrainingJobStatus = (
return trainingService.getTrainingJobStatus(tenantId, jobId);
},
enabled: isEnabled, // Completely disable when WebSocket connected
refetchInterval: (query) => {
// CRITICAL FIX: React Query executes refetchInterval even when enabled=false
// We must check WebSocket connection state here to prevent misleading polling
if (isWebSocketConnected) {
console.log('✅ WebSocket connected - HTTP polling DISABLED');
return false; // Disable polling when WebSocket is active
}
refetchInterval: isEnabled ? (query) => {
// Only set up refetch interval if the query is enabled
const data = query.state.data;
// Stop polling if we get auth errors or training is completed
@@ -96,9 +85,9 @@ export const useTrainingJobStatus = (
return false; // Stop polling when training is done
}
console.log('📊 HTTP fallback polling active (WebSocket actually disconnected) - 5s interval');
console.log('📊 HTTP fallback polling active (WebSocket disconnected) - 5s interval');
return 5000; // Poll every 5 seconds while training (fallback when WebSocket unavailable)
},
} : false, // Completely disable interval when WebSocket connected
staleTime: 1000, // Consider data stale after 1 second
retry: (failureCount, error) => {
// Don't retry on auth errors
@@ -116,9 +105,9 @@ export const useTrainingJobStatus = (
export const useActiveModel = (
tenantId: string,
inventoryProductId: string,
options?: Omit<UseQueryOptions<ActiveModelResponse, ApiError>, 'queryKey' | 'queryFn'>
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ActiveModelResponse, ApiError>({
return useQuery<any, ApiError>({
queryKey: trainingKeys.models.active(tenantId, inventoryProductId),
queryFn: () => trainingService.getActiveModel(tenantId, inventoryProductId),
enabled: !!tenantId && !!inventoryProductId,
@@ -129,10 +118,10 @@ export const useActiveModel = (
export const useModels = (
tenantId: string,
queryParams?: ModelsQueryParams,
options?: Omit<UseQueryOptions<PaginatedResponse<TrainedModelResponse>, ApiError>, 'queryKey' | 'queryFn'>
queryParams?: any,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PaginatedResponse<TrainedModelResponse>, ApiError>({
return useQuery<any, ApiError>({
queryKey: trainingKeys.models.list(tenantId, queryParams),
queryFn: () => trainingService.getModels(tenantId, queryParams),
enabled: !!tenantId,
@@ -158,9 +147,9 @@ export const useModelMetrics = (
export const useModelPerformance = (
tenantId: string,
modelId: string,
options?: Omit<UseQueryOptions<ModelPerformanceResponse, ApiError>, 'queryKey' | 'queryFn'>
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ModelPerformanceResponse, ApiError>({
return useQuery<any, ApiError>({
queryKey: trainingKeys.models.performance(tenantId, modelId),
queryFn: () => trainingService.getModelPerformance(tenantId, modelId),
enabled: !!tenantId && !!modelId,
@@ -172,9 +161,9 @@ export const useModelPerformance = (
// Statistics Queries
export const useTenantTrainingStatistics = (
tenantId: string,
options?: Omit<UseQueryOptions<TenantStatistics, ApiError>, 'queryKey' | 'queryFn'>
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantStatistics, ApiError>({
return useQuery<any, ApiError>({
queryKey: trainingKeys.statistics(tenantId),
queryFn: () => trainingService.getTenantStatistics(tenantId),
enabled: !!tenantId,
@@ -207,7 +196,6 @@ export const useCreateTrainingJob = (
job_id: data.job_id,
status: data.status,
progress: 0,
message: data.message,
}
);
@@ -242,7 +230,6 @@ export const useTrainSingleProduct = (
job_id: data.job_id,
status: data.status,
progress: 0,
message: data.message,
}
);
@@ -448,76 +435,130 @@ export const useTrainingWebSocket = (
}
const message = JSON.parse(event.data);
console.log('🔔 Training WebSocket message received:', message);
// Handle heartbeat messages
if (message.type === 'heartbeat') {
console.log('💓 Heartbeat received from server');
return; // Don't process heartbeats further
// Handle initial state message to restore the latest known state
if (message.type === 'initial_state') {
console.log('📥 Received initial state:', message.data);
const initialData = message.data;
const initialEventData = initialData.data || {};
let initialProgress = initialEventData.progress || 0;
// Calculate progress for product_completed events
if (initialData.type === 'product_completed') {
const productsCompleted = initialEventData.products_completed || 0;
const totalProducts = initialEventData.total_products || 1;
initialProgress = 20 + Math.floor((productsCompleted / totalProducts) * 60);
console.log('📦 Product training completed in initial state',
`${productsCompleted}/${totalProducts}`,
`progress: ${initialProgress}%`);
}
// Update job status in cache with initial state
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, jobId),
(oldData: TrainingJobStatus | undefined) => ({
...oldData,
job_id: jobId,
status: initialData.type === 'completed' ? 'completed' :
initialData.type === 'failed' ? 'failed' :
initialData.type === 'started' ? 'running' :
initialData.type === 'progress' ? 'running' :
initialData.type === 'product_completed' ? 'running' :
initialData.type === 'step_completed' ? 'running' :
oldData?.status || 'running',
progress: typeof initialProgress === 'number' ? initialProgress : oldData?.progress || 0,
current_step: initialEventData.current_step || initialEventData.step_name || oldData?.current_step,
})
);
return; // Initial state messages are only for state restoration, don't process as regular events
}
// Extract data from backend message structure
const eventData = message.data || {};
const progress = eventData.progress || 0;
let progress = eventData.progress || 0;
const currentStep = eventData.current_step || eventData.step_name || '';
const statusMessage = eventData.message || eventData.status || '';
const stepDetails = eventData.step_details || '';
// Update job status in cache with backend structure
// Handle product_completed events - calculate progress dynamically
if (message.type === 'product_completed') {
const productsCompleted = eventData.products_completed || 0;
const totalProducts = eventData.total_products || 1;
// Calculate progress: 20% base + (completed/total * 60%)
progress = 20 + Math.floor((productsCompleted / totalProducts) * 60);
console.log('📦 Product training completed',
`${productsCompleted}/${totalProducts}`,
`progress: ${progress}%`);
}
// Update job status in cache
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, jobId),
(oldData: TrainingJobStatus | undefined) => ({
...oldData,
job_id: jobId,
status: message.type === 'completed' ? 'completed' :
message.type === 'failed' ? 'failed' :
message.type === 'started' ? 'running' :
status: message.type === 'completed' ? 'completed' :
message.type === 'failed' ? 'failed' :
message.type === 'started' ? 'running' :
oldData?.status || 'running',
progress: typeof progress === 'number' ? progress : oldData?.progress || 0,
message: statusMessage || oldData?.message || '',
current_step: currentStep || oldData?.current_step,
estimated_time_remaining: eventData.estimated_time_remaining || oldData?.estimated_time_remaining,
})
);
// Call appropriate callback based on message type (exact backend mapping)
// Call appropriate callback based on message type
switch (message.type) {
case 'connected':
console.log('🔗 WebSocket connected');
break;
case 'started':
console.log('🚀 Training started');
memoizedOptions?.onStarted?.(message);
break;
case 'progress':
console.log('📊 Training progress update', `${progress}%`);
memoizedOptions?.onProgress?.(message);
break;
case 'step_completed':
memoizedOptions?.onProgress?.(message); // Treat step completion as progress
case 'product_completed':
console.log('✅ Product training completed');
// Treat as progress update
memoizedOptions?.onProgress?.({
...message,
data: {
...eventData,
progress, // Use calculated progress
}
});
break;
case 'step_completed':
console.log('📋 Step completed');
memoizedOptions?.onProgress?.(message);
break;
case 'completed':
console.log('✅ Training completed successfully');
memoizedOptions?.onCompleted?.(message);
// Invalidate models and statistics
queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() });
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) });
isManuallyDisconnected = true; // Don't reconnect after completion
isManuallyDisconnected = true;
break;
case 'failed':
console.log('❌ Training failed');
memoizedOptions?.onError?.(message);
isManuallyDisconnected = true; // Don't reconnect after failure
break;
case 'cancelled':
console.log('🛑 Training cancelled');
memoizedOptions?.onCancelled?.(message);
isManuallyDisconnected = true; // Don't reconnect after cancellation
break;
case 'current_status':
console.log('📊 Received current training status');
// Treat current status as progress update if it has progress data
if (message.data) {
memoizedOptions?.onProgress?.(message);
}
isManuallyDisconnected = true;
break;
default:
console.log(`🔍 Received unknown message type: ${message.type}`);
console.log(`🔍 Unknown message type: ${message.type}`);
break;
}
} catch (error) {
@@ -593,28 +634,22 @@ export const useTrainingWebSocket = (
}
};
// Delay initial connection to ensure training job is created
const initialConnectionTimer = setTimeout(() => {
console.log('🚀 Starting initial WebSocket connection...');
connect();
}, 2000); // 2-second delay to let the job initialize
// Connect immediately to avoid missing early progress updates
console.log('🚀 Starting immediate WebSocket connection...');
connect();
// Cleanup function
return () => {
isManuallyDisconnected = true;
if (initialConnectionTimer) {
clearTimeout(initialConnectionTimer);
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
if (ws) {
ws.close(1000, 'Component unmounted');
}
setIsConnected(false);
};
}, [tenantId, jobId, queryClient, memoizedOptions]);
@@ -652,9 +687,8 @@ export const useTrainingProgress = (
return {
progress: jobStatus?.progress || 0,
currentStep: jobStatus?.current_step,
estimatedTimeRemaining: jobStatus?.estimated_time_remaining,
isComplete: jobStatus?.status === 'completed',
isFailed: jobStatus?.status === 'failed',
isRunning: jobStatus?.status === 'running',
};
};
};

View File

@@ -0,0 +1,130 @@
// frontend/src/api/services/external.ts
/**
* External Data API Service
* Handles weather and traffic data operations
*/
import { apiClient } from '../client';
import type {
CityInfoResponse,
DataAvailabilityResponse,
WeatherDataResponse,
TrafficDataResponse,
HistoricalWeatherRequest,
HistoricalTrafficRequest,
} from '../types/external';
class ExternalDataService {
/**
* List all supported cities
*/
async listCities(): Promise<CityInfoResponse[]> {
const response = await apiClient.get<CityInfoResponse[]>(
'/api/v1/external/cities'
);
return response.data;
}
/**
* Get data availability for a specific city
*/
async getCityAvailability(cityId: string): Promise<DataAvailabilityResponse> {
const response = await apiClient.get<DataAvailabilityResponse>(
`/api/v1/external/operations/cities/${cityId}/availability`
);
return response.data;
}
/**
* Get historical weather data (optimized city-based endpoint)
*/
async getHistoricalWeatherOptimized(
tenantId: string,
params: {
latitude: number;
longitude: number;
start_date: string;
end_date: string;
}
): Promise<WeatherDataResponse[]> {
const response = await apiClient.get<WeatherDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/historical-weather-optimized`,
{ params }
);
return response.data;
}
/**
* Get historical traffic data (optimized city-based endpoint)
*/
async getHistoricalTrafficOptimized(
tenantId: string,
params: {
latitude: number;
longitude: number;
start_date: string;
end_date: string;
}
): Promise<TrafficDataResponse[]> {
const response = await apiClient.get<TrafficDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/historical-traffic-optimized`,
{ params }
);
return response.data;
}
/**
* Get current weather for a location (real-time)
*/
async getCurrentWeather(
tenantId: string,
params: {
latitude: number;
longitude: number;
}
): Promise<WeatherDataResponse> {
const response = await apiClient.get<WeatherDataResponse>(
`/api/v1/tenants/${tenantId}/external/operations/weather/current`,
{ params }
);
return response.data;
}
/**
* Get weather forecast
*/
async getWeatherForecast(
tenantId: string,
params: {
latitude: number;
longitude: number;
days?: number;
}
): Promise<WeatherDataResponse[]> {
const response = await apiClient.get<WeatherDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/weather/forecast`,
{ params }
);
return response.data;
}
/**
* Get current traffic conditions (real-time)
*/
async getCurrentTraffic(
tenantId: string,
params: {
latitude: number;
longitude: number;
}
): Promise<TrafficDataResponse> {
const response = await apiClient.get<TrafficDataResponse>(
`/api/v1/tenants/${tenantId}/external/operations/traffic/current`,
{ params }
);
return response.data;
}
}
export const externalDataService = new ExternalDataService();
export default externalDataService;

View File

@@ -317,3 +317,44 @@ export interface TrafficForecastRequest {
longitude: number;
hours?: number; // Default: 24
}
// ================================================================
// CITY-BASED DATA TYPES (NEW)
// ================================================================
/**
* City information response
* Backend: services/external/app/schemas/city_data.py:CityInfoResponse
*/
export interface CityInfoResponse {
city_id: string;
name: string;
country: string;
latitude: number;
longitude: number;
radius_km: number;
weather_provider: string;
traffic_provider: string;
enabled: boolean;
}
/**
* Data availability response
* Backend: services/external/app/schemas/city_data.py:DataAvailabilityResponse
*/
export interface DataAvailabilityResponse {
city_id: string;
city_name: string;
// Weather availability
weather_available: boolean;
weather_start_date: string | null;
weather_end_date: string | null;
weather_record_count: number;
// Traffic availability
traffic_available: boolean;
traffic_start_date: string | null;
traffic_end_date: string | null;
traffic_record_count: number;
}

View File

@@ -131,6 +131,7 @@ const DemandChart: React.FC<DemandChartProps> = ({
// Update zoomed data when filtered data changes
useEffect(() => {
console.log('🔍 Setting zoomed data from filtered data:', filteredData);
// Always update zoomed data when filtered data changes, even if empty
setZoomedData(filteredData);
}, [filteredData]);
@@ -236,11 +237,19 @@ const DemandChart: React.FC<DemandChartProps> = ({
);
}
// Use filteredData if zoomedData is empty but we have data
const displayData = zoomedData.length > 0 ? zoomedData : filteredData;
// Robust fallback logic for display data
const displayData = zoomedData.length > 0 ? zoomedData : (filteredData.length > 0 ? filteredData : chartData);
console.log('📊 Final display data:', {
chartDataLength: chartData.length,
filteredDataLength: filteredData.length,
zoomedDataLength: zoomedData.length,
displayDataLength: displayData.length,
displayData: displayData
});
// Empty state - only show if we truly have no data
if (displayData.length === 0 && chartData.length === 0) {
if (displayData.length === 0) {
return (
<Card className={className}>
<CardHeader>

View File

@@ -95,21 +95,24 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
}
);
// Handle training status updates from HTTP polling (fallback only)
// Handle training status updates from React Query cache (updated by WebSocket or HTTP fallback)
useEffect(() => {
if (!jobStatus || !jobId || trainingProgress?.stage === 'completed') {
return;
}
console.log('📊 HTTP fallback status update:', jobStatus);
console.log('📊 Training status update from cache:', jobStatus,
`(source: ${isConnected ? 'WebSocket' : 'HTTP polling'})`);
// Check if training completed via HTTP polling fallback
// Check if training completed
if (jobStatus.status === 'completed' && trainingProgress?.stage !== 'completed') {
console.log('✅ Training completion detected via HTTP fallback');
console.log(`✅ Training completion detected (source: ${isConnected ? 'WebSocket' : 'HTTP polling'})`);
setTrainingProgress({
stage: 'completed',
progress: 100,
message: 'Entrenamiento completado exitosamente (detectado por verificación HTTP)'
message: isConnected
? 'Entrenamiento completado exitosamente'
: 'Entrenamiento completado exitosamente (detectado por verificación HTTP)'
});
setIsTraining(false);
@@ -122,15 +125,15 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
});
}, 2000);
} else if (jobStatus.status === 'failed') {
console.log('❌ Training failure detected via HTTP fallback');
console.log(`❌ Training failure detected (source: ${isConnected ? 'WebSocket' : 'HTTP polling'})`);
setError('Error detectado durante el entrenamiento (verificación de estado)');
setIsTraining(false);
setTrainingProgress(null);
} else if (jobStatus.status === 'running' && jobStatus.progress !== undefined) {
// Update progress if we have newer information from HTTP polling fallback
// Update progress if we have newer information
const currentProgress = trainingProgress?.progress || 0;
if (jobStatus.progress > currentProgress) {
console.log(`📈 Progress update via HTTP fallback: ${jobStatus.progress}%`);
console.log(`📈 Progress update (source: ${isConnected ? 'WebSocket' : 'HTTP polling'}): ${jobStatus.progress}%`);
setTrainingProgress(prev => ({
...prev,
stage: 'training',
@@ -140,7 +143,7 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
}) as TrainingProgress);
}
}
}, [jobStatus, jobId, trainingProgress?.stage, onComplete]);
}, [jobStatus, jobId, trainingProgress?.stage, onComplete, isConnected]);
// Auto-trigger training when component mounts
useEffect(() => {

View File

@@ -2,7 +2,7 @@ import React, { forwardRef, ButtonHTMLAttributes } from 'react';
import { clsx } from 'clsx';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning';
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'gradient';
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
isLoading?: boolean;
isFullWidth?: boolean;
@@ -29,8 +29,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
'transition-all duration-200 ease-in-out',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
'border rounded-md shadow-sm',
'hover:shadow-md active:shadow-sm'
'border rounded-md',
];
const variantClasses = {
@@ -38,19 +37,22 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
'bg-[var(--color-primary)] text-[var(--text-inverse)] border-[var(--color-primary)]',
'hover:bg-[var(--color-primary-dark)] hover:border-[var(--color-primary-dark)]',
'focus:ring-[var(--color-primary)]/20',
'active:bg-[var(--color-primary-dark)]'
'active:bg-[var(--color-primary-dark)]',
'shadow-sm hover:shadow-md active:shadow-sm'
],
secondary: [
'bg-[var(--color-secondary)] text-[var(--text-inverse)] border-[var(--color-secondary)]',
'hover:bg-[var(--color-secondary-dark)] hover:border-[var(--color-secondary-dark)]',
'focus:ring-[var(--color-secondary)]/20',
'active:bg-[var(--color-secondary-dark)]'
'active:bg-[var(--color-secondary-dark)]',
'shadow-sm hover:shadow-md active:shadow-sm'
],
outline: [
'bg-transparent text-[var(--color-primary)] border-[var(--color-primary)]',
'hover:bg-[var(--color-primary)] hover:text-[var(--text-inverse)]',
'focus:ring-[var(--color-primary)]/20',
'active:bg-[var(--color-primary-dark)] active:border-[var(--color-primary-dark)]'
'active:bg-[var(--color-primary-dark)] active:border-[var(--color-primary-dark)]',
'shadow-sm hover:shadow-md active:shadow-sm'
],
ghost: [
'bg-transparent text-[var(--text-primary)] border-transparent',
@@ -62,19 +64,30 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
'bg-[var(--color-error)] text-[var(--text-inverse)] border-[var(--color-error)]',
'hover:bg-[var(--color-error-dark)] hover:border-[var(--color-error-dark)]',
'focus:ring-[var(--color-error)]/20',
'active:bg-[var(--color-error-dark)]'
'active:bg-[var(--color-error-dark)]',
'shadow-sm hover:shadow-md active:shadow-sm'
],
success: [
'bg-[var(--color-success)] text-[var(--text-inverse)] border-[var(--color-success)]',
'hover:bg-[var(--color-success-dark)] hover:border-[var(--color-success-dark)]',
'focus:ring-[var(--color-success)]/20',
'active:bg-[var(--color-success-dark)]'
'active:bg-[var(--color-success-dark)]',
'shadow-sm hover:shadow-md active:shadow-sm'
],
warning: [
'bg-[var(--color-warning)] text-[var(--text-inverse)] border-[var(--color-warning)]',
'hover:bg-[var(--color-warning-dark)] hover:border-[var(--color-warning-dark)]',
'focus:ring-[var(--color-warning)]/20',
'active:bg-[var(--color-warning-dark)]'
'active:bg-[var(--color-warning-dark)]',
'shadow-sm hover:shadow-md active:shadow-sm'
],
gradient: [
'bg-[var(--color-primary)] text-white border-[var(--color-primary)]',
'hover:bg-[var(--color-primary-dark)] hover:border-[var(--color-primary-dark)]',
'focus:ring-[var(--color-primary)]/20',
'shadow-lg hover:shadow-xl',
'transform hover:scale-105',
'font-semibold'
]
};

View File

@@ -27,7 +27,9 @@ const ForecastingPage: React.FC = () => {
const startDate = new Date();
startDate.setDate(startDate.getDate() - parseInt(forecastPeriod));
// Fetch existing forecasts
// NOTE: We don't need to fetch forecasts from API because we already have them
// from the multi-day forecast response stored in currentForecastData
// Keeping this disabled to avoid unnecessary API calls
const {
data: forecastsData,
isLoading: forecastsLoading,
@@ -38,7 +40,7 @@ const ForecastingPage: React.FC = () => {
...(selectedProduct && { inventory_product_id: selectedProduct }),
limit: 100
}, {
enabled: !!tenantId && hasGeneratedForecast && !!selectedProduct
enabled: false // Disabled - we use currentForecastData from multi-day API response
});
@@ -72,12 +74,15 @@ const ForecastingPage: React.FC = () => {
// Build products list from ingredients that have trained models
const products = useMemo(() => {
if (!ingredientsData || !modelsData?.models) {
if (!ingredientsData || !modelsData) {
return [];
}
// Handle both array and paginated response formats
const modelsList = Array.isArray(modelsData) ? modelsData : (modelsData.models || modelsData.items || []);
// Get inventory product IDs that have trained models
const modelProductIds = new Set(modelsData.models.map(model => model.inventory_product_id));
const modelProductIds = new Set(modelsList.map((model: any) => model.inventory_product_id));
// Filter ingredients to only those with models
const ingredientsWithModels = ingredientsData.filter(ingredient =>
@@ -130,10 +135,10 @@ const ForecastingPage: React.FC = () => {
}
};
// Use either current forecast data or fetched data
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
const hasError = forecastsError || ingredientsError || modelsError;
// Use current forecast data from multi-day API response
const forecasts = currentForecastData;
const isLoading = ingredientsLoading || modelsLoading || isGenerating;
const hasError = ingredientsError || modelsError;
// Calculate metrics from real data
const totalDemand = forecasts.reduce((sum, f) => sum + f.predicted_demand, 0);