Skip to content

Commit 632020c

Browse files
committed
feat: current online users count (resolve #798)
1 parent fcf130b commit 632020c

File tree

11 files changed

+409
-313
lines changed

11 files changed

+409
-313
lines changed

coverage/coverage.out

Lines changed: 307 additions & 291 deletions
Large diffs are not rendered by default.

mocks/user_service.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ func (m *UserServiceMock) Count() (int64, error) {
7474
return int64(args.Int(0)), args.Error(1)
7575
}
7676

77+
func (m *UserServiceMock) CountCurrentlyOnline() (int, error) {
78+
args := m.Called()
79+
return args.Int(0), args.Error(1)
80+
}
81+
7782
func (m *UserServiceMock) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
7883
args := m.Called(signup, isAdmin)
7984
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)

models/view/home.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ type Newsbox struct {
77

88
type HomeViewModel struct {
99
SharedViewModel
10-
TotalHours int
11-
TotalUsers int
12-
Newsbox *Newsbox
10+
TotalHours int
11+
TotalUsers int
12+
CurrentlyOnline int
13+
Newsbox *Newsbox
1314
}
1415

1516
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {

routes/home.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,12 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
5959
}
6060

6161
func (h *HomeHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *view.HomeViewModel {
62-
var totalHours int
63-
var totalUsers int
64-
var newsbox view.Newsbox
62+
var (
63+
totalHours int
64+
totalUsers int
65+
currentlyOnline int
66+
newsbox view.Newsbox
67+
)
6568

6669
if kv, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && kv != nil && kv.Value != "" {
6770
if d, err := time.ParseDuration(kv.Value); err == nil {
@@ -81,12 +84,19 @@ func (h *HomeHandler) buildViewModel(r *http.Request, w http.ResponseWriter) *vi
8184
}
8285
}
8386

87+
if c, err := h.userSrvc.CountCurrentlyOnline(); err == nil {
88+
currentlyOnline = c
89+
} else {
90+
conf.Log().Request(r).Error("failed to count currently online users", "error", err)
91+
}
92+
8493
sharedVm := view.NewSharedViewModel(h.config, nil)
8594
sharedVm.LeaderboardEnabled = sharedVm.LeaderboardEnabled && !h.config.App.LeaderboardRequireAuth // logged in users will never actually see the home route's view
8695
vm := &view.HomeViewModel{
8796
SharedViewModel: sharedVm,
8897
TotalHours: totalHours,
8998
TotalUsers: totalUsers,
99+
CurrentlyOnline: currentlyOnline,
90100
Newsbox: &newsbox,
91101
}
92102
return routeutils.WithSessionMessages(vm, r, w)

routes/home_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func TestHomeHandler_Get_NotLoggedIn(t *testing.T) {
3737

3838
userServiceMock := new(mocks.UserServiceMock)
3939
userServiceMock.On("GetUserById", user1.ID).Return(&user1, nil)
40+
userServiceMock.On("CountCurrentlyOnline").Return(0, nil)
4041

4142
keyValueServiceMock := new(mocks.KeyValueServiceMock)
4243
keyValueServiceMock.On("GetString", config.KeyLatestTotalTime).Return(&models.KeyStringValue{Key: config.KeyLatestTotalTime, Value: "0"}, nil)

services/services.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ type IUserService interface {
149149
GetAllByLeaderboard(bool) ([]*models.User, error)
150150
GetActive(bool) ([]*models.User, error)
151151
Count() (int64, error)
152+
CountCurrentlyOnline() (int, error)
152153
CreateOrGet(*models.Signup, bool) (*models.User, bool, error)
153154
Update(*models.User) (*models.User, error)
154155
Delete(*models.User) error

services/user.go

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,30 @@ import (
1414
"github.com/patrickmn/go-cache"
1515
"log/slog"
1616
"strings"
17+
"sync/atomic"
1718
"time"
1819
)
1920

2021
type UserService struct {
21-
config *config.Config
22-
cache *cache.Cache
23-
eventBus *hub.Hub
24-
keyValueService IKeyValueService
25-
mailService IMailService
26-
repository repositories.IUserRepository
22+
config *config.Config
23+
cache *cache.Cache
24+
eventBus *hub.Hub
25+
keyValueService IKeyValueService
26+
mailService IMailService
27+
repository repositories.IUserRepository
28+
currentOnlineUsers *cache.Cache
29+
countersInitialized atomic.Bool
2730
}
2831

2932
func NewUserService(keyValueService IKeyValueService, mailService IMailService, userRepo repositories.IUserRepository) *UserService {
3033
srv := &UserService{
31-
config: config.Get(),
32-
eventBus: config.EventBus(),
33-
cache: cache.New(1*time.Hour, 2*time.Hour),
34-
keyValueService: keyValueService,
35-
mailService: mailService,
36-
repository: userRepo,
34+
config: config.Get(),
35+
eventBus: config.EventBus(),
36+
cache: cache.New(1*time.Hour, 2*time.Hour),
37+
keyValueService: keyValueService,
38+
mailService: mailService,
39+
repository: userRepo,
40+
currentOnlineUsers: cache.New(models.DefaultHeartbeatsTimeout, 1*time.Minute),
3741
}
3842

3943
sub1 := srv.eventBus.Subscribe(0, config.EventWakatimeFailure)
@@ -58,6 +62,18 @@ func NewUserService(keyValueService IKeyValueService, mailService IMailService,
5862
}
5963
}(&sub1)
6064

65+
sub2 := srv.eventBus.Subscribe(0, config.EventHeartbeatCreate)
66+
go func(sub *hub.Subscription) {
67+
for m := range sub.Receiver {
68+
heartbeat := m.Fields[config.FieldPayload].(*models.Heartbeat)
69+
if time.Now().Sub(heartbeat.Time.T()) > models.DefaultHeartbeatsTimeout {
70+
continue
71+
}
72+
srv.currentOnlineUsers.SetDefault(heartbeat.UserID, true)
73+
slog.Info("user became active again", "timeout", models.DefaultHeartbeatsTimeout, "user", heartbeat.UserID)
74+
}
75+
}(&sub2)
76+
6177
return srv
6278
}
6379

@@ -174,6 +190,22 @@ func (srv *UserService) Count() (int64, error) {
174190
return srv.repository.Count()
175191
}
176192

193+
func (srv *UserService) CountCurrentlyOnline() (int, error) {
194+
if !srv.countersInitialized.Load() {
195+
minDate := time.Now().Add(-1 * models.DefaultHeartbeatsTimeout)
196+
result, err := srv.repository.GetByLastActiveAfter(minDate)
197+
if err != nil {
198+
return 0, err
199+
}
200+
for _, r := range result {
201+
srv.currentOnlineUsers.SetDefault(r.ID, true)
202+
}
203+
srv.countersInitialized.Store(true)
204+
}
205+
206+
return srv.currentOnlineUsers.ItemCount(), nil
207+
}
208+
177209
func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
178210
u := &models.User{
179211
ID: signup.Username,

static/assets/css/app.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,31 @@ main {
199199
.wi-min {
200200
width: min-content !important;
201201
}
202+
203+
#online-indicator {
204+
}
205+
206+
.live-indicator {
207+
width: 10px;
208+
height: 10px;
209+
border-radius: 50%;
210+
background-color: #047857;
211+
display: inline-block;
212+
animation: pulse 2s infinite;
213+
}
214+
215+
@keyframes pulse {
216+
0% {
217+
opacity: 0.5;
218+
}
219+
50% {
220+
opacity: 1;
221+
}
222+
100% {
223+
opacity: 0.5;
224+
}
225+
}
226+
202227
@media print {
203228

204229
/* Avoid the element from being breaked */

static/assets/css/app.dist.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

static/assets/css/app.dist.css.br

78 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)