Initial commit
This commit is contained in:
commit
aef11ed05a
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
data
|
94
README.md
Normal file
94
README.md
Normal 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
7
TODO.md
Normal 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
43
cmd/bot/main.go
Normal 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
169
cmd/import/main.go
Normal 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
49
cmd/web/main.go
Normal 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
12
env.sample
Normal 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
32
go.mod
Normal 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
85
go.sum
Normal 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=
|
48
internal/bot/controller.go
Normal file
48
internal/bot/controller.go
Normal 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
13
internal/bot/model.go
Normal 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
22
internal/bot/routes.go
Normal 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
45
internal/config/config.go
Normal 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
48
internal/config/db.go
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
31
internal/repository/repository.go
Normal file
31
internal/repository/repository.go
Normal 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
20
internal/utils/utils.go
Normal 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
179
internal/web/handlers.go
Normal 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)
|
||||||
|
}
|
32
internal/web/templates/about.html
Normal file
32
internal/web/templates/about.html
Normal 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" . }}
|
23
internal/web/templates/edit.html
Normal file
23
internal/web/templates/edit.html
Normal 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" . }}
|
4
internal/web/templates/partials/footer.html
Normal file
4
internal/web/templates/partials/footer.html
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
21
internal/web/templates/partials/header.html
Normal file
21
internal/web/templates/partials/header.html
Normal 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>
|
244
internal/web/templates/view.html
Normal file
244
internal/web/templates/view.html
Normal 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
275
internal/web/utils.go
Normal 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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user