A task holds the whole night, not one end of it

One command per task was the wrong unit. A charging window is two commands
and reads as one intention, so it was two rows that had to be named twice,
switched off twice, and kept in step by hand — and there was nowhere to put
the third thing, the ease down to 10 A once the house is asleep.

So a task holds a flow. Steps are rows in the editor: an action, a time, and
the ceiling under the one action that takes one. The chargers and the days
belong to the task, because they are the same for every step of a night, and
the switch governs all of it.

The steps keep the order they were written rather than being sorted by the
clock. A night crosses midnight, and clock order files "start at 23:00" last,
behind the stop that closes it — which is not the flow anybody described.
Nothing about firing depends on the order: every step is timed on its own,
and the sweep asks each one whether its minute has come.

Run now moved onto the step. A flow is not a thing that can happen at once —
firing a start and the stop that closes it back to back would leave the
charger where it began and prove nothing — so the button fires the one line
it sits on, and the outcome names the step by its time.

The stored shape changes with it: action/amps/time give way to a steps list.
The collection was a day old and empty, so this replaces them outright rather
than carrying a compatibility path for a schema nothing has run on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 23:32:07 +02:00
co-authored by Claude Opus 5
parent 0f48093d1a
commit 2b4f4f034d
13 changed files with 553 additions and 357 deletions
+8 -4
View File
@@ -319,8 +319,9 @@ export const api = {
// The home-charger scheduler: one list of charging tasks per user, covering
// every charger they own. The server holds the clock — a schedule that only
// fires while this page is open would be a reminder, not a schedule — so the
// page only writes tasks and reads back how each one last went. runChargingTask
// fires one now, whatever its time and whether or not it is switched on.
// page only writes tasks and reads back how each one last went.
// runChargingStep fires one step now, whatever its time and whether or not
// the task it belongs to is switched on.
listChargingTasks: () => request("/charging-tasks").then((r) => r.tasks),
createChargingTask: (body) =>
request("/charging-tasks", { method: "POST", body: JSON.stringify(body) }).then((r) => r.task),
@@ -330,8 +331,11 @@ export const api = {
body: JSON.stringify(body),
}).then((r) => r.task),
deleteChargingTask: (id) => request(`/charging-tasks/${encodeURIComponent(id)}`, { method: "DELETE" }),
runChargingTask: (id) =>
request(`/charging-tasks/${encodeURIComponent(id)}/run`, { method: "POST" }),
// One step of a flow, fired now: running a start and the stop that closes it
// back to back would leave the charger where it began and prove nothing.
runChargingStep: (id, step) =>
request(`/charging-tasks/${encodeURIComponent(id)}/steps/${encodeURIComponent(step)}/run`,
{ method: "POST" }),
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
// returns the resolved view (effective/own/locked per field, secrets and
@@ -9,10 +9,10 @@
// of them" is a standing wish rather than the list that happened to exist that
// day.
//
// One action per task. A charging window is the two tasks that open and close
// it, which is also how it is read back, edited and switched off; folding both
// ends into one row would have made the common case shorter and every other case
// impossible.
// A task holds a flow rather than a single command: start at 23:00, ease down to
// 10 A at 01:00, stop at 06:30. That is one intention, so it is one named thing
// with one switch — splitting a charging window across two tasks meant naming it
// twice and remembering to switch off both ends.
import { ref, computed, watch } from "vue";
import { api } from "../api";
import { t } from "../i18n";
@@ -31,9 +31,26 @@ const emit = defineEmits(["saved", "close"]);
const editing = computed(() => !!props.task?.id);
const name = ref(props.task?.name || "");
const action = ref(props.task?.action || "start");
const at = ref(props.task?.time || "23:00");
const amps = ref(props.task?.amps || 16);
// The flow, as rows the form edits in place. A new task opens with the one step
// most schedules start from, so the common case is a name and a time rather than
// a decision about how many rows to add.
const steps = ref(
(props.task?.steps || []).length
? props.task.steps.map((s) => ({ action: s.action, time: s.time, amps: s.amps || 16 }))
: [{ action: "start", time: "23:00", amps: 16 }]
);
// A flow of one is a flow, so the last row cannot be removed — an empty task
// would have nothing to fire and the server refuses it anyway.
function addStep() {
steps.value = [...steps.value, { action: "stop", time: "06:30", amps: 16 }];
}
function removeStep(i) {
if (steps.value.length <= 1) return;
steps.value = steps.value.filter((_, n) => n !== i);
}
// The chargers this task acts on. Empty is meaningful — it means all of them —
// so the picker has a switch of its own rather than leaving an empty list
// looking like an unfinished form.
@@ -90,8 +107,12 @@ watch(allChargers, (on) => {
});
// A time still being typed is not a time — TimeField says so with an empty
// value, and a task cannot be saved without one.
const canSave = computed(() => !!name.value.trim() && !!at.value && !saving.value);
// value and one unfinished row is enough to make the whole flow unsaveable,
// because the server would otherwise refuse it with a step number the form does
// not show.
const canSave = computed(
() => !!name.value.trim() && steps.value.every((s) => !!s.time) && !saving.value
);
function chargerSubtitle(c) {
return [c.serial, c.model].filter(Boolean).join(" · ");
@@ -103,9 +124,14 @@ async function submit() {
error.value = "";
const body = {
name: name.value.trim(),
action: action.value,
time: at.value,
amps: Number(amps.value) || 0,
// The amps ride along on every step so switching one to "limit" and back
// does not lose the number that was typed; the server keeps them for the
// same reason and ignores them on the actions that have no ceiling.
steps: steps.value.map((s) => ({
action: s.action,
time: s.time,
amps: s.action === "limit" ? Number(s.amps) || 0 : 0,
})),
chargers: allChargers.value ? [] : [...picked.value],
days: everyDay.value ? [] : [...days.value],
// The time is a wall clock, and the server's is not the one it was set by.
@@ -147,29 +173,46 @@ function browserZone() {
<input v-model="name" required class="dh-input" :placeholder="t('forms.chargingTask.namePlaceholder')" />
</div>
<!-- What and when. Side by side because they are read as one sentence:
"start, at 23:00". -->
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="dh-label">{{ t("forms.chargingTask.action") }}</label>
<select v-model="action" class="dh-input">
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
</select>
</div>
<div>
<label class="dh-label">{{ t("forms.chargingTask.time") }}</label>
<TimeField v-model="at" :aria-label="t('forms.chargingTask.time')" />
</div>
</div>
<!-- The flow. One row per step, each an action and the time it fires
read down, they are the night: start at 23:00, ease off at 01:00, stop
at 06:30. -->
<div>
<label class="dh-label">{{ t("forms.chargingTask.flow") }}</label>
<!-- The ceiling, for the one action that takes one. -->
<div v-if="action === 'limit'">
<label class="dh-label">
{{ t("forms.chargingTask.amps") }}
<span class="data float-right font-semibold text-strong">{{ amps }} A</span>
</label>
<input v-model.number="amps" type="range" min="6" max="32" step="1" class="w-full accent-brand-600" />
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.ampsHint") }}</p>
<div v-for="(s, i) in steps" :key="i" class="mb-2 rounded-control bg-sunken p-3">
<div class="flex items-start gap-2">
<select v-model="s.action" class="dh-input min-w-0 flex-1">
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
</select>
<TimeField v-model="s.time" :aria-label="t('forms.chargingTask.time')" />
<!-- The last step cannot go: a task with no steps has nothing to
fire, so the control is absent rather than there and refusing. -->
<button
v-if="steps.length > 1"
type="button"
class="shrink-0 rounded-control px-2 py-2 text-xs font-medium text-muted transition-colors hover:text-danger"
:title="t('forms.chargingTask.removeStep')"
@click="removeStep(i)"
>
&times;
</button>
</div>
<!-- The ceiling, under the one action that takes one. -->
<div v-if="s.action === 'limit'" class="mt-3">
<label class="dh-label">
{{ t("forms.chargingTask.amps") }}
<span class="data float-right font-semibold text-strong">{{ s.amps }} A</span>
</label>
<input v-model.number="s.amps" type="range" min="6" max="32" step="1" class="w-full accent-brand-600" />
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.ampsHint") }}</p>
</div>
</div>
<button type="button" class="dh-btn dh-btn-ghost w-full !py-2 text-xs" @click="addStep">
{{ t("forms.chargingTask.addStep") }}
</button>
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.flowHint") }}</p>
</div>
<!-- Which chargers. The point of one scheduler for all of them. -->
+6 -3
View File
@@ -389,9 +389,9 @@
},
"scheduler": {
"title": "Ladeopgaver",
"subtitle": "Én plan for alle dine ladere. Hver opgave sender én kommando på ét tidspunkt, på de dage du vælger, til de ladere du vælger.",
"subtitle": "Én plan for alle dine ladere. En opgave er et forløb — start, grænse, stop — der kører på de dage du vælger, de ladere du vælger.",
"add": "Ny opgave",
"empty": "Ingen opgaver endnu. En opgave er én kommando på ét tidspunkt — start kl. 23:00 på hverdage, begræns til 10 A når taksten skifter.",
"empty": "Ingen opgaver endnu. En opgave er et helt forløb: start kl. 23:00, begræns til 10 A kl. 01:00, stop kl. 06:30.",
"needCharger": "Importér først en lader under Hjemmeladere — en opgave skal have noget at handle på.",
"serverHint": "Opgaverne kører på serveren, så de udføres uanset om denne side er åben. Tidspunkter læses i den tidszone, du skrev dem i.",
"allChargers": "Alle ladere",
@@ -1278,8 +1278,11 @@
"editTitle": "Rediger ladeopgave",
"name": "Navn",
"namePlaceholder": "Nattakst",
"action": "Gør dette",
"time": "Kl.",
"flow": "Forløbet",
"flowHint": "Hvert trin udføres på sit eget tidspunkt, hver dag opgaven kører. En hel nat er én opgave: start kl. 23:00, stop kl. 06:30.",
"addStep": "+ Tilføj et trin",
"removeStep": "Fjern dette trin",
"amps": "Strømgrænse",
"ampsHint": "6 A er bundgrænsen — derunder sætter laderen på pause i stedet for at lade langsomt.",
"chargers": "På disse ladere",
+6 -3
View File
@@ -388,9 +388,9 @@
},
"scheduler": {
"title": "Charging tasks",
"subtitle": "One schedule for every charger you own. Each task sends one command at one time, on the days you pick, to the chargers you pick.",
"subtitle": "One schedule for every charger you own. A task is a flow — start, limit, stop — running on the days you pick, on the chargers you pick.",
"add": "New task",
"empty": "No tasks yet. A task is one command at one time — start at 23:00 on weeknights, cap at 10 A when the tariff changes.",
"empty": "No tasks yet. A task is a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30.",
"needCharger": "Import a charger under Home chargers first — a task needs something to act on.",
"serverHint": "Tasks run on the server, so they fire whether or not this page is open. Times are read in the time zone you wrote them in.",
"allChargers": "All chargers",
@@ -1277,8 +1277,11 @@
"editTitle": "Edit charging task",
"name": "Name",
"namePlaceholder": "Night rate",
"action": "Do this",
"time": "At",
"flow": "The flow",
"flowHint": "Each step fires at its own time, every day the task runs. A whole night is one task: start at 23:00, stop at 06:30.",
"addStep": "+ Add a step",
"removeStep": "Remove this step",
"amps": "Current limit",
"ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.",
"chargers": "On these chargers",
+6 -3
View File
@@ -391,9 +391,9 @@
},
"scheduler": {
"title": "Zadania ładowania",
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Każde zadanie wysyła jedno polecenie o jednej godzinie, w wybrane dni, do wybranych ładowarek.",
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Zadanie to przebieg — start, limit, stop — wykonywany w wybrane dni, na wybranych ładowarkach.",
"add": "Nowe zadanie",
"empty": "Brak zadań. Zadanie to jedno polecenie o jednej godzinie — start o 23:00 w dni robocze, ograniczenie do 10 A po zmianie taryfy.",
"empty": "Brak zadań. Zadanie to cały przebieg: start o 23:00, ograniczenie do 10 A o 01:00, stop o 06:30.",
"needCharger": "Najpierw zaimportuj ładowarkę w zakładce Ładowarki domowe — zadanie musi mieć na czym działać.",
"serverHint": "Zadania działają na serwerze, więc uruchamiają się niezależnie od tego, czy ta strona jest otwarta. Godziny są odczytywane w strefie czasowej, w której je zapisano.",
"allChargers": "Wszystkie ładowarki",
@@ -1294,8 +1294,11 @@
"editTitle": "Edytuj zadanie ładowania",
"name": "Nazwa",
"namePlaceholder": "Taryfa nocna",
"action": "Zrób to",
"time": "O godzinie",
"flow": "Przebieg",
"flowHint": "Każdy krok uruchamia się o własnej godzinie, w każdy dzień działania zadania. Cała noc to jedno zadanie: start o 23:00, stop o 06:30.",
"addStep": "+ Dodaj krok",
"removeStep": "Usuń ten krok",
"amps": "Limit prądu",
"ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
"chargers": "Na tych ładowarkach",
+70 -43
View File
@@ -1943,7 +1943,7 @@ const tasksError = ref("");
const tasksLoaded = ref(false);
const showTaskForm = ref(false);
const editingTask = ref(null); // the task being edited, or null for a new one
const runningTask = ref(""); // the task whose "Run now" is in flight
const runningTask = ref(""); // "<task id>:<step index>" of the step being fired
const togglingTask = ref("");
async function loadChargingTasks() {
@@ -1974,7 +1974,13 @@ function onTaskSaved(task) {
editingTask.value = null;
const i = tasks.value.findIndex((x) => x.id === task.id);
if (i >= 0) tasks.value.splice(i, 1, task);
else tasks.value = [...tasks.value, task].sort((a, b) => a.time.localeCompare(b.time));
else {
// Ordered by the time each task begins, which is what the server sends back
// and what the day runs them in.
tasks.value = [...tasks.value, task].sort(
(a, b) => (a.steps?.[0]?.time || "").localeCompare(b.steps?.[0]?.time || "")
);
}
}
// The switch in the row. Written straight through rather than optimistically:
@@ -1994,15 +2000,18 @@ async function toggleTask(task) {
}
}
// Fire a task now, without waiting for its time — the only way to find out
// whether it will actually reach the charger before the night it matters. The
// server takes the same path the clock takes, so what comes back is what will
// happen then, errors included.
async function runTask(task) {
// Fire one step now, without waiting for its time — the only way to find out
// whether it will actually reach the charger before the night it matters. One
// step rather than the whole flow: running a start and the stop that closes it
// back to back would leave the charger where it began and prove nothing.
//
// The server takes the same path the clock takes, so what comes back is what
// will happen then, errors included.
async function runStep(task, index) {
tasksError.value = "";
runningTask.value = task.id;
runningTask.value = `${task.id}:${index}`;
try {
const res = await api.runChargingTask(task.id);
const res = await api.runChargingStep(task.id, index);
// The row's own "last run" line is what reports this, so the answer is
// folded into the record rather than announced somewhere else.
const i = tasks.value.findIndex((x) => x.id === task.id);
@@ -2031,12 +2040,11 @@ async function removeTask(task) {
}
}
// What a task says it will do, in one line: the action, the chargers, the days.
// Built here rather than in the template because all three have an "everything"
// case that reads as a word rather than as a list.
function taskActionLabel(task) {
const label = t(`charging.scheduler.actions.${task.action}`);
return task.action === "limit" ? `${label} · ${task.amps} A` : label;
// What one step of a flow does. The ceiling is part of the sentence for the one
// action that has one — "Set current limit" alone does not say to what.
function stepActionLabel(step) {
const label = t(`charging.scheduler.actions.${step.action}`);
return step.action === "limit" ? `${label} · ${step.amps} A` : label;
}
function taskChargersLabel(task) {
@@ -2059,14 +2067,14 @@ function taskDaysLabel(task) {
return sortWeekdays(days).map(weekdayShortName).join(" ");
}
// The task's time, on the clock the user chose. It is stored as 24-hour "HH:MM"
// — the schedule is a wall clock, not a moment, so there is no date to hand
// A step's time, on the clock the user chose. It is stored as 24-hour "HH:MM"
// — a schedule is a wall clock, not a moment, so there is no date to hand
// formatTime — and this is the same reading TimeField offers when editing it.
function taskClock(at) {
const parts = /^(\d{1,2}):(\d{2})$/.exec(at?.time || "");
if (!parts) return at?.time || "";
function stepClock(step) {
const parts = /^(\d{1,2}):(\d{2})$/.exec(step?.time || "");
if (!parts) return step?.time || "";
const h = Number(parts[1]);
if (!clockIsTwelveHour()) return at.time;
if (!clockIsTwelveHour()) return step.time;
return `${String(h % 12 || 12).padStart(2, "0")}:${parts[2]} ${h < 12 ? "am" : "pm"}`;
}
@@ -2083,9 +2091,10 @@ function tasksFor(charger) {
// it has fired once — a task written this afternoon has nothing to report.
function taskRunTone(task) {
if (!task.lastRun) return "var(--text-muted)";
// The server words the outcome as "n of m sent", plus the reasons when the
// two numbers differ. Every charger answering is the only green case.
const m = /^(\d+) of (\d+) sent$/.exec(task.lastResult || "");
// The server words the outcome as the step it fired and then "n of m sent",
// plus the reasons when the two numbers differ. Every charger answering is
// the only green case.
const m = /(\d+) of (\d+) sent$/.exec(task.lastResult || "");
return m && m[1] === m[2] ? TONE.good.fg : TONE.due.fg;
}
@@ -3461,21 +3470,15 @@ onUnmounted(() => {
>
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="data text-lg font-medium tracking-[-0.02em] text-strong">{{ taskClock(task) }}</span>
<span class="truncate text-sm font-semibold text-strong">{{ task.name }}</span>
</div>
<p class="mt-1 text-xs text-muted">
{{ taskActionLabel(task) }} · {{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}
</p>
<p v-if="task.lastRun" class="mt-1 text-[11px]" :style="{ color: taskRunTone(task) }">
{{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }}
{{ task.lastResult || t("charging.scheduler.noResult") }}
<p class="truncate text-sm font-semibold text-strong">{{ task.name }}</p>
<p class="mt-0.5 text-xs text-muted">
{{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}
</p>
</div>
<!-- The switch. Written straight through, so what it shows is
what the server will act on. -->
what the server will act on. It governs the whole flow:
the task is one intention and is switched off as one. -->
<button
type="button"
class="shrink-0 rounded-pill px-3 py-1 text-xs font-semibold transition-colors disabled:opacity-50"
@@ -3488,15 +3491,39 @@ onUnmounted(() => {
</button>
</div>
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
<button
type="button"
class="text-xs font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
:disabled="runningTask === task.id"
@click="runTask(task)"
<!-- The flow, a line per step. Read down, they are the night
which is the whole reason a task holds more than one. -->
<div class="mt-2 flex flex-col gap-1">
<div
v-for="(s, i) in task.steps"
:key="i"
class="flex items-baseline gap-2 rounded-control px-2 py-1 transition-colors hover:bg-card"
>
{{ runningTask === task.id ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
</button>
<span class="data w-16 shrink-0 text-sm font-medium text-strong">{{ stepClock(s) }}</span>
<span class="min-w-0 flex-1 truncate text-xs text-body">{{ stepActionLabel(s) }}</span>
<!-- Per step, because a flow is not a thing that can happen at
once: firing a start and the stop that closes it back to
back would leave the charger where it began. -->
<button
type="button"
class="shrink-0 text-[11px] font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
:disabled="runningTask === `${task.id}:${i}`"
@click="runStep(task, i)"
>
{{ runningTask === `${task.id}:${i}` ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
</button>
</div>
</div>
<p v-if="task.lastRun" class="mt-2 text-[11px]" :style="{ color: taskRunTone(task) }">
<!-- A middle dot rather than a dash: the outcome opens with the
step's own time and a dash of its own, and three dashes in a
row read as one long smudge. -->
{{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }} ·
{{ task.lastResult || t("charging.scheduler.noResult") }}
</p>
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
<button
type="button"
class="text-xs font-medium text-muted transition-colors hover:text-body"