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:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
4335
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal 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
24
frontend/src/App.tsx
Normal 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;
|
||||||
12
frontend/src/api/apiClient.ts
Normal file
12
frontend/src/api/apiClient.ts
Normal 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;
|
||||||
35
frontend/src/components/CurrentWeatherWidget.tsx
Normal file
35
frontend/src/components/CurrentWeatherWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
frontend/src/components/DailyForecastWidget.tsx
Normal file
32
frontend/src/components/DailyForecastWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
frontend/src/components/HourlyForecastWidget.tsx
Normal file
24
frontend/src/components/HourlyForecastWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
83
frontend/src/components/WeatherDashboard.tsx
Normal file
83
frontend/src/components/WeatherDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
136
frontend/src/components/WeatherWidget.tsx
Normal file
136
frontend/src/components/WeatherWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
frontend/src/hooks/useWeather.ts
Normal file
63
frontend/src/hooks/useWeather.ts
Normal 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
9
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
28
frontend/src/services/weatherService.ts
Normal file
28
frontend/src/services/weatherService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
9
frontend/src/types/weather.ts
Normal file
9
frontend/src/types/weather.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
27
frontend/tsconfig.app.json
Normal file
27
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
frontend/tsconfig.node.json
Normal file
25
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -13,7 +13,8 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Clima.Infrastructure.DataFetchers
|
namespace Clima.Infrastructure.DataFetchers
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public class SmnEtlFetcher : IDataFetcher
|
public class SmnEtlFetcher : IDataFetcher
|
||||||
{
|
{
|
||||||
@@ -34,20 +35,22 @@ namespace Clima.Infrastructure.DataFetchers
|
|||||||
_logger = logger;
|
_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()
|
public async Task<(bool Success, string Message)> FetchDataAsync()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Iniciando proceso ETL para {SourceName}.", SourceName);
|
_logger.LogInformation("Iniciando proceso ETL para {SourceName}.", SourceName);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. EXTRACCIÓN (E)
|
// 1. EXTRAER: Descarga el archivo ZIP desde la URL del SMN.
|
||||||
_logger.LogInformation("Descargando archivo ZIP desde la fuente de datos...");
|
|
||||||
var zipBytes = await DownloadZipFileAsync();
|
var zipBytes = await DownloadZipFileAsync();
|
||||||
if (zipBytes == null || zipBytes.Length == 0)
|
if (zipBytes == null || zipBytes.Length == 0)
|
||||||
{
|
{
|
||||||
return (false, "La descarga del archivo ZIP falló o el archivo está vacío.");
|
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...");
|
_logger.LogInformation("Parseando los datos del pronóstico desde el archivo de texto...");
|
||||||
var pronosticosPorEstacion = ParseTxtContent(zipBytes);
|
var pronosticosPorEstacion = ParseTxtContent(zipBytes);
|
||||||
if (!pronosticosPorEstacion.Any())
|
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.");
|
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);
|
_logger.LogInformation("Actualizando la base de datos con {Count} estaciones.", pronosticosPorEstacion.Count);
|
||||||
foreach (var kvp in pronosticosPorEstacion)
|
foreach (var kvp in pronosticosPorEstacion)
|
||||||
{
|
{
|
||||||
var estacion = kvp.Key;
|
await _pronosticoRepository.ReemplazarPronosticosPorEstacionAsync(kvp.Key, kvp.Value);
|
||||||
var pronosticos = kvp.Value;
|
|
||||||
await _pronosticoRepository.ReemplazarPronosticosPorEstacionAsync(estacion, pronosticos);
|
|
||||||
_logger.LogInformation("Datos para la estación '{Estacion}' actualizados ({Count} registros).", estacion, pronosticos.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (true, $"Proceso ETL completado exitosamente. Se procesaron {pronosticosPorEstacion.Count} estaciones.");
|
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()
|
private async Task<byte[]> DownloadZipFileAsync()
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient("SmnApiClient");
|
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)
|
private Dictionary<string, List<Pronostico>> ParseTxtContent(byte[] zipBytes)
|
||||||
{
|
{
|
||||||
var pronosticosAgrupados = new Dictionary<string, List<Pronostico>>();
|
var pronosticosAgrupados = new Dictionary<string, List<Pronostico>>();
|
||||||
|
|
||||||
using var memoryStream = new MemoryStream(zipBytes);
|
using var memoryStream = new MemoryStream(zipBytes);
|
||||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
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));
|
var txtEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
|
||||||
if (txtEntry == null)
|
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;
|
return pronosticosAgrupados;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Procesando archivo: {FileName}", txtEntry.Name);
|
||||||
using var stream = txtEntry.Open();
|
using var stream = txtEntry.Open();
|
||||||
using var reader = new StreamReader(stream);
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
string? currentStation = null;
|
string? currentStation = null;
|
||||||
List<Pronostico> currentForecasts = new List<Pronostico>();
|
|
||||||
|
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = reader.ReadLine()) != null)
|
while ((line = reader.ReadLine()) != null)
|
||||||
{
|
{
|
||||||
// Si la línea es la de separación, y estábamos procesando una estación, la guardamos.
|
string trimmedLine = line.Trim();
|
||||||
if (line.Trim().StartsWith("===="))
|
if (string.IsNullOrEmpty(trimmedLine)) continue;
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si no estamos en una estación, buscamos la siguiente
|
// ESTRATEGIA DE PARSEO:
|
||||||
if (currentStation == null)
|
// 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.
|
||||||
// Una línea de nombre de estación no empieza con espacio
|
// 3. Si no es ninguna de las dos, es una cabecera o separador y simplemente la ignoramos.
|
||||||
if (!string.IsNullOrWhiteSpace(line) && char.IsLetter(line[0]))
|
|
||||||
{
|
|
||||||
currentStation = line.Trim();
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si estamos dentro de una estación, intentamos parsear la línea de datos
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Saltamos líneas vacías o de cabecera
|
var pronostico = ParseForecastLine(line);
|
||||||
if (string.IsNullOrWhiteSpace(line) || !char.IsDigit(line.Trim()[0])) continue;
|
|
||||||
|
if (currentStation != null)
|
||||||
var pronostico = ParseForecastLine(currentStation, line);
|
{
|
||||||
currentForecasts.Add(pronostico);
|
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."
|
// El método más robusto es dividir la línea por espacios, eliminando las entradas vacías.
|
||||||
var fullDateString = line.Substring(2, 18).Trim();
|
// Esto evita errores por pequeños desajustes en el ancho fijo de las columnas.
|
||||||
var datePart = fullDateString.Substring(0, 11);
|
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
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);
|
|
||||||
|
|
||||||
// Parseo de valores numéricos de ancho fijo
|
// Una línea de datos válida debe tener al menos 7 partes.
|
||||||
var temperatura = decimal.Parse(line.Substring(28, 10).Trim(), CultureInfo.InvariantCulture);
|
// Ejemplo: "25/JUL/2025", "00Hs.", "10.7", "51", "|", "12", "0.0"
|
||||||
var vientoData = line.Substring(41, 12).Split('|', StringSplitOptions.TrimEntries);
|
if (parts.Length < 7)
|
||||||
var vientoDir = int.Parse(vientoData[0]);
|
{
|
||||||
var vientoVel = int.Parse(vientoData[1]);
|
throw new FormatException("La línea no tiene suficientes columnas para ser un dato de pronóstico.");
|
||||||
var precipitacion = decimal.Parse(line.Substring(59, 10).Trim(), CultureInfo.InvariantCulture);
|
}
|
||||||
|
|
||||||
return new Pronostico
|
// --- 1. Parseo de Fecha y Hora ---
|
||||||
{
|
var datePart = parts[0];
|
||||||
Estacion = station,
|
var hourPart = parts[1].Substring(0, 2); // Tomamos solo los dígitos "00" de "00Hs."
|
||||||
FechaHora = fechaHora,
|
|
||||||
TemperaturaC = temperatura,
|
// El formato del SMN usa abreviaturas de mes en mayúsculas (JUL).
|
||||||
VientoDirGrados = vientoDir,
|
// .NET InvariantCulture, que es el estándar para parseo, espera un formato "Title Case" (Jul).
|
||||||
VientoKmh = vientoVel,
|
// Normalizamos el mes para que el parseo sea exitoso y no dependa del idioma del sistema.
|
||||||
PrecipitacionMm = precipitacion
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user