feat(frontend): Odśwież button that asks the gateway for fresh data
CI / Check (gateway, backend, frontend) (push) Successful in 1m31s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 18:38:33 +02:00
1 parent aaf08d4f5c
commit 8b07b9ba0e
11 files changed
+208 -2

No files matched your search

+2 -1
View File
@@ -75,7 +75,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.
pressure) of the living room. An "Odśwież" button asks for one extra poll of each device, at most once
a minute per device (the MK01 takes one client).
- 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
+4
View File
@@ -20,6 +20,10 @@ and gateway's versions in the "Wersje" card of the Stan screen.
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
Every screen starts with the "Odśwież" button (`Refresh.tsx`): it posts `api/refresh`, waits 5 s for the gateway's extra poll, then
`refreshAll()` (`poll.ts`) fetches every poller that is on screen again. When the gateway skipped every device (polled within a minute) it says so and
waits out the shortest cooldown.
Ustawienia ends with the "Historia zmian" card (`ChangeHistory.tsx`): the last 7 days of `api/audit`, 20 rows and "Pokaż więcej".
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
+2
View File
@@ -3,6 +3,7 @@ import { celsius, hour, longDate } from "./format";
import { Icon, type IconName } from "./icons";
import { Energy } from "./Energy";
import { Overview } from "./Overview";
import { RefreshButton } from "./Refresh";
import { usePoll } from "./poll";
import { useTheme, type Theme } from "./theme";
import type { HeatPump } from "./types";
@@ -119,6 +120,7 @@ export function App() {
<div className="hidden flex-1 lg:flex lg:flex-col"><SideFooter /></div>
</nav>
<main className="mx-auto max-w-[1500px] px-4 pt-5 pb-28 sm:px-6 lg:px-8 lg:pt-8 lg:pb-10">
<RefreshButton />
<Suspense fallback={<Loading />}><View /></Suspense>
<ThemeSwitch className="mx-auto mt-8 max-w-xs border border-edge bg-panel text-ink lg:hidden" />
</main>
+98
View File
@@ -0,0 +1,98 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { answer, example, serve } from "./fixtures";
const button = () => screen.getByRole("button", { name: "Odśwież" });
const status = () => screen.getByRole("status");
// The button and a poller that shows api/heat-pump, as a screen would.
async function shown(onPost: () => Promise<Response>) {
const fetch = serve({ "api/heat-pump": example("heat-pump") }, onPost);
const { RefreshButton } = await import("./Refresh");
const { usePoll } = await import("./poll");
function Screen() {
usePoll("api/heat-pump");
return <RefreshButton />;
}
render(<Screen />);
await act(async () => {});
return fetch;
}
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
const gets = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method !== "POST");
describe("RefreshButton", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "Date"] });
vi.setSystemTime(new Date("2026-10-24T10:00:00Z"));
});
afterEach(() => vi.useRealTimers());
it("asks once, shows the progress, then fetches again and says when it updated", async () => {
const fetch = await shown(() => answer({ requested: ["deye"], skipped: [] }));
const before = gets(fetch).length;
fireEvent.click(button());
fireEvent.click(button());
await act(async () => {});
expect(posts(fetch)).toHaveLength(1);
expect(posts(fetch)[0][1]!.headers).toMatchObject({ "X-Requested-With": "home-pump" });
expect(status()).toHaveTextContent("Odświeżanie…");
expect(button()).toBeDisabled();
expect(gets(fetch)).toHaveLength(before);
await act(() => vi.advanceTimersByTimeAsync(5000));
expect(gets(fetch)).toHaveLength(before + 1);
expect(status()).toHaveTextContent("Zaktualizowano 12:00:05");
expect(button()).toBeEnabled();
});
it("says the data is fresh when every device was skipped and waits out the cooldown", async () => {
const fetch = await shown(() => answer({
requested: [],
skipped: [{ device: "heat-pump", retryInSeconds: 41 }, { device: "deye", retryInSeconds: 30 }],
}));
const before = gets(fetch).length;
fireEvent.click(button());
await act(async () => {});
expect(status()).toHaveTextContent("Dane są świeże – spróbuj za 30 s");
expect(button()).toBeDisabled();
expect(gets(fetch)).toHaveLength(before);
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(status()).toHaveTextContent("Dane są świeże – spróbuj za 20 s");
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(status()).toBeEmptyDOMElement();
expect(button()).toBeEnabled();
});
it("shows a short error when the backend has no gateway, and allows another try", async () => {
const fetch = await shown(() => answer({ error: "gateway unreachable" }, 503));
fireEvent.click(button());
await act(async () => {});
expect(status()).toHaveTextContent("Nie udało się odświeżyć danych");
expect(button()).toBeEnabled();
fireEvent.click(button());
await act(async () => {});
expect(posts(fetch)).toHaveLength(2);
});
it("says so when the network is down", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
const { RefreshButton } = await import("./Refresh");
render(<RefreshButton />);
fireEvent.click(button());
await act(async () => {});
expect(status()).toHaveTextContent("Brak połączenia z siecią");
});
});
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { postRefresh, WriteError } from "./api";
import { hourWithSeconds } from "./format";
import { Icon } from "./icons";
import { refreshAll } from "./poll";
// The gateway polls the devices about 3 s after the request; the answers are fetched after this.
export const SETTLE_MS = 5000;
type State =
| { kind: "idle" }
| { kind: "busy" }
| { kind: "done"; at: Date }
| { kind: "fresh"; until: number }
| { kind: "error"; text: string };
const failure = (error: unknown) =>
error instanceof WriteError && error.status === 0 ? "Brak połączenia z siecią" : "Nie udało się odświeżyć danych";
// Asks the gateway for current readings; it skips devices it polled less than a minute ago.
export function RefreshButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [now, setNow] = useState(Date.now());
const waiting = state.kind === "fresh" ? Math.ceil((state.until - now) / 1000) : 0;
useEffect(() => {
if (state.kind !== "fresh") return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [state.kind]);
useEffect(() => {
if (state.kind === "fresh" && waiting <= 0) setState({ kind: "idle" });
}, [state.kind, waiting]);
const tap = async () => {
setState({ kind: "busy" });
try {
const { requested, skipped } = await postRefresh();
if (requested.length > 0) {
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
refreshAll();
setState({ kind: "done", at: new Date() });
} else {
const retry = Math.min(...skipped.map((s) => s.retryInSeconds));
setNow(Date.now());
setState({ kind: "fresh", until: Date.now() + retry * 1000 });
}
} catch (error) {
setState({ kind: "error", text: failure(error) });
}
};
const text = {
idle: "", busy: "Odświeżanie…", error: state.kind === "error" ? state.text : "",
done: state.kind === "done" ? `Zaktualizowano ${hourWithSeconds(state.at)}` : "",
fresh: `Dane są świeże – spróbuj za ${waiting} s`,
}[state.kind];
const busy = state.kind === "busy";
return (
<div className="mb-4 flex items-center justify-end gap-3">
<p role="status" className={`text-sm ${state.kind === "error" ? "text-bad" : "text-soft"}`}>{text}</p>
<button onClick={tap} disabled={busy || state.kind === "fresh"} aria-label="Odśwież"
className="flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-xl border-2 border-brand bg-panel px-3 font-bold text-brand transition active:scale-[0.98] disabled:opacity-40">
<Icon name="refresh" className={`size-5 ${busy ? "animate-spin" : ""}`} />
<span className="hidden sm:inline">Odśwież</span>
</button>
</div>
);
}
+15 -1
View File
@@ -1,4 +1,4 @@
import type { CommandResult } from "./types";
import type { CommandResult, RefreshAnswer } from "./types";
// A request that gets no answer in time is given up, so a screen never waits for ever.
const READ_TIMEOUT_MS = 15000;
@@ -35,3 +35,17 @@ export async function postJson<T extends { status?: string; outcome?: string } =
if (!answer?.status && !answer?.outcome) throw new WriteError(response.status);
return answer;
}
// Asks the gateway for fresh readings. Rejects with the HTTP status (503: no gateway), 0 when the network fails.
export async function postRefresh(): Promise<RefreshAnswer> {
const response = await fetch("api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Requested-With": "home-pump" },
body: "{}",
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
}).catch(() => {
throw new WriteError(0);
});
if (!response.ok) throw new WriteError(response.status);
return response.json();
}
+2
View File
@@ -57,6 +57,8 @@ export function warsaw(ms: number) {
export const hour = (date: Date) =>
date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit", timeZone: ZONE });
export const hourWithSeconds = (date: Date) =>
date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: ZONE });
export const weekday = (date: Date) => date.toLocaleDateString("pl-PL", { weekday: "short", timeZone: ZONE });
export const dayAndHour = (date: Date) => `${weekday(date)} ${hour(date)}`;
export const longDate = (date: Date) =>
+1
View File
@@ -16,6 +16,7 @@ const PATHS = {
battery: "M3 7h15a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM22 11v2M6 10v4M10 10v4",
plug: "M9 2v6M15 2v6M6 8h12v4a6 6 0 0 1-12 0zM12 18v4",
alert: "M12 3 2 21h20zM12 10v5M12 18v.5",
refresh: "M20 11a8 8 0 1 0-2.3 6M20 4v7h-7",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
} as const;
+5
View File
@@ -67,3 +67,8 @@ export function usePoll<T>(url: string, intervalMs = REFRESH_MS): Polled<T> {
/** Asks again at once, e.g. after a write. */
export const reload = (url: string) => refresh(url);
/** Asks again at once for everything that is on screen, e.g. after the gateway polled the devices. */
export function refreshAll() {
entries.forEach((entry, url) => entry.listeners.size > 0 && refresh(url));
}
+5
View File
@@ -232,6 +232,11 @@ export interface RestoreResult {
message?: string;
}
export interface RefreshAnswer {
requested: string[];
skipped: { device: string; retryInSeconds: number }[];
}
export interface CommandResult {
status: "APPLIED" | "REJECTED" | "FAILED" | "PARTIAL";
message?: string;
+6
View File
@@ -114,6 +114,12 @@ pending with its backoff.
**Extra poll.** After a write or restore that took, the heat pump is polled once more 3 s later by its own poller thread (so never beside another read, and several writes share one poll), because its derived targets would otherwise show the old value until the next 300 s poll; a failure of it counts like any failed poll.
**Refresh.** `POST /refresh` (same Bearer token and throttle, no body, on when `commands` or `deyeCommands` is enabled, so the token exists)
is the app's "Odśwież" button. It asks every device's poller for one extra poll (after the same 3 s) and answers
`{"requested": ["deye"], "skipped": [{"device": "heat-pump", "retryInSeconds": 41}]}`. A device is polled again only if its
last poll, regular or extra, started at least 60 s ago (`Poller.REFRESH_COOLDOWN`); otherwise it is skipped, not queued. The
heat pump needs the floor most: the Frisko MK01 takes one client and every poll of ours drops the phone app (ADR-003). Read-only.
**Shown value.** `GET /commands` shows, per setpoint, the value read back after the gateway's last write or
restore or the last polled value, whichever is newer: a poll takes minutes, a panel change shows in a later one.