Files
Maven/internal/tasks/stall.go
T
claude 85456d3833 tasks: count overdue by calendar day like the ranker does (V-581)
Stalls compared the due instant to now while Rank compares whole calendar days, so a task due at 09:00 was counted overdue from 09:01 while its own row on the same page still read the reason as today. Stalls now reads dayDelta.

A row with no capture time also counted as sitting, because the zero time is January of year 1 and every span from it clears ten days. Rank already guarded that and Stalls did not.

Folded the open-versus-candidate partition FormatRU and Spoken each carried into one split helper. The two have to agree on where that line falls.
2026-08-06 03:12:28 +04:00

106 lines
4.1 KiB
Go

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
}
// Whole calendar days, the reading Rank already uses. An instant
// comparison calls a task due at 18:00 overdue from 18:01, so the count
// above the table would say "просрочено" beside a row whose own reason
// still said "сегодня".
if it.Due != nil && dayDelta(*it.Due, now) < 0 {
overdue++
}
// A row with no capture time has not sat for anything. Without the
// guard its zero time is January of year 1 and it always counts.
if !it.Created.IsZero() && 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, "; ") + "."
}