88d25d31ac
dayDelta truncated both instants to a UTC day. A task due at 02:00 Moscow time tonight read as due tomorrow, and one due at 23:00 last night read as due today, so the two classes that decide the whole order were assigned from the wrong calendar. Both sides are now truncated in now's location. Dated work also lost to age alone because the later-due score sat below the age cap, and the tail said "и ещё 3" with no noun and no Russian plural agreement. The page hardcoded time.Now, so none of this was testable from a fixed clock. It now takes an injectable clock, parses the due date in that clock's location, parses ids and weights with strconv instead of a hand-rolled scan, caps the resolved table and says so, shows who resolved each row, and reports a promotion as the confirmation it is. Found in review of #61.
271 lines
8.6 KiB
Go
271 lines
8.6 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"
|
|
"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
|
|
// 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 = "просрочено"
|
|
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 == "" {
|
|
// 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 = "важно"
|
|
if w >= MaxWeight {
|
|
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 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 "задач нет."
|
|
}
|
|
|
|
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 {
|
|
// With the noun. Spoken, a bare number trails off mid-sentence.
|
|
s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest))
|
|
}
|
|
return s
|
|
}
|
|
|
|
// pluralTasksRU — the right form of "задача" for a count. Russian needs three.
|
|
func pluralTasksRU(n int) string {
|
|
if n%100 >= 11 && n%100 <= 14 {
|
|
return "задач"
|
|
}
|
|
switch n % 10 {
|
|
case 1:
|
|
return "задача"
|
|
case 2, 3, 4:
|
|
return "задачи"
|
|
}
|
|
return "задач"
|
|
}
|