1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

...

18 Commits

Author SHA1 Message Date
466f2e1786 fix: summary caching (resolve #211) 2021-06-19 12:47:35 +02:00
82b8951437 fix: attempt to fix failing sqlite migrations (resolve #210) 2021-06-13 11:43:24 +02:00
25464e9519 chore: code smells 2021-06-13 10:14:15 +02:00
650fffa344 fix: exclude zero entries again 2021-06-12 12:06:24 +02:00
69627fbe11 fix: exclude zero entries 2021-06-12 12:04:38 +02:00
561198b203 chore: minor ui improvements 2021-06-12 12:01:20 +02:00
7c4a2024b6 chore: link to labels settings 2021-06-12 11:40:13 +02:00
7bcd6890d1 chore: adapt tests and bump version 2021-06-12 11:26:15 +02:00
1e4e530c21 chore: adapt tests 2021-06-12 11:09:24 +02:00
490cca05eb feat: ui for managing project labels 2021-06-12 10:44:19 +02:00
3780ae4255 fix: invalidate user summary cache (fix #209) 2021-06-12 10:43:56 +02:00
628ea0b9dd fix: nil pointer dereference
chore: allow to share labels publicly on settings page
2021-06-12 09:12:28 +02:00
0d64858721 feat: implement project labels (resolve #204) 2021-06-11 20:59:34 +02:00
c1c78d8d5b test: add more api tests 2021-06-11 17:47:33 +02:00
538b9d2463 fix: permissions for stats endpoint 2021-06-11 17:41:45 +02:00
f4612fd542 fix: badge endpoint permission fixes (resolve #205)
fix: reference past x days intervals from now instead of start of day
2021-06-11 16:02:28 +02:00
fb643571d2 Merge remote-tracking branch 'origin/master' 2021-06-10 23:22:58 +02:00
a4d47fb566 test: more api tests [ci skip] 2021-05-29 09:52:26 +02:00
38 changed files with 3102 additions and 724 deletions

View File

@ -4,14 +4,13 @@
<p align="center"> <p align="center">
<img src="https://badges.fw-web.space/github/license/muety/wakapi"> <img src="https://badges.fw-web.space/github/license/muety/wakapi">
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a> <a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi"> <img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
</p> </p>
<p align="center"> <p align="center">
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a> <a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a> <a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a> <a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
</p> </p>

View File

@ -197,6 +197,9 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently { if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
return nil return nil
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,7 @@ var (
heartbeatRepository repositories.IHeartbeatRepository heartbeatRepository repositories.IHeartbeatRepository
userRepository repositories.IUserRepository userRepository repositories.IUserRepository
languageMappingRepository repositories.ILanguageMappingRepository languageMappingRepository repositories.ILanguageMappingRepository
projectLabelRepository repositories.IProjectLabelRepository
summaryRepository repositories.ISummaryRepository summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository keyValueRepository repositories.IKeyValueRepository
) )
@ -58,6 +59,7 @@ var (
heartbeatService services.IHeartbeatService heartbeatService services.IHeartbeatService
userService services.IUserService userService services.IUserService
languageMappingService services.ILanguageMappingService languageMappingService services.ILanguageMappingService
projectLabelService services.IProjectLabelService
summaryService services.ISummaryService summaryService services.ISummaryService
aggregationService services.IAggregationService aggregationService services.IAggregationService
mailService services.IMailService mailService services.IMailService
@ -113,6 +115,7 @@ func main() {
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger}) db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
if config.Db.Dialect == "sqlite3" { if config.Db.Dialect == "sqlite3" {
db.Raw("PRAGMA foreign_keys = ON;") db.Raw("PRAGMA foreign_keys = ON;")
db.DisableForeignKeyConstraintWhenMigrating = true
} }
if config.IsDev() { if config.IsDev() {
@ -135,6 +138,7 @@ func main() {
heartbeatRepository = repositories.NewHeartbeatRepository(db) heartbeatRepository = repositories.NewHeartbeatRepository(db)
userRepository = repositories.NewUserRepository(db) userRepository = repositories.NewUserRepository(db)
languageMappingRepository = repositories.NewLanguageMappingRepository(db) languageMappingRepository = repositories.NewLanguageMappingRepository(db)
projectLabelRepository = repositories.NewProjectLabelRepository(db)
summaryRepository = repositories.NewSummaryRepository(db) summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db) keyValueRepository = repositories.NewKeyValueRepository(db)
@ -142,8 +146,9 @@ func main() {
aliasService = services.NewAliasService(aliasRepository) aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(userRepository) userService = services.NewUserService(userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository) languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService) heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService) summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService) aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
mailService = mail.NewMailService() mailService = mail.NewMailService()
keyValueService = services.NewKeyValueService(keyValueRepository) keyValueService = services.NewKeyValueService(keyValueRepository)
@ -173,7 +178,7 @@ func main() {
// MVC Handlers // MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService) summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService) settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
homeHandler := routes.NewHomeHandler(keyValueService) homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService, mailService) loginHandler := routes.NewLoginHandler(userService, mailService)
imprintHandler := routes.NewImprintHandler(keyValueService) imprintHandler := routes.NewImprintHandler(keyValueService)

View File

@ -0,0 +1,35 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type ProjectLabelServiceMock struct {
mock.Mock
}
func (p *ProjectLabelServiceMock) GetById(u uint) (*models.ProjectLabel, error) {
args := p.Called(u)
return args.Get(0).(*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) GetByUser(s string) ([]*models.ProjectLabel, error) {
args := p.Called(s)
return args.Get(0).([]*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) GetByUserGrouped(s string) (map[string][]*models.ProjectLabel, error) {
args := p.Called(s)
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) Create(l *models.ProjectLabel) (*models.ProjectLabel, error) {
args := p.Called(l)
return args.Get(0).(*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) Delete(l *models.ProjectLabel) error {
args := p.Called(l)
return args.Error(0)
}

View File

@ -6,6 +6,7 @@ type Filters struct {
Language string Language string
Editor string Editor string
Machine string Machine string
Label string
} }
type FilterElement struct { type FilterElement struct {
@ -25,6 +26,8 @@ func NewFiltersWith(entity uint8, key string) *Filters {
return &Filters{Editor: key} return &Filters{Editor: key}
case SummaryMachine: case SummaryMachine:
return &Filters{Machine: key} return &Filters{Machine: key}
case SummaryLabel:
return &Filters{Label: key}
} }
return &Filters{} return &Filters{}
} }
@ -40,6 +43,8 @@ func (f *Filters) One() (bool, uint8, string) {
return true, SummaryEditor, f.Editor return true, SummaryEditor, f.Editor
} else if f.Machine != "" { } else if f.Machine != "" {
return true, SummaryMachine, f.Machine return true, SummaryMachine, f.Machine
} else if f.Label != "" {
return true, SummaryLabel, f.Label
} }
return false, 0, "" return false, 0, ""
} }

13
models/project_label.go Normal file
View File

@ -0,0 +1,13 @@
package models
type ProjectLabel struct {
ID uint `json:"id" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_project_label_user"`
ProjectKey string `json:"project"`
Label string `json:"label" gorm:"type:varchar(64)"`
}
func (l *ProjectLabel) IsValid() bool {
return l.ProjectKey != "" && l.Label != ""
}

View File

@ -1,6 +1,7 @@
package models package models
import ( import (
"errors"
"sort" "sort"
"time" "time"
) )
@ -12,9 +13,11 @@ const (
SummaryEditor uint8 = 2 SummaryEditor uint8 = 2
SummaryOS uint8 = 3 SummaryOS uint8 = 3
SummaryMachine uint8 = 4 SummaryMachine uint8 = 4
SummaryLabel uint8 = 5
) )
const UnknownSummaryKey = "unknown" const UnknownSummaryKey = "unknown"
const DefaultProjectLabel = "default"
type Summary struct { type Summary struct {
ID uint `json:"-" gorm:"primary_key"` ID uint `json:"-" gorm:"primary_key"`
@ -27,6 +30,7 @@ type Summary struct {
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Labels SummaryItems `json:"labels" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
} }
type SummaryItems []*SummaryItem type SummaryItems []*SummaryItem
@ -68,6 +72,10 @@ type SummaryParams struct {
type AliasResolver func(t uint8, k string) string type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 { func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel}
}
func NativeSummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine} return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
} }
@ -77,6 +85,7 @@ func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.OperatingSystems)) sort.Sort(sort.Reverse(s.OperatingSystems))
sort.Sort(sort.Reverse(s.Languages)) sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors)) sort.Sort(sort.Reverse(s.Editors))
sort.Sort(sort.Reverse(s.Labels))
return s return s
} }
@ -91,6 +100,7 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
SummaryEditor: &s.Editors, SummaryEditor: &s.Editors,
SummaryOS: &s.OperatingSystems, SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines, SummaryMachine: &s.Machines,
SummaryLabel: &s.Labels,
} }
} }
@ -109,7 +119,7 @@ of time than the other ones.
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type, To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown". such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
*/ */
func (s *Summary) FillUnknown() { func (s *Summary) FillMissing() {
types := s.Types() types := s.Types()
typeItems := s.MappedItems() typeItems := s.MappedItems()
missingTypes := make([]uint8, 0) missingTypes := make([]uint8, 0)
@ -125,15 +135,46 @@ func (s *Summary) FillUnknown() {
return return
} }
timeSum := s.TotalTime()
// construct dummy item for all missing types // construct dummy item for all missing types
presentType, err := s.findFirstPresentType()
if err != nil {
return // all types are either zero or missing entirely, nothing to fill
}
for _, t := range missingTypes { for _, t := range missingTypes {
*typeItems[t] = append(*typeItems[t], &SummaryItem{ s.FillBy(presentType, t)
Type: t, }
Key: UnknownSummaryKey, }
Total: timeSum,
}) // inplace!
func (s *Summary) FillBy(fromType uint8, toType uint8) {
typeItems := s.MappedItems()
totalWanted := s.TotalTimeBy(fromType)
totalActual := s.TotalTimeBy(toType)
key := UnknownSummaryKey
if toType == SummaryLabel {
key = DefaultProjectLabel
}
existingEntryIdx := -1
for i, item := range *typeItems[toType] {
if item.Key == key {
existingEntryIdx = i
break
}
}
total := (totalWanted - totalActual) / time.Second // workaround
if total > 0 {
if existingEntryIdx >= 0 {
(*typeItems[toType])[existingEntryIdx].Total = total
} else {
*typeItems[toType] = append(*typeItems[toType], &SummaryItem{
Type: toType,
Key: key,
Total: total,
})
}
} }
} }
@ -141,14 +182,12 @@ func (s *Summary) TotalTime() time.Duration {
var timeSum time.Duration var timeSum time.Duration
mappedItems := s.MappedItems() mappedItems := s.MappedItems()
// calculate total duration from any of the present sets of items t, err := s.findFirstPresentType()
for _, t := range s.Types() { if err != nil {
if items := mappedItems[t]; len(*items) > 0 { return 0
for _, item := range *items { }
timeSum += item.Total for _, item := range *mappedItems[t] {
} timeSum += item.Total
break
}
} }
return timeSum * time.Second return timeSum * time.Second
@ -231,10 +270,20 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
s.Languages = processAliases(s.Languages) s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems) s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines) s.Machines = processAliases(s.Machines)
s.Labels = processAliases(s.Labels)
return s return s
} }
func (s *Summary) findFirstPresentType() (uint8, error) {
for _, t := range s.Types() {
if s.TotalTimeBy(t) != 0 {
return t, nil
}
}
return 127, errors.New("no type present")
}
func (s *SummaryItem) TotalFixed() time.Duration { func (s *SummaryItem) TotalFixed() time.Duration {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds // this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
// TODO: fix some day, while migrating persisted summary items // TODO: fix some day, while migrating persisted summary items

View File

@ -6,7 +6,7 @@ import (
"time" "time"
) )
func TestSummary_FillUnknown(t *testing.T) { func TestSummary_FillMissing(t *testing.T) {
testDuration := 10 * time.Minute testDuration := 10 * time.Minute
sut := &Summary{ sut := &Summary{
@ -20,7 +20,7 @@ func TestSummary_FillUnknown(t *testing.T) {
}, },
} }
sut.FillUnknown() sut.FillMissing()
itemLists := [][]*SummaryItem{ itemLists := [][]*SummaryItem{
sut.Machines, sut.Machines,
@ -31,8 +31,12 @@ func TestSummary_FillUnknown(t *testing.T) {
for _, l := range itemLists { for _, l := range itemLists {
assert.Len(t, l, 1) assert.Len(t, l, 1)
assert.Equal(t, UnknownSummaryKey, l[0].Key) assert.Equal(t, UnknownSummaryKey, l[0].Key)
assert.Equal(t, testDuration, l[0].Total) assert.Equal(t, testDuration, l[0].TotalFixed())
} }
assert.Len(t, sut.Labels, 1)
assert.Equal(t, DefaultProjectLabel, sut.Labels[0].Key)
assert.Equal(t, testDuration, sut.Labels[0].TotalFixed())
} }
func TestSummary_TotalTimeBy(t *testing.T) { func TestSummary_TotalTimeBy(t *testing.T) {

View File

@ -23,6 +23,7 @@ type User struct {
ShareProjects bool `json:"-" gorm:"default:false; type:bool"` ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"` ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"` ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
IsAdmin bool `json:"-" gorm:"default:false; type:bool"` IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
HasData bool `json:"-" gorm:"default:false; type:bool"` HasData bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"` WakatimeApiKey string `json:"-"`

View File

@ -6,6 +6,8 @@ type SettingsViewModel struct {
User *models.User User *models.User
LanguageMappings []*models.LanguageMapping LanguageMappings []*models.LanguageMapping
Aliases []*SettingsVMCombinedAlias Aliases []*SettingsVMCombinedAlias
Labels []*SettingsVMCombinedLabel
Projects []string
Success string Success string
Error string Error string
} }
@ -16,6 +18,11 @@ type SettingsVMCombinedAlias struct {
Values []string Values []string
} }
type SettingsVMCombinedLabel struct {
Key string
Values []string
}
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel { func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
s.Success = m s.Success = m
return s return s

View File

@ -0,0 +1,60 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type ProjectLabelRepository struct {
config *config.Config
db *gorm.DB
}
func NewProjectLabelRepository(db *gorm.DB) *ProjectLabelRepository {
return &ProjectLabelRepository{config: config.Get(), db: db}
}
func (r *ProjectLabelRepository) GetAll() ([]*models.ProjectLabel, error) {
var labels []*models.ProjectLabel
if err := r.db.Find(&labels).Error; err != nil {
return nil, err
}
return labels, nil
}
func (r *ProjectLabelRepository) GetById(id uint) (*models.ProjectLabel, error) {
label := &models.ProjectLabel{}
if err := r.db.Where(&models.ProjectLabel{ID: id}).First(label).Error; err != nil {
return label, err
}
return label, nil
}
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
var labels []*models.ProjectLabel
if err := r.db.
Where(&models.ProjectLabel{UserID: userId}).
Find(&labels).Error; err != nil {
return labels, err
}
return labels, nil
}
func (r *ProjectLabelRepository) Insert(label *models.ProjectLabel) (*models.ProjectLabel, error) {
if !label.IsValid() {
return nil, errors.New("invalid label")
}
result := r.db.Create(label)
if err := result.Error; err != nil {
return nil, err
}
return label, nil
}
func (r *ProjectLabelRepository) Delete(id uint) error {
return r.db.
Where("id = ?", id).
Delete(models.ProjectLabel{}).Error
}

View File

@ -46,6 +46,14 @@ type ILanguageMappingRepository interface {
Delete(uint) error Delete(uint) error
} }
type IProjectLabelRepository interface {
GetAll() ([]*models.ProjectLabel, error)
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
Insert(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(uint) error
}
type ISummaryRepository interface { type ISummaryRepository interface {
Insert(*models.Summary) error Insert(*models.Summary) error
GetAll() ([]*models.Summary, error) GetAll() ([]*models.Summary, error)

View File

@ -23,6 +23,7 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
Preload("Editors", "type = ?", models.SummaryEditor). Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS). Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine). Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil { Find(&summaries).Error; err != nil {
return nil, err return nil, err
} }
@ -48,6 +49,7 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
Preload("Editors", "type = ?", models.SummaryEditor). Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS). Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine). Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil { Find(&summaries).Error; err != nil {
return nil, err return nil, err
} }

View File

@ -147,6 +147,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"share_oss": user.ShareOSs, "share_oss": user.ShareOSs,
"share_projects": user.ShareProjects, "share_projects": user.ShareProjects,
"share_machines": user.ShareMachines, "share_machines": user.ShareMachines,
"share_labels": user.ShareLabels,
"wakatime_api_key": user.WakatimeApiKey, "wakatime_api_key": user.WakatimeApiKey,
"has_data": user.HasData, "has_data": user.HasData,
"reset_token": user.ResetToken, "reset_token": user.ResetToken,

View File

@ -27,6 +27,7 @@ const (
DescLanguages = "Total seconds for each language." DescLanguages = "Total seconds for each language."
DescOperatingSystems = "Total seconds for each operating system." DescOperatingSystems = "Total seconds for each operating system."
DescMachines = "Total seconds for each machine." DescMachines = "Total seconds for each machine."
DescLabels = "Total seconds for each project label."
DescAdminTotalTime = "Total seconds (all users, all time)." DescAdminTotalTime = "Total seconds (all users, all time)."
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)" DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
@ -198,6 +199,15 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
}) })
} }
for _, m := range summaryToday.Labels {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_label_seconds_total",
Desc: DescLabels,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
return &metrics, nil return &metrics, nil
} }

