From 85456d383333a40c0ea60a28e370884cfdd1493f Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 03:12:28 +0400 Subject: [PATCH] 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. --- internal/tasks/rank.go | 32 ++++++++++++++++---------------- internal/tasks/stall.go | 10 ++++++++-- internal/tasks/stall_test.go | 21 +++++++++++++++++++++ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go index cafdd9f..7bc525e 100644 --- a/internal/tasks/rank.go +++ b/internal/tasks/rank.go @@ -208,14 +208,7 @@ const SpokenLimit = 5 // 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) - } - } + open, cands := split(ranked) if len(open) == 0 && len(cands) == 0 { return say.S(say.TasksNone, nil) } @@ -243,6 +236,20 @@ func FormatRU(ranked []Ranked) string { 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. @@ -273,14 +280,7 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string { // 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) - } - } + open, cands := split(ranked) out := make([]Ranked, 0, 2*SpokenLimit) for _, group := range [][]Ranked{open, cands} { if len(group) > SpokenLimit { diff --git a/internal/tasks/stall.go b/internal/tasks/stall.go index 63ff9ad..08f7015 100644 --- a/internal/tasks/stall.go +++ b/internal/tasks/stall.go @@ -56,10 +56,16 @@ func Stalls(items []Item, now time.Time) []Stall { // meant to act on. continue } - if it.Due != nil && it.Due.Before(now) { + // Whole calendar days, the reading Rank already uses. An instant + // comparison calls a task due at 18:00 overdue from 18:01, so the count + // above the table would say "просрочено" beside a row whose own reason + // still said "сегодня". + if it.Due != nil && dayDelta(*it.Due, now) < 0 { overdue++ } - if now.Sub(it.Created) >= StallDays*24*time.Hour { + // A row with no capture time has not sat for anything. Without the + // guard its zero time is January of year 1 and it always counts. + if !it.Created.IsZero() && now.Sub(it.Created) >= StallDays*24*time.Hour { sitting++ } } diff --git a/internal/tasks/stall_test.go b/internal/tasks/stall_test.go index 7a3ddec..d082c55 100644 --- a/internal/tasks/stall_test.go +++ b/internal/tasks/stall_test.go @@ -54,6 +54,27 @@ func TestStallsSaysNothingWhenThereIsNothing(t *testing.T) { } } +func TestStallsCountsOverdueByCalendarDay(t *testing.T) { + // Same reading as Rank, or the count above the table contradicts the reason + // in the row: due at 09:00, asked at 12:00, and it is still due today. + now := stallNow() + earlier := now.Add(-3 * time.Hour) + items := []Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-time.Hour), Due: &earlier}} + if got := Stalls(items, now); len(got) != 0 { + t.Fatalf("shapes = %+v, want none — a task due today is not overdue", got) + } +} + +func TestStallsIgnoresATaskWithNoCaptureTime(t *testing.T) { + // The zero time is January of year 1, so an unstamped row would count as + // sitting forever. + now := stallNow() + items := []Item{{ID: 1, Text: "купить молоко", Status: StatusOpen}} + if got := Stalls(items, now); len(got) != 0 { + t.Fatalf("shapes = %+v, want none", got) + } +} + func TestStallsCountsNoJudgement(t *testing.T) { // The line this shape may not cross. Every sentence states a count; none of // them says whether the work matters or should be dropped.