feat(frontend): red accent for running parts and a Salon sensor card
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s

A running pump, pump chip and headline are red instead of green; stopped
stays grey and the alarm is now solid so the two cannot be confused. The
BME280 reading shows in a Salon card, with brak odczytu when stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 14:07:37 +02:00
1 parent 8146296061
commit 25f76ea084
6 files changed
+94 -17

No files matched your search

+45 -1
View File
@@ -3,12 +3,14 @@ import { describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { HeatPump } from "./types";
import { PI_SENSOR_TITLE } from "./Overview";
const overview = async () => (await import("./Overview")).Overview;
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
const history = { series: "x", unit: "°C", points: [] };
function serveHeatPump(hp: HeatPump) {
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history });
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor") });
}
const flowing = (container: HTMLElement) =>
@@ -17,6 +19,48 @@ const glowing = (container: HTMLElement) =>
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
describe("Overview", () => {
it("shows the sensor of the Raspberry Pi in the living room", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
expect(card).toHaveTextContent("Temperatura20,2 °C");
expect(card).toHaveTextContent("Wilgotność45,8 %");
expect(card).toHaveTextContent("Ciśnienie986 hPa");
});
it("says there is no reading when the sensor is stale", async () => {
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor-stale") });
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
expect(card).toHaveTextContent("Temperaturabrak odczytu");
expect(card).toHaveTextContent("Wilgotnośćbrak odczytu");
expect(card).toHaveTextContent("Ciśnieniebrak odczytu");
});
it("shows a running part in the red accent and a stopped one in grey", async () => {
const hp = fresh(example<HeatPump>("heat-pump"));
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true } });
const Overview = await overview();
render(<Overview />);
expect(await screen.findByTitle("Pompa CWU: pracuje")).toHaveClass("bg-run-soft");
expect(screen.getByTitle("Pompa CO: stoi")).not.toHaveClass("bg-run-soft");
expect(screen.getAllByText("Grzeje wodę (CWU)")[0].closest("span")).toHaveClass("text-run");
});
it("keeps the alarm solid, unlike the red of a running part", async () => {
serveHeatPump({ ...fresh(example("heat-pump")), alarm: true });
const Overview = await overview();
render(<Overview />);
expect((await screen.findAllByText("Alarm"))[0].closest("span")).toHaveClass("bg-alarm", "text-white");
});
it("draws a resting pump with nothing flowing", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
+28 -9
View File
@@ -1,10 +1,10 @@
import type { ReactNode } from "react";
import { celsius, degrees, hour, longDate, percent, power } from "./format";
import { celsius, degrees, hour, longDate, number, percent, power } from "./format";
import { Icon, type IconName } from "./icons";
import { usePoll } from "./poll";
import { Schematic } from "./Schematic";
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
import type { Energy, HeatPump, History, PumpState } from "./types";
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
import { EnergyToday, gridText } from "./Energy";
@@ -32,9 +32,9 @@ const TEMPERATURE_LINES: Line[] = [
const onOff = (on: boolean | null, yes: string, no: string) => (on == null ? "—" : on ? yes : no);
function StatusPill({ hp }: { hp: HeatPump }) {
if (hp.alarm) return <Pill tone="bad" live>Alarm</Pill>;
if (hp.alarm) return <Pill tone="bad" live><Icon name="alert" className="size-3.5" />Alarm</Pill>;
if (hp.state == null) return <Pill tone="off">Stan nieznany</Pill>;
return hp.state === "IDLE" ? <Pill tone="ok">Praca prawidłowa</Pill> : <Pill tone="hot" live>{HEADLINES[hp.state]}</Pill>;
return hp.state === "IDLE" ? <Pill tone="off">Praca prawidłowa</Pill> : <Pill tone="run" live>{HEADLINES[hp.state]}</Pill>;
}
function Tile({ icon, title, value, note, status, children }: {
@@ -59,7 +59,7 @@ function Tile({ icon, title, value, note, status, children }: {
}
const running = (on: boolean | null, yes = "Pracuje") =>
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="ok" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
function Temperatures({ hp }: { hp: HeatPump }) {
const t = hp.temperatures;
@@ -80,7 +80,7 @@ function Temperatures({ hp }: { hp: HeatPump }) {
);
}
// Every moving part as a chip under the picture: what runs now is green and pulses.
// Every moving part as a chip under the picture: what runs now is red and pulses.
function Devices({ hp }: { hp: HeatPump }) {
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
const items: [string, boolean | null, string][] = [
@@ -95,11 +95,11 @@ function Devices({ hp }: { hp: HeatPump }) {
<ul aria-label="Urządzenia" className="mt-3 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{items.map(([name, on, text]) => (
<li key={name} title={`${name}: ${text}`}
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-brand/30 bg-brand-soft" : "border-edge text-soft"}`}>
<Dot tone={on ? "ok" : "off"} live={!!on} />
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-run/30 bg-run-soft" : "border-edge text-soft"}`}>
<Dot tone={on ? "run" : "off"} live={!!on} />
<span>{name}</span>
{/* On a phone the colour says it; the word needs the room of a larger screen. */}
<b className={`hidden sm:inline ${on ? "text-brand" : "text-soft"}`}>{text}</b>
<b className={`hidden sm:inline ${on ? "text-run" : "text-soft"}`}>{text}</b>
</li>
))}
</ul>
@@ -119,6 +119,24 @@ function Targets({ hp }: { hp: HeatPump }) {
);
}
// The Raspberry Pi with the BME280 stands in the living room.
export const PI_SENSOR_TITLE = "Salon";
const NO_READING = "brak odczytu";
const reading = (value: number | null | undefined, digits: number, unit: string) =>
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
function PiSensorCard() {
const { data } = usePoll<PiSensor>("api/pi-sensor");
return (
<Card title={PI_SENSOR_TITLE}>
<Row label="Temperatura" value={reading(data?.temperature, 1, "°C")} />
<Row label="Wilgotność" value={reading(data?.humidity, 1, "%")} />
<Row label="Ciśnienie" value={reading(data?.pressure, 0, "hPa")} />
</Card>
);
}
function useTemperatureHistory() {
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
@@ -211,6 +229,7 @@ export function Overview() {
</Tile>
</div>
<TemperatureChart />
<PiSensorCard />
<EnergyToday e={e} />
</div>
)}
+3 -3
View File
@@ -111,10 +111,10 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
{/* Photovoltaics: a light sweeps over the panels while they produce */}
{sunny && <g clipPath="url(#pv)" data-pv="on"><rect className="sweep" x={985} y={180} width={400} height={160} fill="url(#sweep)" /></g>}
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="#10b981" on={compressor} />
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="var(--color-run)" on={compressor} />
<Glow id="cwu" x={639} y={402} w={90} h={216} colour="#f97316" on={cwu} />
<Glow id="buffer" x={746} y={489} w={70} h={134} colour="#ef4444" on={buffer} />
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="#22c55e" />}
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="var(--color-run)" />}
{hp.heaterOn && <rect className="glow" x={518} y={560} width={78} height={14} rx={7} fill="#ef4444" />}
{/* Warmth rising from the floor while the CO pump runs */}
@@ -124,7 +124,7 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
))}
<Callout x={36} y={40} title="Na zewnątrz" value={celsius(t.outdoor)} />
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-brand)" : undefined}
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-run)" : undefined}
value={compressor ? `sprężarka ${percent(hp.compressorPercent)}` : "stoi"} />
<Callout x={540} y={215} title="Ciepła woda (CWU)" to={[684, 410]} tone={cwu ? "#ea580c" : undefined}
value={<>{celsius(t.cwu)}{target(targets.cwu)}</>} />
+5
View File
@@ -13,6 +13,9 @@
--color-edge: #e2e9e5;
--color-brand: #0f8a5f;
--color-brand-soft: #e3f4ec;
/* A part that runs; red but never the alarm, which is solid (ui.tsx). */
--color-run: #c8283f;
--color-run-soft: #fde9ec;
--color-side: #0e2a21;
--color-side-hi: #1b4d3b;
--color-hot: #f26b1d;
@@ -32,6 +35,8 @@
--color-edge: #24352c;
--color-brand: #3fd197;
--color-brand-soft: #143628;
--color-run: #ff7a8a;
--color-run-soft: #3d141b;
--color-side: #08170f;
--color-side-hi: #17402f;
--color-off: #4b5a52;
+8
View File
@@ -20,6 +20,14 @@ export interface HeatPump {
setpoints: Record<"coEconomy" | "coComfort" | "cwuEconomy" | "cwuComfort", number | null>;
}
export interface PiSensor {
time: string | null;
stale: boolean;
temperature: number | null;
humidity: number | null;
pressure: number | null;
}
export interface EnergySource {
name: string;
time: string | null;
+5 -4
View File
@@ -19,18 +19,19 @@ export function Card({ title, aside, className = "", children }: {
);
}
type Tone = "ok" | "warn" | "bad" | "off" | "hot";
type Tone = "ok" | "run" | "warn" | "bad" | "off";
const PILL: Record<Tone, string> = {
ok: "bg-brand-soft text-brand",
warn: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
bad: "bg-red-100 text-alarm dark:bg-red-950 dark:text-red-300",
run: "bg-run-soft text-run",
// Solid, unlike the soft "run": an alarm must never be mistaken for a part that works.
bad: "bg-alarm text-white",
off: "bg-edge text-soft",
hot: "bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300",
};
const DOT: Record<Tone, string> = {
ok: "bg-brand", warn: "bg-amber-500", bad: "bg-alarm", off: "bg-off", hot: "bg-hot",
ok: "bg-brand", run: "bg-run", warn: "bg-amber-500", bad: "bg-alarm", off: "bg-off",
};
export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) {