-
-
Notifications
You must be signed in to change notification settings - Fork 176
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: introduce concept of durations (resolve #261)
- Loading branch information
Showing
10 changed files
with
443 additions
and
110 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
package mocks | ||
|
||
import ( | ||
"github.com/muety/wakapi/models" | ||
"github.com/stretchr/testify/mock" | ||
"time" | ||
) | ||
|
||
type DurationServiceMock struct { | ||
mock.Mock | ||
} | ||
|
||
func (m *DurationServiceMock) Get(time time.Time, time2 time.Time, user *models.User) ([]*models.Duration, error) { | ||
args := m.Called(time, time2, user) | ||
return args.Get(0).([]*models.Duration), args.Error(1) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package models | ||
|
||
import ( | ||
"fmt" | ||
"github.com/emvi/logbuch" | ||
"github.com/mitchellh/hashstructure/v2" | ||
"time" | ||
) | ||
|
||
type Duration struct { | ||
UserID string `json:"user_id"` | ||
Time CustomTime `json:"time" hash:"ignore"` | ||
Duration time.Duration `json:"duration" hash:"ignore"` | ||
Project string `json:"project"` | ||
Language string `json:"language"` | ||
Editor string `json:"editor"` | ||
OperatingSystem string `json:"operating_system"` | ||
Machine string `json:"machine"` | ||
GroupHash string `json:"-" hash:"ignore"` | ||
} | ||
|
||
func NewDurationFromHeartbeat(h *Heartbeat) *Duration { | ||
d := &Duration{ | ||
UserID: h.UserID, | ||
Time: h.Time, | ||
Duration: 0, | ||
Project: h.Project, | ||
Language: h.Language, | ||
Editor: h.Editor, | ||
OperatingSystem: h.OperatingSystem, | ||
Machine: h.Machine, | ||
} | ||
return d.Hashed() | ||
} | ||
|
||
func (d *Duration) Hashed() *Duration { | ||
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil) | ||
if err != nil { | ||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err) | ||
} | ||
d.GroupHash = fmt.Sprintf("%x", hash) | ||
return d | ||
} | ||
|
||
func (d *Duration) GetKey(t uint8) (key string) { | ||
switch t { | ||
case SummaryProject: | ||
key = d.Project | ||
case SummaryEditor: | ||
key = d.Editor | ||
case SummaryLanguage: | ||
key = d.Language | ||
case SummaryOS: | ||
key = d.OperatingSystem | ||
case SummaryMachine: | ||
key = d.Machine | ||
} | ||
|
||
if key == "" { | ||
key = UnknownSummaryKey | ||
} | ||
|
||
return key | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package models | ||
|
||
import "sort" | ||
|
||
type Durations []*Duration | ||
|
||
func (d Durations) Len() int { | ||
return len(d) | ||
} | ||
|
||
func (d Durations) Less(i, j int) bool { | ||
return d[i].Time.T().Before(d[j].Time.T()) | ||
} | ||
|
||
func (d Durations) Swap(i, j int) { | ||
d[i], d[j] = d[j], d[i] | ||
} | ||
|
||
func (d *Durations) Sorted() *Durations { | ||
sort.Sort(d) | ||
return d | ||
} | ||
|
||
func (d *Durations) First() *Duration { | ||
// assumes slice to be sorted | ||
if d.Len() == 0 { | ||
return nil | ||
} | ||
return (*d)[0] | ||
} | ||
|
||
func (d *Durations) Last() *Duration { | ||
// assumes slice to be sorted | ||
if d.Len() == 0 { | ||
return nil | ||
} | ||
return (*d)[d.Len()-1] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
package services | ||
|
||
import ( | ||
"github.com/muety/wakapi/config" | ||
"github.com/muety/wakapi/models" | ||
"time" | ||
) | ||
|
||
const HeartbeatDiffThreshold = 2 * time.Minute | ||
|
||
type DurationService struct { | ||
config *config.Config | ||
heartbeatService IHeartbeatService | ||
} | ||
|
||
func NewDurationService(heartbeatService IHeartbeatService) *DurationService { | ||
srv := &DurationService{ | ||
config: config.Get(), | ||
heartbeatService: heartbeatService, | ||
} | ||
return srv | ||
} | ||
|
||
func (srv *DurationService) Get(from, to time.Time, user *models.User) ([]*models.Duration, error) { | ||
heartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
// Aggregation | ||
var count int | ||
var latest *models.Duration | ||
|
||
mapping := make(map[string][]*models.Duration) | ||
|
||
for _, h := range heartbeats { | ||
d1 := models.NewDurationFromHeartbeat(h) | ||
|
||
if list, ok := mapping[d1.GroupHash]; !ok || len(list) < 1 { | ||
mapping[d1.GroupHash] = []*models.Duration{d1} | ||
} | ||
|
||
if latest == nil { | ||
latest = d1 | ||
continue | ||
} | ||
|
||
dur := d1.Time.T().Sub(latest.Time.T().Add(latest.Duration)) | ||
if dur > HeartbeatDiffThreshold { | ||
dur = HeartbeatDiffThreshold | ||
} | ||
latest.Duration += dur | ||
|
||
if dur >= HeartbeatDiffThreshold || latest.GroupHash != d1.GroupHash { | ||
list := mapping[d1.GroupHash] | ||
if d0 := list[len(list)-1]; d0 != d1 { | ||
mapping[d1.GroupHash] = append(mapping[d1.GroupHash], d1) | ||
} | ||
latest = d1 | ||
} | ||
|
||
count++ | ||
} | ||
|
||
durations := make([]*models.Duration, 0, count) | ||
|
||
for _, list := range mapping { | ||
for _, d := range list { | ||
if d.Duration == 0 { | ||
d.Duration = HeartbeatDiffThreshold | ||
} | ||
durations = append(durations, d) | ||
} | ||
} | ||
|
||
return durations, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
package services | ||
|
||
import ( | ||
"github.com/muety/wakapi/mocks" | ||
"github.com/muety/wakapi/models" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/suite" | ||
"math/rand" | ||
"testing" | ||
"time" | ||
) | ||
|
||
const ( | ||
TestUserId = "muety" | ||
TestProject1 = "test-project-1" | ||
TestProject2 = "test-project-2" | ||
TestLanguageGo = "Go" | ||
TestLanguageJava = "Java" | ||
TestLanguagePython = "Python" | ||
TestEditorGoland = "GoLand" | ||
TestEditorIntellij = "idea" | ||
TestEditorVscode = "vscode" | ||
TestOsLinux = "Linux" | ||
TestOsWin = "Windows" | ||
TestMachine1 = "muety-desktop" | ||
TestMachine2 = "muety-work" | ||
MinUnixTime1 = 1601510400000 * 1e6 | ||
) | ||
|
||
type DurationServiceTestSuite struct { | ||
suite.Suite | ||
TestUser *models.User | ||
TestStartTime time.Time | ||
TestHeartbeats []*models.Heartbeat | ||
TestLabels []*models.ProjectLabel | ||
HeartbeatService *mocks.HeartbeatServiceMock | ||
} | ||
|
||
func (suite *DurationServiceTestSuite) SetupSuite() { | ||
suite.TestUser = &models.User{ID: TestUserId} | ||
|
||
suite.TestStartTime = time.Unix(0, MinUnixTime1) | ||
suite.TestHeartbeats = []*models.Heartbeat{ | ||
{ | ||
ID: uint(rand.Uint32()), | ||
UserID: TestUserId, | ||
Project: TestProject1, | ||
Language: TestLanguageGo, | ||
Editor: TestEditorGoland, | ||
OperatingSystem: TestOsLinux, | ||
Machine: TestMachine1, | ||
Time: models.CustomTime(suite.TestStartTime), // 0:00 | ||
}, | ||
{ | ||
ID: uint(rand.Uint32()), | ||
UserID: TestUserId, | ||
Project: TestProject1, | ||
Language: TestLanguageGo, | ||
Editor: TestEditorGoland, | ||
OperatingSystem: TestOsLinux, | ||
Machine: TestMachine1, | ||
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), // 0:30 | ||
}, | ||
{ | ||
ID: uint(rand.Uint32()), | ||
UserID: TestUserId, | ||
Project: TestProject1, | ||
Language: TestLanguageGo, | ||
Editor: TestEditorGoland, | ||
OperatingSystem: TestOsLinux, | ||
Machine: TestMachine1, | ||
Time: models.CustomTime(suite.TestStartTime.Add((30 + 130) * time.Second)), // 2:40 | ||
}, | ||
{ | ||
ID: uint(rand.Uint32()), | ||
UserID: TestUserId, | ||
Project: TestProject1, | ||
Language: TestLanguageGo, | ||
Editor: TestEditorVscode, | ||
OperatingSystem: TestOsLinux, | ||
Machine: TestMachine1, | ||
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)), // 3:00 | ||
}, | ||
{ | ||
ID: uint(rand.Uint32()), | ||
UserID: TestUserId, | ||
Project: TestProject1, | ||
Language: TestLanguageGo, | ||
Editor: TestEditorVscode, | ||
OperatingSystem: TestOsLinux, | ||
Machine: TestMachine1, | ||
Time: models.CustomTime(suite.TestStartTime.Add(3*time.Minute + 10*time.Second)), // 3:10 | ||
}, | ||
{ | ||
ID: uint(rand.Uint32()), | ||
UserID: TestUserId, | ||
Project: TestProject1, | ||
Language: TestLanguageGo, | ||
Editor: TestEditorVscode, | ||
OperatingSystem: TestOsLinux, | ||
Machine: TestMachine1, | ||
Time: models.CustomTime(suite.TestStartTime.Add(3*time.Minute + 15*time.Second)), // 3:15 | ||
}, | ||
} | ||
} | ||
|
||
func (suite *DurationServiceTestSuite) BeforeTest(suiteName, testName string) { | ||
suite.HeartbeatService = new(mocks.HeartbeatServiceMock) | ||
} | ||
|
||
func TestDurationServiceTestSuite(t *testing.T) { | ||
suite.Run(t, new(DurationServiceTestSuite)) | ||
} | ||
|
||
func (suite *DurationServiceTestSuite) TestDurationService_Get() { | ||
sut := NewDurationService(suite.HeartbeatService) | ||
|
||
var ( | ||
from time.Time | ||
to time.Time | ||
durations models.Durations | ||
err error | ||
) | ||
|
||
/* TEST 1 */ | ||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute) | ||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil) | ||
|
||
durations, err = sut.Get(from, to, suite.TestUser) | ||
|
||
assert.Nil(suite.T(), err) | ||
assert.Empty(suite.T(), durations) | ||
|
||
/* TEST 2 */ | ||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second) | ||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil) | ||
|
||
durations, err = sut.Get(from, to, suite.TestUser) | ||
|
||
assert.Nil(suite.T(), err) | ||
assert.Len(suite.T(), durations, 1) | ||
assert.Equal(suite.T(), HeartbeatDiffThreshold, durations.First().Duration) | ||
|
||
/* TEST 3 */ | ||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) | ||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil) | ||
|
||
durations, err = sut.Get(from, to, suite.TestUser) | ||
|
||
assert.Nil(suite.T(), err) | ||
assert.Len(suite.T(), durations, 3) | ||
assert.Equal(suite.T(), 150*time.Second, durations[0].Duration) | ||
assert.Equal(suite.T(), 20*time.Second, durations[1].Duration) | ||
assert.Equal(suite.T(), 15*time.Second, durations[2].Duration) | ||
assert.Equal(suite.T(), TestEditorGoland, durations[0].Editor) | ||
assert.Equal(suite.T(), TestEditorGoland, durations[1].Editor) | ||
assert.Equal(suite.T(), TestEditorVscode, durations[2].Editor) | ||
} | ||
|
||
func filterHeartbeats(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat { | ||
filtered := make([]*models.Heartbeat, 0, len(heartbeats)) | ||
for _, h := range heartbeats { | ||
if (h.Time.T().Equal(from) || h.Time.T().After(from)) && h.Time.T().Before(to) { | ||
filtered = append(filtered, h) | ||
} | ||
} | ||
return filtered | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.