View File

@ -16,7 +16,7 @@ import (
const ( const (
intervalPattern = `interval:([a-z0-9_]+)` intervalPattern = `interval:([a-z0-9_]+)`
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)` entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-\s]+)`
) )
type BadgeHandler struct { type BadgeHandler struct {
@ -75,7 +75,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ()) _, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))) minStart := rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))
// negative value means no limit // negative value means no limit
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 { if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
@ -83,22 +83,38 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
var permitEntity bool
var filters *models.Filters var filters *models.Filters
switch filterEntity { switch filterEntity {
case "project": case "project":
permitEntity = user.ShareProjects
filters = models.NewFiltersWith(models.SummaryProject, filterKey) filters = models.NewFiltersWith(models.SummaryProject, filterKey)
case "os": case "os":
permitEntity = user.ShareOSs
filters = models.NewFiltersWith(models.SummaryOS, filterKey) filters = models.NewFiltersWith(models.SummaryOS, filterKey)
case "editor": case "editor":
permitEntity = user.ShareEditors
filters = models.NewFiltersWith(models.SummaryEditor, filterKey) filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
case "language": case "language":
permitEntity = user.ShareLanguages
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey) filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
case "machine": case "machine":
permitEntity = user.ShareMachines
filters = models.NewFiltersWith(models.SummaryMachine, filterKey) filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
case "label":
permitEntity = user.ShareLabels
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
default: default:
permitEntity = true
filters = &models.Filters{} filters = &models.Filters{}
} }
if !permitEntity {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("user did not opt in to share entity-specific data"))
return
}
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey) cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
if cacheResult, ok := h.cache.Get(cacheKey); ok { if cacheResult, ok := h.cache.Get(cacheKey); ok {
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData)) utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))

