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