diff --git a/.gitignore b/.gitignore index d87f22c..836a306 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ test.db* plannersync +plan diff --git a/item/event.go b/item/event.go index 5a7fbfc..0b712b8 100644 --- a/item/event.go +++ b/item/event.go @@ -51,7 +51,9 @@ func (e *EventBody) UnmarshalJSON(data []byte) error { } type Event struct { - ID string `json:"id"` + ID string `json:"id"` + Recurrer *Recur `json:"recurrer"` + RecurNext time.Time `json:"recurNext"` EventBody } @@ -66,6 +68,8 @@ func NewEvent(i Item) (Event, error) { } e.ID = i.ID + e.Recurrer = i.Recurrer + e.RecurNext = i.RecurNext return e, nil } @@ -81,9 +85,11 @@ func (e Event) Item() (Item, error) { } return Item{ - ID: e.ID, - Kind: KindEvent, - Body: string(body), + ID: e.ID, + Kind: KindEvent, + Recurrer: e.Recurrer, + RecurNext: e.RecurNext, + Body: string(body), }, nil } @@ -97,6 +103,9 @@ func (e Event) Valid() bool { if e.Duration.Seconds() < 1 { return false } + if e.Recurrer != nil && !e.Recurrer.Valid() { + return false + } return true } diff --git a/item/event_test.go b/item/event_test.go index 2e78cda..86e0a3f 100644 --- a/item/event_test.go +++ b/item/event_test.go @@ -48,6 +48,11 @@ func TestNewEvent(t *testing.T) { 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, + }, Body: `{ "title":"title", "start":"2024-09-20T08:00:00Z", @@ -56,6 +61,11 @@ func TestNewEvent(t *testing.T) { }, 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, + }, EventBody: item.EventBody{ Title: "title", Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC), diff --git a/item/item.go b/item/item.go index 8d5d5ca..93ea4c5 100644 --- a/item/item.go +++ b/item/item.go @@ -18,11 +18,13 @@ var ( ) type Item struct { - ID string `json:"id"` - Kind Kind `json:"kind"` - Updated time.Time `json:"updated"` - Deleted bool `json:"deleted"` - Body string `json:"body"` + ID string `json:"id"` + Kind Kind `json:"kind"` + Updated time.Time `json:"updated"` + Deleted bool `json:"deleted"` + Recurrer *Recur `json:"recurrer"` + RecurNext time.Time `json:"recurNext"` + Body string `json:"body"` } func NewItem(k Kind, body string) Item { diff --git a/item/recur.go b/item/recur.go new file mode 100644 index 0000000..a671cf1 --- /dev/null +++ b/item/recur.go @@ -0,0 +1,85 @@ +package item + +import ( + "slices" + "time" +) + +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"` +} + +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 (r *Recur) onDays(date time.Time) bool { + if r.Start.After(date) { + return false + } + + testDate := r.Start + for { + if testDate.Equal(date) { + return true + } + if testDate.After(date) { + return false + } + + dur := time.Duration(r.Count) * 24 * time.Hour + testDate = testDate.Add(dur) + } +} + +func (r *Recur) onMonths(date time.Time) bool { + if r.Start.After(date) { + return false + } + + tDate := r.Start + for { + if tDate.Equal(date) { + return true + } + 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) + } +} + +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 (r *Recur) Valid() bool { + return r.Start.IsZero() || !slices.Contains(ValidPeriods, r.Period) +} diff --git a/item/recur_test.go b/item/recur_test.go new file mode 100644 index 0000000..3ccca11 --- /dev/null +++ b/item/recur_test.go @@ -0,0 +1,105 @@ +package item_test + +import ( + "testing" + "time" + + "go-mod.ewintr.nl/planner/item" +) + +func TestRecur(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 + + for _, tc := range []struct { + name string + date time.Time + exp bool + }{ + { + name: "before", + date: time.Date(202, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "start", + date: r.Start, + exp: true, + }, + { + name: "after true", + date: r.Start.Add(15 * day), + exp: true, + }, + { + name: "after false", + date: r.Start.Add(16 * day), + }, + } { + 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) + } + }) + } + }) + + 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, + } + + for _, tc := range []struct { + name string + date time.Time + exp bool + }{ + { + name: "before start", + date: time.Date(2021, 1, 27, 0, 0, 0, 0, time.UTC), + }, + { + name: "on start", + date: time.Date(2021, 2, 3, 0, 0, 0, 0, time.UTC), + exp: true, + }, + { + name: "8 weeks after", + date: time.Date(2021, 3, 31, 0, 0, 0, 0, time.UTC), + }, + { + name: "one month", + date: time.Date(2021, 3, 3, 0, 0, 0, 0, time.UTC), + }, + { + name: "3 months", + date: time.Date(2021, 5, 3, 0, 0, 0, 0, time.UTC), + exp: true, + }, + { + name: "4 months", + date: time.Date(2021, 6, 3, 0, 0, 0, 0, time.UTC), + }, + { + name: "6 months", + date: time.Date(2021, 8, 3, 0, 0, 0, 0, time.UTC), + 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) + } + }) + } + }) + +} diff --git a/plan/command/add.go b/plan/command/add.go index 7274159..e8e9875 100644 --- a/plan/command/add.go +++ b/plan/command/add.go @@ -24,9 +24,11 @@ func NewAdd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage syncRepo: syncRepo, argSet: &ArgSet{ Flags: map[string]Flag{ - FlagOn: &FlagDate{}, - FlagAt: &FlagTime{}, - FlagFor: &FlagDuration{}, + FlagOn: &FlagDate{}, + FlagAt: &FlagTime{}, + FlagFor: &FlagDuration{}, + FlagRecStart: &FlagDate{}, + FlagRecPeriod: &FlagPeriod{}, }, }, } @@ -68,6 +70,9 @@ 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() } @@ -93,6 +98,13 @@ func (add *Add) do() error { 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 err := add.eventRepo.Store(e); err != nil { return fmt.Errorf("could not store event: %v", err) } diff --git a/plan/command/add_test.go b/plan/command/add_test.go index 924821f..d82ef81 100644 --- a/plan/command/add_test.go +++ b/plan/command/add_test.go @@ -102,6 +102,46 @@ func TestAdd(t *testing.T) { }, 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() @@ -140,7 +180,7 @@ func TestAdd(t *testing.T) { } tc.expEvent.ID = actEvents[0].ID if diff := cmp.Diff(tc.expEvent, actEvents[0]); diff != "" { - t.Errorf("(exp +, got -)\n%s", diff) + t.Errorf("(exp -, got +)\n%s", diff) } updated, err := syncRepo.FindAll() diff --git a/plan/command/argset.go b/plan/command/argset.go index 138b80d..0667e34 100644 --- a/plan/command/argset.go +++ b/plan/command/argset.go @@ -3,6 +3,8 @@ package command import ( "fmt" "time" + + "go-mod.ewintr.nl/planner/item" ) type ArgSet struct { @@ -61,3 +63,15 @@ func (as *ArgSet) GetDuration(name string) time.Duration { } return val } + +func (as *ArgSet) GetRecurPeriod(name string) item.RecurPeriod { + flag, ok := as.Flags[name] + if !ok { + return item.RecurPeriod("") + } + val, ok := flag.Get().(item.RecurPeriod) + if !ok { + return item.RecurPeriod("") + } + return val +} diff --git a/plan/command/argset_test.go b/plan/command/argset_test.go index e8711e8..2261074 100644 --- a/plan/command/argset_test.go +++ b/plan/command/argset_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/plan/command" ) @@ -52,6 +53,15 @@ func TestArgSet(t *testing.T) { setValue: "2h30m", exp: 2*time.Hour + 30*time.Minute, }, + { + name: "recur period flag success", + flags: map[string]command.Flag{ + "period": &command.FlagPeriod{Name: "period"}, + }, + flagName: "period", + setValue: "month", + exp: item.PeriodMonth, + }, { name: "unknown flag error", flags: map[string]command.Flag{}, diff --git a/plan/command/command.go b/plan/command/command.go index e78dc6a..0ec2a03 100644 --- a/plan/command/command.go +++ b/plan/command/command.go @@ -7,10 +7,12 @@ import ( ) const ( - FlagTitle = "title" - FlagOn = "on" - FlagAt = "at" - FlagFor = "for" + FlagTitle = "title" + FlagOn = "on" + FlagAt = "at" + FlagFor = "for" + FlagRecStart = "rec-start" + FlagRecPeriod = "rec-period" ) type Command interface { diff --git a/plan/command/flag.go b/plan/command/flag.go index 979a1a8..d50f376 100644 --- a/plan/command/flag.go +++ b/plan/command/flag.go @@ -3,7 +3,10 @@ package command import ( "errors" "fmt" + "slices" "time" + + "go-mod.ewintr.nl/planner/item" ) const ( @@ -107,3 +110,24 @@ func (fd *FlagDuration) IsSet() bool { func (fs *FlagDuration) Get() any { return fs.Value } + +type FlagPeriod struct { + Name string + Value item.RecurPeriod +} + +func (fp *FlagPeriod) Set(val string) error { + if !slices.Contains(item.ValidPeriods, item.RecurPeriod(val)) { + return fmt.Errorf("not a valid period: %v", val) + } + fp.Value = item.RecurPeriod(val) + return nil +} + +func (fp *FlagPeriod) IsSet() bool { + return fp.Value != "" +} + +func (fp *FlagPeriod) Get() any { + return fp.Value +} diff --git a/plan/command/flag_test.go b/plan/command/flag_test.go index a663d2c..ae552f4 100644 --- a/plan/command/flag_test.go +++ b/plan/command/flag_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/plan/command" ) @@ -113,3 +114,30 @@ func TestFlagDurationTime(t *testing.T) { t.Errorf("exp %v, got %v", valid, act) } } + +func TestFlagPeriod(t *testing.T) { + t.Parallel() + + valid := item.PeriodMonth + validStr := "month" + f := command.FlagPeriod{} + if f.IsSet() { + t.Errorf("exp false, got true") + } + + if err := f.Set(validStr); err != nil { + t.Errorf("exp nil, got %v", err) + } + + if !f.IsSet() { + t.Errorf("exp true, got false") + } + + act, ok := f.Get().(item.RecurPeriod) + if !ok { + t.Errorf("exp true, got false") + } + if act != valid { + t.Errorf("exp %v, got %v", valid, act) + } +} diff --git a/plan/command/update.go b/plan/command/update.go index 689159c..e958346 100644 --- a/plan/command/update.go +++ b/plan/command/update.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/plan/storage" ) @@ -24,10 +25,12 @@ 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{}, + FlagTitle: &FlagString{}, + FlagOn: &FlagDate{}, + FlagAt: &FlagTime{}, + FlagFor: &FlagDuration{}, + FlagRecStart: &FlagDate{}, + FlagRecPeriod: &FlagPeriod{}, }, }, } @@ -103,6 +106,17 @@ func (update *Update) do() error { 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 !e.Valid() { return fmt.Errorf("event is unvalid") diff --git a/plan/command/update_test.go b/plan/command/update_test.go index 767ef2d..14b5575 100644 --- a/plan/command/update_test.go +++ b/plan/command/update_test.go @@ -149,6 +149,58 @@ func TestUpdateExecute(t *testing.T) { }, }, }, + { + name: "invalid rec start", + main: []string{"update", fmt.Sprintf("%d", lid)}, + flags: map[string]string{ + "rec-start": "invalud", + }, + expErr: true, + }, + { + name: "valid rec start", + main: []string{"update", fmt.Sprintf("%d", lid)}, + flags: map[string]string{ + "rec-start": "2024-12-08", + }, + expEvent: item.Event{ + ID: eid, + Recurrer: &item.Recur{ + Start: time.Date(2024, 12, 8, 0, 0, 0, 0, time.UTC), + }, + 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, + Duration: oneHour, + }, + }, + }, } { t.Run(tc.name, func(t *testing.T) { eventRepo := memory.NewEvent() @@ -182,7 +234,7 @@ func TestUpdateExecute(t *testing.T) { t.Errorf("exp nil, got %v", err) } if diff := cmp.Diff(tc.expEvent, actEvent); diff != "" { - t.Errorf("(exp +, got -)\n%s", diff) + t.Errorf("(exp -, got +)\n%s", diff) } updated, err := syncRepo.FindAll() if err != nil { diff --git a/sync/service/handler.go b/sync/service/handler.go index e5872dc..e93c10c 100644 --- a/sync/service/handler.go +++ b/sync/service/handler.go @@ -146,8 +146,7 @@ func (s *Server) SyncPost(w http.ResponseWriter, r *http.Request) { s.logger.Info(msg) return } - it.Updated = time.Now() - if err := s.syncer.Update(it); err != nil { + if err := s.syncer.Update(it, time.Now()); err != nil { msg := err.Error() http.Error(w, fmtError(msg), http.StatusInternalServerError) s.logger.Error(msg) diff --git a/sync/service/handler_test.go b/sync/service/handler_test.go index 63c6427..750970f 100644 --- a/sync/service/handler_test.go +++ b/sync/service/handler_test.go @@ -65,7 +65,7 @@ func TestSyncGet(t *testing.T) { } for _, item := range items { - if err := mem.Update(item); err != nil { + if err := mem.Update(item, item.Updated); err != nil { t.Errorf("exp nil, got %v", err) } } diff --git a/sync/service/memory.go b/sync/service/memory.go index cbcaf8b..ca92346 100644 --- a/sync/service/memory.go +++ b/sync/service/memory.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "slices" "sync" "time" @@ -19,10 +20,11 @@ func NewMemory() *Memory { } } -func (m *Memory) Update(item item.Item) error { +func (m *Memory) Update(item item.Item, ts time.Time) error { m.mutex.Lock() defer m.mutex.Unlock() + item.Updated = ts m.items[item.ID] = item return nil @@ -44,3 +46,34 @@ 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) { + res := make([]item.Item, 0) + for _, i := range m.items { + if i.Recurrer == nil { + continue + } + if i.RecurNext.Before(date) { + 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 d2f38d3..a512348 100644 --- a/sync/service/memory_test.go +++ b/sync/service/memory_test.go @@ -5,10 +5,12 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "go-mod.ewintr.nl/planner/item" ) -func TestMemoryItem(t *testing.T) { +func TestMemoryUpdate(t *testing.T) { t.Parallel() mem := NewMemory() @@ -24,7 +26,7 @@ func TestMemoryItem(t *testing.T) { t.Log("add one") t1 := item.NewItem(item.Kind("kinda"), "test") - if actErr := mem.Update(t1); actErr != nil { + if actErr := mem.Update(t1, t1.Updated); actErr != nil { t.Errorf("exp nil, got %v", actErr) } actItems, actErr = mem.Updated([]item.Kind{}, time.Time{}) @@ -42,21 +44,17 @@ func TestMemoryItem(t *testing.T) { t.Log("add second") t2 := item.NewItem(item.Kind("kindb"), "test 2") - if actErr := mem.Update(t2); actErr != nil { + if actErr := mem.Update(t2, t2.Updated); actErr != nil { t.Errorf("exp nil, got %v", actErr) } actItems, actErr = mem.Updated([]item.Kind{}, time.Time{}) if actErr != nil { t.Errorf("exp nil, got %v", actErr) } - if len(actItems) != 2 { - t.Errorf("exp 2, gor %d", len(actItems)) - } - if actItems[0].ID != t1.ID { - t.Errorf("exp %v, got %v", actItems[0].ID, t1.ID) - } - if actItems[1].ID != t2.ID { - t.Errorf("exp %v, got %v", actItems[1].ID, t2.ID) + if diff := cmp.Diff([]item.Item{t1, t2}, actItems, cmpopts.SortSlices(func(i, j item.Item) bool { + return i.ID < j.ID + })); diff != "" { + t.Errorf("(exp +, got -)\n%s", diff) } actItems, actErr = mem.Updated([]item.Kind{}, before) @@ -71,8 +69,7 @@ func TestMemoryItem(t *testing.T) { } t.Log("update first") - t1.Updated = time.Now() - if actErr := mem.Update(t1); actErr != nil { + if actErr := mem.Update(t1, time.Now()); actErr != nil { t.Errorf("exp nil, got %v", actErr) } actItems, actErr = mem.Updated([]item.Kind{}, before) @@ -109,3 +106,62 @@ func TestMemoryItem(t *testing.T) { t.Errorf("exp %v, got %v", t1.ID, actItems[0].ID) } } + +func TestMemoryRecur(t *testing.T) { + t.Parallel() + + 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) + + t.Log("start") + i1 := item.Item{ + ID: "a", + Updated: earlier, + Recurrer: &item.Recur{ + Start: yesterday, + Period: item.PeriodDay, + Count: 1, + }, + RecurNext: yesterday, + } + i2 := item.Item{ + ID: "b", + Updated: earlier, + } + + for _, i := range []item.Item{i1, i2} { + if err := mem.Update(i, i.Updated); err != nil { + t.Errorf("exp nil, ot %v", err) + } + } + + t.Log("get recurrers") + rs, err := mem.RecursBefore(today) + if err != nil { + t.Errorf("exp nil, gt %v", err) + } + if diff := cmp.Diff([]item.Item{i1}, rs); diff != "" { + 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 e3196ef..b53ed67 100644 --- a/sync/service/postgres.go +++ b/sync/service/postgres.go @@ -19,6 +19,7 @@ var migrations = []string{ `CREATE TABLE items (id TEXT PRIMARY KEY, kind TEXT, updated TIMESTAMP, deleted BOOLEAN, body TEXT)`, `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`, } var ( @@ -56,16 +57,18 @@ func NewPostgres(host, port, dbname, user, password string) (*Postgres, error) { return p, nil } -func (p *Postgres) Update(item item.Item) error { +func (p *Postgres) Update(item item.Item, ts time.Time) error { _, err := p.db.Exec(` - INSERT INTO items (id, kind, updated, deleted, body) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO items (id, kind, updated, deleted, body, recurrer, recur_next) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO UPDATE SET kind = EXCLUDED.kind, updated = EXCLUDED.updated, deleted = EXCLUDED.deleted, - body = EXCLUDED.body`, - item.ID, item.Kind, item.Updated, item.Deleted, item.Body) + body = EXCLUDED.body, + recurrer = EXCLUDED.recurrer, + recur_next = EXCLUDED.recur_next`, + item.ID, item.Kind, ts, item.Deleted, item.Body, item.Recurrer, item.RecurNext) if err != nil { return fmt.Errorf("%w: %v", ErrPostgresFailure, err) } @@ -74,7 +77,7 @@ func (p *Postgres) Update(item item.Item) error { func (p *Postgres) Updated(ks []item.Kind, t time.Time) ([]item.Item, error) { query := ` - SELECT id, kind, updated, deleted, body + SELECT id, kind, updated, deleted, body, recurrer, recur_next FROM items WHERE updated > $1` args := []interface{}{t} @@ -97,15 +100,81 @@ 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 - if err := rows.Scan(&item.ID, &item.Kind, &item.Updated, &item.Deleted, &item.Body); err != nil { + var recurNext sql.NullTime + if err := rows.Scan(&item.ID, &item.Kind, &item.Updated, &item.Deleted, &item.Body, &item.Recurrer, &recurNext); err != nil { return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err) } + if recurNext.Valid { + item.RecurNext = recurNext.Time + } result = append(result, item) } return result, nil } +func (p *Postgres) RecursBefore(date time.Time) ([]item.Item, error) { + query := ` + SELECT id, kind, updated, deleted, body, recurrer, recur_next + FROM items + WHERE recur_next <= $1 AND recurrer IS NOT NULL` + + rows, err := p.db.Query(query, date) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err) + } + defer rows.Close() + + 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 { + return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err) + } + if recurNext.Valid { + item.RecurNext = recurNext.Time + } + result = append(result, item) + } + + 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 new file mode 100644 index 0000000..04d70fc --- /dev/null +++ b/sync/service/recur.go @@ -0,0 +1,70 @@ +package main + +import ( + "log/slog" + "time" + + "github.com/google/uuid" + "go-mod.ewintr.nl/planner/item" +) + +type Recur struct { + repoSync Syncer + repoRecur Recurrer + logger *slog.Logger +} + +func NewRecur(repoRecur Recurrer, repoSync Syncer, logger *slog.Logger) *Recur { + r := &Recur{ + repoRecur: repoRecur, + repoSync: repoSync, + logger: logger, + } + + return r +} + +func (r *Recur) Run(interval time.Duration) { + ticker := time.NewTicker(interval) + + for range ticker.C { + if err := r.Recur(); err != nil { + r.logger.Error("could not recur", "error", err) + } + } +} + +func (r *Recur) Recur() error { + items, err := r.repoRecur.RecursBefore(time.Now()) + if err != nil { + return err + } + for _, i := range items { + // 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 { + return err + } + + // set next + if err := r.repoRecur.RecursNext(i.ID, i.Recurrer.NextAfter(i.RecurNext), time.Now()); err != nil { + return err + } + } + r.logger.Info("processed recurring items", "count", len(items)) + + return nil +} diff --git a/sync/service/recur_test.go b/sync/service/recur_test.go new file mode 100644 index 0000000..5148237 --- /dev/null +++ b/sync/service/recur_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "io" + "log/slog" + "testing" + "time" + + "go-mod.ewintr.nl/planner/item" +) + +func TestRecur(t *testing.T) { + t.Parallel() + + now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + 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, + Body: `{"title":"Test Event","start":"2024-01-01T10:00:00Z","duration":"30m"}`, + } + + // Store the item + if err := mem.Update(testItem, testItem.Updated); err != nil { + t.Fatalf("failed to store test item: %v", err) + } + + // Run recurrence + if err := rec.Recur(); err != nil { + t.Errorf("Recur failed: %v", err) + } + + // Verify results + items, err := mem.Updated([]item.Kind{item.KindEvent}, now) + if err != nil { + t.Errorf("failed to get updated items: %v", err) + } + + if len(items) != 2 { // Original + new instance + t.Errorf("expected 2 items, got %d", len(items)) + } + + // Check that RecurNext was updated + recurItems, err := mem.RecursBefore(now.Add(48 * time.Hour)) + 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) { + t.Errorf("RecurNext was not updated, still %v", recurItems[0].RecurNext) + } +} diff --git a/sync/service/service.go b/sync/service/service.go index 94c1651..b671b61 100644 --- a/sync/service/service.go +++ b/sync/service/service.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "syscall" + "time" ) var ( @@ -29,7 +30,7 @@ func main() { os.Exit(1) } - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) logger.Info("configuration", "configuration", map[string]string{ "port": *apiPort, "dbHost": *dbHost, @@ -37,6 +38,8 @@ func main() { "dbName": *dbName, "dbUser": *dbUser, }) + recurrer := NewRecur(repo, repo, logger) + go recurrer.Run(12 * time.Hour) 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 ba14634..7240bbb 100644 --- a/sync/service/storage.go +++ b/sync/service/storage.go @@ -8,10 +8,16 @@ import ( ) var ( - ErrNotFound = errors.New("not found") + ErrNotFound = errors.New("not found") + ErrNotARecurrer = errors.New("not a recurrer") ) type Syncer interface { - Update(item item.Item) error + Update(item item.Item, t time.Time) error Updated(kind []item.Kind, t time.Time) ([]item.Item, error) } + +type Recurrer interface { + RecursBefore(date time.Time) ([]item.Item, error) + RecursNext(id string, date time.Time, t time.Time) error +}