1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
|
package models
import (
"testing"
"time"
)
func TestComputeNextOccurrence(t *testing.T) {
mustParse := func(t *testing.T, s string) time.Time {
t.Helper()
d, err := time.Parse("2006-01-02", s)
if err != nil {
t.Fatalf("bad fixture date %q: %v", s, err)
}
return d
}
tests := []struct {
name string
due string
freq string
interval int
weekdays []int
want string
}{
{"daily interval 1", "2026-07-13", "daily", 1, nil, "2026-07-14"},
{"daily interval 3", "2026-07-13", "daily", 3, nil, "2026-07-16"},
{"weekly no weekdays interval 1", "2026-07-13", "weekly", 1, nil, "2026-07-20"},
{"weekly no weekdays interval 2", "2026-07-13", "weekly", 2, nil, "2026-07-27"},
// 2026-07-13 is a Monday (weekday=1).
{"weekly with weekdays same week", "2026-07-13", "weekly", 1, []int{1, 3, 5}, "2026-07-15"},
{"weekly with weekdays wraps to next week", "2026-07-17", "weekly", 1, []int{1, 3, 5}, "2026-07-20"}, // due=Fri(5), wraps to Mon
{"weekly with weekdays interval 2 wraps", "2026-07-13", "weekly", 2, []int{1}, "2026-07-27"}, // due=Mon, only Mon active, skip a week
{"monthly interval 1", "2026-06-13", "monthly", 1, nil, "2026-07-13"},
{"monthly rollover", "2026-01-31", "monthly", 1, nil, "2026-03-03"},
{"monthly rollover locks in on the drifted day", "2026-03-03", "monthly", 1, nil, "2026-04-03"},
{"yearly interval 1", "2026-07-13", "yearly", 1, nil, "2027-07-13"},
{"unknown freq returns due unchanged", "2026-07-13", "bogus", 1, nil, "2026-07-13"},
{"interval below 1 is treated as 1", "2026-07-13", "daily", 0, nil, "2026-07-14"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ComputeNextOccurrence(mustParse(t, tc.due), tc.freq, tc.interval, tc.weekdays)
want := mustParse(t, tc.want)
if !got.Equal(want) {
t.Errorf("ComputeNextOccurrence(%s, %s, %d, %v) = %s, want %s", tc.due, tc.freq, tc.interval, tc.weekdays, got.Format("2006-01-02"), tc.want)
}
})
}
}
func TestCollapseRecurringSeries_KeepsOnlyEarliestOpenInstance(t *testing.T) {
sep1 := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
oct1 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
tasks := []Task{
{ID: "bills-sep", Content: "Bills", DueDate: &sep1, RecurrenceSeriesID: "bills"},
{ID: "bills-oct", Content: "Bills", DueDate: &oct1, RecurrenceSeriesID: "bills"},
{ID: "one-off", Content: "Fix curtain holder"},
}
got := CollapseRecurringSeries(tasks)
var ids []string
for _, task := range got {
ids = append(ids, task.ID)
}
if len(got) != 2 {
t.Fatalf("got %d tasks (%v), want 2: one row per recurring series plus the one-off", len(got), ids)
}
if got[0].ID != "bills-sep" {
t.Errorf("kept %q for the Bills series, want bills-sep (the earliest open instance is the one you actually owe)", got[0].ID)
}
if got[1].ID != "one-off" {
t.Errorf("non-recurring task was dropped or reordered: got %q", got[1].ID)
}
}
func TestCollapseRecurringSeries_UndatedAndDistinctSeriesUntouched(t *testing.T) {
due := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
tasks := []Task{
{ID: "a", RecurrenceSeriesID: "series-a", DueDate: &due},
{ID: "b", RecurrenceSeriesID: "series-b", DueDate: &due},
{ID: "c", RecurrenceSeriesID: "series-c"}, // recurring but undated
}
if got := CollapseRecurringSeries(tasks); len(got) != 3 {
t.Errorf("len = %d, want 3 -- distinct series must not collapse into each other", len(got))
}
}
|