# -*- coding: utf-8 -*-

from flask import Flask, jsonify, request, Response, send_file, render_template_string
from pathlib import Path
from datetime import datetime, timedelta
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter

import csv
import glob
import io
import json
import math
import os
import re
import sqlite3
import threading
import time


# ============================================================
# CONFIGURACIÓN GENERAL
# ============================================================

BASE_DIR = Path(__file__).resolve().parent

# ============================================================

WEATHERLINK_FILE = r"C:\WeatherLink\WallEDav\download.txt"


DATABASE_FILE = str(BASE_DIR / "meteorologia_uca.db")
LOGO_FILE = str(BASE_DIR / "uca_logo.png")

SITE_TITLE = "Centro de Meteorología UCA"

UNIVERSITY_NAME = (
    'Universidad Centroamericana "José Simeón Cañas"'
)

DEPARTMENT_NAME = (
    "CEF: Departamento de Ciencias Energéticas y Fluídicas"
)

STATION_NAME = "Estación Meteorológica UCA - CEF"

STATION_MODEL = "Davis Vantage Pro2"


# Coordenadas de la estación.
# Las podemos cambiar después si fuese necesario.

STATION_LAT = 13.67968
STATION_LON = -89.23559


# Cada cuánto revisará Python si WeatherLink modificó download.txt
IMPORT_CHECK_SECONDS = 30

# Si el último dato tiene más de 30 minutos,
# la web indicará que los datos están retrasados.
ONLINE_THRESHOLD_MINUTES = 30

PORT = 5000


# ============================================================
# LOS 38 CAMPOS DEL ARCHIVO WEATHERLINK
# ============================================================

COLUMNS = [

    {
        "key": "record_date",
        "label": "Fecha",
        "unit": "",
        "type": "TEXT",
        "group": "Tiempo"
    },

    {
        "key": "record_time",
        "label": "Hora",
        "unit": "",
        "type": "TEXT",
        "group": "Tiempo"
    },

    {
        "key": "temp_out",
        "label": "Temperatura exterior",
        "unit": "°C",
        "type": "REAL",
        "group": "Temperatura"
    },

    {
        "key": "hi_temp",
        "label": "Temperatura máxima",
        "unit": "°C",
        "type": "REAL",
        "group": "Temperatura"
    },

    {
        "key": "low_temp",
        "label": "Temperatura mínima",
        "unit": "°C",
        "type": "REAL",
        "group": "Temperatura"
    },

    {
        "key": "out_hum",
        "label": "Humedad exterior",
        "unit": "%",
        "type": "REAL",
        "group": "Humedad"
    },

    {
        "key": "dew_pt",
        "label": "Punto de rocío exterior",
        "unit": "°C",
        "type": "REAL",
        "group": "Temperatura"
    },

    {
        "key": "wind_speed",
        "label": "Velocidad del viento",
        "unit": "m/s",
        "type": "REAL",
        "group": "Viento"
    },

    {
        "key": "wind_dir",
        "label": "Dirección del viento",
        "unit": "",
        "type": "TEXT",
        "group": "Viento"
    },

    {
        "key": "wind_run",
        "label": "Recorrido del viento",
        "unit": "km",
        "type": "REAL",
        "group": "Viento"
    },

    {
        "key": "hi_speed",
        "label": "Ráfaga máxima",
        "unit": "m/s",
        "type": "REAL",
        "group": "Viento"
    },

    {
        "key": "hi_dir",
        "label": "Dirección de ráfaga máxima",
        "unit": "",
        "type": "TEXT",
        "group": "Viento"
    },

    {
        "key": "wind_chill",
        "label": "Sensación térmica",
        "unit": "°C",
        "type": "REAL",
        "group": "Confort térmico"
    },

    {
        "key": "heat_index",
        "label": "Índice de calor",
        "unit": "°C",
        "type": "REAL",
        "group": "Confort térmico"
    },

    {
        "key": "thw_index",
        "label": "Índice THW",
        "unit": "°C",
        "type": "REAL",
        "group": "Confort térmico"
    },

    {
        "key": "thsw_index",
        "label": "Índice THSW",
        "unit": "°C",
        "type": "REAL",
        "group": "Confort térmico"
    },

    {
        "key": "bar",
        "label": "Presión barométrica",
        "unit": "hPa",
        "type": "REAL",
        "group": "Atmósfera"
    },

    {
        "key": "rain",
        "label": "Precipitación",
        "unit": "mm",
        "type": "REAL",
        "group": "Precipitación"
    },

    {
        "key": "rain_rate",
        "label": "Intensidad de lluvia",
        "unit": "mm/h",
        "type": "REAL",
        "group": "Precipitación"
    },

    {
        "key": "solar_rad",
        "label": "Radiación solar",
        "unit": "W/m²",
        "type": "REAL",
        "group": "Radiación"
    },

    {
        "key": "solar_energy",
        "label": "Energía solar",
        "unit": "Ly",
        "type": "REAL",
        "group": "Radiación"
    },

    {
        "key": "hi_solar_rad",
        "label": "Radiación solar máxima",
        "unit": "W/m²",
        "type": "REAL",
        "group": "Radiación"
    },

    {
        "key": "uv_index",
        "label": "Índice UV",
        "unit": "",
        "type": "REAL",
        "group": "Radiación UV"
    },

    {
        "key": "uv_dose",
        "label": "Dosis UV",
        "unit": "",
        "type": "REAL",
        "group": "Radiación UV"
    },

    {
        "key": "hi_uv",
        "label": "Índice UV máximo",
        "unit": "",
        "type": "REAL",
        "group": "Radiación UV"
    },

    {
        "key": "heat_dd",
        "label": "Grados-día calefacción",
        "unit": "°D",
        "type": "REAL",
        "group": "Grados-día"
    },

    {
        "key": "cool_dd",
        "label": "Grados-día refrigeración",
        "unit": "°D",
        "type": "REAL",
        "group": "Grados-día"
    },

    {
        "key": "in_temp",
        "label": "Temperatura interior",
        "unit": "°C",
        "type": "REAL",
        "group": "Interior"
    },

    {
        "key": "in_hum",
        "label": "Humedad interior",
        "unit": "%",
        "type": "REAL",
        "group": "Interior"
    },

    {
        "key": "in_dew",
        "label": "Punto de rocío interior",
        "unit": "°C",
        "type": "REAL",
        "group": "Interior"
    },

    {
        "key": "in_heat",
        "label": "Índice de calor interior",
        "unit": "°C",
        "type": "REAL",
        "group": "Interior"
    },

    {
        "key": "in_emc",
        "label": "EMC interior",
        "unit": "%",
        "type": "REAL",
        "group": "Interior"
    },

    {
        "key": "in_air_density",
        "label": "Densidad del aire interior",
        "unit": "kg/m³",
        "type": "REAL",
        "group": "Interior"
    },

    {
        "key": "et",
        "label": "Evapotranspiración",
        "unit": "mm",
        "type": "REAL",
        "group": "Precipitación"
    },

    {
        "key": "wind_samp",
        "label": "Muestras de viento",
        "unit": "",
        "type": "REAL",
        "group": "Diagnóstico"
    },

    {
        "key": "wind_tx",
        "label": "Transmisor",
        "unit": "",
        "type": "REAL",
        "group": "Diagnóstico"
    },

    {
        "key": "iss_recept",
        "label": "Recepción ISS",
        "unit": "%",
        "type": "REAL",
        "group": "Diagnóstico"
    },

    {
        "key": "arc_int",
        "label": "Intervalo de archivo",
        "unit": "min",
        "type": "REAL",
        "group": "Diagnóstico"
    },

]


COLUMN_KEYS = [column["key"] for column in COLUMNS]

NUMERIC_KEYS = {
    column["key"]
    for column in COLUMNS
    if column["type"] == "REAL"
}


# ============================================================
# VARIABLES PARA LAS GRÁFICAS
# ============================================================

CHART_METRICS = {

    "temp_out": {
        "label": "Temperatura exterior",
        "unit": "°C"
    },

    "hi_temp": {
        "label": "Temperatura máxima",
        "unit": "°C"
    },

    "low_temp": {
        "label": "Temperatura mínima",
        "unit": "°C"
    },

    "out_hum": {
        "label": "Humedad exterior",
        "unit": "%"
    },

    "dew_pt": {
        "label": "Punto de rocío",
        "unit": "°C"
    },

    "wind_speed": {
        "label": "Velocidad del viento",
        "unit": "m/s"
    },

    "hi_speed": {
        "label": "Ráfaga máxima",
        "unit": "m/s"
    },

    "wind_run": {
        "label": "Recorrido del viento",
        "unit": "km"
    },

    "wind_chill": {
        "label": "Sensación térmica",
        "unit": "°C"
    },

    "heat_index": {
        "label": "Índice de calor",
        "unit": "°C"
    },

    "thw_index": {
        "label": "Índice THW",
        "unit": "°C"
    },

    "thsw_index": {
        "label": "Índice THSW",
        "unit": "°C"
    },

    "bar": {
        "label": "Presión barométrica",
        "unit": "hPa"
    },

    "rain": {
        "label": "Precipitación",
        "unit": "mm"
    },

    "rain_rate": {
        "label": "Intensidad de lluvia",
        "unit": "mm/h"
    },

    "solar_rad": {
        "label": "Radiación solar",
        "unit": "W/m²"
    },

    "solar_energy": {
        "label": "Energía solar",
        "unit": "Ly"
    },

    "hi_solar_rad": {
        "label": "Radiación solar máxima",
        "unit": "W/m²"
    },

    "uv_index": {
        "label": "Índice UV",
        "unit": ""
    },

    "uv_dose": {
        "label": "Dosis UV",
        "unit": ""
    },

    "hi_uv": {
        "label": "Índice UV máximo",
        "unit": ""
    },

    "in_temp": {
        "label": "Temperatura interior",
        "unit": "°C"
    },

    "in_hum": {
        "label": "Humedad interior",
        "unit": "%"
    },

    "in_dew": {
        "label": "Punto de rocío interior",
        "unit": "°C"
    },

    "in_heat": {
        "label": "Índice de calor interior",
        "unit": "°C"
    },

    "in_emc": {
        "label": "EMC interior",
        "unit": "%"
    },

    "in_air_density": {
        "label": "Densidad del aire",
        "unit": "kg/m³"
    },

    "et": {
        "label": "Evapotranspiración",
        "unit": "mm"
    },

    "iss_recept": {
        "label": "Recepción ISS",
        "unit": "%"
    },
}


# ============================================================
# BASE DE DATOS SQLITE
# ============================================================

def db_connect():

    connection = sqlite3.connect(
        DATABASE_FILE,
        timeout=30
    )

    connection.row_factory = sqlite3.Row

    return connection


def init_database():

    definitions = []

    for column in COLUMNS:

        definitions.append(
            f'"{column["key"]}" {column["type"]}'
        )

    sql = f"""
        CREATE TABLE IF NOT EXISTS observations (

            timestamp TEXT PRIMARY KEY,

            {", ".join(definitions)},

            imported_at TEXT NOT NULL
        )
    """

    with db_connect() as connection:

        connection.execute(sql)

        connection.execute(
            """
            CREATE INDEX IF NOT EXISTS
            idx_observations_timestamp
            ON observations(timestamp)
            """
        )

        connection.commit()


# ============================================================
# LOCALIZAR DOWNLOAD.TXT
# ============================================================

def resolve_weatherlink_file():

    if WEATHERLINK_FILE.strip():

        return os.path.expandvars(
            WEATHERLINK_FILE.strip()
        )

    candidates = glob.glob(
        r"C:\weatherlink\**\download.txt",
        recursive=True
    )

    if not candidates:

        return ""

    candidates.sort(
        key=lambda path: os.path.getmtime(path),
        reverse=True
    )

    return candidates[0]


