Skip to content

Commit

Permalink
feat: configurable heartbeats timeout and offset (resolve muety#156)
Browse files Browse the repository at this point in the history
  • Loading branch information
muety committed Aug 11, 2024
1 parent 7b2f6a3 commit 224c28f
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 13 deletions.
14 changes: 14 additions & 0 deletions models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import (
"time"
)

const (
DefaultHeartbeatsTimeout = 2 * time.Minute
MinHeartbeatsTimeout = 30 * time.Second
MaxHeartbeatsTimeout = 5 * time.Minute
)

func init() {
mailRegex = regexp.MustCompile(MailPattern)
}
Expand Down Expand Up @@ -42,6 +48,7 @@ type User struct {
StripeCustomerId string `json:"-"`
InvitedBy string `json:"-"`
ExcludeUnknownProjects bool `json:"-"`
HeartbeatsTimeoutSec int `json:"-" gorm:"default:120"` // https://github.com/muety/wakapi/issues/156
}

type Login struct {
Expand Down Expand Up @@ -128,6 +135,13 @@ func (u *User) AvatarURL(urlTemplate string) string {
return urlTemplate
}

func (u *User) HeartbeatsTimeout() time.Duration {
if u.HeartbeatsTimeoutSec > 0 {
return time.Duration(u.HeartbeatsTimeoutSec) * time.Second
}
return DefaultHeartbeatsTimeout
}

// WakaTimeURL returns the user's effective WakaTime URL, i.e. a custom one (which could also point to another Wakapi instance) or fallback if not specified otherwise.
func (u *User) WakaTimeURL(fallback string) string {
if u.WakatimeApiUrl != "" {
Expand Down
1 change: 1 addition & 0 deletions repositories/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"stripe_customer_id": user.StripeCustomerId,
"invited_by": user.InvitedBy,
"exclude_unknown_projects": user.ExcludeUnknownProjects,
"heartbeats_timeout_sec": user.HeartbeatsTimeoutSec,
}

result := r.db.Model(user).Updates(updateMap)
Expand Down
24 changes: 24 additions & 0 deletions routes/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionGenerateInvite
case "update_unknown_projects":
return h.actionUpdateExcludeUnknownProjects
case "update_heartbeats_timeout":
return h.actionUpdateHeartbeatsTimeout
}
return nil
}
Expand Down Expand Up @@ -335,6 +337,28 @@ func (h *SettingsHandler) actionUpdateExcludeUnknownProjects(w http.ResponseWrit
return actionResult{http.StatusOK, "regenerating summaries, this might take a while", "", nil}
}

func (h *SettingsHandler) actionUpdateHeartbeatsTimeout(w http.ResponseWriter, r *http.Request) actionResult {
if h.config.IsDev() {
loadTemplates()
}

var err error
user := middlewares.GetPrincipal(r)
defer h.userSrvc.FlushCache()

val, err := strconv.ParseInt(r.PostFormValue("heartbeats_timeout"), 0, 0)
if dur := time.Duration(val) * time.Second; err != nil || dur < models.MinHeartbeatsTimeout || dur > models.MaxHeartbeatsTimeout {
return actionResult{http.StatusBadRequest, "", "invalid input", nil}
}
user.HeartbeatsTimeoutSec = int(val)

if _, err := h.userSrvc.Update(user); err != nil {
return actionResult{http.StatusInternalServerError, "", "internal sever error", nil}
}

return actionResult{http.StatusOK, "Done. To apply this change to already existing data, please regenerate your summaries.", "", nil}
}

func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Request) actionResult {
if h.config.IsDev() {
loadTemplates()
Expand Down
14 changes: 7 additions & 7 deletions services/duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (
"time"
)

const HeartbeatDiffThreshold = 2 * time.Minute

type DurationService struct {
config *config.Config
heartbeatService IHeartbeatService
Expand All @@ -24,6 +22,8 @@ func NewDurationService(heartbeatService IHeartbeatService) *DurationService {
}

func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *models.Filters) (models.Durations, error) {
heartbeatsTimeout := user.HeartbeatsTimeout()

heartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user)
if err != nil {
return nil, err
Expand Down Expand Up @@ -55,13 +55,13 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
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),
int64(heartbeatsTimeout),
))

