07f7550931
Three defects on the server-rendered pages. /events printed both timestamps in whatever zone the value arrived in. NoticedAt is the bus's local instant; OccurredAt is the store's UTC, or a pubDate internal/rss parsed to UTC. So one row carried two zones and a feed item read hours older than it was, on a page whose hint tells him that column gap is real. /morning printed a plan item's At raw. It is a calendar fact's Ts or a reminder's FireTs, both UTC out of the store, so the same reminder named a different hour here than on /reminders — which does call Local, since V-469. promoteCandidate read the importance select inside `if due != nil`. Confirming a candidate as "срочно" with no deadline threw the word away and the row came back normal with nothing saying why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
413 lines
13 KiB
Go
413 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
|
|
}
|
|
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 <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")
|
|
}
|