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*
plannersync
plan
*.db*

View File

@ -4,5 +4,18 @@ plan-deploy:
sync-run:
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:
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"
"fmt"
"time"
"github.com/google/go-cmp/cmp"
)
type EventBody struct {
Title string `json:"title"`
Start time.Time `json:"start"`
Time Time `json:"time"`
Duration time.Duration `json:"duration"`
}
func (e EventBody) MarshalJSON() ([]byte, error) {
type Alias EventBody
return json.Marshal(&struct {
Start string `json:"start"`
Duration string `json:"duration"`
*Alias
}{
Start: e.Start.UTC().Format(time.RFC3339),
Duration: e.Duration.String(),
Alias: (*Alias)(&e),
})
@ -28,7 +28,6 @@ func (e EventBody) MarshalJSON() ([]byte, error) {
func (e *EventBody) UnmarshalJSON(data []byte) error {
type Alias EventBody
aux := &struct {
Start string `json:"start"`
Duration string `json:"duration"`
*Alias
}{
@ -39,10 +38,6 @@ func (e *EventBody) UnmarshalJSON(data []byte) 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 {
return err
}
@ -51,9 +46,10 @@ func (e *EventBody) UnmarshalJSON(data []byte) error {
}
type Event struct {
ID string `json:"id"`
Recurrer *Recur `json:"recurrer"`
RecurNext time.Time `json:"recurNext"`
ID string `json:"id"`
Date Date `json:"date"`
Recurrer Recurrer `json:"recurrer"`
RecurNext Date `json:"recurNext"`
EventBody
}
@ -68,6 +64,7 @@ func NewEvent(i Item) (Event, error) {
}
e.ID = i.ID
e.Date = i.Date
e.Recurrer = i.Recurrer
e.RecurNext = i.RecurNext
@ -75,18 +72,15 @@ func NewEvent(i Item) (Event, error) {
}
func (e Event) Item() (Item, error) {
body, err := json.Marshal(EventBody{
Title: e.Title,
Start: e.Start,
Duration: e.Duration,
})
body, err := json.Marshal(e.EventBody)
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{
ID: e.ID,
Kind: KindEvent,
Date: e.Date,
Recurrer: e.Recurrer,
RecurNext: e.RecurNext,
Body: string(body),
@ -97,15 +91,26 @@ func (e Event) Valid() bool {
if e.Title == "" {
return false
}
if e.Start.IsZero() || e.Start.Year() < 2024 {
if e.Date.IsZero() {
return false
}
if e.Duration.Seconds() < 1 {
return false
}
if e.Recurrer != nil && !e.Recurrer.Valid() {
return false
}
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",
it: item.Item{
ID: "a",
Date: item.NewDate(2024, 9, 20),
Kind: item.KindTask,
Body: `{
"title":"title",
"start":"2024-09-20T08:00:00Z",
"time":"08:00",
"duration":"1h"
}`,
},
@ -46,29 +47,23 @@ func TestNewEvent(t *testing.T) {
{
name: "valid",
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,
},
ID: "a",
Kind: item.KindEvent,
Date: item.NewDate(2024, 9, 20),
Recurrer: item.NewRecurrer("2024-12-08, daily"),
Body: `{
"title":"title",
"start":"2024-09-20T08:00:00Z",
"time":"08:00",
"duration":"1h"
}`,
},
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,
},
ID: "a",
Date: item.NewDate(2024, 9, 20),
Recurrer: item.NewRecurrer("2024-12-08, daily"),
EventBody: item.EventBody{
Title: "title",
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC),
Time: item.NewTime(8, 0),
Duration: oneHour,
},
},
@ -82,8 +77,8 @@ func TestNewEvent(t *testing.T) {
if tc.expErr {
return
}
if diff := cmp.Diff(tc.expEvent, actEvent); diff != "" {
t.Errorf("(exp +, got -)\n%s", diff)
if diff := item.EventDiff(tc.expEvent, actEvent); diff != "" {
t.Errorf("(+exp, -got)\n%s", diff)
}
})
}
@ -107,16 +102,17 @@ func TestEventItem(t *testing.T) {
expItem: item.Item{
Kind: item.KindEvent,
Updated: time.Time{},
Body: `{"start":"0001-01-01T00:00:00Z","duration":"0s","title":""}`,
Body: `{"duration":"0s","title":"","time":"00:00"}`,
},
},
{
name: "normal",
event: item.Event{
ID: "a",
ID: "a",
Date: item.NewDate(2024, 9, 23),
EventBody: item.EventBody{
Title: "title",
Start: time.Date(2024, 9, 23, 8, 0, 0, 0, time.UTC),
Time: item.NewTime(8, 0),
Duration: oneHour,
},
},
@ -124,7 +120,8 @@ func TestEventItem(t *testing.T) {
ID: "a",
Kind: item.KindEvent,
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"}`,
},
},
} {
@ -162,9 +159,10 @@ func TestEventValidate(t *testing.T) {
{
name: "missing title",
event: item.Event{
ID: "a",
ID: "a",
Date: item.NewDate(2024, 9, 20),
EventBody: item.EventBody{
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC),
Time: item.NewTime(8, 0),
Duration: oneHour,
},
},
@ -175,7 +173,7 @@ func TestEventValidate(t *testing.T) {
ID: "a",
EventBody: item.EventBody{
Title: "title",
Start: time.Date(0, 0, 0, 8, 0, 0, 0, time.UTC),
Time: item.NewTime(8, 0),
Duration: oneHour,
},
},
@ -183,20 +181,22 @@ func TestEventValidate(t *testing.T) {
{
name: "no duration",
event: item.Event{
ID: "a",
ID: "a",
Date: item.NewDate(2024, 9, 20),
EventBody: item.EventBody{
Title: "title",
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC),
Time: item.NewTime(8, 0),
},
},
},
{
name: "valid",
event: item.Event{
ID: "a",
ID: "a",
Date: item.NewDate(2024, 9, 20),
EventBody: item.EventBody{
Title: "title",
Start: time.Date(2024, 9, 20, 8, 0, 0, 0, time.UTC),
Time: item.NewTime(8, 0),
Duration: oneHour,
},
},

View File

@ -1,6 +1,7 @@
package item
import (
"encoding/json"
"time"
"github.com/google/uuid"
@ -22,11 +23,43 @@ type Item struct {
Kind Kind `json:"kind"`
Updated time.Time `json:"updated"`
Deleted bool `json:"deleted"`
Recurrer *Recur `json:"recurrer"`
RecurNext time.Time `json:"recurNext"`
Date Date `json:"date"`
Recurrer Recurrer `json:"recurrer"`
RecurNext Date `json:"recurNext"`
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 {
return Item{
ID: uuid.New().String(),

View File

@ -1,61 +1,270 @@
package item
import (
"slices"
"time"
"fmt"
"strconv"
"strings"
)
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"`
type Recurrer interface {
RecursOn(date Date) bool
First() Date
String() string
}
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 NewRecurrer(recurStr string) Recurrer {
terms := strings.Split(recurStr, ",")
if len(terms) < 2 {
return nil
}
start := NewDateFromString(terms[0])
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
}
}
return nil
}
func (r *Recur) onDays(date time.Time) bool {
if r.Start.After(date) {
return false
}
testDate := r.Start
func FirstRecurAfter(r Recurrer, d Date) Date {
lim := NewDate(2050, 1, 1)
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
}
if testDate.After(date) {
return false
}
dur := time.Duration(r.Count) * 24 * time.Hour
testDate = testDate.Add(dur)
}
return false
}
func (r *Recur) onMonths(date time.Time) bool {
if r.Start.After(date) {
func (w Weekly) First() Date { return FirstRecurAfter(w, w.Start.Add(-1)) }
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))
}
type EveryNWeeks struct {
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
}
tDate := r.Start
tDate := enm.Start
for {
if tDate.Equal(date) {
return true
@ -63,23 +272,13 @@ func (r *Recur) onMonths(date time.Time) bool {
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)
tDate = tDate.AddMonths(enm.N)
}
}
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 (enm EveryNMonths) First() Date { return FirstRecurAfter(enm, enm.Start.Add(-1)) }
func (r *Recur) Valid() bool {
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"
"time"
"github.com/google/go-cmp/cmp"
"go-mod.ewintr.nl/planner/item"
)
func TestRecur(t *testing.T) {
func TestDaily(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
daily := item.Daily{
Start: item.NewDate(2021, 1, 31), // a sunday
}
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 {
name string
date time.Time
date item.Date
exp bool
}{
{
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",
date: r.Start,
date: every.Start,
exp: true,
},
{
name: "after true",
date: r.Start.Add(15 * day),
date: every.Start.Add(15),
exp: true,
},
{
name: "after false",
date: r.Start.Add(16 * day),
date: every.Start.Add(16),
},
} {
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)
if tc.exp != every.RecursOn(tc.date) {
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 {
name string
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 time.Time
date item.Date
exp bool
}{
{
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",
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,
},
{
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",
date: time.Date(2021, 3, 3, 0, 0, 0, 0, time.UTC),
date: item.NewDate(2021, 3, 3),
},
{
name: "3 months",
date: time.Date(2021, 5, 3, 0, 0, 0, 0, time.UTC),
date: item.NewDate(2021, 5, 3),
exp: true,
},
{
name: "4 months",
date: time.Date(2021, 6, 3, 0, 0, 0, 0, time.UTC),
date: item.NewDate(2021, 6, 3),
},
{
name: "6 months",
date: time.Date(2021, 8, 3, 0, 0, 0, 0, time.UTC),
date: item.NewDate(2021, 8, 3),
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)
if tc.exp != everyNMonths.RecursOn(tc.date) {
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 (
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go-mod.ewintr.nl/planner/item"
@ -24,11 +23,10 @@ func NewAdd(localRepo storage.LocalID, eventRepo storage.Event, syncRepo storage
syncRepo: syncRepo,
argSet: &ArgSet{
Flags: map[string]Flag{
FlagOn: &FlagDate{},
FlagAt: &FlagTime{},
FlagFor: &FlagDuration{},
FlagRecStart: &FlagDate{},
FlagRecPeriod: &FlagPeriod{},
FlagOn: &FlagDate{},
FlagAt: &FlagTime{},
FlagFor: &FlagDuration{},
FlagRec: &FlagRecurrer{},
},
},
}
@ -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")
}
}
if as.IsSet(FlagRecStart) != as.IsSet(FlagRecPeriod) {
return fmt.Errorf("rec-start required rec-period and vice versa")
}
return add.do()
}
func (add *Add) do() error {
as := add.argSet
start := as.GetTime(FlagOn)
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)
}
rec := as.GetRecurrer(FlagRec)
e := item.Event{
ID: uuid.New().String(),
ID: uuid.New().String(),
Date: as.GetDate(FlagOn),
Recurrer: rec,
EventBody: item.EventBody{
Title: as.Main,
Start: start,
Title: as.Main,
Time: as.GetTime(FlagAt),
Duration: as.GetDuration(FlagFor),
},
}
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 rec != nil {
e.RecurNext = rec.First()
}
if err := add.eventRepo.Store(e); err != nil {

View File

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

View File

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

View File

@ -56,11 +56,11 @@ func TestArgSet(t *testing.T) {
{
name: "recur period flag success",
flags: map[string]command.Flag{
"period": &command.FlagPeriod{Name: "period"},
"recur": &command.FlagRecurrer{Name: "recur"},
},
flagName: "period",
setValue: "month",
exp: item.PeriodMonth,
flagName: "recur",
setValue: "2024-12-23, daily",
exp: item.NewRecurrer("2024-12-23, daily"),
},
{
name: "unknown flag error",
@ -95,26 +95,9 @@ func TestArgSet(t *testing.T) {
return
}
// Verify IsSet() returns true after setting
if !as.IsSet(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

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ package command
import (
"errors"
"fmt"
"slices"
"strconv"
"time"
"go-mod.ewintr.nl/planner/item"
@ -45,35 +45,35 @@ func (fs *FlagString) Get() any {
type FlagDate struct {
Name string
Value time.Time
Value item.Date
}
func (ft *FlagDate) Set(val string) error {
d, err := time.Parse(DateFormat, val)
if err != nil {
func (fd *FlagDate) Set(val string) error {
d := item.NewDateFromString(val)
if d.IsZero() {
return fmt.Errorf("could not parse date: %v", d)
}
ft.Value = d
fd.Value = d
return nil
}
func (ft *FlagDate) IsSet() bool {
return !ft.Value.IsZero()
func (fd *FlagDate) IsSet() bool {
return !fd.Value.IsZero()
}
func (fs *FlagDate) Get() any {
return fs.Value
func (fd *FlagDate) Get() any {
return fd.Value
}
type FlagTime struct {
Name string
Value time.Time
Value item.Time
}
func (ft *FlagTime) Set(val string) error {
d, err := time.Parse(TimeFormat, val)
if err != nil {
d := item.NewTimeFromString(val)
if d.IsZero() {
return fmt.Errorf("could not parse date: %v", d)
}
ft.Value = d
@ -111,23 +111,46 @@ func (fs *FlagDuration) Get() any {
return fs.Value
}
type FlagPeriod struct {
type FlagRecurrer struct {
Name string
Value item.RecurPeriod
Value item.Recurrer
}
func (fp *FlagPeriod) Set(val string) error {
if !slices.Contains(item.ValidPeriods, item.RecurPeriod(val)) {
return fmt.Errorf("not a valid period: %v", val)
func (fr *FlagRecurrer) Set(val string) error {
fr.Value = item.NewRecurrer(val)
if fr.Value == nil {
return fmt.Errorf("not a valid recurrer: %v", val)
}
fp.Value = item.RecurPeriod(val)
return nil
}
func (fp *FlagPeriod) IsSet() bool {
return fp.Value != ""
func (fr *FlagRecurrer) IsSet() bool {
return fr.Value != nil
}
func (fp *FlagPeriod) Get() any {
return fp.Value
func (fr *FlagRecurrer) Get() any {
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) {
t.Parallel()
valid := time.Date(2024, 11, 20, 0, 0, 0, 0, time.UTC)
validStr := "2024-11-20"
valid := item.NewDate(2024, 11, 20)
f := command.FlagDate{}
if f.IsSet() {
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)
}
@ -52,26 +51,25 @@ func TestFlagDate(t *testing.T) {
t.Errorf("exp true, got false")
}
act, ok := f.Get().(time.Time)
act, ok := f.Get().(item.Date)
if !ok {
t.Errorf("exp true, got false")
}
if act != valid {
t.Errorf("exp %v, got %v", valid, act)
if act.String() != valid.String() {
t.Errorf("exp %v, got %v", valid.String(), act.String())
}
}
func TestFlagTime(t *testing.T) {
t.Parallel()
valid := time.Date(0, 1, 1, 12, 30, 0, 0, time.UTC)
validStr := "12:30"
valid := item.NewTime(12, 30)
f := command.FlagTime{}
if f.IsSet() {
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)
}
@ -79,12 +77,12 @@ func TestFlagTime(t *testing.T) {
t.Errorf("exp true, got false")
}
act, ok := f.Get().(time.Time)
act, ok := f.Get().(item.Time)
if !ok {
t.Errorf("exp true, got false")
}
if act != valid {
t.Errorf("exp %v, got %v", valid, act)
if act.String() != valid.String() {
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()
valid := item.PeriodMonth
validStr := "month"
f := command.FlagPeriod{}
validStr := "2024-12-23, daily"
valid := item.NewRecurrer(validStr)
f := command.FlagRecurrer{}
if f.IsSet() {
t.Errorf("exp false, got true")
}
@ -133,7 +131,7 @@ func TestFlagPeriod(t *testing.T) {
t.Errorf("exp true, got false")
}
act, ok := f.Get().(item.RecurPeriod)
act, ok := f.Get().(item.Recurrer)
if !ok {
t.Errorf("exp true, got false")
}

View File

@ -2,7 +2,6 @@ package command
import (
"fmt"
"time"
"go-mod.ewintr.nl/planner/plan/storage"
)
@ -41,7 +40,7 @@ func (list *List) do() error {
if !ok {
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

View File

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

View File

@ -137,10 +137,10 @@ func TestSyncReceive(t *testing.T) {
}`,
}},
expEvent: []item.Event{{
ID: "a",
ID: "a",
Date: item.NewDate(2024, 10, 23),
EventBody: item.EventBody{
Title: "title",
Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC),
Duration: oneHour,
},
}},
@ -151,10 +151,10 @@ func TestSyncReceive(t *testing.T) {
{
name: "update existing",
present: []item.Event{{
ID: "a",
ID: "a",
Date: item.NewDate(2024, 10, 23),
EventBody: item.EventBody{
Title: "title",
Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC),
Duration: oneHour,
},
}},
@ -168,10 +168,10 @@ func TestSyncReceive(t *testing.T) {
}`,
}},
expEvent: []item.Event{{
ID: "a",
ID: "a",
Date: item.NewDate(2024, 10, 23),
EventBody: item.EventBody{
Title: "new title",
Start: time.Date(2024, 10, 23, 8, 0, 0, 0, time.UTC),
Duration: oneHour,
},
}},
@ -210,7 +210,7 @@ func TestSyncReceive(t *testing.T) {
if err != nil {
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)
}
actLocalIDs, err := localIDRepo.FindAll()

View File

@ -4,9 +4,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/storage"
)
@ -25,12 +23,11 @@ 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{},
FlagRecStart: &FlagDate{},
FlagRecPeriod: &FlagPeriod{},
FlagTitle: &FlagString{},
FlagOn: &FlagDate{},
FlagAt: &FlagTime{},
FlagFor: &FlagDuration{},
FlagRec: &FlagRecurrer{},
},
},
}
@ -87,35 +84,17 @@ func (update *Update) do() error {
if 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) {
on = as.GetTime(FlagOn)
}
if as.IsSet(FlagAt) {
at := 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(FlagOn) {
e.Date = as.GetDate(FlagOn)
}
if as.IsSet(FlagAt) {
e.Time = as.GetTime(FlagAt)
}
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 as.IsSet(FlagRec) {
e.Recurrer = as.GetRecurrer(FlagRec)
}
if !e.Valid() {

View File

@ -5,7 +5,6 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"go-mod.ewintr.nl/planner/item"
"go-mod.ewintr.nl/planner/plan/command"
"go-mod.ewintr.nl/planner/plan/storage/memory"
@ -21,7 +20,8 @@ func TestUpdateExecute(t *testing.T) {
t.Errorf("exp nil, got %v", err)
}
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")
if err != nil {
t.Errorf("exp nil, got %v", err)
@ -49,10 +49,11 @@ func TestUpdateExecute(t *testing.T) {
localID: lid,
main: []string{"update", fmt.Sprintf("%d", lid), "updated"},
expEvent: item.Event{
ID: eid,
ID: eid,
Date: item.NewDate(2024, 10, 6),
EventBody: item.EventBody{
Title: "updated",
Start: start,
Time: aTime,
Duration: oneHour,
},
},
@ -74,10 +75,11 @@ func TestUpdateExecute(t *testing.T) {
"on": "2024-10-02",
},
expEvent: item.Event{
ID: eid,
ID: eid,
Date: item.NewDate(2024, 10, 2),
EventBody: item.EventBody{
Title: title,
Start: time.Date(2024, 10, 2, 10, 0, 0, 0, time.UTC),
Time: aTime,
Duration: oneHour,
},
},
@ -99,10 +101,11 @@ func TestUpdateExecute(t *testing.T) {
"at": "11:00",
},
expEvent: item.Event{
ID: eid,
ID: eid,
Date: item.NewDate(2024, 10, 6),
EventBody: item.EventBody{
Title: title,
Start: time.Date(2024, 10, 6, 11, 0, 0, 0, time.UTC),
Time: item.NewTime(11, 0),
Duration: oneHour,
},
},
@ -116,10 +119,11 @@ func TestUpdateExecute(t *testing.T) {
"at": "11:00",
},
expEvent: item.Event{
ID: eid,
ID: eid,
Date: item.NewDate(2024, 10, 2),
EventBody: item.EventBody{
Title: title,
Start: time.Date(2024, 10, 2, 11, 0, 0, 0, time.UTC),
Time: item.NewTime(11, 0),
Duration: oneHour,
},
},
@ -141,62 +145,36 @@ func TestUpdateExecute(t *testing.T) {
"for": "2h",
},
expEvent: item.Event{
ID: eid,
ID: eid,
Date: item.NewDate(2024, 10, 6),
EventBody: item.EventBody{
Title: title,
Start: time.Date(2024, 10, 6, 10, 0, 0, 0, time.UTC),
Time: aTime,
Duration: twoHour,
},
},
},
{
name: "invalid rec start",
name: "invalid rec",
main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{
"rec-start": "invalud",
"rec": "invalud",
},
expErr: true,
},
{
name: "valid rec start",
name: "valid rec",
main: []string{"update", fmt.Sprintf("%d", lid)},
flags: map[string]string{
"rec-start": "2024-12-08",
"rec": "2024-12-08, daily",
},
expEvent: item.Event{
ID: eid,
Recurrer: &item.Recur{
Start: time.Date(2024, 12, 8, 0, 0, 0, 0, time.UTC),
},
ID: eid,
Date: aDate,
Recurrer: item.NewRecurrer("2024-12-08, daily"),
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,
Time: aTime,
Duration: oneHour,
},
},
@ -207,10 +185,11 @@ func TestUpdateExecute(t *testing.T) {
localIDRepo := memory.NewLocalID()
syncRepo := memory.NewSync()
if err := eventRepo.Store(item.Event{
ID: eid,
ID: eid,
Date: aDate,
EventBody: item.EventBody{
Title: title,
Start: start,
Time: aTime,
Duration: oneHour,
},
}); err != nil {
@ -233,7 +212,7 @@ func TestUpdateExecute(t *testing.T) {
if err != nil {
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)
}
updated, err := syncRepo.FindAll()

View File

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

View File

@ -3,7 +3,6 @@ package memory
import (
"testing"
"github.com/google/go-cmp/cmp"
"go-mod.ewintr.nl/planner/item"
)
@ -50,7 +49,7 @@ func TestEvent(t *testing.T) {
if actErr != nil {
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)
}
}

View File

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

View File

@ -25,6 +25,23 @@ var migrations = []string{
deleted BOOLEAN 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 (

View File

@ -17,7 +17,7 @@ func NewSqliteSync(db *sql.DB) *SqliteSync {
}
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 {
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
for rows.Next() {
var i item.Item
var updatedStr string
err := rows.Scan(&i.ID, &i.Kind, &updatedStr, &i.Deleted, &i.Body)
var updatedStr, recurStr, recurNextStr string
err := rows.Scan(&i.ID, &i.Kind, &updatedStr, &i.Deleted, &recurStr, &recurNextStr, &i.Body)
if err != nil {
return nil, fmt.Errorf("%w: failed to scan item: %v", ErrSqliteFailure, err)
}
i.Updated, err = time.Parse(time.RFC3339, updatedStr)
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)
}
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
}
func (s *SqliteSync) Store(i item.Item) error {
// Ensure we have a valid time
if i.Updated.IsZero() {
i.Updated = time.Now()
}
var recurStr string
if i.Recurrer != nil {
recurStr = i.Recurrer.String()
}
_, 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.Kind,
i.Updated.UTC().Format(time.RFC3339),
i.Deleted,
recurStr,
i.RecurNext.String(),
sql.NullString{String: i.Body, Valid: i.Body != ""}, // This allows empty string but not NULL
)
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)
}
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

View File

@ -1,7 +1,6 @@
package main
import (
"fmt"
"slices"
"sync"
"time"
@ -47,33 +46,15 @@ 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) {
func (m *Memory) ShouldRecur(date item.Date) ([]item.Item, error) {
res := make([]item.Item, 0)
for _, i := range m.items {
if i.Recurrer == nil {
continue
}
if i.RecurNext.Before(date) {
if date.Equal(i.RecurNext) || date.After(i.RecurNext) {
res = append(res, i)
}
}
return res, nil
}
func (m *Memory) RecursNext(id string, date time.Time, ts time.Time) error {
i, ok := m.items[id]
if !ok {
return ErrNotFound
}
if i.Recurrer == nil {
return ErrNotARecurrer
}
if !i.Recurrer.On(date) {
return fmt.Errorf("item does not recur on %v", date)
}
i.RecurNext = date
i.Updated = ts
m.items[id] = i
return nil
}

View File

@ -113,19 +113,14 @@ func TestMemoryRecur(t *testing.T) {
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)
today := item.NewDate(2024, 12, 1)
yesterday := item.NewDate(2024, 11, 30)
t.Log("start")
i1 := item.Item{
ID: "a",
Updated: earlier,
Recurrer: &item.Recur{
Start: yesterday,
Period: item.PeriodDay,
Count: 1,
},
ID: "a",
Updated: earlier,
Recurrer: item.NewRecurrer("2024-11-30, daily"),
RecurNext: yesterday,
}
i2 := item.Item{
@ -140,7 +135,7 @@ func TestMemoryRecur(t *testing.T) {
}
t.Log("get recurrers")
rs, err := mem.RecursBefore(today)
rs, err := mem.ShouldRecur(today)
if err != nil {
t.Errorf("exp nil, gt %v", err)
}
@ -148,20 +143,4 @@ func TestMemoryRecur(t *testing.T) {
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_kind ON items(kind)`,
`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 (
@ -35,13 +42,10 @@ type Postgres struct {
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)
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err)
}
// Test the connection
if err := db.Ping(); err != nil {
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
}
func (p *Postgres) Update(item item.Item, ts time.Time) error {
_, err := p.db.Exec(`
INSERT INTO items (id, kind, updated, deleted, body, recurrer, recur_next)
VALUES ($1, $2, $3, $4, $5, $6, $7)
func (p *Postgres) Update(i item.Item, ts time.Time) error {
if i.Recurrer != nil && i.RecurNext.IsZero() {
i.RecurNext = i.Recurrer.First()
}
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
SET kind = EXCLUDED.kind,
updated = EXCLUDED.updated,
deleted = EXCLUDED.deleted,
body = EXCLUDED.body,
date = EXCLUDED.date,
recurrer = EXCLUDED.recurrer,
recur_next = EXCLUDED.recur_next`,
item.ID, item.Kind, ts, item.Deleted, item.Body, item.Recurrer, item.RecurNext)
if err != nil {
recur_next = EXCLUDED.recur_next,
body = EXCLUDED.body`,
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 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) {
query := `
SELECT id, kind, updated, deleted, body, recurrer, recur_next
SELECT id, kind, updated, deleted, date, recurrer, recur_next, body
FROM items
WHERE updated > $1`
args := []interface{}{t}
if len(ks) > 0 {
placeholder := make([]string, len(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)
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 {
var i item.Item
var date, recurrer, recurNext string
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)
}
if recurNext.Valid {
item.RecurNext = recurNext.Time
}
result = append(result, item)
i.Date = item.NewDateFromString(date)
i.Recurrer = item.NewRecurrer(recurrer)
i.RecurNext = item.NewDateFromString(recurNext)
result = append(result, i)
}
return result, nil
}
func (p *Postgres) RecursBefore(date time.Time) ([]item.Item, error) {
func (p *Postgres) ShouldRecur(date item.Date) ([]item.Item, error) {
query := `
SELECT id, kind, updated, deleted, body, recurrer, recur_next
SELECT id, kind, updated, deleted, date, recurrer, recur_next, body
FROM items
WHERE recur_next <= $1 AND recurrer IS NOT NULL`
rows, err := p.db.Query(query, date)
WHERE
NOT deleted
AND recurrer <> ''
AND recur_next <= $1`
rows, err := p.db.Query(query, date.String())
if err != nil {
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)
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 {
var i item.Item
var date, recurrer, recurNext string
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)
}
if recurNext.Valid {
item.RecurNext = recurNext.Time
}
result = append(result, item)
i.Date = item.NewDateFromString(date)
i.Recurrer = item.NewRecurrer(recurrer)
i.RecurNext = item.NewDateFromString(recurNext)
result = append(result, i)
}
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(`

View File

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

View File

@ -12,23 +12,18 @@ import (
func TestRecur(t *testing.T) {
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()
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,
Recurrer: item.NewRecurrer("2024-01-01, daily"),
RecurNext: today,
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
recurItems, err := mem.RecursBefore(now.Add(48 * time.Hour))
recurItems, err := mem.ShouldRecur(today.Add(1))
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) {
if !recurItems[0].RecurNext.After(today) {
t.Errorf("RecurNext was not updated, still %v", recurItems[0].RecurNext)
}
}

View File

@ -39,7 +39,7 @@ func main() {
"dbUser": *dbUser,
})
recurrer := NewRecur(repo, repo, logger)
go recurrer.Run(12 * time.Hour)
go recurrer.Run(10 * time.Second)
srv := NewServer(repo, *apiKey, logger)
go http.ListenAndServe(fmt.Sprintf(":%s", *apiPort), srv)

View File

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