# ============================================================
# CONVERSIÓN DE DATOS
# ============================================================

def to_number(value):

    if value is None:

        return None

    value = value.strip()

    if value in {
        "",
        "--",
        "---",
        "N/A"
    }:

        return None

    try:

        return float(value)

    except ValueError:

        return None


DATA_LINE_PATTERN = re.compile(
    r"^\s*\d{1,2}/\d{1,2}/\d{2}\s+\d{1,2}:\d{2}\s+"
)


# ============================================================
# LEER UNA FILA DE WEATHERLINK
# ============================================================

def parse_weatherlink_line(line):

    parts = line.strip().split()

    if len(parts) < len(COLUMNS):

        return None

    parts = parts[:len(COLUMNS)]

    record = {}

    for index, column in enumerate(COLUMNS):

        raw_value = parts[index]

        if column["key"] in NUMERIC_KEYS:

            record[column["key"]] = to_number(
                raw_value
            )

        else:

            record[column["key"]] = raw_value

    try:

        date_time = datetime.strptime(

            (
                record["record_date"]
                + " "
                + record["record_time"]
            ),

            "%d/%m/%y %H:%M"
        )

    except ValueError:

        return None

    record["timestamp"] = date_time.strftime(
        "%Y-%m-%d %H:%M:%S"
    )

    return record


# ============================================================
# LEER DOWNLOAD.TXT
# ============================================================

def read_weatherlink_records(path):

    encodings = [
        "utf-8-sig",
        "cp1252",
        "latin-1"
    ]

    text = None

    for encoding in encodings:

        try:

            with open(
                path,
                "r",
                encoding=encoding
            ) as file:

                text = file.read()

            break

        except UnicodeDecodeError:

            continue

    if text is None:

        raise RuntimeError(
            "No se pudo leer download.txt"
        )

    records = []

    for line in text.splitlines():

        if not DATA_LINE_PATTERN.match(line):

            continue

        record = parse_weatherlink_line(line)

        if record:

            records.append(record)

    return records


# ============================================================
# IMPORTACIÓN A SQLITE
# ============================================================

LAST_IMPORT = {

    "ok": False,
    "message": "Todavía no se ha importado.",
    "source": "",
    "records": 0,
    "last_run": None
}


IMPORT_LOCK = threading.Lock()


def import_weatherlink():

    global LAST_IMPORT

    with IMPORT_LOCK:

        path = resolve_weatherlink_file()

        current_time = datetime.now().strftime(
            "%Y-%m-%d %H:%M:%S"
        )

        if not path:

            LAST_IMPORT = {

                "ok": False,

                "message":
                    "No se encontró download.txt.",

                "source": "",

                "records": 0,

                "last_run": current_time
            }

            return LAST_IMPORT

        if not os.path.isfile(path):

            LAST_IMPORT = {

                "ok": False,

                "message":
                    "La ruta de download.txt no existe.",

                "source": path,

                "records": 0,

                "last_run": current_time
            }

            return LAST_IMPORT

        try:

            records = read_weatherlink_records(
                path
            )

            if not records:

                LAST_IMPORT = {

                    "ok": False,

                    "message":
                        "No se encontraron registros válidos.",

                    "source": path,

                    "records": 0,

                    "last_run": current_time
                }

                return LAST_IMPORT

            database_columns = (
                ["timestamp"]
                + COLUMN_KEYS
                + ["imported_at"]
            )

            placeholders = ",".join(
                ["?"] * len(database_columns)
            )

            column_names = ",".join(
                f'"{column}"'
                for column in database_columns
            )

            sql = f"""
                INSERT OR REPLACE
                INTO observations
                ({column_names})
                VALUES ({placeholders})
            """

            rows = []

            for record in records:

                values = [
                    record["timestamp"]
                ]

                values.extend(
                    record.get(key)
                    for key in COLUMN_KEYS
                )

                values.append(current_time)

                rows.append(values)

            with db_connect() as connection:

                connection.executemany(
                    sql,
                    rows
                )

                connection.commit()

            LAST_IMPORT = {

                "ok": True,

                "message":
                    "Datos importados correctamente.",

                "source": path,

                "records": len(records),

                "last_run": current_time
            }

            return LAST_IMPORT

        except PermissionError:

            LAST_IMPORT = {

                "ok": False,

                "message":
                    "WeatherLink está escribiendo el archivo. "
                    "Se volverá a intentar.",

                "source": path,

                "records": 0,

                "last_run": current_time
            }

            return LAST_IMPORT

        except Exception as error:

            LAST_IMPORT = {

                "ok": False,

                "message": str(error),

                "source": path,

                "records": 0,

                "last_run": current_time
            }

            return LAST_IMPORT


# ============================================================
# IMPORTADOR AUTOMÁTICO
# ============================================================

def automatic_import_loop():

    while True:

        import_weatherlink()

        time.sleep(
            IMPORT_CHECK_SECONDS
        )


# ============================================================
# OBTENER ÚLTIMO REGISTRO
# ============================================================

def get_latest():

    with db_connect() as connection:

        row = connection.execute(
            """
            SELECT *
            FROM observations
            ORDER BY timestamp DESC
            LIMIT 1
            """
        ).fetchone()

    if row is None:

        return None

    data = dict(row)

    if data["wind_speed"] is not None:

        data["wind_speed_kmh"] = round(
            data["wind_speed"] * 3.6,
            1
        )

    else:

        data["wind_speed_kmh"] = None

    if data["hi_speed"] is not None:

        data["hi_speed_kmh"] = round(
            data["hi_speed"] * 3.6,
            1
        )

    else:

        data["hi_speed_kmh"] = None

    try:

        record_time = datetime.strptime(
            data["timestamp"],
            "%Y-%m-%d %H:%M:%S"
        )

        age = (
            datetime.now() - record_time
        ).total_seconds() / 60

        data["age_minutes"] = round(
            age,
            1
        )

        data["online"] = (
            age <= ONLINE_THRESHOLD_MINUTES
        )

    except Exception:

        data["online"] = False

    return data


# ============================================================
# RESUMEN DEL DÍA
# ============================================================

def get_summary():

    latest = get_latest()

    if not latest:

        return {}

    date = latest["timestamp"][0:10]

    month = latest["timestamp"][0:7]

    year = latest["timestamp"][0:4]

    with db_connect() as connection:

        day = connection.execute(
            """
            SELECT

                MAX(hi_temp) AS temp_max,

                MIN(low_temp) AS temp_min,

                AVG(out_hum) AS humidity_avg,

                MAX(hi_speed) AS gust_max,

                MAX(hi_uv) AS uv_max,

                MAX(hi_solar_rad) AS solar_max,

                SUM(COALESCE(rain, 0))
                    AS rain_day,

                SUM(COALESCE(et, 0))
                    AS et_day

            FROM observations

            WHERE substr(timestamp,1,10) = ?
            """,
            (date,)
        ).fetchone()

        rain_month = connection.execute(
            """
            SELECT
                SUM(COALESCE(rain,0))
            FROM observations
            WHERE substr(timestamp,1,7) = ?
            """,
            (month,)
        ).fetchone()[0]

        rain_year = connection.execute(
            """
            SELECT
                SUM(COALESCE(rain,0))
            FROM observations
            WHERE substr(timestamp,1,4) = ?
            """,
            (year,)
        ).fetchone()[0]

        total_records = connection.execute(
            """
            SELECT COUNT(*)
            FROM observations
            """
        ).fetchone()[0]

    result = dict(day)

    result["rain_month"] = (
        rain_month or 0
    )

    result["rain_year"] = (
        rain_year or 0
    )

    result["total_records"] = (
        total_records
    )

    if result["gust_max"] is not None:

        result["gust_max_kmh"] = round(
            result["gust_max"] * 3.6,
            1
        )

    else:

        result["gust_max_kmh"] = None

    return result


# ============================================================
# FILTROS DE FECHA
# ============================================================

def build_date_filter(
    start_date,
    end_date
):

    clauses = []

    params = []

    if start_date:

        clauses.append(
            "timestamp >= ?"
        )

        params.append(
            start_date + " 00:00:00"
        )

    if end_date:

        clauses.append(
            "timestamp <= ?"
        )

        params.append(
            end_date + " 23:59:59"
        )

    if clauses:

        return (
            " WHERE "
            + " AND ".join(clauses),
            params
        )

    return "", []


# ============================================================
# FLASK
# ============================================================

app = Flask(__name__)


# ============================================================
# API - ÚLTIMO REGISTRO
# ============================================================

@app.route("/api/latest")
def api_latest():

    data = get_latest()

    if not data:

        return jsonify({

            "ok": False,

            "message":
                "Todavía no existen datos."

        }), 404

    return jsonify({

        "ok": True,

        "data": data

    })


# ============================================================
# API - RESUMEN
# ============================================================

@app.route("/api/summary")
def api_summary():

    return jsonify({

        "ok": True,

        "data": get_summary()

    })


# ============================================================
# API - ESTADO
# ============================================================

@app.route("/api/status")
def api_status():

    with db_connect() as connection:

        count = connection.execute(
            """
            SELECT COUNT(*)
            FROM observations
            """
        ).fetchone()[0]

        first = connection.execute(
            """
            SELECT MIN(timestamp)
            FROM observations
            """
        ).fetchone()[0]

        last = connection.execute(
            """
            SELECT MAX(timestamp)
            FROM observations
            """
        ).fetchone()[0]

    return jsonify({

        "ok": True,

        "import": LAST_IMPORT,

        "database": {

            "records": count,

            "first": first,

            "last": last

        }

    })


# ============================================================
# API - GRÁFICAS
# ============================================================

@app.route("/api/history")
def api_history():

    metric = request.args.get(
        "metric",
        "temp_out"
    )

    span = request.args.get(
        "span",
        "24h"
    )

    if metric not in CHART_METRICS:

        return jsonify({

            "ok": False,

            "message":
                "Variable no permitida."

        }), 400

    latest = get_latest()

    if not latest:

        return jsonify({

            "ok": True,

            "points": [],

            "meta":
                CHART_METRICS[metric]

        })

    end = datetime.strptime(
        latest["timestamp"],
        "%Y-%m-%d %H:%M:%S"
    )

    if span == "7d":

        start = end - timedelta(
            days=7
        )

    elif span == "30d":

        start = end - timedelta(
            days=30
        )

    elif span == "365d":

        start = end - timedelta(
            days=365
        )

    else:

        start = end - timedelta(
            hours=24
        )

    sql = f"""
        SELECT
            timestamp,
            "{metric}" AS value

        FROM observations

        WHERE
            timestamp >= ?
            AND timestamp <= ?
            AND "{metric}" IS NOT NULL

        ORDER BY timestamp ASC
    """

    with db_connect() as connection:

        rows = connection.execute(
            sql,
            (
                start.strftime(
                    "%Y-%m-%d %H:%M:%S"
                ),

                end.strftime(
                    "%Y-%m-%d %H:%M:%S"
                )
            )
        ).fetchall()

    points = [

        {

            "timestamp":
                row["timestamp"],

            "value":
                row["value"]

        }

        for row in rows
    ]

    # Evita mandar demasiados puntos al navegador.

    if len(points) > 1500:

        step = math.ceil(
            len(points) / 1500
        )

        points = points[::step]

    return jsonify({

        "ok": True,

        "meta":
            CHART_METRICS[metric],

        "points":
            points

    })


# ============================================================
# API - TABLA
# ============================================================

