From 38c023c4939c49bae3b906cf47c2e4fc9ecfa6ed Mon Sep 17 00:00:00 2001 From: Mariusz Pacek Date: Sat, 24 Oct 2026 04:45:28 +0200 Subject: [PATCH 1/3] feat(web): add Ustawienia screen with presets and setpoint steppers Co-Authored-By: Claude Sonnet 5.5 --- backend/src/main/resources/web/api.js | 14 ++ backend/src/main/resources/web/app.js | 3 +- backend/src/main/resources/web/heat-pump.js | 11 +- backend/src/main/resources/web/index.html | 1 + backend/src/main/resources/web/settings.js | 165 ++++++++++++++++++++ backend/src/main/resources/web/style.css | 30 +++- 6 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 backend/src/main/resources/web/settings.js diff --git a/backend/src/main/resources/web/api.js b/backend/src/main/resources/web/api.js index 742ae28..242fb9d 100644 --- a/backend/src/main/resources/web/api.js +++ b/backend/src/main/resources/web/api.js @@ -3,3 +3,17 @@ export async function getJson(url) { if (!response.ok) throw new Error(`${url}: ${response.status}`); return response.json(); } + +// A write is sent once and never retried. Resolves with the {status, message, restoreAt} +// answer, also for a refusal; rejects when the gateway is unreachable (503) or the network fails. +export async function postJson(url, body) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + if (response.status === 503) throw new Error(`${url}: 503`); + const answer = await response.json().catch(() => null); + if (!answer?.status) throw new Error(`${url}: ${response.status}`); + return answer; +} diff --git a/backend/src/main/resources/web/app.js b/backend/src/main/resources/web/app.js index 6383dd3..87ac468 100644 --- a/backend/src/main/resources/web/app.js +++ b/backend/src/main/resources/web/app.js @@ -2,11 +2,12 @@ import { ageMinutes, ago } from "./format.js"; import heatPump from "./heat-pump.js"; import energy from "./energy.js"; import history from "./history.js"; +import settings from "./settings.js"; import status from "./status.js"; const REFRESH_MS = 30000; const STALE_MINUTES = 10; -const screens = { "heat-pump": heatPump, energy, history, status }; +const screens = { "heat-pump": heatPump, energy, history, settings, status }; const root = document.getElementById("screen"); const tabs = document.querySelectorAll("#tabs button"); const cache = {}; // last good data per screen, kept while the backend is unreachable diff --git a/backend/src/main/resources/web/heat-pump.js b/backend/src/main/resources/web/heat-pump.js index 56e9d4b..e642842 100644 --- a/backend/src/main/resources/web/heat-pump.js +++ b/backend/src/main/resources/web/heat-pump.js @@ -1,5 +1,5 @@ import { getJson } from "./api.js"; -import { celsius, number, hour } from "./format.js"; +import { celsius, hour } from "./format.js"; import { schematic } from "./schematic.js"; const HEADLINES = { @@ -22,7 +22,7 @@ function pumpRows(pumps) { } function view(data) { - const { targets, setpoints: s, temperatures: t } = data; + const { targets, temperatures: t } = data; const alarm = data.alarm ? `` : ""; return `${alarm} @@ -40,13 +40,6 @@ function view(data) { ${row("Pokój", celsius(targets.room))} ${row("Woda grzewcza CO", celsius(targets.co))} ${row("Ciepła woda CWU", celsius(targets.cwu))} - -

Nastawy

- ${row("CO oszczędnie", `${number(s.coEconomy)} °C`)} - ${row("CO komfort", `${number(s.coComfort)} °C`)} - ${row("CWU oszczędnie", `${number(s.cwuEconomy)} °C`)} - ${row("CWU komfort", `${number(s.cwuComfort)} °C`)} -

Tylko podgląd. Zmiana nastaw będzie później.

