feat(project): Creación inicial del sistema de pronóstico del tiempo

Se establece la arquitectura completa y la funcionalidad base para la aplicación de Clima.

**Backend (.NET 9):**
- Se crea la estructura de la solución con proyectos para API, Worker, Core, Infrastructure y Database.
- Implementado un Worker Service (`SmnSyncService`) para realizar un proceso ETL programado.
- Creado un `SmnEtlFetcher` que descarga y parsea el archivo de pronóstico de 5 días del SMN.
- Configurada la persistencia en SQL Server usando Dapper para repositorios y FluentMigrator para migraciones.
- Desarrollada una API (`WeatherController`) que expone un endpoint para obtener el pronóstico por estación.
- Implementadas políticas de resiliencia con Polly para las llamadas HTTP.

**Frontend (React + Vite):**
- Creado un proyecto con Vite, React 19 y TypeScript.
- Desarrollada una capa de servicio (`weatherService`) y un cliente `apiClient` (axios) para la comunicación con el backend.
- Implementado un hook personalizado `useWeather` para encapsular la lógica de estado (carga, datos, error).
- Diseñado un `WeatherDashboard` modular inspirado en Meteored, compuesto por tres widgets reutilizables:
  - `CurrentWeatherWidget`: Muestra las condiciones actuales.
  - `HourlyForecastWidget`: Muestra el pronóstico por hora para el día completo.
  - `DailyForecastWidget`: Muestra un resumen para los próximos 5 días.
- Configurado el proxy del servidor de desarrollo de Vite para una experiencia local fluida.
This commit is contained in:
2025-07-28 13:03:07 -03:00
parent 9944fc41fd
commit a1bf0da60a
22 changed files with 5081 additions and 70 deletions

24
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { Container, Typography, CssBaseline, AppBar, Toolbar } from '@mui/material';
import { WeatherDashboard } from './components/WeatherDashboard';
function App() {
return (
<>
<CssBaseline />
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div">
Dashboard del Clima
</Typography>
</Toolbar>
</AppBar>
<Container component="main" sx={{ mt: 4, mb: 4 }}>
{/* Usamos el dashboard, que se encargará de todo lo demás */}
<WeatherDashboard stationName="LA_PLATA_AERO" />
</Container>
</>
);
}
export default App;

View File

@@ -0,0 +1,12 @@
import axios from 'axios';
// Creamos una instancia de axios. En desarrollo, las llamadas a '/api'
// serán interceptadas por el proxy de Vite. En producción, serán manejadas
// por el proxy inverso (Nginx).
const apiClient = axios.create({
headers: {
'Content-Type': 'application/json',
},
});
export default apiClient;

View File

@@ -0,0 +1,35 @@
import { Box, Paper, Typography } from '@mui/material';
import WbSunnyIcon from '@mui/icons-material/WbSunny';
import type { Pronostico } from '../types/weather';
interface Props {
data: Pronostico;
stationName: string;
}
// Placeholder: En un futuro, podrías mapear condiciones a iconos aquí
const getWeatherIcon = () => <WbSunnyIcon sx={{ fontSize: '4rem', color: 'orange' }} />;
export const CurrentWeatherWidget = ({ data, stationName }: Props) => {
return (
<Paper elevation={3} sx={{ p: 2, mb: 2 }}>
<Typography variant="h6">{stationName.replace(/_/g, ' ')}</Typography>
<Typography variant="caption" color="text.secondary">
Pronóstico para las {new Date(data.fechaHora).getHours()}:00 hs
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', my: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{getWeatherIcon()}
<Typography variant="h2" component="span" sx={{ ml: 2, fontWeight: 500 }}>
{Math.round(data.temperaturaC)}°
</Typography>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="body1">Precip: {data.precipitacionMm.toFixed(1)} mm</Typography>
<Typography variant="body1">Viento: {data.vientoKmh} km/h</Typography>
</Box>
</Box>
</Paper>
);
};

View File