@app.route("/api/records")
def api_records():

    try:

        page = max(
            1,
            int(
                request.args.get(
                    "page",
                    1
                )
            )
        )

    except ValueError:

        page = 1

    try:

        per_page = int(
            request.args.get(
                "per_page",
                100
            )
        )

    except ValueError:

        per_page = 100

    per_page = min(
        max(per_page, 10),
        300
    )

    start_date = request.args.get(
        "desde"
    )

    end_date = request.args.get(
        "hasta"
    )

    where, params = build_date_filter(
        start_date,
        end_date
    )

    offset = (
        page - 1
    ) * per_page

    with db_connect() as connection:

        total = connection.execute(
            """
            SELECT COUNT(*)
            FROM observations
            """
            + where,
            params
        ).fetchone()[0]

        rows = connection.execute(
            """
            SELECT *
            FROM observations
            """
            + where
            + """
            ORDER BY timestamp DESC
            LIMIT ?
            OFFSET ?
            """,
            params
            + [
                per_page,
                offset
            ]
        ).fetchall()

    total_pages = (
        math.ceil(total / per_page)
        if total
        else 1
    )

    return jsonify({

        "ok": True,

        "page": page,

        "pages":
            total_pages,

        "total":
            total,

        "records": [
            dict(row)
            for row in rows
        ]

    })


# ============================================================
# EXPORTACIÓN
# ============================================================

def export_rows():

    start_date = request.args.get(
        "desde"
    )

    end_date = request.args.get(
        "hasta"
    )

    where, params = build_date_filter(
        start_date,
        end_date
    )

    with db_connect() as connection:

        rows = connection.execute(
            """
            SELECT *
            FROM observations
            """
            + where
            + """
            ORDER BY timestamp ASC
            """,
            params
        ).fetchall()

    return [
        dict(row)
        for row in rows
    ]


# ============================================================
# DESCARGAR EXCEL
# ============================================================

@app.route("/descargar.xlsx")
def download_excel():

    rows = export_rows()

    workbook = Workbook()

    sheet = workbook.active

    sheet.title = (
        "Datos meteorológicos"
    )

    headers = (
        ["Fecha y hora"]
        + [
            column["label"]
            for column in COLUMNS
        ]
    )

    units = (
        [""]
        + [
            column["unit"]
            for column in COLUMNS
        ]
    )

    sheet.append(headers)

    sheet.append(units)

    blue_fill = PatternFill(
        "solid",
        fgColor="173A56"
    )

    secondary_fill = PatternFill(
        "solid",
        fgColor="E7EDF1"
    )

    white_font = Font(
        color="FFFFFF",
        bold=True
    )

    for cell in sheet[1]:

        cell.fill = blue_fill

        cell.font = white_font

        cell.alignment = Alignment(
            horizontal="center"
        )

    for cell in sheet[2]:

        cell.fill = secondary_fill

        cell.font = Font(
            italic=True
        )

    for row in rows:

        values = [
            row["timestamp"]
        ]

        values.extend(
            row.get(column["key"])
            for column in COLUMNS
        )

        sheet.append(values)

    sheet.freeze_panes = "A3"

    sheet.auto_filter.ref = (
        f"A1:"
        f"{get_column_letter(len(headers))}"
        f"{sheet.max_row}"
    )

    for index, header in enumerate(
        headers,
        start=1
    ):

        sheet.column_dimensions[
            get_column_letter(index)
        ].width = min(
            max(len(header) + 2, 12),
            30
        )

    # ========================================================
    # HOJA DE METADATOS
    # ========================================================

    metadata = workbook.create_sheet(
        "Metadatos"
    )

    metadata.append([
        "Centro",
        SITE_TITLE
    ])

    metadata.append([
        "Universidad",
        UNIVERSITY_NAME
    ])

    metadata.append([
        "Departamento",
        DEPARTMENT_NAME
    ])

    metadata.append([
        "Estación",
        STATION_NAME
    ])

    metadata.append([
        "Modelo",
        STATION_MODEL
    ])

    metadata.append([
        "Latitud",
        STATION_LAT
    ])

    metadata.append([
        "Longitud",
        STATION_LON
    ])

    metadata.append([])

    metadata.append([
        "Variable",
        "Unidad",
        "Grupo",
        "Nombre interno"
    ])

    for column in COLUMNS:

        metadata.append([

            column["label"],

            column["unit"],

            column["group"],

            column["key"]

        ])

    for cell in metadata[9]:

        cell.fill = blue_fill

        cell.font = white_font

    output = io.BytesIO()

    workbook.save(output)

    output.seek(0)

    filename = (
        "meteorologia_uca_"
        + datetime.now().strftime(
            "%Y%m%d_%H%M"
        )
        + ".xlsx"
    )

    return send_file(

        output,

        as_attachment=True,

        download_name=filename,

        mimetype=(
            "application/vnd.openxmlformats-"
            "officedocument.spreadsheetml.sheet"
        )

    )


# ============================================================
# DESCARGAR CSV
# ============================================================

@app.route("/descargar.csv")
def download_csv():

    rows = export_rows()

    output = io.StringIO()

    writer = csv.writer(
        output,
        delimiter=";"
    )

    writer.writerow(
        ["Fecha y hora"]
        + [
            column["label"]
            for column in COLUMNS
        ]
    )

    for row in rows:

        writer.writerow(

            [row["timestamp"]]

            + [

                row.get(
                    column["key"]
                )

                for column in COLUMNS

            ]

        )

    content = (
        "\ufeff"
        + output.getvalue()
    ).encode("utf-8")

    filename = (
        "meteorologia_uca_"
        + datetime.now().strftime(
            "%Y%m%d_%H%M"
        )
        + ".csv"
    )

    return Response(

        content,

        mimetype=(
            "text/csv; charset=utf-8"
        ),

        headers={

            "Content-Disposition":
                f'attachment; filename="{filename}"'

        }

    )


# ============================================================
# LOGO
# ============================================================

@app.route("/logo")
def logo():

    if os.path.isfile(
        LOGO_FILE
    ):

        return send_file(
            LOGO_FILE
        )

    # Logo temporal si todavía
    # no has colocado uca_logo.png.

    svg = """
    <svg
        xmlns="http://www.w3.org/2000/svg"
        width="190"
        height="70"
        viewBox="0 0 190 70">

        <rect
            width="190"
            height="70"
            fill="#173A56"/>

        <text
            x="20"
            y="46"
            font-family="Arial"
            font-size="36"
            font-weight="bold"
            fill="white">
            UCA
        </text>

        <text
            x="100"
            y="31"
            font-family="Arial"
            font-size="11"
            fill="white">
            Centro de
        </text>

        <text
            x="100"
            y="45"
            font-family="Arial"
            font-size="11"
            fill="white">
            Meteorología
        </text>

    </svg>
    """

    return Response(
        svg,
        mimetype="image/svg+xml"
    )


# ============================================================
# INTERFAZ WEB
# ============================================================

