fix: various fixes

This commit is contained in:
Willem Serruys
2026-08-21 11:49:49 +02:00
parent 161b226db9
commit 43fda71c1b
10 changed files with 441 additions and 185 deletions

View File

@@ -1,11 +1,24 @@
import { useEffect, useState } from "react";
import { api, type BlockHistoryPoint, type ControlAction, type DeviceStatus, type QuarterHourStatus } from "./api";
import {
api,
type BlockHistoryPoint,
type ControlAction,
type CurrentReadings,
type DeviceStatus,
type QuarterHourStatus,
} from "./api";
import { PeakHistoryChart } from "./PeakHistoryChart";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function formatW(watts: number | null): string {
if (watts == null) return "—";
return Math.abs(watts) >= 1000 ? `${(watts / 1000).toFixed(2)} kW` : `${Math.round(watts)} W`;
}
export function Dashboard() {
const [quarterHour, setQuarterHour] = useState<QuarterHourStatus | null>(null);
const [current, setCurrent] = useState<CurrentReadings | null>(null);
const [devices, setDevices] = useState<DeviceStatus[]>([]);
const [history, setHistory] = useState<BlockHistoryPoint[]>([]);
const [actions, setActions] = useState<ControlAction[]>([]);
@@ -23,6 +36,7 @@ export function Dashboard() {
]);
if (cancelled) return;
setQuarterHour(status.quarterHour);
setCurrent(status.current);
setDevices(status.devices);
setHistory(hist);
setActions(acts);
@@ -58,6 +72,20 @@ export function Dashboard() {
return (
<>
<div className="card">
<h2>Live readings</h2>
<div className="stat-grid">
<div className="stat-tile">
<div className="stat-label">Power usage</div>
<div className="stat-value">{formatW(current?.powerW ?? null)}</div>
</div>
<div className="stat-tile">
<div className="stat-label">Solar generation</div>
<div className="stat-value">{formatW(current?.solarW ?? null)}</div>
</div>
</div>
</div>
<div className="card">
<h2>Current quarter-hour block</h2>
<div className="hero-figure">{(quarterHour.projectedAverageW / 1000).toFixed(2)} kW</div>

View File

@@ -1,7 +1,9 @@
import { useEffect, useState, type FormEvent } from "react";
import { api, type ControlKind, type DeviceConfig } from "./api";
const emptyForm = {
type DeviceFormValues = Omit<DeviceConfig, "id">;
const emptyForm: DeviceFormValues = {
entityId: "",
name: "",
kind: "switch" as ControlKind,
@@ -11,16 +13,158 @@ const emptyForm = {
dwellSeconds: 300,
manualOverride: false,
onlyWhenSun: false,
fromTime: null as string | null,
toTime: null as string | null,
enableUrl: null as string | null,
disableUrl: null as string | null,
fromTime: null,
toTime: null,
enableUrl: null,
disableUrl: null,
};
function DeviceFields({
values,
onChange,
}: {
values: DeviceFormValues;
onChange: (next: DeviceFormValues) => void;
}) {
return (
<div className="form-grid">
<label>
Name
<input
value={values.name}
onChange={(e) => onChange({ ...values, name: e.target.value })}
required
/>
</label>
<label>
Home Assistant entity ID
<input
value={values.entityId}
onChange={(e) => onChange({ ...values, entityId: e.target.value })}
placeholder="switch.ev_charger"
required
/>
</label>
<label>
Control type
<select
value={values.kind}
onChange={(e) =>
onChange({ ...values, kind: e.target.value as ControlKind })
}
>
<option value="switch">Switch (on/off)</option>
<option value="number">Number (set-point)</option>
</select>
</label>
<label>
Priority (lower = shed first)
<input
type="number"
value={values.priority}
onChange={(e) =>
onChange({ ...values, priority: Number(e.target.value) })
}
/>
</label>
{values.kind === "number" && (
<>
<label>
Min value (shed target)
<input
type="number"
value={values.minValue}
onChange={(e) =>
onChange({ ...values, minValue: Number(e.target.value) })
}
/>
</label>
<label>
Max value (normal)
<input
type="number"
value={values.maxValue}
onChange={(e) =>
onChange({ ...values, maxValue: Number(e.target.value) })
}
/>
</label>
</>
)}
<label>
Min dwell time (seconds)
<input
type="number"
value={values.dwellSeconds}
onChange={(e) =>
onChange({ ...values, dwellSeconds: Number(e.target.value) })
}
/>
</label>
<label>
Only enable if sun
<input
type="checkbox"
checked={values.onlyWhenSun}
onChange={(e) =>
onChange({ ...values, onlyWhenSun: e.target.checked })
}
/>
</label>
<label>
From (allowed window start)
<input
type="time"
value={values.fromTime ?? ""}
onChange={(e) =>
onChange({ ...values, fromTime: e.target.value || null })
}
/>
</label>
<label>
To (allowed window end)
<input
type="time"
value={values.toTime ?? ""}
onChange={(e) =>
onChange({ ...values, toTime: e.target.value || null })
}
/>
</label>
<label>
Custom enable endpoint (optional)
<input
type="url"
value={values.enableUrl ?? ""}
onChange={(e) =>
onChange({ ...values, enableUrl: e.target.value || null })
}
placeholder="https://example.local/device/on"
/>
</label>
<label>
Custom disable endpoint (optional)
<input
type="url"
value={values.disableUrl ?? ""}
onChange={(e) =>
onChange({ ...values, disableUrl: e.target.value || null })
}
placeholder="https://example.local/device/off"
/>
</label>
</div>
);
}
export function Devices() {
const [devices, setDevices] = useState<DeviceConfig[]>([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [editForm, setEditForm] = useState<DeviceFormValues>(emptyForm);
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function refresh() {
setDevices(await api.getDevices());
@@ -53,138 +197,40 @@ export function Devices() {
await refresh();
}
function startEdit(d: DeviceConfig) {
const { id: _id, ...rest } = d;
setEditForm(rest);
setEditingId(d.id);
setEditError(null);
}
function cancelEdit() {
setEditingId(null);
setEditError(null);
}
async function saveEdit(e: FormEvent) {
e.preventDefault();
if (editingId == null) return;
setSaving(true);
try {
await api.updateDevice(editingId, editForm);
await refresh();
setEditingId(null);
setEditError(null);
} catch (err) {
setEditError(err instanceof Error ? err.message : "Failed to save device");
} finally {
setSaving(false);
}
}
return (
<>
<div className="card">
<h2>Add device</h2>
<form onSubmit={handleCreate}>
<div className="form-grid">
<label>
Name
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
</label>
<label>
Home Assistant entity ID
<input
value={form.entityId}
onChange={(e) => setForm({ ...form, entityId: e.target.value })}
placeholder="switch.ev_charger"
required
/>
</label>
<label>
Control type
<select
value={form.kind}
onChange={(e) =>
setForm({ ...form, kind: e.target.value as ControlKind })
}
>
<option value="switch">Switch (on/off)</option>
<option value="number">Number (set-point)</option>
</select>
</label>
<label>
Priority (lower = shed first)
<input
type="number"
value={form.priority}
onChange={(e) =>
setForm({ ...form, priority: Number(e.target.value) })
}
/>
</label>
{form.kind === "number" && (
<>
<label>
Min value (shed target)
<input
type="number"
value={form.minValue}
onChange={(e) =>
setForm({ ...form, minValue: Number(e.target.value) })
}
/>
</label>
<label>
Max value (normal)
<input
type="number"
value={form.maxValue}
onChange={(e) =>
setForm({ ...form, maxValue: Number(e.target.value) })
}
/>
</label>
</>
)}
<label>
Min dwell time (seconds)
<input
type="number"
value={form.dwellSeconds}
onChange={(e) =>
setForm({ ...form, dwellSeconds: Number(e.target.value) })
}
/>
</label>
<label>
Only enable if sun
<input
type="checkbox"
checked={form.onlyWhenSun}
onChange={(e) =>
setForm({ ...form, onlyWhenSun: e.target.checked })
}
/>
</label>
<label>
From (allowed window start)
<input
type="time"
value={form.fromTime ?? ""}
onChange={(e) =>
setForm({ ...form, fromTime: e.target.value || null })
}
/>
</label>
<label>
To (allowed window end)
<input
type="time"
value={form.toTime ?? ""}
onChange={(e) =>
setForm({ ...form, toTime: e.target.value || null })
}
/>
</label>
<label>
Custom enable endpoint (optional)
<input
type="url"
value={form.enableUrl ?? ""}
onChange={(e) =>
setForm({ ...form, enableUrl: e.target.value || null })
}
placeholder="https://example.local/device/on"
/>
</label>
<label>
Custom disable endpoint (optional)
<input
type="url"
value={form.disableUrl ?? ""}
onChange={(e) =>
setForm({ ...form, disableUrl: e.target.value || null })
}
placeholder="https://example.local/device/off"
/>
</label>
</div>
<DeviceFields values={form} onChange={setForm} />
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
Set both custom endpoints to control this device over plain HTTP
(POST) instead of Home Assistant. The entity ID above is still used
@@ -211,35 +257,66 @@ export function Devices() {
devices
.slice()
.sort((a, b) => a.priority - b.priority)
.map((d) => (
<div className="device-row" key={d.id}>
<div>
<div className="device-name">{d.name}</div>
<div className="device-meta">
{d.entityId} · {d.kind} · priority {d.priority} · dwell{" "}
{d.dwellSeconds}s{d.onlyWhenSun && " · sun only"}
{d.fromTime && d.toTime && ` · ${d.fromTime}${d.toTime}`}
{d.enableUrl && d.disableUrl && " · custom endpoints"}
<div>{d.enableUrl}</div>
<div>{d.disableUrl}</div>
.map((d) =>
editingId === d.id ? (
<form
className="device-row device-row-editing"
key={d.id}
onSubmit={saveEdit}
>
<DeviceFields values={editForm} onChange={setEditForm} />
{editError && (
<p style={{ color: "var(--status-critical)", fontSize: 13 }}>
{editError}
</p>
)}
<div style={{ display: "flex", gap: 8 }}>
<button className="primary" type="submit" disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
<button
className="secondary"
type="button"
onClick={cancelEdit}
disabled={saving}
>
Cancel
</button>
</div>
</form>
) : (
<div className="device-row" key={d.id}>
<div>
<div className="device-name">{d.name}</div>
<div className="device-meta">
{d.entityId} · {d.kind} · priority {d.priority} · dwell{" "}
{d.dwellSeconds}s{d.onlyWhenSun && " · sun only"}
{d.fromTime && d.toTime && ` · ${d.fromTime}${d.toTime}`}
{d.enableUrl && d.disableUrl && " · custom endpoints"}
<div>{d.enableUrl}</div>
<div>{d.disableUrl}</div>
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{d.manualOverride && (
<span className="badge override">Manual</span>
)}
<button
className="secondary"
onClick={() => toggleOverride(d)}
>
{d.manualOverride ? "Resume auto" : "Manual override"}
</button>
<button className="secondary" onClick={() => startEdit(d)}>
Edit
</button>
<button className="secondary" onClick={() => remove(d)}>
Remove
</button>
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{d.manualOverride && (
<span className="badge override">Manual</span>
)}
<button
className="secondary"
onClick={() => toggleOverride(d)}
>
{d.manualOverride ? "Resume auto" : "Manual override"}
</button>
<button className="secondary" onClick={() => remove(d)}>
Remove
</button>
</div>
</div>
))
),
)
)}
</div>
</>

View File

@@ -34,6 +34,11 @@ export interface QuarterHourStatus {
overTarget: boolean;
}
export interface CurrentReadings {
powerW: number | null;
solarW: number | null;
}
export interface ControlAction {
timestampMs: number;
entityId: string;
@@ -56,7 +61,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const api = {
getStatus: () => request<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }>("/api/status"),
getStatus: () =>
request<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }>(
"/api/status",
),
getHistory: (sinceMs: number) => request<BlockHistoryPoint[]>(`/api/history?sinceMs=${sinceMs}`),
getActions: (limit = 50) => request<ControlAction[]>(`/api/actions?limit=${limit}`),
getDevices: () => request<DeviceConfig[]>("/api/devices"),

View File

@@ -124,6 +124,31 @@ nav.tabs button.active {
margin-top: 4px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.stat-tile {
background: var(--surface-2, transparent);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
line-height: 1.1;
}
.status-pill {
display: inline-flex;
align-items: center;
@@ -162,6 +187,11 @@ nav.tabs button.active {
font-size: 14px;
}
.device-row-editing {
display: block;
padding: 16px 0;
}
.device-meta {
font-size: 12px;
color: var(--text-muted);