Improve status page ergonomics
- WW card shows current tank temperature (Speicher: 48.7°C) - SG-Ready card shows inline compressor power (Kompressor: 1.5 kW / Standby) - Consumer reason text now wraps instead of being silently truncated - Add readiness hints on inactive wallboxes: explains what's blocking activation (SOC too low / forecast insufficient / waiting for PV ≥ Xw) - Add WW readiness hint when forecast is below threshold - Move Monitor-Only toggle button to header (next to timestamp) for symmetric placement with the resume button in the monitor banner - Remove charging advice card (outdated for proactive charging logic) - Remove compressor power footnote from page bottom (now inline in card) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,7 @@ type Store struct {
|
||||
dryRun bool
|
||||
wwConfigured bool
|
||||
strategic config.StrategicConfig
|
||||
carCharging config.CarChargingConfig
|
||||
consumers map[engine.Consumer]*consumerRecord
|
||||
fcResult *forecast.Result
|
||||
monitorMode *monitor.Mode
|
||||
@@ -66,7 +67,7 @@ type Store struct {
|
||||
}
|
||||
|
||||
// NewStore creates a new status store.
|
||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
|
||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, carCharging config.CarChargingConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
|
||||
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||
for _, c := range consumerOrder {
|
||||
consumers[c] = &consumerRecord{}
|
||||
@@ -75,6 +76,7 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig,
|
||||
dryRun: dryRun,
|
||||
wwConfigured: wwConfigured,
|
||||
strategic: strategic,
|
||||
carCharging: carCharging,
|
||||
consumers: consumers,
|
||||
monitorMode: mode,
|
||||
wwBoostDisabled: wwBoostDisabled,
|
||||
@@ -126,20 +128,22 @@ func (s *Store) Update(state collector.SystemState, actions []engine.Action, ove
|
||||
// --- Template data types ---
|
||||
|
||||
type consumerView struct {
|
||||
Icon string
|
||||
Label string
|
||||
Priority string // e.g. "①"
|
||||
ConsumerKey string // e.g. "wallbox_a" — used for the override form
|
||||
Active bool
|
||||
Since time.Time
|
||||
Reason string
|
||||
Unconfigured bool
|
||||
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
||||
IsWW bool // true for ConsumerWW — shows reset button instead of override
|
||||
WWBoostDisabled bool // copied from pageData for template access inside range
|
||||
ManualOverride bool
|
||||
OverrideUntil time.Time
|
||||
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
|
||||
Icon string
|
||||
Label string
|
||||
Priority string // e.g. "①"
|
||||
ConsumerKey string // e.g. "wallbox_a" — used for the override form
|
||||
Active bool
|
||||
Since time.Time
|
||||
Reason string
|
||||
SubDetail string // extra context line: tank temp for WW, compressor for SG-Ready
|
||||
ReadinessHint string // shown when inactive: explains what's blocking activation
|
||||
Unconfigured bool
|
||||
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
||||
IsWW bool // true for ConsumerWW — shows reset button instead of override
|
||||
WWBoostDisabled bool // copied from pageData for template access inside range
|
||||
ManualOverride bool
|
||||
OverrideUntil time.Time
|
||||
LivePowerW float64 // from Shelly PM; 0 for non-PM devices
|
||||
}
|
||||
|
||||
type phaseView struct {
|
||||
@@ -156,13 +160,6 @@ type forecastView struct {
|
||||
Quality string
|
||||
}
|
||||
|
||||
type chargingAdviceView struct {
|
||||
Show bool
|
||||
PlugInBy string // e.g. "11:30"
|
||||
WindowStart string // e.g. "12:00"
|
||||
WindowEnd string // e.g. "16:00"
|
||||
}
|
||||
|
||||
type tripView struct {
|
||||
Active bool
|
||||
CarName string
|
||||
@@ -188,12 +185,12 @@ type pageData struct {
|
||||
PVProductionW float64
|
||||
AmbientTempC float64
|
||||
CompressorPowerW float64
|
||||
WWTopTempC float64
|
||||
IsExporting bool
|
||||
AbsGridW float64
|
||||
HasPhaseData bool
|
||||
Phases []phaseView
|
||||
Forecast forecastView
|
||||
ChargingAdvice chargingAdviceView
|
||||
Consumers []consumerView
|
||||
Trip tripView
|
||||
CarOptions []CarOption
|
||||
@@ -271,6 +268,10 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
s.mu.RLock()
|
||||
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
||||
hasPhase := l1 != 0 || l2 != 0 || l3 != 0
|
||||
forecastKWh := 0.0
|
||||
if s.fcResult != nil {
|
||||
forecastKWh = s.fcResult.TotalKWh
|
||||
}
|
||||
data := pageData{
|
||||
LastUpdate: s.lastUpdate,
|
||||
ErrMsg: s.errMsg,
|
||||
@@ -282,12 +283,16 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
PVProductionW: s.state.PVProductionW,
|
||||
AmbientTempC: s.state.AmbientTempC,
|
||||
CompressorPowerW: s.state.CompressorPowerW,
|
||||
WWTopTempC: s.state.WWTopTempC,
|
||||
IsExporting: s.state.GridPowerW < 0,
|
||||
AbsGridW: abs(s.state.GridPowerW),
|
||||
HasPhaseData: hasPhase,
|
||||
}
|
||||
if hasPhase {
|
||||
for _, ph := range []struct{ label string; w float64 }{
|
||||
for _, ph := range []struct {
|
||||
label string
|
||||
w float64
|
||||
}{
|
||||
{"L1", l1}, {"L2", l2}, {"L3", l3},
|
||||
} {
|
||||
data.Phases = append(data.Phases, phaseView{
|
||||
@@ -305,24 +310,6 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
Icon: s.fcResult.QualityIcon(s.strategic),
|
||||
Quality: s.fcResult.Quality(s.strategic),
|
||||
}
|
||||
|
||||
// Charging advice: show when surplus window is ahead and no wallbox is active
|
||||
ws := s.fcResult.SurplusWindowStart
|
||||
we := s.fcResult.SurplusWindowEnd
|
||||
wallboxActive := s.consumers[engine.ConsumerWallboxA].active ||
|
||||
s.consumers[engine.ConsumerWallboxB].active
|
||||
if !ws.IsZero() && now.Before(ws) && !wallboxActive {
|
||||
plugBy := ws.Add(-30 * time.Minute)
|
||||
if plugBy.Before(now) {
|
||||
plugBy = ws // less than 30 min to window — just show window start
|
||||
}
|
||||
data.ChargingAdvice = chargingAdviceView{
|
||||
Show: true,
|
||||
PlugInBy: plugBy.Format("15:04"),
|
||||
WindowStart: ws.Format("15:04"),
|
||||
WindowEnd: we.Format("15:04"),
|
||||
}
|
||||
}
|
||||
}
|
||||
prioritySymbols := []string{"①", "②", "③", "④"}
|
||||
for i, c := range consumerOrder {
|
||||
@@ -333,19 +320,36 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
prio = prioritySymbols[i]
|
||||
}
|
||||
cv := consumerView{
|
||||
Icon: meta.Icon,
|
||||
Label: meta.Label,
|
||||
Priority: prio,
|
||||
ConsumerKey: c.String(),
|
||||
Active: rec.active,
|
||||
Since: rec.since,
|
||||
Reason: rec.reason,
|
||||
Icon: meta.Icon,
|
||||
Label: meta.Label,
|
||||
Priority: prio,
|
||||
ConsumerKey: c.String(),
|
||||
Active: rec.active,
|
||||
Since: rec.since,
|
||||
Reason: rec.reason,
|
||||
CanOverride: c != engine.ConsumerWW,
|
||||
IsWW: c == engine.ConsumerWW,
|
||||
WWBoostDisabled: c == engine.ConsumerWW && wwBoostDisabled,
|
||||
ManualOverride: rec.manualOverride,
|
||||
OverrideUntil: rec.overrideUntil,
|
||||
LivePowerW: rec.livePowerW,
|
||||
OverrideUntil: rec.overrideUntil,
|
||||
LivePowerW: rec.livePowerW,
|
||||
}
|
||||
// SubDetail: extra context line per consumer type
|
||||
switch c {
|
||||
case engine.ConsumerSGReady:
|
||||
if s.state.CompressorPowerW > 0 {
|
||||
cv.SubDetail = "Kompressor: " + formatW(s.state.CompressorPowerW)
|
||||
} else {
|
||||
cv.SubDetail = "Kompressor: Standby"
|
||||
}
|
||||
case engine.ConsumerWW:
|
||||
if s.state.WWTopTempC > 0 {
|
||||
cv.SubDetail = fmt.Sprintf("Speicher: %.1f°C", s.state.WWTopTempC)
|
||||
}
|
||||
}
|
||||
// ReadinessHint: shown when consumer is inactive and not overridden
|
||||
if !rec.active && !rec.manualOverride {
|
||||
cv.ReadinessHint = s.buildReadinessHint(c, s.state.BatterySOC, s.state.PVProductionW, forecastKWh)
|
||||
}
|
||||
if c == engine.ConsumerWW && !s.wwConfigured {
|
||||
cv.Unconfigured = true
|
||||
@@ -367,6 +371,41 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// buildReadinessHint returns a short string explaining why a consumer is currently inactive.
|
||||
// Returns empty string when the consumer is not forecast/threshold driven.
|
||||
func (s *Store) buildReadinessHint(c engine.Consumer, soc, pvW, forecastKWh float64) string {
|
||||
cc := s.carCharging
|
||||
switch c {
|
||||
case engine.ConsumerWallboxA, engine.ConsumerWallboxB:
|
||||
if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 {
|
||||
return "" // proactive not configured
|
||||
}
|
||||
midKWh := s.strategic.ForecastMidKWh
|
||||
if forecastKWh == 0 {
|
||||
return "Keine Prognose verfügbar"
|
||||
}
|
||||
if forecastKWh < midKWh {
|
||||
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
|
||||
}
|
||||
if float64(cc.MinSOC) > 0 && soc < float64(cc.MinSOC) {
|
||||
return fmt.Sprintf("SOC zu niedrig (min. %d%%)", cc.MinSOC)
|
||||
}
|
||||
threshold := cc.PVThresholdAW
|
||||
if c == engine.ConsumerWallboxB {
|
||||
threshold = cc.PVThresholdBW
|
||||
}
|
||||
if pvW < threshold {
|
||||
return fmt.Sprintf("Warte auf PV ≥ %.0f W (aktuell %.0f W)", threshold, pvW)
|
||||
}
|
||||
return "Bereit — aktiviert bei nächstem Zyklus"
|
||||
case engine.ConsumerWW:
|
||||
if s.strategic.ForecastMidKWh > 0 && forecastKWh < s.strategic.ForecastMidKWh {
|
||||
return fmt.Sprintf("Prognose zu gering (%.0f kWh)", forecastKWh)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildTripView constructs the trip card data for the current cycle.
|
||||
func buildTripView(tm *trip.Manager, now time.Time) tripView {
|
||||
goal := tm.ActiveGoal()
|
||||
@@ -496,13 +535,10 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
font-weight: 700; cursor: pointer; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.resume-btn:active { opacity: 0.7; }
|
||||
.monitor-toggle {
|
||||
text-align: center; margin-top: 1.5rem; padding-bottom: 0.5rem;
|
||||
}
|
||||
.monitor-toggle-btn {
|
||||
background: none; border: 1px solid #d1d5db; color: #9ca3af;
|
||||
border-radius: 8px; padding: 0.35rem 1rem; font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px; padding: 0.25rem 0.75rem; font-size: 0.72rem;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.monitor-toggle-btn:hover { border-color: #9ca3af; color: #6b7280; }
|
||||
|
||||
@@ -627,33 +663,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Charging advice card */
|
||||
.advice-card {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.advice-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #1d4ed8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.advice-main {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
.advice-sub {
|
||||
font-size: 0.78rem;
|
||||
color: #3b82f6;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* Live power badge on consumers */
|
||||
.power-badge {
|
||||
font-size: 0.72rem;
|
||||
@@ -720,12 +729,20 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
font-size: 0.78rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.15rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
||||
.consumer-detail.override { color: #d97706; font-weight: 500; }
|
||||
.consumer-sub {
|
||||
font-size: 0.72rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.consumer-hint {
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dur-select {
|
||||
border: 1px solid #d1d5db;
|
||||
@@ -757,9 +774,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
|
||||
<header>
|
||||
<h1>☀️ Solar Status</h1>
|
||||
{{if not .LastUpdate.IsZero}}
|
||||
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
||||
{{end}}
|
||||
<div style="display:flex;align-items:center;gap:0.6rem">
|
||||
{{if not .LastUpdate.IsZero}}
|
||||
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
||||
{{end}}
|
||||
{{if not .MonitorOnly}}
|
||||
<form method="post" action="/monitor">
|
||||
<button type="submit" class="monitor-toggle-btn">⏸ Monitor</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .MonitorOnly}}
|
||||
@@ -848,16 +872,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
|
||||
</div>
|
||||
|
||||
{{if .ChargingAdvice.Show}}
|
||||
<div class="advice-card">
|
||||
<div class="advice-title">🔌 Ladeempfehlung</div>
|
||||
<div class="advice-main">Auto einstecken bis {{.ChargingAdvice.PlugInBy}} Uhr</div>
|
||||
<div class="advice-sub">
|
||||
Erwartetes Überschuss-Fenster: {{.ChargingAdvice.WindowStart}} – {{.ChargingAdvice.WindowEnd}} Uhr
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="section-title">Verbraucher — Priorität ↓</div>
|
||||
|
||||
{{range .Consumers}}
|
||||
@@ -874,13 +888,15 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
{{else if .ManualOverride}}
|
||||
Manuell bis {{.OverrideUntil.Format "15:04"}}
|
||||
{{else if .Active}}
|
||||
{{formatSince .Since}}
|
||||
{{else if .Reason}}
|
||||
{{.Reason}}
|
||||
{{formatSince .Since}}{{if .Reason}} — {{.Reason}}{{end}}
|
||||
{{else}}
|
||||
Ausgeschaltet
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .SubDetail}}<div class="consumer-sub">{{.SubDetail}}</div>{{end}}
|
||||
{{if and (not .Active) (not .ManualOverride) .ReadinessHint}}
|
||||
<div class="consumer-hint">{{.ReadinessHint}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if and .Active .LivePowerW}}
|
||||
<span class="power-badge">{{formatW .LivePowerW}}</span>
|
||||
@@ -923,21 +939,6 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Compressor status (shown when SG-Ready context is relevant) -->
|
||||
{{if .CompressorPowerW}}
|
||||
<div style="margin-top:0.5rem;font-size:0.75rem;color:#9ca3af;text-align:right">
|
||||
Kompressor: {{formatW .CompressorPowerW}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if not .MonitorOnly}}
|
||||
<div class="monitor-toggle">
|
||||
<form method="post" action="/monitor">
|
||||
<button type="submit" class="monitor-toggle-btn">⏸ Monitor-Only</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Trip.Active}}
|
||||
<div class="trip-card{{if .Trip.IsTight}} tight{{end}}">
|
||||
<div class="trip-header">
|
||||
|
||||
Reference in New Issue
Block a user