diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index dfc94bf..e36ed3d 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -883,14 +883,19 @@ type taskRow struct { Created string Resolved string ResolvedBy string + // DueValue and Weight are the raw values the edit form posts back + // (Vikunja #509). Due above is for reading and says "—" for no date; a + // date input needs "2026-08-07" or the empty string. + DueValue string + Weight int // Why — the ranker's reason for this row's position (Vikunja #129), in // Russian, empty when nothing distinguished the task. Blank is the honest // rendering: he never said this one mattered more. Why string } -// handleTasks serves the task review surface (GET) and the four writes it -// offers (POST): add, confirm, done, drop. +// handleTasks serves the task review surface (GET) and the five writes it +// offers (POST): add, edit, confirm, done, drop. // // Not step-up gated, unlike /tools and /routines, and the difference is the // point: enabling a tool defines argv Maven will execute, and accepting a @@ -900,6 +905,13 @@ type taskRow struct { // still sits behind whatever transport auth fronts mavweb, like every other // page. // +// "edit" was re-argued on the same terms rather than inheriting the exemption +// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads +// himself, the same blast radius "drop" already has on this page, and the store +// refuses the two edits that would cost something: a resolved task keeps the +// text it was finished under, and a text collision with another live row is +// named instead of merged. +// // "confirm" is the only interesting move: it promotes a candidate Maven derived // from something she read into work he owns. That review step is why derived // tasks are captured as candidates in the first place. @@ -965,6 +977,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, Status: t.Status, Created: fmtTaskTime(&t.CreatedTs), Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved), + DueValue: fmtTaskDateValue(t.Due), Weight: t.Weight, Why: r.Reason, } if t.Status == "candidate" { @@ -1000,27 +1013,16 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri return "", errors.New("empty task text") } req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()} - // Importance is his, stated on the form. Out-of-range values are - // clamped rather than rejected — a bad select is not worth a 400. - if v := r.FormValue("weight"); v != "" { - // strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a - // form value is not a place to accept trailing garbage. - wgt, err := strconv.Atoi(v) - if err != nil || wgt < 0 { - return "", fmt.Errorf("bad weight %q", v) - } - if wgt > tasks.MaxWeight { - wgt = tasks.MaxWeight - } - req.Weight = wgt + wgt, err := formWeight(r) + if err != nil { + return "", err } - if d := r.FormValue("due"); d != "" { - due, err := time.ParseInLocation("2006-01-02", d, now().Location()) - if err != nil { - return "", fmt.Errorf("bad due date %q", d) - } - req.Due = &due + req.Weight = wgt + due, err := formDue(r, now()) + if err != nil { + return "", err } + req.Due = due resp, err := core.CaptureTask(ctx, req) if err != nil { return "", err @@ -1038,6 +1040,36 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri if err != nil { return "", errors.New("invalid id") } + + 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. + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + wgt, err := formWeight(r) + if err != nil { + return "", err + } + due, err := formDue(r, now()) + if err != nil { + return "", err + } + switch err := core.EditTask(ctx, id, text, due, wgt); { + case err == nil: + return "saved task", nil + case errors.Is(err, ipc.ErrTaskDuplicate): + // Naming the collision instead of merging: two live rows carry two + // provenances, and picking one is not the page's call. + return "", errors.New("another open task already says this — drop one of the two") + case errors.Is(err, ipc.ErrTaskResolved): + return "", errors.New("a resolved task keeps the text it was finished under") + default: + return "", err + } + } + var status, msg string switch action { case "confirm": @@ -1061,6 +1093,47 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri return msg, nil } +// fmtTaskDateValue renders a due date the way requires, or +// "" for no date. Separate from fmtTaskDate, which renders it for reading. +// 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. +func formWeight(r *http.Request) (int, error) { + v := r.FormValue("weight") + if v == "" { + return 0, nil + } + wgt, err := strconv.Atoi(v) + if err != nil || wgt < 0 { + return 0, fmt.Errorf("bad weight %q", v) + } + if wgt > tasks.MaxWeight { + wgt = tasks.MaxWeight + } + return wgt, nil +} + +// formDue reads the date input. An empty field is nil, which on an edit means +// "clear the date" — the form has no other way to say it. +func formDue(r *http.Request, now time.Time) (*time.Time, error) { + d := r.FormValue("due") + if d == "" { + return nil, nil + } + due, err := time.ParseInLocation("2006-01-02", d, now.Location()) + if err != nil { + return nil, fmt.Errorf("bad due date %q", d) + } + return &due, nil +} + +func fmtTaskDateValue(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Local().Format("2006-01-02") +} + func fmtTaskTime(t *time.Time) string { if t == nil || t.IsZero() { return "—" diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html index e6daba3..583a486 100644 --- a/cmd/mavweb/tasks.html +++ b/cmd/mavweb/tasks.html @@ -60,12 +60,27 @@

open {{len .Open}}

most pressing first — by the deadlines and the urgency you gave. nothing about a task is guessed; the only signal that is not yours is age, which lifts anything sitting here for weeks.
{{if .Open}}
- + {{range .Open}} - + + -
taskwhyfromduecaptured
taskwhyfromcaptured
{{.Text}}
+ + + + + +
{{.Why}} {{.Source}}{{.Due}} {{.Created}}