From 3b75b960e0cc5cda3f66deef1402458d9e1b8a94 Mon Sep 17 00:00:00 2001 From: xuu Date: Mon, 3 Feb 2025 19:08:08 -0700 Subject: [PATCH] feat: parse and project entries --- README.md | 0 entry.go | 602 ++++++++++++++++++++++++++++ entry_test.go | 276 +++++++++++++ go.mod | 5 + go.sum | 2 + main.go | 43 ++ pqueue.go | 40 ++ slate.go | 1057 +++++++++++++++++++++++++++++++++++++++++++++++++ slate_test.go | 637 +++++++++++++++++++++++++++++ test.puml | 19 + todo.txt | 1 + 11 files changed, 2682 insertions(+) create mode 100644 README.md create mode 100644 entry.go create mode 100644 entry_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pqueue.go create mode 100644 slate.go create mode 100644 slate_test.go create mode 100644 test.puml create mode 100644 todo.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/entry.go b/entry.go new file mode 100644 index 0000000..d16e684 --- /dev/null +++ b/entry.go @@ -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() +} diff --git a/entry_test.go b/entry_test.go new file mode 100644 index 0000000..b3bc3e8 --- /dev/null +++ b/entry_test.go @@ -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++ + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dac1063 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module go.sour.is/cal + +go 1.23.3 + +require github.com/matryer/is v1.4.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f95502a --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8cef097 --- /dev/null +++ b/main.go @@ -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()) + } + +} diff --git a/pqueue.go b/pqueue.go new file mode 100644 index 0000000..f4029f7 --- /dev/null +++ b/pqueue.go @@ -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 +} diff --git a/slate.go b/slate.go new file mode 100644 index 0000000..9a9321d --- /dev/null +++ b/slate.go @@ -0,0 +1,1057 @@ +package main + +import ( + "fmt" + "iter" + "math/bits" + "slices" + "strconv" + "strings" + "time" + "unicode" +) + +type Frequency string + +const ( + Yearly Frequency = "yearly" + Monthly Frequency = "monthly" + Weekly Frequency = "weekly" + Daily Frequency = "daily" + Once Frequency = "once" +) + +// Day of the week with week of the month encoded. +// 4 bits 4-7: week of the month 0b00000000_01111111 +// 7 bits 8-16: day of the week 0b00001111_00000000 +type Day uint16 + +const ( + Sunday Day = 1 << iota + Monday Day = 1 << iota + Tuesday Day = 1 << iota + Wednesday Day = 1 << iota + Thursday Day = 1 << iota + Friday Day = 1 << iota + Saturday Day = 1 << iota +) + +func NewDay(d Day, ofMonth int) Day { + return Day(ofMonth&15<<8 | int(d&127)) +} + +func (d *Day) Set(day Day, weekOfMonth int) { + *d = Day(weekOfMonth&15<<8 | int(day&127)) +} + +func (d Day) Day() Day { + return Day(int(d) & 127) +} + +func (d Day) WeekOfMonth() int { + weekOfMonth := int(d>>8) & 15 + if weekOfMonth&0b1000 > 0 { + return -((^weekOfMonth & 15) + 1) + } + return weekOfMonth +} +func (d Day) DayOfWeek() int { + return bits.TrailingZeros16(uint16(d & 127)) +} + +func (d Day) String() string { + var day string + switch d.Day() { + case Monday: + day = "Mon" + case Tuesday: + day = "Tue" + case Wednesday: + day = "Wed" + case Thursday: + day = "Thu" + case Friday: + day = "Fri" + case Saturday: + day = "Sat" + case Sunday: + day = "Sun" + } + if weekOfMonth := d.WeekOfMonth(); weekOfMonth != 0 { + return fmt.Sprintf("%-d%s", weekOfMonth, day) + } + return day +} + +func (d Day) Less(a Day) bool { + return d < a +} + +func (d *Day) Parse(text string) (int, error) { + if len(text) == 0 { + return 0, fmt.Errorf("invalid day") + } + + var weekOfMonth int + var day string + + i := 0 + + for _, c := range text[i:] { + if unicode.IsDigit(c) || c == '-' { + i++ + continue + } + break + } + weekOfMonth, _ = strconv.Atoi(text[:i]) + + di := 0 + for _, c := range text[i:] { + if unicode.IsLetter(c) { + di++ + continue + } + break + } + day = text[i : i+di] + i += di + + switch strings.ToLower(day) { + case "mon", "monday": + d.Set(Monday, weekOfMonth) + case "tue", "tuesday": + d.Set(Tuesday, weekOfMonth) + case "wed", "wednesday": + d.Set(Wednesday, weekOfMonth) + case "thu", "thursday", "thur", "thr": + d.Set(Thursday, weekOfMonth) + case "fri", "friday": + d.Set(Friday, weekOfMonth) + case "sat", "saturday": + d.Set(Saturday, weekOfMonth) + case "sun", "sunday": + d.Set(Sunday, weekOfMonth) + default: + return 0, fmt.Errorf("invalid day") + } + return i, nil +} + +// Month of the year with day of the day of month encoded. +// 5 bits : day of the month 0b00000000_00000000_00011111 +// 22 bits : month of the year 0b00001111_11111111_00000000 +type Month int32 + +const ( + January Month = 1 << (iota + 8) // 0b0000_00000001_00000000 + February Month = 1 << (iota + 8) // 0b0000_00000010_00000000 + March Month = 1 << (iota + 8) // 0b0000_00000100_00000000 + April Month = 1 << (iota + 8) // 0b0000_00001000_00000000 + May Month = 1 << (iota + 8) // 0b0000_00010000_00000000 + June Month = 1 << (iota + 8) // 0b0000_00100000_00000000 + July Month = 1 << (iota + 8) // 0b0000_01000000_00000000 + August Month = 1 << (iota + 8) // 0b0000_10000000_00000000 + September Month = 1 << (iota + 8) // 0b0001_00000000_00000000 + October Month = 1 << (iota + 8) // 0b0010_00000000_00000000 + November Month = 1 << (iota + 8) // 0b0100_00000000_00000000 + December Month = 1 << (iota + 8) // 0b1000_00000000_00000000 + + // day of month -31-31 0b1-0b0011_1111 +) + +func NewMonth(m Month, dayOfMonth int) Month { + return Month(int(m)&^63 | dayOfMonth&63) +} + +func (m Month) Month() Month { + return Month(int(m) & ^int(255)) +} + +func (m Month) DayOfMonth() int { + dayOfMonth := int(m) & 0b11_1111 + if dayOfMonth&0b0010_0000 > 0 { + return -((^dayOfMonth & 0b1_1111) + 1) + } + return dayOfMonth +} + +func (m Month) MonthOfYear() time.Month { + return time.Month(bits.TrailingZeros32(uint32(m>>8)) + 1) +} + +func (m Month) String() string { + var month string + switch m.Month() { + case January: + month = "Jan" + case February: + month = "Feb" + case March: + month = "Mar" + case April: + month = "Apr" + case May: + month = "May" + case June: + month = "Jun" + case July: + month = "Jul" + case August: + month = "Aug" + case September: + month = "Sep" + case October: + month = "Oct" + case November: + month = "Nov" + case December: + month = "Dec" + } + if dayOfMonth := m.DayOfMonth(); dayOfMonth != 0 { + return fmt.Sprintf("%s%-d", month, dayOfMonth) + } + return month +} + +func (m Month) Less(a Month) bool { + return m < a +} + +func (w *Month) Parse(text string) (int, error) { + if len(text) == 0 { + return 0, fmt.Errorf("invalid month") + } + + mon, day := "", 0 + i := 0 + for _, c := range text { + if !unicode.IsLetter(c) { + break + } + i++ + } + + mon = text[:i] + + if i < len(text) { + negative := false + if text[i] == '-' { + i++ + negative = true + } + + di := 0 + for _, c := range text[i:] { + if !unicode.IsDigit(c) { + break + } + di++ + } + + day, _ = strconv.Atoi(text[i : i+di]) + i += di + + if negative { + day = -day + } + } + + switch strings.ToLower(mon) { + case "jan", "january": + *w = NewMonth(January, day) + case "feb", "february": + *w = NewMonth(February, day) + case "mar", "march": + *w = NewMonth(March, day) + case "apr", "april": + *w = NewMonth(April, day) + case "may": + *w = NewMonth(May, day) + case "jun", "june": + *w = NewMonth(June, day) + case "jul", "july": + *w = NewMonth(July, day) + case "aug", "august": + *w = NewMonth(August, day) + case "sep", "sept", "september": + *w = NewMonth(September, day) + case "oct", "october": + *w = NewMonth(October, day) + case "nov", "november": + *w = NewMonth(November, day) + case "dec", "december": + *w = NewMonth(December, day) + default: + return i, fmt.Errorf("unknown month %q", mon) + } + return i, nil +} + +type Slate struct { + Date Date + + Frequency Frequency + + Until time.Time + Interval int + Count int +} + +func (s *Slate) Parse(text string) (int, error) { + i, err := s.Date.Parse(text) + if err != nil { + return 0, err + } + return i, nil +} + +// String +func (s Slate) String() string { + return s.Date.String() +} + +func (s Slate) IsZero() bool { + return s.Date.IsZero() && + s.Frequency == "" && + s.Until.IsZero() && + s.Interval == 0 && + s.Count == 0 +} + +func (s Slate) Project(start time.Time) iter.Seq[time.Time] { + if start.IsZero() { + start = time.Now() + } + + if !s.Until.IsZero() && s.Until.Before(start) { + return slices.Values[[]time.Time](nil) + + } + + var increment time.Duration + switch s.Frequency { + case Once: + increment = 0 + case Daily: + increment = time.Hour * 24 + case Weekly: + increment = time.Hour * 24 * 7 + case Monthly: + increment = time.Hour * 24 * 30 + case Yearly: + increment = time.Hour * 24 * 365 + } + + if s.Interval > 0 { + increment *= time.Duration(s.Interval) + } + + return func(yield func(time.Time) bool) { + for i := 0; i < s.Count; { + for d := range s.Date.Project(start) { + if !s.Until.IsZero() && s.Until.Before(d) { + return + } + + if !yield(d) { + return + } + i++ + } + start = start.Add(increment) + } + } +} + +type Week int8 + +func NewWeek(ofYear int) Week { + return Week(ofYear & 63) +} + +func (w Week) WeekOfYear() int { + return int(w) +} + +func (w Week) String() string { + return fmt.Sprintf("w%02d", int8(w)) +} + +func (w Week) Less(a Week) bool { + return w < a +} + +func (w *Week) Parse(text string) (int, error) { + if len(text) == 0 { + return 0, fmt.Errorf("invalid week") + } + if !strings.HasPrefix(strings.ToLower(text), "w") { + return 0, fmt.Errorf("invalid week") + } + + i := 1 + for _, c := range text[1:] { + if unicode.IsDigit(c) || c == '-' { + i++ + continue + } + break + } + + wk, err := strconv.Atoi(text[1:i]) + if err != nil { + return 0, err + } + *w = Week(wk) + return i, nil +} + +type Date struct { + Year int + Month int + Day int + + MonthOfYear []Month + WeekOfYear []Week + DayOfWeek []Day +} + +func (d Date) String() string { + v, _ := d.MarshalText() + return string(v) +} + +func (d Date) MarshalText() ([]byte, error) { + hasYear := d.Year != 0 + hasMonth := d.Month != 0 + hasDay := d.Day != 0 + + onlyYear := hasYear && !hasMonth && !hasDay + yearAndMonth := hasYear && hasMonth && !hasDay + yearMonthDay := hasYear && hasMonth && hasDay + + hasMonthOfYear := len(d.MonthOfYear) > 0 + hasWeekOfYear := len(d.WeekOfYear) > 0 + hasDayOfWeek := len(d.DayOfWeek) > 0 + + // onlyYear + onlyWeekOfYear := hasWeekOfYear && !hasDayOfWeek && !hasMonthOfYear + onlyMonthOfYear := hasMonthOfYear && !hasWeekOfYear && !hasDayOfWeek + weekOfYearAndDayOfWeek := hasWeekOfYear && !hasMonthOfYear && hasDayOfWeek + monthOfYearAndDayOfWeek := hasMonthOfYear && !hasWeekOfYear && hasDayOfWeek + + // yearAndMonth + onlyDayOfWeek := hasDayOfWeek && !hasWeekOfYear && !hasMonthOfYear + + switch { + case onlyYear && !hasMonthOfYear && !hasWeekOfYear && !hasDayOfWeek: + return []byte(fmt.Sprintf("%04d", d.Year)), nil + case onlyYear && onlyWeekOfYear: + s := apply(d.WeekOfYear, func(w Week) string { + return w.String() + }) + return []byte(fmt.Sprintf("%04d %s", d.Year, strings.Join(s, ", "))), nil + case onlyYear && weekOfYearAndDayOfWeek: + z := zip(d.WeekOfYear, d.DayOfWeek) + s := apply(z, func(w pair[Week, Day]) string { + return fmt.Sprintf("%s %s", w.A.String(), w.B.Day().String()) + }) + return []byte(fmt.Sprintf("%04d %s", d.Year, strings.Join(s, ", "))), nil + case onlyYear && monthOfYearAndDayOfWeek: + z := zip(d.MonthOfYear, d.DayOfWeek) + s := apply(z, func(w pair[Month, Day]) string { + return fmt.Sprintf("%s %s", w.A.Month().String(), w.B.String()) + }) + return []byte(fmt.Sprintf("%04d %s", d.Year, strings.Join(s, ", "))), nil + case onlyYear && onlyMonthOfYear: + s := apply(d.MonthOfYear, func(w Month) string { + return w.String() + }) + return []byte(fmt.Sprintf("%04d %s", d.Year, strings.Join(s, ", "))), nil + + case yearAndMonth && !hasDayOfWeek: + return []byte(fmt.Sprintf("%04d-%02d", d.Year, d.Month)), nil + case yearAndMonth && onlyDayOfWeek: + s := apply(d.DayOfWeek, func(w Day) string { + return w.String() + }) + return []byte(fmt.Sprintf("%04d-%02d %s", d.Year, d.Month, strings.Join(s, ", "))), nil + + case yearMonthDay: + return []byte(fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day)), nil + } + return nil, nil +} + +func (d Date) IsZero() bool { + return d.Year == 0 && + d.Month == 0 && + d.Day == 0 && + len(d.MonthOfYear) == 0 && + len(d.WeekOfYear) == 0 && + len(d.DayOfWeek) == 0 +} + +// ## Date formats ## +// YYYY - year +// 2024 - year 2024 +// 2026 - year 2026 +// YYYY moy Ndow[, ...] - year moy dow +// 2024 Jan 1Mon, Feb -2Tue - first monday of january 2024 and second from last tuesday of february 2024 +// YYYY moyNN[, ...] - year moyNN +// 2024 Jan1, Feb-2 - first day of january 2024 and second last day of february 2024 + +// YYYY 'w'NN[, ...] - year woy +// 2024 w01, w05 - first week of 2024 and fifth week of 2024 +// YYYY 'w'NN dow[, ...] - year woy dow +// 2024 w01 Mon - first monday of 2024 +// 2024 w02 Mon, w05 Tue - first monday on second week of 2024 and first tuesday on fifth week of 2024 + +// YYYY-MM - month +// 2024-01 - month of january 2024 +// YYYY-MM Ndow[, ...] month day of week +// 2024-01 1Mon, 2Tue + +// YYYY-MM-DD day +// 2024-01-01 - first day of january 2024 + +func (d *Date) Parse(text string) (int, error) { + i := 0 + ds, text, ok := strings.Cut(text, " ") + i += len(ds) + if ok { + i++ + } + if t, err := time.Parse("2006-01-02", ds); err == nil { + d.Year = t.Year() + d.Month = int(t.Month()) + d.Day = t.Day() + + return i, nil + } + + if t, err := time.Parse("2006-01", ds); err == nil { + d.Year = t.Year() + d.Month = int(t.Month()) + + var di int + + di, d.DayOfWeek, err = repeatDayOfWeek(text) + if err == nil { + i += di + return i, nil + } + + return i, nil + } + + if t, err := time.Parse("2006", ds); err == nil { + d.Year = t.Year() + + var di int + + di, d.WeekOfYear, d.DayOfWeek, err = repeatWeekOfYearAndDayOfWeek(text) + if err == nil { + i += di + return i, nil + } + + di, d.MonthOfYear, d.DayOfWeek, err = repeatMonthOfYearAndDayOfWeek(text) + if err == nil { + i += di + return i, nil + } + + if di, d.WeekOfYear, err = repeatWeekOfYear(text); err == nil { + i += di + return i, nil + } + + if di, d.MonthOfYear, err = repeatMonth(text); err == nil { + i += di + return i, nil + } + + if di, d.DayOfWeek, err = repeatDayOfWeek(text); err == nil { + i += di + return i, nil + } + + return i, nil + } + + return 0, fmt.Errorf("invalid date") +} + +func (d Date) Project(start time.Time) iter.Seq[time.Time] { + hasYear := d.Year != 0 + hasMonth := d.Month != 0 + hasDay := d.Day != 0 + + onlyYear := hasYear && !hasMonth && !hasDay + yearAndMonth := hasYear && hasMonth && !hasDay + yearMonthDay := hasYear && hasMonth && hasDay + + hasMonthOfYear := len(d.MonthOfYear) > 0 + hasWeekOfYear := len(d.WeekOfYear) > 0 + hasDayOfWeek := len(d.DayOfWeek) > 0 + + // onlyYear + onlyWeekOfYear := hasWeekOfYear && !hasDayOfWeek && !hasMonthOfYear + onlyMonthOfYear := hasMonthOfYear && !hasWeekOfYear && !hasDayOfWeek + weekOfYearAndDayOfWeek := hasWeekOfYear && !hasMonthOfYear && hasDayOfWeek + monthOfYearAndDayOfWeek := hasMonthOfYear && !hasWeekOfYear && hasDayOfWeek + + // yearAndMonth + onlyDayOfWeek := hasDayOfWeek && !hasWeekOfYear && !hasMonthOfYear + + switch { + case onlyYear && !hasMonthOfYear && !hasWeekOfYear && !hasDayOfWeek: + year := max(d.Year, start.Year()) + month := time.Month(max(1, d.Month)) + day := max(1, d.Day) + dt := time.Date(year, month, day, 0, 0, 0, 0, start.Location()) + dt = endOfYear(dt) + + return func(yield func(time.Time) bool) { + yield(dt) + return + } + case onlyYear && onlyWeekOfYear: + year := max(start.Year(), d.Year) + + return func(yield func(time.Time) bool) { + for _, w := range d.WeekOfYear { + if !yield(dayOfWeekNumber(year, w.WeekOfYear(), 6)) { + return + } + } + } + case onlyYear && weekOfYearAndDayOfWeek: + year := max(start.Year(), d.Year) + + return func(yield func(time.Time) bool) { + for i, w := range d.WeekOfYear { + dt := dayOfWeekNumber(year, w.WeekOfYear(), int(d.DayOfWeek[i].DayOfWeek())) + if start.After(dt) { + continue + } + if !yield(dt) { + return + } + } + } + case onlyYear && monthOfYearAndDayOfWeek: + year := max(start.Year(), d.Year) + + return func(yield func(time.Time) bool) { + for i, m := range d.MonthOfYear { + month := m.MonthOfYear() + dt := time.Date(year, month, 1, 0, 0, 0, 0, start.Location()) + dt = monthWithNWeek(d.DayOfWeek[i], dt) + + if start.After(dt) { + continue + } + if !yield(dt) { + return + } + } + } + case onlyYear && onlyMonthOfYear: + year := max(start.Year(), d.Year) + + return func(yield func(time.Time) bool) { + for _, m := range d.MonthOfYear { + month := time.Month(m.MonthOfYear()) + var dt time.Time + if day := m.DayOfMonth(); day == 0 { + dt = time.Date(year, month, 1, 0, 0, 0, 0, start.Location()) + dt = endOfMonth(dt) + } else { + dt = time.Date(year, month, day, 0, 0, 0, 0, start.Location()) + } + + if start.After(dt) { + continue + } + if !yield(dt) { + return + } + } + } + + case yearAndMonth && !hasDayOfWeek: + year := d.Year + month := max(start.Month(), time.Month(d.Month)) + if start.Year() > d.Year { + year = start.Year() + month = start.Month() + } + + return func(yield func(time.Time) bool) { + dt := time.Date(year, month, 1, 0, 0, 0, 0, start.Location()) + dt = endOfMonth(dt) + if !yield(dt) { + return + } + } + case yearAndMonth && onlyDayOfWeek: + year := max(start.Year(), d.Year) + month := max(start.Month(), time.Month(d.Month)) + day := max(start.Day(), d.Day) + + if start.Year() > d.Year { + year = start.Year() + month = start.Month() + day = start.Day() + } + + return func(yield func(time.Time) bool) { + for _, d := range d.DayOfWeek { + dt := time.Date(year, month, day, 0, 0, 0, 0, start.Location()) + dt = beginningOfWeek(dt).AddDate(0,0, d.DayOfWeek()) + + if start.After(dt) { + continue + } + if !yield(dt) { + return + } + } + } + + case yearMonthDay: + year := max(start.Year(), d.Year) + month := max(start.Month(), time.Month(d.Month)) + day := max(start.Day(), d.Day) + + d := time.Date(year, month, day, 0, 0, 0, 0, start.Location()) + + return func(yield func(time.Time) bool) { + yield(d) + return + } + } + + return slices.Values[[]time.Time](nil) +} + +func (d Date) Less(a Date) bool { + if d.Year < a.Year { + return true + } + if d.Year > a.Year { + return false + } + if d.Month != 0 && d.Month < a.Month { + return true + } + if d.Month != 0 && d.Month > a.Month { + return false + } + if d.Day != 0 && d.Day < a.Day { + return true + } + if d.Day != 0 && d.Day > a.Day { + return false + } + return true +} + +func apply[T, F any](arr []T, fn func(T) F) []F { + res := make([]F, len(arr)) + for i, v := range arr { + res[i] = fn(v) + } + return res +} + +type pair[A, B any] struct { + A A + B B +} + +func zip[A, B any](a []A, b []B) []pair[A, B] { + l := min(len(a), len(b)) + + res := make([]pair[A, B], l) + for i := range a { + res[i] = struct { + A A + B B + }{a[i], b[i]} + } + return res +} + +func repeatDayOfWeek(text string) (int, []Day, error) { + var i int + var lis []Day + next := true + for next { + next = false + var dow Day + di, err := dow.Parse(text) + if err != nil { + return 0, nil, err + } + lis = append(lis, dow) + text = text[di:] + i += di + + for di, c := range text { + if c == ',' { + next = true + continue + } + if c == ' ' { + continue + } + text = text[di:] + i += di + break + } + } + + return i, lis, nil +} + +func repeatMonth(text string) (int, []Month, error) { + var i int + var lis []Month + next := true + for next { + next = false + var month Month + di, err := month.Parse(text) + if err != nil { + return 0, nil, err + } + lis = append(lis, month) + text = text[di:] + i += di + + for di, c := range text { + if c == ',' { + next = true + continue + } + if c == ' ' { + continue + } + text = text[di:] + i += di + break + } + } + + return i, lis, nil +} + +func repeatWeekOfYear(text string) (int, []Week, error) { + var i int + var lis []Week + next := true + for next { + next = false + var dow Week + di, err := dow.Parse(text) + if err != nil { + return 0, nil, err + } + lis = append(lis, dow) + text = text[di:] + i += di + + for di, c := range text { + if c == ',' { + next = true + continue + } + if c == ' ' { + continue + } + text = text[di:] + i += di + break + } + } + + return i, lis, nil +} + +func repeatWeekOfYearAndDayOfWeek(text string) (int, []Week, []Day, error) { + var i int + var weeks []Week + var days []Day + next := true + for next { + next = false + var dow Week + di, err := dow.Parse(text) + if err != nil { + return 0, nil, nil, err + } + weeks = append(weeks, dow) + text = text[di:] + i += di + + if len(text) == 0 || text[0] != ' ' { + return 0, nil, nil, fmt.Errorf("invalid date") + } + text = text[1:] + i++ + + var day Day + di, err = day.Parse(text) + if err != nil { + return 0, nil, nil, err + } + days = append(days, day) + text = text[di:] + i += di + + for di, c := range text { + if c == ',' { + next = true + continue + } + if c == ' ' { + continue + } + text = text[di:] + i += di + break + } + } + + return i, weeks, days, nil +} + +func repeatMonthOfYearAndDayOfWeek(text string) (int, []Month, []Day, error) { + var i int + var months []Month + var days []Day + next := true + for next { + next = false + var dow Month + di, err := dow.Parse(text) + if err != nil { + return 0, nil, nil, err + } + months = append(months, dow) + text = text[di:] + i += di + + if len(text) == 0 || text[0] != ' ' { + return 0, nil, nil, fmt.Errorf("invalid date") + } + text = text[1:] + i++ + + var day Day + di, err = day.Parse(text) + if err != nil { + return 0, nil, nil, err + } + days = append(days, day) + text = text[di:] + i += di + + for di, c := range text { + if c == ',' { + next = true + continue + } + if c == ' ' { + continue + } + text = text[di:] + i += di + break + } + } + + return i, months, days, nil +} + +func beginningOfMonth(date time.Time) time.Time { + year, month, _ := date.Date() + return time.Date(year, month, 1, 0,0,0,0, date.Location()) +} + +func endOfMonth(date time.Time) time.Time { + year, month, _ := date.Date() + date = time.Date(year, month, 1, 0,0,0,0, date.Location()) + return date.AddDate(0, 1, -date.Day()) +} + +func beginningOfYear(date time.Time) time.Time { + year, _, _ := date.Date() + return time.Date(year, 1, 1, 0,0,0,0, date.Location()) +} + +func beginningOfWeek(date time.Time) time.Time { + year, month, day := date.Date() + return time.Date(year, month, day-int(date.Weekday()), 0,0,0,0, date.Location()) +} + +func endOfYear(date time.Time) time.Time { + return date.AddDate(1, int(-date.Month()+1), -date.Day()) +} + +func dayOfWeekNumber(year int, week int, day int) time.Time { + // start of the year + date := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + + // clamp to max 53 + week = min(53, week) + + // reverse week starts from the end of year + if week < 0 { + eoy := endOfYear(date) + _, isoWeek := eoy.ISOWeek() + if isoWeek == 1 { + isoWeek = 53 + } + week = isoWeek + week + 1 + } + + // clamp to min 1 + week = max(1, week) + + // move to the Nth week + date = date.AddDate(0, 0, (7 * (week - 1))) + + // move to day + date = date.AddDate(0, 0, -int(date.Weekday())+day) + + return date +} + +func monthWithNWeek(d Day, dt time.Time) time.Time { + dow := d.DayOfWeek() + if wom := d.WeekOfMonth(); wom >= 0 { + if wom == 0 { + wom = 1 + } + day := 7 - int(dt.Weekday()) + + dow + + 7*(wom-1) + + dt = dt.AddDate(0, 0, day) + } else { + dt = endOfMonth(dt) + + day := 7 - int(dt.Weekday()) + wk := 7 - dow + shift := 7 * (-wom - 1) + day = wk - day + shift + + dt = dt.AddDate(0, 0, -day) + } + return dt +} diff --git a/slate_test.go b/slate_test.go new file mode 100644 index 0000000..97a50ac --- /dev/null +++ b/slate_test.go @@ -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) +} diff --git a/test.puml b/test.puml new file mode 100644 index 0000000..1b45bb6 --- /dev/null +++ b/test.puml @@ -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 diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..352e15e --- /dev/null +++ b/todo.txt @@ -0,0 +1 @@ +2025-01 Fri [ ] Take out Trash @weekly