diff --git a/.gitignore b/.gitignore index 836a306..3fb7ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ -test.db* -plannersync -plan +*.db* diff --git a/Makefile b/Makefile index 2f9c7ef..3a302ca 100644 --- a/Makefile +++ b/Makefile @@ -4,5 +4,18 @@ plan-deploy: sync-run: cd sync/service && go run . -dbname localhost -dbport 5432 -dbname planner -dbuser test -dbpassword test -port 8092 -key testKey +sync-debug: + cd sync/service && dlv debug . -- -dbname localhost -dbport 5432 -dbname planner -dbuser test -dbpassword test -port 8092 -key testKey + +sync-build: + go build -o dist/plannersync ./sync/service/ + +sync-deploy: + ssh server sudo /usr/bin/systemctl stop plannersync.service + scp dist/plannersync server:/usr/local/bin/plannersync + ssh server sudo /usr/bin/systemctl start plannersync.service + database: docker run -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=planner -p 5432:5432 postgres:16 + + diff --git a/dist/plannersync b/dist/plannersync new file mode 100755 index 0000000..5a456c3 Binary files /dev/null and b/dist/plannersync differ diff --git a/item/date.go b/item/date.go new file mode 100644 index 0000000..5e48174 --- /dev/null +++ b/item/date.go @@ -0,0 +1,278 @@ +package item + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +const ( + DateFormat = "2006-01-02" +) + +func Today() Date { + year, month, day := time.Now().Date() + return NewDate(year, int(month), day) +} + +type Weekdays []time.Weekday + +func (wds Weekdays) Len() int { return len(wds) } +func (wds Weekdays) Swap(i, j int) { wds[j], wds[i] = wds[i], wds[j] } +func (wds Weekdays) Less(i, j int) bool { + if wds[i] == time.Sunday { + return false + } + if wds[j] == time.Sunday { + return true + } + + return int(wds[i]) < int(wds[j]) +} + +func (wds Weekdays) Unique() Weekdays { + mwds := map[time.Weekday]bool{} + for _, wd := range wds { + mwds[wd] = true + } + newWds := Weekdays{} + for wd := range mwds { + newWds = append(newWds, wd) + } + sort.Sort(newWds) + + return newWds +} + +type Date struct { + t time.Time +} + +func (d *Date) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +func (d *Date) UnmarshalJSON(data []byte) error { + dateString := "" + if err := json.Unmarshal(data, &dateString); err != nil { + return err + } + nd := NewDateFromString(dateString) + d.t = nd.Time() + + return nil +} + +func NewDate(year, month, day int) Date { + if year == 0 || month == 0 || month > 12 || day == 0 { + return Date{} + } + + var m time.Month + switch month { + case 1: + m = time.January + case 2: + m = time.February + case 3: + m = time.March + case 4: + m = time.April + case 5: + m = time.May + case 6: + m = time.June + case 7: + m = time.July + case 8: + m = time.August + case 9: + m = time.September + case 10: + m = time.October + case 11: + m = time.November + case 12: + m = time.December + } + + t := time.Date(year, m, day, 0, 0, 0, 0, time.UTC) + + return Date{ + t: t, + } +} + +func NewDateFromString(date string) Date { + date = strings.ToLower(strings.TrimSpace(date)) + + switch date { + case "": + fallthrough + case "no-date": + fallthrough + case "no date": + return Date{} + case "today": + return Today() + case "tod": + return Today() + case "tomorrow": + return Today().AddDays(1) + case "tom": + return Today().AddDays(1) + } + + t, err := time.Parse("2006-01-02", fmt.Sprintf("%.10s", date)) + if err == nil { + return Date{t: t} + } + + newWeekday, ok := ParseWeekday(date) + if !ok { + return Date{} + } + daysToAdd := findDaysToWeekday(Today().Weekday(), newWeekday) + + return Today().Add(daysToAdd) +} + +func findDaysToWeekday(current, wanted time.Weekday) int { + daysToAdd := int(wanted) - int(current) + if daysToAdd <= 0 { + daysToAdd += 7 + } + + return daysToAdd +} + +func (d Date) DaysBetween(d2 Date) int { + tDate := d2 + end := d + if !end.After(tDate) { + end = d2 + tDate = d + } + + days := 0 + for { + if tDate.Add(days).Equal(end) { + return days + } + days++ + } +} + +func (d Date) String() string { + if d.t.IsZero() { + return "no date" + } + + return strings.ToLower(d.t.Format(DateFormat)) +} + +// func (d Date) Human() string { +// switch { +// case d.IsZero(): +// return "-" +// case d.Equal(Today()): +// return "today" +// case d.Equal(Today().Add(1)): +// return "tomorrow" +// case d.After(Today()) && Today().Add(8).After(d): +// return strings.ToLower(d.t.Format("Monday")) +// default: +// return strings.ToLower(d.t.Format(DateFormat)) +// } +// } + +func (d Date) IsZero() bool { + return d.t.IsZero() +} + +func (d Date) Time() time.Time { + return d.t +} + +func (d Date) Weekday() time.Weekday { + return d.t.Weekday() +} + +func (d Date) Day() int { + return d.t.Day() +} + +func (d Date) Add(days int) Date { + year, month, day := d.t.Date() + return NewDate(year, int(month), day+days) +} + +func (d Date) AddMonths(addMonths int) Date { + year, mmonth, day := d.t.Date() + month := int(mmonth) + for m := 1; m <= addMonths; m++ { + month += 1 + if month == 12 { + year += 1 + month = 1 + } + } + + return NewDate(year, month, day) +} + +func (d Date) Equal(ud Date) bool { + return d.t.Equal(ud.Time()) +} + +// After reports whether d is after ud +func (d Date) After(ud Date) bool { + return d.t.After(ud.Time()) +} + +func (d Date) AddDays(amount int) Date { + year, month, date := d.t.Date() + + return NewDate(year, int(month), date+amount) +} + +func ParseWeekday(wd string) (time.Weekday, bool) { + switch lowerAndTrim(wd) { + case "monday": + return time.Monday, true + case "mon": + return time.Monday, true + case "tuesday": + return time.Tuesday, true + case "tue": + return time.Tuesday, true + case "wednesday": + return time.Wednesday, true + case "wed": + return time.Wednesday, true + case "thursday": + return time.Thursday, true + case "thu": + return time.Thursday, true + case "friday": + return time.Friday, true + case "fri": + return time.Friday, true + case "saturday": + return time.Saturday, true + case "sat": + return time.Saturday, true + case "sunday": + return time.Sunday, true + case "sun": + return time.Sunday, true + default: + return time.Monday, false + } +} + +func lowerAndTrim(str string) string { + return strings.TrimSpace(strings.ToLower(str)) +} diff --git a/item/date_test.go b/item/date_test.go new file mode 100644 index 0000000..b48f82d --- /dev/null +++ b/item/date_test.go @@ -0,0 +1,327 @@ +package item_test + +import ( + "sort" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "go-mod.ewintr.nl/planner/item" +) + +func TestWeekdaysSort(t *testing.T) { + for _, tc := range []struct { + name string + input item.Weekdays + exp item.Weekdays + }{ + { + name: "empty", + }, + { + name: "one", + input: item.Weekdays{time.Tuesday}, + exp: item.Weekdays{time.Tuesday}, + }, + { + name: "multiple", + input: item.Weekdays{time.Wednesday, time.Tuesday, time.Monday}, + exp: item.Weekdays{time.Monday, time.Tuesday, time.Wednesday}, + }, + { + name: "sunday is last", + input: item.Weekdays{time.Saturday, time.Sunday, time.Monday}, + exp: item.Weekdays{time.Monday, time.Saturday, time.Sunday}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + sort.Sort(tc.input) + if diff := cmp.Diff(tc.exp, tc.input); diff != "" { + t.Errorf("(-exp, +got)%s\n", diff) + } + }) + } +} + +func TestWeekdaysUnique(t *testing.T) { + for _, tc := range []struct { + name string + input item.Weekdays + exp item.Weekdays + }{ + { + name: "empty", + input: item.Weekdays{}, + exp: item.Weekdays{}, + }, + { + name: "single", + input: item.Weekdays{time.Monday}, + exp: item.Weekdays{time.Monday}, + }, + { + name: "no doubles", + input: item.Weekdays{time.Monday, time.Tuesday, time.Wednesday}, + exp: item.Weekdays{time.Monday, time.Tuesday, time.Wednesday}, + }, + { + name: "doubles", + input: item.Weekdays{time.Monday, time.Monday, time.Wednesday, time.Monday}, + exp: item.Weekdays{time.Monday, time.Wednesday}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.exp, tc.input.Unique()); diff != "" { + t.Errorf("(-exp, +got)%s\n", diff) + } + }) + } +} + +func TestNewDateFromString(t *testing.T) { + t.Parallel() + + t.Run("simple", func(t *testing.T) { + for _, tc := range []struct { + name string + input string + exp item.Date + }{ + { + name: "empty", + exp: item.Date{}, + }, + { + name: "no date", + input: "no date", + exp: item.Date{}, + }, + { + name: "short", + input: "2021-01-30", + exp: item.NewDate(2021, 1, 30), + }, + } { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.exp, item.NewDateFromString(tc.input)); diff != "" { + t.Errorf("(-exp, +got)%s\n", diff) + } + }) + } + }) + + t.Run("day name", func(t *testing.T) { + monday := item.Today().Add(1) + for { + if monday.Weekday() == time.Monday { + break + } + monday = monday.Add(1) + } + for _, tc := range []struct { + name string + input string + }{ + { + name: "dayname lowercase", + input: "monday", + }, + { + name: "dayname capitalized", + input: "Monday", + }, + { + name: "dayname short", + input: "mon", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(monday, item.NewDateFromString(tc.input)); diff != "" { + t.Errorf("(-exp, +got)%s\n", diff) + } + }) + } + }) + + t.Run("relative days", func(t *testing.T) { + for _, tc := range []struct { + name string + exp item.Date + }{ + { + name: "today", + exp: item.Today(), + }, + { + name: "tod", + exp: item.Today(), + }, + { + name: "tomorrow", + exp: item.Today().Add(1), + }, + { + name: "tom", + exp: item.Today().Add(1), + }, + } { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.exp, item.NewDateFromString(tc.name)); diff != "" { + t.Errorf("(-exp, +got)%s\n", diff) + } + }) + } + }) +} + +func TestDateDaysBetween(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + d1 item.Date + d2 item.Date + exp int + }{ + { + name: "same", + d1: item.NewDate(2021, 6, 23), + d2: item.NewDate(2021, 6, 23), + }, + { + name: "one", + d1: item.NewDate(2021, 6, 23), + d2: item.NewDate(2021, 6, 24), + exp: 1, + }, + { + name: "many", + d1: item.NewDate(2021, 6, 23), + d2: item.NewDate(2024, 3, 7), + exp: 988, + }, + { + name: "edge", + d1: item.NewDate(2020, 12, 30), + d2: item.NewDate(2021, 1, 3), + exp: 4, + }, + { + name: "reverse", + d1: item.NewDate(2021, 6, 23), + d2: item.NewDate(2021, 5, 23), + exp: 31, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.exp != tc.d1.DaysBetween(tc.d2) { + t.Errorf("exp %v, got %v", tc.exp, tc.d1.DaysBetween(tc.d2)) + } + }) + } +} + +func TestDateString(t *testing.T) { + for _, tc := range []struct { + name string + date item.Date + exp string + }{ + { + name: "zero", + date: item.NewDate(0, 0, 0), + exp: "no date", + }, + { + name: "normal", + date: item.NewDate(2021, 5, 30), + exp: "2021-05-30", + }, + { + name: "normalize", + date: item.NewDate(2021, 5, 32), + exp: "2021-06-01", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.exp != tc.date.String() { + t.Errorf("exp %v, got %v", tc.exp, tc.date.String()) + } + }) + } +} + +// func TestDateHuman(t *testing.T) { +// for _, tc := range []struct { +// name string +// date task.Date +// exp string +// }{ +// { +// name: "zero", +// date: task.NewDate(0, 0, 0), +// exp: "-", +// }, +// { +// name: "default", +// date: task.NewDate(2020, 1, 1), +// exp: "2020-01-01 (wednesday)", +// }, +// { +// name: "today", +// date: task.Today(), +// exp: "today", +// }, +// { +// name: "tomorrow", +// date: task.Today().Add(1), +// exp: "tomorrow", +// }, +// } { +// t.Run(tc.name, func(t *testing.T) { +// test.Equals(t, tc.exp, tc.date.Human()) +// }) +// } +// } + +func TestDateIsZero(t *testing.T) { + t.Parallel() + + if !(item.Date{}.IsZero()) { + t.Errorf("exp true, got false") + } + if item.NewDate(2021, 6, 24).IsZero() { + t.Errorf("exp false, got true") + } +} + +func TestDateAfter(t *testing.T) { + t.Parallel() + + day := item.NewDate(2021, 1, 31) + for _, tc := range []struct { + name string + tDay item.Date + exp bool + }{ + { + name: "after", + tDay: item.NewDate(2021, 1, 30), + exp: true, + }, + { + name: "on", + tDay: day, + }, + { + name: "before", + tDay: item.NewDate(2021, 2, 1), + }, + } { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.exp, day.After(tc.tDay)); diff != "" { + t.Errorf("(-exp, +got)%s\n", diff) + } + }) + } +} diff --git a/item/event.go b/item/event.go index 0b712b8..8241645 100644 --- a/item/event.go +++ b/item/event.go @@ -4,22 +4,22 @@ import ( "encoding/json" "fmt" "time" + + "github.com/google/go-cmp/cmp" ) type EventBody struct { Title string `json:"title"` - Start time.Time `json:"start"` + Time Time `json:"time"` Duration time.Duration `json:"duration"` } func (e EventBody) MarshalJSON() ([]byte, error) { type Alias EventBody return json.Marshal(&struct { - Start string `json:"start"` Duration string `json:"duration"` *Alias }{ - Start: e.Start.UTC().Format(time.RFC3339), Duration: e.Duration.String(), Alias: (*Alias)(&e), }) @@ -28,7 +28,6 @@ func (e EventBody) MarshalJSON() ([]byte, error) { func (e *EventBody) UnmarshalJSON(data []byte) error { type Alias EventBody aux := &struct { - Start string `json:"start"` Duration string `json:"duration"` *Alias }{ @@ -39,10 +38,6 @@ func (e *EventBody) UnmarshalJSON(data []byte) error { } var err error - if e.Start, err = time.Parse(time.RFC3339, aux.Start); err != nil { - return err - } - if e.Duration, err = time.ParseDuration(aux.Duration); err != nil { return err } @@ -51,9 +46,10 @@ func (e *EventBody) UnmarshalJSON(data []byte) error { } type Event struct { - ID string `json:"id"` - Recurrer *Recur `json:"recurrer"` - RecurNext time.Time `json:"recurNext"` + ID string `json:"id"` + Date Date `json:"date"` + Recurrer Recurrer `json:"recurrer"` + RecurNext Date `json:"recurNext"` EventBody } @@ -68,6 +64,7 @@ func NewEvent(i Item) (Event, error) { } e.ID = i.ID + e.Date = i.Date e.Recurrer = i.Recurrer e.RecurNext = i.RecurNext @@ -75,18 +72,15 @@ func NewEvent(i Item) (Event, error) { } func (e Event) Item() (Item, error) { - body, err := json.Marshal(EventBody{ - Title: e.Title, - Start: e.Start, - Duration: e.Duration, - }) + body, err := json.Marshal(e.EventBody) if err != nil { - return Item{}, fmt.Errorf("could not marshal event to json") + return Item{}, fmt.Errorf("could not marshal event body to json") } return Item{ ID: e.ID, Kind: KindEvent, + Date: e.Date, Recurrer: e.Recurrer, RecurNext: e.RecurNext, Body: string(body), @@ -97,15 +91,26 @@ func (e Event) Valid() bool { if e.Title == "" { return false } - if e.Start.IsZero() || e.Start.Year() < 2024 { + if e.Date.IsZero() { return false } if e.Duration.Seconds() < 1 { return false } - if e.Recurrer != nil && !e.Recurrer.Valid() { - return false - } return true } + +func EventDiff(a, b Event) string { + aJSON, _ := json.Marshal(a) + bJSON, _ := json.Marshal(b) + + return cmp.Diff(string(aJSON), string(bJSON)) +} + +func EventDiffs(a, b []Event) string { + aJSON, _ := json.Marshal(a) + bJSON, _ := json.Marshal(b) + + return cmp.Diff(string(aJSON), string(bJSON)) +} diff --git a/item/event_test.go b/item/event_test.go index 86e0a3f..7c20bda 100644 --- a/item/event_test.go +++ b/item/event_test.go @@ -25,11 +25,12 @@ func TestNewEvent(t *testing.T) { name: "wrong kind", it: item.Item{ ID: "a", + Date: item.NewDate(2024, 9, 20), Kind: item.KindTask, Body: `{ "title":"title", - "start":"2024-09-20T08:00:00Z", - "duration":"1h" + "time":"08:00", + "duration":"1h" }`, }, expErr: true, @@ -46,29 +47,23 @@ func TestNewEvent(t *testing.T) { { name: "valid", it: item.Item{ - ID: "a", - Kind: item.KindEvent, - Recurrer: &item.Recur{ - Start: time.Date(2024, 12, 8, 9, 0, 0, 0, time.UTC), - Period: item.PeriodDay, - Count: 1, - }, + ID: "a", + Kind: item.KindEvent, + Date: item.NewDate(2024, 9, 20), + Recurrer: item.NewRecurrer("2024-12-08, daily"), Body: `{ "title":"title", - "start":"2024-09-20T08:00:00Z", - "duration":"1h" + "time":"08:00", + "duration":"1h" }`, }, expEvent: item.Event{ - ID: "a", - Recurrer: &item.Recur{ - Start: time.Date(2024, 12, 8, 9, 0, 0, 0, time.UTC), - Period: item.PeriodDay, - Count: 1, - }, + ID: "a", + Date: item.NewDate(2024, 9, 20), + Recurrer: item.NewRecurrer("2024-12-08, daily"), EventBody: item.EventBody{ Title: "title", - Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC), + Time: item.NewTime(8, 0), Duration: oneHour, }, }, @@ -82,8 +77,8 @@ func TestNewEvent(t *testing.T) { if tc.expErr { return } - if diff := cmp.Diff(tc.expEvent, actEvent); diff != "" { - t.Errorf("(exp +, got -)\n%s", diff) + if diff := item.EventDiff(tc.expEvent, actEvent); diff != "" { + t.Errorf("(+exp, -got)\n%s", diff) } }) } @@ -107,16 +102,17 @@ func TestEventItem(t *testing.T) { expItem: item.Item{ Kind: item.KindEvent, Updated: time.Time{}, - Body: `{"start":"0001-01-01T00:00:00Z","duration":"0s","title":""}`, + Body: `{"duration":"0s","title":"","time":"00:00"}`, }, }, { name: "normal", event: item.Event{ - ID: "a", + ID: "a", + Date: item.NewDate(2024, 9, 23), EventBody: item.EventBody{ Title: "title", - Start: time.Date(2024, 9, 23, 8, 0, 0, 0, time.UTC), + Time: item.NewTime(8, 0), Duration: oneHour, }, }, @@ -124,7 +120,8 @@ func TestEventItem(t *testing.T) { ID: "a", Kind: item.KindEvent, Updated: time.Time{}, - Body: `{"start":"2024-09-23T08:00:00Z","duration":"1h0m0s","title":"title"}`, + Date: item.NewDate(2024, 9, 23), + Body: `{"duration":"1h0m0s","title":"title","time":"08:00"}`, }, }, } { @@ -162,9 +159,10 @@ func TestEventValidate(t *testing.T) { { name: "missing title", event: item.Event{ - ID: "a", + ID: "a", + Date: item.NewDate(2024, 9, 20), EventBody: item.EventBody{ - Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC), + Time: item.NewTime(8, 0), Duration: oneHour, }, }, @@ -175,7 +173,7 @@ func TestEventValidate(t *testing.T) { ID: "a", EventBody: item.EventBody{ Title: "title", - Start: time.Date(0, 0, 0, 8, 0, 0, 0, time.UTC), + Time: item.NewTime(8, 0), Duration: oneHour, }, }, @@ -183,20 +181,22 @@ func TestEventValidate(t *testing.T) { { name: "no duration", event: item.Event{ - ID: "a", + ID: "a", + Date: item.NewDate(2024, 9, 20), EventBody: item.EventBody{ Title: "title", - Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC), + Time: item.NewTime(8, 0), }, }, }, { name: "valid", event: item.Event{ - ID: "a", + ID: "a", + Date: item.NewDate(2024, 9, 20), EventBody: item.EventBody{ Title: "title", - Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC), + Time: item.NewTime(8, 0), Duration: oneHour, }, }, diff --git a/item/item.go b/item/item.go index 93ea4c5..c6b20aa 100644 --- a/item/item.go +++ b/item/item.go @@ -1,6 +1,7 @@ package item import ( + "encoding/json" "time" "github.com/google/uuid" @@ -22,11 +23,43 @@ type Item struct { Kind Kind `json:"kind"` Updated time.Time `json:"updated"` Deleted bool `json:"deleted"` - Recurrer *Recur `json:"recurrer"` - RecurNext time.Time `json:"recurNext"` + Date Date `json:"date"` + Recurrer Recurrer `json:"recurrer"` + RecurNext Date `json:"recurNext"` Body string `json:"body"` } +func (i Item) MarshalJSON() ([]byte, error) { + var recurStr string + if i.Recurrer != nil { + recurStr = i.Recurrer.String() + } + type Alias Item + return json.Marshal(&struct { + Recurrer string `json:"recurrer"` + *Alias + }{ + Recurrer: recurStr, + Alias: (*Alias)(&i), + }) +} + +func (i *Item) UnmarshalJSON(data []byte) error { + type Alias Item + aux := &struct { + Recurrer string `json:"recurrer"` + *Alias + }{ + Alias: (*Alias)(i), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + i.Recurrer = NewRecurrer(aux.Recurrer) + + return nil +} + func NewItem(k Kind, body string) Item { return Item{ ID: uuid.New().String(), diff --git a/item/recur.go b/item/recur.go index a671cf1..a20d89f 100644 --- a/item/recur.go +++ b/item/recur.go @@ -1,61 +1,270 @@ package item import ( - "slices" - "time" + "fmt" + "strconv" + "strings" ) -type RecurPeriod string - -const ( - PeriodDay RecurPeriod = "day" - PeriodMonth RecurPeriod = "month" -) - -var ValidPeriods = []RecurPeriod{PeriodDay, PeriodMonth} - -type Recur struct { - Start time.Time `json:"start"` - Period RecurPeriod `json:"period"` - Count int `json:"count"` +type Recurrer interface { + RecursOn(date Date) bool + First() Date + String() string } -func (r *Recur) On(date time.Time) bool { - switch r.Period { - case PeriodDay: - return r.onDays(date) - case PeriodMonth: - return r.onMonths(date) - default: - return false +func NewRecurrer(recurStr string) Recurrer { + terms := strings.Split(recurStr, ",") + if len(terms) < 2 { + return nil } + + start := NewDateFromString(terms[0]) + if start.IsZero() { + return nil + } + + terms = terms[1:] + for i, t := range terms { + terms[i] = strings.TrimSpace(t) + } + + for _, parseFunc := range []func(Date, []string) (Recurrer, bool){ + ParseDaily, ParseEveryNDays, ParseWeekly, + ParseEveryNWeeks, ParseEveryNMonths, + } { + if recur, ok := parseFunc(start, terms); ok { + return recur + } + } + + return nil } -func (r *Recur) onDays(date time.Time) bool { - if r.Start.After(date) { - return false - } - - testDate := r.Start +func FirstRecurAfter(r Recurrer, d Date) Date { + lim := NewDate(2050, 1, 1) for { - if testDate.Equal(date) { + d = d.Add(1) + if r.RecursOn(d) || d.Equal(lim) { + return d + } + } +} + +type Daily struct { + Start Date +} + +// yyyy-mm-dd, daily +func ParseDaily(start Date, terms []string) (Recurrer, bool) { + if len(terms) < 1 { + return nil, false + } + + if terms[0] != "daily" { + return nil, false + } + + return Daily{ + Start: start, + }, true +} + +func (d Daily) RecursOn(date Date) bool { + return date.Equal(d.Start) || date.After(d.Start) +} + +func (d Daily) First() Date { return FirstRecurAfter(d, d.Start.Add(-1)) } + +func (d Daily) String() string { + return fmt.Sprintf("%s, daily", d.Start.String()) +} + +type EveryNDays struct { + Start Date + N int +} + +// yyyy-mm-dd, every 3 days +func ParseEveryNDays(start Date, terms []string) (Recurrer, bool) { + if len(terms) != 1 { + return EveryNDays{}, false + } + + terms = strings.Split(terms[0], " ") + if len(terms) != 3 || terms[0] != "every" || terms[2] != "days" { + return EveryNDays{}, false + } + + n, err := strconv.Atoi(terms[1]) + if err != nil { + return EveryNDays{}, false + } + + return EveryNDays{ + Start: start, + N: n, + }, true +} + +func (nd EveryNDays) RecursOn(date Date) bool { + if nd.Start.After(date) { + return false + } + + testDate := nd.Start + for { + switch { + case testDate.Equal(date): + return true + case testDate.After(date): + return false + default: + testDate = testDate.Add(nd.N) + } + } +} + +func (nd EveryNDays) First() Date { return FirstRecurAfter(nd, nd.Start.Add(-1)) } + +func (nd EveryNDays) String() string { + return fmt.Sprintf("%s, every %d days", nd.Start.String(), nd.N) +} + +type Weekly struct { + Start Date + Weekdays Weekdays +} + +// yyyy-mm-dd, weekly, wednesday & saturday & sunday +func ParseWeekly(start Date, terms []string) (Recurrer, bool) { + if len(terms) < 2 { + return nil, false + } + + if terms[0] != "weekly" { + return nil, false + } + + wds := Weekdays{} + for _, wdStr := range strings.Split(terms[1], "&") { + wd, ok := ParseWeekday(wdStr) + if !ok { + continue + } + wds = append(wds, wd) + } + if len(wds) == 0 { + return nil, false + } + + return Weekly{ + Start: start, + Weekdays: wds.Unique(), + }, true +} + +func (w Weekly) RecursOn(date Date) bool { + if w.Start.After(date) { + return false + } + + for _, wd := range w.Weekdays { + if wd == date.Weekday() { return true } - if testDate.After(date) { - return false - } - - dur := time.Duration(r.Count) * 24 * time.Hour - testDate = testDate.Add(dur) } + + return false } -func (r *Recur) onMonths(date time.Time) bool { - if r.Start.After(date) { +func (w Weekly) First() Date { return FirstRecurAfter(w, w.Start.Add(-1)) } + +func (w Weekly) String() string { + weekdayStrs := []string{} + for _, wd := range w.Weekdays { + weekdayStrs = append(weekdayStrs, wd.String()) + } + weekdayStr := strings.Join(weekdayStrs, " & ") + + return fmt.Sprintf("%s, weekly, %s", w.Start.String(), strings.ToLower(weekdayStr)) +} + +type EveryNWeeks struct { + Start Date + N int +} + +// yyyy-mm-dd, every 3 weeks +func ParseEveryNWeeks(start Date, terms []string) (Recurrer, bool) { + if len(terms) != 1 { + return nil, false + } + + terms = strings.Split(terms[0], " ") + if len(terms) != 3 || terms[0] != "every" || terms[2] != "weeks" { + return nil, false + } + n, err := strconv.Atoi(terms[1]) + if err != nil || n < 1 { + return nil, false + } + + return EveryNWeeks{ + Start: start, + N: n, + }, true +} + +func (enw EveryNWeeks) RecursOn(date Date) bool { + if enw.Start.After(date) { + return false + } + if enw.Start.Equal(date) { + return true + } + + intervalDays := enw.N * 7 + return enw.Start.DaysBetween(date)%intervalDays == 0 +} + +func (enw EveryNWeeks) First() Date { return FirstRecurAfter(enw, enw.Start.Add(-1)) } + +func (enw EveryNWeeks) String() string { + return fmt.Sprintf("%s, every %d weeks", enw.Start.String(), enw.N) +} + +type EveryNMonths struct { + Start Date + N int +} + +// yyyy-mm-dd, every 3 months +func ParseEveryNMonths(start Date, terms []string) (Recurrer, bool) { + if len(terms) != 1 { + return nil, false + } + + terms = strings.Split(terms[0], " ") + if len(terms) != 3 || terms[0] != "every" || terms[2] != "months" { + return nil, false + } + n, err := strconv.Atoi(terms[1]) + if err != nil { + return nil, false + } + + return EveryNMonths{ + Start: start, + N: n, + }, true +} + +func (enm EveryNMonths) RecursOn(date Date) bool { + if enm.Start.After(date) { return false } - tDate := r.Start + tDate := enm.Start for { if tDate.Equal(date) { return true @@ -63,23 +272,13 @@ func (r *Recur) onMonths(date time.Time) bool { if tDate.After(date) { return false } - - y, m, d := tDate.Date() - tDate = time.Date(y, m+time.Month(r.Count), d, 0, 0, 0, 0, time.UTC) + tDate = tDate.AddMonths(enm.N) } + } -func (r *Recur) NextAfter(old time.Time) time.Time { - day, _ := time.ParseDuration("24h") - test := old.Add(day) - for { - if r.On(test) || test.After(time.Date(2500, 1, 1, 0, 0, 0, 0, time.UTC)) { - return test - } - test.Add(day) - } -} +func (enm EveryNMonths) First() Date { return FirstRecurAfter(enm, enm.Start.Add(-1)) } -func (r *Recur) Valid() bool { - return r.Start.IsZero() || !slices.Contains(ValidPeriods, r.Period) +func (enm EveryNMonths) String() string { + return fmt.Sprintf("%s, every %d months", enm.Start.String(), enm.N) } diff --git a/item/recur_test.go b/item/recur_test.go index 3ccca11..a3e89f7 100644 --- a/item/recur_test.go +++ b/item/recur_test.go @@ -4,102 +4,425 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" "go-mod.ewintr.nl/planner/item" ) -func TestRecur(t *testing.T) { +func TestDaily(t *testing.T) { t.Parallel() - t.Run("days", func(t *testing.T) { - r := item.Recur{ - Start: time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC), - Period: item.PeriodDay, - Count: 5, - } - day := 24 * time.Hour + daily := item.Daily{ + Start: item.NewDate(2021, 1, 31), // a sunday + } + dailyStr := "2021-01-31, daily" + t.Run("parse", func(t *testing.T) { + if diff := cmp.Diff(daily, item.NewRecurrer(dailyStr)); diff != "" { + t.Errorf("(-exp +got):\n%s", diff) + } + }) + + t.Run("string", func(t *testing.T) { + if dailyStr != daily.String() { + t.Errorf("exp %v, got %v", dailyStr, daily.String()) + } + }) + + t.Run("recurs_on", func(t *testing.T) { for _, tc := range []struct { name string - date time.Time + date item.Date exp bool }{ { name: "before", - date: time.Date(202, 1, 1, 0, 0, 0, 0, time.UTC), + date: item.NewDate(2021, 1, 30), + }, + { + name: "on", + date: daily.Start, + exp: true, + }, + { + name: "after", + date: item.NewDate(2021, 2, 1), + exp: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.exp != daily.RecursOn(tc.date) { + t.Errorf("exp %v, got %v", tc.exp, daily.RecursOn(tc.date)) + } + }) + } + }) +} + +func TestEveryNDays(t *testing.T) { + t.Parallel() + + every := item.EveryNDays{ + Start: item.NewDate(2022, 6, 8), + N: 5, + } + everyStr := "2022-06-08, every 5 days" + + t.Run("parse", func(t *testing.T) { + if diff := cmp.Diff(every, item.NewRecurrer(everyStr)); diff != "" { + t.Errorf("(-exp +got):\n%s", diff) + } + }) + + t.Run("string", func(t *testing.T) { + if everyStr != every.String() { + t.Errorf("exp %v, got %v", everyStr, every.String()) + } + }) + + t.Run("recurs on", func(t *testing.T) { + for _, tc := range []struct { + name string + date item.Date + exp bool + }{ + { + name: "before", + date: item.NewDate(2022, 1, 1), }, { name: "start", - date: r.Start, + date: every.Start, exp: true, }, { name: "after true", - date: r.Start.Add(15 * day), + date: every.Start.Add(15), exp: true, }, { name: "after false", - date: r.Start.Add(16 * day), + date: every.Start.Add(16), }, } { t.Run(tc.name, func(t *testing.T) { - if act := r.On(tc.date); tc.exp != act { - t.Errorf("exp %v, got %v", tc.exp, act) + if tc.exp != every.RecursOn(tc.date) { + t.Errorf("exp %v, got %v", tc.exp, tc.date) } }) } }) +} - t.Run("months", func(t *testing.T) { - r := item.Recur{ - Start: time.Date(2021, 2, 3, 0, 0, 0, 0, time.UTC), - Period: item.PeriodMonth, - Count: 3, +func TestParseWeekly(t *testing.T) { + t.Parallel() + + start := item.NewDate(2021, 2, 7) + for _, tc := range []struct { + name string + input []string + expOK bool + expWeekly item.Weekly + }{ + { + name: "empty", + }, + { + name: "wrong type", + input: []string{"daily"}, + }, + { + name: "wrong count", + input: []string{"weeekly"}, + }, + { + name: "unknown day", + input: []string{"weekly", "festivus"}, + }, + { + name: "one day", + input: []string{"weekly", "monday"}, + expOK: true, + expWeekly: item.Weekly{ + Start: start, + Weekdays: item.Weekdays{ + time.Monday, + }, + }, + }, + { + name: "multiple days", + input: []string{"weekly", "monday & thursday & saturday"}, + expOK: true, + expWeekly: item.Weekly{ + Start: start, + Weekdays: item.Weekdays{ + time.Monday, + time.Thursday, + time.Saturday, + }, + }, + }, + { + name: "wrong order", + input: []string{"weekly", "sunday & thursday & wednesday"}, + expOK: true, + expWeekly: item.Weekly{ + Start: start, + Weekdays: item.Weekdays{ + time.Wednesday, + time.Thursday, + time.Sunday, + }, + }, + }, + { + name: "doubles", + input: []string{"weekly", "sunday & sunday & monday"}, + expOK: true, + expWeekly: item.Weekly{ + Start: start, + Weekdays: item.Weekdays{ + time.Monday, + time.Sunday, + }, + }, + }, + { + name: "one unknown", + input: []string{"weekly", "sunday & someday"}, + expOK: true, + expWeekly: item.Weekly{ + Start: start, + Weekdays: item.Weekdays{ + time.Sunday, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actWeekly, actOK := item.ParseWeekly(start, tc.input) + if tc.expOK != actOK { + t.Errorf("exp %v, got %v", tc.expOK, actOK) + } + if !tc.expOK { + return + } + if diff := cmp.Diff(tc.expWeekly, actWeekly); diff != "" { + t.Errorf("(-exp, +got)%s\n", diff) + } + }) + } +} + +func TestWeekly(t *testing.T) { + t.Parallel() + + weekly := item.Weekly{ + Start: item.NewDate(2021, 1, 31), // a sunday + Weekdays: item.Weekdays{ + time.Monday, + time.Wednesday, + time.Thursday, + }, + } + weeklyStr := "2021-01-31, weekly, monday & wednesday & thursday" + + t.Run("parse", func(t *testing.T) { + if diff := cmp.Diff(weekly, item.NewRecurrer(weeklyStr)); diff != "" { + t.Errorf("(-exp, +got)%s\n", diff) } + }) + t.Run("string", func(t *testing.T) { + if weeklyStr != weekly.String() { + t.Errorf("exp %v, got %v", weeklyStr, weekly.String()) + } + }) + + t.Run("recurs_on", func(t *testing.T) { for _, tc := range []struct { name string - date time.Time + date item.Date exp bool }{ { name: "before start", - date: time.Date(2021, 1, 27, 0, 0, 0, 0, time.UTC), + date: item.NewDate(2021, 1, 27), // a wednesday + }, + { + name: "right weekday", + date: item.NewDate(2021, 2, 1), // a monday + exp: true, + }, + { + name: "another right day", + date: item.NewDate(2021, 2, 3), // a wednesday + exp: true, + }, + { + name: "wrong weekday", + date: item.NewDate(2021, 2, 5), // a friday + }, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.exp != weekly.RecursOn(tc.date) { + t.Errorf("exp %v, got %v", tc.exp, weekly.RecursOn(tc.date)) + } + }) + } + }) +} + +func TestEveryNWeeks(t *testing.T) { + t.Parallel() + + everyNWeeks := item.EveryNWeeks{ + Start: item.NewDate(2021, 2, 3), + N: 3, + } + everyNWeeksStr := "2021-02-03, every 3 weeks" + + t.Run("parse", func(t *testing.T) { + if everyNWeeks != item.NewRecurrer(everyNWeeksStr) { + t.Errorf("exp %v, got %v", everyNWeeks, item.NewRecurrer(everyNWeeksStr)) + } + }) + + t.Run("string", func(t *testing.T) { + if everyNWeeksStr != everyNWeeks.String() { + t.Errorf("exp %v, got %v", everyNWeeksStr, everyNWeeks.String()) + } + }) + + t.Run("recurs on", func(t *testing.T) { + for _, tc := range []struct { + name string + date item.Date + exp bool + }{ + { + name: "before start", + date: item.NewDate(2021, 1, 27), }, { name: "on start", - date: time.Date(2021, 2, 3, 0, 0, 0, 0, time.UTC), + date: item.NewDate(2021, 2, 3), + exp: true, + }, + { + name: "wrong day", + date: item.NewDate(2021, 2, 4), + }, + { + name: "one week after", + date: item.NewDate(2021, 2, 10), + }, + { + name: "first interval", + date: item.NewDate(2021, 2, 24), + exp: true, + }, + { + name: "second interval", + date: item.NewDate(2021, 3, 17), + exp: true, + }, + { + name: "second interval plus one week", + date: item.NewDate(2021, 3, 24), + }, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.exp != everyNWeeks.RecursOn(tc.date) { + t.Errorf("exp %v, got %v", tc.exp, everyNWeeks.RecursOn(tc.date)) + } + }) + } + }) +} + +func TestEveryNMonths(t *testing.T) { + everyNMonths := item.EveryNMonths{ + Start: item.NewDate(2021, 2, 3), + N: 3, + } + everyNMonthsStr := "2021-02-03, every 3 months" + + t.Run("parse", func(t *testing.T) { + if diff := cmp.Diff(everyNMonths, item.NewRecurrer(everyNMonthsStr)); diff != "" { + t.Errorf("(-exp, +got)%s\n", diff) + } + }) + + t.Run("string", func(t *testing.T) { + if everyNMonthsStr != everyNMonths.String() { + t.Errorf("exp %v, got %v", everyNMonthsStr, everyNMonths.String()) + } + }) + + t.Run("recurs on", func(t *testing.T) { + for _, tc := range []struct { + name string + date item.Date + exp bool + }{ + { + name: "before start", + date: item.NewDate(2021, 1, 27), + }, + { + name: "on start", + date: item.NewDate(2021, 2, 3), exp: true, }, { name: "8 weeks after", - date: time.Date(2021, 3, 31, 0, 0, 0, 0, time.UTC), + date: item.NewDate(2021, 3, 31), }, { name: "one month", - date: time.Date(2021, 3, 3, 0, 0, 0, 0, time.UTC), + date: item.NewDate(2021, 3, 3), }, { name: "3 months", - date: time.Date(2021, 5, 3, 0, 0, 0, 0, time.UTC), + date: item.NewDate(2021, 5, 3), exp: true, }, { name: "4 months", - date: time.Date(2021, 6, 3, 0, 0, 0, 0, time.UTC), + date: item.NewDate(2021, 6, 3), }, { name: "6 months", - date: time.Date(2021, 8, 3, 0, 0, 0, 0, time.UTC), + date: item.NewDate(2021, 8, 3), exp: true, }, } { t.Run(tc.name, func(t *testing.T) { - if act := r.On(tc.date); tc.exp != act { - t.Errorf("exp %v, got %v", tc.exp, act) + if tc.exp != everyNMonths.RecursOn(tc.date) { + t.Errorf("exp %v, got %v", tc.exp, everyNMonths.RecursOn(tc.date)) } }) } }) + t.Run("recurs every year", func(t *testing.T) { + recur := item.EveryNMonths{ + Start: item.NewDate(2021, 3, 1), + N: 12, + } + if recur.RecursOn(item.NewDate(2021, 3, 9)) { + t.Errorf("exp false, got true") + } + }) + + t.Run("bug", func(t *testing.T) { + recur := item.EveryNMonths{ + Start: item.NewDate(2021, 3, 1), + N: 1, + } + if recur.RecursOn(item.NewDate(2021, 11, 3)) { + t.Errorf("exp false, got true") + } + }) } diff --git a/item/time.go b/item/time.go new file mode 100644 index 0000000..af7ae0c --- /dev/null +++ b/item/time.go @@ -0,0 +1,70 @@ +package item + +import ( + "encoding/json" + "time" +) + +const ( + TimeFormat = "15:04" +) + +type Time struct { + t time.Time +} + +func (t Time) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + +func (t *Time) UnmarshalJSON(data []byte) error { + timeString := "" + if err := json.Unmarshal(data, &timeString); err != nil { + return err + } + nt := NewTimeFromString(timeString) + t.t = nt.Time() + + return nil +} + +func NewTime(hour, minute int) Time { + return Time{ + t: time.Date(0, 0, 0, hour, minute, 0, 0, time.UTC), + } +} + +func NewTimeFromString(timeStr string) Time { + tm, err := time.Parse(TimeFormat, timeStr) + if err != nil { + return Time{t: time.Time{}} + } + + return Time{t: tm} +} + +func (t *Time) String() string { + return t.t.Format(TimeFormat) +} + +func (t *Time) Time() time.Time { + return t.t +} + +func (t *Time) IsZero() bool { + return t.t.IsZero() +} + +func (t *Time) Hour() int { + return t.t.Hour() +} + +func (t *Time) Minute() int { + return t.t.Minute() +} + +func (t *Time) Add(d time.Duration) Time { + return Time{ + t: t.t.Add(d), + } +} diff --git a/item/time_test.go b/item/time_test.go new file mode 100644 index 0000000..3811cca --- /dev/null +++ b/item/time_test.go @@ -0,0 +1,67 @@ +package item_test + +import ( + "encoding/json" + "fmt" + "testing" + + "go-mod.ewintr.nl/planner/item" +) + +func TestTime(t *testing.T) { + t.Parallel() + + h, m := 11, 18 + tm := item.NewTime(h, m) + expStr := "11:18" + if expStr != tm.String() { + t.Errorf("exp %v, got %v", expStr, tm.String()) + } + actJSON, err := json.Marshal(tm) + if err != nil { + t.Errorf("exp nil, got %v", err) + } + expJSON := fmt.Sprintf("%q", expStr) + if expJSON != string(actJSON) { + t.Errorf("exp %v, got %v", expJSON, string(actJSON)) + } + var actTM item.Time + if err := json.Unmarshal(actJSON, &actTM); err != nil { + t.Errorf("exp nil, got %v", err) + } + if expStr != actTM.String() { + t.Errorf("ecp %v, got %v", expStr, actTM.String()) + } +} + +func TestTimeFromString(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + str string + exp string + }{ + { + name: "empty", + exp: "00:00", + }, + { + name: "invalid", + str: "invalid", + exp: "00:00", + }, + { + name: "valid", + str: "11:42", + exp: "11:42", + }, + } { + t.Run(tc.name, func(t *testing.T) { + act := item.NewTimeFromString(tc.str) + if tc.exp != act.String() { + t.Errorf("exp %v, got %v", tc.exp, act.String()) + } + }) + } +} diff --git a/plan/command/add.go b/plan/command/add.go index e8e9875..8568c9e 100644 --- a/plan/command/add.go +++ b/plan/command/add.go @@ -3,7 +3,6 @@ package command import ( "fmt" "strings" - "time" "github.com/google/uuid" "go-mod.ewintr.nl/planner/item" @@ -24,11 +23,10 @@ func NewAdd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage syncRepo: syncRepo, argSet: &ArgSet{ Flags: map[string]Flag{ - FlagOn: &FlagDate{}, - FlagAt: &FlagTime{}, - FlagFor: &FlagDuration{}, - FlagRecStart: &FlagDate{}, - FlagRecPeriod: &FlagPeriod{}, + FlagOn: &FlagDate{}, + FlagAt: &FlagTime{}, + FlagFor: &FlagDuration{}, + FlagRec: &FlagRecurrer{}, }, }, } @@ -70,39 +68,25 @@ func (add *Add) Execute(main []string, flags map[string]string) error { return fmt.Errorf("could not set duration to 24 hours") } } - if as.IsSet(FlagRecStart) != as.IsSet(FlagRecPeriod) { - return fmt.Errorf("rec-start required rec-period and vice versa") - } return add.do() } func (add *Add) do() error { as := add.argSet - start := as.GetTime(FlagOn) - if as.IsSet(FlagAt) { - at := as.GetTime(FlagAt) - h := time.Duration(at.Hour()) * time.Hour - m := time.Duration(at.Minute()) * time.Minute - start = start.Add(h).Add(m) - } - + rec := as.GetRecurrer(FlagRec) e := item.Event{ - ID: uuid.New().String(), + ID: uuid.New().String(), + Date: as.GetDate(FlagOn), + Recurrer: rec, EventBody: item.EventBody{ - Title: as.Main, - Start: start, + Title: as.Main, + Time: as.GetTime(FlagAt), + Duration: as.GetDuration(FlagFor), }, } - - if as.IsSet(FlagFor) { - e.Duration = as.GetDuration(FlagFor) - } - if as.IsSet(FlagRecStart) { - e.Recurrer = &item.Recur{ - Start: as.GetTime(FlagRecStart), - Period: as.GetRecurPeriod(FlagRecPeriod), - } + if rec != nil { + e.RecurNext = rec.First() } if err := add.eventRepo.Store(e); err != nil { diff --git a/plan/command/add_test.go b/plan/command/add_test.go index d82ef81..212ad69 100644 --- a/plan/command/add_test.go +++ b/plan/command/add_test.go @@ -4,7 +4,6 @@ import ( "testing" "time" - "github.com/google/go-cmp/cmp" "go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/storage/memory" @@ -13,13 +12,11 @@ import ( func TestAdd(t *testing.T) { t.Parallel() - aDateStr := "2024-11-02" - aDate := time.Date(2024, 11, 2, 0, 0, 0, 0, time.UTC) - aTimeStr := "12:00" + aDate := item.NewDate(2024, 11, 2) + aTime := item.NewTime(12, 0) aDay := time.Duration(24) * time.Hour anHourStr := "1h" anHour := time.Hour - aDateAndTime := time.Date(2024, 11, 2, 12, 0, 0, 0, time.UTC) for _, tc := range []struct { name string @@ -36,7 +33,7 @@ func TestAdd(t *testing.T) { name: "title missing", main: []string{"add"}, flags: map[string]string{ - command.FlagOn: aDateStr, + command.FlagOn: aDate.String(), }, expErr: true, }, @@ -49,46 +46,31 @@ func TestAdd(t *testing.T) { name: "only date", main: []string{"add", "title"}, flags: map[string]string{ - command.FlagOn: aDateStr, + command.FlagOn: aDate.String(), }, expEvent: item.Event{ - ID: "title", + ID: "title", + Date: aDate, EventBody: item.EventBody{ Title: "title", - Start: aDate, Duration: aDay, }, }, }, - { - name: "date and time", - main: []string{"add", "title"}, - flags: map[string]string{ - command.FlagOn: aDateStr, - command.FlagAt: aTimeStr, - }, - expEvent: item.Event{ - ID: "title", - EventBody: item.EventBody{ - Title: "title", - Start: aDateAndTime, - Duration: anHour, - }, - }, - }, { name: "date, time and duration", main: []string{"add", "title"}, flags: map[string]string{ - command.FlagOn: aDateStr, - command.FlagAt: aTimeStr, + command.FlagOn: aDate.String(), + command.FlagAt: aTime.String(), command.FlagFor: anHourStr, }, expEvent: item.Event{ - ID: "title", + ID: "title", + Date: aDate, EventBody: item.EventBody{ Title: "title", - Start: aDateAndTime, + Time: aTime, Duration: anHour, }, }, @@ -97,51 +79,11 @@ func TestAdd(t *testing.T) { name: "date and duration", main: []string{"add", "title"}, flags: map[string]string{ - command.FlagOn: aDateStr, + command.FlagOn: aDate.String(), command.FlagFor: anHourStr, }, expErr: true, }, - { - name: "rec-start without rec-period", - main: []string{"add", "title"}, - flags: map[string]string{ - command.FlagOn: aDateStr, - command.FlagRecStart: "2024-12-08", - }, - expErr: true, - }, - { - name: "rec-period without rec-start", - main: []string{"add", "title"}, - flags: map[string]string{ - command.FlagOn: aDateStr, - command.FlagRecPeriod: "day", - }, - expErr: true, - }, - { - name: "rec-start with rec-period", - main: []string{"add", "title"}, - flags: map[string]string{ - command.FlagOn: aDateStr, - command.FlagRecStart: "2024-12-08", - command.FlagRecPeriod: "day", - }, - expEvent: item.Event{ - ID: "title", - Recurrer: &item.Recur{ - Start: time.Date(2024, 12, 8, 0, 0, 0, 0, time.UTC), - Period: item.PeriodDay, - }, - RecurNext: time.Time{}, - EventBody: item.EventBody{ - Title: "title", - Start: aDate, - Duration: aDay, - }, - }, - }, } { t.Run(tc.name, func(t *testing.T) { eventRepo := memory.NewEvent() @@ -179,7 +121,7 @@ func TestAdd(t *testing.T) { t.Errorf("exp string not te be empty") } tc.expEvent.ID = actEvents[0].ID - if diff := cmp.Diff(tc.expEvent, actEvents[0]); diff != "" { + if diff := item.EventDiff(tc.expEvent, actEvents[0]); diff != "" { t.Errorf("(exp -, got +)\n%s", diff) } diff --git a/plan/command/argset.go b/plan/command/argset.go index 0667e34..7464d1f 100644 --- a/plan/command/argset.go +++ b/plan/command/argset.go @@ -40,14 +40,26 @@ func (as *ArgSet) GetString(name string) string { return val } -func (as *ArgSet) GetTime(name string) time.Time { +func (as *ArgSet) GetDate(name string) item.Date { flag, ok := as.Flags[name] if !ok { - return time.Time{} + return item.Date{} } - val, ok := flag.Get().(time.Time) + val, ok := flag.Get().(item.Date) if !ok { - return time.Time{} + return item.Date{} + } + return val +} + +func (as *ArgSet) GetTime(name string) item.Time { + flag, ok := as.Flags[name] + if !ok { + return item.Time{} + } + val, ok := flag.Get().(item.Time) + if !ok { + return item.Time{} } return val } @@ -64,14 +76,26 @@ func (as *ArgSet) GetDuration(name string) time.Duration { return val } -func (as *ArgSet) GetRecurPeriod(name string) item.RecurPeriod { +func (as *ArgSet) GetRecurrer(name string) item.Recurrer { flag, ok := as.Flags[name] if !ok { - return item.RecurPeriod("") + return nil } - val, ok := flag.Get().(item.RecurPeriod) + val, ok := flag.Get().(item.Recurrer) if !ok { - return item.RecurPeriod("") + return nil + } + return val +} + +func (as *ArgSet) GetInt(name string) int { + flag, ok := as.Flags[name] + if !ok { + return 0 + } + val, ok := flag.Get().(int) + if !ok { + return 0 } return val } diff --git a/plan/command/argset_test.go b/plan/command/argset_test.go index 2261074..0e03ede 100644 --- a/plan/command/argset_test.go +++ b/plan/command/argset_test.go @@ -56,11 +56,11 @@ func TestArgSet(t *testing.T) { { name: "recur period flag success", flags: map[string]command.Flag{ - "period": &command.FlagPeriod{Name: "period"}, + "recur": &command.FlagRecurrer{Name: "recur"}, }, - flagName: "period", - setValue: "month", - exp: item.PeriodMonth, + flagName: "recur", + setValue: "2024-12-23, daily", + exp: item.NewRecurrer("2024-12-23, daily"), }, { name: "unknown flag error", @@ -95,26 +95,9 @@ func TestArgSet(t *testing.T) { return } - // Verify IsSet() returns true after setting if !as.IsSet(tt.flagName) { t.Errorf("ArgSet.IsSet() = false, want true for flag %s", tt.flagName) } - - // Verify the value was set correctly based on flag type - switch v := tt.exp.(type) { - case string: - if got := as.GetString(tt.flagName); got != v { - t.Errorf("ArgSet.GetString() = %v, want %v", got, v) - } - case time.Time: - if got := as.GetTime(tt.flagName); !got.Equal(v) { - t.Errorf("ArgSet.GetTime() = %v, want %v", got, v) - } - case time.Duration: - if got := as.GetDuration(tt.flagName); got != v { - t.Errorf("ArgSet.GetDuration() = %v, want %v", got, v) - } - } }) } } diff --git a/plan/command/command.go b/plan/command/command.go index 0ec2a03..6c1daa8 100644 --- a/plan/command/command.go +++ b/plan/command/command.go @@ -7,12 +7,11 @@ import ( ) const ( - FlagTitle = "title" - FlagOn = "on" - FlagAt = "at" - FlagFor = "for" - FlagRecStart = "rec-start" - FlagRecPeriod = "rec-period" + FlagTitle = "title" + FlagOn = "on" + FlagAt = "at" + FlagFor = "for" + FlagRec = "rec" ) type Command interface { diff --git a/plan/command/command_test.go b/plan/command/command_test.go index 09241e0..54af244 100644 --- a/plan/command/command_test.go +++ b/plan/command/command_test.go @@ -1,109 +1,68 @@ package command_test -// func TestArgSet(t *testing.T) { -// t.Parallel() +import ( + "testing" -// as := command.ArgSet{ -// Main: "main", -// Flags: map[string]string{ -// "name 1": "value 1", -// "name 2": "value 2", -// "name 3": "value 3", -// }, -// } + "github.com/google/go-cmp/cmp" + "go-mod.ewintr.nl/planner/plan/command" +) -// t.Run("hasflag", func(t *testing.T) { -// t.Run("true", func(t *testing.T) { -// if has := as.HasFlag("name 1"); !has { -// t.Errorf("exp true, got %v", has) -// } -// }) -// t.Run("false", func(t *testing.T) { -// if has := as.HasFlag("unknown"); has { -// t.Errorf("exp false, got %v", has) -// } -// }) -// }) +func TestParseArgs(t *testing.T) { + t.Parallel() -// t.Run("flag", func(t *testing.T) { -// t.Run("known", func(t *testing.T) { -// if val := as.Flag("name 1"); val != "value 1" { -// t.Errorf("exp value 1, got %v", val) -// } -// }) -// t.Run("unknown", func(t *testing.T) { -// if val := as.Flag("unknown"); val != "" { -// t.Errorf(`exp "", got %v`, val) -// } -// }) -// }) - -// t.Run("setflag", func(t *testing.T) { -// exp := "new value" -// as.SetFlag("new name", exp) -// if act := as.Flag("new name"); exp != act { -// t.Errorf("exp %v, got %v", exp, act) -// } -// }) -// } - -// func TestParseArgs(t *testing.T) { -// t.Parallel() - -// for _, tc := range []struct { -// name string -// args []string -// expAS *command.ArgSet -// expErr bool -// }{ -// { -// name: "empty", -// expAS: &command.ArgSet{ -// Flags: map[string]string{}, -// }, -// }, -// { -// name: "just main", -// args: []string{"one", "two three", "four"}, -// expAS: &command.ArgSet{ -// Main: "one two three four", -// Flags: map[string]string{}, -// }, -// }, -// { -// name: "with flags", -// args: []string{"-flag1", "value1", "one", "two", "-flag2", "value2", "-flag3", "value3"}, -// expAS: &command.ArgSet{ -// Main: "one two", -// Flags: map[string]string{ -// "flag1": "value1", -// "flag2": "value2", -// "flag3": "value3", -// }, -// }, -// }, -// { -// name: "flag without value", -// args: []string{"one", "two", "-flag1"}, -// expErr: true, -// }, -// { -// name: "split main", -// args: []string{"one", "-flag1", "value1", "two"}, -// expErr: true, -// }, -// } { -// t.Run(tc.name, func(t *testing.T) { -// actAS, actErr := command.ParseArgs(tc.args) -// if tc.expErr != (actErr != nil) { -// t.Errorf("exp %v, got %v", tc.expErr, actErr) -// } -// if tc.expErr { -// return -// } -// if diff := cmp.Diff(tc.expAS, actAS); diff != "" { -// t.Errorf("(exp +, got -)\n%s", diff) -// } -// }) -// } -// } + for _, tc := range []struct { + name string + args []string + expMain []string + expFlags map[string]string + expErr bool + }{ + { + name: "empty", + expMain: []string{}, + expFlags: map[string]string{}, + }, + { + name: "just main", + args: []string{"one", "two three", "four"}, + expMain: []string{"one", "two three", "four"}, + expFlags: map[string]string{}, + }, + { + name: "with flags", + args: []string{"-flag1", "value1", "one", "two", "-flag2", "value2", "-flag3", "value3"}, + expMain: []string{"one", "two"}, + expFlags: map[string]string{ + "flag1": "value1", + "flag2": "value2", + "flag3": "value3", + }, + }, + { + name: "flag without value", + args: []string{"one", "two", "-flag1"}, + expErr: true, + }, + { + name: "split main", + args: []string{"one", "-flag1", "value1", "two"}, + expErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actMain, actFlags, actErr := command.ParseFlags(tc.args) + if tc.expErr != (actErr != nil) { + t.Errorf("exp %v, got %v", tc.expErr, actErr) + } + if tc.expErr { + return + } + if diff := cmp.Diff(tc.expMain, actMain); diff != "" { + t.Errorf("(exp +, got -)\n%s", diff) + } + if diff := cmp.Diff(tc.expFlags, actFlags); diff != "" { + t.Errorf("(exp +, got -)\n%s", diff) + } + }) + } +} diff --git a/plan/command/delete_test.go b/plan/command/delete_test.go index 607516a..9005498 100644 --- a/plan/command/delete_test.go +++ b/plan/command/delete_test.go @@ -3,7 +3,6 @@ package command_test import ( "errors" "testing" - "time" "go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/plan/command" @@ -15,10 +14,10 @@ func TestDelete(t *testing.T) { t.Parallel() e := item.Event{ - ID: "id", + ID: "id", + Date: item.NewDate(2024, 10, 7), EventBody: item.EventBody{ Title: "name", - Start: time.Date(2024, 10, 7, 9, 30, 0, 0, time.UTC), }, } diff --git a/plan/command/flag.go b/plan/command/flag.go index d50f376..c771e60 100644 --- a/plan/command/flag.go +++ b/plan/command/flag.go @@ -3,7 +3,7 @@ package command import ( "errors" "fmt" - "slices" + "strconv" "time" "go-mod.ewintr.nl/planner/item" @@ -45,35 +45,35 @@ func (fs *FlagString) Get() any { type FlagDate struct { Name string - Value time.Time + Value item.Date } -func (ft *FlagDate) Set(val string) error { - d, err := time.Parse(DateFormat, val) - if err != nil { +func (fd *FlagDate) Set(val string) error { + d := item.NewDateFromString(val) + if d.IsZero() { return fmt.Errorf("could not parse date: %v", d) } - ft.Value = d + fd.Value = d return nil } -func (ft *FlagDate) IsSet() bool { - return !ft.Value.IsZero() +func (fd *FlagDate) IsSet() bool { + return !fd.Value.IsZero() } -func (fs *FlagDate) Get() any { - return fs.Value +func (fd *FlagDate) Get() any { + return fd.Value } type FlagTime struct { Name string - Value time.Time + Value item.Time } func (ft *FlagTime) Set(val string) error { - d, err := time.Parse(TimeFormat, val) - if err != nil { + d := item.NewTimeFromString(val) + if d.IsZero() { return fmt.Errorf("could not parse date: %v", d) } ft.Value = d @@ -111,23 +111,46 @@ func (fs *FlagDuration) Get() any { return fs.Value } -type FlagPeriod struct { +type FlagRecurrer struct { Name string - Value item.RecurPeriod + Value item.Recurrer } -func (fp *FlagPeriod) Set(val string) error { - if !slices.Contains(item.ValidPeriods, item.RecurPeriod(val)) { - return fmt.Errorf("not a valid period: %v", val) +func (fr *FlagRecurrer) Set(val string) error { + fr.Value = item.NewRecurrer(val) + if fr.Value == nil { + return fmt.Errorf("not a valid recurrer: %v", val) } - fp.Value = item.RecurPeriod(val) return nil } -func (fp *FlagPeriod) IsSet() bool { - return fp.Value != "" +func (fr *FlagRecurrer) IsSet() bool { + return fr.Value != nil } -func (fp *FlagPeriod) Get() any { - return fp.Value +func (fr *FlagRecurrer) Get() any { + return fr.Value +} + +type FlagInt struct { + Name string + Value int +} + +func (fi *FlagInt) Set(val string) error { + i, err := strconv.Atoi(val) + if err != nil { + return fmt.Errorf("not a valid integer: %v", val) + } + + fi.Value = i + return nil +} + +func (fi *FlagInt) IsSet() bool { + return fi.Value != 0 +} + +func (fi *FlagInt) Get() any { + return fi.Value } diff --git a/plan/command/flag_test.go b/plan/command/flag_test.go index ae552f4..1a18e90 100644 --- a/plan/command/flag_test.go +++ b/plan/command/flag_test.go @@ -37,14 +37,13 @@ func TestFlagString(t *testing.T) { func TestFlagDate(t *testing.T) { t.Parallel() - valid := time.Date(2024, 11, 20, 0, 0, 0, 0, time.UTC) - validStr := "2024-11-20" + valid := item.NewDate(2024, 11, 20) f := command.FlagDate{} if f.IsSet() { t.Errorf("exp false, got true") } - if err := f.Set(validStr); err != nil { + if err := f.Set(valid.String()); err != nil { t.Errorf("exp nil, got %v", err) } @@ -52,26 +51,25 @@ func TestFlagDate(t *testing.T) { t.Errorf("exp true, got false") } - act, ok := f.Get().(time.Time) + act, ok := f.Get().(item.Date) if !ok { t.Errorf("exp true, got false") } - if act != valid { - t.Errorf("exp %v, got %v", valid, act) + if act.String() != valid.String() { + t.Errorf("exp %v, got %v", valid.String(), act.String()) } } func TestFlagTime(t *testing.T) { t.Parallel() - valid := time.Date(0, 1, 1, 12, 30, 0, 0, time.UTC) - validStr := "12:30" + valid := item.NewTime(12, 30) f := command.FlagTime{} if f.IsSet() { t.Errorf("exp false, got true") } - if err := f.Set(validStr); err != nil { + if err := f.Set(valid.String()); err != nil { t.Errorf("exp nil, got %v", err) } @@ -79,12 +77,12 @@ func TestFlagTime(t *testing.T) { t.Errorf("exp true, got false") } - act, ok := f.Get().(time.Time) + act, ok := f.Get().(item.Time) if !ok { t.Errorf("exp true, got false") } - if act != valid { - t.Errorf("exp %v, got %v", valid, act) + if act.String() != valid.String() { + t.Errorf("exp %v, got %v", valid.String(), act.String()) } } @@ -115,12 +113,12 @@ func TestFlagDurationTime(t *testing.T) { } } -func TestFlagPeriod(t *testing.T) { +func TestFlagRecurrer(t *testing.T) { t.Parallel() - valid := item.PeriodMonth - validStr := "month" - f := command.FlagPeriod{} + validStr := "2024-12-23, daily" + valid := item.NewRecurrer(validStr) + f := command.FlagRecurrer{} if f.IsSet() { t.Errorf("exp false, got true") } @@ -133,7 +131,7 @@ func TestFlagPeriod(t *testing.T) { t.Errorf("exp true, got false") } - act, ok := f.Get().(item.RecurPeriod) + act, ok := f.Get().(item.Recurrer) if !ok { t.Errorf("exp true, got false") } diff --git a/plan/command/list.go b/plan/command/list.go index e94ca91..a41ae66 100644 --- a/plan/command/list.go +++ b/plan/command/list.go @@ -2,7 +2,6 @@ package command import ( "fmt" - "time" "go-mod.ewintr.nl/planner/plan/storage" ) @@ -41,7 +40,7 @@ func (list *List) do() error { if !ok { return fmt.Errorf("could not find local id for %s", e.ID) } - fmt.Printf("%s\t%d\t%s\t%s\t%s\n", e.ID, lid, e.Title, e.Start.Format(time.DateTime), e.Duration.String()) + fmt.Printf("%s\t%d\t%s\t%s\t%s\n", e.ID, lid, e.Title, e.Date.String(), e.Duration.String()) } return nil diff --git a/plan/command/list_test.go b/plan/command/list_test.go index 53c8707..67033ad 100644 --- a/plan/command/list_test.go +++ b/plan/command/list_test.go @@ -2,7 +2,6 @@ package command_test import ( "testing" - "time" "go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/plan/command" @@ -15,10 +14,10 @@ func TestList(t *testing.T) { eventRepo := memory.NewEvent() localRepo := memory.NewLocalID() e := item.Event{ - ID: "id", + ID: "id", + Date: item.NewDate(2024, 10, 7), EventBody: item.EventBody{ Title: "name", - Start: time.Date(2024, 10, 7, 9, 30, 0, 0, time.UTC), }, } if err := eventRepo.Store(e); err != nil { diff --git a/plan/command/sync_test.go b/plan/command/sync_test.go index c50151f..080d04a 100644 --- a/plan/command/sync_test.go +++ b/plan/command/sync_test.go @@ -137,10 +137,10 @@ func TestSyncReceive(t *testing.T) { }`, }}, expEvent: []item.Event{{ - ID: "a", + ID: "a", + Date: item.NewDate(2024, 10, 23), EventBody: item.EventBody{ Title: "title", - Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC), Duration: oneHour, }, }}, @@ -151,10 +151,10 @@ func TestSyncReceive(t *testing.T) { { name: "update existing", present: []item.Event{{ - ID: "a", + ID: "a", + Date: item.NewDate(2024, 10, 23), EventBody: item.EventBody{ Title: "title", - Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC), Duration: oneHour, }, }}, @@ -168,10 +168,10 @@ func TestSyncReceive(t *testing.T) { }`, }}, expEvent: []item.Event{{ - ID: "a", + ID: "a", + Date: item.NewDate(2024, 10, 23), EventBody: item.EventBody{ Title: "new title", - Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC), Duration: oneHour, }, }}, @@ -210,7 +210,7 @@ func TestSyncReceive(t *testing.T) { if err != nil { t.Errorf("exp nil, got %v", err) } - if diff := cmp.Diff(tc.expEvent, actEvents); diff != "" { + if diff := item.EventDiffs(tc.expEvent, actEvents); diff != "" { t.Errorf("(exp +, got -)\n%s", diff) } actLocalIDs, err := localIDRepo.FindAll() diff --git a/plan/command/update.go b/plan/command/update.go index e958346..16a4749 100644 --- a/plan/command/update.go +++ b/plan/command/update.go @@ -4,9 +4,7 @@ import ( "fmt" "strconv" "strings" - "time" - "go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/plan/storage" ) @@ -25,12 +23,11 @@ func NewUpdate(localIDRepo storage.LocalID, eventRepo storage.Event, syncRepo st syncRepo: syncRepo, argSet: &ArgSet{ Flags: map[string]Flag{ - FlagTitle: &FlagString{}, - FlagOn: &FlagDate{}, - FlagAt: &FlagTime{}, - FlagFor: &FlagDuration{}, - FlagRecStart: &FlagDate{}, - FlagRecPeriod: &FlagPeriod{}, + FlagTitle: &FlagString{}, + FlagOn: &FlagDate{}, + FlagAt: &FlagTime{}, + FlagFor: &FlagDuration{}, + FlagRec: &FlagRecurrer{}, }, }, } @@ -87,35 +84,17 @@ func (update *Update) do() error { if as.Main != "" { e.Title = as.Main } - if as.IsSet(FlagOn) || as.IsSet(FlagAt) { - on := time.Date(e.Start.Year(), e.Start.Month(), e.Start.Day(), 0, 0, 0, 0, time.UTC) - atH := time.Duration(e.Start.Hour()) * time.Hour - atM := time.Duration(e.Start.Minute()) * time.Minute - - if as.IsSet(FlagOn) { - on = as.GetTime(FlagOn) - } - if as.IsSet(FlagAt) { - at := as.GetTime(FlagAt) - atH = time.Duration(at.Hour()) * time.Hour - atM = time.Duration(at.Minute()) * time.Minute - } - e.Start = on.Add(atH).Add(atM) + if as.IsSet(FlagOn) { + e.Date = as.GetDate(FlagOn) + } + if as.IsSet(FlagAt) { + e.Time = as.GetTime(FlagAt) } - if as.IsSet(FlagFor) { e.Duration = as.GetDuration(FlagFor) } - if as.IsSet(FlagRecStart) || as.IsSet(FlagRecPeriod) { - if e.Recurrer == nil { - e.Recurrer = &item.Recur{} - } - if as.IsSet(FlagRecStart) { - e.Recurrer.Start = as.GetTime(FlagRecStart) - } - if as.IsSet(FlagRecPeriod) { - e.Recurrer.Period = as.GetRecurPeriod(FlagRecPeriod) - } + if as.IsSet(FlagRec) { + e.Recurrer = as.GetRecurrer(FlagRec) } if !e.Valid() { diff --git a/plan/command/update_test.go b/plan/command/update_test.go index 14b5575..98db5c1 100644 --- a/plan/command/update_test.go +++ b/plan/command/update_test.go @@ -5,7 +5,6 @@ import ( "testing" "time" - "github.com/google/go-cmp/cmp" "go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/storage/memory" @@ -21,7 +20,8 @@ func TestUpdateExecute(t *testing.T) { t.Errorf("exp nil, got %v", err) } title := "title" - start := time.Date(2024, 10, 6, 10, 0, 0, 0, time.UTC) + aDate := item.NewDate(2024, 10, 6) + aTime := item.NewTime(10, 0) twoHour, err := time.ParseDuration("2h") if err != nil { t.Errorf("exp nil, got %v", err) @@ -49,10 +49,11 @@ func TestUpdateExecute(t *testing.T) { localID: lid, main: []string{"update", fmt.Sprintf("%d", lid), "updated"}, expEvent: item.Event{ - ID: eid, + ID: eid, + Date: item.NewDate(2024, 10, 6), EventBody: item.EventBody{ Title: "updated", - Start: start, + Time: aTime, Duration: oneHour, }, }, @@ -74,10 +75,11 @@ func TestUpdateExecute(t *testing.T) { "on": "2024-10-02", }, expEvent: item.Event{ - ID: eid, + ID: eid, + Date: item.NewDate(2024, 10, 2), EventBody: item.EventBody{ Title: title, - Start: time.Date(2024, 10, 2, 10, 0, 0, 0, time.UTC), + Time: aTime, Duration: oneHour, }, }, @@ -99,10 +101,11 @@ func TestUpdateExecute(t *testing.T) { "at": "11:00", }, expEvent: item.Event{ - ID: eid, + ID: eid, + Date: item.NewDate(2024, 10, 6), EventBody: item.EventBody{ Title: title, - Start: time.Date(2024, 10, 6, 11, 0, 0, 0, time.UTC), + Time: item.NewTime(11, 0), Duration: oneHour, }, }, @@ -116,10 +119,11 @@ func TestUpdateExecute(t *testing.T) { "at": "11:00", }, expEvent: item.Event{ - ID: eid, + ID: eid, + Date: item.NewDate(2024, 10, 2), EventBody: item.EventBody{ Title: title, - Start: time.Date(2024, 10, 2, 11, 0, 0, 0, time.UTC), + Time: item.NewTime(11, 0), Duration: oneHour, }, }, @@ -141,62 +145,36 @@ func TestUpdateExecute(t *testing.T) { "for": "2h", }, expEvent: item.Event{ - ID: eid, + ID: eid, + Date: item.NewDate(2024, 10, 6), EventBody: item.EventBody{ Title: title, - Start: time.Date(2024, 10, 6, 10, 0, 0, 0, time.UTC), + Time: aTime, Duration: twoHour, }, }, }, { - name: "invalid rec start", + name: "invalid rec", main: []string{"update", fmt.Sprintf("%d", lid)}, flags: map[string]string{ - "rec-start": "invalud", + "rec": "invalud", }, expErr: true, }, { - name: "valid rec start", + name: "valid rec", main: []string{"update", fmt.Sprintf("%d", lid)}, flags: map[string]string{ - "rec-start": "2024-12-08", + "rec": "2024-12-08, daily", }, expEvent: item.Event{ - ID: eid, - Recurrer: &item.Recur{ - Start: time.Date(2024, 12, 8, 0, 0, 0, 0, time.UTC), - }, + ID: eid, + Date: aDate, + Recurrer: item.NewRecurrer("2024-12-08, daily"), EventBody: item.EventBody{ Title: title, - Start: start, - Duration: oneHour, - }, - }, - }, - { - name: "invalid rec period", - main: []string{"update", fmt.Sprintf("%d", lid)}, - flags: map[string]string{ - "rec-period": "invalid", - }, - expErr: true, - }, - { - name: "valid rec period", - main: []string{"update", fmt.Sprintf("%d", lid)}, - flags: map[string]string{ - "rec-period": "month", - }, - expEvent: item.Event{ - ID: eid, - Recurrer: &item.Recur{ - Period: item.PeriodMonth, - }, - EventBody: item.EventBody{ - Title: title, - Start: start, + Time: aTime, Duration: oneHour, }, }, @@ -207,10 +185,11 @@ func TestUpdateExecute(t *testing.T) { localIDRepo := memory.NewLocalID() syncRepo := memory.NewSync() if err := eventRepo.Store(item.Event{ - ID: eid, + ID: eid, + Date: aDate, EventBody: item.EventBody{ Title: title, - Start: start, + Time: aTime, Duration: oneHour, }, }); err != nil { @@ -233,7 +212,7 @@ func TestUpdateExecute(t *testing.T) { if err != nil { t.Errorf("exp nil, got %v", err) } - if diff := cmp.Diff(tc.expEvent, actEvent); diff != "" { + if diff := item.EventDiff(tc.expEvent, actEvent); diff != "" { t.Errorf("(exp -, got +)\n%s", diff) } updated, err := syncRepo.FindAll() diff --git a/plan/main.go b/plan/main.go index 9dab701..9f94df2 100644 --- a/plan/main.go +++ b/plan/main.go @@ -12,12 +12,16 @@ import ( ) func main() { - confPath, err := os.UserConfigDir() - if err != nil { - fmt.Printf("could not get config path: %s\n", err) - os.Exit(1) + confPath := os.Getenv("PLAN_CONFIG_PATH") + if confPath == "" { + userConfigDir, err := os.UserConfigDir() + if err != nil { + fmt.Printf("could not get config path: %s\n", err) + os.Exit(1) + } + confPath = filepath.Join(userConfigDir, "planner", "plan", "config.yaml") } - conf, err := LoadConfig(filepath.Join(confPath, "planner", "plan", "config.yaml")) + conf, err := LoadConfig(confPath) if err != nil { fmt.Printf("could not open config file: %s\n", err) os.Exit(1) diff --git a/plan/storage/memory/event_test.go b/plan/storage/memory/event_test.go index 54645c3..a7dea4d 100644 --- a/plan/storage/memory/event_test.go +++ b/plan/storage/memory/event_test.go @@ -3,7 +3,6 @@ package memory import ( "testing" - "github.com/google/go-cmp/cmp" "go-mod.ewintr.nl/planner/item" ) @@ -50,7 +49,7 @@ func TestEvent(t *testing.T) { if actErr != nil { t.Errorf("exp nil, got %v", actErr) } - if diff := cmp.Diff([]item.Event{e1, e2}, actEvents); diff != "" { + if diff := item.EventDiffs([]item.Event{e1, e2}, actEvents); diff != "" { t.Errorf("(exp -, got +)\n%s", diff) } } diff --git a/plan/storage/sqlite/event.go b/plan/storage/sqlite/event.go index 6f553b2..595ad41 100644 --- a/plan/storage/sqlite/event.go +++ b/plan/storage/sqlite/event.go @@ -14,18 +14,25 @@ type SqliteEvent struct { } func (s *SqliteEvent) Store(event item.Event) error { + var recurStr string + if event.Recurrer != nil { + recurStr = event.Recurrer.String() + } if _, err := s.db.Exec(` INSERT INTO events -(id, title, start, duration) +(id, title, date, time, duration, recurrer) VALUES -(?, ?, ?, ?) +(?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title=?, -start=?, -duration=?`, - event.ID, event.Title, event.Start.Format(timestampFormat), event.Duration.String(), - event.Title, event.Start.Format(timestampFormat), event.Duration.String()); err != nil { +date=?, +time=?, +duration=?, +recurrer=? +`, + event.ID, event.Title, event.Date.String(), event.Time.String(), event.Duration.String(), recurStr, + event.Title, event.Date.String(), event.Time.String(), event.Duration.String(), recurStr); err != nil { return fmt.Errorf("%w: %v", ErrSqliteFailure, err) } return nil @@ -33,29 +40,32 @@ duration=?`, func (s *SqliteEvent) Find(id string) (item.Event, error) { var event item.Event - var durStr string + var dateStr, timeStr, recurStr, durStr string err := s.db.QueryRow(` -SELECT id, title, start, duration +SELECT id, title, date, time, duration, recurrer FROM events -WHERE id = ?`, id).Scan(&event.ID, &event.Title, &event.Start, &durStr) +WHERE id = ?`, id).Scan(&event.ID, &event.Title, &dateStr, &timeStr, &durStr, &recurStr) switch { case err == sql.ErrNoRows: return item.Event{}, fmt.Errorf("event not found: %w", err) case err != nil: return item.Event{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) } + event.Date = item.NewDateFromString(dateStr) + event.Time = item.NewTimeFromString(timeStr) dur, err := time.ParseDuration(durStr) if err != nil { - return item.Event{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + return item.Event{}, fmt.Errorf("could not unmarshal recurrer: %v", err) } event.Duration = dur + event.Recurrer = item.NewRecurrer(recurStr) return event, nil } func (s *SqliteEvent) FindAll() ([]item.Event, error) { rows, err := s.db.Query(` -SELECT id, title, start, duration +SELECT id, title, date, time, duration, recurrer FROM events`) if err != nil { return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) @@ -64,15 +74,19 @@ FROM events`) defer rows.Close() for rows.Next() { var event item.Event - var durStr string - if err := rows.Scan(&event.ID, &event.Title, &event.Start, &durStr); err != nil { + var dateStr, timeStr, recurStr, durStr string + if err := rows.Scan(&event.ID, &event.Title, &dateStr, &timeStr, &durStr, &recurStr); err != nil { return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) } dur, err := time.ParseDuration(durStr) if err != nil { return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) } + event.Date = item.NewDateFromString(dateStr) + event.Time = item.NewTimeFromString(timeStr) event.Duration = dur + event.Recurrer = item.NewRecurrer(recurStr) + result = append(result, event) } diff --git a/plan/storage/sqlite/sqlite.go b/plan/storage/sqlite/sqlite.go index f976280..bec4db3 100644 --- a/plan/storage/sqlite/sqlite.go +++ b/plan/storage/sqlite/sqlite.go @@ -25,6 +25,23 @@ var migrations = []string{ deleted BOOLEAN NOT NULL, body TEXT NOT NULL )`, + `ALTER TABLE events ADD COLUMN recur_period TEXT`, + `ALTER TABLE events ADD COLUMN recur_count INTEGER`, + `ALTER TABLE events ADD COLUMN recur_start TIMESTAMP`, + `ALTER TABLE events ADD COLUMN recur_next TIMESTAMP`, + `ALTER TABLE events DROP COLUMN recur_period`, + `ALTER TABLE events DROP COLUMN recur_count`, + `ALTER TABLE events DROP COLUMN recur_start`, + `ALTER TABLE events DROP COLUMN recur_next`, + `ALTER TABLE events ADD COLUMN recur TEXT`, + `ALTER TABLE items ADD COLUMN recurrer TEXT`, + `ALTER TABLE events DROP COLUMN recur`, + `ALTER TABLE events ADD COLUMN recurrer TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE events ADD COLUMN recur_next TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE events DROP COLUMN start`, + `ALTER TABLE events ADD COLUMN date TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE events ADD COLUMN time TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE items ADD COLUMN recur_next TEXT NOT NULL DEFAULT ''`, } var ( diff --git a/plan/storage/sqlite/sync.go b/plan/storage/sqlite/sync.go index 76669d6..55798d1 100644 --- a/plan/storage/sqlite/sync.go +++ b/plan/storage/sqlite/sync.go @@ -17,7 +17,7 @@ func NewSqliteSync(db *sql.DB) *SqliteSync { } func (s *SqliteSync) FindAll() ([]item.Item, error) { - rows, err := s.db.Query("SELECT id, kind, updated, deleted, body FROM items") + rows, err := s.db.Query("SELECT id, kind, updated, deleted, recurrer, recur_next, body FROM items") if err != nil { return nil, fmt.Errorf("%w: failed to query items: %v", ErrSqliteFailure, err) } @@ -26,37 +26,46 @@ func (s *SqliteSync) FindAll() ([]item.Item, error) { var items []item.Item for rows.Next() { var i item.Item - var updatedStr string - err := rows.Scan(&i.ID, &i.Kind, &updatedStr, &i.Deleted, &i.Body) + var updatedStr, recurStr, recurNextStr string + err := rows.Scan(&i.ID, &i.Kind, &updatedStr, &i.Deleted, &recurStr, &recurNextStr, &i.Body) if err != nil { return nil, fmt.Errorf("%w: failed to scan item: %v", ErrSqliteFailure, err) } i.Updated, err = time.Parse(time.RFC3339, updatedStr) if err != nil { - return nil, fmt.Errorf("%w: failed to parse updated time: %v", ErrSqliteFailure, err) + return nil, fmt.Errorf("failed to parse updated time: %v", err) } + i.Recurrer = item.NewRecurrer(recurStr) + i.RecurNext = item.NewDateFromString(recurNextStr) + items = append(items, i) } if err = rows.Err(); err != nil { - return nil, fmt.Errorf("%w: error iterating over rows: %v", ErrSqliteFailure, err) + return nil, fmt.Errorf("error iterating over rows: %v", err) } return items, nil } func (s *SqliteSync) Store(i item.Item) error { - // Ensure we have a valid time if i.Updated.IsZero() { i.Updated = time.Now() } + var recurStr string + if i.Recurrer != nil { + recurStr = i.Recurrer.String() + } _, err := s.db.Exec( - "INSERT OR REPLACE INTO items (id, kind, updated, deleted, body) VALUES (?, ?, ?, ?, ?)", + `INSERT OR REPLACE INTO items (id, kind, updated, deleted, recurrer, recur_next, body) + VALUES (?, ?, ?, ?, ?, ?, ?)`, i.ID, i.Kind, i.Updated.UTC().Format(time.RFC3339), i.Deleted, + recurStr, + i.RecurNext.String(), sql.NullString{String: i.Body, Valid: i.Body != ""}, // This allows empty string but not NULL ) if err != nil { diff --git a/plan/test-conf.yaml b/plan/test-conf.yaml new file mode 100644 index 0000000..0b2f181 --- /dev/null +++ b/plan/test-conf.yaml @@ -0,0 +1,3 @@ +db_path: ./plan.db +sync_url: http://localhost:8092 +api_key: testKey diff --git a/sync/client/http.go b/sync/client/http.go index a94aeba..324c490 100644 --- a/sync/client/http.go +++ b/sync/client/http.go @@ -45,7 +45,8 @@ func (c *HTTP) Update(items []item.Item) error { return fmt.Errorf("could not make request: %v", err) } if res.StatusCode != http.StatusNoContent { - return fmt.Errorf("server returned status %d", res.StatusCode) + body, _ := io.ReadAll(res.Body) + return fmt.Errorf("server returned status %d, body: %s", res.StatusCode, body) } return nil diff --git a/sync/service/memory.go b/sync/service/memory.go index ca92346..e079ced 100644 --- a/sync/service/memory.go +++ b/sync/service/memory.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "slices" "sync" "time" @@ -47,33 +46,15 @@ func (m *Memory) Updated(kinds []item.Kind, timestamp time.Time) ([]item.Item, e return result, nil } -func (m *Memory) RecursBefore(date time.Time) ([]item.Item, error) { +func (m *Memory) ShouldRecur(date item.Date) ([]item.Item, error) { res := make([]item.Item, 0) for _, i := range m.items { if i.Recurrer == nil { continue } - if i.RecurNext.Before(date) { + if date.Equal(i.RecurNext) || date.After(i.RecurNext) { res = append(res, i) } } return res, nil } - -func (m *Memory) RecursNext(id string, date time.Time, ts time.Time) error { - i, ok := m.items[id] - if !ok { - return ErrNotFound - } - if i.Recurrer == nil { - return ErrNotARecurrer - } - if !i.Recurrer.On(date) { - return fmt.Errorf("item does not recur on %v", date) - } - i.RecurNext = date - i.Updated = ts - m.items[id] = i - - return nil -} diff --git a/sync/service/memory_test.go b/sync/service/memory_test.go index a512348..b9009da 100644 --- a/sync/service/memory_test.go +++ b/sync/service/memory_test.go @@ -113,19 +113,14 @@ func TestMemoryRecur(t *testing.T) { mem := NewMemory() now := time.Now() earlier := now.Add(-5 * time.Minute) - today := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) - yesterday := time.Date(2024, 11, 30, 0, 0, 0, 0, time.UTC) - tomorrow := time.Date(2024, 12, 2, 0, 0, 0, 0, time.UTC) + today := item.NewDate(2024, 12, 1) + yesterday := item.NewDate(2024, 11, 30) t.Log("start") i1 := item.Item{ - ID: "a", - Updated: earlier, - Recurrer: &item.Recur{ - Start: yesterday, - Period: item.PeriodDay, - Count: 1, - }, + ID: "a", + Updated: earlier, + Recurrer: item.NewRecurrer("2024-11-30, daily"), RecurNext: yesterday, } i2 := item.Item{ @@ -140,7 +135,7 @@ func TestMemoryRecur(t *testing.T) { } t.Log("get recurrers") - rs, err := mem.RecursBefore(today) + rs, err := mem.ShouldRecur(today) if err != nil { t.Errorf("exp nil, gt %v", err) } @@ -148,20 +143,4 @@ func TestMemoryRecur(t *testing.T) { t.Errorf("(exp +, got -)\n%s", diff) } - t.Log("set next") - if err := mem.RecursNext(i1.ID, tomorrow, time.Now()); err != nil { - t.Errorf("exp nil, got %v", err) - } - - t.Log("check result") - us, err := mem.Updated([]item.Kind{}, now) - if err != nil { - t.Errorf("exp nil, got %v", err) - } - if len(us) != 1 { - t.Errorf("exp 1, got %v", len(us)) - } - if us[0].ID != i1.ID { - t.Errorf("exp %v, got %v", i1.ID, us[0].ID) - } } diff --git a/sync/service/postgres.go b/sync/service/postgres.go index b53ed67..faa15b0 100644 --- a/sync/service/postgres.go +++ b/sync/service/postgres.go @@ -20,6 +20,13 @@ var migrations = []string{ `CREATE INDEX idx_items_updated ON items(updated)`, `CREATE INDEX idx_items_kind ON items(kind)`, `ALTER TABLE items ADD COLUMN recurrer JSONB, ADD COLUMN recur_next TIMESTAMP`, + `ALTER TABLE items ALTER COLUMN recurrer TYPE TEXT USING recurrer::TEXT, + ALTER COLUMN recurrer SET NOT NULL, + ALTER COLUMN recurrer SET DEFAULT ''`, + `ALTER TABLE items ALTER COLUMN recur_next TYPE TEXT USING TO_CHAR(recur_next, 'YYYY-MM-DD'), + ALTER COLUMN recur_next SET NOT NULL, + ALTER COLUMN recur_next SET DEFAULT ''`, + `ALTER TABLE items ADD COLUMN date TEXT NOT NULL DEFAULT ''`, } var ( @@ -35,13 +42,10 @@ type Postgres struct { func NewPostgres(host, port, dbname, user, password string) (*Postgres, error) { connStr := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=disable", host, port, dbname, user, password) - db, err := sql.Open("postgres", connStr) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err) } - - // Test the connection if err := db.Ping(); err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err) } @@ -57,19 +61,26 @@ func NewPostgres(host, port, dbname, user, password string) (*Postgres, error) { return p, nil } -func (p *Postgres) Update(item item.Item, ts time.Time) error { - _, err := p.db.Exec(` - INSERT INTO items (id, kind, updated, deleted, body, recurrer, recur_next) - VALUES ($1, $2, $3, $4, $5, $6, $7) +func (p *Postgres) Update(i item.Item, ts time.Time) error { + if i.Recurrer != nil && i.RecurNext.IsZero() { + i.RecurNext = i.Recurrer.First() + } + var recurStr string + if i.Recurrer != nil { + recurStr = i.Recurrer.String() + } + if _, err := p.db.Exec(` + INSERT INTO items (id, kind, updated, deleted, date, recurrer, recur_next, body) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET kind = EXCLUDED.kind, updated = EXCLUDED.updated, deleted = EXCLUDED.deleted, - body = EXCLUDED.body, + date = EXCLUDED.date, recurrer = EXCLUDED.recurrer, - recur_next = EXCLUDED.recur_next`, - item.ID, item.Kind, ts, item.Deleted, item.Body, item.Recurrer, item.RecurNext) - if err != nil { + recur_next = EXCLUDED.recur_next, + body = EXCLUDED.body`, + i.ID, i.Kind, ts, i.Deleted, i.Date.String(), recurStr, i.RecurNext.String(), i.Body); err != nil { return fmt.Errorf("%w: %v", ErrPostgresFailure, err) } return nil @@ -77,11 +88,10 @@ func (p *Postgres) Update(item item.Item, ts time.Time) error { func (p *Postgres) Updated(ks []item.Kind, t time.Time) ([]item.Item, error) { query := ` - SELECT id, kind, updated, deleted, body, recurrer, recur_next + SELECT id, kind, updated, deleted, date, recurrer, recur_next, body FROM items WHERE updated > $1` args := []interface{}{t} - if len(ks) > 0 { placeholder := make([]string, len(ks)) for i := range ks { @@ -99,27 +109,29 @@ func (p *Postgres) Updated(ks []item.Kind, t time.Time) ([]item.Item, error) { result := make([]item.Item, 0) for rows.Next() { - var item item.Item - var recurNext sql.NullTime - if err := rows.Scan(&item.ID, &item.Kind, &item.Updated, &item.Deleted, &item.Body, &item.Recurrer, &recurNext); err != nil { + var i item.Item + var date, recurrer, recurNext string + if err := rows.Scan(&i.ID, &i.Kind, &i.Updated, &i.Deleted, &date, &recurrer, &recurNext, &i.Body); err != nil { return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err) } - if recurNext.Valid { - item.RecurNext = recurNext.Time - } - result = append(result, item) + i.Date = item.NewDateFromString(date) + i.Recurrer = item.NewRecurrer(recurrer) + i.RecurNext = item.NewDateFromString(recurNext) + result = append(result, i) } return result, nil } -func (p *Postgres) RecursBefore(date time.Time) ([]item.Item, error) { +func (p *Postgres) ShouldRecur(date item.Date) ([]item.Item, error) { query := ` - SELECT id, kind, updated, deleted, body, recurrer, recur_next + SELECT id, kind, updated, deleted, date, recurrer, recur_next, body FROM items - WHERE recur_next <= $1 AND recurrer IS NOT NULL` - - rows, err := p.db.Query(query, date) + WHERE + NOT deleted + AND recurrer <> '' + AND recur_next <= $1` + rows, err := p.db.Query(query, date.String()) if err != nil { return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err) } @@ -127,54 +139,20 @@ func (p *Postgres) RecursBefore(date time.Time) ([]item.Item, error) { result := make([]item.Item, 0) for rows.Next() { - var item item.Item - var recurNext sql.NullTime - if err := rows.Scan(&item.ID, &item.Kind, &item.Updated, &item.Deleted, &item.Body, &item.Recurrer, &recurNext); err != nil { + var i item.Item + var date, recurrer, recurNext string + if err := rows.Scan(&i.ID, &i.Kind, &i.Updated, &i.Deleted, &date, &recurrer, &recurNext, &i.Body); err != nil { return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err) } - if recurNext.Valid { - item.RecurNext = recurNext.Time - } - result = append(result, item) + i.Date = item.NewDateFromString(date) + i.Recurrer = item.NewRecurrer(recurrer) + i.RecurNext = item.NewDateFromString(recurNext) + result = append(result, i) } return result, nil } -func (p *Postgres) RecursNext(id string, date time.Time, ts time.Time) error { - var recurrer *item.Recur - err := p.db.QueryRow(` - SELECT recurrer - FROM items - WHERE id = $1`, id).Scan(&recurrer) - if err != nil { - if err == sql.ErrNoRows { - return ErrNotFound - } - return fmt.Errorf("%w: %v", ErrPostgresFailure, err) - } - - if recurrer == nil { - return ErrNotARecurrer - } - - // Verify that the new date is actually a valid recurrence - if !recurrer.On(date) { - return fmt.Errorf("%w: date %v is not a valid recurrence", ErrPostgresFailure, date) - } - - _, err = p.db.Exec(` - UPDATE items - SET recur_next = $1, - updated = $2 - WHERE id = $3`, date, ts, id) - if err != nil { - return fmt.Errorf("%w: %v", ErrPostgresFailure, err) - } - - return nil -} - func (p *Postgres) migrate(wanted []string) error { // Create migration table if not exists _, err := p.db.Exec(` diff --git a/sync/service/recur.go b/sync/service/recur.go index 04d70fc..c48d9d1 100644 --- a/sync/service/recur.go +++ b/sync/service/recur.go @@ -35,34 +35,31 @@ func (r *Recur) Run(interval time.Duration) { } func (r *Recur) Recur() error { - items, err := r.repoRecur.RecursBefore(time.Now()) + r.logger.Info("start looking for recurring items") + today := item.NewDateFromString(time.Now().Format(item.DateFormat)) + items, err := r.repoRecur.ShouldRecur(today) if err != nil { return err } + r.logger.Info("found recurring items", "count", len(items)) for _, i := range items { + r.logger.Info("processing recurring item", "id", i.ID) // spawn instance - ne, err := item.NewEvent(i) - if err != nil { - return err - } - y, m, d := i.RecurNext.Date() - ne.ID = uuid.New().String() - ne.Recurrer = nil - ne.RecurNext = time.Time{} - ne.Start = time.Date(y, m, d, ne.Start.Hour(), ne.Start.Minute(), 0, 0, time.UTC) - - ni, err := ne.Item() - if err != nil { - return err - } - if err := r.repoSync.Update(ni, time.Now()); err != nil { + newItem := i + newItem.ID = uuid.New().String() + newItem.Date = i.RecurNext + newItem.Recurrer = nil + newItem.RecurNext = item.Date{} + if err := r.repoSync.Update(newItem, time.Now()); err != nil { return err } - // set next - if err := r.repoRecur.RecursNext(i.ID, i.Recurrer.NextAfter(i.RecurNext), time.Now()); err != nil { + // update recurrer + i.RecurNext = item.FirstRecurAfter(i.Recurrer, i.RecurNext) + if err := r.repoSync.Update(i, time.Now()); err != nil { return err } + r.logger.Info("recurring item processed", "id", i.ID, "recurNext", i.RecurNext.String()) } r.logger.Info("processed recurring items", "count", len(items)) diff --git a/sync/service/recur_test.go b/sync/service/recur_test.go index 5148237..2aabe14 100644 --- a/sync/service/recur_test.go +++ b/sync/service/recur_test.go @@ -12,23 +12,18 @@ import ( func TestRecur(t *testing.T) { t.Parallel() - now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + now := time.Now() + today := item.NewDate(2024, 1, 1) mem := NewMemory() rec := NewRecur(mem, mem, slog.New(slog.NewTextHandler(io.Discard, nil))) - // Create a recurring item - recur := &item.Recur{ - Start: now, - Period: item.PeriodDay, - Count: 1, - } testItem := item.Item{ ID: "test-1", Kind: item.KindEvent, Updated: now, Deleted: false, - Recurrer: recur, - RecurNext: now, + Recurrer: item.NewRecurrer("2024-01-01, daily"), + RecurNext: today, Body: `{"title":"Test Event","start":"2024-01-01T10:00:00Z","duration":"30m"}`, } @@ -53,14 +48,14 @@ func TestRecur(t *testing.T) { } // Check that RecurNext was updated - recurItems, err := mem.RecursBefore(now.Add(48 * time.Hour)) + recurItems, err := mem.ShouldRecur(today.Add(1)) if err != nil { t.Fatal(err) } if len(recurItems) != 1 { t.Errorf("expected 1 recur item, got %d", len(recurItems)) } - if !recurItems[0].RecurNext.After(now) { + if !recurItems[0].RecurNext.After(today) { t.Errorf("RecurNext was not updated, still %v", recurItems[0].RecurNext) } } diff --git a/sync/service/service.go b/sync/service/service.go index b671b61..4238b1c 100644 --- a/sync/service/service.go +++ b/sync/service/service.go @@ -39,7 +39,7 @@ func main() { "dbUser": *dbUser, }) recurrer := NewRecur(repo, repo, logger) - go recurrer.Run(12 * time.Hour) + go recurrer.Run(10 * time.Second) srv := NewServer(repo, *apiKey, logger) go http.ListenAndServe(fmt.Sprintf(":%s", *apiPort), srv) diff --git a/sync/service/storage.go b/sync/service/storage.go index 7240bbb..d05dc74 100644 --- a/sync/service/storage.go +++ b/sync/service/storage.go @@ -18,6 +18,5 @@ type Syncer interface { } type Recurrer interface { - RecursBefore(date time.Time) ([]item.Item, error) - RecursNext(id string, date time.Time, t time.Time) error + ShouldRecur(date item.Date) ([]item.Item, error) }