diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go index e6ddb05..b73ceb2 100644 --- a/cmd/mavend/actions_task.go +++ b/cmd/mavend/actions_task.go @@ -82,7 +82,17 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text}) } h.offerCandidates(ctx, cands) - return tasks.FormatRU(ranked), true + reply := tasks.FormatRU(ranked) + // The counted shapes, after the list and only when there are any (V-512). + // They answer "what is going wrong with this list" without assessing any of + // it, and they are said here rather than announced: no tick rule reads them. + if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" { + if !strings.HasSuffix(reply, ".") { + reply += "." + } + reply += " " + stalls + } + return reply, true } // resolveTaskStatus moves a task he named out loud (Vikunja #512). diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index a36b70a..510faca 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -979,11 +979,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 + Stalls []tasks.Stall Candidates []taskRow Open []taskRow Resolved []taskRow ResolvedMore bool - }{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil { + }{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}); err != nil { log.Printf("tasks render: %v", err) } } diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html index 70f6797..e6daba3 100644 --- a/cmd/mavweb/tasks.html +++ b/cmd/mavweb/tasks.html @@ -21,6 +21,18 @@ +{{if .Stalls}} +
+

shapes

+ +
+ +{{range .Stalls}}{{end}} +
countshape
{{.N}}{{.Line}}
+
+{{end}} + {{if .Candidates}}

found, not confirmed {{len .Candidates}}

