/tasks edits a task in place (V-509)
The open list carries the text, the date and the importance as an inline form
with a save button. The status is not in it: that ladder is one-way and has
its own two buttons.
The step-up gate was re-argued rather than inherited, which is what the task
asked for, and edit stays ungated. It rewrites a line on a list he reads
himself, the same blast radius drop already has here, and the store refuses
the two edits that would cost something. A collision is named ("another open
task already says this"), not merged.
A weight outside the three rungs keeps its own option in the select, or
saving an unrelated edit would silently reset it to normal.
This commit is contained in:
+94
-21
@@ -883,14 +883,19 @@ type taskRow struct {
|
|||||||
Created string
|
Created string
|
||||||
Resolved string
|
Resolved string
|
||||||
ResolvedBy 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
|
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||||
// Russian, empty when nothing distinguished the task. Blank is the honest
|
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||||
// rendering: he never said this one mattered more.
|
// rendering: he never said this one mattered more.
|
||||||
Why string
|
Why string
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTasks serves the task review surface (GET) and the four writes it
|
// handleTasks serves the task review surface (GET) and the five writes it
|
||||||
// offers (POST): add, confirm, done, drop.
|
// offers (POST): add, edit, confirm, done, drop.
|
||||||
//
|
//
|
||||||
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
// 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
|
// 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
|
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||||
// page.
|
// 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
|
// "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
|
// from something she read into work he owns. That review step is why derived
|
||||||
// tasks are captured as candidates in the first place.
|
// 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,
|
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||||
|
DueValue: fmtTaskDateValue(t.Due), Weight: t.Weight,
|
||||||
Why: r.Reason,
|
Why: r.Reason,
|
||||||
}
|
}
|
||||||
if t.Status == "candidate" {
|
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")
|
return "", errors.New("empty task text")
|
||||||
}
|
}
|
||||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
||||||
// Importance is his, stated on the form. Out-of-range values are
|
wgt, err := formWeight(r)
|
||||||
// clamped rather than rejected — a bad select is not worth a 400.
|
if err != nil {
|
||||||
if v := r.FormValue("weight"); v != "" {
|
return "", err
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
if d := r.FormValue("due"); d != "" {
|
req.Weight = wgt
|
||||||
due, err := time.ParseInLocation("2006-01-02", d, now().Location())
|
due, err := formDue(r, now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("bad due date %q", d)
|
return "", err
|
||||||
}
|
|
||||||
req.Due = &due
|
|
||||||
}
|
}
|
||||||
|
req.Due = due
|
||||||
resp, err := core.CaptureTask(ctx, req)
|
resp, err := core.CaptureTask(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -1038,6 +1040,36 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.New("invalid id")
|
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
|
var status, msg string
|
||||||
switch action {
|
switch action {
|
||||||
case "confirm":
|
case "confirm":
|
||||||
@@ -1061,6 +1093,47 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
|||||||
return msg, nil
|
return msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fmtTaskDateValue renders a due date the way <input type=date> 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 {
|
func fmtTaskTime(t *time.Time) string {
|
||||||
if t == nil || t.IsZero() {
|
if t == nil || t.IsZero() {
|
||||||
return "—"
|
return "—"
|
||||||
|
|||||||
+18
-3
@@ -60,12 +60,27 @@
|
|||||||
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||||
<div class=hint>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.</div>
|
<div class=hint>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.</div>
|
||||||
{{if .Open}}<div class=scroll><table>
|
{{if .Open}}<div class=scroll><table>
|
||||||
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
<tr><th>task</th><th>why</th><th>from</th><th>captured</th><th></th><th></th></tr>
|
||||||
{{range .Open}}<tr>
|
{{range .Open}}<tr>
|
||||||
<td class=text-max>{{.Text}}</td>
|
<!-- The text, the date and the importance are editable in place (V-509): a
|
||||||
|
dictated task can carry a typo, and a deadline moves. The status is not
|
||||||
|
here — that ladder is one-way and has its own two buttons. -->
|
||||||
|
<td class=text-max><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=edit>
|
||||||
|
<input type=text name=text value="{{.Text}}" size=30 required>
|
||||||
|
<input type=date name=due value="{{.DueValue}}" title="due date">
|
||||||
|
<select name=weight title=importance>
|
||||||
|
<!-- Any weight that is not one of the three rungs keeps its own option, or
|
||||||
|
saving an unrelated edit would silently reset it to normal. -->
|
||||||
|
{{if and (ne .Weight 0) (ne .Weight 2) (ne .Weight 3)}}<option value={{.Weight}} selected>{{.Weight}}</option>{{end}}
|
||||||
|
<option value=0 {{if eq .Weight 0}}selected{{end}}>normal</option>
|
||||||
|
<option value=2 {{if eq .Weight 2}}selected{{end}}>важно</option>
|
||||||
|
<option value=3 {{if eq .Weight 3}}selected{{end}}>срочно</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-muted">save</button></form></td>
|
||||||
<td class=hint>{{.Why}}</td>
|
<td class=hint>{{.Why}}</td>
|
||||||
<td class=hint>{{.Source}}</td>
|
<td class=hint>{{.Source}}</td>
|
||||||
<td>{{.Due}}</td>
|
|
||||||
<td class=muted>{{.Created}}</td>
|
<td class=muted>{{.Created}}</td>
|
||||||
<td><form method=post action=/tasks class=inline-form>
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
|||||||
Reference in New Issue
Block a user