package main import ( "context" _ "embed" "errors" "fmt" "log" "net/http" "strconv" "strings" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/tasks" ) //go:embed tasks.html var tasksHTML string var tasksTmpl = parsePage("tasks", tasksHTML, nil) // now — the wall clock, indirected so the task page can be rendered at a fixed // instant in a test. internal/tasks is pure and the daemon path already ranks // through a clock it is handed; the page had no reason to be the one surface // that could only be tested at whatever time it happened to run. var now = time.Now // resolvedShown — how many finished tasks the page renders. The list is // history, it only grows, and the rows below the first screen are read by // nobody. const resolvedShown = 50 // taskRow is one line on /tasks, with every timestamp already formatted so the // template holds no date logic. type taskRow struct { ID int64 Text string Source string Evidence string Status string Due string 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 } // rowOf renders one wire task into the shared read-only columns. The two call // sites below add what only they need: the live rows carry the edit form's raw // values and the ranker's reason, the resolved rows carry neither. func rowOf(t ipc.Task) taskRow { return taskRow{ 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), ResolvedBy: t.ResolvedBy, } } // 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 // routine hands the tick loop a new standing reason to interrupt him. A task is // neither — nothing in the tick loop reads the tasks table, so the worst a // weaker caller can do here is write a line onto a list he reads himself. It // 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. func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if !requireCore(w, core, "tasks") { return } ctx := r.Context() var msg, errMsg string if r.Method == http.MethodPost { var err error msg, err = applyTaskPost(ctx, core, r) if err != nil { log.Printf("tasks: %v", err) errMsg = err.Error() } } all, err := core.ListTasks(ctx, "") if err != nil { log.Printf("tasks: list: %v", err) http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway) return } // Live rows are ordered by the same ranker the spoken list uses, so the page // and the voice reply can never disagree about what comes first. Resolved // rows keep store order (newest first) — ranking finished work is pointless. var live []tasks.Item var resolved []taskRow resolvedTotal := 0 for _, t := range all { switch t.Status { case "candidate", "open": live = append(live, tasks.Item{ ID: t.ID, Text: t.Text, Status: t.Status, Created: t.CreatedTs, Due: t.Due, Weight: t.Weight, }) default: resolvedTotal++ // Finished work is history, and the history only grows. The page // showed every row that ever existed, which is a page that gets // slower every month for a section nobody reads past the top of. if len(resolved) >= resolvedShown { continue } resolved = append(resolved, rowOf(t)) } } byID := make(map[int64]ipc.Task, len(all)) for _, t := range all { byID[t.ID] = t } var cands, open []taskRow for _, r := range tasks.Rank(live, now()) { t := byID[r.ID] row := rowOf(t) row.DueValue = fmtTaskDateValue(t.Due) row.Weight = t.Weight row.Why = r.Reason if t.Status == "candidate" { // A candidate's due date is Maven's reading of a mail, so its // ranking reason is not shown as if he had set a priority. row.Why = "" cands = append(cands, row) } else { open = append(open, row) } } renderPage(w, tasksTmpl, struct { Msg, Err string Stalls []tasks.Stall Candidates []taskRow Open []taskRow Resolved []taskRow ResolvedMore bool }{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}) } // applyTaskPost performs one write and returns the message to show. A bad // request returns an error, which the page renders inline rather than as a // bare 400 — this is a form surface, not an API. func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { action := r.FormValue("action") if action == "add" { text := strings.TrimSpace(r.FormValue("text")) if text == "" { return "", errors.New("empty task text") } req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()} wgt, err := formWeight(r) if err != nil { return "", err } 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 } if resp.Promoted { return "confirmed a candidate maven had found", nil } if !resp.Created { return "already on the list", nil } return "added task", nil } id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) if err != nil { 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. 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": status, msg = "open", "confirmed task" case "done": status, msg = "done", "task done" case "drop": status, msg = "dropped", "dropped task" default: return "", fmt.Errorf("unknown action %q", action) } if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil { return "", statusWriteErr(err) } return msg, nil } // errNoDoneWhen — the refusal has to name what is missing, or the button looks // broken. The field it asks for arrives with the intake form (Vikunja #511). var errNoDoneWhen = errors.New("write a definition of done before confirming this candidate") // statusWriteErr translates a SetTaskStatus failure into what the page says. func statusWriteErr(err error) error { if errors.Is(err, ipc.ErrTaskNoDoneWhen) { return errNoDoneWhen } return err } // 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, err := resolveBlocker(ctx, core, r.FormValue("blocked_on")) if err != nil { return "", err } if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil { return "", err } wgt, err := formWeight(r) if err != nil { return "", err } // The importance select is posted whether or not a date is. This ran under // `if due != nil`, so confirming a candidate as "срочно" with no deadline // dropped the word on the floor — the row came back normal and nothing said // why. A promote with neither field set still writes nothing. if due != nil || wgt != 0 { 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 { return "", statusWriteErr(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 } // resolveBlocker turns the blocked-on NAME the form posts into the entity id // the row stores. Identity lives in Nexus, so an unresolvable name stops the // promotion instead of being written as free text. An empty field is no // blocker and reaches Nexus not at all. func resolveBlocker(ctx context.Context, core ipc.CoreAPI, field string) (string, error) { name := strings.TrimSpace(field) if name == "" { return "", nil } 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, ", ")) } return ref.ID, 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. 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 } // fmtTaskDateValue renders a due date the way requires, or // "" for no date. Separate from fmtTaskDate, which renders it for reading. 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 "—" } return t.Local().Format("02 Jan 15:04") } func fmtTaskDate(t *time.Time) string { if t == nil || t.IsZero() { return "—" } return t.Local().Format("02 Jan") }