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:
2026-04-07 21:30:01 +02:00
parent 971e22c4be
commit 2dd53d1543
2 changed files with 119 additions and 118 deletions

View File

@@ -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 &mdash; 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">