feat: parse and project entries

This commit is contained in:
xuu 2025-02-03 19:08:08 -07:00
commit 3b75b960e0
Signed by: xuu
GPG Key ID: 8B3B0604F164E04F
11 changed files with 2682 additions and 0 deletions

0
README.md Normal file

602
entry.go Normal file

@ -0,0 +1,602 @@
package main
import (
"fmt"
"iter"
"maps"
"slices"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
// 2024-01-01 12:01 [ ] (A) Do this thing @daily interval:2 count:6 until:2024-12-31
// 2024-01-01 12:01 [ ] (A) Do this thing @daily interval:2 count:6 until:2024-12-31 id:1
// 2024-01-01 12:01 [x] (A) Do this thing @daily interval:2 count:6 until:2024-12-31 id:1 completed:2024-01-01
// 2024-01-01 12:01 [ ] (A) Do this thing @daily interval:2 count:6 until:2024-12-31 id:2
// # Complete the entire series.
// 2024-01-01 12:01 [x] (A) Do this thing @daily interval:2 count:6 until:2024-12-31 completed:2024-12-31
// # multi line
// 2024-01-01
// [ ] (A) Do this thing @high
// [ ] (B) Do that thing @medium
// 12:01-13:01 read emails @low
// ## Time formats ##
// HH - start hour
// HH:MM - start hour and minute
// HH-HH - start and end hour
// HH:MM-HH:MM - start and end hour and minute
// ## To Do ##
// [ ] not done
// [!] at risk ?
// [x] done
// ## Todo Priority ##
// (A) High
// (B) Medium
// (C) Low
// None
// ## Repeat frequencies ##
// @yearly
// @monthly
// @weekly
// @daily
// ## Repeat modifiers ##
// interval:N
// count:N
// until:YYYY-MM-DD
type Entry struct {
Title string
Note string
Projects Set[string]
Contexts Set[string]
Attributes Attributes
Slate
Task
Event
}
func (e *Entry) Parse(text string) (int, error) {
i, err := e.Slate.Parse(text)
if err != nil {
i += trimSpace(text[i:])
// return 0, err
}
if strings.HasPrefix(text[i:], "- ") {
i++
i += trimSpace(text[i:])
e.Note = text[i:]
i += len(text[i:])
return i, nil
}
di, err := e.Task.Parse(text[i:])
if err == nil {
i += di
}
di, err = e.Event.Parse(text[i:])
if err == nil {
i += di
}
text = text[i:]
if len(text) > 0 {
i += len(text)
e.Title = text
}
// parse attributes
e.Projects = make(Set[string])
e.Contexts = make(Set[string])
e.Attributes = make(Attributes)
for _, field := range strings.Fields(e.Title) {
if strings.HasPrefix(field, "@") {
e.Contexts.Add(field[1:])
continue
}
if strings.HasPrefix(field, "+") {
e.Projects.Add(field[1:])
continue
}
if key, value, ok := strings.Cut(field, ":"); ok {
e.Attributes[key] = append(e.Attributes[key], value)
}
}
switch {
case e.Contexts.Has("yearly"):
e.Slate.Frequency = Yearly
case e.Contexts.Has("monthly"):
e.Slate.Frequency = Monthly
case e.Contexts.Has("weekly"):
e.Slate.Frequency = Weekly
case e.Contexts.Has("daily"):
e.Slate.Frequency = Daily
}
if v, ok := e.Attributes.GetN("until", 0); ok {
e.Slate.Until, _ = time.Parse("2006-01-02", v)
}
if v, ok := e.Attributes.GetN("interval", 0); ok {
e.Slate.Interval, _ = strconv.Atoi(v)
}
if v, ok := e.Attributes.GetN("count", 0); ok {
e.Slate.Count, _ = strconv.Atoi(v)
}
if v, ok := e.Attributes.GetN("rel", 0); ok {
e.Task.RelatedTo, _ = time.Parse("2006-01-02", v)
}
if v, ok := e.Attributes.GetN("completed", 0); ok {
e.Task.CompletedOn, _ = time.Parse("2006-01-02", v)
}
return i, nil
}
func (e Entry) String() string {
var b strings.Builder
b.WriteString(e.Slate.String())
b.WriteString(e.DetailString())
return b.String()
}
func (e Entry) DetailString() string {
var b strings.Builder
if !e.Task.IsZero(){
b.WriteString(" " + e.Task.String())
}
if !e.Event.IsZero() {
b.WriteString(" " + e.Event.String())
}
if len(e.Title) > 0 {
b.WriteString(" " + e.Title)
}
return b.String()
}
func (e Entry) IsZero() bool {
return e.Slate.IsZero() &&
e.Task.IsZero() &&
e.Event.IsZero() &&
len(e.Attributes) == 0 &&
len(e.Contexts) == 0 &&
len(e.Projects) == 0 &&
len(e.Title) == 0
}
func (e Entry) IsMulti() bool {
return !e.Slate.IsZero() &&
e.Task.IsZero() &&
e.Event.IsZero() &&
len(e.Attributes) == 0 &&
len(e.Contexts) == 0 &&
len(e.Projects) == 0 &&
len(e.Title) == 0
}
func (e Entry) Project(start time.Time, until time.Duration) iter.Seq[time.Time] {
begin := start
end := start.Add(until)
return func(yield func(time.Time) bool) {
doProject := func(start time.Time) bool {
for ts := range e.Date.Project(start) {
if ts.Before(begin) || ts.After(end) {
continue
}
if !yield(ts) {
return false
}
}
return true
}
switch e.Frequency {
case Yearly:
start = beginningOfYear(start)
for start.Before(end) {
if !doProject(start) {
break
}
start = start.AddDate(1, 0, 0)
start = beginningOfYear(start)
}
case Monthly:
start = beginningOfMonth(start)
for start.Before(end) {
if !doProject(start) {
break
}
start = start.AddDate(0, 1, 0)
}
case Weekly:
start = beginningOfWeek(start)
for start.Before(end) {
if !doProject(start) {
break
}
start = start.AddDate(0, 0, 7)
}
case Daily:
for start.Before(end) {
if !doProject(start) {
break
}
start = start.AddDate(0, 0, 1)
}
default:
start = time.Date(e.Date.Year, 1,1,0,0, 0, 0, time.UTC)
doProject(start)
}
}
}
func (e Entry) Less(c Entry) bool { return false }
type Set[T comparable] map[T]struct{}
func NewSet[T comparable](vals ...T) Set[T] {
s := make(Set[T])
s.Add(vals...)
return s
}
func (s *Set[T]) Add(keys ...T) {
for _, key := range keys {
(*s)[key] = struct{}{}
}
}
func (s Set[T]) Has(key T) bool {
_, ok := s[key]
return ok
}
type Attributes map[string][]string
func (a Attributes) GetN(key string, n int) (string, bool) {
if v, ok := a[key]; ok {
if len(v) > n {
return v[n], true
}
}
return "", false
}
type Task struct {
Done bool
Priority Priority
RelatedTo time.Time
CompletedOn time.Time
}
func (t *Task) Parse(text string) (int, error) {
if len(text) < 3 {
return 0, fmt.Errorf("invalid task")
}
i := 0
switch {
case strings.HasPrefix(text, "[ ]"):
i += 3
case strings.HasPrefix(text, "[x]"):
i += 3
t.Done = true
default:
return 0, fmt.Errorf("invalid task")
}
t.Priority = None
i += trimSpace(text[i:])
di, err := t.Priority.Parse(text[i:])
if err == nil {
i += di
}
i += trimSpace(text[i:])
return i, nil
}
// String
func (t Task) String() string {
var b strings.Builder
b.WriteRune('[')
if t.Done {
b.WriteRune('x')
} else {
b.WriteRune(' ')
}
b.WriteRune(']')
b.WriteString(t.Priority.String())
return b.String()
}
func (t *Task) IsZero() bool {
if t == nil {
return true
}
return t.Priority == 0 &&
t.CompletedOn.IsZero() &&
t.RelatedTo.IsZero()
}
type Event struct {
StartsAt Time
EndsAt Time
}
func (e *Event) Parse(text string) (int, error) {
if len(text) == 0 {
return 0, fmt.Errorf("invalid event")
}
i := 0
di, v, err := parseDigits(text)
if err == nil {
e.StartsAt.Valid = true
e.StartsAt.Hour = v
i += di
} else {
return 0, fmt.Errorf("invalid event: %w", err)
}
if len(text[i:]) > 0 && text[i] == ':' {
i++
di, v, err = parseDigits(text[i:])
if err == nil {
e.StartsAt.Minute = v
i += di
}
}
if len(text[i:]) > 0 && text[i] == '-' {
i++
di, v, err = parseDigits(text[i:])
if err == nil {
e.EndsAt.Valid = true
e.EndsAt.Hour = v
i += di
}
if len(text[i:]) > 0 && text[i] == ':' {
i++
di, v, err = parseDigits(text[i:])
if err == nil {
e.EndsAt.Minute = v
i += di
}
}
}
i += trimSpace(text[i:])
return i, nil
}
// String
func (e Event) String() string {
var b strings.Builder
if !e.StartsAt.Valid {
return ""
}
b.WriteString(e.StartsAt.String())
if e.EndsAt.Valid {
b.WriteRune('-')
b.WriteString(e.EndsAt.String())
}
return b.String()
}
func (e *Event) IsZero() bool {
if e == nil {
return true
}
return !e.StartsAt.Valid &&
!e.EndsAt.Valid
}
type Priority rune
const (
High Priority = 'A'
Medium Priority = 'B'
Low Priority = 'C'
None Priority = ' '
)
func (p *Priority) Parse(text string) (int, error) {
if len(text) == 0 {
return 0, nil
}
i := 0
switch {
case strings.HasPrefix(text, "(A)"):
i += 3
*p = High
case strings.HasPrefix(text, "(B)"):
i += 3
*p = Medium
case strings.HasPrefix(text, "(C)"):
i += 3
*p = Low
default:
return 0, fmt.Errorf("invalid priority")
}
return i, nil
}
func (p Priority) String() string {
var b strings.Builder
switch p {
case High, Medium, Low:
b.WriteRune(' ')
b.WriteRune('(')
b.WriteRune(rune(p))
b.WriteRune(')')
}
return b.String()
}
func trimSpace(text string) int {
i := 0
for _, c := range text {
if unicode.IsSpace(c) {
i++
continue
}
break
}
return i
}
func parseDigits(text string) (int, int, error) {
i := 0
for _, c := range text {
if unicode.IsDigit(c) {
i++
continue
}
break
}
v, err := strconv.Atoi(text[:i])
return i, v, err
}
type Time struct {
Hour int
Minute int
Valid bool
}
func (t *Time) Duration() time.Duration {
return time.Duration(t.Hour)*time.Hour + time.Duration(t.Minute)*time.Minute
}
func (t *Time) String() string {
var b strings.Builder
b.WriteString(strconv.Itoa(t.Hour))
if t.Minute > 0 {
b.WriteRune(':')
b.WriteString(strconv.Itoa(t.Minute))
}
return b.String()
}
type Entries map[string][]Entry
func NewEntries(entries ...Entry) Entries {
var e Entries
e.add(entries...)
return e
}
func (e *Entries) add(entries ...Entry) {
if *e == nil {
*e = make(map[string][]Entry, len(entries))
}
for _, entry := range entries {
(*e)[entry.Slate.Date.String()] = append((*e)[entry.Slate.Date.String()], entry)
}
}
func (e Entries) Iter() iter.Seq[Entry] {
keys := slices.Collect(maps.Keys(e))
sort.Strings(keys)
return func(yield func(Entry) bool) {
for _, k := range keys {
for _, i := range (e)[k] {
if !yield(i) {
return
}
}
}
}
}
func (e *Entries) Parse(text string) (int, error) {
if len(text) == 0 {
return 0, nil
}
var lastDate Date
var lis []Entry
i := 0
for _, line := range strings.Split(text, "\n") {
i++
var e Entry
di, err := e.Parse(line)
i += di
if err != nil {
continue
}
if e.IsZero() {
continue
}
if e.Date.IsZero() {
e.Date = lastDate
}
if e.IsMulti() {
lastDate = e.Date
continue
}
lis = append(lis, e)
}
i--
e.add(lis...)
return i, nil
}
func (e Entries) String() string {
keys := slices.Collect(maps.Keys(e))
sort.Strings(keys)
var b strings.Builder
for _, key := range keys {
lis := e[key]
if len(lis) > 1 {
b.WriteString(key)
b.WriteString("\n")
for _, e := range lis {
b.WriteString("\t")
b.WriteString(e.DetailString())
b.WriteString("\n")
}
} else {
for _, e := range lis {
b.WriteString(e.String())
b.WriteString("\n")
}
}
}
return b.String()
}

276
entry_test.go Normal file

@ -0,0 +1,276 @@
package main_test
import (
_ "embed"
"iter"
"slices"
"testing"
"github.com/matryer/is"
cal "go.sour.is/cal"
)
func TestEntryParse(t *testing.T) {
tests := []struct {
entry cal.Entry
string string
}{
{
cal.Entry{
Slate: cal.Slate{
Date: cal.Date{Year: 2024, Month: 5, Day: 14},
},
Title: "make food",
Task: cal.Task{
Priority: cal.Priority('A'),
},
},
"2024-05-14 [ ] (A) make food",
},
{
cal.Entry{
Slate: cal.Slate{
Date: cal.Date{Year: 2024, Month: 5, Day: 14},
},
Title: "make food",
Event: cal.Event{
StartsAt: cal.Time{Valid: true, Hour: 12},
EndsAt: cal.Time{Valid: true, Hour: 13},
},
},
"2024-05-14 12:00-13:00 make food",
},
{
cal.Entry{
Slate: cal.Slate{
Date: cal.Date{Year: 2024, Month: 5, Day: 14},
},
Title: "make food",
Task: cal.Task{
Priority: cal.Priority('A'),
},
Event: cal.Event{
StartsAt: cal.Time{Valid: true, Hour: 12},
EndsAt: cal.Time{Valid: true, Hour: 13},
},
},
"2024-05-14 [ ] (A) 12:00-13:00 make food",
},
{
cal.Entry{
Slate: cal.Slate{
Date: cal.Date{
Year: 2024,
Month: 5,
DayOfWeek: []cal.Day{
cal.NewDay(cal.Wednesday, 0),
},
},
},
},
"2024-05 Wed",
},
{
cal.Entry{
Title: "make food",
Task: cal.Task{
Priority: cal.Priority('A'),
},
},
"[ ] (A) make food",
},
{
cal.Entry{
Title: "make food @dinner +mealprep due:2024-05-14",
Task: cal.Task{
Priority: cal.Priority('A'),
},
Contexts: cal.NewSet("dinner"),
Projects: cal.NewSet("mealprep"),
Attributes: map[string][]string{
"due": {"2024-05-14"},
},
},
"[ ] (A) make food @dinner +mealprep due:2024-05-14",
},
{
cal.Entry{
Note: "this is a note",
},
"- this is a note",
},
{
cal.Entry{
Note: "this is a note",
Slate: cal.Slate{
Date: cal.Date{
Year: 2024,
Month: 5,
DayOfWeek: []cal.Day{
cal.NewDay(cal.Wednesday, 0),
},
},
},
},
"2024-05 Wed - this is a note",
},
}
for _, tt := range tests {
t.Run(tt.string, func(t *testing.T) {
is := is.New(t)
var entry cal.Entry
i, err := entry.Parse(tt.string)
is.NoErr(err)
compareEntry(t, entry, tt.entry)
is.Equal(i, len(tt.string))
})
}
}
func compareEntry(t *testing.T, a, b cal.Entry) {
t.Helper()
is := is.New(t)
is.Equal(a.Slate, b.Slate)
is.Equal(a.Title, b.Title)
if !a.Task.IsZero() {
is.Equal(a.Task, b.Task)
} else {
is.True(b.Task.IsZero())
}
if !a.Event.IsZero() {
is.True(!b.Event.IsZero())
is.Equal(a.Event, b.Event)
} else {
is.True(b.Event.IsZero())
}
if len(a.Contexts) > 0 {
is.Equal(a.Contexts, b.Contexts)
}
if len(a.Projects) > 0 {
is.Equal(a.Projects, b.Projects)
}
if len(a.Attributes) > 0 {
is.Equal(a.Attributes, b.Attributes)
}
}
func TestEntriesParse(t *testing.T) {
tests := []struct {
string string
entries cal.Entries
}{
{
string: `2024-05-14 [ ] (A) make food
2024-05 Wed
[ ] one
[x] 5 two
[ ] (A) three
2025 Jan1 8-9 four`,
entries: cal.NewEntries(
cal.Entry{
Slate: cal.Slate{
Date: cal.Date{Year: 2024, Month: 5, Day: 14},
},
Title: "make food",
Task: cal.Task{
Priority: cal.Priority('A'),
},
},
cal.Entry{
Slate: cal.Slate{
Date: cal.Date{Year: 2024, Month: 5, DayOfWeek: []cal.Day{
cal.NewDay(cal.Wednesday, 0),
}},
},
Title: "one",
Task: cal.Task{},
},
cal.Entry{
Slate: cal.Slate{
Date: cal.Date{Year: 2024, Month: 5, DayOfWeek: []cal.Day{
cal.NewDay(cal.Wednesday, 0),
}},
},
Title: "two",
Task: cal.Task{
Done: true,
},
Event: cal.Event{
StartsAt: cal.Time{Valid: true, Hour: 5},
},
},
cal.Entry{
Slate: cal.Slate{
Date: cal.Date{Year: 2024, Month: 5, DayOfWeek: []cal.Day{
cal.NewDay(cal.Wednesday, 0),
}},
},
Title: "three",
Task: cal.Task{
Priority: cal.Priority('A'),
},
},
cal.Entry{
Slate: cal.Slate{
Date: cal.Date{Year: 2025, MonthOfYear: []cal.Month{cal.NewMonth(cal.January, 1)}},
},
Title: "four",
Event: cal.Event{
StartsAt: cal.Time{Valid: true, Hour: 8},
EndsAt: cal.Time{Valid: true, Hour: 9},
},
},
),
},
}
for _, tt := range tests {
t.Run(tt.string[:15], func(t *testing.T) {
is := is.New(t)
var entries cal.Entries
i, err := entries.Parse(tt.string)
ttEntries := slices.Collect(tt.entries.Iter())
for i, entry := range enumerate(entries.Iter()) {
compareEntry(t, entry, ttEntries[i])
}
is.NoErr(err)
is.Equal(i, len(tt.string))
})
}
}
//go:embed todo.txt
var todotxt string
func TestFileParse(t *testing.T) {
is := is.New(t)
var entries cal.Entries
i, err := entries.Parse(todotxt)
is.NoErr(err)
is.Equal(i, len(todotxt))
_ = entries.String()
}
func enumerate[T any](it iter.Seq[T]) iter.Seq2[int, T] {
return func(yield func(int, T) bool) {
i := 0
for v := range it {
if !yield(i, v) {
return
}
i++
}
}
}

5
go.mod Normal file

@ -0,0 +1,5 @@
module go.sour.is/cal
go 1.23.3
require github.com/matryer/is v1.4.1

2
go.sum Normal file

@ -0,0 +1,2 @@
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=

43
main.go Normal file

@ -0,0 +1,43 @@
package main
import (
_ "embed"
"fmt"
"time"
)
//go:embed todo.txt
var todotxt []byte
func main() {
fmt.Println("hello!")
// text, _ := io.ReadAll(os.Stdin)
var lis Entries
lis.Parse(string(todotxt))
type entry struct {
time time.Time
e Entry
}
// now := time.Date(time.Now().Year(), 1, 1, 0, 0, 0, 0, time.UTC)
pq := PriorityQueue(func(a, b *entry) bool {
return a.time.Before(b.time)
})
now := time.Now()
for e := range lis.Iter() {
for p := range e.Project(now, 365*24*time.Hour) {
pq.Insert(&entry{p, e})
}
}
for !pq.IsEmpty() {
entry := pq.ExtractMin()
e, p := entry.e, entry.time
fmt.Println(p.Format("2006-01-02"), e.DetailString())
}
}

40
pqueue.go Normal file

@ -0,0 +1,40 @@
package main
import "sort"
type priorityQueue[T any] struct {
elems []*T
less func(a, b *T) bool
maxDepth int
totalEnqueue int
totalDequeue int
}
// PriorityQueue implements a simple slice based queue.
// less is the function for sorting. reverse a and b to reverse the sort.
// T is the item
// U is a slice of T
func PriorityQueue[T any](less func(a, b *T) bool) *priorityQueue[T] {
return &priorityQueue[T]{less: less}
}
func (pq *priorityQueue[T]) Insert(elem *T) {
pq.totalEnqueue++
pq.elems = append(pq.elems, elem)
pq.maxDepth = max(pq.maxDepth, len(pq.elems))
}
func (pq *priorityQueue[T]) IsEmpty() bool {
return len(pq.elems) == 0
}
func (pq *priorityQueue[T]) ExtractMin() *T {
pq.totalDequeue++
var elem *T
if pq.IsEmpty() {
return elem
}
sort.Slice(pq.elems, func(i, j int) bool { return pq.less(pq.elems[j], pq.elems[i]) })
pq.elems, elem = pq.elems[:len(pq.elems)-1], pq.elems[len(pq.elems)-1]
return elem
}

1057
slate.go Normal file

File diff suppressed because it is too large Load Diff

637
slate_test.go Normal file

@ -0,0 +1,637 @@
package main_test
import (
"fmt"
"iter"
"strings"
"testing"
"time"
"github.com/matryer/is"
cal "go.sour.is/cal"
)
func TestDay(t *testing.T) {
tests := []struct {
name string
dayOfWeek cal.Day
weekOfMonth int
string string
}{
{
"test monday 0",
cal.Monday,
0,
"Mon",
},
{
"test monday 1",
cal.Monday,
1,
"1Mon",
},
{
"test monday 5",
cal.Monday,
5,
"5Mon",
},
{
"test monday -1",
cal.Monday,
-1,
"-1Mon",
},
{
"test monday -5",
cal.Monday,
-5,
"-5Mon",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := is.New(t)
day := cal.NewDay(tt.dayOfWeek, tt.weekOfMonth)
is.Equal(day.Day(), tt.dayOfWeek)
is.Equal(day.WeekOfMonth(), tt.weekOfMonth)
is.Equal(day.String(), tt.string)
})
}
}
func TestMonth(t *testing.T) {
tests := []struct {
month cal.Month
dayOfMonth int
string string
}{
{
cal.January,
0,
"Jan",
},
{
cal.February,
1,
"Feb1",
},
{
cal.March,
31,
"Mar31",
},
{month: cal.April, string: "Apr"},
{month: cal.May, string: "May"},
{month: cal.June, string: "Jun"},
{month: cal.July, string: "Jul"},
{month: cal.August, string: "Aug"},
{month: cal.September, string: "Sep"},
{month: cal.October, string: "Oct"},
{month: cal.November, string: "Nov"},
{month: cal.December, string: "Dec"},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("%d %s %d %s", i, tt.month, tt.dayOfMonth, tt.string), func(t *testing.T) {
is := is.New(t)
month := cal.NewMonth(tt.month, tt.dayOfMonth)
is.Equal(month.Month(), tt.month)
is.Equal(month.DayOfMonth(), tt.dayOfMonth)
is.Equal(month.String(), tt.string)
})
}
}
func TestWeek(t *testing.T) {
tests := []struct {
name string
week int
string string
}{
{
"test week 1",
1,
"w01",
},
{
"test week 52",
52,
"w52",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := is.New(t)
week := cal.NewWeek(tt.week)
is.Equal(week.WeekOfYear(), tt.week)
is.Equal(week.String(), tt.string)
})
}
}
func TestDate(t *testing.T) {
tests := []struct {
date cal.Date
string string
}{
{
cal.Date{
Year: 2022,
},
"2022",
},
{
cal.Date{
Year: 2022,
Month: 2,
},
"2022-02",
},
{
cal.Date{
Year: 2022,
Month: 1,
Day: 1,
},
"2022-01-01",
},
{
cal.Date{
Year: 2022,
WeekOfYear: []cal.Week{
cal.NewWeek(1),
},
},
"2022 w01",
},
{
cal.Date{
Year: 2022,
WeekOfYear: []cal.Week{
cal.NewWeek(1),
cal.NewWeek(4),
},
},
"2022 w01, w04",
},
{
cal.Date{
Year: 2022,
MonthOfYear: []cal.Month{
cal.NewMonth(cal.January, 17),
},
},
"2022 Jan17",
},
{
cal.Date{
Year: 2022,
MonthOfYear: []cal.Month{
cal.NewMonth(cal.January, 1),
cal.NewMonth(cal.December, -1),
},
},
"2022 Jan1, Dec-1",
},
{
cal.Date{
Year: 2022,
MonthOfYear: []cal.Month{
cal.January,
},
DayOfWeek: []cal.Day{
cal.NewDay(cal.Monday, 2),
},
},
"2022 Jan 2Mon",
},
{
cal.Date{
Year: 2022,
WeekOfYear: []cal.Week{
cal.NewWeek(6),
},
DayOfWeek: []cal.Day{
cal.Tuesday,
},
},
"2022 w06 Tue",
},
{
cal.Date{
Year: 2022,
Month: 8,
DayOfWeek: []cal.Day{
cal.Wednesday,
},
},
"2022-08 Wed",
},
{
cal.Date{
Year: 2022,
Month: 8,
DayOfWeek: []cal.Day{
cal.Thursday,
cal.Friday,
cal.Saturday,
cal.Sunday,
},
},
"2022-08 Thu, Fri, Sat, Sun",
},
{
cal.Date{},
"",
},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("%d %s", i, tt.string), func(t *testing.T) {
_ = i
is := is.New(t)
s := tt.date.String()
is.Equal(s, tt.string)
})
}
}
func TestMonthParse(t *testing.T) {
tests := []struct {
month cal.Month
day int
string string
}{
{
cal.January,
1,
"Jan1",
},
{
cal.March,
2,
"March2",
},
{
cal.February,
3,
"feb3",
},
{
cal.April,
-3,
"april-3",
},
{
cal.May,
0,
"MAY",
},
{
cal.June,
-30,
"JUN-30",
},
{
cal.July,
4,
"jul4",
},
{
cal.August,
3,
"aug3",
},
{
cal.September,
21,
"sept21",
},
{
cal.October,
31,
"Oct31",
},
{
cal.November,
15,
"nov15",
},
{
cal.December,
25,
"december25",
},
}
for _, tt := range tests {
t.Run(tt.string, func(t *testing.T) {
is := is.New(t)
var m cal.Month
i, err := m.Parse(tt.string)
is.NoErr(err)
is.Equal(m.Month(), tt.month)
is.Equal(m.DayOfMonth(), tt.day)
is.Equal(i, len(tt.string))
})
}
}
func TestDayParse(t *testing.T) {
tests := []struct {
day cal.Day
week int
string string
}{
{
cal.Monday,
1,
"1monday",
},
{
cal.Tuesday,
2,
"2Tuesday",
},
{
cal.Wednesday,
3,
"3Wed",
},
{
cal.Thursday,
4,
"4Thur",
},
{
cal.Friday,
-5,
"-5Friday",
},
{
cal.Saturday,
-2,
"-2Saturday",
},
{
cal.Sunday,
-1,
"-1Sunday",
},
}
for _, tt := range tests {
t.Run(tt.string, func(t *testing.T) {
is := is.New(t)
var d cal.Day
i, err := d.Parse(tt.string)
is.NoErr(err)
is.Equal(d.Day(), tt.day)
is.Equal(d.WeekOfMonth(), tt.week)
is.Equal(i, len(tt.string))
})
}
}
func TestWeekParse(t *testing.T) {
tests := []struct {
week int
string string
}{
{
1,
"w01",
},
{
52,
"w52",
},
}
for _, tt := range tests {
t.Run(tt.string, func(t *testing.T) {
is := is.New(t)
var w cal.Week
i, err := w.Parse(tt.string)
is.NoErr(err)
is.Equal(w.WeekOfYear(), tt.week)
is.Equal(i, len(tt.string))
})
}
}
func TestDateParse(t *testing.T) {
tests := []struct {
date cal.Date
string string
extra string
}{
{
date: cal.Date{Year: 2024},
string: "2024",
},
{
date: cal.Date{Year: 2024},
string: "2024 ",
extra: "make food",
},
{
date: cal.Date{Year: 2024, WeekOfYear: []cal.Week{cal.NewWeek(5)}},
string: "2024 w05",
},
{
date: cal.Date{Year: 2024, DayOfWeek: []cal.Day{cal.NewDay(cal.Monday, 2)}},
string: "2024 2mon",
},
{
date: cal.Date{Year: 2024, WeekOfYear: []cal.Week{cal.NewWeek(5), cal.NewWeek(10)}},
string: "2024 w05, w10",
},
{
date: cal.Date{Year: 2024, WeekOfYear: []cal.Week{cal.NewWeek(5)}, DayOfWeek: []cal.Day{cal.NewDay(cal.Monday, 0)}},
string: "2024 w05 mon",
},
{
date: cal.Date{Year: 2024, WeekOfYear: []cal.Week{cal.NewWeek(5)}},
string: "2024 w05 ",
extra: "monitoring",
},
{
date: cal.Date{
Year: 2024,
WeekOfYear: []cal.Week{cal.NewWeek(5), cal.NewWeek(17)},
DayOfWeek: []cal.Day{cal.NewDay(cal.Monday, 0), cal.NewDay(cal.Friday, 0)},
},
string: "2024 w05 mon, w17 fri",
},
{
date: cal.Date{
Year: 2024,
MonthOfYear: []cal.Month{cal.NewMonth(cal.September, 0)},
DayOfWeek: []cal.Day{cal.NewDay(cal.Monday, 1)},
},
string: "2024 Sept 1mon",
},
{
date: cal.Date{
Year: 2024,
MonthOfYear: []cal.Month{cal.NewMonth(cal.September, 1)},
},
string: "2024 Sept1 ",
extra: "monitoring",
},
{
date: cal.Date{
Year: 2024,
MonthOfYear: []cal.Month{
cal.NewMonth(cal.September, 1),
cal.NewMonth(cal.October, -1),
},
},
string: "2024 Sept1, October-1 ",
extra: "monitoring",
},
{
date: cal.Date{
Year: 2024,
MonthOfYear: []cal.Month{cal.NewMonth(cal.September, 1)},
},
string: "2024 Sept1 ",
extra: "monitoring",
},
{
date: cal.Date{
Year: 2024,
MonthOfYear: []cal.Month{cal.NewMonth(cal.August, 0), cal.NewMonth(cal.October, 0)},
DayOfWeek: []cal.Day{cal.NewDay(cal.Monday, 2), cal.NewDay(cal.Friday, -1)},
},
string: "2024 Aug 2mon, Oct -1fri",
},
{
date: cal.Date{Year: 2024, Month: 5},
string: "2024-05",
},
{
date: cal.Date{Year: 2024, Month: 5, DayOfWeek: []cal.Day{cal.NewDay(cal.Monday, 2)}},
string: "2024-05 2mon",
},
{
date: cal.Date{Year: 2024, Month: 5, DayOfWeek: []cal.Day{
cal.NewDay(cal.Monday, 2),
cal.NewDay(cal.Thursday, 3),
}},
string: "2024-05 2mon, 3thur",
},
{
date: cal.Date{Year: 2024, Month: 5, DayOfWeek: []cal.Day{
cal.NewDay(cal.Monday, 2),
cal.NewDay(cal.Thursday, 3),
}},
string: "2024-05 2mon, 3thur ",
extra: "make food",
},
{
date: cal.Date{Year: 2024, Month: 5, Day: 14},
string: "2024-05-14",
},
{
date: cal.Date{Year: 2024, Month: 5, Day: 14},
string: "2024-05-14 ",
extra: "make food",
},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("%d %s%s", i, tt.string, tt.extra), func(t *testing.T) {
is := is.New(t)
var d cal.Date
i, err := d.Parse(tt.string + tt.extra)
is.NoErr(err)
is.Equal(fmt.Sprintf("%#v", d), fmt.Sprintf("%#v", tt.date))
is.Equal(d, tt.date)
is.Equal(i, len(tt.string))
})
}
}
func TestDateProject(t *testing.T) {
type test struct {
date string
start string
out string
}
tests := []test{
{"2024-05-14", "2024-05-14", "2024-05-14"},
{"2024-05-14", "2024-06-24", "2024-06-24"},
{"2024-05", "2024-05-01", "2024-05-31"},
{"2024-05", "2025-02-01", "2025-02-28"},
{"2024-05 2mon", "2025-02-01", "2025-02-10"},
{"2024-05 2mon, 2fri", "2025-02-11", "2025-02-14"},
{"2024-05 -1mon, -2fri", "2025-02-01", "2025-02-24 2025-02-21"},
{"2024 feb -1mon, feb -2fri", "2025-02-01", "2025-02-24 2025-02-21"},
{"2024", "2025-02-01", "2025-12-31"},
{"2024 mar mon, mar 3mon", "2025-02-01", "2025-03-03 2025-03-17"},
{"2024 mar3, mar17", "2025-03-01", "2025-03-03 2025-03-17"},
{"2024 mar, nov", "2025-03-01", "2025-03-31 2025-11-30"},
{"2024 w03, w-2", "2025-01-01", "2025-01-18 2025-12-27"},
{"2024 w03 mon, w-1 wed, w-01 sat", "2025-01-01", "2025-01-13 2025-12-31 2026-01-03"},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("%03d %s %s", i, tt.date, tt.start), func(t *testing.T) {
is := is.New(t)
var date cal.Date
_, err := date.Parse(tt.date)
is.NoErr(err)
start, err := time.Parse("2006-01-02", tt.start)
is.NoErr(err)
out := collectN(len(tt.out)+1, date.Project(start))
fields := strings.Fields(tt.out)
is.Equal(len(out), len(fields))
for i, v := range fields {
is.Equal(out[i].Format("2006-01-02"), v)
}
})
}
}
func collectN[T any](n int, seq iter.Seq[T]) []T {
out := make([]T, 0, n)
i := 0
for v := range seq {
out = append(out, v)
i++
if i == n {
break
}
}
return out
}
func timeDate(year, month, day int) time.Time {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}

19
test.puml Normal file

@ -0,0 +1,19 @@
@startuml test
split
-[hidden]->
split
-[hidden]->
:Delta;
split again
-[hidden]->
:Cumulative;
end split
:Derived;
split again
-[hidden]->
:foo;
end split
:member profile;
@enduml

1
todo.txt Normal file

@ -0,0 +1 @@
2025-01 Fri [ ] Take out Trash @weekly