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