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

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
};
}
}
}