Merge branch 'fix/g05' into fix/integrated
This commit is contained in:
+49
-21
@@ -18,6 +18,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -904,17 +905,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.
|
||||
@@ -962,6 +975,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":
|
||||
@@ -970,10 +984,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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -982,7 +1004,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,
|
||||
@@ -1001,11 +1023,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)
|
||||
}
|
||||
}
|
||||
@@ -1020,12 +1043,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 {
|
||||
@@ -1034,7 +1059,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)
|
||||
}
|
||||
@@ -1044,14 +1069,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
|
||||
@@ -1065,7 +1093,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
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<input type=hidden name=action value=add>
|
||||
<input type=text name=text placeholder="что нужно сделать" size=44 required>
|
||||
<input type=date name=due title="due date (optional)">
|
||||
<!-- weight 1 is skipped on purpose: the two rungs here are the two words she
|
||||
recognises out loud ("важно", "срочно"), so the form and the spoken markers
|
||||
mean the same thing. -->
|
||||
<select name=weight title="importance (optional)">
|
||||
<option value=0>normal</option>
|
||||
<option value=2>важно</option>
|
||||
@@ -43,7 +46,7 @@
|
||||
|
||||
<section class=card>
|
||||
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div>
|
||||
<div class=hint>most pressing first — by the deadlines and the urgency you gave. nothing about a task is guessed; the only signal that is not yours is age, which lifts anything sitting here for weeks.</div>
|
||||
{{if .Open}}<div class=scroll><table>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Open}}<tr>
|
||||
@@ -72,12 +75,14 @@
|
||||
<section class=card>
|
||||
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>task</th><th>status</th><th>when</th></tr>
|
||||
<tr><th>task</th><th>status</th><th>when</th><th>by</th></tr>
|
||||
{{range .Resolved}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td class=muted>{{.Resolved}}</td>
|
||||
<td class=hint>{{.ResolvedBy}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{if .ResolvedMore}}<div class=hint>only the {{len .Resolved}} most recent are shown.</div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}
|
||||
|
||||
+119
-3
@@ -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, "<td class=hint>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user