Initial commit

This commit is contained in:
Óscar M. Lage 2024-11-28 13:52:58 +01:00
commit aef11ed05a
23 changed files with 1497 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
data

94
README.md Normal file
View File

@ -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.

7
TODO.md Normal file
View File

@ -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

43
cmd/bot/main.go Normal file
View File

@ -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)
}

169
cmd/import/main.go Normal file
View File

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

49
cmd/web/main.go Normal file
View File

@ -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)
}
}

12
env.sample Normal file
View File

@ -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"

32
go.mod Normal file
View File

@ -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
)

85
go.sum Normal file
View File

@ -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=

View File

@ -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"))
}

13
internal/bot/model.go Normal file
View File

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

22
internal/bot/routes.go Normal file
View File

@ -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)
}
}
}

45
internal/config/config.go Normal file
View File

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

48
internal/config/db.go Normal file
View File

@ -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.")
}
}

View File

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

20
internal/utils/utils.go Normal file
View File

@ -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))
}

179
internal/web/handlers.go Normal file
View File

@ -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)
}

View File

@ -0,0 +1,32 @@
{{ template "header.html" . }}
<div class="container mx-auto my-8 p-6 bg-white rounded-lg shadow-lg">
<div class="summary max-w-4xl mx-auto bg-gray-100 border-l-4 border-blue-500 p-6 rounded-lg shadow-lg mb-4">
<div class="flex flex-col md:flex-row md:space-x-8 items-center">
<!-- Avatar -->
<div class="w-40 h-40 md:w-48 md:h-48 rounded-full overflow-hidden bg-gray-300">
<img src="{{ .ProgramAvatar }}"
alt="Avatar"
class="w-full h-full object-cover">
</div>
<!-- Información -->
<div class="flex flex-col space-y-4 mt-4 md:mt-0 text-center md:text-left">
<ul class="list-none space-y-2">
<li><strong>Author:</strong> <a
href="{{ .ProgramLink }}">{{ .ProgramAuthor }}</a></li>
<li><strong>Program:</strong> {{.ProgramName}}</li>
<li><strong>Version:</strong> {{.ProgramVersion}}</li>
<li><strong>Year:</strong> {{.ProgramYear}}</li>
<li><strong>Stack:</strong> {{.ProgramTechnologies}}</li>
</ul>
</div>
</div>
</div>
</div>
{{ template "footer.html" . }}

View File

@ -0,0 +1,23 @@
{{ template "header.html" . }}
<div class="container mx-auto my-8 p-6 bg-white rounded-lg shadow-lg">
<h1 class="text-2xl font-semibold text-gray-800 mb-6">Editar Evento</h1>
<form method="POST" action="/events/edit/{{.ID}}" class="space-y-6">
<!-- Campo EventType -->
<div>
<label for="event_type" class="block text-sm font-medium text-gray-700">Tipo de Evento</label>
<input type="text" id="event_type" name="event_type" value="{{.EventType}}" class="mt-1 p-2 block w-full border-2 border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all duration-200 ease-in-out">
</div>
<!-- Campo Timestamp -->
<div>
<label for="timestamp" class="block text-sm font-medium text-gray-700">Fecha y Hora</label>
<input type="datetime-local" id="timestamp" name="timestamp" value="{{.Timestamp | date }}" class="mt-1 p-2 block w-full border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<button class="w-full py-2 px-4 bg-indigo-600 text-white font-semibold rounded-lg shadow-md hover:bg-indigo-700 transition-colors duration-300 ease-in-out">Actualizar</button>
</form>
</div>
{{ template "footer.html" . }}

View File

@ -0,0 +1,4 @@
</body>
</html>

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heating monitor</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/@heroicons/react@1.0.6/outline/index.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 font-sans">
<header class="bg-indigo-600 text-white p-4 shadow-lg">
<div class="container mx-auto flex justify-between items-center">
<h1 class="text-xl font-semibold"><a href="/">Heating monitor</a></h1>
<nav class="flex space-x-4">
<a href="/" class="text-white hover:bg-indigo-500 hover:text-white py-2 px-4 rounded transition-colors">Inicio</a>
<a href="/about" class="text-white hover:bg-indigo-500 hover:text-white py-2 px-4 rounded transition-colors">About</a>
</nav>
</div>
</header>

