new types for date, time and recurrer

This commit is contained in:
Erik Winter 2024-12-19 12:06:03 +01:00
parent caa1a45efb
commit 34cdfc73e2
40 changed files with 1848 additions and 692 deletions

4
.gitignore vendored
View File

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

View File

@ -4,5 +4,18 @@ plan-deploy:
sync-run: sync-run:
cd sync/service && go run . -dbname localhost -dbport 5432 -dbname planner -dbuser test -dbpassword test -port 8092 -key testKey cd sync/service && go run . -dbname localhost -dbport 5432 -dbname planner -dbuser test -dbpassword test -port 8092 -key testKey
sync-debug:
cd sync/service && dlv debug . -- -dbname localhost -dbport 5432 -dbname planner -dbuser test -dbpassword test -port 8092 -key testKey
sync-build:
go build -o dist/plannersync ./sync/service/
sync-deploy:
ssh server sudo /usr/bin/systemctl stop plannersync.service
scp dist/plannersync server:/usr/local/bin/plannersync
ssh server sudo /usr/bin/systemctl start plannersync.service
database: database:
docker run -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=planner -p 5432:5432 postgres:16 docker run -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=planner -p 5432:5432 postgres:16

BIN
dist/plannersync vendored Executable file

Binary file not shown.

278
item/date.go Normal file
View File

@ -0,0 +1,278 @@
package item
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
const (
DateFormat = "2006-01-02"
)
func Today() Date {
year, month, day := time.Now().Date()
return NewDate(year, int(month), day)
}
type Weekdays []time.Weekday
func (wds Weekdays) Len() int { return len(wds) }
func (wds Weekdays) Swap(i, j int) { wds[j], wds[i] = wds[i], wds[j] }
func (wds Weekdays) Less(i, j int) bool {
if wds[i] == time.Sunday {
return false
}
if wds[j] == time.Sunday {
return true
}
return int(wds[i]) < int(wds[j])
}
func (wds Weekdays) Unique() Weekdays {
mwds := map[time.Weekday]bool{}
for _, wd := range wds {
mwds[wd] = true
}
newWds := Weekdays{}
for wd := range mwds {
newWds = append(newWds, wd)
}
sort.Sort(newWds)
return newWds
}
type Date struct {
t time.Time
}
func (d *Date) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
func (d *Date) UnmarshalJSON(data []byte) error {
dateString := ""
if err := json.Unmarshal(data, &dateString); err != nil {
return err
}
nd := NewDateFromString(dateString)
d.t = nd.Time()
return nil
}
func NewDate(year, month, day int) Date {
if year == 0 || month == 0 || month > 12 || day == 0 {
return Date{}
}
var m time.Month
switch month {
case 1:
m = time.January
case 2:
m = time.February
case 3:
m = time.March
case 4:
m = time.April
case 5:
m = time.May
case 6:
m = time.June
case 7:
m = time.July
case 8:
m = time.August
case 9:
m = time.September
case 10:
m = time.October
case 11:
m = time.November
case 12:
m = time.December
}
t := time.Date(year, m, day, 0, 0, 0, 0, time.UTC)
return Date{
t: t,
}
}
func NewDateFromString(date string) Date {
date = strings.ToLower(strings.TrimSpace(date))
switch date {
case "":
fallthrough
case "no-date":
fallthrough
case "no date":
return Date{}
case "today":
return Today()
case "tod":
return Today()
case "tomorrow":
return Today().AddDays(1)
case "tom":
return Today().AddDays(1)
}
t, err := time.Parse("2006-01-02", fmt.Sprintf("%.10s", date))
if err == nil {
return Date{t: t}
}
newWeekday, ok := ParseWeekday(date)
if !ok {
return Date{}
}
daysToAdd := findDaysToWeekday(Today().Weekday(), newWeekday)
return Today().Add(daysToAdd)
}
func findDaysToWeekday(current, wanted time.Weekday) int {
daysToAdd := int(wanted) - int(current)
if daysToAdd <= 0 {
daysToAdd += 7
}
return daysToAdd
}
func (d Date) DaysBetween(d2 Date) int {
tDate := d2
end := d
if !end.After(tDate) {
end = d2
tDate = d
}
days := 0
for {
if tDate.Add(days).Equal(end) {
return days
}
days++
}
}
func (d Date) String() string {
if d.t.IsZero() {
return "no date"
}
return strings.ToLower(d.t.Format(DateFormat))
}
// func (d Date) Human() string {
// switch {
// case d.IsZero():
// return "-"
// case d.Equal(Today()):
// return "today"
// case d.Equal(Today().Add(1)):
// return "tomorrow"
// case d.After(Today()) && Today().Add(8).After(d):
// return strings.ToLower(d.t.Format("Monday"))
// default:
// return strings.ToLower(d.t.Format(DateFormat))
// }
// }
func (d Date) IsZero() bool {
return d.t.IsZero()
}
func (d Date) Time() time.Time {
return d.t
}
func (d Date) Weekday() time.Weekday {
return d.t.Weekday()
}
func (d Date) Day() int {
return d.t.Day()
}
func (d Date) Add(days int) Date {
year, month, day := d.t.Date()
return NewDate(year, int(month), day+days)
}
func (d Date) AddMonths(addMonths int) Date {
year, mmonth, day := d.t.Date()
month := int(mmonth)
for m := 1; m <= addMonths; m++ {
month += 1
if month == 12 {
year += 1
month = 1
}
}
return NewDate(year, month, day)
}
func (d Date) Equal(ud Date) bool {
return d.t.Equal(ud.Time())
}
// After reports whether d is after ud
func (d Date) After(ud Date) bool {
return d.t.After(ud.Time())
}
func (d Date) AddDays(amount int) Date {
year, month, date := d.t.Date()
return NewDate(year, int(month), date+amount)
}
func ParseWeekday(wd string) (time.Weekday, bool) {
switch lowerAndTrim(wd) {
case "monday":
return time.Monday, true
case "mon":
return time.Monday, true
case "tuesday":
return time.Tuesday, true
case "tue":
return time.Tuesday, true
case "wednesday":
return time.Wednesday, true
case "wed":
return time.Wednesday, true
case "thursday":
return time.Thursday, true
case "thu":
return time.Thursday, true
case "friday":
return time.Friday, true
case "fri":
return time.Friday, true
case "saturday":
return time.Saturday, true
case "sat":
return time.Saturday, true
case "sunday":
return time.Sunday, true
case "sun":
return time.Sunday, true
default:
return time.Monday, false
}
}
func lowerAndTrim(str string) string {
return strings.TrimSpace(strings.ToLower(str))
}

327
item/date_test.go Normal file
View File

@ -0,0 +1,327 @@
package item_test
import (
"sort"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"go-mod.ewintr.nl/planner/item"
)
func TestWeekdaysSort(t *testing.T) {
for _, tc := range []struct {
name string
input item.Weekdays
exp item.Weekdays
}{
{
name: "empty",
},
{
name: "one",
input: item.Weekdays{time.Tuesday},
exp: item.Weekdays{time.Tuesday},
},
{
name: "multiple",
input: item.Weekdays{time.Wednesday, time.Tuesday, time.Monday},
exp: item.Weekdays{time.Monday, time.Tuesday, time.Wednesday},
},
{
name: "sunday is last",
input: item.Weekdays{time.Saturday, time.Sunday, time.Monday},
exp: item.Weekdays{time.Monday, time.Saturday, time.Sunday},
},
} {
t.Run(tc.name, func(t *testing.T) {
sort.Sort(tc.input)
if diff := cmp.Diff(tc.exp, tc.input); diff != "" {
t.Errorf("(-exp, +got)%s\n", diff)
}
})
}
}
func TestWeekdaysUnique(t *testing.T) {
for _, tc := range []struct {
name string
input item.Weekdays
exp item.Weekdays
}{
{
name: "empty",
input: item.Weekdays{},
exp: item.Weekdays{},
},
{
name: "single",
input: item.Weekdays{time.Monday},
exp: item.Weekdays{time.Monday},
},
{
name: "no doubles",
input: item.Weekdays{time.Monday, time.Tuesday, time.Wednesday},
exp: item.Weekdays{time.Monday, time.Tuesday, time.Wednesday},
},
{
name: "doubles",
input: item.Weekdays{time.Monday, time.Monday, time.Wednesday, time.Monday},
exp: item.Weekdays{time.Monday, time.Wednesday},
},
} {
t.Run(tc.name, func(t *testing.T) {
if diff := cmp.Diff(tc.exp, tc.input.Unique()); diff != "" {
t.Errorf("(-exp, +got)%s\n", diff)
}
})
}
}
func TestNewDateFromString(t *testing.T) {
t.Parallel()
t.Run("simple", func(t *testing.T) {
for _, tc := range []struct {
name string
input string
exp item.Date
}{
{
name: "empty",
exp: item.Date{},
},
{
name: "no date",
input: "no date",
exp: item.Date{},
},
{
name: "short",
input: "2021-01-30",
exp: item.NewDate(2021, 1, 30),
},
} {
t.Run(tc.name, func(t *testing.T) {
if diff := cmp.Diff(tc.exp, item.NewDateFromString(tc.input)); diff != "" {
t.Errorf("(-exp, +got)%s\n", diff)
}
})
}
})
t.Run("day name", func(t *testing.T) {
monday := item.Today().Add(1)
for {
if monday.Weekday() == time.Monday {
break
}
monday = monday.Add(1)
}
for _, tc := range []struct {
name string
input string
}{
{
name: "dayname lowercase",
input: "monday",
},
{
name: "dayname capitalized",
input: "Monday",
},
{
name: "dayname short",
input: "mon",
},
} {
t.Run(tc.name, func(t *testing.T) {
if diff := cmp.Diff(monday, item.NewDateFromString(tc.input)); diff != "" {
t.Errorf("(-exp, +got)%s\n", diff)
}
})
}
})
t.Run("relative days", func(t *testing.T) {
for _, tc := range []struct {
name string
exp item.Date
}{
{
name: "today",
exp: item.Today(),
},
{
name: "tod",
exp: item.Today(),
},
{
name: "tomorrow",
exp: item.Today().Add(1),
},
{
name: "tom",
exp: item.Today().Add(1),
},
} {
t.Run(tc.name, func(t *testing.T) {
if diff := cmp.Diff(tc.exp, item.NewDateFromString(tc.name)); diff != "" {
t.Errorf("(-exp, +got)%s\n", diff)
}
})
}
})
}
func TestDateDaysBetween(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
d1 item.Date
d2 item.Date
exp int
}{
{
name: "same",
d1: item.NewDate(2021, 6, 23),
d2: item.NewDate(2021, 6, 23),
},
{
name: "one",
d1: item.NewDate(2021, 6, 23),
d2: item.NewDate(2021, 6, 24),
exp: 1,
},
{
name: "many",
d1: item.NewDate(2021, 6, 23),
d2: item.NewDate(2024, 3, 7),
exp: 988,
},
{
name: "edge",
d1: item.NewDate(2020, 12, 30),
d2: item.NewDate(2021, 1, 3),
exp: 4,
},
{
name: "reverse",
d1: item.NewDate(2021, 6, 23),
d2: item.NewDate(2021, 5, 23),
exp: 31,
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.exp != tc.d1.DaysBetween(tc.d2) {
t.Errorf("exp %v, got %v", tc.exp, tc.d1.DaysBetween(tc.d2))
}
})
}
}
func TestDateString(t *testing.T) {
for _, tc := range []struct {
name string
date item.Date
exp string
}{
{
name: "zero",
date: item.NewDate(0, 0, 0),
exp: "no date",
},
{
name: "normal",
date: item.NewDate(2021, 5, 30),
exp: "2021-05-30",
},
{
name: "normalize",
date: item.NewDate(2021, 5, 32),
exp: "2021-06-01",
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.exp != tc.date.String() {
t.Errorf("exp %v, got %v", tc.exp, tc.date.String())
}
})
}
}
// func TestDateHuman(t *testing.T) {
// for _, tc := range []struct {
// name string
// date task.Date
// exp string
// }{
// {
// name: "zero",
// date: task.NewDate(0, 0, 0),
// exp: "-",
// },
// {
// name: "default",
// date: task.NewDate(2020, 1, 1),
// exp: "2020-01-01 (wednesday)",
// },
// {
// name: "today",
// date: task.Today(),
// exp: "today",
// },
// {
// name: "tomorrow",
// date: task.Today().Add(1),
// exp: "tomorrow",
// },
// } {
// t.Run(tc.name, func(t *testing.T) {
// test.Equals(t, tc.exp, tc.date.Human())
// })
// }
// }
func TestDateIsZero(t *testing.T) {
t.Parallel()
if !(item.Date{}.IsZero()) {
t.Errorf("exp true, got false")
}
if item.NewDate(2021, 6, 24).IsZero() {
t.Errorf("exp false, got true")
}
}
func TestDateAfter(t *testing.T) {
t.Parallel()
day := item.NewDate(2021, 1, 31)
for _, tc := range []struct {
name string
tDay item.Date
exp bool
}{
{
name: "after",
tDay: item.NewDate(2021, 1, 30),
exp: true,
},
{
name: "on",
tDay: day,
},
{
name: "before",
tDay: item.NewDate(2021, 2, 1),
},
} {
t.Run(tc.name, func(t *testing.T) {
if diff := cmp.Diff(tc.exp, day.After(tc.tDay)); diff != "" {
t.Errorf("(-exp, +got)%s\n", diff)
}
})
}
}

