feat(frontend): Przywróć teraz for pending changes in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 13:34:34 +02:00
1 parent f9fbff4eea
commit 09042c6338
4 files changed
+131 -9

No files matched your search

+69
View File
@@ -171,4 +171,73 @@ describe("Settings", () => {
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
});
describe("restore now", () => {
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
it("asks first, then restores and reloads the state", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await restoreButton());
expect(screen.getByText("Przywrócić 8 °C teraz?")).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(0);
const loads = fetch.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
expect(posts(fetch)).toHaveLength(1);
expect(posts(fetch)[0][0]).toBe("api/setpoints/cwu_economy/restore");
expect(await screen.findByText("Przywrócono poprzednią wartość.")).toBeInTheDocument();
expect(fetch.mock.calls.length).toBeGreaterThan(loads + 1);
});
it("sends nothing when cancelled", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await restoreButton());
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
expect(posts(fetch)).toHaveLength(0);
expect(await restoreButton()).toBeInTheDocument();
});
it("has a button large enough to tap", async () => {
serve({ "api/setpoints": example("setpoints") });
const Settings = await settings();
render(<Settings />);
expect((await restoreButton()).className).toContain("min-h-12");
});
it.each([
["CONFLICT", 409, "zostawiliśmy ją bez zmian"],
["TOO_SOON", 429, "Za wcześnie"],
["FAILED", 502, "Zmiana nadal czeka"],
["NOT_PENDING", 404, "już się zakończyła"],
])("explains %s in Polish", async (outcome, status, text) => {
serve({ "api/setpoints": example("setpoints") }, restoreAnswer(outcome, status));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await restoreButton());
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
it("never claims nothing changed when the answer is lost", async () => {
serve({ "api/setpoints": example("setpoints") }, () => Promise.reject(new TypeError("network")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await restoreButton());
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Nie wiadomo, czy zmiana doszła do pompy");
});
});
});
+49 -5
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { postJson, WriteError } from "./api";
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
import { reload, usePoll } from "./poll";
import type { Change, CommandResult, LineStatus, Setpoint, Setpoints } from "./types";
import type { Change, CommandResult, LineStatus, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
import { Card, Freshness, Loading, PageTitle } from "./ui";
const URL = "api/setpoints";
@@ -27,6 +27,13 @@ const RESULT_TEXT: Record<string, string> = {
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
PARTIAL: "Tylko część zmian jest zapisana. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
};
const RESTORE_TEXT: Record<RestoreOutcome, string> = {
RESTORED: "Przywrócono poprzednią wartość.",
NOT_PENDING: "Ta zmiana już się zakończyła. Nic nie zostało zmienione.",
CONFLICT: "Wartość została w międzyczasie zmieniona (np. na panelu pompy), więc zostawiliśmy ją bez zmian.",
TOO_SOON: "Za wcześnie po poprzednim zapisie. Spróbuj za minutę.",
FAILED: "Nie udało się przywrócić. Zmiana nadal czeka, spróbujemy ponownie sami. Sprawdź wartości poniżej.",
};
const LINE_TEXT: Record<LineStatus, string> = {
APPLIED: "zapisano",
REJECTED: "pompa nie przyjęła zmiany",
@@ -81,6 +88,8 @@ function ResultBox({ result }: { result: Result }) {
</ul>
</>
);
} else if (ok && result.text) {
body = result.text;
} else if (ok) {
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości ${dateTime(result.restoreAt)}.` : ""}</>;
} else {
@@ -132,6 +141,8 @@ export function Settings() {
const [presetId, setPresetId] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
// The setpoint whose pending change is about to be restored, waiting for "Tak, przywróć".
const [restoring, setRestoring] = useState<string | null>(null);
const [result, setResult] = useState<Result>(null);
// A refresh can lose the reading while a change is half-made; drop what was built on it.
@@ -145,7 +156,7 @@ export function Settings() {
const title = <PageTitle title="Ustawienia" subtitle="Temperatury zadane pompy ciepła" />;
if (!data) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setResult(null); };
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setRestoring(null); setResult(null); };
async function send(url: string, payload: unknown, changes?: Change[]) {
setBusy(true);
@@ -166,6 +177,23 @@ export function Settings() {
reload(URL);
}
async function restore(id: string) {
setBusy(true);
let answer: Result;
try {
const { outcome } = await postJson<RestoreResult>(`${URL}/${id}/restore`);
answer = { status: outcome === "RESTORED" ? "APPLIED" : "FAILED", text: RESTORE_TEXT[outcome] };
} catch (error) {
const status = error instanceof WriteError ? error.status : 0;
answer = { status: "FAILED", text: ERROR_TEXT[status] ?? WRITE_LOST };
}
setResult(answer);
setBusy(false);
setRestoring(null);
window.scrollTo(0, 0);
reload(URL);
}
function step(s: Setpoint, direction: number) {
if (unread(s)) return;
setResult(null);
@@ -211,9 +239,25 @@ export function Settings() {
{data.restores.length > 0 && (
<Card title="Zmiany czasowe">
{data.restores.map((r) => (
<p key={r.setpoint} className="my-1">
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${dateTime(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
</p>
<div key={r.setpoint} className="border-t border-edge py-3 first:border-t-0 first:pt-0">
<p className="my-1">
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${dateTime(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
</p>
{restoring === r.setpoint ? (
<>
<p className="my-2 text-lg font-semibold">Przywrócić {degrees(r.originalValue)} teraz?</p>
<div className="flex gap-3">
<Button onClick={() => setRestoring(null)} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => restore(r.setpoint)} disabled={busy}>Tak, przywróć</Button>
</div>
</>
) : (
<div className="mt-2 flex">
<Button aria-label={`Przywróć teraz: ${name(r.setpoint)}`} disabled={busy}
onClick={() => { setResult(null); setRestoring(r.setpoint); }}>Przywróć teraz</Button>
</div>
)}
</div>
))}
</Card>
)}
+6 -4
View File
@@ -16,10 +16,12 @@ export class WriteError extends Error {
}
}
// A write is sent once and never retried. Resolves with the {status, ...} answer, also for a
// refusal (422) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
// A write is sent once and never retried. Resolves with the {status, ...} (or {outcome, ...}) answer, also for a
// refusal (422), a conflict (409) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
// and with status 0 when the network fails or the answer does not come in time.
export async function postJson(url: string, body?: unknown): Promise<CommandResult> {
export async function postJson<T extends { status?: string; outcome?: string } = CommandResult>(
url: string, body?: unknown,
): Promise<T> {
const response = await fetch(url, {
method: "POST",
// The custom header is what the backend uses to tell this page from another site's form.
@@ -30,6 +32,6 @@ export async function postJson(url: string, body?: unknown): Promise<CommandResu
throw new WriteError(0);
});
const answer = await response.json().catch(() => null);
if (!answer?.status) throw new WriteError(response.status);
if (!answer?.status && !answer?.outcome) throw new WriteError(response.status);
return answer;
}
+7
View File
@@ -115,6 +115,13 @@ export interface Setpoints {
export type LineStatus = "APPLIED" | "REJECTED" | "FAILED" | "UNKNOWN" | "NOT_ATTEMPTED";
export type RestoreOutcome = "RESTORED" | "NOT_PENDING" | "CONFLICT" | "TOO_SOON" | "FAILED";
export interface RestoreResult {
outcome: RestoreOutcome;
message?: string;
}
export interface CommandResult {
status: "APPLIED" | "REJECTED" | "FAILED" | "PARTIAL";
message?: string;