@@ -0,0 +1,32 @@
import { Box, Paper, Typography, Divider } from '@mui/material';
import WbSunnyIcon from '@mui/icons-material/WbSunny';
// Creamos un tipo para los datos procesados diarios
type DailyData = {
date: Date;
minTemp: number;
maxTemp: number;
precipitacionTotal: number;
};
export const DailyForecastWidget = ({ data }: { data: DailyData[] }) => {
return (
<Paper elevation={3} sx={{ p: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>PRONÓSTICO 5 DÍAS</Typography>
{data.map((day, index) => (
<Box key={day.date.toString()}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 500, minWidth: '100px' }}>
{day.date.toLocaleDateString('es-AR', { weekday: 'long' })}
</Typography>
<WbSunnyIcon sx={{ mx: 2, color: 'orange' }} />
<Typography variant="body1" color="text.secondary">{Math.round(day.minTemp)}°</Typography>
<Box sx={{ width: '50px', height: '4px', background: 'linear-gradient(90deg, #64b5f6, #ffb74d)', mx: 1, borderRadius: '2px' }} />
<Typography variant="body1" sx={{ fontWeight: 500 }}>{Math.round(day.maxTemp)}°</Typography>
</Box>
{index < data.length - 1 && <Divider />}
</Box>
))}
</Paper>
);
};

View File

@@ -0,0 +1,24 @@
import { Box, Paper, Typography } from '@mui/material';
import WbSunnyIcon from '@mui/icons-material/WbSunny';
import type { Pronostico } from '../types/weather';
export const HourlyForecastWidget = ({ data }: { data: Pronostico[] }) => {
if (data.length === 0) return null;
return (
<Paper elevation={3} sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 2, color: 'text.secondary' }}>PRONÓSTICO POR HORA</Typography>
<Box sx={{ display: 'flex', overflowX: 'auto', gap: 2, pb: 1 }}>
{data.map(item => (
<Box key={item.id} sx={{ textAlign: 'center', minWidth: '60px' }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{new Date(item.fechaHora).getHours()}:00
</Typography>
<WbSunnyIcon sx={{ my: 1, color: 'orange' }} />
<Typography variant="h6">{Math.round(item.temperaturaC)}°</Typography>
</Box>
))}
</Box>
</Paper>
);
};

View File

