Merge PR #87: feat(frontend): tidy the main screen, each value once
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s

This commit is contained in:
root committed 2026-10-24 17:42:32 +02:00
commit e88c28899f
9 files changed
+251 -161

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ In the Bateria view, `editable` from `api/battery` (the gateway takes battery ch
schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI 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). checks the gateway's rules first (levels from the reserve to 100 %, starts increasing).
Podgląd shows the "Pogoda" card (3 days, PV estimate) and one banner per IMGW warning from `api/weather`; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself). Podgląd opens with the alarm and one collapsible line per distinct IMGW warning, then four key figures (CWU, home, outdoor, power), the schematic with the device rows, the "Pogoda" card (3 days, PV estimate, from `api/weather`) and "Szczegóły temperatur"; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of 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. compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
+8 -3
View File
@@ -7,6 +7,7 @@ import { usePoll } from "./poll";
import { useTheme, type Theme } from "./theme"; import { useTheme, type Theme } from "./theme";
import type { HeatPump } from "./types"; import type { HeatPump } from "./types";
import { Dot, Loading } from "./ui"; import { Dot, Loading } from "./ui";
import { useWeather, WeatherIcon } from "./Weather";
// Energy shares code with the first screen; the others load when opened. // Energy shares code with the first screen; the others load when opened.
const History = lazy(() => import("./History").then((m) => ({ default: m.History }))); const History = lazy(() => import("./History").then((m) => ({ default: m.History })));
@@ -56,10 +57,11 @@ function ThemeSwitch({ className }: { className: string }) {
function SideFooter() { function SideFooter() {
const { data, offline } = usePoll<HeatPump>("api/heat-pump"); const { data, offline } = usePoll<HeatPump>("api/heat-pump");
const now = useClock(); const now = useClock();
const weather = useWeather();
return ( return (
<div className="mt-auto space-y-4 px-2"> <div className="mt-auto space-y-4 px-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Icon name="sun" className="size-9 text-amber-400" /> {weather && <WeatherIcon code={weather.today.weatherCode} className="size-9" />}
<div> <div>
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p> <p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
<p className="text-sm text-white/60">Szczepańcowa</p> <p className="text-sm text-white/60">Szczepańcowa</p>
@@ -81,14 +83,17 @@ function SideFooter() {
export function App() { export function App() {
const [screen, setScreen] = useState<ScreenId>(fromHash); const [screen, setScreen] = useState<ScreenId>(fromHash);
useEffect(() => { useEffect(() => {
const onHash = () => setScreen(fromHash()); // A link to another screen, such as "Energia ›", arrives here too.
const onHash = () => {
setScreen(fromHash());
scrollTo(0, 0);
};
addEventListener("hashchange", onHash); addEventListener("hashchange", onHash);
return () => removeEventListener("hashchange", onHash); return () => removeEventListener("hashchange", onHash);
}, []); }, []);
const show = (id: ScreenId) => { const show = (id: ScreenId) => {
location.hash = id; location.hash = id;
setScreen(id); setScreen(id);
scrollTo(0, 0);
}; };
const View = SCREENS[screen].view; const View = SCREENS[screen].view;
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { celsius, degrees, number } from "./format";
import type { HeatPump, PiSensor } from "./types";
import { Card, Row } from "./ui";
// The Raspberry Pi with the BME280 stands in the living room.
const NO_READING = "brak odczytu";
const reading = (value: number | null | undefined, digits: number, unit: string) =>
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
function Group({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="mt-3 first:mt-0 [&>div]:py-1.5">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-soft">{title}</h3>
{children}
</div>
);
}
const difference = (a: number | null, b: number | null) => (a != null && b != null ? celsius(a - b) : "—");
/** Every temperature and setpoint the overview does not already show in its four key figures. */
export function TemperatureDetails({ hp, pi }: { hp: HeatPump; pi: PiSensor | undefined }) {
const { temperatures: t, targets, setpoints: s } = hp;
return (
<Card title="Szczegóły temperatur">
<Group title="Dom">
<Row label="Salon" value={reading(pi?.temperature, 1, "°C")} />
<Row label="Wilgotność w salonie" value={reading(pi?.humidity, 1, "%")} />
<Row label="Ciśnienie" value={reading(pi?.pressure, 0, "hPa")} />
<Row label="Pokój według pompy" value={celsius(t.indoor)} />
<Row label="Pokój, cel teraz" value={celsius(targets.room)} />
<Row label="Na zewnątrz, średnia" value={celsius(t.outdoorAvg)} />
</Group>
<Group title="Woda i ogrzewanie">
<Row label="Bufor CO" value={celsius(t.coBuffer)} />
<Row label="Bufor CO, cel" value={celsius(targets.co)} />
<Row label="Wyjście pompy" value={celsius(t.pumpOutlet)} />
</Group>
<Group title="Dolne źródło">
<Row label="Do pompy" value={celsius(t.groundIn)} />
<Row label="Z pompy" value={celsius(t.groundOut)} />
<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)}`} />
</Group>
</Card>
);
}
+8 -2
View File
@@ -121,7 +121,7 @@ function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void })
} }
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give. // Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
export function EnergyToday({ e }: { e: EnergyData | undefined }) { export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
const today = e?.today; const today = e?.today;
const load = today?.loadKwh ?? null; const load = today?.loadKwh ?? null;
const bought = today?.gridBoughtKwh ?? null; const bought = today?.gridBoughtKwh ?? null;
@@ -131,8 +131,14 @@ export function EnergyToday({ e }: { e: EnergyData | undefined }) {
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" }, { name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
]; ];
const partial = e ? silentInverters(e).length > 0 : false; const partial = e ? silentInverters(e).length > 0 : false;
const aside = (
<span className="flex items-center gap-3">
{partial && <Pill tone="warn">Tylko część danych</Pill>}
{link && <a href="#energy" className="font-semibold text-brand">Energia ›</a>}
</span>
);
return ( return (
<Card title="Energia – dziś" aside={partial ? <Pill tone="warn">Tylko część danych</Pill> : undefined}> <Card title="Energia – dziś" aside={aside}>
{!e ? <Loading /> : ( {!e ? <Loading /> : (
<div className="flex flex-wrap items-center gap-5"> <div className="flex flex-wrap items-center gap-5">
<div className="relative size-36 shrink-0"> <div className="relative size-36 shrink-0">
+49 -12
View File
@@ -1,9 +1,8 @@
import { render, screen } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { example, serve } from "./fixtures"; import { example, serve } from "./fixtures";
import type { HeatPump } from "./types"; import type { HeatPump } from "./types";
import { PI_SENSOR_TITLE } from "./Overview";
const overview = async () => (await import("./Overview")).Overview; const overview = async () => (await import("./Overview")).Overview;
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() }); const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
const history = { series: "x", unit: "°C", points: [] }; const history = { series: "x", unit: "°C", points: [] };
@@ -19,29 +18,67 @@ const glowing = (container: HTMLElement) =>
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow")); [...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
describe("Overview", () => { describe("Overview", () => {
it("shows the sensor of the Raspberry Pi in the living room", async () => { it("shows the four key figures once, with the living room as the home temperature", async () => {
serveHeatPump(fresh(example("heat-pump"))); serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor"), "api/weather": example("weather") });
const Overview = await overview(); const Overview = await overview();
render(<Overview />); render(<Overview />);
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!; const now = (await screen.findByRole("region", { name: "Teraz" }));
expect(card).toHaveTextContent("Temperatura20,2 °C"); const [water, home, outdoor, power] = [...now.children];
expect(card).toHaveTextContent("Wilgotność45,8 %"); expect(water).toHaveTextContent("Ciepła woda13,6 °Ccel 8,0 °C");
expect(card).toHaveTextContent("Ciśnienie986 hPa"); expect(home).toHaveTextContent("W domu20,2 °Cwilgotność 46 %");
expect(outdoor).toHaveTextContent("Na zewnątrz4,3 °CPochmurno");
expect(power).toHaveTextContent("Prąd");
expect(power).toHaveTextContent(/pobór|oddawanie|bez wymiany/);
}); });
it("says there is no reading when the sensor is stale", async () => { it("falls back to the pump's room sensor when the living room has no reading", async () => {
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history, serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor-stale") }); "api/pi-sensor": example("pi-sensor-stale") });
const Overview = await overview(); const Overview = await overview();
render(<Overview />); render(<Overview />);
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!; const home = (await screen.findByRole("region", { name: "Teraz" })).children[1];
expect(card).toHaveTextContent("Temperaturabrak odczytu"); expect(home).toHaveTextContent("W domu21,2 °Cczujnik pompy");
expect(card).toHaveTextContent("Wilgotnośćbrak odczytu"); });
it("lists the living room and the other temperatures once in the details card", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: "Szczegóły temperatur" })).closest("section")!;
expect(card).toHaveTextContent("Salon20,2 °C");
expect(card).toHaveTextContent("Wilgotność w salonie45,8 %");
expect(card).toHaveTextContent("Ciśnienie986 hPa");
expect(card).toHaveTextContent("Różnica1,7 °C");
expect(within(card).getAllByRole("heading", { level: 3 }).map((h) => h.textContent))
.toEqual(["Dom", "Woda i ogrzewanie", "Dolne źródło", "Nastawy"]);
});
it("says there is no reading in the details 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: "Szczegóły temperatur" })).closest("section")!;
expect(card).toHaveTextContent("Salonbrak odczytu");
expect(card).toHaveTextContent("Wilgotność w saloniebrak odczytu");
expect(card).toHaveTextContent("Ciśnieniebrak odczytu"); expect(card).toHaveTextContent("Ciśnieniebrak odczytu");
}); });
it("offers the four devices and a link to the Energia screen", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
render(<Overview />);
const devices = await screen.findByRole("region", { name: "Stan urządzeń" });
expect(devices.children).toHaveLength(4);
expect(screen.getByRole("link", { name: "Energia ›" })).toHaveAttribute("href", "#energy");
});
it("shows a running part in the red accent and a stopped one in grey", async () => { it("shows a running part in the red accent and a stopped one in grey", async () => {
const hp = fresh(example<HeatPump>("heat-pump")); const hp = fresh(example<HeatPump>("heat-pump"));
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true } }); serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true } });
+81 -107
View File
@@ -1,13 +1,15 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { celsius, degrees, hour, longDate, number, percent, power } from "./format"; import { celsius, hour, longDate, number, percent, power } from "./format";
import { Icon, type IconName } from "./icons"; import { Icon, type IconName } from "./icons";
import { flowing } from "./flow";
import { usePoll } from "./poll"; import { usePoll } from "./poll";
import { Schematic } from "./Schematic"; import { Schematic } from "./Schematic";
import { Legend, TimeChart, toRows, type Line } from "./TimeChart"; import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types"; import type { Energy, HeatPump, History, PiSensor, PumpState, Weather } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui"; import { Card, Dot, Freshness, Loading, PageTitle, Pill } from "./ui";
import { EnergyToday, gridText } from "./Energy"; import { EnergyToday, gridText } from "./Energy";
import { useWeather, WeatherCard, WeatherWarnings } from "./Weather"; import { TemperatureDetails } from "./Details";
import { useWeather, WeatherCard, WeatherIcon, WeatherWarnings } from "./Weather";
const HEADLINES: Record<PumpState, string> = { const HEADLINES: Record<PumpState, string> = {
IDLE: "Pompa odpoczywa", IDLE: "Pompa odpoczywa",
@@ -38,49 +40,76 @@ function StatusPill({ hp }: { hp: HeatPump }) {
return hp.state === "IDLE" ? <Pill tone="off">Praca prawidłowa</Pill> : <Pill tone="run" 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 }: { const running = (on: boolean | null, yes = "Pracuje") =>
icon: IconName; title: string; value: ReactNode; note: string; status: ReactNode; children: ReactNode; on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
}) {
// One key figure; the number never wraps, so it is sized to fit two across a phone.
function Figure({ icon, label, value, note }: { icon: IconName; label: string; value: string; note: ReactNode }) {
return ( return (
<Card className="flex flex-col"> <Card className="min-w-0 p-3! sm:p-5!">
<div className="flex items-start gap-3"> <p className="flex items-center gap-1.5 text-sm font-semibold text-soft"><Icon name={icon} className="size-4 shrink-0" />{label}</p>
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand"> <p className="mt-1 whitespace-nowrap text-[1.7rem] font-bold leading-tight tabular-nums tracking-tight sm:text-3xl">{value}</p>
<Icon name={icon} className="size-6" /> <p className="mt-0.5 flex min-h-5 items-center gap-1.5 text-xs text-soft sm:text-sm">{note}</p>
</span>
<div className="min-w-0">
<p className="text-sm font-semibold">{title}</p>
<p className="text-2xl font-bold tabular-nums tracking-tight">{value}</p>
<p className="text-xs text-soft">{note}</p>
</div>
</div>
<div className="my-3">{status}</div>
<div className="mt-auto text-sm [&>div]:text-sm">{children}</div>
</Card> </Card>
); );
} }
const running = (on: boolean | null, yes = "Pracuje") => const gridNote = (watts: number | null | undefined) =>
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>; watts == null ? "brak danych o sieci"
: flowing(watts) ? `${gridText(watts)} ${power(Math.abs(watts))}` : gridText(watts);
function Temperatures({ hp }: { hp: HeatPump }) { // The four figures the family checks first; each value is shown here and nowhere else on the screen.
const t = hp.temperatures; function Now({ hp, e, pi, weather }: { hp: HeatPump; e: Energy | undefined; pi: PiSensor | undefined; weather: Weather | undefined }) {
const rows: [IconName, string, number | null, string][] = [ const home = pi?.temperature ?? hp.temperatures.indoor;
["thermometer", "Na zewnątrz", t.outdoor, "text-cold"], const humidity = pi?.temperature == null ? null : pi.humidity;
["thermometer", "Średnia zewnętrzna", t.outdoorAvg, "text-cold"],
["home", "W domu", t.indoor, "text-brand"],
["heat", "Bufor CO", t.coBuffer, "text-hot"],
["drop", "Ciepła woda (CWU)", t.cwu, "text-hot"],
["fan", "Wyjście pompy", t.pumpOutlet, "text-hot"],
];
return ( return (
<Card title="Temperatury"> <section aria-label="Teraz" className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 lg:grid-cols-4">
{rows.map(([icon, label, value, colour]) => ( <Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={`cel ${celsius(hp.targets.cwu)}`} />
<Row key={label} label={label} value={celsius(value)} icon={<span className={colour}><Icon name={icon} /></span>} /> <Figure icon="home" label="W domu" value={celsius(home)}
))} note={humidity == null ? "czujnik pompy" : `wilgotność ${number(humidity)} %`} />
<Figure icon="thermometer" label="Na zewnątrz" value={celsius(hp.temperatures.outdoor)}
note={weather && <><WeatherIcon code={weather.today.weatherCode} className="size-5" />{weather.today.description}</>} />
<Figure icon="sun" label="Prąd" value={power(e?.pvPowerW)} note={gridNote(e?.gridPowerW)} />
</section>
);
}
// A device on one row: what it is, what it does now, and the one thing only it knows.
function Tile({ icon, title, detail, value, status }: {
icon: IconName; title: string; detail: string; value?: string; status: ReactNode;
}) {
return (
<Card className="flex items-center gap-3 p-3! sm:p-5!">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
<Icon name={icon} className="size-6" />
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold">{title}</p>
<p className="text-xs text-soft sm:text-sm">{detail}</p>
</div>
<div className="flex flex-col items-end gap-1">
{value && <p className="text-xl font-bold tabular-nums">{value}</p>}
{status}
</div>
</Card> </Card>
); );
} }
function DeviceTiles({ hp }: { hp: HeatPump }) {
return (
<section aria-label="Stan urządzeń" className="grid gap-3 sm:grid-cols-2 sm:gap-4">
<Tile icon="fan" title="Pompa ciepła" detail={`sprężarka, grzałka ${onOff(hp.heaterOn, "włączona", "wyłączona")}`}
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)} />
<Tile icon="tank" title="Zbiornik CWU" detail={`cyrkulacja ${onOff(hp.pumps.circulation, "pracuje", "stoi")}`}
status={running(hp.pumps.cwu, "Grzeje wodę")} />
<Tile icon="heat" title="Ogrzewanie CO" detail={`pompa bufora ${onOff(hp.pumps.buffer, "pracuje", "stoi")}`}
status={running(hp.pumps.co, "Grzeje dom")} />
<Tile icon="borehole" title="Odwierty" detail="dolne źródło ciepła" status={running(hp.pumps.ground)} />
</section>
);
}
// Every moving part as a chip under the picture: what runs now is red and pulses. // Every moving part as a chip under the picture: what runs now is red and pulses.
function Devices({ hp }: { hp: HeatPump }) { function Devices({ hp }: { hp: HeatPump }) {
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0; const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
@@ -107,37 +136,6 @@ function Devices({ hp }: { hp: HeatPump }) {
); );
} }
function Targets({ hp }: { hp: HeatPump }) {
const s = hp.setpoints;
return (
<Card title="Cele i nastawy">
<Row label="Pokój, cel teraz" value={celsius(hp.targets.room)} />
<Row label="Woda grzewcza CO, cel" value={celsius(hp.targets.co)} />
<Row label="Ciepła woda CWU, cel" value={celsius(hp.targets.cwu)} />
<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)}`} />
</Card>
);
}
// 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() { function useTemperatureHistory() {
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS); 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); const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
@@ -172,6 +170,7 @@ function NoReading() {
export function Overview() { export function Overview() {
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump"); const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
const { data: e } = usePoll<Energy>("api/energy"); const { data: e } = usePoll<Energy>("api/energy");
const { data: pi } = usePoll<PiSensor>("api/pi-sensor");
const weather = useWeather(); const weather = useWeather();
const now = hp?.time ? new Date(hp.time) : null; const now = hp?.time ? new Date(hp.time) : null;
const title = ( const title = (
@@ -193,48 +192,23 @@ export function Overview() {
{weather && <WeatherWarnings warnings={weather.warnings} />} {weather && <WeatherWarnings warnings={weather.warnings} />}
{hp.time == null ? <NoReading /> : ( {hp.time == null ? <NoReading /> : (
<div className="grid gap-4 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-3">
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />} className="lg:col-span-2 lg:row-span-2"> <Now hp={hp} e={e} pi={pi} weather={weather} />
<Schematic hp={hp} pvPowerW={e?.pvPowerW} /> <div className="grid content-start gap-4 lg:col-span-2">
<Devices hp={hp} /> <Card title="Schemat instalacji" aside={<StatusPill hp={hp} />}>
<p className="mt-3 text-xs text-soft"> <Schematic hp={hp} pvPowerW={e?.pvPowerW} />
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje. <Devices hp={hp} />
</p> <p className="mt-3 text-xs text-soft">
</Card> Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
<Temperatures hp={hp} /> </p>
<Targets hp={hp} /> </Card>
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 xl:grid-cols-5"> <DeviceTiles hp={hp} />
<Tile icon="fan" title="Pompa ciepła" note="sprężarka"
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)}>
<Row label="Wyjście" value={celsius(hp.temperatures.pumpOutlet)} />
<Row label="Grzałka" value={onOff(hp.heaterOn, "włączona", "wyłączona")} />
</Tile>
<Tile icon="drop" title="Zbiornik CWU" note="ciepła woda" value={celsius(hp.temperatures.cwu)}
status={running(hp.pumps.cwu, "Grzeje wodę")}>
<Row label="Cel teraz" value={celsius(hp.targets.cwu)} />
<Row label="Cyrkulacja" value={onOff(hp.pumps.circulation, "pracuje", "stoi")} />
</Tile>
<Tile icon="heat" title="Ogrzewanie CO" note="bufor" value={celsius(hp.temperatures.coBuffer)}
status={running(hp.pumps.co, "Grzeje dom")}>
<Row label="Cel bufora" value={celsius(hp.targets.co)} />
<Row label="Pokój" value={celsius(hp.temperatures.indoor)} />
</Tile>
<Tile icon="borehole" title="Odwierty" note="do pompy" value={celsius(hp.temperatures.groundIn)}
status={running(hp.pumps.ground)}>
<Row label="Z pompy" value={celsius(hp.temperatures.groundOut)} />
<Row label="Różnica" value={hp.temperatures.groundIn != null && hp.temperatures.groundOut != null
? celsius(hp.temperatures.groundIn - hp.temperatures.groundOut) : "—"} />
</Tile>
<Tile icon="sun" title="Prąd teraz" note="produkcja PV" value={power(e?.pvPowerW)}
status={e ? <Pill tone={(e.gridPowerW ?? 0) < 0 ? "ok" : "off"}>Sieć: {gridText(e.gridPowerW)}</Pill> : <Pill tone="off">Brak danych</Pill>}>
<Row label="Zużycie domu" value={power(e?.loadPowerW)} />
<Row label="Bateria" value={percent(e?.batterySocPct)} />
</Tile>
</div> </div>
{weather && <WeatherCard weather={weather} />} <div className="grid content-start gap-4">
{weather && <WeatherCard weather={weather} />}
<TemperatureDetails hp={hp} pi={pi} />
</div>
<EnergyToday e={e} link />
<TemperatureChart /> <TemperatureChart />
<PiSensorCard />
<EnergyToday e={e} />
</div> </div>
)} )}
</> </>
+21 -9
View File
@@ -1,4 +1,5 @@
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { example, serve } from "./fixtures"; import { example, serve } from "./fixtures";
import type { HeatPump, Weather } from "./types"; import type { HeatPump, Weather } from "./types";
@@ -25,7 +26,6 @@ describe("Pogoda card", () => {
const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!; const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!;
const [today, tomorrow, dayAfter] = within(card).getAllByRole("listitem"); const [today, tomorrow, dayAfter] = within(card).getAllByRole("listitem");
expect(card).toHaveTextContent("teraz 10,6 °C");
expect(today).toHaveTextContent("Dziś"); expect(today).toHaveTextContent("Dziś");
expect(today).toHaveTextContent("Pochmurno"); expect(today).toHaveTextContent("Pochmurno");
expect(today).toHaveTextContent("10,9 °C / 3,8 °C"); expect(today).toHaveTextContent("10,9 °C / 3,8 °C");
@@ -64,27 +64,39 @@ describe("Pogoda card", () => {
const { Overview } = await import("./Overview"); const { Overview } = await import("./Overview");
render(<Overview />); render(<Overview />);
await screen.findByRole("heading", { name: "Salon" }); await screen.findByRole("heading", { name: "Szczegóły temperatur" });
expect(screen.queryByRole("heading", { name: "Pogoda" })).not.toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Pogoda" })).not.toBeInTheDocument();
}); });
}); });
describe("IMGW warnings on the overview", () => { describe("IMGW warnings on the overview", () => {
it("shows each warning as a banner with its degree, period and text", async () => { it("shows each warning as one line with its degree and period, and the text on a tap", async () => {
await overview(); await overview();
const banner = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/); const line = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
expect(banner.closest("[role=alert]")).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru"); const banner = line.closest("details")!;
expect(banner.closest("[role=alert]")).toHaveClass("border-orange-500"); expect(banner).toHaveClass("border-orange-500");
expect(banner).not.toHaveAttribute("open");
await userEvent.click(line);
expect(banner).toHaveAttribute("open");
expect(banner).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru");
}); });
it("colours the degrees yellow, orange and red", async () => { it("colours the degrees yellow, orange and red", async () => {
await overview({ warnings: [warning("Mróz", 1), warning("Upał", 2), warning("Burza", 3)] }); await overview({ warnings: [warning("Mróz", 1), warning("Upał", 2), warning("Burza", 3)] });
await screen.findByText(/stopień 3: Burza/); await screen.findByText(/stopień 3: Burza/);
expect(screen.getByText(/stopień 1: Mróz/).closest("[role=alert]")).toHaveClass("border-yellow-500"); expect(screen.getByText(/stopień 1: Mróz/).closest("details")).toHaveClass("border-yellow-500");
expect(screen.getByText(/stopień 2: Upał/).closest("[role=alert]")).toHaveClass("border-orange-500"); expect(screen.getByText(/stopień 2: Upał/).closest("details")).toHaveClass("border-orange-500");
expect(screen.getByText(/stopień 3: Burza/).closest("[role=alert]")).toHaveClass("border-alarm"); expect(screen.getByText(/stopień 3: Burza/).closest("details")).toHaveClass("border-alarm");
});
it("shows one banner for identical warnings and keeps ones that differ only in their end", async () => {
const w = warning("Silny wiatr", 1);
await overview({ warnings: [w, { ...w }, { ...w, to: "2026-10-26T18:00:00+01:00" }] });
await screen.findByRole("heading", { name: "Pogoda" });
expect(screen.getAllByText(/stopień 1: Silny wiatr/)).toHaveLength(2);
}); });
it("shows no banner without warnings", async () => { it("shows no banner without warnings", async () => {
+31 -26
View File
@@ -35,12 +35,12 @@ const PICTURES: Record<Kind, { label: string; paths: string[]; colour: string }>
storm: { label: "burza", paths: [CLOUD, "M12.5 14l-2.5 4h3l-1.5 4"], colour: "text-alarm" }, storm: { label: "burza", paths: [CLOUD, "M12.5 14l-2.5 4h3l-1.5 4"], colour: "text-alarm" },
}; };
export function WeatherIcon({ code }: { code: number | null }) { export function WeatherIcon({ code, className = "size-10" }: { code: number | null; className?: string }) {
const kind = kindOf(code); const kind = kindOf(code);
if (!kind) return <span className="grid size-10 place-items-center text-soft">—</span>; if (!kind) return <span className={`grid shrink-0 place-items-center text-soft ${className}`}>—</span>;
const { label, paths, colour } = PICTURES[kind]; const { label, paths, colour } = PICTURES[kind];
return ( return (
<svg viewBox="0 0 24 24" role="img" aria-label={label} className={`size-10 ${colour}`} fill="none" <svg viewBox="0 0 24 24" role="img" aria-label={label} className={`shrink-0 ${className} ${colour}`} fill="none"
stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round"> stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
{paths.map((d) => <path key={d} d={d} />)} {paths.map((d) => <path key={d} d={d} />)}
</svg> </svg>
@@ -52,15 +52,16 @@ const DAY_NAMES = ["Dziś", "Jutro", "Pojutrze"];
function Day({ name, day }: { name: string; day: WeatherDay }) { function Day({ name, day }: { name: string; day: WeatherDay }) {
const rain = day.precipitationMm; const rain = day.precipitationMm;
return ( return (
<li className="flex flex-col items-center gap-1 text-center"> <li className="flex items-center gap-3 border-t border-edge py-2.5 first:border-t-0 first:pt-0 last:pb-0">
<b>{name}</b> <WeatherIcon code={day.weatherCode} className="size-9" />
<WeatherIcon code={day.weatherCode} /> <div className="min-w-0 flex-1">
<span className="text-sm text-soft">{day.description}</span> <p><b>{name}</b> <span className="text-soft">{day.description}</span></p>
<span className="tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</span> <p className="flex flex-wrap gap-x-3 text-xs text-soft">
<span className="text-sm text-soft">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span> <span className="whitespace-nowrap">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
{day.pvForecastKwh != null && ( {day.pvForecastKwh != null && <b className="whitespace-nowrap font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</b>}
<span className="text-sm font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</span> </p>
)} </div>
<p className="whitespace-nowrap tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</p>
</li> </li>
); );
} }
@@ -68,9 +69,8 @@ function Day({ name, day }: { name: string; day: WeatherDay }) {
export function WeatherCard({ weather }: { weather: Weather }) { export function WeatherCard({ weather }: { weather: Weather }) {
const days = [weather.today, weather.tomorrow, weather.dayAfter]; const days = [weather.today, weather.tomorrow, weather.dayAfter];
return ( return (
<Card title="Pogoda" aside={<span className="text-sm text-soft">teraz {celsius(weather.now.temperature)}</span>} <Card title="Pogoda">
className="lg:col-span-2"> <ul>
<ul className="grid grid-cols-3 gap-2">
{days.map((day, i) => <Day key={day.date} name={DAY_NAMES[i]} day={day} />)} {days.map((day, i) => <Day key={day.date} name={DAY_NAMES[i]} day={day} />)}
</ul> </ul>
{weather.stale && <p className="mt-3 text-xs text-soft">Nie udało się odświeżyć pogody, pokazuję starsze dane.</p>} {weather.stale && <p className="mt-3 text-xs text-soft">Nie udało się odświeżyć pogody, pokazuję starsze dane.</p>}
@@ -84,20 +84,25 @@ const LEVELS: Record<number, string> = {
3: "border-alarm bg-red-50 text-alarm dark:bg-red-950/60 dark:text-red-200", 3: "border-alarm bg-red-50 text-alarm dark:bg-red-950/60 dark:text-red-200",
}; };
/** The IMGW warnings, one banner each, coloured by degree. The text is IMGW's own, shown as text. */ /** The IMGW warnings, one line each (degree colour, event, period); the text is IMGW's own and opens on a tap. */
export function WeatherWarnings({ warnings }: { warnings: WeatherWarning[] }) { export function WeatherWarnings({ warnings }: { warnings: WeatherWarning[] }) {
// IMGW sends one warning per area, so the same one can arrive twice.
const key = (w: WeatherWarning) => `${w.event}|${w.level}|${w.from}|${w.to}`;
const distinct = warnings.filter((w, i) => warnings.findIndex((o) => key(o) === key(w)) === i);
return ( return (
<> <>
{warnings.map((w) => ( {distinct.map((w) => (
<div key={`${w.event}${w.level}${w.from}`} role="alert" <details key={key(w)} role="alert"
className={`mb-4 flex items-start gap-3 rounded-xl border-2 px-4 py-3 ${LEVELS[w.level] ?? LEVELS[1]}`}> className={`mb-3 rounded-xl border-2 px-4 py-2 ${LEVELS[w.level] ?? LEVELS[1]}`}>
<Icon name="alert" className="size-6 shrink-0" /> <summary className="flex cursor-pointer items-center gap-3">
<div> <Icon name="alert" className="size-5 shrink-0" />
<p className="font-semibold">Ostrzeżenie IMGW, stopień {w.level}: {w.event}</p> <span className="min-w-0 flex-1">
<p className="text-sm">{dateTime(w.from)} – {dateTime(w.to)}</p> <b>Ostrzeżenie IMGW, stopień {w.level}: {w.event}</b>
<p className="mt-1 text-sm">{w.text}</p> <span className="block text-sm">{dateTime(w.from)} – {dateTime(w.to)}</span>
</div> </span>
</div> </summary>
<p className="mt-2 text-sm">{w.text}</p>
</details>
))} ))}
</> </>
); );
+1 -1
View File
@@ -36,7 +36,7 @@ const DOT: Record<Tone, string> = {
export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) { export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) {
return ( return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}> <span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}>
<Dot tone={tone} live={live} /> <Dot tone={tone} live={live} />
{children} {children}
</span> </span>