package services import ( "github.com/duke-git/lancet/v2/datetime" "github.com/duke-git/lancet/v2/mathutil" "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, filters *models.Filters) (models.Durations, error) { get := srv.heartbeatService.GetAllWithin if filters != nil && !filters.IsEmpty() { get = func(t1 time.Time, t2 time.Time, user *models.User) ([]*models.Heartbeat, error) { return srv.heartbeatService.GetAllWithinByFilters(t1, t2, user, filters) } } heartbeats, err := get(from, to, user) if err != nil { return nil, err } // Aggregation // the below logic is approximately equivalent to the SQL query at scripts/aggregate_durations.sql, // but unfortunately we cannot use it, as it features mysql-specific functions (lag(), timediff(), ...) var count int var latest *models.Duration mapping := make(map[string][]*models.Duration) for _, h := range heartbeats { if filters != nil && !filters.Match(h) { continue } 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 } sameDay := datetime.BeginOfDay(d1.Time.T()) == datetime.BeginOfDay(latest.Time.T()) dur := time.Duration(mathutil.Min( int64(d1.Time.T().Sub(latest.Time.T().Add(latest.Duration))), int64(HeartbeatDiffThreshold), )) // skip heartbeats that span across two adjacent summaries (assuming there are no more than 1 summary per day) // this is relevant to prevent the time difference between generating summaries from raw heartbeats and aggregating pre-generated summaries // for the latter case, the very last heartbeat of a day won't be counted, so we don't want to count it here either // another option would be to adapt the Summarize() method to always append up to HeartbeatDiffThreshold seconds to a day's very last duration if !sameDay { dur = 0 } latest.Duration += dur // start new "group" if: // (a) heartbeats were too far apart each other, // (b) if they are of a different entity or, // (c) if they span across two days if dur >= HeartbeatDiffThreshold || latest.GroupHash != d1.GroupHash || !sameDay { list := mapping[d1.GroupHash] if d0 := list[len(list)-1]; d0 != d1 { mapping[d1.GroupHash] = append(mapping[d1.GroupHash], d1) } latest = d1 } else { latest.NumHeartbeats++ } count++ } durations := make(models.Durations, 0, count) for _, list := range mapping { for _, d := range list { // will only happen if two heartbeats with different hashes (e.g. different project) have the same timestamp // that, in turn, will most likely only happen for mysql, where `time` column's precision was set to second for a while // assume that two non-identical heartbeats with identical time are sub-second apart from each other, so round up to expectancy value // also see https://github.com/muety/wakapi/issues/340 if d.Duration == 0 { d.Duration = 500 * time.Millisecond } durations = append(durations, d) } } if len(heartbeats) == 1 && len(durations) == 1 { durations[0].Duration = HeartbeatDiffThreshold } return durations.Sorted(), nil }