View File

@ -4,22 +4,22 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"time" "time"
"github.com/google/go-cmp/cmp"
) )
type EventBody struct { type EventBody struct {
Title string `json:"title"` Title string `json:"title"`
Start time.Time `json:"start"` Time Time `json:"time"`
Duration time.Duration `json:"duration"` Duration time.Duration `json:"duration"`
} }
func (e EventBody) MarshalJSON() ([]byte, error) { func (e EventBody) MarshalJSON() ([]byte, error) {
type Alias EventBody type Alias EventBody
return json.Marshal(&struct { return json.Marshal(&struct {
Start string `json:"start"`
Duration string `json:"duration"` Duration string `json:"duration"`
*Alias *Alias
}{ }{
Start: e.Start.UTC().Format(time.RFC3339),
Duration: e.Duration.String(), Duration: e.Duration.String(),
Alias: (*Alias)(&e), Alias: (*Alias)(&e),
}) })
@ -28,7 +28,6 @@ func (e EventBody) MarshalJSON() ([]byte, error) {
func (e *EventBody) UnmarshalJSON(data []byte) error { func (e *EventBody) UnmarshalJSON(data []byte) error {
type Alias EventBody type Alias EventBody
aux := &struct { aux := &struct {
Start string `json:"start"`
Duration string `json:"duration"` Duration string `json:"duration"`
*Alias *Alias
}{ }{
@ -39,10 +38,6 @@ func (e *EventBody) UnmarshalJSON(data []byte) error {
} }
var err error var err error
if e.Start, err = time.Parse(time.RFC3339, aux.Start); err != nil {
return err
}
if e.Duration, err = time.ParseDuration(aux.Duration); err != nil { if e.Duration, err = time.ParseDuration(aux.Duration); err != nil {
return err return err
} }
@ -52,8 +47,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"` Date Date `json:"date"`
RecurNext time.Time `json:"recurNext"` Recurrer Recurrer `json:"recurrer"`
RecurNext Date `json:"recurNext"`
EventBody EventBody
} }
@ -68,6 +64,7 @@ func NewEvent(i Item) (Event, error) {
} }
e.ID = i.ID e.ID = i.ID
e.Date = i.Date
e.Recurrer = i.Recurrer e.Recurrer = i.Recurrer
e.RecurNext = i.RecurNext e.RecurNext = i.RecurNext
@ -75,18 +72,15 @@ func NewEvent(i Item) (Event, error) {
} }
func (e Event) Item() (Item, error) { func (e Event) Item() (Item, error) {
body, err := json.Marshal(EventBody{ body, err := json.Marshal(e.EventBody)
Title: e.Title,
Start: e.Start,
Duration: e.Duration,
})
if err != nil { if err != nil {
return Item{}, fmt.Errorf("could not marshal event to json") return Item{}, fmt.Errorf("could not marshal event body to json")
} }
return Item{ return Item{
ID: e.ID, ID: e.ID,
Kind: KindEvent, Kind: KindEvent,
Date: e.Date,
Recurrer: e.Recurrer, Recurrer: e.Recurrer,
RecurNext: e.RecurNext, RecurNext: e.RecurNext,
Body: string(body), Body: string(body),
@ -97,15 +91,26 @@ func (e Event) Valid() bool {
if e.Title == "" { if e.Title == "" {
return false return false
} }
if e.Start.IsZero() || e.Start.Year() < 2024 { if e.Date.IsZero() {
return false return false
} }
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
} }
func EventDiff(a, b Event) string {
aJSON, _ := json.Marshal(a)
bJSON, _ := json.Marshal(b)
return cmp.Diff(string(aJSON), string(bJSON))
}
func EventDiffs(a, b []Event) string {
aJSON, _ := json.Marshal(a)
bJSON, _ := json.Marshal(b)
return cmp.Diff(string(aJSON), string(bJSON))
}

View File

@ -25,10 +25,11 @@ func TestNewEvent(t *testing.T) {
name: "wrong kind", name: "wrong kind",
it: item.Item{ it: item.Item{
ID: "a", ID: "a",
Date: item.NewDate(2024, 9, 20),
Kind: item.KindTask, Kind: item.KindTask,
Body: `{ Body: `{
"title":"title", "title":"title",
"start":"2024-09-20T08:00:00Z", "time":"08:00",
"duration":"1h" "duration":"1h"
}`, }`,
}, },
@ -48,27 +49,21 @@ func TestNewEvent(t *testing.T) {
it: item.Item{ it: item.Item{
ID: "a", ID: "a",
Kind: item.KindEvent, Kind: item.KindEvent,
Recurrer: &item.Recur{ Date: item.NewDate(2024, 9, 20),
Start: time.Date(2024, 12, 8, 9, 0, 0, 0, time.UTC), Recurrer: item.NewRecurrer("2024-12-08, daily"),
Period: item.PeriodDay,
Count: 1,
},
Body: `{ Body: `{
"title":"title", "title":"title",
"start":"2024-09-20T08:00:00Z", "time":"08:00",
"duration":"1h" "duration":"1h"
}`, }`,
}, },
expEvent: item.Event{ expEvent: item.Event{
ID: "a", ID: "a",
Recurrer: &item.Recur{ Date: item.NewDate(2024, 9, 20),
Start: time.Date(2024, 12, 8, 9, 0, 0, 0, time.UTC), Recurrer: item.NewRecurrer("2024-12-08, daily"),
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), Time: item.NewTime(8, 0),
Duration: oneHour, Duration: oneHour,
}, },
}, },
@ -82,8 +77,8 @@ func TestNewEvent(t *testing.T) {
if tc.expErr { if tc.expErr {
return return
} }
if diff := cmp.Diff(tc.expEvent, actEvent); diff != "" { if diff := item.EventDiff(tc.expEvent, actEvent); diff != "" {
t.Errorf("(exp +, got -)\n%s", diff) t.Errorf("(+exp, -got)\n%s", diff)
} }
}) })
} }
@ -107,16 +102,17 @@ func TestEventItem(t *testing.T) {
expItem: item.Item{ expItem: item.Item{
Kind: item.KindEvent, Kind: item.KindEvent,
Updated: time.Time{}, Updated: time.Time{},
Body: `{"start":"0001-01-01T00:00:00Z","duration":"0s","title":""}`, Body: `{"duration":"0s","title":"","time":"00:00"}`,
}, },
}, },
{ {
name: "normal", name: "normal",
event: item.Event{ event: item.Event{
ID: "a", ID: "a",
Date: item.NewDate(2024, 9, 23),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "title", Title: "title",
Start: time.Date(2024, 9, 23, 8, 0, 0, 0, time.UTC), Time: item.NewTime(8, 0),
Duration: oneHour, Duration: oneHour,
}, },
}, },
@ -124,7 +120,8 @@ func TestEventItem(t *testing.T) {
ID: "a", ID: "a",
Kind: item.KindEvent, Kind: item.KindEvent,
Updated: time.Time{}, Updated: time.Time{},
Body: `{"start":"2024-09-23T08:00:00Z","duration":"1h0m0s","title":"title"}`, Date: item.NewDate(2024, 9, 23),
Body: `{"duration":"1h0m0s","title":"title","time":"08:00"}`,
}, },
}, },
} { } {
@ -163,8 +160,9 @@ func TestEventValidate(t *testing.T) {
name: "missing title", name: "missing title",
event: item.Event{ event: item.Event{
ID: "a", ID: "a",
Date: item.NewDate(2024, 9, 20),
EventBody: item.EventBody{ EventBody: item.EventBody{
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC), Time: item.NewTime(8, 0),
Duration: oneHour, Duration: oneHour,
}, },
}, },
@ -175,7 +173,7 @@ func TestEventValidate(t *testing.T) {
ID: "a", ID: "a",
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "title", Title: "title",
Start: time.Date(0, 0, 0, 8, 0, 0, 0, time.UTC), Time: item.NewTime(8, 0),
Duration: oneHour, Duration: oneHour,
}, },
}, },
@ -184,9 +182,10 @@ func TestEventValidate(t *testing.T) {
name: "no duration", name: "no duration",
event: item.Event{ event: item.Event{
ID: "a", ID: "a",
Date: item.NewDate(2024, 9, 20),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "title", Title: "title",
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC), Time: item.NewTime(8, 0),
}, },
}, },
}, },
@ -194,9 +193,10 @@ func TestEventValidate(t *testing.T) {
name: "valid", name: "valid",
event: item.Event{ event: item.Event{
ID: "a", ID: "a",
Date: item.NewDate(2024, 9, 20),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "title", Title: "title",
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC), Time: item.NewTime(8, 0),
Duration: oneHour, Duration: oneHour,
}, },
}, },

View File

