diff --git a/cli/create.go b/cli/create.go index 8c0b8bf..7e546bb 100644 --- a/cli/create.go +++ b/cli/create.go @@ -17,6 +17,7 @@ func createCommand(t *core.Timetrace) *cobra.Command { } create.AddCommand(createProjectCommand(t)) + create.AddCommand(createRecordCommand(t)) return create } @@ -44,3 +45,74 @@ func createProjectCommand(t *core.Timetrace) *cobra.Command { return createProject } + +func createRecordCommand(t *core.Timetrace) *cobra.Command { + var options startOptions + createRecord := &cobra.Command{ + Use: "record {|today|yesterday} ", + Short: "Create a new record", + Args: cobra.ExactArgs(4), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + project, err := t.LoadProject(key) + if err != nil { + out.Err("Failed to get project: %s", key) + return + } + + date, err := t.Formatter().ParseDate(args[1]) + if err != nil { + out.Err("failed to parse date: %s", err.Error()) + return + } + + start, err := t.Formatter().ParseTime(args[2]) + if err != nil { + out.Err("failed to parse start time: %s", err.Error()) + return + } + start = t.Formatter().CombineDateAndTime(date, start) + + end, err := t.Formatter().ParseTime(args[3]) + if err != nil { + out.Err("failed to parse end time: %s", err.Error()) + return + } + end = t.Formatter().CombineDateAndTime(date, end) + + if end.Before(start) { + out.Err("end time is before start time!") + return + } + + record := core.Record{ + Project: project, + Start: start, + End: &end, + IsBillable: options.isBillable, + } + + collides, err := t.RecordCollides(record) + if err != nil { + out.Err("Error on check if record collides: %s", err.Error()) + return + } + if collides { + out.Err("Record collides with other record!") + return + } + + if err := t.SaveRecord(record, false); err != nil { + out.Err("Failed to create record: %s", err.Error()) + return + } + + out.Success("Created record %s in project %s", t.Formatter().TimeString(record.Start), key) + }, + } + + createRecord.Flags().BoolVarP(&options.isBillable, "billable", "b", + false, `mark tracked time as billable`) + + return createRecord +} diff --git a/core/formatter.go b/core/formatter.go index 67f14ea..0fe7b47 100644 --- a/core/formatter.go +++ b/core/formatter.go @@ -31,6 +31,22 @@ func (f *Formatter) ParseDate(input string) (time.Time, error) { return date, nil } +// ParseTime parses a time from an input string in the configured timeLayout +func (f *Formatter) ParseTime(input string) (time.Time, error) { + date, err := time.Parse(f.timeLayout(), input) + if err != nil { + return time.Time{}, err + } + return date, nil +} + +// CombineDateAndTime takes a date and a time and combines them to the time +// struct that represents the given time on the given day +func (f *Formatter) CombineDateAndTime(d, t time.Time) time.Time { + year, month, day := d.Date() + return time.Date(year, month, day, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.Local) +} + const ( defaultTimeLayout = "15:04" default12HoursTimeLayout = "03:04PM" diff --git a/core/timetrace.go b/core/timetrace.go index 01dd9a7..256dfad 100644 --- a/core/timetrace.go +++ b/core/timetrace.go @@ -234,3 +234,40 @@ func (t *Timetrace) latestNonEmptyDir(dirs []string) (string, error) { return "", ErrAllDirectoriesEmpty } + +// RecordCollides checks if the time of a record collides +// with other records of the same day and returns a bool +func (t *Timetrace) RecordCollides(toCheck Record) (bool, error) { + allRecords, err := t.loadAllRecords(toCheck.Start) + if err != nil { + return false, err + } + + if toCheck.Start.Day() != toCheck.End.Day() { + moreRecords, err := t.loadAllRecords(*toCheck.End) + if err != nil { + return false, err + } + for _, rec := range moreRecords { + allRecords = append(allRecords, rec) + } + } + + return collides(toCheck, allRecords), nil +} + +func collides(toCheck Record, allRecords []*Record) bool { + for _, rec := range allRecords { + if rec.Start.Before(toCheck.Start) && rec.End.After(toCheck.Start) { + return true + } else if rec.Start.Before(*toCheck.End) && rec.End.After(*toCheck.End) { + return true + } else if toCheck.Start.Before(rec.Start) && toCheck.End.After(rec.Start) { + return true + } else if toCheck.Start.Before(*rec.End) && toCheck.End.After(*rec.End) { + return true + } + } + + return false +} diff --git a/core/timetrace_test.go b/core/timetrace_test.go index ad62946..fc77f4a 100644 --- a/core/timetrace_test.go +++ b/core/timetrace_test.go @@ -5,6 +5,59 @@ import ( "time" ) +func newTestRecord(s int, e int) Record { + start := time.Now().Add(time.Duration(s) * time.Minute) + end := time.Now().Add(time.Duration(e) * time.Minute) + return Record{Start: start, End: &end} +} + +func TestCollides(t *testing.T) { + savedRec := newTestRecord(-60, -1) + allRecs := []*Record{&savedRec} + + // rec1 starts and end after savedRec + rec1 := newTestRecord(-1, 0) + + if collides(rec1, allRecs) { + t.Error("records should not collide") + } + + // rec2 starts in savedRec, ends after + rec2 := newTestRecord(-30, 1) + + if !collides(rec2, allRecs) { + t.Error("records should collide") + } + + // rec3 start before savedRec, ends inside + rec3 := newTestRecord(-75, -30) + + if !collides(rec3, allRecs) { + t.Error("records should collide") + } + + // rec4 starts and ends before savedRec + rec4 := newTestRecord(-75, -70) + + if collides(rec4, allRecs) { + t.Error("records should not collide") + } + + // rec5 starts and ends inside savedRec + rec5 := newTestRecord(-40, -20) + + if !collides(rec5, allRecs) { + t.Error("records should collide") + } + + // rec6 starts before and ends after savedRec + rec6 := newTestRecord(-70, 10) + + if !collides(rec6, allRecs) { + t.Error("records should collide") + } +} + func TestFormatDuration(t *testing.T) { tt := []struct {