`; } diff --git a/backend/src/main/resources/web/index.html b/backend/src/main/resources/web/index.html index 82ad994..ba68884 100644 --- a/backend/src/main/resources/web/index.html +++ b/backend/src/main/resources/web/index.html @@ -15,6 +15,7 @@ + diff --git a/backend/src/main/resources/web/settings.js b/backend/src/main/resources/web/settings.js new file mode 100644 index 0000000..20c4f70 --- /dev/null +++ b/backend/src/main/resources/web/settings.js @@ -0,0 +1,165 @@ +import { getJson, postJson } from "./api.js"; +import { number, hour, dayAndHour } from "./format.js"; + +const NAMES = { + cwu_economy: "Ciepła woda, oszczędnie", + cwu_comfort: "Ciepła woda, komfort", + co_economy: "Ogrzewanie domu, oszczędnie", + co_comfort: "Ogrzewanie domu, komfort", +}; +const DURATIONS = [ + { hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." }, + { hours: 4, label: "4 godz." }, { hours: null, label: "Do odwołania" }, +]; +const OFFLINE = "Dom nie odpowiada, spróbuj za chwilę."; +const RESULT_TEXT = { + REJECTED: "Pompa nie przyjęła tej zmiany. Nic nie zostało zmienione.", + FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.", +}; + +// Screen state outlives the 30 s refresh, so a half-made change is not lost. +// draft: the one setpoint being changed; confirming: the summary is shown, waiting for "Tak". +const state = { draft: null, hours: 2, preset: null, confirming: false, busy: false, result: null }; + +const name = (id) => NAMES[id] ?? id; +const degrees = (value) => `${number(value, 1).replace(/,0$/, "")} °C`; +const disabled = () => (state.busy ? " disabled" : ""); +const find = (data, id) => data.setpoints.find((s) => s.name === id); + +function when(iso) { + const date = new Date(iso); + return date.toDateString() === new Date().toDateString() ? hour(date) : dayAndHour(date); +} + +function restores(data) { + if (!data.restores.length) return ""; + const lines = data.restores.map((r) => + `

${name(r.setpoint)}: ${degrees(r.appliedValue)} do ${when(r.due)}, potem ${degrees(r.originalValue)}.

`); + return `

Zmiany czasowe

${lines.join("")}
`; +} + +function resultBox() { + const r = state.result; + if (!r) return ""; + if (r.status === "APPLIED") { + const back = r.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(r.restoreAt)}.` : ""; + return ``; + } + const detail = r.message ? `
${r.message}` : ""; + return ``; +} + +function confirmButtons(yes, action) { + return `
+
`; +} + +function presets(data) { + const buttons = data.presets.map((p) => + ``).join(""); + return `

Szybkie ustawienia

${buttons}
`; +} + +function presetConfirm(data) { + const preset = data.presets.find((p) => p.id === state.preset); + const effect = preset.description ? `

${preset.description}

` : ""; + return `

${preset.label}

${effect}

Potwierdzasz?

+ ${confirmButtons("Tak, ustaw", "preset-ok")}
`; +} + +function stepper(s) { + const shown = state.draft?.name === s.name ? state.draft.value : s.value; + const label = name(s.name); + return `
${label} +
+ + ${degrees(shown)} + +
od ${degrees(s.min)} do ${degrees(s.max)}
`; +} + +function saveBox(data) { + const d = state.draft; + if (!d) return ""; + const s = find(data, d.name); + const options = DURATIONS.map((o) => + ``).join(""); + const action = state.confirming + ? `

${saveSummary(s)}

${confirmButtons("Tak, zapisz", "save-ok")}` + : `
+
`; + return `

${name(d.name)}: z ${degrees(s.value)} na ${degrees(d.value)}

+

Na jak długo?

${options}
${action}
`; +} + +function saveSummary(s) { + const value = degrees(state.draft.value); + if (!state.hours) return `Ustawię ${value} do odwołania. Poprzednia wartość (${degrees(s.value)}) nie wróci sama.`; + const back = when(new Date(Date.now() + state.hours * 3600000).toISOString()); + return `Ustawię ${value}. O ${back} wrócimy do ${degrees(s.value)}.`; +} + +function body(data) { + if (state.preset) return resultBox() + presetConfirm(data); + return `${resultBox()}${restores(data)}${presets(data)} +

Nastawy

${data.setpoints.map(stepper).join("")}${saveBox(data)}
`; +} + +const view = (data) => `
${body(data)}
`; + +function change(data, el) { + const { step, name: id, hours, preset, act } = el.dataset; + state.result = null; + if (step) { + const s = find(data, id); + const from = state.draft?.name === id ? state.draft.value : s.value; + const value = Math.min(s.max, Math.max(s.min, from + Number(step) * s.step)); + state.draft = value === s.value ? null : { name: id, value }; + state.confirming = false; + } else if (hours !== undefined) { + state.hours = hours === "" ? null : Number(hours); + state.confirming = false; + } else if (preset) { + state.preset = preset; + } else if (act === "cancel") { + Object.assign(state, { draft: null, preset: null, confirming: false }); + } else if (act === "save") { + state.confirming = true; + } +} + +function bind(root, data, reload) { + const box = root.querySelector("#settings"); + + async function send(url, payload) { + state.busy = true; + box.innerHTML = body(data); + try { + state.result = await postJson(url, payload); + if (state.result.status === "APPLIED") state.draft = null; + } catch { + state.result = { status: "FAILED", text: OFFLINE }; + } + Object.assign(state, { busy: false, preset: null, confirming: false }); + window.scrollTo(0, 0); + reload(); + } + + box.addEventListener("click", (event) => { + const el = event.target.closest("button"); + if (!el || state.busy) return; + const act = el.dataset.act; + if (act === "save-ok") { + const { name: setpoint, value } = state.draft; + send("api/setpoints", { setpoint, value, hours: state.hours ?? undefined }); + } else if (act === "preset-ok") { + send(`api/presets/${state.preset}`); + } else { + change(data, el); + box.innerHTML = body(data); + box.querySelector(".savebox")?.scrollIntoView({ block: "nearest" }); + } + }); +} + +export default { load: () => getJson("api/setpoints"), view, bind }; diff --git a/backend/src/main/resources/web/style.css b/backend/src/main/resources/web/style.css index 09b7d59..c65ddf6 100644 --- a/backend/src/main/resources/web/style.css +++ b/backend/src/main/resources/web/style.css @@ -2,14 +2,14 @@ --bg: #f1f5f2; --card: #ffffff; --text: #14201a; --muted: #4a5a52; --line: #cfd9d3; --accent: #0d6b45; --on-accent: #ffffff; --ok: #0d6b45; --warn: #8a5a00; --bad: #b3261e; --warn-bg: #fff1cc; --bad-bg: #fde0dd; --hot: #c8501a; --cold: #1b6fb5; --off: #8d9a93; - --box: #e8f0eb; + --box: #e8f0eb; --ok-bg: #d9f2e4; } @media (prefers-color-scheme: dark) { :root { --bg: #0f1713; --card: #19241e; --text: #f0f6f2; --muted: #b4c4ba; --line: #34463c; --accent: #4fcf94; --on-accent: #06210f; --ok: #4fcf94; --warn: #f2c14e; --bad: #ff8a80; --warn-bg: #3d3210; --bad-bg: #4a1f1c; --hot: #ff9a62; --cold: #6bb6f2; --off: #7b8b82; - --box: #223229; + --box: #223229; --ok-bg: #153d2a; } } * { box-sizing: border-box; } @@ -41,7 +41,7 @@ nav { } nav button { flex: 1; min-height: 64px; border: 0; background: none; color: var(--muted); font: inherit; - font-size: 0.75rem; font-weight: 600; white-space: nowrap; display: flex; flex-direction: column; align-items: center; + font-size: 0.7rem; font-weight: 600; white-space: nowrap; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; padding: 4px 2px; } nav svg { width: 26px; height: 26px; fill: currentColor; stroke: currentColor; stroke-linejoin: round; stroke-linecap: round; } @@ -76,3 +76,27 @@ svg .sub { fill: var(--muted); } .chart .mark { fill: var(--accent); } .chart { touch-action: pan-y; width: 100%; height: auto; display: block; } .readout { min-height: 2.7em; font-weight: 600; } +.banner.ok { background: var(--ok-bg); color: var(--ok); border: 2px solid var(--ok); } +.detail { font-weight: 400; font-size: 0.85rem; } +.lead { font-size: 1.1rem; font-weight: 600; margin: 8px 0; } +.stack { display: grid; gap: 10px; } +button.big, .seg button.primary { + min-height: 64px; border: 2px solid var(--accent); border-radius: 12px; font: inherit; + font-size: 1.1rem; font-weight: 700; background: var(--card); color: var(--accent); +} +.seg button.primary { background: var(--accent); color: var(--on-accent); } +button:disabled { opacity: 0.45; } +.seg.wrap { flex-wrap: wrap; } +.seg.wrap button { flex: 1 1 40%; } +.setpoint { padding: 10px 0; border-top: 1px solid var(--line); } +.setpoint:first-of-type { border-top: 0; } +.setpoint .name { display: block; font-weight: 600; } +.setpoint.changed { border-left: 5px solid var(--accent); padding-left: 10px; } +.step { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; } +.step button { + width: 64px; height: 64px; border: 2px solid var(--accent); border-radius: 12px; background: var(--card); + color: var(--accent); font: inherit; font-size: 2rem; font-weight: 700; line-height: 1; +} +.step output { flex: 1; text-align: center; font-size: 1.8rem; font-weight: 700; font-variant-numeric: tabular-nums; } +.savebox { border-top: 2px solid var(--accent); margin-top: 8px; padding-top: 8px; } +.savebox { scroll-margin-bottom: 84px; } From 1c623d496c475e73295ee1dd215207b892459247 Mon Sep 17 00:00:00 2001 From: Mariusz Pacek Date: Sat, 24 Oct 2026 04:51:07 +0200 Subject: [PATCH 2/3] fix(web): align the Ustawienia tab with the setpoints API contract Presets show name and a sentence built from their changes, preset results list one line per change, 400/404/503 get their own Polish messages. Co-Authored-By: Claude Sonnet 5.5 --- backend/src/main/resources/web/api.js | 11 ++--- backend/src/main/resources/web/settings.js | 49 ++++++++++++++++++---- backend/src/main/resources/web/style.css | 2 +- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/backend/src/main/resources/web/api.js b/backend/src/main/resources/web/api.js index 242fb9d..cb57a38 100644 --- a/backend/src/main/resources/web/api.js +++ b/backend/src/main/resources/web/api.js @@ -4,16 +4,17 @@ export async function getJson(url) { return response.json(); } -// A write is sent once and never retried. Resolves with the {status, message, restoreAt} -// answer, also for a refusal; rejects when the gateway is unreachable (503) or the network fails. +// 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, +// and with status 0 when the network fails. export async function postJson(url, body) { + const fail = (status) => Object.assign(new Error(`${url}: ${status}`), { status }); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}), - }); - if (response.status === 503) throw new Error(`${url}: 503`); + }).catch(() => { throw fail(0); }); const answer = await response.json().catch(() => null); - if (!answer?.status) throw new Error(`${url}: ${response.status}`); + if (!answer?.status) throw fail(response.status); return answer; } diff --git a/backend/src/main/resources/web/settings.js b/backend/src/main/resources/web/settings.js index 20c4f70..2ff3382 100644 --- a/backend/src/main/resources/web/settings.js +++ b/backend/src/main/resources/web/settings.js @@ -12,10 +12,19 @@ const DURATIONS = [ { hours: 4, label: "4 godz." }, { hours: null, label: "Do odwołania" }, ]; const OFFLINE = "Dom nie odpowiada, spróbuj za chwilę."; +const ERROR_TEXT = { + 400: "Nieprawidłowa wartość. Nic nie zostało zmienione.", + 404: "Nie ma takiego ustawienia. Nic nie zostało zmienione.", +}; const RESULT_TEXT = { REJECTED: "Pompa nie przyjęła tej zmiany. Nic nie zostało zmienione.", FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.", }; +const LINE_TEXT = { + APPLIED: "zapisano", + REJECTED: "pompa nie przyjęła zmiany", + FAILED: "nie wiadomo, czy doszło do pompy", +}; // Screen state outlives the 30 s refresh, so a half-made change is not lost. // draft: the one setpoint being changed; confirming: the summary is shown, waiting for "Tak". @@ -38,15 +47,25 @@ function restores(data) { return `

