Compare commits

...
2 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 2b4f4f034d 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>
2026-09-03 23:32:07 +02:00
tajniak81andClaude Opus 5 0f48093d1a A week that starts where the person reading it starts theirs
The scheduler's day picker began on Sunday because that is where Intl
numbers the days from, which is a fact about the API and not about
anybody's week. Monday leads it across most of Europe. A row of seven
buttons in the wrong order is not just odd to read — it is easy to
misclick, and a misclicked day in a schedule is a car charging on the
wrong night.

So Settings › Appearance asks, beneath the date and the clock, as the
third question a region gets: first day of the week, following the region
unless it is answered outright. The same shape the time format already
had, and the same "auto" default, so nothing changes for an account that
never opens it.

The rule lives in lib/format.js beside the clock's, with the ordering, the
day names and the sort all coming from there. The two places that lay
weekdays out — the picker and the line each task is summarised on — read
it rather than each keeping an opinion, so a day set is written and read
back in the same order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:12:10 +02:00
17 changed files with 707 additions and 389 deletions
+128 -120
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
// 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}/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,26 +39,29 @@ 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"`
Zone string `json:"zone"`
Days []int `json:"days"`
Enabled bool `json:"enabled"`
LastRun string `json:"last_run"`
LastResult string `json:"last_result"`
Owner string `json:"owner"`
Created string `json:"created"`
ID string `json:"id"`
Name string `json:"name"`
Chargers []string `json:"chargers"`
Steps []models.ChargingStep `json:"steps"`
Zone string `json:"zone"`
Days []int `json:"days"`
Enabled bool `json:"enabled"`
LastRun string `json:"last_run"`
LastResult string `json:"last_result"`
Owner string `json:"owner"`
Created string `json:"created"`
}
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,18 +88,26 @@ 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"`
Zone *string `json:"zone"`
Days *[]int `json:"days"`
Enabled *bool `json:"enabled"`
Name *string `json:"name"`
Chargers *[]string `json:"chargers"`
Steps *[]models.ChargingStep `json:"steps"`
Zone *string `json:"zone"`
Days *[]int `json:"days"`
Enabled *bool `json:"enabled"`
}
// parseHHMM splits a 24-hour clock time into its two numbers. "H:MM" is accepted
@@ -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{
"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(""))
}
res, err := s.pb.List(r.Context(), colChargingTasks, url.Values{
"filter": {fmt.Sprintf("owner='%s'", me)},
"perPage": {"200"},
})
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),
})
}
+77 -45
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)
if err != nil {
out.Error = err.Error()
} else {
out.Status = status
for _, step := range steps {
one := out
status, err := s.sendStep(ctx, who, c.Serial, step)
if err != nil {
one.Error = err.Error()
} else {
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)
+136 -76
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",
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),
Name: str(" Night rate "),
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)
}
}
+16
View File
@@ -30,6 +30,7 @@ type userRecord struct {
Locale string `json:"locale"`
DateFormat string `json:"date_format"`
TimeFormat string `json:"time_format"`
WeekStart string `json:"week_start"`
Currency string `json:"currency"`
FontSize string `json:"font_size"`
DragLocked bool `json:"drag_locked"`
@@ -99,6 +100,7 @@ func (rec userRecord) toModel() models.User {
Locale: orDefault(rec.Locale, "en-US"),
DateFormat: orDefault(rec.DateFormat, "YMD"),
TimeFormat: orDefault(rec.TimeFormat, "auto"),
WeekStart: orDefault(rec.WeekStart, "auto"),
Currency: orDefault(rec.Currency, "USD"),
FontSize: orDefault(rec.FontSize, "medium"),
DragLocked: rec.DragLocked,
@@ -164,6 +166,7 @@ type updateMeRequest struct {
Locale *string `json:"locale"`
DateFormat *string `json:"dateFormat"`
TimeFormat *string `json:"timeFormat"`
WeekStart *string `json:"weekStart"`
Currency *string `json:"currency"`
FontSize *string `json:"fontSize"`
DragLocked *bool `json:"dragLocked"`
@@ -264,6 +267,12 @@ var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true
// the app did before there was a setting; the other two say it outright, for
// the people whose region and habit disagree.
var validTimeFormats = map[string]bool{"auto": true, "24": true, "12": true}
// Which day a week is drawn as starting on. "auto" is the chosen region's own
// convention — Monday across most of Europe, Sunday in the US — and is what
// every weekday row read before there was a setting; the other two say it
// outright, for the people whose region and habit disagree.
var validWeekStarts = map[string]bool{"auto": true, "monday": true, "sunday": true}
var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true}
// Kept in step with the users.currency select options in setup-pocketbase.mjs:
@@ -333,6 +342,13 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
payload["time_format"] = *in.TimeFormat
}
if in.WeekStart != nil {
if !validWeekStarts[*in.WeekStart] {
writeError(w, http.StatusBadRequest, "weekStart must be auto, monday, or sunday")
return
}
payload["week_start"] = *in.WeekStart
}
if in.Currency != nil {
if !validCurrencies[*in.Currency] {
writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code")
+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)
+10 -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"),
@@ -262,6 +263,10 @@ var collectionsSchema = map[string][]fieldDef{
// "auto" is the region's own convention, which is what every clock in the
// app read before this field existed.
fSelect("time_format", []string{"auto", "24", "12"}, false),
// The day a week is drawn as starting on, wherever weekdays are laid out
// in a row. "auto" is the region's own convention, which is what the
// scheduler's day picker read before this field existed.
fSelect("week_start", []string{"auto", "monday", "sunday"}, false),
fSelect("currency", []string{
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
+33 -19
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"`
@@ -519,9 +530,12 @@ type User struct {
Locale string `json:"locale"` // e.g. "en-US"
DateFormat string `json:"dateFormat"` // YMD | DMY | MDY
TimeFormat string `json:"timeFormat"` // auto (the region's own) | 24 | 12
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
FontSize string `json:"fontSize"` // small | medium | large
Role string `json:"role"` // user | admin
// The day a week is drawn as starting on, wherever a client lays weekdays
// out in a row — the scheduler's day picker today.
WeekStart string `json:"weekStart"` // auto (the region's own) | monday | sunday
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
FontSize string `json:"fontSize"` // small | medium | large
Role string `json:"role"` // user | admin
// DragLocked holds every arrangement on this account's pages still: the
// garage, a car's tabs, its Information rows, the provider's readings. A
+9 -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"),
@@ -517,6 +518,10 @@ const DESIRED = {
// "auto" is the region's own convention. Kept in step with
// validTimeFormats in internal/api/me.go.
F.select("time_format", ["auto", "24", "12"]),
// The day a week is drawn as starting on, wherever a client lays weekdays
// out in a row. "auto" is the region's own convention. Kept in step with
// validWeekStarts in internal/api/me.go.
F.select("week_start", ["auto", "monday", "sunday"]),
// European currencies plus the non-European ones the panel already offered.
// Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES
// in the web app's Settings.vue.
+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,13 +9,14 @@
// 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";
import { weekdaysInOrder, weekdayShortName } from "../lib/format.js";
import Modal from "./Modal.vue";
import TimeField from "./TimeField.vue";
@@ -30,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.
@@ -51,19 +69,13 @@ const error = ref("");
// 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);
}
}
// The row starts on whichever day this account reads a week as starting on —
// Settings Appearance First day of the week, following the region unless it
// was answered outright. A computed rather than a constant, so changing the
// setting in another tab re-lays the row out instead of leaving it on the old
// week. Both of these come from lib/format.js, which owns the rule for every
// weekday row in the app.
const weekdays = computed(() => weekdaysInOrder());
function toggleDay(day) {
everyDay.value = false;
@@ -95,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(" · ");
@@ -108,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.
@@ -152,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">
<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 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.flow") }}</label>
<!-- 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 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>
<!-- 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>
<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. -->
@@ -209,7 +247,7 @@ function browserZone() {
</label>
<div class="mt-1 flex flex-wrap gap-1.5">
<button
v-for="d in WEEKDAYS"
v-for="d in weekdays"
:key="d"
type="button"
class="rounded-pill border px-3 py-1.5 text-xs font-semibold transition-colors"
@@ -218,7 +256,7 @@ function browserZone() {
: 'border-subtle text-muted hover:bg-sunken'"
@click="toggleDay(d)"
>
{{ weekdayLabel(d) }}
{{ weekdayShortName(d) }}
</button>
</div>
</div>
+11 -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",
@@ -555,6 +555,11 @@
"time24": "24-timers",
"time12": "12-timers",
"timeExample": "Eksempel: {example}",
"weekStart": "Første dag i ugen",
"weekAuto": "Følg regionen",
"weekMonday": "Mandag",
"weekSunday": "Søndag",
"weekExample": "Eksempel: {example}",
"currency": "Valuta",
"currencyExample": "Eksempel: {example} — kun visning, ingen beløb omregnes.",
"fontSize": "Skriftstørrelse",
@@ -1273,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",
+11 -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",
@@ -554,6 +554,11 @@
"time24": "24-hour",
"time12": "12-hour",
"timeExample": "Example: {example}",
"weekStart": "First day of the week",
"weekAuto": "Follow the region",
"weekMonday": "Monday",
"weekSunday": "Sunday",
"weekExample": "Example: {example}",
"currency": "Currency",
"currencyExample": "Example: {example} — display only, no amounts are converted.",
"fontSize": "Font size",
@@ -1272,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",
+11 -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",
@@ -561,6 +561,11 @@
"time24": "24-godzinny",
"time12": "12-godzinny",
"timeExample": "Przykład: {example}",
"weekStart": "Pierwszy dzień tygodnia",
"weekAuto": "Zgodnie z regionem",
"weekMonday": "Poniedziałek",
"weekSunday": "Niedziela",
"weekExample": "Przykład: {example}",
"currency": "Waluta",
"currencyExample": "Przykład: {example} — tylko wyświetlanie, kwoty nie są przeliczane.",
"fontSize": "Rozmiar czcionki",
@@ -1289,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",
+72
View File
@@ -151,6 +151,78 @@ function regionReadsTwelveHour() {
return twelveHourRegions.get(locale);
}
// --- Weekdays --------------------------------------------------------------
//
// A week does not start on the same day everywhere: Monday across most of
// Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons
// that always begins on Sunday reads wrong to half the people looking at it,
// and reads wrong in a way that is easy to misclick — Settings Appearance
// First day of the week is the answer, with "auto" following the chosen region
// the way the clock setting does.
//
// Everything that lays weekdays out in a row goes through these two, so there
// is one answer to "which day comes first" rather than one per screen. Days are
// numbered the way Date.getDay() and the scheduler's stored tasks number them:
// 0 = Sunday … 6 = Saturday.
// Whether weeks are drawn as starting on Monday right now: what the setting
// says outright, or what the region says when it is left on auto.
export function weekStartsOnMonday() {
const mode = prefs.weekStart;
if (mode === "monday") return true;
if (mode === "sunday") return false;
return regionStartsOnMonday();
}
// The one question "auto" asks the region. Cached per locale like the clock's,
// and for the same reason — it is asked once per weekday button.
const mondayRegions = new Map();
function regionStartsOnMonday() {
const locale = prefs.locale || "";
if (!mondayRegions.has(locale)) {
// ISO 8601 numbers the days 1 = Monday … 7 = Sunday, which is what weekInfo
// reports. Browsers expose it as a method on some engines and a property on
// others, hence both.
let monday = true;
try {
const info = new Intl.Locale(locale || "en-US");
const first = (info.getWeekInfo?.() || info.weekInfo)?.firstDay;
if (first) monday = first === 1;
} catch {
// An engine without week information, or an unusable locale. Monday is
// the safer default: it is ISO 8601's, and the convention in every region
// this app's own currency list covers bar one.
}
mondayRegions.set(locale, monday);
}
return mondayRegions.get(locale);
}
// The seven days in the order they should be drawn, as day numbers.
export function weekdaysInOrder() {
return weekStartsOnMonday() ? [1, 2, 3, 4, 5, 6, 0] : [0, 1, 2, 3, 4, 5, 6];
}
// One day's short name in the user's own language, so a row reads Pn Wt Śr in
// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0
// sits, so the offset lands each number on its own day.
export function weekdayShortName(day) {
try {
return new Intl.DateTimeFormat(prefs.locale || undefined, { weekday: "short", timeZone: "UTC" })
.format(new Date(Date.UTC(2024, 0, 7 + day)));
} catch {
return String(day);
}
}
// A set of days, listed in the order this account reads a week in — so the same
// three days always come out in the same order wherever they are shown.
export function sortWeekdays(days) {
const order = weekdaysInOrder();
return [...(days || [])].sort((a, b) => order.indexOf(a) - order.indexOf(b));
}
// Every number we render goes through here so the grouping separator follows
// the user's chosen region rather than the browser's own locale — otherwise the
// odometer disagrees with the dates and costs beside it.
+5
View File
@@ -7,6 +7,10 @@ export const prefs = reactive({
locale: "en-US",
dateFormat: "YMD", // YMD | DMY | MDY
timeFormat: "auto", // auto (the region's own convention) | 24 | 12
// The day a week is drawn as starting on, wherever weekdays are laid out in a
// row — the charging scheduler's day picker today. See lib/format.js, which
// owns the rule so every such row reads the same.
weekStart: "auto", // auto (the region's own convention) | monday | sunday
currency: "USD", // ISO 4217 code
fontSize: "medium", // small | medium | large
// Holds every arrangement still: the garage, a car's tabs, its Information
@@ -58,6 +62,7 @@ export function applyProfilePrefs(profile) {
prefs.locale = profile.locale || "en-US";
prefs.dateFormat = profile.dateFormat || "YMD";
prefs.timeFormat = profile.timeFormat || "auto";
prefs.weekStart = profile.weekStart || "auto";
prefs.currency = profile.currency || "USD";
prefs.fontSize = profile.fontSize || "medium";
prefs.dragLocked = !!profile.dragLocked;
+75 -56
View File
@@ -4,7 +4,8 @@ import { t } from "../i18n";
import { prefs } from "../prefs";
import { askConfirm } from "../lib/confirm.js";
import { api } from "../api";
import { formatDateTime, clockIsTwelveHour } from "../lib/format.js";
import { formatDateTime, clockIsTwelveHour, weekdayShortName, sortWeekdays }
from "../lib/format.js";
import TimeField from "../components/TimeField.vue";
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js";
import ChargerImportModal from "../components/ChargerImportModal.vue";
@@ -1942,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() {
@@ -1973,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:
@@ -1993,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);
@@ -2030,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) {
@@ -2053,28 +2062,19 @@ function taskChargersLabel(task) {
function taskDaysLabel(task) {
const days = task.days || [];
if (days.length === 0) return t("charging.scheduler.everyDay");
return [...days].sort((a, b) => a - b).map(weekdayShort).join(" ");
// Listed in the order this account reads a week in, so "Mon Fri" and the
// picker that wrote it agree about which end of the week comes first.
return sortWeekdays(days).map(weekdayShortName).join(" ");
}
// The weekday in the user's own language. 2024-01-07 was a Sunday, which is
// where Intl starts counting, so the offset lands each number on its own day.
function weekdayShort(day) {
try {
return new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" })
.format(new Date(Date.UTC(2024, 0, 7 + day)));
} catch {
return String(day);
}
}
// The task's time, on the clock the user chose. It is stored as 24-hour "HH:MM"
// — the schedule is a wall clock, not a moment, so there is no date to hand
// 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"}`;
}
@@ -2091,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;
}
@@ -3469,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"
@@ -3496,15 +3491,39 @@ onUnmounted(() => {
</button>
</div>
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
<button
type="button"
class="text-xs font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
:disabled="runningTask === task.id"
@click="runTask(task)"
<!-- 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"
>
{{ runningTask === task.id ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
</button>
<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="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}:${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"
+17 -1
View File
@@ -4,7 +4,8 @@ import { useRoute, useRouter } from "vue-router";
import { api } from "../api";
import { state, isAdmin, logout, refreshProfile } from "../auth";
import { prefs, applyProfilePrefs } from "../prefs";
import { formatDate, formatMoney, formatTime } from "../lib/format.js";
import { formatDate, formatMoney, formatTime, weekdaysInOrder, weekdayShortName }
from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
import { TAB_SURFACES, SETTINGS_TABS, defaultTabFor } from "../lib/tabs.js";
import { askConfirm } from "../lib/confirm.js";
@@ -197,6 +198,9 @@ const timeFormatExample = computed(() => {
return formatTime(d);
});
const currencyExample = computed(() => formatMoney(1234.5));
// The week as this account will now see it drawn — the clearest possible
// example, because the setting has no other visible effect on this page.
const weekStartExample = computed(() => weekdaysInOrder().map(weekdayShortName).join(" "));
// Language and region are two controls over the one stored BCP-47 locale, so
// the pair can be mixed freely (English in Poland, say) rather than being
@@ -1161,6 +1165,18 @@ onBeforeUnmount(() => {
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}</p>
</div>
<!-- Under the clock, as the last of the three questions a region is
asked and the one it is least often asked out loud. -->
<div>
<label class="dh-label">{{ t("settings.appearance.weekStart") }}</label>
<select :value="prefs.weekStart" class="dh-input" @change="saveAppearance({ weekStart: $event.target.value })">
<option value="auto">{{ t("settings.appearance.weekAuto") }}</option>
<option value="monday">{{ t("settings.appearance.weekMonday") }}</option>
<option value="sunday">{{ t("settings.appearance.weekSunday") }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.weekExample", { example: weekStartExample }) }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">