commit aef11ed05ad5380361919bd4c027109cc90b6330 Author: Óscar M. Lage Date: Thu Nov 28 13:52:58 2024 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1269488 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data diff --git a/README.md b/README.md new file mode 100644 index 0000000..38e1a9f --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Proyecto HeatingEvent + +Este proyecto gestiona eventos relacionados con el encendido y apagado de sistemas de calefacción, ofreciendo tres funcionalidades principales: una aplicación web, un bot de Telegram y un comando para importar datos desde un archivo CSV a la base de datos. + +## Comandos disponibles + +### 1. **Comando Web** + +Este comando inicia un servidor web que permite visualizar los eventos de calefacción a través de una interfaz web. + +#### Uso: +```bash +go run cmd/web/main.go +``` + +#### Opciones: + +- WEB_PORT: Puerto en el que se ejecuta el servidor web. Puedes configurarlo en el archivo .env. + +### 2. Comando Bot +Este comando inicia un bot de Telegram que permite interactuar con el sistema de calefacción a través de mensajes. + +#### Uso: +```sh +go run cmd/bot/main.go +``` + +#### Opciones: + +- TELEGRAM_TOKEN: Token de acceso del bot de Telegram. +- TELEGRAM_CHATID: ID del chat de Telegram donde el bot enviará los mensajes. + +### 3. Comando Import + +Este comando importa los datos de un archivo CSV (con formato específico) a la base de datos SQLite, creando eventos de encendido y apagado en función de la información proporcionada en el archivo. + +#### Uso: +``` +go run cmd/import/main.go -f /ruta/al/archivo.csv +-f, --file: Especifica la ruta del archivo CSV a importar. +--dry-run: Muestra los datos que se importarían sin hacer cambios en la base de datos (útil para verificar la información antes de insertarla). +``` + +#### Configuración + +1. Crear archivo .env +Copia el archivo .env.sample a .env y configura las variables de entorno necesarias: +```sh +cp .env.sample .env +``` +Luego edita el archivo .env con los valores correspondientes: +```ini +# Telegram Bot Configuration +TELEGRAM_TOKEN=xxx:yyy +TELEGRAM_CHATID=123123123 +# Web Server Configuration +WEB_PORT=9900 +``` + +2. Base de Datos +El proyecto utiliza una base de datos SQLite para almacenar los eventos de calefacción. La configuración de la base de datos se gestiona automáticamente a través del código en el archivo internal/config/db.go. Asegúrate de que el archivo de la base de datos se encuentra en la ubicación correcta para que la conexión funcione correctamente. + +### Dependencias + +Este proyecto utiliza varias dependencias de Go. Asegúrate de tenerlas instaladas y configuradas correctamente: + +```sh +go mod tidy +``` + +### Estructura del Proyecto + +```sh +. +├── cmd +│ ├── web # Comando para la aplicación web +│ ├── bot # Comando para el bot de Telegram +│ └── import # Comando para importar CSV a la base de datos +├── internal +│ ├── config # Configuración de la base de datos y otros parámetros +│ └── model # Modelos y lógica de negocio +├── .env.sample # Archivo de ejemplo para configuración del entorno +└── README.md # Este archivo +``` + +### Contribución + +Si deseas contribuir a este proyecto, por favor sigue estos pasos: + +1. Haz un fork del proyecto. +2. Crea una nueva rama (git checkout -b feature-nueva-caracteristica). +3. Realiza tus cambios y haz commit de ellos (git commit -am 'Añadir nueva característica'). +4. Haz push a la rama (git push origin feature-nueva-caracteristica). +5. Crea un pull request describiendo tus cambios. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..6a38489 --- /dev/null +++ b/TODO.md @@ -0,0 +1,7 @@ +# TODO + +- Evitar que se puedan hacer 2 ON seguidos ó 2 OFF seguidos +- Comprobar que la hora de OFF siempre es mayor que la hora de ON +- Abstraer los mensajes de conexión del main.go a un sitio más limpio +- Argumento de comando -d para debug +- BONUS, comando para importación de datos del excel que tengo diff --git a/cmd/bot/main.go b/cmd/bot/main.go new file mode 100644 index 0000000..b0913c9 --- /dev/null +++ b/cmd/bot/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "log" + "os" + "strconv" + + "heating-monitor/internal/bot" + "heating-monitor/internal/config" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/joho/godotenv" +) + +func main() { + // Cargar variables de entorno + err := godotenv.Load() + if err != nil { + log.Fatal("Error cargando el archivo .env") + } + + // Iniciar la base de datos + config.InitDB() + + // Iniciar el bot de Telegram + botAPI, err := tgbotapi.NewBotAPI(os.Getenv("TELEGRAM_TOKEN")) + if err != nil { + log.Panic(err) + } + + // Mensajes de conexión + botAPI.Debug = true + log.Printf("Bot conectado como %s. Esperando instrucciones...", botAPI.Self.UserName) + log.Printf("Autenticado como %s", botAPI.Self.UserName) + chatID, _ := strconv.ParseInt(os.Getenv("TELEGRAM_CHATID"), 10, 64) + msg := tgbotapi.NewMessage(chatID, "¡El bot está conectado y funcionando!") + if _, err := botAPI.Send(msg); err != nil { + log.Printf("Error al enviar el mensaje inicial: %v", err) + } + + // Iniciar el bot y pasar la instancia de la base de datos + bot.StartBot(botAPI, config.DB) +} diff --git a/cmd/import/main.go b/cmd/import/main.go new file mode 100644 index 0000000..4b4e627 --- /dev/null +++ b/cmd/import/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "encoding/csv" + "fmt" + "log" + "os" + "strings" + "time" + + "heating-monitor/internal/bot" + "heating-monitor/internal/config" + + "github.com/spf13/cobra" + "gorm.io/gorm" +) + +func main() { + var filePath string + var dryRun bool + + // Define el comando principal + var cmd = &cobra.Command{ + Use: "import", + Short: "Importa datos desde un CSV a la base de datos", + Run: func(cmd *cobra.Command, args []string) { + if filePath == "" { + log.Fatal("Debe especificar un archivo CSV con el flag -f o --file") + } + + // Abre el archivo CSV + file, err := os.Open(filePath) + if err != nil { + log.Fatalf("No se pudo abrir el archivo: %v", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.Comma = ';' // Usamos punto y coma como separador + + // Lee el archivo CSV + records, err := reader.ReadAll() + if err != nil { + log.Fatalf("Error al leer el archivo CSV: %v", err) + } + + // Ignorar la primera línea de encabezado + records = records[1:] + + insertedCount := 0 + // Procesa cada línea del CSV + for _, record := range records { + // Ignorar encabezado + if strings.HasPrefix(record[0], ";") { + continue + } + + // Parsear la fecha + fecha, err := time.Parse("2006-01-02", record[1]) + if err != nil { + log.Printf("Error al parsear la fecha: %v", err) + continue + } + + // Procesar las columnas ON/OFF + for i := 2; i <= 6; i += 3 { + if record[i] != "" && record[i+1] != "" { + // Parsear las horas ON/OFF + onTime, err := time.Parse("15:04", record[i]) + if err != nil { + log.Printf("Error al parsear hora ON: %v", err) + continue + } + + offTime, err := time.Parse("15:04", record[i+1]) + if err != nil { + log.Printf("Error al parsear hora OFF: %v", err) + continue + } + + // Ajustar los tiempos a la fecha correcta + onTimestamp := time.Date(fecha.Year(), fecha.Month(), fecha.Day(), onTime.Hour(), onTime.Minute(), 0, 0, time.UTC) + offTimestamp := time.Date(fecha.Year(), fecha.Month(), fecha.Day(), offTime.Hour(), offTime.Minute(), 0, 0, time.UTC) + + // Mostrar en dry-run + if dryRun { + fmt.Printf("ON: %v, OFF: %v\n", onTimestamp, offTimestamp) + } else { + // Inicializar la base de datos + config.InitDB() + + // Insertar en la base de datos + existsOn, err := eventExists(config.DB, "ON", onTimestamp) + if err != nil { + log.Printf("Error al verificar existencia de evento ON: %v", err) + continue + } + if !existsOn { + err := insertHeatingEvent(config.DB, "ON", onTimestamp) + if err != nil { + log.Printf("Error al insertar evento ON: %v", err) + continue + } + insertedCount++ + } else { + log.Printf("Evento ON ya existe: %v", onTimestamp) + } + + existsOff, err := eventExists(config.DB, "OFF", offTimestamp) + if err != nil { + log.Printf("Error al verificar existencia de evento OFF: %v", err) + continue + } + if !existsOff { + err := insertHeatingEvent(config.DB, "OFF", offTimestamp) + if err != nil { + log.Printf("Error al insertar evento OFF: %v", err) + continue + } + insertedCount++ + } else { + log.Printf("Evento OFF ya existe: %v", offTimestamp) + } + } + } + } + } + log.Printf("Se insertaron %d registros en la base de datos", insertedCount) + }, + } + + // Añadir flags al comando + cmd.Flags().StringVarP(&filePath, "file", "f", "", "Ruta del archivo CSV") + cmd.Flags().BoolVarP(&dryRun, "dry-run", "", false, "Simula la importación y muestra los datos") + + // Ejecutar el comando + if err := cmd.Execute(); err != nil { + log.Fatal(err) + } +} + +// insertHeatingEvent inserta un evento de calefacción en la base de datos +func insertHeatingEvent(db *gorm.DB, eventType string, timestamp time.Time) error { + event := bot.HeatingEvent{ + EventType: eventType, + Timestamp: timestamp, + } + + // Insertar el evento en la base de datos + if err := db.Create(&event).Error; err != nil { + return fmt.Errorf("no se pudo insertar el evento: %v", err) + } + return nil +} + +// eventExists verifica si un evento con el mismo tipo y timestamp ya existe +func eventExists(db *gorm.DB, eventType string, timestamp time.Time) (bool, error) { + var existingEvent bot.HeatingEvent + err := db.Where("event_type = ? AND timestamp = ?", eventType, timestamp).First(&existingEvent).Error + if err == nil { + // Si err es nil, significa que se encontró un evento existente + return true, nil + } else if err == gorm.ErrRecordNotFound { + // Si el error es ErrRecordNotFound, no existe el evento + return false, nil + } + // Si hay otro tipo de error + return false, err +} diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..aceebce --- /dev/null +++ b/cmd/web/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "log" + + "heating-monitor/internal/config" + "heating-monitor/internal/web" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +func main() { + // Cargar configuración + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Error al cargar la configuración: %v", err) + } + + // Inicializar la base de datos + config.InitDB() + + // Crear instancia de Echo + e := echo.New() + + // Middleware de logging + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // Rutas + e.GET("/", func(c echo.Context) error { + return web.GetEventsHandler(c, config.DB) + }) + e.GET("/events/edit/:id", func(c echo.Context) error { + return web.GetEventHandler(c, config.DB) + }) + e.POST("/events/edit/:id", func(c echo.Context) error { + return web.UpdateEventHandler(c, config.DB) + }) + e.GET("/about", func(c echo.Context) error { + return web.AboutHandler(c, config.DB) + }) + + // Iniciar servidor + log.Printf("Servidor web escuchando en http://localhost:%s", cfg.WebPort) + if err := e.Start(":" + cfg.WebPort); err != nil { + log.Fatalf("Error al iniciar el servidor web: %v", err) + } +} diff --git a/env.sample b/env.sample new file mode 100644 index 0000000..d616090 --- /dev/null +++ b/env.sample @@ -0,0 +1,12 @@ +TELEGRAM_TOKEN=aaaa:bbbb +TELEGRAM_CHATID=123123 + +WEB_PORT=9900 + +PROGRAM_AUTHOR="oscarmlage" +PROGRAM_LINK="https://oscarmlage.com" +PROGRAM_AVATAR="https://mastodon.bofhers.es/system/accounts/avatars/108/369/580/175/949/602/original/01f8e5e7fcc34119.jpg" +PROGRAM_NAME="Heating monitor" +PROGRAM_VERSION="1.0.0" +PROGRAM_YEAR="2024" +PROGRAM_TECHNOLOGIES="Go, SQLite, TailwindCSS, FontAwesome, ChartJS" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c9e254a --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module heating-monitor + +go 1.21.4 + +require ( + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/jinzhu/gorm v1.9.16 + github.com/joho/godotenv v1.5.1 + github.com/labstack/echo/v4 v4.12.0 + github.com/spf13/cobra v1.8.1 + gorm.io/driver/sqlite v1.5.6 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.29.0 // indirect + golang.org/x/net v0.31.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + golang.org/x/time v0.5.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5d8c96b --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= +github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= +gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/internal/bot/controller.go b/internal/bot/controller.go new file mode 100644 index 0000000..d02961f --- /dev/null +++ b/internal/bot/controller.go @@ -0,0 +1,48 @@ +package bot + +import ( + "log" + "strings" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "gorm.io/gorm" +) + +func ProcesarMensaje(update tgbotapi.Update, db *gorm.DB) { + if update.Message == nil || update.Message.Text == "" { + return + } + + parts := strings.Fields(update.Message.Text) + command := strings.ToUpper(parts[0]) + var timestamp time.Time + + if len(parts) == 1 { + timestamp = update.Message.Time() + } else { + parsedTime, err := time.Parse("15:04", parts[1]) + if err != nil { + log.Println("Formato de hora inválido:", parts[1]) + return + } + now := time.Now() + timestamp = time.Date(now.Year(), now.Month(), now.Day(), parsedTime.Hour(), parsedTime.Minute(), 0, 0, now.Location()) + } + + if command != "ON" && command != "OFF" { + log.Println("Comando desconocido:", command) + return + } + + event := HeatingEvent{ + EventType: command, + Timestamp: timestamp, + } + if err := db.Create(&event).Error; err != nil { + log.Printf("Error al guardar el evento %s: %v", command, err) + return + } + + log.Printf("Evento %s registrado con éxito a las %s", command, timestamp.Format("15:04")) +} diff --git a/internal/bot/model.go b/internal/bot/model.go new file mode 100644 index 0000000..3a20ae4 --- /dev/null +++ b/internal/bot/model.go @@ -0,0 +1,13 @@ +package bot + +import ( + "time" +) + +// HeatingEvent representa un evento de calefacción (ON/OFF) +type HeatingEvent struct { + ID uint `gorm:"primaryKey" json:"id" form:"id"` + EventType string `gorm:"type:varchar(3);not null" json:"event_type" form:"event_type"` + Timestamp time.Time `gorm:"not null" json:"timestamp" form:"timestamp"` + CreatedAt time.Time +} diff --git a/internal/bot/routes.go b/internal/bot/routes.go new file mode 100644 index 0000000..9f1c7cf --- /dev/null +++ b/internal/bot/routes.go @@ -0,0 +1,22 @@ +package bot + +import ( + "log" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "gorm.io/gorm" +) + +func StartBot(bot *tgbotapi.BotAPI, db *gorm.DB) { + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := bot.GetUpdatesChan(u) + + for update := range updates { + if update.Message != nil { + log.Printf("Mensaje recibido: %s", update.Message.Text) + ProcesarMensaje(update, db) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6b6e6fc --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,45 @@ +package config + +import ( + "log" + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + TelegramToken string + ChatID string + DBPath string + WebPort string + ProgramAuthor string + ProgramLink string + ProgramAvatar string + ProgramName string + ProgramVersion string + ProgramYear string + ProgramTechnologies string +} + +func LoadConfig() (*Config, error) { + err := godotenv.Load() + if err != nil { + log.Println("No se pudo cargar el archivo .env, usando variables de entorno") + } + + cfg := &Config{ + TelegramToken: os.Getenv("TELEGRAM_TOKEN"), + ChatID: os.Getenv("CHAT_ID"), + DBPath: os.Getenv("DB_PATH"), + WebPort: os.Getenv("WEB_PORT"), + ProgramAuthor: os.Getenv("PROGRAM_AUTHOR"), + ProgramLink: os.Getenv("PROGRAM_LINK"), + ProgramAvatar: os.Getenv("PROGRAM_AVATAR"), + ProgramName: os.Getenv("PROGRAM_NAME"), + ProgramVersion: os.Getenv("PROGRAM_VERSION"), + ProgramYear: os.Getenv("PROGRAM_YEAR"), + ProgramTechnologies: os.Getenv("PROGRAM_TECHNOLOGIES"), + } + + return cfg, nil +} diff --git a/internal/config/db.go b/internal/config/db.go new file mode 100644 index 0000000..5dbd2f2 --- /dev/null +++ b/internal/config/db.go @@ -0,0 +1,48 @@ +package config + +import ( + "heating-monitor/internal/bot" + "log" + "os" + "path/filepath" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDB() { + // Crear directorio 'data' si no existe + dbDir := "data" + if _, err := os.Stat(dbDir); os.IsNotExist(err) { + err := os.Mkdir(dbDir, 0755) + if err != nil { + log.Fatalf("Error creando el directorio %s: %v", dbDir, err) + } + } + + // Ruta de la base de datos + dbPath := filepath.Join(dbDir, "heating.db") + + // Conectar con SQLite + var err error + DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + log.Fatal("Error al conectar con la base de datos:", err) + } + + log.Println("Base de datos inicializada en", dbPath) + + // Migrar el modelo HeatingEvent + if err := DB.AutoMigrate(&bot.HeatingEvent{}); err != nil { + log.Fatalf("Error al migrar el modelo HeatingEvent: %v", err) + } + + // Verificar que la base de datos está abierta correctamente + if DB != nil { + log.Println("Conexión a la base de datos establecida correctamente.") + } else { + log.Fatal("La conexión a la base de datos es nula.") + } +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go new file mode 100644 index 0000000..8913ff2 --- /dev/null +++ b/internal/repository/repository.go @@ -0,0 +1,31 @@ +package repository + +import ( + "heating-monitor/internal/bot" + + "gorm.io/gorm" +) + +// GetEventByID obtiene un evento de la base de datos por su ID +func GetEventByID(db *gorm.DB, id int) (*bot.HeatingEvent, error) { + var event bot.HeatingEvent + if err := db.First(&event, id).Error; err != nil { + return nil, err + } + return &event, nil +} + +// UpdateEventByID actualiza un evento por su ID +func UpdateEventByID(db *gorm.DB, id int, updatedEvent *bot.HeatingEvent) (*bot.HeatingEvent, error) { + var event bot.HeatingEvent + if err := db.First(&event, id).Error; err != nil { + return nil, err + } + // Actualizamos los campos relevantes del evento + event.EventType = updatedEvent.EventType + event.Timestamp = updatedEvent.Timestamp + if err := db.Save(&event).Error; err != nil { + return nil, err + } + return &event, nil +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..f93d8a7 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,20 @@ +package utils + +import ( + "encoding/json" + "fmt" +) + +func Debug(v interface{}, args ...string) { + // utils.Debug(variable) + debug, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Println("Error marshaling JSON:", err) + return + } + // Print a title if there is one + if len(args) > 0 { + fmt.Printf("%s\n", args[0]) + } + fmt.Printf("DEBUG:\n%s\n", string(debug)) +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 0000000..0c76838 --- /dev/null +++ b/internal/web/handlers.go @@ -0,0 +1,179 @@ +package web + +import ( + "log" + "math" + "net/http" + "strconv" + "time" + + "heating-monitor/internal/bot" + "heating-monitor/internal/config" + "heating-monitor/internal/repository" + + "github.com/labstack/echo/v4" + "gorm.io/gorm" +) + +func GetEventsHandler(c echo.Context, db *gorm.DB) error { + // Verificar que la base de datos esté correctamente inicializada + if db == nil { + log.Printf("Error: base de datos no inicializada") + return c.JSON(http.StatusInternalServerError, "Error al conectar con la base de datos") + } + + // Obtener los parámetros de entrada + pageNum, sizeNum := getPaginationParams(c, 10) + filter := c.QueryParam("filter") + search := c.QueryParam("search") + startDateStr := c.QueryParam("startDate") + endDateStr := c.QueryParam("endDate") + + // Construir y aplicar filtros dinámicos + query := applyFilters(db.Model(&bot.HeatingEvent{}), filter, search, startDateStr, endDateStr) + + // Crear una consulta separada para todos los eventos (sin paginación) + var allEvents []bot.HeatingEvent + err := query.Find(&allEvents).Error + if err != nil { + log.Printf("Error al obtener todos los eventos filtrados: %v", err) + return c.JSON(http.StatusInternalServerError, "Error al obtener eventos completos") + } + + // Calcular el tiempo total de encendido para todos los eventos filtrados + totalHours, totalMinutes := calculateTotalOnHours(allEvents) + + // Obtener el total de resultados antes de la paginación + var count int64 + err = query.Count(&count).Error + if err != nil { + log.Printf("Error al contar eventos: %v", err) + return c.JSON(http.StatusInternalServerError, "Error al contar eventos") + } + + // Calcular offset y limitar resultados + offset := (pageNum - 1) * sizeNum + var events []bot.HeatingEvent + //err = query.Offset(offset).Limit(sizeNum).Find(&events).Error + err = query.Order("timestamp DESC").Offset(offset).Limit(sizeNum).Find(&events).Error + if err != nil { + log.Printf("Error al obtener eventos: %v", err) + return c.JSON(http.StatusInternalServerError, "Error al obtener eventos") + } + + // Calcular la gráfica de consumos + consumptionData, isGroupedByMonth := calculateConsumptionData(allEvents) + // Separar las etiquetas y los totales + labels := make([]string, 0, len(consumptionData)) + totals := make([]float64, 0, len(consumptionData)) + for label, total := range consumptionData { + labels = append(labels, label) + totals = append(totals, total) + } + + // Calcular paginación + totalPages := int(math.Ceil(float64(count) / float64(sizeNum))) + prevPage := pageNum - 1 + if prevPage < 1 { + prevPage = 1 + } + nextPage := pageNum + 1 + if nextPage > totalPages { + nextPage = totalPages + } + + // Renderizar en la plantilla + return LoadTemplate(c, "view.html", map[string]interface{}{ + "events": events, + "page": pageNum, + "totalPages": totalPages, + "size": sizeNum, + "prevPage": prevPage, + "nextPage": nextPage, + "count": count, + "filter": filter, + "search": search, + "startDate": startDateStr, + "endDate": endDateStr, + "totalHours": totalHours, + "totalMinutes": totalMinutes, + "labels": labels, + "totals": totals, + "isGroupedByMonth": isGroupedByMonth, + }) +} + +// GetEventHandler obtiene un evento por ID y muestra la página de edición +func GetEventHandler(c echo.Context, db *gorm.DB) error { + // Obtener el ID del evento + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("Error al convertir ID: %v", err) + return c.JSON(http.StatusBadRequest, "ID inválido") + } + + // Buscar el evento usando el repositorio + event, err := repository.GetEventByID(db, id) + if err != nil { + log.Printf("Error al obtener evento con ID %d: %v", id, err) + return c.JSON(http.StatusNotFound, "Evento no encontrado") + } + + // Renderizar los eventos en la plantilla + return LoadTemplate(c, "edit.html", event) +} + +// UpdateEventHandler actualiza un evento en la base de datos +func UpdateEventHandler(c echo.Context, db *gorm.DB) error { + // Obtener el ID del evento + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("Error al convertir ID: %v", err) + return c.JSON(http.StatusBadRequest, "ID inválido") + } + + // Obtener los datos del evento desde el formulario + event := new(bot.HeatingEvent) + + // Convertir el timestamp manualmente desde la cadena + timestampStr := c.FormValue("timestamp") + timestamp, err := time.Parse("2006-01-02T15:04", timestampStr) + if err != nil { + log.Printf("Error al convertir el timestamp: %v", err) + return c.JSON(http.StatusBadRequest, "Formato de fecha inválido") + } + + // Asignar el timestamp convertido + event.Timestamp = timestamp + event.EventType = c.FormValue("event_type") + + // Actualizar el evento usando el repositorio + _, err = repository.UpdateEventByID(db, id, event) + if err != nil { + log.Printf("Error al actualizar evento con ID %d: %v", id, err) + return c.JSON(http.StatusInternalServerError, "Error al actualizar evento") + } + + return c.Redirect(http.StatusFound, "/") +} + +func AboutHandler(c echo.Context, db *gorm.DB) error { + // Cargar configuración desde .env + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Error al cargar la configuración: %v", err) + } + + // Pasar los datos de configuración a la plantilla + data := map[string]interface{}{ + "ProgramAuthor": cfg.ProgramAuthor, + "ProgramLink": cfg.ProgramLink, + "ProgramAvatar": cfg.ProgramAvatar, + "ProgramName": cfg.ProgramName, + "ProgramVersion": cfg.ProgramVersion, + "ProgramYear": cfg.ProgramYear, + "ProgramTechnologies": cfg.ProgramTechnologies, + } + + return LoadTemplate(c, "about.html", data) +} diff --git a/internal/web/templates/about.html b/internal/web/templates/about.html new file mode 100644 index 0000000..f1c3442 --- /dev/null +++ b/internal/web/templates/about.html @@ -0,0 +1,32 @@ +{{ template "header.html" . }} +
+ +
+ +
+ + +
+ Avatar +
+ + +
+
    +
  • Author: {{ .ProgramAuthor }}
  • +
  • Program: {{.ProgramName}}
  • +
  • Version: {{.ProgramVersion}}
  • +
  • Year: {{.ProgramYear}}
  • +
  • Stack: {{.ProgramTechnologies}}
  • +
