One schedule for every charger, and a clock on the server to keep it

The charger's own cloud schedule is one window inside one box: charge
between these hours, every day, and that is the whole vocabulary. A third
tab on Charging holds a list instead — each line an action, a time, the
days it repeats on and the chargers it acts on — and one list covers the
whole account rather than each charger hiding its own.

The clock is the server's. A schedule that only fires while a tab is open
is a reminder, so a ticker sweeps every enabled task and fires whichever
minute has come. It sends by handing a synthesised request to the same
control endpoint the page's buttons use, so a scheduled command goes
through the same cascade, ownership gate, rate limit and audit trail —
what the owner cannot press by hand, the scheduler cannot send for them.

A task names its chargers, or names none, which means all of them and
keeps meaning that for a charger imported next year. Times are stored as
a wall clock plus the zone they were written in, so 23:00 stays 23:00
wherever the server sits. One action per task: a charging window is the
two tasks that open and close it, which is how it is read back, edited
and switched off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 22:47:30 +02:00
co-authored by Claude Opus 5
parent 8e4c22cfcf
commit 5a4515978f
15 changed files with 1856 additions and 7 deletions
+33 -1
View File
@@ -462,6 +462,30 @@ const DESIRED = {
// defined through the API, so it is declared here like the audit trail's.
F.autodate("created", true, false),
],
// The home-charger scheduler: a user's own list of charging tasks, one list
// covering every charger they own. The charger's own cloud schedule holds one
// window per box; this holds as many tasks as they like, each naming its own
// chargers, days and action. Run by the ticker in the API Server
// (internal/api/chargingtasks_run.go).
charging_tasks: [
F.text("name", true),
// The home_chargers rows this task acts on. A list of ids rather than a
// relation because empty has to mean "every charger I own" — a standing wish
// that keeps covering chargers imported later.
F.json("chargers", 2000),
F.select("action", ["start", "stop", "limit", "boost"], true),
F.number("amps"), // the ceiling, for the "limit" action
// 24-hour "HH:MM", read in the IANA zone the task was written in.
F.text("time", true),
F.text("zone"),
F.json("days", 200), // 0=Sunday … 6=Saturday; empty means every day
F.bool("enabled"),
F.text("last_run"), // RFC3339, UTC — also the guard against a double firing
F.text("last_result"),
// Owner. Non-cascading, like a charger's.
F.relation("owner", "users", false, false),
F.autodate("created", true, false),
],
// Server-wide settings as a single record, keyed "global". Today it holds
// pluginSettings: the top (L1) layer of the integration cascade — every
// plugin's enable state, its global config, and the registration of any
@@ -549,6 +573,11 @@ const INDEXES = {
// A charger is looked up by its owner, and by serial when checking whether the
// account it came from has already been imported.
home_chargers: ["CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"],
// The runner sweeps every enabled task on every tick, and the page reads one
// owner's; both go through these two columns.
charging_tasks: [
"CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)",
],
// Audit is queried "this charger's events, newest first" and "this user's events".
control_audit: [
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
@@ -586,6 +615,7 @@ async function main() {
"reminders",
"control_audit",
"home_chargers",
"charging_tasks",
]) {
if (collections.some((c) => c.name === name)) continue;
await createCollection(token, name, DESIRED[name], format, idByName);
@@ -614,6 +644,7 @@ async function main() {
"reminders",
"control_audit",
"home_chargers",
"charging_tasks",
]) {
await reconcileFields(token, name, DESIRED[name], format, idByName);
}
@@ -622,7 +653,8 @@ async function main() {
"\nDone. Collections ready: app_settings, organizations, users, cars,\n" +
"service_records,\n" +
"technical_checks, parts, car_shares, fuel_entries, charging_sessions,\n" +
"maintenance_entries, car_documents, reminders, control_audit, home_chargers.",
"maintenance_entries, car_documents, reminders, control_audit, home_chargers,\n" +
"charging_tasks.",
);
console.log(
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +