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:
1 parent
8e4c22cfcf
commit
5a4515978f
15 files changed
+1856
-7
No files matched your search
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user