diff --git a/docs/spec.md b/docs/spec.md index 8aea355..d4958be 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -86,7 +86,7 @@ Functional: - F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery). - F5 Health of backend, device links and tunnel. - F6 Later, low priority: show the weekly program (reading needs selector writes). -- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals. +- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter). Safety, for every write: diff --git a/frontend/src/DeviceDetails.tsx b/frontend/src/DeviceDetails.tsx new file mode 100644 index 0000000..9aae0c6 --- /dev/null +++ b/frontend/src/DeviceDetails.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef } from "react"; +import { quantity, when } from "./format"; +import type { EnergyDetailsDevice } from "./types"; +import { Row } from "./ui"; + +/** The tappable row that says in words and with a chevron that a device has more to show. */ +export function DetailsButton({ device, onOpen }: { device: EnergyDetailsDevice; onOpen: () => void }) { + return ( + + ); +} + +/** A bottom sheet on a phone, a dialog on a wide screen; closes by button, backdrop or Escape. */ +export function DeviceDetails({ device, onClose }: { device: EnergyDetailsDevice; onClose: () => void }) { + const close = useRef(null); + + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + const overflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + close.current?.focus(); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + // The close button is the only control inside, so Tab stays on it. + if (event.key === "Tab") { + event.preventDefault(); + close.current?.focus(); + } + }; + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = overflow; + opener?.focus(); + }; + }, [onClose]); + + return ( +
+
event.stopPropagation()} + className="flex max-h-[85dvh] w-full flex-col rounded-t-2xl border border-edge bg-panel shadow-xl sm:max-w-lg sm:rounded-2xl"> +
+
+

{device.name}

+ {device.time &&

Odczyt z {when(device.time)}

} +
+ +
+
+ {device.groups.length === 0 && ( +

+ Brak aktualnych danych{device.time ? ` (ostatni odczyt ${when(device.time)})` : ""}. +

+ )} + {device.groups.map((group) => ( +
+

{group.title}

+ {group.items.map((item) => ( + + ))} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/Energy.test.tsx b/frontend/src/Energy.test.tsx new file mode 100644 index 0000000..a5d8d0e --- /dev/null +++ b/frontend/src/Energy.test.tsx @@ -0,0 +1,74 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { example, serve } from "./fixtures"; +import type { EnergyDetails } from "./types"; + +// A fresh module per test: the poller keeps its answers between screens, not between tests. +const energy = async () => (await import("./Energy")).Energy; + +function serveEnergy(details: EnergyDetails = example("energy-details")) { + serve({ "api/energy/details": details, "api/energy": example("energy") }); +} + +describe("Energy details", () => { + it("offers every device with a visible way to open it", async () => { + serveEnergy(); + const Energy = await energy(); + render(); + + for (const name of ["Falownik Deye", "Falownik Fronius", "Licznik domu"]) { + expect(await screen.findByRole("button", { name: new RegExp(`${name}.*Szczegóły`) })).toBeInTheDocument(); + } + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("opens the groups of a device and closes by button, backdrop and Escape", async () => { + serveEnergy(); + const Energy = await energy(); + render(); + + const opener = await screen.findByRole("button", { name: /Falownik Deye/ }); + await userEvent.click(opener); + const dialog = screen.getByRole("dialog", { name: "Falownik Deye" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(within(dialog).getByRole("button", { name: "Zamknij" })).toHaveFocus(); + expect(within(dialog).getByRole("heading", { name: "Bateria" })).toBeInTheDocument(); + expect(within(dialog).getByText("Naładowanie").parentElement).toHaveTextContent("99 %"); + expect(within(dialog).getByText("Pobrano z sieci").parentElement).toHaveTextContent("16,8 kWh"); + + await userEvent.click(within(dialog).getByRole("button", { name: "Zamknij" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(opener).toHaveFocus(); + + await userEvent.click(opener); + await userEvent.click(screen.getByTestId("backdrop")); + expect(screen.queryByRole("dialog")).toBeNull(); + + await userEvent.click(opener); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("stays open when the panel itself is tapped", async () => { + serveEnergy(); + const Energy = await energy(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: /Licznik domu/ })); + await userEvent.click(screen.getByRole("heading", { name: "Licznik domu" })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("says there is no current data for a device without a fresh reading", async () => { + const details = example("energy-details"); + serveEnergy({ devices: details.devices.map((d) => (d.id === "fronius" ? { ...d, stale: true, groups: [] } : d)) }); + const Energy = await energy(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: /Falownik Fronius/ })); + + expect(screen.getByRole("dialog")).toHaveTextContent("Brak aktualnych danych"); + }); +}); diff --git a/frontend/src/Energy.tsx b/frontend/src/Energy.tsx index 8ae3ed5..7fd295b 100644 --- a/frontend/src/Energy.tsx +++ b/frontend/src/Energy.tsx @@ -1,8 +1,10 @@ +import { useCallback, useState } from "react"; import { Cell, Pie, PieChart } from "recharts"; import { kwh, percent, power, when } from "./format"; import { Icon, type IconName } from "./icons"; import { usePoll } from "./poll"; -import type { Energy as EnergyData } from "./types"; +import type { Energy as EnergyData, EnergyDetails } from "./types"; +import { DetailsButton, DeviceDetails } from "./DeviceDetails"; import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui"; const FLOWING_W = 10; @@ -138,6 +140,23 @@ function Stat({ icon, label, value, note }: { icon: IconName; label: string; val ); } +function Devices() { + const { data } = usePoll("api/energy/details"); + const [openId, setOpenId] = useState(null); + const close = useCallback(() => setOpenId(null), []); + const open = data?.devices.find((d) => d.id === openId); + return ( + + {!data ? : ( +
+ {data.devices.map((d) => setOpenId(d.id)} />)} +
+ )} + {open && } +
+ ); +} + export function Energy() { const { data: e, offline } = usePoll("api/energy"); const title = ; @@ -157,6 +176,7 @@ export function Energy() { + ); diff --git a/frontend/src/format.ts b/frontend/src/format.ts index a2ca5ef..fa31d1f 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -18,6 +18,10 @@ export const kwh = (value: Value) => (value == null ? DASH : `${number(value, 1) // "45 °C" for a whole setpoint, "45,5 °C" otherwise. export const degrees = (value: Value) => (value == null ? DASH : `${number(value, 1).replace(/,0$/, "")} °C`); +// A value in the unit the backend names, with the precision it asks for. +export const quantity = (value: number, unit: string, digits: number) => + unit ? `${number(value, digits)} ${unit}` : number(value, digits); + export function power(watts: Value): string { if (watts == null) return DASH; return Math.abs(watts) >= 1000 ? `${number(watts / 1000, 1)} kW` : `${number(watts)} W`; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 088b17e..5b7de10 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -37,6 +37,25 @@ export interface Energy { today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; loadKwh: number | null }; } +export interface EnergyDetailsItem { + label: string; + value: number; + unit: string; + digits: number; +} + +export interface EnergyDetailsDevice { + id: string; + name: string; + time: string | null; + stale: boolean; + groups: { title: string; items: EnergyDetailsItem[] }[]; +} + +export interface EnergyDetails { + devices: EnergyDetailsDevice[]; +} + export interface Point { t: string; v: number;