diff --git a/backend/README.md b/backend/README.md index 77376d8..49a94b0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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`): diff --git a/backend/src/main/java/home/backend/Backend.java b/backend/src/main/java/home/backend/Backend.java index 09291af..357c2cf 100644 --- a/backend/src/main/java/home/backend/Backend.java +++ b/backend/src/main/java/home/backend/Backend.java @@ -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). */ diff --git a/backend/src/main/java/home/backend/adapters/http/ApiServer.java b/backend/src/main/java/home/backend/adapters/http/ApiServer.java index e078b59..5a4761e 100644 --- a/backend/src/main/java/home/backend/adapters/http/ApiServer.java +++ b/backend/src/main/java/home/backend/adapters/http/ApiServer.java @@ -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; } diff --git a/backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java b/backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java index 2519c31..a2a0a1f 100644 --- a/backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java +++ b/backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java @@ -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 samples(Instant from, Instant to) throws IOException { + String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli()); + return InfluxSamples.parse(fetch(SeriesSources.LEGACY, query)); + } + private Map 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)); diff --git a/backend/src/main/java/home/backend/adapters/influx/InfluxSamples.java b/backend/src/main/java/home/backend/adapters/influx/InfluxSamples.java new file mode 100644 index 0000000..d463a46 --- /dev/null +++ b/backend/src/main/java/home/backend/adapters/influx/InfluxSamples.java @@ -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 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 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()); + } + } +} diff --git a/backend/src/main/java/home/backend/adapters/influx/SeriesSources.java b/backend/src/main/java/home/backend/adapters/influx/SeriesSources.java index cd13ac2..c980100 100644 --- a/backend/src/main/java/home/backend/adapters/influx/SeriesSources.java +++ b/backend/src/main/java/home/backend/adapters/influx/SeriesSources.java @@ -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> SOURCES = Map.ofEntries( diff --git a/backend/src/main/java/home/backend/application/DayTally.java b/backend/src/main/java/home/backend/application/DayTally.java new file mode 100644 index 0000000..26f1f2e --- /dev/null +++ b/backend/src/main/java/home/backend/application/DayTally.java @@ -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; + } +} diff --git a/backend/src/main/java/home/backend/application/HeatPumpSamples.java b/backend/src/main/java/home/backend/application/HeatPumpSamples.java new file mode 100644 index 0000000..9270e47 --- /dev/null +++ b/backend/src/main/java/home/backend/application/HeatPumpSamples.java @@ -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 samples(Instant from, Instant to) throws IOException; +} diff --git a/backend/src/main/java/home/backend/application/StatsService.java b/backend/src/main/java/home/backend/application/StatsService.java new file mode 100644 index 0000000..be4eb99 --- /dev/null +++ b/backend/src/main/java/home/backend/application/StatsService.java @@ -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 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 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 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 samples, Instant now, Map 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 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; + } + } +} diff --git a/backend/src/main/java/home/backend/domain/HeatPumpSample.java b/backend/src/main/java/home/backend/domain/HeatPumpSample.java new file mode 100644 index 0000000..a4d9bed --- /dev/null +++ b/backend/src/main/java/home/backend/domain/HeatPumpSample.java @@ -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) { +} diff --git a/backend/src/main/java/home/backend/domain/StatsView.java b/backend/src/main/java/home/backend/domain/StatsView.java new file mode 100644 index 0000000..e764964 --- /dev/null +++ b/backend/src/main/java/home/backend/domain/StatsView.java @@ -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 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) { + } +} diff --git a/backend/src/test/java/home/backend/adapters/http/ApiServerTest.java b/backend/src/test/java/home/backend/adapters/http/ApiServerTest.java index f21e5b8..af34d9b 100644 --- a/backend/src/test/java/home/backend/adapters/http/ApiServerTest.java +++ b/backend/src/test/java/home/backend/adapters/http/ApiServerTest.java @@ -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 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()); diff --git a/backend/src/test/java/home/backend/adapters/influx/InfluxHistoryRepositoryTest.java b/backend/src/test/java/home/backend/adapters/influx/InfluxHistoryRepositoryTest.java index 50baaf7..04e8d05 100644 --- a/backend/src/test/java/home/backend/adapters/influx/InfluxHistoryRepositoryTest.java +++ b/backend/src/test/java/home/backend/adapters/influx/InfluxHistoryRepositoryTest.java @@ -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 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, diff --git a/backend/src/test/java/home/backend/application/StatsServiceTest.java b/backend/src/test/java/home/backend/application/StatsServiceTest.java new file mode 100644 index 0000000..f952b1a --- /dev/null +++ b/backend/src/test/java/home/backend/application/StatsServiceTest.java @@ -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 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")); + } +} diff --git a/backend/src/test/resources/api/stats.json b/backend/src/test/resources/api/stats.json new file mode 100644 index 0000000..5724766 --- /dev/null +++ b/backend/src/test/resources/api/stats.json @@ -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 + } +} diff --git a/backend/src/test/resources/influx/influx-query-heat-pump.json b/backend/src/test/resources/influx/influx-query-heat-pump.json new file mode 100644 index 0000000..eb339af --- /dev/null +++ b/backend/src/test/resources/influx/influx-query-heat-pump.json @@ -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]]}]}]} diff --git a/docs/spec.md b/docs/spec.md index 43fc479..4507647 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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. diff --git a/frontend/README.md b/frontend/README.md index 285b408..0a436bd 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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. diff --git a/frontend/src/Energy.tsx b/frontend/src/Energy.tsx index dc168a3..b529801 100644 --- a/frontend/src/Energy.tsx +++ b/frontend/src/Energy.tsx @@ -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 ( -
- {(Object.entries(VIEWS) as [ViewId, string][]).map(([id, label]) => ( - - ))} -
- ); -} export function Energy() { - const [view, show] = useView(); + const [view, show] = useSubView("energy", VIEWS); return ( <> - -
+ + {view === "now" && show("battery")} />} {view === "battery" && } {view === "inverters" && } -
+ ); } diff --git a/frontend/src/History.test.tsx b/frontend/src/History.test.tsx new file mode 100644 index 0000000..b577e8c --- /dev/null +++ b/frontend/src/History.test.tsx @@ -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(); + 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(); + 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.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.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.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(); + }); +}); diff --git a/frontend/src/History.tsx b/frontend/src/History.tsx index 142f11a..4c36ea8 100644 --- a/frontend/src/History.tsx +++ b/frontend/src/History.tsx @@ -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("cwu_temp"); const [range, setRange] = useState("24h"); + return ( + +
+ +
+ {Object.entries(RANGES).map(([id, name]) => ( + + ))} +
+
+ +
+ ); +} + +const VIEWS = { charts: "Wykresy", stats: "Statystyki" } as const; + +export function History() { + const [view, show] = useSubView("history", VIEWS); return ( <> - -
- -
- {Object.entries(RANGES).map(([id, name]) => ( - - ))} -
-
- -
+ + + {view === "charts" ? : } + ); } diff --git a/frontend/src/Stats.tsx b/frontend/src/Stats.tsx new file mode 100644 index 0000000..d707956 --- /dev/null +++ b/frontend/src/Stats.tsx @@ -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 ( +
+

{value}

+

{label}

+ {hint &&

{hint}

} +
+ ); +} + +function Totals({ stats }: { stats: StatsData }) { + const t = stats.totals; + return ( +
+ + + + + 0} + hint={t.heaterHours > 0 ? "Prąd z grzałki jest drogi" : undefined} /> +
+ ); +} + +function DailyChart({ days }: { days: StatsDay[] }) { + const rows = days.map((d) => ({ label: dayLabel(d.date), hours: d.compressorHours, outdoor: d.avgOutdoorTemp })); + return ( +
+ + + + + number(v, 0)} /> + number(v, 0)} /> + [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 }} + /> + + + + +
+ ); +} + +function StatsFor({ range }: { range: RangeId }) { + // One poller per range, so an answer is never shown for the other one. + const { data, offline } = usePoll(`api/stats?range=${range}`); + if (!data) return <>{!offline && }; + const withData = data.days.filter((d) => d.compressorHours != null); + return ( + <> + {offline && } + {withData.length === 0 ?

Za mało danych w tym okresie.

: ( + <> + + {takting(data.days) && ( +

+ 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. +

+ )} + +

+ 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. +

+ + )} + + ); +} + +export function Stats() { + const [range, setRange] = useState("7d"); + return ( + +
+ {(Object.entries(RANGES) as [RangeId, string][]).map(([id, name]) => ( + + ))} +
+ +
+ ); +} diff --git a/frontend/src/SubView.tsx b/frontend/src/SubView.tsx new file mode 100644 index 0000000..739d883 --- /dev/null +++ b/frontend/src/SubView.tsx @@ -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(screen: string, views: Record) { + 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({ screen, label, views, view, onSelect }: { + screen: string; label: string; views: Record; view: T; onSelect: (id: T) => void; +}) { + return ( +
+ {(Object.entries(views) as [T, string][]).map(([id, name]) => ( + + ))} +
+ ); +} + +export function SubViewPanel({ screen, view, children }: { screen: string; view: string; children: ReactNode }) { + return
{children}
; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2bf49bc..ec04fd0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; +}