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, "; ") + "." }