Compare commits
3 Commits
ebce90b984
...
01230bf16b
| Author | SHA1 | Date | |
|---|---|---|---|
| 01230bf16b | |||
| 5447f08c06 | |||
| 85456d3833 |
+35
-19
@@ -104,14 +104,22 @@ func OptionalOnly(missing []Item) []Item {
|
||||
}
|
||||
|
||||
// Validate reports the first structural problem with a routine set: missing
|
||||
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
|
||||
// within a routine, or an out-of-range weekday. Called at config load so a
|
||||
// typo surfaces at startup, not as a silently-broken checklist at runtime.
|
||||
// name/items, an unparseable HH:MM, an inverted window, a duplicate routine or
|
||||
// item key, or an out-of-range weekday. Called at config load so a typo
|
||||
// surfaces at startup, not as a silently-broken checklist at runtime.
|
||||
//
|
||||
// Routine names must be unique because Due keys its once-a-day map by name. Two
|
||||
// routines sharing one would take turns suppressing each other's nudge.
|
||||
func Validate(routines []Routine) error {
|
||||
names := make(map[string]bool, len(routines))
|
||||
for _, r := range routines {
|
||||
if r.Name == "" {
|
||||
return fmt.Errorf("morning: name is required")
|
||||
}
|
||||
if names[r.Name] {
|
||||
return fmt.Errorf("morning routine %q: duplicate name", r.Name)
|
||||
}
|
||||
names[r.Name] = true
|
||||
if len(r.Items) == 0 {
|
||||
return fmt.Errorf("morning routine %q: at least one item is required", r.Name)
|
||||
}
|
||||
@@ -177,6 +185,17 @@ func Evaluate(r Routine, facts map[string]store.Fact, now time.Time) Status {
|
||||
return st
|
||||
}
|
||||
|
||||
// missing lists the items of r with no evidence in [start, now].
|
||||
func missing(r Routine, facts map[string]store.Fact, start, now time.Time) []Item {
|
||||
var out []Item
|
||||
for _, it := range r.Items {
|
||||
if !evidenced(it, facts, start, now) {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Outstanding reports the items of a routine that today has no evidence for,
|
||||
// whether or not the window is still open. Evaluate answers "what is missing
|
||||
// right now" and goes silent the moment the window closes; the day plan asks a
|
||||
@@ -191,13 +210,7 @@ func Outstanding(r Routine, facts map[string]store.Fact, now time.Time) []Item {
|
||||
if !ok || now.Before(start) {
|
||||
return nil
|
||||
}
|
||||
var missing []Item
|
||||
for _, it := range r.Items {
|
||||
if !evidenced(it, facts, start, now) {
|
||||
missing = append(missing, it)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
return missing(r, facts, start, now)
|
||||
}
|
||||
|
||||
// Due returns the routines that have reached their nudge time today with at
|
||||
@@ -222,24 +235,19 @@ func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.T
|
||||
if !ok || now.Before(nudgeAt) {
|
||||
continue
|
||||
}
|
||||
var missing []Item
|
||||
for _, it := range r.Items {
|
||||
if !evidenced(it, facts, start, now) {
|
||||
missing = append(missing, it)
|
||||
}
|
||||
}
|
||||
skipped := missing(r, facts, start, now)
|
||||
// A day where only the optional items were skipped is a fine day, and
|
||||
// nagging about it is what teaches him to stop listening (Vikunja
|
||||
// #473). The optional ones still travel in Missing so the message can
|
||||
// mention them when it is being sent anyway.
|
||||
if len(Required(missing)) == 0 {
|
||||
if len(Required(skipped)) == 0 {
|
||||
continue
|
||||
}
|
||||
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
|
||||
continue
|
||||
}
|
||||
last[r.Name] = now
|
||||
out = append(out, Candidate{Routine: r, Missing: missing})
|
||||
out = append(out, Candidate{Routine: r, Missing: skipped})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -284,13 +292,21 @@ func sameDay(a, b time.Time) bool {
|
||||
return ay == by && am == bm && ad == bd
|
||||
}
|
||||
|
||||
// parseHHMM reads a five-character "HH:MM". Every digit is checked as a digit:
|
||||
// arithmetic alone lets a stray character cancel out, so "2 :00" used to load
|
||||
// as 04:00 and Validate passed the typo it exists to catch.
|
||||
func parseHHMM(s string) (hour, min int, ok bool) {
|
||||
if len(s) != 5 || s[2] != ':' {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, i := range [4]int{0, 1, 3, 4} {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
h := int(s[0]-'0')*10 + int(s[1]-'0')
|
||||
m := int(s[3]-'0')*10 + int(s[4]-'0')
|
||||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
if h > 23 || m > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return h, m, true
|
||||
|
||||
@@ -56,6 +56,19 @@ func TestValidate(t *testing.T) {
|
||||
if err := Validate([]Routine{bad}); err == nil {
|
||||
t.Fatal("expected error for duplicate item key")
|
||||
}
|
||||
|
||||
// Due keys its once-a-day map by name, so two routines sharing one would
|
||||
// suppress each other's nudge instead of both firing.
|
||||
if err := Validate([]Routine{r, r}); err == nil {
|
||||
t.Fatal("expected error for duplicate routine name")
|
||||
}
|
||||
|
||||
// Arithmetic alone let a stray character cancel out: "2 :00" read as 04:00.
|
||||
bad = r
|
||||
bad.WindowStart = "2 :00"
|
||||
if err := Validate([]Routine{bad}); err == nil {
|
||||
t.Fatal("expected error for a non-digit in window_start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateInactiveOutsideWindow(t *testing.T) {
|
||||
|
||||
@@ -113,12 +113,12 @@ func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminder
|
||||
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
|
||||
var out []PlanEntry
|
||||
for _, r := range routines {
|
||||
missing := Outstanding(r, facts, now)
|
||||
if len(missing) == 0 {
|
||||
left := Outstanding(r, facts, now)
|
||||
if len(left) == 0 {
|
||||
continue
|
||||
}
|
||||
labels := make([]string, 0, len(missing))
|
||||
for _, it := range missing {
|
||||
labels := make([]string, 0, len(left))
|
||||
for _, it := range left {
|
||||
label := it.Label
|
||||
if label == "" {
|
||||
label = it.Key
|
||||
|
||||
@@ -38,13 +38,22 @@ type Routine struct {
|
||||
}
|
||||
|
||||
// Validate reports the first structural problem with a routine set: a missing
|
||||
// name/cron/body or an unparseable cron expression. Called at config load so a
|
||||
// typo surfaces at startup, not as a silently-never-firing routine at runtime.
|
||||
// name/cron/body, a duplicate name or an unparseable cron expression. Called at
|
||||
// config load so a typo surfaces at startup, not as a silently-never-firing
|
||||
// routine at runtime.
|
||||
//
|
||||
// Names must be unique because Due keys its last-fired map by name. Two
|
||||
// routines sharing one would take turns being suppressed by each other's fire.
|
||||
func Validate(routines []Routine) error {
|
||||
seen := make(map[string]bool, len(routines))
|
||||
for _, r := range routines {
|
||||
if r.Name == "" {
|
||||
return fmt.Errorf("routine: name is required")
|
||||
}
|
||||
if seen[r.Name] {
|
||||
return fmt.Errorf("routine %q: duplicate name", r.Name)
|
||||
}
|
||||
seen[r.Name] = true
|
||||
if r.Body == "" {
|
||||
return fmt.Errorf("routine %q: body is required", r.Name)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ func TestValidate(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Due keys its last-fired map by name, so two routines sharing one would
|
||||
// take turns being suppressed by the other's fire.
|
||||
if err := Validate(append(ok, ok[0])); err == nil {
|
||||
t.Error("expected an error for a duplicate name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDue(t *testing.T) {
|
||||
|
||||
+16
-16
@@ -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 {
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user