View File

@ -0,0 +1,244 @@
{{ template "header.html" . }}
<script>
document.addEventListener("DOMContentLoaded", function () {
const button = document.getElementById("filterDropdownButton");
const menu = document.getElementById("filterDropdownMenu");
button.addEventListener("click", () => {
menu.classList.toggle("hidden");
});
// Cerrar el menú si haces clic fuera de él
document.addEventListener("click", (event) => {
if (!button.contains(event.target) && !menu.contains(event.target)) {
menu.classList.add("hidden");
}
});
});
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<div class="container mx-auto my-8 p-6 bg-white rounded-lg shadow-lg">
<div class="flex justify-between items-center mb-4">
<!-- Dropdown de filtros -->
<div class="relative">
<button id="filterDropdownButton"
class="inline-flex items-center px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded hover:bg-blue-600">
Filtrar por
<svg class="w-4 h-4 ml-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 011.414 0L10 13.414l3.293-3.707a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<div id="filterDropdownMenu"
class="hidden absolute mt-2 w-48 bg-white rounded shadow-lg border">
<a href="/?filter=today" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Hoy</a>
<a href="/?filter=yesterday" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Ayer</a>
<a href="/?filter=this-week" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Esta semana</a>
<a href="/?filter=this-month" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Este mes</a>
<a href="/?filter=this-year" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Este año</a>
<a href="/?startDate=2022-10-01&endDate=2023-05-31" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">2022-2023</a>
<a href="/?startDate=2023-10-01&endDate=2024-05-31" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">2023-2024</a>
<a href="/?startDate=2024-10-01&endDate=2025-05-31" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">2024-2025</a>
</div>
</div>
<!-- Filtros por fechas -->
<div class="flex space-x-4">
<form action="/" method="GET" class="flex space-x-2 items-center">
<div>
<input type="date" id="startDate" name="startDate" class="px-4 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-10" />
</div>
<div>
<input type="date" id="endDate" name="endDate" class="px-4 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-10" />
</div>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded hover:bg-blue-600 h-10">
Filtrar fechas
</button>
</form>
</div>
<!-- Campo para búsquedas (opcional), visible solo en pantallas grandes -->
<div class="hidden lg:block">
<form action="/" method="GET">
<input type="text" name="search"
placeholder="Buscar eventos..."
class="px-4 py-2 border border-gray-300 rounded w-64 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="submit" class="px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded hover:bg-blue-600">
Buscar
</button>
</form>
</div>
</div>
<div class="summary bg-gray-100 border-l-4 border-blue-500 p-4 rounded-lg shadow-lg mb-4">
<p class="text-xl font-semibold text-gray-700">
Total de horas encendidas: <span class="text-blue-600">{{ .totalHours }} horas</span> y
<span class="text-blue-600">{{ .totalMinutes }} minutos</span>
</p>
</div>
<table class="min-w-full divide-y divide-gray-200 border border-gray-300">
<thead class="bg-blue-600 text-white">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">ID</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium
uppercase tracking-wider">Fecha</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium
uppercase tracking-wider">Stamp</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium
uppercase tracking-wider">Acción</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{{range .events}}
<tr class="hover:bg-gray-100">
<td class="px-6 py-4 text-sm text-gray-700">{{.ID}}</td>
<td class="px-6 py-4 text-sm text-gray-700">{{.Timestamp}}</td>
<td class="px-6 py-4 text-sm text-gray-700">{{.EventType}}</td>
<td class="px-6 py-4 text-sm text-gray-700"><a href="/events/edit/{{.ID}}">Editar</a></td>
</tr>
{{end}}
</tbody>
<tfoot class="bg-blue-600 text-white">
<tr>
<td colspan="4" class="px-6 py-3 text-center">
<div class="flex items-center justify-between">
<!-- Mostrando registros -->
<span class="text-sm text-white opacity-80">Mostrando {{len .events}}
eventos de {{ .count }}</span>
<!-- Paginador -->
<div class="flex space-x-2">
<!-- Primera página -->
<a href="/?page=1&size={{.size}}{{if .search}}&search={{.search}}{{end}}{{if .startDate}}&startDate={{.startDate}}{{end}}{{if .endDate}}&endDate={{.endDate}}{{end}}{{if .filter}}&filter={{.filter}}{{end}}"
class="px-3 py-1 text-sm bg-blue-500 text-white
rounded hover:bg-blue-600 {{if eq .page 1}}cursor-not-allowed opacity-50{{end}}">
Primera
</a>
<!-- Página anterior -->
<a href="/?page={{.prevPage}}&size={{.size}}{{if .search}}&search={{.search}}{{end}}{{if .startDate}}&startDate={{.startDate}}{{end}}{{if .endDate}}&endDate={{.endDate}}{{end}}{{if .filter}}&filter={{.filter}}{{end}}"
class="px-3 py-1 text-sm bg-blue-500 text-white
rounded hover:bg-blue-600 {{if eq .page 1}}cursor-not-allowed opacity-50{{end}}">
Anterior
</a>
<!-- Información de la página actual -->
<span class="px-3 py-1 text-sm text-white opacity-80">
Página {{.page}} de {{.totalPages}}
</span>
<!-- Página siguiente -->
<a href="/?page={{.nextPage}}&size={{.size}}{{if .search}}&search={{.search}}{{end}}{{if .startDate}}&startDate={{.startDate}}{{end}}{{if .endDate}}&endDate={{.endDate}}{{end}}{{if .filter}}&filter={{.filter}}{{end}}"
class="px-3 py-1 text-sm bg-blue-500 text-white
rounded hover:bg-blue-600 {{if eq .page .totalPages}}cursor-not-allowed opacity-50{{end}}">
Siguiente
</a>
<!-- Última página -->
<a href="/?page={{.totalPages}}&size={{.size}}{{if .search}}&search={{.search}}{{end}}{{if .startDate}}&startDate={{.startDate}}{{end}}{{if .endDate}}&endDate={{.endDate}}{{end}}{{if .filter}}&filter={{.filter}}{{end}}"
class="px-3 py-1 text-sm bg-blue-500 text-white
rounded hover:bg-blue-600 {{if eq .page .totalPages}}cursor-not-allowed opacity-50{{end}}">
Última
</a>
</div>
</div>
</td>
</tr>
</tfoot>
</table>
<canvas id="myChart" class="w-full lg:w-auto"></canvas>
<script>
document.addEventListener('DOMContentLoaded', function () {
const ctx = document.getElementById('myChart').getContext('2d');
// Datos sin ordenar
const labels = {{ .labels | json }};
const data = {{ .totals | json }};
// Función para convertir el formato "Month Year" a "YYYY-MM"
function formatMonthYearToDate(monthYear) {
const [month, year] = monthYear.split(" ");
const monthIndex = new Date(`${month} 1`).getMonth();
return `${year}-${String(monthIndex + 1).padStart(2, "0")}`;
}
// Función para asegurarnos de que las fechas estén en un formato "YYYY-MM-DD"
function formatDateForSorting(dateString) {
if (dateString.includes("-")) {
// Si ya está en formato YYYY-MM-DD, no es necesario convertirlo
return dateString;
} else {
// Si es formato "Month Year", lo convertimos a "YYYY-MM"
return formatMonthYearToDate(dateString);
}
}
// Combina los datos y las fechas en un solo array de objetos
const combinedData = labels.map((label, index) => ({
label: label,
data: data[index],
sortableDate: formatDateForSorting(label),
}));
// Ordena los datos por la fecha (label)
combinedData.sort((a, b) => new Date(a.sortableDate) - new Date(b.sortableDate));
// Separa los labels y data ordenados
const sortedLabels = combinedData.map(item => item.label);
const sortedData = combinedData.map(item => item.data);
new Chart(ctx, {
type: 'line',
data: {
labels: sortedLabels,
datasets: [{
label: 'Consumo (horas)',
data: sortedData,
borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw; // Valor numérico del consumo
const hours = Math.floor(value); // Parte entera (horas)
const minutes = Math.round((value - hours) * 60); // Parte decimal convertida a minutos
return `Consumo (horas): ${hours}h ${minutes}m`; // Formato personalizado
}
}
}
},
scales: {
x: {
title: {
display: true,
text: 'Día'
}
},
y: {
title: {
display: true,
text: 'Consumo (horas)'
},
beginAtZero: true
}
}
}
});
});
</script>
{{ template "footer.html" . }}

275
internal/web/utils.go Normal file
View File

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