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
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
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 type { HeatPump } from "./types";
import { Dot, Loading } from "./ui";
import { useWeather, WeatherIcon } from "./Weather";
// Energy shares code with the first screen; the others load when opened.
const History = lazy(() => import("./History").then((m) => ({ default: m.History })));
@@ -56,10 +57,11 @@ function ThemeSwitch({ className }: { className: string }) {
function SideFooter() {
const { data, offline } = usePoll<HeatPump>("api/heat-pump");
const now = useClock();
const weather = useWeather();
return (
<div className="mt-auto space-y-4 px-2">
<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>
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
<p className="text-sm text-white/60">Szczepańcowa</p>
@@ -81,14 +83,17 @@ function SideFooter() {
export function App() {
const [screen, setScreen] = useState<ScreenId>(fromHash);
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);
return () => removeEventListener("hashchange", onHash);
}, []);
const show = (id: ScreenId) => {
location.hash = id;
setScreen(id);
scrollTo(0, 0);
};
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.
export function EnergyToday({ e }: { e: EnergyData | undefined }) {
export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
const today = e?.today;
const load = today?.loadKwh ?? 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" },
];
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 (
<Card title="Energia – dziś" aside={partial ? <Pill tone="warn">Tylko część danych</Pill> : undefined}>
<Card title="Energia – dziś" aside={aside}>
{!e ? <Loading /> : (
<div className="flex flex-wrap items-center gap-5">
<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 { 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: [] };
@@ -19,29 +18,67 @@ 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")));
it("shows the four key figures once, with the living room as the home temperature", async () => {
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();
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");
const now = (await screen.findByRole("region", { name: "Teraz" }));
const [water, home, outdoor, power] = [...now.children];
expect(water).toHaveTextContent("Ciepła woda13,6 °Ccel 8,0 °C");
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,
"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");
const home = (await screen.findByRole("region", { name: "Teraz" })).children[1];
expect(home).toHaveTextContent("W domu21,2 °Cczujnik pompy");
});
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");
});
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 () => {
const hp = fresh(example<HeatPump>("heat-pump"));
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 { 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 { flowing } from "./flow";
import { usePoll } from "./poll";
import { Schematic } from "./Schematic";
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
import type { Energy, HeatPump, History, PiSensor, PumpState, Weather } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill } from "./ui";
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> = {
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>;
}
function Tile({ icon, title, value, note, status, children }: {
icon: IconName; title: string; value: ReactNode; note: string; status: ReactNode; children: ReactNode;
}) {
const running = (on: boolean | null, yes = "Pracuje") =>
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 (
<Card className="flex flex-col">
<div className="flex items-start gap-3">
<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">
<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 className="min-w-0 p-3! sm:p-5!">
<p className="flex items-center gap-1.5 text-sm font-semibold text-soft"><Icon name={icon} className="size-4 shrink-0" />{label}</p>
<p className="mt-1 whitespace-nowrap text-[1.7rem] font-bold leading-tight tabular-nums tracking-tight sm:text-3xl">{value}</p>
<p className="mt-0.5 flex min-h-5 items-center gap-1.5 text-xs text-soft sm:text-sm">{note}</p>
</Card>
);
}
const running = (on: boolean | null, yes = "Pracuje") =>
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
const gridNote = (watts: number | null | undefined) =>
watts == null ? "brak danych o sieci"
: flowing(watts) ? `${gridText(watts)} ${power(Math.abs(watts))}` : gridText(watts);
function Temperatures({ hp }: { hp: HeatPump }) {
const t = hp.temperatures;
const rows: [IconName, string, number | null, string][] = [
["thermometer", "Na zewnątrz", t.outdoor, "text-cold"],
["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"],
];
// The four figures the family checks first; each value is shown here and nowhere else on the screen.
function Now({ hp, e, pi, weather }: { hp: HeatPump; e: Energy | undefined; pi: PiSensor | undefined; weather: Weather | undefined }) {
const home = pi?.temperature ?? hp.temperatures.indoor;
const humidity = pi?.temperature == null ? null : pi.humidity;
return (
<Card title="Temperatury">
{rows.map(([icon, label, value, colour]) => (
<Row key={label} label={label} value={celsius(value)} icon={<span className={colour}><Icon name={icon} /></span>} />
))}
<section aria-label="Teraz" className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 lg:grid-cols-4">
<Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={`cel ${celsius(hp.targets.cwu)}`} />
<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>
);
}
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.
function Devices({ hp }: { hp: HeatPump }) {
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() {
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);
@@ -172,6 +170,7 @@ function NoReading() {
export function Overview() {
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
const { data: e } = usePoll<Energy>("api/energy");
const { data: pi } = usePoll<PiSensor>("api/pi-sensor");
const weather = useWeather();
const now = hp?.time ? new Date(hp.time) : null;
const title = (
@@ -193,48 +192,23 @@ export function Overview() {
{weather && <WeatherWarnings warnings={weather.warnings} />}
{hp.time == null ? <NoReading /> : (
<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">
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
<Devices hp={hp} />
<p className="mt-3 text-xs text-soft">
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.
</p>
</Card>
<Temperatures hp={hp} />
<Targets hp={hp} />
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 xl:grid-cols-5">
<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>
<Now hp={hp} e={e} pi={pi} weather={weather} />
<div className="grid content-start gap-4 lg:col-span-2">
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />}>
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
<Devices hp={hp} />
<p className="mt-3 text-xs text-soft">
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.
</p>
</Card>
<DeviceTiles hp={hp} />
</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 />
<PiSensorCard />
<EnergyToday e={e} />
</div>
)}
</>
+21 -9
View File
@@ -1,4 +1,5 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { HeatPump, Weather } from "./types";
@@ -25,7 +26,6 @@ describe("Pogoda card", () => {
const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!;
const [today, tomorrow, dayAfter] = within(card).getAllByRole("listitem");
expect(card).toHaveTextContent("teraz 10,6 °C");
expect(today).toHaveTextContent("Dziś");
expect(today).toHaveTextContent("Pochmurno");
expect(today).toHaveTextContent("10,9 °C / 3,8 °C");
@@ -64,27 +64,39 @@ describe("Pogoda card", () => {
const { Overview } = await import("./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();
});
});
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();
const banner = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
expect(banner.closest("[role=alert]")).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru");
expect(banner.closest("[role=alert]")).toHaveClass("border-orange-500");
const line = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
const banner = line.closest("details")!;
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 () => {
await overview({ warnings: [warning("Mróz", 1), warning("Upał", 2), warning("Burza", 3)] });
await screen.findByText(/stopień 3: Burza/);
expect(screen.getByText(/stopień 1: Mróz/).closest("[role=alert]")).toHaveClass("border-yellow-500");
expect(screen.getByText(/stopień 2: Upał/).closest("[role=alert]")).toHaveClass("border-orange-500");
expect(screen.getByText(/stopień 3: Burza/).closest("[role=alert]")).toHaveClass("border-alarm");
expect(screen.getByText(/stopień 1: Mróz/).closest("details")).toHaveClass("border-yellow-500");
expect(screen.getByText(/stopień 2: Upał/).closest("details")).toHaveClass("border-orange-500");
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 () => {
+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" },
};
export function WeatherIcon({ code }: { code: number | null }) {
export function WeatherIcon({ code, className = "size-10" }: { code: number | null; className?: string }) {
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];
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">
{paths.map((d) => <path key={d} d={d} />)}
</svg>
@@ -52,15 +52,16 @@ const DAY_NAMES = ["Dziś", "Jutro", "Pojutrze"];
function Day({ name, day }: { name: string; day: WeatherDay }) {
const rain = day.precipitationMm;
return (
<li className="flex flex-col items-center gap-1 text-center">
<b>{name}</b>
<WeatherIcon code={day.weatherCode} />
<span className="text-sm text-soft">{day.description}</span>
<span className="tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</span>
<span className="text-sm text-soft">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
{day.pvForecastKwh != null && (
<span className="text-sm font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</span>
)}
<li className="flex items-center gap-3 border-t border-edge py-2.5 first:border-t-0 first:pt-0 last:pb-0">
<WeatherIcon code={day.weatherCode} className="size-9" />
<div className="min-w-0 flex-1">
<p><b>{name}</b> <span className="text-soft">{day.description}</span></p>
<p className="flex flex-wrap gap-x-3 text-xs text-soft">
<span className="whitespace-nowrap">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
{day.pvForecastKwh != null && <b className="whitespace-nowrap font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</b>}
</p>
</div>
<p className="whitespace-nowrap tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</p>
</li>
);
}
@@ -68,9 +69,8 @@ function Day({ name, day }: { name: string; day: WeatherDay }) {
export function WeatherCard({ weather }: { weather: Weather }) {
const days = [weather.today, weather.tomorrow, weather.dayAfter];
return (
<Card title="Pogoda" aside={<span className="text-sm text-soft">teraz {celsius(weather.now.temperature)}</span>}
className="lg:col-span-2">
<ul className="grid grid-cols-3 gap-2">
<Card title="Pogoda">
<ul>
{days.map((day, i) => <Day key={day.date} name={DAY_NAMES[i]} day={day} />)}
</ul>
{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",
};
/** 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[] }) {
// 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 (
<>
{warnings.map((w) => (
<div key={`${w.event}${w.level}${w.from}`} role="alert"
className={`mb-4 flex items-start gap-3 rounded-xl border-2 px-4 py-3 ${LEVELS[w.level] ?? LEVELS[1]}`}>
<Icon name="alert" className="size-6 shrink-0" />
<div>
<p className="font-semibold">Ostrzeżenie IMGW, stopień {w.level}: {w.event}</p>
<p className="text-sm">{dateTime(w.from)} – {dateTime(w.to)}</p>
<p className="mt-1 text-sm">{w.text}</p>
</div>
</div>
{distinct.map((w) => (
<details key={key(w)} role="alert"
className={`mb-3 rounded-xl border-2 px-4 py-2 ${LEVELS[w.level] ?? LEVELS[1]}`}>
<summary className="flex cursor-pointer items-center gap-3">
<Icon name="alert" className="size-5 shrink-0" />
<span className="min-w-0 flex-1">
<b>Ostrzeżenie IMGW, stopień {w.level}: {w.event}</b>
<span className="block text-sm">{dateTime(w.from)} – {dateTime(w.to)}</span>
</span>
</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 }) {
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} />
{children}
</span>