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/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Widgets Clima - El Día</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4335
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0",
"@mui/material": "^7.2.0",
"axios": "^1.11.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
}
}

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" />

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Cualquier petición que el frontend haga a /api/...
'/api': {
// ...Vite la redirigirá a nuestro backend de .NET
target: 'http://localhost:5036',
changeOrigin: true,
},
}
}
})

View File

@@ -13,7 +13,8 @@ using Microsoft.Extensions.Logging;
namespace Clima.Infrastructure.DataFetchers
{
/// <summary>
/// Realiza el proceso ETL completo para los datos de pronóstico de 5 días del SMN.
/// Realiza el proceso de Extracción, Transformación y Carga (ETL) para los datos
/// del pronóstico de 5 días del Servicio Meteorológico Nacional (SMN) de Argentina.
/// </summary>
public class SmnEtlFetcher : IDataFetcher
{
@@ -34,20 +35,22 @@ namespace Clima.Infrastructure.DataFetchers
_logger = logger;
}
/// <summary>
/// Orquesta el proceso completo de ETL: descarga, parseo y guardado en la base de datos.
/// </summary>
public async Task<(bool Success, string Message)> FetchDataAsync()
{
_logger.LogInformation("Iniciando proceso ETL para {SourceName}.", SourceName);
try
{
// 1. EXTRACCIÓN (E)
_logger.LogInformation("Descargando archivo ZIP desde la fuente de datos...");
// 1. EXTRAER: Descarga el archivo ZIP desde la URL del SMN.
var zipBytes = await DownloadZipFileAsync();
if (zipBytes == null || zipBytes.Length == 0)
{
return (false, "La descarga del archivo ZIP falló o el archivo está vacío.");
}
// 2. TRANSFORMACIÓN (T)
// 2. TRANSFORMAR: Parsea el contenido del archivo .txt para convertirlo en objetos Pronostico.
_logger.LogInformation("Parseando los datos del pronóstico desde el archivo de texto...");
var pronosticosPorEstacion = ParseTxtContent(zipBytes);
if (!pronosticosPorEstacion.Any())
@@ -55,14 +58,11 @@ namespace Clima.Infrastructure.DataFetchers
return (true, "Proceso completado, pero no se encontraron datos de pronóstico válidos para procesar.");
}
// 3. CARGA (L)
// 3. CARGAR: Reemplaza los datos viejos en la base de datos con los nuevos.
_logger.LogInformation("Actualizando la base de datos con {Count} estaciones.", pronosticosPorEstacion.Count);
foreach (var kvp in pronosticosPorEstacion)
{
var estacion = kvp.Key;
var pronosticos = kvp.Value;
await _pronosticoRepository.ReemplazarPronosticosPorEstacionAsync(estacion, pronosticos);
_logger.LogInformation("Datos para la estación '{Estacion}' actualizados ({Count} registros).", estacion, pronosticos.Count);
await _pronosticoRepository.ReemplazarPronosticosPorEstacionAsync(kvp.Key, kvp.Value);
}
return (true, $"Proceso ETL completado exitosamente. Se procesaron {pronosticosPorEstacion.Count} estaciones.");
@@ -74,106 +74,157 @@ namespace Clima.Infrastructure.DataFetchers
}
}
/// <summary>
/// Descarga el archivo ZIP que contiene los datos del pronóstico.
/// </summary>
private async Task<byte[]> DownloadZipFileAsync()
{
var client = _httpClientFactory.CreateClient("SmnApiClient");
return await client.GetByteArrayAsync(DataUrl);
client.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
);
_logger.LogInformation("Descargando bytes desde {Url}...", DataUrl);
var downloadedBytes = await client.GetByteArrayAsync(DataUrl);
_logger.LogInformation("Descarga completada. Se recibieron {Length} bytes.", downloadedBytes.Length);
// Opcional: Guarda una copia local del archivo para facilitar la depuración manual.
var debugFilePath = Path.Combine(Path.GetTempPath(), "smn_download_debug.zip");
try
{
await File.WriteAllBytesAsync(debugFilePath, downloadedBytes);
_logger.LogInformation("DEBUG: El archivo descargado se ha guardado para inspección en: {Path}", debugFilePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "DEBUG: Falló al intentar guardar el archivo de depuración.");
}
return downloadedBytes;
}
/// <summary>
/// Lee el contenido de un archivo .txt dentro de un ZIP y lo transforma en un diccionario
/// de pronósticos agrupados por nombre de estación.
/// </summary>
private Dictionary<string, List<Pronostico>> ParseTxtContent(byte[] zipBytes)
{
var pronosticosAgrupados = new Dictionary<string, List<Pronostico>>();
using var memoryStream = new MemoryStream(zipBytes);
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
// Buscamos el primer archivo .txt dentro del ZIP
var txtEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
if (txtEntry == null)
{
_logger.LogWarning("No se encontró un archivo .txt dentro del ZIP descargado.");
_logger.LogWarning("No se encontró un archivo .txt en el ZIP.");
return pronosticosAgrupados;
}
_logger.LogInformation("Procesando archivo: {FileName}", txtEntry.Name);
using var stream = txtEntry.Open();
using var reader = new StreamReader(stream);
string? currentStation = null;
List<Pronostico> currentForecasts = new List<Pronostico>();
string? line;
while ((line = reader.ReadLine()) != null)
{
// Si la línea es la de separación, y estábamos procesando una estación, la guardamos.
if (line.Trim().StartsWith("===="))
{
if (currentStation != null && currentForecasts.Any())
{
pronosticosAgrupados[currentStation] = new List<Pronostico>(currentForecasts);
_logger.LogDebug("Estación '{Station}' parseada con {Count} registros.", currentStation, currentForecasts.Count);
}
currentStation = null;
currentForecasts.Clear();
continue;
}
string trimmedLine = line.Trim();
if (string.IsNullOrEmpty(trimmedLine)) continue;
// Si no estamos en una estación, buscamos la siguiente
if (currentStation == null)
{
// Una línea de nombre de estación no empieza con espacio
if (!string.IsNullOrWhiteSpace(line) && char.IsLetter(line[0]))
{
currentStation = line.Trim();
}
continue;
}
// ESTRATEGIA DE PARSEO:
// 1. Intentamos tratar la línea como un dato de pronóstico. Si tiene éxito, la procesamos y continuamos.
// 2. Si falla (lanza una excepción), significa que no era un dato. Entonces, verificamos si es un nombre de estación.
// 3. Si no es ninguna de las dos, es una cabecera o separador y simplemente la ignoramos.
// Si estamos dentro de una estación, intentamos parsear la línea de datos
try
{
// Saltamos líneas vacías o de cabecera
if (string.IsNullOrWhiteSpace(line) || !char.IsDigit(line.Trim()[0])) continue;
var pronostico = ParseForecastLine(currentStation, line);
currentForecasts.Add(pronostico);
var pronostico = ParseForecastLine(line);
if (currentStation != null)
{
pronostico.Estacion = currentStation;
pronosticosAgrupados[currentStation].Add(pronostico);
}
continue; // Línea procesada con éxito, pasamos a la siguiente.
}
catch (Exception ex)
catch (Exception)
{
_logger.LogWarning(ex, "Se omitió una línea de pronóstico inválida para la estación '{Station}': '{Line}'", currentStation, line);
// La excepción es esperada para cabeceras, separadores y nombres de estación.
// Simplemente la ignoramos y dejamos que el código continúe para ver si la línea es un nombre de estación.
}
// Verificamos si la línea es un nombre de estación.
// Regla: Contiene letras y solo se compone de mayúsculas, números, '_', '(', ')' y espacios.
if (trimmedLine.Any(char.IsLetter) && trimmedLine.All(c => char.IsUpper(c) || char.IsDigit(c) || c == '_' || c == ' ' || c == '(' || c == ')'))
{
currentStation = trimmedLine;
if (!pronosticosAgrupados.ContainsKey(currentStation))
{
pronosticosAgrupados[currentStation] = new List<Pronostico>();
}
_logger.LogDebug("Cambiando a la estación: {Station}", currentStation);
}
}
return pronosticosAgrupados;
// Filtramos estaciones que pudieron ser detectadas pero para las cuales no se encontró ningún dato válido.
var estacionesConDatos = pronosticosAgrupados.Where(kvp => kvp.Value.Any()).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
_logger.LogInformation("Parseo finalizado. Se encontraron datos para {Count} estaciones.", estacionesConDatos.Count);
return estacionesConDatos;
}
private Pronostico ParseForecastLine(string station, string line)
/// <summary>
/// Parsea una única línea del archivo de texto para extraer los datos de un pronóstico.
/// Este método es estricto y lanzará una excepción si el formato no es el esperado.
/// </summary>
/// <param name="line">La línea de texto a parsear.</param>
/// <returns>Una entidad <see cref="Pronostico"/>.</returns>
/// <exception cref="FormatException">Si la línea no contiene el formato de datos esperado.</exception>
private Pronostico ParseForecastLine(string line)
{
// Parseo de fecha y hora. Ej: "25/JUL/2025 00Hs."
var fullDateString = line.Substring(2, 18).Trim();
var datePart = fullDateString.Substring(0, 11);
var hourPart = fullDateString.Substring(12, 2);
// Creamos un string de fecha compatible: "25/JUL/2025 00"
var parsableDateString = $"{datePart} {hourPart}";
// Usamos CultureInfo para que entienda "JUL" en español
var culture = new CultureInfo("es-AR");
var fechaHora = DateTime.ParseExact(parsableDateString, "dd/MMM/yyyy HH", culture);
// El método más robusto es dividir la línea por espacios, eliminando las entradas vacías.
// Esto evita errores por pequeños desajustes en el ancho fijo de las columnas.
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
// Parseo de valores numéricos de ancho fijo
var temperatura = decimal.Parse(line.Substring(28, 10).Trim(), CultureInfo.InvariantCulture);
var vientoData = line.Substring(41, 12).Split('|', StringSplitOptions.TrimEntries);
var vientoDir = int.Parse(vientoData[0]);
var vientoVel = int.Parse(vientoData[1]);
var precipitacion = decimal.Parse(line.Substring(59, 10).Trim(), CultureInfo.InvariantCulture);
// Una línea de datos válida debe tener al menos 7 partes.
// Ejemplo: "25/JUL/2025", "00Hs.", "10.7", "51", "|", "12", "0.0"
if (parts.Length < 7)
{
throw new FormatException("La línea no tiene suficientes columnas para ser un dato de pronóstico.");
}
return new Pronostico
{
Estacion = station,
FechaHora = fechaHora,
TemperaturaC = temperatura,
VientoDirGrados = vientoDir,
VientoKmh = vientoVel,
PrecipitacionMm = precipitacion
};
// --- 1. Parseo de Fecha y Hora ---
var datePart = parts[0];
var hourPart = parts[1].Substring(0, 2); // Tomamos solo los dígitos "00" de "00Hs."
// El formato del SMN usa abreviaturas de mes en mayúsculas (JUL).
// .NET InvariantCulture, que es el estándar para parseo, espera un formato "Title Case" (Jul).
// Normalizamos el mes para que el parseo sea exitoso y no dependa del idioma del sistema.
string monthAbbr = datePart.Substring(3, 3);
string monthProperCase = monthAbbr.Substring(0, 1).ToUpper() + monthAbbr.Substring(1).ToLower();
datePart = datePart.Replace(monthAbbr, monthProperCase);
var parsableDateString = $"{datePart} {hourPart}";
var culture = CultureInfo.InvariantCulture;
var fechaHora = DateTime.ParseExact(parsableDateString, "dd/MMM/yyyy HH", culture, DateTimeStyles.AssumeUniversal).ToUniversalTime();
// --- 2. Parseo de los demás datos por su posición en el array resultante del Split ---
var temperatura = decimal.Parse(parts[2], CultureInfo.InvariantCulture);
var vientoDir = int.Parse(parts[3]);
// El separador "|" está en parts[4], por lo que la velocidad está en parts[5]
var vientoVel = int.Parse(parts[5]);
var precipitacion = decimal.Parse(parts[6], CultureInfo.InvariantCulture);
// Devolvemos el pronóstico. La estación se asignará en el método que llama a este.
return new Pronostico
{
FechaHora = fechaHora,
TemperaturaC = temperatura,
VientoDirGrados = vientoDir,
VientoKmh = vientoVel,
PrecipitacionMm = precipitacion
};
}
}
}