View File

@ -79,7 +79,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))) minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) && if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 { rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)

View File

@ -121,17 +121,16 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
end = utils.EndOfDay(end).Add(-1 * time.Second) end = utils.EndOfDay(end).Add(-1 * time.Second)
overallParams := &models.SummaryParams{ overallParams := &models.SummaryParams{
From: start, From: start,
To: end, To: end,
User: user, User: user,
Recompute: false,
} }
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To) intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
summaries := make([]*models.Summary, len(intervals)) summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals { for i, interval := range intervals {
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, false) summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, end.After(time.Now()))
if err != nil { if err != nil {
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }

View File

@ -112,6 +112,9 @@ func typeName(t uint8) string {
if t == models.SummaryMachine { if t == models.SummaryMachine {
return "machine" return "machine"
} }
if t == models.SummaryLabel {
return "label"
}
return "unknown" return "unknown"
} }

View File

@ -14,10 +14,14 @@ import (
"github.com/muety/wakapi/services/imports" "github.com/muety/wakapi/services/imports"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings"
"time" "time"
) )
const criticalError = "a critical error has occurred, sorry"
type SettingsHandler struct { type SettingsHandler struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService userSrvc services.IUserService
@ -26,6 +30,7 @@ type SettingsHandler struct {
aliasSrvc services.IAliasService aliasSrvc services.IAliasService
aggregationSrvc services.IAggregationService aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService languageMappingSrvc services.ILanguageMappingService
projectLabelSrvc services.IProjectLabelService
keyValueSrvc services.IKeyValueService keyValueSrvc services.IKeyValueService
mailSrvc services.IMailService mailSrvc services.IMailService
httpClient *http.Client httpClient *http.Client
@ -40,6 +45,7 @@ func NewSettingsHandler(
aliasService services.IAliasService, aliasService services.IAliasService,
aggregationService services.IAggregationService, aggregationService services.IAggregationService,
languageMappingService services.ILanguageMappingService, languageMappingService services.ILanguageMappingService,
projectLabelService services.IProjectLabelService,
keyValueService services.IKeyValueService, keyValueService services.IKeyValueService,
mailService services.IMailService, mailService services.IMailService,
) *SettingsHandler { ) *SettingsHandler {
@ -49,6 +55,7 @@ func NewSettingsHandler(
aliasSrvc: aliasService, aliasSrvc: aliasService,
aggregationSrvc: aggregationService, aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService, languageMappingSrvc: languageMappingService,
projectLabelSrvc: projectLabelService,
userSrvc: userService, userSrvc: userService,
heartbeatSrvc: heartbeatService, heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService, keyValueSrvc: keyValueService,
@ -70,7 +77,6 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
} }
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r)) templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
} }
@ -128,6 +134,10 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionDeleteAlias return h.actionDeleteAlias
case "add_alias": case "add_alias":
return h.actionAddAlias return h.actionAddAlias
case "add_label":
return h.actionAddLabel
case "delete_label":
return h.actionDeleteLabel
case "delete_mapping": case "delete_mapping":
return h.actionDeleteLanguageMapping return h.actionDeleteLanguageMapping
case "add_mapping": case "add_mapping":
@ -252,6 +262,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors")) user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss")) user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines")) user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
user.ShareLabels, err = strconv.ParseBool(r.PostFormValue("share_labels"))
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days")) user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
if err != nil { if err != nil {
@ -313,6 +324,59 @@ func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request)
return http.StatusOK, "alias added successfully", "" return http.StatusOK, "alias added successfully", ""
} }
func (h *SettingsHandler) actionAddLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
label := &models.ProjectLabel{
UserID: user.ID,
ProjectKey: r.PostFormValue("key"),
Label: r.PostFormValue("value"),
}
if !label.IsValid() {
return http.StatusBadRequest, "", "invalid input"
}
if _, err := h.projectLabelSrvc.Create(label); err != nil {
// TODO: distinguish between bad request, conflict and server error
return http.StatusBadRequest, "", "invalid input"
}
return http.StatusOK, "label added successfully", ""
}
func (h *SettingsHandler) actionDeleteLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
labelKey := r.PostFormValue("key")
labelValue := r.PostFormValue("value")
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
if err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
if projectLabels, ok := labelMap[labelKey]; ok {
for _, l := range projectLabels {
if l.Label == labelValue {
if err := h.projectLabelSrvc.Delete(l); err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
return http.StatusOK, "label deleted successfully", ""
}
}
return http.StatusNotFound, "", "label not found"
} else {
return http.StatusNotFound, "", "project not found"
}
}
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) { func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
@ -553,8 +617,16 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel { func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
user := middlewares.GetPrincipal(r) user := middlewares.GetPrincipal(r)
// mappings
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID) mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
// aliases
aliases, err := h.aliasSrvc.GetByUser(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building alias map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
aliasMap := make(map[string][]*models.Alias) aliasMap := make(map[string][]*models.Alias)
for _, a := range aliases { for _, a := range aliases {
k := fmt.Sprintf("%s_%d", a.Key, a.Type) k := fmt.Sprintf("%s_%d", a.Key, a.Type)
@ -578,10 +650,42 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
combinedAliases = append(combinedAliases, ca) combinedAliases = append(combinedAliases, ca)
} }
// labels
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
for _, l := range labelMap {
cl := &view.SettingsVMCombinedLabel{
Key: l[0].ProjectKey,
Values: make([]string, len(l)),
}
for i, l1 := range l {
cl.Values[i] = l1.Label
}
combinedLabels = append(combinedLabels, cl)
}
sort.Slice(combinedLabels, func(i, j int) bool {
return strings.Compare(combinedLabels[i].Key, combinedLabels[j].Key) < 0
})
// projects
projects, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
if err != nil {
conf.Log().Request(r).Error("error while fetching projects - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
sort.Strings(projects)
return &view.SettingsViewModel{ return &view.SettingsViewModel{
User: user, User: user,
LanguageMappings: mappings, LanguageMappings: mappings,
Aliases: combinedAliases, Aliases: combinedAliases,
Labels: combinedLabels,
Projects: projects,
Success: r.URL.Query().Get("success"), Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"), Error: r.URL.Query().Get("error"),
} }

View File

@ -6,6 +6,7 @@ import (
"github.com/muety/wakapi/repositories" "github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"strings"
"time" "time"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
@ -90,8 +91,16 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
if err != nil { if err != nil {
return nil, err return nil, err
} }
srv.cache.Set(cacheKey, utils.StringsToSet(results), cache.DefaultExpiration)
return results, nil filtered := make([]string, 0, len(results))
for _, r := range results {
if strings.TrimSpace(r) != "" {
filtered = append(filtered, r)
}
}
srv.cache.Set(cacheKey, utils.StringsToSet(filtered), cache.DefaultExpiration)
return filtered, nil
} }
func (srv *HeartbeatService) DeleteBefore(t time.Time) error { func (srv *HeartbeatService) DeleteBefore(t time.Time) error {

78
services/project_label.go Normal file
View File

@ -0,0 +1,78 @@
package services
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"time"
)
type ProjectLabelService struct {
config *config.Config
cache *cache.Cache
repository repositories.IProjectLabelRepository
}
func NewProjectLabelService(projectLabelRepository repositories.IProjectLabelRepository) *ProjectLabelService {
return &ProjectLabelService{
config: config.Get(),
repository: projectLabelRepository,
cache: cache.New(24*time.Hour, 24*time.Hour),
}
}
func (srv *ProjectLabelService) GetById(id uint) (*models.ProjectLabel, error) {
return srv.repository.GetById(id)
}
func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel, error) {
if labels, found := srv.cache.Get(userId); found {
return labels.([]*models.ProjectLabel), nil
}
labels, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
srv.cache.Set(userId, labels, cache.DefaultExpiration)
return labels, nil
}
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
labels := make(map[string][]*models.ProjectLabel)
userLabels, err := srv.GetByUser(userId)
if err != nil {
return nil, err
}
for _, l := range userLabels {
if _, ok := labels[l.ProjectKey]; !ok {
labels[l.ProjectKey] = []*models.ProjectLabel{l}
} else {
labels[l.ProjectKey] = append(labels[l.ProjectKey], l)
}
}
return labels, nil
}
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {
result, err := srv.repository.Insert(label)
if err != nil {
return nil, err
}
srv.cache.Delete(result.UserID)
return result, nil
}
func (srv *ProjectLabelService) Delete(label *models.ProjectLabel) error {
if label.UserID == "" {
return errors.New("no user id specified")
}
err := srv.repository.Delete(label.ID)
srv.cache.Delete(label.UserID)
return err
}

View File

@ -54,6 +54,14 @@ type ILanguageMappingService interface {
Delete(mapping *models.LanguageMapping) error Delete(mapping *models.LanguageMapping) error
} }
type IProjectLabelService interface {
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(*models.ProjectLabel) error
}
type IMailService interface { type IMailService interface {
SendPasswordReset(*models.User, string) error SendPasswordReset(*models.User, string) error
SendImportNotification(*models.User, time.Duration, int) error SendImportNotification(*models.User, time.Duration, int) error

View File

@ -1,7 +1,6 @@
package services package services
import ( import (
"crypto/md5"
"errors" "errors"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
@ -10,28 +9,31 @@ import (
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"math" "math"
"sort" "sort"
"strings"
"time" "time"
) )
const HeartbeatDiffThreshold = 2 * time.Minute const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct { type SummaryService struct {
config *config.Config config *config.Config
cache *cache.Cache cache *cache.Cache
repository repositories.ISummaryRepository repository repositories.ISummaryRepository
heartbeatService IHeartbeatService heartbeatService IHeartbeatService
aliasService IAliasService aliasService IAliasService
projectLabelService IProjectLabelService
} }
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error) type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService { func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
return &SummaryService{ return &SummaryService{
config: config.Get(), config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour), cache: cache.New(24*time.Hour, 24*time.Hour),
repository: summaryRepo, repository: summaryRepo,
heartbeatService: heartbeatService, heartbeatService: heartbeatService,
aliasService: aliasService, aliasService: aliasService,
projectLabelService: projectLabelService,
} }
} }
@ -63,17 +65,14 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
// Post-process summary and cache it // Post-process summary and cache it
summary := s.WithResolvedAliases(resolve) summary := s.WithResolvedAliases(resolve)
summary.FillBy(models.SummaryProject, models.SummaryLabel) // first fill up labels from projects
summary.FillMissing() // then, full up types which are entirely missing
srv.cache.SetDefault(cacheKey, summary) srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil return summary.Sorted(), nil
} }
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) { func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(*models.Summary), nil
}
// Get all already existing, pre-generated summaries that fall into the requested interval // Get all already existing, pre-generated summaries that fall into the requested interval
summaries, err := srv.repository.GetByUserWithin(user, from, to) summaries, err := srv.repository.GetByUserWithin(user, from, to)
if err != nil { if err != nil {
@ -96,8 +95,6 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
return nil, err return nil, err
} }
// Cache 'em
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil return summary.Sorted(), nil
} }
@ -110,7 +107,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
return nil, err return nil, err
} }
types := models.SummaryTypes() types := models.NativeSummaryTypes()
typedAggregations := make(chan models.SummaryItemContainer) typedAggregations := make(chan models.SummaryItemContainer)
defer close(typedAggregations) defer close(typedAggregations)
@ -156,8 +153,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
OperatingSystems: osItems, OperatingSystems: osItems,
Machines: machineItems, Machines: machineItems,
} }
summary = srv.withProjectLabels(summary)
//summary.FillUnknown()
return summary.Sorted(), nil return summary.Sorted(), nil
} }
@ -169,10 +165,12 @@ func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
} }
func (srv *SummaryService) DeleteByUser(userId string) error { func (srv *SummaryService) DeleteByUser(userId string) error {
srv.invalidateUserCache(userId)
return srv.repository.DeleteByUser(userId) return srv.repository.DeleteByUser(userId)
} }
func (srv *SummaryService) Insert(summary *models.Summary) error { func (srv *SummaryService) Insert(summary *models.Summary) error {
srv.invalidateUserCache(summary.UserID)
return srv.repository.Insert(summary) return srv.repository.Insert(summary)
} }
@ -220,6 +218,49 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
c <- models.SummaryItemContainer{Type: summaryType, Items: items} c <- models.SummaryItemContainer{Type: summaryType, Items: items}
} }
func (srv *SummaryService) withProjectLabels(summary *models.Summary) *models.Summary {
newEntry := func(key string, total time.Duration) *models.SummaryItem {
return &models.SummaryItem{
Type: models.SummaryLabel,
Key: key,
Total: total,
}
}
allLabels, err := srv.projectLabelService.GetByUser(summary.UserID)
if err != nil {
logbuch.Error("failed to retrieve project labels for user summary ('%s', '%s', '%s')", summary.UserID, summary.FromTime.String(), summary.ToTime.String())
return summary
}
mappedProjects := make(map[string]*models.SummaryItem, len(summary.Projects))
for _, p := range summary.Projects {
mappedProjects[p.Key] = p
}
var totalLabelTime time.Duration
labelMap := make(map[string]*models.SummaryItem, 0)
for _, l := range allLabels {
if p, ok := mappedProjects[l.ProjectKey]; ok {
if _, ok2 := labelMap[l.Label]; !ok2 {
labelMap[l.Label] = newEntry(l.Label, 0)
}
labelMap[l.Label].Total += p.Total
totalLabelTime += p.Total
}
}
//labelMap[models.DefaultProjectLabel] = newEntry(models.DefaultProjectLabel, summary.TotalTimeBy(models.SummaryProject) / time.Second-totalLabelTime)
labels := make([]*models.SummaryItem, 0, len(labelMap))
for _, v := range labelMap {
if v.Total > 0 {
labels = append(labels, v)
}
}
summary.Labels = labels
return summary
}
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) { func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
if len(summaries) < 1 { if len(summaries) < 1 {
return nil, errors.New("no summaries given") return nil, errors.New("no summaries given")
@ -235,6 +276,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
Editors: make([]*models.SummaryItem, 0), Editors: make([]*models.SummaryItem, 0),
OperatingSystems: make([]*models.SummaryItem, 0), OperatingSystems: make([]*models.SummaryItem, 0),
Machines: make([]*models.SummaryItem, 0), Machines: make([]*models.SummaryItem, 0),
Labels: make([]*models.SummaryItem, 0),
} }
var processed = map[time.Time]bool{} var processed = map[time.Time]bool{}
@ -263,6 +305,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors) finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems) finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines) finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
processed[hash] = true processed[hash] = true
} }
@ -349,9 +392,13 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
} }
func (srv *SummaryService) getHash(args ...string) string { func (srv *SummaryService) getHash(args ...string) string {
digest := md5.New() return strings.Join(args, "__")
for _, a := range args { }
digest.Write([]byte(a))
} func (srv *SummaryService) invalidateUserCache(userId string) {
return string(digest.Sum(nil)) for key := range srv.cache.Items() {
if strings.Contains(key, userId) {
srv.cache.Delete(key)
}
}
} }