@ -1,6 +1,7 @@
package item package item
import ( import (
"encoding/json"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -22,11 +23,43 @@ type Item struct {
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"`
Recurrer *Recur `json:"recurrer"` Date Date `json:"date"`
RecurNext time.Time `json:"recurNext"` Recurrer Recurrer `json:"recurrer"`
RecurNext Date `json:"recurNext"`
Body string `json:"body"` Body string `json:"body"`
} }
func (i Item) MarshalJSON() ([]byte, error) {
var recurStr string
if i.Recurrer != nil {
recurStr = i.Recurrer.String()
}
type Alias Item
return json.Marshal(&struct {
Recurrer string `json:"recurrer"`
*Alias
}{
Recurrer: recurStr,
Alias: (*Alias)(&i),
})
}
func (i *Item) UnmarshalJSON(data []byte) error {
type Alias Item
aux := &struct {
Recurrer string `json:"recurrer"`
*Alias
}{
Alias: (*Alias)(i),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
i.Recurrer = NewRecurrer(aux.Recurrer)
return nil
}
func NewItem(k Kind, body string) Item { func NewItem(k Kind, body string) Item {
return Item{ return Item{
ID: uuid.New().String(), ID: uuid.New().String(),

View File

@ -1,61 +1,270 @@
package item package item
import ( import (
"slices" "fmt"
"time" "strconv"
"strings"
) )
type RecurPeriod string type Recurrer interface {
RecursOn(date Date) bool
const ( First() Date
PeriodDay RecurPeriod = "day" String() string
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 { func NewRecurrer(recurStr string) Recurrer {
switch r.Period { terms := strings.Split(recurStr, ",")
case PeriodDay: if len(terms) < 2 {
return r.onDays(date) return nil
case PeriodMonth: }
return r.onMonths(date)
default: start := NewDateFromString(terms[0])
return false if start.IsZero() {
return nil
}
terms = terms[1:]
for i, t := range terms {
terms[i] = strings.TrimSpace(t)
}
for _, parseFunc := range []func(Date, []string) (Recurrer, bool){
ParseDaily, ParseEveryNDays, ParseWeekly,
ParseEveryNWeeks, ParseEveryNMonths,
} {
if recur, ok := parseFunc(start, terms); ok {
return recur
} }
} }
func (r *Recur) onDays(date time.Time) bool { return nil
if r.Start.After(date) {
return false
} }
testDate := r.Start func FirstRecurAfter(r Recurrer, d Date) Date {
lim := NewDate(2050, 1, 1)
for { for {
if testDate.Equal(date) { d = d.Add(1)
if r.RecursOn(d) || d.Equal(lim) {
return d
}
}
}
type Daily struct {
Start Date
}
// yyyy-mm-dd, daily
func ParseDaily(start Date, terms []string) (Recurrer, bool) {
if len(terms) < 1 {
return nil, false
}
if terms[0] != "daily" {
return nil, false
}
return Daily{
Start: start,
}, true
}
func (d Daily) RecursOn(date Date) bool {
return date.Equal(d.Start) || date.After(d.Start)
}
func (d Daily) First() Date { return FirstRecurAfter(d, d.Start.Add(-1)) }
func (d Daily) String() string {
return fmt.Sprintf("%s, daily", d.Start.String())
}
type EveryNDays struct {
Start Date
N int
}
// yyyy-mm-dd, every 3 days
func ParseEveryNDays(start Date, terms []string) (Recurrer, bool) {
if len(terms) != 1 {
return EveryNDays{}, false
}
terms = strings.Split(terms[0], " ")
if len(terms) != 3 || terms[0] != "every" || terms[2] != "days" {
return EveryNDays{}, false
}
n, err := strconv.Atoi(terms[1])
if err != nil {
return EveryNDays{}, false
}
return EveryNDays{
Start: start,
N: n,
}, true
}
func (nd EveryNDays) RecursOn(date Date) bool {
if nd.Start.After(date) {
return false
}
testDate := nd.Start
for {
switch {
case testDate.Equal(date):
return true
case testDate.After(date):
return false
default:
testDate = testDate.Add(nd.N)
}
}
}
func (nd EveryNDays) First() Date { return FirstRecurAfter(nd, nd.Start.Add(-1)) }
func (nd EveryNDays) String() string {
return fmt.Sprintf("%s, every %d days", nd.Start.String(), nd.N)
}
type Weekly struct {
Start Date
Weekdays Weekdays
}
// yyyy-mm-dd, weekly, wednesday & saturday & sunday
func ParseWeekly(start Date, terms []string) (Recurrer, bool) {
if len(terms) < 2 {
return nil, false
}
if terms[0] != "weekly" {
return nil, false
}
wds := Weekdays{}
for _, wdStr := range strings.Split(terms[1], "&") {
wd, ok := ParseWeekday(wdStr)
if !ok {
continue
}
wds = append(wds, wd)
}
if len(wds) == 0 {
return nil, false
}
return Weekly{
Start: start,
Weekdays: wds.Unique(),
}, true
}
func (w Weekly) RecursOn(date Date) bool {
if w.Start.After(date) {
return false
}
for _, wd := range w.Weekdays {
if wd == date.Weekday() {
return true return true
} }
if testDate.After(date) { }
return false return false
} }
dur := time.Duration(r.Count) * 24 * time.Hour func (w Weekly) First() Date { return FirstRecurAfter(w, w.Start.Add(-1)) }
testDate = testDate.Add(dur)
func (w Weekly) String() string {
weekdayStrs := []string{}
for _, wd := range w.Weekdays {
weekdayStrs = append(weekdayStrs, wd.String())
} }
weekdayStr := strings.Join(weekdayStrs, " & ")
return fmt.Sprintf("%s, weekly, %s", w.Start.String(), strings.ToLower(weekdayStr))
} }
func (r *Recur) onMonths(date time.Time) bool { type EveryNWeeks struct {
if r.Start.After(date) { Start Date
N int
}
// yyyy-mm-dd, every 3 weeks
func ParseEveryNWeeks(start Date, terms []string) (Recurrer, bool) {
if len(terms) != 1 {
return nil, false
}
terms = strings.Split(terms[0], " ")
if len(terms) != 3 || terms[0] != "every" || terms[2] != "weeks" {
return nil, false
}
n, err := strconv.Atoi(terms[1])
if err != nil || n < 1 {
return nil, false
}
return EveryNWeeks{
Start: start,
N: n,
}, true
}
func (enw EveryNWeeks) RecursOn(date Date) bool {
if enw.Start.After(date) {
return false
}
if enw.Start.Equal(date) {
return true
}
intervalDays := enw.N * 7
return enw.Start.DaysBetween(date)%intervalDays == 0
}
func (enw EveryNWeeks) First() Date { return FirstRecurAfter(enw, enw.Start.Add(-1)) }
func (enw EveryNWeeks) String() string {
return fmt.Sprintf("%s, every %d weeks", enw.Start.String(), enw.N)
}
type EveryNMonths struct {
Start Date
N int
}
// yyyy-mm-dd, every 3 months
func ParseEveryNMonths(start Date, terms []string) (Recurrer, bool) {
if len(terms) != 1 {
return nil, false
}
terms = strings.Split(terms[0], " ")
if len(terms) != 3 || terms[0] != "every" || terms[2] != "months" {
return nil, false
}
n, err := strconv.Atoi(terms[1])
if err != nil {
return nil, false
}
return EveryNMonths{
Start: start,
N: n,
}, true
}
func (enm EveryNMonths) RecursOn(date Date) bool {
if enm.Start.After(date) {
return false return false
} }
tDate := r.Start tDate := enm.Start
for { for {
if tDate.Equal(date) { if tDate.Equal(date) {
return true return true
@ -63,23 +272,13 @@ func (r *Recur) onMonths(date time.Time) bool {
if tDate.After(date) { if tDate.After(date) {
return false return false
} }
tDate = tDate.AddMonths(enm.N)
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 { func (enm EveryNMonths) First() Date { return FirstRecurAfter(enm, enm.Start.Add(-1)) }
return r.Start.IsZero() || !slices.Contains(ValidPeriods, r.Period)
func (enm EveryNMonths) String() string {
return fmt.Sprintf("%s, every %d months", enm.Start.String(), enm.N)
} }

View File

@ -4,102 +4,425 @@ import (
"testing" "testing"
"time" "time"
"github.com/google/go-cmp/cmp"
"go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/item"
) )
func TestRecur(t *testing.T) { func TestDaily(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("days", func(t *testing.T) { daily := item.Daily{
r := item.Recur{ Start: item.NewDate(2021, 1, 31), // a sunday
Start: time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC),
Period: item.PeriodDay,
Count: 5,
} }
day := 24 * time.Hour dailyStr := "2021-01-31, daily"
t.Run("parse", func(t *testing.T) {
if diff := cmp.Diff(daily, item.NewRecurrer(dailyStr)); diff != "" {
t.Errorf("(-exp +got):\n%s", diff)
}
})
t.Run("string", func(t *testing.T) {
if dailyStr != daily.String() {
t.Errorf("exp %v, got %v", dailyStr, daily.String())
}
})
t.Run("recurs_on", func(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
date time.Time date item.Date
exp bool exp bool
}{ }{
{ {
name: "before", name: "before",
date: time.Date(202, 1, 1, 0, 0, 0, 0, time.UTC), date: item.NewDate(2021, 1, 30),
},
{
name: "on",
date: daily.Start,
exp: true,
},
{
name: "after",
date: item.NewDate(2021, 2, 1),
exp: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.exp != daily.RecursOn(tc.date) {
t.Errorf("exp %v, got %v", tc.exp, daily.RecursOn(tc.date))
}
})
}
})
}
func TestEveryNDays(t *testing.T) {
t.Parallel()
every := item.EveryNDays{
Start: item.NewDate(2022, 6, 8),
N: 5,
}
everyStr := "2022-06-08, every 5 days"
t.Run("parse", func(t *testing.T) {
if diff := cmp.Diff(every, item.NewRecurrer(everyStr)); diff != "" {
t.Errorf("(-exp +got):\n%s", diff)
}
})
t.Run("string", func(t *testing.T) {
if everyStr != every.String() {
t.Errorf("exp %v, got %v", everyStr, every.String())
}
})
t.Run("recurs on", func(t *testing.T) {
for _, tc := range []struct {
name string
date item.Date
exp bool
}{
{
name: "before",
date: item.NewDate(2022, 1, 1),
}, },
{ {
name: "start", name: "start",
date: r.Start, date: every.Start,
exp: true, exp: true,
}, },
{ {
name: "after true", name: "after true",
date: r.Start.Add(15 * day), date: every.Start.Add(15),
exp: true, exp: true,
}, },
{ {
name: "after false", name: "after false",
date: r.Start.Add(16 * day), date: every.Start.Add(16),
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if act := r.On(tc.date); tc.exp != act { if tc.exp != every.RecursOn(tc.date) {
t.Errorf("exp %v, got %v", tc.exp, act) t.Errorf("exp %v, got %v", tc.exp, tc.date)
} }
}) })
} }
}) })
t.Run("months", func(t *testing.T) {
r := item.Recur{
Start: time.Date(2021, 2, 3, 0, 0, 0, 0, time.UTC),
Period: item.PeriodMonth,
Count: 3,
} }
func TestParseWeekly(t *testing.T) {
t.Parallel()
start := item.NewDate(2021, 2, 7)
for _, tc := range []struct { for _, tc := range []struct {
name string name string
date time.Time input []string
expOK bool
expWeekly item.Weekly
}{
{
name: "empty",
},
{
name: "wrong type",
input: []string{"daily"},
},
{
name: "wrong count",
input: []string{"weeekly"},
},
{
name: "unknown day",
input: []string{"weekly", "festivus"},
},
{
name: "one day",
input: []string{"weekly", "monday"},
expOK: true,
expWeekly: item.Weekly{
Start: start,
Weekdays: item.Weekdays{
time.Monday,
},
},
},
{
name: "multiple days",
input: []string{"weekly", "monday & thursday & saturday"},
expOK: true,
expWeekly: item.Weekly{
Start: start,
Weekdays: item.Weekdays{
time.Monday,
time.Thursday,
time.Saturday,
},
},
},
{
name: "wrong order",
input: []string{"weekly", "sunday & thursday & wednesday"},
expOK: true,
expWeekly: item.Weekly{
Start: start,
Weekdays: item.Weekdays{
time.Wednesday,
time.Thursday,
time.Sunday,
},
},
},
{
name: "doubles",
input: []string{"weekly", "sunday & sunday & monday"},
expOK: true,
expWeekly: item.Weekly{
Start: start,
Weekdays: item.Weekdays{
time.Monday,
time.Sunday,
},
},
},
{
name: "one unknown",
input: []string{"weekly", "sunday & someday"},
expOK: true,
expWeekly: item.Weekly{
Start: start,
Weekdays: item.Weekdays{
time.Sunday,
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
actWeekly, actOK := item.ParseWeekly(start, tc.input)
if tc.expOK != actOK {
t.Errorf("exp %v, got %v", tc.expOK, actOK)
}
if !tc.expOK {
return
}
if diff := cmp.Diff(tc.expWeekly, actWeekly); diff != "" {
t.Errorf("(-exp, +got)%s\n", diff)
}
})
}
}
func TestWeekly(t *testing.T) {
t.Parallel()
weekly := item.Weekly{
Start: item.NewDate(2021, 1, 31), // a sunday
Weekdays: item.Weekdays{
time.Monday,
time.Wednesday,
time.Thursday,
},
}
weeklyStr := "2021-01-31, weekly, monday & wednesday & thursday"
t.Run("parse", func(t *testing.T) {
if diff := cmp.Diff(weekly, item.NewRecurrer(weeklyStr)); diff != "" {
t.Errorf("(-exp, +got)%s\n", diff)
}
})
t.Run("string", func(t *testing.T) {
if weeklyStr != weekly.String() {
t.Errorf("exp %v, got %v", weeklyStr, weekly.String())
}
})
t.Run("recurs_on", func(t *testing.T) {
for _, tc := range []struct {
name string
date item.Date
exp bool exp bool
}{ }{
{ {
name: "before start", name: "before start",
date: time.Date(2021, 1, 27, 0, 0, 0, 0, time.UTC), date: item.NewDate(2021, 1, 27), // a wednesday
},
{
name: "right weekday",
date: item.NewDate(2021, 2, 1), // a monday
exp: true,
},
{
name: "another right day",
date: item.NewDate(2021, 2, 3), // a wednesday
exp: true,
},
{
name: "wrong weekday",
date: item.NewDate(2021, 2, 5), // a friday
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.exp != weekly.RecursOn(tc.date) {
t.Errorf("exp %v, got %v", tc.exp, weekly.RecursOn(tc.date))
}
})
}
})
}
func TestEveryNWeeks(t *testing.T) {
t.Parallel()
everyNWeeks := item.EveryNWeeks{
Start: item.NewDate(2021, 2, 3),
N: 3,
}
everyNWeeksStr := "2021-02-03, every 3 weeks"
t.Run("parse", func(t *testing.T) {
if everyNWeeks != item.NewRecurrer(everyNWeeksStr) {
t.Errorf("exp %v, got %v", everyNWeeks, item.NewRecurrer(everyNWeeksStr))
}
})
t.Run("string", func(t *testing.T) {
if everyNWeeksStr != everyNWeeks.String() {
t.Errorf("exp %v, got %v", everyNWeeksStr, everyNWeeks.String())
}
})
t.Run("recurs on", func(t *testing.T) {
for _, tc := range []struct {
name string
date item.Date
exp bool
}{
{
name: "before start",
date: item.NewDate(2021, 1, 27),
}, },
{ {
name: "on start", name: "on start",
date: time.Date(2021, 2, 3, 0, 0, 0, 0, time.UTC), date: item.NewDate(2021, 2, 3),
exp: true,
},
{
name: "wrong day",
date: item.NewDate(2021, 2, 4),
},
{
name: "one week after",
date: item.NewDate(2021, 2, 10),
},
{
name: "first interval",
date: item.NewDate(2021, 2, 24),
exp: true,
},
{
name: "second interval",
date: item.NewDate(2021, 3, 17),
exp: true,
},
{
name: "second interval plus one week",
date: item.NewDate(2021, 3, 24),
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.exp != everyNWeeks.RecursOn(tc.date) {
t.Errorf("exp %v, got %v", tc.exp, everyNWeeks.RecursOn(tc.date))
}
})
}
})
}
func TestEveryNMonths(t *testing.T) {
everyNMonths := item.EveryNMonths{
Start: item.NewDate(2021, 2, 3),
N: 3,
}
everyNMonthsStr := "2021-02-03, every 3 months"
t.Run("parse", func(t *testing.T) {
if diff := cmp.Diff(everyNMonths, item.NewRecurrer(everyNMonthsStr)); diff != "" {
t.Errorf("(-exp, +got)%s\n", diff)
}
})
t.Run("string", func(t *testing.T) {
if everyNMonthsStr != everyNMonths.String() {
t.Errorf("exp %v, got %v", everyNMonthsStr, everyNMonths.String())
}
})
t.Run("recurs on", func(t *testing.T) {
for _, tc := range []struct {
name string
date item.Date
exp bool
}{
{
name: "before start",
date: item.NewDate(2021, 1, 27),
},
{
name: "on start",
date: item.NewDate(2021, 2, 3),
exp: true, exp: true,
}, },
{ {
name: "8 weeks after", name: "8 weeks after",
date: time.Date(2021, 3, 31, 0, 0, 0, 0, time.UTC), date: item.NewDate(2021, 3, 31),
}, },
{ {
name: "one month", name: "one month",
date: time.Date(2021, 3, 3, 0, 0, 0, 0, time.UTC), date: item.NewDate(2021, 3, 3),
}, },
{ {
name: "3 months", name: "3 months",
date: time.Date(2021, 5, 3, 0, 0, 0, 0, time.UTC), date: item.NewDate(2021, 5, 3),
exp: true, exp: true,
}, },
{ {
name: "4 months", name: "4 months",
date: time.Date(2021, 6, 3, 0, 0, 0, 0, time.UTC), date: item.NewDate(2021, 6, 3),
}, },
{ {
name: "6 months", name: "6 months",
date: time.Date(2021, 8, 3, 0, 0, 0, 0, time.UTC), date: item.NewDate(2021, 8, 3),
exp: true, exp: true,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if act := r.On(tc.date); tc.exp != act { if tc.exp != everyNMonths.RecursOn(tc.date) {
t.Errorf("exp %v, got %v", tc.exp, act) t.Errorf("exp %v, got %v", tc.exp, everyNMonths.RecursOn(tc.date))
} }
}) })
} }
}) })
t.Run("recurs every year", func(t *testing.T) {
recur := item.EveryNMonths{
Start: item.NewDate(2021, 3, 1),
N: 12,
}
if recur.RecursOn(item.NewDate(2021, 3, 9)) {
t.Errorf("exp false, got true")
}
})
t.Run("bug", func(t *testing.T) {
recur := item.EveryNMonths{
Start: item.NewDate(2021, 3, 1),
N: 1,
}
if recur.RecursOn(item.NewDate(2021, 11, 3)) {
t.Errorf("exp false, got true")
}
})
} }

70
item/time.go Normal file
View File

@ -0,0 +1,70 @@
package item
import (
"encoding/json"
"time"
)
const (
TimeFormat = "15:04"
)
type Time struct {
t time.Time
}
func (t Time) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}
func (t *Time) UnmarshalJSON(data []byte) error {
timeString := ""
if err := json.Unmarshal(data, &timeString); err != nil {
return err
}
nt := NewTimeFromString(timeString)
t.t = nt.Time()
return nil
}
func NewTime(hour, minute int) Time {
return Time{
t: time.Date(0, 0, 0, hour, minute, 0, 0, time.UTC),
}
}
func NewTimeFromString(timeStr string) Time {
tm, err := time.Parse(TimeFormat, timeStr)
if err != nil {
return Time{t: time.Time{}}
}
return Time{t: tm}
}
func (t *Time) String() string {
return t.t.Format(TimeFormat)
}
func (t *Time) Time() time.Time {
return t.t
}
func (t *Time) IsZero() bool {
return t.t.IsZero()
}
func (t *Time) Hour() int {
return t.t.Hour()
}
func (t *Time) Minute() int {
return t.t.Minute()
}
func (t *Time) Add(d time.Duration) Time {
return Time{
t: t.t.Add(d),
}
}

67
item/time_test.go Normal file
View File

@ -0,0 +1,67 @@
package item_test
import (
"encoding/json"
"fmt"
"testing"
"go-mod.ewintr.nl/planner/item"
)
func TestTime(t *testing.T) {
t.Parallel()
h, m := 11, 18
tm := item.NewTime(h, m)
expStr := "11:18"
if expStr != tm.String() {
t.Errorf("exp %v, got %v", expStr, tm.String())
}
actJSON, err := json.Marshal(tm)
if err != nil {
t.Errorf("exp nil, got %v", err)
}
expJSON := fmt.Sprintf("%q", expStr)
if expJSON != string(actJSON) {
t.Errorf("exp %v, got %v", expJSON, string(actJSON))
}
var actTM item.Time
if err := json.Unmarshal(actJSON, &actTM); err != nil {
t.Errorf("exp nil, got %v", err)
}
if expStr != actTM.String() {
t.Errorf("ecp %v, got %v", expStr, actTM.String())
}
}
func TestTimeFromString(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
str string
exp string
}{
{
name: "empty",
exp: "00:00",
},
{
name: "invalid",
str: "invalid",
exp: "00:00",
},
{
name: "valid",
str: "11:42",
exp: "11:42",
},
} {
t.Run(tc.name, func(t *testing.T) {
act := item.NewTimeFromString(tc.str)
if tc.exp != act.String() {
t.Errorf("exp %v, got %v", tc.exp, act.String())
}
})
}
}

View File

@ -3,7 +3,6 @@ package command
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/item"
@ -27,8 +26,7 @@ func NewAdd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage
FlagOn: &FlagDate{}, FlagOn: &FlagDate{},
FlagAt: &FlagTime{}, FlagAt: &FlagTime{},
FlagFor: &FlagDuration{}, FlagFor: &FlagDuration{},
FlagRecStart: &FlagDate{}, FlagRec: &FlagRecurrer{},
FlagRecPeriod: &FlagPeriod{},
}, },
}, },
} }
@ -70,39 +68,25 @@ func (add *Add) Execute(main []string, flags map[string]string) error {
return fmt.Errorf("could not set duration to 24 hours") 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()
} }
func (add *Add) do() error { func (add *Add) do() error {
as := add.argSet as := add.argSet
start := as.GetTime(FlagOn) rec := as.GetRecurrer(FlagRec)
if as.IsSet(FlagAt) {
at := as.GetTime(FlagAt)
h := time.Duration(at.Hour()) * time.Hour
m := time.Duration(at.Minute()) * time.Minute
start = start.Add(h).Add(m)
}
e := item.Event{ e := item.Event{
ID: uuid.New().String(), ID: uuid.New().String(),
Date: as.GetDate(FlagOn),
Recurrer: rec,
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: as.Main, Title: as.Main,
Start: start, Time: as.GetTime(FlagAt),
Duration: as.GetDuration(FlagFor),
}, },
} }
if rec != nil {
if as.IsSet(FlagFor) { e.RecurNext = rec.First()
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 {

View File

@ -4,7 +4,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/google/go-cmp/cmp"
"go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/command"
"go-mod.ewintr.nl/planner/plan/storage/memory" "go-mod.ewintr.nl/planner/plan/storage/memory"
@ -13,13 +12,11 @@ import (
func TestAdd(t *testing.T) { func TestAdd(t *testing.T) {
t.Parallel() t.Parallel()
aDateStr := "2024-11-02" aDate := item.NewDate(2024, 11, 2)
aDate := time.Date(2024, 11, 2, 0, 0, 0, 0, time.UTC) aTime := item.NewTime(12, 0)
aTimeStr := "12:00"
aDay := time.Duration(24) * time.Hour aDay := time.Duration(24) * time.Hour
anHourStr := "1h" anHourStr := "1h"
anHour := time.Hour anHour := time.Hour
aDateAndTime := time.Date(2024, 11, 2, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@ -36,7 +33,7 @@ func TestAdd(t *testing.T) {
name: "title missing", name: "title missing",
main: []string{"add"}, main: []string{"add"},
flags: map[string]string{ flags: map[string]string{
command.FlagOn: aDateStr, command.FlagOn: aDate.String(),
}, },
expErr: true, expErr: true,
}, },
@ -49,46 +46,31 @@ func TestAdd(t *testing.T) {
name: "only date", name: "only date",
main: []string{"add", "title"}, main: []string{"add", "title"},
flags: map[string]string{ flags: map[string]string{
command.FlagOn: aDateStr, command.FlagOn: aDate.String(),
}, },
expEvent: item.Event{ expEvent: item.Event{
ID: "title", ID: "title",
Date: aDate,
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "title", Title: "title",
Start: aDate,
Duration: aDay, Duration: aDay,
}, },
}, },
}, },
{
name: "date and time",
main: []string{"add", "title"},
flags: map[string]string{
command.FlagOn: aDateStr,
command.FlagAt: aTimeStr,
},
expEvent: item.Event{
ID: "title",
EventBody: item.EventBody{
Title: "title",
Start: aDateAndTime,
Duration: anHour,
},
},
},
{ {
name: "date, time and duration", name: "date, time and duration",
main: []string{"add", "title"}, main: []string{"add", "title"},
flags: map[string]string{ flags: map[string]string{
command.FlagOn: aDateStr, command.FlagOn: aDate.String(),
command.FlagAt: aTimeStr, command.FlagAt: aTime.String(),
command.FlagFor: anHourStr, command.FlagFor: anHourStr,
}, },
expEvent: item.Event{ expEvent: item.Event{
ID: "title", ID: "title",
Date: aDate,
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "title", Title: "title",
Start: aDateAndTime, Time: aTime,
Duration: anHour, Duration: anHour,
}, },
}, },
@ -97,51 +79,11 @@ func TestAdd(t *testing.T) {
name: "date and duration", name: "date and duration",
main: []string{"add", "title"}, main: []string{"add", "title"},
flags: map[string]string{ flags: map[string]string{
command.FlagOn: aDateStr, command.FlagOn: aDate.String(),
command.FlagFor: anHourStr, command.FlagFor: anHourStr,
}, },
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()
@ -179,7 +121,7 @@ func TestAdd(t *testing.T) {
t.Errorf("exp string not te be empty") t.Errorf("exp string not te be empty")
} }
tc.expEvent.ID = actEvents[0].ID tc.expEvent.ID = actEvents[0].ID
if diff := cmp.Diff(tc.expEvent, actEvents[0]); diff != "" { if diff := item.EventDiff(tc.expEvent, actEvents[0]); diff != "" {
t.Errorf("(exp -, got +)\n%s", diff) t.Errorf("(exp -, got +)\n%s", diff)
} }

View File

@ -40,14 +40,26 @@ func (as *ArgSet) GetString(name string) string {
return val return val
} }
func (as *ArgSet) GetTime(name string) time.Time { func (as *ArgSet) GetDate(name string) item.Date {
flag, ok := as.Flags[name] flag, ok := as.Flags[name]
if !ok { if !ok {
return time.Time{} return item.Date{}
} }
val, ok := flag.Get().(time.Time) val, ok := flag.Get().(item.Date)
if !ok { if !ok {
return time.Time{} return item.Date{}
}
return val
}
func (as *ArgSet) GetTime(name string) item.Time {
flag, ok := as.Flags[name]
if !ok {
return item.Time{}
}
val, ok := flag.Get().(item.Time)
if !ok {
return item.Time{}
} }
return val return val
} }
@ -64,14 +76,26 @@ func (as *ArgSet) GetDuration(name string) time.Duration {
return val return val
} }
func (as *ArgSet) GetRecurPeriod(name string) item.RecurPeriod { func (as *ArgSet) GetRecurrer(name string) item.Recurrer {
flag, ok := as.Flags[name] flag, ok := as.Flags[name]
if !ok { if !ok {
return item.RecurPeriod("") return nil
} }
val, ok := flag.Get().(item.RecurPeriod) val, ok := flag.Get().(item.Recurrer)
if !ok { if !ok {
return item.RecurPeriod("") return nil
}
return val
}
func (as *ArgSet) GetInt(name string) int {
flag, ok := as.Flags[name]
if !ok {
return 0
}
val, ok := flag.Get().(int)
if !ok {
return 0
} }
return val return val
} }

View File

@ -56,11 +56,11 @@ func TestArgSet(t *testing.T) {
{ {
name: "recur period flag success", name: "recur period flag success",
flags: map[string]command.Flag{ flags: map[string]command.Flag{
"period": &command.FlagPeriod{Name: "period"}, "recur": &command.FlagRecurrer{Name: "recur"},
}, },
flagName: "period", flagName: "recur",
setValue: "month", setValue: "2024-12-23, daily",
exp: item.PeriodMonth, exp: item.NewRecurrer("2024-12-23, daily"),
}, },
{ {
name: "unknown flag error", name: "unknown flag error",
@ -95,26 +95,9 @@ func TestArgSet(t *testing.T) {
return return
} }
// Verify IsSet() returns true after setting
if !as.IsSet(tt.flagName) { if !as.IsSet(tt.flagName) {
t.Errorf("ArgSet.IsSet() = false, want true for flag %s", tt.flagName) t.Errorf("ArgSet.IsSet() = false, want true for flag %s", tt.flagName)
} }
// Verify the value was set correctly based on flag type
switch v := tt.exp.(type) {
case string:
if got := as.GetString(tt.flagName); got != v {
t.Errorf("ArgSet.GetString() = %v, want %v", got, v)
}
case time.Time:
if got := as.GetTime(tt.flagName); !got.Equal(v) {
t.Errorf("ArgSet.GetTime() = %v, want %v", got, v)
}
case time.Duration:
if got := as.GetDuration(tt.flagName); got != v {
t.Errorf("ArgSet.GetDuration() = %v, want %v", got, v)
}
}
}) })
} }
} }

