Files
Maven/internal/tasks/rank.go
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

293 lines
9.7 KiB
Go

// Package tasks ranks captured work (Vikunja #129).
//
// The ordering is COMPUTED, not generated. Asking a 1.7B model which of his
// tasks matters most would produce a fluent opinion about his life with no
// basis in anything, and a confidently wrong priority is worse than no
// priority at all — the same reasoning as the behaviour profile in
// internal/memory, which counts instead of summarising.
//
// So: four signals, all of them things he told her, and a reason string naming
// the one that decided each row. Nothing here invents urgency. A task with no
// due date and no weight scores nothing and sits where its age puts it, which
// is the honest answer to "which of these matters?" when he never said.
//
// Ranking is a READ. It sorts and renders; it never writes, schedules or
// announces. Maven is not a nag: a task rising to the top of this list is not a
// reason to speak, only the order she recites in when asked.
package tasks
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// Status values, mirroring internal/store so a caller can rank ipc.Task rows
// without importing the store.
const (
StatusCandidate = "candidate"
StatusOpen = "open"
)
// Item — one task to rank. The subset of a task that ranking depends on;
// callers map their own row type onto it.
type Item struct {
ID int64
Text string
Status string
Created time.Time
Due *time.Time
Weight int
}
// Ranked — one task with its score and the reason that decided it.
type Ranked struct {
Item
Score float64
// Reason — the dominant signal, in Russian, for the page and the spoken
// list. Empty when nothing distinguished this task: no due date, no
// weight, not old. Saying "потому что" about a task he never prioritised
// would be making something up.
Reason string
}
// Scoring weights. Deliberately coarse round numbers: this is a knob, not
// math, and the only property that has to hold is the ordering between classes
// (overdue beats today beats this week beats undated).
const (
scoreOverdue = 100 // he already missed it
scoreOverduePer = 5 // per further day late, capped
scoreOverdueCap = 40
scoreDueToday = 60
scoreDueTomorrow = 40
scoreDueWeek = 20
// 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
)
// Rank scores every item and returns them ordered: confirmed work first, then
// candidates, each by score descending, oldest first on a tie.
//
// Candidates never outrank open work, whatever their due date. A task Maven
// derived from something she read is a suggestion until he confirms it, and
// putting her guess above his own stated work would be reading his priorities
// back to him wrong.
func Rank(items []Item, now time.Time) []Ranked {
out := make([]Ranked, 0, len(items))
for _, it := range items {
score, reason := score(it, now)
out = append(out, Ranked{Item: it, Score: score, Reason: reason})
}
sort.SliceStable(out, func(i, j int) bool {
ci, cj := out[i].Status == StatusCandidate, out[j].Status == StatusCandidate
if ci != cj {
return !ci // open before candidate
}
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].Created.Before(out[j].Created) // oldest first, FIFO
})
return out
}
// score — the per-item scoring function. Returns the score and the dominant
// reason. Deadline beats weight when both are present: a date is a fact about
// the world, a weight is how he felt when he filed it.
func score(it Item, now time.Time) (float64, string) {
var total float64
reason := ""
if it.Due != nil {
days := dayDelta(*it.Due, now)
switch {
case days < 0:
late := -days
bonus := float64(late * scoreOverduePer)
if bonus > scoreOverdueCap {
bonus = scoreOverdueCap
}
total += scoreOverdue + bonus
reason = say.S(say.ReasonOverdue, nil)
if late > 0 {
// One day needs no arm of its own: «просрочено на 1 день»
// falls out of the count helper like every other number.
reason = say.S(say.ReasonOverdueDays, map[string]string{
"n": strconv.Itoa(late), "word": say.Days(late),
})
}
case days == 0:
total += scoreDueToday
reason = say.S(say.ReasonToday, nil)
case days == 1:
total += scoreDueTomorrow
reason = say.S(say.ReasonTomorrow, nil)
case days <= 7:
total += scoreDueWeek
reason = say.S(say.ReasonInDays, map[string]string{
"n": strconv.Itoa(days), "word": say.Days(days),
})
default:
total += scoreDueLater
}
}
w := it.Weight
if w > MaxWeight {
w = MaxWeight
}
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 = say.S(say.ReasonImportant, nil)
if w >= MaxWeight {
reason = say.S(say.ReasonUrgent, nil)
}
}
}
if !it.Created.IsZero() {
weeks := int(now.Sub(it.Created).Hours() / (24 * 7))
if weeks > 0 {
age := float64(weeks * scorePerWeekOld)
if age > scoreAgeCap {
age = scoreAgeCap
}
total += age
if reason == "" && weeks >= 2 {
reason = say.S(say.ReasonStale, nil)
}
}
}
return total, reason
}
// 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 := 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
// rest. A recital of twenty items is noise; five is a list he can hold.
const SpokenLimit = 5
// FormatRU renders a ranked list the way Maven says it. Confirmed work first,
// with the reason attached where there is one; candidates named as
// unconfirmed, never recited as his work.
//
// One renderer for the voice reply and the web page, for the same reason
// DayPlan.Spoken is built core-side: two formatters drift, and then she says
// one order and shows another.
func FormatRU(ranked []Ranked) string {
open, cands := split(ranked)
if len(open) == 0 && len(cands) == 0 {
return say.S(say.TasksNone, nil)
}
var b strings.Builder
if len(open) > 0 {
b.WriteString(say.S(say.TasksFirst, map[string]string{
"items": joinRU(open, SpokenLimit, true),
}))
}
if len(cands) > 0 {
// Two sentences, and the first one ends on a joined list that carries
// whatever punctuation its last task had — usually none. So the break
// is the caller's to make, not the line file's (Vikunja #521).
if b.Len() > 0 {
if !strings.HasSuffix(b.String(), ".") {
b.WriteString(".")
}
b.WriteString(" ")
}
b.WriteString(say.S(say.TasksCandidates, map[string]string{
"items": joinRU(cands, SpokenLimit, false),
}))
}
return b.String()
}
// split separates confirmed work from candidates, preserving Rank's order
// within each half. FormatRU and Spoken have to agree on where the line falls,
// so they read it from one place.
func split(ranked []Ranked) (open, cands []Ranked) {
for _, r := range ranked {
if r.Status == StatusCandidate {
cands = append(cands, r)
} else {
open = append(open, r)
}
}
return open, cands
}
// joinRU lists up to limit tasks, then says how many are left. withReasons
// attaches the parenthesised reason — candidates are listed bare, since their
// due dates are Maven's reading of a mail and not something he stated.
func joinRU(rs []Ranked, limit int, withReasons bool) string {
shown := rs
rest := 0
if len(rs) > limit {
shown, rest = rs[:limit], len(rs)-limit
}
parts := make([]string, 0, len(shown))
for _, r := range shown {
if withReasons && r.Reason != "" {
parts = append(parts, r.Text+" ("+r.Reason+")")
} else {
parts = append(parts, r.Text)
}
}
s := strings.Join(parts, "; ")
if rest > 0 {
// With the noun. Spoken, a bare number trails off mid-sentence.
s += fmt.Sprintf("; и ещё %d %s", rest, say.CountWord(rest, "задача", "задачи", "задач"))
}
return s
}
// Spoken — the tasks FormatRU actually named, in the order it named them
// (Vikunja #448). "второй" has to mean the second thing she said, so the list
// an ordinal resolves against is built here and not by a caller guessing how
// the renderer split and truncated it.
func Spoken(ranked []Ranked) []Ranked {
open, cands := split(ranked)
out := make([]Ranked, 0, 2*SpokenLimit)
for _, group := range [][]Ranked{open, cands} {
if len(group) > SpokenLimit {
group = group[:SpokenLimit]
}
out = append(out, group...)
}
return out
}