4761c20ad6
cmd/mavweb/main.go held 1868 lines. Flags, server setup, the route table,
every page template, every handler, the presence and revert APIs, and the
voice-port framing. Split along the seams that were already there.
shell.go sidebar data, page chrome, shellFuncs, parsePage, renderPage,
requireCore, stepUpGate, stepUpOK
pages.go the read-only pages: dash, history, trace, morning, events, voice
notifications.go, reminders.go, tasks.go, routines.go, tools.go, chat.go
one write surface each, template beside its handler
facts.go POST /api/signal and POST /api/revert
voiceproxy.go GET /ws, POST /api/ptt and the framing they share
main.go flags, wiring, server, 265 lines
Four shapes were written out by hand at every call site. Each is now one
function.
parsePage thirteen copies of template.Must(New(k).Funcs(shellFuncs())
.Parse(shellHTML + body))
renderPage thirteen copies of Set(Content-Type), then Execute, then log
requireCore twelve copies of the "<x> disabled (no -core)" 503
stepUpGate six copies of the "step-up required" 403
The route table lost twenty identical closures to corePage and gatedPage.
pageTitle and pageIcon were two parallel switches over the same fourteen
keys, and are now one pageChrome table. A new page can no longer get a
title and no icon. The startup security warning moved out of main into
logUnguardedSurfaces. Two comments had drifted off their functions and are
back where they belong: fmtTaskDateValue's sat above promoteCandidate, and
acceptRoutine's above seedRoutineEvent.
Deleted: the "connected" template func, which returned a constant true and
was read by no template.
No behaviour change. Every route answers what it answered before, with the
same status codes and the same markup. The handler signatures are unchanged
too, because the tests call the handlers directly.
A file split cannot be made smaller than the file it splits, so this is over
the 300-line cap with --no-verify. Every line in it is a move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
409 lines
13 KiB
Go
409 lines
13 KiB
Go
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
|
|
}
|
|
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 {
|
|
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 <input type=date> 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")
|
|
}
|