recurring items
This commit is contained in:
parent
aac74357a0
commit
63d7792a1e
|
@ -1,2 +1,3 @@
|
||||||
test.db*
|
test.db*
|
||||||
plannersync
|
plannersync
|
||||||
|
plan
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
12
item/item.go
12
item/item.go
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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{},
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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(`
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue