diff --git a/backend/src/main/resources/web/chart.js b/backend/src/main/resources/web/chart.js
new file mode 100644
index 0000000..1b04c36
--- /dev/null
+++ b/backend/src/main/resources/web/chart.js
@@ -0,0 +1,71 @@
+import { number, hour, weekday, dayAndHour } from "./format.js";
+
+const W = 340, H = 220;
+const MARGIN = { left: 48, right: 10, top: 12, bottom: 26 };
+const Y_TICKS = 4; // about; the step is rounded
+const X_TICKS = 4;
+
+// 1, 2 or 5 times a power of ten, so the value labels are round numbers.
+function niceStep(rough) {
+ const magnitude = 10 ** Math.floor(Math.log10(rough));
+ return [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough);
+}
+
+// Time and value axes for a series of {t, v} points; returns the scales and the SVG markup.
+export function lineChart(points, range) {
+ const times = points.map((p) => Date.parse(p.t));
+ const values = points.map((p) => p.v);
+ const [t0, t1] = [Math.min(...times), Math.max(...times)];
+ let [lo, hi] = [Math.min(...values), Math.max(...values)];
+ if (hi - lo < 1) [lo, hi] = [lo - 0.5, hi + 0.5];
+ const step = niceStep((hi - lo) / Y_TICKS);
+ [lo, hi] = [Math.floor(lo / step) * step, Math.ceil(hi / step) * step];
+
+ const x = (t) => MARGIN.left + ((t - t0) / (t1 - t0 || 1)) * (W - MARGIN.left - MARGIN.right);
+ const y = (v) => H - MARGIN.bottom - ((v - lo) / (hi - lo)) * (H - MARGIN.top - MARGIN.bottom);
+
+ const digits = step < 1 ? 1 : 0;
+ const yLabels = Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => {
+ const v = lo + i * step;
+ return `
+ ${number(v, digits)}`;
+ }).join("");
+ const label = range === "7d" ? weekday : hour;
+ const xLabels = Array.from({ length: X_TICKS + 1 }, (_, i) => {
+ const t = t0 + ((t1 - t0) * i) / X_TICKS;
+ const anchor = i === 0 ? "start" : i === X_TICKS ? "end" : "middle";
+ return `${label(new Date(t))}`;
+ }).join("");
+ const path = points.map((p, i) => `${i ? "L" : "M"}${x(times[i]).toFixed(1)} ${y(p.v).toFixed(1)}`).join("");
+
+ const svg = ``;
+ return { svg, x, y, min: Math.min(...values), max: Math.max(...values) };
+}
+
+// Shows the point nearest to the finger or mouse in the readout element.
+export function trackPointer(svg, readout, chart, { points, unit }) {
+ const cursor = svg.querySelector("#cursor");
+ const times = points.map((p) => Date.parse(p.t));
+ const show = (event) => {
+ const box = svg.getBoundingClientRect();
+ const px = ((event.clientX - box.left) / box.width) * W;
+ let best = 0;
+ times.forEach((time, i) => {
+ if (Math.abs(chart.x(time) - px) < Math.abs(chart.x(times[best]) - px)) best = i;
+ });
+ const [cx, cy] = [chart.x(times[best]), chart.y(points[best].v)];
+ cursor.hidden = false;
+ cursor.querySelector("line").setAttribute("x1", cx);
+ cursor.querySelector("line").setAttribute("x2", cx);
+ cursor.querySelector("circle").setAttribute("cx", cx);
+ cursor.querySelector("circle").setAttribute("cy", cy);
+ readout.textContent = `${dayAndHour(new Date(times[best]))}: ${number(points[best].v, unit === "W" || unit === "%" ? 0 : 1)} ${unit}`;
+ };
+ svg.addEventListener("pointerdown", show);
+ svg.addEventListener("pointermove", (event) => {
+ if (event.buttons || event.pointerType === "mouse") show(event);
+ });
+}
diff --git a/backend/src/main/resources/web/history.js b/backend/src/main/resources/web/history.js
new file mode 100644
index 0000000..8db4418
--- /dev/null
+++ b/backend/src/main/resources/web/history.js
@@ -0,0 +1,61 @@
+import { getJson } from "./api.js";
+import { number } from "./format.js";
+import { lineChart, trackPointer } from "./chart.js";
+
+// Ids as in Series.java.
+const SERIES = {
+ outdoor_temp: "Temperatura na zewnątrz",
+ indoor_temp: "Temperatura w domu",
+ co_buffer_temp: "Bufor CO",
+ cwu_temp: "Ciepła woda (CWU)",
+ ground_in_temp: "Gruntowe: do pompy",
+ ground_out_temp: "Gruntowe: z pompy",
+ pump_outlet_temp: "Wyjście pompy ciepła",
+ grid_power: "Moc z sieci (+ pobór, − oddawanie)",
+ pv_power: "Produkcja PV",
+ load_power: "Zużycie domu",
+ battery_soc: "Bateria (naładowanie)",
+};
+const RANGES = { "24h": "24 godziny", "7d": "7 dni" };
+
+const choice = { series: "cwu_temp", range: "24h" };
+let chart = null; // the one drawn by the last view, for the pointer handler
+
+const options = () => Object.entries(SERIES)
+ .map(([id, name]) => ``).join("");
+
+const rangeButtons = () => Object.entries(RANGES)
+ .map(([id, name]) => ``).join("");
+
+function body(data) {
+ chart = null;
+ if (data.points.length < 2) return `
Za mało danych w tym okresie.
`;
+ chart = lineChart(data.points, choice.range);
+ const digits = data.unit === "°C" ? 1 : 0;
+ return `${chart.svg}
+ Dotknij wykresu, aby zobaczyć wartość.
+ Najmniej ${number(chart.min, digits)} ${data.unit} · najwięcej ${number(chart.max, digits)} ${data.unit}
`;
+}
+
+const view = (data) => `Historia
+
+ ${rangeButtons()}
+ ${body(data)}`;
+
+function bind(root, data, reload) {
+ root.querySelector("#series").addEventListener("change", (event) => {
+ choice.series = event.target.value;
+ reload();
+ });
+ for (const button of root.querySelectorAll("[data-range]")) {
+ button.addEventListener("click", () => {
+ choice.range = button.dataset.range;
+ reload();
+ });
+ }
+ if (chart) trackPointer(root.querySelector("svg.chart"), root.querySelector("#readout"), chart, data);
+}
+
+const load = () => getJson(`api/history?series=${choice.series}&range=${choice.range}`);
+
+export default { load, view, bind };