+
+
+ +
+ +
+ +{{ template "footer.html" . }} diff --git a/internal/web/templates/edit.html b/internal/web/templates/edit.html new file mode 100644 index 0000000..d0be53e --- /dev/null +++ b/internal/web/templates/edit.html @@ -0,0 +1,23 @@ +{{ template "header.html" . }} + +
+

Editar Evento

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+
+ +{{ template "footer.html" . }} diff --git a/internal/web/templates/partials/footer.html b/internal/web/templates/partials/footer.html new file mode 100644 index 0000000..d549cb0 --- /dev/null +++ b/internal/web/templates/partials/footer.html @@ -0,0 +1,4 @@ + + + + diff --git a/internal/web/templates/partials/header.html b/internal/web/templates/partials/header.html new file mode 100644 index 0000000..c8d2bb6 --- /dev/null +++ b/internal/web/templates/partials/header.html @@ -0,0 +1,21 @@ + + + + + + Heating monitor + + + + + + +
+ +
diff --git a/internal/web/templates/view.html b/internal/web/templates/view.html new file mode 100644 index 0000000..89008df --- /dev/null +++ b/internal/web/templates/view.html @@ -0,0 +1,244 @@ +{{ template "header.html" . }} + + + + +
+ +
+ +
+ + +
+ + +
+
+
+ +
+ +
+ +
+ + +
+
+ + + +
+ +
+