@@ -0,0 +1,83 @@
import { useMemo } from 'react';
import { Box, CircularProgress, Alert } from '@mui/material';
import { useWeather } from '../hooks/useWeather';
// Importaremos los nuevos widgets que vamos a crear
import { CurrentWeatherWidget } from './CurrentWeatherWidget';
import { HourlyForecastWidget } from './HourlyForecastWidget';
import { DailyForecastWidget } from './DailyForecastWidget';
interface WeatherDashboardProps {
stationName: string;
}
/**
* Un componente "inteligente" que obtiene y procesa los datos del clima,
* y luego orquesta la renderización de los widgets visuales.
*/
export const WeatherDashboard = ({ stationName }: WeatherDashboardProps) => {
const { data, isLoading, error } = useWeather(stationName);
const processedData = useMemo(() => {
if (!data || data.length === 0) {
return null;
}
const now = new Date();
let currentIndex = data.findIndex(p => new Date(p.fechaHora) >= now);
if (currentIndex === -1) currentIndex = data.length - 1;
const currentForecast = data[currentIndex];
// --- V INICIO DE LA MODIFICACIÓN V ---
// Obtenemos el día del pronóstico "actual" para usarlo como referencia.
const currentDay = new Date(currentForecast.fechaHora).getDate();
// Ahora, filtramos TODAS las entradas que pertenezcan a ese día, sin importar la hora.
const hourlyForecast = data.filter(p => new Date(p.fechaHora).getDate() === currentDay);
// --- ^ FIN DE LA MODIFICACIÓN ^ ---
const dailyForecast = data.reduce((acc, forecast) => {
const day = new Date(forecast.fechaHora).toLocaleDateString('es-AR', { year: 'numeric', month: '2-digit', day: '2-digit' });
if (!acc[day]) {
acc[day] = {
date: new Date(forecast.fechaHora),
minTemp: forecast.temperaturaC,
maxTemp: forecast.temperaturaC,
precipitacionTotal: 0,
};
}
acc[day].minTemp = Math.min(acc[day].minTemp, forecast.temperaturaC);
acc[day].maxTemp = Math.max(acc[day].maxTemp, forecast.temperaturaC);
acc[day].precipitacionTotal += forecast.precipitacionMm;
return acc;
}, {} as Record<string, { date: Date; minTemp: number; maxTemp: number; precipitacionTotal: number }>);
return {
current: currentForecast,
hourly: hourlyForecast,
daily: Object.values(dailyForecast),
};
}, [data])
// --- Renderizado Condicional ---
if (isLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
if (!processedData) {
return <Alert severity="info">No hay datos de pronóstico disponibles.</Alert>;
}
// --- Ensamblaje de Widgets ---
return (
<Box>
{/* Aquí puedes reorganizar el orden de los widgets como quieras */}
<CurrentWeatherWidget data={processedData.current} stationName={stationName} />
<HourlyForecastWidget data={processedData.hourly} />
<DailyForecastWidget data={processedData.daily} />
</Box>
);
};

View File

@@ -0,0 +1,136 @@
import { Box, CircularProgress, Alert, Paper, Typography, Divider } from '@mui/material';
import { useWeather } from '../hooks/useWeather';
// Importamos algunos iconos para darle un toque visual
import AirIcon from '@mui/icons-material/Air';
import WaterDropIcon from '@mui/icons-material/WaterDrop';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import WbSunnyIcon from '@mui/icons-material/WbSunny';
/**
* Propiedades que el componente WeatherWidget aceptará.
*/
interface WeatherWidgetProps {
stationName: string;
}
/**
* Función de ayuda para formatear la fecha y hora.
* Ejemplo: "2025-07-25T03:00:00Z" -> "Vie 03:00"
*/
const formatForecastTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('es-AR', {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
});
};
/**
* Un widget que muestra el pronóstico del tiempo actual y futuro para una estación.
*/
export const WeatherWidget = ({ stationName }: WeatherWidgetProps) => {
// --- Usar el Hook ---
// Toda la lógica compleja de fetching y estado vive aquí.
const { data, isLoading, error } = useWeather(stationName);
// --- Renderizado Condicional ---
if (isLoading) {
return (
<Paper sx={{ p: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 150 }}>
<CircularProgress />
</Paper>
);
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
// Si no hay datos (y no es por carga o error), mostramos un mensaje.
if (!data || data.length === 0) {
return <Alert severity="info">No se encontraron datos de pronóstico para esta estación.</Alert>;
}
// 1. Obtener la fecha y hora actual.
const now = new Date();
// 2. Encontrar el índice del pronóstico más cercano en el futuro.
// 'findIndex' devuelve el índice del primer elemento que cumple la condición.
let currentIndex = data.findIndex(forecast => new Date(forecast.fechaHora) >= now);
// 3. Caso Borde: Si no se encuentra ninguno (ej. son las 22:00 y el último pronóstico era a las 21:00),
// simplemente mostramos el último disponible.
if (currentIndex === -1) {
currentIndex = data.length - 1;
}
// 4. Separar los datos basándose en el índice encontrado.
const currentForecast = data[currentIndex];
const futureForecasts = data.slice(currentIndex + 1);
// --- ^ FIN DE LA LÓGICA INTELIGENTE ^ ---
// Si después de la lógica, no tenemos un pronóstico actual, mostramos un mensaje.
if (!currentForecast) {
return <Alert severity="warning">No hay un pronóstico actual disponible. Los datos pueden estar desactualizados.</Alert>;
}
return (
<Paper elevation={3} sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<WbSunnyIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>
Pronóstico para {stationName.replace(/_/g, ' ')}
</Typography>
</Box>
{/* Panel del Pronóstico Actual */}
<Box sx={{ display: 'flex', justifyContent: 'space-around', alignItems: 'center', textAlign: 'center', my: 3 }}>
<Box>
<Typography variant="h3" component="p" sx={{ fontWeight: 500 }}>
{currentForecast.temperaturaC.toFixed(1)}°C
</Typography>
{/* Añadimos la hora del pronóstico actual para mayor claridad */}
<Typography variant="caption" color="text.secondary">
Pronóstico para las {new Date(currentForecast.fechaHora).getHours()}:00 hs
</Typography>
</Box>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<AirIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
<Typography variant="body1">{currentForecast.vientoKmh} km/h</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<WaterDropIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
<Typography variant="body1">{currentForecast.precipitacionMm} mm</Typography>
</Box>
</Box>
</Box>
<Divider sx={{ my: 2 }} />
{/* Lista del Pronóstico Futuro */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>Próximas horas:</Typography>
{futureForecasts.length > 0 ? (
futureForecasts.slice(0, 5).map(forecast => (
<Box key={forecast.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<AccessTimeIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
<Typography variant="body2">{formatForecastTime(forecast.fechaHora)}</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{forecast.temperaturaC.toFixed(1)}°C</Typography>
</Box>
))
) : (
<Typography variant="body2" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
No hay más pronósticos disponibles por hoy.
</Typography>
)}
</Box>
</Paper>
);
};

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import type { Pronostico } from '../types/weather';
import { getForecastByStation } from '../services/weatherService';
/**
* Un hook personalizado de React para obtener y gestionar el estado
* del pronóstico del tiempo para una estación específica.
*
* @param stationName El nombre de la estación a consultar.
* @returns Un objeto con `data`, `isLoading`, y `error`.
*/
export const useWeather = (stationName: string) => {
// --- 1. Definición de los Estados ---
// Estado para almacenar los datos del pronóstico. Inicialmente nulo.
const [data, setData] = useState<Pronostico[] | null>(null);
// Estado para saber si estamos esperando una respuesta de la API. Inicialmente true.
const [isLoading, setIsLoading] = useState<boolean>(true);
// Estado para almacenar cualquier mensaje de error. Inicialmente nulo.
const [error, setError] = useState<string | null>(null);
// --- 2. Lógica de Obtención de Datos ---
// useEffect se ejecuta cuando el componente se monta y cada vez que 'stationName' cambia.
useEffect(() => {
// Definimos una función asíncrona dentro del efecto para poder usar await.
const fetchForecast = async () => {
// Si no hay un nombre de estación, no hacemos nada.
if (!stationName) {
setIsLoading(false);
return;
}
// Al empezar una nueva búsqueda, reseteamos los estados.
setIsLoading(true);
setError(null);
setData(null);
try {
// Llamamos a nuestro servicio de datos.
const forecastData = await getForecastByStation(stationName);
// Si la llamada es exitosa, actualizamos el estado con los datos.
setData(forecastData);
} catch (err) {
// Si hay un error, lo guardamos en el estado de error.
setError(`No se pudo cargar el pronóstico para ${stationName}.`);
console.error(err); // También lo mostramos en consola para depuración.
} finally {
// Al terminar (ya sea con éxito o error), dejamos de cargar.
setIsLoading(false);
}
};
fetchForecast();
}, [stationName]); // El array de dependencias asegura que esto se re-ejecute si 'stationName' cambia.
// --- 3. Devolución de los Estados ---
// El hook expone sus estados para que el componente que lo usa pueda reaccionar a ellos.
return { data, isLoading, error };
};

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,28 @@
import apiClient from '../api/apiClient';
import type { Pronostico } from '../types/weather';
/**
* Obtiene el pronóstico de 5 días para una estación específica desde la API.
*
* @param stationName El nombre de la estación a consultar (ej. "LA_PLATA_AERO").
* @returns Una promesa que se resuelve con un array de objetos Pronostico.
* @throws Lanza un error si la llamada a la API falla.
*/
export const getForecastByStation = async (stationName: string): Promise<Pronostico[]> => {
try {
// Construimos la URL del endpoint.
// Ejemplo: /api/Weather/LA_PLATA_AERO
const endpoint = `/api/Weather/${encodeURIComponent(stationName)}`;
// Hacemos la llamada GET usando nuestro cliente axios.
const response = await apiClient.get<Pronostico[]>(endpoint);
// Devolvemos los datos que vienen en la respuesta.
return response.data;
} catch (error) {
// Si hay un error, lo registramos en la consola y lo relanzamos
// para que el componente que llama pueda manejarlo.
console.error(`Error al obtener el pronóstico para la estación ${stationName}:`, error);
throw error;
}
};

View File

@@ -0,0 +1,9 @@
export interface Pronostico {
id: number;
estacion: string;
fechaHora: string; // Se recibe como un string en formato ISO 8601 UTC (ej. "2025-07-25T00:00:00Z")
temperaturaC: number;
vientoDirGrados: number;
vientoKmh: number;
precipitacionMm: number;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />