Capture tasks, with one intake seam mail can call later (#130)

A task is not a fact and not a note. A fact is a claim about the world that a
correction supersedes; a note is something to recall by meaning. A task is work
with a lifecycle, and the read that matters is "everything outstanding right
now" — which over an append-only log would mean replaying history on every
question. So: a tasks table, migration #14, statuses candidate/open/done/dropped
that each move forward exactly once.

Dedupe is on normalised text among LIVE rows only, via a partial unique index.
That is the property the mail side needs: an extractor may call CaptureTask for
every message it reads, as often as it likes, without growing the list — while a
weekly errand is still capturable again once the last one is done.

Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path
(router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never
"надо бы поспать"), the /tasks form, and the email reader from #246 when it
exists. Mail-derived items set Source "email:<account>", Status "candidate" and
Evidence to whatever makes the row reviewable; a candidate is inert until he
confirms it on /tasks, and Maven names it as unconfirmed when she recites the
list rather than putting words in his mouth.

No new intent — the router enum is a contract with the relabelling prompt, so
capture rides the note intent and the list rides a query source, both matched
deterministically like the calendar and plan matchers already are.

Nothing here speaks. No tick rule reads tasks; the list is answered when asked
about, which is why /tasks POST is not step-up gated the way /tools and
/routines are — a task write moves no boundary.

Vikunja #130
This commit is contained in:
kami
2026-08-01 02:33:47 +04:00
parent c8444813e2
commit 7b2b96b957
18 changed files with 1584 additions and 1 deletions
+154
View File
@@ -58,6 +58,9 @@ var notificationsHTML string
//go:embed reminders.html
var remindersHTML string
//go:embed tasks.html
var tasksHTML string
//go:embed voice.html
var voiceHTML string
@@ -97,6 +100,7 @@ var sidebarSections = []struct {
Pages: []struct{ Label, URL, Key string }{
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
{Label: "Routines", URL: "/routines", Key: "routines"},
{Label: "Morning", URL: "/morning", Key: "morning"},
@@ -170,6 +174,8 @@ func pageIcon(key string) string {
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
case "notifications":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
case "tasks":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "reminders":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
case "routines":
@@ -202,6 +208,8 @@ func pageTitle(key string) string {
return "Rule Trace"
case "notifications":
return "Notifications"
case "tasks":
return "Tasks"
case "reminders":
return "Reminders"
case "routines":
@@ -408,6 +416,11 @@ func main() {
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
handleReminders(w, r, core)
})
// /tasks — capture + review. POST is not step-up gated; see handleTasks for
// why a task write is not in the same class as /tools or /routines.
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
handleTasks(w, r, core)
})
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
handleMorning(w, r, core)
})
@@ -744,6 +757,8 @@ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Pars
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
@@ -814,6 +829,145 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
}
}
// 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
}
// handleTasks serves the task review surface (GET) and the four writes it
// offers (POST): add, 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.
//
// "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 core == nil {
http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable)
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
}
var cands, open, resolved []taskRow
for _, t := range all {
row := 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),
}
switch t.Status {
case "candidate":
cands = append(cands, row)
case "open":
open = append(open, row)
default:
resolved = append(resolved, row)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tasksTmpl.Execute(w, struct {
Msg, Err string
Candidates []taskRow
Open []taskRow
Resolved []taskRow
}{msg, errMsg, cands, open, resolved}); err != nil {
log.Printf("tasks render: %v", err)
}
}
// 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: time.Now()}
if d := r.FormValue("due"); d != "" {
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
if err != nil {
return "", fmt.Errorf("bad due date %q", d)
}
req.Due = &due
}
resp, err := core.CaptureTask(ctx, req)
if err != nil {
return "", err
}
if !resp.Created {
return "already on the list", nil
}
return "added task", nil
}
var id int64
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
return "", errors.New("invalid id")
}
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, time.Now()); err != nil {
return "", err
}
return msg, nil
}
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")
}
// routineRow is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it.
type routineRow struct {