HTML = """
<!DOCTYPE html>
<html lang="es">

<head>

<meta charset="UTF-8">

<meta
    name="viewport"
    content="width=device-width, initial-scale=1">

<title>{{ site_title }}</title>


<!-- =========================================================
     CHART.JS
     ========================================================= -->

<script
src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js">
</script>


<!-- =========================================================
     LEAFLET
     ========================================================= -->

<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">

<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js">
</script>


<style>


/* =========================================================
   IDENTIDAD VISUAL
   ========================================================= */

:root {

    --uca-navy: #123B56;

    --uca-navy-dark: #0C2C41;

    --uca-blue: #206889;

    --uca-blue-light: #DDEBF1;

    --uca-gold: #B9914D;

    --uca-red: #923B41;

    --uca-green: #2F7960;

    --page-bg: #F2F5F6;

    --surface: #FFFFFF;

    --surface-soft: #F8FAFB;

    --text: #24323A;

    --muted: #6D7B83;

    --border: #D8E1E5;

    --border-light: #E9EEF0;

    --success: #26835F;

    --warning: #A26B1B;

    --danger: #A53F46;

    --shadow:
        0 6px 22px
        rgba(18, 59, 86, .07);

}


/* =========================================================
   GENERAL
   ========================================================= */

* {

    box-sizing: border-box;

}


html {

    scroll-behavior: smooth;

}


body {

    margin: 0;

    background: var(--page-bg);

    color: var(--text);

    font-family:
        "Segoe UI",
        Arial,
        Helvetica,
        sans-serif;

}


button,
input,
select {

    font-family: inherit;

}


button {

    transition:
        background .2s,
        color .2s,
        border-color .2s,
        transform .15s;

}


button:hover {

    transform: translateY(-1px);

}


/* =========================================================
   CABECERA INSTITUCIONAL
   ========================================================= */

.site-header {

    position: sticky;

    top: 0;

    z-index: 1000;

    background:
        var(--uca-navy);

    color: white;

    box-shadow:
        0 2px 12px
        rgba(0,0,0,.18);

}

.header-top {
    min-height: 68px;
    display: flex;
    align-items: stretch;
}


.brand {
    display: flex;
    align-items: center;
    gap: 13px;
    padding: 8px 18px;
    flex: 1;
    min-width: 0;
}



.brand-logo {
    width: 88px;
    height: 48px;
    object-fit: contain;
    background: white;
    padding: 4px 7px;
    border-radius: 2px;
}



.brand-text {

    min-width: 0;

}


.brand-title {

    margin: 0;

    font-family:
        Georgia,
        "Times New Roman",
        serif;

    font-size: 23px;

    line-height: 1.1;

    font-weight: 500;

    letter-spacing: -.2px;

}


.brand-university {
    margin-top: 3px;
    font-size: 10px;
    color: rgba(255,255,255,.88);
}


.brand-department {
    margin-top: 1px;
    font-size: 10px;
    color: rgba(255,255,255,.70);
}

.header-status {
    width: 245px;
    padding: 8px 16px;
    border-left: 1px solid rgba(255,255,255,.15);
    display: flex;
    flex-direction: column;
    justify-content: center;
}


.status-main {

    display: flex;

    align-items: center;

    gap: 9px;

    font-size: 13px;

}


.status-dot {

    width: 10px;

    height: 10px;

    border-radius: 50%;

    background: #9DA8AE;

    box-shadow:
        0 0 0 3px
        rgba(255,255,255,.10);

}


.status-dot.online {

    background: #52C994;

}


.status-dot.offline {

    background: #E36D72;

}


#last-update {

    margin-top: 5px;

    font-size: 11px;

    color:
        rgba(255,255,255,.72);

}


/* =========================================================
   NAVEGACIÓN
   ========================================================= */

.main-nav {
    height: 44px;
    display: flex;
    align-items: stretch;
    background: var(--uca-navy-dark);
    border-top: 1px solid rgba(255,255,255,.08);
}


.main-nav button {
    border: 0;
    border-bottom: 3px solid transparent;
    min-width: 110px;
    padding: 0 20px;
    background: transparent;
    color: rgba(255,255,255,.82);
    font-family: Georgia, "Times New Roman", serif;
    font-size: 15px;
    cursor: pointer;
}


.main-nav button:hover {

    background:
        rgba(255,255,255,.06);

    color: white;

}


.main-nav button.active {

    background:
        rgba(255,255,255,.08);

    border-bottom-color:
        var(--uca-gold);

    color: white;

}


/* =========================================================
   BARRA DE ESTACIÓN
   ========================================================= */

.station-bar {

    background: white;

    min-height: 45px;

    padding:
        9px 24px;

    border-bottom:
        1px solid var(--border);

    display: flex;

    align-items: center;

    justify-content:
        space-between;

    gap: 20px;

    font-size: 12px;

}


.station-identification {

    display: flex;

    align-items: center;

    gap: 10px;

}


.station-name {

    color:
        var(--uca-navy);

    font-weight: 700;

}


.station-separator {

    color:
        var(--uca-gold);

}


.station-model {

    color:
        var(--muted);

}


#database-status {

    color:
        var(--muted);

}


/* =========================================================
   CONTENEDOR PRINCIPAL
   ========================================================= */

main {

    max-width: 1850px;

    margin: auto;

    padding: 18px;

}


.page {

    display: none;

}


.page.active {

    display: block;

}


.page-heading {

    margin-bottom: 15px;

}


.page-heading h2 {

    margin: 0;

    color:
        var(--uca-navy);

    font-family:
        Georgia,
        "Times New Roman",
        serif;

    font-size: 27px;

    font-weight: 500;

}


.page-heading p {

    margin:
        5px 0 0;

    color:
        var(--muted);

    font-size: 13px;

}


/* =========================================================
   ERROR
   ========================================================= */

#error {

    display: none;

    background: #FFF4F4;

    color:
        var(--danger);

    border:
        1px solid #EBCACA;

    border-left:
        5px solid
        var(--danger);

    padding: 12px 15px;

    margin-bottom: 14px;

    border-radius: 4px;

}


/* =========================================================
   FRANJA DE ESTADO GENERAL
   ========================================================= */

.live-strip {

    display: grid;

    grid-template-columns:
        1.5fr
        1fr
        1fr
        1fr;

    gap: 10px;

    margin-bottom: 12px;

}


.live-card {

    background: white;

    border:
        1px solid var(--border);

    min-height: 78px;

    padding:
        14px 16px;

    box-shadow:
        var(--shadow);

    display: flex;

    flex-direction: column;

    justify-content: center;

}


.live-card-label {

    font-size: 10px;

    text-transform: uppercase;

    letter-spacing: .6px;

    color:
        var(--muted);

}


.live-card-value {

    margin-top: 5px;

    color:
        var(--uca-navy);

    font-size: 18px;

    font-weight: 650;

}


.live-card:first-child {

    border-left:
        4px solid var(--uca-green);

}


/* =========================================================
   RESUMEN DIARIO
   ========================================================= */

.summary-grid {

    display: grid;

    grid-template-columns:
        repeat(6, 1fr);

    gap: 8px;

    margin-bottom: 12px;

}


.summary-card {

    background: white;

    border:
        1px solid var(--border);

    padding:
        12px 10px;

    text-align: center;

}


.summary-label {

    color:
        var(--muted);

    font-size: 10px;

    margin-bottom: 5px;

}


.summary-value {

    color:
        var(--uca-navy);

    font-size: 19px;

    font-weight: 650;

}


/* =========================================================
   DASHBOARD PRINCIPAL
   ========================================================= */

.dashboard {

    display: grid;

    grid-template-columns:
        repeat(4, minmax(240px,1fr));

    gap: 10px;

}


.card {

    position: relative;

    background:
        var(--surface);

    border:
        1px solid var(--border);

    min-height: 235px;

    padding: 17px;

    overflow: hidden;

    box-shadow:
        var(--shadow);

}


.card.large {

    grid-column:
        span 2;

}


.card.full {

    grid-column:
        1 / -1;

}


.card-header {

    display: flex;

    justify-content:
        space-between;

    align-items: start;

    margin-bottom: 8px;

}


.card-title {

    color:
        var(--uca-navy);

    font-family:
        Georgia,
        "Times New Roman",
        serif;

    font-size: 17px;

}


.card-source {

    margin-top: 2px;

    font-family:
        Georgia,
        "Times New Roman",
        serif;

    font-style: italic;

    color:
        var(--muted);

    font-size: 10px;

}


.current-value {

    text-align: center;

    margin:
        23px 0 24px;

    color:
        var(--text);

    font-size: 42px;

    font-weight: 300;

    line-height: 1;

}


.current-value .unit {

    color:
        var(--muted);

    font-size: 14px;

    margin-left: 3px;

}


.metric-details {

    display: grid;

    grid-template-columns:
        repeat(2,1fr);

    gap: 7px;

}


.metric {

    border-top:
        1px solid var(--border-light);

    padding-top: 7px;

    min-height: 44px;

}


.metric-label {

    display: block;

    color:
        var(--muted);

    font-size: 10px;

    line-height: 1.3;

}


.metric-value {

    display: block;

    margin-top: 2px;

    font-size: 14px;

    font-weight: 650;

}


/* =========================================================
   MEDIDOR SEMICIRCULAR
   ========================================================= */

.gauge-wrap {

    display: flex;

    flex-direction: column;

    align-items: center;

    margin-top: 20px;

}


.gauge {

    position: relative;

    width: 180px;

    height: 100px;

    overflow: hidden;

    margin:
        0 auto 5px;

}


.gauge-ring,
.gauge-progress {

    position: absolute;

    width: 180px;

    height: 180px;

    left: 0;

    top: 0;

    border-radius: 50%;

    /*
       Esta máscara convierte el círculo
       en un anillo.
    */

    -webkit-mask:
        radial-gradient(
            farthest-side,
            transparent calc(100% - 22px),
            #000 calc(100% - 21px)
        );

    mask:
        radial-gradient(
            farthest-side,
            transparent calc(100% - 22px),
            #000 calc(100% - 21px)
        );

}


.gauge-ring {

    background:
        conic-gradient(
            from 270deg,
            #E6EDF0 0deg,
            #E6EDF0 180deg,
            transparent 180deg,
            transparent 360deg
        );

}


.gauge-progress {

    background:
        conic-gradient(
            from 270deg,

            var(
                --gauge-color,
                #206889
            )

            0deg,

            var(
                --gauge-color,
                #206889
            )

            var(
                --gauge-angle,
                0deg
            ),

            transparent
            var(
                --gauge-angle,
                0deg
            ),

            transparent
            360deg
        );

    transition:
        background .6s ease;

}


/* =========================================================
   BRÚJULA DE VIENTO
   ========================================================= */

.compass-wrap {

    display: flex;

    flex-direction: column;

    align-items: center;

}


.compass {

    position: relative;

    width: 165px;

    height: 165px;

    margin-top: 12px;

    border:
        1px solid #C5D1D7;

    border-radius: 50%;

    background:

        linear-gradient(
            0deg,
            transparent 49.5%,
            #DCE4E8 49.5%,
            #DCE4E8 50.5%,
            transparent 50.5%
        ),

        linear-gradient(
            90deg,
            transparent 49.5%,
            #DCE4E8 49.5%,
            #DCE4E8 50.5%,
            transparent 50.5%
        ),

        linear-gradient(
            45deg,
            transparent 49.5%,
            #E4EAED 49.5%,
            #E4EAED 50.5%,
            transparent 50.5%
        ),

        linear-gradient(
            -45deg,
            transparent 49.5%,
            #E4EAED 49.5%,
            #E4EAED 50.5%,
            transparent 50.5%
        );

}


.compass-label {

    position: absolute;

    color:
        var(--muted);

    font-size: 10px;

    font-weight: 600;

}


.compass-n {

    top: -17px;

    left: 50%;

    transform:
        translateX(-50%);

}


.compass-s {

    bottom: -17px;

    left: 50%;

    transform:
        translateX(-50%);

}


.compass-e {

    right: -15px;

    top: 50%;

    transform:
        translateY(-50%);

}


.compass-w {

    left: -18px;

    top: 50%;

    transform:
        translateY(-50%);

}


.needle {

    position: absolute;

    width: 3px;

    height: 66px;

    background:
        var(--uca-red);

    left:
        calc(50% - 1.5px);

    bottom: 50%;

    transform-origin:
        50% 100%;

    transition:
        transform .5s ease;

}


.needle::before {

    content: "";

    position: absolute;

    top: -7px;

    left: -4px;

    border-left:
        5px solid transparent;

    border-right:
        5px solid transparent;

    border-bottom:
        10px solid
        var(--uca-red);

}


.compass-center {

    position: absolute;

    width: 12px;

    height: 12px;

    background:
        var(--uca-navy);

    border-radius: 50%;

    top:
        calc(50% - 6px);

    left:
        calc(50% - 6px);

}


.direction {

    margin-top: 24px;

    color:
        var(--uca-navy);

    font-size: 22px;

    font-weight: 650;

}


/* =========================================================
   GRÁFICAS
   ========================================================= */

.chart-container {

    width: 100%;

    height: 300px;

}


.chart-container.large-chart {

    height: 540px;

}


/* =========================================================
   SECCIONES SECUNDARIAS
   ========================================================= */

.section-divider {

    margin:
        20px 0 10px;

    display: flex;

    align-items: center;

    gap: 12px;

}


.section-divider h3 {

    margin: 0;

    color:
        var(--uca-navy);

    font-family:
        Georgia,
        "Times New Roman",
        serif;

    font-size: 19px;

    font-weight: 500;

}


.section-divider::after {

    content: "";

    height: 1px;

    background:
        var(--border);

    flex: 1;

}


/* =========================================================
   CONTROLES
   ========================================================= */

.toolbar {

    background:
        var(--surface);

    border:
        1px solid var(--border);

    padding: 13px;

    display: flex;

    flex-wrap: wrap;

    align-items: end;

    gap: 10px;

    margin-bottom: 12px;

}


.control {

    display: flex;

    flex-direction: column;

    gap: 4px;

}


.control label {

    color:
        var(--muted);

    font-size: 10px;

}


input,
select {

    height: 38px;

    padding:
        0 10px;

    background: white;

    color:
        var(--text);

    border:
        1px solid #C8D3D9;

    border-radius: 2px;

}


input:focus,
select:focus {

    outline:
        2px solid
        rgba(32,104,137,.18);

    border-color:
        var(--uca-blue);

}


.action {

    height: 38px;

    padding:
        0 16px;

    border:
        1px solid
        var(--uca-navy);

    background:
        var(--uca-navy);

    color: white;

    cursor: pointer;

    font-size: 12px;

    font-weight: 650;

}


.action:hover {

    background:
        var(--uca-navy-dark);

}


.action.secondary {

    background: white;

    color:
        var(--uca-navy);

}


.action.secondary:hover {

    background:
        var(--uca-blue-light);

}


/* =========================================================
   TABLA DE DATOS
   ========================================================= */

.table-wrapper {

    background: white;

    border:
        1px solid var(--border);

}


.table-container {

    overflow: auto;

    max-height:
        calc(100vh - 310px);

}


table {

    border-collapse: collapse;

    width: max-content;

    min-width: 100%;

    font-size: 11px;

}


thead th {

    position: sticky;

    top: 0;

    z-index: 4;

    background:
        var(--uca-navy);

    color: white;

    padding:
        9px 10px;

    border-right:
        1px solid
        rgba(255,255,255,.13);

    text-align: center;

    white-space: nowrap;

    min-width: 90px;

}


thead th small {

    color:
        rgba(255,255,255,.73);

    font-weight: normal;

}


thead th:first-child {

    left: 0;

    z-index: 5;

}


tbody td {

    padding:
        7px 9px;

    border-right:
        1px solid
        var(--border-light);

    border-bottom:
        1px solid
        var(--border-light);

    text-align: right;

    white-space: nowrap;

}


tbody td:first-child {

    position: sticky;

    left: 0;

    z-index: 2;

    background:
        #F7FAFB;

    color:
        var(--uca-navy);

    font-weight: 650;

    text-align: left;

}


tbody tr:hover td {

    background:
        #F0F6F8;

}


tbody tr:hover td:first-child {

    background:
        #E6F0F4;

}


.pager {

    background: white;

    border-top:
        1px solid var(--border);

    padding: 10px 12px;

    display: flex;

    align-items: center;

    justify-content:
        space-between;

    gap: 12px;

    color:
        var(--muted);

    font-size: 11px;

}


/* =========================================================
   MAPA
   ========================================================= */

#map {

    width: 100%;

    height: 650px;

    border:
        1px solid var(--border);

    box-shadow:
        var(--shadow);

}


/* =========================================================
   DESCARGAS
   ========================================================= */

.download-layout {

    display: grid;

    grid-template-columns:
        minmax(0, 1.6fr)
        minmax(280px, .7fr);

    gap: 14px;

}


.download-box {

    background: white;

    border:
        1px solid var(--border);

    padding: 24px;

    box-shadow:
        var(--shadow);

}


.download-box h3 {

    margin:
        0 0 7px;

    color:
        var(--uca-navy);

    font-family:
        Georgia,
        "Times New Roman",
        serif;

    font-size: 23px;

    font-weight: 500;

}


.download-box p {

    color:
        var(--muted);

    font-size: 13px;

    line-height: 1.6;

}


.download-note {

    background:
        var(--surface-soft);

    border-left:
        4px solid
        var(--uca-gold);

    padding: 13px;

    margin-top: 17px;

    color:
        var(--muted);

    font-size: 12px;

    line-height: 1.6;

}


.metadata-panel {

    background: white;

    border:
        1px solid var(--border);

    padding: 20px;

}


.metadata-title {

    color:
        var(--uca-navy);

    font-family:
        Georgia,
        serif;

    font-size: 18px;

    margin-bottom: 15px;

}


.metadata-row {

    padding:
        8px 0;

    border-bottom:
        1px solid
        var(--border-light);

}


.metadata-label {

    color:
        var(--muted);

    font-size: 10px;

}


.metadata-value {

    margin-top: 2px;

    color:
        var(--text);

    font-size: 13px;

    font-weight: 600;

}


/* =========================================================
   PIE
   ========================================================= */

footer {

    margin-top: 30px;

    background:
        var(--uca-navy-dark);

    color:
        rgba(255,255,255,.78);

    padding:
        26px;

    text-align: center;

    font-size: 11px;

    line-height: 1.65;

}


footer strong {

    color: white;

    font-family:
        Georgia,
        "Times New Roman",
        serif;

    font-size: 15px;

    font-weight: normal;

}


/* =========================================================
   RESPONSIVE
   ========================================================= */

@media(max-width: 1300px) {

    .dashboard {

        grid-template-columns:
            repeat(3,1fr);

    }

    .summary-grid {

        grid-template-columns:
            repeat(3,1fr);

    }

    .live-strip {

        grid-template-columns:
            repeat(2,1fr);

    }

}

@media(max-width: 750px) {

    .site-header {
        position: sticky;
    }

    .header-status {
        display: none;
    }

    .brand-title {
        font-size: 18px;
    }

    .brand-logo {
        width: 78px;
        height: 44px;
    }

    .brand-university,
    .brand-department {
        font-size: 9px;
    }

    .main-nav {
        overflow-x: auto;
    }

    .main-nav button {
        flex: none;
        min-width: 100px;
    }

    main {
        padding: 10px;
    }

    .station-bar {
        flex-direction: column;
        align-items: start;
    }

    .dashboard {
        grid-template-columns: 1fr;
    }

    .card.large,
    .card.full {
        grid-column: span 1;
    }

    .summary-grid,
    .live-strip {
        grid-template-columns: repeat(2,1fr);
    }

}


@media(max-width: 470px) {

    .brand {

        padding:
            10px 12px;

    }

    .brand-logo {

        width: 82px;

        height: 50px;

    }

    .brand-title {

        font-size: 19px;

    }

    .summary-grid {

        grid-template-columns:
            1fr 1fr;

    }

    .live-strip {

        grid-template-columns:
            1fr;

    }

}

/* =========================================================
   MEJORAS DE MEDIDORES
   ========================================================= */

.gauge {

    position: relative;

    width: 180px;

    height: 105px;

    overflow: visible;

    margin-bottom: 4px;

}


.gauge::before {

    content: attr(data-min);

    position: absolute;

    left: -3px;

    bottom: 5px;

    color: var(--muted);

    font-size: 9px;

}


.gauge::after {

    content: attr(data-max);

    position: absolute;

    right: -6px;

    bottom: 5px;

    color: var(--muted);

    font-size: 9px;

}


.gauge-ring {

    width: 180px;

    height: 180px;

}


.gauge-progress {

    width: 180px;

    height: 180px;

    transition:
        transform .7s ease,
        border-color .4s ease;

}


.gauge-center {

    margin-top: 3px;

}


.gauge-number {

    font-size: 31px;

    color: var(--uca-navy);

}


.gauge-unit {

    font-size: 10px;

}

</style>

</head>


<body>


<!-- =========================================================
     ENCABEZADO
     ========================================================= -->

<header class="site-header">

<div class="header-top">

    <div class="brand">

        <img
            src="/logo"
            class="brand-logo"
            alt="Universidad Centroamericana José Simeón Cañas">

        <div class="brand-text">

            <h1 class="brand-title">
                {{ site_title }}
            </h1>

            <div class="brand-university">
                {{ university_name }}
            </div>

            <div class="brand-department">
                {{ department_name }}
            </div>

        </div>

    </div>


    <div class="header-status">

        <div class="status-main">

            <span
                id="status-dot"
                class="status-dot">
            </span>

            <strong id="status-text">
                Comprobando estación
            </strong>

        </div>

        <div id="last-update">
            Último dato: --
        </div>

    </div>

</div>


<nav class="main-nav">

    <button
        class="active"
        data-page="bulletin">
        Boletín
    </button>

    <button
        data-page="charts">
        Gráficas
    </button>

    <button
        data-page="data">
        Datos
    </button>

    <button
        data-page="map-page">
        Mapa
    </button>

    <button
        data-page="downloads">
        Descargas
    </button>

</nav>

</header>


<!-- =========================================================
     INFORMACIÓN DE ESTACIÓN
     ========================================================= -->

<div class="station-bar">

    <div class="station-identification">

        <span class="station-name">
            {{ station_name }}
        </span>

        <span class="station-separator">
            |
        </span>

        <span class="station-model">
            {{ station_model }}
        </span>

    </div>

    <div id="database-status">
        WeatherLink 6.0.5
    </div>

</div>


<main>


<div id="error"></div>


<!-- =========================================================
     BOLETÍN
     ========================================================= -->

<section
    id="bulletin"
    class="page active">


<div class="page-heading">

    <h2>
        Boletín meteorológico
    </h2>

    <p>
        Condiciones registradas por la estación meteorológica
        del Departamento de Ciencias Energéticas y Fluídicas.
    </p>

</div>


<!-- ESTADO GENERAL -->

<div class="live-strip">

    <div class="live-card">

        <div class="live-card-label">
            Estado de la estación
        </div>

        <div
            class="live-card-value"
            id="station-state-main">
            Operativa
        </div>

    </div>


    <div class="live-card">

        <div class="live-card-label">
            Temperatura exterior
        </div>

        <div class="live-card-value">

            <span id="temp-out-top">
                --
            </span>

            °C

        </div>

    </div>


    <div class="live-card">

        <div class="live-card-label">
            Humedad exterior
        </div>

        <div class="live-card-value">

            <span id="humidity-top">
                --
            </span>

            %

        </div>

    </div>


    <div class="live-card">

        <div class="live-card-label">
            Recepción ISS
        </div>

        <div class="live-card-value">

            <span id="iss-top">
                --
            </span>

            %

        </div>

    </div>

</div>


<!-- RESUMEN DIARIO -->

<div class="summary-grid">


<div class="summary-card">

    <div class="summary-label">
        Temperatura máxima
    </div>

    <div
        id="day-max"
        class="summary-value">
        --
    </div>

</div>


<div class="summary-card">

    <div class="summary-label">
        Temperatura mínima
    </div>

    <div
        id="day-min"
        class="summary-value">
        --
    </div>

</div>


<div class="summary-card">

    <div class="summary-label">
        Precipitación del día
    </div>

    <div
        id="day-rain"
        class="summary-value">
        --
    </div>

</div>


<div class="summary-card">

    <div class="summary-label">
        Precipitación del mes
    </div>

    <div
        id="month-rain"
        class="summary-value">
        --
    </div>

</div>


<div class="summary-card">

    <div class="summary-label">
        Índice UV máximo
    </div>

    <div
        id="day-uv"
        class="summary-value">
        --
    </div>

</div>


<div class="summary-card">

    <div class="summary-label">
        Ráfaga máxima
    </div>

    <div
        id="day-gust"
        class="summary-value">
        --
    </div>

</div>


</div>


<!-- DASHBOARD PRINCIPAL -->

<div class="dashboard">


<!-- TEMPERATURA -->

<article class="card">

<div class="card-header">

<div>

<div class="card-title">
Temperatura
</div>

<div class="card-source">
Weather Station
</div>

</div>

</div>


<div class="current-value">

<span id="temp-out">
--
</span>

<span class="unit">
°C
</span>

</div>


<div class="metric-details">

<div class="metric">

<span class="metric-label">
Máxima del intervalo
</span>

<span
class="metric-value"
id="hi-temp">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Mínima del intervalo
</span>

<span
class="metric-value"
id="low-temp">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Punto de rocío
</span>

<span
class="metric-value"
id="dew-point">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Índice de calor
</span>

<span
class="metric-value"
id="heat-index">
--
</span>

</div>

</div>

</article>


<!-- HUMEDAD -->

<article class="card">

<div class="card-title">
Humedad exterior
</div>

<div class="card-source">
Estación meteorológica
</div>


<div class="current-value">

<span id="humidity">
--
</span>

<span class="unit">
%
</span>

</div>


<div class="metric-details">

<div class="metric">

<span class="metric-label">
Promedio del día
</span>

<span
class="metric-value"
id="humidity-average">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Punto de rocío
</span>

<span
class="metric-value"
id="humidity-dew">
--
</span>

</div>

</div>

</article>


<!-- PRESIÓN -->

<article class="card">

<div class="card-title">
Presión barométrica
</div>

<div class="card-source">
Consola
</div>


<div class="current-value">

<span id="bar">
--
</span>

<span class="unit">
hPa
</span>

</div>

<div class="metric-details">

<div class="metric">

<span class="metric-label">
Variable atmosférica
</span>

<span class="metric-value">
Presión reducida
</span>

</div>

<div class="metric">

<span class="metric-label">
Unidad WeatherLink
</span>

<span class="metric-value">
hPa
</span>

</div>

</div>

</article>


<!-- VIENTO -->

<article class="card">

<div class="card-title">
Velocidad del viento
</div>

<div class="card-source">
Weather Station
</div>

<div class="current-value">

<span id="wind-speed">
--
</span>

<span class="unit">
m/s
</span>

</div>


<div class="metric-details">

<div class="metric">

<span class="metric-label">
Equivalente
</span>

<span
class="metric-value"
id="wind-speed-ms">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Ráfaga
</span>

<span
class="metric-value"
id="wind-gust">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Dirección de ráfaga
</span>

<span
class="metric-value"
id="wind-gust-dir">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Recorrido del viento
</span>

<span
class="metric-value"
id="wind-run">
--
</span>

</div>

</div>

</article>


<!-- DIRECCIÓN DEL VIENTO -->

<article class="card">

<div class="card-title">
Dirección del viento
</div>

<div class="card-source">
Weather Station
</div>


<div class="compass-wrap">

<div class="compass">

<span class="compass-label compass-n">
N
</span>

<span class="compass-label compass-s">
S
</span>

<span class="compass-label compass-e">
E
</span>

<span class="compass-label compass-w">
O
</span>

<div
id="needle"
class="needle">
</div>

<div class="compass-center">
</div>

</div>


<div
class="direction"
id="wind-dir">
--
</div>

</div>

</article>


<!-- RADIACIÓN SOLAR -->

<article class="card">

<div class="card-title">
Radiación solar
</div>

<div class="card-source">
Weather Station
</div>


<div class="gauge-wrap">

<div
    class="gauge"
    data-min="0"
    data-max="1200">

<div class="gauge-ring">
</div>

<div
id="solar-gauge"
class="gauge-progress">
</div>

</div>


<div class="gauge-center">

<span
class="gauge-number"
id="solar-rad">
--
</span>

<span class="gauge-unit">
W/m²
</span>

</div>

</div>


<div class="metric-details">

<div class="metric">

<span class="metric-label">
Máxima del intervalo
</span>

<span
class="metric-value"
id="solar-high">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Energía solar
</span>

<span
class="metric-value"
id="solar-energy">
--
</span>

</div>

</div>

</article>


<!-- UV -->

<article class="card">

<div class="card-title">
Radiación ultravioleta
</div>

<div class="card-source">
Weather Station
</div>


<div class="gauge-wrap">

<div
    class="gauge"
    data-min="0"
    data-max="14">

<div class="gauge-ring">
</div>

<div
id="uv-gauge"
class="gauge-progress">
</div>

</div>


<div class="gauge-center">

<span
class="gauge-number"
id="uv">
--
</span>

<span class="gauge-unit">
Índice UV
</span>

</div>

</div>


<div class="metric-details">

<div class="metric">

<span class="metric-label">
Índice UV máximo
</span>

<span
class="metric-value"
id="uv-high">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Dosis UV
</span>

<span
class="metric-value"
id="uv-dose">
--
</span>

</div>

</div>

</article>


<!-- LLUVIA -->

<article class="card">

<div class="card-title">
Precipitación
</div>

<div class="card-source">
Weather Station
</div>


<div class="current-value">

<span id="rain">
--
</span>

<span class="unit">
mm
</span>

</div>


<div class="metric-details">

<div class="metric">

<span class="metric-label">
Intensidad de lluvia
</span>

<span
class="metric-value"
id="rain-rate">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Evapotranspiración
</span>

<span
class="metric-value"
id="et">
--
</span>

</div>

</div>

</article>


</div>


<!-- GRÁFICAS PRINCIPALES -->

<div class="section-divider">

<h3>
Evolución durante las últimas 24 horas
</h3>

</div>


<div class="dashboard">


<article class="card full">

<div class="card-title">
Temperatura exterior
</div>

<div class="card-source">
Histórico de 24 horas
</div>

<div class="chart-container">

<canvas id="temperature-chart">
</canvas>

</div>

</article>


<article class="card full">

<div class="card-title">
Radiación solar
</div>

<div class="card-source">
Histórico de 24 horas
</div>

<div class="chart-container">

<canvas id="solar-chart">
</canvas>

</div>

</article>


</div>


<!-- VARIABLES AVANZADAS -->

<div class="section-divider">

<h3>
Variables meteorológicas avanzadas
</h3>

</div>


<div class="dashboard">


<!-- ÍNDICES TÉRMICOS -->

<article class="card large">

<div class="card-title">
Índices térmicos
</div>

<div class="card-source">
Weather Station
</div>


<div
class="metric-details"
style="margin-top:24px">


<div class="metric">

<span class="metric-label">
Sensación térmica
</span>

<span
class="metric-value"
id="wind-chill">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Índice de calor
</span>

<span
class="metric-value"
id="heat-index-2">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Índice THW
</span>

<span
class="metric-value"
id="thw">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Índice THSW
</span>

<span
class="metric-value"
id="thsw">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Grados-día calefacción
</span>

<span
class="metric-value"
id="heat-dd">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Grados-día refrigeración
</span>

<span
class="metric-value"
id="cool-dd">
--
</span>

</div>


</div>

</article>


<!-- CONDICIONES INTERIORES -->

<article class="card large">

<div class="card-title">
Condiciones interiores
</div>

<div class="card-source">
Console
</div>


<div
class="metric-details"
style="margin-top:24px">


<div class="metric">

<span class="metric-label">
Temperatura interior
</span>

<span
class="metric-value"
id="in-temp">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Humedad interior
</span>

<span
class="metric-value"
id="in-hum">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Punto de rocío interior
</span>

<span
class="metric-value"
id="in-dew">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Índice de calor interior
</span>

<span
class="metric-value"
id="in-heat">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
EMC interior
</span>

<span
class="metric-value"
id="in-emc">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Densidad del aire
</span>

<span
class="metric-value"
id="air-density">
--
</span>

</div>


</div>

</article>


<!-- ESTADO -->

<article class="card full">

<div class="card-title">
Diagnóstico y estado de la estación
</div>

<div class="card-source">
Sistema de adquisición
</div>


<div
class="metric-details"
style="
margin-top:20px;
grid-template-columns:repeat(4,1fr);
">


<div class="metric">

<span class="metric-label">
Recepción ISS
</span>

<span
class="metric-value"
id="iss">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Muestras de viento
</span>

<span
class="metric-value"
id="wind-samp">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Transmisor
</span>

<span
class="metric-value"
id="wind-tx">
--
</span>

</div>


<div class="metric">

<span class="metric-label">
Intervalo de archivo
</span>

<span
class="metric-value"
id="arc-int">
--
</span>

</div>


</div>

</article>


</div>


</section>


<!-- =========================================================
     GRÁFICAS
     ========================================================= -->

<section
id="charts"
class="page">


<div class="page-heading">

<h2>
Gráficas meteorológicas
</h2>

<p>
Explore el comportamiento histórico de las variables
registradas por la estación.
</p>

</div>


<div class="toolbar">


<div class="control">

<label>
Variable meteorológica
</label>

<select id="metric">
</select>

</div>


<div class="control">

<label>
Período
</label>

<select id="span">

<option value="24h">
Últimas 24 horas
</option>

<option value="7d">
Últimos 7 días
</option>

<option value="30d">
Últimos 30 días
</option>

<option value="365d">
Último año
</option>

</select>

</div>


<button
class="action"
id="load-chart">

Actualizar gráfica

</button>


</div>


<article class="card">

<div
class="card-title"
id="main-chart-title">
Serie meteorológica
</div>

<div
class="card-source"
id="main-chart-unit">
</div>


<div class="chart-container large-chart">

<canvas id="main-chart">
</canvas>

</div>

</article>


</section>


<!-- =========================================================
     DATOS
     ========================================================= -->

<section
id="data"
class="page">


<div class="page-heading">

<h2>
Datos registrados
</h2>

<p>
Consulta completa de los registros archivados
por WeatherLink y almacenados en la base histórica.
</p>

</div>


<div class="toolbar">


<div class="control">

<label>
Desde
</label>

<input
type="date"
id="data-from">

</div>


<div class="control">

<label>
Hasta
</label>

<input
type="date"
id="data-to">

</div>


<div class="control">

<label>
Registros por página
</label>

<select id="per-page">

<option value="50">
50
</option>

<option
value="100"
selected>
100
</option>

<option value="200">
200
</option>

<option value="300">
300
</option>

</select>

</div>


<button
class="action"
id="load-data">

Consultar

</button>


<button
class="action secondary"
id="excel-data">

Descargar Excel

</button>


<button
class="action secondary"
id="csv-data">

Descargar CSV

</button>


</div>


<div class="table-wrapper">


<div class="table-container">

<table>

<thead>

<tr id="table-head">
</tr>

</thead>


<tbody id="table-body">
</tbody>

</table>

</div>


<div class="pager">

<button
class="action secondary"
id="previous">

Anterior

</button>


<span id="page-info">
Página 1
</span>


<button
class="action secondary"
id="next">

Siguiente

</button>


</div>

</div>


</section>


<!-- =========================================================
     MAPA
     ========================================================= -->

<section
id="map-page"
class="page">


<div class="page-heading">

<h2>
Ubicación de la estación
</h2>

<p>
Localización geográfica de la estación meteorológica
del Departamento de Ciencias Energéticas y Fluídicas.
</p>

</div>


<div id="map">
</div>


</section>


<!-- =========================================================
     DESCARGAS
     ========================================================= -->

<section
id="downloads"
class="page">


<div class="page-heading">

<h2>
Descarga de datos
</h2>

<p>
Acceso a los registros meteorológicos para fines académicos,
científicos y de investigación.
</p>

</div>


<div class="download-layout">


<div class="download-box">


<h3>
Base de datos meteorológica
</h3>


<p>

Seleccione un rango de fechas para obtener los registros
almacenados por el Centro de Meteorología UCA.

El archivo Excel contiene todas las variables disponibles
en la exportación de WeatherLink, además de una hoja de
metadatos que identifica las unidades y variables utilizadas.

</p>


<div class="toolbar">


<div class="control">

<label>
Fecha inicial
</label>

<input
type="date"
id="download-from">

</div>


<div class="control">

<label>
Fecha final
</label>

<input
type="date"
id="download-to">

</div>


</div>


<button
class="action"
id="download-excel">

Descargar Excel (.xlsx)

</button>


<button
class="action secondary"
id="download-csv">

Descargar CSV

</button>


<div class="download-note">

Los registros se almacenan de manera histórica
en la base de datos local del sistema.
La exportación puede realizarse para todo el período
disponible o para un intervalo seleccionado.

</div>


</div>


<div class="metadata-panel">


<div class="metadata-title">
Información del conjunto de datos
</div>


<div class="metadata-row">

<div class="metadata-label">
Institución
</div>

<div class="metadata-value">
{{ university_name }}
</div>

</div>


<div class="metadata-row">

<div class="metadata-label">
Departamento
</div>

<div class="metadata-value">
{{ department_name }}
</div>

</div>


<div class="metadata-row">

<div class="metadata-label">
Estación
</div>

<div class="metadata-value">
{{ station_name }}
</div>

</div>


<div class="metadata-row">

<div class="metadata-label">
Equipo
</div>

<div class="metadata-value">
{{ station_model }}
</div>

</div>


<div class="metadata-row">

<div class="metadata-label">
Fuente de adquisición
</div>

<div class="metadata-value">
WeatherLink 6.0.5
</div>

</div>


<div class="metadata-row">

<div class="metadata-label">
Registros disponibles
</div>

<div
class="metadata-value"
id="total-records">
--
</div>

</div>


</div>


</div>


</section>


</main>


<footer>

<strong>
{{ site_title }}
</strong>

<br>

{{ university_name }}

<br>

{{ department_name }}

</footer>


<!-- =========================================================
     JAVASCRIPT
     ========================================================= -->

<script>


const COLUMNS =
{{ columns_json | safe }};


const METRICS =
{{ metrics_json | safe }};


const STATION = {

    lat:
        {{ station_lat }},

    lon:
        {{ station_lon }},

    name:
        {{ station_name_json | safe }}

};


let temperatureChart = null;

let solarChart = null;

let mainChart = null;

let currentPage = 1;

let totalPages = 1;

let map = null;

let mapMarker = null;

let latestData = null;


const DIRECTIONS = {

    N: 0,

    NNE: 22.5,

    NE: 45,

    ENE: 67.5,

    E: 90,

    ESE: 112.5,

    SE: 135,

    SSE: 157.5,

    S: 180,

    SSW: 202.5,

    SW: 225,

    WSW: 247.5,

    W: 270,

    WNW: 292.5,

    NW: 315,

    NNW: 337.5

};


function element(id) {

    return document.getElementById(id);

}


function number(
    value,
    decimals = 1
) {

    if (
        value === null
        ||
        value === undefined
        ||
        value === ""
    ) {

        return "--";

    }


    const converted =
        Number(value);


    if (
        Number.isNaN(
            converted
        )
    ) {

        return "--";

    }


    return converted.toFixed(
        decimals
    );

}


function text(
    id,
    value
) {

    const target =
        element(id);


    if (target) {

        target.textContent =
            value;

    }

}


async function fetchJSON(url) {

    const response =
        await fetch(

            url,

            {
                cache:
                    "no-store"
            }

        );


    const data =
        await response.json();


    if (!response.ok) {

        throw new Error(

            data.message
            ||
            "Error del servidor"

        );

    }


    return data;

}


function showError(message) {

    const box =
        element("error");


    box.textContent =
        message;


    box.style.display =
        "block";

}


function hideError() {

    element(
        "error"
    ).style.display =
        "none";

}


/* =========================================================
   MEDIDORES
   ========================================================= */

function setGauge(
    id,
    value,
    maximum,
    type = "normal"
) {

    const gauge =
        element(id);


    if (!gauge) {

        return;

    }


    let numeric =
        Number(value);


    if (!Number.isFinite(numeric)) {

        numeric = 0;

    }


    numeric =
        Math.max(
            0,
            Math.min(
                maximum,
                numeric
            )
        );


    const percentage =
        numeric / maximum;


    const angle =
        percentage * 180;


    let gaugeColor =
        "#206889";


    if (type === "uv") {

        if (numeric < 3) {

            gaugeColor =
                "#2F7960";

        }

        else if (numeric < 6) {

            gaugeColor =
                "#C5A02E";

        }

        else if (numeric < 8) {

            gaugeColor =
                "#C87827";

        }

        else if (numeric < 11) {

            gaugeColor =
                "#A53F46";

        }

        else {

            gaugeColor =
                "#70456E";

        }

    }


    gauge.style.setProperty(
        "--gauge-angle",
        angle + "deg"
    );


    gauge.style.setProperty(
        "--gauge-color",
        gaugeColor
    );

}


/* =========================================================
   ÚLTIMO REGISTRO
   ========================================================= */

async function loadLatest() {

    try {

        const result =
            await fetchJSON(
                "/api/latest"
            );


        const data =
            result.data;


        latestData =
            data;


        hideError();


        /* TEMPERATURA */

        text(
            "temp-out",
            number(
                data.temp_out
            )
        );


        text(
            "temp-out-top",
            number(
                data.temp_out
            )
        );


        text(
            "hi-temp",
            number(
                data.hi_temp
            )
            +
            " °C"
        );


        text(
            "low-temp",
            number(
                data.low_temp
            )
            +
            " °C"
        );


        text(
            "dew-point",
            number(
                data.dew_pt
            )
            +
            " °C"
        );


        text(
            "heat-index",
            number(
                data.heat_index
            )
            +
            " °C"
        );


        /* HUMEDAD */

        text(
            "humidity",
            number(
                data.out_hum,
                0
            )
        );


        text(
            "humidity-top",
            number(
                data.out_hum,
                0
            )
        );


        text(
            "humidity-dew",
            number(
                data.dew_pt
            )
            +
            " °C"
        );


        /* VIENTO */

        text(
            "wind-dir",
            data.wind_dir
            ||
            "--"
        );


        const direction =
            DIRECTIONS[
                data.wind_dir
            ]
            ?? 0;


        element(
            "needle"
        ).style.transform =
            `rotate(${direction}deg)`;


       text(
    "wind-speed",
    number(
        data.wind_speed
    )
);


       text(
    "wind-speed-ms",
    number(
        data.wind_speed_kmh
    )
    +
    " km/h"
);

      text(
    "wind-gust",
    number(
        data.hi_speed
    )
    +
    " m/s"
);


        text(
            "wind-gust-dir",
            data.hi_dir
            ||
            "--"
        );


        text(
            "wind-run",
            number(
                data.wind_run,
                2
            )
            +
            " km"
        );


        /* SOL */

        text(
            "solar-rad",
            number(
                data.solar_rad,
                0
            )
        );


        text(
            "solar-high",
            number(
                data.hi_solar_rad,
                0
            )
            +
            " W/m²"
        );


        text(
            "solar-energy",
            number(
                data.solar_energy,
                2
            )
            +
            " Ly"
        );


        setGauge(
            "solar-gauge",
            data.solar_rad,
            1200
        );


        /* UV */

        text(
            "uv",
            number(
                data.uv_index
            )
        );


        text(
            "uv-high",
            number(
                data.hi_uv
            )
        );


        text(
            "uv-dose",
            number(
                data.uv_dose,
                2
            )
        );


        setGauge(
            "uv-gauge",
            data.uv_index,
            14
        );


        /* LLUVIA */

        text(
            "rain",
            number(
                data.rain,
                2
            )
        );


        text(
            "rain-rate",
            number(
                data.rain_rate
            )
            +
            " mm/h"
        );


        text(
            "et",
            number(
                data.et,
                2
            )
            +
            " mm"
        );


        /* PRESIÓN */

        text(
            "bar",
            number(
                data.bar
            )
        );


        /* ÍNDICES */

        text(
            "wind-chill",
            number(
                data.wind_chill
            )
            +
            " °C"
        );


        text(
            "heat-index-2",
            number(
                data.heat_index
            )
            +
            " °C"
        );


        text(
            "thw",
            number(
                data.thw_index
            )
            +
            " °C"
        );


        text(
            "thsw",
            number(
                data.thsw_index
            )
            +
            " °C"
        );


        text(
            "heat-dd",
            number(
                data.heat_dd,
                3
            )
        );


        text(
            "cool-dd",
            number(
                data.cool_dd,
                3
            )
        );


        /* INTERIOR */

        text(
            "in-temp",
            number(
                data.in_temp
            )
            +
            " °C"
        );


        text(
            "in-hum",
            number(
                data.in_hum,
                0
            )
            +
            " %"
        );


        text(
            "in-dew",
            number(
                data.in_dew
            )
            +
            " °C"
        );


        text(
            "in-heat",
            number(
                data.in_heat
            )
            +
            " °C"
        );


        text(
            "in-emc",
            number(
                data.in_emc,
                2
            )
            +
            " %"
        );


        text(
            "air-density",
            number(
                data.in_air_density,
                4
            )
            +
            " kg/m³"
        );


        /* DIAGNÓSTICO */

        text(
            "iss",
            number(
                data.iss_recept,
                1
            )
            +
            " %"
        );


        text(
            "iss-top",
            number(
                data.iss_recept,
                1
            )
        );


        text(
            "wind-samp",
            number(
                data.wind_samp,
                0
            )
        );


        text(
            "wind-tx",
            number(
                data.wind_tx,
                0
            )
        );


        text(
            "arc-int",
            number(
                data.arc_int,
                0
            )
            +
            " min"
        );


        /* ESTADO */

        const dot =
            element(
                "status-dot"
            );


        if (
            data.online
        ) {

            dot.classList.add(
                "online"
            );


            dot.classList.remove(
                "offline"
            );


            text(
                "status-text",
                "Estación actualizada"
            );


            text(
                "station-state-main",
                "Estación operativa"
            );

        }

        else {

            dot.classList.add(
                "offline"
            );


            dot.classList.remove(
                "online"
            );


            text(
                "status-text",
                "Datos retrasados"
            );


            text(
                "station-state-main",
                "Datos retrasados"
            );

        }


        text(
            "last-update",

            "Último registro: "
            +
            data.timestamp

        );


        if (
            mapMarker
        ) {

            mapMarker.setPopupContent(

                popupContent(
                    data
                )

            );

        }

    }


    catch (error) {

        showError(
            error.message
        );

    }

}


/* =========================================================
   RESUMEN DEL DÍA
   ========================================================= */

async function loadSummary() {

    try {

        const result =
            await fetchJSON(
                "/api/summary"
            );


        const data =
            result.data;


        text(
            "day-max",
            number(
                data.temp_max
            )
            +
            " °C"
        );


        text(
            "day-min",
            number(
                data.temp_min
            )
            +
            " °C"
        );


        text(
            "day-rain",
            number(
                data.rain_day,
                2
            )
            +
            " mm"
        );


        text(
            "month-rain",
            number(
                data.rain_month,
                2
            )
            +
            " mm"
        );


        text(
            "day-uv",
            number(
                data.uv_max
            )
        );


       text(
    "day-gust",
    number(
        data.gust_max
    )
    +
    " m/s"
);


        text(
            "humidity-average",
            number(
                data.humidity_avg
            )
            +
            " %"
        );


        text(
            "total-records",

            (
                data.total_records
                ||
                0
            )
            +
            " registros"

        );

    }


    catch (error) {

        console.log(
            error
        );

    }

}


/* =========================================================
   CREAR GRÁFICA
   ========================================================= */

   function createChart(
    canvas,
    metadata,
    points,
    current
) {

    if (current) {
        current.destroy();
    }


    const labels =
        points.map(
            point =>
                point.timestamp
        );


    const values =
        points.map(
            point =>
                point.value
        );


    return new Chart(

        element(canvas),

        {

            type: "line",

            data: {

                labels: labels,

                datasets: [

                    {

                        label:
                            metadata.label,

                        data:
                            values,

                        borderColor:
                            "#206889",

                        backgroundColor:
                            "rgba(32,104,137,.10)",

                        borderWidth:
                            2,

                        pointRadius:
                            0,

                        pointHoverRadius:
                            4,

                        tension:
                            0.18,

                        fill:
                            true

                    }

                ]

            },


            options: {

                responsive:
                    true,

                maintainAspectRatio:
                    false,

                interaction: {

                    mode:
                        "index",

                    intersect:
                        false

                },


                plugins: {

                    legend: {

                        display:
                            false

                    },


                    tooltip: {

                        displayColors:
                            false,

                        callbacks: {

                            title: function(items) {

                                if (!items.length) {
                                    return "";
                                }

                                const value =
                                    items[0].label;

                                const parts =
                                    value.split(" ");

                                if (parts.length < 2) {
                                    return value;
                                }

                                const date =
                                    parts[0];

                                const time =
                                    parts[1].slice(0,5);

                                const dateParts =
                                    date.split("-");

                                if (dateParts.length !== 3) {
                                    return value;
                                }

                                return (
                                    dateParts[2]
                                    + "/"
                                    + dateParts[1]
                                    + "/"
                                    + dateParts[0]
                                    + " "
                                    + time
                                );

                            },


                            label: function(context) {

                                const unit =
                                    metadata.unit
                                    ?
                                    " " + metadata.unit
                                    :
                                    "";

                                return (
                                    metadata.label
                                    + ": "
                                    + context.parsed.y
                                    + unit
                                );

                            }

                        }

                    }

                },


                scales: {

                    x: {

                        grid: {

                            display:
                                false

                        },

                        ticks: {

                            maxTicksLimit:
                                12,

                            color:
                                "#718087",

                            maxRotation:
                                0,

                            minRotation:
                                0,

                            callback: function(value) {

                                const fullLabel =
                                    this.getLabelForValue(
                                        value
                                    );

                                if (!fullLabel) {
                                    return "";
                                }

                                const parts =
                                    fullLabel.split(" ");

                                if (parts.length < 2) {
                                    return fullLabel;
                                }

                                return parts[1].slice(
                                    0,
                                    5
                                );

                            }

                        }

                    },


                    y: {

                        ticks: {

                            color:
                                "#718087"

                        },

                        title: {

                            display:
                                Boolean(
                                    metadata.unit
                                ),

                            text:
                                metadata.unit

                        }

                    }

                }

            }

        }

    );

}


/* =========================================================
   GRÁFICAS PEQUEÑAS
   ========================================================= */

async function loadMiniCharts() {

    try {

        const temperature =
            await fetchJSON(

                "/api/history"
                +
                "?metric=temp_out"
                +
                "&span=24h"

            );


        temperatureChart =
            createChart(

                "temperature-chart",

                temperature.meta,

                temperature.points,

                temperatureChart

            );


        const solar =
            await fetchJSON(

                "/api/history"
                +
                "?metric=solar_rad"
                +
                "&span=24h"

            );


        solarChart =
            createChart(

                "solar-chart",

                solar.meta,

                solar.points,

                solarChart

            );

    }


    catch (error) {

        console.log(
            error
        );

    }

}


/* =========================================================
   GRÁFICA PRINCIPAL
   ========================================================= */

async function loadMainChart() {

    const metric =
        element(
            "metric"
        ).value;


    const span =
        element(
            "span"
        ).value;


    try {

        const result =
            await fetchJSON(

                "/api/history"
                +
                "?metric="
                +
                encodeURIComponent(
                    metric
                )
                +
                "&span="
                +
                encodeURIComponent(
                    span
                )

            );


        text(
            "main-chart-title",
            result.meta.label
        );


        text(
            "main-chart-unit",

            result.meta.unit

            ?

            "Unidad: "
            +
            result.meta.unit

            :

            ""

        );


        mainChart =
            createChart(

                "main-chart",

                result.meta,

                result.points,

                mainChart

            );

    }


    catch (error) {

        showError(
            error.message
        );

    }

}


/* =========================================================
   TABLA
   ========================================================= */

function createTableHeader() {

    const head =
        element(
            "table-head"
        );


    head.innerHTML =
        "";


    const dateHeader =
        document.createElement(
            "th"
        );


    dateHeader.textContent =
        "Fecha y hora";


    head.appendChild(
        dateHeader
    );


    COLUMNS.forEach(

        column => {

            const th =
                document.createElement(
                    "th"
                );


            th.innerHTML =

                column.label

                +

                (
                    column.unit

                    ?

                    "<br><small>"
                    +
                    column.unit
                    +
                    "</small>"

                    :

                    ""
                );


            head.appendChild(
                th
            );

        }

    );

}


/* =========================================================
   CARGAR TABLA
   ========================================================= */

async function loadTable(
    page = 1
) {

    currentPage =
        page;


    const params =
        new URLSearchParams();


    params.set(
        "page",
        page
    );


    params.set(
        "per_page",
        element(
            "per-page"
        ).value
    );


    const start =
        element(
            "data-from"
        ).value;


    const end =
        element(
            "data-to"
        ).value;


    if (
        start
    ) {

        params.set(
            "desde",
            start
        );

    }


    if (
        end
    ) {

        params.set(
            "hasta",
            end
        );

    }


    try {

        const result =
            await fetchJSON(

                "/api/records?"
                +
                params.toString()

            );


        totalPages =
            result.pages;


        const body =
            element(
                "table-body"
            );


        body.innerHTML =
            "";


        result.records.forEach(

            row => {

                const tr =
                    document.createElement(
                        "tr"
                    );


                const timestamp =
                    document.createElement(
                        "td"
                    );


                timestamp.textContent =
                    row.timestamp;


                tr.appendChild(
                    timestamp
                );


                COLUMNS.forEach(

                    column => {

                        const td =
                            document.createElement(
                                "td"
                            );


                        const value =
                            row[
                                column.key
                            ];


                        td.textContent =

                            (
                                value === null
                                ||
                                value === undefined
                            )

                            ?

                            "--"

                            :

                            value;


                        tr.appendChild(
                            td
                        );

                    }

                );


                body.appendChild(
                    tr
                );

            }

        );


        text(

            "page-info",

            "Página "
            +
            result.page
            +
            " de "
            +
            result.pages
            +
            " · "
            +
            result.total
            +
            " registros"

        );

    }


    catch (error) {

        showError(
            error.message
        );

    }

}


/* =========================================================
   DESCARGAR ARCHIVO
   ========================================================= */

function download(
    extension,
    startElement,
    endElement
) {

    const params =
        new URLSearchParams();


    const start =
        element(
            startElement
        ).value;


    const end =
        element(
            endElement
        ).value;


    if (
        start
    ) {

        params.set(
            "desde",
            start
        );

    }


    if (
        end
    ) {

        params.set(
            "hasta",
            end
        );

    }


    const query =
        params.toString();


    window.location.href =

        "/descargar."
        +
        extension

        +

        (
            query

            ?

            "?"
            +
            query

            :

            ""
        );

}


/* =========================================================
   MAPA
   ========================================================= */

function popupContent(
    data
) {

    if (
        !data
    ) {

        return (

            "<strong>"
            +
            STATION.name
            +
            "</strong>"

        );

    }


    return (

        "<strong>"
        +
        STATION.name
        +
        "</strong>"

        +

        "<br><br>Temperatura: "
        +
        number(
            data.temp_out
        )
        +
        " °C"

        +

        "<br>Humedad: "
        +
        number(
            data.out_hum,
            0
        )
        +
        " %"

        +

        "<br>Viento: "
        +
        number(
            data.wind_speed_kmh
        )
        +
        " km/h "

        +
        (
            data.wind_dir
            ||
            ""
        )

        +

        "<br>Presión: "
        +
        number(
            data.bar
        )
        +
        " hPa"

        +

        "<br><br>Último registro:<br>"
        +
        data.timestamp

    );

}


function initializeMap() {

    if (
        map
    ) {

        setTimeout(

            () =>
                map.invalidateSize(),

            100

        );

        return;

    }


    map =
        L.map(
            "map"
        ).setView(

            [
                STATION.lat,
                STATION.lon
            ],

            16

        );


    L.tileLayer(

        "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",

        {

            maxZoom:
                19,

            attribution:
                "&copy; OpenStreetMap contributors"

        }

    ).addTo(
        map
    );


    mapMarker =
        L.marker(

            [
                STATION.lat,
                STATION.lon
            ]

        ).addTo(
            map
        );


    mapMarker
        .bindPopup(

            popupContent(
                latestData
            )

        )
        .openPopup();

}


/* =========================================================
   ESTADO DE BASE
   ========================================================= */

async function loadStatus() {

    try {

        const result =
            await fetchJSON(
                "/api/status"
            );


        text(

            "database-status",

            "WeatherLink 6.0.5"
            +
            " · "
            +
            result.database.records
            +
            " registros almacenados"

        );

    }


    catch (error) {

        console.log(
            error
        );

    }

}


/* =========================================================
   NAVEGACIÓN
   ========================================================= */

document
.querySelectorAll(
    ".main-nav button"
)
.forEach(

    button => {

        button.addEventListener(

            "click",

            function() {


                document
                .querySelectorAll(
                    ".main-nav button"
                )
                .forEach(

                    item =>
                        item.classList.remove(
                            "active"
                        )

                );


                this.classList.add(
                    "active"
                );


                document
                .querySelectorAll(
                    ".page"
                )
                .forEach(

                    page =>
                        page.classList.remove(
                            "active"
                        )

                );


                const target =
                    element(
                        this.dataset.page
                    );


                target.classList.add(
                    "active"
                );


                if (
                    this.dataset.page
                    ===
                    "charts"
                ) {

                    loadMainChart();

                }


                if (
                    this.dataset.page
                    ===
                    "data"
                ) {

                    loadTable(
                        1
                    );

                }


                if (
                    this.dataset.page
                    ===
                    "map-page"
                ) {

                    initializeMap();

                }

            }

        );

    }

);


/* =========================================================
   CREAR SELECTOR DE VARIABLES
   ========================================================= */

Object.entries(
    METRICS
)
.forEach(

    (
        [
            key,
            metadata
        ]
    ) => {

        const option =
            document.createElement(
                "option"
            );


        option.value =
            key;


        option.textContent =
            metadata.label;


        element(
            "metric"
        ).appendChild(
            option
        );

    }

);


/* =========================================================
   BOTONES
   ========================================================= */

element(
    "load-chart"
)
.addEventListener(

    "click",

    loadMainChart

);


element(
    "load-data"
)
.addEventListener(

    "click",

    () =>
        loadTable(
            1
        )

);


element(
    "previous"
)
.addEventListener(

    "click",

    () => {

        if (
            currentPage > 1
        ) {

            loadTable(
                currentPage - 1
            );

        }

    }

);


element(
    "next"
)
.addEventListener(

    "click",

    () => {

        if (
            currentPage
            <
            totalPages
        ) {

            loadTable(
                currentPage + 1
            );

        }

    }

);


element(
    "excel-data"
)
.addEventListener(

    "click",

    () =>
        download(

            "xlsx",

            "data-from",

            "data-to"

        )

);


element(
    "csv-data"
)
.addEventListener(

    "click",

    () =>
        download(

            "csv",

            "data-from",

            "data-to"

        )

);


element(
    "download-excel"
)
.addEventListener(

    "click",

    () =>
        download(

            "xlsx",

            "download-from",

            "download-to"

        )

);


element(
    "download-csv"
)
.addEventListener(

    "click",

    () =>
        download(

            "csv",

            "download-from",

            "download-to"

        )

);


/* =========================================================
   INICIO
   ========================================================= */

createTableHeader();


async function refresh() {

    await Promise.all([

        loadLatest(),

        loadSummary(),

        loadStatus()

    ]);

}


refresh();


loadMiniCharts();


/* =========================================================
   ACTUALIZACIÓN AUTOMÁTICA
   ========================================================= */

setInterval(

    async () => {

        await refresh();

        await loadMiniCharts();

    },

    60000

);


</script>


</body>

</html>
"""


