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:
tajniak81andClaude Opus 5 committed 2026-09-03 22:47:30 +02:00
1 parent 8e4c22cfcf
commit 5a4515978f
15 files changed
+1856 -7

No files matched your search

+17
View File
@@ -316,6 +316,23 @@ export const api = {
}),
deleteHomeCharger: (id) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "DELETE" }),
// 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.
listChargingTasks: () => request("/charging-tasks").then((r) => r.tasks),
createChargingTask: (body) =>
request("/charging-tasks", { method: "POST", body: JSON.stringify(body) }).then((r) => r.task),
updateChargingTask: (id, body) =>
request(`/charging-tasks/${encodeURIComponent(id)}`, {
method: "PATCH",
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" }),
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
// returns the resolved view (effective/own/locked per field, secrets and
// inherited emails masked); saveAnkerSolix writes the caller's editable layer;
@@ -0,0 +1,234 @@
<script setup>
// One line of the home-charger scheduler, being written or edited.
//
// The charger's own cloud schedule asks four questions and asks them inside one
// charger: on/off, mode, from, to. This asks five, and the fifth is the one that
// makes it a scheduler rather than a second copy of that: *which* chargers. A
// task can name one, several, or none at all — and none means every charger on
// the account, including ones imported after the task was written, because "all
// 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.
import { ref, computed, watch } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import Modal from "./Modal.vue";
import TimeField from "./TimeField.vue";
const props = defineProps({
// The task being edited, or null to write a new one.
task: { type: Object, default: null },
// The chargers this account has, as the Charging page already loaded them.
chargers: { type: Array, default: () => [] },
});
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 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.
const allChargers = ref(!props.task || (props.task.chargers || []).length === 0);
const picked = ref([...(props.task?.chargers || [])]);
const days = ref([...(props.task?.days || [])]);
const everyDay = ref(!props.task || (props.task.days || []).length === 0);
const saving = ref(false);
const error = ref("");
// What the charger can actually be asked to do. Boost and the current limit only
// reach it over the Anker cloud connection; start and stop reach it over all
// three transports. The control mode is a Settings choice, not this form's
// business, so all four are offered and the one that cannot be sent says so when
// it fires — same as the buttons on the page behind this.
const ACTIONS = ["start", "stop", "limit", "boost"];
// Sunday first, as Intl numbers the weekdays — the names come from the user's
// own locale, so the row reads Pn Wt Śr… in Polish without a table here.
const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
function weekdayLabel(day) {
// 2024-01-07 was a Sunday, so this offset lands each index on its own day.
const date = new Date(Date.UTC(2024, 0, 7 + day));
try {
return new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" }).format(date);
} catch {
return String(day);
}
}
function toggleDay(day) {
everyDay.value = false;
days.value = days.value.includes(day)
? days.value.filter((d) => d !== day)
: [...days.value, day];
}
function toggleCharger(id) {
allChargers.value = false;
picked.value = picked.value.includes(id)
? picked.value.filter((x) => x !== id)
: [...picked.value, id];
}
// Unticking every day (or every charger) by hand is the same wish as the "all"
// switch, so it lands there rather than leaving a task that acts on nothing.
watch(days, (list) => {
if (list.length === 0) everyDay.value = true;
});
watch(picked, (list) => {
if (list.length === 0) allChargers.value = true;
});
watch(everyDay, (on) => {
if (on) days.value = [];
});
watch(allChargers, (on) => {
if (on) picked.value = [];
});
// 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);
function chargerSubtitle(c) {
return [c.serial, c.model].filter(Boolean).join(" · ");
}
async function submit() {
if (!canSave.value) return;
saving.value = true;
error.value = "";
const body = {
name: name.value.trim(),
action: action.value,
time: at.value,
amps: Number(amps.value) || 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.
// Sending the zone the browser is in is what keeps 23:00 at 23:00 for a
// server sitting in another country.
zone: browserZone(),
};
try {
const task = editing.value
? await api.updateChargingTask(props.task.id, body)
: await api.createChargingTask(body);
emit("saved", task);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
function browserZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
} catch {
return "";
}
}
</script>
<template>
<Modal
:title="editing ? t('forms.chargingTask.editTitle') : t('forms.chargingTask.title')"
@close="emit('close')"
>
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="dh-label">{{ t("forms.chargingTask.name") }}</label>
<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 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>
<!-- Which chargers. The point of one scheduler for all of them. -->
<div>
<label class="dh-label">{{ t("forms.chargingTask.chargers") }}</label>
<label class="flex cursor-pointer items-center gap-2 rounded-control p-2 hover:bg-sunken">
<input v-model="allChargers" type="checkbox" />
<span class="text-sm font-medium text-strong">{{ t("forms.chargingTask.allChargers") }}</span>
</label>
<p v-if="allChargers" class="px-2 pb-1 text-xs text-muted">{{ t("forms.chargingTask.allChargersHint") }}</p>
<p v-if="chargers.length === 0" class="px-2 text-xs text-muted">{{ t("forms.chargingTask.noChargers") }}</p>
<label
v-for="c in chargers"
:key="c.id"
class="flex cursor-pointer items-center gap-2 rounded-control p-2 transition-colors hover:bg-sunken"
>
<input type="checkbox" :checked="!allChargers && picked.includes(c.id)" @change="toggleCharger(c.id)" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-strong">{{ c.name }}</span>
<span v-if="chargerSubtitle(c)" class="data block truncate text-[11px] text-muted">{{ chargerSubtitle(c) }}</span>
</span>
</label>
</div>
<!-- Which days. -->
<div>
<label class="dh-label">{{ t("forms.chargingTask.days") }}</label>
<label class="flex cursor-pointer items-center gap-2 rounded-control p-2 hover:bg-sunken">
<input v-model="everyDay" type="checkbox" />
<span class="text-sm font-medium text-strong">{{ t("forms.chargingTask.everyDay") }}</span>
</label>
<div class="mt-1 flex flex-wrap gap-1.5">
<button
v-for="d in WEEKDAYS"
:key="d"
type="button"
class="rounded-pill border px-3 py-1.5 text-xs font-semibold transition-colors"
:class="!everyDay && days.includes(d)
? 'border-accent bg-brand-100 text-strong'
: 'border-subtle text-muted hover:bg-sunken'"
@click="toggleDay(d)"
>
{{ weekdayLabel(d) }}
</button>
</div>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="!canSave" class="dh-btn dh-btn-primary">
{{ saving ? t("common.saving") : t("common.save") }}
</button>
</div>
</form>
</Modal>
</template>
+45 -1
View File
@@ -50,7 +50,8 @@
"tabs": {
"dragHint": "Træk en fane for at ændre rækkefølgen af opladningsfanerne.",
"public": "Offentlige ladere",
"home": "Hjemmeladere"
"home": "Hjemmeladere",
"scheduler": "Planlægning af hjemmelader"
},
"liveMap": "Live-kort",
"youAreHere": "Du er her",
@@ -385,6 +386,33 @@
"refresh": "Opdater",
"rawTitle": "Som tjenesten melder det",
"rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt."
},
"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.",
"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.",
"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",
"missingChargers": "Ingen lader på kontoen længere",
"everyDay": "Hver dag",
"runNow": "Kør nu",
"running": "Sender…",
"lastRun": "Sidst kørt {when}",
"noResult": "intet resultat registreret",
"toggleHint": "Om uret udløser denne opgave.",
"removeConfirm": "Slet opgaven “{name}”?",
"taskCount": {
"one": "{n} opgave",
"other": "{n} opgaver"
},
"actions": {
"start": "Start opladning",
"stop": "Stop opladning",
"limit": "Sæt strømgrænse",
"boost": "Boost sessionen"
}
}
},
@@ -1239,6 +1267,22 @@
"submit": "Del",
"peopleWithAccess": "Personer med adgang",
"notShared": "Endnu ikke delt med nogen."
},
"chargingTask": {
"title": "Ny ladeopgave",
"editTitle": "Rediger ladeopgave",
"name": "Navn",
"namePlaceholder": "Nattakst",
"action": "Gør dette",
"time": "Kl.",
"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",
"allChargers": "Alle mine ladere",
"allChargersHint": "Inklusive ladere du importerer senere.",
"noChargers": "Ingen ladere på kontoen endnu.",
"days": "På disse dage",
"everyDay": "Hver dag"
}
},
+45 -1
View File
@@ -50,7 +50,8 @@
"tabs": {
"dragHint": "Drag a tab to rearrange the charging tabs.",
"public": "Public chargers",
"home": "Home chargers"
"home": "Home chargers",
"scheduler": "Home charger scheduler"
},
"liveMap": "Live map",
"youAreHere": "You are here",
@@ -384,6 +385,33 @@
"full": "Full",
"offPeak": "off-peak",
"homeCharger": "Home charger"
},
"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.",
"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.",
"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",
"missingChargers": "No charger on your account any more",
"everyDay": "Every day",
"runNow": "Run now",
"running": "Sending…",
"lastRun": "Last run {when}",
"noResult": "no result recorded",
"toggleHint": "Whether the clock fires this task.",
"removeConfirm": "Delete the task “{name}”?",
"taskCount": {
"one": "{n} task",
"other": "{n} tasks"
},
"actions": {
"start": "Start charging",
"stop": "Stop charging",
"limit": "Set current limit",
"boost": "Boost the session"
}
}
},
@@ -1238,6 +1266,22 @@
"submit": "Share",
"peopleWithAccess": "People with access",
"notShared": "Not shared with anyone yet."
},
"chargingTask": {
"title": "New charging task",
"editTitle": "Edit charging task",
"name": "Name",
"namePlaceholder": "Night rate",
"action": "Do this",
"time": "At",
"amps": "Current limit",
"ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.",
"chargers": "On these chargers",
"allChargers": "All my chargers",
"allChargersHint": "Including any charger you import later.",
"noChargers": "No chargers on your account yet.",
"days": "On these days",
"everyDay": "Every day"
}
},
+47 -1
View File
@@ -50,7 +50,8 @@
"tabs": {
"dragHint": "Przeciągnij kartę, aby zmienić kolejność kart ładowania.",
"public": "Ładowarki publiczne",
"home": "Ładowarki domowe"
"home": "Ładowarki domowe",
"scheduler": "Harmonogram ładowarki"
},
"liveMap": "Mapa na żywo",
"youAreHere": "Tu jesteś",
@@ -387,6 +388,35 @@
"refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa",
"rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw."
},
"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.",
"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.",
"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",
"missingChargers": "Nie ma już takiej ładowarki na koncie",
"everyDay": "Codziennie",
"runNow": "Uruchom teraz",
"running": "Wysyłanie…",
"lastRun": "Ostatnio {when}",
"noResult": "brak zapisanego wyniku",
"toggleHint": "Czy zegar uruchamia to zadanie.",
"removeConfirm": "Usunąć zadanie „{name}”?",
"taskCount": {
"one": "{n} zadanie",
"few": "{n} zadania",
"many": "{n} zadań",
"other": "{n} zadania"
},
"actions": {
"start": "Rozpocznij ładowanie",
"stop": "Zatrzymaj ładowanie",
"limit": "Ustaw limit prądu",
"boost": "Przyspiesz sesję"
}
}
},
@@ -1253,6 +1283,22 @@
"submit": "Udostępnij",
"peopleWithAccess": "Osoby z dostępem",
"notShared": "Jeszcze nikomu nie udostępniono."
},
"chargingTask": {
"title": "Nowe zadanie ładowania",
"editTitle": "Edytuj zadanie ładowania",
"name": "Nazwa",
"namePlaceholder": "Taryfa nocna",
"action": "Zrób to",
"time": "O godzinie",
"amps": "Limit prądu",
"ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
"chargers": "Na tych ładowarkach",
"allChargers": "Wszystkie moje ładowarki",
"allChargersHint": "Łącznie z ładowarkami zaimportowanymi później.",
"noChargers": "Na koncie nie ma jeszcze ładowarek.",
"days": "W te dni",
"everyDay": "Codziennie"
}
},
+3 -1
View File
@@ -10,7 +10,9 @@
import { prefs } from "../prefs.js";
export const CHARGING_TABS = ["public", "home"];
// The scheduler comes last by default: it acts on the chargers the tab before
// it lists, so it reads as the thing you set up once the chargers are there.
export const CHARGING_TABS = ["public", "home", "scheduler"];
// A car's tabs in their default order. Information sits second because the
// connected service, when there is one, is what you came to look at.
+320 -2
View File
@@ -4,10 +4,11 @@ import { t } from "../i18n";
import { prefs } from "../prefs";
import { askConfirm } from "../lib/confirm.js";
import { api } from "../api";
import { formatDateTime } from "../lib/format.js";
import { formatDateTime, clockIsTwelveHour } from "../lib/format.js";
import TimeField from "../components/TimeField.vue";
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js";
import ChargerImportModal from "../components/ChargerImportModal.vue";
import ChargingTaskModal from "../components/ChargingTaskModal.vue";
// Charging & map screen, mirroring the web-dashboard UI kit. There is no live
// charging API yet (only the Anker Solix credential cascade in Settings), so the
@@ -1925,10 +1926,183 @@ function cancelReset() {
resetPassword.value = "";
}
// --- The scheduler: one list of charging tasks, covering every charger --------
//
// The charger's own cloud schedule is one window inside one box: charge between
// these hours, every day, and that is the whole vocabulary. This is a list —
// 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, not this page's. A schedule that only fires while a
// tab is open would be a reminder; the page writes tasks and reads back how each
// one last went.
const tasks = ref([]);
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 togglingTask = ref("");
async function loadChargingTasks() {
tasksError.value = "";
try {
tasks.value = await api.listChargingTasks();
tasksLoaded.value = true;
} catch (e) {
tasksError.value = e.message;
}
}
function newTask() {
editingTask.value = null;
showTaskForm.value = true;
}
function editTask(task) {
editingTask.value = task;
showTaskForm.value = true;
}
// A saved task replaces its old self in place rather than the list being fetched
// again: the row that was just edited should not move under the pointer that
// edited it, and a new one belongs where its time puts it.
function onTaskSaved(task) {
showTaskForm.value = false;
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));
}
// The switch in the row. Written straight through rather than optimistically:
// a schedule that says it is on when the server thinks otherwise is the one
// mistake this list must not make.
async function toggleTask(task) {
tasksError.value = "";
togglingTask.value = task.id;
try {
const saved = await api.updateChargingTask(task.id, { enabled: !task.enabled });
const i = tasks.value.findIndex((x) => x.id === task.id);
if (i >= 0) tasks.value.splice(i, 1, saved);
} catch (e) {
tasksError.value = e.message;
} finally {
togglingTask.value = "";
}
}
// 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) {
tasksError.value = "";
runningTask.value = task.id;
try {
const res = await api.runChargingTask(task.id);
// 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);
if (i >= 0) {
tasks.value.splice(i, 1, {
...task,
lastRun: new Date().toISOString(),
lastResult: res.summary || "",
});
}
} catch (e) {
tasksError.value = e.message;
} finally {
runningTask.value = "";
}
}
async function removeTask(task) {
tasksError.value = "";
if (!(await askConfirm(t("charging.scheduler.removeConfirm", { name: task.name })))) return;
try {
await api.deleteChargingTask(task.id);
tasks.value = tasks.value.filter((x) => x.id !== task.id);
} catch (e) {
tasksError.value = e.message;
}
}
// 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;
}
function taskChargersLabel(task) {
const ids = task.chargers || [];
if (ids.length === 0) return t("charging.scheduler.allChargers");
const names = ids
.map((id) => homeChargers.value.find((c) => c.id === id)?.name)
.filter(Boolean);
// A task can outlive a charger it names — the server skips the missing one
// rather than failing, and the row says as much instead of showing a gap.
if (names.length === 0) return t("charging.scheduler.missingChargers");
return names.join(", ");
}
function taskDaysLabel(task) {
const days = task.days || [];
if (days.length === 0) return t("charging.scheduler.everyDay");
return [...days].sort((a, b) => a - b).map(weekdayShort).join(" ");
}
// The weekday in the user's own language. 2024-01-07 was a Sunday, which is
// where Intl starts counting, so the offset lands each number on its own day.
function weekdayShort(day) {
try {
return new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" })
.format(new Date(Date.UTC(2024, 0, 7 + day)));
} catch {
return String(day);
}
}
// 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
// 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 || "";
const h = Number(parts[1]);
if (!clockIsTwelveHour()) return at.time;
return `${String(h % 12 || 12).padStart(2, "0")}:${parts[2]} ${h < 12 ? "am" : "pm"}`;
}
// The tasks that will act on one charger — the ones that name it, plus every
// task that names none, since those act on all of them.
function tasksFor(charger) {
return tasks.value.filter((task) => {
const ids = task.chargers || [];
return ids.length === 0 || ids.includes(charger.id);
});
}
// Whether the last firing went through, so the row can colour it. Unknown until
// 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 || "");
return m && m[1] === m[2] ? TONE.good.fg : TONE.due.fg;
}
// Opening the home tab is the moment reachability is being asked about; the
// public half never needs it.
// public half never needs it. The scheduler acts on the same chargers, so it
// wants the list too — and its own tasks, once.
watch(chargerTab, (tab) => {
if (tab === "home") loadChargerLive();
if (tab === "scheduler" && !tasksLoaded.value) loadChargingTasks();
startLivePoll();
});
@@ -1937,6 +2111,7 @@ onMounted(async () => {
await loadHomeChargers();
await loadChargerProviders();
if (chargerTab.value === "home") loadChargerLive();
if (chargerTab.value === "scheduler") loadChargingTasks();
startLivePoll();
await loadCtlMode();
if (ctlActive.value) await loadChargers();
@@ -3248,10 +3423,153 @@ onUnmounted(() => {
</div>
</div>
<!-- The scheduler: the user's own list of charging tasks, covering every
charger on the account. The charger's own cloud schedule is one window
inside one box; this is a list, and one list for all of them. -->
<div v-show="chargerTab === 'scheduler'" class="grid gap-6 lg:grid-cols-[1fr_360px] lg:items-start">
<div class="flex flex-col gap-4">
<div class="dh-card p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-strong">{{ t("charging.scheduler.title") }}</p>
<p class="mt-1 text-xs text-muted">{{ t("charging.scheduler.subtitle") }}</p>
</div>
<button
type="button"
class="dh-btn dh-btn-primary shrink-0 !px-3 !py-1.5 text-xs"
:disabled="homeChargers.length === 0"
@click="newTask"
>
{{ t("charging.scheduler.add") }}
</button>
</div>
<p v-if="tasksError" class="mt-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">
{{ tasksError }}
</p>
<!-- Nothing to act on yet: a task with no charger behind it would
only ever report that it could not send anything. -->
<p v-if="homeChargers.length === 0" class="mt-4 text-sm text-muted">
{{ t("charging.scheduler.needCharger") }}
</p>
<p v-else-if="tasks.length === 0" class="mt-4 text-sm text-muted">
{{ t("charging.scheduler.empty") }}
</p>
<!-- One row per task: what it does and when, which chargers, which
days, and how the last firing went. -->
<div v-else class="mt-3 flex flex-col gap-2">
<div
v-for="task in tasks"
:key="task.id"
class="rounded-control bg-sunken p-3"
:class="task.enabled ? '' : 'opacity-60'"
>
<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>
</div>
<!-- The switch. Written straight through, so what it shows is
what the server will act on. -->
<button
type="button"
class="shrink-0 rounded-pill px-3 py-1 text-xs font-semibold transition-colors disabled:opacity-50"
:class="task.enabled ? 'bg-brand-600 text-white' : 'bg-card text-muted'"
:disabled="togglingTask === task.id"
:title="t('charging.scheduler.toggleHint')"
@click="toggleTask(task)"
>
{{ task.enabled ? t("common.yes") : t("common.no") }}
</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)"
>
{{ runningTask === task.id ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
</button>
<button
type="button"
class="text-xs font-medium text-muted transition-colors hover:text-body"
@click="editTask(task)"
>
{{ t("common.edit") }}
</button>
<button
type="button"
class="text-xs font-medium text-muted transition-colors hover:text-danger"
@click="removeTask(task)"
>
{{ t("common.remove") }}
</button>
</div>
</div>
</div>
<p class="mt-4 text-xs text-muted">{{ t("charging.scheduler.serverHint") }}</p>
</div>
</div>
<!-- The same chargers the tab before this one lists, so the schedule can
be read against the boxes it acts on without switching back. Each says
how many tasks touch it, which is the question this column is here to
answer. Read-only: this tab is about the schedule. -->
<div class="flex flex-col gap-4">
<div class="dh-card p-2">
<div class="eyebrow px-3 pb-1.5 pt-2.5">
{{ t("charging.stations.homeHeading") }} · {{ t("charging.home.count", { n: homeChargers.length }) }}
</div>
<div v-for="c in homeChargers" :key="c.id" class="flex w-full items-center gap-3 rounded-control p-3 text-left">
<span
class="grid h-9 w-9 flex-none place-items-center rounded-control bg-sunken"
:title="homeChargerStatusLabel(c)"
>
<svg viewBox="0 0 24 24" fill="none" :stroke="homeChargerTone(c)" stroke-width="2" class="h-4.5 w-4.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-strong">{{ c.name }}</span>
<span class="data block truncate text-[11px] text-muted">{{ homeChargerSubtitle(c) }}</span>
</span>
<span class="shrink-0 text-[11px] text-muted">{{ t("charging.scheduler.taskCount", { n: tasksFor(c).length }) }}</span>
</div>
<div v-if="homeChargers.length === 0" class="px-3 pb-3 pt-1">
<p class="text-sm text-muted">{{ t("charging.home.empty") }}</p>
</div>
</div>
</div>
</div>
<ChargerImportModal
v-if="showChargerImport"
@saved="onChargerImported"
@close="showChargerImport = false"
/>
<ChargingTaskModal
v-if="showTaskForm"
:task="editingTask"
:chargers="homeChargers"
@saved="onTaskSaved"
@close="showTaskForm = false"
/>
</div>
</template>