View File

@ -16,6 +16,9 @@ const (
TestUserId = "muety" TestUserId = "muety"
TestProject1 = "test-project-1" TestProject1 = "test-project-1"
TestProject2 = "test-project-2" TestProject2 = "test-project-2"
TestProjectLabel1 = "private"
TestProjectLabel2 = "work"
TestProjectLabel3 = "non-existing"
TestLanguageGo = "Go" TestLanguageGo = "Go"
TestLanguageJava = "Java" TestLanguageJava = "Java"
TestLanguagePython = "Python" TestLanguagePython = "Python"
@ -31,12 +34,14 @@ const (
type SummaryServiceTestSuite struct { type SummaryServiceTestSuite struct {
suite.Suite suite.Suite
TestUser *models.User TestUser *models.User
TestStartTime time.Time TestStartTime time.Time
TestHeartbeats []*models.Heartbeat TestHeartbeats []*models.Heartbeat
SummaryRepository *mocks.SummaryRepositoryMock TestLabels []*models.ProjectLabel
HeartbeatService *mocks.HeartbeatServiceMock SummaryRepository *mocks.SummaryRepositoryMock
AliasService *mocks.AliasServiceMock HeartbeatService *mocks.HeartbeatServiceMock
AliasService *mocks.AliasServiceMock
ProjectLabelService *mocks.ProjectLabelServiceMock
} }
func (suite *SummaryServiceTestSuite) SetupSuite() { func (suite *SummaryServiceTestSuite) SetupSuite() {
@ -75,12 +80,27 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)), Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
}, },
} }
suite.TestLabels = []*models.ProjectLabel{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
ProjectKey: TestProject1,
Label: TestProjectLabel1,
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
ProjectKey: TestProjectLabel3,
Label: "blaahh",
},
}
} }
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) { func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.SummaryRepository = new(mocks.SummaryRepositoryMock) suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
suite.HeartbeatService = new(mocks.HeartbeatServiceMock) suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
suite.AliasService = new(mocks.AliasServiceMock) suite.AliasService = new(mocks.AliasServiceMock)
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
} }
func TestSummaryServiceTestSuite(t *testing.T) { func TestSummaryServiceTestSuite(t *testing.T) {
@ -88,7 +108,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() { func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService) sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
var ( var (
from time.Time from time.Time
@ -100,6 +120,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 1 */ /* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute) from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil) suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser) result, err = sut.Summarize(from, to, suite.TestUser)
@ -113,6 +134,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 2 */ /* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second) from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil) suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser) result, err = sut.Summarize(from, to, suite.TestUser)
@ -126,6 +148,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 3 */ /* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil) suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser) result, err = sut.Summarize(from, to, suite.TestUser)
@ -136,12 +159,16 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
assert.Equal(suite.T(), 150*time.Second, result.TotalTime()) assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland)) assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode)) assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
assert.Equal(suite.T(), 150*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1))
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel3))
assert.Len(suite.T(), result.Editors, 2) assert.Len(suite.T(), result.Editors, 2)
assertNumAllItems(suite.T(), 1, result, "e") assertNumAllItems(suite.T(), 1, result, "e")
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService) sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var ( var (
summaries []*models.Summary summaries []*models.Summary
@ -292,7 +319,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() { func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService) sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var ( var (
summaries []*models.Summary summaries []*models.Summary
@ -338,7 +367,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() { func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService) sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var ( var (
from time.Time from time.Time

View File

@ -1,3 +1,3 @@
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/** sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**,views/mail/**
sonar.tests=. sonar.tests=.
sonar.go.coverage.reportPaths=coverage/coverage.out sonar.go.coverage.reportPaths=coverage/coverage.out

View File

@ -5,16 +5,18 @@ const osCanvas = document.getElementById('chart-os')
const editorsCanvas = document.getElementById('chart-editor') const editorsCanvas = document.getElementById('chart-editor')
const languagesCanvas = document.getElementById('chart-language') const languagesCanvas = document.getElementById('chart-language')
const machinesCanvas = document.getElementById('chart-machine') const machinesCanvas = document.getElementById('chart-machine')
const labelsCanvas = document.getElementById('chart-label')
const projectContainer = document.getElementById('project-container') const projectContainer = document.getElementById('project-container')
const osContainer = document.getElementById('os-container') const osContainer = document.getElementById('os-container')
const editorContainer = document.getElementById('editor-container') const editorContainer = document.getElementById('editor-container')
const languageContainer = document.getElementById('language-container') const languageContainer = document.getElementById('language-container')
const machineContainer = document.getElementById('machine-container') const machineContainer = document.getElementById('machine-container')
const labelContainer = document.getElementById('label-container')
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer] const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas] const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines] const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels]
let topNPickers = [...document.getElementsByClassName('top-picker')] let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value))) topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
@ -255,9 +257,42 @@ function draw(subselection) {
}) })
: null : null
let labelChart = !labelsCanvas.classList.contains('hidden') && shouldUpdate(5)
? new Chart(labelsCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
}],
labels: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('labels'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
getTotal(wakapiData.operatingSystems) getTotal(wakapiData.operatingSystems)
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c) charts = [projectChart, osChart, editorChart, languageChart, machineChart, labelChart].filter(c => !!c)
if (!subselection) { if (!subselection) {
charts.forEach(c => c.options.onResize(c.chart)) charts.forEach(c => c.options.onResize(c.chart))

View File

@ -641,6 +641,12 @@ video {
background-color: rgba(47, 133, 90, var(--bg-opacity)); background-color: rgba(47, 133, 90, var(--bg-opacity));
} }
.hover\:bg-gray-700:hover {
--bg-opacity: 1;
background-color: #4a5568;
background-color: rgba(74, 85, 104, var(--bg-opacity));
}
.hover\:bg-red-600:hover { .hover\:bg-red-600:hover {
--bg-opacity: 1; --bg-opacity: 1;
background-color: #e53e3e; background-color: #e53e3e;
@ -713,6 +719,10 @@ video {
border-radius: 0.375rem; border-radius: 0.375rem;
} }
.rounded-full {
border-radius: 9999px;
}
.border { .border {
border-width: 1px; border-width: 1px;
} }
@ -753,6 +763,10 @@ video {
display: flex; display: flex;
} }
.inline-flex {
display: inline-flex;
}
.table { .table {
display: table; display: table;
} }
@ -821,6 +835,10 @@ video {
font-weight: 600; font-weight: 600;
} }
.h-4 {
height: 1rem;
}
.h-8 { .h-8 {
height: 2rem; height: 2rem;
} }
@ -853,6 +871,10 @@ video {
font-size: 2.25rem; font-size: 2.25rem;
} }
.leading-none {
line-height: 1;
}
.list-inside { .list-inside {
list-style-position: inside; list-style-position: inside;
} }
@ -1186,6 +1208,10 @@ video {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.w-4 {
width: 1rem;
}
.w-1\/2 { .w-1\/2 {
width: 50%; width: 50%;
} }

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ security:
insecure_cookies: true insecure_cookies: true
cookie_max_age: 172800 cookie_max_age: 172800
allow_signup: true allow_signup: true
expose_metrics: false expose_metrics: true
sentry: sentry:
dsn: dsn:

View File

@ -1,4 +1,14 @@
BEGIN TRANSACTION; BEGIN TRANSACTION;
INSERT INTO "users" ("id","api_key","email","location","password","created_at","last_logged_in_at","share_data_max_days","share_editors","share_languages","share_projects","share_oss","share_machines","is_admin","has_data","wakatime_api_key","reset_token","reports_weekly") VALUES ('readuser','33e7f538-0dce-4eba-8ffe-53db6814ed42','','Europe/Berlin','$2a$10$RCyfAFdlZdFJVWbxKz4f2uJ/MospiE1EFAIjvRizC4Nop9GfjgKzW','2021-05-28 12:34:25','2021-05-28 14:34:34.178+02:00',0,0,0,0,0,0,0,0,'','',0); INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
INSERT INTO "users" ("id","api_key","email","location","password","created_at","last_logged_in_at","share_data_max_days","share_editors","share_languages","share_projects","share_oss","share_machines","is_admin","has_data","wakatime_api_key","reset_token","reports_weekly") VALUES ('writeuser','f7aa255c-8647-4d0b-b90f-621c58fd580f','','Europe/Berlin','$2a$10$vsksPpiXZE9/xG9pRrZP.eKkbe/bGWW4wpPoXqvjiImZqMbN5c4Km','2021-05-28 12:34:56','2021-05-28 14:35:05.118+02:00',0,0,0,0,0,0,0,1,'','',0); "share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
VALUES ('readuser', '33e7f538-0dce-4eba-8ffe-53db6814ed42', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:25',
'2021-05-28 14:34:34.178+02:00', 0, 0, 0, 0, 0, 0, 1, 0, '', '', 0);
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
VALUES ('writeuser', 'f7aa255c-8647-4d0b-b90f-621c58fd580f', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:56',
'2021-05-28 14:35:05.118+02:00', 7, 0, 0, 1, 0, 0, 0, 1, '', '', 0);
COMMIT; COMMIT;

View File

@ -1,8 +1,8 @@
#!/bin/bash #!/bin/bash
if [ ! -f "wakapi" ]; then if [ ! -f "wakapi" ]; then
echo "Wakapi executable not found. Run 'go build' first." echo "Wakapi executable not found. Compiling."
exit 1 go build
fi fi
if ! command -v newman &> /dev/null if ! command -v newman &> /dev/null

9
utils/collection.go Normal file
View File

@ -0,0 +1,9 @@
package utils
func GetMapValues(m map[string]interface{}) []interface{} {
values := make([]interface{}, 0, len(m))
for _, v := range m {
values = append(values, v)
}
return values
}

View File

@ -30,7 +30,8 @@ func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from,
} }
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) { func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
to = time.Now().In(tz) now := time.Now().In(tz)
to = now
switch interval { switch interval {
case models.IntervalToday: case models.IntervalToday:
@ -51,16 +52,16 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
case models.IntervalThisYear: case models.IntervalThisYear:
from = StartOfThisYear(tz) from = StartOfThisYear(tz)
case models.IntervalPast7Days: case models.IntervalPast7Days:
from = StartOfToday(tz).AddDate(0, 0, -7) from = now.AddDate(0, 0, -7)
case models.IntervalPast7DaysYesterday: case models.IntervalPast7DaysYesterday:
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7) from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = StartOfToday(tz).AddDate(0, 0, -1) to = StartOfToday(tz).AddDate(0, 0, -1)
case models.IntervalPast14Days: case models.IntervalPast14Days:
from = StartOfToday(tz).AddDate(0, 0, -14) from = now.AddDate(0, 0, -14)
case models.IntervalPast30Days: case models.IntervalPast30Days:
from = StartOfToday(tz).AddDate(0, 0, -30) from = now.AddDate(0, 0, -30)
case models.IntervalPast12Months: case models.IntervalPast12Months:
from = StartOfToday(tz).AddDate(0, -12, 0) from = now.AddDate(0, -12, 0)
case models.IntervalAny: case models.IntervalAny:
from = time.Time{} from = time.Time{}
default: default:

View File

@ -1 +1 @@
1.27.3 1.28.2

View File

@ -35,7 +35,7 @@
<main class="mt-4 flex-grow flex justify-center w-full"> <main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-2xl mt-8"> <div class="flex flex-col flex-grow max-w-2xl mt-8">
<details class="my-8 pb-8 border-b border-gray-700"> <details class="my-8 pb-8 border-b border-gray-700" id="details-account">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="preferences-heading"> id="preferences-heading">
@ -88,7 +88,7 @@
</div> </div>
</details> </details>
<details class="mb-8 pb-8 border-b border-gray-700"> <details class="mb-8 pb-8 border-b border-gray-700" id="details-password">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password"> <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
Change Password Change Password
@ -127,7 +127,7 @@
</div> </div>
</details> </details>
<details class="mb-8 pb-8 border-b border-gray-700"> <details class="mb-8 pb-8 border-b border-gray-700" id="details-aliases">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"> <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Aliases Aliases
@ -203,7 +203,80 @@
</div> </div>
</details> </details>
<details class="mb-8 pb-8 border-b border-gray-700"> <details class="mb-8 pb-8 border-b border-gray-700" id="details-labels">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Project Labels
</h2>
</summary>
<div class="w-full" id="project-labels">
<div class="text-gray-300 text-sm mb-4 mt-6">
You can assign labels (aka. tags) to projects to group them together, e.g. by <span class="inline-block mb-1 text-gray-500 italic">private</span> and <span
class="inline-block mb-1 text-gray-500 italic">work</span>. Please note that labels are only applied to new data, not to existing summaries in retrospect. To label historic data, you will need to clear and regenerate your summaries (see down below).
</div>
{{ if .Labels }}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Labels</h3>
{{ range $i, $label := .Labels }}
<div class="flex items-center" action="" method="post">
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
style="line-height: 1.8">
&#9656;&nbsp;<span class="font-semibold text-white">{{ $label.Key }}:</span>
{{ range $j, $value := $label.Values }}
<form action="" method="post" class="text-white text-xs bg-gray-900 rounded-full py-1 px-2 font-mono inline-flex justify-between items-center space-x-2">
<input type="hidden" name="action" value="delete_label">
<input type="hidden" name="key" value="{{ $label.Key }}">
<input type="hidden" name="value" value="{{ $value }}">
<span>{{- $value -}}</span>
<button type="submit" class="bg-gray-800 text-center hover:bg-gray-700 rounded-full w-4 h-4 leading-none" title="Delete label">x</button>
</form>
{{ if lt $j (add (len $label.Values) -1) }}
<span class="-ml-1">{{- ", " | capitalize -}}</span>
{{ end }}
{{ end }}
</div>
</div>
{{end}}
<div class="mb-8"></div>
{{end}}
{{ if .Projects }}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Label</h3>
<form action="" method="post">
<input type="hidden" name="action" value="add_label">
<div class="flex flex-col space-y-4">
<div class="flex justify-between items-center mt-2 w-full text-gray-500 text-sm space-x-4">
<div class="w-1/2 flex flex-col flex-grow">
<span>Project</span>
<select name="key" id="select-project"
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer">
{{ range $i, $p := .Projects }}
<option value="{{ $p }}">{{ $p }}</option>
{{ end }}
</select>
</div>
<div class="w-1/2 flex flex-col flex-grow">
<span>Label</span>
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
type="text" id="label-value"
name="value" placeholder="work" minlength="1" required>
</div>
</div>
<div class="flex-grow flex justify-end">
<button type="submit"
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Add
</button>
</div>
</div>
</form>
{{ else }}
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
{{ end }}
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700" id="details-mappings">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="languages"> id="languages">
@ -263,7 +336,7 @@
</div> </div>
</details> </details>
<details class="mb-8 pb-8 border-b border-gray-700" id="public_data"> <details class="mb-8 pb-8 border-b border-gray-700" id="details-public-data">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"> <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Public Data Public Data
@ -298,11 +371,9 @@
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_projects" <select autocomplete="off" name="share_projects"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected <option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No
{{ end }}>No
</option> </option>
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end <option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes
}}>Yes
</option> </option>
</select> </select>
</div> </div>
@ -314,11 +385,9 @@
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_languages" <select autocomplete="off" name="share_languages"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected <option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No
{{ end }}>No
</option> </option>
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ <option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes
end }}>Yes
</option> </option>
</select> </select>
</div> </div>
@ -330,11 +399,9 @@
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_editors" <select autocomplete="off" name="share_editors"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ <option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No
end }}>No
</option> </option>
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end <option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes
}}>Yes
</option> </option>
</select> </select>
</div> </div>
@ -346,8 +413,7 @@
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_oss" <select autocomplete="off" name="share_oss"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end <option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No
}}>No
</option> </option>
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}> <option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
Yes Yes
@ -362,11 +428,23 @@
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_machines" <select autocomplete="off" name="share_machines"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected <option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No
{{ end }}>No
</option> </option>
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end <option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes
}}>Yes </option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share project labels: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_labels"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLabels }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareLabels }} selected {{ end }}>Yes
</option> </option>
</select> </select>
</div> </div>
@ -383,7 +461,7 @@
</div> </div>
</details> </details>
<details class="mb-8 pb-8 border-b border-gray-700"> <details class="mb-8 pb-8 border-b border-gray-700" id="details-integrations">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="integrations"> id="integrations">
@ -506,7 +584,7 @@
<p>You have the ability to create badges from your coding statistics using <a <p>You have the ability to create badges from your coding statistics using <a
href="https://shields.io" target="_blank" class="border-b border-green-800" href="https://shields.io" target="_blank" class="border-b border-green-800"
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public access to the respective endpoint. See <a href="settings#details-public-data" class="underline">Public
Data</a> setting.</p> Data</a> setting.</p>
{{ end }} {{ end }}
</div> </div>
@ -528,7 +606,7 @@
</div> </div>
<div class="flex flex-col mb-4 mt-2"> <div class="flex flex-col mb-4 mt-2">
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact" <img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact"
class="with-url-src-no-scheme"> class="with-url-src-no-scheme" alt="Readme Stats Card">
<p class="mt-2"><strong>Source URL:</strong> <p class="mt-2"><strong>Source URL:</strong>
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme"> <span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
@ -540,7 +618,7 @@
</div> </div>
</details> </details>
<details class="mb-8 pb-8"> <details class="mb-8 pb-8" id="details-danger-zone">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger"> <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
<span class="iconify inline" data-icon="emojione-v1:warning"></span>&nbsp; Danger Zone <span class="iconify inline" data-icon="emojione-v1:warning"></span>&nbsp; Danger Zone
@ -677,6 +755,12 @@
tzs.sort() tzs.sort()
.map(createTzOption) .map(createTzOption)
.forEach(o => selectTimezone.appendChild(o)) .forEach(o => selectTimezone.appendChild(o))
const hash = location.hash.replace('#', '')
if (hash) {
const elem = document.getElementById(hash)
if (elem) elem.open = true
}
</script> </script>
{{ template "footer.tpl.html" . }} {{ template "footer.tpl.html" . }}

View File

@ -170,6 +170,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="label-container" style="height: 300px">
<div class="flex justify-between">
<div class="w-1/4 flex-1">
<a href="settings#details-labels" class="h-8 inline">
<span class="iconify inline" data-icon="twemoji:gear"></span>
</a>
</div>
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Labels</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="label-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="label-top-picker" data-entity="5" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-label"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
</div> </div>
{{ else }} {{ else }}
@ -228,6 +248,7 @@
wakapiData.editors = {{ .Editors | json }} wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }} wakapiData.languages = {{ .Languages | json }}
wakapiData.machines = {{ .Machines | json }} wakapiData.machines = {{ .Machines | json }}
wakapiData.labels = {{ .Labels | json }}
document.getElementById("to-date-picker").onchange = function () { document.getElementById("to-date-picker").onchange = function () {
var input = document.getElementById("from-date-picker"); var input = document.getElementById("from-date-picker");