// 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 { var open, cands []Ranked for _, r := range ranked { if r.Status == StatusCandidate { cands = append(cands, r) } else { open = append(open, r) } } 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() } // 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 { var open, cands []Ranked for _, r := range ranked { if r.Status == StatusCandidate { cands = append(cands, r) } else { open = append(open, r) } } 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 }