// 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
// another option would be to adapt the Summarize() method to always append up to DefaultHeartbeatsTimeout seconds to a day's very last duration
if !sameDay {
dur = 0
}
Expand All @@ -71,7 +71,7 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
// (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 {
if dur >= heartbeatsTimeout || 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)
Expand All @@ -89,7 +89,7 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
for _, list := range mapping {
for _, d := range list {
// even when filters are applied, we'll still have to compute the whole summary first and then filter out non-matching durations
// if we fetched only matching heartbeats in the first place, there will be false positive gaps (see HeartbeatDiffThreshold)
// if we fetched only matching heartbeats in the first place, there will be false positive gaps (see DefaultHeartbeatsTimeout)
// in case the user worked on different projects in parallel
// see https://github.com/muety/wakapi/issues/535
if filters != nil && !filters.MatchDuration(d) {
Expand All @@ -112,7 +112,7 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
}

if len(heartbeats) == 1 && len(durations) == 1 {
durations[0].Duration = HeartbeatDiffThreshold
durations[0].Duration = heartbeatsTimeout
}

return durations.Sorted(), nil
Expand Down
55 changes: 54 additions & 1 deletion services/duration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type DurationServiceTestSuite struct {
func (suite *DurationServiceTestSuite) SetupSuite() {
suite.TestUser = &models.User{ID: TestUserId}

// https://anchr.io/i/F0HEK.jpg
suite.TestStartTime = time.Unix(0, MinUnixTime1)
suite.TestHeartbeats = []*models.Heartbeat{
{
Expand Down Expand Up @@ -131,6 +132,7 @@ func TestDurationServiceTestSuite(t *testing.T) {
}

func (suite *DurationServiceTestSuite) TestDurationService_Get() {
// https://anchr.io/i/F0HEK.jpg
sut := NewDurationService(suite.HeartbeatService)

var (
Expand All @@ -157,7 +159,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() {

assert.Nil(suite.T(), err)
assert.Len(suite.T(), durations, 1)
assert.Equal(suite.T(), HeartbeatDiffThreshold, durations.First().Duration)
assert.Equal(suite.T(), models.DefaultHeartbeatsTimeout, durations.First().Duration)
assert.Equal(suite.T(), 1, durations.First().NumHeartbeats)

/* TEST 3 */
Expand Down Expand Up @@ -200,6 +202,57 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get_Filtered() {
}
}

func (suite *DurationServiceTestSuite) TestDurationService_Get_CustomTimeout() {
sut := NewDurationService(suite.HeartbeatService)

var (
from time.Time
to time.Time
durations models.Durations
)

defer func() {
suite.TestUser.HeartbeatsTimeoutSec = int(models.DefaultHeartbeatsTimeout / time.Second) // revert to defaults
}()

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)

/* Test 1 */
suite.TestUser.HeartbeatsTimeoutSec = 60
durations, _ = sut.Get(from, to, suite.TestUser, nil)

assert.Len(suite.T(), durations, 3)
assert.Equal(suite.T(), 90*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(), 3, durations[0].NumHeartbeats)
assert.Equal(suite.T(), 1, durations[1].NumHeartbeats)
assert.Equal(suite.T(), 3, durations[2].NumHeartbeats)

/* Test 2 */
suite.TestUser.HeartbeatsTimeoutSec = 130
durations, _ = sut.Get(from, to, suite.TestUser, nil)

assert.Len(suite.T(), durations, 3)
assert.Equal(suite.T(), 160*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(), 3, durations[0].NumHeartbeats)
assert.Equal(suite.T(), 1, durations[1].NumHeartbeats)
assert.Equal(suite.T(), 3, durations[2].NumHeartbeats)

/* Test 3 */
suite.TestUser.HeartbeatsTimeoutSec = 300
durations, _ = sut.Get(from, to, suite.TestUser, nil)

assert.Len(suite.T(), durations, 2)
assert.Equal(suite.T(), 180*time.Second, durations[0].Duration)
assert.Equal(suite.T(), 15*time.Second, durations[1].Duration)
assert.Equal(suite.T(), 4, durations[0].NumHeartbeats)
assert.Equal(suite.T(), 3, durations[1].NumHeartbeats)
}

func filterHeartbeats(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
for _, h := range heartbeats {
Expand Down
40 changes: 35 additions & 5 deletions views/settings.tpl.html
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ <h1 class="font-semibold text-3xl text-white m-0 mb-4">Settings</h1>
<div class="flex-col w-full md:w-2/3 inline-block space-y-4">
<div class="flex justify-between items-center">
<div class="flex flex-col gap-y-1">
<label class="font-semibold text-gray-300" for="share_projects">Exclude unknown projects</label>
<label class="font-semibold text-gray-300" for="unknown-projects-toggle">Exclude unknown projects</label>
<select autocomplete="off" id="unknown-projects-toggle" name="exclude_unknown_projects" class="select-default wi-min">
<option value="false" class="cursor-pointer" {{ if not .User.ExcludeUnknownProjects }} selected {{ end }}>No
</option>
Expand Down Expand Up @@ -405,7 +405,7 @@ <h3 class="inline-block font-semibold text-gray-300">Rules</h3>
{{ range $i, $mapping := .LanguageMappings }}
<div class="flex items-center mb-2">
<div class="text-gray-300 border-1 w-full inline-block my-1 py-1 text-align text-sm">
&#9656;&nbsp; When filename ends in <span
&#9656;&nbsp; When filename ends in <span
class="text-green-700 chip mr-1">{{ $mapping.Extension }}</span>
then change the <span class="font-semibold">language</span> to <span
class="text-green-700 chip mr-1">{{ $mapping.Language }}</span>
Expand All @@ -426,11 +426,11 @@ <h3 class="inline-block font-semibold text-gray-300">Add Rule</h3>
<input type="hidden" name="action" value="add_mapping">
<div class="flex items-center w-full text-gray-500 text-sm">
<span class="mr-2">When filename ends in</span>
<input class="select-default grow"
<input class="input-default grow"
type="text" id="extension" style="width: 70px"
name="extension" placeholder=".py" minlength="1" required>
<span class="mx-2">change language to</span>
<input class="select-default grow"
<input class="input-default grow"
type="text" id="language" style="width: 100px"
name="language" placeholder="Python" minlength="1" required>
<div class="flex justify-end ml-4">
Expand All @@ -448,6 +448,36 @@ <h3 class="inline-block font-semibold text-gray-300">Add Rule</h3>
<hr class="border-t border-gray-800 my-4">
</div>

<!-- Heartbeats Timeout -->
<form class="w-full" action="" method="post">
<input type="hidden" name="action" value="update_heartbeats_timeout">
<div class="flex flex-wrap md:flex-nowrap mb-2 gap-x-4">
<div class="w-full md:w-1/3 mb-2 md:mb-0 inline-block">
<span class="font-semibold text-gray-300 text-lg">Heartbeats Timeout</span>
<p class="block text-sm text-gray-600">
This parameter affects the heuristic based on which a series of consecutive heartbeats sent by your IDE are aggregated to total coding time. Please see the <i>"How are durations calculated?"</i> section in our <a class="link" href="https://github.com/muety/wakapi?tab=readme-ov-file#-faqs" rel="noreferrer noopener" target="_blank">FAQs</a> as well as the discussion in <a class="link" href="https://github.com/muety/wakapi/issues/156" rel="noreferrer noopener" target="_blank">#156</a>.
</p>
</div>

<div class="flex-col w-full md:w-2/3 inline-block space-y-4">
<div class="flex justify-between items-center">
<div class="flex flex-col flex-grow gap-y-1">
<label class="font-semibold text-gray-300" for="heartbeats_timeout">Timeout / offset (seconds)</label>
<div class="flex gap-x-2 items-center">
<input class="input-default" type="number" id="heartbeats_timeout" name="heartbeats_timeout" style="max-width: 100px;" placeholder="120" min="30" max="300" step="10" required value="{{ .User.HeartbeatsTimeoutSec }}">
<span class="text-gray-600 text-sm">(min. 30 seconds, max. 5 minutes)</span>
</div>
</div>
<button type="submit" class="btn-primary h-min">Save</button>
</div>
</div>
</div>
</form>

<div class="w-full">
<hr class="border-t border-gray-800 my-4">
</div>

<!-- Colors -->
<div class="w-full">
<div class="flex flex-wrap md:flex-nowrap mb-8 gap-x-4">
Expand Down Expand Up @@ -484,7 +514,7 @@ <h3 class="inline-block font-semibold text-gray-300">Add Rule</h3>

<div class="flex gap-x-8">
<div class="grow">
<label class="font-semibold text-gray-300" for="share_projects">Participate in leaderboard</label>
<label class="font-semibold text-gray-300" for="enable_leaderboard">Participate in leaderboard</label>
</div>
<div>
<select autocomplete="off" id="enable_leaderboard" name="enable_leaderboard" class="select-default grow">
Expand Down

0 comments on commit 224c28f

Please sign in to comment.