Improve the dahboard 4

This commit is contained in:
Urtzi Alfaro
2025-08-18 20:50:41 +02:00
parent 523fc663e8
commit 18355cd8be
10 changed files with 1133 additions and 152 deletions

View File

@@ -13,21 +13,77 @@ import { ApiErrorHandler } from '../utils';
* Automatically adds authentication headers to requests
*/
class AuthInterceptor {
static isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
return payload.exp <= currentTime;
} catch (error) {
console.warn('Error parsing token:', error);
return true; // Treat invalid tokens as expired
}
}
static isTokenExpiringSoon(token: string, bufferMinutes: number = 5): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
const bufferSeconds = bufferMinutes * 60;
return payload.exp <= (currentTime + bufferSeconds);
} catch (error) {
console.warn('Error parsing token for expiration check:', error);
return true;
}
}
static async refreshTokenIfNeeded(): Promise<void> {
const token = localStorage.getItem('auth_token');
const refreshToken = localStorage.getItem('refresh_token');
if (!token || !refreshToken) {
return;
}
// If token is expiring within 5 minutes, proactively refresh
if (this.isTokenExpiringSoon(token, 5)) {
try {
const baseURL = (apiClient as any).baseURL || window.location.origin;
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('auth_token', data.access_token);
if (data.refresh_token) {
localStorage.setItem('refresh_token', data.refresh_token);
}
} else {
console.warn('Token refresh failed:', response.status);
}
} catch (error) {
console.warn('Token refresh error:', error);
}
}
}
static setup() {
apiClient.addRequestInterceptor({
onRequest: async (config: RequestConfig) => {
// Proactively refresh token if needed
await this.refreshTokenIfNeeded();
let token = localStorage.getItem('auth_token');
console.log('🔐 AuthInterceptor: Checking auth token...', token ? 'Found' : 'Missing');
if (token) {
console.log('🔐 AuthInterceptor: Token preview:', token.substring(0, 20) + '...');
}
// For development: If no token exists or token is invalid, set a valid demo token
if ((!token || token === 'demo-development-token') && window.location.hostname === 'localhost') {
console.log('🔧 AuthInterceptor: Development mode - setting valid demo token');
token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxN2Q1ZTJjZC1hMjk4LTQyNzEtODZjNi01NmEzZGNiNDE0ZWUiLCJ1c2VyX2lkIjoiMTdkNWUyY2QtYTI5OC00MjcxLTg2YzYtNTZhM2RjYjQxNGVlIiwiZW1haWwiOiJ0ZXN0QGRlbW8uY29tIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NTI3MzEyNSwiaWF0IjoxNzU1MjcxMzI1LCJpc3MiOiJiYWtlcnktYXV0aCIsImZ1bGxfbmFtZSI6IkRlbW8gVXNlciIsImlzX3ZlcmlmaWVkIjpmYWxzZSwiaXNfYWN0aXZlIjp0cnVlLCJyb2xlIjoidXNlciJ9.RBfzH9L_NKySYkyLzBLYAApnrCFNK4OsGLLO-eCaTSI';
localStorage.setItem('auth_token', token);
// Check if token is expired
if (this.isTokenExpired(token)) {
console.warn('Token expired, removing from storage');
localStorage.removeItem('auth_token');
token = null;
}
}
if (token) {
@@ -35,9 +91,8 @@ class AuthInterceptor {
...config.headers,
Authorization: `Bearer ${token}`,
};
console.log('🔐 AuthInterceptor: Added Authorization header');
} else {
console.warn('⚠️ AuthInterceptor: No auth token found in localStorage');
console.warn('No valid auth token found - authentication required');
}
return config;
@@ -48,9 +103,6 @@ class AuthInterceptor {
throw error;
},
});
// Note: 401 handling is now managed by ErrorRecoveryInterceptor
// This allows token refresh to work before redirecting to login
}
}
@@ -179,8 +231,7 @@ class ErrorRecoveryInterceptor {
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return apiClient.request(originalRequest.url, originalRequest);
return this.retryRequestWithNewToken(originalRequest, token);
}).catch(err => {
throw err;
});
@@ -196,7 +247,7 @@ class ErrorRecoveryInterceptor {
throw new Error('No refresh token available');
}
// Attempt to refresh token
// Use direct fetch to avoid interceptor recursion
const baseURL = (apiClient as any).baseURL || window.location.origin;
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
method: 'POST',
@@ -207,22 +258,31 @@ class ErrorRecoveryInterceptor {
});
if (!response.ok) {
throw new Error('Token refresh failed');
throw new Error(`Token refresh failed: ${response.status}`);
}
const data = await response.json();
const newToken = data.access_token;
if (!newToken) {
throw new Error('No access token received');
}
localStorage.setItem('auth_token', newToken);
// Update new refresh token if provided
if (data.refresh_token) {
localStorage.setItem('refresh_token', data.refresh_token);
}
// Process failed queue
this.processQueue(null, newToken);
// Retry original request
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return apiClient.request(originalRequest.url, originalRequest);
// Retry original request with new token
return this.retryRequestWithNewToken(originalRequest, newToken);
} catch (refreshError) {
console.warn('Token refresh failed:', refreshError);
this.processQueue(refreshError, null);
// Clear auth data and redirect to login
@@ -230,7 +290,8 @@ class ErrorRecoveryInterceptor {
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_data');
if (typeof window !== 'undefined') {
// Only redirect if we're not already on the login page
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
@@ -245,6 +306,55 @@ class ErrorRecoveryInterceptor {
});
}
private static async retryRequestWithNewToken(originalRequest: any, token: string) {
try {
// Use direct fetch instead of apiClient to avoid interceptor recursion
const url = originalRequest.url || originalRequest.endpoint;
const method = originalRequest.method || 'GET';
const fetchOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...originalRequest.headers
}
};
// Add body for non-GET requests
if (method !== 'GET' && originalRequest.body) {
fetchOptions.body = typeof originalRequest.body === 'string'
? originalRequest.body
: JSON.stringify(originalRequest.body);
}
// Add query parameters if present
let fullUrl = url;
if (originalRequest.params) {
const urlWithParams = new URL(fullUrl, (apiClient as any).baseURL);
Object.entries(originalRequest.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlWithParams.searchParams.append(key, String(value));
}
});
fullUrl = urlWithParams.toString();
}
// Retry request with refreshed token
const response = await fetch(fullUrl, fetchOptions);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return await response.json();
} catch (retryError) {
console.warn('Request retry failed:', retryError);
throw retryError;
}
}
private static processQueue(error: any, token: string | null) {
this.failedQueue.forEach(({ resolve, reject }) => {
if (error) {

View File

@@ -6,12 +6,13 @@
import { useState, useCallback } from 'react';
import { externalService } from '../services/external.service';
import type { WeatherData, TrafficData, WeatherForecast } from '../services/external.service';
import type { WeatherData, TrafficData, WeatherForecast, HourlyForecast } from '../services/external.service';
export const useExternal = () => {
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
const [hourlyForecast, setHourlyForecast] = useState<HourlyForecast[]>([]);
const [trafficForecast, setTrafficForecast] = useState<TrafficData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -67,6 +68,32 @@ export const useExternal = () => {
}
}, []);
/**
* Get Hourly Weather Forecast
*/
const getHourlyWeatherForecast = useCallback(async (
tenantId: string,
lat: number,
lon: number,
hours: number = 48
): Promise<HourlyForecast[]> => {
try {
setIsLoading(true);
setError(null);
const forecast = await externalService.getHourlyWeatherForecast(tenantId, lat, lon, hours);
setHourlyForecast(forecast);
return forecast;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get hourly weather forecast';
setError(message);
throw error;
} finally {
setIsLoading(false);
}
}, []);
/**
* Get Historical Weather Data
*/
@@ -194,11 +221,13 @@ export const useExternal = () => {
weatherData,
trafficData,
weatherForecast,
hourlyForecast,
trafficForecast,
isLoading,
error,
getCurrentWeather,
getWeatherForecast,
getHourlyWeatherForecast,
getHistoricalWeather,
getCurrentTraffic,
getTrafficForecast,

View File

@@ -40,6 +40,18 @@ export interface WeatherForecast {
wind_speed?: number;
}
export interface HourlyForecast {
forecast_datetime: string;
generated_at: string;
temperature: number;
precipitation: number;
humidity: number;
wind_speed: number;
description: string;
source: string;
hour: number;
}
export class ExternalService {
/**
* Get Current Weather Data
@@ -104,6 +116,43 @@ export class ExternalService {
}
}
/**
* Get Hourly Weather Forecast (NEW)
*/
async getHourlyWeatherForecast(
tenantId: string,
lat: number,
lon: number,
hours: number = 48
): Promise<HourlyForecast[]> {
try {
console.log(`🕒 Fetching hourly weather forecast from AEMET API for tenant ${tenantId}`, {
latitude: lat,
longitude: lon,
hours: hours
});
const response = await apiClient.post(`/tenants/${tenantId}/weather/hourly-forecast`, {
latitude: lat,
longitude: lon,
hours: hours
});
// Handle response format
if (Array.isArray(response)) {
return response;
} else if (response && response.data) {
return response.data;
} else {
console.warn('Unexpected hourly forecast response format:', response);
return [];
}
} catch (error) {
console.error('Failed to fetch hourly forecast from AEMET API:', error);
throw new Error(`Hourly forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
}
}
/**
* Get Historical Weather Data
*/

View File

@@ -16,6 +16,8 @@ import {
import { useExternal } from '../../api';
import { useTenantId } from '../../hooks/useTenantId';
import type { WeatherData, TrafficData, WeatherForecast } from '../../api/services/external.service';
import type { HourlyForecast } from '../../types/weather';
import { WeatherRecommendationService, WeatherRecommendation, WeatherAnalyzer } from '../../services/weatherRecommendationService';
import Card from '../ui/Card';
interface WeatherContextProps {
@@ -28,6 +30,7 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
getCurrentWeather,
getCurrentTraffic,
getWeatherForecast,
getHourlyWeatherForecast,
isLoading,
error
} = useExternal();
@@ -35,6 +38,8 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
const [hourlyForecast, setHourlyForecast] = useState<HourlyForecast[]>([]);
const [recommendations, setRecommendations] = useState<WeatherRecommendation[]>([]);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -49,10 +54,11 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
try {
// Load current weather data from AEMET
const [weather, traffic, forecast] = await Promise.allSettled([
const [weather, traffic, forecast, hourlyData] = await Promise.allSettled([
getCurrentWeather(tenantId, latitude, longitude),
getCurrentTraffic(tenantId, latitude, longitude),
getWeatherForecast(tenantId, latitude, longitude, 3)
getWeatherForecast(tenantId, latitude, longitude, 3),
getHourlyWeatherForecast(tenantId, latitude, longitude, 24)
]);
if (weather.status === 'fulfilled') {
@@ -76,6 +82,22 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
setWeatherForecast([]);
}
if (hourlyData.status === 'fulfilled') {
setHourlyForecast(hourlyData.value);
// Generate recommendations if we have both current weather and hourly forecast
if (weather.status === 'fulfilled' && hourlyData.value.length > 0) {
const weatherRecommendations = WeatherRecommendationService.generateRecommendations(
weather.value,
hourlyData.value
);
setRecommendations(weatherRecommendations);
}
} else {
console.warn('Hourly forecast unavailable:', hourlyData.reason);
setHourlyForecast([]);
}
setLastUpdated(new Date());
} catch (error) {
console.error('Failed to load weather context:', error);
@@ -356,6 +378,105 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
</div>
)}
{/* Business Recommendations - Most Important Section */}
{recommendations.length > 0 && (
<div className="mb-6 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-bold text-blue-800 mb-4 flex items-center">
<TrendingUp className="h-5 w-5 mr-2" />
Recomendaciones Meteorológicas para el Negocio
</h4>
<div className="grid grid-cols-1 gap-3">
{recommendations.slice(0, 4).map((rec) => (
<div
key={rec.id}
className={`p-3 rounded-lg border-l-4 ${
rec.priority === 'urgent' ? 'border-red-500 bg-red-50' :
rec.priority === 'high' ? 'border-orange-500 bg-orange-50' :
rec.priority === 'medium' ? 'border-yellow-500 bg-yellow-50' :
'border-green-500 bg-green-50'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center mb-1">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
rec.type === 'production' ? 'bg-blue-100 text-blue-800' :
rec.type === 'inventory' ? 'bg-green-100 text-green-800' :
rec.type === 'marketing' ? 'bg-purple-100 text-purple-800' :
rec.type === 'sales' ? 'bg-orange-100 text-orange-800' :
'bg-gray-100 text-gray-800'
}`}>
{rec.type.toUpperCase()}
</span>
<span className={`text-xs ml-2 px-1 py-0.5 rounded ${
rec.priority === 'urgent' ? 'bg-red-200 text-red-800' :
rec.priority === 'high' ? 'bg-orange-200 text-orange-800' :
rec.priority === 'medium' ? 'bg-yellow-200 text-yellow-800' :
'bg-green-200 text-green-800'
}`}>
{rec.priority.toUpperCase()}
</span>
</div>
<h5 className="font-semibold text-sm text-gray-900 mb-1">{rec.title}</h5>
<p className="text-xs text-gray-700 mb-1">{rec.description}</p>
<p className="text-xs font-medium text-gray-800 mb-1">
<strong>Acción:</strong> {rec.action}
</p>
<p className="text-xs text-gray-600">{rec.impact}</p>
{rec.businessMetrics?.expectedSalesChange && (
<div className="mt-2 text-xs">
<span className={`font-medium ${
rec.businessMetrics.expectedSalesChange > 0 ? 'text-green-700' : 'text-red-700'
}`}>
Ventas: {rec.businessMetrics.expectedSalesChange > 0 ? '+' : ''}{rec.businessMetrics.expectedSalesChange}%
</span>
</div>
)}
</div>
<div className="text-right ml-3">
<div className="text-xs text-gray-500">{rec.timeframe}</div>
<div className="text-xs text-gray-400 mt-1">
Confianza: {rec.confidence}%
</div>
</div>
</div>
</div>
))}
</div>
<div className="mt-3 text-xs text-blue-600 flex items-center">
<CheckCircle className="h-3 w-3 mr-1" />
Recomendaciones basadas en datos meteorológicos de AEMET en tiempo real
</div>
</div>
)}
{/* Hourly Forecast Preview */}
{hourlyForecast.length > 0 && (
<div className="mb-6">
<h4 className="font-medium text-gray-800 mb-3">Pronóstico por Horas (Próximas 8 horas)</h4>
<div className="grid grid-cols-4 gap-2">
{hourlyForecast.slice(0, 8).map((hourly, index) => (
<div key={index} className="bg-gray-50 rounded-lg p-2 text-center">
<div className="text-xs text-gray-600 mb-1">
{new Date(hourly.forecast_datetime).getHours().toString().padStart(2, '0')}:00
</div>
<div className="flex justify-center mb-1">
{getWeatherIcon(hourly.description, hourly.precipitation)}
</div>
<div className="text-sm font-semibold text-gray-900">
{hourly.temperature.toFixed(0)}°
</div>
{hourly.precipitation > 0 && (
<div className="text-xs text-blue-600">
{hourly.precipitation.toFixed(1)}mm
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Weather Forecast */}
{weatherForecast.length > 0 && (
<div>

View File

@@ -0,0 +1,449 @@
// Weather-based Business Recommendations Service
import { HourlyForecast, WeatherData } from '../types/weather';
export interface WeatherRecommendation {
id: string;
type: 'inventory' | 'production' | 'marketing' | 'operations' | 'sales';
priority: 'low' | 'medium' | 'high' | 'urgent';
title: string;
description: string;
action: string;
impact: string;
confidence: number; // 0-100
timeframe: string;
weatherTrigger: {
condition: string;
value: number;
description: string;
};
businessMetrics?: {
expectedSalesChange?: number; // percentage
productDemandChange?: { product: string; change: number }[];
staffingRecommendation?: string;
};
}
export class WeatherRecommendationService {
/**
* Generate comprehensive weather-based recommendations for bakery operations
*/
static generateRecommendations(
currentWeather: WeatherData,
hourlyForecast: HourlyForecast[],
businessHours = { start: 6, end: 20 }
): WeatherRecommendation[] {
const recommendations: WeatherRecommendation[] = [];
// Analyze current conditions
recommendations.push(...this.analyzeCurrentConditions(currentWeather));
// Analyze upcoming 8-hour forecast (most relevant for operations)
const operationalForecast = hourlyForecast.slice(0, 8);
recommendations.push(...this.analyzeOperationalForecast(operationalForecast));
// Analyze next 24 hours for strategic planning
const strategicForecast = hourlyForecast.slice(0, 24);
recommendations.push(...this.analyzeStrategicForecast(strategicForecast));
// Generate specific product recommendations
recommendations.push(...this.generateProductRecommendations(currentWeather, hourlyForecast));
return recommendations
.sort((a, b) => this.getPriorityScore(b.priority) - this.getPriorityScore(a.priority))
.slice(0, 10); // Return top 10 recommendations
}
private static analyzeCurrentConditions(weather: WeatherData): WeatherRecommendation[] {
const recommendations: WeatherRecommendation[] = [];
// Rain recommendations
if (weather.precipitation && weather.precipitation > 1) {
recommendations.push({
id: 'rain-current',
type: 'operations',
priority: weather.precipitation > 5 ? 'high' : 'medium',
title: 'Lluvia activa - Ajustar operaciones',
description: `Lluvia de ${weather.precipitation.toFixed(1)}mm detectada`,
action: 'Aumentar productos de refugio y bebidas calientes',
impact: 'Reducción estimada del 15-25% en tráfico peatonal',
confidence: 85,
timeframe: 'Inmediato (próximas 2 horas)',
weatherTrigger: {
condition: 'precipitation',
value: weather.precipitation,
description: 'Precipitación activa'
},
businessMetrics: {
expectedSalesChange: -20,
productDemandChange: [
{ product: 'Café', change: 30 },
{ product: 'Bollería', change: 15 },
{ product: 'Pan tostado', change: 20 }
],
staffingRecommendation: 'Mantener personal mínimo'
}
});
}
// Cold weather recommendations
if (weather.temperature && weather.temperature < 10) {
recommendations.push({
id: 'cold-current',
type: 'production',
priority: weather.temperature < 5 ? 'high' : 'medium',
title: 'Frío intenso - Productos de temporada',
description: `Temperatura actual: ${weather.temperature.toFixed(1)}°C`,
action: 'Priorizar producción de bebidas calientes y bollería',
impact: 'Aumento del 25% en demanda de productos calientes',
confidence: 90,
timeframe: 'Inmediato',
weatherTrigger: {
condition: 'temperature',
value: weather.temperature,
description: 'Temperatura baja'
},
businessMetrics: {
expectedSalesChange: 5,
productDemandChange: [
{ product: 'Chocolate caliente', change: 40 },
{ product: 'Croissants', change: 25 },
{ product: 'Pan recién horneado', change: 30 }
]
}
});
}
// Hot weather recommendations
if (weather.temperature && weather.temperature > 25) {
recommendations.push({
id: 'hot-current',
type: 'inventory',
priority: weather.temperature > 30 ? 'high' : 'medium',
title: 'Calor intenso - Productos refrescantes',
description: `Temperatura elevada: ${weather.temperature.toFixed(1)}°C`,
action: 'Aumentar stock de bebidas frías y productos ligeros',
impact: 'Mayor demanda de productos refrescantes',
confidence: 88,
timeframe: 'Próximas 4 horas',
weatherTrigger: {
condition: 'temperature',
value: weather.temperature,
description: 'Temperatura alta'
},
businessMetrics: {
expectedSalesChange: 10,
productDemandChange: [
{ product: 'Bebidas frías', change: 45 },
{ product: 'Helados', change: 60 },
{ product: 'Ensaladas', change: 30 }
]
}
});
}
// High humidity recommendations
if (weather.humidity && weather.humidity > 85) {
recommendations.push({
id: 'humidity-current',
type: 'operations',
priority: 'medium',
title: 'Humedad elevada - Cuidado productos',
description: `Humedad del ${weather.humidity.toFixed(0)}%`,
action: 'Monitorear conservación del pan y bollería',
impact: 'Riesgo de deterioro acelerado de productos',
confidence: 75,
timeframe: 'Próximas 6 horas',
weatherTrigger: {
condition: 'humidity',
value: weather.humidity,
description: 'Humedad alta'
}
});
}
return recommendations;
}
private static analyzeOperationalForecast(forecast: HourlyForecast[]): WeatherRecommendation[] {
const recommendations: WeatherRecommendation[] = [];
// Check for incoming rain in next 8 hours
const incomingRain = forecast.find(h => h.precipitation > 1);
if (incomingRain) {
const hoursUntilRain = forecast.indexOf(incomingRain);
recommendations.push({
id: 'rain-incoming',
type: 'inventory',
priority: hoursUntilRain <= 2 ? 'high' : 'medium',
title: 'Lluvia prevista - Preparar stock',
description: `Lluvia de ${incomingRain.precipitation.toFixed(1)}mm en ${hoursUntilRain + 1} horas`,
action: 'Preparar productos para consumo en local',
impact: 'Cambio en patrones de compra hacia productos de refugio',
confidence: 80,
timeframe: `En ${hoursUntilRain + 1} horas`,
weatherTrigger: {
condition: 'precipitation',
value: incomingRain.precipitation,
description: 'Lluvia prevista'
},
businessMetrics: {
expectedSalesChange: -15,
productDemandChange: [
{ product: 'Café para llevar', change: -30 },
{ product: 'Merienda en local', change: 40 }
]
}
});
}
// Check for temperature swings
const tempChange = this.getTemperatureSwing(forecast);
if (Math.abs(tempChange) > 8) {
recommendations.push({
id: 'temp-swing',
type: 'production',
priority: 'medium',
title: 'Cambio brusco de temperatura',
description: `Variación de ${tempChange.toFixed(1)}°C en las próximas 8 horas`,
action: 'Ajustar mix de productos calientes/fríos',
impact: 'Necesidad de flexibilidad en la oferta',
confidence: 70,
timeframe: 'Próximas 8 horas',
weatherTrigger: {
condition: 'temperature_change',
value: tempChange,
description: 'Cambio térmico significativo'
}
});
}
return recommendations;
}
private static analyzeStrategicForecast(forecast: HourlyForecast[]): WeatherRecommendation[] {
const recommendations: WeatherRecommendation[] = [];
// Check for extended rain periods
const rainyHours = forecast.filter(h => h.precipitation > 0.5).length;
if (rainyHours > 12) {
recommendations.push({
id: 'extended-rain',
type: 'marketing',
priority: 'high',
title: 'Día lluvioso - Estrategia especial',
description: `${rainyHours} horas de lluvia previstas en las próximas 24h`,
action: 'Activar promociones para consumo en local y delivery',
impact: 'Oportunidad de fidelizar clientes con ofertas especiales',
confidence: 85,
timeframe: 'Próximas 24 horas',
weatherTrigger: {
condition: 'extended_rain',
value: rainyHours,
description: 'Periodo lluvioso prolongado'
},
businessMetrics: {
expectedSalesChange: -10,
staffingRecommendation: 'Reforzar personal de cocina y delivery'
}
});
}
// Check for perfect weather window
const perfectHours = forecast.filter(h =>
h.temperature >= 18 && h.temperature <= 24 &&
h.precipitation < 0.1
).length;
if (perfectHours > 16) {
recommendations.push({
id: 'perfect-weather',
type: 'marketing',
priority: 'high',
title: 'Día perfecto - Maximizar ventas',
description: `${perfectHours} horas de tiempo ideal en las próximas 24h`,
action: 'Preparar productos estrella y aumentar producción',
impact: 'Oportunidad de día de ventas excepcionales',
confidence: 90,
timeframe: 'Próximas 24 horas',
weatherTrigger: {
condition: 'perfect_weather',
value: perfectHours,
description: 'Condiciones meteorológicas ideales'
},
businessMetrics: {
expectedSalesChange: 25,
staffingRecommendation: 'Reforzar todo el personal'
}
});
}
return recommendations;
}
private static generateProductRecommendations(
current: WeatherData,
forecast: HourlyForecast[]
): WeatherRecommendation[] {
const recommendations: WeatherRecommendation[] = [];
// Morning temperature analysis for production planning
const morningForecast = forecast.slice(0, 6);
const avgMorningTemp = morningForecast.reduce((sum, h) => sum + h.temperature, 0) / morningForecast.length;
if (avgMorningTemp < 12) {
recommendations.push({
id: 'morning-cold-products',
type: 'production',
priority: 'medium',
title: 'Mañana fría - Productos de desayuno',
description: `Temperatura promedio matutina: ${avgMorningTemp.toFixed(1)}°C`,
action: 'Aumentar producción de café, tés y bollería caliente',
impact: 'Mayor demanda de productos que generen calor',
confidence: 85,
timeframe: 'Mañana (6-12h)',
weatherTrigger: {
condition: 'morning_temperature',
value: avgMorningTemp,
description: 'Mañana fría'
},
businessMetrics: {
productDemandChange: [
{ product: 'Café americano', change: 35 },
{ product: 'Té caliente', change: 45 },
{ product: 'Croissants recién hechos', change: 25 }
]
}
});
}
// Weekend special weather recommendations
const isWeekend = new Date().getDay() === 0 || new Date().getDay() === 6;
if (isWeekend && current.temperature > 20 && current.precipitation < 0.1) {
recommendations.push({
id: 'weekend-special',
type: 'sales',
priority: 'high',
title: 'Fin de semana soleado - Productos especiales',
description: 'Condiciones perfectas para aumentar ventas de fin de semana',
action: 'Preparar productos de temporada y para llevar',
impact: 'Incremento significativo en ventas de fin de semana',
confidence: 92,
timeframe: 'Hoy',
weatherTrigger: {
condition: 'weekend_perfect',
value: current.temperature,
description: 'Fin de semana con buen tiempo'
},
businessMetrics: {
expectedSalesChange: 35,
productDemandChange: [
{ product: 'Tartas y pasteles', change: 50 },
{ product: 'Sándwiches gourmet', change: 40 },
{ product: 'Bebidas premium', change: 30 }
]
}
});
}
return recommendations;
}
private static getTemperatureSwing(forecast: HourlyForecast[]): number {
const temperatures = forecast.map(h => h.temperature);
return Math.max(...temperatures) - Math.min(...temperatures);
}
private static getPriorityScore(priority: string): number {
switch (priority) {
case 'urgent': return 4;
case 'high': return 3;
case 'medium': return 2;
case 'low': return 1;
default: return 0;
}
}
}
// Weather condition analyzers
export class WeatherAnalyzer {
/**
* Analyze if weather conditions are favorable for bakery sales
*/
static isFavorableForSales(weather: WeatherData): { favorable: boolean; reason: string; confidence: number } {
if (weather.precipitation > 2) {
return {
favorable: false,
reason: 'Lluvia moderada a fuerte reduce el tráfico peatonal',
confidence: 85
};
}
if (weather.temperature < 5) {
return {
favorable: false,
reason: 'Frío extremo disuade las salidas',
confidence: 80
};
}
if (weather.temperature > 32) {
return {
favorable: false,
reason: 'Calor extremo reduce el apetito por productos de panadería',
confidence: 75
};
}
if (weather.temperature >= 18 && weather.temperature <= 25 && weather.precipitation < 0.5) {
return {
favorable: true,
reason: 'Condiciones ideales para ventas de panadería',
confidence: 90
};
}
return {
favorable: true,
reason: 'Condiciones normales para la operación',
confidence: 70
};
}
/**
* Predict customer behavior based on weather
*/
static predictCustomerBehavior(weather: WeatherData, hourlyForecast: HourlyForecast[]) {
const behavior = {
peakHours: [] as string[],
preferredProducts: [] as string[],
shoppingPattern: 'normal' as 'rush' | 'extended' | 'normal' | 'reduced',
deliveryDemand: 'normal' as 'high' | 'normal' | 'low'
};
// Rush behavior in bad weather
if (weather.precipitation > 1 || weather.temperature < 8) {
behavior.shoppingPattern = 'rush';
behavior.deliveryDemand = 'high';
behavior.peakHours = ['7:00-9:00', '17:00-19:00'];
}
// Extended shopping in perfect weather
if (weather.temperature >= 20 && weather.temperature <= 24 && weather.precipitation < 0.1) {
behavior.shoppingPattern = 'extended';
behavior.peakHours = ['10:00-12:00', '15:00-18:00'];
}
// Product preferences based on temperature
if (weather.temperature < 15) {
behavior.preferredProducts = ['Café', 'Té', 'Bollería caliente', 'Sopas'];
} else if (weather.temperature > 25) {
behavior.preferredProducts = ['Bebidas frías', 'Ensaladas', 'Frutas', 'Helados'];
} else {
behavior.preferredProducts = ['Productos variados', 'Mix equilibrado'];
}
return behavior;
}
}

View File

@@ -0,0 +1,43 @@
// Weather data types for the bakery application
export interface WeatherData {
date: string;
temperature: number;
precipitation: number;
humidity: number;
wind_speed: number;
pressure: number;
description: string;
source: 'aemet' | 'synthetic' | 'default';
}
export interface HourlyForecast {
forecast_datetime: string;
generated_at: string;
temperature: number;
precipitation: number;
humidity: number;
wind_speed: number;
description: string;
source: string;
hour: number;
}
export interface WeatherForecast {
forecast_date: string;
generated_at: string;
temperature: number;
precipitation: number;
humidity: number;
wind_speed: number;
description: string;
source: string;
}
export interface WeatherImpact {
type: 'sales' | 'operations' | 'inventory' | 'staffing';
severity: 'low' | 'medium' | 'high';
description: string;
recommendation: string;
confidence: number;
}