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>
235 lines
9.1 KiB
Vue
235 lines
9.1 KiB
Vue
<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>
|