View File

@ -11,8 +11,7 @@ const (
FlagOn = "on" FlagOn = "on"
FlagAt = "at" FlagAt = "at"
FlagFor = "for" FlagFor = "for"
FlagRecStart = "rec-start" FlagRec = "rec"
FlagRecPeriod = "rec-period"
) )
type Command interface { type Command interface {

View File

@ -1,109 +1,68 @@
package command_test package command_test
// func TestArgSet(t *testing.T) { import (
// t.Parallel() "testing"
// as := command.ArgSet{ "github.com/google/go-cmp/cmp"
// Main: "main", "go-mod.ewintr.nl/planner/plan/command"
// Flags: map[string]string{ )
// "name 1": "value 1",
// "name 2": "value 2",
// "name 3": "value 3",
// },
// }
// t.Run("hasflag", func(t *testing.T) { func TestParseArgs(t *testing.T) {
// t.Run("true", func(t *testing.T) { t.Parallel()
// if has := as.HasFlag("name 1"); !has {
// t.Errorf("exp true, got %v", has)
// }
// })
// t.Run("false", func(t *testing.T) {
// if has := as.HasFlag("unknown"); has {
// t.Errorf("exp false, got %v", has)
// }
// })
// })
// t.Run("flag", func(t *testing.T) { for _, tc := range []struct {
// t.Run("known", func(t *testing.T) { name string
// if val := as.Flag("name 1"); val != "value 1" { args []string
// t.Errorf("exp value 1, got %v", val) expMain []string
// } expFlags map[string]string
// }) expErr bool
// t.Run("unknown", func(t *testing.T) { }{
// if val := as.Flag("unknown"); val != "" { {
// t.Errorf(`exp "", got %v`, val) name: "empty",
// } expMain: []string{},
// }) expFlags: map[string]string{},
// }) },
{
// t.Run("setflag", func(t *testing.T) { name: "just main",
// exp := "new value" args: []string{"one", "two three", "four"},
// as.SetFlag("new name", exp) expMain: []string{"one", "two three", "four"},
// if act := as.Flag("new name"); exp != act { expFlags: map[string]string{},
// t.Errorf("exp %v, got %v", exp, act) },
// } {
// }) name: "with flags",
// } args: []string{"-flag1", "value1", "one", "two", "-flag2", "value2", "-flag3", "value3"},
expMain: []string{"one", "two"},
// func TestParseArgs(t *testing.T) { expFlags: map[string]string{
// t.Parallel() "flag1": "value1",
"flag2": "value2",
// for _, tc := range []struct { "flag3": "value3",
// name string },
// args []string },
// expAS *command.ArgSet {
// expErr bool name: "flag without value",
// }{ args: []string{"one", "two", "-flag1"},
// { expErr: true,
// name: "empty", },
// expAS: &command.ArgSet{ {
// Flags: map[string]string{}, name: "split main",
// }, args: []string{"one", "-flag1", "value1", "two"},
// }, expErr: true,
// { },
// name: "just main", } {
// args: []string{"one", "two three", "four"}, t.Run(tc.name, func(t *testing.T) {
// expAS: &command.ArgSet{ actMain, actFlags, actErr := command.ParseFlags(tc.args)
// Main: "one two three four", if tc.expErr != (actErr != nil) {
// Flags: map[string]string{}, t.Errorf("exp %v, got %v", tc.expErr, actErr)
// }, }
// }, if tc.expErr {
// { return
// name: "with flags", }
// args: []string{"-flag1", "value1", "one", "two", "-flag2", "value2", "-flag3", "value3"}, if diff := cmp.Diff(tc.expMain, actMain); diff != "" {
// expAS: &command.ArgSet{ t.Errorf("(exp +, got -)\n%s", diff)
// Main: "one two", }
// Flags: map[string]string{ if diff := cmp.Diff(tc.expFlags, actFlags); diff != "" {
// "flag1": "value1", t.Errorf("(exp +, got -)\n%s", diff)
// "flag2": "value2", }
// "flag3": "value3", })
// }, }
// }, }
// },
// {
// name: "flag without value",
// args: []string{"one", "two", "-flag1"},
// expErr: true,
// },
// {
// name: "split main",
// args: []string{"one", "-flag1", "value1", "two"},
// expErr: true,
// },
// } {
// t.Run(tc.name, func(t *testing.T) {
// actAS, actErr := command.ParseArgs(tc.args)
// if tc.expErr != (actErr != nil) {
// t.Errorf("exp %v, got %v", tc.expErr, actErr)
// }
// if tc.expErr {
// return
// }
// if diff := cmp.Diff(tc.expAS, actAS); diff != "" {
// t.Errorf("(exp +, got -)\n%s", diff)
// }
// })
// }
// }

View File

@ -3,7 +3,6 @@ package command_test
import ( import (
"errors" "errors"
"testing" "testing"
"time"
"go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/command"
@ -16,9 +15,9 @@ func TestDelete(t *testing.T) {
e := item.Event{ e := item.Event{
ID: "id", ID: "id",
Date: item.NewDate(2024, 10, 7),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "name", Title: "name",
Start: time.Date(2024, 10, 7, 9, 30, 0, 0, time.UTC),
}, },
} }

View File

@ -3,7 +3,7 @@ package command
import ( import (
"errors" "errors"
"fmt" "fmt"
"slices" "strconv"
"time" "time"
"go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/item"
@ -45,35 +45,35 @@ func (fs *FlagString) Get() any {
type FlagDate struct { type FlagDate struct {
Name string Name string
Value time.Time Value item.Date
} }
func (ft *FlagDate) Set(val string) error { func (fd *FlagDate) Set(val string) error {
d, err := time.Parse(DateFormat, val) d := item.NewDateFromString(val)
if err != nil { if d.IsZero() {
return fmt.Errorf("could not parse date: %v", d) return fmt.Errorf("could not parse date: %v", d)
} }
ft.Value = d fd.Value = d
return nil return nil
} }
func (ft *FlagDate) IsSet() bool { func (fd *FlagDate) IsSet() bool {
return !ft.Value.IsZero() return !fd.Value.IsZero()
} }
func (fs *FlagDate) Get() any { func (fd *FlagDate) Get() any {
return fs.Value return fd.Value
} }
type FlagTime struct { type FlagTime struct {
Name string Name string
Value time.Time Value item.Time
} }
func (ft *FlagTime) Set(val string) error { func (ft *FlagTime) Set(val string) error {
d, err := time.Parse(TimeFormat, val) d := item.NewTimeFromString(val)
if err != nil { if d.IsZero() {
return fmt.Errorf("could not parse date: %v", d) return fmt.Errorf("could not parse date: %v", d)
} }
ft.Value = d ft.Value = d
@ -111,23 +111,46 @@ func (fs *FlagDuration) Get() any {
return fs.Value return fs.Value
} }
type FlagPeriod struct { type FlagRecurrer struct {
Name string Name string
Value item.RecurPeriod Value item.Recurrer
} }
func (fp *FlagPeriod) Set(val string) error { func (fr *FlagRecurrer) Set(val string) error {
if !slices.Contains(item.ValidPeriods, item.RecurPeriod(val)) { fr.Value = item.NewRecurrer(val)
return fmt.Errorf("not a valid period: %v", val) if fr.Value == nil {
return fmt.Errorf("not a valid recurrer: %v", val)
} }
fp.Value = item.RecurPeriod(val)
return nil return nil
} }
func (fp *FlagPeriod) IsSet() bool { func (fr *FlagRecurrer) IsSet() bool {
return fp.Value != "" return fr.Value != nil
} }
func (fp *FlagPeriod) Get() any { func (fr *FlagRecurrer) Get() any {
return fp.Value return fr.Value
}
type FlagInt struct {
Name string
Value int
}
func (fi *FlagInt) Set(val string) error {
i, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("not a valid integer: %v", val)
}
fi.Value = i
return nil
}
func (fi *FlagInt) IsSet() bool {
return fi.Value != 0
}
func (fi *FlagInt) Get() any {
return fi.Value
} }

View File

@ -37,14 +37,13 @@ func TestFlagString(t *testing.T) {
func TestFlagDate(t *testing.T) { func TestFlagDate(t *testing.T) {
t.Parallel() t.Parallel()
valid := time.Date(2024, 11, 20, 0, 0, 0, 0, time.UTC) valid := item.NewDate(2024, 11, 20)
validStr := "2024-11-20"
f := command.FlagDate{} f := command.FlagDate{}
if f.IsSet() { if f.IsSet() {
t.Errorf("exp false, got true") t.Errorf("exp false, got true")
} }
if err := f.Set(validStr); err != nil { if err := f.Set(valid.String()); err != nil {
t.Errorf("exp nil, got %v", err) t.Errorf("exp nil, got %v", err)
} }
@ -52,26 +51,25 @@ func TestFlagDate(t *testing.T) {
t.Errorf("exp true, got false") t.Errorf("exp true, got false")
} }
act, ok := f.Get().(time.Time) act, ok := f.Get().(item.Date)
if !ok { if !ok {
t.Errorf("exp true, got false") t.Errorf("exp true, got false")
} }
if act != valid { if act.String() != valid.String() {
t.Errorf("exp %v, got %v", valid, act) t.Errorf("exp %v, got %v", valid.String(), act.String())
} }
} }
func TestFlagTime(t *testing.T) { func TestFlagTime(t *testing.T) {
t.Parallel() t.Parallel()
valid := time.Date(0, 1, 1, 12, 30, 0, 0, time.UTC) valid := item.NewTime(12, 30)
validStr := "12:30"
f := command.FlagTime{} f := command.FlagTime{}
if f.IsSet() { if f.IsSet() {
t.Errorf("exp false, got true") t.Errorf("exp false, got true")
} }
if err := f.Set(validStr); err != nil { if err := f.Set(valid.String()); err != nil {
t.Errorf("exp nil, got %v", err) t.Errorf("exp nil, got %v", err)
} }
@ -79,12 +77,12 @@ func TestFlagTime(t *testing.T) {
t.Errorf("exp true, got false") t.Errorf("exp true, got false")
} }
act, ok := f.Get().(time.Time) act, ok := f.Get().(item.Time)
if !ok { if !ok {
t.Errorf("exp true, got false") t.Errorf("exp true, got false")
} }
if act != valid { if act.String() != valid.String() {
t.Errorf("exp %v, got %v", valid, act) t.Errorf("exp %v, got %v", valid.String(), act.String())
} }
} }
@ -115,12 +113,12 @@ func TestFlagDurationTime(t *testing.T) {
} }
} }
func TestFlagPeriod(t *testing.T) { func TestFlagRecurrer(t *testing.T) {
t.Parallel() t.Parallel()
valid := item.PeriodMonth validStr := "2024-12-23, daily"
validStr := "month" valid := item.NewRecurrer(validStr)
f := command.FlagPeriod{} f := command.FlagRecurrer{}
if f.IsSet() { if f.IsSet() {
t.Errorf("exp false, got true") t.Errorf("exp false, got true")
} }
@ -133,7 +131,7 @@ func TestFlagPeriod(t *testing.T) {
t.Errorf("exp true, got false") t.Errorf("exp true, got false")
} }
act, ok := f.Get().(item.RecurPeriod) act, ok := f.Get().(item.Recurrer)
if !ok { if !ok {
t.Errorf("exp true, got false") t.Errorf("exp true, got false")
} }

View File

@ -2,7 +2,6 @@ package command
import ( import (
"fmt" "fmt"
"time"
"go-mod.ewintr.nl/planner/plan/storage" "go-mod.ewintr.nl/planner/plan/storage"
) )
@ -41,7 +40,7 @@ func (list *List) do() error {
if !ok { if !ok {
return fmt.Errorf("could not find local id for %s", e.ID) return fmt.Errorf("could not find local id for %s", e.ID)
} }
fmt.Printf("%s\t%d\t%s\t%s\t%s\n", e.ID, lid, e.Title, e.Start.Format(time.DateTime), e.Duration.String()) fmt.Printf("%s\t%d\t%s\t%s\t%s\n", e.ID, lid, e.Title, e.Date.String(), e.Duration.String())
} }
return nil return nil

View File

@ -2,7 +2,6 @@ package command_test
import ( import (
"testing" "testing"
"time"
"go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/command"
@ -16,9 +15,9 @@ func TestList(t *testing.T) {
localRepo := memory.NewLocalID() localRepo := memory.NewLocalID()
e := item.Event{ e := item.Event{
ID: "id", ID: "id",
Date: item.NewDate(2024, 10, 7),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "name", Title: "name",
Start: time.Date(2024, 10, 7, 9, 30, 0, 0, time.UTC),
}, },
} }
if err := eventRepo.Store(e); err != nil { if err := eventRepo.Store(e); err != nil {

View File

@ -138,9 +138,9 @@ func TestSyncReceive(t *testing.T) {
}}, }},
expEvent: []item.Event{{ expEvent: []item.Event{{
ID: "a", ID: "a",
Date: item.NewDate(2024, 10, 23),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "title", Title: "title",
Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC),
Duration: oneHour, Duration: oneHour,
}, },
}}, }},
@ -152,9 +152,9 @@ func TestSyncReceive(t *testing.T) {
name: "update existing", name: "update existing",
present: []item.Event{{ present: []item.Event{{
ID: "a", ID: "a",
Date: item.NewDate(2024, 10, 23),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "title", Title: "title",
Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC),
Duration: oneHour, Duration: oneHour,
}, },
}}, }},
@ -169,9 +169,9 @@ func TestSyncReceive(t *testing.T) {
}}, }},
expEvent: []item.Event{{ expEvent: []item.Event{{
ID: "a", ID: "a",
Date: item.NewDate(2024, 10, 23),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "new title", Title: "new title",
Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC),
Duration: oneHour, Duration: oneHour,
}, },
}}, }},
@ -210,7 +210,7 @@ func TestSyncReceive(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("exp nil, got %v", err) t.Errorf("exp nil, got %v", err)
} }
if diff := cmp.Diff(tc.expEvent, actEvents); diff != "" { if diff := item.EventDiffs(tc.expEvent, actEvents); diff != "" {
t.Errorf("(exp +, got -)\n%s", diff) t.Errorf("(exp +, got -)\n%s", diff)
} }
actLocalIDs, err := localIDRepo.FindAll() actLocalIDs, err := localIDRepo.FindAll()