# ============================================================
# PÁGINA PRINCIPAL
# ============================================================

@app.route("/")
def home():

    return render_template_string(

        HTML,

        site_title=
            SITE_TITLE,

        university_name=
            UNIVERSITY_NAME,

        department_name=
            DEPARTMENT_NAME,

        station_name=
            STATION_NAME,

        station_model=
            STATION_MODEL,

        station_lat=
            STATION_LAT,

        station_lon=
            STATION_LON,

        station_name_json=
            json.dumps(
                STATION_NAME,
                ensure_ascii=False
            ),

        columns_json=
            json.dumps(
                COLUMNS,
                ensure_ascii=False
            ),

        metrics_json=
            json.dumps(
                CHART_METRICS,
                ensure_ascii=False
            )

    )


# ============================================================
# ARRANQUE
# ============================================================

init_database()

# Primera importación
import_weatherlink()


# Hilo que revisará WeatherLink cada 30 segundos
thread = threading.Thread(

    target=
        automatic_import_loop,

    daemon=True

)

thread.start()


if __name__ == "__main__":

    print()
    print("=" * 70)

    print(
        "CENTRO DE METEOROLOGÍA UCA"
    )

    print("=" * 70)

    print(
        "Archivo WeatherLink:"
    )

    print(
        resolve_weatherlink_file()
        or
        "NO ENCONTRADO"
    )

    print()

    print(
        "Base de datos:"
    )

    print(
        DATABASE_FILE
    )

    print()

    print(
        "Página local:"
    )

    print(
        f"http://127.0.0.1:{PORT}"
    )

    print()

    print("=" * 70)

    app.run(

        host=
            "0.0.0.0",

        port=
            PORT,

        debug=
            False,

        use_reloader=
            False,

        threaded=
            True

    )