@@ -72,12 +75,14 @@
resolved {{len .Resolved}}
+{{if .ResolvedMore}}only the {{len .Resolved}} most recent are shown.
{{end}}
{{end}}
{{template "shellBottom"}}
diff --git a/cmd/mavweb/tasks_test.go b/cmd/mavweb/tasks_test.go
index 625143f..5f945ba 100644
--- a/cmd/mavweb/tasks_test.go
+++ b/cmd/mavweb/tasks_test.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "fmt"
"net/http"
"net/http/httptest"
"net/url"
@@ -27,7 +28,10 @@ type fakeTaskCore struct {
statusID int64
statusVal string
+ statusBy string
statusErr error
+
+ promoted bool
}
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
@@ -42,11 +46,11 @@ func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (i
if f.captureErr != nil {
return ipc.CaptureTaskResp{}, f.captureErr
}
- return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
+ return ipc.CaptureTaskResp{ID: 7, Created: f.created, Promoted: f.promoted}, nil
}
-func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
- f.statusID, f.statusVal = id, status
+func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
+ f.statusID, f.statusVal, f.statusBy = id, status, by
return f.statusErr
}
@@ -223,3 +227,115 @@ func TestApplyTaskPostClampsWeight(t *testing.T) {
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
}
}
+
+// The page ranked with the wall clock while the daemon path ranked with a clock
+// it was handed, so this was the one surface that could only be tested at
+// whatever time it happened to run.
+func TestHandleTasksRanksAtTheInjectedClock(t *testing.T) {
+ fixed := time.Date(2026, 8, 1, 10, 0, 0, 0, time.FixedZone("UTC+4", 4*3600))
+ old := now
+ now = func() time.Time { return fixed }
+ t.Cleanup(func() { now = old })
+
+ // Due tomorrow, local time, stored the way the store hands it back: UTC.
+ due := time.Date(2026, 8, 2, 0, 0, 0, 0, fixed.Location()).UTC()
+ core := &fakeTaskCore{tasks: []ipc.Task{
+ {ID: 1, Text: "оплатить интернет", Status: "open", CreatedTs: fixed, Due: &due},
+ }}
+ rec := httptest.NewRecorder()
+ handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
+ body := rec.Body.String()
+ if !strings.Contains(body, "завтра") {
+ t.Errorf("why column does not say завтра: %q", why(body))
+ }
+ if strings.Contains(body, "сегодня") || strings.Contains(body, "просрочено") {
+ t.Error("a task due tomorrow was ranked as today's or overdue")
+ }
+}
+
+// why is a crude excerpt of the rendered why column, for a readable failure.
+func why(body string) string {
+ i := strings.Index(body, "")
+ if i < 0 {
+ return body
+ }
+ j := i + 200
+ if j > len(body) {
+ j = len(body)
+ }
+ return body[i:j]
+}
+
+// The resolved section rendered every row that ever existed.
+func TestHandleTasksBoundsResolved(t *testing.T) {
+ base := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
+ var rows []ipc.Task
+ for i := 0; i < resolvedShown+10; i++ {
+ ts := base.Add(time.Duration(i) * time.Minute)
+ rows = append(rows, ipc.Task{
+ ID: int64(i + 1), Text: fmt.Sprintf("задача %d", i), Status: "done",
+ CreatedTs: ts, Resolved: &ts,
+ })
+ }
+ core := &fakeTaskCore{tasks: rows}
+ rec := httptest.NewRecorder()
+ handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
+ body := rec.Body.String()
+ if n := strings.Count(body, "задача "); n != resolvedShown {
+ t.Errorf("rendered %d resolved rows, want the %d-row bound", n, resolvedShown)
+ }
+ if !strings.Contains(body, "most recent are shown") {
+ t.Error("the page must say it is showing only part of the history")
+ }
+}
+
+// A capture over a candidate is a confirmation, not a duplicate.
+func TestHandleTasksAddSaysPromoted(t *testing.T) {
+ core := &fakeTaskCore{promoted: true}
+ form := url.Values{"action": {"add"}, "text": {"продлить страховку"}}
+ req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handleTasks(rec, req, core)
+ if !strings.Contains(rec.Body.String(), "confirmed a candidate") {
+ t.Error("a promoted capture must not read as a duplicate")
+ }
+}
+
+// Sscanf accepted "3junk" as 3, and the same call parsed the row id.
+func TestApplyTaskPostRejectsTrailingGarbage(t *testing.T) {
+ core := &fakeTaskCore{created: true}
+ form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"3junk"}}
+ req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handleTasks(rec, req, core)
+ if len(core.captured) != 0 {
+ t.Errorf("captured %+v, want nothing on a malformed weight", core.captured)
+ }
+ if !strings.Contains(rec.Body.String(), "bad weight") {
+ t.Error("error not surfaced on the page")
+ }
+
+ core = &fakeTaskCore{}
+ form = url.Values{"action": {"done"}, "id": {"42junk"}}
+ req = httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ handleTasks(httptest.NewRecorder(), req, core)
+ if core.statusID != 0 {
+ t.Errorf("SetTaskStatus called with id %d on a malformed id", core.statusID)
+ }
+}
+
+// A resolution says what resolved it: resolved_ts recorded when and never by
+// what.
+func TestHandleTasksRecordsTheCaller(t *testing.T) {
+ core := &fakeTaskCore{}
+ form := url.Values{"action": {"done"}, "id": {"42"}}
+ req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ handleTasks(httptest.NewRecorder(), req, core)
+ if core.statusBy != "tap:web" {
+ t.Errorf("resolved by %q, want tap:web", core.statusBy)
+ }
+}
diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go
index 24c31d7..e2f88fd 100644
--- a/internal/tasks/rank.go
+++ b/internal/tasks/rank.go
@@ -62,10 +62,13 @@ const (
scoreDueToday = 60
scoreDueTomorrow = 40
scoreDueWeek = 20
- scoreDueLater = 5
- scorePerWeight = 15 // "срочно" / "важно" / the web form's select
- scorePerWeekOld = 1 // so nothing rots at the bottom forever
- scoreAgeCap = 10
+ // Above scoreAgeCap on purpose: a dated task must outrank an undated one
+ // however long the undated one has sat, or the class ordering this block
+ // claims is inverted by age alone.
+ scoreDueLater = 12
+ scorePerWeight = 15 // "срочно" / "важно" / the web form's select
+ scorePerWeekOld = 1 // so nothing rots at the bottom forever
+ scoreAgeCap = 10
// MaxWeight — the highest importance hint capture accepts. Three rungs is
// as many as anyone can rank by hand honestly.
MaxWeight = 3
@@ -141,7 +144,13 @@ func score(it Item, now time.Time) (float64, string) {
if w > 0 {
total += float64(w * scorePerWeight)
if reason == "" {
+ // The rungs get their own words. The reason string is the one place
+ // the ranking explains itself, and reading "важно" back at a task
+ // he flagged "срочно" reports a word he did not say.
reason = "важно"
+ if w >= MaxWeight {
+ reason = "срочно"
+ }
}
}
@@ -161,15 +170,23 @@ func score(it Item, now time.Time) (float64, string) {
return total, reason
}
-// dayDelta — calendar days from now to due, in due's own location. Whole days,
-// not hours: a task due today is due today whether it is 09:00 or 23:00, and an
+// dayDelta — calendar days from now to due, in NOW's location. Whole days, not
+// hours: a task due today is due today whether it is 09:00 or 23:00, and an
// hours-based comparison would call this evening's task "overdue" all afternoon.
+//
+// The location has to come from now. A due date read back from the store is a
+// UTC instant (store.scanTask ends in time.UnixMilli(...).UTC()), so taking the
+// location from it compared calendar days in UTC while the page rendered the
+// same date in local time. East of Greenwich that is off by one all morning: a
+// task due tomorrow read "сегодня", and on its due date it read "просрочено на
+// день" and scored 105 instead of 60, one table cell away from a due column
+// that said otherwise.
func dayDelta(due, now time.Time) int {
- loc := due.Location()
- d := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, loc)
- n := now.In(loc)
- n = time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
- return int(d.Sub(n).Hours() / 24)
+ loc := now.Location()
+ d := due.In(loc)
+ dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
+ nn := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
+ return int(dd.Sub(nn).Hours() / 24)
}
// SpokenLimit — how many tasks the spoken list names before it summarises the
@@ -232,7 +249,22 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
}
s := strings.Join(parts, "; ")
if rest > 0 {
- s += fmt.Sprintf("; и ещё %d", rest)
+ // With the noun. Spoken, a bare number trails off mid-sentence.
+ s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest))
}
return s
}
+
+// pluralTasksRU — the right form of "задача" for a count. Russian needs three.
+func pluralTasksRU(n int) string {
+ if n%100 >= 11 && n%100 <= 14 {
+ return "задач"
+ }
+ switch n % 10 {
+ case 1:
+ return "задача"
+ case 2, 3, 4:
+ return "задачи"
+ }
+ return "задач"
+}
diff --git a/internal/tasks/rank_test.go b/internal/tasks/rank_test.go
index 227c3e1..3364e27 100644
--- a/internal/tasks/rank_test.go
+++ b/internal/tasks/rank_test.go
@@ -175,3 +175,71 @@ func TestFormatRUEmpty(t *testing.T) {
t.Errorf("reply = %q", got)
}
}
+
+// A due date read back from the store is a UTC instant, so comparing calendar
+// days in ITS location put every date a day out east of Greenwich: the row said
+// "сегодня" for a task due tomorrow, and "просрочено на день" on the due date
+// itself while the due column one cell over said otherwise.
+func TestRankComparesDaysInTheCallersLocation(t *testing.T) {
+ tz := time.FixedZone("UTC+4", 4*3600)
+ // Entered on the web form as 2026-08-02 local, stored and read back as UTC.
+ due := time.Date(2026, 8, 2, 0, 0, 0, 0, tz).UTC()
+ local := time.Date(2026, 8, 1, 10, 0, 0, 0, tz)
+
+ got := Rank([]Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: &due, Created: local}}, local)
+ if got[0].Reason != "завтра" {
+ t.Errorf("reason = %q, want завтра on the day before", got[0].Reason)
+ }
+ // The morning of the due date itself.
+ onTheDay := time.Date(2026, 8, 2, 10, 0, 0, 0, tz)
+ got = Rank([]Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: &due, Created: local}}, onTheDay)
+ if got[0].Reason != "сегодня" {
+ t.Errorf("reason = %q, want сегодня on the due date", got[0].Reason)
+ }
+ if got[0].Score != scoreDueToday {
+ t.Errorf("score = %v, want %v", got[0].Score, float64(scoreDueToday))
+ }
+}
+
+// "срочно" and "важно" are two rungs and the read-back said "важно" for both,
+// which reports a word he did not say.
+func TestRankNamesTheUrgencyHeStated(t *testing.T) {
+ got := Rank([]Item{
+ {ID: 1, Text: "оплатить интернет", Status: StatusOpen, Weight: 3, Created: now()},
+ {ID: 2, Text: "починить кран", Status: StatusOpen, Weight: 2, Created: now()},
+ }, now())
+ if got[0].Reason != "срочно" {
+ t.Errorf("reason = %q, want срочно", got[0].Reason)
+ }
+ if got[1].Reason != "важно" {
+ t.Errorf("reason = %q, want важно", got[1].Reason)
+ }
+}
+
+// The package doc guarantees a class ordering. Age used to invert it: an
+// undated task at the age cap outscored a dated one three weeks out.
+func TestRankDatedWorkBeatsAgeAlone(t *testing.T) {
+ got := Rank([]Item{
+ {ID: 1, Text: "старьё", Status: StatusOpen, Created: now().AddDate(0, 0, -70)},
+ {ID: 2, Text: "через три недели", Status: StatusOpen, Due: at(2026, 8, 22), Created: now()},
+ }, now())
+ if got[0].Text != "через три недели" {
+ t.Errorf("order = %v, want the dated task first", texts(got))
+ }
+}
+
+// A bare "и ещё 5" trails off when spoken.
+func TestFormatRUTailCarriesTheNoun(t *testing.T) {
+ var items []Item
+ for i := 0; i < SpokenLimit+3; i++ {
+ items = append(items, Item{ID: int64(i), Text: "дело", Status: StatusOpen, Created: now()})
+ }
+ if got := FormatRU(Rank(items, now())); !strings.Contains(got, "и ещё 3 задачи") {
+ t.Errorf("reply = %q, want the count with its noun", got)
+ }
+ for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} {
+ if got := pluralTasksRU(n); got != want {
+ t.Errorf("pluralTasksRU(%d) = %q, want %q", n, got, want)
+ }
+ }
+}
|