recurring items

This commit is contained in:
Erik Winter 2024-12-01 10:22:47 +01:00
parent aac74357a0
commit 63d7792a1e
24 changed files with 759 additions and 49 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
test.db* test.db*
plannersync plannersync
plan

View File

@ -51,7 +51,9 @@ func (e *EventBody) UnmarshalJSON(data []byte) error {
} }
type Event struct { type Event struct {
ID string `json:"id"` ID string `json:"id"`
Recurrer *Recur `json:"recurrer"`
RecurNext time.Time `json:"recurNext"`
EventBody EventBody
} }
@ -66,6 +68,8 @@ func NewEvent(i Item) (Event, error) {
} }
e.ID = i.ID e.ID = i.ID
e.Recurrer = i.Recurrer
e.RecurNext = i.RecurNext
return e, nil return e, nil
} }
@ -81,9 +85,11 @@ func (e Event) Item() (Item, error) {
} }
return Item{ return Item{
ID: e.ID, ID: e.ID,
Kind: KindEvent, Kind: KindEvent,
Body: string(body), Recurrer: e.Recurrer,
RecurNext: e.RecurNext,
Body: string(body),
}, nil }, nil
} }
@ -97,6 +103,9 @@ func (e Event) Valid() bool {
if e.Duration.Seconds() < 1 { if e.Duration.Seconds() < 1 {
return false return false
} }
if e.Recurrer != nil && !e.Recurrer.Valid() {
return false
}
return true return true
} }

View File

@ -48,6 +48,11 @@ func TestNewEvent(t *testing.T) {
it: item.Item{ it: item.Item{
ID: "a", ID: "a",
Kind: item.KindEvent, Kind: item.KindEvent,
Recurrer: &item.Recur{
Start: time.Date(2024, 12, 8, 9, 0, 0, 0, time.UTC),
Period: item.PeriodDay,
Count: 1,
},
Body: `{ Body: `{
"title":"title", "title":"title",
"start":"2024-09-20T08:00:00Z", "start":"2024-09-20T08:00:00Z",
@ -56,6 +61,11 @@ func TestNewEvent(t *testing.T) {
}, },
expEvent: item.Event{ expEvent: item.Event{
ID: "a", 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{ EventBody: item.EventBody{
Title: "title", Title: "title",
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC), Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC),

View File

@ -18,11 +18,13 @@ var (
) )
type Item struct { type Item struct {
ID string `json:"id"` ID string `json:"id"`
Kind Kind `json:"kind"` Kind Kind `json:"kind"`
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
Deleted bool `json:"deleted"` Deleted bool `json:"deleted"`
Body string `json:"body"` Recurrer *Recur `json:"recurrer"`
RecurNext time.Time `json:"recurNext"`
Body string `json:"body"`
} }
func NewItem(k Kind, body string) Item { func NewItem(k Kind, body string) Item {

85
item/recur.go Normal file
View File

@ -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)
}

105
item/recur_test.go Normal file
View File

@ -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)
}
})
}
})
}

View File

