tasks: compare due dates in the caller's day, not in UTC

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.
This commit is contained in:
kami
2026-08-01 14:16:56 +04:00
parent 708a69375f
commit 88d25d31ac
5 changed files with 287 additions and 38 deletions
+49 -21
View File
@@ -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