Skip to content

Commit

Permalink
refactor: introduce concept of durations (resolve #261)
Browse files Browse the repository at this point in the history
  • Loading branch information
muety committed Jan 2, 2022
1 parent 15c8838 commit 5d86ea5
Show file tree
Hide file tree
Showing 10 changed files with 443 additions and 110 deletions.
4 changes: 3 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ var (
userService services.IUserService
languageMappingService services.ILanguageMappingService
projectLabelService services.IProjectLabelService
durationService services.IDurationService
summaryService services.ISummaryService
aggregationService services.IAggregationService
mailService services.IMailService
Expand Down Expand Up @@ -154,7 +155,8 @@ func main() {
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService)
durationService = services.NewDurationService(heartbeatService)
summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService)
Expand Down
16 changes: 16 additions & 0 deletions mocks/duration_service.go
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)
}
64 changes: 64 additions & 0 deletions models/duration.go
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
}
38 changes: 38 additions & 0 deletions models/durations.go
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]
}
77 changes: 77 additions & 0 deletions services/duration.go
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
}
168 changes: 168 additions & 0 deletions services/duration_test.go
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
}
4 changes: 4 additions & 0 deletions services/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ type IMailService interface {
SendReport(*models.User, *models.Report) error
}

type IDurationService interface {
Get(time.Time, time.Time, *models.User) ([]*models.Duration, error)
}

type ISummaryService interface {
Aliased(time.Time, time.Time, *models.User, SummaryRetriever, bool) (*models.Summary, error)
Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error)
Expand Down
Loading

0 comments on commit 5d86ea5

Please sign in to comment.