A task holds the whole night, not one end of it

One command per task was the wrong unit. A charging window is two commands
and reads as one intention, so it was two rows that had to be named twice,
switched off twice, and kept in step by hand — and there was nowhere to put
the third thing, the ease down to 10 A once the house is asleep.

So a task holds a flow. Steps are rows in the editor: an action, a time, and
the ceiling under the one action that takes one. The chargers and the days
belong to the task, because they are the same for every step of a night, and
the switch governs all of it.

The steps keep the order they were written rather than being sorted by the
clock. A night crosses midnight, and clock order files "start at 23:00" last,
behind the stop that closes it — which is not the flow anybody described.
Nothing about firing depends on the order: every step is timed on its own,
and the sweep asks each one whether its minute has come.

Run now moved onto the step. A flow is not a thing that can happen at once —
firing a start and the stop that closes it back to back would leave the
charger where it began and prove nothing — so the button fires the one line
it sits on, and the outcome names the step by its time.

The stored shape changes with it: action/amps/time give way to a steps list.
The collection was a day old and empty, so this replaces them outright rather
than carrying a compatibility path for a schema nothing has run on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 23:32:07 +02:00
co-authored by Claude Opus 5
parent 0f48093d1a
commit 2b4f4f034d
13 changed files with 553 additions and 357 deletions
+107 -99
View File
@@ -3,16 +3,15 @@ package api
// The home-charger scheduler: the user's own list of charging tasks.
//
// The charger's own cloud schedule can say one thing — "charge between these
// hours" — and it says it inside one charger. This is a list: as many tasks as
// the owner likes, each with its own action, time, days and set of chargers, and
// one list covering every charger on the account rather than a schedule hidden
// inside each box.
// hours" — and it says it inside one charger. This is a list, and each entry is
// a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these
// chargers, on these days. One named thing, switched on and off as one.
//
// GET /api/charging-tasks — the caller's tasks, earliest first
// POST /api/charging-tasks — write one
// PATCH /api/charging-tasks/{id} — edit one (any subset of its fields)
// DELETE /api/charging-tasks/{id} — forget one
// POST /api/charging-tasks/{id}/run — fire it now, without waiting for its time
// POST /api/charging-tasks/{id}/steps/{n}/run — fire one step now
//
// A task belongs to the person, like the chargers it acts on, so there is no
// sharing here: everyone sees their own list only. Firing one is the business of
@@ -23,13 +22,14 @@ import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"drivervault/apiserver/internal/models"
)
// The actions a task may carry. Each is a control command the charger already
// The actions a step may carry. Each is a control command the charger already
// understands; the scheduler adds no verbs of its own, so a task can only ask
// for something the buttons on the Charging page can ask for by hand.
var taskActions = map[string]bool{
@@ -39,14 +39,17 @@ var taskActions = map[string]bool{
"boost": true,
}
// maxTaskSteps bounds one task's flow. Well past any real schedule — a night
// rate is two or three steps — and it keeps a client from parking an unbounded
// blob on the record, which the sweep then reads every thirty seconds.
const maxTaskSteps = 24
// chargingTaskRecord is the PocketBase-facing shape of a task.
type chargingTaskRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Chargers []string `json:"chargers"`
Action string `json:"action"`
Amps float64 `json:"amps"`
Time string `json:"time"`
Steps []models.ChargingStep `json:"steps"`
Zone string `json:"zone"`
Days []int `json:"days"`
Enabled bool `json:"enabled"`
@@ -57,8 +60,8 @@ type chargingTaskRecord struct {
}
func (rec chargingTaskRecord) toModel() models.ChargingTask {
// The two lists are never null on the wire: the page iterates them, and a
// null would make "every charger" and "every day" read as an error there.
// The lists are never null on the wire: the page iterates them, and a null
// would make "every charger" and "every day" read as an error there.
chargers := rec.Chargers
if chargers == nil {
chargers = []string{}
@@ -67,13 +70,15 @@ func (rec chargingTaskRecord) toModel() models.ChargingTask {
if days == nil {
days = []int{}
}
steps := rec.Steps
if steps == nil {
steps = []models.ChargingStep{}
}
return models.ChargingTask{
ID: rec.ID,
Name: rec.Name,
Chargers: chargers,
Action: rec.Action,
Amps: rec.Amps,
Time: rec.Time,
Steps: steps,
Zone: rec.Zone,
Days: days,
Enabled: rec.Enabled,
@@ -83,15 +88,23 @@ func (rec chargingTaskRecord) toModel() models.ChargingTask {
}
}
// firstStepTime is the time of day a task begins — its first step's, which is
// what the list is ordered by, so the evening's task sits below the morning's. A
// task with no steps cannot exist, but the ordering must not depend on that.
func (rec chargingTaskRecord) firstStepTime() string {
if len(rec.Steps) == 0 {
return "99:99"
}
return rec.Steps[0].Time
}
// chargingTaskBody is the write shape. Every field is a pointer so a PATCH can
// name one of them without the rest being read as "clear these" — a task
// switched off from the list must not lose its days on the way.
// switched off from the list must not lose its flow on the way.
type chargingTaskBody struct {
Name *string `json:"name"`
Chargers *[]string `json:"chargers"`
Action *string `json:"action"`
Amps *float64 `json:"amps"`
Time *string `json:"time"`
Steps *[]models.ChargingStep `json:"steps"`
Zone *string `json:"zone"`
Days *[]int `json:"days"`
Enabled *bool `json:"enabled"`
@@ -147,6 +160,46 @@ func normalizeDays(days []int) []int {
return out
}
// normalizeSteps validates a flow, keeping the steps in the order they were
// written.
//
// Not sorted by time: a night crosses midnight, and clock order would file
// "start at 23:00" last, behind the stop that closes it. Nothing about firing
// depends on the order — every step is timed on its own — so the order is free
// to be the one that reads as the intention it is.
func normalizeSteps(steps []models.ChargingStep) ([]models.ChargingStep, error) {
if len(steps) == 0 {
return nil, fmt.Errorf("a task needs at least one step")
}
if len(steps) > maxTaskSteps {
return nil, fmt.Errorf("a task can hold at most %d steps", maxTaskSteps)
}
out := make([]models.ChargingStep, 0, len(steps))
for i, step := range steps {
action := strings.TrimSpace(step.Action)
if !taskActions[action] {
return nil, fmt.Errorf("step %d: unknown action: %s", i+1, action)
}
at := normalizeTaskTime(step.Time)
if at == "" {
return nil, fmt.Errorf("step %d: time must be a 24-hour clock time, e.g. 23:00", i+1)
}
// The ceiling only means anything to "limit", but it is kept whatever the
// action is: switching a step to "limit" and back should not lose the amps
// that were typed. 6 A is the charger's own floor — below it the box pauses
// rather than charging slowly — and its ratings top out at 32 A.
if step.Amps != 0 && (step.Amps < 6 || step.Amps > 32) {
return nil, fmt.Errorf("step %d: the current limit must be between 6 and 32 A", i+1)
}
// A limit step with no ceiling would fire and ask the charger for 0 A.
if action == "limit" && step.Amps <= 0 {
return nil, fmt.Errorf("step %d: a limit step needs the amps to limit to", i+1)
}
out = append(out, models.ChargingStep{Action: action, Amps: step.Amps, Time: at})
}
return out, nil
}
// taskPayload turns a write body into the PocketBase fields it names, validating
// as it goes. `full` demands the fields a task cannot exist without, so a POST is
// checked as a whole and a PATCH only where it speaks.
@@ -163,36 +216,14 @@ func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) {
return nil, fmt.Errorf("name is required")
}
if body.Action != nil {
action := strings.TrimSpace(*body.Action)
if !taskActions[action] {
return nil, fmt.Errorf("unknown action: %s", action)
if body.Steps != nil {
steps, err := normalizeSteps(*body.Steps)
if err != nil {
return nil, err
}
payload["action"] = action
payload["steps"] = steps
} else if full {
return nil, fmt.Errorf("action is required")
}
if body.Time != nil {
at := normalizeTaskTime(*body.Time)
if at == "" {
return nil, fmt.Errorf("time must be a 24-hour clock time, e.g. 23:00")
}
payload["time"] = at
} else if full {
return nil, fmt.Errorf("time is required")
}
// The ceiling only means anything to "limit", but it is stored whatever the
// action is: switching a task to "limit" and back should not lose the amps
// that were typed. 6 A is the charger's own floor — below it the box pauses
// rather than charging slowly — and its ratings top out at 32 A.
if body.Amps != nil {
amps := *body.Amps
if amps != 0 && (amps < 6 || amps > 32) {
return nil, fmt.Errorf("the current limit must be between 6 and 32 A")
}
payload["amps"] = amps
return nil, fmt.Errorf("a task needs at least one step")
}
if body.Chargers != nil {
@@ -220,50 +251,20 @@ func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) {
return payload, nil
}
// checkLimitAmps refuses a task that ends up a "limit" with nothing to limit to,
// which would fire and ask the charger for 0 A. Both halves are read from what
// the write leaves behind — the payload where it speaks, the stored record where
// it does not — so a PATCH that names only one of them is checked against the
// other rather than against nothing.
func checkLimitAmps(payload map[string]any, stored chargingTaskRecord) error {
action := stored.Action
if v, ok := payload["action"].(string); ok {
action = v
}
amps := stored.Amps
if v, ok := payload["amps"].(float64); ok {
amps = v
}
if action == "limit" && amps <= 0 {
return fmt.Errorf("a limit task needs the amps to limit to")
}
return nil
}
// listChargingTasks returns the caller's own tasks, earliest in the day first —
// the order the day runs in, which is the order a schedule is read in.
// listChargingTasks returns the caller's own tasks, the one that starts earliest
// first — the order the day runs them in, which is the order a schedule is read
// in. Ordered here rather than by PocketBase because the time a task starts is
// now inside its flow, which is not a column to sort on.
func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) {
me := s.currentUserID(r)
if me == "" {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
query := func(sort string) url.Values {
q := url.Values{
res, err := s.pb.List(r.Context(), colChargingTasks, url.Values{
"filter": {fmt.Sprintf("owner='%s'", me)},
"perPage": {"200"},
}
if sort != "" {
q.Set("sort", sort)
}
return q
}
res, err := s.pb.List(r.Context(), colChargingTasks, query("time"))
if err != nil {
// Same fallback the charger list makes: an order is a nicety, the list is
// not, so a collection that predates the sort field still answers.
res, err = s.pb.List(r.Context(), colChargingTasks, query(""))
}
})
if err != nil {
writePBError(w, err)
return
@@ -273,6 +274,9 @@ func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
sort.SliceStable(recs, func(i, j int) bool {
return recs[i].firstStepTime() < recs[j].firstStepTime()
})
out := make([]models.ChargingTask, 0, len(recs))
for _, rec := range recs {
out = append(out, rec.toModel())
@@ -318,10 +322,6 @@ func (s *Server) createChargingTask(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := checkLimitAmps(payload, chargingTaskRecord{}); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["owner"] = me
var rec chargingTaskRecord
if err := s.pb.Create(r.Context(), colChargingTasks, payload, &rec); err != nil {
@@ -346,10 +346,6 @@ func (s *Server) updateChargingTask(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := checkLimitAmps(payload, rec); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if len(payload) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"task": rec.toModel()})
return
@@ -374,25 +370,37 @@ func (s *Server) deleteChargingTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// runChargingTaskNow fires a task on demand — the "Run now" button beside it. It
// takes the same path the ticker does, so what comes back is exactly what the
// task will do at its own time, errors included. A switched-off task still runs
// from here: the switch says whether the clock fires it, not whether the button
// does.
func (s *Server) runChargingTaskNow(w http.ResponseWriter, r *http.Request) {
// runChargingStepNow fires one step of a task on demand — the "Run now" beside
// it. One step rather than the whole flow, because a flow is not a thing that
// can happen at once: running a start and the stop that closes it back to back
// would leave the charger where it began and prove nothing.
//
// It takes the same path the ticker does, so what comes back is exactly what
// that step will do at its own time, errors included. A switched-off task still
// runs from here: the switch says whether the clock fires it, not whether the
// button does.
func (s *Server) runChargingStepNow(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedChargingTask(w, r)
if !ok {
return
}
n, err := strconv.Atoi(strings.TrimSpace(r.PathValue("step")))
if err != nil || n < 0 || n >= len(rec.Steps) {
writeError(w, http.StatusNotFound, "this task has no such step")
return
}
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
results := s.fireChargingTask(r.Context(), who, rec)
s.recordTaskRun(r.Context(), rec, results)
step := rec.Steps[n]
results := s.fireChargingSteps(r.Context(), who, rec, []models.ChargingStep{step})
s.recordTaskRun(r.Context(), rec, step, results)
// The same line that was just stored, so the row the caller updates from this
// answer reads identically to the one a page reload would fetch.
writeJSON(w, http.StatusOK, map[string]any{
"results": results,
"summary": summarizeTaskRun(results),
"summary": taskRunLine(step, results),
})
}
+75 -43
View File
@@ -26,6 +26,8 @@ import (
"strings"
"time"
_ "time/tzdata" // tasks are timed in the user's own zone; hosts without a zone database are common on Windows
"drivervault/apiserver/internal/models"
)
// How often the clock is looked at. Tasks are timed to the minute, so a sweep
@@ -97,7 +99,8 @@ func (s *Server) sweepChargingTasks(ctx context.Context) {
}
now := time.Now()
for _, rec := range recs {
if !taskIsDue(rec, now) {
due := dueSteps(rec, now)
if len(due) == 0 {
continue
}
who, _, err := s.callerForUser(ctx, rec.Owner)
@@ -105,29 +108,31 @@ func (s *Server) sweepChargingTasks(ctx context.Context) {
log.Printf("scheduler: task %s (%s): owner unavailable: %v", rec.ID, rec.Name, err)
continue
}
results := s.fireChargingTask(ctx, who, rec)
s.recordTaskRun(ctx, rec, results)
log.Printf("scheduler: task %s (%s) fired: %s", rec.ID, rec.Name, summarizeTaskRun(results))
// Two steps of one task timed to the same minute contradict each other,
// but nothing stops somebody writing them, so both are sent in the order
// the flow holds them rather than one silently winning.
for _, step := range due {
results := s.fireChargingSteps(ctx, who, rec, []models.ChargingStep{step})
s.recordTaskRun(ctx, rec, step, results)
log.Printf("scheduler: task %s (%s) step %s %s: %s",
rec.ID, rec.Name, step.Time, step.Action, summarizeTaskRun(results))
}
}
}
// taskIsDue reports whether a task's minute has come and it has not already been
// fired in it.
// dueSteps returns the steps of a task whose minute has come — usually one, and
// none at all for the rest of the day.
//
// The time is read in the task's own zone: the browser that wrote it said which,
// and the server's clock is not the one the user set 23:00 by. A zone the host
// has no database for falls back to the server's own rather than silently
// The times are read in the task's own zone: the browser that wrote it said
// which, and the server's clock is not the one the user set 23:00 by. A zone the
// host has no database for falls back to the server's own rather than silently
// shifting the schedule to UTC.
//
// A minute that passed while the server was down is not caught up afterwards. A
// charging window that opened an hour ago is not a window anyone still wants
// opened, and firing a backlog on boot would be the surprising half of the
// choice.
func taskIsDue(rec chargingTaskRecord, now time.Time) bool {
h, m, ok := parseHHMM(rec.Time)
if !ok {
return false
}
func dueSteps(rec chargingTaskRecord, now time.Time) []models.ChargingStep {
loc := time.Local
if rec.Zone != "" {
if l, err := time.LoadLocation(rec.Zone); err == nil {
@@ -135,20 +140,29 @@ func taskIsDue(rec chargingTaskRecord, now time.Time) bool {
}
}
local := now.In(loc)
if local.Hour() != h || local.Minute() != m {
return false
}
if len(rec.Days) > 0 && !containsDay(rec.Days, int(local.Weekday())) {
return false
return nil
}
// The guard against firing twice: the sweep runs more often than once a
// minute, so a task that has already run inside this minute is done.
// minute, so a task that has already run inside this minute is done. It is
// per task rather than per step because two steps never share a minute in
// any flow that means anything — and when they do, they are sent together.
if last, err := time.Parse(time.RFC3339, rec.LastRun); err == nil {
if !last.Before(local.Truncate(time.Minute)) {
return false
return nil
}
}
return true
var due []models.ChargingStep
for _, step := range rec.Steps {
h, m, ok := parseHHMM(step.Time)
if !ok {
continue // a step whose time is not a time of day fires nothing
}
if local.Hour() == h && local.Minute() == m {
due = append(due, step)
}
}
return due
}
func containsDay(days []int, day int) bool {
@@ -160,11 +174,12 @@ func containsDay(days []int, day int) bool {
return false
}
// fireChargingTask sends the task's command to each of its chargers and reports
// what each one said. A task naming no chargers acts on every charger its owner
// has — "all of them" is a standing wish, so a charger imported after the task
// was written is covered by it too.
func (s *Server) fireChargingTask(ctx context.Context, who *callerIdentity, rec chargingTaskRecord) []taskRunResult {
// fireChargingSteps sends the given steps to each of the task's chargers and
// reports what each one said. A task naming no chargers acts on every charger
// its owner has — "all of them" is a standing wish, so a charger imported after
// the task was written is covered by it too.
func (s *Server) fireChargingSteps(ctx context.Context, who *callerIdentity,
rec chargingTaskRecord, steps []models.ChargingStep) []taskRunResult {
chargers, err := s.ownerChargers(ctx, rec.Owner)
if err != nil {
return []taskRunResult{{Error: err.Error()}}
@@ -183,13 +198,16 @@ func (s *Server) fireChargingTask(ctx context.Context, who *callerIdentity, rec
results = append(results, out)
continue
}
status, err := s.sendTaskAction(ctx, who, c.Serial, rec)
for _, step := range steps {
one := out
status, err := s.sendStep(ctx, who, c.Serial, step)
if err != nil {
out.Error = err.Error()
one.Error = err.Error()
} else {
out.Status = status
one.Status = status
}
results = append(results, one)
}
results = append(results, out)
}
if len(results) == 0 {
results = append(results, taskRunResult{Error: "this task names no charger that still exists"})
@@ -224,23 +242,23 @@ func containsID(ids []string, id string) bool {
return false
}
// sendTaskAction sends one task's command to one charger, through the control
// endpoint the page's own buttons use.
// sendStep sends one step's command to one charger, through the control endpoint
// the page's own buttons use.
//
// The request is synthesised rather than the transports being called directly,
// so a scheduled command cannot end up on a different footing from a pressed
// one: the cascade, the ownership gate, the rate limit and the audit line are
// the endpoint's, and there is no second copy of them here to drift.
func (s *Server) sendTaskAction(ctx context.Context, who *callerIdentity, serial string, rec chargingTaskRecord) (string, error) {
func (s *Server) sendStep(ctx context.Context, who *callerIdentity, serial string, step models.ChargingStep) (string, error) {
body := map[string]any{}
if rec.Action == "limit" {
body["amps"] = rec.Amps
if step.Action == "limit" {
body["amps"] = step.Amps
}
raw, err := json.Marshal(body)
if err != nil {
return "", err
}
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + rec.Action
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + step.Action
req, err := http.NewRequestWithContext(
context.WithValue(ctx, ctxCaller, who), http.MethodPost, path, bytes.NewReader(raw))
if err != nil {
@@ -250,7 +268,7 @@ func (s *Server) sendTaskAction(ctx context.Context, who *callerIdentity, serial
// The endpoint reads its charger and its verb from the route, which nothing
// matched here — this request never went through a mux.
req.SetPathValue("sn", serial)
req.SetPathValue("action", rec.Action)
req.SetPathValue("action", step.Action)
rw := &captureWriter{header: http.Header{}}
s.handleAnkerControlAction(rw, req)
@@ -332,14 +350,28 @@ func summarizeTaskRun(results []taskRunResult) string {
}
}
// recordTaskRun stamps a task with when it last fired and how it went. The stamp
// is also the guard that keeps a task from firing twice inside its minute, so a
// firing that could not be written down is worth a log line: without it the next
// sweep would send the command again.
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord, results []taskRunResult) {
// taskRunLine is the outcome of one firing as the list shows it: which step, and
// how it went. A task holds several steps now, and "2 of 2 sent" says nothing
// about which of them it was.
//
// The step is named by its time rather than its action, because everything this
// server writes into last_result is in its own words — charger names, the
// control gate's refusals — while the page has a translation for every action.
// A clock time reads the same in all three languages.
func taskRunLine(step models.ChargingStep, results []taskRunResult) string {
return step.Time + " — " + summarizeTaskRun(results)
}
// recordTaskRun stamps a task with when it last fired and how it went.
//
// The stamp is also the guard that keeps a task from firing twice inside its
// minute, so a firing that could not be written down is worth a log line:
// without it the next sweep would send the command again.
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord,
step models.ChargingStep, results []taskRunResult) {
payload := map[string]any{
"last_run": time.Now().UTC().Format(time.RFC3339),
"last_result": summarizeTaskRun(results),
"last_result": taskRunLine(step, results),
}
if err := s.pb.Update(ctx, colChargingTasks, rec.ID, payload, nil); err != nil {
log.Printf("scheduler: task %s fired but its outcome could not be saved: %v", rec.ID, err)
+134 -74
View File
@@ -4,14 +4,20 @@ import (
"strings"
"testing"
"time"
"drivervault/apiserver/internal/models"
)
// The scheduler's clock is the part worth pinning down: it decides on its own,
// with nobody watching, whether to send a car a command. A task that fires in
// with nobody watching, whether to send a car a command. A step that fires in
// the wrong hour is worse than one that does not fire at all, so the zone, the
// weekday and the not-twice-in-one-minute guard are each checked here.
func TestTaskIsDue(t *testing.T) {
func step(action, at string, amps float64) models.ChargingStep {
return models.ChargingStep{Action: action, Time: at, Amps: amps}
}
func TestDueSteps(t *testing.T) {
warsaw, err := time.LoadLocation("Europe/Warsaw")
if err != nil {
t.Fatalf("Europe/Warsaw: %v", err)
@@ -19,32 +25,52 @@ func TestTaskIsDue(t *testing.T) {
// A Wednesday, 23:00 in Warsaw — 22:00 UTC.
at2300 := time.Date(2026, 9, 2, 23, 0, 30, 0, warsaw)
base := chargingTaskRecord{Time: "23:00", Zone: "Europe/Warsaw", Enabled: true}
if !taskIsDue(base, at2300) {
t.Error("a task set for 23:00 is not due at 23:00 in its own zone")
}
// The same instant, handed over as UTC. The zone on the task is what the
// time is read in, so where the server thinks it is must not matter.
if !taskIsDue(base, at2300.UTC()) {
t.Error("the task stopped being due when the same instant arrived as UTC")
}
if taskIsDue(base, at2300.Add(time.Minute)) {
t.Error("a task fired a minute after its time")
}
if taskIsDue(base, at2300.Add(-time.Minute)) {
t.Error("a task fired a minute before its time")
// A whole night in one task, which is the point of a flow: it opens, it
// eases off, it closes.
base := chargingTaskRecord{
Zone: "Europe/Warsaw",
Enabled: true,
Steps: []models.ChargingStep{
step("start", "23:00", 0),
step("limit", "01:00", 10),
step("stop", "06:30", 0),
},
}
// Weekdays. 2026-09-02 is a Wednesday (3).
due := dueSteps(base, at2300)
if len(due) != 1 || due[0].Action != "start" {
t.Fatalf("at 23:00 got %+v, want just the start step", due)
}
// The same instant handed over as UTC. The zone on the task is what the
// times are read in, so where the server thinks it is must not matter.
if got := dueSteps(base, at2300.UTC()); len(got) != 1 || got[0].Action != "start" {
t.Errorf("the step stopped being due when the same instant arrived as UTC: %+v", got)
}
// The other two steps, each in its own minute and no other.
if got := dueSteps(base, time.Date(2026, 9, 3, 1, 0, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "limit" {
t.Errorf("at 01:00 got %+v, want the limit step", got)
}
if got := dueSteps(base, time.Date(2026, 9, 3, 6, 30, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "stop" {
t.Errorf("at 06:30 got %+v, want the stop step", got)
}
// A minute the flow says nothing about fires nothing — the point being that
// a task with a step at 23:00 is not "on" from 23:00 onwards.
for _, at := range []time.Time{at2300.Add(time.Minute), at2300.Add(-time.Minute),
time.Date(2026, 9, 3, 3, 0, 0, 0, warsaw)} {
if got := dueSteps(base, at); len(got) != 0 {
t.Errorf("at %s got %+v, want nothing due", at.Format("15:04"), got)
}
}
// Weekdays gate the whole task. 2026-09-02 is a Wednesday (3).
weeknights := base
weeknights.Days = []int{1, 2, 3, 4, 5}
if !taskIsDue(weeknights, at2300) {
t.Error("a weeknight task is not due on a Wednesday")
if len(dueSteps(weeknights, at2300)) != 1 {
t.Error("a weeknight task did not fire on a Wednesday")
}
weekends := base
weekends.Days = []int{0, 6}
if taskIsDue(weekends, at2300) {
if len(dueSteps(weekends, at2300)) != 0 {
t.Error("a weekend task fired on a Wednesday")
}
@@ -52,34 +78,45 @@ func TestTaskIsDue(t *testing.T) {
// and the second sweep must not send the command again.
fired := base
fired.LastRun = at2300.UTC().Format(time.RFC3339)
if taskIsDue(fired, at2300.Add(20*time.Second)) {
t.Error("a task fired twice inside its own minute")
if len(dueSteps(fired, at2300.Add(20*time.Second))) != 0 {
t.Error("a step fired twice inside its own minute")
}
// Yesterday's firing is not this minute's.
yesterday := base
yesterday.LastRun = at2300.Add(-24 * time.Hour).UTC().Format(time.RFC3339)
if !taskIsDue(yesterday, at2300) {
if len(dueSteps(yesterday, at2300)) != 1 {
t.Error("yesterday's run stopped today's from firing")
}
// A time that is not a time of day fires nothing rather than firing at
// midnight, which is what a zero hour and minute would have meant.
// A step whose time is not a time of day fires nothing rather than firing at
// midnight, which is what a zero hour and minute would have meant — and it
// does not take the rest of the flow down with it.
broken := base
broken.Time = "later"
if taskIsDue(broken, at2300) {
t.Error("a task with an unreadable time fired")
broken.Steps = []models.ChargingStep{step("start", "later", 0), step("stop", "23:00", 0)}
got := dueSteps(broken, at2300)
if len(got) != 1 || got[0].Action != "stop" {
t.Errorf("got %+v, want only the readable step", got)
}
// Two steps timed to the same minute contradict each other, but nothing
// stops somebody writing them, so both are returned rather than one
// silently winning.
clash := base
clash.Steps = []models.ChargingStep{step("start", "23:00", 0), step("boost", "23:00", 0)}
if got := dueSteps(clash, at2300); len(got) != 2 {
t.Errorf("got %+v, want both steps sharing the minute", got)
}
}
// A zone the host has no database for falls back to the server's own clock
// rather than silently shifting the schedule to UTC.
func TestTaskIsDueUnknownZone(t *testing.T) {
func TestDueStepsUnknownZone(t *testing.T) {
now := time.Now()
rec := chargingTaskRecord{
Time: now.Format("15:04"),
Zone: "Mars/Olympus_Mons",
Steps: []models.ChargingStep{step("start", now.Format("15:04"), 0)},
}
if !taskIsDue(rec, now) {
if len(dueSteps(rec, now)) != 1 {
t.Error("a task in an unknown zone did not fall back to the server's clock")
}
}
@@ -130,16 +167,58 @@ func TestNormalizeDays(t *testing.T) {
}
}
func TestNormalizeSteps(t *testing.T) {
// A night, written the way it is meant: it opens, it eases off, it closes.
// The half-written times are the client's business to send and this
// function's business to pad.
got, err := normalizeSteps([]models.ChargingStep{
step("start", "23:00", 0),
step("limit", "1:00", 10),
step("stop", "6:30", 0),
})
if err != nil {
t.Fatalf("normalizeSteps: %v", err)
}
// Kept in the order it was written. Sorting by the clock would file the
// 23:00 start last, behind the stop that closes it, which is not the flow
// anybody described — and firing does not depend on the order at all.
wantTimes := []string{"23:00", "01:00", "06:30"}
for i, want := range wantTimes {
if got[i].Time != want {
t.Errorf("step %d time = %q, want %q (whole flow: %+v)", i, got[i].Time, want, got)
}
}
for name, steps := range map[string][]models.ChargingStep{
"no steps at all": {},
"an unknown action": {step("melt", "23:00", 0)},
"a time that is not one": {step("start", "half past", 0)},
"a limit with no amps": {step("limit", "23:00", 0)},
"amps below the floor": {step("limit", "23:00", 3)},
"amps above the rating": {step("limit", "23:00", 40)},
} {
if _, err := normalizeSteps(steps); err == nil {
t.Errorf("normalizeSteps accepted %s", name)
}
}
// The cap bounds what the sweep re-reads every thirty seconds.
tooMany := make([]models.ChargingStep, maxTaskSteps+1)
for i := range tooMany {
tooMany[i] = step("start", "23:00", 0)
}
if _, err := normalizeSteps(tooMany); err == nil {
t.Errorf("normalizeSteps accepted %d steps, past the cap of %d", len(tooMany), maxTaskSteps)
}
}
func TestTaskPayloadValidation(t *testing.T) {
str := func(s string) *string { return &s }
num := func(f float64) *float64 { return &f }
steps := func(s ...models.ChargingStep) *[]models.ChargingStep { return &s }
// A whole task, as a create sends it.
full := chargingTaskBody{
Name: str(" Night rate "),
Action: str("limit"),
Time: str("7:05"),
Amps: num(10),
Steps: steps(step("start", "23:00", 0), step("stop", "6:30", 0)),
}
payload, err := taskPayload(full, true)
if err != nil {
@@ -148,36 +227,26 @@ func TestTaskPayloadValidation(t *testing.T) {
if payload["name"] != "Night rate" {
t.Errorf("name = %v, want it trimmed", payload["name"])
}
if payload["time"] != "07:05" {
t.Errorf("time = %v, want the padded 07:05", payload["time"])
}
if payload["enabled"] != true {
t.Error("a new task was written switched off; nobody fills in a schedule to leave it off")
}
if flow, _ := payload["steps"].([]models.ChargingStep); len(flow) != 2 || flow[0].Time != "23:00" {
t.Errorf("steps = %+v, want both, as written", payload["steps"])
}
// The fields a task cannot exist without.
// The two things a task cannot exist without.
for _, missing := range []chargingTaskBody{
{Action: str("start"), Time: str("23:00")},
{Name: str("x"), Time: str("23:00")},
{Name: str("x"), Action: str("start")},
{Name: str(" "), Action: str("start"), Time: str("23:00")},
{Name: str("x"), Action: str("melt"), Time: str("23:00")},
{Name: str("x"), Action: str("start"), Time: str("half past")},
{Steps: steps(step("start", "23:00", 0))},
{Name: str("x")},
{Name: str(" "), Steps: steps(step("start", "23:00", 0))},
} {
if _, err := taskPayload(missing, true); err == nil {
t.Errorf("taskPayload accepted an incomplete task: %+v", missing)
}
}
// The charger's own floor and ceiling.
for _, amps := range []float64{1, 5, 33} {
if _, err := taskPayload(chargingTaskBody{Amps: num(amps)}, false); err == nil {
t.Errorf("taskPayload accepted %g A, outside what the charger will take", amps)
}
}
// A partial write says only what it names, so a task switched off from the
// list keeps its days and its time.
// list keeps its flow, its chargers and its days.
on := true
partial, err := taskPayload(chargingTaskBody{Enabled: &on}, false)
if err != nil {
@@ -188,26 +257,17 @@ func TestTaskPayloadValidation(t *testing.T) {
}
}
// A "limit" with no ceiling would fire and ask the charger for 0 A. Both halves
// are read from whatever the write leaves behind, so a PATCH naming one of them
// is checked against the stored other.
func TestCheckLimitAmps(t *testing.T) {
stored := chargingTaskRecord{Action: "start", Amps: 0}
if err := checkLimitAmps(map[string]any{"action": "limit"}, stored); err == nil {
t.Error("a task switched to limit with no amps was accepted")
// The list is ordered by the time a task begins, which lives inside its flow
// rather than in a column PocketBase could sort on.
func TestFirstStepTime(t *testing.T) {
rec := chargingTaskRecord{Steps: []models.ChargingStep{step("start", "23:00", 0), step("stop", "06:30", 0)}}
if got := rec.firstStepTime(); got != "23:00" {
t.Errorf("firstStepTime = %q, want the first step's 23:00", got)
}
if err := checkLimitAmps(map[string]any{"action": "limit", "amps": 16.0}, stored); err != nil {
t.Errorf("a complete limit task was refused: %v", err)
}
// The amps are already on the record; the PATCH only changes the action.
hasAmps := chargingTaskRecord{Action: "start", Amps: 16}
if err := checkLimitAmps(map[string]any{"action": "limit"}, hasAmps); err != nil {
t.Errorf("a limit task with stored amps was refused: %v", err)
}
// And the other way round: clearing the amps on a task that limits.
limits := chargingTaskRecord{Action: "limit", Amps: 16}
if err := checkLimitAmps(map[string]any{"amps": 0.0}, limits); err == nil {
t.Error("the amps were cleared off a limit task")
// A task with no steps cannot be written, but sorting must not depend on
// that — it sorts last rather than first.
if got := (chargingTaskRecord{}).firstStepTime(); got < "23:59" {
t.Errorf("an empty task sorted to %q, want it last", got)
}
}
+1 -1
View File
@@ -502,7 +502,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/charging-tasks", s.createChargingTask)
mux.HandleFunc("PATCH /api/charging-tasks/{id}", s.updateChargingTask)
mux.HandleFunc("DELETE /api/charging-tasks/{id}", s.deleteChargingTask)
mux.HandleFunc("POST /api/charging-tasks/{id}/run", s.runChargingTaskNow)
mux.HandleFunc("POST /api/charging-tasks/{id}/steps/{step}/run", s.runChargingStepNow)
// Cars + sharing.
mux.HandleFunc("GET /api/cars", s.listCars)
+6 -5
View File
@@ -236,11 +236,12 @@ var collectionsSchema = map[string][]fieldDef{
// standing wish that keeps covering chargers imported later — and a
// multi-relation would have to be rewritten on every import to say it.
fJSON("chargers", 2000),
fSelect("action", []string{"start", "stop", "limit", "boost"}, true),
fNumber("amps"), // the ceiling, for the "limit" action
// 24-hour "HH:MM", read in the IANA zone the task was written in. The
// server's clock is not the one the user set the time by.
fText("time", true),
// The flow: [{action, amps, time}, …] in the order it runs. A whole
// charging window is one task rather than the two that would otherwise
// open and close it, so it is named once and switched off once. Times are
// 24-hour "HH:MM" read in the zone below — the server's clock is not the
// one the user set 23:00 by. Validated in internal/api/chargingtasks.go.
fJSON("steps", 4000),
fText("zone", false),
fJSON("days", 200), // 0=Sunday … 6=Saturday; empty means every day
fBool("enabled"),
+27 -16
View File
@@ -152,14 +152,26 @@ type HomeCharger struct {
Created string `json:"created,omitempty"`
}
// ChargingTask is one line of the home-charger scheduler: an action, a time of
// day, the days it repeats on, and the chargers it acts on. It belongs to the
// person, like the chargers themselves — one list covering every charger they
// own, rather than a separate schedule inside each one.
// ChargingStep is one command in a task's flow: what to do, and at what time
// of day. A step is the smallest thing the scheduler sends.
type ChargingStep struct {
// start, stop, limit (to Amps) or boost.
Action string `json:"action"`
Amps float64 `json:"amps,omitempty"`
// A 24-hour "HH:MM", read in the task's zone.
Time string `json:"time"`
}
// ChargingTask is one entry in the home-charger scheduler: a flow of steps, the
// days it repeats on, and the chargers it acts on. It belongs to the person,
// like the chargers themselves — one list covering every charger they own,
// rather than a separate schedule inside each one.
//
// The charger's own cloud schedule can only say "charge between these hours,
// every day, on this one box". This says "at 23:00 on weeknights, cap these two
// chargers at 10 A" — several tasks, each naming its own chargers.
// every day, on this one box". This says "start at 23:00, cap to 10 A at 01:00,
// stop at 06:30 — on weeknights, on these two chargers", as one named thing that
// is switched on and off as one.
type ChargingTask struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -169,16 +181,14 @@ type ChargingTask struct {
// them" is a standing wish, not the list that happened to exist that day.
Chargers []string `json:"chargers"`
// What to do: start, stop, limit (to Amps) or boost. One action per task; a
// charging window is the two tasks that open and close it, which is also how
// it is edited and how it is switched off.
Action string `json:"action"`
Amps float64 `json:"amps,omitempty"`
// The flow, in the order it runs. A whole charging window lives in one task
// rather than in the two that used to open and close it: it is named once,
// switched off once, and reads as the one intention it is.
Steps []ChargingStep `json:"steps"`
// When, as a 24-hour "HH:MM" read in Zone — the IANA zone the browser was in
// The IANA zone the steps' times are read in — the one the browser was in
// when the task was written. The server's own clock is not the one the user
// set the time by, and a laptop that travels must not move the schedule.
Time string `json:"time"`
// set 23:00 by, and a laptop that travels must not move the schedule.
Zone string `json:"zone,omitempty"`
// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
@@ -186,8 +196,9 @@ type ChargingTask struct {
Enabled bool `json:"enabled"`
// What happened the last time it fired, so a task that has been failing
// quietly for a week says so in the list rather than in a log nobody reads.
// What happened the last time a step of it fired, so a task that has been
// failing quietly for a week says so in the list rather than in a log nobody
// reads.
LastRun string `json:"lastRun,omitempty"` // RFC3339, UTC
LastResult string `json:"lastResult,omitempty"`
+5 -4
View File
@@ -473,10 +473,11 @@ const DESIRED = {
// relation because empty has to mean "every charger I own" — a standing wish
// that keeps covering chargers imported later.
F.json("chargers", 2000),
F.select("action", ["start", "stop", "limit", "boost"], true),
F.number("amps"), // the ceiling, for the "limit" action
// 24-hour "HH:MM", read in the IANA zone the task was written in.
F.text("time", true),
// The flow: [{action, amps, time}, …] in the order it runs. A whole charging
// window is one task rather than the two that would otherwise open and close
// it, so it is named once and switched off once. Times are 24-hour "HH:MM"
// read in the zone below.
F.json("steps", 4000),
F.text("zone"),
F.json("days", 200), // 0=Sunday … 6=Saturday; empty means every day
F.bool("enabled"),
+8 -4
View File
@@ -319,8 +319,9 @@ export const api = {
// 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.
// page only writes tasks and reads back how each one last went.
// runChargingStep fires one step now, whatever its time and whether or not
// the task it belongs to 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),
@@ -330,8 +331,11 @@ export const api = {
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" }),
// One step of a flow, fired now: running a start and the stop that closes it
// back to back would leave the charger where it began and prove nothing.
runChargingStep: (id, step) =>
request(`/charging-tasks/${encodeURIComponent(id)}/steps/${encodeURIComponent(step)}/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
@@ -9,10 +9,10 @@
// 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.
// A task holds a flow rather than a single command: start at 23:00, ease down to
// 10 A at 01:00, stop at 06:30. That is one intention, so it is one named thing
// with one switch — splitting a charging window across two tasks meant naming it
// twice and remembering to switch off both ends.
import { ref, computed, watch } from "vue";
import { api } from "../api";
import { t } from "../i18n";
@@ -31,9 +31,26 @@ 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 flow, as rows the form edits in place. A new task opens with the one step
// most schedules start from, so the common case is a name and a time rather than
// a decision about how many rows to add.
const steps = ref(
(props.task?.steps || []).length
? props.task.steps.map((s) => ({ action: s.action, time: s.time, amps: s.amps || 16 }))
: [{ action: "start", time: "23:00", amps: 16 }]
);
// A flow of one is a flow, so the last row cannot be removed — an empty task
// would have nothing to fire and the server refuses it anyway.
function addStep() {
steps.value = [...steps.value, { action: "stop", time: "06:30", amps: 16 }];
}
function removeStep(i) {
if (steps.value.length <= 1) return;
steps.value = steps.value.filter((_, n) => n !== i);
}
// 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.
@@ -90,8 +107,12 @@ watch(allChargers, (on) => {
});
// 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);
// value and one unfinished row is enough to make the whole flow unsaveable,
// because the server would otherwise refuse it with a step number the form does
// not show.
const canSave = computed(
() => !!name.value.trim() && steps.value.every((s) => !!s.time) && !saving.value
);
function chargerSubtitle(c) {
return [c.serial, c.model].filter(Boolean).join(" · ");
@@ -103,9 +124,14 @@ async function submit() {
error.value = "";
const body = {
name: name.value.trim(),
action: action.value,
time: at.value,
amps: Number(amps.value) || 0,
// The amps ride along on every step so switching one to "limit" and back
// does not lose the number that was typed; the server keeps them for the
// same reason and ignores them on the actions that have no ceiling.
steps: steps.value.map((s) => ({
action: s.action,
time: s.time,
amps: s.action === "limit" ? Number(s.amps) || 0 : 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.
@@ -147,29 +173,46 @@ function browserZone() {
<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">
<!-- The flow. One row per step, each an action and the time it fires
read down, they are the night: start at 23:00, ease off at 01:00, stop
at 06:30. -->
<div>
<label class="dh-label">{{ t("forms.chargingTask.action") }}</label>
<select v-model="action" class="dh-input">
<label class="dh-label">{{ t("forms.chargingTask.flow") }}</label>
<div v-for="(s, i) in steps" :key="i" class="mb-2 rounded-control bg-sunken p-3">
<div class="flex items-start gap-2">
<select v-model="s.action" class="dh-input min-w-0 flex-1">
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
</select>
<TimeField v-model="s.time" :aria-label="t('forms.chargingTask.time')" />
<!-- The last step cannot go: a task with no steps has nothing to
fire, so the control is absent rather than there and refusing. -->
<button
v-if="steps.length > 1"
type="button"
class="shrink-0 rounded-control px-2 py-2 text-xs font-medium text-muted transition-colors hover:text-danger"
:title="t('forms.chargingTask.removeStep')"
@click="removeStep(i)"
>
&times;
</button>
</div>
<div>
<label class="dh-label">{{ t("forms.chargingTask.time") }}</label>
<TimeField v-model="at" :aria-label="t('forms.chargingTask.time')" />
<!-- The ceiling, under the one action that takes one. -->
<div v-if="s.action === 'limit'" class="mt-3">
<label class="dh-label">
{{ t("forms.chargingTask.amps") }}
<span class="data float-right font-semibold text-strong">{{ s.amps }} A</span>
</label>
<input v-model.number="s.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>
</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>
<button type="button" class="dh-btn dh-btn-ghost w-full !py-2 text-xs" @click="addStep">
{{ t("forms.chargingTask.addStep") }}
</button>
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.flowHint") }}</p>
</div>
<!-- Which chargers. The point of one scheduler for all of them. -->
+6 -3
View File
@@ -389,9 +389,9 @@
},
"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.",
"subtitle": "Én plan for alle dine ladere. En opgave er et forløb — start, grænse, stop — der kører på de dage du vælger, 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.",
"empty": "Ingen opgaver endnu. En opgave er et helt forløb: start kl. 23:00, begræns til 10 A kl. 01:00, stop kl. 06:30.",
"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",
@@ -1278,8 +1278,11 @@
"editTitle": "Rediger ladeopgave",
"name": "Navn",
"namePlaceholder": "Nattakst",
"action": "Gør dette",
"time": "Kl.",
"flow": "Forløbet",
"flowHint": "Hvert trin udføres på sit eget tidspunkt, hver dag opgaven kører. En hel nat er én opgave: start kl. 23:00, stop kl. 06:30.",
"addStep": "+ Tilføj et trin",
"removeStep": "Fjern dette trin",
"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",
+6 -3
View File
@@ -388,9 +388,9 @@
},
"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.",
"subtitle": "One schedule for every charger you own. A task is a flow — start, limit, stop — running on the days you pick, on 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.",
"empty": "No tasks yet. A task is a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30.",
"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",
@@ -1277,8 +1277,11 @@
"editTitle": "Edit charging task",
"name": "Name",
"namePlaceholder": "Night rate",
"action": "Do this",
"time": "At",
"flow": "The flow",
"flowHint": "Each step fires at its own time, every day the task runs. A whole night is one task: start at 23:00, stop at 06:30.",
"addStep": "+ Add a step",
"removeStep": "Remove this step",
"amps": "Current limit",
"ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.",
"chargers": "On these chargers",
+6 -3
View File
@@ -391,9 +391,9 @@
},
"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.",
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Zadanie to przebieg — start, limit, stop — wykonywany w wybrane dni, na wybranych ładowarkach.",
"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.",
"empty": "Brak zadań. Zadanie to cały przebieg: start o 23:00, ograniczenie do 10 A o 01:00, stop o 06:30.",
"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",
@@ -1294,8 +1294,11 @@
"editTitle": "Edytuj zadanie ładowania",
"name": "Nazwa",
"namePlaceholder": "Taryfa nocna",
"action": "Zrób to",
"time": "O godzinie",
"flow": "Przebieg",
"flowHint": "Każdy krok uruchamia się o własnej godzinie, w każdy dzień działania zadania. Cała noc to jedno zadanie: start o 23:00, stop o 06:30.",
"addStep": "+ Dodaj krok",
"removeStep": "Usuń ten krok",
"amps": "Limit prądu",
"ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
"chargers": "Na tych ładowarkach",
+67 -40
View File
@@ -1943,7 +1943,7 @@ 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 runningTask = ref(""); // "<task id>:<step index>" of the step being fired
const togglingTask = ref("");
async function loadChargingTasks() {
@@ -1974,7 +1974,13 @@ function onTaskSaved(task) {
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));
else {
// Ordered by the time each task begins, which is what the server sends back
// and what the day runs them in.
tasks.value = [...tasks.value, task].sort(
(a, b) => (a.steps?.[0]?.time || "").localeCompare(b.steps?.[0]?.time || "")
);
}
}
// The switch in the row. Written straight through rather than optimistically:
@@ -1994,15 +2000,18 @@ async function toggleTask(task) {
}
}
// 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) {
// Fire one step now, without waiting for its time — the only way to find out
// whether it will actually reach the charger before the night it matters. One
// step rather than the whole flow: running a start and the stop that closes it
// back to back would leave the charger where it began and prove nothing.
//
// The server takes the same path the clock takes, so what comes back is what
// will happen then, errors included.
async function runStep(task, index) {
tasksError.value = "";
runningTask.value = task.id;
runningTask.value = `${task.id}:${index}`;
try {
const res = await api.runChargingTask(task.id);
const res = await api.runChargingStep(task.id, index);
// 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);
@@ -2031,12 +2040,11 @@ async function removeTask(task) {
}
}
// 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;
// What one step of a flow does. The ceiling is part of the sentence for the one
// action that has one — "Set current limit" alone does not say to what.
function stepActionLabel(step) {
const label = t(`charging.scheduler.actions.${step.action}`);
return step.action === "limit" ? `${label} · ${step.amps} A` : label;
}
function taskChargersLabel(task) {
@@ -2059,14 +2067,14 @@ function taskDaysLabel(task) {
return sortWeekdays(days).map(weekdayShortName).join(" ");
}
// 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
// A step's time, on the clock the user chose. It is stored as 24-hour "HH:MM"
// — a 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 || "";
function stepClock(step) {
const parts = /^(\d{1,2}):(\d{2})$/.exec(step?.time || "");
if (!parts) return step?.time || "";
const h = Number(parts[1]);
if (!clockIsTwelveHour()) return at.time;
if (!clockIsTwelveHour()) return step.time;
return `${String(h % 12 || 12).padStart(2, "0")}:${parts[2]} ${h < 12 ? "am" : "pm"}`;
}
@@ -2083,9 +2091,10 @@ function tasksFor(charger) {
// 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 || "");
// The server words the outcome as the step it fired and then "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;
}
@@ -3461,21 +3470,15 @@ onUnmounted(() => {
>
<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 class="truncate text-sm font-semibold text-strong">{{ task.name }}</p>
<p class="mt-0.5 text-xs text-muted">
{{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}
</p>
</div>
<!-- The switch. Written straight through, so what it shows is
what the server will act on. -->
what the server will act on. It governs the whole flow:
the task is one intention and is switched off as one. -->
<button
type="button"
class="shrink-0 rounded-pill px-3 py-1 text-xs font-semibold transition-colors disabled:opacity-50"
@@ -3488,15 +3491,39 @@ onUnmounted(() => {
</button>
</div>
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
<!-- The flow, a line per step. Read down, they are the night
which is the whole reason a task holds more than one. -->
<div class="mt-2 flex flex-col gap-1">
<div
v-for="(s, i) in task.steps"
:key="i"
class="flex items-baseline gap-2 rounded-control px-2 py-1 transition-colors hover:bg-card"
>
<span class="data w-16 shrink-0 text-sm font-medium text-strong">{{ stepClock(s) }}</span>
<span class="min-w-0 flex-1 truncate text-xs text-body">{{ stepActionLabel(s) }}</span>
<!-- Per step, because a flow is not a thing that can happen at
once: firing a start and the stop that closes it back to
back would leave the charger where it began. -->
<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)"
class="shrink-0 text-[11px] font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
:disabled="runningTask === `${task.id}:${i}`"
@click="runStep(task, i)"
>
{{ runningTask === task.id ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
{{ runningTask === `${task.id}:${i}` ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
</button>
</div>
</div>
<p v-if="task.lastRun" class="mt-2 text-[11px]" :style="{ color: taskRunTone(task) }">
<!-- A middle dot rather than a dash: the outcome opens with the
step's own time and a dash of its own, and three dashes in a
row read as one long smudge. -->
{{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }} ·
{{ task.lastResult || t("charging.scheduler.noResult") }}
</p>
<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"