+ Total de horas encendidas: {{ .totalHours }} horas y + {{ .totalMinutes }} minutos +

+
+ + + + + + + + + + + + {{range .events}} + + + + + + + {{end}} + + + + + + +
IDFechaStampAcción
{{.ID}}{{.Timestamp}}{{.EventType}}Editar
+
+ + Mostrando {{len .events}} + eventos de {{ .count }} + + +
+ + + Primera + + + + + Anterior + + + + + Página {{.page}} de {{.totalPages}} + + + + + Siguiente + + + + + Última + +
+
+
+ + + + + +{{ template "footer.html" . }} diff --git a/internal/web/utils.go b/internal/web/utils.go new file mode 100644 index 0000000..1a68151 --- /dev/null +++ b/internal/web/utils.go @@ -0,0 +1,275 @@ +package web + +import ( + "encoding/json" + "heating-monitor/internal/bot" + "html/template" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "time" + + "github.com/labstack/echo/v4" + "gorm.io/gorm" +) + +// Función para formatear la fecha en el formato necesario para el campo datetime-local +func FormatDate(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02T15:04") +} + +// Función para convertir un valor a JSON +func ToJson(v interface{}) template.JS { + b, err := json.Marshal(v) + if err != nil { + log.Printf("Error al convertir a JSON: %v", err) + return template.JS("[]") + } + return template.JS(b) +} + +// Función para cargar todas las plantillas desde un directorio +func loadTemplatesFromDir(dir string) ([]string, error) { + var templates []string + + // Buscar todos los archivos con extensión .html en el directorio y subdirectorios + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && filepath.Ext(path) == ".html" { + templates = append(templates, path) + } + return nil + }) + + return templates, err +} + +// Cargar las plantillas y los partials de los directorios "templates" y "partials" +func LoadTemplate(c echo.Context, templateName string, data interface{}) error { + // Creamos un mapa con las funciones que vamos a pasar al template + funcs := template.FuncMap{ + "date": FormatDate, // Asignamos la función FormatDate con el nombre "date" + "json": ToJson, // Agregamos la función ToJson con el nombre "json" + } + + // Cargar las plantillas desde los directorios + templates, err := loadTemplatesFromDir("internal/web/templates") + if err != nil { + log.Printf("Error al cargar las plantillas: %v", err) + return err + } + + // Parsear las plantillas con las funciones + tmpl, err := template.New(templateName).Funcs(funcs).ParseFiles(templates...) + if err != nil { + log.Printf("Error al parsear las plantillas: %v", err) + return err + } + + // Ejecutamos el template y lo renderizamos + return tmpl.Execute(c.Response(), data) +} + +func getPaginationParams(c echo.Context, defaultPageSize int) (int, int) { + page := c.QueryParam("page") + size := c.QueryParam("size") + + pageNum, err := strconv.Atoi(page) + if err != nil || pageNum < 1 { + pageNum = 1 + } + + sizeNum, err := strconv.Atoi(size) + if err != nil || sizeNum < 1 { + sizeNum = defaultPageSize + } + + return pageNum, sizeNum +} + +func getDateFilter(filter string) (time.Time, time.Time) { + now := time.Now() + var startDate, endDate time.Time + + switch filter { + case "today": + startDate = now.Truncate(24 * time.Hour) + endDate = startDate.Add(24 * time.Hour) + case "yesterday": + endDate = now.Truncate(24 * time.Hour) + startDate = endDate.Add(-24 * time.Hour) + case "this-week": + weekday := int(now.Weekday()) + startDate = now.AddDate(0, 0, -weekday).Truncate(24 * time.Hour) + endDate = startDate.AddDate(0, 0, 7) + case "this-month": + startDate = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + endDate = startDate.AddDate(0, 1, 0) + case "this-year": + startDate = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()) + endDate = startDate.AddDate(1, 0, 0) + } + + return startDate, endDate +} + +func applyFilters(query *gorm.DB, filter string, search string, startDateStr string, endDateStr string) *gorm.DB { + // Filtrar por fechas si se proporcionaron + if startDateStr != "" && endDateStr != "" { + // Convertir las fechas de string a time.Time + startDate, err := time.Parse("2006-01-02", startDateStr) // Formato esperado: "YYYY-MM-DD" + if err != nil { + log.Printf("Error al parsear startDate: %v", err) + } + endDate, err := time.Parse("2006-01-02", endDateStr) + if err != nil { + log.Printf("Error al parsear endDate: %v", err) + } + // Establecemos la hora a las 23:59:59 + endDate = endDate.Add(23 * time.Hour).Add(59 * time.Minute).Add(59 * time.Second) + + // Filtrar eventos por el rango de fechas + query = query.Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) + } + + // Filtrar por búsqueda (si se proporcionó) + if search != "" { + query = query.Where("event_type LIKE ?", "%"+search+"%") + } + + // Filtrar por otros filtros (como "Hoy", "Este mes", etc.), si se aplica + if filter != "" { + startDate, endDate := getDateFilter(filter) + if !startDate.IsZero() && !endDate.IsZero() { + query = query.Where("timestamp >= ? AND timestamp < ?", startDate, endDate) + } + } + + return query +} + +func calculateTotalOnHours(events []bot.HeatingEvent) (int, int) { + if len(events) == 0 { + return 0, 0 + } + + // Ordenar los eventos por Timestamp + sort.Slice(events, func(i, j int) bool { + return events[i].Timestamp.Before(events[j].Timestamp) + }) + + var totalDuration time.Duration + var lastOnTime time.Time // Usamos una variable no-puntero + + for _, event := range events { + + if event.EventType == "ON" { + // Guardamos la fecha del evento ON en lastOnTime como una copia (no puntero) + lastOnTime = event.Timestamp + } else if event.EventType == "OFF" && !lastOnTime.IsZero() { + // Verificar si los tiempos son realmente distintos + utcLastOnTime := lastOnTime.UTC() + utcCurrent := event.Timestamp.UTC() + + // Asegúrate de que no estemos comparando el mismo segundo + if utcCurrent.After(utcLastOnTime) { + duration := utcCurrent.Sub(utcLastOnTime) + + if duration > 0 { + totalDuration += duration + } + } + + // Resetear lastOnTime después de usarlo + lastOnTime = time.Time{} + } + } + + // Calcular horas y minutos + totalHours := totalDuration.Hours() // Total en horas con decimales + hours := int(totalHours) // Parte entera: horas completas + minutes := int((totalHours - float64(hours)) * 60) // Parte decimal convertida a minutos + + // Devolver resultado + return hours, minutes +} + +func calculateConsumptionData(events []bot.HeatingEvent) (map[string]float64, bool) { + if len(events) == 0 { + return map[string]float64{}, false + } + + // Ordenar los eventos por Timestamp (aseguramos el orden cronológico) + sort.Slice(events, func(i, j int) bool { + return events[i].Timestamp.Before(events[j].Timestamp) + }) + + // Variable para determinar si agrupamos por mes + isGroupedByMonth := determineGrouping(events) + + // Mapas para acumular consumos + consumptionByKey := make(map[string]float64) // Mapa general para día o mes + monthAggregation := make(map[string]float64) // Solo para el caso de agrupación por mes + + var lastOnTime time.Time + + for _, event := range events { + if event.EventType == "ON" { + // Guardamos el evento ON en la variable temporal + lastOnTime = event.Timestamp + } else if event.EventType == "OFF" && !lastOnTime.IsZero() { + // Calcular el consumo para este intervalo ON -> OFF + duration := event.Timestamp.Sub(lastOnTime).Hours() + + // Determinar la clave para agrupar (día o mes) + var key string + if isGroupedByMonth { + key = lastOnTime.Format("January 2006") // Agrupado por mes + monthAggregation[key] += duration // Sumar al total del mes + } else { + key = lastOnTime.Format("2006-01-02") // Agrupado por día + consumptionByKey[key] += duration // Sumar al total del día + } + + // Reiniciar lastOnTime + lastOnTime = time.Time{} + } + } + + // Si estamos agrupando por mes, transferimos los valores de monthAggregation a consumptionByKey + if isGroupedByMonth { + for month, total := range monthAggregation { + consumptionByKey[month] = total + } + } + + return consumptionByKey, isGroupedByMonth +} + +// Función para determinar si los eventos deben agruparse por mes o por día +func determineGrouping(events []bot.HeatingEvent) bool { + // Usamos un mapa para almacenar pares de año y mes únicos + uniqueMonths := make(map[string]struct{}) + + // Recorremos todos los eventos y extraemos el año y mes + for _, event := range events { + // Extraemos el mes y el año del timestamp + yearMonth := event.Timestamp.Format("2006-01") // Formato "Año-Mes" + uniqueMonths[yearMonth] = struct{}{} // Usamos un map para asegurarnos de que no se repita + + // Si ya tenemos más de un mes, terminamos + if len(uniqueMonths) > 1 { + return true // Agrupar por mes + } + } + + // Si solo hay un mes único, agrupamos por día + return false +}