View File

@ -4,9 +4,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/storage" "go-mod.ewintr.nl/planner/plan/storage"
) )
@ -29,8 +27,7 @@ func NewUpdate(localIDRepo storage.LocalID, eventRepo storage.Event, syncRepo st
FlagOn: &FlagDate{}, FlagOn: &FlagDate{},
FlagAt: &FlagTime{}, FlagAt: &FlagTime{},
FlagFor: &FlagDuration{}, FlagFor: &FlagDuration{},
FlagRecStart: &FlagDate{}, FlagRec: &FlagRecurrer{},
FlagRecPeriod: &FlagPeriod{},
}, },
}, },
} }
@ -87,35 +84,17 @@ func (update *Update) do() error {
if as.Main != "" { if as.Main != "" {
e.Title = as.Main e.Title = as.Main
} }
if as.IsSet(FlagOn) || as.IsSet(FlagAt) {
on := time.Date(e.Start.Year(), e.Start.Month(), e.Start.Day(), 0, 0, 0, 0, time.UTC)
atH := time.Duration(e.Start.Hour()) * time.Hour
atM := time.Duration(e.Start.Minute()) * time.Minute
if as.IsSet(FlagOn) { if as.IsSet(FlagOn) {
on = as.GetTime(FlagOn) e.Date = as.GetDate(FlagOn)
} }
if as.IsSet(FlagAt) { if as.IsSet(FlagAt) {
at := as.GetTime(FlagAt) e.Time = as.GetTime(FlagAt)
atH = time.Duration(at.Hour()) * time.Hour
atM = time.Duration(at.Minute()) * time.Minute
} }
e.Start = on.Add(atH).Add(atM)
}
if as.IsSet(FlagFor) { if as.IsSet(FlagFor) {
e.Duration = as.GetDuration(FlagFor) e.Duration = as.GetDuration(FlagFor)
} }
if as.IsSet(FlagRecStart) || as.IsSet(FlagRecPeriod) { if as.IsSet(FlagRec) {
if e.Recurrer == nil { e.Recurrer = as.GetRecurrer(FlagRec)
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() {

View File

@ -5,7 +5,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/google/go-cmp/cmp"
"go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/command" "go-mod.ewintr.nl/planner/plan/command"
"go-mod.ewintr.nl/planner/plan/storage/memory" "go-mod.ewintr.nl/planner/plan/storage/memory"
@ -21,7 +20,8 @@ func TestUpdateExecute(t *testing.T) {
t.Errorf("exp nil, got %v", err) t.Errorf("exp nil, got %v", err)
} }
title := "title" title := "title"
start := time.Date(2024, 10, 6, 10, 0, 0, 0, time.UTC) aDate := item.NewDate(2024, 10, 6)
aTime := item.NewTime(10, 0)
twoHour, err := time.ParseDuration("2h") twoHour, err := time.ParseDuration("2h")
if err != nil { if err != nil {
t.Errorf("exp nil, got %v", err) t.Errorf("exp nil, got %v", err)
@ -50,9 +50,10 @@ func TestUpdateExecute(t *testing.T) {
main: []string{"update", fmt.Sprintf("%d", lid), "updated"}, main: []string{"update", fmt.Sprintf("%d", lid), "updated"},
expEvent: item.Event{ expEvent: item.Event{
ID: eid, ID: eid,
Date: item.NewDate(2024, 10, 6),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: "updated", Title: "updated",
Start: start, Time: aTime,
Duration: oneHour, Duration: oneHour,
}, },
}, },
@ -75,9 +76,10 @@ func TestUpdateExecute(t *testing.T) {
}, },
expEvent: item.Event{ expEvent: item.Event{
ID: eid, ID: eid,
Date: item.NewDate(2024, 10, 2),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: title, Title: title,
Start: time.Date(2024, 10, 2, 10, 0, 0, 0, time.UTC), Time: aTime,
Duration: oneHour, Duration: oneHour,
}, },
}, },
@ -100,9 +102,10 @@ func TestUpdateExecute(t *testing.T) {
}, },
expEvent: item.Event{ expEvent: item.Event{
ID: eid, ID: eid,
Date: item.NewDate(2024, 10, 6),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: title, Title: title,
Start: time.Date(2024, 10, 6, 11, 0, 0, 0, time.UTC), Time: item.NewTime(11, 0),
Duration: oneHour, Duration: oneHour,
}, },
}, },
@ -117,9 +120,10 @@ func TestUpdateExecute(t *testing.T) {
}, },
expEvent: item.Event{ expEvent: item.Event{
ID: eid, ID: eid,
Date: item.NewDate(2024, 10, 2),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: title, Title: title,
Start: time.Date(2024, 10, 2, 11, 0, 0, 0, time.UTC), Time: item.NewTime(11, 0),
Duration: oneHour, Duration: oneHour,
}, },
}, },
@ -142,61 +146,35 @@ func TestUpdateExecute(t *testing.T) {
}, },
expEvent: item.Event{ expEvent: item.Event{
ID: eid, ID: eid,
Date: item.NewDate(2024, 10, 6),
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: title, Title: title,
Start: time.Date(2024, 10, 6, 10, 0, 0, 0, time.UTC), Time: aTime,
Duration: twoHour, Duration: twoHour,
}, },
}, },
}, },
{ {
name: "invalid rec start", name: "invalid rec",
main: []string{"update", fmt.Sprintf("%d", lid)}, main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{ flags: map[string]string{
"rec-start": "invalud", "rec": "invalud",
}, },
expErr: true, expErr: true,
}, },
{ {
name: "valid rec start", name: "valid rec",
main: []string{"update", fmt.Sprintf("%d", lid)}, main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{ flags: map[string]string{
"rec-start": "2024-12-08", "rec": "2024-12-08, daily",
}, },
expEvent: item.Event{ expEvent: item.Event{
ID: eid, ID: eid,
Recurrer: &item.Recur{ Date: aDate,
Start: time.Date(2024, 12, 8, 0, 0, 0, 0, time.UTC), Recurrer: item.NewRecurrer("2024-12-08, daily"),
},
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: title, Title: title,
Start: start, Time: aTime,
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, Duration: oneHour,
}, },
}, },
@ -208,9 +186,10 @@ func TestUpdateExecute(t *testing.T) {
syncRepo := memory.NewSync() syncRepo := memory.NewSync()
if err := eventRepo.Store(item.Event{ if err := eventRepo.Store(item.Event{
ID: eid, ID: eid,
Date: aDate,
EventBody: item.EventBody{ EventBody: item.EventBody{
Title: title, Title: title,
Start: start, Time: aTime,
Duration: oneHour, Duration: oneHour,
}, },
}); err != nil { }); err != nil {
@ -233,7 +212,7 @@ func TestUpdateExecute(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("exp nil, got %v", err) t.Errorf("exp nil, got %v", err)
} }
if diff := cmp.Diff(tc.expEvent, actEvent); diff != "" { if diff := item.EventDiff(tc.expEvent, actEvent); diff != "" {
t.Errorf("(exp -, got +)\n%s", diff) t.Errorf("(exp -, got +)\n%s", diff)
} }
updated, err := syncRepo.FindAll() updated, err := syncRepo.FindAll()

View File

@ -12,12 +12,16 @@ import (
) )
func main() { func main() {
confPath, err := os.UserConfigDir() confPath := os.Getenv("PLAN_CONFIG_PATH")
if confPath == "" {
userConfigDir, err := os.UserConfigDir()
if err != nil { if err != nil {
fmt.Printf("could not get config path: %s\n", err) fmt.Printf("could not get config path: %s\n", err)
os.Exit(1) os.Exit(1)
} }
conf, err := LoadConfig(filepath.Join(confPath, "planner", "plan", "config.yaml")) confPath = filepath.Join(userConfigDir, "planner", "plan", "config.yaml")
}
conf, err := LoadConfig(confPath)
if err != nil { if err != nil {
fmt.Printf("could not open config file: %s\n", err) fmt.Printf("could not open config file: %s\n", err)
os.Exit(1) os.Exit(1)

View File

@ -3,7 +3,6 @@ package memory
import ( import (
"testing" "testing"
"github.com/google/go-cmp/cmp"
"go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/item"
) )
@ -50,7 +49,7 @@ func TestEvent(t *testing.T) {
if actErr != nil { if actErr != nil {
t.Errorf("exp nil, got %v", actErr) t.Errorf("exp nil, got %v", actErr)
} }
if diff := cmp.Diff([]item.Event{e1, e2}, actEvents); diff != "" { if diff := item.EventDiffs([]item.Event{e1, e2}, actEvents); diff != "" {
t.Errorf("(exp -, got +)\n%s", diff) t.Errorf("(exp -, got +)\n%s", diff)
} }
} }

View File

@ -14,18 +14,25 @@ type SqliteEvent struct {
} }
func (s *SqliteEvent) Store(event item.Event) error { func (s *SqliteEvent) Store(event item.Event) error {
var recurStr string
if event.Recurrer != nil {
recurStr = event.Recurrer.String()
}
if _, err := s.db.Exec(` if _, err := s.db.Exec(`
INSERT INTO events INSERT INTO events
(id, title, start, duration) (id, title, date, time, duration, recurrer)
VALUES VALUES
(?, ?, ?, ?) (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE ON CONFLICT(id) DO UPDATE
SET SET
title=?, title=?,
start=?, date=?,
duration=?`, time=?,
event.ID, event.Title, event.Start.Format(timestampFormat), event.Duration.String(), duration=?,
event.Title, event.Start.Format(timestampFormat), event.Duration.String()); err != nil { recurrer=?
`,
event.ID, event.Title, event.Date.String(), event.Time.String(), event.Duration.String(), recurStr,
event.Title, event.Date.String(), event.Time.String(), event.Duration.String(), recurStr); err != nil {
return fmt.Errorf("%w: %v", ErrSqliteFailure, err) return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
} }
return nil return nil
@ -33,29 +40,32 @@ duration=?`,
func (s *SqliteEvent) Find(id string) (item.Event, error) { func (s *SqliteEvent) Find(id string) (item.Event, error) {
var event item.Event var event item.Event
var durStr string var dateStr, timeStr, recurStr, durStr string
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT id, title, start, duration SELECT id, title, date, time, duration, recurrer
FROM events FROM events
WHERE id = ?`, id).Scan(&event.ID, &event.Title, &event.Start, &durStr) WHERE id = ?`, id).Scan(&event.ID, &event.Title, &dateStr, &timeStr, &durStr, &recurStr)
switch { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return item.Event{}, fmt.Errorf("event not found: %w", err) return item.Event{}, fmt.Errorf("event not found: %w", err)
case err != nil: case err != nil:
return item.Event{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) return item.Event{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
} }
event.Date = item.NewDateFromString(dateStr)
event.Time = item.NewTimeFromString(timeStr)
dur, err := time.ParseDuration(durStr) dur, err := time.ParseDuration(durStr)
if err != nil { if err != nil {
return item.Event{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) return item.Event{}, fmt.Errorf("could not unmarshal recurrer: %v", err)
} }
event.Duration = dur event.Duration = dur
event.Recurrer = item.NewRecurrer(recurStr)
return event, nil return event, nil
} }
func (s *SqliteEvent) FindAll() ([]item.Event, error) { func (s *SqliteEvent) FindAll() ([]item.Event, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id, title, start, duration SELECT id, title, date, time, duration, recurrer
FROM events`) FROM events`)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
@ -64,15 +74,19 @@ FROM events`)
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var event item.Event var event item.Event
var durStr string var dateStr, timeStr, recurStr, durStr string
if err := rows.Scan(&event.ID, &event.Title, &event.Start, &durStr); err != nil { if err := rows.Scan(&event.ID, &event.Title, &dateStr, &timeStr, &durStr, &recurStr); err != nil {
return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
} }
dur, err := time.ParseDuration(durStr) dur, err := time.ParseDuration(durStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
} }
event.Date = item.NewDateFromString(dateStr)
event.Time = item.NewTimeFromString(timeStr)
event.Duration = dur event.Duration = dur
event.Recurrer = item.NewRecurrer(recurStr)
result = append(result, event) result = append(result, event)
} }

