feat(frontend): red accent for running parts and a Salon sensor card
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
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:
1 parent
8146296061
commit
25f76ea084
6 files changed
+94
-17
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)}</>} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 }) {
|
||||
|
||||
Reference in new issue
Block a user