diff --git a/internal/say/summary.go b/internal/say/summary.go index e4f6791..0ae0cfa 100644 --- a/internal/say/summary.go +++ b/internal/say/summary.go @@ -36,6 +36,13 @@ const ( TasksFirst = "tasks_first" TasksCandidates = "tasks_candidates" + // The counted stall shapes on /tasks and in the spoken list (V-512). Each + // one states a count and nothing about what it means: "лежит дольше десяти + // дней" is arithmetic, "стоит бросить" would be a judgement she may not make. + StallOverdue = "stall_overdue" + StallSitting = "stall_sitting" + StallUnconfirmed = "stall_unconfirmed" + ReasonOverdue = "reason_overdue" ReasonOverdueDays = "reason_overdue_days" ReasonToday = "reason_today" @@ -64,6 +71,7 @@ const ( var summaryKeys = []string{ PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain, TasksNone, TasksFirst, TasksCandidates, + StallOverdue, StallSitting, StallUnconfirmed, ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow, ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale, HabitWeekday, HabitWeekdaySame, HabitWeekdayNone, @@ -86,6 +94,10 @@ var summaryFloor = map[string]string{ TasksFirst: "сначала: {items}", TasksCandidates: "нашла ещё, но ты не подтверждал: {items}", + StallOverdue: "{n} {word} просрочено", + StallSitting: "{n} {word} лежит дольше {days} {dayword}", + StallUnconfirmed: "{n} {word} ждёт подтверждения", + ReasonOverdue: "просрочено", ReasonOverdueDays: "просрочено на {n} {word}", ReasonToday: "сегодня", @@ -128,6 +140,9 @@ func LoadSummaries(src rand.Source) (*Summaries, error) { {PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"}, {PlanUncertain, "{line}"}, {TasksFirst, "{items}"}, {TasksCandidates, "{items}"}, + {StallOverdue, "{n}"}, {StallOverdue, "{word}"}, + {StallSitting, "{n}"}, {StallSitting, "{days}"}, + {StallUnconfirmed, "{n}"}, {StallUnconfirmed, "{word}"}, {ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"}, {ReasonInDays, "{n}"}, {ReasonInDays, "{word}"}, {HabitWeekday, "{day}"}, {HabitWeekday, "{items}"}, diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json index daed24e..74048fc 100644 --- a/internal/say/summary_ru_v1.json +++ b/internal/say/summary_ru_v1.json @@ -44,6 +44,19 @@ "variants": ["нашла ещё, но ты не подтверждал: {items}"] }, + "stall_overdue": { + "fixed": true, + "variants": ["{n} {word} просрочено"] + }, + "stall_sitting": { + "fixed": true, + "variants": ["{n} {word} лежит дольше {days} {dayword}"] + }, + "stall_unconfirmed": { + "fixed": true, + "variants": ["{n} {word} ждёт подтверждения"] + }, + "reason_overdue": { "fixed": true, "variants": ["просрочено"] diff --git a/internal/tasks/stall.go b/internal/tasks/stall.go new file mode 100644 index 0000000..63ff9ad --- /dev/null +++ b/internal/tasks/stall.go @@ -0,0 +1,99 @@ +package tasks + +import ( + "fmt" + "strings" + "time" + + "github.com/kami/maven/internal/say" +) + +// Counted stall shapes (Vikunja #512, step 5 of docs/plans/15-board-surface.md). +// +// She may count. She may not assess. Every shape here is arithmetic over rows he +// can see — how many, how long, how many undated — and none of it says whether a +// task matters, whether a blocker is real, or whether something should be +// dropped. That is the line internal/memory/behavior.go already drew for habits +// and the reason is the same: a 1.7B asked to judge will agree fluently and +// launder a guess into a decision. +// +// Not a nag either. Nothing here is read by the tick loop; the counts go on +// /tasks and into the answer when he asks for the list. tickLoop.dayPlan +// deliberately does not read tasks — keep it that way. + +// StallDays — how long a live task has to have sat before it is counted as +// sitting. Ten days rather than a week, because a task captured on a Friday and +// still open the next Friday is an ordinary week, not a stall. +// +// Measured from Created, which is the only clock a live row carries: the store +// stamps resolved_ts and nothing else, so "no state change in eleven days" is +// exactly "captured eleven days ago and still live". That is a narrower claim +// than the plan's wording and it is the one the data supports. +const StallDays = 10 + +// Stall — one counted shape: how many tasks, and the sentence that says what +// they have in common. N is always ≥ 1; a shape with no tasks in it is not +// returned, because "нет просроченных" is a reassurance nobody asked for. +type Stall struct { + N int + Line string +} + +// Stalls counts the shapes present in a set of live tasks, in a fixed order: +// overdue first, then sitting, then unconfirmed. Fixed because the order is what +// he reads first, and sorting by count would move the sections around every time +// one number changed. +// +// Resolved tasks are not passed in and would not be counted if they were: this +// is a statement about outstanding work. +func Stalls(items []Item, now time.Time) []Stall { + var overdue, sitting, unconfirmed int + for _, it := range items { + if it.Status == StatusCandidate { + unconfirmed++ + // A candidate is Maven's reading of something she read. Counting it as + // overdue would put her own guess about a deadline in a number he is + // meant to act on. + continue + } + if it.Due != nil && it.Due.Before(now) { + overdue++ + } + if now.Sub(it.Created) >= StallDays*24*time.Hour { + sitting++ + } + } + var out []Stall + add := func(n int, key string, args map[string]string) { + if n == 0 { + return + } + out = append(out, Stall{N: n, Line: say.S(key, args)}) + } + add(overdue, say.StallOverdue, map[string]string{ + "n": fmt.Sprint(overdue), "word": say.CountWord(overdue, "задача", "задачи", "задач"), + }) + add(sitting, say.StallSitting, map[string]string{ + "n": fmt.Sprint(sitting), "word": say.CountWord(sitting, "задача", "задачи", "задач"), + "days": fmt.Sprint(StallDays), "dayword": say.Days(StallDays), + }) + add(unconfirmed, say.StallUnconfirmed, map[string]string{ + "n": fmt.Sprint(unconfirmed), "word": say.CountWord(unconfirmed, "задача", "задачи", "задач"), + }) + return out +} + +// StallsRU joins the shapes into one sentence for the spoken list, or "" when +// there are none. Empty on purpose: the list read is the answer to his question, +// and appending "ничего не залежалось" to it every time is a nag with a friendly +// face. +func StallsRU(stalls []Stall) string { + if len(stalls) == 0 { + return "" + } + parts := make([]string, 0, len(stalls)) + for _, s := range stalls { + parts = append(parts, s.Line) + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/tasks/stall_test.go b/internal/tasks/stall_test.go new file mode 100644 index 0000000..7a3ddec --- /dev/null +++ b/internal/tasks/stall_test.go @@ -0,0 +1,68 @@ +package tasks + +import ( + "strings" + "testing" + "time" +) + +func stallNow() time.Time { return time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) } + +func TestStallsCountsTheThreeShapes(t *testing.T) { + now := stallNow() + day := 24 * time.Hour + yesterday := now.Add(-day) + items := []Item{ + // Overdue and sitting at once: it counts in both, because they are two + // different things wrong with one task. + {ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-20 * day), Due: &yesterday}, + {ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-12 * day)}, + {ID: 3, Text: "позвонить маме", Status: StatusOpen, Created: now.Add(-time.Hour)}, + {ID: 4, Text: "продлить домен", Status: StatusCandidate, Created: now.Add(-30 * day), Due: &yesterday}, + } + got := Stalls(items, now) + if len(got) != 3 { + t.Fatalf("shapes = %+v, want overdue, sitting, unconfirmed", got) + } + if got[0].N != 1 { + t.Errorf("overdue = %d, want 1 — a candidate's due date is Maven's reading of a mail", got[0].N) + } + if got[1].N != 2 { + t.Errorf("sitting = %d, want 2", got[1].N) + } + if got[2].N != 1 { + t.Errorf("unconfirmed = %d, want 1", got[2].N) + } + line := StallsRU(got) + for _, want := range []string{"просрочено", "лежит", "подтверждения"} { + if !strings.Contains(line, want) { + t.Errorf("StallsRU = %q, want it to mention %q", line, want) + } + } +} + +func TestStallsSaysNothingWhenThereIsNothing(t *testing.T) { + now := stallNow() + items := []Item{{ID: 1, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-time.Hour)}} + if got := Stalls(items, now); len(got) != 0 { + t.Fatalf("shapes = %+v, want none", got) + } + // "ничего не залежалось" appended to every list read is a nag with a + // friendly face, so the empty case renders as nothing at all. + if got := StallsRU(nil); got != "" { + t.Errorf("StallsRU(nil) = %q, want empty", got) + } +} + +func TestStallsCountsNoJudgement(t *testing.T) { + // The line this shape may not cross. Every sentence states a count; none of + // them says whether the work matters or should be dropped. + now := stallNow() + items := []Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-40 * 24 * time.Hour)}} + line := StallsRU(Stalls(items, now)) + for _, banned := range []string{"стоит", "лучше", "надо", "брось", "важно"} { + if strings.Contains(line, banned) { + t.Errorf("StallsRU = %q — %q is an assessment, and counting is the whole licence here", line, banned) + } + } +}