diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index e36ed3d..88b4414 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1041,6 +1041,14 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri return "", errors.New("invalid id") } + if action == "promote" { + msg, err := promoteCandidate(ctx, core, r, id) + if err != nil { + return "", err + } + return msg, nil + } + if action == "edit" { // The three fields capture set, and only those (Vikunja #509). Status // is not editable here: that ladder is one-way and has its own buttons. @@ -1095,6 +1103,82 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri // fmtTaskDateValue renders a due date the way requires, or // "" for no date. Separate from fmtTaskDate, which renders it for reading. +// promoteCandidate turns a candidate into open work with the three things the +// board needs (Vikunja #511): a definition of done, an optional blocker, and an +// optional date. +// +// The definition of done is required, and the refusal is the store's — this +// only reaches it in a readable order. The blocker is a NAME here and an entity +// id in the row: identity lives in Nexus, so the name is resolved first and a +// name Nexus cannot resolve stops the promotion instead of being stored. +// +// A date set here writes a reminder, which is the one unprompted delivery the +// persona allows: he asked to be told, on a day he named. +func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) { + doneWhen := strings.TrimSpace(r.FormValue("done_when")) + if doneWhen == "" { + return "", errors.New("write a definition of done — what has to be true for this to be finished") + } + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + due, err := formDue(r, now()) + if err != nil { + return "", err + } + + blockedOn := "" + if name := strings.TrimSpace(r.FormValue("blocked_on")); name != "" { + ref, err := core.ResolveEntity(ctx, name, []string{"person"}) + switch { + case errors.Is(err, ipc.ErrNotImplemented): + return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty") + case errors.Is(err, ipc.ErrNoEntity): + return "", fmt.Errorf("nexus does not know %q", name) + case err != nil: + return "", fmt.Errorf("resolving %q: %w", name, err) + case ref.Ambiguous: + // Asking, not picking: a task blocked on the wrong person is a + // mistake nobody can see afterwards. + return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", ")) + } + blockedOn = ref.ID + } + + if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil { + return "", err + } + if due != nil { + wgt, err := formWeight(r) + if err != nil { + return "", err + } + if err := core.EditTask(ctx, id, text, due, wgt); err != nil { + return "", err + } + } + if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil { + if errors.Is(err, ipc.ErrTaskNoDoneWhen) { + return "", errors.New("write a definition of done before confirming this candidate") + } + return "", err + } + if due == nil { + return "confirmed", nil + } + // A date-only field has no hour. Nine in the morning, because the reminder + // is about a day's work and being told at midnight is being told the night + // before. + fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location()) + if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil { + // The task IS promoted; only the reminder failed. Saying "confirmed" + // and nothing else would leave him expecting a nudge that will not come. + return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err) + } + return "confirmed, and maven will remind you that morning", nil +} + // formWeight reads the importance select. Out-of-range clamps rather than // rejects — a bad select is not worth a 400 — but trailing garbage is refused, // because strconv is not Sscanf and "3junk" is not a 3. diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html index 583a486..427feb5 100644 --- a/cmd/mavweb/tasks.html +++ b/cmd/mavweb/tasks.html @@ -37,16 +37,27 @@

found, not confirmed {{len .Candidates}}

maven derived these from something she read. nothing counts as your work until you confirm it.
+
confirming asks for a definition of done: what has to be true for this to be finished. a task without one can never leave the board. a date here also books a reminder that morning.
- + {{range .Candidates}} -
taskwhere fromduecaptured
taskwhere fromcapturedconfirm
{{.Text}} {{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}{{.Due}} {{.Created}}
- + + + + + + +
diff --git a/cmd/mavweb/tasks_test.go b/cmd/mavweb/tasks_test.go index 5f945ba..cbd639e 100644 --- a/cmd/mavweb/tasks_test.go +++ b/cmd/mavweb/tasks_test.go @@ -77,10 +77,14 @@ func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) { t.Errorf("body missing %q", want) } } - // The candidate must offer confirm, and the open task must not. - if !strings.Contains(body, "value=confirm") { + // The candidate must offer the intake form, and it asks for a definition of + // done before it will confirm anything (V-511). + if !strings.Contains(body, "value=promote") { t.Error("candidate row has no confirm action") } + if !strings.Contains(body, "name=done_when") { + t.Error("the confirm form does not ask for a definition of done") + } } func TestHandleTasksAddCaptures(t *testing.T) {