feat: heat pump statistics per day in the Historia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s

GET /api/stats?range=7d|30d computes compressor, CWU, CO and heater hours,
compressor starts and temperatures per Europe/Warsaw day from the raw
heat_pump polls. Hours hold each poll for at most 600 s so gaps add nothing;
starts are 0 -> >0 transitions and are a lower bound at 300 s polling.
Historia gets a Wykresy | Statystyki switch (shared SubView helper, also
used by Energia) with totals tiles, a daily chart and a cycling hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 16:59:42 +02:00
1 parent b288c98106
commit cbf507fa18
24 files changed
+1018 -63

No files matched your search

+5
View File
@@ -8,6 +8,11 @@ The records in `home.backend.domain` and the examples in `src/test/resources/api
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
the three values are null (`pi-sensor-stale.json`).
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
@@ -12,6 +12,7 @@ import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.LeaseRenewer;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
@@ -42,7 +43,7 @@ final class Backend {
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
new BatteryService(gateway, commands, clock));
new BatteryService(gateway, commands, clock), new StatsService(influx, clock));
}
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
@@ -15,6 +15,7 @@ import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.domain.BatteryChange;
@@ -87,7 +88,7 @@ public class ApiServer {
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery) throws IOException {
BatteryService battery, StatsService stats) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
@@ -107,6 +108,7 @@ public class ApiServer {
api("/api/pi-sensor", query -> piSensor.current());
api("/api/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/stats", exchange -> handleStats(exchange, stats));
route("/api/setpoints", exchange -> handle(exchange, Map.of(
"GET", () -> getSetpoints(exchange, setpoints),
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
@@ -337,6 +339,18 @@ public class ApiServer {
}));
}
private void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("stats unavailable"));
}
}));
}
private interface Action {
void run() throws IOException;
}
@@ -1,6 +1,8 @@
package home.backend.adapters.influx;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryRepository;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
@@ -20,7 +22,7 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
public final class InfluxHistoryRepository implements HistoryRepository {
public final class InfluxHistoryRepository implements HistoryRepository, HeatPumpSamples {
private final InfluxSettings settings;
private final HttpClient http;
@@ -57,6 +59,12 @@ public final class InfluxHistoryRepository implements HistoryRepository {
.toList();
}
@Override
public List<HeatPumpSample> samples(Instant from, Instant to) throws IOException {
String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli());
return InfluxSamples.parse(fetch(SeriesSources.LEGACY, query));
}
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
return InfluxResponse.parse(fetch(source.database(), query));
@@ -0,0 +1,58 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
final class InfluxSamples {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxSamples() {
}
static String query(long fromMs, long toMs) {
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
}
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
static List<HeatPumpSample> parse(String body) throws IOException {
JsonNode root;
try {
root = JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
failOnError(root);
List<HeatPumpSample> samples = new ArrayList<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
number(row, 5), number(row, 6)));
}
}
}
return samples;
}
private static Double number(JsonNode row, int column) {
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -8,7 +8,7 @@ import java.util.Map;
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
final class SeriesSources {
private static final String LEGACY = "Dom_Pompa";
static final String LEGACY = "Dom_Pompa";
private static final String ELECTRICITY = "electricity";
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
@@ -0,0 +1,96 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView.Day;
import home.backend.domain.StatsView.Totals;
import java.time.Duration;
import java.time.LocalDate;
/** What the heat pump did over some span of polls; days are added up into the totals. */
final class DayTally {
private double compressorSeconds;
private double cwuSeconds;
private double coSeconds;
private double heaterSeconds;
private int starts;
private int samples;
private double outdoorSum;
private int outdoorCount;
private Double minCwu;
private Double maxCwu;
void sample(HeatPumpSample s) {
samples++;
if (s.outdoorTemp() != null) {
outdoorSum += s.outdoorTemp();
outdoorCount++;
}
if (s.cwuTemp() != null) {
minCwu = lower(minCwu, s.cwuTemp());
maxCwu = higher(maxCwu, s.cwuTemp());
}
}
/** The outputs of {@code s} held for {@code held}. */
void hold(HeatPumpSample s, Duration held) {
double seconds = held.toMillis() / 1000.0;
compressorSeconds += on(s.compressor()) ? seconds : 0;
cwuSeconds += on(s.cwu()) ? seconds : 0;
coSeconds += on(s.co()) ? seconds : 0;
heaterSeconds += on(s.heater()) ? seconds : 0;
}
void start() {
starts++;
}
void add(DayTally other) {
compressorSeconds += other.compressorSeconds;
cwuSeconds += other.cwuSeconds;
coSeconds += other.coSeconds;
heaterSeconds += other.heaterSeconds;
starts += other.starts;
samples += other.samples;
outdoorSum += other.outdoorSum;
outdoorCount += other.outdoorCount;
if (other.minCwu != null) {
minCwu = lower(minCwu, other.minCwu);
maxCwu = higher(maxCwu, other.maxCwu);
}
}
Day day(LocalDate date) {
if (samples == 0) {
return new Day(date, null, null, null, null, null, null, null, null);
}
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
}
Totals totals() {
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
}
static boolean on(Double output) {
return output != null && output > 0;
}
private static Double lower(Double known, double value) {
return known == null ? value : Math.min(known, value);
}
private static Double higher(Double known, double value) {
return known == null ? value : Math.max(known, value);
}
private Double avgOutdoor() {
return outdoorCount == 0 ? null : Math.round(outdoorSum / outdoorCount * 10) / 10.0;
}
private static double hours(double seconds) {
return Math.round(seconds / 3600 * 100) / 100.0;
}
}
@@ -0,0 +1,13 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** The heat pump polls stored in InfluxDB, oldest first. */
public interface HeatPumpSamples {
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
}
@@ -0,0 +1,89 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* F2: the heat pump's work per local day over the last 7 or 30 days. A poll is taken to hold its
* outputs until the next poll, but at most {@link #MAX_HOLD}, so a gap in the data adds no hours.
*/
public class StatsService {
static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
static final Duration MAX_HOLD = Duration.ofSeconds(600);
private static final Map<String, Integer> RANGE_DAYS = Map.of("7d", 7, "30d", 30);
private final HeatPumpSamples repository;
private final Clock clock;
public StatsService(HeatPumpSamples repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 7d} or {@code 30d}
* @throws IllegalArgumentException for an unknown range
*/
public StatsView stats(String range) throws IOException {
Integer length = range == null ? null : RANGE_DAYS.get(range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant now = clock.instant();
LocalDate first = now.atZone(ZONE).toLocalDate().minusDays(length - 1);
Map<LocalDate, DayTally> tallies = new TreeMap<>();
for (int i = 0; i < length; i++) {
tallies.put(first.plusDays(i), new DayTally());
}
tally(repository.samples(first.atStartOfDay(ZONE).toInstant(), now), now, tallies);
DayTally all = new DayTally();
List<Day> days = new ArrayList<>();
tallies.forEach((date, tally) -> {
days.add(tally.day(date));
all.add(tally);
});
return new StatsView(range, days, all.totals());
}
private static void tally(List<HeatPumpSample> samples, Instant now, Map<LocalDate, DayTally> tallies) {
HeatPumpSample previous = null;
for (int i = 0; i < samples.size(); i++) {
HeatPumpSample s = samples.get(i);
DayTally day = tallies.get(s.t().atZone(ZONE).toLocalDate());
day.sample(s);
if (previous != null && previous.compressor() != null && previous.compressor() == 0
&& DayTally.on(s.compressor())) {
day.start();
}
previous = s;
hold(s, i + 1 < samples.size() ? samples.get(i + 1).t() : now, tallies);
}
}
/** Adds the time {@code s} was in force to the days it falls in; a stretch over midnight is split. */
private static void hold(HeatPumpSample s, Instant next, Map<LocalDate, DayTally> tallies) {
Instant end = Duration.between(s.t(), next).compareTo(MAX_HOLD) > 0 ? s.t().plus(MAX_HOLD) : next;
Instant at = s.t();
while (at.isBefore(end)) {
LocalDate date = at.atZone(ZONE).toLocalDate();
Instant midnight = date.plusDays(1).atStartOfDay(ZONE).toInstant();
Instant stop = midnight.isBefore(end) ? midnight : end;
tallies.get(date).hold(s, Duration.between(at, stop));
at = stop;
}
}
}
@@ -0,0 +1,8 @@
package home.backend.domain;
import java.time.Instant;
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co, Double heater,
Double outdoorTemp, Double cwuTemp) {
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.List;
/**
* Heat pump work per local day (F2). Hours are approximated from the polls, starts are counted
* between polls, so short cycles are missed. A day without polls has null values.
*/
public record StatsView(String range, List<Day> days, Totals totals) {
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
Double coHours, Double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
}
@@ -24,11 +24,14 @@ import home.backend.domain.SetpointState;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryRepository;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -63,6 +66,7 @@ class ApiServerTest {
private GatewaySnapshot snapshot;
private boolean gatewayDown;
private boolean historyDown;
private HeatPumpSamples samples;
private RuntimeException gatewayBug;
private final List<Command> sent = new ArrayList<>();
private Status verdict;
@@ -95,6 +99,16 @@ class ApiServerTest {
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
};
samples = (from, to) -> {
if (historyDown) {
throw new IOException("influx down");
}
return List.of(
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
};
verdict = Status.APPLIED;
restoreOutcome = Outcome.RESTORED;
answersBeforeDrop = Integer.MAX_VALUE;
@@ -178,7 +192,7 @@ class ApiServerTest {
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
new BatteryService(gateway, batteryCommands, clock));
new BatteryService(gateway, batteryCommands, clock), new StatsService(samples, clock));
server.start();
}
@@ -421,6 +435,25 @@ class ApiServerTest {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
}
@Test
void statsMatchExample() throws Exception {
assertMatchesExample("/api/stats?range=7d", "stats");
}
@Test
void statsNeedAKnownRange() throws Exception {
assertEquals(400, get("/api/stats").statusCode());
assertEquals(400, get("/api/stats?range=24h").statusCode());
assertEquals(200, get("/api/stats?range=30d").statusCode());
}
@Test
void statsAre502WhenInfluxIsDown() throws Exception {
historyDown = true;
assertEquals(502, get("/api/stats?range=7d").statusCode());
}
@Test
void historyRangeDefaultsTo24Hours() throws Exception {
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
@@ -1,6 +1,7 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.AfterEach;
@@ -208,6 +209,35 @@ class InfluxHistoryRepositoryTest {
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
}
@Test
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
defaultBody = fixture("influx-query-heat-pump.json");
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
assertEquals("Dom_Pompa", requests.get(0).get("db"));
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
requests.get(0).get("q"));
assertEquals(3, samples.size());
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
samples.get(1));
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
samples.get(2));
}
@Test
void noHeatPumpPollsIsAnEmptyList() throws IOException {
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
}
@Test
void heatPumpQueryErrorIsAnIoException() throws IOException {
defaultBody = fixture("influx-query-error.json");
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
}
@Test
void rejectsAnEmptyRange() {
assertThrows(IllegalArgumentException.class,
@@ -0,0 +1,214 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class StatsServiceTest {
/** Monday 2026-10-26 11:00 in Warsaw; the week before it holds the change to winter time. */
private static final Instant NOW = Instant.parse("2026-10-26T10:00:00Z");
private Instant askedFrom;
private Instant askedTo;
private StatsView stats(String range, Instant now, HeatPumpSample... samples) throws IOException {
HeatPumpSamples repository = (from, to) -> {
askedFrom = from;
askedTo = to;
return List.of(samples);
};
return new StatsService(repository, Clock.fixed(now, ZoneOffset.UTC)).stats(range);
}
private static HeatPumpSample compressor(String time, double stage) {
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, 0.0, null, null);
}
private static Day day(StatsView view, String date) {
return view.days().stream().filter(d -> d.date().equals(LocalDate.parse(date))).findFirst().orElseThrow();
}
@Test
void asksForWholeLocalDaysUpToNow() throws IOException {
stats("7d", NOW);
assertEquals(Instant.parse("2026-10-19T22:00:00Z"), askedFrom);
assertEquals(NOW, askedTo);
}
@Test
void listsOneDayPerLocalDateOldestFirst() throws IOException {
StatsView view = stats("30d", NOW);
assertEquals(30, view.days().size());
assertEquals(LocalDate.parse("2026-09-27"), view.days().getFirst().date());
assertEquals(LocalDate.parse("2026-10-26"), view.days().getLast().date());
}
@Test
void noDataGivesEmptyDaysAndZeroTotals() throws IOException {
StatsView view = stats("7d", NOW);
assertEquals(7, view.days().size());
for (Day d : view.days()) {
assertNull(d.compressorHours());
assertNull(d.compressorStarts());
assertNull(d.avgOutdoorTemp());
}
assertEquals(0.0, view.totals().compressorHours());
assertEquals(0, view.totals().compressorStarts());
assertNull(view.totals().avgOutdoorTemp());
assertNull(view.totals().minCwuTemp());
}
@Test
void aGapIsCappedAtTenMinutesPerPoll() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-23T10:00:00Z", 100),
compressor("2026-10-23T10:05:00Z", 100),
compressor("2026-10-23T11:00:00Z", 0));
assertEquals(0.25, day(view, "2026-10-23").compressorHours());
}
@Test
void theLastPollHoldsUntilNowAtMostTenMinutes() throws IOException {
StatsView recent = stats("7d", NOW, compressor("2026-10-26T09:58:00Z", 50));
StatsView old = stats("7d", NOW, compressor("2026-10-26T08:00:00Z", 50));
assertEquals(0.03, day(recent, "2026-10-26").compressorHours());
assertEquals(0.17, day(old, "2026-10-26").compressorHours());
}
@Test
void countsOnlyTransitionsFromOffToOn() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-23T10:00:00Z", 100),
compressor("2026-10-23T10:05:00Z", 100),
compressor("2026-10-23T10:10:00Z", 0),
compressor("2026-10-23T10:15:00Z", 0),
compressor("2026-10-23T10:20:00Z", 50),
compressor("2026-10-23T10:25:00Z", 100),
compressor("2026-10-23T10:30:00Z", 0),
compressor("2026-10-23T10:35:00Z", 100));
assertEquals(2, day(view, "2026-10-23").compressorStarts());
assertEquals(2, view.totals().compressorStarts());
}
@Test
void aStartAtMidnightBelongsToTheNewDay() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-24T21:55:00Z", 0),
compressor("2026-10-24T22:00:00Z", 100));
assertEquals(0, day(view, "2026-10-24").compressorStarts());
assertEquals(1, day(view, "2026-10-25").compressorStarts());
}
@Test
void splitsAStretchAtLocalMidnightInSummerTime() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-24T21:58:00Z", 100),
compressor("2026-10-24T22:03:00Z", 0));
assertEquals(0.03, day(view, "2026-10-24").compressorHours());
assertEquals(0.05, day(view, "2026-10-25").compressorHours());
}
@Test
void splitsAStretchAtLocalMidnightInWinterTime() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-25T22:58:00Z", 100),
compressor("2026-10-25T23:03:00Z", 0));
assertEquals(0.03, day(view, "2026-10-25").compressorHours());
assertEquals(0.05, day(view, "2026-10-26").compressorHours());
}
@Test
void theDayOfTheClockChangeLastsTwentyFiveHours() throws IOException {
List<HeatPumpSample> samples = new ArrayList<>();
Instant at = Instant.parse("2026-10-24T22:00:00Z");
while (at.isBefore(Instant.parse("2026-10-25T23:00:00Z"))) {
samples.add(compressor(at.toString(), 100));
at = at.plusSeconds(300);
}
StatsView view = stats("7d", NOW, samples.toArray(HeatPumpSample[]::new));
assertEquals(25.0, day(view, "2026-10-25").compressorHours());
assertNull(day(view, "2026-10-26").compressorHours());
}
@Test
void sumsEachOutputSeparately() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 0.0, 10.0, 40.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 100.0, 12.0, 38.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, 0.0, null, null));
Day d = day(view, "2026-10-23");
assertEquals(0.17, d.compressorHours());
assertEquals(0.08, d.cwuHours());
assertEquals(0.08, d.coHours());
assertEquals(0.08, d.heaterHours());
assertEquals(11.0, d.avgOutdoorTemp());
assertEquals(38.0, d.minCwuTemp());
assertEquals(40.0, d.maxCwuTemp());
}
@Test
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 0.0, 10.0, 30.0),
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 10.0, 31.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 100.0, 4.0, 45.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 4.0, 44.0));
assertEquals(0.17, view.totals().compressorHours());
assertEquals(0.08, view.totals().heaterHours());
assertEquals(7.0, view.totals().avgOutdoorTemp());
assertEquals(30.0, view.totals().minCwuTemp());
assertEquals(45.0, view.totals().maxCwuTemp());
}
@Test
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, null),
compressor("2026-10-23T10:05:00Z", 100));
assertEquals(0, day(view, "2026-10-23").compressorStarts());
assertNull(day(view, "2026-10-23").avgOutdoorTemp());
}
@Test
void rejectsAnUnknownRange() {
assertThrows(IllegalArgumentException.class, () -> stats("24h", NOW));
assertThrows(IllegalArgumentException.class, () -> stats(null, NOW));
}
@Test
void propagatesRepositoryFailure() {
StatsService service = new StatsService((from, to) -> {
throw new IOException("influx down");
}, Clock.fixed(NOW, ZoneOffset.UTC));
assertThrows(IOException.class, () -> service.stats("7d"));
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"range": "7d",
"days": [
{
"date": "2026-10-18",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-19",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-20",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-21",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-22",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-23",
"compressorHours": 0.17,
"compressorStarts": 1,
"cwuHours": 0.08,
"coHours": 0.0,
"heaterHours": 0.08,
"avgOutdoorTemp": 8.6,
"minCwuTemp": 43.0,
"maxCwuTemp": 46.0
},
{
"date": "2026-10-24",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
}
],
"totals": {
"compressorHours": 0.17,
"compressorStarts": 1,
"cwuHours": 0.08,
"coHours": 0.0,
"heaterHours": 0.08,
"avgOutdoorTemp": 8.6,
"minCwuTemp": 43.0,
"maxCwuTemp": 46.0
}
}
@@ -0,0 +1 @@
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","output_heater","outdoor_temp","cwu_temp"],"values":[[1792771200000,0,0,0,0,8.5,44.2],[1792771500000,100,100,0,0,8.5,43.9],[1792771800000,50,0,100,100,null,null]]}]}]}
+2 -1
View File
@@ -76,7 +76,8 @@ Functional:
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
pressure) of the living room.
- F2 History for the last 7 days.
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU, CO and heater hours,
starts "at least" because polls are 5 minutes apart).
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
(4131, 4132). Any value within limits, or one tap on a named preset. Limits and presets live in
the backend config: CWU 5–48 °C (decided; the gateway's ceiling stays 50), CO 5–25 °C.
+3
View File
@@ -23,3 +23,6 @@ opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falo
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI
checks the gateway's rules first (levels from the reserve to 100 %, starts increasing).
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of
compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
+6 -39
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type KeyboardEvent } from "react";
import { useCallback, useState, type KeyboardEvent } from "react";
import { Cell, Pie, PieChart } from "recharts";
import { Battery } from "./Battery";
import { flowing } from "./flow";
@@ -7,6 +7,7 @@ import { Icon, type IconName } from "./icons";
import { usePoll } from "./poll";
import type { Energy as EnergyData, EnergyDetails } from "./types";
import { DetailsButton, DeviceDetails } from "./DeviceDetails";
import { SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
const INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
@@ -214,52 +215,18 @@ function Now({ onBattery }: { onBattery: () => void }) {
}
const VIEWS = { now: "Teraz", battery: "Bateria", inverters: "Falowniki" } as const;
type ViewId = keyof typeof VIEWS;
// #energy is "Teraz", #energy/battery and #energy/inverters the others: every view is a history entry, so Back returns.
function viewFromHash(): ViewId {
const [screen, view = ""] = location.hash.slice(1).split("/");
return screen === "energy" && view in VIEWS ? (view as ViewId) : "now";
}
function useView() {
const [view, setView] = useState(viewFromHash);
useEffect(() => {
const onHash = () => setView(viewFromHash());
addEventListener("hashchange", onHash);
return () => removeEventListener("hashchange", onHash);
}, []);
const show = (id: ViewId) => {
location.hash = id === "now" ? "energy" : `energy/${id}`;
};
return [view, show] as const;
}
function ViewSwitch({ view, onSelect }: { view: ViewId; onSelect: (id: ViewId) => void }) {
return (
<div role="tablist" aria-label="Widok energii" className="mb-5 flex gap-1 rounded-xl border border-edge bg-panel p-1 lg:max-w-md">
{(Object.entries(VIEWS) as [ViewId, string][]).map(([id, label]) => (
<button key={id} role="tab" id={`energy-tab-${id}`} aria-selected={id === view} aria-controls="energy-view"
onClick={() => onSelect(id)}
className="min-h-11 flex-1 rounded-lg px-3 font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-selected:bg-brand aria-selected:text-white dark:aria-selected:text-black">
{label}
</button>
))}
</div>
);
}
export function Energy() {
const [view, show] = useView();
const [view, show] = useSubView("energy", VIEWS);
return (
<>
<PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />
<ViewSwitch view={view} onSelect={show} />
<div role="tabpanel" id="energy-view" aria-labelledby={`energy-tab-${view}`}>
<SubViewSwitch screen="energy" label="Widok energii" views={VIEWS} view={view} onSelect={show} />
<SubViewPanel screen="energy" view={view}>
{view === "now" && <Now onBattery={() => show("battery")} />}
{view === "battery" && <Battery />}
{view === "inverters" && <Devices />}
</div>
</SubViewPanel>
</>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { Stats } from "./types";
// A fresh module per test: the poller keeps its answers between screens, not between tests.
const historyScreen = async () => (await import("./History")).History;
const tab = (name: string) => screen.getByRole("tab", { name });
beforeEach(() => {
location.hash = "history";
});
function serveStats(stats: Stats = example("stats")) {
return serve({ "api/stats": stats, "api/history": { series: "cwu_temp", unit: "°C", points: [] } });
}
async function open(stats?: Stats) {
const fetch = serveStats(stats);
location.hash = "history/stats";
const History = await historyScreen();
render(<History />);
return fetch;
}
describe("Historia views", () => {
it("starts on Wykresy and keeps Statystyki in the hash, so Back returns", async () => {
serveStats();
const History = await historyScreen();
render(<History />);
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Wykresy", "Statystyki"]);
expect(tab("Wykresy")).toHaveAttribute("aria-selected", "true");
await userEvent.click(tab("Statystyki"));
expect(location.hash).toBe("#history/stats");
expect(await screen.findByText("Sprężarka")).toBeInTheDocument();
history.back();
await waitFor(() => expect(tab("Wykresy")).toHaveAttribute("aria-selected", "true"));
});
it("opens Statystyki when the hash names it", async () => {
await open();
expect(tab("Statystyki")).toHaveAttribute("aria-selected", "true");
expect(await screen.findByText("Ciepła woda (CWU)")).toBeInTheDocument();
});
});
describe("Statystyki", () => {
it("shows the totals, the starts as a minimum and the heater highlighted when it ran", async () => {
await open();
expect(await screen.findByText("co najmniej 1")).toBeInTheDocument();
expect(screen.getAllByText("0,2 h").length).toBeGreaterThan(0);
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "true");
expect(screen.getByText("Prąd z grzałki jest drogi")).toBeInTheDocument();
expect(screen.queryByRole("status")).toBeNull();
});
it("does not highlight a heater that stayed off", async () => {
const stats = example<Stats>("stats");
stats.totals.heaterHours = 0;
await open(stats);
await screen.findByText("Grzałka");
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "false");
});
it("asks for 30 days when chosen", async () => {
const fetch = await open();
await screen.findByText("Sprężarka");
await userEvent.click(screen.getByRole("button", { name: "30 dni" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining("api/stats?range=30d"), expect.anything()));
expect(screen.getByRole("button", { name: "30 dni" })).toHaveAttribute("aria-pressed", "true");
});
it("warns about cycling above 24 starts a day", async () => {
const stats = example<Stats>("stats");
stats.days[5].compressorStarts = 25;
await open(stats);
expect(await screen.findByRole("status")).toHaveTextContent("Dużo załączeń – sprężarka może taktować");
});
it("says so when there is no data", async () => {
const stats = example<Stats>("stats");
stats.days = stats.days.map((d) => ({ ...d, compressorHours: null, compressorStarts: null }));
await open(stats);
expect(await screen.findByText("Za mało danych w tym okresie.")).toBeInTheDocument();
});
});
+32 -18
View File
@@ -1,6 +1,8 @@
import { useState } from "react";
import { number } from "./format";
import { usePoll } from "./poll";
import { Stats } from "./Stats";
import { SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { TimeChart, toRows, type Range } from "./TimeChart";
import type { History as HistoryData } from "./types";
import { Card, Freshness, Loading, PageTitle } from "./ui";
@@ -47,29 +49,41 @@ function Chart({ series, range }: { series: SeriesId; range: Range }) {
);
}
export function History() {
function Charts() {
const [series, setSeries] = useState<SeriesId>("cwu_temp");
const [range, setRange] = useState<Range>("24h");
return (
<Card>
<div className="mb-4 flex flex-wrap gap-3">
<select value={series} onChange={(e) => setSeries(e.target.value as SeriesId)} aria-label="Wielkość"
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
</select>
<div className="flex gap-2 rounded-xl bg-page p-1">
{Object.entries(RANGES).map(([id, name]) => (
<button key={id} onClick={() => setRange(id as Range)} aria-pressed={id === range}
className="min-h-10 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
{name}
</button>
))}
</div>
</div>
<Chart series={series} range={range} />
</Card>
);
}
const VIEWS = { charts: "Wykresy", stats: "Statystyki" } as const;
export function History() {
const [view, show] = useSubView("history", VIEWS);
return (
<>
<PageTitle title="Historia" subtitle="Dane z bazy pomiarów" />
<Card>
<div className="mb-4 flex flex-wrap gap-3">
<select value={series} onChange={(e) => setSeries(e.target.value as SeriesId)} aria-label="Wielkość"
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
</select>
<div className="flex gap-2 rounded-xl bg-page p-1">
{Object.entries(RANGES).map(([id, name]) => (
<button key={id} onClick={() => setRange(id as Range)} aria-pressed={id === range}
className="min-h-10 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
{name}
</button>
))}
</div>
</div>
<Chart series={series} range={range} />
</Card>
<SubViewSwitch screen="history" label="Widok historii" views={VIEWS} view={view} onSelect={show} />
<SubViewPanel screen="history" view={view}>
{view === "charts" ? <Charts /> : <Stats />}
</SubViewPanel>
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useState } from "react";
import { Bar, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { DASH, degrees, number } from "./format";
import { usePoll } from "./poll";
import type { Stats as StatsData, StatsDay } from "./types";
import { Card, Freshness, Loading } from "./ui";
const RANGES = { "7d": "7 dni", "30d": "30 dni" } as const;
type RangeId = keyof typeof RANGES;
const TAKTING_STARTS_PER_DAY = 24;
const BAR = "#0f8a5f";
const TEMPERATURE = "#3b82f6";
const hours = (value: number | null) => (value == null ? DASH : `${number(value, 1)} h`);
// "2026-10-24" as "sb 24"; noon UTC keeps the date whatever the viewer's zone.
function dayLabel(date: string) {
const noon = new Date(`${date}T12:00:00Z`);
const weekday = noon.toLocaleDateString("pl-PL", { weekday: "short", timeZone: "UTC" });
return `${weekday} ${noon.getUTCDate()}`;
}
/** Days with more starts than this suggest short cycling. */
export function takting(days: StatsDay[]) {
return days.some((d) => (d.compressorStarts ?? 0) > TAKTING_STARTS_PER_DAY);
}
function Tile({ label, value, hint, alert = false }: { label: string; value: string; hint?: string; alert?: boolean }) {
return (
<div data-alert={alert} className={`rounded-xl border p-3 ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
<p className="text-2xl font-bold tabular-nums">{value}</p>
<p className="text-sm text-soft">{label}</p>
{hint && <p className="text-xs text-soft">{hint}</p>}
</div>
);
}
function Totals({ stats }: { stats: StatsData }) {
const t = stats.totals;
return (
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
<Tile label="Sprężarka" value={hours(t.compressorHours)} />
<Tile label="Załączenia sprężarki" value={`co najmniej ${number(t.compressorStarts)}`} />
<Tile label="Ciepła woda (CWU)" value={hours(t.cwuHours)} />
<Tile label="Ogrzewanie (CO)" value={hours(t.coHours)} />
<Tile label="Grzałka" value={hours(t.heaterHours)} alert={t.heaterHours > 0}
hint={t.heaterHours > 0 ? "Prąd z grzałki jest drogi" : undefined} />
</div>
);
}
function DailyChart({ days }: { days: StatsDay[] }) {
const rows = days.map((d) => ({ label: dayLabel(d.date), hours: d.compressorHours, outdoor: d.avgOutdoorTemp }));
return (
<div style={{ height: 280 }} className="w-full" role="img" aria-label="Godziny pracy sprężarki w kolejnych dniach">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 8, right: 0, bottom: 0, left: -8 }}>
<CartesianGrid stroke="var(--color-edge)" vertical={false} />
<XAxis dataKey="label" stroke="var(--color-soft)" fontSize={12} tickLine={false} minTickGap={12} />
<YAxis yAxisId="hours" stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={40}
domain={[0, "auto"]} tickFormatter={(v: number) => number(v, 0)} />
<YAxis yAxisId="temperature" orientation="right" stroke="var(--color-soft)" fontSize={12} tickLine={false}
axisLine={false} width={40} tickFormatter={(v: number) => number(v, 0)} />
<Tooltip
formatter={(v, name) => [name === "Średnia temperatura zewnętrzna" ? degrees(Number(v)) : hours(Number(v)), name]}
contentStyle={{ background: "var(--color-panel)", border: "1px solid var(--color-edge)", borderRadius: 12 }}
/>
<Bar yAxisId="hours" dataKey="hours" name="Sprężarka (godziny)" fill={BAR} radius={[4, 4, 0, 0]} isAnimationActive={false} />
<Line yAxisId="temperature" dataKey="outdoor" name="Średnia temperatura zewnętrzna" stroke={TEMPERATURE}
strokeWidth={2.5} dot={false} isAnimationActive={false} connectNulls={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
function StatsFor({ range }: { range: RangeId }) {
// One poller per range, so an answer is never shown for the other one.
const { data, offline } = usePoll<StatsData>(`api/stats?range=${range}`);
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
const withData = data.days.filter((d) => d.compressorHours != null);
return (
<>
{offline && <Freshness offline />}
{withData.length === 0 ? <p className="py-8 text-soft">Za mało danych w tym okresie.</p> : (
<>
<Totals stats={data} />
{takting(data.days) && (
<p role="status" className="mb-4 rounded-xl bg-run-soft p-3 text-sm text-run">
Dużo załączeń – sprężarka może taktować, czyli często się włączać i wyłączać na krótko. To zużywa ją szybciej.
</p>
)}
<DailyChart days={data.days} />
<p className="mt-2 text-sm text-soft">
Zielone słupki: godziny pracy sprężarki, niebieska linia: średnia temperatura na zewnątrz.
Wartości liczone z pomiarów co 5 minut, więc załączenia są policzone „co najmniej”: krótkie cykle między pomiarami są niewidoczne.
</p>
</>
)}
</>
);
}
export function Stats() {
const [range, setRange] = useState<RangeId>("7d");
return (
<Card>
<div className="mb-4 flex gap-2 rounded-xl bg-page p-1 sm:w-fit">
{(Object.entries(RANGES) as [RangeId, string][]).map(([id, name]) => (
<button key={id} onClick={() => setRange(id)} aria-pressed={id === range}
className="min-h-10 flex-1 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
{name}
</button>
))}
</div>
<StatsFor range={range} />
</Card>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState, type ReactNode } from "react";
/**
* Views inside one screen, kept in the URL hash so Back works: the first view is `#screen`,
* the others `#screen/id`. Every view is a history entry. `views` must be a constant.
*/
export function useSubView<T extends string>(screen: string, views: Record<T, string>) {
const ids = Object.keys(views) as T[];
const fromHash = (): T => {
const [hashScreen, view = ""] = location.hash.slice(1).split("/");
return hashScreen === screen && view in views ? (view as T) : ids[0];
};
const [view, setView] = useState(fromHash);
useEffect(() => {
const onHash = () => setView(fromHash());
addEventListener("hashchange", onHash);
return () => removeEventListener("hashchange", onHash);
}, [screen, views]);
const show = (id: T) => {
location.hash = id === ids[0] ? screen : `${screen}/${id}`;
};
return [view, show] as const;
}
export function SubViewSwitch<T extends string>({ screen, label, views, view, onSelect }: {
screen: string; label: string; views: Record<T, string>; view: T; onSelect: (id: T) => void;
}) {
return (
<div role="tablist" aria-label={label} className="mb-5 flex gap-1 rounded-xl border border-edge bg-panel p-1 lg:max-w-md">
{(Object.entries(views) as [T, string][]).map(([id, name]) => (
<button key={id} role="tab" id={`${screen}-tab-${id}`} aria-selected={id === view} aria-controls={`${screen}-view`}
onClick={() => onSelect(id)}
className="min-h-11 flex-1 rounded-lg px-3 font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-selected:bg-brand aria-selected:text-white dark:aria-selected:text-black">
{name}
</button>
))}
</div>
);
}
export function SubViewPanel({ screen, view, children }: { screen: string; view: string; children: ReactNode }) {
return <div role="tabpanel" id={`${screen}-view`} aria-labelledby={`${screen}-tab-${view}`}>{children}</div>;
}
+29
View File
@@ -192,3 +192,32 @@ export interface CommandResult {
restoreAt?: string | null;
results?: { status: LineStatus; message: string; restoreAt: string | null }[];
}
export interface StatsDay {
date: string; // local day, YYYY-MM-DD
compressorHours: number | null;
compressorStarts: number | null;
cwuHours: number | null;
coHours: number | null;
heaterHours: number | null;
avgOutdoorTemp: number | null;
minCwuTemp: number | null;
maxCwuTemp: number | null;
}
export interface StatsTotals {
compressorHours: number;
compressorStarts: number;
cwuHours: number;
coHours: number;
heaterHours: number;
avgOutdoorTemp: number | null;
minCwuTemp: number | null;
maxCwuTemp: number | null;
}
export interface Stats {
range: "7d" | "30d";
days: StatsDay[];
totals: StatsTotals;
}