Files
DriverVault/Web App/web/src/components/ChargingTaskModal.vue
T
tajniak81andClaude Opus 5 5a4515978f 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>
2026-09-03 22:47:30 +02:00

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>