@ -24,9 +24,11 @@ func NewAdd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage
syncRepo: syncRepo, syncRepo: syncRepo,
argSet: &ArgSet{ argSet: &ArgSet{
Flags: map[string]Flag{ Flags: map[string]Flag{
FlagOn: &FlagDate{}, FlagOn: &FlagDate{},
FlagAt: &FlagTime{}, FlagAt: &FlagTime{},
FlagFor: &FlagDuration{}, 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") 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() return add.do()
} }
@ -93,6 +98,13 @@ func (add *Add) do() error {
if as.IsSet(FlagFor) { if as.IsSet(FlagFor) {
e.Duration = as.GetDuration(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 { if err := add.eventRepo.Store(e); err != nil {
return fmt.Errorf("could not store event: %v", err) return fmt.Errorf("could not store event: %v", err)
} }

View File

@ -102,6 +102,46 @@ func TestAdd(t *testing.T) {
}, },
expErr: true, 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) { t.Run(tc.name, func(t *testing.T) {
eventRepo := memory.NewEvent() eventRepo := memory.NewEvent()
@ -140,7 +180,7 @@ func TestAdd(t *testing.T) {
} }
tc.expEvent.ID = actEvents[0].ID tc.expEvent.ID = actEvents[0].ID
if diff := cmp.Diff(tc.expEvent, actEvents[0]); diff != "" { 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() updated, err := syncRepo.FindAll()

View File

@ -3,6 +3,8 @@ package command
import ( import (
"fmt" "fmt"
"time" "time"
"go-mod.ewintr.nl/planner/item"
) )
type ArgSet struct { type ArgSet struct {
@ -61,3 +63,15 @@ func (as *ArgSet) GetDuration(name string) time.Duration {
} }
return val 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
}

View File

@ -4,6 +4,7 @@ import (
"testing" "testing"
"time" "time"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/command"
) )
@ -52,6 +53,15 @@ func TestArgSet(t *testing.T) {
setValue: "2h30m", setValue: "2h30m",
exp: 2*time.Hour + 30*time.Minute, 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", name: "unknown flag error",
flags: map[string]command.Flag{}, flags: map[string]command.Flag{},

View File

@ -7,10 +7,12 @@ import (
) )
const ( const (
FlagTitle = "title" FlagTitle = "title"
FlagOn = "on" FlagOn = "on"
FlagAt = "at" FlagAt = "at"
FlagFor = "for" FlagFor = "for"
FlagRecStart = "rec-start"
FlagRecPeriod = "rec-period"
) )
type Command interface { type Command interface {

View File

@ -3,7 +3,10 @@ package command
import ( import (
"errors" "errors"
"fmt" "fmt"
"slices"
"time" "time"
"go-mod.ewintr.nl/planner/item"
) )
const ( const (
@ -107,3 +110,24 @@ func (fd *FlagDuration) IsSet() bool {
func (fs *FlagDuration) Get() any { func (fs *FlagDuration) Get() any {
return fs.Value 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
}

View File

@ -4,6 +4,7 @@ import (
"testing" "testing"
"time" "time"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/command"
) )
@ -113,3 +114,30 @@ func TestFlagDurationTime(t *testing.T) {
t.Errorf("exp %v, got %v", valid, act) 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)
}
}

View File

@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/storage" "go-mod.ewintr.nl/planner/plan/storage"
) )
@ -24,10 +25,12 @@ func NewUpdate(localIDRepo storage.LocalID, eventRepo storage.Event, syncRepo st
syncRepo: syncRepo, syncRepo: syncRepo,
argSet: &ArgSet{ argSet: &ArgSet{
Flags: map[string]Flag{ Flags: map[string]Flag{
FlagTitle: &FlagString{}, FlagTitle: &FlagString{},
FlagOn: &FlagDate{}, FlagOn: &FlagDate{},
FlagAt: &FlagTime{}, FlagAt: &FlagTime{},
FlagFor: &FlagDuration{}, FlagFor: &FlagDuration{},
FlagRecStart: &FlagDate{},
FlagRecPeriod: &FlagPeriod{},
}, },
}, },
} }
@ -103,6 +106,17 @@ func (update *Update) do() error {
if as.IsSet(FlagFor) { if as.IsSet(FlagFor) {
e.Duration = as.GetDuration(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() { if !e.Valid() {
return fmt.Errorf("event is unvalid") return fmt.Errorf("event is unvalid")

View File

@ -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) { t.Run(tc.name, func(t *testing.T) {
eventRepo := memory.NewEvent() eventRepo := memory.NewEvent()
@ -182,7 +234,7 @@ func TestUpdateExecute(t *testing.T) {
t.Errorf("exp nil, got %v", err) t.Errorf("exp nil, got %v", err)
} }
if diff := cmp.Diff(tc.expEvent, actEvent); diff != "" { 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() updated, err := syncRepo.FindAll()
if err != nil { if err != nil {

View File

@ -146,8 +146,7 @@ func (s *Server) SyncPost(w http.ResponseWriter, r *http.Request) {
s.logger.Info(msg) s.logger.Info(msg)
return return
} }
it.Updated = time.Now() if err := s.syncer.Update(it, time.Now()); err != nil {
if err := s.syncer.Update(it); err != nil {
msg := err.Error() msg := err.Error()
http.Error(w, fmtError(msg), http.StatusInternalServerError) http.Error(w, fmtError(msg), http.StatusInternalServerError)
s.logger.Error(msg) s.logger.Error(msg)

View File

@ -65,7 +65,7 @@ func TestSyncGet(t *testing.T) {
} }
for _, item := range items { 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) t.Errorf("exp nil, got %v", err)
} }
} }

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"slices" "slices"
"sync" "sync"
"time" "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() m.mutex.Lock()
defer m.mutex.Unlock() defer m.mutex.Unlock()
item.Updated = ts
m.items[item.ID] = item m.items[item.ID] = item
return nil return nil
@ -44,3 +46,34 @@ func (m *Memory) Updated(kinds []item.Kind, timestamp time.Time) ([]item.Item, e
return result, nil 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
}

View File

@ -5,10 +5,12 @@ import (
"testing" "testing"
"time" "time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/item"
) )
func TestMemoryItem(t *testing.T) { func TestMemoryUpdate(t *testing.T) {
t.Parallel() t.Parallel()
mem := NewMemory() mem := NewMemory()
@ -24,7 +26,7 @@ func TestMemoryItem(t *testing.T) {
t.Log("add one") t.Log("add one")
t1 := item.NewItem(item.Kind("kinda"), "test") 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) t.Errorf("exp nil, got %v", actErr)
} }
actItems, actErr = mem.Updated([]item.Kind{}, time.Time{}) actItems, actErr = mem.Updated([]item.Kind{}, time.Time{})
@ -42,21 +44,17 @@ func TestMemoryItem(t *testing.T) {
t.Log("add second") t.Log("add second")
t2 := item.NewItem(item.Kind("kindb"), "test 2") 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) t.Errorf("exp nil, got %v", actErr)
} }
actItems, actErr = mem.Updated([]item.Kind{}, time.Time{}) actItems, actErr = mem.Updated([]item.Kind{}, time.Time{})
if actErr != nil { if actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
if len(actItems) != 2 { if diff := cmp.Diff([]item.Item{t1, t2}, actItems, cmpopts.SortSlices(func(i, j item.Item) bool {
t.Errorf("exp 2, gor %d", len(actItems)) return i.ID < j.ID
} })); diff != "" {
if actItems[0].ID != t1.ID { t.Errorf("(exp +, got -)\n%s", diff)
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)
} }
actItems, actErr = mem.Updated([]item.Kind{}, before) actItems, actErr = mem.Updated([]item.Kind{}, before)
@ -71,8 +69,7 @@ func TestMemoryItem(t *testing.T) {
} }
t.Log("update first") t.Log("update first")
t1.Updated = time.Now() if actErr := mem.Update(t1, time.Now()); actErr != nil {
if actErr := mem.Update(t1); actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
actItems, actErr = mem.Updated([]item.Kind{}, before) 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) 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)
}
}

View File

@ -19,6 +19,7 @@ var migrations = []string{
`CREATE TABLE items (id TEXT PRIMARY KEY, kind TEXT, updated TIMESTAMP, deleted BOOLEAN, body TEXT)`, `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_updated ON items(updated)`,
`CREATE INDEX idx_items_kind ON items(kind)`, `CREATE INDEX idx_items_kind ON items(kind)`,
`ALTER TABLE items ADD COLUMN recurrer JSONB, ADD COLUMN recur_next TIMESTAMP`,
} }
var ( var (
@ -56,16 +57,18 @@ func NewPostgres(host, port, dbname, user, password string) (*Postgres, error) {
return p, nil 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(` _, err := p.db.Exec(`
INSERT INTO items (id, kind, updated, deleted, body) INSERT INTO items (id, kind, updated, deleted, body, recurrer, recur_next)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE ON CONFLICT (id) DO UPDATE
SET kind = EXCLUDED.kind, SET kind = EXCLUDED.kind,
updated = EXCLUDED.updated, updated = EXCLUDED.updated,
deleted = EXCLUDED.deleted, deleted = EXCLUDED.deleted,
body = EXCLUDED.body`, body = EXCLUDED.body,
item.ID, item.Kind, item.Updated, item.Deleted, item.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 { if err != nil {
return fmt.Errorf("%w: %v", ErrPostgresFailure, err) 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) { func (p *Postgres) Updated(ks []item.Kind, t time.Time) ([]item.Item, error) {
query := ` query := `
SELECT id, kind, updated, deleted, body SELECT id, kind, updated, deleted, body, recurrer, recur_next
FROM items FROM items
WHERE updated > $1` WHERE updated > $1`
args := []interface{}{t} 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) result := make([]item.Item, 0)
for rows.Next() { for rows.Next() {
var item item.Item 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) return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err)
} }
if recurNext.Valid {
item.RecurNext = recurNext.Time
}
result = append(result, item) result = append(result, item)
} }
return result, nil 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 { func (p *Postgres) migrate(wanted []string) error {
// Create migration table if not exists // Create migration table if not exists
_, err := p.db.Exec(` _, err := p.db.Exec(`

70
sync/service/recur.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

View File

@ -8,6 +8,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
) )
var ( var (
@ -29,7 +30,7 @@ func main() {
os.Exit(1) 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{ logger.Info("configuration", "configuration", map[string]string{
"port": *apiPort, "port": *apiPort,
"dbHost": *dbHost, "dbHost": *dbHost,
@ -37,6 +38,8 @@ func main() {
"dbName": *dbName, "dbName": *dbName,
"dbUser": *dbUser, "dbUser": *dbUser,
}) })
recurrer := NewRecur(repo, repo, logger)
go recurrer.Run(12 * time.Hour)
srv := NewServer(repo, *apiKey, logger) srv := NewServer(repo, *apiKey, logger)
go http.ListenAndServe(fmt.Sprintf(":%s", *apiPort), srv) go http.ListenAndServe(fmt.Sprintf(":%s", *apiPort), srv)

View File

@ -8,10 +8,16 @@ import (
) )
var ( var (
ErrNotFound = errors.New("not found") ErrNotFound = errors.New("not found")
ErrNotARecurrer = errors.New("not a recurrer")
) )
type Syncer interface { 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) 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
}