Merge PR #91: fix(frontend): stale states, consistent setpoint names, contrast, 44 px theme switch
This commit is contained in:
commit
655e84be8d
12 files changed
+108
-54
No files matched your search
@@ -19,8 +19,8 @@ public class AuditService {
|
||||
private static final Map<String, Duration> RANGES =
|
||||
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7), "30d", Duration.ofDays(30));
|
||||
private static final Map<String, String> SETPOINTS = Map.of(
|
||||
"cwu_economy", "CWU eko", "cwu_comfort", "CWU komfort",
|
||||
"co_economy", "CO eko", "co_comfort", "CO komfort");
|
||||
"cwu_economy", "Ciepła woda – oszczędnie", "cwu_comfort", "Ciepła woda – komfort",
|
||||
"co_economy", "Ogrzewanie – oszczędnie", "co_comfort", "Ogrzewanie – komfort");
|
||||
private static final String RESTORE_USER = "restore";
|
||||
private static final String RESTORE_LABEL = "automatycznie (przywrócenie)";
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class AuditServiceTest {
|
||||
|
||||
var entries = service.audit(null).entries();
|
||||
|
||||
assertEquals(List.of("CWU eko", "Rezerwa baterii", "CO komfort"),
|
||||
assertEquals(List.of("Ciepła woda – oszczędnie", "Rezerwa baterii", "Ogrzewanie – komfort"),
|
||||
entries.stream().map(AuditEntry::what).toList());
|
||||
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
|
||||
entries.stream().map(AuditEntry::source).toList());
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"entries": [
|
||||
{ "time": "2026-10-24T09:30:00Z", "source": "battery", "what": "Rezerwa baterii", "oldValue": 50, "newValue": 60,
|
||||
"unit": "%", "who": "mariusz", "status": "APPLIED" },
|
||||
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "CWU eko", "oldValue": 45, "newValue": 8,
|
||||
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "Ciepła woda – oszczędnie", "oldValue": 45, "newValue": 8,
|
||||
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
|
||||
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
|
||||
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
|
||||
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "CO komfort", "oldValue": null, "newValue": 22,
|
||||
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "Ogrzewanie – komfort", "oldValue": null, "newValue": 22,
|
||||
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Overview } from "./Overview";
|
||||
import { usePoll } from "./poll";
|
||||
import { useTheme, type Theme } from "./theme";
|
||||
import type { HeatPump } from "./types";
|
||||
import { Dot, Loading } from "./ui";
|
||||
import { Dot, isStale, Loading } from "./ui";
|
||||
import { useWeather, WeatherIcon } from "./Weather";
|
||||
|
||||
// Energy shares code with the first screen; the others load when opened.
|
||||
@@ -45,7 +45,7 @@ function ThemeSwitch({ className }: { className: string }) {
|
||||
<div role="group" aria-label="Motyw" className={`flex gap-1 rounded-xl p-1 text-sm ${className}`}>
|
||||
{Object.entries(THEMES).map(([id, label]) => (
|
||||
<button key={id} aria-pressed={id === theme} onClick={() => setTheme(id as Theme)}
|
||||
className="flex-1 rounded-lg px-2.5 py-1.5 font-semibold opacity-70 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
|
||||
className="min-h-11 flex-1 rounded-lg px-2.5 font-semibold opacity-80 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
@@ -57,9 +57,10 @@ function ThemeSwitch({ className }: { className: string }) {
|
||||
function SideFooter() {
|
||||
const { data, offline } = usePoll<HeatPump>("api/heat-pump");
|
||||
const now = useClock();
|
||||
const old = !offline && data?.time != null && isStale(data.time, data.stale);
|
||||
const weather = useWeather();
|
||||
return (
|
||||
<div className="mt-auto space-y-4 px-2">
|
||||
<div className="mt-auto space-y-3 border-t border-white/10 px-2 pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{weather && <WeatherIcon code={weather.today.weatherCode} className="size-9" />}
|
||||
<div>
|
||||
@@ -70,9 +71,9 @@ function SideFooter() {
|
||||
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
||||
<ThemeSwitch className="bg-white/10" />
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<Dot tone={offline ? "bad" : "ok"} live={!offline} />
|
||||
<Dot tone={offline ? "bad" : old ? "warn" : "ok"} live={!offline && !old} />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold">{offline ? "Brak połączenia" : "System online"}</p>
|
||||
<p className="font-semibold">{offline ? "Brak połączenia" : old ? "Dane nieaktualne" : "System online"}</p>
|
||||
<p className="text-white/60">{data?.time ? `Ostatni odczyt: ${hour(new Date(data.time))}` : "Czekam na odczyt"}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,8 +105,8 @@ export function App() {
|
||||
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
|
||||
<div>
|
||||
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
|
||||
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
|
||||
<p className="text-base font-bold leading-tight">Dom Szczepańcowa</p>
|
||||
<p className="text-xs text-white/70">Pompa ciepła i energia</p>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(SCREENS).map(([id, s]) => (
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("ChangeHistory", () => {
|
||||
expect(rows[1]).toHaveTextContent("automatycznie (przywrócenie)");
|
||||
expect(rows[2]).toHaveTextContent("nie → tak");
|
||||
expect(within(rows[2]).getByText("nieudana")).toBeInTheDocument();
|
||||
expect(rows[3]).toHaveTextContent("— → 22 °C");
|
||||
expect(rows[3]).toHaveTextContent("ustawiono 22 °C");
|
||||
expect(within(rows[3]).getByText("odrzucona")).toBeInTheDocument();
|
||||
expect(within(rows[0]).queryByText(/nieudana|odrzucona|konflikt|nieważna/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Card, Pill } from "./ui";
|
||||
|
||||
const URL = "api/audit?range=7d";
|
||||
const PAGE = 20;
|
||||
const STATUS: Record<string, string> = {
|
||||
FAILED: "nieudana", REJECTED: "odrzucona", CONFLICT: "konflikt", INVALID: "nieważna",
|
||||
// A failure may have reached the pump, so it is red; the others changed nothing, so amber.
|
||||
const STATUS: Record<string, { text: string; tone: "bad" | "warn" }> = {
|
||||
FAILED: { text: "nieudana", tone: "bad" }, REJECTED: { text: "odrzucona", tone: "warn" },
|
||||
CONFLICT: { text: "konflikt", tone: "warn" }, INVALID: { text: "nieważna", tone: "warn" },
|
||||
};
|
||||
|
||||
// 1830 is a clock time, 0/1 is off/on.
|
||||
@@ -20,17 +22,20 @@ const SHOWN: Record<string, (value: number) => string> = {
|
||||
const shown = (value: number | null, unit: string) => (value == null ? "—" : (SHOWN[unit] ?? number)(value));
|
||||
|
||||
function Row({ entry }: { entry: AuditEntry }) {
|
||||
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? entry.status);
|
||||
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? { text: entry.status, tone: "warn" as const });
|
||||
return (
|
||||
<li className="border-t border-edge py-3 first:border-t-0 first:pt-0">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-semibold">{entry.what}</span>
|
||||
<span className="text-sm text-soft">{dateTime(entry.time)}</span>
|
||||
<span className="shrink-0 text-sm text-soft">{dateTime(entry.time)}</span>
|
||||
</div>
|
||||
<p className="my-1">{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></p>
|
||||
<p className="my-1">
|
||||
{entry.oldValue == null ? <>ustawiono <b>{shown(entry.newValue, entry.unit)}</b></>
|
||||
: <>{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></>}
|
||||
</p>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-soft">
|
||||
{entry.who}
|
||||
{status && <Pill tone="off">{status}</Pill>}
|
||||
{status && <Pill tone={status.tone}>{status.text}</Pill>}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -43,8 +43,8 @@ export function TemperatureDetails({ hp, pi }: { hp: HeatPump; pi: PiSensor | un
|
||||
<Row label="Różnica" value={difference(t.groundIn, t.groundOut)} />
|
||||
</Group>
|
||||
<Group title="Nastawy">
|
||||
<Row label="CWU oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
|
||||
<Row label="CO oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
|
||||
<Row label="Ciepła woda – oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
|
||||
<Row label="Ogrzewanie – oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -14,12 +14,21 @@ const paragraph = (text: string) => (_: string, el: Element | null) => el?.tagNa
|
||||
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
|
||||
|
||||
describe("Settings", () => {
|
||||
it("describes each quick action in one line", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByText("Ciepła woda – oszczędnie: 45 °C na 2 godz.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Obniża temperatury na czas nieobecności, do dnia powrotu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes only after the summary is confirmed", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie"));
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
expect(screen.getByText("Ustawię 9 °C. Za 2 godz. wrócimy do 8 °C.")).toBeInTheDocument();
|
||||
@@ -35,7 +44,7 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie domu, komfort"));
|
||||
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie – komfort"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
|
||||
|
||||
@@ -48,7 +57,7 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – komfort"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
|
||||
|
||||
@@ -61,15 +70,15 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
expect(screen.getByText(paragraph("Ciepła woda – komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/away");
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
|
||||
expect(alert).toHaveTextContent("Ciepła woda, komfort: zapisano");
|
||||
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
expect(alert).toHaveTextContent("Ciepła woda – komfort: zapisano");
|
||||
expect(alert).toHaveTextContent("Ogrzewanie – komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
});
|
||||
|
||||
it("asks for the return time of a trip and sends the chosen time with the local offset", async () => {
|
||||
@@ -107,10 +116,10 @@ describe("Settings", () => {
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getAllByRole("status").map((o) => o.textContent)).toEqual(["10 °C", "15 °C"]);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort"));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie – komfort"));
|
||||
expect(screen.getByText(paragraph("Ciepła woda – komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
|
||||
const body = JSON.parse(String(posts(fetch)[0][1]!.body));
|
||||
@@ -125,8 +134,8 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByText(paragraph("Ogrzewanie domu, komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
|
||||
expect(screen.getByText(paragraph("Ogrzewanie – komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables a trip temperature that cannot be lowered and the confirmation with it", async () => {
|
||||
@@ -137,8 +146,8 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ogrzewanie – komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
|
||||
expect(screen.getByText("Już na minimum (5 °C), nie da się obniżyć.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
|
||||
});
|
||||
@@ -189,7 +198,7 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
|
||||
expect(await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a change without an end time as lasting while the server runs", async () => {
|
||||
@@ -200,7 +209,7 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
|
||||
await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -211,13 +220,13 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("restore now", () => {
|
||||
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
|
||||
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda – oszczędnie" });
|
||||
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
|
||||
|
||||
it("asks first, then restores and reloads the state", async () => {
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Button, Card, Freshness, Loading, PageTitle, Stepper } from "./ui";
|
||||
|
||||
const URL = "api/setpoints";
|
||||
const NAMES: Record<string, string> = {
|
||||
cwu_economy: "Ciepła woda, oszczędnie",
|
||||
cwu_comfort: "Ciepła woda, komfort",
|
||||
co_economy: "Ogrzewanie domu, oszczędnie",
|
||||
co_comfort: "Ogrzewanie domu, komfort",
|
||||
cwu_economy: "Ciepła woda – oszczędnie",
|
||||
cwu_comfort: "Ciepła woda – komfort",
|
||||
co_economy: "Ogrzewanie – oszczędnie",
|
||||
co_comfort: "Ogrzewanie – komfort",
|
||||
};
|
||||
const DURATIONS = [
|
||||
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
|
||||
@@ -144,6 +144,11 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
);
|
||||
}
|
||||
|
||||
// What a preset does, in one line under its button; a held preset has no fixed values (the user picks them).
|
||||
const presetHint = (p: Preset) => p.until
|
||||
? "Obniża temperatury na czas nieobecności, do dnia powrotu"
|
||||
: p.changes.map((c) => `${name(c.setpoint)}: ${degrees(c.value)}${c.hours == null ? " do odwołania" : ` na ${duration(c.hours)}`}`).join(", ");
|
||||
|
||||
function changeSentence(data: Setpoints, c: Change, held: boolean) {
|
||||
const back = degrees(returnValue(data, c.setpoint));
|
||||
const tail = held ? `do powrotu, potem wróci ${back}`
|
||||
@@ -284,15 +289,21 @@ export function Settings() {
|
||||
{data.presets.map((p) => {
|
||||
const ready = presetReady(data, p.changes);
|
||||
return (
|
||||
<Button key={p.id} big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
|
||||
{p.name}{ready ? "" : " (brak odczytu)"}
|
||||
</Button>
|
||||
<div key={p.id} className="flex flex-col gap-1.5">
|
||||
<Button big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
|
||||
{p.name}{ready ? "" : " (brak odczytu)"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-soft">{presetHint(p)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card title="Nastawy">
|
||||
<p className="mb-3 text-sm text-soft">
|
||||
„Komfort” i „oszczędnie” to dwie temperatury, między którymi pompa przełącza się sama według tygodniowego programu na sterowniku.
|
||||
</p>
|
||||
{data.setpoints.map((s) => {
|
||||
const shown = draft?.name === s.name ? draft.value : s.value;
|
||||
const label = name(s.name);
|
||||
|
||||
@@ -27,4 +27,27 @@ describe("Status", () => {
|
||||
expect(versions()).toHaveTextContent("Bramkanieznana");
|
||||
expect(versions()).toHaveTextContent("Serwerv1.3.0");
|
||||
});
|
||||
|
||||
it("does not call a device working when its last reading is old", async () => {
|
||||
const health = example<Health>("health");
|
||||
const pump = health.devices[0];
|
||||
health.devices[0] = { ...pump, lastSuccess: new Date(Date.now() - 17 * 3600_000).toISOString() };
|
||||
serve({ "api/health": health });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("brak świeżych danych")).toBeInTheDocument();
|
||||
expect(screen.queryByText("działa")).toBeNull();
|
||||
});
|
||||
|
||||
it("says a device works when it answered a moment ago", async () => {
|
||||
const health = example<Health>("health");
|
||||
health.devices[0] = { ...health.devices[0], lastSuccess: new Date().toISOString() };
|
||||
serve({ "api/health": health });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("działa")).toBeInTheDocument();
|
||||
expect(screen.queryByText("brak świeżych danych")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ago } from "./format";
|
||||
import { Icon } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Health } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Row } from "./ui";
|
||||
import { Card, Dot, Freshness, isStale, Loading, PageTitle, Row } from "./ui";
|
||||
|
||||
const DEVICE_NAMES: Record<string, string> = {
|
||||
"heat-pump": "Pompa ciepła",
|
||||
@@ -30,11 +31,15 @@ export function Status() {
|
||||
{data.devices.length === 0 ? <p className="text-soft">Lista pojawi się, gdy dom odpowie.</p>
|
||||
: data.devices.map((d) => {
|
||||
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
|
||||
// "Up" says the last try worked; a reading that old is no longer news.
|
||||
const old = d.up && d.lastSuccess != null && isStale(d.lastSuccess);
|
||||
return (
|
||||
<Row key={d.name} icon={<Dot tone={d.up ? "ok" : "bad"} />}
|
||||
<Row key={d.name} icon={<Dot tone={d.up ? (old ? "warn" : "ok") : "bad"} />}
|
||||
label={<span><span className="text-ink">{DEVICE_NAMES[d.name] ?? d.name}</span><br />
|
||||
<span className="text-xs">{d.up ? last : `nie odpowiada, ${last}`}</span></span>}
|
||||
value={<span className={d.up ? "text-brand" : "text-alarm"}>{d.up ? "działa" : "brak"}</span>} />
|
||||
value={d.up ? (old ? <span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
||||
<Icon name="alert" className="size-4" />brak świeżych danych</span> : <span className="text-brand">działa</span>)
|
||||
: <span className="text-alarm">brak</span>} />
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
--color-page: #f2f6f3;
|
||||
--color-panel: #ffffff;
|
||||
--color-ink: #13201a;
|
||||
--color-soft: #5b6b63;
|
||||
--color-soft: #4f5f57;
|
||||
--color-edge: #e2e9e5;
|
||||
--color-brand: #0f8a5f;
|
||||
--color-brand-soft: #e3f4ec;
|
||||
@@ -29,10 +29,10 @@
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-page: #0b1310;
|
||||
--color-panel: #142019;
|
||||
--color-panel: #1a2a21;
|
||||
--color-ink: #eef5f1;
|
||||
--color-soft: #a9bab1;
|
||||
--color-edge: #24352c;
|
||||
--color-edge: #35493d;
|
||||
--color-brand: #3fd197;
|
||||
--color-brand-soft: #143628;
|
||||
--color-run: #ff7a8a;
|
||||
|
||||
Reference in new issue
Block a user