feat: heat pump statistics per day in the Historia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
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:
1 parent
b288c98106
commit
cbf507fa18
24 files changed
+1018
-63
No files matched your search
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user