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:
co-authored by
Claude Opus 5
parent
0f48093d1a
commit
2b4f4f034d
@@ -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)"
|
||||
>
|
||||
×
|
||||
</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. -->
|
||||
|
||||
@@ -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, på 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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user