Files
HeatGuard/internal/store/room.go
vikingowl 1c9db02334 feat: add web UI with full CRUD setup page
Add server-side rendered setup UI accessible via `heatwave web`.
The dashboard is now re-rendered per request and includes a nav bar
linking to the new /setup page. Setup provides full CRUD for profiles,
rooms, devices, occupants, AC units (with room assignment), scenario
toggles, and forecast fetching — all via POST/redirect/GET forms.

- Add ShowNav field to DashboardData for conditional nav bar
- Extract fetchForecastForProfile() for reuse by web handler
- Create setup.html.tmpl with Tailwind-styled entity sections
- Create web_handlers.go with 15 route handlers and flash cookies
- Switch web.go from pre-rendered to per-request dashboard rendering
- Graceful dashboard fallback when no forecast data exists
2026-02-09 10:39:00 +01:00

152 lines
4.7 KiB
Go

package store
import (
"database/sql"
"fmt"
"time"
)
type Room struct {
ID int64
ProfileID int64
Name string
AreaSqm float64
CeilingHeightM float64
Floor int
Orientation string
ShadingType string
ShadingFactor float64
Ventilation string
VentilationACH float64
WindowFraction float64
SHGC float64
Insulation string
CreatedAt time.Time
}
// RoomParams holds optional room parameters with defaults.
type RoomParams struct {
CeilingHeightM float64
VentilationACH float64
WindowFraction float64
SHGC float64
}
// DefaultRoomParams returns sensible defaults for room physics parameters.
func DefaultRoomParams() RoomParams {
return RoomParams{
CeilingHeightM: 2.5,
VentilationACH: 0.5,
WindowFraction: 0.15,
SHGC: 0.6,
}
}
func (s *Store) CreateRoom(profileID int64, name string, areaSqm float64, floor int, orientation, shadingType string, shadingFactor float64, ventilation, insulation string, params RoomParams) (*Room, error) {
if shadingType == "" {
shadingType = "none"
}
if shadingFactor == 0 {
shadingFactor = 1.0
}
if ventilation == "" {
ventilation = "natural"
}
if insulation == "" {
insulation = "average"
}
if params.CeilingHeightM == 0 {
params.CeilingHeightM = 2.5
}
if params.VentilationACH == 0 {
params.VentilationACH = 0.5
}
if params.WindowFraction == 0 {
params.WindowFraction = 0.15
}
if params.SHGC == 0 {
params.SHGC = 0.6
}
res, err := s.db.Exec(
`INSERT INTO rooms (profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
profileID, name, areaSqm, params.CeilingHeightM, floor, orientation, shadingType, shadingFactor, ventilation, params.VentilationACH, params.WindowFraction, params.SHGC, insulation,
)
if err != nil {
return nil, fmt.Errorf("create room: %w", err)
}
id, _ := res.LastInsertId()
return s.GetRoom(id)
}
func (s *Store) GetRoom(id int64) (*Room, error) {
r := &Room{}
var created string
err := s.db.QueryRow(
`SELECT id, profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation, created_at FROM rooms WHERE id = ?`, id,
).Scan(&r.ID, &r.ProfileID, &r.Name, &r.AreaSqm, &r.CeilingHeightM, &r.Floor, &r.Orientation, &r.ShadingType, &r.ShadingFactor, &r.Ventilation, &r.VentilationACH, &r.WindowFraction, &r.SHGC, &r.Insulation, &created)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("room not found: %d", id)
}
if err != nil {
return nil, fmt.Errorf("get room: %w", err)
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
return r, nil
}
func (s *Store) ListRooms(profileID int64) ([]Room, error) {
rows, err := s.db.Query(
`SELECT id, profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation, created_at FROM rooms WHERE profile_id = ? ORDER BY name`, profileID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var rooms []Room
for rows.Next() {
var r Room
var created string
if err := rows.Scan(&r.ID, &r.ProfileID, &r.Name, &r.AreaSqm, &r.CeilingHeightM, &r.Floor, &r.Orientation, &r.ShadingType, &r.ShadingFactor, &r.Ventilation, &r.VentilationACH, &r.WindowFraction, &r.SHGC, &r.Insulation, &created); err != nil {
return nil, err
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
rooms = append(rooms, r)
}
return rooms, rows.Err()
}
func (s *Store) UpdateRoom(id int64, field, value string) error {
allowed := map[string]bool{
"name": true, "area_sqm": true, "ceiling_height_m": true, "floor": true, "orientation": true,
"shading_type": true, "shading_factor": true, "ventilation": true, "ventilation_ach": true,
"window_fraction": true, "shgc": true, "insulation": true,
}
if !allowed[field] {
return fmt.Errorf("invalid field: %s", field)
}
_, err := s.db.Exec(
fmt.Sprintf(`UPDATE rooms SET %s = ? WHERE id = ?`, field), value, id,
)
return err
}
func (s *Store) DeleteRoom(id int64) error {
_, err := s.db.Exec(`DELETE FROM rooms WHERE id = ?`, id)
return err
}
// GetRoomACCapacity returns total AC capacity in BTU/h assigned to a room.
func (s *Store) GetRoomACCapacity(roomID int64) (float64, error) {
var total sql.NullFloat64
err := s.db.QueryRow(
`SELECT SUM(a.capacity_btu) FROM ac_units a JOIN ac_room_assignments ar ON a.id = ar.ac_id WHERE ar.room_id = ?`, roomID,
).Scan(&total)
if err != nil {
return 0, err
}
if !total.Valid {
return 0, nil
}
return total.Float64, nil
}