// 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" "strings" "time" ) // 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 scoreDueLater = 5 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 = "просрочено" if late == 1 { reason = "просрочено на день" } else if late > 1 { reason = fmt.Sprintf("просрочено на %d дн.", late) } case days == 0: total += scoreDueToday reason = "сегодня" case days == 1: total += scoreDueTomorrow reason = "завтра" case days <= 7: total += scoreDueWeek reason = fmt.Sprintf("через %d дн.", days) default: total += scoreDueLater } } w := it.Weight if w > MaxWeight { w = MaxWeight } if w > 0 { total += float64(w * scorePerWeight) if reason == "" { reason = "важно" } } 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 = "давно в списке" } } } return total, reason } // dayDelta — calendar days from now to due, in due's own 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. func dayDelta(due, now time.Time) int { loc := due.Location() d := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, loc) n := now.In(loc) n = time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc) return int(d.Sub(n).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 "задач нет." } var b strings.Builder if len(open) > 0 { b.WriteString("сначала: ") b.WriteString(joinRU(open, SpokenLimit, true)) b.WriteString(".") } if len(cands) > 0 { if b.Len() > 0 { b.WriteString(" ") } b.WriteString("ещё я нашла, но ты не подтвердил: ") b.WriteString(joinRU(cands, SpokenLimit, false)) b.WriteString(".") } 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 { s += fmt.Sprintf("; и ещё %d", rest) } return s }