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,
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user