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
152 lines
4.7 KiB
Go
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
|
|
}
|