Zmiany czasowe

${lines.join("")}
`; } +function lines(r) { + return r.results.map((x, i) => { + const back = x.restoreAt ? `, wróci o ${when(x.restoreAt)}` : ""; + return `
  • ${name(r.changes[i].setpoint)}: ${LINE_TEXT[x.status]}${x.status === "APPLIED" ? back : ""}
  • `; + }).join(""); +} + function resultBox() { const r = state.result; if (!r) return ""; + if (r.results) { + const head = r.status === "APPLIED" ? "Zapisano." : RESULT_TEXT[r.status]; + return ``; + } if (r.status === "APPLIED") { const back = r.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(r.restoreAt)}.` : ""; return ``; } - const detail = r.message ? `
    ${r.message}` : ""; - return ``; + return ``; } function confirmButtons(yes, action) { @@ -56,14 +75,24 @@ function confirmButtons(yes, action) { function presets(data) { const buttons = data.presets.map((p) => - ``).join(""); + ``).join(""); return `

    Szybkie ustawienia

    ${buttons}
    `; } +function duration(hours) { + return hours < 1 ? `${Math.round(hours * 60)} min` : `${number(hours, 1).replace(/,0$/, "")} godz.`; +} + +function changeSentence(data, c) { + const now = degrees(find(data, c.setpoint).value); + const tail = c.hours == null ? "do odwołania" : `przez ${duration(c.hours)}, potem wróci ${now}`; + return `${name(c.setpoint)}: ${degrees(c.value)} ${tail}`; +} + function presetConfirm(data) { const preset = data.presets.find((p) => p.id === state.preset); - const effect = preset.description ? `

    ${preset.description}

    ` : ""; - return `

    ${preset.label}

    ${effect}

    Potwierdzasz?

    + const effect = preset.changes.map((c) => `

    ${changeSentence(data, c)}.

    `).join(""); + return `

    ${preset.name}

    ${effect}

    Potwierdzasz?

    ${confirmButtons("Tak, ustaw", "preset-ok")}
    `; } @@ -131,14 +160,15 @@ function change(data, el) { function bind(root, data, reload) { const box = root.querySelector("#settings"); - async function send(url, payload) { + async function send(url, payload, changes) { state.busy = true; box.innerHTML = body(data); try { state.result = await postJson(url, payload); + if (changes) state.result.changes = changes; if (state.result.status === "APPLIED") state.draft = null; - } catch { - state.result = { status: "FAILED", text: OFFLINE }; + } catch (error) { + state.result = { status: "FAILED", text: ERROR_TEXT[error.status] ?? OFFLINE }; } Object.assign(state, { busy: false, preset: null, confirming: false }); window.scrollTo(0, 0); @@ -153,7 +183,8 @@ function bind(root, data, reload) { const { name: setpoint, value } = state.draft; send("api/setpoints", { setpoint, value, hours: state.hours ?? undefined }); } else if (act === "preset-ok") { - send(`api/presets/${state.preset}`); + const preset = data.presets.find((p) => p.id === state.preset); + send(`api/presets/${preset.id}`, undefined, preset.changes); } else { change(data, el); box.innerHTML = body(data); diff --git a/backend/src/main/resources/web/style.css b/backend/src/main/resources/web/style.css index c65ddf6..7ec7fda 100644 --- a/backend/src/main/resources/web/style.css +++ b/backend/src/main/resources/web/style.css @@ -77,7 +77,7 @@ svg .sub { fill: var(--muted); } .chart { touch-action: pan-y; width: 100%; height: auto; display: block; } .readout { min-height: 2.7em; font-weight: 600; } .banner.ok { background: var(--ok-bg); color: var(--ok); border: 2px solid var(--ok); } -.detail { font-weight: 400; font-size: 0.85rem; } +.banner ul { margin: 6px 0 0; padding-left: 18px; font-weight: 500; } .lead { font-size: 1.1rem; font-weight: 600; margin: 8px 0; } .stack { display: grid; gap: 10px; } button.big, .seg button.primary { From 38dbcfe1db9a6ac476184419c87f94dbc9c5384f Mon Sep 17 00:00:00 2001 From: Mariusz Pacek Date: Sat, 24 Oct 2026 04:51:43 +0200 Subject: [PATCH 3/3] fix(web): never say a lost write changed nothing The backend answers 503 also when the link drops in the middle of a preset, after some changes were applied; the screen now says it is not known whether the change reached the pump. Co-Authored-By: Claude Opus 5.5 --- backend/src/main/resources/web/settings.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/main/resources/web/settings.js b/backend/src/main/resources/web/settings.js index 2ff3382..7da4284 100644 --- a/backend/src/main/resources/web/settings.js +++ b/backend/src/main/resources/web/settings.js @@ -11,7 +11,9 @@ const DURATIONS = [ { hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." }, { hours: 4, label: "4 godz." }, { hours: null, label: "Do odwołania" }, ]; -const OFFLINE = "Dom nie odpowiada, spróbuj za chwilę."; +// A write that loses its answer may still have reached the pump (the backend also answers 503 +// when the link drops in the middle of a preset), so never claim that nothing changed. +const WRITE_LOST = "Dom nie odpowiada. Nie wiadomo, czy zmiana doszła do pompy: sprawdź wartości, gdy połączenie wróci."; const ERROR_TEXT = { 400: "Nieprawidłowa wartość. Nic nie zostało zmienione.", 404: "Nie ma takiego ustawienia. Nic nie zostało zmienione.", @@ -168,7 +170,7 @@ function bind(root, data, reload) { if (changes) state.result.changes = changes; if (state.result.status === "APPLIED") state.draft = null; } catch (error) { - state.result = { status: "FAILED", text: ERROR_TEXT[error.status] ?? OFFLINE }; + state.result = { status: "FAILED", text: ERROR_TEXT[error.status] ?? WRITE_LOST }; } Object.assign(state, { busy: false, preset: null, confirming: false }); window.scrollTo(0, 0);