From 88d25d31ac3bc571435abfdde1789927340d8b54 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:16:56 +0400 Subject: [PATCH] tasks: compare due dates in the caller's day, not in UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dayDelta truncated both instants to a UTC day. A task due at 02:00 Moscow time tonight read as due tomorrow, and one due at 23:00 last night read as due today, so the two classes that decide the whole order were assigned from the wrong calendar. Both sides are now truncated in now's location. Dated work also lost to age alone because the later-due score sat below the age cap, and the tail said "и ещё 3" with no noun and no Russian plural agreement. The page hardcoded time.Now, so none of this was testable from a fixed clock. It now takes an injectable clock, parses the due date in that clock's location, parses ids and weights with strconv instead of a hand-rolled scan, caps the resolved table and says so, shows who resolved each row, and reports a promotion as the confirmation it is. Found in review of #61. --- cmd/mavweb/main.go | 70 ++++++++++++++------- cmd/mavweb/tasks.html | 9 ++- cmd/mavweb/tasks_test.go | 122 +++++++++++++++++++++++++++++++++++- internal/tasks/rank.go | 56 +++++++++++++---- internal/tasks/rank_test.go | 68 ++++++++++++++++++++ 5 files changed, 287 insertions(+), 38 deletions(-) diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 17e49af..f0c8b42 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -18,6 +18,7 @@ import ( "net/url" "os" "os/signal" + "strconv" "strings" "time" @@ -865,17 +866,29 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } } +// 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 + ID int64 + Text string + Source string + Evidence string + Status string + Due string + Created string + Resolved string + ResolvedBy string // 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. @@ -923,6 +936,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { // 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": @@ -931,10 +945,18 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { 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, 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, }) } } @@ -943,7 +965,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { byID[t.ID] = t } var cands, open []taskRow - for _, r := range tasks.Rank(live, time.Now()) { + for _, r := range tasks.Rank(live, now()) { t := byID[r.ID] row := taskRow{ ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, @@ -962,11 +984,12 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } 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 { + Msg, Err string + Candidates []taskRow + Open []taskRow + Resolved []taskRow + ResolvedMore bool + }{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil { log.Printf("tasks render: %v", err) } } @@ -981,12 +1004,14 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri if text == "" { return "", errors.New("empty task text") } - req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.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 // clamped rather than rejected — a bad select is not worth a 400. if v := r.FormValue("weight"); v != "" { - var wgt int - if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 { + // 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 { @@ -995,7 +1020,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri req.Weight = wgt } if d := r.FormValue("due"); d != "" { - due, err := time.ParseInLocation("2006-01-02", d, time.Local) + due, err := time.ParseInLocation("2006-01-02", d, now().Location()) if err != nil { return "", fmt.Errorf("bad due date %q", d) } @@ -1005,14 +1030,17 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri 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 } - var id int64 - if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 { + id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) + if err != nil { return "", errors.New("invalid id") } var status, msg string @@ -1026,7 +1054,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri default: return "", fmt.Errorf("unknown action %q", action) } - if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil { + if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil { return "", err } return msg, nil diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html index 8194506..70f6797 100644 --- a/cmd/mavweb/tasks.html +++ b/cmd/mavweb/tasks.html @@ -9,6 +9,9 @@ +