tasks: compare due dates in the caller's day, not in UTC
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.
This commit is contained in:
+44
-12
@@ -62,10 +62,13 @@ const (
|
||||
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
|
||||
// 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
|
||||
@@ -141,7 +144,13 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
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 = "срочно"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,15 +170,23 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
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
|
||||
// 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 := 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)
|
||||
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
|
||||
@@ -232,7 +249,22 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
|
||||
}
|
||||
s := strings.Join(parts, "; ")
|
||||
if rest > 0 {
|
||||
s += fmt.Sprintf("; и ещё %d", rest)
|
||||
// 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 "задач"
|
||||
}
|
||||
|
||||
@@ -175,3 +175,71 @@ func TestFormatRUEmpty(t *testing.T) {
|
||||
t.Errorf("reply = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A due date read back from the store is a UTC instant, so comparing calendar
|
||||
// days in ITS location put every date a day out east of Greenwich: the row said
|
||||
// "сегодня" for a task due tomorrow, and "просрочено на день" on the due date
|
||||
// itself while the due column one cell over said otherwise.
|
||||
func TestRankComparesDaysInTheCallersLocation(t *testing.T) {
|
||||
tz := time.FixedZone("UTC+4", 4*3600)
|
||||
// Entered on the web form as 2026-08-02 local, stored and read back as UTC.
|
||||
due := time.Date(2026, 8, 2, 0, 0, 0, 0, tz).UTC()
|
||||
local := time.Date(2026, 8, 1, 10, 0, 0, 0, tz)
|
||||
|
||||
got := Rank([]Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: &due, Created: local}}, local)
|
||||
if got[0].Reason != "завтра" {
|
||||
t.Errorf("reason = %q, want завтра on the day before", got[0].Reason)
|
||||
}
|
||||
// The morning of the due date itself.
|
||||
onTheDay := time.Date(2026, 8, 2, 10, 0, 0, 0, tz)
|
||||
got = Rank([]Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: &due, Created: local}}, onTheDay)
|
||||
if got[0].Reason != "сегодня" {
|
||||
t.Errorf("reason = %q, want сегодня on the due date", got[0].Reason)
|
||||
}
|
||||
if got[0].Score != scoreDueToday {
|
||||
t.Errorf("score = %v, want %v", got[0].Score, float64(scoreDueToday))
|
||||
}
|
||||
}
|
||||
|
||||
// "срочно" and "важно" are two rungs and the read-back said "важно" for both,
|
||||
// which reports a word he did not say.
|
||||
func TestRankNamesTheUrgencyHeStated(t *testing.T) {
|
||||
got := Rank([]Item{
|
||||
{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Weight: 3, Created: now()},
|
||||
{ID: 2, Text: "починить кран", Status: StatusOpen, Weight: 2, Created: now()},
|
||||
}, now())
|
||||
if got[0].Reason != "срочно" {
|
||||
t.Errorf("reason = %q, want срочно", got[0].Reason)
|
||||
}
|
||||
if got[1].Reason != "важно" {
|
||||
t.Errorf("reason = %q, want важно", got[1].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// The package doc guarantees a class ordering. Age used to invert it: an
|
||||
// undated task at the age cap outscored a dated one three weeks out.
|
||||
func TestRankDatedWorkBeatsAgeAlone(t *testing.T) {
|
||||
got := Rank([]Item{
|
||||
{ID: 1, Text: "старьё", Status: StatusOpen, Created: now().AddDate(0, 0, -70)},
|
||||
{ID: 2, Text: "через три недели", Status: StatusOpen, Due: at(2026, 8, 22), Created: now()},
|
||||
}, now())
|
||||
if got[0].Text != "через три недели" {
|
||||
t.Errorf("order = %v, want the dated task first", texts(got))
|
||||
}
|
||||
}
|
||||
|
||||
// A bare "и ещё 5" trails off when spoken.
|
||||
func TestFormatRUTailCarriesTheNoun(t *testing.T) {
|
||||
var items []Item
|
||||
for i := 0; i < SpokenLimit+3; i++ {
|
||||
items = append(items, Item{ID: int64(i), Text: "дело", Status: StatusOpen, Created: now()})
|
||||
}
|
||||
if got := FormatRU(Rank(items, now())); !strings.Contains(got, "и ещё 3 задачи") {
|
||||
t.Errorf("reply = %q, want the count with its noun", got)
|
||||
}
|
||||
for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} {
|
||||
if got := pluralTasksRU(n); got != want {
|
||||
t.Errorf("pluralTasksRU(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user