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
@@ -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>
|
||||
Reference in new issue
Block a user