From 1734c701547df8db7bfabcba77fac338b4b2c54c Mon Sep 17 00:00:00 2001 From: jansorg Date: Fri, 14 Jun 2019 20:38:07 +0200 Subject: [PATCH] fix: properly distribute frames to split buckets for frames which span multiple buckets. --- .idea/dictionaries/jansorg.xml | 1 + go-tom/dateTime/range.go | 4 ++ go-tom/model/frame.go | 31 +++++++++ go-tom/model/frame_list.go | 55 +++++++++++++--- go-tom/model/frame_test.go | 30 +++++++++ go-tom/report/bucket.go | 29 +++++---- go-tom/report/report.go | 14 ++-- go-tom/report/report_test.go | 116 ++++++++++++++++++++++++++++++++- 8 files changed, 251 insertions(+), 29 deletions(-) create mode 100644 go-tom/model/frame_test.go diff --git a/.idea/dictionaries/jansorg.xml b/.idea/dictionaries/jansorg.xml index 494fd49..633e78c 100644 --- a/.idea/dictionaries/jansorg.xml +++ b/.idea/dictionaries/jansorg.xml @@ -7,6 +7,7 @@ gotime multisite unmarshall + untracked wonka diff --git a/go-tom/dateTime/range.go b/go-tom/dateTime/range.go index 74614ac..bede410 100644 --- a/go-tom/dateTime/range.go +++ b/go-tom/dateTime/range.go @@ -203,6 +203,10 @@ func (r DateRange) Intersection(start *time.Time, end *time.Time) time.Duration return 0 } +func (r DateRange) Intersects(start *time.Time, end *time.Time) bool { + return r.Intersection(start, end) > 0 +} + func (r DateRange) Years(loc *time.Location) []DateRange { first := r.Start.In(loc).Year() last := r.End.In(loc).Year() diff --git a/go-tom/model/frame.go b/go-tom/model/frame.go index 51bce76..0ac4ed4 100644 --- a/go-tom/model/frame.go +++ b/go-tom/model/frame.go @@ -29,6 +29,19 @@ type Frame struct { Archived bool `json:"archived,omitempty"` } +func (f *Frame) copy() *Frame { + return &Frame{ + ID: f.ID, + ProjectId: f.ProjectId, + Start: f.Start, + End: f.End, + Updated: f.Updated, + Notes: f.Notes, + TagIDs: f.TagIDs, + Archived: f.Archived, + } +} + func (f *Frame) sortTagIDs() { sort.Strings(f.TagIDs) } @@ -135,6 +148,24 @@ func (f *Frame) Intersection(activeEnd *time.Time, timeRange *dateTime.DateRange return time.Duration(0) } +// Contains returns if this frame's time range contains this ref +// an unstopped frame contains ref if the start time is equal to it +func (f *Frame) Contains(ref *time.Time) bool { + if ref == nil || ref.IsZero() { + return false + } + + if f.Start != nil && f.End != nil { + debug := fmt.Sprintf("%s -> %s <- %s", f.Start.String(), ref.String(), f.End.String()) + fmt.Println(debug) + + return ref.Equal(*f.Start) || + ref.Equal(*f.End) || + ref.After(*f.Start) && ref.Before(*f.End) + } + return f.Start != nil && f.Start.Equal(*ref) +} + func (f *Frame) IsBefore(other *Frame) bool { return f.Start != nil && other.Start != nil && f.Start.Before(*other.Start) } diff --git a/go-tom/model/frame_list.go b/go-tom/model/frame_list.go index 58ab0a8..c2ea503 100644 --- a/go-tom/model/frame_list.go +++ b/go-tom/model/frame_list.go @@ -88,24 +88,37 @@ func (f *FrameList) FilterByEndDate(maxEndDate time.Time, acceptUnstopped bool) }) } -func (f *FrameList) FilterByDateRange(dateRange dateTime.DateRange, acceptUnstopped bool) { +func (f *FrameList) FilterByDateRange(dateRange dateTime.DateRange, acceptUnstopped bool, keepOverlapping bool) { + if keepOverlapping { + f.Filter(func(frame *Frame) bool { + return acceptUnstopped && frame.End == nil || dateRange.Intersects(frame.Start, frame.End) + }) + return + } + f.FilterByStartDate(*dateRange.Start) if dateRange.IsClosed() { f.FilterByEndDate(*dateRange.End, acceptUnstopped) } } -func (f *FrameList) FilterByDate(start time.Time, end time.Time, acceptUnstopped bool) { - f.FilterByStartDate(start) - f.FilterByEndDate(end, acceptUnstopped) +func (f *FrameList) FilterByDate(start time.Time, end time.Time, acceptUnstopped bool, keepOverlapping bool) { + f.FilterByDatePtr(&start, &end, acceptUnstopped, keepOverlapping) } -func (f *FrameList) FilterByDatePtr(start *time.Time, end *time.Time, includeActive bool) { - if start != nil && !start.IsZero() { - f.FilterByStartDate(*start) +func (f *FrameList) FilterByDatePtr(start *time.Time, end *time.Time, acceptUnstopped bool, keepOverlapping bool) { + if keepOverlapping { + f.Filter(func(frame *Frame) bool { + return acceptUnstopped && frame.End == nil || + start != nil && frame.Contains(start) || + end != nil && frame.Contains(end) + }) + return } - if end != nil && !end.IsZero() { - f.FilterByEndDate(*end, includeActive) + + f.FilterByStartDate(*start) + if end != nil { + f.FilterByEndDate(*end, acceptUnstopped) } } @@ -127,6 +140,30 @@ func (f *FrameList) ExcludeArchived() { }) } +// CutEntriesTo cuts entries which span more than start->end +func (f *FrameList) CutEntriesTo(start *time.Time, end *time.Time) { + length := len(*f) + for i := 0; i < length; i++ { + frame := (*f)[i] + + // must not modify the original frame, it may still be referenced by other lists + adaptStart := start != nil && frame.Start.Before(*start) + adaptEnd := end != nil && frame.End != nil && frame.End.After(*end) + + if adaptStart || adaptEnd { + copied := frame.copy() + if adaptStart { + copied.Start = start + } + if adaptEnd { + copied.End = end + } + (*f)[i] = copied + } + } + f.Sort() +} + // Split splits all frames into one ore more parts // The part a frame belongs to is coputed by the key function // because the distribution of keys is not always in order a map has to be used here diff --git a/go-tom/model/frame_test.go b/go-tom/model/frame_test.go new file mode 100644 index 0000000..b866288 --- /dev/null +++ b/go-tom/model/frame_test.go @@ -0,0 +1,30 @@ +package model + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestContains(t *testing.T) { + start := newDate(2019, time.January, 1, 10, 0) + end := newDate(2019, time.January, 1, 12, 0) + f := Frame{ + Start: start, + End: end, + } + + ref := start.Add(-10 * time.Minute) + assert.False(t, f.Contains(&ref)) + + assert.True(t, f.Contains(start)) + + ref = start.Add(10 * time.Minute) + assert.True(t, f.Contains(&ref)) + + assert.True(t, f.Contains(end)) + + ref = end.Add(10 * time.Minute) + assert.False(t, f.Contains(&ref)) +} diff --git a/go-tom/report/bucket.go b/go-tom/report/bucket.go index 984ed77..bd12186 100644 --- a/go-tom/report/bucket.go +++ b/go-tom/report/bucket.go @@ -313,30 +313,31 @@ func (b *ResultBucket) SplitByDateRange(splitType SplitOperation) { start := filterRange.Start end := filterRange.End - var value dateTime.DateRange + var splitValue dateTime.DateRange switch splitType { case SplitByYear: - value = dateTime.NewYearRange(*start, b.ctx.Locale, start.Location()) + splitValue = dateTime.NewYearRange(*start, b.ctx.Locale, start.Location()) case SplitByMonth: - value = dateTime.NewMonthRange(*start, b.ctx.Locale, start.Location()) + splitValue = dateTime.NewMonthRange(*start, b.ctx.Locale, start.Location()) case SplitByWeek: - value = dateTime.NewWeekRange(*start, b.ctx.Locale, start.Location()) + splitValue = dateTime.NewWeekRange(*start, b.ctx.Locale, start.Location()) case SplitByDay: - value = dateTime.NewDayRange(*start, b.ctx.Locale, start.Location()) + splitValue = dateTime.NewDayRange(*start, b.ctx.Locale, start.Location()) } - for value.IsClosed() && value.Start.Before(*end) { + for splitValue.IsClosed() && splitValue.Start.Before(*end) { matchingFrames := b.Frames.Copy() - matchingFrames.FilterByDateRange(value, false) + matchingFrames.FilterByDateRange(splitValue, false, true) + matchingFrames.CutEntriesTo(splitValue.Start, splitValue.End) - rangeCopy := value + rangeCopy := splitValue if b.config.ShowEmpty || !matchingFrames.Empty() { b.AddChild(&ResultBucket{ - dateRange: value, + dateRange: splitValue, Frames: matchingFrames, Duration: dateTime.NewEmptyCopy(b.Duration), SplitByType: splitType, - SplitBy: value, + SplitBy: splitValue, DailyTracked: dateTime.NewTrackedDaily(&rangeCopy), DailyUnTracked: dateTime.NewUntrackedDaily(&rangeCopy), }) @@ -344,13 +345,13 @@ func (b *ResultBucket) SplitByDateRange(splitType SplitOperation) { switch splitType { case SplitByYear: - value = value.Shift(1, 0, 0) + splitValue = splitValue.Shift(1, 0, 0) case SplitByMonth: - value = value.Shift(0, 1, 0) + splitValue = splitValue.Shift(0, 1, 0) case SplitByWeek: - value = value.Shift(0, 0, 7) + splitValue = splitValue.Shift(0, 0, 7) case SplitByDay: - value = value.Shift(0, 0, 1) + splitValue = splitValue.Shift(0, 0, 1) } } diff --git a/go-tom/report/report.go b/go-tom/report/report.go index ae37998..6eb12b5 100644 --- a/go-tom/report/report.go +++ b/go-tom/report/report.go @@ -34,7 +34,8 @@ func (b *BucketReport) Update() *ResultBucket { b.source.ExcludeArchived() } if !b.config.DateFilterRange.Empty() { - b.source.FilterByDateRange(b.config.DateFilterRange, false) + b.source.FilterByDateRange(b.config.DateFilterRange, false, true) + b.source.CutEntriesTo(b.config.DateFilterRange.Start, b.config.DateFilterRange.End) } projectIDs := b.config.ProjectIDs @@ -62,18 +63,23 @@ func (b *BucketReport) Update() *ResultBucket { }) } + // setup the date filter range in the target timezone config := b.config + var filterRange *dateTime.DateRange if config.DateFilterRange.Empty() { config.DateFilterRange = b.source.DateRange(b.ctx.Locale).In(config.Timezone) - } else if config.Timezone != nil { - config.DateFilterRange = config.DateFilterRange.In(config.Timezone) + } else { + if config.Timezone != nil { + config.DateFilterRange = config.DateFilterRange.In(config.Timezone) + } + filterRange = &config.DateFilterRange } b.result = &ResultBucket{ ctx: b.ctx, config: config, Frames: b.source, - Duration: dateTime.NewDurationSumAll(b.config.EntryRounding, nil, nil), + Duration: dateTime.NewDurationSumAll(b.config.EntryRounding, filterRange, nil), DailyTracked: dateTime.NewTrackedDaily(nil), DailyUnTracked: dateTime.NewUntrackedDaily(nil), } diff --git a/go-tom/report/report_test.go b/go-tom/report/report_test.go index 9adc8bf..e8d0959 100644 --- a/go-tom/report/report_test.go +++ b/go-tom/report/report_test.go @@ -87,11 +87,11 @@ func TestReportSplitYear(t *testing.T) { require.NoError(t, err) defer test_setup.CleanupTestContext(ctx) - // two hours + // two hours, 10th of march 2018 start := newDate(2018, time.March, 10, 10, 0) end := newDate(2018, time.March, 10, 12, 0) - // one hour + // one hour. 9th of march 2019 start2 := newDate(2019, time.March, 10, 9, 0) end2 := newDate(2019, time.March, 10, 10, 0) @@ -562,6 +562,118 @@ func TestReportTimeFilter(t *testing.T) { assert.EqualValues(t, "2019-01-01 10:00:00 +0800 UTC+8 - 2019-01-02 00:00:00 +0800 UTC+8", report.result.config.DateFilterRange.String()) } +// test for https://github.com/jansorg/tom-ui/issues/91 +// make sure that entries which overlap the filter range are properly handled +func TestReportTimeFilterOverlap(t *testing.T) { + ctx, err := test_setup.CreateTestContext(language.German) + require.NoError(t, err) + defer test_setup.CleanupTestContext(ctx) + + p1, _, err := ctx.StoreHelper.GetOrCreateNestedProjectNames("top") + require.NoError(t, err) + + start := time.Date(2019, time.January, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(1 * time.Hour) + + // the frame start 10 minutes before and end 10 minutes after the filter range + frameStart := start.Add(-10 * time.Minute) + frameEnd := end.Add(10 * time.Minute) + + frames := model.NewEmptyFrameList() + frames.Append(&model.Frame{Start: &frameStart, End: &frameEnd, ProjectId: p1.ID}) + + // with filter + report := NewBucketReport(frames, Config{ + ProjectIDs: []string{p1.ID}, + IncludeSubprojects: true, + Splitting: []SplitOperation{SplitByProject}, + DateFilterRange: dateTime.NewDateRange(&start, &end, ctx.Locale), + Timezone: time.UTC, + }, ctx) + report.Update() + assert.EqualValues(t, 1*time.Hour, report.Result().Duration.GetExact(), "1 hour max range expected for overlapping entries") +} + +// test for https://github.com/jansorg/tom-ui/issues/91 +// make sure that entries which overlap the filter range are properly handled +func TestReportTimeFilterNoOverlap(t *testing.T) { + ctx, err := test_setup.CreateTestContext(language.German) + require.NoError(t, err) + defer test_setup.CleanupTestContext(ctx) + + p1, _, err := ctx.StoreHelper.GetOrCreateNestedProjectNames("top") + require.NoError(t, err) + + start := time.Date(2019, time.January, 15, 12, 0, 0, 0, time.UTC) + end := start.Add(1 * time.Hour) + + // the frame start 10 minutes before and end 10 minutes after the filter range + frameStart := start.Add(-10 * time.Minute) + frameEnd := end.Add(10 * time.Minute) + + frames := model.NewEmptyFrameList() + frames.Append(&model.Frame{Start: &frameStart, End: &frameEnd, ProjectId: p1.ID}) + + // without broader filter, i.e. without overlapping + beforeStart := start.AddDate(0, 0, -1) + afterEnd := end.AddDate(0, 0, 1) + + report := NewBucketReport(frames, Config{ + ProjectIDs: []string{p1.ID}, + IncludeSubprojects: true, + Splitting: []SplitOperation{SplitByProject}, + DateFilterRange: dateTime.NewDateRange(&beforeStart, &afterEnd, ctx.Locale), + Timezone: time.UTC, + }, ctx) + report.Update() + assert.EqualValues(t, 1*time.Hour+20*time.Minute, report.Result().Duration.GetExact(), "expected full duration for non-overlapping frames") +} + +func TestReportTimeFilterOverlapMultipleMonths(t *testing.T) { + ctx, err := test_setup.CreateTestContext(language.German) + require.NoError(t, err) + defer test_setup.CleanupTestContext(ctx) + + p1, _, err := ctx.StoreHelper.GetOrCreateNestedProjectNames("top") + require.NoError(t, err) + + // January 2019 + start := time.Date(2019, time.January, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 0, 1) + + // the frame start 10 minutes before and end 10 minutes after the filter range + frameStart := start.Add(-10 * time.Minute) + frameEnd := end.Add(10 * time.Minute) + + frames := model.NewEmptyFrameList() + frames.Append(&model.Frame{Start: &frameStart, End: &frameEnd, ProjectId: p1.ID}) + + // no filter, but the month buckets must properly split the single frame + report := NewBucketReport(frames, Config{ + ProjectIDs: []string{p1.ID}, + IncludeSubprojects: true, + Splitting: []SplitOperation{SplitByDay}, + Timezone: time.UTC, + }, ctx) + report.Update() + + assert.EqualValues(t, 24*time.Hour+20*time.Minute, report.Result().Duration.GetExact()) + + subBuckets := report.Result().ChildBuckets + + require.EqualValues(t, 3, len(subBuckets)) + + assert.EqualValues(t, 10*time.Minute, subBuckets[0].Duration.GetExact()) + assert.EqualValues(t, 1, subBuckets[0].FrameCount) + assert.EqualValues(t, 10*time.Minute, (*subBuckets[0].Frames)[0].Duration()) + + assert.EqualValues(t, 24*time.Hour, subBuckets[1].Duration.GetExact()) + assert.EqualValues(t, 1, subBuckets[1].FrameCount) + + assert.EqualValues(t, 10*time.Minute, subBuckets[2].Duration.GetExact()) + assert.EqualValues(t, 1, subBuckets[2].FrameCount) +} + func newDate(year int, month time.Month, day, hour, minute int) *time.Time { date := time.Date(year, month, day, hour, minute, 0, 0, time.Local) return &date