View File

@ -25,6 +25,23 @@ var migrations = []string{
deleted BOOLEAN NOT NULL, deleted BOOLEAN NOT NULL,
body TEXT NOT NULL body TEXT NOT NULL
)`, )`,
`ALTER TABLE events ADD COLUMN recur_period TEXT`,
`ALTER TABLE events ADD COLUMN recur_count INTEGER`,
`ALTER TABLE events ADD COLUMN recur_start TIMESTAMP`,
`ALTER TABLE events ADD COLUMN recur_next TIMESTAMP`,
`ALTER TABLE events DROP COLUMN recur_period`,
`ALTER TABLE events DROP COLUMN recur_count`,
`ALTER TABLE events DROP COLUMN recur_start`,
`ALTER TABLE events DROP COLUMN recur_next`,
`ALTER TABLE events ADD COLUMN recur TEXT`,
`ALTER TABLE items ADD COLUMN recurrer TEXT`,
`ALTER TABLE events DROP COLUMN recur`,
`ALTER TABLE events ADD COLUMN recurrer TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE events ADD COLUMN recur_next TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE events DROP COLUMN start`,
`ALTER TABLE events ADD COLUMN date TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE events ADD COLUMN time TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE items ADD COLUMN recur_next TEXT NOT NULL DEFAULT ''`,
} }
var ( var (

View File

@ -17,7 +17,7 @@ func NewSqliteSync(db *sql.DB) *SqliteSync {
} }
func (s *SqliteSync) FindAll() ([]item.Item, error) { func (s *SqliteSync) FindAll() ([]item.Item, error) {
rows, err := s.db.Query("SELECT id, kind, updated, deleted, body FROM items") rows, err := s.db.Query("SELECT id, kind, updated, deleted, recurrer, recur_next, body FROM items")
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: failed to query items: %v", ErrSqliteFailure, err) return nil, fmt.Errorf("%w: failed to query items: %v", ErrSqliteFailure, err)
} }
@ -26,37 +26,46 @@ func (s *SqliteSync) FindAll() ([]item.Item, error) {
var items []item.Item var items []item.Item
for rows.Next() { for rows.Next() {
var i item.Item var i item.Item
var updatedStr string var updatedStr, recurStr, recurNextStr string
err := rows.Scan(&i.ID, &i.Kind, &updatedStr, &i.Deleted, &i.Body) err := rows.Scan(&i.ID, &i.Kind, &updatedStr, &i.Deleted, &recurStr, &recurNextStr, &i.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: failed to scan item: %v", ErrSqliteFailure, err) return nil, fmt.Errorf("%w: failed to scan item: %v", ErrSqliteFailure, err)
} }
i.Updated, err = time.Parse(time.RFC3339, updatedStr) i.Updated, err = time.Parse(time.RFC3339, updatedStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: failed to parse updated time: %v", ErrSqliteFailure, err) return nil, fmt.Errorf("failed to parse updated time: %v", err)
} }
i.Recurrer = item.NewRecurrer(recurStr)
i.RecurNext = item.NewDateFromString(recurNextStr)
items = append(items, i) items = append(items, i)
} }
if err = rows.Err(); err != nil { if err = rows.Err(); err != nil {
return nil, fmt.Errorf("%w: error iterating over rows: %v", ErrSqliteFailure, err) return nil, fmt.Errorf("error iterating over rows: %v", err)
} }
return items, nil return items, nil
} }
func (s *SqliteSync) Store(i item.Item) error { func (s *SqliteSync) Store(i item.Item) error {
// Ensure we have a valid time
if i.Updated.IsZero() { if i.Updated.IsZero() {
i.Updated = time.Now() i.Updated = time.Now()
} }
var recurStr string
if i.Recurrer != nil {
recurStr = i.Recurrer.String()
}
_, err := s.db.Exec( _, err := s.db.Exec(
"INSERT OR REPLACE INTO items (id, kind, updated, deleted, body) VALUES (?, ?, ?, ?, ?)", `INSERT OR REPLACE INTO items (id, kind, updated, deleted, recurrer, recur_next, body)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
i.ID, i.ID,
i.Kind, i.Kind,
i.Updated.UTC().Format(time.RFC3339), i.Updated.UTC().Format(time.RFC3339),
i.Deleted, i.Deleted,
recurStr,
i.RecurNext.String(),
sql.NullString{String: i.Body, Valid: i.Body != ""}, // This allows empty string but not NULL sql.NullString{String: i.Body, Valid: i.Body != ""}, // This allows empty string but not NULL
) )
if err != nil { if err != nil {

3
plan/test-conf.yaml Normal file
View File

@ -0,0 +1,3 @@
db_path: ./plan.db
sync_url: http://localhost:8092
api_key: testKey

View File

@ -45,7 +45,8 @@ func (c *HTTP) Update(items []item.Item) error {
return fmt.Errorf("could not make request: %v", err) return fmt.Errorf("could not make request: %v", err)
} }
if res.StatusCode != http.StatusNoContent { if res.StatusCode != http.StatusNoContent {
return fmt.Errorf("server returned status %d", res.StatusCode) body, _ := io.ReadAll(res.Body)
return fmt.Errorf("server returned status %d, body: %s", res.StatusCode, body)
} }
return nil return nil

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"fmt"
"slices" "slices"
"sync" "sync"
"time" "time"
@ -47,33 +46,15 @@ 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) { func (m *Memory) ShouldRecur(date item.Date) ([]item.Item, error) {
res := make([]item.Item, 0) res := make([]item.Item, 0)
for _, i := range m.items { for _, i := range m.items {
if i.Recurrer == nil { if i.Recurrer == nil {
continue continue
} }
if i.RecurNext.Before(date) { if date.Equal(i.RecurNext) || date.After(i.RecurNext) {
res = append(res, i) res = append(res, i)
} }
} }
return res, nil 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

@ -113,19 +113,14 @@ func TestMemoryRecur(t *testing.T) {
mem := NewMemory() mem := NewMemory()
now := time.Now() now := time.Now()
earlier := now.Add(-5 * time.Minute) earlier := now.Add(-5 * time.Minute)
today := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) today := item.NewDate(2024, 12, 1)
yesterday := time.Date(2024, 11, 30, 0, 0, 0, 0, time.UTC) yesterday := item.NewDate(2024, 11, 30)
tomorrow := time.Date(2024, 12, 2, 0, 0, 0, 0, time.UTC)
t.Log("start") t.Log("start")
i1 := item.Item{ i1 := item.Item{
ID: "a", ID: "a",
Updated: earlier, Updated: earlier,
Recurrer: &item.Recur{ Recurrer: item.NewRecurrer("2024-11-30, daily"),
Start: yesterday,
Period: item.PeriodDay,
Count: 1,
},
RecurNext: yesterday, RecurNext: yesterday,
} }
i2 := item.Item{ i2 := item.Item{
@ -140,7 +135,7 @@ func TestMemoryRecur(t *testing.T) {
} }
t.Log("get recurrers") t.Log("get recurrers")
rs, err := mem.RecursBefore(today) rs, err := mem.ShouldRecur(today)
if err != nil { if err != nil {
t.Errorf("exp nil, gt %v", err) t.Errorf("exp nil, gt %v", err)
} }
@ -148,20 +143,4 @@ func TestMemoryRecur(t *testing.T) {
t.Errorf("(exp +, got -)\n%s", 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

@ -20,6 +20,13 @@ var migrations = []string{
`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`, `ALTER TABLE items ADD COLUMN recurrer JSONB, ADD COLUMN recur_next TIMESTAMP`,
`ALTER TABLE items ALTER COLUMN recurrer TYPE TEXT USING recurrer::TEXT,
ALTER COLUMN recurrer SET NOT NULL,
ALTER COLUMN recurrer SET DEFAULT ''`,
`ALTER TABLE items ALTER COLUMN recur_next TYPE TEXT USING TO_CHAR(recur_next, 'YYYY-MM-DD'),
ALTER COLUMN recur_next SET NOT NULL,
ALTER COLUMN recur_next SET DEFAULT ''`,
`ALTER TABLE items ADD COLUMN date TEXT NOT NULL DEFAULT ''`,
} }
var ( var (
@ -35,13 +42,10 @@ type Postgres struct {
func NewPostgres(host, port, dbname, user, password string) (*Postgres, error) { func NewPostgres(host, port, dbname, user, password string) (*Postgres, error) {
connStr := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=disable", host, port, dbname, user, password) connStr := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=disable", host, port, dbname, user, password)
db, err := sql.Open("postgres", connStr) db, err := sql.Open("postgres", connStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err) return nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err)
} }
// Test the connection
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err) return nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err)
} }
@ -57,19 +61,26 @@ func NewPostgres(host, port, dbname, user, password string) (*Postgres, error) {
return p, nil return p, nil
} }
func (p *Postgres) Update(item item.Item, ts time.Time) error { func (p *Postgres) Update(i item.Item, ts time.Time) error {
_, err := p.db.Exec(` if i.Recurrer != nil && i.RecurNext.IsZero() {
INSERT INTO items (id, kind, updated, deleted, body, recurrer, recur_next) i.RecurNext = i.Recurrer.First()
VALUES ($1, $2, $3, $4, $5, $6, $7) }
var recurStr string
if i.Recurrer != nil {
recurStr = i.Recurrer.String()
}
if _, err := p.db.Exec(`
INSERT INTO items (id, kind, updated, deleted, date, recurrer, recur_next, body)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE 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, date = EXCLUDED.date,
recurrer = EXCLUDED.recurrer, recurrer = EXCLUDED.recurrer,
recur_next = EXCLUDED.recur_next`, recur_next = EXCLUDED.recur_next,
item.ID, item.Kind, ts, item.Deleted, item.Body, item.Recurrer, item.RecurNext) body = EXCLUDED.body`,
if err != nil { i.ID, i.Kind, ts, i.Deleted, i.Date.String(), recurStr, i.RecurNext.String(), i.Body); err != nil {
return fmt.Errorf("%w: %v", ErrPostgresFailure, err) return fmt.Errorf("%w: %v", ErrPostgresFailure, err)
} }
return nil return nil
@ -77,11 +88,10 @@ func (p *Postgres) Update(item item.Item, ts time.Time) error {
func (p *Postgres) Updated(ks []item.Kind, t time.Time) ([]item.Item, error) { func (p *Postgres) Updated(ks []item.Kind, t time.Time) ([]item.Item, error) {
query := ` query := `
SELECT id, kind, updated, deleted, body, recurrer, recur_next SELECT id, kind, updated, deleted, date, recurrer, recur_next, body
FROM items FROM items
WHERE updated > $1` WHERE updated > $1`
args := []interface{}{t} args := []interface{}{t}
if len(ks) > 0 { if len(ks) > 0 {
placeholder := make([]string, len(ks)) placeholder := make([]string, len(ks))
for i := range ks { for i := range ks {
@ -99,27 +109,29 @@ func (p *Postgres) Updated(ks []item.Kind, t time.Time) ([]item.Item, error) {
result := make([]item.Item, 0) result := make([]item.Item, 0)
for rows.Next() { for rows.Next() {
var item item.Item var i item.Item
var recurNext sql.NullTime var date, recurrer, recurNext string
if err := rows.Scan(&item.ID, &item.Kind, &item.Updated, &item.Deleted, &item.Body, &item.Recurrer, &recurNext); err != nil { if err := rows.Scan(&i.ID, &i.Kind, &i.Updated, &i.Deleted, &date, &recurrer, &recurNext, &i.Body); err != nil {
return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err) return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err)
} }
if recurNext.Valid { i.Date = item.NewDateFromString(date)
item.RecurNext = recurNext.Time i.Recurrer = item.NewRecurrer(recurrer)
} i.RecurNext = item.NewDateFromString(recurNext)
result = append(result, item) result = append(result, i)
} }
return result, nil return result, nil
} }
func (p *Postgres) RecursBefore(date time.Time) ([]item.Item, error) { func (p *Postgres) ShouldRecur(date item.Date) ([]item.Item, error) {
query := ` query := `
SELECT id, kind, updated, deleted, body, recurrer, recur_next SELECT id, kind, updated, deleted, date, recurrer, recur_next, body
FROM items FROM items
WHERE recur_next <= $1 AND recurrer IS NOT NULL` WHERE
NOT deleted
rows, err := p.db.Query(query, date) AND recurrer <> ''
AND recur_next <= $1`
rows, err := p.db.Query(query, date.String())
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err) return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err)
} }
@ -127,54 +139,20 @@ func (p *Postgres) RecursBefore(date time.Time) ([]item.Item, error) {
result := make([]item.Item, 0) result := make([]item.Item, 0)
for rows.Next() { for rows.Next() {
var item item.Item var i item.Item
var recurNext sql.NullTime var date, recurrer, recurNext string
if err := rows.Scan(&item.ID, &item.Kind, &item.Updated, &item.Deleted, &item.Body, &item.Recurrer, &recurNext); err != nil { if err := rows.Scan(&i.ID, &i.Kind, &i.Updated, &i.Deleted, &date, &recurrer, &recurNext, &i.Body); err != nil {
return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err) return nil, fmt.Errorf("%w: %v", ErrPostgresFailure, err)
} }
if recurNext.Valid { i.Date = item.NewDateFromString(date)
item.RecurNext = recurNext.Time i.Recurrer = item.NewRecurrer(recurrer)
} i.RecurNext = item.NewDateFromString(recurNext)
result = append(result, item) result = append(result, i)
} }
return result, nil 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(`

View File

@ -35,34 +35,31 @@ func (r *Recur) Run(interval time.Duration) {
} }
func (r *Recur) Recur() error { func (r *Recur) Recur() error {
items, err := r.repoRecur.RecursBefore(time.Now()) r.logger.Info("start looking for recurring items")
today := item.NewDateFromString(time.Now().Format(item.DateFormat))
items, err := r.repoRecur.ShouldRecur(today)
if err != nil { if err != nil {
return err return err
} }
r.logger.Info("found recurring items", "count", len(items))
for _, i := range items { for _, i := range items {
r.logger.Info("processing recurring item", "id", i.ID)
// spawn instance // spawn instance
ne, err := item.NewEvent(i) newItem := i
if err != nil { newItem.ID = uuid.New().String()
return err newItem.Date = i.RecurNext
} newItem.Recurrer = nil
y, m, d := i.RecurNext.Date() newItem.RecurNext = item.Date{}
ne.ID = uuid.New().String() if err := r.repoSync.Update(newItem, time.Now()); err != nil {
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 return err
} }
// set next // update recurrer
if err := r.repoRecur.RecursNext(i.ID, i.Recurrer.NextAfter(i.RecurNext), time.Now()); err != nil { i.RecurNext = item.FirstRecurAfter(i.Recurrer, i.RecurNext)
if err := r.repoSync.Update(i, time.Now()); err != nil {
return err return err
} }
r.logger.Info("recurring item processed", "id", i.ID, "recurNext", i.RecurNext.String())
} }
r.logger.Info("processed recurring items", "count", len(items)) r.logger.Info("processed recurring items", "count", len(items))

View File

@ -12,23 +12,18 @@ import (
func TestRecur(t *testing.T) { func TestRecur(t *testing.T) {
t.Parallel() t.Parallel()
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) now := time.Now()
today := item.NewDate(2024, 1, 1)
mem := NewMemory() mem := NewMemory()
rec := NewRecur(mem, mem, slog.New(slog.NewTextHandler(io.Discard, nil))) 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{ testItem := item.Item{
ID: "test-1", ID: "test-1",
Kind: item.KindEvent, Kind: item.KindEvent,
Updated: now, Updated: now,
Deleted: false, Deleted: false,
Recurrer: recur, Recurrer: item.NewRecurrer("2024-01-01, daily"),
RecurNext: now, RecurNext: today,
Body: `{"title":"Test Event","start":"2024-01-01T10:00:00Z","duration":"30m"}`, Body: `{"title":"Test Event","start":"2024-01-01T10:00:00Z","duration":"30m"}`,
} }
@ -53,14 +48,14 @@ func TestRecur(t *testing.T) {
} }
// Check that RecurNext was updated // Check that RecurNext was updated
recurItems, err := mem.RecursBefore(now.Add(48 * time.Hour)) recurItems, err := mem.ShouldRecur(today.Add(1))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(recurItems) != 1 { if len(recurItems) != 1 {
t.Errorf("expected 1 recur item, got %d", len(recurItems)) t.Errorf("expected 1 recur item, got %d", len(recurItems))
} }
if !recurItems[0].RecurNext.After(now) { if !recurItems[0].RecurNext.After(today) {
t.Errorf("RecurNext was not updated, still %v", recurItems[0].RecurNext) t.Errorf("RecurNext was not updated, still %v", recurItems[0].RecurNext)
} }
} }

View File

@ -39,7 +39,7 @@ func main() {
"dbUser": *dbUser, "dbUser": *dbUser,
}) })
recurrer := NewRecur(repo, repo, logger) recurrer := NewRecur(repo, repo, logger)
go recurrer.Run(12 * time.Hour) go recurrer.Run(10 * time.Second)
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

@ -18,6 +18,5 @@ type Syncer interface {
} }
type Recurrer interface { type Recurrer interface {
RecursBefore(date time.Time) ([]item.Item, error) ShouldRecur(date item.Date) ([]item.Item, error)
RecursNext(id string, date time.Time, t time.Time) error
} }